package worker

import (
	"context"
	"os"
	"path/filepath"
	"testing"

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

// reconcileFixture готовит done-задачу с одной разложенной ссылкой: цель —
// файл в temp-каталоге (создаётся при makeTarget), источник — наличие
// раздачи в fakeQbt (sourcePresent).
type reconcileFixture struct {
	w   *Worker
	st  *memStore
	dst string
}

func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTarget bool) reconcileFixture {
	t.Helper()
	dir := t.TempDir()
	dst := filepath.Join(dir, "Movie (2024).mkv")
	src := filepath.Join(dir, "src.mkv")
	if makeTarget {
		if err := os.WriteFile(dst, []byte("video"), 0o644); err != nil {
			t.Fatal(err)
		}
	}

	st := newMemStore()
	d := completedDownload("1")
	d.State = state
	st.put(d)
	st.links = append(st.links, store.FileLink{
		DownloadID: "1", ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
		Kind: "video", Status: "linked",
	})

	var torrents []qbt.Torrent
	if sourcePresent {
		// Раздача докачана (seeding) — classReady: preflight Relink требует
		// готовности источника, а не только присутствия.
		torrents = []qbt.Torrent{{Hash: ihTest, State: "uploading"}}
	}
	w := testWorkerWith(st, &fakeQbt{torrents: torrents}, nil, nil)
	w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже (без задержки)
	return reconcileFixture{w: w, st: st, dst: dst}
}

func TestReconcileMatrix(t *testing.T) {
	cases := []struct {
		name           string
		source, target bool
		want           store.State
	}{
		{"источник+цель → done", true, true, store.StateDone},
		{"цель удалена → target_missing", true, false, store.StateTargetMissing},
		{"источник удалён → orphaned", false, true, store.StateOrphaned},
		{"оба удалены → deleted", false, false, store.StateDeleted},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			f := newReconcileFixture(t, store.StateDone, tc.source, tc.target)
			if err := f.w.Poll(context.Background()); err != nil {
				t.Fatalf("Poll: %v", err)
			}
			if got := f.st.downloads["1"].State; got != tc.want {
				t.Errorf("state = %q, want %q", got, tc.want)
			}
		})
	}
}

func TestReconcileHealing(t *testing.T) {
	// Источник вернулся, цель на месте → orphaned лечится обратно в done.
	f := newReconcileFixture(t, store.StateOrphaned, true, true)
	if err := f.w.Poll(context.Background()); err != nil {
		t.Fatalf("Poll: %v", err)
	}
	if got := f.st.downloads["1"].State; got != store.StateDone {
		t.Errorf("state = %q, want done (healing)", got)
	}
}

func TestReconcilePartialTargetLoss(t *testing.T) {
	// Две ссылки, одна цель удалена → цель считается отсутствующей.
	f := newReconcileFixture(t, store.StateDone, true, true)
	missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
	f.st.links = append(f.st.links, store.FileLink{
		DownloadID: "1", ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
		Kind: "subtitle", Status: "linked",
	})
	if err := f.w.Poll(context.Background()); err != nil {
		t.Fatalf("Poll: %v", err)
	}
	if got := f.st.downloads["1"].State; got != store.StateTargetMissing {
		t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
	}
}

func TestReconcileSkipsDeleted(t *testing.T) {
	// deleted терминально: сверка его не переоценивает, даже если по бывшему
	// пути снова появился файл (его забрала другая загрузка) и источника нет.
	f := newReconcileFixture(t, store.StateDeleted, false, true)
	if err := f.w.Poll(context.Background()); err != nil {
		t.Fatalf("Poll: %v", err)
	}
	if got := f.st.downloads["1"].State; got != store.StateDeleted {
		t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
	}
}

func TestReconcileDebounce(t *testing.T) {
	// Порог 3: первые два промаха не помечают, третий — помечает orphaned;
	// возврат источника лечит обратно и сбрасывает счётчик.
	f := newReconcileFixture(t, store.StateDone, false, true)
	f.w.cfg.SourceMissingThreshold = 3

	for i := 1; i <= 2; i++ {
		if err := f.w.Poll(context.Background()); err != nil {
			t.Fatalf("Poll %d: %v", i, err)
		}
		if got := f.st.downloads["1"].State; got != store.StateDone {
			t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
		}
		if got := f.st.downloads["1"].SourceMissCount; got != i {
			t.Errorf("tick %d: miss = %d, want %d", i, got, i)
		}
	}
	if err := f.w.Poll(context.Background()); err != nil { // третий промах
		t.Fatalf("Poll 3: %v", err)
	}
	if got := f.st.downloads["1"].State; got != store.StateOrphaned {
		t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
	}

	// Источник вернулся.
	f.w.qbt.(*fakeQbt).torrents = []qbt.Torrent{{Hash: ihTest}}
	if err := f.w.Poll(context.Background()); err != nil {
		t.Fatalf("Poll heal: %v", err)
	}
	if got := f.st.downloads["1"].State; got != store.StateDone {
		t.Errorf("state = %q, want done (источник вернулся)", got)
	}
	if got := f.st.downloads["1"].SourceMissCount; got != 0 {
		t.Errorf("miss = %d, want 0 (сброс)", got)
	}
}

func TestReconcileSkipsActiveStates(t *testing.T) {
	// Матрица сверки (reconcileDesync) не трогает активные состояния, даже если
	// раздачи нет в qBittorrent. Берём review (не downloading): активный цикл
	// поллинга листает только downloading, поэтому review остаётся нетронутым
	// — пропажу источника у downloading отдельно ведёт правило source_gone
	// (см. recovery_test: TestSourceGone*).
	f := newReconcileFixture(t, store.StateReview, false, true)
	if err := f.w.Poll(context.Background()); err != nil {
		t.Fatalf("Poll: %v", err)
	}
	if got := f.st.downloads["1"].State; got != store.StateReview {
		t.Errorf("state = %q, want review (сверка не трогает активные)", got)
	}
}

func TestUndoRejectedForOrphaned(t *testing.T) {
	f := newReconcileFixture(t, store.StateOrphaned, false, true)
	err := f.w.Undo(context.Background(), "1")
	if err == nil {
		t.Fatal("ожидали отказ Undo для orphaned")
	}
	if got := f.st.downloads["1"].State; got != store.StateOrphaned {
		t.Errorf("state = %q, want orphaned (без изменений)", got)
	}
}

func TestRelinkFromTargetMissing(t *testing.T) {
	// target_missing + источник на месте → relink ведёт в recognizing.
	f := newReconcileFixture(t, store.StateTargetMissing, true, false)
	if err := f.w.Relink(context.Background(), "1"); err != nil {
		t.Fatalf("Relink: %v", err)
	}
	if got := f.st.downloads["1"].State; got != store.StateRecognizing {
		t.Errorf("state = %q, want recognizing", got)
	}
	if f.st.overrides["1"][ovrForceReview] != "1" {
		t.Errorf("force_review = %q, want 1", f.st.overrides["1"][ovrForceReview])
	}
}

func TestPreflightFixesStaleState(t *testing.T) {
	// В БД target_missing (источник якобы есть), но фактически источник пропал,
	// а цель на месте: relink немедленно приводит состояние к orphaned, не
	// дожидаясь фоновой сверки.
	f := newReconcileFixture(t, store.StateTargetMissing, false, true)
	if err := f.w.Relink(context.Background(), "1"); err == nil {
		t.Fatal("ожидали отказ relink при пропавшем источнике")
	}
	if got := f.st.downloads["1"].State; got != store.StateOrphaned {
		t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
	}
}
