package workspacefs

import (
	"errors"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/desktop/internal/profile"
	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
)

func setupConfigDir(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, dir)
	return dir
}

func writeFile(t *testing.T, path string, content string) {
	t.Helper()
	require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
	require.NoError(t, os.WriteFile(path, []byte(content), 0644))
}

func TestListDirSortsFiltersAndHidesRuntimeRootFiles(t *testing.T) {
	setupConfigDir(t)
	workDir := WorkDir("agent-a")
	writeFile(t, filepath.Join(workDir, "zeta.txt"), "z")
	writeFile(t, filepath.Join(workDir, "alpha.md"), "alpha")
	writeFile(t, filepath.Join(workDir, ".hidden"), "hidden")
	writeFile(t, filepath.Join(workDir, "AGENTS.md"), "runtime instructions")
	writeFile(t, filepath.Join(workDir, "node_modules", "pkg.js"), "module")
	writeFile(t, filepath.Join(workDir, "beta", "nested.txt"), "nested")
	writeFile(t, filepath.Join(workDir, "aardvark", "nested.txt"), "nested")

	files, err := ListDir("agent-a", "")
	require.NoError(t, err)
	require.Equal(t, []string{"aardvark", "beta", "alpha.md", "zeta.txt"}, fileNames(files))
	require.True(t, files[0].IsDirectory)
	require.True(t, files[1].IsDirectory)
	require.False(t, files[2].IsDirectory)
	require.Equal(t, int64(len("alpha")), files[2].Size)
	require.NotEmpty(t, files[2].ModifiedAt)
	require.Equal(t, 0, files[1].ChildDirCount)
	require.Equal(t, 1, files[1].ChildFileCount)

	nested, err := ListDir("agent-a", "beta")
	require.NoError(t, err)
	require.Equal(t, []string{"nested.txt"}, fileNames(nested))
	require.Equal(t, filepath.Join("beta", "nested.txt"), nested[0].Path)
}

func TestListTargetDirUsesExplicitTargetAndAllowsAgentFileName(t *testing.T) {
	setupConfigDir(t)
	target := t.TempDir()
	writeFile(t, filepath.Join(target, "AGENTS.md"), "visible for explicit target")
	writeFile(t, filepath.Join(target, "sub", "file.txt"), "content")

	files, err := ListTargetDir("ignored-agent", target, "")
	require.NoError(t, err)
	require.Equal(t, []string{"sub", "AGENTS.md"}, fileNames(files))

	missing, err := ListTargetDir("ignored-agent", target, "missing")
	require.NoError(t, err)
	require.Empty(t, missing)
}

func TestListTargetDirPageExpandsTildeAndReturnsRootPath(t *testing.T) {
	setupConfigDir(t)
	home := t.TempDir()
	t.Setenv("HOME", home)
	if runtime.GOOS == "windows" {
		t.Setenv("USERPROFILE", home)
	}
	writeFile(t, filepath.Join(home, "agentteam", "main.go"), "package main")

	files, page, err := ListTargetDirPage("ignored-agent", "~/agentteam", "", ListOptions{Limit: 10})
	require.NoError(t, err)
	require.Equal(t, filepath.Join(home, "agentteam"), page.RootPath)
	require.Equal(t, []string{"main.go"}, fileNames(files))
}

func TestListTargetDirPageReturnsBoundedPages(t *testing.T) {
	setupConfigDir(t)
	workDir := WorkDir("agent-page")
	for _, name := range []string{"c.txt", "a.txt", "b.txt"} {
		writeFile(t, filepath.Join(workDir, name), name)
	}

	first, page, err := ListTargetDirPage("agent-page", "", "", ListOptions{Limit: 2})
	require.NoError(t, err)
	require.Len(t, first, 2)
	require.Equal(t, 2, page.Limit)
	require.True(t, page.HasMore)
	require.Equal(t, "2", page.NextCursor)

	second, page, err := ListTargetDirPage("agent-page", "", "", ListOptions{Limit: 2, Cursor: page.NextCursor})
	require.NoError(t, err)
	require.Len(t, second, 1)
	require.False(t, page.HasMore)
	require.Empty(t, page.NextCursor)

	names := append(fileNames(first), fileNames(second)...)
	sort.Strings(names)
	require.Equal(t, []string{"a.txt", "b.txt", "c.txt"}, names)
}

