package worker

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"git.vakhrushev.me/av/jellybit/internal/layout"
	"git.vakhrushev.me/av/jellybit/internal/metadata"
	"git.vakhrushev.me/av/jellybit/internal/qbt"
	"git.vakhrushev.me/av/jellybit/internal/recognize"
	"git.vakhrushev.me/av/jellybit/internal/store"
)

// recordingNotifier ловит события пинга (Notify асинхронен — через канал).
type notifyEvent struct {
	id string
	ev NotifyEvent
}
type recordingNotifier struct{ ch chan notifyEvent }

func (n *recordingNotifier) Notify(_ context.Context, id string, ev NotifyEvent) {
	n.ch <- notifyEvent{id, ev}
}

func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent {
	t.Helper()
	select {
	case e := <-n.ch:
		return e
	case <-time.After(2 * time.Second):
		t.Fatal("пинг не пришёл")
		return notifyEvent{}
	}
}

func TestNotifier_FiresOnReview(t *testing.T) {
	st := newMemStore()
	st.put(completedDownload("1"))
	qb := &fakeQbt{
		torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
		files:    []qbt.File{{Name: "Show/e1.mkv", Size: 1}},
	}
	w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
	n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
	w.SetNotifier(n)

	w.recognizeOne(context.Background(), "1")

	e := waitNotify(t, n)
	if e.id != "1" || e.ev != EventReview {
		t.Errorf("event = %+v, want {1 review}", e)
	}
}

func TestNotifier_FiresOnDone(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
	f.w.SetNotifier(n)

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	e := waitNotify(t, n)
	if e.id != "1" || e.ev != EventDone {
		t.Errorf("event = %+v, want {1 done}", e)
	}
}

// recordingScanner ловит вызовы пересканирования Jellyfin (RefreshLibraries
// асинхронен — через канал).
type recordingScanner struct{ ch chan struct{} }

func (s *recordingScanner) RefreshLibraries(_ context.Context) error {
	s.ch <- struct{}{}
	return nil
}

func TestScanner_FiresOnDone(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	s := &recordingScanner{ch: make(chan struct{}, 4)}
	f.w.SetScanner(s)

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	select {
	case <-s.ch:
	case <-time.After(2 * time.Second):
		t.Fatal("пересканирование Jellyfin не запустилось")
	}
}

// waitScan ждёт вызова пересканирования Jellyfin.
func waitScan(t *testing.T, s *recordingScanner, when string) {
	t.Helper()
	select {
	case <-s.ch:
	case <-time.After(2 * time.Second):
		t.Fatalf("пересканирование Jellyfin %s не запустилось", when)
	}
}

// TestScanner_FiresOnReverted — после Undo наши библиотечные хардлинки сняты, вход
// в reverted тоже дёргает пересканирование (Jellyfin не держит битые пути).
func TestScanner_FiresOnReverted(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	// Скан подключаем ПОСЛЕ раскладки, чтобы поймать именно вход в reverted.
	s := &recordingScanner{ch: make(chan struct{}, 4)}
	f.w.SetScanner(s)

	if err := f.w.Undo(context.Background(), "1"); err != nil {
		t.Fatalf("Undo: %v", err)
	}
	waitScan(t, s, "после Undo")
}

// TestScanner_FiresOnDeleted — после Delete наши ссылки сняты, вход в deleted
// дёргает пересканирование.
func TestScanner_FiresOnDeleted(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	s := &recordingScanner{ch: make(chan struct{}, 4)}
	f.w.SetScanner(s)

	if err := f.w.Delete(context.Background(), "1"); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	waitScan(t, s, "после Delete")
}

// TestScanner_SilentOnNonLinkChange — вход, не меняющий наши библиотечные ссылки
// (deferred), скан не дёргает: гейт только по {done, reverted, deleted}.
func TestScanner_SilentOnNonLinkChange(t *testing.T) {
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateReview
	st.put(d)
	w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
	s := &recordingScanner{ch: make(chan struct{}, 4)}
	w.SetScanner(s)

	if err := w.Defer(context.Background(), "1"); err != nil {
		t.Fatalf("Defer: %v", err)
	}
	select {
	case <-s.ch:
		t.Fatal("скан не должен дёргаться на входе в deferred")
	case <-time.After(200 * time.Millisecond):
	}
}

func revertedDownload(id string) *store.Download {
	d := completedDownload(id)
	d.State = store.StateReverted
	return d
}

func TestRelink_RevertedToRecognizing(t *testing.T) {
	st := newMemStore()
	st.put(revertedDownload("1"))
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "uploading"}}}
	w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)

	if err := w.Relink(context.Background(), "1"); err != nil {
		t.Fatalf("Relink: %v", err)
	}
	if st.downloads["1"].State != store.StateRecognizing {
		t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
	}
	if st.overrides["1"][ovrForceReview] != "1" {
		t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview])
	}
}

func TestRelink_CancelledToRecognizing(t *testing.T) {
	st := newMemStore()
	d := revertedDownload("1")
	d.State = store.StateCancelled
	st.put(d)
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "uploading"}}}
	w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)

	if err := w.Relink(context.Background(), "1"); err != nil {
		t.Fatalf("Relink: %v", err)
	}
	if st.downloads["1"].State != store.StateRecognizing {
		t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
	}
	if st.overrides["1"][ovrForceReview] != "1" {
		t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview])
	}
}

func TestRelink_RejectsActiveState(t *testing.T) {
	st := newMemStore()
	st.put(completedDownload("1")) // не reverted/cancelled
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
	w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)

	if err := w.Relink(context.Background(), "1"); err == nil {
		t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil")
	}
}

func TestRerecognize_ReviewToRecognizing(t *testing.T) {
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateReview
	st.put(d)
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}}
	w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)

	if err := w.Rerecognize(context.Background(), "1"); err != nil {
		t.Fatalf("Rerecognize: %v", err)
	}
	if st.downloads["1"].State != store.StateRecognizing {
		t.Fatalf("state = %q, want recognizing", st.downloads["1"].State)
	}
}

func TestRerecognize_RejectsNonReview(t *testing.T) {
	st := newMemStore()
	st.put(completedDownload("1")) // completed, не review/deferred
	w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)

	if err := w.Rerecognize(context.Background(), "1"); err == nil {
		t.Fatal("ожидали ошибку для не-review задачи, получили nil")
	}
}

func TestRelink_TorrentMissing(t *testing.T) {
	st := newMemStore()
	st.put(revertedDownload("1"))
	qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет
	w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)

	if err := w.Relink(context.Background(), "1"); err == nil {
		t.Fatal("ожидали ошибку при отсутствии торрента, получили nil")
	}
	// Preflight приводит состояние к реальности: источника нет и цели нет
	// (reverted — ссылки сняты) → deleted (см. state-reconciliation).
	if st.downloads["1"].State != store.StateDeleted {
		t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads["1"].State)
	}
}

// TestRelink_ForceReviewSkipsAuto проверяет, что после перепривязки даже
// уверенный матч не уходит в авто-раскладку, а ждёт подтверждения в review.
func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	// Готовим состояние «как после Relink»: reverted, force_review выставлен.
	f.st.downloads["1"].State = store.StateReverted
	_ = f.st.SetOverride(context.Background(), "1", ovrForceReview, "1")

	auto := seriesResult()
	auto.Decision.Auto = true
	auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"}
	f.w.recognizer = &fakeRecognizer{result: auto}

	if err := f.w.Relink(context.Background(), "1"); err != nil {
		t.Fatalf("Relink: %v", err)
	}
	f.w.recognizeOne(context.Background(), "1")

	if f.st.downloads["1"].State != store.StateReview {
		t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads["1"].State)
	}
	if len(f.st.links) != 0 {
		t.Errorf("file_links = %d, want 0 (ничего не линковали)", len(f.st.links))
	}
}

// TestReviewCommands_RejectNotReadySource — readiness-preflight: команды,
// вводящие задачу в recognizing, отклоняют недокачанный (downloading-класс)
// источник с ErrNotReady и НЕ трогают состояние. Иначе недокачанную задачу
// можно было бы провести в авто-раскладку и захардлинкать неполные файлы.
func TestReviewCommands_RejectNotReadySource(t *testing.T) {
	notReady := []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "downloading"}}

	// review/deferred-команды на задаче в deferred/review.
	cases := []struct {
		name  string
		state store.State
		call  func(w *Worker) error
	}{
		{"rerecognize", store.StateDeferred, func(w *Worker) error { return w.Rerecognize(context.Background(), "1") }},
		{"refine", store.StateReview, func(w *Worker) error { return w.Refine(context.Background(), "1", "подсказка") }},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			st := newMemStore()
			d := completedDownload("1")
			d.State = tc.state
			st.put(d)
			w := testWorkerWith(st, &fakeQbt{torrents: notReady}, &fakeRecognizer{}, nil)

			if err := tc.call(w); !errors.Is(err, ErrNotReady) {
				t.Fatalf("err = %v, want ErrNotReady", err)
			}
			if got := st.downloads["1"].State; got != tc.state {
				t.Errorf("state = %q, want %q (не тронуто)", got, tc.state)
			}
		})
	}

	// Relink из reverted — тоже требует готовности источника.
	t.Run("relink", func(t *testing.T) {
		st := newMemStore()
		st.put(revertedDownload("1"))
		w := testWorkerWith(st, &fakeQbt{torrents: notReady}, &fakeRecognizer{}, nil)

		if err := w.Relink(context.Background(), "1"); !errors.Is(err, ErrNotReady) {
			t.Fatalf("err = %v, want ErrNotReady", err)
		}
		if got := st.downloads["1"].State; got != store.StateReverted {
			t.Errorf("state = %q, want reverted (не тронуто)", got)
		}
		if _, ok := st.overrides["1"][ovrForceReview]; ok {
			t.Errorf("force_review проставлен, а не должен: отказ до записи override")
		}
	})
}

// TestApply_RejectsNotReadySource — последний рубеж: Apply не создаёт хардлинки
// на недокачанный источник, даже если задача оказалась в review.
func TestApply_RejectsNotReadySource(t *testing.T) {
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateReview
	st.put(d)
	planJSON, _ := json.Marshal(seriesResult().Plan)
	st.recs = append(st.recs, &store.Recognition{
		ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
	})
	lay, err := layout.New(layout.Config{MoviesDir: t.TempDir(), SeriesDir: t.TempDir()}, nil)
	if err != nil {
		t.Fatal(err)
	}
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: "/d", State: "downloading"}}}
	w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)

	if err := w.Apply(context.Background(), "1"); !errors.Is(err, ErrNotReady) {
		t.Fatalf("err = %v, want ErrNotReady", err)
	}
	if got := st.downloads["1"].State; got != store.StateReview {
		t.Errorf("state = %q, want review (не тронуто)", got)
	}
	if len(st.links) != 0 {
		t.Errorf("file_links = %d, want 0 (хардлинки не создаём)", len(st.links))
	}
}

