package restic

import (
	"errors"
	"io"
	"io/fs"
	"testing"
	"time"

	"git.secluded.site/keld/internal/config"
)

// testNodes returns a small but representative set of LsNode values for
// testing SnapshotFS. The structure is:
//
//	/music/
//	/music/album-01/
//	/music/album-01/01 - track.flac
//	/music/album-01/02 - track.flac
//	/music/album-02/
//	/music/album-02/01 - song.flac
//	/docs/
//	/docs/notes.txt
//	/readme.txt
//
// All paths are absolute, as restic ls produces them.
func testNodes() []LsNode {
	now := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC)
	return []LsNode{
		{Name: "music", Type: "dir", Path: "/music", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
		{Name: "album-01", Type: "dir", Path: "/music/album-01", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
		{Name: "01 - track.flac", Type: "file", Path: "/music/album-01/01 - track.flac", Size: 1000, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
		{Name: "02 - track.flac", Type: "file", Path: "/music/album-01/02 - track.flac", Size: 2000, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
		{Name: "album-02", Type: "dir", Path: "/music/album-02", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
		{Name: "01 - song.flac", Type: "file", Path: "/music/album-02/01 - song.flac", Size: 3000, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
		{Name: "docs", Type: "dir", Path: "/docs", Mode: 2147484141, Permissions: "drwxr-xr-x", Mtime: now},
		{Name: "notes.txt", Type: "file", Path: "/docs/notes.txt", Size: 500, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
		{Name: "readme.txt", Type: "file", Path: "/readme.txt", Size: 100, Mode: 420, Permissions: "-rw-r--r--", Mtime: now},
	}
}

func TestSnapshotFSReadDirRoot(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())
	entries, err := fs.ReadDir(sfs, ".")
	if err != nil {
		t.Fatalf("ReadDir(.): %v", err)
	}

	// Root should have: docs, music, readme.txt — sorted.
	wantNames := []string{"docs", "music", "readme.txt"}
	if len(entries) != len(wantNames) {
		t.Fatalf("ReadDir(.) count: got %d, want %d", len(entries), len(wantNames))
	}
	for i, want := range wantNames {
		if entries[i].Name() != want {
			t.Errorf("ReadDir(.)[%d].Name(): got %q, want %q", i, entries[i].Name(), want)
		}
	}

	// Verify types.
	if !entries[0].IsDir() {
		t.Error("docs should be a directory")
	}
	if !entries[1].IsDir() {
		t.Error("music should be a directory")
	}
	if entries[2].IsDir() {
		t.Error("readme.txt should not be a directory")
	}
}

func TestSnapshotFSReadDirSubdir(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())
	entries, err := fs.ReadDir(sfs, "music")
	if err != nil {
		t.Fatalf("ReadDir(music): %v", err)
	}

	wantNames := []string{"album-01", "album-02"}
	if len(entries) != len(wantNames) {
		t.Fatalf("ReadDir(music) count: got %d, want %d", len(entries), len(wantNames))
	}
	for i, want := range wantNames {
		if entries[i].Name() != want {
			t.Errorf("ReadDir(music)[%d].Name(): got %q, want %q", i, entries[i].Name(), want)
		}
	}
}

func TestSnapshotFSReadDirFiles(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())
	entries, err := fs.ReadDir(sfs, "music/album-01")
	if err != nil {
		t.Fatalf("ReadDir(music/album-01): %v", err)
	}

	wantNames := []string{"01 - track.flac", "02 - track.flac"}
	if len(entries) != len(wantNames) {
		t.Fatalf("ReadDir(music/album-01) count: got %d, want %d", len(entries), len(wantNames))
	}
	for i, want := range wantNames {
		if entries[i].Name() != want {
			t.Errorf("ReadDir(music/album-01)[%d].Name(): got %q, want %q", i, entries[i].Name(), want)
		}
		if entries[i].IsDir() {
			t.Errorf("%q should not be a directory", want)
		}
	}
}

func TestSnapshotFSReadDirOfFile(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())
	_, err := fs.ReadDir(sfs, "readme.txt")
	if err == nil {
		t.Fatal("ReadDir(readme.txt) should fail for a file")
	}
}

func TestSnapshotFSReadDirNotExist(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())
	_, err := fs.ReadDir(sfs, "nonexistent")
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("ReadDir(nonexistent): got %v, want fs.ErrNotExist", err)
	}
}

