package recognize

import (
	"context"
	"errors"
	"slices"
	"testing"

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

type fakeProvider struct {
	name       string
	candidates []metadata.Candidate
	byTitle    map[string][]metadata.Candidate // если задано — результат зависит от запроса
	counts     map[int]int
	searchErr  error
	exactYear  bool // имитирует жёсткий year-фильтр базы: q.Year>0 отсекает по точному году
	searched   int
	queries    []string // строки запросов в порядке вызова
}

func (f *fakeProvider) Name() string {
	if f.name == "" {
		return "tmdb"
	}
	return f.name
}
func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.Candidate, error) {
	f.searched++
	f.queries = append(f.queries, q.Title)
	if f.searchErr != nil {
		return nil, f.searchErr
	}
	res := f.candidates
	if f.byTitle != nil {
		res = f.byTitle[q.Title]
	}
	if f.exactYear && q.Year > 0 {
		var filtered []metadata.Candidate
		for _, c := range res {
			if c.Year == q.Year {
				filtered = append(filtered, c)
			}
		}
		return filtered, nil
	}
	return res, nil
}
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
	return f.counts, nil
}

func recognizerWith(p metadata.Provider) *Recognizer {
	var providers []metadata.Provider
	if p != nil {
		providers = []metadata.Provider{p}
	}
	return New(&fakeLLM{}, providers, Config{}, testLogger())
}

func TestMatchMetadata_SingleStrong(t *testing.T) {
	p := &fakeProvider{candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999},
		{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
	}}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
	if m == nil {
		t.Fatal("expected match")
	}
	if m.ProviderID != "603" || m.Provider != "tmdb" {
		t.Errorf("match = %+v", m)
	}
}

func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
	// Два кандидата с тем же названием и годом — неоднозначно.
	p := &fakeProvider{candidates: []metadata.Candidate{
		{ID: "1", Title: "Fargo", Year: 2014},
		{ID: "2", Title: "Fargo", Year: 2014},
	}}
	r := recognizerWith(p)
	if m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
		t.Errorf("ambiguous must not match, got %+v", m)
	}
}

func TestMatchMetadata_ReturnsCandidates(t *testing.T) {
	// Нет сильного матча (разные названия), но кандидаты собраны для выбора.
	p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
		{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
		{Provider: "tvmaze", ID: "2", Title: "Fargo Idaho", Year: 2010},
		{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014}, // дубль по id
	}}
	r := recognizerWith(p)
	m, cands := r.matchMetadata(context.Background(),
		Plan{Type: MediaSeries, Title: "Совсем другое", Year: 2014})
	if m != nil {
		t.Errorf("strong match не ожидался: %+v", m)
	}
	if len(cands) != 2 { // дубль отброшен
		t.Fatalf("candidates = %d, want 2: %+v", len(cands), cands)
	}
	// CandidateTag даёт внешний TVDB-id для первого.
	prov, id := CandidateTag(cands[0])
	if prov != "tvdb" || id != "269613" {
		t.Errorf("tag = %s/%s", prov, id)
	}
}

func TestRecognize_PopulatesCandidates(t *testing.T) {
	in := Input{Name: "Show.S01", Files: []File{{Path: "e1.mkv", Size: 1}}}
	resp := `{"type":"series","title":"Show","year":2020,"confidence":0.9,
		"files":[{"src":"e1.mkv","role":"episode","season":1,"episode":1}]}`
	p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
		{Provider: "tvmaze", ID: "1", Title: "Show One", Year: 2020},
		{Provider: "tvmaze", ID: "2", Title: "Show Two", Year: 2019},
	}}
	r := New(&fakeLLM{responses: []string{resp}}, []metadata.Provider{p}, Config{}, testLogger())
	res, err := r.Recognize(context.Background(), in)
	if err != nil {
		t.Fatalf("Recognize: %v", err)
	}
	if res.Match != nil {
		t.Errorf("strong match не ожидался")
	}
	if len(res.Candidates) != 2 {
		t.Errorf("Result.Candidates = %d, want 2", len(res.Candidates))
	}
}