// memStore — полноценный in-memory store для тестов Ф3.
type memStore struct {
	downloads  map[string]*store.Download
	recs       []*store.Recognition
	hints      map[string][]string
	overrides  map[string]map[string]string
	links      []store.FileLink
	candidates []store.MetadataCandidate
	torrents   map[string][]byte

	// Инъекция сбоев (для тестов устойчивости раскладки).
	failCreateLinks error                   // CreateFileLinks вернёт эту ошибку
	failSetState    func(store.State) error // SetDownloadState вернёт ошибку для перехода

	// clock — монотонный счётчик для updated_at (как реальный store бьёт метку на
	// каждом переходе); питает проверку claim-токена распознавания (MINOR-8).
	clock int
}

func newMemStore() *memStore {
	return &memStore{
		downloads: map[string]*store.Download{},
		hints:     map[string][]string{},
		overrides: map[string]map[string]string{},
	}
}

func (m *memStore) put(d *store.Download) { m.downloads[d.ID] = d }

func (m *memStore) ListDownloadsByState(_ context.Context, states ...store.State) ([]store.Download, error) {
	var out []store.Download
	for _, d := range m.downloads {
		for _, s := range states {
			if d.State == s {
				out = append(out, *d)
			}
		}
	}
	return out, nil
}

func (m *memStore) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) {
	var out []store.Download
	for _, d := range m.downloads {
		if d.State != store.StateFailed && d.State != store.StateStuck {
			continue
		}
		for _, c := range codes {
			if d.ErrorCode.Valid && d.ErrorCode.String == c {
				out = append(out, *d)
				break
			}
		}
	}
	return out, nil
}

func (m *memStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
	for _, d := range m.downloads {
		if hasAnyHash(d, hashes) {
			return true, nil
		}
	}
	return false, nil
}

func (m *memStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
	for _, d := range m.downloads {
		if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
			cp := *d
			return &cp, nil
		}
	}
	return nil, nil
}

func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
	if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil {
		return existing, nil
	}
	id := itoa(len(m.downloads) + 1)
	cp := *d
	cp.ID = id
	for _, h := range hashes {
		h = store.NormalizeHash(h)
		cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
	}
	m.downloads[id] = &cp
	if len(torrentBlob) > 0 {
		if m.torrents == nil {
			m.torrents = map[string][]byte{}
		}
		m.torrents[id] = torrentBlob
	}
	d.ID = id
	d.Infohashes = cp.Infohashes
	return nil, nil
}

func (m *memStore) GetTorrentData(_ context.Context, downloadID string) ([]byte, error) {
	if data, ok := m.torrents[downloadID]; ok {
		return data, nil
	}
	return nil, store.ErrNotFound
}

func (m *memStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
	d, ok := m.downloads[id]
	if !ok {
		return os.ErrNotExist
	}
	for _, other := range m.downloads {
		if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
			return store.ErrInfohashTaken
		}
	}
	return m.SetDownloadState(ctx, id, st, code, msg)
}

func (m *memStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
	d, ok := m.downloads[id]
	if !ok {
		return os.ErrNotExist
	}
	for _, h := range hashes {
		h = store.NormalizeHash(h)
		if !hasAnyHash(d, []string{h}) {
			d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
		}
	}
	return nil
}

func (m *memStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
	d, ok := m.downloads[id]
	if !ok {
		return nil, os.ErrNotExist
	}
	cp := *d
	return &cp, nil
}

func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
	if m.failSetState != nil {
		if err := m.failSetState(st); err != nil {
			return err
		}
	}
	d := m.downloads[id]
	d.State = st
	d.ErrorCode = store.NullString(code)
	d.ErrorMsg = store.NullString(msg)
	m.clock++
	d.UpdatedAt = itoa(m.clock) // свежая метка перехода — claim-токен (MINOR-8)
	return nil
}

func (m *memStore) PromoteCatched(_ context.Context, id, displayName string) error {
	d, ok := m.downloads[id]
	if !ok || d.State != store.StateCatched {
		return fmt.Errorf("promote catched %s: not in catched", id)
	}
	d.State = store.StateDownloading
	d.DisplayName = displayName
	return nil
}

func (m *memStore) SetDisplayName(_ context.Context, id, name string) error {
	if d, ok := m.downloads[id]; ok {
		d.DisplayName = name
	}
	return nil
}

func (m *memStore) SetParsedContext(_ context.Context, id, jsonStr string) error {
	if d, ok := m.downloads[id]; ok {
		d.ParsedContext = jsonStr
	}
	return nil
}

func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
	if d, ok := m.downloads[id]; ok {
		d.SourceMissCount = n
	}
	return nil
}

func (m *memStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
	if d, ok := m.downloads[id]; ok && !d.SourceAddedAt.Valid {
		d.SourceAddedAt = store.NullString(store.FormatTime(t))
	}
	return nil
}

func (m *memStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
	if d, ok := m.downloads[id]; ok {
		d.RetriedAt = store.NullString(store.FormatTime(t))
	}
	return nil
}

func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) {
	for _, e := range m.recs {
		if e.DownloadID == r.DownloadID {
			e.IsCurrent = false
		}
	}
	cp := *r
	cp.ID = itoa(len(m.recs) + 1)
	cp.IsCurrent = true
	cp.AttemptNo = 1
	for _, e := range m.recs {
		if e.DownloadID == r.DownloadID {
			cp.AttemptNo++
		}
	}
	b, _ := jsonMarshal(reasons)
	cp.Reasons = b
	m.recs = append(m.recs, &cp)
	return cp.ID, nil
}

func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID string) (*store.Recognition, error) {
	for _, e := range m.recs {
		if e.DownloadID == downloadID && e.IsCurrent {
			cp := *e
			return &cp, nil
		}
	}
	return nil, nil
}

func (m *memStore) AddHint(_ context.Context, id string, text string) error {
	m.hints[id] = append(m.hints[id], text)
	return nil
}
func (m *memStore) ListHints(_ context.Context, id string) ([]string, error) { return m.hints[id], nil }

func (m *memStore) SetOverride(_ context.Context, id string, field, value string) error {
	if m.overrides[id] == nil {
		m.overrides[id] = map[string]string{}
	}
	m.overrides[id][field] = value
	return nil
}
func (m *memStore) ListOverrides(_ context.Context, id string) (map[string]string, error) {
	return m.overrides[id], nil
}

func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) error {
	if m.failCreateLinks != nil {
		return m.failCreateLinks
	}
	m.links = append(m.links, links...)
	return nil
}
func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID string, dstPaths []string) error {
	if len(dstPaths) == 0 {
		return nil
	}
	want := make(map[string]bool, len(dstPaths))
	for _, p := range dstPaths {
		want[p] = true
	}
	for i := range m.links {
		l := &m.links[i]
		if l.DownloadID == downloadID || !want[l.DstPath] {
			continue
		}
		if isLaidOut(l.Status) {
			l.Status = string(layout.StatusSuperseded)
		}
	}
	return nil
}
func (m *memStore) LiveTitleFolders(_ context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
	if provider == "" || providerID == "" {
		return nil, nil
	}
	match := map[string]bool{} // download_id с current recognition этого матча
	for _, r := range m.recs {
		if r.IsCurrent && r.Provider.String == provider && r.ProviderID.String == providerID {
			match[r.DownloadID] = true
		}
	}
	var out []string
	for _, l := range m.links {
		if l.DownloadID == excludeDownloadID || !match[l.DownloadID] {
			continue
		}
		if isLaidOut(l.Status) {
			out = append(out, l.DstPath)
		}
	}
	return out, nil
}
func (m *memStore) LatestBatchID(_ context.Context, id string) (string, error) {
	for i := len(m.links) - 1; i >= 0; i-- {
		if m.links[i].DownloadID == id {
			return m.links[i].ApplyBatchID, nil
		}
	}
	return "", nil
}
func (m *memStore) ListFileLinksByBatch(_ context.Context, batch string) ([]store.FileLink, error) {
	var out []store.FileLink
	for _, l := range m.links {
		if l.ApplyBatchID == batch {
			out = append(out, l)
		}
	}
	return out, nil
}
func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error {
	kept := m.links[:0]
	for _, l := range m.links {
		if l.ApplyBatchID != batch {
			kept = append(kept, l)
		}
	}
	m.links = kept
	return nil
}

func (m *memStore) CreateCandidates(_ context.Context, cands []store.MetadataCandidate) error {
	for _, c := range cands {
		c.ID = itoa(len(m.candidates) + 1)
		m.candidates = append(m.candidates, c)
	}
	return nil
}
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID string) ([]store.MetadataCandidate, error) {
	var out []store.MetadataCandidate
	for _, c := range m.candidates {
		if c.RecognitionID == recID {
			out = append(out, c)
		}
	}
	return out, nil
}
func (m *memStore) GetCandidate(_ context.Context, id string) (*store.MetadataCandidate, error) {
	for i := range m.candidates {
		if m.candidates[i].ID == id {
			cp := m.candidates[i]
			return &cp, nil
		}
	}
	return nil, nil
}
func (m *memStore) SetCandidateChosen(_ context.Context, recID, id string) error {
	for i := range m.candidates {
		if m.candidates[i].RecognitionID == recID {
			m.candidates[i].Chosen = m.candidates[i].ID == id
		}
	}
	return nil
}

func jsonMarshal(v any) (string, error) {
	b, err := json.Marshal(v)
	return string(b), err
}

// fakeRecognizer возвращает заданный результат; onCall — побочный эффект для
// симуляции гонок (напр. отмена во время вызова LLM).
type fakeRecognizer struct {
	result   recognize.Result
	err      error
	onCall   func()
	calls    int
	director string // возвращается Director (режиссёр выбранного источника)
}

func (f *fakeRecognizer) Recognize(_ context.Context, _ recognize.Input) (recognize.Result, error) {
	f.calls++
	if f.onCall != nil {
		f.onCall()
	}
	return f.result, f.err
}

func (f *fakeRecognizer) Director(_ context.Context, _ recognize.MediaType, _, _ string) string {
	return f.director
}

func testWorkerWith(st Store, qb QBittorrent, rec Recognizer, lay Layouter) *Worker {
	w := New(st, qb, rec, lay, Config{Category: "jellybit"},
		slog.New(slog.DiscardHandler))
	n := 0
	w.newID = func() string { n++; return "batch-" + itoa(n) }
	return w
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	var b []byte
	for n > 0 {
		b = append([]byte{byte('0' + n%10)}, b...)
		n /= 10
	}
	return string(b)
}

const ihTest = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"

func completedDownload(id string) *store.Download {
	return &store.Download{
		ID: id, State: store.StateCompleted, SourceType: store.SourceMagnet,
		SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohashes: hashesOf(id, ihTest),
		Context: "ctx",
	}
}