func TestSnapshotFSStat(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())

	// Stat a file.
	info, err := fs.Stat(sfs, "music/album-01/01 - track.flac")
	if err != nil {
		t.Fatalf("Stat(file): %v", err)
	}
	if info.Name() != "01 - track.flac" {
		t.Errorf("Stat(file).Name(): got %q, want %q", info.Name(), "01 - track.flac")
	}
	if info.Size() != 1000 {
		t.Errorf("Stat(file).Size(): got %d, want 1000", info.Size())
	}
	if info.IsDir() {
		t.Error("Stat(file).IsDir() should be false")
	}

	// Stat a directory.
	info, err = fs.Stat(sfs, "music")
	if err != nil {
		t.Fatalf("Stat(dir): %v", err)
	}
	if info.Name() != "music" {
		t.Errorf("Stat(dir).Name(): got %q, want %q", info.Name(), "music")
	}
	if !info.IsDir() {
		t.Error("Stat(dir).IsDir() should be true")
	}

	// Stat root.
	info, err = fs.Stat(sfs, ".")
	if err != nil {
		t.Fatalf("Stat(.): %v", err)
	}
	if !info.IsDir() {
		t.Error("Stat(.).IsDir() should be true")
	}
}

func TestSnapshotFSStatNotExist(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())
	_, err := fs.Stat(sfs, "no/such/path")
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("Stat(missing): got %v, want fs.ErrNotExist", err)
	}
}

func TestSnapshotFSOpenFile(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())
	f, err := sfs.Open("readme.txt")
	if err != nil {
		t.Fatalf("Open(readme.txt): %v", err)
	}
	defer func() { _ = f.Close() }()

	info, err := f.Stat()
	if err != nil {
		t.Fatalf("Open(readme.txt).Stat(): %v", err)
	}
	if info.Name() != "readme.txt" {
		t.Errorf("Name(): got %q, want %q", info.Name(), "readme.txt")
	}
	if info.Size() != 100 {
		t.Errorf("Size(): got %d, want 100", info.Size())
	}
}

func TestSnapshotFSOpenNotExist(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())
	_, err := sfs.Open("nope.txt")
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("Open(missing): got %v, want fs.ErrNotExist", err)
	}
}

func TestSnapshotFSSynthesizedParents(t *testing.T) {
	t.Parallel()

	// Only file nodes, no explicit directory entries.
	// SnapshotFS must synthesize /a and /a/b as directories.
	nodes := []LsNode{
		{Name: "file.txt", Type: "file", Path: "/a/b/file.txt", Size: 42, Mode: 420, Permissions: "-rw-r--r--", Mtime: time.Now()},
	}

	sfs := NewSnapshotFS(nodes)

	// Root should contain synthesized "a".
	entries, err := fs.ReadDir(sfs, ".")
	if err != nil {
		t.Fatalf("ReadDir(.): %v", err)
	}
	if len(entries) != 1 || entries[0].Name() != "a" {
		t.Fatalf("ReadDir(.): got %v, want [a]", names(entries))
	}
	if !entries[0].IsDir() {
		t.Error("synthesized 'a' should be a directory")
	}

	// "a" should contain synthesized "b".
	entries, err = fs.ReadDir(sfs, "a")
	if err != nil {
		t.Fatalf("ReadDir(a): %v", err)
	}
	if len(entries) != 1 || entries[0].Name() != "b" {
		t.Fatalf("ReadDir(a): got %v, want [b]", names(entries))
	}

	// "a/b" should contain the file.
	entries, err = fs.ReadDir(sfs, "a/b")
	if err != nil {
		t.Fatalf("ReadDir(a/b): %v", err)
	}
	if len(entries) != 1 || entries[0].Name() != "file.txt" {
		t.Fatalf("ReadDir(a/b): got %v, want [file.txt]", names(entries))
	}
}

func TestSnapshotFSExplicitOverridesSynthesized(t *testing.T) {
	t.Parallel()

	mtime := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
	nodes := []LsNode{
		// Explicit directory with real metadata.
		{Name: "a", Type: "dir", Path: "/a", Mode: 2147484157, Permissions: "drwxrwxr-x", Mtime: mtime},
		// File inside it — would cause "a" to be synthesized if the
		// explicit entry were missing.
		{Name: "file.txt", Type: "file", Path: "/a/file.txt", Size: 10, Mode: 420, Permissions: "-rw-r--r--", Mtime: mtime},
	}

	sfs := NewSnapshotFS(nodes)
	info, err := fs.Stat(sfs, "a")
	if err != nil {
		t.Fatalf("Stat(a): %v", err)
	}
	if !info.ModTime().Equal(mtime) {
		t.Errorf("Stat(a).ModTime(): got %v, want %v (explicit metadata should win)", info.ModTime(), mtime)
	}
}

