package analysis

import (
	"fmt"
	"math"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/zzet/gortex/internal/graph"
	"pgregory.net/rapid"
)

// Feature: gortex-enhancements, Property 5: Dead code has zero incoming edges

// --- Generators ---

// deadCodeGraphResult holds a generated graph with known dead and live symbols.
type deadCodeGraphResult struct {
	Graph     *graph.Graph
	Processes *ProcessResult
	DeadIDs   []string // symbols that should be detected as dead code
	LiveIDs   []string // symbols that should NOT be detected as dead code
}

// genDeadCodeGraph generates a random graph with a mix of dead and live symbols.
// Dead symbols: unexported, non-test, non-process, zero incoming calls/references.
// Live symbols: have incoming edges, or are exported, or are in test files, or are in processes.
func genDeadCodeGraph() *rapid.Generator[deadCodeGraphResult] {
	return rapid.Custom(func(t *rapid.T) deadCodeGraphResult {
		g := graph.New()

		processes := &ProcessResult{
			Processes:   nil,
			NodeToProcs: make(map[string][]string),
		}

		var deadIDs []string
		var liveIDs []string

		// Generate 2-6 dead symbols: unexported, non-test file, no incoming edges, not in process
		numDead := rapid.IntRange(2, 6).Draw(t, "numDead")
		for i := range numDead {
			id := fmt.Sprintf("pkg/internal/mod%d.go::helper%d", i, i)
			g.AddNode(&graph.Node{
				ID:        id,
				Kind:      graph.KindFunction,
				Name:      fmt.Sprintf("helper%d", i), // lowercase = unexported in Go
				FilePath:  fmt.Sprintf("pkg/internal/mod%d.go", i),
				StartLine: 1,
				EndLine:   10,
				Language:  "go",
			})
			deadIDs = append(deadIDs, id)
		}

		// Generate 1-4 live symbols with incoming call edges
		numLiveCalled := rapid.IntRange(1, 4).Draw(t, "numLiveCalled")
		for i := range numLiveCalled {
			calleeID := fmt.Sprintf("pkg/called%d.go::calledFunc%d", i, i)
			callerID := fmt.Sprintf("pkg/caller%d.go::callerFunc%d", i, i)
			g.AddNode(&graph.Node{
				ID:        calleeID,
				Kind:      graph.KindFunction,
				Name:      fmt.Sprintf("calledFunc%d", i),
				FilePath:  fmt.Sprintf("pkg/called%d.go", i),
				StartLine: 1,
				EndLine:   10,
				Language:  "go",
			})
			g.AddNode(&graph.Node{
				ID:        callerID,
				Kind:      graph.KindFunction,
				Name:      fmt.Sprintf("callerFunc%d", i),
				FilePath:  fmt.Sprintf("pkg/caller%d.go", i),
				StartLine: 1,
				EndLine:   10,
				Language:  "go",
			})
			g.AddEdge(&graph.Edge{
				From: callerID,
				To:   calleeID,
				Kind: graph.EdgeCalls,
			})
			liveIDs = append(liveIDs, calleeID)
		}

		// Generate 1-3 live symbols that are exported (uppercase name in Go)
		numExported := rapid.IntRange(1, 3).Draw(t, "numExported")
		for i := range numExported {
			id := fmt.Sprintf("pkg/exported%d.go::ExportedFunc%d", i, i)
			g.AddNode(&graph.Node{
				ID:        id,
				Kind:      graph.KindFunction,
				Name:      fmt.Sprintf("ExportedFunc%d", i), // uppercase = exported
				FilePath:  fmt.Sprintf("pkg/exported%d.go", i),
				StartLine: 1,
				EndLine:   10,
				Language:  "go",
			})
			liveIDs = append(liveIDs, id)
		}

		// Generate 1-2 live symbols in test files
		numTest := rapid.IntRange(1, 2).Draw(t, "numTest")
		for i := range numTest {
			id := fmt.Sprintf("pkg/mod_test.go::testHelper%d", i)
			g.AddNode(&graph.Node{
				ID:        id,
				Kind:      graph.KindFunction,
				Name:      fmt.Sprintf("testHelper%d", i),
				FilePath:  "pkg/mod_test.go",
				StartLine: 1,
				EndLine:   10,
				Language:  "go",
			})
			liveIDs = append(liveIDs, id)
		}

		// Generate 1-2 live symbols that are process members
		numProcess := rapid.IntRange(1, 2).Draw(t, "numProcess")
		var procSteps []Step
		for i := range numProcess {
			id := fmt.Sprintf("pkg/entry%d.go::entryFunc%d", i, i)
			g.AddNode(&graph.Node{
				ID:        id,
				Kind:      graph.KindFunction,
				Name:      fmt.Sprintf("entryFunc%d", i),
				FilePath:  fmt.Sprintf("pkg/entry%d.go", i),
				StartLine: 1,
				EndLine:   10,
				Language:  "go",
			})
			procSteps = append(procSteps, Step{ID: id, Depth: i})
			processes.NodeToProcs[id] = []string{"process-0"}
			liveIDs = append(liveIDs, id)
		}
		processes.Processes = []Process{{
			ID:         "process-0",
			Name:       "test process",
			EntryPoint: procSteps[0].ID,
			Steps:      procSteps,
			StepCount:  len(procSteps),
		}}

		// Generate 1-2 live symbols with incoming reference edges
		numReferenced := rapid.IntRange(1, 2).Draw(t, "numReferenced")
		for i := range numReferenced {
			refID := fmt.Sprintf("pkg/ref%d.go::referencedVar%d", i, i)
			refByID := fmt.Sprintf("pkg/refby%d.go::refByFunc%d", i, i)
			g.AddNode(&graph.Node{
				ID:        refID,
				Kind:      graph.KindVariable,
				Name:      fmt.Sprintf("referencedVar%d", i),
				FilePath:  fmt.Sprintf("pkg/ref%d.go", i),
				StartLine: 1,
				EndLine:   5,
				Language:  "go",
			})
			g.AddNode(&graph.Node{
				ID:        refByID,
				Kind:      graph.KindFunction,
				Name:      fmt.Sprintf("refByFunc%d", i),
				FilePath:  fmt.Sprintf("pkg/refby%d.go", i),
				StartLine: 1,
				EndLine:   10,
				Language:  "go",
			})
			g.AddEdge(&graph.Edge{
				From: refByID,
				To:   refID,
				Kind: graph.EdgeReferences,
			})
			liveIDs = append(liveIDs, refID)
		}

		return deadCodeGraphResult{
			Graph:     g,
			Processes: processes,
			DeadIDs:   deadIDs,
			LiveIDs:   liveIDs,
		}
	})
}