func seriesResult() recognize.Result {
	s, e1, e2 := 2, 1, 2
	return recognize.Result{
		Plan: recognize.Plan{
			Type: recognize.MediaSeries, Title: "Show", Year: 2006, Confidence: 0.7,
			Files: []recognize.PlanFile{
				{Src: "Show/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e1},
				{Src: "Show/e2.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e2},
			},
		},
		Decision: recognize.Decision{Reasons: []string{"нет матча в базе"}},
		Raw:      `{"type":"series"}`,
	}
}

func TestRecognizeOne_CompletedToReview(t *testing.T) {
	st := newMemStore()
	st.put(completedDownload("1"))
	qb := &fakeQbt{
		torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", Category: "jellybit"}},
		files:    []qbt.File{{Name: "Show/e1.mkv", Size: 100}, {Name: "Show/e2.mkv", Size: 100}},
	}
	rec := &fakeRecognizer{result: seriesResult()}
	w := testWorkerWith(st, qb, rec, nil)

	w.recognizeOne(context.Background(), "1")

	if st.downloads["1"].State != store.StateReview {
		t.Fatalf("state = %q, want review", st.downloads["1"].State)
	}
	cur, _ := st.GetCurrentRecognition(context.Background(), "1")
	if cur == nil || cur.Title.String != "Show" {
		t.Fatalf("recognition = %+v", cur)
	}
	if !cur.Plan.Valid {
		t.Error("plan must be persisted")
	}
}

// TestRecognizeOne_FindsTagAdoptedTorrent — регрессия: раздача, усыновлённая
// по тегу, имеет чужую (или пустую) категорию. Поиск по infohash при
// распознавании обязан её найти; раньше фильтр по w.cfg.Category её терял и
// распознавание падало с «torrent not found in qBittorrent».
func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
	st := newMemStore()
	st.put(completedDownload("1"))
	qb := &fakeQbt{
		torrents: []qbt.Torrent{{
			Hash: ihTest, Name: "ThePitt", SavePath: "/d",
			Category: "movies", Tags: "jellybit", // тег наш, категория чужая
		}},
		files: []qbt.File{{Name: "ThePitt/e1.mkv", Size: 100}, {Name: "ThePitt/e2.mkv", Size: 100}},
	}
	rec := &fakeRecognizer{result: seriesResult()}
	w := testWorkerWith(st, qb, rec, nil)

	w.recognizeOne(context.Background(), "1")

	if st.downloads["1"].State != store.StateReview {
		t.Fatalf("state = %q, want review", st.downloads["1"].State)
	}
	// Recognizer вернул бы Title="Show" только если торрент найден по infohash;
	// при потере (фильтр по категории) был бы пустой план с причиной «not found».
	cur, _ := st.GetCurrentRecognition(context.Background(), "1")
	if cur == nil || cur.Title.String != "Show" {
		t.Fatalf("recognizer did not run on found torrent (title=%q): torrent must be found by infohash despite foreign category",
			func() string {
				if cur == nil {
					return "<nil>"
				}
				return cur.Title.String
			}())
	}
}

func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
	st := newMemStore()
	st.put(completedDownload("1"))
	qb := &fakeQbt{
		torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
		files:    []qbt.File{{Name: "Show/e1.mkv", Size: 100}},
	}
	// Во время вызова LLM задачу отменяют.
	rec := &fakeRecognizer{result: seriesResult(), onCall: func() {
		st.downloads["1"].State = store.StateCancelled
	}}
	w := testWorkerWith(st, qb, rec, nil)

	w.recognizeOne(context.Background(), "1")

	if st.downloads["1"].State != store.StateCancelled {
		t.Errorf("state = %q, want cancelled (result discarded)", st.downloads["1"].State)
	}
	if cur, _ := st.GetCurrentRecognition(context.Background(), "1"); cur != nil {
		t.Error("recognition must not be persisted after discard")
	}
}

// TestRecognizeOne_DiscardsStaleClaim — MINOR-8: если во время вызова LLM задачу
// увели из recognizing и вернули обратно (cancel → relink revive), это уже другой
// recognizing-эпизод (updated_at сменился). Устаревший результат прежнего прогона
// отбрасывается, задача остаётся в recognizing для перезапуска поллингом.
func TestRecognizeOne_DiscardsStaleClaim(t *testing.T) {
	st := newMemStore()
	st.put(completedDownload("1"))
	qb := &fakeQbt{
		torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
		files:    []qbt.File{{Name: "Show/e1.mkv", Size: 100}},
	}
	// Во время вызова LLM задачу уводят из recognizing и возвращают обратно
	// (симуляция cancel → relink): состояние снова recognizing, но updated_at сменился.
	rec := &fakeRecognizer{result: seriesResult(), onCall: func() {
		_ = st.SetDownloadState(context.Background(), "1", store.StateCancelled, "", "")
		_ = st.SetDownloadState(context.Background(), "1", store.StateRecognizing, "", "")
	}}
	w := testWorkerWith(st, qb, rec, nil)

	w.recognizeOne(context.Background(), "1")

	if st.downloads["1"].State != store.StateRecognizing {
		t.Errorf("state = %q, want recognizing (устаревший результат отброшен, ждём перезапуска)", st.downloads["1"].State)
	}
	if cur, _ := st.GetCurrentRecognition(context.Background(), "1"); cur != nil {
		t.Error("recognition устаревшей попытки не должна сохраняться")
	}
}

func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
	st := newMemStore()
	st.put(completedDownload("1"))
	qb := &fakeQbt{torrents: nil} // торрент пропал
	rec := &fakeRecognizer{result: seriesResult()}
	w := testWorkerWith(st, qb, rec, nil)

	w.recognizeOne(context.Background(), "1")

	if st.downloads["1"].State != store.StateReview {
		t.Fatalf("state = %q, want review", st.downloads["1"].State)
	}
	cur, _ := st.GetCurrentRecognition(context.Background(), "1")
	if cur == nil || len(cur.ReasonList()) == 0 {
		t.Fatal("expected review with reason")
	}
}

func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateReview
	st.put(d)
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}}
	w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)

	if err := w.Refine(context.Background(), "1", "это второй сезон"); err != nil {
		t.Fatalf("Refine: %v", err)
	}
	if st.downloads["1"].State != store.StateRecognizing {
		t.Errorf("state = %q, want recognizing", st.downloads["1"].State)
	}
	if h := st.hints["1"]; len(h) != 1 || h[0] != "это второй сезон" {
		t.Errorf("hints = %v", h)
	}
	if err := w.Refine(context.Background(), "1", "  "); err == nil {
		t.Error("empty hint must be rejected")
	}
}

func TestIgnoreFile(t *testing.T) {
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateReview
	st.put(d)
	w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)

	if err := w.IgnoreFile(context.Background(), "1", "Show/sample.mkv"); err != nil {
		t.Fatalf("IgnoreFile: %v", err)
	}
	if err := w.IgnoreFile(context.Background(), "1", "Show/sample.mkv"); err != nil { // повтор не дублирует
		t.Fatalf("IgnoreFile repeat: %v", err)
	}
	ignored := parseIgnored(st.overrides["1"][ovrIgnoredFiles])
	if len(ignored) != 1 || ignored[0] != "Show/sample.mkv" {
		t.Errorf("ignored = %v", ignored)
	}
	if st.downloads["1"].State != store.StateReview {
		t.Errorf("ignore must keep review, got %q", st.downloads["1"].State)
	}
}

func TestDefer(t *testing.T) {
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateReview
	st.put(d)
	w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)

	if err := w.Defer(context.Background(), "1"); err != nil {
		t.Fatalf("Defer: %v", err)
	}
	if st.downloads["1"].State != store.StateDeferred {
		t.Errorf("state = %q, want deferred", st.downloads["1"].State)
	}
}

// Defer из пре-источникового catched отклоняется конфликтом: торрент ещё не
// добавлен в qBittorrent, откладывать нечего, а catched → deferred увёл бы
// задачу в лимбо → необратимый deleted (MAJOR-6). Состояние не меняется.
func TestDeferRejectsCatched(t *testing.T) {
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateCatched
	st.put(d)
	w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)

	err := w.Defer(context.Background(), "1")
	if !errors.Is(err, ErrConflict) {
		t.Fatalf("Defer from catched = %v, want ErrConflict", err)
	}
	if st.downloads["1"].State != store.StateCatched {
		t.Errorf("state = %q, want catched (не тронуто)", st.downloads["1"].State)
	}
}

// applyFixture — реальный layouter с temp-библиотеками и исходными файлами.
type applyFixture struct {
	w         *Worker
	st        *memStore
	qb        *fakeQbt
	downloads string
	movies    string
	series    string
}

// newApplyFixture готовит worker с реальным layouter: исходные файлы лежат в
// downloads (он же savePath торрента), библиотеки — movies/series.
func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
	t.Helper()
	root := t.TempDir()
	downloads := filepath.Join(root, "downloads")
	movies := filepath.Join(root, "movies")
	series := filepath.Join(root, "series")
	for _, d := range []string{downloads, movies, series} {
		_ = os.MkdirAll(d, 0o755)
	}
	for _, f := range plan.Files {
		p := filepath.Join(downloads, f.Src)
		_ = os.MkdirAll(filepath.Dir(p), 0o755)
		if err := os.WriteFile(p, []byte("data-"+f.Src), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	lay, err := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil)
	if err != nil {
		t.Fatal(err)
	}

	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateReview
	st.put(d)
	planJSON, _ := json.Marshal(plan)
	st.recs = append(st.recs, &store.Recognition{
		ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
	})
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit", State: "uploading"}}}
	w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)

	return applyFixture{w: w, st: st, qb: qb, downloads: downloads, movies: movies, series: series}
}

func TestApply_LinksAndDone(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	if f.st.downloads["1"].State != store.StateDone {
		t.Fatalf("state = %q, want done", f.st.downloads["1"].State)
	}
	if len(f.st.links) != 2 {
		t.Fatalf("file_links = %d, want 2", len(f.st.links))
	}
	want := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	if _, err := os.Stat(want); err != nil {
		t.Errorf("expected hardlink %q: %v", want, err)
	}
	if _, err := os.Stat(filepath.Join(f.downloads, "Show/e1.mkv")); err != nil {
		t.Errorf("source must remain: %v", err)
	}
}

func TestApply_IgnoredFileSkipped(t *testing.T) {
	plan := seriesResult().Plan
	s, e := 2, 9
	plan.Files = append(plan.Files, recognize.PlanFile{
		Src: "Show/sample.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e,
	})
	f := newApplyFixture(t, plan)
	_ = f.st.SetOverride(context.Background(), "1", ovrIgnoredFiles, `["Show/sample.mkv"]`)

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	if len(f.st.links) != 2 { // sample пропущен
		t.Errorf("file_links = %d, want 2 (sample ignored)", len(f.st.links))
	}
}

func TestApply_CollisionStaysReview(t *testing.T) {
	plan := seriesResult().Plan
	f := newApplyFixture(t, plan)
	// Занимаем цель первой серии чужим файлом.
	dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	_ = os.MkdirAll(filepath.Dir(dst), 0o755)
	_ = os.WriteFile(dst, []byte("foreign"), 0o644)

	err := f.w.Apply(context.Background(), "1")
	if err == nil {
		t.Fatal("want collision error")
	}
	if f.st.downloads["1"].State != store.StateReview {
		t.Errorf("state = %q, want review after collision", f.st.downloads["1"].State)
	}
	b, _ := os.ReadFile(dst)
	if string(b) != "foreign" {
		t.Errorf("foreign file overwritten: %q", b)
	}
}

