package indexer

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

// TestIncrementalReindexPaths_EmptyPathsFallsBackToWholeRoot verifies
// that passing no paths makes IncrementalReindexPaths behave exactly
// like a whole-root IncrementalReindex: a changed file anywhere in the
// tree is picked up.
func TestIncrementalReindexPaths_EmptyPathsFallsBackToWholeRoot(t *testing.T) {
	dir := t.TempDir()
	require.NoError(t, os.MkdirAll(filepath.Join(dir, "pkg"), 0o755))
	writeFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc Main() {}\n")
	writeFile(t, filepath.Join(dir, "pkg", "util.go"), "package pkg\n\nfunc Util() {}\n")

	g := graph.New()
	idx := newTestIndexer(g)
	_, err := idx.Index(dir)
	require.NoError(t, err)

	bumpMtime(t, filepath.Join(dir, "pkg", "util.go"),
		"package pkg\n\nfunc Util() {}\n\nfunc UtilTwo() {}\n")

	res, err := idx.IncrementalReindexPaths(dir, nil)
	require.NoError(t, err)
	require.NotNil(t, res)
	assert.Equal(t, 1, res.StaleFileCount,
		"empty paths must scan the whole root and pick up the changed file")
	assert.NotEmpty(t, g.FindNodesByName("UtilTwo"),
		"the whole-root pass must re-index the changed file")
}

// TestIncrementalReindexPaths_ScopesToDirectory checks that a directory
// path scopes the pass: a stale file inside the scoped directory is
// re-indexed, while a stale file outside it is left untouched.
func TestIncrementalReindexPaths_ScopesToDirectory(t *testing.T) {
	dir := t.TempDir()
	require.NoError(t, os.MkdirAll(filepath.Join(dir, "in"), 0o755))
	require.NoError(t, os.MkdirAll(filepath.Join(dir, "out"), 0o755))
	writeFile(t, filepath.Join(dir, "in", "a.go"), "package in\n\nfunc A() {}\n")
	writeFile(t, filepath.Join(dir, "out", "b.go"), "package out\n\nfunc B() {}\n")

	g := graph.New()
	idx := newTestIndexer(g)
	_, err := idx.Index(dir)
	require.NoError(t, err)

	// Make BOTH files stale, then scope the reindex to the "in" dir.
	bumpMtime(t, filepath.Join(dir, "in", "a.go"),
		"package in\n\nfunc A() {}\n\nfunc AScoped() {}\n")
	bumpMtime(t, filepath.Join(dir, "out", "b.go"),
		"package out\n\nfunc B() {}\n\nfunc BUnscoped() {}\n")

	res, err := idx.IncrementalReindexPaths(dir, []string{filepath.Join(dir, "in")})
	require.NoError(t, err)
	assert.Equal(t, 1, res.StaleFileCount,
		"only the file inside the scoped directory should be re-indexed")
	assert.NotEmpty(t, g.FindNodesByName("AScoped"),
		"the scoped file's new symbol must be in the graph")
	assert.Empty(t, g.FindNodesByName("BUnscoped"),
		"a file outside the scoped directory must NOT be re-indexed")
}

// TestIncrementalReindexPaths_RelativePathScope verifies that scoped
// paths may be supplied relative to the repository root, not just
// absolute.
func TestIncrementalReindexPaths_RelativePathScope(t *testing.T) {
	dir := t.TempDir()
	require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o755))
	writeFile(t, filepath.Join(dir, "sub", "c.go"), "package sub\n\nfunc C() {}\n")
	writeFile(t, filepath.Join(dir, "root.go"), "package main\n\nfunc Root() {}\n")

	g := graph.New()
	idx := newTestIndexer(g)
	_, err := idx.Index(dir)
	require.NoError(t, err)

	bumpMtime(t, filepath.Join(dir, "sub", "c.go"),
		"package sub\n\nfunc C() {}\n\nfunc CRel() {}\n")
	bumpMtime(t, filepath.Join(dir, "root.go"),
		"package main\n\nfunc Root() {}\n\nfunc RootRel() {}\n")

	// "sub" is repo-root-relative.
	res, err := idx.IncrementalReindexPaths(dir, []string{"sub"})
	require.NoError(t, err)
	assert.Equal(t, 1, res.StaleFileCount)
	assert.NotEmpty(t, g.FindNodesByName("CRel"))
	assert.Empty(t, g.FindNodesByName("RootRel"),
		"the root-level file is outside the scoped relative path")
}

