package layout

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"syscall"
	"testing"
)

func intp(n int) *int { return &n }

// fixture создаёт раскладчик с временными корнями downloads/movies/series и
// одним исходным файлом.
type fixture struct {
	l         *Layouter
	downloads string
	movies    string
	series    string
}

func newFixture(t *testing.T) fixture {
	t.Helper()
	root := t.TempDir()
	downloads := filepath.Join(root, "downloads")
	movies := filepath.Join(root, "movies")
	series := filepath.Join(root, "series")
	for _, d := range []string{downloads, movies, series} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	l, err := New(Config{MoviesDir: movies, SeriesDir: series}, nil)
	if err != nil {
		t.Fatal(err)
	}
	return fixture{l: l, downloads: downloads, movies: movies, series: series}
}

func (f fixture) srcFile(t *testing.T, rel, content string) string {
	t.Helper()
	p := filepath.Join(f.downloads, rel)
	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
	return p
}

func TestBuildLinks_Movie(t *testing.T) {
	f := newFixture(t)
	src := f.srcFile(t, "The.Matrix/movie.mkv", "x")
	sub := f.srcFile(t, "The.Matrix/movie.ru.srt", "y")
	plan := Plan{
		Type: Movie, Title: "The Matrix", Year: 1999, ProviderTag: "tmdbid-603",
		Files: []PlanFile{
			{Src: src, Role: RoleMain},
			{Src: sub, Role: RoleSubtitle, Lang: "ru"},
			{Src: f.srcFile(t, "The.Matrix/sample.mkv", "z"), Role: "sample"},
		},
	}
	links, err := f.l.BuildLinks(plan)
	if err != nil {
		t.Fatalf("BuildLinks: %v", err)
	}
	if len(links) != 2 { // sample пропущен
		t.Fatalf("want 2 links, got %d: %+v", len(links), links)
	}
	wantMain := filepath.Join(f.movies, "The Matrix (1999) [tmdbid-603]", "The Matrix (1999).mkv")
	wantSub := filepath.Join(f.movies, "The Matrix (1999) [tmdbid-603]", "The Matrix (1999).ru.srt")
	if links[0].Dst != wantMain || links[0].Kind != KindVideo {
		t.Errorf("main = %+v, want %q", links[0], wantMain)
	}
	if links[1].Dst != wantSub || links[1].Kind != KindSubtitle {
		t.Errorf("sub = %+v, want %q", links[1], wantSub)
	}
}

func TestBuildLinks_Series(t *testing.T) {
	f := newFixture(t)
	plan := Plan{
		Type: Series, Title: "Fargo", Year: 2015,
		Files: []PlanFile{
			{Src: f.srcFile(t, "Fargo/e1.mkv", "1"), Role: RoleEpisode, Season: intp(2), Episode: intp(1)},
			{Src: f.srcFile(t, "Fargo/e2.mkv", "2"), Role: RoleEpisode, Season: intp(2), Episode: intp(2)},
		},
	}
	links, err := f.l.BuildLinks(plan)
	if err != nil {
		t.Fatalf("BuildLinks: %v", err)
	}
	want := filepath.Join(f.series, "Fargo (2015)", "Season 02", "Fargo (2015) S02E01.mkv")
	if links[0].Dst != want {
		t.Errorf("ep1 = %q, want %q", links[0].Dst, want)
	}
}

func TestBuildLinks_FolderBaseOverridesSeries(t *testing.T) {
	f := newFixture(t)
	// LLM дал «Fargo» 2017, но живой якорь — «Фарго (2014)». FolderBase должна
	// перекрыть Title/Year и в папке, И в имени файла.
	plan := Plan{
		Type: Series, Title: "Fargo", Year: 2017, ProviderTag: "tvdbid-269613",
		FolderBase: "Фарго (2014)",
		Files: []PlanFile{
			{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(2), Episode: intp(1)},
		},
	}
	links, err := f.l.BuildLinks(plan)
	if err != nil {
		t.Fatalf("BuildLinks: %v", err)
	}
	want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv")
	if links[0].Dst != want {
		t.Errorf("ep = %q, want %q", links[0].Dst, want)
	}
}