func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
	// Прежняя загрузка (id=2) была разложена по тому же пути, её файл удалён.
	// Новая раскладка (id=1) ложится на освободившийся путь и забирает владение.
	plan := seriesResult().Plan
	f := newApplyFixture(t, plan)
	e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	f.st.put(completedDownload("2"))
	f.st.links = append(f.st.links, store.FileLink{
		DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
		Kind: "video", Status: "linked",
	})

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}

	// Чужая ссылка на перехваченный путь — superseded.
	var foreign *store.FileLink
	for i := range f.st.links {
		if f.st.links[i].DownloadID == "2" {
			foreign = &f.st.links[i]
		}
	}
	if foreign == nil || foreign.Status != string(layout.StatusSuperseded) {
		t.Errorf("чужая ссылка status = %v, want superseded", foreign)
	}
	// Свои ссылки (id=1) не тронуты — download_id != self.
	for _, l := range f.st.links {
		if l.DownloadID == "1" && !isLaidOut(l.Status) {
			t.Errorf("своя ссылка %q стала %q, ожидали разложенную", l.DstPath, l.Status)
		}
	}
	// Прежняя загрузка больше не владеет путём → цель отсутствует.
	present, err := f.w.targetPresent(context.Background(), "2")
	if err != nil {
		t.Fatalf("targetPresent: %v", err)
	}
	if present {
		t.Error("targetPresent(2) = true, want false (путь забран)")
	}
}

// --- Правило сходимости папки (проблема второго сезона) ---

// matchCurrentRec помечает current recognition загрузки id подтверждённым
// матчем (для правила сходимости).
func matchCurrentRec(f applyFixture, id, provider, providerID string) {
	for _, r := range f.st.recs {
		if r.DownloadID == id && r.IsCurrent {
			r.Provider = store.NullString(provider)
			r.ProviderID = store.NullString(providerID)
		}
	}
}

// seedLiveAnchor заводит живой якорь тайтла: current recognition матча +
// разложенную ссылку на папку folder под series, и создаёт эту папку на диске.
func seedLiveAnchor(t *testing.T, f applyFixture, dlID, provider, providerID, folder string, onDisk bool) {
	t.Helper()
	dst := filepath.Join(f.series, folder, "Season 01", folderBaseTest(folder)+" S01E01.mkv")
	if onDisk {
		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(dst, []byte("anchor"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	f.st.recs = append(f.st.recs, &store.Recognition{
		ID: "rec-" + dlID, DownloadID: dlID, IsCurrent: true,
		Provider: store.NullString(provider), ProviderID: store.NullString(providerID),
	})
	f.st.links = append(f.st.links, store.FileLink{
		DownloadID: dlID, ApplyBatchID: "anchor-" + dlID, SrcPath: "/anchor/e1.mkv",
		DstPath: dst, Kind: "video", Status: "linked",
	})
}

// folderBaseTest — «Фарго (2014)» из «Фарго (2014) [tvdbid-269613]» (для имён
// файлов якоря в тесте).
func folderBaseTest(folder string) string {
	if i := strings.LastIndex(folder, " ["); i >= 0 {
		return folder[:i]
	}
	return folder
}

func TestApply_FolderConvergence_SecondSeason(t *testing.T) {
	// Текущая загрузка распознана как «Show (2006)», но матч tvdb=269613
	// совпадает с живым якорем «Фарго (2014)» → наследуем его базу.
	f := newApplyFixture(t, seriesResult().Plan)
	matchCurrentRec(f, "1", "tvdb", "269613")
	seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	// Второй сезон лёг в папку якоря, база — унаследованная и в имени файла.
	want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv")
	if _, err := os.Stat(want); err != nil {
		t.Errorf("ожидали раскладку в папку якоря %q: %v", want, err)
	}
	// Своей папки «Show (2006)» быть не должно.
	if _, err := os.Stat(filepath.Join(f.series, "Show (2006) [tvdbid-269613]")); err == nil {
		t.Error("создана отдельная папка вместо сходимости")
	}
}

func TestApply_FolderConvergence_NoAnchorPrintsFresh(t *testing.T) {
	// Матч есть, но живого якоря нет → печатаем базу из распознавания.
	f := newApplyFixture(t, seriesResult().Plan)
	matchCurrentRec(f, "1", "tvdb", "269613")

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
	if _, err := os.Stat(want); err != nil {
		t.Errorf("ожидали свежую папку из распознавания %q: %v", want, err)
	}
}

func TestApply_FolderConvergence_ProviderMismatchNoInherit(t *testing.T) {
	// Якорь заматчен другим провайдером (tmdb) → по (tvdb,269613) не находится,
	// базу не наследуем.
	f := newApplyFixture(t, seriesResult().Plan)
	matchCurrentRec(f, "1", "tvdb", "269613")
	seedLiveAnchor(t, f, "2", "tmdb", "60622", "Фарго (2014) [tmdbid-60622]", true)

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
	if _, err := os.Stat(want); err != nil {
		t.Errorf("смена провайдера не должна склеивать; ожидали %q: %v", want, err)
	}
}

func TestApply_FolderConvergence_RenamedAnchorPrintsFresh(t *testing.T) {
	// Ссылка якоря живая в БД, но папки на диске нет (переименована) → не якорь.
	f := newApplyFixture(t, seriesResult().Plan)
	matchCurrentRec(f, "1", "tvdb", "269613")
	seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", false) // onDisk=false

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
	if _, err := os.Stat(want); err != nil {
		t.Errorf("отсутствующая на диске папка не якорь; ожидали %q: %v", want, err)
	}
}

func TestApply_FolderConvergence_DesyncGoesReview(t *testing.T) {
	// Две разные живые папки одного матча → рассинхрон → review, без раскладки.
	f := newApplyFixture(t, seriesResult().Plan)
	matchCurrentRec(f, "1", "tvdb", "269613")
	seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
	seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)

	err := f.w.Apply(context.Background(), "1")
	if err == nil {
		t.Fatal("ожидали ошибку рассинхрона")
	}
	if f.st.downloads["1"].State != store.StateReview {
		t.Errorf("state = %q, want review", f.st.downloads["1"].State)
	}
	if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
		t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
	}
	// Раскладки не было.
	if len(f.st.links) != 2 { // только два якоря
		t.Errorf("file_links = %d, раскладка не должна была выполниться", len(f.st.links))
	}
}

func TestApply_FolderConvergence_DesyncFromDeferredGoesReview(t *testing.T) {
	// Регрессия: apply из deferred при рассинхроне. Уход в review идёт из linking
	// (linking→review легален), а не прямым deferred→review (ребра в графе нет).
	f := newApplyFixture(t, seriesResult().Plan)
	f.st.downloads["1"].State = store.StateDeferred
	matchCurrentRec(f, "1", "tvdb", "269613")
	seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
	seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)

	if err := f.w.Apply(context.Background(), "1"); err == nil {
		t.Fatal("ожидали ошибку рассинхрона")
	}
	if f.st.downloads["1"].State != store.StateReview {
		t.Errorf("state = %q, want review", f.st.downloads["1"].State)
	}
	if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
		t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
	}
}

func TestReviewData_PreviewMatchesConvergedApply(t *testing.T) {
	// Превью активного источника наследует ту же базу, что даст применение.
	f := newApplyFixture(t, seriesResult().Plan)
	matchCurrentRec(f, "1", "tvdb", "269613")
	seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)

	rd, err := f.w.ReviewData(context.Background(), "1")
	if err != nil {
		t.Fatalf("ReviewData: %v", err)
	}
	if len(rd.Preview) == 0 {
		t.Fatal("превью пусто")
	}
	// Превью ведёт в папку якоря (относительные пути, с тегом).
	if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Фарго (2014) [tvdbid-269613]", "Season 02")) {
		t.Errorf("превью = %q, ожидали папку якоря", rd.Preview[0].Dst)
	}
}

func TestApply_FolderConvergence_NoMatchNoInherit(t *testing.T) {
	// Матча нет (provider=none), но рядом есть живой якорь — наследования быть не
	// должно (сходимость только при подтверждённом матче).
	f := newApplyFixture(t, seriesResult().Plan) // current rec без provider
	seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	// Без матча тег пуст, база из распознавания.
	want := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	if _, err := os.Stat(want); err != nil {
		t.Errorf("без матча наследования нет; ожидали %q: %v", want, err)
	}
}

func TestReviewData_PreviewDesyncPrintsFreshNoReview(t *testing.T) {
	// Рассинхрон в превью НЕ переводит задачу в review (превью информационно):
	// база печатается из распознавания, состояние остаётся review.
	f := newApplyFixture(t, seriesResult().Plan)
	matchCurrentRec(f, "1", "tvdb", "269613")
	seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
	seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)

	rd, err := f.w.ReviewData(context.Background(), "1")
	if err != nil {
		t.Fatalf("ReviewData: %v", err)
	}
	if f.st.downloads["1"].State != store.StateReview {
		t.Errorf("state = %q, показ превью не должен менять состояние", f.st.downloads["1"].State)
	}
	if len(rd.Preview) == 0 {
		t.Fatal("превью пусто")
	}
	// База из распознавания (не унаследованная), тег текущего матча.
	if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Show (2006) [tvdbid-269613]", "Season 02")) {
		t.Errorf("превью при рассинхроне = %q, ожидали свежую базу из распознавания", rd.Preview[0].Dst)
	}
}

func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
	// Если файл прежней загрузки ВСЁ ЕЩЁ на месте — коллизия → review, владение
	// не отбирается (supersede не срабатывает для не-разложенного пути).
	plan := seriesResult().Plan
	f := newApplyFixture(t, plan)
	e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	_ = os.MkdirAll(filepath.Dir(e01), 0o755)
	_ = os.WriteFile(e01, []byte("foreign"), 0o644)
	f.st.put(completedDownload("2"))
	f.st.links = append(f.st.links, store.FileLink{
		DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
		Kind: "video", Status: "linked",
	})

	if err := f.w.Apply(context.Background(), "1"); err == nil {
		t.Fatal("want collision error")
	}
	for _, l := range f.st.links {
		if l.DownloadID == "2" && l.Status != "linked" {
			t.Errorf("чужая ссылка стала %q при коллизии, владение не должно отбираться", l.Status)
		}
	}
}

func TestUndo_RevertsLinks(t *testing.T) {
	plan := seriesResult().Plan
	f := newApplyFixture(t, plan)
	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	if _, err := os.Stat(dst); err != nil {
		t.Fatalf("precondition: link must exist: %v", err)
	}

	if err := f.w.Undo(context.Background(), "1"); err != nil {
		t.Fatalf("Undo: %v", err)
	}
	if f.st.downloads["1"].State != store.StateReverted {
		t.Errorf("state = %q, want reverted", f.st.downloads["1"].State)
	}
	if _, err := os.Stat(dst); !os.IsNotExist(err) {
		t.Errorf("link must be removed: %v", err)
	}
	if len(f.st.links) != 0 {
		t.Errorf("file_links must be deleted, got %d", len(f.st.links))
	}
	// Источник цел.
	if _, err := os.Stat(filepath.Join(f.downloads, "Show/e1.mkv")); err != nil {
		t.Errorf("source removed by undo: %v", err)
	}
}

