package indexer

import (
	"context"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/zap"

	"github.com/zzet/gortex/internal/config"
	"github.com/zzet/gortex/internal/graph"
	"github.com/zzet/gortex/internal/search"
)

func runGit(t *testing.T, dir string, args ...string) {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	// Disable any global gpg signing or templates that might be
	// configured on the dev's machine — the test needs reproducible
	// commit behaviour.
	cmd.Env = append(os.Environ(),
		"GIT_CONFIG_GLOBAL=/dev/null",
		"GIT_CONFIG_SYSTEM=/dev/null",
	)
	out, err := cmd.CombinedOutput()
	require.NoErrorf(t, err, "git %v: %s", args, string(out))
}

func testCtx() context.Context { return context.Background() }

// TestGitWatcher_BranchSwitchReconciles is the end-to-end proof that
// the .git/HEAD watcher catches branch switches and dispatches the
// correct evict/index decisions per path. The test creates a real
// git repo with two branches differing in file content, indexes
// branch A, switches to branch B, and asserts the graph reflects B's
// files and not A's. Without this path, the per-file fsnotify watcher
// sees 500 Remove+Create events for a checkout and walks them through
// the per-file path (slow, wrong for renames).
func TestGitWatcher_BranchSwitchReconciles(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git binary not available in PATH")
	}

	repoDir := t.TempDir()
	runGit(t, repoDir, "init", "-q", "-b", "main")
	runGit(t, repoDir, "config", "user.email", "test@example.com")
	runGit(t, repoDir, "config", "user.name", "Test")
	runGit(t, repoDir, "config", "commit.gpgsign", "false")

	// Main branch: a.go defines Alpha.
	writeFile(t, filepath.Join(repoDir, "a.go"), "package main\nfunc Alpha() {}\n")
	runGit(t, repoDir, "add", ".")
	runGit(t, repoDir, "commit", "-q", "-m", "main: Alpha")

	g := graph.New()
	idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
	idx.search = search.NewBM25()
	idx.SetRootPath(repoDir)
	_, err := idx.IndexCtx(testCtx(), repoDir)
	require.NoError(t, err)
	require.NotEmpty(t, g.GetFileNodes("a.go"), "Alpha must be indexed on main")

	// Create a feature branch that replaces a.go with b.go.
	runGit(t, repoDir, "checkout", "-q", "-b", "feature")
	require.NoError(t, os.Remove(filepath.Join(repoDir, "a.go")))
	writeFile(t, filepath.Join(repoDir, "b.go"), "package main\nfunc Beta() {}\n")
	runGit(t, repoDir, "add", "-A")
	runGit(t, repoDir, "commit", "-q", "-m", "feature: Beta replaces Alpha")

	// Return to main so the diff has a clean "old" reference, then
	// start the watcher there and switch to feature. Checking out
	// feature again will move HEAD from main→feature; the watcher
	// observes the move and reconciles.
	runGit(t, repoDir, "checkout", "-q", "main")

	// Ensure the graph reflects main again — simulate a "daemon
	// started on main" by re-indexing explicitly.
	g2 := graph.New()
	idx2 := New(g2, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
	idx2.search = search.NewBM25()
	idx2.SetRootPath(repoDir)
	_, err = idx2.IndexCtx(testCtx(), repoDir)
	require.NoError(t, err)
	require.NotEmpty(t, g2.GetFileNodes("a.go"), "main branch must have Alpha")
	require.Empty(t, g2.GetFileNodes("b.go"), "main branch must not have Beta")

	gw, err := NewGitWatcher(repoDir, idx2, zap.NewNop())
	require.NoError(t, err)
	// Shorter debounce so the test finishes quickly.
	gw.debounce = 50 * time.Millisecond

	drained := make(chan int, 1)
	gw.drained = func(n int) {
		select {
		case drained <- n:
		default:
		}
	}
	require.NoError(t, gw.Start())
	t.Cleanup(func() { _ = gw.Stop() })

	// Switch branches — this is the signal the watcher is supposed
	// to act on.
	runGit(t, repoDir, "checkout", "-q", "feature")

	select {
	case n := <-drained:
		assert.GreaterOrEqual(t, n, 1, "drain must touch at least one file")
	case <-time.After(5 * time.Second):
		t.Fatal("git watcher did not reconcile within timeout")
	}

	// After the reconcile, the graph must reflect the feature
	// branch: Beta present, Alpha gone.
	assert.Empty(t, g2.GetFileNodes("a.go"),
		"after checkout feature, Alpha's file-nodes must be evicted")
	assert.NotEmpty(t, g2.GetFileNodes("b.go"),
		"after checkout feature, Beta must be indexed")
}