// --- Property Tests ---

// TestPropertyDeadCode_ZeroIncomingEdges verifies that every symbol returned by
// FindDeadCode has zero incoming calls or references edges, is not a process member,
// is not in a test file, and is not exported.
func TestPropertyDeadCode_ZeroIncomingEdges(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		tc := genDeadCodeGraph().Draw(rt, "deadCodeGraph")

		result := FindDeadCode(tc.Graph, tc.Processes, nil)

		resultIDs := make(map[string]bool)
		for _, entry := range result {
			resultIDs[entry.ID] = true

			// Verify zero incoming calls or references
			inEdges := tc.Graph.GetInEdges(entry.ID)
			for _, e := range inEdges {
				if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences {
					rt.Errorf("dead code entry %s has incoming %s edge from %s", entry.ID, e.Kind, e.From)
				}
			}

			// Verify not a process member
			if _, inProc := tc.Processes.NodeToProcs[entry.ID]; inProc {
				rt.Errorf("dead code entry %s is a process member", entry.ID)
			}

			// Verify not in a test file
			if isTestFilePath(entry.FilePath) {
				rt.Errorf("dead code entry %s is in a test file: %s", entry.ID, entry.FilePath)
			}

			// Verify not exported
			node := tc.Graph.GetNode(entry.ID)
			if node != nil && isExportedSymbol(node.Name, node.Language) {
				rt.Errorf("dead code entry %s is exported: %s", entry.ID, node.Name)
			}
		}

		// Verify all known dead symbols are in the result
		for _, deadID := range tc.DeadIDs {
			if !resultIDs[deadID] {
				rt.Errorf("expected dead symbol %s was not in FindDeadCode result", deadID)
			}
		}

		// Verify no known live symbols are in the result
		for _, liveID := range tc.LiveIDs {
			if resultIDs[liveID] {
				rt.Errorf("live symbol %s was incorrectly reported as dead code", liveID)
			}
		}
	})
}

// TestPropertyDeadCode_Completeness verifies that no symbol meeting all dead code
// criteria is omitted from the result (the converse direction).
func TestPropertyDeadCode_Completeness(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		tc := genDeadCodeGraph().Draw(rt, "deadCodeGraph")

		result := FindDeadCode(tc.Graph, tc.Processes, nil)
		resultIDs := make(map[string]bool)
		for _, entry := range result {
			resultIDs[entry.ID] = true
		}

		// For every node in the graph, if it meets all dead code criteria,
		// it must be in the result.
		for _, node := range tc.Graph.AllNodes() {
			// Never-reported kinds (file/package/import + the broader
			// noise set: param/closure/module/string/etc).
			if neverDeadCodeKinds[node.Kind] {
				continue
			}

			// Variables / fields / constants excluded by default — same
			// reasoning the analyzer uses (graph lacks intra-function
			// data flow, so a non-opt-in scan must skip them).
			if node.Kind == graph.KindVariable {
				continue
			}
			if node.Kind == graph.KindField {
				continue
			}
			if node.Kind == graph.KindConstant {
				continue
			}

			// Go init() excluded
			if node.Name == "init" && node.Language == "go" {
				continue
			}

			// Go main() excluded
			if node.Name == "main" && node.Language == "go" && node.Kind == graph.KindFunction {
				continue
			}

			// Generated/vendored files excluded
			if isVendoredOrGenerated(node.FilePath) {
				continue
			}

			// Build-constrained files excluded
			if node.Language == "go" && hasBuildConstraint(node.FilePath) {
				continue
			}

			// Per-kind incoming-edge allowlist — mirror what the
			// analyzer does. Functions/methods need Calls/References;
			// types need References/Instantiates/MemberOf/...; etc.
			allowed := incomingUsageKinds(node.Kind)
			inEdges := tc.Graph.GetInEdges(node.ID)
			hasIncoming := false
			for _, e := range inEdges {
				for _, k := range allowed {
					if e.Kind == k {
						hasIncoming = true
						break
					}
				}
				if hasIncoming {
					break
				}
			}
			if hasIncoming {
				continue
			}

			// Well-known interface methods excluded
			if node.Kind == graph.KindMethod && isWellKnownInterfaceMethod(node.Name, node.Language) {
				continue
			}

			// CGo exports excluded by default
			if cgoExport, ok := node.Meta["cgo_export"].(bool); ok && cgoExport {
				continue
			}

			// go:linkname targets excluded by default
			if linkname, ok := node.Meta["go_linkname"].(bool); ok && linkname {
				continue
			}

			// Check exclusions
			if _, inProc := tc.Processes.NodeToProcs[node.ID]; inProc {
				continue
			}
			if isTestFilePath(node.FilePath) {
				continue
			}
			if isExportedSymbol(node.Name, node.Language) && !isPackagePrivateByConvention(node.FilePath, node.Language) {
				continue
			}

			// This node meets all dead code criteria — it must be in the result
			if !resultIDs[node.ID] {
				rt.Errorf("symbol %s meets all dead code criteria but was not returned by FindDeadCode", node.ID)
			}
		}
	})
}