func TestDelete_FromDone_RemovesBothSides(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")

	if err := f.w.Delete(context.Background(), "1"); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	d := f.st.downloads["1"]
	if d.State != store.StateDeleted {
		t.Errorf("state = %q, want deleted", d.State)
	}
	if d.ErrorCode.String != "user_delete" {
		t.Errorf("error_code = %q, want user_delete", d.ErrorCode.String)
	}
	if _, err := os.Stat(dst); !os.IsNotExist(err) {
		t.Errorf("library link must be removed: %v", err)
	}
	if len(f.st.links) != 0 {
		t.Errorf("file_links must be deleted, got %d", len(f.st.links))
	}
	if len(f.qb.deleted) != 1 || len(f.qb.deleted[0]) == 0 || f.qb.deleted[0][0] != ihTest {
		t.Errorf("qbt.Delete calls = %v, want [[%s]]", f.qb.deleted, ihTest)
	}
}

func TestDelete_FromOrphaned_RemovesLastCopy(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	// Источник пропал → библиотечная ссылка стала последней копией (nlink==1):
	// Undo бы отказал, Delete снимает осознанно.
	for _, src := range []string{"Show/e1.mkv", "Show/e2.mkv"} {
		_ = os.Remove(filepath.Join(f.downloads, src))
	}
	f.qb.torrents = nil // раздачи в qBittorrent больше нет
	f.st.downloads["1"].State = store.StateOrphaned

	if err := f.w.Delete(context.Background(), "1"); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	if f.st.downloads["1"].State != store.StateDeleted {
		t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
	}
	if _, err := os.Stat(dst); !os.IsNotExist(err) {
		t.Errorf("last-copy link must be removed despite guard: %v", err)
	}
	// Отсутствие раздачи не ошибка — вызов всё равно состоялся идемпотентно.
	if len(f.qb.deleted) != 1 {
		t.Errorf("qbt.Delete calls = %d, want 1 (idempotent)", len(f.qb.deleted))
	}
}

func TestDelete_FromTargetMissing_Idempotent(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	// Цель удалена вручную, источник на месте.
	dst1 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	dst2 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E02.mkv")
	_ = os.Remove(dst1)
	_ = os.Remove(dst2)
	f.st.downloads["1"].State = store.StateTargetMissing

	if err := f.w.Delete(context.Background(), "1"); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	if f.st.downloads["1"].State != store.StateDeleted {
		t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
	}
	if len(f.qb.deleted) != 1 { // остаточную раздачу снесли
		t.Errorf("qbt.Delete calls = %d, want 1", len(f.qb.deleted))
	}
}

func TestDelete_WrongStateRejected(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan) // состояние review
	err := f.w.Delete(context.Background(), "1")
	if !errors.Is(err, ErrConflict) {
		t.Fatalf("Delete from review = %v, want ErrConflict", err)
	}
	if f.st.downloads["1"].State != store.StateReview {
		t.Errorf("state = %q, want review (unchanged)", f.st.downloads["1"].State)
	}
	if len(f.qb.deleted) != 0 {
		t.Errorf("qbt.Delete must not be called, got %v", f.qb.deleted)
	}
}

func TestDelete_QbtErrorKeepsState(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	f.qb.deleteErr = errors.New("qbittorrent boom")

	if err := f.w.Delete(context.Background(), "1"); err == nil {
		t.Fatal("Delete must return qbittorrent error")
	}
	// Ложно в deleted не уходим (место не освобождено).
	if f.st.downloads["1"].State == store.StateDeleted {
		t.Errorf("state = deleted despite qbt error; want unchanged (done)")
	}
	// Повторный delete после устранения сбоя идемпотентно дожимает удаление:
	// цель уже снята (file_links удалены), источник сносится, задача → deleted.
	f.qb.deleteErr = nil
	if err := f.w.Delete(context.Background(), "1"); err != nil {
		t.Fatalf("repeat Delete: %v", err)
	}
	if f.st.downloads["1"].State != store.StateDeleted {
		t.Errorf("state = %q after repeat, want deleted", f.st.downloads["1"].State)
	}
}

func TestReviewData(t *testing.T) {
	plan := seriesResult().Plan
	f := newApplyFixture(t, plan)
	_ = f.st.AddHint(context.Background(), "1", "подсказка")

	rd, err := f.w.ReviewData(context.Background(), "1")
	if err != nil {
		t.Fatalf("ReviewData: %v", err)
	}
	if rd.Recognition == nil || len(rd.Plan.Files) != 2 {
		t.Fatalf("plan files = %+v", rd.Plan)
	}
	if len(rd.Preview) != 2 {
		t.Errorf("preview links = %d, want 2", len(rd.Preview))
	}
	if len(rd.Hints) != 1 {
		t.Errorf("hints = %v", rd.Hints)
	}
}

func TestApplyOverrides(t *testing.T) {
	plan := recognize.Plan{
		Type:  recognize.MediaMovie,
		Title: "Сырое название",
		Files: []recognize.PlanFile{
			{Src: "a.mkv", Role: recognize.RoleMain},
			{Src: "b.mkv", Role: recognize.RoleEpisode},
		},
	}
	out := applyOverrides(plan, map[string]string{
		ovrTitle:        "Каноническое название",
		ovrIgnoredFiles: `["a.mkv"]`,
	})
	if out.Title != "Каноническое название" {
		t.Errorf("title = %q, want override", out.Title)
	}
	if out.Files[0].Role != "ignore" {
		t.Errorf("a.mkv role = %q, want ignore", out.Files[0].Role)
	}
}

func TestRecognizeOne_AutoApplies(t *testing.T) {
	root := t.TempDir()
	downloads := filepath.Join(root, "downloads")
	movies := filepath.Join(root, "movies")
	series := filepath.Join(root, "series")
	for _, d := range []string{downloads, movies, series} {
		_ = os.MkdirAll(d, 0o755)
	}
	plan := seriesResult().Plan
	plan.Confidence = 0.95
	for _, f := range plan.Files {
		p := filepath.Join(downloads, f.Src)
		_ = os.MkdirAll(filepath.Dir(p), 0o755)
		_ = os.WriteFile(p, []byte("x"), 0o644)
	}
	lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil)

	st := newMemStore()
	st.put(completedDownload("1"))
	qb := &fakeQbt{
		torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: downloads, Category: "jellybit"}},
		files:    []qbt.File{{Name: "Show/e1.mkv", Size: 1}, {Name: "Show/e2.mkv", Size: 1}},
	}
	rec := &fakeRecognizer{result: recognize.Result{
		Plan:     plan,
		Decision: recognize.Decision{Auto: true},
		Match:    &recognize.Match{Provider: "tmdb", ProviderID: "42", Title: "Show", Year: 2006},
	}}
	w := testWorkerWith(st, qb, rec, lay)

	w.recognizeOne(context.Background(), "1")

	if st.downloads["1"].State != store.StateDone {
		t.Fatalf("state = %q, want done (auto)", st.downloads["1"].State)
	}
	// Provider-тег попал в имя папки.
	want := filepath.Join(series, "Show (2006) [tmdbid-42]", "Season 02", "Show (2006) S02E01.mkv")
	if _, err := os.Stat(want); err != nil {
		t.Errorf("expected auto-linked file %q: %v", want, err)
	}
	if len(st.links) != 2 {
		t.Errorf("file_links = %d, want 2", len(st.links))
	}
}

func TestApply_UsesProviderTag(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	f.st.recs[0].Provider = store.NullString("tmdb")
	f.st.recs[0].ProviderID = store.NullString("603")

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	want := filepath.Join(f.series, "Show (2006) [tmdbid-603]", "Season 02", "Show (2006) S02E01.mkv")
	if _, err := os.Stat(want); err != nil {
		t.Errorf("expected tagged path %q: %v", want, err)
	}
}

func TestProviderTag(t *testing.T) {
	cases := []struct{ provider, id, want string }{
		{"tmdb", "603", "tmdbid-603"},
		{"tvdb", "123", "tvdbid-123"},
		{"imdb", "tt2802850", "imdbid-tt2802850"},
		{"none", "", ""},
		{"tmdb", "", ""},
		{"weird", "1", ""},
	}
	for _, c := range cases {
		if got := providerTag(c.provider, c.id); got != c.want {
			t.Errorf("providerTag(%q,%q) = %q, want %q", c.provider, c.id, got, c.want)
		}
	}
}

// reviewWithCandidate готовит memStore: задача в review, одна попытка
// распознавания с одним кандидатом базы.
func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore) {
	t.Helper()
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateReview
	st.put(d)
	planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaSeries, Title: "Догадка", Year: 2000})
	st.recs = append(st.recs, &store.Recognition{
		ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
		Provider: store.NullString("none"),
	})
	cand.RecognitionID = "1"
	_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
	w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
	return w, st
}

func TestRecognizeOne_PersistsCandidates(t *testing.T) {
	st := newMemStore()
	st.put(completedDownload("1"))
	qb := &fakeQbt{
		torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
		files:    []qbt.File{{Name: "e1.mkv", Size: 1}},
	}
	res := seriesResult()
	res.Candidates = []metadata.Candidate{
		{Provider: "tvmaze", ID: "1", Title: "Show A", Year: 2006, TagProvider: "tvdb", TagID: "269613", URL: "https://www.tvmaze.com/shows/1"},
		{Provider: "tvmaze", ID: "2", Title: "Show B", Year: 2007},
	}
	w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)

	w.recognizeOne(context.Background(), "1")

	if len(st.candidates) != 2 {
		t.Fatalf("candidates = %d, want 2", len(st.candidates))
	}
	// Тег-предпочтительный provider/id сохранён (TVMaze → tvdb).
	if st.candidates[0].Provider != "tvdb" || st.candidates[0].ProviderID != "269613" {
		t.Errorf("candidate[0] = %+v", st.candidates[0])
	}
	// URL первого кандидата сохранён.
	if st.candidates[0].URL.String != "https://www.tvmaze.com/shows/1" || !st.candidates[0].URL.Valid {
		t.Errorf("candidate[0].URL = (%q, valid=%v), want url", st.candidates[0].URL.String, st.candidates[0].URL.Valid)
	}
	// У второго кандидата URL не задан — в БД должен быть NULL (Valid=false).
	if st.candidates[1].URL.Valid {
		t.Error("candidate[1].URL must be NULL (Valid=false) for candidate without URL")
	}
}

func TestChooseCandidate_PinsOverrides(t *testing.T) {
	w, st := reviewWithCandidate(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "269613",
		Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true},
	})
	candID := st.candidates[0].ID

	if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
		t.Fatalf("ChooseCandidate: %v", err)
	}
	ov := st.overrides["1"]
	if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" ||
		ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" {
		t.Errorf("overrides = %v", ov)
	}
	if !st.candidates[0].Chosen {
		t.Error("кандидат не помечен выбранным")
	}
	// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
	plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
	if err != nil {
		t.Fatalf("effectivePlan: %v", err)
	}
	if plan.Title != "Fargo" || plan.Year != 2014 {
		t.Errorf("plan = %q (%d)", plan.Title, plan.Year)
	}
	if tag := providerTag(prov, pid); tag != "tvdbid-269613" {
		t.Errorf("tag = %q", tag)
	}
}

