// Package edge_accuracy measures the accuracy of tree-sitter (ast_inferred)
// edge extraction by comparing it against go/ast (ast_resolved) edges as
// ground truth.
//
// The test indexes the knowing repo with both extractors and computes:
// - Confirmation rate: what % of ast_inferred edges have a matching ast_resolved edge
// - False positive rate: what % of ast_inferred edges have no match in ast_resolved
// - Miss rate: what % of ast_resolved edges were missed by tree-sitter
//
// Edge matching: two edges match if they have the same source_hash, target_hash,
// and edge_type (provenance differs because extractors tag differently).
package edge_accuracy

import (
	"context"
	"database/sql"
	"fmt"
	"os"
	"strings"
	"testing"

	"github.com/blackwell-systems/knowing/internal/indexer"
	"github.com/blackwell-systems/knowing/internal/indexer/goextractor"
	"github.com/blackwell-systems/knowing/internal/indexer/gotsextractor"
	"github.com/blackwell-systems/knowing/internal/snapshot"
	"github.com/blackwell-systems/knowing/internal/store"
	_ "modernc.org/sqlite"
)

// edgeKey is the matching identity for edges across extractors.
// Two edges "match" if they share the same source, target, and type.
type edgeKey struct {
	SourceHash string
	TargetHash string
	EdgeType   string
}

// edgeStats holds per-edge-type accuracy metrics.
type edgeStats struct {
	TotalInferred int // edges from tree-sitter (ast_inferred)
	TotalResolved int // edges from go/ast (ast_resolved)
	Confirmed     int // edges present in both
	InferredOnly  int // edges only in ast_inferred (potential false positives)
	ResolvedOnly  int // edges only in ast_resolved (misses)
}

func TestEdgeAccuracy(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping edge accuracy benchmark in short mode")
	}

	repoRoot := findRepoRoot(t)
	ctx := context.Background()

	// Phase 1: Index with tree-sitter extractor (ast_inferred edges).
	t.Log("=== Phase 1: Indexing with tree-sitter extractor ===")
	tsDBPath := t.TempDir() + "/treesitter.db"
	tsSt, err := store.NewSQLiteStore(tsDBPath)
	if err != nil {
		t.Fatalf("create tree-sitter store: %v", err)
	}
	defer tsSt.Close()

	tsSnapMgr := snapshot.NewSnapshotManager(tsSt)
	tsIdx := indexer.NewIndexer(tsSt, tsSnapMgr)
	tsIdx.Register(gotsextractor.NewGoTreeSitterExtractor())

	_, err = tsIdx.IndexRepo(ctx, "github.com/blackwell-systems/knowing", repoRoot, "HEAD")
	if err != nil {
		t.Fatalf("tree-sitter index: %v", err)
	}

	// Phase 2: Index with go/ast extractor (ast_resolved edges).
	t.Log("=== Phase 2: Indexing with go/ast extractor ===")
	goDBPath := t.TempDir() + "/goast.db"
	goSt, err := store.NewSQLiteStore(goDBPath)
	if err != nil {
		t.Fatalf("create go/ast store: %v", err)
	}
	defer goSt.Close()

	goSnapMgr := snapshot.NewSnapshotManager(goSt)
	goIdx := indexer.NewIndexer(goSt, goSnapMgr)
	goIdx.Register(goextractor.NewGoExtractor())

	_, err = goIdx.IndexRepo(ctx, "github.com/blackwell-systems/knowing", repoRoot, "HEAD")
	if err != nil {
		t.Fatalf("go/ast index: %v", err)
	}

	// Phase 3: Query all edges from both DBs.
	t.Log("=== Phase 3: Comparing edges ===")
	tsEdges := queryAllEdges(t, tsDBPath)
	goEdges := queryAllEdges(t, goDBPath)

	t.Logf("  Tree-sitter edges (ast_inferred): %d", len(tsEdges))
	t.Logf("  Go/ast edges (ast_resolved): %d", len(goEdges))

	// Phase 4: Compute accuracy metrics.
	// Build sets keyed by (source_hash, target_hash, edge_type).
	tsSet := make(map[edgeKey]bool, len(tsEdges))
	for _, e := range tsEdges {
		tsSet[e] = true
	}

	goSet := make(map[edgeKey]bool, len(goEdges))
	for _, e := range goEdges {
		goSet[e] = true
	}

	// Overall stats.
	overall := computeStats(tsSet, goSet)

	// Per-edge-type stats.
	edgeTypes := []string{"calls", "imports", "implements", "references"}
	byType := make(map[string]*edgeStats)
	for _, et := range edgeTypes {
		tsSubset := filterByType(tsSet, et)
		goSubset := filterByType(goSet, et)
		byType[et] = computeStats(tsSubset, goSubset)
	}

	// Phase 5: Report results.
	t.Log("")
	t.Log("=== Edge Accuracy Results ===")
	t.Log("")
	reportStats(t, "OVERALL", overall)
	t.Log("")
	for _, et := range edgeTypes {
		stats := byType[et]
		if stats.TotalInferred > 0 || stats.TotalResolved > 0 {
			reportStats(t, strings.ToUpper(et), stats)
		}
	}

	// Sanity check: tree-sitter should find at least some edges.
	if overall.TotalInferred == 0 {
		t.Fatalf("tree-sitter found zero edges; indexing may have failed")
	}
	if overall.TotalResolved == 0 {
		t.Fatalf("go/ast found zero edges; indexing may have failed")
	}

	// Log the confirmation rate prominently.
	confirmRate := float64(overall.Confirmed) / float64(overall.TotalInferred) * 100
	t.Logf("\n  CONFIRMATION RATE: %.1f%% of tree-sitter edges confirmed by go/ast", confirmRate)

	// Performance contract: two-tier extraction must achieve minimum accuracy.
	overallConfirmationRate := confirmRate
	if overallConfirmationRate < 15.0 {
		t.Errorf("Overall confirmation rate %.1f%% below 15%% floor (regression)", overallConfirmationRate)
	}

	// Write FINDINGS.md with actual results.
	writeFindingsReport(t, overall, byType, edgeTypes)
}