// Feature: gortex-enhancements, Property 6: Hotspot complexity score matches formula

// --- Generators ---

// hotspotGraphResult holds a generated graph with known fan_in/fan_out/crossing values.
type hotspotGraphResult struct {
	Graph       *graph.Graph
	Communities *CommunityResult
	// Expected raw values per function node ID
	ExpectedFanIn    map[string]int
	ExpectedFanOut   map[string]int
	ExpectedCrossing map[string]int
}

// genHotspotGraph generates a graph with function nodes that have known
// fan_in, fan_out, and community crossing values for score verification.
func genHotspotGraph() *rapid.Generator[hotspotGraphResult] {
	return rapid.Custom(func(t *rapid.T) hotspotGraphResult {
		g := graph.New()

		// Create 3-8 function nodes spread across 2-3 communities
		numFuncs := rapid.IntRange(3, 8).Draw(t, "numFuncs")
		numComms := rapid.IntRange(2, 3).Draw(t, "numComms")

		commNames := make([]string, numComms)
		for i := range numComms {
			commNames[i] = fmt.Sprintf("community-%d", i)
		}

		nodeToComm := make(map[string]string)
		funcIDs := make([]string, numFuncs)
		funcComms := make([]string, numFuncs) // which community each func belongs to

		for i := range numFuncs {
			id := fmt.Sprintf("pkg/mod%d.go::func%d", i, i)
			funcIDs[i] = id
			commIdx := rapid.IntRange(0, numComms-1).Draw(t, fmt.Sprintf("comm%d", i))
			funcComms[i] = commNames[commIdx]
			nodeToComm[id] = commNames[commIdx]

			g.AddNode(&graph.Node{
				ID:        id,
				Kind:      graph.KindFunction,
				Name:      fmt.Sprintf("func%d", i),
				FilePath:  fmt.Sprintf("pkg/mod%d.go", i),
				StartLine: 1,
				EndLine:   10,
				Language:  "go",
			})
		}

		communities := &CommunityResult{
			NodeToComm: nodeToComm,
		}

		expectedFanIn := make(map[string]int)
		expectedFanOut := make(map[string]int)
		expectedCrossing := make(map[string]int)

		// Add random call edges between functions
		numEdges := rapid.IntRange(2, numFuncs*2).Draw(t, "numEdges")
		seen := make(map[string]bool)
		for e := 0; e < numEdges; e++ {
			fromIdx := rapid.IntRange(0, numFuncs-1).Draw(t, fmt.Sprintf("from%d", e))
			toIdx := rapid.IntRange(0, numFuncs-1).Draw(t, fmt.Sprintf("to%d", e))
			if fromIdx == toIdx {
				continue
			}
			key := fmt.Sprintf("%d->%d", fromIdx, toIdx)
			if seen[key] {
				continue
			}
			seen[key] = true

			fromID := funcIDs[fromIdx]
			toID := funcIDs[toIdx]

			// Randomly choose calls or references
			edgeKind := graph.EdgeCalls
			if rapid.Bool().Draw(t, fmt.Sprintf("isRef%d", e)) {
				edgeKind = graph.EdgeReferences
			}

			g.AddEdge(&graph.Edge{
				From: fromID,
				To:   toID,
				Kind: edgeKind,
			})

			// fan_in: incoming calls + references
			expectedFanIn[toID]++

			// fan_out: outgoing calls only
			if edgeKind == graph.EdgeCalls {
				expectedFanOut[fromID]++
			}

			// community crossings: outgoing edges to different community
			if funcComms[fromIdx] != funcComms[toIdx] {
				expectedCrossing[fromID]++
			}
		}

		return hotspotGraphResult{
			Graph:            g,
			Communities:      communities,
			ExpectedFanIn:    expectedFanIn,
			ExpectedFanOut:   expectedFanOut,
			ExpectedCrossing: expectedCrossing,
		}
	})
}