// refreshSetup: загрузка в review с распознаванием (movie «Догадка»/2000),
// display_name="Unknown", кандидатом cand и раздачей в qBit по ihTest — для
// проверок перелива имени после подтверждения матча.
func refreshSetup(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore, *fakeQbt) {
	t.Helper()
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateReview
	d.DisplayName = "Unknown"
	st.put(d)
	planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000})
	st.recs = append(st.recs, &store.Recognition{
		ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
		Provider: store.NullString("none"),
	})
	cand.RecognitionID = "1"
	_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
	// Ключ раздачи в qBit (Hash) НАМЕРЕННО отличается от нашего инфохэша: matched
	// по InfohashV1. Так тест ловит, что rename идёт по реальному t.Hash (мотив
	// D3: для гибрид/v2 t.Hash ≠ PrimaryInfohash), а не по сырому инфохэшу.
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: qbtHash, InfohashV1: ihTest, Name: "Unknown"}}}
	w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
	return w, st, qb
}

// qbtHash — внутренний ключ раздачи в qBittorrent, отличный от инфохэша загрузки.
const qbtHash = "qbt-internal-key"

func TestChooseCandidate_RefreshesDisplayName(t *testing.T) {
	w, st, qb := refreshSetup(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "343558",
		Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
	})
	if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
		t.Fatalf("ChooseCandidate: %v", err)
	}
	const want = "Harold and the Purple Crayon (2024)"
	if got := st.downloads["1"].DisplayName; got != want {
		t.Errorf("display_name = %q, want %q", got, want)
	}
	// rename адресован по реальному t.Hash (qbtHash), а НЕ по инфохэшу загрузки.
	if len(qb.renamed) != 1 || qb.renamed[0].hash != qbtHash || qb.renamed[0].name != want {
		t.Errorf("renamed = %+v, want hash=%s name=%q", qb.renamed, qbtHash, want)
	}
	if qbtHash == ihTest {
		t.Fatal("тест бессмыслен: qbtHash совпал с инфохэшем загрузки")
	}
}

// A2: выбор кандидата тянет режиссёра из метабазы (recognizer.Director), пиннит
// его как ovrDirector и переливает полный ярлык «Название (режиссёр, год)».
func TestChooseCandidate_DirectorFromMetabase(t *testing.T) {
	w, st, _ := refreshSetup(t, store.MetadataCandidate{
		Provider: "tmdb", ProviderID: "693134",
		Title: store.NullString("Дюна"), Year: sql.NullInt64{Int64: 2024, Valid: true},
	})
	w.recognizer = &fakeRecognizer{director: "Дени Вильнёв"}

	if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
		t.Fatalf("ChooseCandidate: %v", err)
	}
	if ov := st.overrides["1"]; ov[ovrDirector] != "Дени Вильнёв" {
		t.Errorf("ovrDirector = %q, want «Дени Вильнёв»", ov[ovrDirector])
	}
	const want = "Дюна (Дени Вильнёв, 2024)"
	if got := st.downloads["1"].DisplayName; got != want {
		t.Errorf("display_name = %q, want %q", got, want)
	}
}

func TestChooseCandidate_RefreshDisplayName_QbitErrorTolerated(t *testing.T) {
	w, st, qb := refreshSetup(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "343558",
		Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
	})
	qb.renameErr = errors.New("qbit down")
	// Best-effort: сбой переименования раздачи не должен ронять выбор кандидата.
	if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
		t.Fatalf("ChooseCandidate must tolerate qbit rename failure: %v", err)
	}
	if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
		t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой qBit)", got)
	}
}

func TestChooseCandidate_RefreshDisplayName_TorrentMissing(t *testing.T) {
	// Раздача удалена (нет в qBit) — display_name всё равно обновлён, rename нет.
	w, st, qb := refreshSetup(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "343558",
		Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
	})
	qb.torrents = nil // torrentByInfohash не найдёт раздачу → best-effort no-op
	if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
		t.Fatalf("ChooseCandidate: %v", err)
	}
	if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
		t.Errorf("display_name = %q (должен быть обновлён и без раздачи)", got)
	}
	if len(qb.renamed) != 0 {
		t.Errorf("renamed = %+v, want none (раздачи нет)", qb.renamed)
	}
}

func TestChooseCandidate_RefreshDisplayName_TorrentsLookupError(t *testing.T) {
	// Листинг qBit упал — display_name обновлён, rename не зовём, команда цела.
	w, st, qb := refreshSetup(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "343558",
		Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
	})
	qb.torrentsErr = errors.New("qbit down")
	if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
		t.Fatalf("ChooseCandidate must tolerate qbit lookup failure: %v", err)
	}
	if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
		t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой листинга)", got)
	}
	if len(qb.renamed) != 0 {
		t.Errorf("renamed = %+v, want none (листинг упал)", qb.renamed)
	}
}

func TestAddManualSource_RefreshesDisplayName(t *testing.T) {
	// Ручное добавление источника — тоже подтверждение матча → refresh.
	// Кандидат без title/year → имя берётся из распознавания (fallback).
	w, st, qb := refreshSetup(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"})
	if err := w.AddManualSource(context.Background(), "1", "tmdb", "603"); err != nil {
		t.Fatalf("AddManualSource: %v", err)
	}
	if got := st.downloads["1"].DisplayName; got != "Догадка (2000)" {
		t.Errorf("display_name = %q, want fallback «Догадка (2000)»", got)
	}
	if len(qb.renamed) != 1 || qb.renamed[0].name != "Догадка (2000)" {
		t.Errorf("renamed = %+v", qb.renamed)
	}
}

func TestRefreshDisplayName_NoRecognitionNoop(t *testing.T) {
	// Нет распознавания — обновлять нечего: display_name не трогаем, rename нет.
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateDownloading
	d.DisplayName = ""
	st.put(d)
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "raw"}}}
	w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)

	if err := w.RefreshDisplayName(context.Background(), "1"); err != nil {
		t.Fatalf("RefreshDisplayName: %v", err)
	}
	if got := st.downloads["1"].DisplayName; got != "" {
		t.Errorf("display_name = %q, want empty (no-op)", got)
	}
	if len(qb.renamed) != 0 {
		t.Errorf("renamed = %+v, want none", qb.renamed)
	}
}

func TestChooseCandidate_RejectsForeign(t *testing.T) {
	w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
	if err := w.ChooseCandidate(context.Background(), "1", "999"); err == nil {
		t.Error("чужой кандидат должен отклоняться")
	}
}

func TestSetProviderID(t *testing.T) {
	w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
	if err := w.SetProviderID(context.Background(), "1", "TMDB", " 603 "); err != nil {
		t.Fatalf("SetProviderID: %v", err)
	}
	if st.overrides["1"][ovrProvider] != "tmdb" || st.overrides["1"][ovrProviderID] != "603" {
		t.Errorf("overrides = %v", st.overrides["1"])
	}
	if err := w.SetProviderID(context.Background(), "1", "kinopoisk", "1"); err == nil {
		t.Error("недопустимый провайдер должен отклоняться")
	}
	if err := w.SetProviderID(context.Background(), "1", "tmdb", ""); err == nil {
		t.Error("пустой id должен отклоняться")
	}
}

func TestClearProvider(t *testing.T) {
	w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
	_ = st.SetOverride(context.Background(), "1", ovrProvider, "tvdb")
	if err := w.ClearProvider(context.Background(), "1"); err != nil {
		t.Fatalf("ClearProvider: %v", err)
	}
	if st.overrides["1"][ovrProvider] != "none" {
		t.Errorf("provider override = %q, want none", st.overrides["1"][ovrProvider])
	}
	// «Без базы» → пустой тег.
	_, prov, pid, _ := w.effectivePlan(context.Background(), "1")
	if tag := providerTag(prov, pid); tag != "" {
		t.Errorf("tag = %q, want empty", tag)
	}
}

// TestClearProvider_ClearsInheritedTitle — регрессия на залипший override
// (решение 1a): после выбора титульного кандидата переключение на нейронку
// возвращает название/год плана, а не унаследованные от кандидата.
func TestClearProvider_ClearsInheritedTitle(t *testing.T) {
	w, st := reviewWithCandidate(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "269613",
		Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true},
	})
	candID := st.candidates[0].ID
	if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
		t.Fatalf("ChooseCandidate: %v", err)
	}
	if err := w.ClearProvider(context.Background(), "1"); err != nil {
		t.Fatalf("ClearProvider: %v", err)
	}
	// Название/год снова из плана распознавания, тег пуст.
	plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
	if err != nil {
		t.Fatalf("effectivePlan: %v", err)
	}
	if plan.Title != "Догадка" || plan.Year != 2000 {
		t.Errorf("plan = %q (%d), want plan-defaults", plan.Title, plan.Year)
	}
	if tag := providerTag(prov, pid); tag != "" {
		t.Errorf("tag = %q, want empty", tag)
	}
	if ov := st.overrides["1"]; ov[ovrTitle] != "" || ov[ovrYear] != "" {
		t.Errorf("title/year override not cleared: %v", ov)
	}
}

// TestAddManualSource_ChoosesAndClears — ручной источник создаётся, выбирается
// и (будучи безтайтловым) очищает унаследованный пин названия.
func TestAddManualSource_ChoosesAndClears(t *testing.T) {
	w, st := reviewWithCandidate(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "269613",
		Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true},
	})
	if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
		t.Fatalf("ChooseCandidate: %v", err)
	}
	if err := w.AddManualSource(context.Background(), "1", "TMDB", " 603 "); err != nil {
		t.Fatalf("AddManualSource: %v", err)
	}
	if len(st.candidates) != 2 {
		t.Fatalf("candidates = %d, want 2 (manual added)", len(st.candidates))
	}
	plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
	if err != nil {
		t.Fatalf("effectivePlan: %v", err)
	}
	if plan.Title != "Догадка" { // безтайтловый источник → название из плана
		t.Errorf("plan title = %q, want plan-default", plan.Title)
	}
	if tag := providerTag(prov, pid); tag != "tmdbid-603" {
		t.Errorf("tag = %q", tag)
	}
}

func TestAddManualSource_DedupAndInvalid(t *testing.T) {
	w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"})
	// Дубль (provider:id уже есть) → не плодим строку, выбираем существующую.
	if err := w.AddManualSource(context.Background(), "1", "tvdb", "269613"); err != nil {
		t.Fatalf("AddManualSource dup: %v", err)
	}
	if len(st.candidates) != 1 {
		t.Errorf("candidates = %d, want 1 (dedup)", len(st.candidates))
	}
	if !st.candidates[0].Chosen {
		t.Error("существующий кандидат должен стать выбранным")
	}
	if err := w.AddManualSource(context.Background(), "1", "kinopoisk", "1"); err == nil {
		t.Error("недопустимый провайдер должен отклоняться")
	}
	if err := w.AddManualSource(context.Background(), "1", "tmdb", "  "); err == nil {
		t.Error("пустой id должен отклоняться")
	}
}

