// Package naming выводит человекочитаемое отображаемое имя торрента из
// текстового контекста загрузки. Имя нужно лишь как ярлык в списке
// qBittorrent (вместо безликого dn вроде rutracker-topic-6514485) и не
// влияет на пути на диске или распознавание.
//
// Стратегия: сначала пробуем LLM (структурированный вывод названия/года/
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
// вывода имени.
//
// Извлечённая структура ([Fields]) — базовый (наименее доверенный) слой
// источника полей имени: её сохраняют у загрузки (parsed_context) и позже
// переиспользуют при обновлении display_name (поле, которого нет в
// распознавании/матче, — например режиссёр из контекста — не теряется).
package naming

import (
	"context"
	"encoding/json"
	"log/slog"
	"strconv"
	"strings"
	"unicode"
	"unicode/utf8"

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

// maxNameLen — ограничение длины отображаемого имени (символов/рун).
const maxNameLen = 200

const (
	typeMovie  = "movie"
	typeSeries = "series"
)

// Fields — скалярные поля имени, извлечённые из одного источника (контекст).
// Это же схема ответа LLM и схема хранения parsed_context. Year/Director/Season
// опциональны (пустые в ярлык не попадают).
type Fields struct {
	Type          string `json:"type"` // "movie"|"series"
	Title         string `json:"title"`
	OriginalTitle string `json:"original_title"`
	Year          int    `json:"year"`
	Director      string `json:"director"`
	Season        *int   `json:"season"`
	IsRussian     bool   `json:"is_russian"`
}

// LabelFields — эффективные скалярные поля отображаемого ярлыка после слоистого
// разрешения (план → контекст). Значения уже очищены (sanitize): управляющие
// символы вырезаны, пробелы схлопнуты — те же, что попадут внутрь Label, поэтому
// присутствие/отсутствие поля определяется одинаково при прямом выводе (шаблон)
// и внутри Label. Season — готовая строка-сводка сезонов (для фильма пусто).
type LabelFields struct {
	Title    string
	Director string
	Year     int
	Season   string
}

// Label собирает полный ярлык display_name из эффективных полей. sanitize внутри
// идемпотентен, поэтому на уже очищенных полях результат тот же.
func (f LabelFields) Label() string {
	return Label(f.Title, f.Director, f.Year, f.Season)
}

// EffectiveFields разрешает поля отображаемого ярлыка слоями: план (override →
// распознавание+матч, уже свёрнут вызывающим) с фолбэком на извлечённый из
// контекста слой (parsedContext, схема Fields) для полей, которых план не дал
// (например режиссёр из контекста при распознавании без матча). Сводка сезонов —
// recognize.SeasonSummary(plan) с фолбэком на контекстный скаляр. parsedContext
// недоверен: битый JSON → пустой слой (best-effort). Выбор слоя идёт по сырым
// значениям (как прежде), выбранные скаляры возвращаются очищенными (sanitize),
// чтобы прямой вывод поля во вью совпадал с тем, что даёт Label в шапке.
func EffectiveFields(parsedContext string, plan recognize.Plan) LabelFields {
	var ctxf Fields
	if s := strings.TrimSpace(parsedContext); s != "" {
		_ = json.Unmarshal([]byte(s), &ctxf)
	}
	title := plan.Title
	if title == "" {
		title = ctxf.Title
	}
	director := plan.Director
	if director == "" {
		director = ctxf.Director
	}
	year := plan.Year
	if year == 0 {
		year = ctxf.Year
	}
	season := recognize.SeasonSummary(plan)
	if season == "" && plan.Type == recognize.MediaSeries {
		season = ctxf.SeasonLabel()
	}
	return LabelFields{
		Title:    sanitize(title),
		Director: sanitize(director),
		Year:     year,
		Season:   sanitize(season),
	}
}

// SeasonLabel — сводка сезона из контекстного скаляра: «Сезон N» для сериала с
// заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от
// плана распознавания, где он per-file и сводится recognize.SeasonSummary).
// Используется как fallback-слой сводки сезонов, когда план распознавания её не
// даёт.
func (f Fields) SeasonLabel() string {
	if f.Type == typeSeries && f.Season != nil && *f.Season > 0 {
		return "Сезон " + strconv.Itoa(*f.Season)
	}
	return ""
}

// Namer выводит отображаемое имя. provider может быть nil — тогда работает
// только алгоритмический фолбек.
type Namer struct {
	provider llm.Provider
	// attempts — число попыток получить валидный ответ LLM ([llm].max_retries).
	// Здесь это ровно столько вызовов модели (в отличие от recognize, где
	// max_retries — это число ПЕРЕразборов, т.е. max_retries+1 вызовов).
	attempts int
	log      *slog.Logger
}

// New собирает Namer. provider nil → только фолбек. attempts < 1 → 1.
// logger nil → slog.Default().
func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
	if attempts < 1 {
		attempts = 1
	}
	if logger == nil {
		logger = slog.Default()
	}
	return &Namer{provider: provider, attempts: attempts, log: logger}
}

