package recognize

import (
	"context"
	"testing"

	"git.vakhrushev.me/av/jellybit/internal/metadata"
)

// Провенанс набора входов — отчёт триажа ревью tvdb-title-locale (находка 2) и
// раздел «Воспроизведение» задачи metadata-title-sanitize. До правки каждый из
// них давал auto=true и уезжал в имя каталога библиотеки дословно.
const dunePlanResp = `{"type":"movie","title":"Dune","original_title":"Dune","year":2021,
	"confidence":0.9,"files":[
		{"src":"Dune.2021/movie.mkv","role":"main","season":null,"episode":null}
	]}`

func duneInput() Input {
	return Input{
		Name:  "Dune.2021.2160p.BluRay.x265",
		Files: []File{{Path: "Dune.2021/movie.mkv", Size: 20 << 30}},
	}
}

// recognizeWithMatchTitle прогоняет полный Recognize против базы, единственная
// запись которой названа dbTitle. Год и оригинальное название совпадают с планом,
// поэтому матч подтверждается и все прочие условия авто чисты — судим ровно
// подстановку названия.
func recognizeWithMatchTitle(t *testing.T, dbTitle string) Result {
	t.Helper()
	p := &fakeProvider{candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "438631", Title: dbTitle, OriginalTitle: "Dune", Year: 2021},
	}}
	r := New(&fakeLLM{responses: []string{dunePlanResp}}, []metadata.Provider{p},
		Config{MaxRetries: 2}, testLogger())
	res, err := r.Recognize(context.Background(), duneInput())
	if err != nil {
		t.Fatalf("Recognize: %v", err)
	}
	return res
}

// Четыре входа из «Воспроизведения»: значение в плане обязано совпадать с тем,
// что дал бы санитайзер, а не с тем, что отдала база.
func TestRecognize_MatchTitleSanitized(t *testing.T) {
	cases := []struct {
		name  string
		db    string
		want  string
		auto  bool
		about string
	}{
		{
			name: "zero-width внутри слова", db: "Du\u200bne", want: "Dune", auto: true,
			about: "невидимка снимается, авто остаётся",
		},
		{
			name: "RLO переворачивает отображение", db: "Dune\u202egnp.mkv",
			want: "Dunegnp.mkv", auto: true,
			about: "управляющий символ снимается",
		},
		{
			name: "перевод строки", db: "Dune\nHACK", want: "Dune HACK", auto: true,
			about: "перевод строки сводится к пробелу",
		},
		{
			name: "кириллический двойник", db: "Dunа", want: "Duna", auto: true,
			about: "двойник сворачивается в доминирующий скрипт",
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			res := recognizeWithMatchTitle(t, c.db)
			if res.Match == nil {
				t.Fatalf("матч должен быть подтверждён (%s)", c.about)
			}
			if res.Plan.Title != c.want {
				t.Errorf("plan.Title = %q, want %q", res.Plan.Title, c.want)
			}
			if res.Plan.Title == c.db {
				t.Errorf("значение базы уехало в план дословно: %q", res.Plan.Title)
			}
			if res.Decision.Auto != c.auto {
				t.Errorf("auto = %v, want %v; reasons=%v",
					res.Decision.Auto, c.auto, res.Decision.Reasons)
			}
		})
	}
}

// Название, непригодное как имя каталога: пустое после чистки либо голая
// пунктуация. Подстановки не происходит, авто заблокирована причиной, но матч
// остаётся подтверждённым — id и год верны, распознавание не падает.
func TestRecognize_MatchTitleUnusable(t *testing.T) {
	for _, db := range []string{"\u200b\u200b\u200b", ".", "...", "-", " . "} {
		t.Run(db, func(t *testing.T) {
			res := recognizeWithMatchTitle(t, db)
			if res.Plan.Title != "Dune" {
				t.Errorf("plan.Title = %q, want %q (название распознавания)",
					res.Plan.Title, "Dune")
			}
			if res.Decision.Auto {
				t.Error("авто-раскладка должна быть заблокирована")
			}
			if !hasReason(res.Decision.Reasons, "непригодно как имя каталога") {
				t.Errorf("причина не названа: %v", res.Decision.Reasons)
			}
			if res.Match == nil || res.Match.ProviderID != "438631" {
				t.Errorf("матч обязан остаться подтверждённым, got %+v", res.Match)
			}
			if res.Plan.Year != 2021 {
				t.Errorf("год должен быть подставлен, got %d", res.Plan.Year)
			}
		})
	}
}