func writeFindingsReport(t *testing.T, overall *edgeStats, byType map[string]*edgeStats, edgeTypes []string) {
	t.Helper()

	confirmRate := 0.0
	fpRate := 0.0
	missRate := 0.0
	if overall.TotalInferred > 0 {
		confirmRate = float64(overall.Confirmed) / float64(overall.TotalInferred) * 100
		fpRate = float64(overall.InferredOnly) / float64(overall.TotalInferred) * 100
	}
	if overall.TotalResolved > 0 {
		missRate = float64(overall.ResolvedOnly) / float64(overall.TotalResolved) * 100
	}

	// Fair comparison: only edge types that both extractors produce (calls + imports).
	fairInferred := 0
	fairConfirmed := 0
	fairFP := 0
	fairResolved := 0
	fairMissed := 0
	for _, et := range []string{"calls", "imports"} {
		if s, ok := byType[et]; ok {
			fairInferred += s.TotalInferred
			fairConfirmed += s.Confirmed
			fairFP += s.InferredOnly
			fairResolved += s.TotalResolved
			fairMissed += s.ResolvedOnly
		}
	}
	fairConfirmRate := 0.0
	fairFPRate := 0.0
	fairMissRate := 0.0
	if fairInferred > 0 {
		fairConfirmRate = float64(fairConfirmed) / float64(fairInferred) * 100
		fairFPRate = float64(fairFP) / float64(fairInferred) * 100
	}
	if fairResolved > 0 {
		fairMissRate = float64(fairMissed) / float64(fairResolved) * 100
	}

	var sb strings.Builder
	sb.WriteString("# Edge Accuracy Benchmark: Tree-Sitter vs Go/AST\n\n")
	sb.WriteString("**Auto-generated from test run. Do not edit manually.**\n\n")

	sb.WriteString("## Methodology\n\n")
	sb.WriteString("Compares knowing's two Go extraction tiers by indexing the same repo with each:\n\n")
	sb.WriteString("1. **Tree-sitter** (`gotsextractor`): Syntax-only, fast. Produces `calls` and `imports` edges.\n")
	sb.WriteString("   Provenance `ast_inferred`, confidence 0.7.\n")
	sb.WriteString("2. **Go/AST** (`goextractor`): Full type resolution via `go/packages`. Produces `calls`,\n")
	sb.WriteString("   `imports`, `implements`, and `references` edges. Provenance `ast_resolved`, confidence 1.0.\n\n")
	sb.WriteString("Edges match by identity tuple: `(source_hash, target_hash, edge_type)`. Provenance\n")
	sb.WriteString("is excluded from matching since it differs by design.\n\n")

	sb.WriteString("## Overall Results\n\n")
	sb.WriteString(fmt.Sprintf("| Metric | Count | Rate |\n"))
	sb.WriteString(fmt.Sprintf("|--------|-------|------|\n"))
	sb.WriteString(fmt.Sprintf("| Tree-sitter edges (ast_inferred) | %d | - |\n", overall.TotalInferred))
	sb.WriteString(fmt.Sprintf("| Go/ast edges (ast_resolved) | %d | - |\n", overall.TotalResolved))
	sb.WriteString(fmt.Sprintf("| Confirmed (in both) | %d | %.1f%% of inferred |\n", overall.Confirmed, confirmRate))
	sb.WriteString(fmt.Sprintf("| Inferred-only (potential FP) | %d | %.1f%% of inferred |\n", overall.InferredOnly, fpRate))
	sb.WriteString(fmt.Sprintf("| Resolved-only (missed) | %d | %.1f%% of resolved |\n\n", overall.ResolvedOnly, missRate))

	sb.WriteString("## Per-Edge-Type Breakdown\n\n")
	sb.WriteString("| Edge Type | Tree-sitter | Go/ast | Confirmed | FP Rate | Miss Rate |\n")
	sb.WriteString("|-----------|-------------|--------|-----------|---------|----------|\n")
	for _, et := range edgeTypes {
		s := byType[et]
		if s.TotalInferred == 0 && s.TotalResolved == 0 {
			continue
		}
		cr := 0.0
		fp := 0.0
		mr := 0.0
		if s.TotalInferred > 0 {
			cr = float64(s.Confirmed) / float64(s.TotalInferred) * 100
			fp = float64(s.InferredOnly) / float64(s.TotalInferred) * 100
		}
		if s.TotalResolved > 0 {
			mr = float64(s.ResolvedOnly) / float64(s.TotalResolved) * 100
		}
		sb.WriteString(fmt.Sprintf("| %s | %d | %d | %.1f%% | %.1f%% | %.1f%% |\n",
			et, s.TotalInferred, s.TotalResolved, cr, fp, mr))
	}

	sb.WriteString("\n## Fair Comparison (calls + imports only)\n\n")
	sb.WriteString("Tree-sitter does not produce `implements` or `references` edges (these require\n")
	sb.WriteString("type resolution). The overall numbers are misleading because go/ast's 19K+ reference\n")
	sb.WriteString("edges inflate the miss rate. A fair comparison restricts to edge types both extractors\n")
	sb.WriteString("attempt:\n\n")
	sb.WriteString(fmt.Sprintf("| Metric | Count | Rate |\n"))
	sb.WriteString(fmt.Sprintf("|--------|-------|------|\n"))
	sb.WriteString(fmt.Sprintf("| Tree-sitter edges | %d | - |\n", fairInferred))
	sb.WriteString(fmt.Sprintf("| Go/ast edges | %d | - |\n", fairResolved))
	sb.WriteString(fmt.Sprintf("| Confirmed | %d | %.1f%% of inferred |\n", fairConfirmed, fairConfirmRate))
	sb.WriteString(fmt.Sprintf("| Inferred-only (FP) | %d | %.1f%% of inferred |\n", fairFP, fairFPRate))
	sb.WriteString(fmt.Sprintf("| Resolved-only (missed) | %d | %.1f%% of resolved |\n\n", fairMissed, fairMissRate))

	sb.WriteString("## Interpretation\n\n")

	sb.WriteString("### Why confirmation rate is low for `calls`\n\n")
	sb.WriteString("Tree-sitter identifies function calls syntactically (any `identifier()` pattern) but\n")
	sb.WriteString("cannot resolve which package the callee belongs to. It generates candidate edges using\n")
	sb.WriteString("name matching heuristics. Go/ast resolves the actual call target through type information.\n")
	sb.WriteString("The mismatch means tree-sitter over-generates call edges (multiple candidates per call site)\n")
	sb.WriteString("while go/ast produces one precise edge per call.\n\n")

	sb.WriteString("### Why `imports` has high confirmation\n\n")
	sb.WriteString("Import statements are unambiguous in Go syntax. Both extractors detect them reliably.\n")
	sb.WriteString("The tree-sitter inferred-only imports are likely aliased or dot imports where the\n")
	sb.WriteString("hash computation differs.\n\n")

	sb.WriteString("### What this means for knowing's two-tier strategy\n\n")
	sb.WriteString(fmt.Sprintf("The %.1f%% confirmation rate for calls+imports means tree-sitter provides\n", fairConfirmRate))
	sb.WriteString("a noisy but non-zero signal. The lower confidence score (0.7 vs 1.0) causes the\n")
	sb.WriteString("context engine to rank tree-sitter-only edges below confirmed edges in scoring.\n")
	sb.WriteString("This is the intended behavior: tree-sitter provides fast initial coverage that\n")
	sb.WriteString("the go/ast extractor later refines with precision.\n\n")
	sb.WriteString("The value proposition is speed vs accuracy: tree-sitter runs in milliseconds per file,\n")
	sb.WriteString("while go/ast requires loading the full dependency graph (~30s for this repo).\n")
	sb.WriteString("For real-time IDE feedback, the noisy tree-sitter signal is better than no signal.\n")
	sb.WriteString("For batch indexing (CI, nightly), go/ast provides ground truth.\n\n")

	sb.WriteString("## Reproducibility\n\n")
	sb.WriteString("```bash\nGOWORK=off go test ./bench/edge-accuracy/ -v -timeout 5m\n```\n")

	err := os.WriteFile("FINDINGS.md", []byte(sb.String()), 0644)
	if err != nil {
		t.Logf("Warning: could not write FINDINGS.md: %v", err)
	} else {
		t.Logf("Wrote FINDINGS.md")
	}
}