// --- Property Tests ---

// hotspotBetweennessComponent recomputes the 0-100 normalized
// betweenness term FindHotspots folds into a node's raw score. The
// generated hotspot graphs are tiny, so ComputeBetweenness runs the
// exact path here — the expected score stays faithful to the
// implementation without re-deriving Brandes' in the test.
func hotspotBetweennessComponent(g *graph.Graph) map[string]float64 {
	bc := ComputeBetweenness(g)
	out := make(map[string]float64, len(bc.Scores))
	if bc.Max > 0 {
		for id, v := range bc.Scores {
			out[id] = (v / bc.Max) * 100.0
		}
	}
	return out
}

// TestPropertyHotspot_ComplexityScoreFormula verifies that for every hotspot entry,
// ComplexityScore equals (fan_in * 2) + (fan_out * 1.5) + (community_crossings * 3) +
// (betweenness * hotspotBetweennessWeight) normalized to [0, 100], and that
// FindHotspots returns exactly those symbols whose score exceeds the threshold.
func TestPropertyHotspot_ComplexityScoreFormula(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		tc := genHotspotGraph().Draw(rt, "hotspotGraph")

		// Use threshold=0 to get ALL function nodes with any score
		result := FindHotspots(tc.Graph, tc.Communities, 0)

		betweenness := hotspotBetweennessComponent(tc.Graph)

		// Compute the max raw score across all function nodes to verify normalization
		maxRaw := 0.0
		for _, id := range tc.Graph.AllNodes() {
			if id.Kind != graph.KindFunction && id.Kind != graph.KindMethod {
				continue
			}
			fi := tc.ExpectedFanIn[id.ID]
			fo := tc.ExpectedFanOut[id.ID]
			cc := tc.ExpectedCrossing[id.ID]
			raw := float64(fi)*2.0 + float64(fo)*1.5 + float64(cc)*3.0 + betweenness[id.ID]*hotspotBetweennessWeight
			if raw > maxRaw {
				maxRaw = raw
			}
		}

		for _, entry := range result {
			fi := tc.ExpectedFanIn[entry.ID]
			fo := tc.ExpectedFanOut[entry.ID]
			cc := tc.ExpectedCrossing[entry.ID]

			// Verify fan_in, fan_out, community_crossings match
			if entry.FanIn != fi {
				rt.Errorf("hotspot %s: FanIn = %d, expected %d", entry.ID, entry.FanIn, fi)
			}
			if entry.FanOut != fo {
				rt.Errorf("hotspot %s: FanOut = %d, expected %d", entry.ID, entry.FanOut, fo)
			}
			if entry.CommunityCrossings != cc {
				rt.Errorf("hotspot %s: CommunityCrossings = %d, expected %d", entry.ID, entry.CommunityCrossings, cc)
			}

			// Verify the reported betweenness matches the 0-100 normalized value.
			expectedBw := math.Round(betweenness[entry.ID]*100) / 100
			if math.Abs(entry.Betweenness-expectedBw) > 0.01 {
				rt.Errorf("hotspot %s: Betweenness = %.2f, expected %.2f", entry.ID, entry.Betweenness, expectedBw)
			}

			// Verify complexity score matches formula
			rawScore := float64(fi)*2.0 + float64(fo)*1.5 + float64(cc)*3.0 + betweenness[entry.ID]*hotspotBetweennessWeight
			var expectedNormalized float64
			if maxRaw > 0 {
				expectedNormalized = (rawScore / maxRaw) * 100.0
			}
			expectedNormalized = math.Round(expectedNormalized*100) / 100

			if math.Abs(entry.ComplexityScore-expectedNormalized) > 0.01 {
				rt.Errorf("hotspot %s: ComplexityScore = %.2f, expected %.2f (raw=%.2f, maxRaw=%.2f, fi=%d, fo=%d, cc=%d)",
					entry.ID, entry.ComplexityScore, expectedNormalized, rawScore, maxRaw, fi, fo, cc)
			}

			// Verify score is in [0, 100]
			if entry.ComplexityScore < 0 || entry.ComplexityScore > 100 {
				rt.Errorf("hotspot %s: ComplexityScore = %.2f, not in [0, 100]", entry.ID, entry.ComplexityScore)
			}
		}
	})
}

