package worker

import (
	"context"
	"testing"
	"time"

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

// addedRecent — added_on торрента «минуту назад» относительно зафиксированного
// в newTestWorker now (2026-06-14 10:00:00 UTC).
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()

func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
	return &fakeStore{downloads: map[string]*store.Download{
		"1": {
			ID:         "1",
			State:      state,
			SourceType: store.SourceMagnet,
			SourceRef:  "magnet:?xt=urn:btih:" + infohash,
			Infohashes: hashesOf("1", infohash),
			ErrorCode:  store.NullString(code),
			CreatedAt:  createdAt,
		},
	}}
}

func TestRecovery(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	tests := []struct {
		name      string
		state     store.State
		code      string
		qbitState string
		want      store.State
	}{
		{"метаданные пришли → downloading", store.StateFailed, errCodeMagnetTimeout, "downloading", store.StateDownloading},
		{"торрент готов → completed", store.StateFailed, errCodeMagnetTimeout, "uploading", store.StateCompleted},
		{"всё ещё metaDL → остаётся failed", store.StateFailed, errCodeMagnetTimeout, "metaDL", store.StateFailed},
		{"stalled ожил → downloading", store.StateStuck, errCodeStalled, "downloading", store.StateDownloading},
		{"stalled всё ещё stalledDL → остаётся stuck", store.StateStuck, errCodeStalled, "stalledDL", store.StateStuck},
		{"qbit_error не восстанавливается", store.StateFailed, errCodeQbitError, "downloading", store.StateFailed},
		{"ошибка торрента не восстанавливает", store.StateFailed, errCodeMagnetTimeout, "error", store.StateFailed},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			st := oneFailed(tc.state, tc.code, ih, timeOld)
			qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: tc.qbitState, AddedOn: addedRecent}}}
			w := newTestWorker(st, qb)
			if err := w.Poll(context.Background()); err != nil {
				t.Fatalf("Poll: %v", err)
			}
			if got := st.downloads["1"].State; got != tc.want {
				t.Errorf("state = %q, want %q", got, tc.want)
			}
		})
	}
}

// Источник пропал (торрента нет в qBittorrent) — задача остаётся failed,
// воскрешать нечего (вернёт ручной retry).
func TestRecoveryNoSourceStaysFailed(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
	w := newTestWorker(st, &fakeQbt{torrents: nil})
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if st.downloads["1"].State != store.StateFailed {
		t.Errorf("без источника задача должна остаться failed, got %q", st.downloads["1"].State)
	}
}

// Конфликт идемпотентности: тот же infohash уже взяла другая активная задача —
// упавшую не воскрешаем (иначе нарушим «одна активная задача на infohash»).
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
	st.downloads["2"] = &store.Download{
		ID:         "2",
		State:      store.StateDownloading,
		SourceType: store.SourceMagnet,
		Infohashes: hashesOf("2", ih),
		CreatedAt:  timeRecent,
	}
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
	w := newTestWorker(st, qb)
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if st.downloads["1"].State != store.StateFailed {
		t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads["1"].State)
	}
}

// Тот же конфликт ключа, но торрент уже готов (recovery хочет completed):
// completed тоже нетерминален и восстановил бы idempotency_key — проверка
// конфликта обязана покрывать и эту ветку.
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
	st.downloads["2"] = &store.Download{
		ID:         "2",
		State:      store.StateDownloading,
		SourceType: store.SourceMagnet,
		Infohashes: hashesOf("2", ih),
		CreatedAt:  timeRecent,
	}
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
	w := newTestWorker(st, qb)
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if st.downloads["1"].State != store.StateFailed {
		t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads["1"].State)
	}
}

// Повторное падение одной задачи в пределах окна дебаунса шлёт уведомление лишь
// раз (защита от спама при флаппинге stuck↔downloading).
func TestFailNotifyDebounce(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld)
	w := newTestWorker(st, &fakeQbt{})
	n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
	w.SetNotifier(n)
	d := *st.downloads["1"]

	w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
	if e := waitNotify(t, n); e.ev != EventFailed {
		t.Fatalf("первый пинг: ev=%v, want failed", e.ev)
	}
	// Второе падение при том же w.now() — в пределах дебаунса, без пинга.
	w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
	select {
	case e := <-n.ch:
		t.Fatalf("повторный пинг в пределах дебаунса не ожидался: %+v", e)
	case <-time.After(200 * time.Millisecond):
	}
}

// Retry при живом торренте перецепляется к нему (без повторного Add) и не падает
// снова на ближайшем тике: базис таймаута берётся от added_on, а не от старого
// created_at.
func TestRetryReattachesNoReadd(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
	// Торрент жив, всё ещё тянет метаданные, но добавлен только что (added_on).
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
	w := newTestWorker(st, qb)

	if err := w.Retry(context.Background(), "1"); err != nil {
		t.Fatalf("Retry: %v", err)
	}
	if st.downloads["1"].State != store.StateDownloading {
		t.Fatalf("после retry ожидался downloading, got %q", st.downloads["1"].State)
	}
	if len(qb.added) != 0 {
		t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
	}
	// Ближайший тик: metaDL свежий (added_on минуту назад) — не падает по таймауту.
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if st.downloads["1"].State != store.StateDownloading {
		t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
	}
}

