// Package config загружает конфигурацию jellybit из TOML-файла.
package config

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/pelletier/go-toml/v2"
)

// DefaultPath — имя конфига по умолчанию: ищется в рабочей директории
// процесса. Переопределяется опцией --config=path.
const DefaultPath = "config.toml"

// Config — корневая конфигурация сервиса (см. config.example.toml).
type Config struct {
	General     General     `toml:"general"`
	QBittorrent QBittorrent `toml:"qbittorrent"`
	Paths       Paths       `toml:"paths"`
	Storage     Storage     `toml:"storage"`
	LLM         LLM         `toml:"llm"`
	Metadata    Metadata    `toml:"metadata"`
	Jellyfin    Jellyfin    `toml:"jellyfin"`
	Worker      Worker      `toml:"worker"`
	Recognition Recognition `toml:"recognition"`
	Telegram    Telegram    `toml:"telegram"`
	HTTP        HTTP        `toml:"http"`
	Log         Log         `toml:"log"`
}

// General — общие настройки приложения.
type General struct {
	// Timezone — таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр.
	// "Europe/Moscow"). Хранение всегда UTC; настройка влияет только на рендеринг.
	// Пусто → UTC. База зон встроена (time/tzdata), поэтому имя валидируется
	// одинаково на любом хосте (см. DisplayLocation).
	Timezone string `toml:"timezone"`
	// Language — язык локализованного вывода: `title` от LLM-детектора и
	// режиссёр/локаль запросов к метабазам. Допустимо: "ru" | "en", пусто → "en"
	// (см. ContentLanguage). `original_title` от настройки не зависит — всегда на
	// языке оригинала. Диалект локали провайдера (напр. TMDB ru-RU/en-US) выводит
	// сам провайдер из этого кода.
	Language string `toml:"language"`
}

// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
type QBittorrent struct {
	URL      string `toml:"url"`
	Username string `toml:"username"`
	Password string `toml:"password"`
	// Category — категория для добавляемых jellybit раздач (push, savepath).
	Category string `toml:"category"`
	// Tag — метка для усыновления существующих раздач (pull, не трогает
	// категорию/savepath). Discovery подхватывает раздачи с этой категорией
	// ИЛИ этим тегом.
	Tag      string            `toml:"tag"`
	SavePath string            `toml:"savepath"`
	PathMap  map[string]string `toml:"path_map"`
}

// Paths — хост-пути медиа-песочницы (см. docs/architecture.md).
type Paths struct {
	Downloads string `toml:"downloads"`
	Movies    string `toml:"movies"`
	Series    string `toml:"series"`
}

// Storage — расположение БД.
type Storage struct {
	DBPath string `toml:"db_path"`
}

// LLM — провайдер распознавания (дискриминатор type).
type LLM struct {
	Type       string   `toml:"type"`
	BaseURL    string   `toml:"base_url"`
	APIKey     string   `toml:"api_key"`
	Model      string   `toml:"model"`
	Proxy      string   `toml:"proxy"`
	Timeout    Duration `toml:"timeout"`
	MaxRetries int      `toml:"max_retries"`
}

// Metadata — внешние базы метаданных (опциональны).
type Metadata struct {
	TMDB   MetadataProvider `toml:"tmdb"`
	TVDB   MetadataProvider `toml:"tvdb"`
	TVMaze MetadataProvider `toml:"tvmaze"` // без ключа, только сериалы
}

// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
// (TVMaze) поле api_key не используется. Язык названий задаёт глобальный
// [general].language (локаль провайдера выводится из него), отдельной настройки
// у провайдера нет.
type MetadataProvider struct {
	Enabled bool     `toml:"enabled"`
	APIKey  string   `toml:"api_key"`
	Proxy   string   `toml:"proxy"`
	Timeout Duration `toml:"timeout"`
}

// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
// конфигом; без него скан не дёргается.
type Jellyfin struct {
	Enabled bool     `toml:"enabled"`
	URL     string   `toml:"url"`
	APIKey  string   `toml:"api_key"`
	Proxy   string   `toml:"proxy"` // опц. HTTP-прокси
	Timeout Duration `toml:"timeout"`
}

// Worker — параметры фонового цикла.
type Worker struct {
	PollInterval  Duration `toml:"poll_interval"`
	StuckAfter    Duration `toml:"stuck_after"`
	MagnetTimeout Duration `toml:"magnet_timeout"`
	// CatchTimeout — сколько пойманная (catched) загрузка может ждать добавления
	// в qBittorrent, прежде чем счесть его невозможным и увести задачу в failed.
	// Редкий предохранитель на случай устойчивой недоступности qBittorrent.
	CatchTimeout Duration `toml:"catch_timeout"`
	// SourceMissingThreshold — сколько подряд тиков сверки без раздачи в
	// qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи,
	// см. state-reconciliation). Любое появление раздачи сбрасывает счётчик.
	SourceMissingThreshold int `toml:"source_missing_threshold"`
}