// TestPropertyHotspot_ThresholdFiltering verifies that FindHotspots returns
// exactly those symbols whose normalized score exceeds the given threshold.
func TestPropertyHotspot_ThresholdFiltering(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		tc := genHotspotGraph().Draw(rt, "hotspotGraph")

		// Pick a random threshold between 0 and 100
		threshold := rapid.Float64Range(0.01, 99.0).Draw(rt, "threshold")

		result := FindHotspots(tc.Graph, tc.Communities, threshold)

		// All returned entries must have score >= threshold
		for _, entry := range result {
			if entry.ComplexityScore < threshold {
				rt.Errorf("hotspot %s has score %.2f below threshold %.2f",
					entry.ID, entry.ComplexityScore, threshold)
			}
		}

		// Compute all scores to verify no symbol above threshold is missing
		betweenness := hotspotBetweennessComponent(tc.Graph)
		maxRaw := 0.0
		type nodeScore struct {
			id  string
			raw float64
		}
		var allScores []nodeScore
		for _, n := range tc.Graph.AllNodes() {
			if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
				continue
			}
			fi := tc.ExpectedFanIn[n.ID]
			fo := tc.ExpectedFanOut[n.ID]
			cc := tc.ExpectedCrossing[n.ID]
			raw := float64(fi)*2.0 + float64(fo)*1.5 + float64(cc)*3.0 + betweenness[n.ID]*hotspotBetweennessWeight
			allScores = append(allScores, nodeScore{n.ID, raw})
			if raw > maxRaw {
				maxRaw = raw
			}
		}

		resultIDs := make(map[string]bool)
		for _, entry := range result {
			resultIDs[entry.ID] = true
		}

		for _, ns := range allScores {
			var normalized float64
			if maxRaw > 0 {
				normalized = (ns.raw / maxRaw) * 100.0
			}
			normalized = math.Round(normalized*100) / 100

			if normalized >= threshold && !resultIDs[ns.id] {
				rt.Errorf("symbol %s has score %.2f >= threshold %.2f but was not returned",
					ns.id, normalized, threshold)
			}
		}
	})
}

// --- Unit Tests ---

// TestHotspots_SmallGraphError verifies that a graph with fewer than 10 function/method
// symbols returns an empty result from FindHotspots.
func TestHotspots_SmallGraphError(t *testing.T) {
	g := graph.New()

	// Add only 5 function nodes (< 10)
	for i := 0; i < 5; i++ {
		g.AddNode(&graph.Node{
			ID:        fmt.Sprintf("pkg/small%d.go::func%d", i, i),
			Kind:      graph.KindFunction,
			Name:      fmt.Sprintf("func%d", i),
			FilePath:  fmt.Sprintf("pkg/small%d.go", i),
			StartLine: 1,
			EndLine:   10,
			Language:  "go",
		})
	}

	// Add some edges so there's non-zero scores
	g.AddEdge(&graph.Edge{
		From: "pkg/small0.go::func0",
		To:   "pkg/small1.go::func1",
		Kind: graph.EdgeCalls,
	})
	g.AddEdge(&graph.Edge{
		From: "pkg/small2.go::func2",
		To:   "pkg/small3.go::func3",
		Kind: graph.EdgeCalls,
	})

	communities := &CommunityResult{
		NodeToComm: make(map[string]string),
	}

	// With default threshold (mean + 2*stddev), a small graph should return
	// empty or very few results. The MCP handler enforces the < 10 symbols error,
	// but at the analysis level, FindHotspots should still work correctly.
	result := FindHotspots(g, communities, 0)

	// With threshold=0, we get all nodes that have any score.
	// The important thing is the function doesn't panic on small graphs.
	assert.LessOrEqual(t, len(result), 5, "small graph should have at most 5 hotspots")

	// Verify that with a very high threshold, we get empty results
	result = FindHotspots(g, communities, 101)
	assert.Empty(t, result, "threshold above 100 should return no hotspots")
}

// TestHotspots_EmptyGraph verifies FindHotspots handles an empty graph gracefully.
func TestHotspots_EmptyGraph(t *testing.T) {
	g := graph.New()
	communities := &CommunityResult{
		NodeToComm: make(map[string]string),
	}

	result := FindHotspots(g, communities, 0)
	assert.Empty(t, result)
}

// TestDeadCode_StructuralNodesExcluded verifies that file, import, and package
// nodes are never reported as dead code.
func TestDeadCode_StructuralNodesExcluded(t *testing.T) {
	g := graph.New()

	g.AddNode(&graph.Node{ID: "file1.go", Kind: graph.KindFile, Name: "file1.go", FilePath: "file1.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg1", Kind: graph.KindPackage, Name: "pkg1", FilePath: "pkg1", Language: "go"})
	g.AddNode(&graph.Node{ID: "import1", Kind: graph.KindImport, Name: "fmt", FilePath: "file1.go", Language: "go"})

	processes := &ProcessResult{
		NodeToProcs: make(map[string][]string),
	}

	result := FindDeadCode(g, processes, nil)
	assert.Empty(t, result, "structural nodes should never be reported as dead code")
}

// TestDeadCode_GeneratedFilesExcluded verifies that symbols in generated files
// (protobuf, codegen, mocks) are not reported as dead code.
func TestDeadCode_GeneratedFilesExcluded(t *testing.T) {
	g := graph.New()

	generatedFiles := []struct {
		file string
		name string
	}{
		{"pkg/api.pb.go", "apiHelper"},
		{"pkg/api_grpc.pb.go", "grpcHelper"},
		{"pkg/types_gen.go", "genHelper"},
		{"pkg/types_generated.go", "generatedHelper"},
		{"pkg/types.gen.go", "dotGenHelper"},
		{"pkg/zz_generated.deepcopy.go", "deepCopyHelper"},
		{"pkg/mock_service.go", "mockHelper"},
		{"pkg/service_mock.go", "mockHelper2"},
	}

	for _, gf := range generatedFiles {
		g.AddNode(&graph.Node{
			ID: gf.file + "::" + gf.name, Kind: graph.KindFunction,
			Name: gf.name, FilePath: gf.file, StartLine: 1, EndLine: 10, Language: "go",
		})
	}

	result := FindDeadCode(g, nil, nil)
	assert.Empty(t, result, "symbols in generated files should be excluded")
}

