// Package layout раскладывает распознанные файлы по конвенциям Jellyfin
// хардлинками, не трогая исходную раздачу (см. openspec/specs/file-layout/spec.md).
//
// Инварианты безопасности (см. architecture.md → «Раскладка файлов»):
//   - линкуем только файлы; целевые каталоги создаём mkdir;
//   - целевое имя санитизируется, итоговый путь обязан быть строго под
//     paths.movies/paths.series — иначе отказ (защита от traversal);
//   - существующее не перезаписываем: тот же inode → идемпотентно «готово»,
//     другой файл → коллизия (review);
//   - источник неприкосновенен: undo удаляет только ссылки своего батча и
//     только под библиотекой.
package layout

import (
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"syscall"

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

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

const (
	Movie  MediaType = "movie"
	Series MediaType = "series"
)

// Role — роль файла. Линкуются только main/episode/subtitle; остальные
// (extra/sample/ignore) раскладка пропускает.
type Role string

const (
	RoleMain     Role = "main"
	RoleEpisode  Role = "episode"
	RoleSubtitle Role = "subtitle"
)

// Kind — вид целевой ссылки (для file_link.kind).
type Kind string

const (
	KindVideo    Kind = "video"
	KindSubtitle Kind = "subtitle"
)

// PlanFile — один файл к раскладке.
type PlanFile struct {
	Src        string // абсолютный путь источника (save_path + относительное имя)
	Role       Role
	Season     *int     // для сериала
	Episode    *int     // для сериала
	EpisodeEnd *int     // двойная серия SxxEyy-Ezz (опц.)
	Lang       string   // язык субтитров (опц.)
	Flags      []string // флаги субтитров: forced/sdh/... (опц.)
}

// Plan — что и куда раскладывать.
type Plan struct {
	Type        MediaType
	Title       string
	Year        int
	ProviderTag string // напр. "tmdbid-693134"; пусто — без тега
	// FolderBase — унаследованная от живого якоря база имени ("Название (Год)").
	// Пусто → база печатается из Title/Year (первая загрузка тайтла). Непусто →
	// перекрывает Title/Year и идёт и в папку, и в имена файлов (правило
	// сходимости папки, см. file-layout spec). Provider-тег добавляется отдельно
	// из ProviderTag.
	FolderBase string
	Files      []PlanFile
}

// Link — посчитанная пара источник → цель.
type Link struct {
	Src  string
	Dst  string
	Kind Kind
}

// Config — корни библиотек и режим каталогов.
type Config struct {
	MoviesDir string
	SeriesDir string
	DirMode   os.FileMode // 0 → 0755
}

// Layouter строит и применяет раскладку.
type Layouter struct {
	movies  string
	series  string
	dirMode os.FileMode
	log     *slog.Logger
}

// New собирает раскладчик. Корни нормализуются (filepath.Clean). logger nil →
// slog.Default().
func New(cfg Config, logger *slog.Logger) (*Layouter, error) {
	if cfg.MoviesDir == "" || cfg.SeriesDir == "" {
		return nil, fmt.Errorf("layout: movies/series dirs required")
	}
	mode := cfg.DirMode
	if mode == 0 {
		mode = 0o755
	}
	if logger == nil {
		logger = slog.Default()
	}
	return &Layouter{
		movies:  filepath.Clean(cfg.MoviesDir),
		series:  filepath.Clean(cfg.SeriesDir),
		dirMode: mode,
		log:     logger,
	}, nil
}

// root возвращает корень библиотеки для типа.
func (l *Layouter) root(t MediaType) (string, error) {
	switch t {
	case Movie:
		return l.movies, nil
	case Series:
		return l.series, nil
	default:
		return "", fmt.Errorf("layout: unknown type %q", t)
	}
}

// BuildLinks вычисляет целевые пути (без обращения к ФС, кроме отсутствия —
// чистая функция от плана). Файлы-роли вне main/episode/subtitle
// пропускаются. Любая невалидность (пустое название, серия без номера,
// выход за пределы библиотеки) — ошибка целиком, частичную раскладку не
// начинаем.
func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
	root, err := l.root(p.Type)
	if err != nil {
		return nil, err
	}
	base, err := planBase(p)
	if err != nil {
		return nil, err
	}
	folder := folderName(base, p.ProviderTag)

	var links []Link
	for i := range p.Files {
		f := &p.Files[i]
		var dst string
		var kind Kind
		var berr error

		switch p.Type {
		case Movie:
			dst, kind, berr = l.movieDst(root, folder, base, f)
		case Series:
			dst, kind, berr = l.seriesDst(root, folder, base, f)
		}
		if berr != nil {
			return nil, berr
		}
		if dst == "" {
			continue // роль не линкуется (extra/sample/ignore)
		}
		if !underRoot(root, dst) {
			return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src)
		}
		// Длина проверяется ПОСЛЕ песочницы: путь, вышедший за библиотеку, —
		// находка безопасности, и подменять её косметической причиной нельзя.
		if err := checkComponentLengths(root, dst); err != nil {
			return nil, err
		}
		links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind})
	}
	if len(links) == 0 {
		return nil, fmt.Errorf("layout: plan produced no links")
	}
	return links, nil
}

