package store

import (
	"context"
	"errors"
	"slices"
	"strings"
	"sync"
	"testing"
)

func newTestStore(t *testing.T) *Store {
	t.Helper()
	st, err := Open(t.TempDir() + "/test.db")
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })
	return st
}

func newDownloading() *Download {
	return &Download{
		SourceType: SourceMagnet,
		SourceRef:  "magnet:?xt=urn:btih:test",
		Context:    "ctx",
		State:      StateDownloading,
	}
}

func newCatched() *Download {
	return &Download{
		SourceType: SourceMagnet,
		SourceRef:  "magnet:?xt=urn:btih:test",
		Context:    "ctx",
		State:      StateCatched,
	}
}

func TestSetDisplayNamePostHoc(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()

	d := newDownloading()
	d.DisplayName = "Unknown"
	if existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, nil); err != nil || existing != nil {
		t.Fatalf("create: existing=%v err=%v", existing, err)
	}
	// Терминальное состояние — SetDisplayName не завязан на state (в отличие от
	// PromoteCatched) и должен обновлять имя и на done.
	forceState(t, st, d.ID, StateDone)

	if err := st.SetDisplayName(ctx, d.ID, "Harold and the Purple Crayon (2024)"); err != nil {
		t.Fatalf("SetDisplayName: %v", err)
	}
	got, err := st.GetDownload(ctx, d.ID)
	if err != nil {
		t.Fatalf("GetDownload: %v", err)
	}
	if got.DisplayName != "Harold and the Purple Crayon (2024)" {
		t.Errorf("display_name = %q", got.DisplayName)
	}
	if got.State != StateDone {
		t.Errorf("state = %q, SetDisplayName не должен двигать FSM", got.State)
	}
}

// catched — нетерминальное активное состояние: его наличие блокирует повторный
// приём того же infohash (инвариант «≤1 активная на infohash»).
func TestCatchedIsActiveForDedup(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const ih = "aabbccddeeff00112233445566778899aabbccdd"

	d1 := newCatched()
	if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}, nil); err != nil || existing != nil {
		t.Fatalf("первый catched: existing=%v err=%v", existing, err)
	}
	// Повторный приём того же хеша → дедуп на активную catched-задачу.
	existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih}, nil)
	if err != nil {
		t.Fatalf("повторный приём: %v", err)
	}
	if existing == nil || existing.ID != d1.ID {
		t.Errorf("ожидался дедуп на catched %s, got %v", d1.ID, existing)
	}
}

func TestPromoteCatched(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const ih = "aabbccddeeff00112233445566778899aabbccdd"

	d := newCatched()
	if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil); err != nil {
		t.Fatalf("create: %v", err)
	}
	if err := st.PromoteCatched(ctx, d.ID, "Дюна (2024)"); err != nil {
		t.Fatalf("promote: %v", err)
	}
	got, err := st.GetDownload(ctx, d.ID)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.State != StateDownloading {
		t.Errorf("state = %q, want downloading", got.State)
	}
	if got.DisplayName != "Дюна (2024)" {
		t.Errorf("display_name = %q", got.DisplayName)
	}
	// Повторный promote (уже не catched) — отклоняется гардом state='catched'.
	if err := st.PromoteCatched(ctx, d.ID, "X"); err == nil {
		t.Error("ожидалась ошибка promote для не-catched задачи")
	}
}

// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
// существующую активную — ошибка теста.
func mustCreate(t *testing.T, st *Store, infohash string) string {
	t.Helper()
	d := newDownloading()
	existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash}, nil)
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if existing != nil {
		t.Fatalf("unexpected dedup to %s", existing.ID)
	}
	return d.ID
}

// forceState принудительно проставляет состояние прямым UPDATE в обход гейта
// графа переходов — для подготовки фикстур, где проверяется поведение в заданном
// состоянии, а не путь его достижения. Реальные переходы идут через
// SetDownloadState/ActivateIfNoOtherActive (их легальность проверяет
// transition_test.go).
func forceState(t *testing.T, st *Store, id string, state State) {
	t.Helper()
	if _, err := st.DB.ExecContext(context.Background(),
		`UPDATE download SET state = ? WHERE id = ?`, string(state), id); err != nil {
		t.Fatalf("force state %s: %v", state, err)
	}
}