// addedLongAgo — added_on «5 часов назад» относительно now теста (10:00:00 UTC):
// торрент давно в qBittorrent (возраст сам по себе большой).
var addedLongAgo = time.Date(2026, 6, 14, 5, 0, 0, 0, time.UTC).Unix()

// lastActivityLongAgo — last_activity «2 часа назад»: данные давно не двигались.
var lastActivityLongAgo = time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC).Unix()

// MAJOR-1: retry живого, но давно добавленного и простаивающего stalledDL-торрента
// сбрасывает базис таймаута (retried_at), поэтому СЛЕДУЮЩИЙ тик не роняет задачу
// снова в stuck. Без сброса базиса stallDuration=now−last_activity (2ч) > StuckAfter
// (1ч) → задача мгновенно вернулась бы в stuck (регрессия, которую прячет
// TestRetryReattachesNoReadd, ставящий added_on/last_activity «минуту назад»).
func TestRetryResetsTimeoutBasis(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld)
	// Торрент жив, но давно добавлен (added_on 5ч) и давно простаивает
	// (last_activity 2ч) — по старой мере «возраст» он мгновенно снова stuck.
	qb := &fakeQbt{torrents: []qbt.Torrent{{
		Hash: ih, State: "stalledDL",
		AddedOn: addedLongAgo, LastActivity: lastActivityLongAgo,
	}}}
	w := newTestWorker(st, qb)

	if err := w.Retry(context.Background(), "1"); err != nil {
		t.Fatalf("Retry: %v", err)
	}
	if len(qb.added) != 0 {
		t.Errorf("живой здоровый торрент не должен добавляться повторно, got %d Add", len(qb.added))
	}
	if !st.downloads["1"].RetriedAt.Valid {
		t.Error("retry должен проставить retried_at (сброс базиса)")
	}
	// Ключевая проверка MAJOR-1: следующий тик поллинга.
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if got := st.downloads["1"].State; got != store.StateDownloading {
		t.Errorf("после retry задача не должна снова падать в stuck на ближайшем тике, got %q", got)
	}
}

// MAJOR-2: stuck_after мерит ДЛИТЕЛЬНОСТЬ ПРОСТОЯ (now−last_activity), а не возраст
// торрента. Долго качавшийся торрент (added_on 5ч назад) с недавним движением
// данных (last_activity 30с назад), на миг зашедший в stalledDL, НЕ уходит в stuck.
func TestStallMeasuredFromLastActivity(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	lastActivityRecent := time.Date(2026, 6, 14, 9, 59, 30, 0, time.UTC).Unix() // 30с назад
	st := oneDownloading(ih, timeOld)
	qb := &fakeQbt{torrents: []qbt.Torrent{{
		Hash: ih, State: "stalledDL",
		AddedOn: addedLongAgo, LastActivity: lastActivityRecent,
	}}}
	w := newTestWorker(st, qb)
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if got := st.downloads["1"].State; got != store.StateDownloading {
		t.Errorf("свежая активность (30с) — не stuck несмотря на возраст 5ч, got %q", got)
	}

	// Контроль: тот же торрент, но данные давно не двигались (last_activity 2ч) —
	// простой превысил StuckAfter (1ч) → stuck.
	qb.torrents[0].LastActivity = lastActivityLongAgo
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if got := st.downloads["1"].State; got != store.StateStuck {
		t.Errorf("простой 2ч > stuck_after 1ч должен дать stuck, got %q", got)
	}
}

// Оборонительный клэмп: last_activity из будущего (перекос часов или sentinel
// «никогда не был активен») трактуется как непригодное значение. Иначе простой
// вышел бы отрицательным и реально застрявший торрент никогда не пометился бы
// stuck. Значение игнорируется, простой считается от базиса добавления (5ч).
func TestStallIgnoresFutureLastActivity(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	lastActivityFuture := time.Date(2026, 6, 14, 11, 0, 0, 0, time.UTC).Unix() // 1ч в будущем
	st := oneDownloading(ih, timeOld)
	qb := &fakeQbt{torrents: []qbt.Torrent{{
		Hash: ih, State: "stalledDL",
		AddedOn: addedLongAgo, LastActivity: lastActivityFuture,
	}}}
	w := newTestWorker(st, qb)
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if got := st.downloads["1"].State; got != store.StateStuck {
		t.Errorf("last_activity из будущего игнорируется (фолбэк на возраст 5ч > stuck_after 1ч → stuck), got %q", got)
	}
}