func TestReadTargetFileTextBinaryDirectoryAndSizeBoundaries(t *testing.T) {
	setupConfigDir(t)
	workDir := WorkDir("agent-read")
	writeFile(t, filepath.Join(workDir, "notes.md"), "hello")
	writeFile(t, filepath.Join(workDir, "image.png"), string([]byte{0, 1, 2, 3}))
	require.NoError(t, os.MkdirAll(filepath.Join(workDir, "folder"), 0755))
	writeFile(t, filepath.Join(workDir, "large.txt"), strings.Repeat("x", maxWorkspaceFileSize+1))

	content, binary, err := ReadTargetFile("agent-read", "", "notes.md")
	require.NoError(t, err)
	require.False(t, binary)
	require.Equal(t, "hello", content)

	content, binary, err = ReadTargetFile("agent-read", "", "image.png")
	require.NoError(t, err)
	require.True(t, binary)
	require.Empty(t, content)

	_, _, err = ReadTargetFile("agent-read", "", "folder")
	require.ErrorContains(t, err, "cannot read a directory")

	_, _, err = ReadTargetFile("agent-read", "", "large.txt")
	require.ErrorContains(t, err, "file too large")
}

func TestApplySeedFilesWritesNestedFilesAndRejectsInvalidPaths(t *testing.T) {
	setupConfigDir(t)

	err := ApplySeedFiles("agent-seed", []types.WorkspaceSeedFile{
		{Path: "README.md", Content: "root"},
		{Path: "dir/config.json", Content: `{"ok":true}`},
	})
	require.NoError(t, err)
	content, binary, err := ReadTargetFile("agent-seed", "", "dir/config.json")
	require.NoError(t, err)
	require.False(t, binary)
	require.Equal(t, `{"ok":true}`, content)

	require.ErrorContains(t, ApplySeedFiles("agent-seed", []types.WorkspaceSeedFile{{Path: "   ", Content: "x"}}), "seed file path is required")
	require.ErrorContains(t, ApplySeedFiles("agent-seed", []types.WorkspaceSeedFile{{Path: "../escape.txt", Content: "x"}}), "path traversal")
}

func TestApplySeedFilesRejectsOversizedInputWithoutPartialWrites(t *testing.T) {
	setupConfigDir(t)

	tooMany := make([]types.WorkspaceSeedFile, maxSeedFiles+1)
	for i := range tooMany {
		tooMany[i] = types.WorkspaceSeedFile{Path: filepath.Join("files", string(rune('a'+i))), Content: "x"}
	}
	require.ErrorContains(t, ApplySeedFiles("agent-seed-cap", tooMany), "seed files has")

	err := ApplySeedFiles("agent-seed-cap", []types.WorkspaceSeedFile{
		{Path: "first.txt", Content: "first"},
		{Path: "large.txt", Content: strings.Repeat("x", maxSeedFileBytes+1)},
	})
	require.ErrorContains(t, err, "exceeds")
	_, statErr := os.Stat(filepath.Join(WorkDir("agent-seed-cap"), "first.txt"))
	require.True(t, os.IsNotExist(statErr), "first file should not be written before seed validation fails")

	err = ApplySeedFiles("agent-seed-cap", []types.WorkspaceSeedFile{
		{Path: "first.txt", Content: "first"},
		{Path: "../escape.txt", Content: "x"},
	})
	require.ErrorContains(t, err, "path traversal")
	_, statErr = os.Stat(filepath.Join(WorkDir("agent-seed-cap"), "first.txt"))
	require.True(t, os.IsNotExist(statErr), "first file should not be written before path validation fails")

	totalTooLarge := make([]types.WorkspaceSeedFile, 5)
	for i := range totalTooLarge {
		totalTooLarge[i] = types.WorkspaceSeedFile{
			Path:    filepath.Join("big", string(rune('a'+i))),
			Content: strings.Repeat("x", maxSeedFileBytes),
		}
	}
	require.ErrorContains(t, ApplySeedFiles("agent-seed-cap", totalTooLarge), "total bytes")

	require.ErrorContains(t, ApplySeedFiles("agent-seed-cap", []types.WorkspaceSeedFile{{
		Path:    strings.Repeat("x", maxSeedFilePathRunes+1),
		Content: "x",
	}}), "path[0] exceeds")
}

