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

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

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

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

// mediaType — вид контента в извлечённой структуре.
type mediaType string

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

// extracted — структура имени, извлечённая из контекста (схема ответа LLM).
// Year и Director опциональны (пустое значение → в ярлык не попадают).
type extracted struct {
	Type          mediaType `json:"type"`
	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"`
}

// 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}
}

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

// FormatTitleYear собирает детерминированный ярлык «Title (Year)» из уже
// распознанного названия — без сети и LLM (перелив канонического имени после
// распознавания). Год опционален; имя очищается от управляющих символов и
// обрезается по длине. Пустой title → пустая строка. В отличие от render это
// НЕ формат add-шага (там ещё режиссёр и сезон): у перелива их нет.
func FormatTitleYear(title string, year int) string {
	title = sanitize(title)
	if title == "" {
		return ""
	}
	if year > 0 {
		title += " (" + strconv.Itoa(year) + ")"
	}
	return truncate(title, maxNameLen)
}

// render собирает из структуры короткий ярлык:
//   - movie:  "Title (Director, Year)" — режиссёр и год опциональны;
//   - series: то же + ". Сезон N", если сезон задан.
//
// Имя очищается от управляющих символов/переводов строк и обрезается по
// длине. Пустой Title → пустая строка.
func render(ex extracted) string {
	title := sanitize(ex.Title)
	if title == "" {
		return ""
	}

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

	name := title
	if len(paren) > 0 {
		name += " (" + strings.Join(paren, ", ") + ")"
	}
	if ex.Type == typeSeries && ex.Season != nil && *ex.Season > 0 {
		name += ". Сезон " + strconv.Itoa(*ex.Season)
	}
	return truncate(name, maxNameLen)
}

// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
func sanitize(s string) string {
	s = strings.Map(func(r rune) rune {
		if r == '\n' || r == '\t' || r == '\r' {
			return ' '
		}
		if r < 0x20 {
			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
}