// Recognition — пороги распознавания.
type Recognition struct {
	AutoConfidenceThreshold float64 `toml:"auto_confidence_threshold"`
}

// Telegram — настройки бота (Ф5).
type Telegram struct {
	Enabled        bool    `toml:"enabled"`
	Token          string  `toml:"token"`
	AllowedUserIDs []int64 `toml:"allowed_user_ids"`
	WebBaseURL     string  `toml:"web_base_url"` // для deep-link «открыть в вебе» (опц.)
	Proxy          string  `toml:"proxy"`        // опц. HTTP-прокси для api.telegram.org
}

// HTTP — параметры веб-сервера.
type HTTP struct {
	Listen string `toml:"listen"`
	// TrustedSubnets — allowlist подсетей. ПОКА НЕ ПРИМЕНЯЕТСЯ: деплой только
	// в локальную сеть без доступа из интернета, поэтому middleware отложено
	// (см. architecture.md). Поле сохранено под будущую реализацию.
	TrustedSubnets []string `toml:"trusted_subnets"`
}

// Log — параметры логирования.
type Log struct {
	Level  string `toml:"level"`
	Format string `toml:"format"`
}

// Duration — time.Duration, читаемый из TOML-строки вида "5s".
type Duration time.Duration

// UnmarshalText разбирает строку длительности (encoding.TextUnmarshaler).
func (d *Duration) UnmarshalText(text []byte) error {
	v, err := time.ParseDuration(string(text))
	if err != nil {
		return err
	}
	*d = Duration(v)
	return nil
}

// Std возвращает обычный time.Duration.
func (d Duration) Std() time.Duration { return time.Duration(d) }

// DisplayLocation возвращает таймзону отображения времени в веб-UI (пусто → UTC).
// Ошибка — если имя зоны не распознано; валидируется на старте (validate).
// Зоны доступны на любом хосте: база zoneinfo встроена в бинарь (time/tzdata),
// поэтому ошибка означает именно битое имя, а не отсутствие zoneinfo.
func (c *Config) DisplayLocation() (*time.Location, error) {
	if c.General.Timezone == "" {
		return time.UTC, nil
	}
	loc, err := time.LoadLocation(c.General.Timezone)
	if err != nil {
		return nil, fmt.Errorf("general.timezone %q: %w", c.General.Timezone, err)
	}
	return loc, nil
}

// ContentLanguage возвращает язык локализованного вывода абстрактным кодом
// ("ru" | "en"); пусто → "en". Диалект локали конкретного провайдера (ru-RU,
// en-US, …) выводит сам провайдер из этого кода — здесь его не знаем. Значение
// провалидировано на старте (validate): либо пусто, либо один из кодов.
func (c *Config) ContentLanguage() string {
	if c.General.Language == "" {
		return "en"
	}
	return c.General.Language
}

// Default возвращает конфиг с разумными умолчаниями; значения из файла
// перекрывают их при загрузке.
func Default() *Config {
	return &Config{
		General: General{Timezone: "UTC", Language: "en"},
		QBittorrent: QBittorrent{
			URL:      "http://qbit:8989",
			Username: "admin",
			Category: "jellybit",
			SavePath: "/srv/media/downloads",
		},
		Paths: Paths{
			Downloads: "/srv/media/downloads",
			Movies:    "/srv/media/movies",
			Series:    "/srv/media/series",
		},
		Storage: Storage{DBPath: "/data/jellybit.db"},
		LLM: LLM{
			Type:       "openai-compat",
			Timeout:    Duration(120 * time.Second),
			MaxRetries: 3,
		},
		Metadata: Metadata{
			TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
			TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
		},
		Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
		Worker: Worker{
			PollInterval:           Duration(5 * time.Second),
			StuckAfter:             Duration(time.Hour),
			MagnetTimeout:          Duration(24 * time.Hour),
			CatchTimeout:           Duration(10 * time.Minute),
			SourceMissingThreshold: 3,
		},
		Recognition: Recognition{AutoConfidenceThreshold: 0.85},
		HTTP:        HTTP{Listen: ":8080"},
		Log:         Log{Level: "info", Format: "json"},
	}
}

// Load читает и валидирует конфиг из path.
func Load(path string) (*Config, error) {
	cfg := Default()

	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config %q: %w", path, err)
	}
	if err := toml.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("parse config %q: %w", path, err)
	}
	if err := cfg.validate(); err != nil {
		return nil, fmt.Errorf("invalid config %q: %w", path, err)
	}
	return cfg, nil
}

