package store

import (
	"context"
	"fmt"
	"testing"
	"time"
)

// hashN — детерминированный валидный 40-hex infohash по номеру.
func hashN(n int) string { return fmt.Sprintf("%040x", n) }

// mkDownload заводит загрузку в заданном состоянии с display_name.
func mkDownload(t *testing.T, st *Store, n int, state State, display string) string {
	t.Helper()
	ctx := context.Background()
	d := newDownloading()
	d.DisplayName = display
	existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)}, nil)
	if err != nil {
		t.Fatalf("create #%d: %v", n, err)
	}
	if existing != nil {
		t.Fatalf("create #%d: unexpected dedup", n)
	}
	if state != StateDownloading {
		forceState(t, st, d.ID, state)
	}
	return d.ID
}

func ids(ds []Download) []string {
	out := make([]string, len(ds))
	for i, d := range ds {
		out[i] = d.ID
	}
	return out
}

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

	review := mkDownload(t, st, 1, StateReview, "Дюна")
	active := mkDownload(t, st, 2, StateDownloading, "Fargo")
	done := mkDownload(t, st, 3, StateDone, "Оно")
	deleted := mkDownload(t, st, 4, StateDeleted, "Старьё")
	cancelled := mkDownload(t, st, 5, StateCancelled, "Отменённое")

	// Группа review — только review/deferred.
	page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	if total != 1 || len(page) != 1 || page[0].ID != review {
		t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review)
	}

	// all: deleted и cancelled скрыты по умолчанию (скрытые терминальные).
	page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	if total != 3 {
		t.Fatalf("all (без скрытых терминальных) total = %d, want 3; ids=%v", total, ids(page))
	}
	for _, d := range page {
		if d.ID == deleted {
			t.Fatalf("deleted не должен быть виден по умолчанию")
		}
		if d.ID == cancelled {
			t.Fatalf("cancelled не должен быть виден по умолчанию")
		}
	}
	_ = active
	_ = done

	// all + IncludeDeleted: показываем всё, включая deleted и cancelled.
	page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	if total != 5 {
		t.Fatalf("all+скрытые терминальные total = %d, want 5", total)
	}
	var seenDeleted, seenCancelled bool
	for _, d := range page {
		switch d.ID {
		case deleted:
			seenDeleted = true
		case cancelled:
			seenCancelled = true
		}
	}
	if !seenDeleted || !seenCancelled {
		t.Fatalf("под IncludeDeleted должны быть видны deleted и cancelled: deleted=%v cancelled=%v", seenDeleted, seenCancelled)
	}
}

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

	mkDownload(t, st, 1, StateReview, "Дюна: Часть вторая")
	mkDownload(t, st, 2, StateReview, "Fargo")

	// Поиск по display_name (кириллица — как введено, с учётом регистра).
	page, total, err := st.ListDownloadsPage(ctx, ListFilter{Query: "Дюна", Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	if total != 1 || len(page) != 1 || page[0].DisplayName != "Дюна: Часть вторая" {
		t.Fatalf("search 'Дюна' = %v (total %d)", ids(page), total)
	}

	// Латиница ищется регистронезависимо (ASCII case-folding LIKE).
	page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: "fargo", Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	if len(page) != 1 || page[0].DisplayName != "Fargo" {
		t.Fatalf("search 'fargo' = %v", ids(page))
	}

	// Поиск по infohash.
	page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: hashN(2), Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	if len(page) != 1 || page[0].PrimaryInfohash() != hashN(2) {
		t.Fatalf("search by infohash = %v", ids(page))
	}
}

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

	// Три задачи; у #1 и #2 задано source_added_at (разное), у #3 — нет (фолбек
	// на created_at). Проверяем порядок DESC по времени добавления и tie-break.
	id1 := mkDownload(t, st, 1, StateDownloading, "one")
	id2 := mkDownload(t, st, 2, StateDownloading, "two")
	id3 := mkDownload(t, st, 3, StateDownloading, "three")

	base := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
	// id1 добавлен раньше, id2 позже → id2 должен идти выше id1.
	if err := st.SetSourceAddedAt(ctx, id1, base); err != nil {
		t.Fatal(err)
	}
	if err := st.SetSourceAddedAt(ctx, id2, base.Add(time.Hour)); err != nil {
		t.Fatal(err)
	}

	page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	if total != 3 {
		t.Fatalf("total = %d, want 3", total)
	}
	// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
	// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
	got := ids(page)
	want := []string{id3, id2, id1}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("порядок = %v, want %v", got, want)
		}
	}

	// Пагинация: по 2 на страницу.
	p1, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 0})
	if err != nil {
		t.Fatal(err)
	}
	if total != 3 || len(p1) != 2 {
		t.Fatalf("page1 len=%d total=%d, want 2/3", len(p1), total)
	}
	p2, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 2})
	if err != nil {
		t.Fatal(err)
	}
	if len(p2) != 1 {
		t.Fatalf("page2 len=%d, want 1", len(p2))
	}
	// Страницы не пересекаются и покрывают весь список.
	if p1[0].ID != id3 || p1[1].ID != id2 || p2[0].ID != id1 {
		t.Fatalf("страницы разъехались: p1=%v p2=%v", ids(p1), ids(p2))
	}
}

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

	// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
	same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
	var idList []string
	for i := 1; i <= 3; i++ {
		id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
		if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
			t.Fatal(err)
		}
		idList = append(idList, id)
	}
	page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	got := ids(page)
	want := []string{idList[2], idList[1], idList[0]} // id DESC
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("tie-break порядок = %v, want %v", got, want)
		}
	}
}

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

	id := mkDownload(t, st, 1, StateDownloading, "x")
	first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
	second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)

	if err := st.SetSourceAddedAt(ctx, id, first); err != nil {
		t.Fatal(err)
	}
	// Повторная запись не должна перезаписать (гард IS NULL).
	if err := st.SetSourceAddedAt(ctx, id, second); err != nil {
		t.Fatal(err)
	}
	d, err := st.GetDownload(ctx, id)
	if err != nil {
		t.Fatal(err)
	}
	if !d.SourceAddedAt.Valid || d.SourceAddedAt.String != FormatTime(first) {
		t.Fatalf("source_added_at = %q, want %q (первое значение неизменно)",
			d.SourceAddedAt.String, FormatTime(first))
	}
}

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

	id := mkDownload(t, st, 1, StateStuck, "x")
	first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
	second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)

	if err := st.SetRetriedAt(ctx, id, first); err != nil {
		t.Fatal(err)
	}
	// В отличие от source_added_at — повторный retry перезаписывает базис.
	if err := st.SetRetriedAt(ctx, id, second); err != nil {
		t.Fatal(err)
	}
	d, err := st.GetDownload(ctx, id)
	if err != nil {
		t.Fatal(err)
	}
	if !d.RetriedAt.Valid || d.RetriedAt.String != FormatTime(second) {
		t.Fatalf("retried_at = %q, want %q (последний retry)", d.RetriedAt.String, FormatTime(second))
	}
	got, ok := d.RetriedTime()
	if !ok || !got.Equal(second) {
		t.Fatalf("RetriedTime() = %v, %v; want %v, true", got, ok, second)
	}
}

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

	// Загрузка без display_name, но с распознанным названием — RecTitle из join.
	id := mkDownload(t, st, 1, StateReview, "")
	if _, err := st.CreateRecognition(ctx, &Recognition{
		DownloadID: id,
		Title:      NullString("Настоящее название"),
	}, nil); err != nil {
		t.Fatal(err)
	}

	page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	if len(page) != 1 {
		t.Fatalf("len = %d", len(page))
	}
	if !page[0].RecTitle.Valid || page[0].RecTitle.String != "Настоящее название" {
		t.Fatalf("RecTitle = %q, want распознанное название", page[0].RecTitle.String)
	}
}

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

	// Тип текущей попытки распознавания подтягивается join'ом для значка списка.
	id := mkDownload(t, st, 1, StateReview, "Fargo")
	if _, err := st.CreateRecognition(ctx, &Recognition{
		DownloadID: id,
		MediaType:  NullString("series"),
		Title:      NullString("Fargo"),
	}, nil); err != nil {
		t.Fatal(err)
	}

	page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	if len(page) != 1 {
		t.Fatalf("len = %d", len(page))
	}
	if !page[0].RecMediaType.Valid || page[0].RecMediaType.String != "series" {
		t.Fatalf("RecMediaType = %q (valid=%v), want series", page[0].RecMediaType.String, page[0].RecMediaType.Valid)
	}
}

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

	id := mkDownload(t, st, 1, StateDownloading, "Дюна: Часть вторая (2024)")
	d, err := st.GetDownload(ctx, id)
	if err != nil {
		t.Fatal(err)
	}
	if d.DisplayName != "Дюна: Часть вторая (2024)" {
		t.Fatalf("display_name = %q", d.DisplayName)
	}
}

// Выборка для страницы группового удаления отдаёт ровно те состояния, где домен
// допускает полное удаление, — и ничего сверх них. Перебираем все состояния:
// разъезд запроса с предикатом иначе виден только на проде.
func TestListDeletableDownloads(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()

	all := []State{
		StateCatched, StateDownloading, StateCompleted, StateRecognizing,
		StateReview, StateLinking, StateDone, StateDeferred, StateStuck,
		StateFailed, StateCancelled, StateReverted,
		StateTargetMissing, StateOrphaned, StateDeleted,
	}
	byID := map[string]State{}
	for i, s := range all {
		byID[mkDownload(t, st, i+1, s, "задача "+string(s))] = s
	}

	got, err := st.ListDeletableDownloads(ctx)
	if err != nil {
		t.Fatal(err)
	}
	seen := map[State]bool{}
	for _, d := range got {
		if !d.State.CanDelete() {
			t.Errorf("в выборке состояние %s, удаление из которого недоступно", d.State)
		}
		seen[d.State] = true
	}
	for _, s := range all {
		if s.CanDelete() && !seen[s] {
			t.Errorf("состояние %s допускает удаление, но в выборку не попало", s)
		}
	}
}
