package indexer

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

	"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"
)

// TestReconcileRepoCtx_EvictsOfflineDeletions simulates the exact B2
// scenario: daemon indexes a repo, saves its mtimes to a "snapshot",
// a file is deleted while the daemon is down, daemon restarts and
// reconciles via ReconcileRepoCtx. After reconcile, the deleted file's
// nodes must be absent from the graph.
func TestReconcileRepoCtx_EvictsOfflineDeletions(t *testing.T) {
	dir := t.TempDir()
	repoPath := filepath.Join(dir, "repo")
	require.NoError(t, os.MkdirAll(repoPath, 0o755))
	writeFile(t, filepath.Join(repoPath, "a.go"), "package main\nfunc Alpha() {}\n")
	writeFile(t, filepath.Join(repoPath, "b.go"), "package main\nfunc Beta() {}\n")

	cfgPath := filepath.Join(dir, "config.yaml")
	gc := &config.GlobalConfig{Repos: []config.RepoEntry{{Path: repoPath, Name: "repo"}}}
	gc.SetConfigPath(cfgPath)
	require.NoError(t, gc.Save())
	cm, err := config.NewConfigManager(cfgPath)
	require.NoError(t, err)

	// First "daemon run": index the repo, capture mtimes as if we were
	// writing a snapshot.
	g := graph.New()
	mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
	_, err = mi.IndexAll()
	require.NoError(t, err)

	meta := mi.GetMetadata("repo")
	require.NotNil(t, meta)
	priorMtimes := meta.FileMtimes

	// Before we "restart", delete b.go from disk. This mirrors the
	// user editing offline while the daemon is stopped.
	require.NoError(t, os.Remove(filepath.Join(repoPath, "b.go")))

	// Locate nodes for b.go before reconciliation — they exist in
	// the graph since the first pass indexed it.
	assert.NotEmpty(t, g.GetFileNodes("b.go"), "b.go nodes must exist pre-reconcile")

	// Second "daemon run": fresh MultiIndexer, graph already populated
	// from the "snapshot", reconcile with prior mtimes.
	mi2 := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
	_, err = mi2.ReconcileRepoCtx(context.Background(), config.RepoEntry{Path: repoPath, Name: "repo"}, priorMtimes)
	require.NoError(t, err)

	// The deleted file's nodes must be evicted — that's B2's contract.
	assert.Empty(t, g.GetFileNodes("b.go"),
		"offline-deleted file's nodes must be evicted by reconciliation (B2)")
	// The surviving file's nodes must still be present.
	assert.NotEmpty(t, g.GetFileNodes("a.go"),
		"unchanged file's nodes must survive reconciliation")
}

// TestReconcileRepoCtx_DoesNotDuplicateUnchanged is the B1 companion:
// reconciling a repo whose files haven't changed must be a no-op on the
// graph — no new nodes, no new edges, no duplicated secondary-index
// entries. Before Phase 1, the same scenario ran IndexCtx on top of a
// warm graph and doubled edges.
func TestReconcileRepoCtx_DoesNotDuplicateUnchanged(t *testing.T) {
	dir := t.TempDir()
	repoPath := filepath.Join(dir, "repo")
	require.NoError(t, os.MkdirAll(repoPath, 0o755))
	writeFile(t, filepath.Join(repoPath, "a.go"), "package main\nfunc Alpha() {}\nfunc Beta() {}\n")

	cfgPath := filepath.Join(dir, "config.yaml")
	gc := &config.GlobalConfig{Repos: []config.RepoEntry{{Path: repoPath, Name: "repo"}}}
	gc.SetConfigPath(cfgPath)
	require.NoError(t, gc.Save())
	cm, err := config.NewConfigManager(cfgPath)
	require.NoError(t, err)

	g := graph.New()
	mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
	_, err = mi.IndexAll()
	require.NoError(t, err)

	want := g.Stats()
	priorMtimes := mi.GetMetadata("repo").FileMtimes

	// Simulate restart: fresh MultiIndexer on the same graph, reconcile.
	mi2 := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
	_, err = mi2.ReconcileRepoCtx(context.Background(), config.RepoEntry{Path: repoPath, Name: "repo"}, priorMtimes)
	require.NoError(t, err)

	got := g.Stats()
	assert.Equal(t, want.TotalNodes, got.TotalNodes,
		"reconciling unchanged files must not grow nodes")
	assert.Equal(t, want.TotalEdges, got.TotalEdges,
		"reconciling unchanged files must not grow edges (B1 regression)")
}

// TestReconcileAll_CatchesJanitorTargets runs ReconcileAll directly —
// the entry point the daemon's periodic janitor calls. Tests the same
// B2 invariant but through the public janitor API rather than the
// warmup path: once a file is deleted on disk, the next ReconcileAll
// must reflect that in the graph.
func TestReconcileAll_CatchesJanitorTargets(t *testing.T) {
	dir := t.TempDir()
	repoPath := filepath.Join(dir, "repo")
	require.NoError(t, os.MkdirAll(repoPath, 0o755))
	writeFile(t, filepath.Join(repoPath, "keep.go"), "package main\nfunc Keep() {}\n")
	writeFile(t, filepath.Join(repoPath, "drop.go"), "package main\nfunc Drop() {}\n")

	cfgPath := filepath.Join(dir, "config.yaml")
	gc := &config.GlobalConfig{Repos: []config.RepoEntry{{Path: repoPath, Name: "repo"}}}
	gc.SetConfigPath(cfgPath)
	require.NoError(t, gc.Save())
	cm, err := config.NewConfigManager(cfgPath)
	require.NoError(t, err)

	g := graph.New()
	mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
	_, err = mi.IndexAll()
	require.NoError(t, err)

	require.NotEmpty(t, g.GetFileNodes("drop.go"))

	// Simulate an edit the watcher missed: delete drop.go on disk
	// without routing through IndexFile.
	require.NoError(t, os.Remove(filepath.Join(repoPath, "drop.go")))

	mi.ReconcileAll()

	assert.Empty(t, g.GetFileNodes("drop.go"),
		"janitor must evict files deleted outside the watcher path")
	assert.NotEmpty(t, g.GetFileNodes("keep.go"),
		"janitor must not disturb unchanged files")
}