func TestDeletePathRemovesFilesAndDirectoriesWithTraversalGuard(t *testing.T) {
	setupConfigDir(t)
	workDir := WorkDir("agent-delete")
	writeFile(t, filepath.Join(workDir, "remove.txt"), "bye")
	writeFile(t, filepath.Join(workDir, "dir", "nested.txt"), "bye")

	require.NoError(t, DeletePath("agent-delete", "remove.txt"))
	_, err := os.Stat(filepath.Join(workDir, "remove.txt"))
	require.True(t, os.IsNotExist(err))

	require.NoError(t, DeletePath("agent-delete", "dir"))
	_, err = os.Stat(filepath.Join(workDir, "dir"))
	require.True(t, os.IsNotExist(err))

	require.ErrorContains(t, DeletePath("agent-delete", ""), "workspace path is required")
	require.ErrorContains(t, DeletePath("agent-delete", "../outside"), "path traversal")
}

func TestDeleteDirOnlyRemovesNamedAgentWorkspace(t *testing.T) {
	setupConfigDir(t)
	writeFile(t, filepath.Join(WorkDir("agent-gone"), "file.txt"), "bye")

	deleted, err := DeleteDir("agent-gone")
	require.NoError(t, err)
	require.True(t, deleted)
	_, err = os.Stat(WorkDir("agent-gone"))
	require.True(t, os.IsNotExist(err))

	for _, invalid := range []string{"../agent", "parent/child", `parent\child`, "agent..name"} {
		t.Run(invalid, func(t *testing.T) {
			deleted, err := DeleteDir(invalid)
			require.False(t, deleted)
			require.ErrorContains(t, err, "invalid directory name")
		})
	}
}

func TestScanAllSummarizesAgentDirectoriesSortedOneLevelOnly(t *testing.T) {
	setupConfigDir(t)
	writeFile(t, filepath.Join(WorkDir("b-agent"), "b.txt"), "bb")
	writeFile(t, filepath.Join(WorkDir("a-agent"), "a.txt"), "aaa")
	writeFile(t, filepath.Join(WorkDir("a-agent"), "nested", "ignored.txt"), "ignored")
	writeFile(t, filepath.Join(agentsBaseDir(), "not-a-dir.txt"), "ignored")

	infos, err := ScanAll()
	require.NoError(t, err)
	require.Len(t, infos, 2)
	require.Equal(t, "a-agent", infos[0].DirectoryName)
	require.Equal(t, int64(3), infos[0].TotalSizeBytes)
	require.Equal(t, int64(1), infos[0].FileCount)
	require.NotEmpty(t, infos[0].LastModified)
	require.Equal(t, "b-agent", infos[1].DirectoryName)
	require.Equal(t, int64(2), infos[1].TotalSizeBytes)
	require.Equal(t, int64(1), infos[1].FileCount)
}

func TestScanAllPageOnlySummarizesReturnedDirectoryPage(t *testing.T) {
	setupConfigDir(t)
	writeFile(t, filepath.Join(WorkDir("a-agent"), "a.txt"), "aaa")
	writeFile(t, filepath.Join(WorkDir("b-agent"), "b.txt"), "bb")
	writeFile(t, filepath.Join(WorkDir("c-agent"), "c.txt"), "c")

	first, page, err := ScanAllPage(ListOptions{Limit: 2})
	require.NoError(t, err)
	require.Len(t, first, 2)
	require.True(t, page.HasMore)
	require.Equal(t, "2", page.NextCursor)

	second, page, err := ScanAllPage(ListOptions{Limit: 2, Cursor: page.NextCursor})
	require.NoError(t, err)
	require.Len(t, second, 1)
	require.False(t, page.HasMore)

	names := append(dirNames(first), dirNames(second)...)
	sort.Strings(names)
	require.Equal(t, []string{"a-agent", "b-agent", "c-agent"}, names)
}

func TestScanAllMissingAgentsDirReturnsEmpty(t *testing.T) {
	setupConfigDir(t)

	infos, err := ScanAll()
	require.NoError(t, err)
	require.Empty(t, infos)
}