// queryAllEdges opens the DB directly and reads all edges as edgeKeys.
func queryAllEdges(t *testing.T, dbPath string) []edgeKey {
	t.Helper()

	db, err := sql.Open("sqlite", dbPath)
	if err != nil {
		t.Fatalf("open db %s: %v", dbPath, err)
	}
	defer db.Close()

	rows, err := db.Query("SELECT source_hash, target_hash, edge_type FROM edges")
	if err != nil {
		t.Fatalf("query edges from %s: %v", dbPath, err)
	}
	defer rows.Close()

	var edges []edgeKey
	for rows.Next() {
		var sourceHash, targetHash, edgeType string
		if err := rows.Scan(&sourceHash, &targetHash, &edgeType); err != nil {
			t.Fatalf("scan edge: %v", err)
		}
		edges = append(edges, edgeKey{
			SourceHash: sourceHash,
			TargetHash: targetHash,
			EdgeType:   edgeType,
		})
	}
	if err := rows.Err(); err != nil {
		t.Fatalf("rows iteration: %v", err)
	}
	return edges
}

// computeStats computes accuracy metrics between two edge sets.
func computeStats(tsSet, goSet map[edgeKey]bool) *edgeStats {
	stats := &edgeStats{
		TotalInferred: len(tsSet),
		TotalResolved: len(goSet),
	}

	for k := range tsSet {
		if goSet[k] {
			stats.Confirmed++
		} else {
			stats.InferredOnly++
		}
	}

	for k := range goSet {
		if !tsSet[k] {
			stats.ResolvedOnly++
		}
	}

	return stats
}