func TestMatchMetadata_YearMismatch(t *testing.T) {
	p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
	r := recognizerWith(p)
	if m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
		t.Errorf("year mismatch must not match, got %+v", m)
	}
}

func TestMatchMetadata_OriginalTitle(t *testing.T) {
	p := &fakeProvider{candidates: []metadata.Candidate{
		{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
	}}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
	if m == nil || m.ProviderID != "1" {
		t.Errorf("should match by original title, got %+v", m)
	}
}

// Цена локализованного названия кандидата: множество осей сравнения выросло с
// одной до двух, и там, где сильный кандидат был один, их может стать двое —
// тогда подтверждённого матча нет и обе записи уходят в review. На этом счётчике
// держится инвариант «авто-раскладка только при подтверждённом матче».
func TestMatchMetadata_TranslationMakesTwoStrong(t *testing.T) {
	p := &fakeProvider{candidates: []metadata.Candidate{
		{Provider: "tvdb", ID: "1", Title: "Нэчжа", OriginalTitle: "哪吒之魔童降世", Year: 2019},
		{Provider: "tvdb", ID: "2", Title: "Ne Zha", OriginalTitle: "Ne Zha", Year: 2019},
	}}
	r := recognizerWith(p)
	m, cands := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Нэчжа", OriginalTitle: "Ne Zha", Year: 2019})
	if m != nil {
		t.Errorf("двое сильных — подтверждённого матча быть не должно, got %+v", m)
	}
	if len(cands) != 2 {
		t.Errorf("candidates = %d, want 2 (обе записи уходят в review)", len(cands))
	}
}

func TestMatchMetadata_MatchByOriginalFirst(t *testing.T) {
	// Реальный кейс: русское релиз-имя, матч по оригинальному названию.
	// При сильном матче по первому ключу дальнейшие запросы не делаются.
	p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
		"The Dark Knight": {{Provider: "tmdb", ID: "155", Title: "The Dark Knight", Year: 2008}},
	}}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Тёмный рыцарь", OriginalTitle: "The Dark Knight", Year: 2008})
	if m == nil || m.ProviderID != "155" {
		t.Fatalf("should match by original title, got %+v", m)
	}
	if want := []string{"The Dark Knight"}; !slices.Equal(p.queries, want) {
		t.Errorf("queries = %v, want %v (ранний стоп на оригинале)", p.queries, want)
	}
}

func TestMatchMetadata_FallbackToTitle(t *testing.T) {
	// Запрос по original ничего не дал — фолбэк на локализованный title.
	p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
		"Wrong Original": {},
		"Fargo":          {{Provider: "tmdb", ID: "1", Title: "Fargo", Year: 2014}},
	}}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaSeries, Title: "Fargo", OriginalTitle: "Wrong Original", Year: 2014})
	if m == nil || m.ProviderID != "1" {
		t.Fatalf("should fall back to title, got %+v", m)
	}
	if want := []string{"Wrong Original", "Fargo"}; !slices.Equal(p.queries, want) {
		t.Errorf("queries = %v, want %v", p.queries, want)
	}
}

func TestMatchMetadata_FallbackToProviderHint(t *testing.T) {
	// Ни original, ни title не нашли — третий фолбэк по provider_hint.
	// Гейт по-прежнему требует совпадения названия кандидата с title/original.
	p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
		"Orig": {},
		"Loc":  {},
		"Hint": {{Provider: "tmdb", ID: "7", Title: "Loc", Year: 2000}},
	}}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Loc", OriginalTitle: "Orig", ProviderHint: "Hint", Year: 2000})
	if m == nil || m.ProviderID != "7" {
		t.Fatalf("should fall back to provider_hint, got %+v", m)
	}
	if want := []string{"Orig", "Loc", "Hint"}; !slices.Equal(p.queries, want) {
		t.Errorf("queries = %v, want %v", p.queries, want)
	}
}