func TestCreateAndGetDownload(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()

	const ih = "AABBCCDDEEFF00112233445566778899aabbccdd" // смешанный регистр — нормализуется
	id := mustCreate(t, st, ih)

	if len(id) != 26 || id != strings.ToLower(id) {
		t.Errorf("id = %q, want lowercase ULID (26 chars)", id)
	}

	got, err := st.GetDownload(ctx, id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.State != StateDownloading {
		t.Errorf("state = %q, want downloading", got.State)
	}
	if got.Context != "ctx" {
		t.Errorf("context = %q", got.Context)
	}
	if got.CreatedAt == "" {
		t.Error("created_at пуст")
	}
	if len(got.Infohashes) != 1 {
		t.Fatalf("infohashes = %v, want 1", got.Infohashes)
	}
	if h := got.Infohashes[0]; h.Infohash != strings.ToLower(ih) || h.Kind != HashV1 {
		t.Errorf("infohash = %+v, want lowercase v1", h)
	}
}

func TestFindActiveByInfohash(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const ih = "1111111111111111111111111111111111111111"

	if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
		t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
	}

	id := mustCreate(t, st, ih)
	d, err := st.FindActiveByInfohash(ctx, ih)
	if err != nil {
		t.Fatal(err)
	}
	if d == nil || d.ID != id {
		t.Fatalf("активная задача не найдена: %v", d)
	}
	if len(d.Infohashes) != 1 {
		t.Fatalf("хеши не подгружены: %v", d.Infohashes)
	}
}

// F1: дедуп-ветка CreateDownloadIfNoActive доносит недостающие хеши под пер-хеш
// гардом владения — хеш, которым владеет ДРУГАЯ активная задача (split-identity
// гибрида), красть не должна, иначе две активные владели бы одним инфохэшем.
func TestDedupTopUpDoesNotStealForeignHash(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	v1 := hashN(1)
	v2 := hashN(2)

	// A владеет v1, B владеет v2 — две активные задачи одного гибрида (split).
	idA := mustCreate(t, st, v1)
	idB := mustCreate(t, st, v2)

	// Приём гибрида {v1, v2} → дедуп на одну из активных; чужой хеш не крадём.
	existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2}, nil)
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if existing == nil {
		t.Fatal("ожидался дедуп на активную задачу")
	}

	assertHashes(t, st, idA, v1) // A по-прежнему владеет только v1
	assertHashes(t, st, idB, v2) // B по-прежнему владеет только v2
}

// assertHashes проверяет точный набор инфохэшей загрузки.
func assertHashes(t *testing.T, st *Store, id string, want ...string) {
	t.Helper()
	d, err := st.GetDownload(context.Background(), id)
	if err != nil {
		t.Fatalf("get %s: %v", id, err)
	}
	got := d.HashList()
	if len(got) != len(want) {
		t.Fatalf("%s hashes = %v, want %v", id, got, want)
	}
	for _, w := range want {
		if !slices.Contains(got, w) {
			t.Fatalf("%s hashes = %v, want содержит %s", id, got, w)
		}
	}
}

// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
// в них не должна считаться «активной» (иначе relink/ingest-дедуп решат, что
// для infohash уже есть активная задача). Регрессия: FindActiveByInfohash и
// IsTerminal обязаны опираться на один список терминальных состояний.
func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
	for _, st := range []State{StateTargetMissing, StateOrphaned, StateDeleted} {
		t.Run(string(st), func(t *testing.T) {
			store := newTestStore(t)
			ctx := context.Background()
			const ih = "3333333333333333333333333333333333333333"

			id := mustCreate(t, store, ih)
			forceState(t, store, id, st)
			if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
				t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err)
			}
		})
	}
}