func TestReviewData_BuildsSources(t *testing.T) {
	w, st := reviewWithCandidate(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
	})
	rd, err := w.ReviewData(context.Background(), "1")
	if err != nil {
		t.Fatalf("ReviewData: %v", err)
	}
	// Построение источников (в т.ч. предпросмотр) не должно писать overrides.
	if ov := st.overrides["1"]; len(ov) != 0 {
		t.Errorf("ReviewData записал overrides: %v", ov)
	}
	if len(rd.Sources) != 2 {
		t.Fatalf("sources = %d, want 2 (neural + candidate)", len(rd.Sources))
	}
	if rd.Sources[0].Kind != SourceNeural || !rd.Sources[0].Active {
		t.Errorf("source[0] = %+v, want активная нейронка", rd.Sources[0])
	}
	if rd.Sources[0].Title != "Догадка" {
		t.Errorf("neural title = %q, want plan title", rd.Sources[0].Title)
	}
	if rd.Sources[1].Kind != SourceCandidate || rd.Sources[1].Title != "Fargo" || rd.Sources[1].Active {
		t.Errorf("source[1] = %+v, want неактивный кандидат Fargo", rd.Sources[1])
	}
}

// TestReviewData_SourcePreviewMatchesChosen — предпросмотр источника совпадает
// с превью активного источника после его выбора (эфемерное превью == то, что
// применится). Требует реального layouter.
func TestReviewData_SourcePreviewMatchesChosen(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	_ = f.st.CreateCandidates(context.Background(), []store.MetadataCandidate{
		{RecognitionID: "1", Provider: "tvdb", ProviderID: "269613"},
	})
	candID := f.st.candidates[0].ID

	rd, err := f.w.ReviewData(context.Background(), "1")
	if err != nil {
		t.Fatalf("ReviewData: %v", err)
	}
	var srcPreview []layout.Link
	for _, s := range rd.Sources {
		if s.Kind == SourceCandidate && s.ProviderID == "269613" {
			srcPreview = s.Preview
		}
	}
	if len(srcPreview) == 0 {
		t.Fatal("предпросмотр кандидата пуст")
	}
	if err := f.w.ChooseCandidate(context.Background(), "1", candID); err != nil {
		t.Fatalf("ChooseCandidate: %v", err)
	}
	rd2, err := f.w.ReviewData(context.Background(), "1")
	if err != nil {
		t.Fatalf("ReviewData: %v", err)
	}
	if !sameLinks(srcPreview, rd2.Preview) {
		t.Errorf("превью источника != превью активного:\n src=%v\n act=%v", srcPreview, rd2.Preview)
	}
}

func sameLinks(a, b []layout.Link) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i].Src != b[i].Src || a[i].Dst != b[i].Dst {
			return false
		}
	}
	return true
}

func TestReviewData_IncludesCandidates(t *testing.T) {
	w, st := reviewWithCandidate(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
	})
	candID := st.candidates[0].ID
	if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
		t.Fatal(err)
	}
	rd, err := w.ReviewData(context.Background(), "1")
	if err != nil {
		t.Fatalf("ReviewData: %v", err)
	}
	if len(rd.Candidates) != 1 {
		t.Fatalf("candidates = %d", len(rd.Candidates))
	}
	if rd.Provider != "tvdb" || rd.ProviderID != "269613" {
		t.Errorf("eff provider = %s/%s", rd.Provider, rd.ProviderID)
	}
	if rd.Plan.Title != "Fargo" {
		t.Errorf("plan title = %q", rd.Plan.Title)
	}
}

func TestToStoreCandidates_URL(t *testing.T) {
	// Кандидат с URL: URL должен быть проброшен как непустой NullString.
	// Кандидат без URL: URL должен быть пустым NullString (Valid=false → NULL).
	candURL := toStoreCandidates("1", []metadata.Candidate{
		{Provider: "tmdb", ID: "603", Title: "With URL", URL: "https://www.themoviedb.org/movie/603"},
		{Provider: "tvdb", ID: "1", Title: "Without URL", URL: ""},
	})
	if len(candURL) != 2 {
		t.Fatalf("len = %d, want 2", len(candURL))
	}
	if c := candURL[0]; c.URL.String != "https://www.themoviedb.org/movie/603" || !c.URL.Valid {
		t.Errorf("URL[0] = (%q, valid=%v), want (url, true)", c.URL.String, c.URL.Valid)
	}
	if c := candURL[1]; c.URL.String != "" || c.URL.Valid {
		t.Errorf("URL[1] = (%q, valid=%v), want (\"\", false)", c.URL.String, c.URL.Valid)
	}
}

func TestToLayoutPlan(t *testing.T) {
	s, e := 1, 3
	plan := recognize.Plan{
		Type: recognize.MediaSeries, Title: "X", Year: 2020,
		Files: []recognize.PlanFile{
			{Src: "e.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e},
			{Src: "sample.mkv", Role: "sample"},
		},
	}
	lp := toLayoutPlan(plan, "/d", "tmdbid-1", "")
	if len(lp.Files) != 1 {
		t.Fatalf("want 1 linkable file, got %d", len(lp.Files))
	}
	if lp.Files[0].Src != filepath.Join("/d", "e.mkv") {
		t.Errorf("src = %q", lp.Files[0].Src)
	}
	if lp.Files[0].Role != layout.RoleEpisode {
		t.Errorf("role = %q", lp.Files[0].Role)
	}
	if lp.ProviderTag != "tmdbid-1" {
		t.Errorf("provider tag = %q", lp.ProviderTag)
	}
}

// TestToLayoutPlan_SrcPrefixIsSavePath фиксирует семантику префикса: имена
// файлов из qBittorrent /torrents/files относительны save_path и уже содержат
// корневую папку для многофайловых раздач. Префикс — save_path, а не
// content_path (иначе корневая папка удвоилась бы, а однофайловая раздача
// получила бы путь под самим файлом). Это регрессионный страж против правки
// префикса на content_path.
func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) {
	const savePath = "/srv/media/downloads"
	s, e := 1, 1
	cases := []struct {
		name string
		src  string
		want string
	}{
		// Многофайловая раздача: имя включает корневую папку торрента.
		{"multi-file", "Show.S01/e1.mkv", filepath.Join(savePath, "Show.S01/e1.mkv")},
		// Однофайловая раздача: имя — просто файл (content_path = save_path+файл).
		{"single-file", "movie.mkv", filepath.Join(savePath, "movie.mkv")},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			plan := recognize.Plan{
				Type: recognize.MediaMovie, Title: "X", Year: 2020,
				Files: []recognize.PlanFile{
					{Src: tc.src, Role: recognize.RoleMain, Season: &s, Episode: &e},
				},
			}
			lp := toLayoutPlan(plan, savePath, "", "")
			if len(lp.Files) != 1 {
				t.Fatalf("want 1 file, got %d", len(lp.Files))
			}
			if lp.Files[0].Src != tc.want {
				t.Errorf("src = %q, want %q", lp.Files[0].Src, tc.want)
			}
		})
	}
}

// --- Устойчивость раскладки и переходов (MAJOR-4, MINOR-7) ---

// TestApply_ClaimFailureAbortsBeforeLinks (MINOR-7): если запись claim перехода
// в linking падает, Apply ОБЯЗАН прерваться ДО создания хардлинков — иначе
// ссылки лягут при задаче в review, а финальный linking→done граф отклонит.
func TestApply_ClaimFailureAbortsBeforeLinks(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	f.st.failSetState = func(st store.State) error {
		if st == store.StateLinking {
			return errors.New("boom: claim persist failed")
		}
		return nil
	}

	err := f.w.Apply(context.Background(), "1")
	if err == nil {
		t.Fatal("Apply must fail when linking claim persist fails")
	}
	if f.st.downloads["1"].State != store.StateReview {
		t.Errorf("state = %q, want review (claim not committed)", f.st.downloads["1"].State)
	}
	if len(f.st.links) != 0 {
		t.Errorf("file_links = %d, want 0 (no linking before committed claim)", len(f.st.links))
	}
	// Хардлинки на диск НЕ созданы — раскладка не запускалась.
	dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	if _, statErr := os.Stat(dst); statErr == nil {
		t.Errorf("hardlink %q created despite failed claim", dst)
	}
}

// TestApply_PersistFailureLeavesReview (MAJOR-4 A): хардлинки созданы, но
// CreateFileLinks упал транзиентно → задача не должна застрять в linking; уходит
// в review с причиной, повтор идемпотентен.
func TestApply_PersistFailureLeavesReview(t *testing.T) {
	f := newApplyFixture(t, seriesResult().Plan)
	f.st.failCreateLinks = errors.New("boom: sqlite busy")

	err := f.w.Apply(context.Background(), "1")
	if err == nil {
		t.Fatal("Apply must fail when persisting links fails")
	}
	if f.st.downloads["1"].State != store.StateReview {
		t.Fatalf("state = %q, want review (not stranded in linking)", f.st.downloads["1"].State)
	}
	if f.st.downloads["1"].ErrorCode.String != "persist" {
		t.Errorf("error_code = %q, want persist", f.st.downloads["1"].ErrorCode.String)
	}
	// Хардлинки уже на диске (учёт лишь не записан) — повторный Apply их допишет.
	dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
	if _, statErr := os.Stat(dst); statErr != nil {
		t.Errorf("expected hardlink on disk despite persist failure: %v", statErr)
	}
}

// TestSweepLinking_OrphanedToReview (MAJOR-4 B): задача, застрявшая в linking
// после краха, на тике/старте возвращается в review с причиной.
func TestSweepLinking_OrphanedToReview(t *testing.T) {
	st := newMemStore()
	d := completedDownload("1")
	d.State = store.StateLinking
	st.put(d)
	w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)

	w.sweepLinking(context.Background())

	got := st.downloads["1"]
	if got.State != store.StateReview {
		t.Fatalf("state = %q, want review", got.State)
	}
	if got.ErrorCode.String != "interrupted" {
		t.Errorf("error_code = %q, want interrupted", got.ErrorCode.String)
	}
	if got.ErrorMsg.String == "" {
		t.Error("expected error_msg with reason")
	}
}

// TestSweepLinking_LeavesOtherStates: sweep трогает только linking, прочие
// состояния (в т.ч. done) не задевает.
func TestSweepLinking_LeavesOtherStates(t *testing.T) {
	st := newMemStore()
	done := completedDownload("1")
	done.State = store.StateDone
	st.put(done)
	review := completedDownload("2")
	review.State = store.StateReview
	st.put(review)
	w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)

	w.sweepLinking(context.Background())

	if st.downloads["1"].State != store.StateDone {
		t.Errorf("done task moved to %q", st.downloads["1"].State)
	}
	if st.downloads["2"].State != store.StateReview {
		t.Errorf("review task moved to %q", st.downloads["2"].State)
	}
}

