// Package recognize по сигналам торрента определяет фильм/сериал, строит
// план раскладки и оценивает уверенность.
//
// Конвейер (см. openspec/specs/recognition/spec.md):
//  1. пред-парс имени релиза (go-ptn) — черновые название/год/сезон/серия;
//  2. вызов LLM со структурированным выводом → план в нашей схеме;
//  3. сверка с базами метаданных (TMDB/TVDB, опц.) — единичный сильный матч
//     по названию+году даёт официальный id и каноническое имя;
//  4. решение «авто или review»: авто только при подтверждённом матче,
//     чистой структурной валидации (для сериала — число серий бьётся с
//     базой), согласованности с пред-парсом и уверенности не ниже порога.
//
// Без включённых баз (или без матча) авто-раскладка не делается — задача
// уходит в review. Выход LLM недоверенный: план принимается только если
// каждый files[].src совпадает с реальным файлом торрента; итоговая
// безопасность пути держится на раскладке (layout).
package recognize

import (
	"context"
	"fmt"
	"log/slog"

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

// MediaType — вид контента.
type MediaType string

const (
	MediaMovie  MediaType = "movie"
	MediaSeries MediaType = "series"
)

// FileRole — роль файла в раздаче.
type FileRole string

const (
	RoleMain     FileRole = "main"     // основной видеофайл фильма
	RoleEpisode  FileRole = "episode"  // серия сериала
	RoleSubtitle FileRole = "subtitle" // внешние субтитры
	RoleExtra    FileRole = "extra"    // допматериалы
	RoleSample   FileRole = "sample"   // семпл
	RoleIgnore   FileRole = "ignore"   // мусор/не нужное
)

func (r FileRole) valid() bool {
	switch r {
	case RoleMain, RoleEpisode, RoleSubtitle, RoleExtra, RoleSample, RoleIgnore:
		return true
	default:
		return false
	}
}

// File — входной файл торрента (путь относительно save_path и размер).
type File struct {
	Path string
	Size int64
}

// Input — сигналы для распознавания одной раздачи.
type Input struct {
	Name    string   // имя торрента
	Files   []File   // список файлов с размерами
	Context string   // текстовый контекст человека (опц.)
	Hints   []string // накопленные подсказки из review (Ф3; в Ф2 обычно пусто)
}

// PlanFile — файл в плане раскладки. Season/Episode заданы на файле, чтобы
// выражать мультисезонные паки и спецвыпуски (см. recognition.md).
type PlanFile struct {
	Src     string   `json:"src"`
	Role    FileRole `json:"role"`
	Season  *int     `json:"season,omitempty"`
	Episode *int     `json:"episode,omitempty"`
}

// Plan — структурированный результат распознавания (схема ответа LLM).
type Plan struct {
	Type          MediaType `json:"type"`
	Title         string    `json:"title"`
	OriginalTitle string    `json:"original_title,omitempty"`
	Year          int       `json:"year,omitempty"`
	// Director — режиссёр (опц.). LLM его НЕ заполняет и не валидируется по нему;
	// его вкладывает подтверждённый матч метабазы (buildMatch) или закреплённый в
	// ревью источник (override). Недоверенное косметическое поле для вывода
	// отображаемого имени; в plan-санитайзинг не входит (чистится на рендере).
	Director     string     `json:"director,omitempty"`
	ProviderHint string     `json:"provider_hint,omitempty"`
	Files        []PlanFile `json:"files"`
	Confidence   float64    `json:"confidence"`
	Notes        string     `json:"notes,omitempty"`
}

// PreParse — черновой разбор имени релиза (go-ptn).
type PreParse struct {
	Title   string
	Year    int
	Season  int
	Episode int
	Quality string
}

// Decision — решение модели уверенности.
type Decision struct {
	Auto    bool     // авто-раскладка без review (в Ф2 всегда false)
	Reasons []string // причины ухода в review / предупреждения валидации
}

// Match — подтверждение распознавания базой метаданных.
type Match struct {
	Provider            string      // "tmdb" | "tvdb"
	ProviderID          string      // официальный id
	Title               string      // каноническое название
	Year                int         // каноничный год
	Director            string      // режиссёр (best-effort из credits; пусто — нет)
	SeasonEpisodeCounts map[int]int // число серий по сезонам (для сериала)
}

// Result — итог распознавания.
type Result struct {
	Plan       Plan
	PreParse   PreParse
	Decision   Decision
	Match      *Match               // подтверждённый единичный матч (nil — нет)
	Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
	Attempts   int                  // сколько вызовов LLM понадобилось (вкл. ретраи)
	Raw        string               // сырой ответ LLM последней попытки
}

// LLM — нужная recognize часть провайдера.
type LLM interface {
	Complete(ctx context.Context, req llm.Request) (llm.Response, error)
}

// Config — параметры распознавания.
type Config struct {
	MaxRetries    int     // переразбор ответа со схемой-в-промпте ([llm].max_retries)
	MaxTokens     int     // лимит ответа модели (0 — дефолт)
	MaxFiles      int     // усечение списка файлов в промпте (0 — дефолт)
	AutoThreshold float64 // порог уверенности для авто (0 — дефолт 0.85)
	// Language — язык локализованного `title` в промпте ("ru" | "en"); пусто →
	// "en". `original_title` от него не зависит (см. prompt.go).
	Language string
}

const (
	defaultMaxTokens     = 4000
	defaultMaxFiles      = 100
	defaultAutoThreshold = 0.85
	defaultLanguage      = "en"
)

// Recognizer — реализация распознавания.
type Recognizer struct {
	llm       LLM
	providers []metadata.Provider
	maxRetry  int
	maxTokens int
	maxFiles  int
	threshold float64
	language  string
	log       *slog.Logger
}

// New собирает распознаватель. providers — включённые базы метаданных
// (пусто → сверки нет, авто-раскладка не делается).
func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logger) *Recognizer {
	maxTokens := cfg.MaxTokens
	if maxTokens <= 0 {
		maxTokens = defaultMaxTokens
	}
	maxFiles := cfg.MaxFiles
	if maxFiles <= 0 {
		maxFiles = defaultMaxFiles
	}
	retries := cfg.MaxRetries
	if retries < 0 {
		retries = 0
	}
	threshold := cfg.AutoThreshold
	if threshold <= 0 {
		threshold = defaultAutoThreshold
	}
	// Канонический дефолт языка — config.ContentLanguage() (единственный питатель
	// на проде). Здесь defensive-нормализация для прямых вызовов конструктора
	// (тесты); держать равным этому дефолту.
	language := cfg.Language
	if language == "" {
		language = defaultLanguage
	}
	return &Recognizer{
		llm:       provider,
		providers: providers,
		maxRetry:  retries,
		maxTokens: maxTokens,
		maxFiles:  maxFiles,
		threshold: threshold,
		language:  language,
		log:       log,
	}
}