func (l *Layouter) movieDst(root, folder, base string, f *PlanFile) (string, Kind, error) {
	dir := filepath.Join(root, folder)
	switch f.Role {
	case RoleMain:
		return filepath.Join(dir, base+ext(f.Src)), KindVideo, nil
	case RoleSubtitle:
		name := base + subtitleSuffix(f.Lang, f.Flags) + ext(f.Src)
		return filepath.Join(dir, name), KindSubtitle, nil
	default:
		return "", "", nil
	}
}

func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Kind, error) {
	if f.Role != RoleEpisode && f.Role != RoleSubtitle {
		return "", "", nil
	}
	if f.Season == nil || f.Episode == nil {
		return "", "", fmt.Errorf("layout: file %q has no season/episode", f.Src)
	}
	episodeEnd := 0
	if f.EpisodeEnd != nil {
		episodeEnd = *f.EpisodeEnd
	}
	dir := filepath.Join(root, folder, seasonFolder(*f.Season))
	stem := episodeStem(base, *f.Season, *f.Episode, episodeEnd)
	switch f.Role {
	case RoleEpisode:
		return filepath.Join(dir, stem+ext(f.Src)), KindVideo, nil
	case RoleSubtitle:
		name := stem + subtitleSuffix(f.Lang, f.Flags) + ext(f.Src)
		return filepath.Join(dir, name), KindSubtitle, nil
	default:
		return "", "", nil
	}
}

// TitleFolder разбирает целевой путь dst живой ссылки в папку тайтла и её базу
// имени для правила сходимости (см. file-layout spec). Возвращает абсолютный
// путь папки тайтла (первый сегмент под корнем библиотеки типа t), её базу
// (снят хвостовой provider-тег) и ok. ok=false, если dst не под корнем нужной
// библиотеки или база пуста — вызывающий трактует как «нет якоря». Существование
// папки на диске здесь НЕ проверяется (это делает worker через os.Lstat).
func (l *Layouter) TitleFolder(t MediaType, dst string) (dir, base string, ok bool) {
	root, err := l.root(t)
	if err != nil {
		return "", "", false
	}
	dst = filepath.Clean(dst)
	if !underRoot(root, dst) {
		return "", "", false
	}
	rel, err := filepath.Rel(root, dst)
	if err != nil {
		return "", "", false
	}
	first, _, _ := strings.Cut(rel, string(filepath.Separator))
	if first == "" || first == "." {
		return "", "", false
	}
	base = folderBase(first)
	if base == "" {
		return "", "", false
	}
	return filepath.Join(root, first), base, true
}

// LinkStatus — исход создания одной ссылки.
type LinkStatus string

const (
	StatusLinked     LinkStatus = "linked"     // хардлинк создан
	StatusCopied     LinkStatus = "copied"     // хардлинк невозможен — файл скопирован (фолбэк)
	StatusExists     LinkStatus = "exists"     // уже была (тот же inode) — идемпотентно
	StatusCollision  LinkStatus = "collision"  // цель занята другим файлом
	StatusSuperseded LinkStatus = "superseded" // путь перехватила другая загрузка (см. state-reconciliation, владение путём)
)

// Result — итог по одной ссылке.
type Result struct {
	Link   Link
	Status LinkStatus
	Size   int64 // размер разложенного файла (байт); 0, если stat не удался
}

// ErrCollision — цель существует и это другой файл (нужен review).
var ErrCollision = errors.New("layout: target collision")

// ErrNameTooLong — компонент целевого пути длиннее предела длины имени
// (maxComponentBytes). Проверяется в BuildLinks, до первой операции с ФС:
// задача уходит в review с доменной причиной, а не в failed с текстом ядра.
var ErrNameTooLong = errors.New("layout: имя не помещается")

// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
// единственный файл (см. state-reconciliation, инвариант безопасного Undo).
var ErrLastCopy = errors.New("layout: refusing to remove last remaining copy")

// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
// не поддерживает link) — фолбэк на копирование файла с предупреждением в лог.
func (l *Layouter) Apply(ctx context.Context, links []Link) ([]Result, error) {
	log := logctx.FromOr(ctx, l.log)
	results := make([]Result, 0, len(links))
	for _, ln := range links {
		root := l.movies
		if !underRoot(l.movies, ln.Dst) {
			root = l.series
		}
		if !underRoot(root, ln.Dst) {
			return results, fmt.Errorf("layout: target %q is outside libraries", ln.Dst)
		}
		if err := os.MkdirAll(filepath.Dir(ln.Dst), l.dirMode); err != nil {
			return results, fmt.Errorf("layout: mkdir %q: %w", filepath.Dir(ln.Dst), err)
		}

		status, err := l.linkOne(log, ln.Src, ln.Dst)
		if err != nil {
			log.Error("layout link failed",
				"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "error", err)
			return results, err
		}
		log.Debug("layout link applied",
			"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
		// Размер разложенного файла для показа в веб-UI (фолбэк размера раздачи,
		// когда торрента нет в снимке). Best-effort: файл только что слинкован/
		// скопирован/уже существовал — stat должен пройти; сбой не валит
		// раскладку, размер остаётся 0.
		var size int64
		if fi, serr := os.Stat(ln.Dst); serr == nil {
			size = fi.Size()
		} else {
			log.Debug("layout stat size failed", "dst", ln.Dst, "error", serr)
		}
		results = append(results, Result{Link: ln, Status: status, Size: size})
	}
	return results, nil
}

// linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность
// хардлинка (фолбэк на копирование).
func (l *Layouter) linkOne(log *slog.Logger, src, dst string) (LinkStatus, error) {
	err := os.Link(src, dst)
	if err == nil {
		return StatusLinked, nil
	}
	if errors.Is(err, fs.ErrExist) {
		same, serr := sameFile(src, dst)
		if serr != nil {
			return "", fmt.Errorf("layout: stat existing %q: %w", dst, serr)
		}
		if same {
			return StatusExists, nil // идемпотентно: тот же inode
		}
		return StatusCollision, fmt.Errorf("%w: %q is occupied by a different file", ErrCollision, dst)
	}
	if hardlinkUnsupported(err) {
		// Хардлинк невозможен (граница ФС или ФС без поддержки link). Не валим
		// раскладку — копируем файл и предупреждаем: диск дублируется, но
		// задача доходит до конца. dst здесь заведомо отсутствует (иначе был бы
		// fs.ErrExist выше).
		log.Warn("layout hardlink unsupported, file copy fallback",
			"src", src, "dst", dst, "error", err)
		if cerr := copyFile(src, dst); cerr != nil {
			return "", fmt.Errorf("layout: copy fallback %q → %q: %w", src, dst, cerr)
		}
		return StatusCopied, nil
	}
	return "", fmt.Errorf("layout: link %q → %q: %w", src, dst, err)
}

// hardlinkUnsupported сообщает, означает ли ошибка os.Link, что хардлинк между
// этими путями в принципе невозможен (а не временный сбой): разные ФС (EXDEV)
// или ФС без поддержки жёстких ссылок (ENOTSUP/EOPNOTSUPP, у части ФС — EPERM).
func hardlinkUnsupported(err error) bool {
	return errors.Is(err, syscall.EXDEV) ||
		errors.Is(err, syscall.ENOTSUP) ||
		errors.Is(err, syscall.EOPNOTSUPP) ||
		errors.Is(err, syscall.EPERM)
}

// copyFile копирует src в dst через временный файл в каталоге назначения и
// атомарный rename — так сбой посреди копирования не оставит частичный файл на
// месте dst. Источник не модифицируется. Вызывается, только когда хардлинк
// невозможен (см. linkOne).
func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return fmt.Errorf("open source: %w", err)
	}
	defer func() { _ = in.Close() }()

	info, err := in.Stat()
	if err != nil {
		return fmt.Errorf("stat source: %w", err)
	}

	tmp, err := os.CreateTemp(filepath.Dir(dst), ".jellybit-copy-*")
	if err != nil {
		return fmt.Errorf("create temp: %w", err)
	}
	tmpName := tmp.Name()
	cleanup := true
	defer func() {
		if cleanup {
			_ = os.Remove(tmpName)
		}
	}()

	if _, err := io.Copy(tmp, in); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("copy data: %w", err)
	}
	if err := tmp.Sync(); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("sync temp: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("close temp: %w", err)
	}
	if err := os.Chmod(tmpName, info.Mode().Perm()); err != nil {
		return fmt.Errorf("chmod temp: %w", err)
	}
	if err := os.Rename(tmpName, dst); err != nil {
		return fmt.Errorf("rename into place: %w", err)
	}
	cleanup = false
	return nil
}

// sameFile сообщает, указывают ли src и dst на один inode.
func sameFile(src, dst string) (bool, error) {
	si, err := os.Stat(src)
	if err != nil {
		return false, err
	}
	di, err := os.Stat(dst)
	if err != nil {
		return false, err
	}
	return os.SameFile(si, di), nil
}

// Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути
// строго под библиотеками (источник недосягаем). Отсутствующая цель — не
// ошибка (идемпотентно). Возвращает число удалённых ссылок.
//
// Защита от потери данных: сперва предпроверка всего батча — если хоть одна
// существующая цель оказывается последней копией (источник пропал или
// nlink<=1), весь Undo отклоняется с ErrLastCopy и НИ ОДНА ссылка не
// снимается (иначе частичный откат стёр бы часть данных). Так откат снимает
// лишний хардлинк, а не единственный файл.
func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
	log := logctx.FromOr(ctx, l.log)

	// Предпроверка: путь под библиотекой + не последняя копия.
	for _, ln := range links {
		if _, err := undoRoot(l, ln.Dst); err != nil {
			return 0, err
		}
		fi, err := os.Lstat(ln.Dst)
		if err != nil {
			if errors.Is(err, fs.ErrNotExist) {
				continue // цели уже нет — снимать нечего (идемпотентно)
			}
			return 0, fmt.Errorf("layout: undo stat %q: %w", ln.Dst, err)
		}
		last, err := isLastCopy(fi, ln.Src)
		if err != nil {
			return 0, fmt.Errorf("layout: undo check %q: %w", ln.Dst, err)
		}
		if last {
			return 0, fmt.Errorf("%w: %q (источник %q недоступен)", ErrLastCopy, ln.Dst, ln.Src)
		}
	}

	removed := 0
	for _, ln := range links {
		root, err := undoRoot(l, ln.Dst)
		if err != nil {
			return removed, err
		}
		if err := os.Remove(ln.Dst); err != nil {
			if errors.Is(err, fs.ErrNotExist) {
				continue
			}
			log.Error("layout undo remove failed", "dst", ln.Dst, "error", err)
			return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err)
		}
		removed++
		log.Debug("layout link removed", "dst", ln.Dst)
		pruneEmptyDirs(filepath.Dir(ln.Dst), root)
	}
	return removed, nil
}

// Remove снимает целевые ссылки БЕЗ гарда последней копии — в отличие от Undo,
// который отказывается стирать единственную копию данных. Служит осознанному
// удалению загрузки («освободить место»): снимает последнюю библиотечную ссылку,
// даже если источник уже пропал. Как и Undo, трогает только пути строго под
// библиотеками (источник недосягаем) и идемпотентен к отсутствующей цели.
// Возвращает число удалённых ссылок.
func (l *Layouter) Remove(ctx context.Context, links []Link) (int, error) {
	log := logctx.FromOr(ctx, l.log)
	removed := 0
	for _, ln := range links {
		root, err := undoRoot(l, ln.Dst)
		if err != nil {
			return removed, err
		}
		if err := os.Remove(ln.Dst); err != nil {
			if errors.Is(err, fs.ErrNotExist) {
				continue // цели уже нет — снимать нечего (идемпотентно)
			}
			log.Error("layout remove failed", "dst", ln.Dst, "error", err)
			return removed, fmt.Errorf("layout: remove %q: %w", ln.Dst, err)
		}
		removed++
		log.Debug("layout link removed (delete)", "dst", ln.Dst)
		pruneEmptyDirs(filepath.Dir(ln.Dst), root)
	}
	return removed, nil
}

// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку,
// если путь не под movies/series (откат трогает только библиотеку).
func undoRoot(l *Layouter, dst string) (string, error) {
	root := l.movies
	if !underRoot(l.movies, dst) {
		root = l.series
	}
	if !underRoot(root, dst) {
		return "", fmt.Errorf("layout: undo outside library: %q", dst)
	}
	return root, nil
}

// isLastCopy сообщает, является ли цель последней копией данных: исходный файл
// уже не существует, либо у цели не осталось других жёстких ссылок (nlink<=1).
// В обоих случаях unlink цели уничтожил бы единственную копию.
func isLastCopy(fi os.FileInfo, src string) (bool, error) {
	if _, err := os.Lstat(src); err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return true, nil // источника нет — цель единственная копия
		}
		return false, fmt.Errorf("stat source %q: %w", src, err)
	}
	st, ok := fi.Sys().(*syscall.Stat_t)
	if !ok {
		return false, fmt.Errorf("unexpected stat type for %q", fi.Name())
	}
	return st.Nlink <= 1, nil
}

// pruneEmptyDirs удаляет опустевшие каталоги вверх до (не включая) root.
// Ошибки игнорируются: непустой каталог os.Remove не удалит — это и нужно.
func pruneEmptyDirs(dir, root string) {
	for dir != root && underRoot(root, dir) {
		if err := os.Remove(dir); err != nil {
			return // непустой или нет прав — останавливаемся
		}
		dir = filepath.Dir(dir)
	}
}