func TestBuildLinks_FolderBaseOverridesMovie(t *testing.T) {
	f := newFixture(t)
	plan := Plan{
		Type: Movie, Title: "Dune", Year: 2021, ProviderTag: "tmdbid-693134",
		FolderBase: "Дюна Часть вторая (2024)",
		Files:      []PlanFile{{Src: f.srcFile(t, "m/f.mkv", "1"), Role: RoleMain}},
	}
	links, err := f.l.BuildLinks(plan)
	if err != nil {
		t.Fatalf("BuildLinks: %v", err)
	}
	want := filepath.Join(f.movies, "Дюна Часть вторая (2024) [tmdbid-693134]", "Дюна Часть вторая (2024).mkv")
	if links[0].Dst != want {
		t.Errorf("main = %q, want %q", links[0].Dst, want)
	}
}

func TestTitleFolder(t *testing.T) {
	f := newFixture(t)
	epDst := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 01", "Фарго (2014) S01E01.mkv")
	dir, base, ok := f.l.TitleFolder(Series, epDst)
	if !ok {
		t.Fatal("want ok for path under series root")
	}
	if wantDir := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]"); dir != wantDir {
		t.Errorf("dir = %q, want %q", dir, wantDir)
	}
	if base != "Фарго (2014)" {
		t.Errorf("base = %q, want %q", base, "Фарго (2014)")
	}

	// Фильм: папка тайтла — первый сегмент, база без тега.
	mvDst := filepath.Join(f.movies, "Dune (2024) [tmdbid-1]", "Dune (2024).mkv")
	if dir, base, ok := f.l.TitleFolder(Movie, mvDst); !ok ||
		dir != filepath.Join(f.movies, "Dune (2024) [tmdbid-1]") || base != "Dune (2024)" {
		t.Errorf("movie: dir=%q base=%q ok=%v", dir, base, ok)
	}

	// Путь не под корнем нужной библиотеки → not ok.
	if _, _, ok := f.l.TitleFolder(Series, mvDst); ok {
		t.Error("movie path must not resolve under series root")
	}
	if _, _, ok := f.l.TitleFolder(Movie, "/etc/passwd"); ok {
		t.Error("path outside library must be rejected")
	}
	// Сам корень (нет сегмента папки) → not ok.
	if _, _, ok := f.l.TitleFolder(Movie, f.movies); ok {
		t.Error("root itself has no title folder")
	}
}

func TestBuildLinks_SeriesEpisodeWithoutNumber(t *testing.T) {
	f := newFixture(t)
	plan := Plan{
		Type: Series, Title: "X", Year: 2020,
		Files: []PlanFile{{Src: f.srcFile(t, "x/e.mkv", "1"), Role: RoleEpisode, Season: intp(1)}},
	}
	if _, err := f.l.BuildLinks(plan); err == nil {
		t.Fatal("want error for episode without number")
	}
}

func TestBuildLinks_EmptyPlanRejected(t *testing.T) {
	f := newFixture(t)
	plan := Plan{Type: Movie, Title: "X", Year: 2020,
		Files: []PlanFile{{Src: "/x/sample.mkv", Role: "sample"}}}
	if _, err := f.l.BuildLinks(plan); err == nil {
		t.Fatal("want error when no linkable files")
	}
}

func TestBuildLinks_TraversalTitleStaysInside(t *testing.T) {
	f := newFixture(t)
	// Враждебное название не должно вывести за пределы библиотеки.
	plan := Plan{Type: Movie, Title: "../../etc/passwd", Year: 2020,
		Files: []PlanFile{{Src: f.srcFile(t, "m/f.mkv", "1"), Role: RoleMain}}}
	links, err := f.l.BuildLinks(plan)
	if err != nil {
		t.Fatalf("BuildLinks: %v", err)
	}
	if !underRoot(f.movies, links[0].Dst) {
		t.Errorf("dst escaped library: %q", links[0].Dst)
	}
}