// validate — fail-fast проверка конфига на старте: обязательные поля заданы,
// медиа-пути доступны и не выходят из песочницы, диапазоны соблюдены, секреты
// включённых секций не пусты. Длительности уже провалидированы при разборе
// TOML (UnmarshalText). Лог об ошибке пишет граница (cmd/jellybit), не загрузчик.
func (c *Config) validate() error {
	// Собираем все проблемы разом (errors.Join), чтобы оператор увидел все
	// огрехи отрендеренного файла за один проход, а не правил их по одной.
	var errs []error

	// Обязательные поля ядра.
	if c.QBittorrent.URL == "" {
		errs = append(errs, errors.New("qbittorrent.url is empty"))
	}
	if c.HTTP.Listen == "" {
		errs = append(errs, errors.New("http.listen is empty"))
	}
	if c.Storage.DBPath == "" {
		errs = append(errs, errors.New("storage.db_path is empty"))
	}
	if c.LLM.Type != "openai-compat" {
		errs = append(errs, fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type))
	}
	// Таймзона отображения: имя должно распознаваться (zoneinfo встроен).
	if _, err := c.DisplayLocation(); err != nil {
		errs = append(errs, err)
	}
	// Язык локализованного вывода: пусто (→ en) или один из кодов. Fail-fast,
	// как llm.type: мусорное значение не должно молча дефолтить. Множество
	// {ru, en} — канон; при добавлении кода синхронно расширь мапперы
	// metadata.tmdbLocale, metadata.tvdbLocale и recognize.languageDirective,
	// иначе новый язык молча даст английский вывод.
	switch c.General.Language {
	case "", "ru", "en":
	default:
		errs = append(errs, fmt.Errorf("unsupported general.language %q (supported: ru, en)", c.General.Language))
	}

	// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
	for _, p := range []struct{ name, path string }{
		{"paths.downloads", c.Paths.Downloads},
		{"paths.movies", c.Paths.Movies},
		{"paths.series", c.Paths.Series},
	} {
		if err := validateMediaDir(p.name, p.path); err != nil {
			errs = append(errs, err)
		}
	}

	// Диапазоны.
	if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 {
		errs = append(errs, fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t))
	}
	if c.LLM.MaxRetries < 0 {
		errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
	}
	if c.Worker.SourceMissingThreshold < 1 {
		errs = append(errs, fmt.Errorf("worker.source_missing_threshold %d must be >= 1", c.Worker.SourceMissingThreshold))
	}

	// qbittorrent.password намеренно не обязателен: qBittorrent может работать без
	// аутентификации (например, обход авторизации для клиентов из доверенной
	// подсети) — пустой пароль валиден.
	// llm.api_key намеренно не обязателен: keyless-local LLM (LM Studio с
	// заданным base_url, но без ключа) — валидный документированный дефолт.

	// Консистентность опциональных секций: enabled ⇒ заданы нужные поля/секреты.
	if c.Metadata.TMDB.Enabled && c.Metadata.TMDB.APIKey == "" {
		errs = append(errs, errors.New("metadata.tmdb.enabled but metadata.tmdb.api_key is empty"))
	}
	if c.Metadata.TVDB.Enabled && c.Metadata.TVDB.APIKey == "" {
		errs = append(errs, errors.New("metadata.tvdb.enabled but metadata.tvdb.api_key is empty"))
	}
	if c.Jellyfin.Enabled {
		if c.Jellyfin.URL == "" {
			errs = append(errs, errors.New("jellyfin.enabled but jellyfin.url is empty"))
		}
		if c.Jellyfin.APIKey == "" {
			errs = append(errs, errors.New("jellyfin.enabled but jellyfin.api_key is empty (required secret)"))
		}
	}
	if c.Telegram.Enabled && c.Telegram.Token == "" {
		errs = append(errs, errors.New("telegram.enabled but telegram.token is empty (required secret)"))
	}
	return errors.Join(errs...)
}

// validateMediaDir проверяет путь медиа-песочницы: непустой, абсолютный, без
// traversal (filepath.Clean — без `..`/лишних разделителей) и указывает на
// существующий доступный каталог. Отдельного корня песочницы в конфиге нет,
// поэтому «строго под песочницей» обеспечиваем абсолютностью и отсутствием
// traversal; единый монтируемый корень (/srv/media) — забота деплоя.
func validateMediaDir(name, path string) error {
	if path == "" {
		return fmt.Errorf("%s is empty", name)
	}
	if !filepath.IsAbs(path) {
		return fmt.Errorf("%s %q must be an absolute path", name, path)
	}
	if filepath.Clean(path) != path {
		return fmt.Errorf("%s %q must be a clean path (no .. or redundant separators)", name, path)
	}
	info, err := os.Stat(path)
	if err != nil {
		return fmt.Errorf("%s %q is not accessible: %w", name, path, err)
	}
	if !info.IsDir() {
		return fmt.Errorf("%s %q is not a directory", name, path)
	}
	return nil
}
