package recognize

import (
	"context"
	"log/slog"
	"strings"
	"unicode"

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

// maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
const maxCandidates = 8

// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
// сильный матч — ровно один кандидат с совпадением названия и года (для него
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
// из всех выполненных заходов (топ-N, дедуп) — чтобы человек мог выбрать в
// review, когда сильного матча нет. Ошибки провайдера не валят распознавание.
//
// Поиск идёт по нескольким названиям в порядке убывания силы ключа
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
//
// Проходов сверки два: pass 1 — с годом плана (дешёвое сужение при верном годе),
// pass 2 — fallback без года, только если год известен и pass 1 не подтвердил
// матч. Exact-year фильтр запроса строже гейта strongMatches (год ±1): безгодовой
// проход восстанавливает off-by-one авто-матчи (запись, которую точный фильтр
// отсёк, а гейт принял бы) и пополняет кандидатов для review при бо́льших ошибках
// года. Гейт при этом не меняется (год ±1 по plan.Year), инвариант авто-матча не
// двигается. Кандидаты копятся через оба прохода (дедуп по provider:id, потолок).
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
	if len(r.providers) == 0 {
		return nil, nil
	}
	mt := metadata.Movie
	if plan.Type == MediaSeries {
		mt = metadata.Series
	}

	matchTitles := normSet(plan.Title, plan.OriginalTitle)
	keys := searchKeys(plan)

	// Год запроса по проходам: сначала год плана, затем 0 (без года) как fallback.
	// При неизвестном годе второй проход был бы идентичен первому — не делаем.
	queryYears := []int{plan.Year}
	if plan.Year > 0 {
		queryYears = append(queryYears, 0)
	}

	var match *Match
	var candidates []metadata.Candidate
	seen := map[string]bool{}

	for _, qYear := range queryYears {
		// Безгодовой fallback-проход (есть только при известном годе плана).
		// В нём требуем известный год кандидата: off-by-one даёт авто-матч, а
		// запись с неизвестным годом — только кандидат в review (год подтвердить
		// нечем, авто было бы недо-подтверждённым). В pass 1 leniency yearMatches
		// к unknown-году сохраняется как прежде.
		fallback := qYear == 0 && plan.Year > 0
		for _, key := range keys {
			for _, p := range r.providers {
				cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: qYear})
				if err != nil {
					// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
					// доменное решение «пропускаем провайдера, пробуем следующий».
					logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
					continue
				}

				// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
				// Названия чистим на КОПИИ: выбор кандидата человеком — такой же
				// путь подтверждения матча, как авто, и закреплённое название так
				// же уезжает в имя каталога. Исходный cands не трогаем — по нему
				// ниже ищется сильный матч, и сдвигать его гейт задача не заказывала.
				for _, c := range cands {
					ck := c.Provider + ":" + c.ID
					if seen[ck] || len(candidates) >= maxCandidates {
						continue
					}
					seen[ck] = true
					candidates = append(candidates, sanitizeCandidate(c))
				}

				// Единичный сильный матч ищем у первого подходящего провайдера.
				// Гейт по plan.Year (не qYear): безгодовой проход расширяет только
				// выдачу запроса, но требует год кандидата ±1 (и известный — в
				// fallback), поэтому условие подтверждения не ослабляется.
				if match != nil {
					continue
				}
				strong := strongMatches(cands, plan.Year, matchTitles, fallback)
				if len(strong) != 1 {
					continue
				}
				match = r.buildMatch(ctx, p, strong[0], mt)
			}
			if match != nil {
				break
			}
		}
		if match != nil {
			break // pass 1 подтвердил матч — безгодовой проход не нужен
		}
	}
	return match, candidates
}

// searchKeys строит ключи поиска по базе в порядке убывания силы:
// original_title → title → provider_hint. Пустые и нормализованно совпадающие
// с уже добавленным ключом пропускаем, чтобы не обращаться к базе дважды с тем
// же запросом (частый случай — российский фильм, где original_title дублирует
// title).
func searchKeys(plan Plan) []string {
	var keys []string
	seen := map[string]bool{}
	for _, t := range []string{plan.OriginalTitle, plan.Title, plan.ProviderHint} {
		if strings.TrimSpace(t) == "" {
			continue
		}
		n := normalize(t)
		if n == "" || seen[n] {
			continue
		}
		seen[n] = true
		keys = append(keys, t)
	}
	return keys
}