func TestApply_CreatesHardlink(t *testing.T) {
	f := newFixture(t)
	src := f.srcFile(t, "m/film.mkv", "data")
	links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
		Files: []PlanFile{{Src: src, Role: RoleMain}}})

	res, err := f.l.Apply(context.Background(), links)
	if err != nil {
		t.Fatalf("Apply: %v", err)
	}
	if len(res) != 1 || res[0].Status != StatusLinked {
		t.Fatalf("res = %+v", res)
	}
	if res[0].Size != int64(len("data")) {
		t.Errorf("res.Size = %d, want %d", res[0].Size, len("data"))
	}
	// Тот же inode, источник цел.
	si, _ := os.Stat(src)
	di, _ := os.Stat(links[0].Dst)
	if !os.SameFile(si, di) {
		t.Error("dst is not a hardlink of src")
	}
	if _, err := os.Stat(src); err != nil {
		t.Errorf("source must remain: %v", err)
	}
}

func TestApply_Idempotent(t *testing.T) {
	f := newFixture(t)
	src := f.srcFile(t, "m/film.mkv", "data")
	links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
		Files: []PlanFile{{Src: src, Role: RoleMain}}})

	if _, err := f.l.Apply(context.Background(), links); err != nil {
		t.Fatalf("first apply: %v", err)
	}
	res, err := f.l.Apply(context.Background(), links)
	if err != nil {
		t.Fatalf("second apply: %v", err)
	}
	if res[0].Status != StatusExists {
		t.Errorf("status = %q, want exists (idempotent)", res[0].Status)
	}
}

func TestApply_CollisionNotOverwritten(t *testing.T) {
	f := newFixture(t)
	src := f.srcFile(t, "m/film.mkv", "original")
	links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
		Files: []PlanFile{{Src: src, Role: RoleMain}}})

	// Занимаем цель посторонним файлом.
	if err := os.MkdirAll(filepath.Dir(links[0].Dst), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(links[0].Dst, []byte("foreign"), 0o644); err != nil {
		t.Fatal(err)
	}

	_, err := f.l.Apply(context.Background(), links)
	if !errors.Is(err, ErrCollision) {
		t.Fatalf("err = %v, want ErrCollision", err)
	}
	// Посторонний файл не тронут.
	b, _ := os.ReadFile(links[0].Dst)
	if string(b) != "foreign" {
		t.Errorf("foreign file overwritten: %q", b)
	}
}

func TestUndo_RemovesLinksAndPrunesDirs(t *testing.T) {
	f := newFixture(t)
	links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021,
		Files: []PlanFile{
			{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
		}})
	if _, err := f.l.Apply(context.Background(), links); err != nil {
		t.Fatal(err)
	}

	n, err := f.l.Undo(context.Background(), links)
	if err != nil {
		t.Fatalf("Undo: %v", err)
	}
	if n != 1 {
		t.Errorf("removed = %d, want 1", n)
	}
	if _, err := os.Stat(links[0].Dst); !errors.Is(err, os.ErrNotExist) {
		t.Errorf("link still exists: %v", err)
	}
	// Пустые каталоги сезона и сериала подчищены, корень цел.
	if _, err := os.Stat(filepath.Join(f.series, "Show (2021)")); !errors.Is(err, os.ErrNotExist) {
		t.Errorf("show dir not pruned: %v", err)
	}
	if _, err := os.Stat(f.series); err != nil {
		t.Errorf("series root must remain: %v", err)
	}
	// Источник цел.
	if _, err := os.Stat(links[0].Src); err != nil {
		t.Errorf("source removed by undo: %v", err)
	}
}

func TestUndo_RefusesLastCopyWhenSourceGone(t *testing.T) {
	f := newFixture(t)
	links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
		Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "data"), Role: RoleMain}}})
	if _, err := f.l.Apply(context.Background(), links); err != nil {
		t.Fatal(err)
	}
	// Источник удалён (как при удалении раздачи из qBittorrent) → цель стала
	// последней копией (nlink упал до 1).
	if err := os.Remove(links[0].Src); err != nil {
		t.Fatal(err)
	}

	n, err := f.l.Undo(context.Background(), links)
	if !errors.Is(err, ErrLastCopy) {
		t.Fatalf("err = %v, want ErrLastCopy", err)
	}
	if n != 0 {
		t.Errorf("removed = %d, want 0 (ничего не сняли)", n)
	}
	// Цель НЕ удалена — данные целы.
	if _, err := os.Stat(links[0].Dst); err != nil {
		t.Errorf("target must remain (last copy): %v", err)
	}
}