// Дедуп повторного приёма блокируют не только активные, но и удерживающие
// источник desync-записи (target_missing/orphaned): по ним приём привязывается к
// существующей, а не плодит близнеца. Прочие терминальные (done/cancelled/failed/
// reverted/deleted) повторный приём НЕ блокируют — осознанная свежая попытка.
func TestFindReingestBlockingByInfohash(t *testing.T) {
	ctx := context.Background()

	blocking := []State{
		StateCatched, StateDownloading, StateReview, // активные (примеры)
		StateTargetMissing, StateOrphaned, // desync, удерживающие источник
	}
	for _, s := range blocking {
		t.Run("blocking/"+string(s), func(t *testing.T) {
			st := newTestStore(t)
			ih := hashN(1)
			id := mustCreate(t, st, ih)
			forceState(t, st, id, s)
			d, err := st.FindReingestBlockingByInfohash(ctx, ih)
			if err != nil {
				t.Fatal(err)
			}
			if d == nil || d.ID != id {
				t.Fatalf("%s должна блокировать приём, получили %v", s, d)
			}
			if len(d.Infohashes) != 1 {
				t.Fatalf("хеши не подгружены: %v", d.Infohashes)
			}
		})
	}

	nonBlocking := []State{
		StateDone, StateCancelled, StateFailed, StateReverted, StateDeleted,
	}
	for _, s := range nonBlocking {
		t.Run("non-blocking/"+string(s), func(t *testing.T) {
			st := newTestStore(t)
			ih := hashN(2)
			id := mustCreate(t, st, ih)
			forceState(t, st, id, s)
			d, err := st.FindReingestBlockingByInfohash(ctx, ih)
			if err != nil || d != nil {
				t.Fatalf("%s блокировать приём не должна, получили (%v,%v)", s, d, err)
			}
		})
	}

	t.Run("priority-active-over-desync", func(t *testing.T) {
		st := newTestStore(t)
		ih := hashN(3)
		// Старая запись ушла в target_missing (terminal освобождает хеш), затем по
		// тому же хешу завелась новая активная — инвариант «≤1 активной» это
		// допускает. Дедуп обязан присоединиться к активной, а не к desync.
		oldID := mustCreate(t, st, ih)
		forceState(t, st, oldID, StateTargetMissing)
		newID := mustCreate(t, st, ih)
		d, err := st.FindReingestBlockingByInfohash(ctx, ih)
		if err != nil {
			t.Fatal(err)
		}
		if d == nil || d.ID != newID {
			t.Fatalf("ожидалась активная %s (приоритет над desync %s), получили %v", newID, oldID, d)
		}
	})
}

// Терминальное состояние освобождает infohash: тот же хеш заводится заново
// новой задачей (повторная закачка спустя время) — активность выводится
// только из state.
func TestTerminalReleasesInfohash(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const ih = "2222222222222222222222222222222222222222"

	id := mustCreate(t, st, ih)
	if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
		t.Fatal(err)
	}

	// После терминального состояния активной задачи нет.
	if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
		t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
	}
	got, err := st.GetDownload(ctx, id)
	if err != nil {
		t.Fatal(err)
	}
	if got.ErrorCode.String != "qbit_add" {
		t.Errorf("error_code = %q", got.ErrorCode.String)
	}

	// Тот же infohash заводится заново новой задачей.
	id2 := mustCreate(t, st, ih)
	if id2 == id {
		t.Error("ожидалась новая задача")
	}
}

// Повторный приём при активной задаче дедуплицируется: вторая вставка не
// создаёт строку, а возвращает существующую активную задачу.
func TestActiveDuplicateDeduplicated(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const ih = "3333333333333333333333333333333333333333"

	id := mustCreate(t, st, ih)

	d := newDownloading()
	existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
	if err != nil {
		t.Fatal(err)
	}
	if existing == nil || existing.ID != id {
		t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
	}
	if len(existing.Infohashes) != 1 {
		t.Fatalf("у существующей задачи не подгружены хеши: %v", existing.Infohashes)
	}
}

// Дедуп ловит совпадение по ЛЮБОМУ из хешей: активная задача знает v1+v2,
// новый приём приходит только с v2.
func TestDedupByAnyHash(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const v1 = "4444444444444444444444444444444444444444"
	const v2 = "6666666666666666666666666666666666666666666666666666666666666666"

	id := mustCreate(t, st, v1)
	if err := st.AddInfohashes(ctx, id, []string{v2}); err != nil {
		t.Fatal(err)
	}

	existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
	if err != nil {
		t.Fatal(err)
	}
	if existing == nil || existing.ID != id {
		t.Fatalf("ожидался дедуп по v2-хешу на %s, получили %v", id, existing)
	}

	// Хеши задачи: v1 раньше v2 (стабильный порядок), повторное добавление
	// идемпотентно.
	if err := st.AddInfohashes(ctx, id, []string{v2, v1}); err != nil {
		t.Fatal(err)
	}
	got, err := st.GetDownload(ctx, id)
	if err != nil {
		t.Fatal(err)
	}
	if len(got.Infohashes) != 2 || got.Infohashes[0].Kind != HashV1 || got.Infohashes[1].Kind != HashV2 {
		t.Fatalf("infohashes = %+v, want [v1, v2]", got.Infohashes)
	}
	if got.PrimaryInfohash() != v1 {
		t.Errorf("primary = %q, want v1", got.PrimaryInfohash())
	}
}