func TestMatchMetadata_SkipsDuplicateKey(t *testing.T) {
	// Российский фильм: original_title дублирует title — база дёргается раз.
	p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
		"Брат": {{Provider: "tmdb", ID: "1", Title: "Брат", Year: 1997}},
	}}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Брат", OriginalTitle: "Брат", Year: 1997})
	if m == nil || m.ProviderID != "1" {
		t.Fatalf("expected match, got %+v", m)
	}
	if p.searched != 1 {
		t.Errorf("searched = %d, want 1 (дубль-ключ пропущен)", p.searched)
	}
}

func TestMatchMetadata_TagFromExternal(t *testing.T) {
	// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
	p := &fakeProvider{
		name: "tvmaze",
		candidates: []metadata.Candidate{
			{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
		},
		counts: map[int]int{1: 10},
	}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
	if m == nil {
		t.Fatal("expected match")
	}
	// Провенанс/тег — внешний TVDB-id, а не нативный tvmaze.
	if m.Provider != "tvdb" || m.ProviderID != "269613" {
		t.Errorf("match provider = %s/%s, want tvdb/269613", m.Provider, m.ProviderID)
	}
	if m.SeasonEpisodeCounts[1] != 10 {
		t.Errorf("counts not fetched by native id: %+v", m.SeasonEpisodeCounts)
	}
}

func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
	p := &fakeProvider{
		candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
		counts:     map[int]int{1: 10, 2: 10},
	}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
	if m == nil || m.SeasonEpisodeCounts[1] != 10 {
		t.Errorf("counts not fetched: %+v", m)
	}
}

func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
	p := &fakeProvider{searchErr: errors.New("upstream down")}
	r := recognizerWith(p)
	if m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
		t.Errorf("provider error must yield no match, got %+v", m)
	}
}

func TestMatchMetadata_Disabled(t *testing.T) {
	r := recognizerWith(nil)
	if m, _ := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
		t.Errorf("no providers → no match, got %+v", m)
	}
}

func TestMatchMetadata_YearlessFallbackOffByOne(t *testing.T) {
	// Год плана off-by-one: exact-year фильтр запроса отсекает запись в pass 1,
	// безгодовой pass 2 её возвращает, гейт ±1 принимает → авто-матч.
	p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "42", Title: "Harold and the Purple Crayon", Year: 2024},
	}}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Harold and the Purple Crayon", Year: 2023})
	if m == nil || m.ProviderID != "42" {
		t.Fatalf("off-by-one год: ожидался матч через безгодовой проход, got %+v", m)
	}
	if p.searched != 2 {
		t.Errorf("searched = %d, want 2 (pass1 с годом + pass2 без года)", p.searched)
	}
}

func TestMatchMetadata_YearlessFallbackBigGapReviewOnly(t *testing.T) {
	// Год расходится больше чем на 1: pass 2 вернёт запись, но гейт по году её
	// отклонит — подтверждённого матча нет, кандидат уходит в review.
	p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "7", Title: "X", Year: 2005},
	}}
	r := recognizerWith(p)
	m, cands := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "X", Year: 2010})
	if m != nil {
		t.Errorf("расхождение года >1: авто-матч не ожидался, got %+v", m)
	}
	if len(cands) != 1 || cands[0].ID != "7" {
		t.Errorf("кандидат должен собраться для review: %+v", cands)
	}
}

func TestMatchMetadata_YearlessUnknownYearReviewOnly(t *testing.T) {
	// Год плана известен, но у записи в базе год неизвестен: pass 1 (с годом) её
	// не находит, pass 2 (без года) находит, но в fallback требуется известный
	// год → авто-матча нет, кандидат уходит в review.
	p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "9", Title: "X", Year: 0},
	}}
	r := recognizerWith(p)
	m, cands := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "X", Year: 2020})
	if m != nil {
		t.Errorf("год кандидата неизвестен: авто-матч не ожидался, got %+v", m)
	}
	if len(cands) != 1 || cands[0].ID != "9" {
		t.Errorf("кандидат должен собраться для review: %+v", cands)
	}
}