// Recognize прогоняет конвейер. Транспортная ошибка LLM возвращается как
// error (наверху решат retry/failed). Неразобранный после ретраев ответ —
// не ошибка, а Result с решением review (см. recognition.md).
func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
	log := logctx.FromOr(ctx, r.log)
	pre := preParse(in.Name)
	msgs := buildMessages(in, pre, r.maxFiles, r.language)

	temp := 0.0
	var raw string
	var plan Plan
	var parseErr error
	attempts := 0

	for attempt := 0; attempt <= r.maxRetry; attempt++ {
		attempts++
		resp, err := r.llm.Complete(ctx, llm.Request{
			Messages:    msgs,
			JSONMode:    true,
			Temperature: &temp,
			MaxTokens:   r.maxTokens,
		})
		if err != nil {
			return Result{}, fmt.Errorf("recognize: llm complete: %w", err)
		}
		raw = resp.Content

		plan, parseErr = parsePlan(raw, in, log)
		if parseErr == nil {
			break
		}
		log.Warn("recognition llm response unparsed",
			"attempt", attempts, "error", parseErr)
		// Просим модель исправиться, повторяя схему и ошибку.
		msgs = append(msgs,
			llm.Message{Role: llm.RoleAssistant, Content: raw},
			llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr, in, r.maxFiles)})
	}

	if parseErr != nil {
		return Result{
			PreParse: pre,
			Attempts: attempts,
			Raw:      raw,
			Decision: Decision{
				Auto:    false,
				Reasons: []string{"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error()},
			},
		}, nil
	}

	// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
	// в плане заменяем на каноничные. Кандидаты копим для ручного выбора в
	// review, когда единичного сильного матча нет.
	match, candidates := r.matchMetadata(ctx, plan)
	if match != nil {
		// Match.Title пришёл санитизированным (buildMatch чистит его в единственной
		// точке сборки матча). Здесь остаётся только вопрос пригодности: название,
		// не годное как имя каталога, не подставляем вовсе — в плане остаётся
		// название распознавания, а decide уводит раздачу в review.
		if UsableTitle(match.Title) {
			plan.Title = match.Title
		}
		if match.Year != 0 {
			plan.Year = match.Year
		}
		if match.Director != "" {
			plan.Director = match.Director
		}
	}

	dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
	log.Info("recognition done",
		"media_type", plan.Type, "title", plan.Title, "year", plan.Year,
		"files", len(plan.Files), "attempts", attempts,
		"matched", match != nil, "candidates", len(candidates),
		"auto", dec.Auto, "reasons", len(dec.Reasons))
	return Result{
		Plan:       plan,
		PreParse:   pre,
		Decision:   dec,
		Match:      match,
		Candidates: candidates,
		Attempts:   attempts,
		Raw:        raw,
	}, nil
}