func TestScanDirOneLevelCountsFilesAndTracksDirectoryModTime(t *testing.T) {
	dir := t.TempDir()
	writeFile(t, filepath.Join(dir, "a.txt"), "a")
	writeFile(t, filepath.Join(dir, "bb.txt"), "bb")
	subdir := filepath.Join(dir, "subdir")
	require.NoError(t, os.MkdirAll(subdir, 0755))
	subdirMod := time.Now().Add(2 * time.Hour).Truncate(time.Second)
	require.NoError(t, os.Chtimes(subdir, subdirMod, subdirMod))

	lastMod, totalSize, fileCount := ScanDirOneLevel(dir)
	require.Equal(t, int64(3), totalSize)
	require.Equal(t, int64(2), fileCount)
	require.True(t, lastMod.Equal(subdirMod) || lastMod.After(subdirMod.Add(-time.Second)), "lastMod = %s, want near/after %s", lastMod, subdirMod)

	lastMod, totalSize, fileCount = ScanDirOneLevel(filepath.Join(dir, "missing"))
	require.True(t, lastMod.IsZero())
	require.Zero(t, totalSize)
	require.Zero(t, fileCount)
}

func TestSymlinkEscapesAreRejected(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("symlink creation requires elevated privileges on some Windows setups")
	}
	setupConfigDir(t)
	workDir := WorkDir("agent-link")
	outside := t.TempDir()
	writeFile(t, filepath.Join(outside, "secret.txt"), "secret")
	require.NoError(t, os.MkdirAll(workDir, 0755))
	link := filepath.Join(workDir, "outside")
	if err := os.Symlink(outside, link); err != nil {
		if errors.Is(err, os.ErrPermission) {
			t.Skipf("cannot create symlink: %v", err)
		}
		require.NoError(t, err)
	}

	_, _, err := ReadTargetFile("agent-link", "", filepath.Join("outside", "secret.txt"))
	require.ErrorContains(t, err, "path traversal detected through symlink")

	err = ApplySeedFiles("agent-link", []types.WorkspaceSeedFile{{Path: filepath.Join("outside", "new.txt"), Content: "nope"}})
	require.ErrorContains(t, err, "path traversal detected through symlink")

	err = DeletePath("agent-link", filepath.Join("outside", "secret.txt"))
	require.ErrorContains(t, err, "path traversal detected through symlink")
}

func TestListTargetDirRejectsTraversalAndPropagatesReadDirErrors(t *testing.T) {
	setupConfigDir(t)
	workDir := WorkDir("agent-list-errors")
	writeFile(t, filepath.Join(workDir, "file.txt"), "content")

	_, err := ListDir("agent-list-errors", "../outside")
	require.ErrorContains(t, err, "path traversal")

	_, err = ListDir("agent-list-errors", "file.txt")
	require.Error(t, err)
}

func TestReadTargetFileRejectsTraversalAndMissingFiles(t *testing.T) {
	setupConfigDir(t)

	_, _, err := ReadTargetFile("agent-read-errors", "", "../outside.txt")
	require.ErrorContains(t, err, "path traversal")

	_, _, err = ReadTargetFile("agent-read-errors", "", "missing.txt")
	require.Error(t, err)
}

func TestScanAllPropagatesReadDirErrors(t *testing.T) {
	configDir := setupConfigDir(t)
	writeFile(t, filepath.Join(configDir, "agents"), "not a directory")

	infos, err := ScanAll()
	require.Error(t, err)
	require.Nil(t, infos)
}

func TestApplySeedFilesPropagatesWriteErrors(t *testing.T) {
	setupConfigDir(t)
	workDir := WorkDir("agent-seed-error")
	require.NoError(t, os.MkdirAll(filepath.Join(workDir, "already-dir"), 0755))

	err := ApplySeedFiles("agent-seed-error", []types.WorkspaceSeedFile{{Path: "already-dir", Content: "not a dir"}})
	require.Error(t, err)
}

func fileNames(files []File) []string {
	names := make([]string, 0, len(files))
	for _, file := range files {
		names = append(names, file.Name)
	}
	return names
}

func dirNames(dirs []DirInfo) []string {
	names := make([]string, 0, len(dirs))
	for _, dir := range dirs {
		names = append(names, dir.DirectoryName)
	}
	return names
}