// ActivateIfNoOtherActive отказывает, когда хешем владеет другая активная
// задача, и пропускает, когда владелец ушёл в терминал.
func TestActivateIfNoOtherActive(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const ih = "5555555555555555555555555555555555555555"

	id1 := mustCreate(t, st, ih)
	if err := st.SetDownloadState(ctx, id1, StateFailed, "magnet_timeout", ""); err != nil {
		t.Fatal(err)
	}
	// Хеш перехватила новая активная задача.
	id2 := mustCreate(t, st, ih)

	err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", "")
	if !errors.Is(err, ErrInfohashTaken) {
		t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
	}
	if d, _ := st.GetDownload(ctx, id1); d.State != StateFailed {
		t.Fatalf("задача не должна была активироваться: %s", d.State)
	}

	// Владелец завершился → активация проходит.
	forceState(t, st, id2, StateDone)
	if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
		t.Fatalf("активация после ухода владельца: %v", err)
	}
	if d, _ := st.GetDownload(ctx, id1); d.State != StateDownloading {
		t.Fatalf("state = %s, want downloading", d.State)
	}
}

// Дедуп-ветка дописывает существующей задаче недостающие хеши гибридного
// вызова — второй хеш не теряется, и последующий приём по нему дедупится.
func TestCreateDedupTopsUpHashes(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const v1 = "aaaa111111111111111111111111111111111111"
	const v2 = "bbbb222222222222222222222222222222222222222222222222222222222222"

	id := mustCreate(t, st, v1)

	// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
	existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2}, nil)
	if err != nil {
		t.Fatal(err)
	}
	if existing == nil || existing.ID != id {
		t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
	}
	if len(existing.Infohashes) != 2 {
		t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
	}
	// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
	byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
	if err != nil {
		t.Fatal(err)
	}
	if byV2 == nil || byV2.ID != id {
		t.Fatalf("дедуп по донесённому v2 не сработал: %v", byV2)
	}
}

// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
// не дописывается — возвращается ErrInfohashTaken.
func TestAddInfohashesGuard(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const h1 = "cccc111111111111111111111111111111111111"
	const h2 = "dddd222222222222222222222222222222222222"

	a := mustCreate(t, st, h1)
	_ = mustCreate(t, st, h2) // активный владелец h2

	err := st.AddInfohashes(ctx, a, []string{h2})
	if !errors.Is(err, ErrInfohashTaken) {
		t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
	}
	got, _ := st.GetDownload(ctx, a)
	if len(got.Infohashes) != 1 || got.Infohashes[0].Infohash != h1 {
		t.Fatalf("чужой хеш не должен был дописаться: %+v", got.Infohashes)
	}

	// Хеш терминального владельца дописывается свободно.
	forceState(t, st, a, StateDone)
	b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
	if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
		t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
	}
}

// Гард активации не маскирует конфликт самой активируемой задачей: stuck
// (нетерминальна) с более новым id не должна перекрыть старшего владельца.
func TestActivateExcludesSelf(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const h = "ffff111111111111111111111111111111111111"

	older := mustCreate(t, st, h) // активный владелец, id старше
	newer := mustCreate(t, st, "0000222222222222222222222222222222222222")
	if err := st.SetDownloadState(ctx, newer, StateStuck, "stalled", ""); err != nil {
		t.Fatal(err)
	}
	// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
	// такого не создаст, но обязан не маскировать).
	if _, err := st.DB.ExecContext(ctx,
		`INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, 'v1', ?)`,
		newer, h, FormatTime(Now())); err != nil {
		t.Fatal(err)
	}

	err := st.ActivateIfNoOtherActive(ctx, newer, StateDownloading, "", "")
	if !errors.Is(err, ErrInfohashTaken) {
		t.Fatalf("гард замаскирован self-строкой: ожидался ErrInfohashTaken, получили %v", err)
	}
	_ = older
}

// Механический бэкстоп: публичный SetDownloadState не оживляет терминальную
// задачу — возврат в активное только через ActivateIfNoOtherActive.
func TestSetDownloadStateRejectsRevive(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()

	id := mustCreate(t, st, "1234511111111111111111111111111111111111")
	if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil {
		t.Fatal(err)
	}
	// Терминал→активное обычным SetDownloadState отклоняется (revive-гард): ребро
	// failed → downloading в графе есть, но проходит только через гард владения.
	if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil {
		t.Fatal("терминал→активное мимо гарда должно отклоняться")
	}
	if d, _ := st.GetDownload(ctx, id); d.State != StateFailed {
		t.Fatalf("state = %s, want failed (без изменений)", d.State)
	}
	// Штатный путь оживления работает.
	if err := st.ActivateIfNoOtherActive(ctx, id, StateDownloading, "", ""); err != nil {
		t.Fatalf("оживление через гард: %v", err)
	}
}