// TestDeadCode_MainFunctionExcluded verifies that Go main() is not reported as dead.
func TestDeadCode_MainFunctionExcluded(t *testing.T) {
	g := graph.New()

	g.AddNode(&graph.Node{
		ID: "cmd/app/main.go::main", Kind: graph.KindFunction,
		Name: "main", FilePath: "cmd/app/main.go", StartLine: 5, EndLine: 20, Language: "go",
	})

	result := FindDeadCode(g, nil, nil)
	assert.Empty(t, result, "Go main() should be excluded as runtime entry point")
}

// TestDeadCode_MainMethodNotExcluded verifies that a method named main on a
// type IS reported as dead (only the package-level main function is special).
func TestDeadCode_MainMethodNotExcluded(t *testing.T) {
	g := graph.New()

	g.AddNode(&graph.Node{
		ID: "pkg/foo.go::Foo.main", Kind: graph.KindMethod,
		Name: "main", FilePath: "pkg/foo.go", StartLine: 5, EndLine: 10, Language: "go",
	})

	result := FindDeadCode(g, nil, nil)
	assert.Len(t, result, 1, "a method named main should still be reported as dead")
}

// TestDeadCode_WellKnownMethodsExcluded verifies that methods matching
// well-known stdlib interface names are excluded even without implements edges.
func TestDeadCode_WellKnownMethodsExcluded(t *testing.T) {
	g := graph.New()

	wellKnown := []string{"ServeHTTP", "MarshalJSON", "UnmarshalJSON", "String", "Error", "Read", "Write", "Close"}
	for _, name := range wellKnown {
		g.AddNode(&graph.Node{
			ID: "pkg/foo.go::myType." + name, Kind: graph.KindMethod,
			Name: name, FilePath: "pkg/foo.go", StartLine: 1, EndLine: 5, Language: "go",
		})
	}

	result := FindDeadCode(g, nil, nil)
	assert.Empty(t, result, "well-known interface methods should be excluded")
}

// TestDeadCode_WellKnownDoesNotSuppressOtherMethods verifies that non-well-known
// method names are still reported as dead.
func TestDeadCode_WellKnownDoesNotSuppressOtherMethods(t *testing.T) {
	g := graph.New()

	g.AddNode(&graph.Node{
		ID: "pkg/foo.go::myType.handleInternal", Kind: graph.KindMethod,
		Name: "handleInternal", FilePath: "pkg/foo.go", StartLine: 1, EndLine: 5, Language: "go",
	})

	result := FindDeadCode(g, nil, nil)
	assert.Len(t, result, 1, "non-well-known methods should still be reported")
	assert.Equal(t, "pkg/foo.go::myType.handleInternal", result[0].ID)
}

// TestDeadCode_CgoExportExcluded verifies that functions with cgo_export Meta
// are excluded by default but included when IncludeCgoExports is set.
func TestDeadCode_CgoExportExcluded(t *testing.T) {
	g := graph.New()

	g.AddNode(&graph.Node{
		ID: "pkg/bridge.go::bridge_init", Kind: graph.KindFunction,
		Name: "bridge_init", FilePath: "pkg/bridge.go", StartLine: 10, EndLine: 20, Language: "go",
		Meta: map[string]any{"cgo_export": true},
	})

	// Default: excluded
	result := FindDeadCode(g, nil, nil)
	assert.Empty(t, result, "CGo exports should be excluded by default")

	// With IncludeCgoExports: included
	result = FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeCgoExports: true})
	assert.Len(t, result, 1, "CGo exports should be included when IncludeCgoExports is true")
}

// TestDeadCode_LinknameExcluded verifies that functions with go_linkname Meta
// are excluded by default but included when IncludeLinknameTargets is set.
func TestDeadCode_LinknameExcluded(t *testing.T) {
	g := graph.New()

	g.AddNode(&graph.Node{
		ID: "pkg/runtime.go::nanotime", Kind: graph.KindFunction,
		Name: "nanotime", FilePath: "pkg/runtime.go", StartLine: 10, EndLine: 15, Language: "go",
		Meta: map[string]any{"go_linkname": true},
	})

	// Default: excluded
	result := FindDeadCode(g, nil, nil)
	assert.Empty(t, result, "linkname targets should be excluded by default")

	// With IncludeLinknameTargets: included
	result = FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeLinknameTargets: true})
	assert.Len(t, result, 1, "linkname targets should be included when IncludeLinknameTargets is true")
}

