package modes

import (
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"
	"time"
)

func TestExpandFileChipsAtPickerShape(t *testing.T) {
	cwd := filepath.Join(string(filepath.Separator), "repo")
	in := "read [file:README.md] and [dir:docs/]"
	want := "read " + filepath.Join(cwd, "README.md") + " and " + filepath.Join(cwd, "docs")
	if got := expandFileChips(in, cwd); got != want {
		t.Fatalf("expandFileChips() = %q, want %q", got, want)
	}
}

func TestExpandFileChipsLeavesEditorPlaceholderShapeAlone(t *testing.T) {
	cwd := filepath.Join(string(filepath.Separator), "repo")
	// tui.Editor.SubmitValue should expand [file:N:name] using its
	// private path map before modes see the text. If such a token leaks
	// through, modes must not guess that "1:foo.txt" is a relative path.
	in := "read [file:1:foo.txt]"
	if got := expandFileChips(in, cwd); got != in {
		t.Fatalf("editor placeholder was changed: %q", got)
	}
}

// TestFileSuggesterPicksUpNewEntries pins the cache-invalidation bug
// fix: creating a subdirectory after the picker has already scanned
// the cwd must surface that subdirectory on the next scan, without
// any explicit invalidation call. The cache is keyed on the dir's
// mtime, which the OS bumps on every entry add/remove/rename.
func TestFileSuggesterPicksUpNewEntries(t *testing.T) {
	tmp := t.TempDir()
	if err := os.WriteFile(filepath.Join(tmp, "existing.txt"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}

	s := newFileSuggester()
	s.SetCWD(tmp)

	first := s.scan()
	if !containsEntry(first, "existing.txt", false) {
		t.Fatalf("first scan missing existing.txt: %#v", first)
	}
	if containsEntry(first, "test", true) {
		t.Fatalf("first scan unexpectedly saw the not-yet-created test/: %#v", first)
	}

	// Sleep one filesystem tick: HFS+/APFS/ext4 all bump directory
	// mtime with at-least 1s resolution depending on mount options.
	// Without this sleep on coarse-resolution filesystems the mtime
	// after Mkdir can equal the mtime captured during the first scan
	// and the cache would (correctly) be retained.
	time.Sleep(1100 * time.Millisecond)
	if err := os.Mkdir(filepath.Join(tmp, "test"), 0o755); err != nil {
		t.Fatal(err)
	}

	second := s.scan()
	if !containsEntry(second, "test", true) {
		t.Fatalf("second scan did not pick up the newly created test/: %#v", second)
	}
	if !containsEntry(second, "existing.txt", false) {
		t.Fatalf("second scan dropped existing.txt: %#v", second)
	}

	// Directories sort before files.
	sorted := make([]string, 0, len(second))
	for _, e := range second {
		sorted = append(sorted, e.name)
	}
	if !sort.IsSorted(byDirsFirst(second)) {
		t.Fatalf("entries are not dirs-first / case-insensitive sorted: %v", sorted)
	}
}

func containsEntry(entries []fileEntry, name string, isDir bool) bool {
	for _, e := range entries {
		if e.name == name && e.isDir == isDir {
			return true
		}
	}
	return false
}

type byDirsFirst []fileEntry

func (b byDirsFirst) Len() int      { return len(b) }
func (b byDirsFirst) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
func (b byDirsFirst) Less(i, j int) bool {
	if b[i].isDir != b[j].isDir {
		return b[i].isDir
	}
	return b[i].name < b[j].name
}

// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with
// a fuzzy subsequence match rather than a plain substring, so a
// non-contiguous pattern like "fsg" still finds "file_suggest.go".
func TestFileSuggesterFuzzyMatch(t *testing.T) {
	tmp := t.TempDir()
	for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} {
		if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	s := newFileSuggester()
	s.SetCWD(tmp)

	got := s.matches("@fsg")
	if !containsEntry(got, "file_suggest.go", false) {
		t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got)
	}
	if len(got) == 0 || got[0].name != "file_suggest.go" {
		t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got)
	}
}

// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
// tree and matches against the cwd-relative path, so a pattern can
// span directory boundaries.
func TestFileSuggesterRecursiveMatch(t *testing.T) {
	tmp := t.TempDir()
	if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	s := newFileSuggester()
	s.SetCWD(tmp)
	s.SetRecursive(true)

	rel := filepath.Join("src", "foo", "bar.go")
	got := s.matches("@foobar")
	if !containsEntry(got, rel, false) {
		t.Fatalf("recursive @foobar did not match %s: %#v", rel, got)
	}
}

// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes
// directories like .git that would otherwise dominate the budget.
func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
	tmp := t.TempDir()
	if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	s := newFileSuggester()
	s.SetCWD(tmp)
	s.SetRecursive(true)

	all := s.scan()
	for _, e := range all {
		if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
			t.Fatalf("recursive scan descended into .git: %#v", e)
		}
	}
	if !containsEntry(all, "main.go", false) {
		t.Fatalf("recursive scan missing main.go: %#v", all)
	}
}

// TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk
// prunes anything listed in the project's root .gitignore — build
// outputs, dependency dirs, and IaC tool caches like
// .terraform/.terragrunt-cache — while still surfacing tracked files.
func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) {
	tmp := t.TempDir()
	if err := os.WriteFile(filepath.Join(tmp, ".gitignore"),
		[]byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	ignored := []string{".terraform", ".terragrunt-cache", "node_modules"}
	for _, dir := range ignored {
		nested := filepath.Join(tmp, dir, "deep")
		if err := os.MkdirAll(nested, 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	s := newFileSuggester()
	s.SetCWD(tmp)
	s.SetRecursive(true)

	all := s.scan()
	for _, e := range all {
		for _, skip := range ignored {
			if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) {
				t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e)
			}
		}
		if e.rel == "debug.log" {
			t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e)
		}
	}
	if !containsEntry(all, "main.tf", false) {
		t.Fatalf("recursive scan missing tracked main.tf: %#v", all)
	}
}

// TestFileSuggesterFlatModeHonorsGitignore verifies the default
// directory-by-directory browse also hides gitignored entries and
// always hides .git.
func TestFileSuggesterFlatModeHonorsGitignore(t *testing.T) {
	tmp := t.TempDir()
	if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("node_modules/\n*.log\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	s := newFileSuggester()
	s.SetCWD(tmp) // flat mode, respectGitignore on by default

	all := s.scan()
	if containsEntry(all, "node_modules", true) {
		t.Fatal("flat scan surfaced gitignored node_modules/")
	}
	if containsEntry(all, ".git", true) {
		t.Fatal("flat scan surfaced .git/")
	}
	if containsEntry(all, "debug.log", false) {
		t.Fatal("flat scan surfaced gitignored *.log file")
	}
	if !containsEntry(all, "main.go", false) {
		t.Fatalf("flat scan missing tracked main.go: %#v", all)
	}
	// .gitignore itself is not ignored, so it should remain visible.
	if !containsEntry(all, ".gitignore", false) {
		t.Fatalf("flat scan missing .gitignore: %#v", all)
	}
}

// TestFileSuggesterRespectGitignoreToggle verifies disabling the
// setting surfaces gitignored entries in both modes (while .git stays
// hidden in recursive mode to protect the entry budget).
func TestFileSuggesterRespectGitignoreToggle(t *testing.T) {
	tmp := t.TempDir()
	if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil {
		t.Fatal(err)
	}
	s := newFileSuggester()
	s.SetCWD(tmp)

	// On by default: dist/ hidden.
	if containsEntry(s.scan(), "dist", true) {
		t.Fatal("dist/ should be hidden while respectGitignore is on")
	}
	// Toggle off: dist/ visible (flat mode).
	s.SetRespectGitignore(false)
	if !containsEntry(s.scan(), "dist", true) {
		t.Fatal("dist/ should be visible after disabling respectGitignore (flat)")
	}
	// And in recursive mode.
	s.SetRecursive(true)
	if !containsEntry(s.scan(), "dist", true) {
		t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)")
	}
}

// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
// cached scan so the next matches() reflects the new mode.
func TestFileSuggesterToggleResetsCache(t *testing.T) {
	tmp := t.TempDir()
	if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	s := newFileSuggester()
	s.SetCWD(tmp)

	rel := filepath.Join("pkg", "nested.go")
	if containsEntry(s.matches("@nested"), rel, false) {
		t.Fatal("flat mode unexpectedly saw nested.go")
	}
	s.SetRecursive(true)
	if !containsEntry(s.matches("@nested"), rel, false) {
		t.Fatal("recursive mode did not surface nested.go after toggle")
	}
}
