package recognize

import (
	"encoding/json"
	"fmt"
	"log/slog"
	"sort"
	"strings"

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

// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
// предупреждения (см. decide) ошибкой не считаются — они уводят в review.
//
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
// sanitizePlan) ДО структурной валидации: так, например, title из одних
// zero-width символов схлопывается в пустой и корректно уводит в ретрай.
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
	jsonStr, err := llm.ExtractJSONObject(raw)
	if err != nil {
		return Plan{}, fmt.Errorf("no JSON object in response")
	}

	var p Plan
	dec := json.NewDecoder(strings.NewReader(jsonStr))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&p); err != nil {
		// Повторяем без строгого режима: лишние поля — не повод падать,
		// но если и так не разобралось — это ошибка схемы.
		if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
			return Plan{}, fmt.Errorf("JSON not parsed: %w", err2)
		}
	}

	sanitizePlan(&p, log)

	if err := validateSchema(&p, in); err != nil {
		return Plan{}, err
	}
	return p, nil
}

// validateSchema проверяет обязательную структуру плана. Главный инвариант
// безопасности: каждый files[].src совпадает с реальным файлом торрента —
// недоверенный выход LLM не может сослаться на посторонний путь.
func validateSchema(p *Plan, in Input) error {
	switch p.Type {
	case MediaMovie, MediaSeries:
	case "":
		return fmt.Errorf("field type is empty (expected movie or series)")
	default:
		return fmt.Errorf("unknown type %q", p.Type)
	}
	if strings.TrimSpace(p.Title) == "" {
		return fmt.Errorf("field title is empty")
	}
	if len(p.Files) == 0 {
		return fmt.Errorf("files list is empty")
	}

	known := make(map[string]bool, len(in.Files))
	for _, f := range in.Files {
		known[f.Path] = true
	}

	for i := range p.Files {
		pf := &p.Files[i]
		if !pf.Role.valid() {
			return fmt.Errorf("file %q: unknown role %q", pf.Src, pf.Role)
		}
		if strings.TrimSpace(pf.Src) == "" {
			return fmt.Errorf("file with empty src")
		}
		if !known[pf.Src] {
			return fmt.Errorf("src %q not found among torrent files", pf.Src)
		}
		if pf.Role == RoleEpisode && pf.Episode == nil {
			return fmt.Errorf("episode %q has no episode number", pf.Src)
		}
	}
	return nil
}

// decide считает решение модели уверенности (см. recognition.md). Авто —
// только если выполнено всё: подтверждённый единичный матч в базе; каноническое
// название матча пригодно как имя каталога; чистая структурная валидация (для
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
	var reasons []string

	switch {
	case !metadataEnabled:
		reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна")
	case match == nil:
		reasons = append(reasons, "не найдено в базе или несколько кандидатов")
	}

	// Каноническое название базы, непригодное как имя каталога (пустое или без
	// единой буквы и цифры), в план не подставлено — там осталось название
	// распознавания. Матч при этом верен: id, год и режиссёр на месте, поэтому
	// отклоняем не матч, а авто-раскладку. Match.Title уже санитизирован
	// (buildMatch), второй раз не чистим: два независимых пересчёта одного
	// условия разъедутся на первой же правке одного из них.
	if match != nil && !UsableTitle(match.Title) {
		reasons = append(reasons, "название из базы непригодно как имя каталога")
	}

	reasons = append(reasons, structuralWarnings(p)...)

	if match != nil && p.Type == MediaSeries {
		reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...)
	}

	reasons = append(reasons, consistencyWarnings(p, pre)...)

	if p.Confidence < threshold {
		reasons = append(reasons,
			fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
	}

	return Decision{Auto: len(reasons) == 0, Reasons: reasons}
}

// episodeCountWarnings сверяет число распознанных серий по сезонам с базой.
// Нет данных по сезону → блокируем авто (полноту пака не подтвердить).
func episodeCountWarnings(p Plan, counts map[int]int) []string {
	recognized := map[int]int{}
	for _, f := range p.Files {
		if f.Role == RoleEpisode && f.Episode != nil {
			season := 0
			if f.Season != nil {
				season = *f.Season
			}
			recognized[season]++
		}
	}
	var w []string
	for _, season := range sortedKeys(toSlices(recognized)) {
		rc := recognized[season]
		dbc, ok := counts[season]
		switch {
		case !ok || dbc == 0:
			w = append(w, fmt.Sprintf("сезон %d: в базе нет данных о числе серий", season))
		case rc != dbc:
			w = append(w, fmt.Sprintf("сезон %d: распознано серий %d, в базе %d", season, rc, dbc))
		}
	}
	return w
}

// toSlices превращает map[int]int в map[int][]int для sortedKeys (нужны
// только ключи).
func toSlices(m map[int]int) map[int][]int {
	out := make(map[int][]int, len(m))
	for k := range m {
		out[k] = nil
	}
	return out
}

// structuralWarnings — нарушения структуры плана (мягкие, не блокируют разбор).
func structuralWarnings(p Plan) []string {
	var w []string
	switch p.Type {
	case MediaMovie:
		mains := 0
		for _, f := range p.Files {
			if f.Role == RoleMain {
				mains++
			}
		}
		if mains != 1 {
			w = append(w, fmt.Sprintf("фильм: основных видеофайлов %d, ожидался ровно 1", mains))
		}
	case MediaSeries:
		w = append(w, seriesWarnings(p.Files)...)
	}
	return w
}

// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам.
func seriesWarnings(files []PlanFile) []string {
	type key struct{ s, e int }
	seen := map[key]int{}
	bySeason := map[int][]int{}
	var w []string

	for _, f := range files {
		if f.Role != RoleEpisode || f.Episode == nil {
			continue
		}
		season := 0
		if f.Season != nil {
			season = *f.Season
		}
		k := key{season, *f.Episode}
		seen[k]++
		if seen[k] == 2 {
			w = append(w, fmt.Sprintf("сериал: дубль серии S%02dE%02d", season, *f.Episode))
		}
		bySeason[season] = append(bySeason[season], *f.Episode)
	}

	for _, season := range sortedKeys(bySeason) {
		eps := bySeason[season]
		sort.Ints(eps)
		for i := 1; i < len(eps); i++ {
			if eps[i] > eps[i-1]+1 {
				w = append(w, fmt.Sprintf("сериал: пропуск серий в сезоне %d между E%02d и E%02d",
					season, eps[i-1], eps[i]))
			}
		}
	}
	return w
}

// consistencyWarnings — расхождения LLM с черновым пред-парсом.
func consistencyWarnings(p Plan, pre PreParse) []string {
	var w []string
	if pre.Year != 0 && p.Year != 0 && pre.Year != p.Year {
		w = append(w, fmt.Sprintf("год расходится: пред-парс=%d, LLM=%d", pre.Year, p.Year))
	}
	if (pre.Season != 0 || pre.Episode != 0) && p.Type == MediaMovie {
		w = append(w, "тип расходится: пред-парс указывает на сериал, LLM — фильм")
	}
	return w
}

func sortedKeys(m map[int][]int) []int {
	ks := make([]int, 0, len(m))
	for k := range m {
		ks = append(ks, k)
	}
	sort.Ints(ks)
	return ks
}
