package layout

import (
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// countDirEntries считает всё, что появилось под корнем библиотеки: проверка
// длины обязана отказать ДО первой операции с ФС, поэтому пусто — это часть
// утверждения, а не гигиена.
func countDirEntries(t *testing.T, root string) int {
	t.Helper()
	n := 0
	err := filepath.WalkDir(root, func(p string, _ os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if p != root {
			n++
		}
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
	return n
}

// 2.1 Имя файла длиннее предела: отказ целиком, ни одного каталога на диске.
func TestBuildLinks_FileNameTooLong(t *testing.T) {
	f := newFixture(t)
	src := f.srcFile(t, "long/movie.mkv", "x")
	// Название впритык под папку, но имя файла = база + ".mkv".
	title := strings.Repeat("a", maxComponentBytes-len(" (1999)"))
	plan := Plan{
		Type: Movie, Title: title, Year: 1999,
		Files: []PlanFile{{Src: src, Role: RoleMain}},
	}
	links, err := f.l.BuildLinks(plan)
	if !errors.Is(err, ErrNameTooLong) {
		t.Fatalf("err = %v, want ErrNameTooLong", err)
	}
	if links != nil {
		t.Errorf("links = %v, want nil (отказ целиком)", links)
	}
	if n := countDirEntries(t, f.movies); n != 0 {
		t.Errorf("под movies появилось %d записей, ожидалось 0 (проверка до операций с ФС)", n)
	}
}

// 2.2 Папка тайтла длиннее предела, хотя имя файла бы поместилось.
func TestBuildLinks_FolderNameTooLong(t *testing.T) {
	f := newFixture(t)
	src := f.srcFile(t, "long/movie.mkv", "x")
	// База помещается, но provider-тег выталкивает папку за предел; имя файла
	// тега не несёт и остаётся коротким.
	tag := "tmdbid-693134"
	title := strings.Repeat("b", maxComponentBytes-len(" (1999)")-len(" [")-len(tag)-len("]"))
	plan := Plan{
		Type: Movie, Title: title, Year: 1999, ProviderTag: tag,
		Files: []PlanFile{{Src: src, Role: RoleMain}},
	}
	if _, err := f.l.BuildLinks(plan); err != nil {
		t.Fatalf("контроль: имя ровно в предел должно проходить, got %v", err)
	}
	plan.Title = title + "c" // +1 байт — папка перестаёт помещаться
	_, err := f.l.BuildLinks(plan)
	if !errors.Is(err, ErrNameTooLong) {
		t.Fatalf("err = %v, want ErrNameTooLong", err)
	}
	if n := countDirEntries(t, f.movies); n != 0 {
		t.Errorf("под movies появилось %d записей, ожидалось 0", n)
	}
}

// 2.3 Предел меряется в БАЙТАХ, а не в рунах: кириллица упирается вдвое раньше.
// Заодно граница 255/256.
func TestBuildLinks_LimitIsBytesNotRunes(t *testing.T) {
	f := newFixture(t)
	src := f.srcFile(t, "long/movie.mkv", "x")

	build := func(title string) error {
		_, err := f.l.BuildLinks(Plan{
			Type: Movie, Title: title,
			Files: []PlanFile{{Src: src, Role: RoleMain}},
		})
		return err
	}

	const ext = ".mkv"
	// Граница ровно на 255 байтах имени файла.
	fit := strings.Repeat("a", maxComponentBytes-len(ext))
	if err := build(fit); err != nil {
		t.Fatalf("255 байт должны помещаться, got %v", err)
	}
	if err := build(fit + "a"); !errors.Is(err, ErrNameTooLong) {
		t.Fatalf("256 байт: err = %v, want ErrNameTooLong", err)
	}

	// Столько же ЗНАКОВ кириллицей — вдвое больше байтов, отказ.
	cyr := strings.Repeat("я", maxComponentBytes-len(ext))
	if err := build(cyr); !errors.Is(err, ErrNameTooLong) {
		t.Fatalf("кириллица той же длины в знаках: err = %v, want ErrNameTooLong "+
			"(предел меряется в байтах)", err)
	}
	// Граница кириллицей — тоже по байтам. 125 букв = 250 байт, плюс ".mkv" = 254:
	// помещается. Ещё одна буква даёт 256 — не помещается.
	cyrFit := strings.Repeat("я", (maxComponentBytes-len(ext))/2)
	if err := build(cyrFit); err != nil {
		t.Fatalf("254 байта кириллицей должны помещаться, got %v", err)
	}
	if err := build(cyrFit + "я"); !errors.Is(err, ErrNameTooLong) {
		t.Fatalf("256 байт кириллицей: err = %v, want ErrNameTooLong", err)
	}
}

// 2.4 Приоритет: путь и вне библиотеки, и слишком длинный → отказ называет
// выход за библиотеку, а не длину. Иначе находка безопасности спрячется за
// косметической причиной.
func TestBuildLinks_OutsideLibraryBeatsTooLong(t *testing.T) {
	f := newFixture(t)
	src := f.srcFile(t, "long/movie.mkv", "x")
	// Санитизация режет разделители, поэтому traversal через Title недостижим;
	// проверяем сам порядок на checkComponentLengths напрямую: путь вне корня
	// до неё не доходит, а внутри корня — доходит.
	outside := filepath.Join(filepath.Dir(f.movies), strings.Repeat("z", 300))
	if underRoot(f.movies, outside) {
		t.Fatal("подготовка теста неверна: путь обязан быть вне корня")
	}
	_, err := f.l.BuildLinks(Plan{
		Type: Movie, Title: strings.Repeat("z", 300),
		Files: []PlanFile{{Src: src, Role: RoleMain}},
	})
	if !errors.Is(err, ErrNameTooLong) {
		t.Fatalf("внутри корня длинное имя даёт ErrNameTooLong, got %v", err)
	}
	// Прямая сверка порядка в BuildLinks: underRoot стоит раньше и его отказ
	// формулируется своим текстом (см. layout.go).
	if err := checkComponentLengths(f.movies, outside); !errors.Is(err, ErrNameTooLong) {
		t.Fatalf("checkComponentLengths вне корня: %v", err)
	}
}

// 2.7 Название не усекается: на непомещающемся входе ссылок нет вовсе, а не
// возвращена усечённая. Усечение схлопнуло бы два разных названия в один каталог.
func TestBuildLinks_NoTruncation(t *testing.T) {
	f := newFixture(t)
	src := f.srcFile(t, "long/movie.mkv", "x")
	links, err := f.l.BuildLinks(Plan{
		Type: Movie, Title: strings.Repeat("d", 400),
		Files: []PlanFile{{Src: src, Role: RoleMain}},
	})
	if err == nil {
		t.Fatalf("ожидался отказ, получено %d ссылок", len(links))
	}
	if len(links) != 0 {
		t.Errorf("вернулось %d ссылок — усечение недопустимо", len(links))
	}
}

// 2.8 Мерится финальный компонент, а не название: суффикс субтитров
// дописывается после и обязан учитываться.
func TestBuildLinks_SubtitleSuffixCounted(t *testing.T) {
	f := newFixture(t)
	video := f.srcFile(t, "long/ep.mkv", "x")
	sub := f.srcFile(t, "long/ep.ru.srt", "y")
	// База подобрана так, что видеофайл помещается, а субтитр с ".ru.forced.srt" —
	// уже нет: разница ровно в длине суффикса.
	const stem = " S01E02"
	title := strings.Repeat("e", maxComponentBytes-len(stem)-len(".mkv"))
	plan := Plan{
		Type: Series, Title: title,
		Files: []PlanFile{
			{Src: video, Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
		},
	}
	if _, err := f.l.BuildLinks(plan); err != nil {
		t.Fatalf("видеофайл впритык должен проходить, got %v", err)
	}
	plan.Files = append(plan.Files, PlanFile{
		Src: sub, Role: RoleSubtitle, Season: intp(1), Episode: intp(2),
		Lang: "ru", Flags: []string{"forced"},
	})
	if _, err := f.l.BuildLinks(plan); !errors.Is(err, ErrNameTooLong) {
		t.Fatalf("субтитр с суффиксом обязан упереться: err = %v", err)
	}
}

// 2.9 Вырожденные входы проверку не роняют.
func TestCheckComponentLengths_Degenerate(t *testing.T) {
	root := "/srv/media/movies"
	cases := []string{
		root + "/",
		root + "/   ",
		root + "/" + string([]byte{0xff, 0xfe, 0xfd}), // невалидный UTF-8
		root + "/" + strings.Repeat("x", 1<<16),
		root,
	}
	for _, dst := range cases {
		// Требование одно: не паниковать и вернуть значение.
		_ = checkComponentLengths(root, dst)
	}
	if err := checkComponentLengths(root, root+"/"+strings.Repeat("x", 1<<16)); !errors.Is(err, ErrNameTooLong) {
		t.Error("очень длинный компонент обязан давать ErrNameTooLong")
	}
}

// Корень библиотеки под проверку не попадает: его каталоги задаёт оператор.
func TestCheckComponentLengths_RootNotChecked(t *testing.T) {
	root := "/srv/" + strings.Repeat("r", 300)
	if err := checkComponentLengths(root, root+"/Dune (2024)/Dune (2024).mkv"); err != nil {
		t.Errorf("длинный корень не должен считаться отказом: %v", err)
	}
}

// Нерасчислимый относительный путь (корень абсолютный, цель относительная) —
// не отказ по длине, а отдельная ошибка: путать их нельзя, иначе диагноз соврёт.
func TestCheckComponentLengths_UnrelatablePath(t *testing.T) {
	err := checkComponentLengths("/srv/media/movies", "relative/path.mkv")
	if err == nil {
		t.Fatal("want error for unrelatable path")
	}
	if errors.Is(err, ErrNameTooLong) {
		t.Errorf("нерасчислимый путь не должен выдаваться за отказ по длине: %v", err)
	}
}

// shorten держит текст причины коротким: имя в сообщении усечено серединой, а
// точную длину несёт число рядом. Короткое имя не трогается.
func TestShorten(t *testing.T) {
	short := strings.Repeat("a", errNameSample)
	if got := shorten(short); got != short {
		t.Errorf("имя в предел образца не должно меняться: %q", got)
	}
	long := strings.Repeat("я", 200)
	got := shorten(long)
	if r := []rune(got); len(r) != errNameSample+1 { // +1 — многоточие
		t.Errorf("длина образца = %d рун, want %d", len(r), errNameSample+1)
	}
	if !strings.Contains(got, "…") {
		t.Errorf("усечённое имя должно нести многоточие: %q", got)
	}
	// Режем по рунам: обрыв посреди буквы дал бы мусор вместо кириллицы.
	if strings.ContainsRune(got, '�') {
		t.Errorf("усечение разорвало руну: %q", got)
	}
}