// TestGitWatcher_NoopWhenHeadUnchanged covers the "ref file touched
// but commit unchanged" case — e.g., a git gc that packs refs without
// moving any branch. The watcher should read the current SHA, find
// it matches the last seen one, and skip the diff entirely. A naive
// "any event → reconcile" would call git diff with empty ranges and
// log noise without changing the graph.
func TestGitWatcher_NoopWhenHeadUnchanged(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git binary not available in PATH")
	}
	repoDir := t.TempDir()
	runGit(t, repoDir, "init", "-q", "-b", "main")
	runGit(t, repoDir, "config", "user.email", "test@example.com")
	runGit(t, repoDir, "config", "user.name", "Test")
	runGit(t, repoDir, "config", "commit.gpgsign", "false")
	writeFile(t, filepath.Join(repoDir, "a.go"), "package main\n")
	runGit(t, repoDir, "add", ".")
	runGit(t, repoDir, "commit", "-q", "-m", "init")

	g := graph.New()
	idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
	idx.search = search.NewBM25()
	idx.SetRootPath(repoDir)
	_, err := idx.IndexCtx(testCtx(), repoDir)
	require.NoError(t, err)

	gw, err := NewGitWatcher(repoDir, idx, zap.NewNop())
	require.NoError(t, err)
	gw.debounce = 50 * time.Millisecond
	gw.drained = func(int) {
		t.Error("drained must not fire when HEAD didn't move")
	}
	require.NoError(t, gw.Start())
	t.Cleanup(func() { _ = gw.Stop() })

	// Trigger a reconcile manually without moving HEAD — simulates
	// a spurious ref-file touch.
	gw.reconcile("synthetic")

	// Give any unexpected drain a window to fire.
	time.Sleep(200 * time.Millisecond)
}

// TestGitWatcher_DeletedClassification checks the 'D' diff status is
// classified by what is on disk, not by what git tracks: a file gone
// from disk is evicted, but a file removed from tracking yet still on
// disk ("untracked but visible") must stay indexed.
func TestGitWatcher_DeletedClassification(t *testing.T) {
	dir := t.TempDir()
	writeFile(t, filepath.Join(dir, "ondisk.go"), "package main\n\nfunc OnDisk() {}\n")
	writeFile(t, filepath.Join(dir, "gone.go"), "package main\n\nfunc Gone() {}\n")

	g := graph.New()
	idx := newTestIndexer(g)
	_, err := idx.Index(dir)
	require.NoError(t, err)
	require.NotEmpty(t, g.FindNodesByName("OnDisk"))
	require.NotEmpty(t, g.FindNodesByName("Gone"))

	gw, err := NewGitWatcher(dir, idx, zap.NewNop())
	require.NoError(t, err)
	defer func() { _ = gw.Stop() }()

	// gone.go is genuinely removed from disk; ondisk.go merely left
	// git tracking. Both arrive as a 'D' in the diff.
	require.NoError(t, os.Remove(filepath.Join(dir, "gone.go")))
	gw.applyChanges([]gitChange{
		{Status: 'D', Path: "gone.go"},
		{Status: 'D', Path: "ondisk.go"},
	})

	assert.Empty(t, g.FindNodesByName("Gone"),
		"a file gone from disk must be evicted on a 'D'")
	assert.NotEmpty(t, g.FindNodesByName("OnDisk"),
		"a file un-tracked but still on disk must stay indexed on a 'D'")
}

// TestGitWatcher_UntrackedFileStaysIndexed is the end-to-end proof for
// the untracked-but-visible case: `git rm --cached` removes a file
// from tracking but leaves it on disk, the commit moves HEAD, and the
// watcher's diff reports the file as deleted. The reconcile must keep
// the still-present file indexed rather than evict it.
func TestGitWatcher_UntrackedFileStaysIndexed(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git binary not available in PATH")
	}

	repoDir := t.TempDir()
	runGit(t, repoDir, "init", "-q", "-b", "main")
	runGit(t, repoDir, "config", "user.email", "test@example.com")
	runGit(t, repoDir, "config", "user.name", "Test")
	runGit(t, repoDir, "config", "commit.gpgsign", "false")

	writeFile(t, filepath.Join(repoDir, "core.go"), "package main\nfunc Core() {}\n")
	writeFile(t, filepath.Join(repoDir, "generated.go"), "package main\nfunc Generated() {}\n")
	runGit(t, repoDir, "add", ".")
	runGit(t, repoDir, "commit", "-q", "-m", "init")

	g := graph.New()
	idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
	idx.search = search.NewBM25()
	idx.SetRootPath(repoDir)
	_, err := idx.IndexCtx(testCtx(), repoDir)
	require.NoError(t, err)
	require.NotEmpty(t, g.GetFileNodes("generated.go"))

	gw, err := NewGitWatcher(repoDir, idx, zap.NewNop())
	require.NoError(t, err)
	gw.debounce = 50 * time.Millisecond
	drained := make(chan int, 1)
	gw.drained = func(n int) {
		select {
		case drained <- n:
		default:
		}
	}
	require.NoError(t, gw.Start())
	t.Cleanup(func() { _ = gw.Stop() })

	// Stop tracking generated.go but keep it on disk, then commit so
	// HEAD moves and the watcher fires. The diff reports a 'D'.
	runGit(t, repoDir, "rm", "--cached", "-q", "generated.go")
	runGit(t, repoDir, "commit", "-q", "-m", "untrack generated.go")

	select {
	case <-drained:
	case <-time.After(5 * time.Second):
		t.Fatal("git watcher did not reconcile within timeout")
	}

	assert.NotEmpty(t, g.GetFileNodes("generated.go"),
		"a file removed from git tracking but still on disk must stay indexed")
	assert.NotEmpty(t, g.GetFileNodes("core.go"), "core.go is untouched")
}