// TestDeadCode_CrossRepoNodeExcluded verifies that nodes with a RepoPrefix
// are excluded when SkipCrossRepoNodes is set.
func TestDeadCode_CrossRepoNodeExcluded(t *testing.T) {
	g := graph.New()

	g.AddNode(&graph.Node{
		ID: "github.com/other/repo/pkg/util.go::helperFunc", Kind: graph.KindFunction,
		Name: "helperFunc", FilePath: "pkg/util.go", StartLine: 1, EndLine: 10, Language: "go",
		RepoPrefix: "github.com/other/repo",
	})

	// Default: included (so users see them)
	result := FindDeadCode(g, nil, nil)
	assert.Len(t, result, 1, "cross-repo nodes should be included by default")

	// With SkipCrossRepoNodes: excluded
	result = FindDeadCode(g, nil, nil, FindDeadCodeOptions{SkipCrossRepoNodes: true})
	assert.Empty(t, result, "cross-repo nodes should be excluded when SkipCrossRepoNodes is true")
}

// TestDeadCode_ExpandedBuildConstraints verifies that the expanded set of
// OS/arch-suffixed files are excluded.
func TestDeadCode_ExpandedBuildConstraints(t *testing.T) {
	g := graph.New()

	constrainedFiles := []string{
		"pkg/net_openbsd.go", "pkg/net_plan9.go", "pkg/net_js.go",
		"pkg/asm_riscv64.go", "pkg/asm_s390x.go", "pkg/sys_purego.go",
	}
	for i, f := range constrainedFiles {
		g.AddNode(&graph.Node{
			ID: f + "::helper", Kind: graph.KindFunction,
			Name: "helper", FilePath: f, StartLine: 1, EndLine: 10, Language: "go",
		})
		_ = i
	}

	result := FindDeadCode(g, nil, nil)
	assert.Empty(t, result, "symbols in build-constrained files should be excluded")
}

// Regression: exported symbols inside `internal/` directories are
// package-private by Go's import rules — the compiler refuses to let
// external packages import them. So if the indexed graph has no caller,
// they're genuinely dead. The pre-fix behaviour was to skip every
// exported name unconditionally, silently hiding dead code anywhere a
// project used Go's `internal/` convention.
func TestDeadCode_ExportedInsideInternalIsSurfaced(t *testing.T) {
	g := graph.New()

	// Exported (capitalised) method inside `internal/` with zero callers.
	// Real-world case: `func (b *Node) Test() bool` in
	// gortex/internal/parser/tsitter/tsitter.go — used by the user as a
	// dead-code-detection probe and expected to show up.
	g.AddNode(&graph.Node{
		ID: "gortex/internal/parser/tsitter/tsitter.go::Node.Test",
		Kind: graph.KindMethod, Name: "Test",
		FilePath: "gortex/internal/parser/tsitter/tsitter.go",
		StartLine: 85, EndLine: 85, Language: "go",
		Meta: map[string]any{"receiver": "Node"},
	})

	// Also: an exported function that's NOT inside internal/. Must still
	// be excluded (the user's public-API code).
	g.AddNode(&graph.Node{
		ID: "pkg/gortex/api.go::DoThing", Kind: graph.KindFunction,
		Name: "DoThing", FilePath: "pkg/gortex/api.go",
		StartLine: 10, EndLine: 12, Language: "go",
	})

	// And an unexported function inside internal/ — pre-fix this was
	// already surfaced; the new code path must not regress that.
	g.AddNode(&graph.Node{
		ID: "gortex/internal/helpers.go::helper", Kind: graph.KindFunction,
		Name: "helper", FilePath: "gortex/internal/helpers.go",
		StartLine: 5, EndLine: 7, Language: "go",
	})

	result := FindDeadCode(g, nil, nil)
	ids := make(map[string]bool)
	for _, e := range result {
		ids[e.ID] = true
	}

	assert.True(t, ids["gortex/internal/parser/tsitter/tsitter.go::Node.Test"],
		"exported method inside internal/ with zero callers must be surfaced as dead code")
	assert.False(t, ids["pkg/gortex/api.go::DoThing"],
		"exported function outside internal/ stays excluded — could be called externally")
	assert.True(t, ids["gortex/internal/helpers.go::helper"],
		"unexported function inside internal/ stays surfaced (no regression)")
}