// TestIncrementalReindexPaths_ScopesToSingleFile checks that a single
// file path scopes the pass to exactly that file.
func TestIncrementalReindexPaths_ScopesToSingleFile(t *testing.T) {
	dir := t.TempDir()
	writeFile(t, filepath.Join(dir, "one.go"), "package main\n\nfunc One() {}\n")
	writeFile(t, filepath.Join(dir, "two.go"), "package main\n\nfunc Two() {}\n")

	g := graph.New()
	idx := newTestIndexer(g)
	_, err := idx.Index(dir)
	require.NoError(t, err)

	bumpMtime(t, filepath.Join(dir, "one.go"),
		"package main\n\nfunc One() {}\n\nfunc OneEdited() {}\n")
	bumpMtime(t, filepath.Join(dir, "two.go"),
		"package main\n\nfunc Two() {}\n\nfunc TwoEdited() {}\n")

	res, err := idx.IncrementalReindexPaths(dir, []string{filepath.Join(dir, "one.go")})
	require.NoError(t, err)
	assert.Equal(t, 1, res.StaleFileCount)
	assert.Equal(t, 1, res.FileCount,
		"FileCount for a single-file scope is the one in-scope file")
	assert.NotEmpty(t, g.FindNodesByName("OneEdited"))
	assert.Empty(t, g.FindNodesByName("TwoEdited"),
		"the unscoped file must not be re-indexed")
}

// TestIncrementalReindexPaths_EvictsDeletedFileInScope verifies that a
// file deleted from disk under a scoped path is evicted, while a
// deletion outside the scope is left alone.
func TestIncrementalReindexPaths_EvictsDeletedFileInScope(t *testing.T) {
	dir := t.TempDir()
	require.NoError(t, os.MkdirAll(filepath.Join(dir, "in"), 0o755))
	require.NoError(t, os.MkdirAll(filepath.Join(dir, "out"), 0o755))
	inGone := filepath.Join(dir, "in", "gone.go")
	outGone := filepath.Join(dir, "out", "gone.go")
	writeFile(t, inGone, "package in\n\nfunc InGone() {}\n")
	writeFile(t, outGone, "package out\n\nfunc OutGone() {}\n")

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

	require.NoError(t, os.Remove(inGone))
	require.NoError(t, os.Remove(outGone))

	_, err = idx.IncrementalReindexPaths(dir, []string{filepath.Join(dir, "in")})
	require.NoError(t, err)

	assert.Empty(t, g.FindNodesByName("InGone"),
		"a file deleted under the scoped path must be evicted")
	assert.NotEmpty(t, g.FindNodesByName("OutGone"),
		"a deletion outside the scoped path must NOT be evicted by a scoped pass")
}

// TestIncrementalReindexPaths_RejectsPathOutsideRoot verifies that a
// scoped path escaping the repository root is rejected — scoping is a
// narrowing operation, never an escape hatch.
func TestIncrementalReindexPaths_RejectsPathOutsideRoot(t *testing.T) {
	root := t.TempDir()
	outside := t.TempDir()
	writeFile(t, filepath.Join(root, "main.go"), "package main\n\nfunc Main() {}\n")

	g := graph.New()
	idx := newTestIndexer(g)
	_, err := idx.Index(root)
	require.NoError(t, err)

	_, err = idx.IncrementalReindexPaths(root, []string{outside})
	require.Error(t, err, "a path outside the repo root must be rejected")
	assert.Contains(t, err.Error(), "outside repository root")
}

// TestIncrementalReindexPaths_ConvergesForScopedFile is the consistency
// invariant for the scoped path: editing a file and reconciling it with
// a file-scoped IncrementalReindexPaths must leave that file's symbols
// identical to a full index of the same disk state.
func TestIncrementalReindexPaths_ConvergesForScopedFile(t *testing.T) {
	build := func(dir string) {
		writeFile(t, filepath.Join(dir, "main.go"),
			"package main\n\nfunc main() { helper() }\n\nfunc helper() {}\n")
		writeFile(t, filepath.Join(dir, "other.go"),
			"package main\n\nfunc Other() {}\n")
	}

	dirA := t.TempDir()
	build(dirA)
	gA := graph.New()
	idxA := newTestIndexer(gA)
	_, err := idxA.Index(dirA)
	require.NoError(t, err)

	bumpMtime(t, filepath.Join(dirA, "main.go"),
		"package main\n\nfunc main() { helper(); helper() }\n\nfunc helper() {}\n")
	_, err = idxA.IncrementalReindexPaths(dirA, []string{filepath.Join(dirA, "main.go")})
	require.NoError(t, err)

	dirB := t.TempDir()
	build(dirB)
	writeFile(t, filepath.Join(dirB, "main.go"),
		"package main\n\nfunc main() { helper(); helper() }\n\nfunc helper() {}\n")
	gB := graph.New()
	idxB := newTestIndexer(gB)
	_, err = idxB.Index(dirB)
	require.NoError(t, err)

	// main.go's symbols must match between the scoped-incremental and
	// the full-index graphs.
	assert.Len(t, gA.FindNodesByName("main"), len(gB.FindNodesByName("main")))
	assert.Len(t, gA.FindNodesByName("helper"), len(gB.FindNodesByName("helper")))
	assert.NotEmpty(t, gA.FindNodesByName("Other"),
		"a scoped reindex must leave the untouched file's nodes intact")
}