func TestUndo_RefusesWholeBatchIfAnyLastCopy(t *testing.T) {
	f := newFixture(t)
	links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021,
		Files: []PlanFile{
			{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
			{Src: f.srcFile(t, "s/e2.mkv", "2"), Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
		}})
	if _, err := f.l.Apply(context.Background(), links); err != nil {
		t.Fatal(err)
	}
	// Источник второй серии пропал — весь батч должен быть отклонён целиком.
	if err := os.Remove(links[1].Src); err != nil {
		t.Fatal(err)
	}

	n, err := f.l.Undo(context.Background(), links)
	if !errors.Is(err, ErrLastCopy) {
		t.Fatalf("err = %v, want ErrLastCopy", err)
	}
	if n != 0 {
		t.Errorf("removed = %d, want 0 (батч не трогаем)", n)
	}
	// Первая ссылка (источник жив) НЕ снята — частичного отката нет.
	if _, err := os.Stat(links[0].Dst); err != nil {
		t.Errorf("first target must remain (no partial undo): %v", err)
	}
}

func TestUndo_Idempotent(t *testing.T) {
	f := newFixture(t)
	links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
		Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "1"), Role: RoleMain}}})
	if _, err := f.l.Apply(context.Background(), links); err != nil {
		t.Fatal(err)
	}
	if _, err := f.l.Undo(context.Background(), links); err != nil {
		t.Fatal(err)
	}
	// Повторный undo — не ошибка (цель уже снята).
	n, err := f.l.Undo(context.Background(), links)
	if err != nil {
		t.Fatalf("second undo: %v", err)
	}
	if n != 0 {
		t.Errorf("removed = %d, want 0", n)
	}
}

func TestCopyFile_DuplicatesContentAndKeepsSource(t *testing.T) {
	dir := t.TempDir()
	src := filepath.Join(dir, "src.mkv")
	dst := filepath.Join(dir, "sub", "dst.mkv")
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(src, []byte("payload"), 0o640); err != nil {
		t.Fatal(err)
	}

	if err := copyFile(src, dst); err != nil {
		t.Fatalf("copyFile: %v", err)
	}

	got, err := os.ReadFile(dst)
	if err != nil || string(got) != "payload" {
		t.Fatalf("dst content = %q, err = %v", got, err)
	}
	// Источник цел и это отдельный inode (копия, не хардлинк).
	si, _ := os.Stat(src)
	di, _ := os.Stat(dst)
	if os.SameFile(si, di) {
		t.Error("dst must be a distinct copy, not a hardlink")
	}
	if di.Mode().Perm() != 0o640 {
		t.Errorf("dst mode = %v, want source mode 0640", di.Mode().Perm())
	}
	// Временные файлы копирования подчищены.
	entries, _ := os.ReadDir(filepath.Dir(dst))
	for _, e := range entries {
		if len(e.Name()) >= 14 && e.Name()[:14] == ".jellybit-copy" {
			t.Errorf("leftover temp file: %s", e.Name())
		}
	}
}

func TestHardlinkUnsupported(t *testing.T) {
	cases := []struct {
		err  error
		want bool
	}{
		{syscall.EXDEV, true},
		{syscall.ENOTSUP, true},
		{syscall.EOPNOTSUPP, true},
		{syscall.EPERM, true},
		{syscall.ENOENT, false},
		{os.ErrExist, false},
		{errors.New("random"), false},
	}
	for _, tc := range cases {
		if got := hardlinkUnsupported(tc.err); got != tc.want {
			t.Errorf("hardlinkUnsupported(%v) = %v, want %v", tc.err, got, tc.want)
		}
	}
}

func TestUndo_RefusesOutsideLibrary(t *testing.T) {
	f := newFixture(t)
	outside := filepath.Join(f.downloads, "victim.mkv")
	if _, err := f.l.Undo(context.Background(), []Link{{Dst: outside}}); err == nil {
		t.Fatal("undo must refuse paths outside libraries")
	}
}