// filterByType returns a subset of edges matching the given edge type.
func filterByType(set map[edgeKey]bool, edgeType string) map[edgeKey]bool {
	subset := make(map[edgeKey]bool)
	for k := range set {
		if k.EdgeType == edgeType {
			subset[k] = true
		}
	}
	return subset
}

// reportStats logs accuracy metrics for a given category.
func reportStats(t *testing.T, label string, stats *edgeStats) {
	t.Helper()

	confirmRate := 0.0
	fpRate := 0.0
	missRate := 0.0

	if stats.TotalInferred > 0 {
		confirmRate = float64(stats.Confirmed) / float64(stats.TotalInferred) * 100
		fpRate = float64(stats.InferredOnly) / float64(stats.TotalInferred) * 100
	}
	if stats.TotalResolved > 0 {
		missRate = float64(stats.ResolvedOnly) / float64(stats.TotalResolved) * 100
	}

	t.Logf("  %s:", label)
	t.Logf("    Tree-sitter (ast_inferred): %d edges", stats.TotalInferred)
	t.Logf("    Go/ast (ast_resolved):      %d edges", stats.TotalResolved)
	t.Logf("    Confirmed (both):           %d (%.1f%% of inferred)", stats.Confirmed, confirmRate)
	t.Logf("    Inferred-only (FP):         %d (%.1f%% of inferred)", stats.InferredOnly, fpRate)
	t.Logf("    Resolved-only (missed):     %d (%.1f%% of resolved)", stats.ResolvedOnly, missRate)
}

func findRepoRoot(t *testing.T) string {
	t.Helper()
	dir, _ := os.Getwd()
	for {
		if _, err := os.Stat(dir + "/go.mod"); err == nil {
			return dir
		}
		parent := dir[:strings.LastIndex(dir, "/")]
		if parent == dir {
			t.Fatal("cannot find repo root")
		}
		dir = parent
	}
}