// Derive выводит отображаемое имя и извлечённую структуру. hint — подсказка из
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает имя
// ("" если вывести не удалось — вызывающий не задаёт rename) и извлечённую
// структуру с ok=true, если LLM дал валидные поля (тогда их JSON сохраняют как
// parsed_context). Фолбек полей не даёт (ok=false): его выход — только строка.
func (n *Namer) Derive(ctx context.Context, contextText, hint string) (name string, fields Fields, ok bool) {
	// Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом
	// входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не
	// зовём: имя считается не выведенным, вызывающий добавит загрузку без rename.
	if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" {
		return "", Fields{}, false
	}
	if n.provider != nil {
		if ex, extracted := n.extractViaLLM(ctx, contextText, hint); extracted {
			if label := render(ex); label != "" {
				return label, ex, true
			}
		}
	}
	return fallbackName(contextText, hint), Fields{}, false
}

// DeriveName — тонкая обёртка над Derive, когда структура не нужна (возвращает
// только имя).
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
	name, _, _ := n.Derive(ctx, contextText, hint)
	return name
}

// Label собирает полный детерминированный ярлык display_name из эффективных
// полей: «Название (режиссёр, год)», для сериала — хвост «. <сводка сезонов>».
// Все части, кроме названия, опциональны и выпадают, если пусты. seasonSummary —
// уже готовая строка сводки (для плана — recognize.SeasonSummary; для контекста —
// «Сезон N»). Имя очищается от управляющих символов и обрезается по длине.
// Пустое название → пустая строка. Единый рендер для шага добавления и перелива
// имени по распознаванию, чтобы формат ярлыка совпадал на всех путях.
func Label(title, director string, year int, seasonSummary string) string {
	title = sanitize(title)
	if title == "" {
		return ""
	}

	var paren []string
	if d := sanitize(director); d != "" {
		paren = append(paren, d)
	}
	if year > 0 {
		paren = append(paren, strconv.Itoa(year))
	}

	name := title
	if len(paren) > 0 {
		name += " (" + strings.Join(paren, ", ") + ")"
	}
	if s := sanitize(seasonSummary); s != "" {
		name += ". " + s
	}
	return truncate(name, maxNameLen)
}

// render собирает ярлык из извлечённой структуры (контекст) через общий Label.
func render(f Fields) string {
	return Label(f.Title, f.Director, f.Year, f.SeasonLabel())
}

// sanitize убирает управляющие и невидимые символы, переводы строк, схлопывает
// пробелы. Категорию Cf (zero-width, BOM, RLO) снимаем наравне с C0: значения
// приходят из метабазы и из сохранённого контекста, то есть снаружи, а ярлык
// уезжает в карточку Telegram, в шапку веб-UI и в имя раздачи qBittorrent —
// RLO там переворачивает отображение. Это же обещает требование metadata-match:
// режиссёр в плане не чистится именно потому, что его чистит рендер.
func sanitize(s string) string {
	s = strings.Map(func(r rune) rune {
		if r == '\n' || r == '\t' || r == '\r' {
			return ' '
		}
		if r < 0x20 || unicode.Is(unicode.Cf, r) {
			return -1
		}
		return r
	}, s)
	return strings.Join(strings.Fields(s), " ")
}

// truncate обрезает строку до n рун (без разрыва символа), отбрасывая хвост.
func truncate(s string, n int) string {
	if utf8.RuneCountInString(s) <= n {
		return s
	}
	count := 0
	for i := range s {
		if count == n {
			return strings.TrimRight(s[:i], " ")
		}
		count++
	}
	return s
}