// TestDeadCode_NeverDeadCodeKinds verifies that the never-reported kinds
// (param, closure, module, string, enum_member, todo, etc) are NEVER
// returned, even when they have zero incoming edges and look "dead"
// structurally. Without this filter, a typical workspace surfaces
// ~18k noise rows (params, fields, npm modules) that drown out the
// ~300 real function-level dead-code signals.
func TestDeadCode_NeverDeadCodeKinds(t *testing.T) {
	g := graph.New()

	// One example per never-reported kind, all unexported and
	// edge-less so they'd otherwise look dead.
	cases := []struct {
		id   string
		kind graph.NodeKind
	}{
		{"pkg/a.go::func#param:x", graph.KindParam},
		{"pkg/a.go::func#closure@10", graph.KindClosure},
		{"pkg/a.go::Foo#tparam:T", graph.KindGenericParam},
		{"module::npm:lodash@4.17.21", graph.KindModule},
		{"string::error_msg::oops", graph.KindString},
		{"pkg/a.go::Color.Red", graph.KindEnumMember},
		{"db::pg::public.users.email", graph.KindColumn},
		{"db::pg::public.users", graph.KindTable},
		{"cfg::env::API_KEY", graph.KindConfigKey},
		{"flag::launchdarkly::beta", graph.KindFlag},
		{"event::metric::requests_total", graph.KindEvent},
		{"migration::pg::001_init", graph.KindMigration},
		{"fixture::testdata/sample.json", graph.KindFixture},
		{"todo::pkg/a.go:42", graph.KindTodo},
		{"team::@platform", graph.KindTeam},
		{"release::v1.2.3", graph.KindRelease},
		{"license::MIT", graph.KindLicense},
		{"k8s::Deployment::default::api", graph.KindResource},
		{"kustomize::overlays/prod", graph.KindKustomization},
		{"image::nginx:1.25", graph.KindImage},
		{"contract::Request::POST::/v1/foo", graph.KindContract},
	}
	for _, c := range cases {
		g.AddNode(&graph.Node{
			ID: c.id, Kind: c.kind, Name: "x", FilePath: "pkg/a.go",
			StartLine: 1, EndLine: 1, Language: "go",
		})
	}

	result := FindDeadCode(g, nil, nil, FindDeadCodeOptions{
		// Enable the broadest opt-ins; never-dead kinds must still
		// stay suppressed even with every flag flipped.
		IncludeVariables: true, IncludeFields: true, IncludeConstants: true,
	})
	for _, e := range result {
		t.Errorf("never-reported kind %s leaked into dead_code: %s", e.Kind, e.ID)
	}
}

// TestDeadCode_FieldsAndConstantsOptIn verifies fields and constants
// are excluded by default and only appear when their opt-in flags
// fire. Mirrors the existing IncludeVariables contract.
func TestDeadCode_FieldsAndConstantsOptIn(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "pkg/a.go::User.email", Kind: graph.KindField, Name: "email",
		FilePath: "pkg/a.go", StartLine: 5, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "pkg/a.go::maxRetries", Kind: graph.KindConstant, Name: "maxRetries",
		FilePath: "pkg/a.go", StartLine: 10, Language: "go",
	})

	// Default: both excluded.
	def := FindDeadCode(g, nil, nil)
	assert.Empty(t, def, "fields and constants excluded by default")

	// Fields opt-in: only the field surfaces.
	withFields := FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeFields: true})
	require.Len(t, withFields, 1)
	assert.Equal(t, "field", withFields[0].Kind)

	// Constants opt-in: only the constant surfaces.
	withConsts := FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeConstants: true})
	require.Len(t, withConsts, 1)
	assert.Equal(t, "constant", withConsts[0].Kind)

	// Both flags: both surface.
	both := FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeFields: true, IncludeConstants: true})
	assert.Len(t, both, 2)
}

// TestDeadCode_FieldWithReadIsAlive — fields are exercised by EdgeReads,
// not EdgeCalls. The pre-fix global allowlist {Calls,References,...}
// missed every field read, so every field-with-readers also looked
// "dead" once IncludeFields was on. The per-kind allowlist must count
// EdgeReads (and EdgeWrites) for KindField, KindVariable, KindConstant.
func TestDeadCode_FieldWithReadIsAlive(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "pkg/a.go::User.email", Kind: graph.KindField, Name: "email",
		FilePath: "pkg/a.go", StartLine: 5, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "pkg/a.go::printUser", Kind: graph.KindFunction, Name: "printUser",
		FilePath: "pkg/a.go", StartLine: 20, Language: "go",
	})
	// printUser reads User.email — EdgeReads, exactly what the
	// resolver lands on a field selector.
	g.AddEdge(&graph.Edge{
		From: "pkg/a.go::printUser", To: "pkg/a.go::User.email",
		Kind: graph.EdgeReads, FilePath: "pkg/a.go", Line: 21,
	})

	result := FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeFields: true})
	for _, e := range result {
		assert.NotEqual(t, "pkg/a.go::User.email", e.ID,
			"field with an incoming Reads edge must not be reported as dead")
	}
}

// TestDeadCode_TypeReferencedAsParamIsAlive — types are exercised by
// References / TypedAs / Instantiates. The pre-fix global allowlist
// caught References but not TypedAs, so a type used only as a
// parameter declaration (no constructor, no method receivers) would
// have been reported dead. The per-kind allowlist covers it.
func TestDeadCode_TypeReferencedAsParamIsAlive(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "pkg/a.go::Config", Kind: graph.KindType, Name: "Config",
		FilePath: "pkg/a.go", StartLine: 5, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "pkg/a.go::Run#param:cfg", Kind: graph.KindParam, Name: "cfg",
		FilePath: "pkg/a.go", StartLine: 20, Language: "go",
	})
	g.AddEdge(&graph.Edge{
		From: "pkg/a.go::Run#param:cfg", To: "pkg/a.go::Config",
		Kind: graph.EdgeTypedAs, FilePath: "pkg/a.go", Line: 20,
	})

	result := FindDeadCode(g, nil, nil)
	for _, e := range result {
		assert.NotEqual(t, "pkg/a.go::Config", e.ID,
			"type referenced as a param type must not be reported as dead")
	}
}