// buildMatch тянет число серий (по нативному id) и собирает Match с
// тег-предпочтительным провенансом.
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
	var counts map[int]int
	if mt == metadata.Series {
		if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
			counts = got
		} else {
			logctx.FromOr(ctx, r.log).Debug("metadata episode counts skipped", "provider", p.Name(), "id", c.ID)
		}
	}
	prov, pid := CandidateTag(c)
	// Каноническое название чистим здесь — в единственной точке сборки Match, и
	// уже ПОСЛЕ гейта сильного матча (strongMatches сравнивает по значениям
	// провайдера). Дальше по потоку значение считается чистым: и подстановка в
	// план, и решение auto/review, и диагностика сухого прогона берут его как
	// есть, а не чистят каждый заново.
	title := c.Title
	if clean := SanitizeTitle(title); clean != title {
		logctx.FromOr(ctx, r.log).Debug("metadata match title sanitized",
			"before", title, "after", clean)
		title = clean
	}
	return &Match{
		Provider:            prov,
		ProviderID:          pid,
		Title:               title,
		Year:                c.Year,
		Director:            directorOf(ctx, r.log, p, mt, c.ID),
		SeasonEpisodeCounts: counts,
	}
}

// directorOf тянет режиссёра из провайдера по нативному id, если он умеет
// (DirectorProvider). Best-effort: ошибка/неподдержка → пусто, распознавание не
// валится. Режиссёр — недоверенное косметическое поле.
func directorOf(ctx context.Context, log *slog.Logger, p metadata.Provider, mt metadata.MediaType, id string) string {
	dp, ok := p.(metadata.DirectorProvider)
	if !ok {
		return ""
	}
	d, err := dp.Director(ctx, mt, id)
	if err != nil {
		logctx.FromOr(ctx, log).Debug("metadata director skipped", "provider", p.Name(), "id", id)
		return ""
	}
	return d
}

// Director тянет режиссёра выбранного источника по (provider, id) — точка входа
// для ручного выбора кандидата в ревью (см. worker). Ищет провайдера поиска по
// имени; best-effort — нет провайдера/не умеет/ошибка → пусто. mt — тип из плана.
func (r *Recognizer) Director(ctx context.Context, mt MediaType, provider, providerID string) string {
	metaType := metadata.Movie
	if mt == MediaSeries {
		metaType = metadata.Series
	}
	for _, p := range r.providers {
		if p.Name() != provider {
			continue
		}
		return directorOf(ctx, r.log, p, metaType, providerID)
	}
	return ""
}

// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
// поиска. Используется и в матче, и при сохранении кандидатов.
func CandidateTag(c metadata.Candidate) (provider, id string) {
	if c.TagProvider != "" {
		return c.TagProvider, c.TagID
	}
	return c.Provider, c.ID
}

// strongMatches оставляет кандидатов, чьё название совпадает с одним из
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
// по id. requireKnownYear (безгодовой fallback-проход) дополнительно отсекает
// кандидатов с неизвестным годом: авто-матч там требует подтверждённого года.
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool, requireKnownYear bool) []metadata.Candidate {
	seen := map[string]bool{}
	var out []metadata.Candidate
	for _, c := range cands {
		if requireKnownYear && c.Year == 0 {
			continue
		}
		if !yearMatches(year, c.Year) {
			continue
		}
		if !titles[normalize(c.Title)] && !titles[normalize(c.OriginalTitle)] {
			continue
		}
		if seen[c.ID] {
			continue
		}
		seen[c.ID] = true
		out = append(out, c)
	}
	return out
}

// yearMatches: год известен у обоих и расходится не больше чем на 1 (разные
// базы по-разному датируют релиз), либо где-то год неизвестен.
func yearMatches(a, b int) bool {
	if a == 0 || b == 0 {
		return true
	}
	d := a - b
	if d < 0 {
		d = -d
	}
	return d <= 1
}

// normSet — множество нормализованных непустых названий.
func normSet(titles ...string) map[string]bool {
	out := map[string]bool{}
	for _, t := range titles {
		if n := normalize(t); n != "" {
			out[n] = true
		}
	}
	return out
}

// normalize приводит название к сравнимому виду: нижний регистр, только
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
// расхождение написания: «Тёмный» vs «Темный»). Перед этим сворачиваем
// кирилло-латинские homoglyph-двойники (foldHomoglyphs) — defense-in-depth на
// случай двойников со стороны кандидата базы: гейт сильного матча должен быть
// устойчив к ним независимо от санитайзинга плана.
func normalize(s string) string {
	var b strings.Builder
	prevSpace := false
	for _, r := range strings.ToLower(foldHomoglyphs(s)) {
		if r == 'ё' {
			r = 'е'
		}
		switch {
		case unicode.IsLetter(r) || unicode.IsDigit(r):
			b.WriteRune(r)
			prevSpace = false
		case !prevSpace:
			b.WriteByte(' ')
			prevSpace = true
		}
	}
	return strings.TrimSpace(b.String())
}