func TestMatchMetadata_NoSecondPassWhenPass1Matches(t *testing.T) {
	// pass 1 подтвердил матч — безгодовой проход не выполняется.
	p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "1", Title: "X", Year: 2000},
	}}
	r := recognizerWith(p)
	m, _ := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "X", Year: 2000})
	if m == nil {
		t.Fatal("ожидался матч в pass 1")
	}
	if p.searched != 1 {
		t.Errorf("searched = %d, want 1 (второго прохода быть не должно)", p.searched)
	}
}

func TestMatchMetadata_NoSecondPassWhenYearUnknown(t *testing.T) {
	// Год неизвестен (0): второй проход был бы идентичен первому — не делаем.
	p := &fakeProvider{candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "1", Title: "Y", Year: 2000},
	}}
	r := recognizerWith(p)
	// Название кандидата совпадает, но нет матча по названию плана — гейт не пройдёт;
	// проверяем именно число запросов.
	r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "Совсем другое"})
	if p.searched != 1 {
		t.Errorf("searched = %d, want 1 (год неизвестен → одного прохода достаточно)", p.searched)
	}
}

func TestMatchMetadata_YearlessAmbiguousNoMatch(t *testing.T) {
	// Несколько кандидатов из безгодового прохода, проходящих гейт → не подтверждён.
	p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "1", Title: "Twin", Year: 2001},
		{Provider: "tmdb", ID: "2", Title: "Twin", Year: 2001},
	}}
	r := recognizerWith(p)
	m, cands := r.matchMetadata(context.Background(),
		Plan{Type: MediaMovie, Title: "Twin", Year: 2000})
	if m != nil {
		t.Errorf("неоднозначность из pass 2: матч не ожидался, got %+v", m)
	}
	if len(cands) != 2 {
		t.Errorf("оба кандидата должны собраться для review: %+v", cands)
	}
}

func TestNormalize(t *testing.T) {
	cases := map[string]string{
		"The Matrix":     "the matrix",
		"Léon: The Pro!": "léon the pro",
		"  A  B  ":       "a b",
		"Привет, Мир":    "привет мир",
		"Тёмный рыцарь":  "темный рыцарь", // ё → е
		"ЁЖ":             "еж",
	}
	for in, want := range cases {
		if got := normalize(in); got != want {
			t.Errorf("normalize(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestNormalize_FoldsHomoglyph(t *testing.T) {
	// «Harold» с кириллической буквой-двойником 'а' (U+0430) в первом слове
	// нормализуется к тому же виду, что и чистая латиница (defense-in-depth на
	// стороне кандидата базы).
	dirty := "Hаrold and the Purple Crayon" // 'а' — кириллица
	clean := "Harold and the Purple Crayon"
	if normalize(dirty) != normalize(clean) {
		t.Errorf("normalize(%q)=%q != normalize(%q)=%q", dirty, normalize(dirty), clean, normalize(clean))
	}
}

// Сквозной авто: LLM-план + матч в базе + чистая валидация → Decision.Auto.
func TestRecognize_AutoWithMatch(t *testing.T) {
	in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}}
	resp := `{"type":"movie","title":"The Matrix","year":1999,"confidence":0.95,
		"provider_hint":"The Matrix","files":[{"src":"m/film.mkv","role":"main"}]}`
	llmFake := &fakeLLM{responses: []string{resp}}
	p := &fakeProvider{candidates: []metadata.Candidate{
		{Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999},
	}}
	r := New(llmFake, []metadata.Provider{p}, Config{}, testLogger())

	res, err := r.Recognize(context.Background(), in)
	if err != nil {
		t.Fatalf("Recognize: %v", err)
	}
	if !res.Decision.Auto {
		t.Errorf("expected auto, reasons: %v", res.Decision.Reasons)
	}
	if res.Match == nil || res.Match.ProviderID != "603" {
		t.Errorf("match = %+v", res.Match)
	}
}