// Нормальное название: значение базы доезжает как есть, авто разрешена. Это
// защита от регресса на TMDB — санитайзинг на чистом значении не делает ничего.
func TestRecognize_MatchTitleNormalUnchanged(t *testing.T) {
	res := recognizeWithMatchTitle(t, "Dune: Part One")
	if res.Plan.Title != "Dune: Part One" {
		t.Errorf("plan.Title = %q, want %q", res.Plan.Title, "Dune: Part One")
	}
	if !res.Decision.Auto {
		t.Errorf("auto = false, reasons=%v", res.Decision.Reasons)
	}
}

// Кандидаты, уходящие в review, очищены: их названия закрепляет человек, и они
// становятся именем каталога так же, как каноническое.
func TestMatchMetadata_CandidatesSanitized(t *testing.T) {
	p := &fakeProvider{candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "1", Title: "Du\u200bne", OriginalTitle: "Dun\u200be", Year: 2021},
		{Provider: "tmdb", ID: "2", Title: "Dunа Part Two", Year: 2024},
	}}
	r := recognizerWith(p)
	_, cands := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Nothing Matches Here", Year: 1900})
	if len(cands) != 2 {
		t.Fatalf("candidates = %d, want 2", len(cands))
	}
	if cands[0].Title != "Dune" || cands[0].OriginalTitle != "Dune" {
		t.Errorf("кандидат не очищен: %+v", cands[0])
	}
	if cands[1].Title != "Duna Part Two" {
		t.Errorf("двойник у кандидата не свёрнут: %q", cands[1].Title)
	}
}

// Гейт матча не сдвинулся: кандидат, отличающийся от плана только невидимым
// символом внутри слова, сильным не считается. Сравнение идёт по значению
// провайдера — санитизируется только копия, уходящая в review.
func TestMatchMetadata_SanitizeDoesNotMoveGate(t *testing.T) {
	p := &fakeProvider{candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "1", Title: "Du\u200bne", Year: 2021},
	}}
	r := recognizerWith(p)
	m, cands := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
	if m != nil {
		t.Errorf("невидимка внутри слова не должна давать сильный матч, got %+v", m)
	}
	if len(cands) != 1 || cands[0].Title != "Dune" {
		t.Errorf("кандидат обязан уйти в review очищенным, got %+v", cands)
	}
}

// Список кандидатов не обеднел: после подтверждённого матча у первого провайдера
// остальные продолжают пополнять список для review.
func TestMatchMetadata_CandidatesFromAllProvidersKept(t *testing.T) {
	a := &fakeProvider{name: "tmdb", candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "1", Title: "Dune", Year: 2021},
	}}
	b := &fakeProvider{name: "tvdb", candidates: []metadata.Candidate{
		{Provider: "tvdb", ID: "9", Title: "Dune Other", Year: 2021},
	}}
	r := New(&fakeLLM{}, []metadata.Provider{a, b}, Config{}, testLogger())
	m, cands := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
	if m == nil {
		t.Fatal("матч у первого провайдера должен подтвердиться")
	}
	if len(cands) != 2 {
		t.Errorf("candidates = %d, want 2 (кандидаты второго провайдера не теряются)",
			len(cands))
	}
}

func TestUsableTitle(t *testing.T) {
	usable := []string{"Dune", "2001", "Ne Zha", "Брат", "«Дюна»"}
	unusable := []string{"", ".", "..", "...", "-", " . ", "—", "!?"}
	for _, s := range usable {
		if !UsableTitle(s) {
			t.Errorf("UsableTitle(%q) = false, want true", s)
		}
	}
	for _, s := range unusable {
		if UsableTitle(s) {
			t.Errorf("UsableTitle(%q) = true, want false", s)
		}
	}
}

// Идемпотентность несёт два утверждения сразу: что правка не двигает путей у
// раздач с нормальным названием и что «в плане нет несанитизированных полей»
// вообще проверяемо. Без теста она подразумевалась.
func TestSanitizeTitle_Idempotent(t *testing.T) {
	inputs := []string{
		"Du\u200bne", "Dune\u202egnp.mkv", "Dune\nHACK", "Dunа",
		"Dune: Part One", "  spaced  out  ", "\u200b\u200b\u200b", ".", "-",
		"Тёмный рыцарь", "哪吒之魔童降世",
	}
	for _, in := range inputs {
		once := SanitizeTitle(in)
		if twice := SanitizeTitle(once); twice != once {
			t.Errorf("sanitizeTitle не идемпотентен на %q: %q → %q", in, once, twice)
		}
	}
}