func TestSnapshotFSReadDirFileStateful(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())

	// Open the root as a file and call ReadDir(n) incrementally.
	f, err := sfs.Open(".")
	if err != nil {
		t.Fatalf("Open(.): %v", err)
	}
	defer func() { _ = f.Close() }()

	rdf, ok := f.(fs.ReadDirFile)
	if !ok {
		t.Fatal("Open(.) should return an fs.ReadDirFile")
	}

	// Root has 3 entries: docs, music, readme.txt
	// Read 2, then 2 more (should get 1 + io.EOF).
	batch1, err := rdf.ReadDir(2)
	if err != nil {
		t.Fatalf("ReadDir(2) first call: %v", err)
	}
	if len(batch1) != 2 {
		t.Fatalf("ReadDir(2) first call: got %d entries, want 2", len(batch1))
	}
	if batch1[0].Name() != "docs" || batch1[1].Name() != "music" {
		t.Errorf("ReadDir(2) first call: got [%q, %q], want [docs, music]",
			batch1[0].Name(), batch1[1].Name())
	}

	batch2, err := rdf.ReadDir(2)
	if err != io.EOF {
		t.Fatalf("ReadDir(2) second call: err=%v, want io.EOF", err)
	}
	if len(batch2) != 1 {
		t.Fatalf("ReadDir(2) second call: got %d entries, want 1", len(batch2))
	}
	if batch2[0].Name() != "readme.txt" {
		t.Errorf("ReadDir(2) second call: got %q, want readme.txt", batch2[0].Name())
	}

	// A third call should return nil, io.EOF.
	batch3, err := rdf.ReadDir(1)
	if err != io.EOF {
		t.Fatalf("ReadDir(1) third call: err=%v, want io.EOF", err)
	}
	if len(batch3) != 0 {
		t.Errorf("ReadDir(1) third call: got %d entries, want 0", len(batch3))
	}
}

func TestSnapshotFSInvalidPath(t *testing.T) {
	t.Parallel()

	sfs := NewSnapshotFS(testNodes())

	// Leading slash is not a valid fs.FS path.
	_, err := sfs.Open("/music")
	if err == nil {
		t.Error("Open(/music) should fail for invalid path")
	}
}

func TestSnapshotFSFromTestRepo(t *testing.T) {
	t.Parallel()

	repoPath := testdataRepoPath(t)
	cfg := &config.ResolvedConfig{
		Command: "restore",
		Flags: []config.Flag{
			{Name: "--repo", Value: repoPath},
		},
		Environ: map[string]string{
			"RESTIC_PASSWORD": "test",
		},
	}

	nodes, err := RunLs(cfg, "03b061a6")
	if err != nil {
		t.Fatalf("RunLs(): %v", err)
	}

	sfs := NewSnapshotFS(nodes)

	// Root should contain: .config, documents, music, photos, projects
	entries, err := fs.ReadDir(sfs, ".")
	if err != nil {
		t.Fatalf("ReadDir(.): %v", err)
	}
	wantRoot := []string{".config", "documents", "music", "photos", "projects"}
	if len(entries) != len(wantRoot) {
		t.Fatalf("ReadDir(.) names: got %v, want %v", names(entries), wantRoot)
	}
	for i, want := range wantRoot {
		if entries[i].Name() != want {
			t.Errorf("ReadDir(.)[%d]: got %q, want %q", i, entries[i].Name(), want)
		}
	}

	// Navigate into a Unicode directory.
	entries, err = fs.ReadDir(sfs, "music/アーティスト/album-01")
	if err != nil {
		t.Fatalf("ReadDir(music/アーティスト/album-01): %v", err)
	}
	wantAlbum := []string{"01 - 夜明け.flac", "02 - 風の歌.flac", "03 - 星空.flac"}
	if len(entries) != len(wantAlbum) {
		t.Fatalf("ReadDir(album-01) names: got %v, want %v", names(entries), wantAlbum)
	}
	for i, want := range wantAlbum {
		if entries[i].Name() != want {
			t.Errorf("album-01[%d]: got %q, want %q", i, entries[i].Name(), want)
		}
	}

	// Stat a file with spaces in its path.
	info, err := fs.Stat(sfs, "music/Jazz Café/live/02 - So What.flac")
	if err != nil {
		t.Fatalf("Stat(So What): %v", err)
	}
	if info.IsDir() {
		t.Error("So What.flac should not be a directory")
	}
	if info.Size() == 0 {
		t.Error("So What.flac should have nonzero size")
	}

	// Deep path through projects.
	entries, err = fs.ReadDir(sfs, "projects/keld/internal")
	if err != nil {
		t.Fatalf("ReadDir(projects/keld/internal): %v", err)
	}
	wantInternal := []string{"config", "restic"}
	if len(entries) != len(wantInternal) {
		t.Fatalf("ReadDir(internal) names: got %v, want %v", names(entries), wantInternal)
	}

	// Missing path.
	_, err = fs.Stat(sfs, "nonexistent/path")
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("Stat(missing): got %v, want fs.ErrNotExist", err)
	}
}

// names extracts entry names for test failure messages.
func names(entries []fs.DirEntry) []string {
	out := make([]string, len(entries))
	for i, e := range entries {
		out[i] = e.Name()
	}
	return out
}