// --- MAJOR-3: пропажа источника у активной (downloading) загрузки ---

// Источник активной загрузки устойчиво пропал из qBittorrent → задача уходит в
// failed(source_gone) с уведомлением, а не остаётся вечным зомби.
func TestSourceGoneMarksFailed(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneDownloading(ih, timeRecent)
	w := newTestWorker(st, &fakeQbt{torrents: nil}) // раздачи нет
	w.cfg.SourceMissingThreshold = 1                // помечаем при первой же пропаже
	n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
	w.SetNotifier(n)

	if err := w.Poll(context.Background()); err != nil {
		t.Fatalf("Poll: %v", err)
	}
	d := st.downloads["1"]
	if d.State != store.StateFailed {
		t.Fatalf("state = %q, want failed", d.State)
	}
	if got := d.ErrorCode.String; got != errCodeSourceGone {
		t.Errorf("error_code = %q, want %q", got, errCodeSourceGone)
	}
	if e := waitNotify(t, n); e.ev != EventFailed {
		t.Errorf("пинг: ev=%v, want failed", e.ev)
	}
}

// Кратковременная пропажа (меньше порога) не роняет задачу — дебаунс терпит
// транзиентную недоступность qBittorrent (рестарт демона).
func TestSourceGoneDebounced(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneDownloading(ih, timeRecent)
	w := newTestWorker(st, &fakeQbt{torrents: nil})
	w.cfg.SourceMissingThreshold = 3

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

// Возврат раздачи до порога сбрасывает счётчик промахов и задача ведётся
// обычной сверкой (не падает).
func TestSourceGoneResetOnReturn(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneDownloading(ih, timeRecent)
	qb := &fakeQbt{torrents: nil}
	w := newTestWorker(st, qb)
	w.cfg.SourceMissingThreshold = 3

	if err := w.Poll(context.Background()); err != nil { // промах 1
		t.Fatalf("Poll miss: %v", err)
	}
	if got := st.downloads["1"].SourceMissCount; got != 1 {
		t.Fatalf("miss = %d, want 1", got)
	}
	// Раздача вернулась (свежий metaDL — не падает по таймауту).
	qb.torrents = []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}
	if err := w.Poll(context.Background()); err != nil {
		t.Fatalf("Poll return: %v", err)
	}
	if got := st.downloads["1"].State; got != store.StateDownloading {
		t.Errorf("state = %q, want downloading (источник вернулся)", got)
	}
	if got := st.downloads["1"].SourceMissCount; got != 0 {
		t.Errorf("miss = %d, want 0 (сброс)", got)
	}
}

// source_gone восстановлению сверкой не подлежит: даже если раздача снова
// появилась и продвинулась, reconcileRecovery её не воскрешает — нужен ручной
// Retry.
func TestSourceGoneNotAutoRecovered(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneFailed(store.StateFailed, errCodeSourceGone, ih, timeOld)
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
	w := newTestWorker(st, qb)
	if err := w.Poll(context.Background()); err != nil {
		t.Fatalf("Poll: %v", err)
	}
	if got := st.downloads["1"].State; got != store.StateFailed {
		t.Errorf("state = %q, want failed (source_gone сверкой не воскрешается)", got)
	}
}

// Retry задачи source_gone сбрасывает source_miss_count, чтобы вернувшаяся в
// downloading задача получила полное грейс-окно, а не упала снова на ближайшем
// тике, если переотданная раздача ещё не видна в qBittorrent.
func TestRetryResetsSourceMissCount(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneFailed(store.StateFailed, errCodeSourceGone, ih, timeRecent)
	st.downloads["1"].SourceMissCount = 3 // задача упала по порогу
	qb := &fakeQbt{torrents: nil}         // раздачи всё ещё нет (Add вслепую)
	w := newTestWorker(st, qb)
	w.cfg.SourceMissingThreshold = 3

	if err := w.Retry(context.Background(), "1"); err != nil {
		t.Fatalf("Retry: %v", err)
	}
	if got := st.downloads["1"].SourceMissCount; got != 0 {
		t.Fatalf("после retry miss = %d, want 0 (сброс)", got)
	}
	if len(qb.added) != 1 {
		t.Errorf("retry без живой раздачи должен переотдать источник, got %d Add", len(qb.added))
	}
	// Грейс-окно: раздача ещё не видна — задача НЕ падает на ближайшем тике
	// (miss стал бы 1 < 3, а не 4 при несброшенном счётчике).
	if err := w.Poll(context.Background()); err != nil {
		t.Fatalf("Poll after retry: %v", err)
	}
	if got := st.downloads["1"].State; got != store.StateDownloading {
		t.Errorf("state = %q, want downloading (грейс-окно не съедено)", got)
	}
	if got := st.downloads["1"].SourceMissCount; got != 1 {
		t.Errorf("miss = %d, want 1 (один промах после сброса)", got)
	}
}