// Конкурентные создания одного infohash сериализуются write-транзакциями
// (_txlock=immediate): ровно одна задача создаётся, остальные дедупятся.
func TestConcurrentCreateDedup(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const ih = "abcde11111111111111111111111111111111111"
	const n = 8

	ids := make(chan string, n)
	errs := make(chan error, n)
	var wg sync.WaitGroup
	for range n {
		wg.Add(1)
		go func() {
			defer wg.Done()
			d := newDownloading()
			existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
			if err != nil {
				errs <- err
				return
			}
			if existing != nil {
				ids <- existing.ID
			} else {
				ids <- d.ID
			}
		}()
	}
	wg.Wait()
	close(ids)
	close(errs)
	for err := range errs {
		t.Fatalf("конкурентное создание упало: %v", err)
	}
	uniq := map[string]bool{}
	for id := range ids {
		uniq[id] = true
	}
	if len(uniq) != 1 {
		t.Fatalf("создано %d разных задач на один infohash, want 1: %v", len(uniq), uniq)
	}
	all, _ := st.ListDownloads(ctx)
	if len(all) != 1 {
		t.Fatalf("в БД %d строк, want 1", len(all))
	}
}

func TestExistsByInfohash(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	const ih = "7777777777777777777777777777777777777777"

	if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || ok {
		t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err)
	}
	id := mustCreate(t, st, ih)
	forceState(t, st, id, StateDone)
	// Exists видит и терминальные (в отличие от FindActive).
	if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok {
		t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err)
	}
}

func TestListAndByState(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()

	id1 := mustCreate(t, st, "4444444444444444444444444444444444444444")
	id2 := mustCreate(t, st, "5555555555555555555555555555555555555555")
	if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
		t.Fatal(err)
	}

	all, err := st.ListDownloads(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if len(all) != 2 {
		t.Fatalf("ListDownloads = %d, want 2", len(all))
	}
	for _, d := range all {
		if len(d.Infohashes) != 1 {
			t.Fatalf("у %s не подгружены хеши", d.ID)
		}
	}

	dl, err := st.ListDownloadsByState(ctx, StateDownloading)
	if err != nil {
		t.Fatal(err)
	}
	if len(dl) != 1 || dl[0].ID != id1 {
		t.Fatalf("ListDownloadsByState(downloading) = %v", dl)
	}
}

// TestIsObservable: наблюдаемость — «состояние ещё может измениться без
// человека». Нетерминальные наблюдаемы все; из терминальных — те, которые фон
// возвращает в поток сам (failed по восстановимым кодам, target_missing и
// orphaned переоценивает сверка). done в перечень не входит сознательно: его
// переоценка означает удаление файлов мимо сервиса.
func TestIsObservable(t *testing.T) {
	observable := []State{
		StateCatched, StateDownloading, StateCompleted, StateRecognizing,
		StateReview, StateLinking, StateDeferred, StateStuck,
		StateFailed, StateTargetMissing, StateOrphaned,
	}
	silent := []State{StateDone, StateCancelled, StateReverted, StateDeleted}

	for _, s := range observable {
		if !s.IsObservable() {
			t.Errorf("%s: IsObservable=false, want true", s)
		}
	}
	for _, s := range silent {
		if s.IsObservable() {
			t.Errorf("%s: IsObservable=true, want false", s)
		}
	}
	// Наблюдаемое множество не сводится к нетерминальному — иначе предикат был
	// бы лишним, а карточка упавшей задачи замирала бы навсегда.
	if !StateFailed.IsTerminal() || !StateFailed.IsObservable() {
		t.Error("failed должно быть терминальным и при этом наблюдаемым")
	}
}

// TestCanDelete: допуск полного удаления — единый дом перечня состояний, на
// который смотрят и ядро, и все транспорты. Перебираем ВСЕ состояния: новое
// состояние, попавшее в перечень молча, тест покажет.
func TestCanDelete(t *testing.T) {
	deletable := []State{StateDone, StateOrphaned, StateTargetMissing}

	for _, s := range allStates {
		want := slices.Contains(deletable, s)
		if got := s.CanDelete(); got != want {
			t.Errorf("%s: CanDelete=%v, want %v", s, got, want)
		}
	}
	// deleted терминально и удалению не подлежит — иначе повторная отправка
	// формы группового удаления сносила бы уже снесённое второй раз.
	if StateDeleted.CanDelete() {
		t.Error("deleted не должно допускать удаление")
	}
}