// Кандидат с непригодным названием (голая пунктуация — TVDB правится
// сообществом, мусорные записи там штатны): пин названия не ставится, и в
// эффективном плане остаётся название распознавания. Иначе в библиотеке
// появился бы каталог «- (2000)», а на «.» раскладка упала бы ошибкой слоя
// layout — тот же исход, что и у канонического названия на авто-пути.
func TestChooseCandidate_UnusableTitleNotPinned(t *testing.T) {
	for _, title := range []string{"-", ".", "..."} {
		t.Run(title, func(t *testing.T) {
			w, st := reviewWithCandidate(t, store.MetadataCandidate{
				Provider: "tvdb", ProviderID: "269613",
				Title: store.NullString(title),
				Year:  sql.NullInt64{Int64: 2014, Valid: true},
			})
			if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
				t.Fatalf("ChooseCandidate: %v", err)
			}
			if ov := st.overrides["1"]; ov[ovrTitle] != "" {
				t.Errorf("непригодное название закреплено: %q", ov[ovrTitle])
			}
			// Провайдер, id и год закрепляются как обычно — матч верен.
			if ov := st.overrides["1"]; ov[ovrProviderID] != "269613" || ov[ovrYear] != "2014" {
				t.Errorf("overrides = %v", st.overrides["1"])
			}
			plan, _, _, err := w.effectivePlan(context.Background(), "1")
			if err != nil {
				t.Fatalf("effectivePlan: %v", err)
			}
			if plan.Title != "Догадка" {
				t.Errorf("plan.Title = %q, want название распознавания", plan.Title)
			}
		})
	}
}

// Кандидат, сохранённый ПРЕЖНЕЙ версией (до чистки на входе в список), лежит в
// БД грязным. Гарантия чистоты не может держаться на времени записи: чистка
// стоит на закреплении, в sourcePins, и идемпотентна — на новых строках это
// no-op. Без неё невидимка доезжает до имени каталога, потому что
// layout.sanitizeComponent категорию Cf не трогает.
func TestChooseCandidate_DirtyLegacyTitleSanitized(t *testing.T) {
	w, st := reviewWithCandidate(t, store.MetadataCandidate{
		Provider: "tvdb", ProviderID: "269613",
		Title: store.NullString("Far\u200bgo"),
		Year:  sql.NullInt64{Int64: 2014, Valid: true},
	})
	if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
		t.Fatalf("ChooseCandidate: %v", err)
	}
	if got := st.overrides["1"][ovrTitle]; got != "Fargo" {
		t.Errorf("закреплено %q, want %q — грязная строка из БД не очищена", got, "Fargo")
	}
	plan, _, _, err := w.effectivePlan(context.Background(), "1")
	if err != nil {
		t.Fatalf("effectivePlan: %v", err)
	}
	if plan.Title != "Fargo" {
		t.Errorf("plan.Title = %q", plan.Title)
	}
}

// Превью = применение: строка источника показывает ровно то название, которое
// закрепится по клику. Прежде превью считалось из сырого названия кандидата, а
// гейт пригодности стоял только на закреплении — экран обещал одно, раскладка
// делала другое.
func TestBuildSources_PreviewMatchesApply(t *testing.T) {
	for _, c := range []struct{ name, dbTitle, want string }{
		{"непригодное название", "-", "Догадка"},
		{"грязное название", "Far\u200bgo", "Fargo"},
	} {
		t.Run(c.name, func(t *testing.T) {
			w, st := reviewWithCandidate(t, store.MetadataCandidate{
				Provider: "tvdb", ProviderID: "269613",
				Title: store.NullString(c.dbTitle),
				Year:  sql.NullInt64{Int64: 2014, Valid: true},
			})
			rd, err := w.ReviewData(context.Background(), "1")
			if err != nil {
				t.Fatalf("ReviewData: %v", err)
			}
			var src *SourceOption
			for i := range rd.Sources {
				if rd.Sources[i].Kind == SourceCandidate {
					src = &rd.Sources[i]
				}
			}
			if src == nil {
				t.Fatal("кандидат не попал в источники")
			}
			if src.Title != c.want {
				t.Errorf("превью показывает %q, want %q", src.Title, c.want)
			}
			if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
				t.Fatalf("ChooseCandidate: %v", err)
			}
			plan, _, _, err := w.effectivePlan(context.Background(), "1")
			if err != nil {
				t.Fatalf("effectivePlan: %v", err)
			}
			if plan.Title != src.Title {
				t.Errorf("превью %q, применилось %q — расхождение", src.Title, plan.Title)
			}
		})
	}
}

// Пин, закреплённый ПРЕЖНЕЙ версией, лежит в overrides грязным, и обычное
// «Применить» его не переписывает — источник заново не выбирают. Поэтому чистка
// стоит и на чтении: иначе раздача, стоящая в ревью на момент выката, создаёт
// ровно тот каталог, ради которого затевалась правка.
func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
	plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000}
	got := applyOverrides(plan, map[string]string{ovrTitle: "Fa\u200brgo"})
	if got.Title != "Fargo" {
		t.Errorf("plan.Title = %q, want %q — грязный пин доехал до раскладки", got.Title, "Fargo")
	}
	// Непригодный пин названием не становится: остаётся название распознавания.
	if got := applyOverrides(plan, map[string]string{ovrTitle: "-"}); got.Title != "Догадка" {
		t.Errorf("plan.Title = %q, want название распознавания", got.Title)
	}
}

// Раскладка с непомещающимся именем уводит задачу в review со своим кодом
// причины, а не в failed с текстом ядра. Каталог библиотеки при этом пуст:
// проверка стоит до первой операции с ФС (критерии приёмки A1, A2, A4).
func TestApply_NameTooLongGoesReview(t *testing.T) {
	plan := seriesResult().Plan
	plan.Title = strings.Repeat("ы", 200) // 400 байт — не помещается ни в имя, ни в папку
	f := newApplyFixture(t, plan)

	err := f.w.Apply(context.Background(), "1")
	if !errors.Is(err, layout.ErrNameTooLong) {
		t.Fatalf("Apply err = %v, want ErrNameTooLong", err)
	}
	d := f.st.downloads["1"]
	if d.State != store.StateReview {
		t.Errorf("state = %q, want review (а не failed)", d.State)
	}
	if d.ErrorCode.String != reasonNameTooLong {
		t.Errorf("error_code = %q, want %q", d.ErrorCode.String, reasonNameTooLong)
	}
	// A3: причина — наш доменный текст, а не текст ядра.
	if msg := d.ErrorMsg.String; strings.Contains(msg, "file name too long") ||
		strings.Contains(msg, "ENAMETOOLONG") {
		t.Errorf("error_msg несёт текст системной ошибки: %q", msg)
	}
	if !strings.Contains(d.ErrorMsg.String, "не помещается") {
		t.Errorf("error_msg не называет причину по-человечески: %q", d.ErrorMsg.String)
	}
	// A2/A4: под библиотекой не появилось ничего.
	entries, rerr := os.ReadDir(f.series)
	if rerr != nil {
		t.Fatal(rerr)
	}
	if len(entries) != 0 {
		t.Errorf("под series появилось %d записей, ожидалось 0", len(entries))
	}
	if len(f.st.links) != 0 {
		t.Errorf("file_links = %d, want 0 (частичной раскладки не остаётся)", len(f.st.links))
	}
}

// Прочие отказы построения плана свой код сохраняют: непомещающееся имя не
// поглотило соседний случай.
func TestApply_EmptyTitleKeepsBuildReason(t *testing.T) {
	plan := seriesResult().Plan
	plan.Title = "..." // санитизация схлопывает в пустое
	f := newApplyFixture(t, plan)

	if err := f.w.Apply(context.Background(), "1"); err == nil {
		t.Fatal("want build error")
	}
	if got := f.st.downloads["1"].ErrorCode.String; got != reasonBuild {
		t.Errorf("error_code = %q, want %q", got, reasonBuild)
	}
}

// Экран ревью обязан назвать причину, даже когда в состоянии её нет. Самый
// частый вход в review — распознавание без подтверждённого матча — приходит
// туда с пустым error_msg и до раскладки не доходит, поэтому единственный
// источник причины здесь — построение предпросмотра.
func TestReviewData_PreviewErrorWhenStateHasNoReason(t *testing.T) {
	plan := seriesResult().Plan
	plan.Title = strings.Repeat("ы", 200)
	f := newApplyFixture(t, plan)
	// Состояние без записанной причины: так задачу оставляет finishRecognition.
	d := f.st.downloads["1"]
	d.ErrorCode = store.NullString("")
	d.ErrorMsg = store.NullString("")
	f.st.put(d)

	rd, err := f.w.ReviewData(context.Background(), "1")
	if err != nil {
		t.Fatalf("ReviewData: %v", err)
	}
	if len(rd.Preview) != 0 {
		t.Fatalf("предпросмотр = %d ссылок, ожидался пустой", len(rd.Preview))
	}
	if rd.PreviewError == "" {
		t.Fatal("предпросмотр пуст, а причины нет — экран промолчит о том, почему пропала кнопка")
	}
	if !strings.Contains(rd.PreviewError, "не помещается") {
		t.Errorf("PreviewError = %q, ожидалась причина по длине имени", rd.PreviewError)
	}
}

// Сценарий спеки «Подсказка человека чинит случай, когда база печатается из
// распознавания»: короткое название доводит задачу до done.
func TestApply_ShorterTitleFixesNameTooLong(t *testing.T) {
	plan := seriesResult().Plan
	plan.Title = strings.Repeat("ы", 200)
	f := newApplyFixture(t, plan)

	if err := f.w.Apply(context.Background(), "1"); !errors.Is(err, layout.ErrNameTooLong) {
		t.Fatalf("подготовка: ожидался отказ по длине, got %v", err)
	}
	// Человек задаёт название короче (жёсткая правка поля — override).
	if err := f.st.SetOverride(context.Background(), "1", ovrTitle, "Шоу"); err != nil {
		t.Fatal(err)
	}
	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("Apply после укорачивания: %v", err)
	}
	if got := f.st.downloads["1"].State; got != store.StateDone {
		t.Errorf("state = %q, want done", got)
	}
}

// Унаследованная база отказа по длине дать не может, и это свойство арифметики,
// а не совпадение: папка-якоря лежит на диске, значит её имя <= предела, а хвост
// имени файла (" S02E01" + расширение, 11 байт) не длиннее хвоста папки
// (" [" + provider-тег + "]", минимум 11 байт). Тест держит это свойство: если
// суффиксы имён вырастут (язык и флаги субтитров, двойные серии), он покраснеет
// раньше, чем задача упрётся в тупик, из которого подсказка не выводит.
func TestApply_InheritedBaseAtLimitStillFits(t *testing.T) {
	plan := seriesResult().Plan
	f := newApplyFixture(t, plan)
	prov, pid := "tvdb", "269613"
	tag := prov + "id-" + pid

	// Папка якоря ровно в предел: длиннее её на диске быть не может.
	base := strings.Repeat("a", 255-len(" [")-len(tag)-len("]"))
	anchorDir := filepath.Join(f.series, base+" ["+tag+"]")
	if err := os.MkdirAll(filepath.Join(anchorDir, "Season 01"), 0o755); err != nil {
		t.Fatal(err)
	}
	f.st.links = append(f.st.links, store.FileLink{
		DownloadID: "other", DstPath: filepath.Join(anchorDir, "Season 01", base+" S01E01.mkv"),
		Status: string(layout.StatusLinked),
	})
	_ = f.st.SetOverride(context.Background(), "1", ovrProvider, prov)
	_ = f.st.SetOverride(context.Background(), "1", ovrProviderID, pid)

	if err := f.w.Apply(context.Background(), "1"); err != nil {
		t.Fatalf("унаследованная база в предел должна раскладываться: %v", err)
	}
	if got := f.st.downloads["1"].State; got != store.StateDone {
		t.Errorf("state = %q, want done", got)
	}
}
