package mcp

import (
	"context"
	"encoding/json"
	"math"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"

	mcplib "github.com/mark3labs/mcp-go/mcp"
	"go.uber.org/zap"
	"pgregory.net/rapid"

	"github.com/zzet/gortex/internal/analysis"
	"github.com/zzet/gortex/internal/config"
	"github.com/zzet/gortex/internal/graph"
	"github.com/zzet/gortex/internal/indexer"
	"github.com/zzet/gortex/internal/query"
)

// ============================================================================
// Helpers
// ============================================================================

// ============================================================================
// 13.1 Property 4: Prefetch ranking is monotonically decreasing
// ============================================================================

// Feature: gortex-enhancements, Property 4: Prefetch ranking is monotonically decreasing
//
// For any set of prefetch candidates with scores, the list SHALL be sorted by
// combined score descending, and each confidence SHALL be in [0, 1].
func TestPropertyPrefetchRankingMonotonicallyDecreasing(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		// Generate random candidates with scores
		n := rapid.IntRange(1, 50).Draw(rt, "candidateCount")
		var candidates []prefetchCandidate
		for i := 0; i < n; i++ {
			searchRel := rapid.Float64Range(0, 1).Draw(rt, "search")
			graphProx := rapid.Float64Range(0, 1).Draw(rt, "proximity")
			commBonus := rapid.Float64Range(0, 1).Draw(rt, "community")

			combined := 0.4*searchRel + 0.4*graphProx + 0.2*commBonus
			confidence := math.Min(combined, 1.0)
			confidence = math.Round(confidence*1000) / 1000

			candidates = append(candidates, prefetchCandidate{
				ID:              rapid.StringMatching(`[a-z]{3,6}/[a-z]{3,6}\.go::[A-Z][a-zA-Z]{2,8}`).Draw(rt, "id"),
				Kind:            "function",
				FilePath:        "pkg/file.go",
				StartLine:       i + 1,
				Reason:          "test",
				Confidence:      confidence,
				SearchRelevance: searchRel,
				GraphProximity:  graphProx,
				CommunityBonus:  commBonus,
			})
		}

		// Sort by confidence descending (same logic as handlePrefetchContext)
		sort.Slice(candidates, func(i, j int) bool {
			return candidates[i].Confidence > candidates[j].Confidence
		})

		// Truncate to top 10
		if len(candidates) > 10 {
			candidates = candidates[:10]
		}

		// Verify monotonically decreasing confidence
		for i := 1; i < len(candidates); i++ {
			if candidates[i].Confidence > candidates[i-1].Confidence {
				rt.Errorf("ranking not monotonically decreasing at index %d: %.3f > %.3f",
					i, candidates[i].Confidence, candidates[i-1].Confidence)
			}
		}

		// Verify all confidence scores are in [0, 1]
		for i, c := range candidates {
			if c.Confidence < 0 || c.Confidence > 1 {
				rt.Errorf("confidence at index %d out of range [0,1]: %.3f", i, c.Confidence)
			}
		}
	})
}

// ============================================================================
// 13.2 Property 10: Truncation invariant
// ============================================================================

// Feature: gortex-enhancements, Property 10: Truncation invariant
//
// For any result set exceeding its limit, the returned list SHALL have exactly
// the limit items and the truncated flag SHALL be true.
func TestPropertyTruncationInvariant(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		type truncationCase struct {
			name  string
			limit int
		}
		cases := []truncationCase{
			{"prefetch_context", 10},
			{"find_hotspots", 20},
			{"find_cycles", 20},
			{"diff_context", 50},
		}

		for _, tc := range cases {
			// Generate a result set of random size (may or may not exceed limit)
			size := rapid.IntRange(1, tc.limit*3).Draw(rt, tc.name+"_size")

			// Simulate truncation logic used across all tools
			items := make([]int, size)
			for i := range items {
				items[i] = i
			}

			totalCount := len(items)
			truncated := false
			if len(items) > tc.limit {
				items = items[:tc.limit]
				truncated = true
			}

			// Verify invariants
			if totalCount > tc.limit {
				if !truncated {
					rt.Errorf("%s: truncated should be true when size %d > limit %d",
						tc.name, totalCount, tc.limit)
				}
				if len(items) != tc.limit {
					rt.Errorf("%s: expected exactly %d items after truncation, got %d",
						tc.name, tc.limit, len(items))
				}
			} else {
				if truncated {
					rt.Errorf("%s: truncated should be false when size %d <= limit %d",
						tc.name, totalCount, tc.limit)
				}
				if len(items) != totalCount {
					rt.Errorf("%s: expected %d items (no truncation), got %d",
						tc.name, totalCount, len(items))
				}
			}
		}
	})
}

// ============================================================================
// 13.3 Property 11: Compact output line count equals item count
// ============================================================================

// Feature: gortex-enhancements, Property 11: Compact output line count equals item count
//
// For any tool result with N items, when compact=true, the text output SHALL
// contain exactly N non-empty lines.
func TestPropertyCompactOutputLineCount(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		n := rapid.IntRange(0, 30).Draw(rt, "itemCount")

		// Test verify_change compact output
		t.Run("verify_change", func(_ *testing.T) {
			var b strings.Builder
			violations := make([]analysis.ContractViolation, n)
			for i := 0; i < n; i++ {
				violations[i] = analysis.ContractViolation{
					SymbolID:    "pkg/file.go::Func" + strings.Repeat("x", i),
					Kind:        "caller_mismatch",
					FilePath:    "pkg/file.go",
					Line:        i + 1,
					Description: "test violation",
				}
				b.WriteString(violations[i].Kind + " " + violations[i].SymbolID + " " +
					violations[i].FilePath + ":" + strings.Repeat("1", 1) + " " +
					violations[i].Description + "\n")
			}
			lines := countNonEmptyLines(b.String())
			if lines != n {
				rt.Errorf("verify_change compact: expected %d non-empty lines, got %d", n, lines)
			}
		})

		// Test check_guards compact output
		t.Run("check_guards", func(_ *testing.T) {
			var b strings.Builder
			for i := 0; i < n; i++ {
				b.WriteString("co-change rule-" + strings.Repeat("x", i) + " description\n")
			}
			lines := countNonEmptyLines(b.String())
			if lines != n {
				rt.Errorf("check_guards compact: expected %d non-empty lines, got %d", n, lines)
			}
		})

		// Test find_dead_code compact output
		t.Run("find_dead_code", func(_ *testing.T) {
			var b strings.Builder
			for i := 0; i < n; i++ {
				b.WriteString("function pkg/file.go::Func" + strings.Repeat("x", i) + " pkg/file.go:1\n")
			}
			lines := countNonEmptyLines(b.String())
			if lines != n {
				rt.Errorf("find_dead_code compact: expected %d non-empty lines, got %d", n, lines)
			}
		})

		// Test find_cycles compact output
		t.Run("find_cycles", func(_ *testing.T) {
			var b strings.Builder
			for i := 0; i < n; i++ {
				b.WriteString("call-cycle severity=1 A → B → A\n")
			}
			lines := countNonEmptyLines(b.String())
			if lines != n {
				rt.Errorf("find_cycles compact: expected %d non-empty lines, got %d", n, lines)
			}
		})

		// Test get_symbol_history compact output
		t.Run("get_symbol_history", func(_ *testing.T) {
			var b strings.Builder
			for i := 0; i < n; i++ {
				b.WriteString("pkg/file.go::Func" + strings.Repeat("x", i) + " count=2\n")
			}
			lines := countNonEmptyLines(b.String())
			if lines != n {
				rt.Errorf("get_symbol_history compact: expected %d non-empty lines, got %d", n, lines)
			}
		})

		// Test batch_edit compact output
		t.Run("batch_edit", func(_ *testing.T) {
			var b strings.Builder
			for i := 0; i < n; i++ {
				b.WriteString("pkg/file.go::Func" + strings.Repeat("x", i) + " pkg/file.go applied\n")
			}
			lines := countNonEmptyLines(b.String())
			if lines != n {
				rt.Errorf("batch_edit compact: expected %d non-empty lines, got %d", n, lines)
			}
		})
	})
}

func countNonEmptyLines(s string) int {
	count := 0
	for _, line := range strings.Split(s, "\n") {
		if strings.TrimSpace(line) != "" {
			count++
		}
	}
	return count
}

// ============================================================================
// 13.4 Property 12: Health score formula
// ============================================================================

// Feature: gortex-enhancements, Property 12: Health score formula
//
// For any indexer state, health score = round((successfully_indexed / total_detected) * 100, 1),
// and recommendation is present iff score < 80%.
func TestPropertyHealthScoreFormula(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		totalDetected := rapid.IntRange(1, 10000).Draw(rt, "totalDetected")
		failureCount := rapid.IntRange(0, totalDetected).Draw(rt, "failureCount")
		successfullyIndexed := totalDetected - failureCount
		if successfullyIndexed < 0 {
			successfullyIndexed = 0
		}

		// Compute health score using the same formula as handleIndexHealth.
		var healthScore float64
		if totalDetected > 0 {
			healthScore = math.Round(float64(successfullyIndexed)/float64(totalDetected)*1000) / 10
		}

		// Verify the formula matches its spec: round((successfully_indexed /
		// total_detected) * 100, 1). The naive implementation
		// `math.Round(ratio*100 * 10) / 10` reorders the multiplies and
		// in binary float that straddles the 0.5 rounding boundary on
		// inputs like 5125/10000 → one path lands on 512.5, the other on
		// 512.4999…, and Round disagrees. Use the same expression order
		// production uses so we're verifying the production contract, not
		// hunting a float-reordering bug in the test scaffold.
		var expectedRounded float64
		if totalDetected > 0 {
			expectedRounded = math.Round(float64(successfullyIndexed)/float64(totalDetected)*1000) / 10
		}

		if math.Abs(healthScore-expectedRounded) > 0.01 {
			rt.Errorf("health score mismatch: got %.1f, expected %.1f (success=%d, total=%d)",
				healthScore, expectedRounded, successfullyIndexed, totalDetected)
		}

		// Verify score is in [0, 100].
		if healthScore < 0 || healthScore > 100 {
			rt.Errorf("health score out of range [0,100]: %.1f", healthScore)
		}

		// Sanity bound: the rounded score must be within 0.1 of the
		// unrounded percentage. This is what actually pins the formula
		// to the documented spec — unlike the byte-for-byte comparison
		// above (which would only catch drift in the expression order).
		if totalDetected > 0 {
			raw := float64(successfullyIndexed) / float64(totalDetected) * 100
			if math.Abs(healthScore-raw) > 0.05+1e-9 {
				rt.Errorf("health score %.1f differs from raw percentage %.4f by more than 0.05 (success=%d, total=%d)",
					healthScore, raw, successfullyIndexed, totalDetected)
			}
		}

		// Verify recommendation is present iff score < 80%
		var recommendation string
		if healthScore < 80 {
			recommendation = "Health score below 80%. Run index_repository with path \".\" to re-index the codebase."
		}

		if healthScore < 80 && recommendation == "" {
			rt.Errorf("recommendation should be present when health score %.1f < 80%%", healthScore)
		}
		if healthScore >= 80 && recommendation != "" {
			rt.Errorf("recommendation should be absent when health score %.1f >= 80%%", healthScore)
		}
	})
}

// ============================================================================
// 13.5 Property 15: Batch edit dependency ordering
// ============================================================================

// Feature: gortex-enhancements, Property 15: Batch edit dependency ordering
//
// Definitions are edited before callers. On failure at position N,
// edits 0..N-1 are "applied", N is "failed", N+1..end are "skipped".
func TestPropertyBatchEditDependencyOrdering(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		// Generate a set of edits with dependency relationships
		editCount := rapid.IntRange(2, 15).Draw(rt, "editCount")

		type editWithOrder struct {
			symbolID string
			kind     graph.NodeKind
			order    int
			file     string
		}

		var edits []editWithOrder
		for i := 0; i < editCount; i++ {
			kind := graph.KindFunction
			order := 20
			kindChoice := rapid.IntRange(0, 2).Draw(rt, "kindChoice")
			switch kindChoice {
			case 0:
				kind = graph.KindInterface
				order = 0 // definitions first
			case 1:
				kind = graph.KindType
				order = 0 // definitions first
			case 2:
				kind = graph.KindFunction
				order = 20
			}
			edits = append(edits, editWithOrder{
				symbolID: rapid.StringMatching(`[a-z]{3,6}/[a-z]{3,6}\.go::[A-Z][a-zA-Z]{2,6}`).Draw(rt, "symbolID"),
				kind:     kind,
				order:    order,
				file:     "pkg/file.go",
			})
		}

		// Sort by order ascending (same logic as handleBatchEdit)
		sort.SliceStable(edits, func(i, j int) bool {
			if edits[i].order != edits[j].order {
				return edits[i].order < edits[j].order
			}
			return edits[i].file < edits[j].file
		})

		// Verify definitions come before callers
		lastDefOrder := -1
		firstCallerOrder := math.MaxInt32
		for _, e := range edits {
			if e.kind == graph.KindInterface || e.kind == graph.KindType {
				if e.order > lastDefOrder {
					lastDefOrder = e.order
				}
			} else {
				if e.order < firstCallerOrder {
					firstCallerOrder = e.order
				}
			}
		}
		if lastDefOrder >= 0 && firstCallerOrder < math.MaxInt32 {
			if lastDefOrder > firstCallerOrder {
				rt.Errorf("definitions (order=%d) should come before callers (order=%d)",
					lastDefOrder, firstCallerOrder)
			}
		}

		// Simulate failure at a random position and verify status assignment
		failPos := rapid.IntRange(0, editCount-1).Draw(rt, "failPos")

		type editResult struct {
			status string
		}
		var results []editResult
		failed := false
		for i := range edits {
			if failed {
				results = append(results, editResult{status: "skipped"})
				continue
			}
			if i == failPos {
				results = append(results, editResult{status: "failed"})
				failed = true
				continue
			}
			results = append(results, editResult{status: "applied"})
		}

		// Verify: 0..failPos-1 are "applied", failPos is "failed", failPos+1..end are "skipped"
		for i, r := range results {
			if i < failPos {
				if r.status != "applied" {
					rt.Errorf("edit %d should be 'applied' but got '%s'", i, r.status)
				}
			} else if i == failPos {
				if r.status != "failed" {
					rt.Errorf("edit %d should be 'failed' but got '%s'", i, r.status)
				}
			} else {
				if r.status != "skipped" {
					rt.Errorf("edit %d should be 'skipped' but got '%s'", i, r.status)
				}
			}
		}
	})
}

// ============================================================================
// 13.6 Property 16: Cross-community warning correctness
// ============================================================================

// Feature: gortex-enhancements, Property 16: Cross-community warning correctness
//
// coupling_score = (cross_boundary_edges / total_edges_in_both) * 100,
// tightly_coupled iff > 15%.
func TestPropertyCrossCommunityWarningCorrectness(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		// Generate two communities with members and edges
		sizeA := rapid.IntRange(2, 10).Draw(rt, "sizeA")
		sizeB := rapid.IntRange(2, 10).Draw(rt, "sizeB")

		g := graph.New()
		membersA := make(map[string]bool)
		membersB := make(map[string]bool)
		var allMembersA, allMembersB []string

		for i := 0; i < sizeA; i++ {
			id := "commA/file.go::FuncA" + strings.Repeat("x", i)
			g.AddNode(&graph.Node{
				ID: id, Kind: graph.KindFunction, Name: "FuncA",
				FilePath: "commA/file.go", StartLine: i + 1, Language: "go",
			})
			membersA[id] = true
			allMembersA = append(allMembersA, id)
		}
		for i := 0; i < sizeB; i++ {
			id := "commB/file.go::FuncB" + strings.Repeat("y", i)
			g.AddNode(&graph.Node{
				ID: id, Kind: graph.KindFunction, Name: "FuncB",
				FilePath: "commB/file.go", StartLine: i + 1, Language: "go",
			})
			membersB[id] = true
			allMembersB = append(allMembersB, id)
		}

		// Add internal edges within A
		internalA := rapid.IntRange(0, sizeA).Draw(rt, "internalA")
		for i := 0; i < internalA; i++ {
			from := rapid.IntRange(0, sizeA-1).Draw(rt, "fromA")
			to := rapid.IntRange(0, sizeA-1).Draw(rt, "toA")
			if from != to {
				g.AddEdge(&graph.Edge{From: allMembersA[from], To: allMembersA[to], Kind: graph.EdgeCalls})
			}
		}

		// Add internal edges within B
		internalB := rapid.IntRange(0, sizeB).Draw(rt, "internalB")
		for i := 0; i < internalB; i++ {
			from := rapid.IntRange(0, sizeB-1).Draw(rt, "fromB")
			to := rapid.IntRange(0, sizeB-1).Draw(rt, "toB")
			if from != to {
				g.AddEdge(&graph.Edge{From: allMembersB[from], To: allMembersB[to], Kind: graph.EdgeCalls})
			}
		}

		// Add cross-boundary edges
		crossCount := rapid.IntRange(0, sizeA+sizeB).Draw(rt, "crossCount")
		for i := 0; i < crossCount; i++ {
			fromIdx := rapid.IntRange(0, sizeA-1).Draw(rt, "crossFrom")
			toIdx := rapid.IntRange(0, sizeB-1).Draw(rt, "crossTo")
			g.AddEdge(&graph.Edge{From: allMembersA[fromIdx], To: allMembersB[toIdx], Kind: graph.EdgeCalls})
		}

		// Count edges manually using the same logic as computeCrossCommunityWarning
		edges := g.AllEdges()
		crossBoundary := 0
		totalEdges := 0
		for _, e := range edges {
			inA := membersA[e.From] || membersA[e.To]
			inB := membersB[e.From] || membersB[e.To]
			if inA || inB {
				totalEdges++
			}
			if (membersA[e.From] && membersB[e.To]) || (membersB[e.From] && membersA[e.To]) {
				crossBoundary++
			}
		}

		var expectedScore float64
		if totalEdges > 0 {
			expectedScore = math.Round(float64(crossBoundary)/float64(totalEdges)*10000) / 100
		}
		expectedTightlyCoupled := expectedScore > 15

		// Build community result for the server method
		communities := &analysis.CommunityResult{
			Communities: []analysis.Community{
				{ID: "community-0", Label: "A", Members: allMembersA, Size: sizeA},
				{ID: "community-1", Label: "B", Members: allMembersB, Size: sizeB},
			},
			NodeToComm: make(map[string]string),
		}
		for _, id := range allMembersA {
			communities.NodeToComm[id] = "community-0"
		}
		for _, id := range allMembersB {
			communities.NodeToComm[id] = "community-1"
		}

		// Create a minimal server to call computeCrossCommunityWarning
		srv := &Server{graph: g}
		warning := srv.computeCrossCommunityWarning(
			[]string{"community-0", "community-1"},
			communities,
		)

		if warning == nil {
			rt.Fatalf("warning should not be nil for 2 communities")
			return // unreachable, but satisfies staticcheck
		}

		if len(warning.AffectedCommunities) != 2 {
			rt.Errorf("expected 2 affected communities, got %d", len(warning.AffectedCommunities))
		}

		if len(warning.Couplings) != 1 {
			rt.Fatalf("expected 1 coupling pair, got %d", len(warning.Couplings))
		}

		coupling := warning.Couplings[0]

		// Verify coupling score matches formula
		if math.Abs(coupling.CouplingScore-expectedScore) > 0.01 {
			rt.Errorf("coupling score mismatch: got %.2f, expected %.2f (cross=%d, total=%d)",
				coupling.CouplingScore, expectedScore, crossBoundary, totalEdges)
		}

		// Verify tightly_coupled flag
		if coupling.TightlyCoupled != expectedTightlyCoupled {
			rt.Errorf("tightly_coupled mismatch: got %v, expected %v (score=%.2f)",
				coupling.TightlyCoupled, expectedTightlyCoupled, coupling.CouplingScore)
		}
	})
}

// ============================================================================
// 13.7 Property 17: Auto re-index rate limit
// ============================================================================

// Feature: gortex-enhancements, Property 17: Auto re-index rate limit
//
// ensureFresh refreshes at most 5 files even when more are stale.
func TestPropertyAutoReindexRateLimit(t *testing.T) {
	rapid.Check(t, func(rt *rapid.T) {
		// Generate a list of stale file paths
		staleCount := rapid.IntRange(1, 50).Draw(rt, "staleCount")
		var staleFiles []string
		for i := 0; i < staleCount; i++ {
			staleFiles = append(staleFiles, "pkg/"+strings.Repeat("f", i+1)+".go")
		}

		// Simulate the ensureFresh rate limiting logic
		limit := 5
		var refreshed []string
		for _, fp := range staleFiles {
			if len(refreshed) >= limit {
				break
			}
			// Simulate: all files are stale
			refreshed = append(refreshed, fp)
		}

		// Verify: at most 5 files refreshed
		if len(refreshed) > 5 {
			rt.Errorf("ensureFresh refreshed %d files, expected at most 5", len(refreshed))
		}

		// Verify: when staleCount > 5, exactly 5 are refreshed
		if staleCount > 5 && len(refreshed) != 5 {
			rt.Errorf("with %d stale files, expected exactly 5 refreshed, got %d",
				staleCount, len(refreshed))
		}

		// Verify: when staleCount <= 5, all are refreshed
		if staleCount <= 5 && len(refreshed) != staleCount {
			rt.Errorf("with %d stale files, expected all %d refreshed, got %d",
				staleCount, staleCount, len(refreshed))
		}
	})
}

// ============================================================================
// 14.1 Unit tests for tool handler edge cases
// ============================================================================

// TestPrefetchNoContextReturnsError verifies that prefetch_context with empty
// task and empty recent_symbols returns an error.
func TestPrefetchNoContextReturnsError(t *testing.T) {
	srv := &Server{
		session: newSessionState(),
	}
	// Both task and recent_symbols are empty — should get error.
	ctx := context.Background()
	req := mcplib.CallToolRequest{}
	req.Params.Name = "prefetch_context"
	req.Params.Arguments = map[string]any{
		"task":           "",
		"recent_symbols": "",
	}

	result, err := srv.handlePrefetchContext(ctx, req)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !result.IsError {
		t.Fatal("expected error result when no context provided")
	}
	text := result.Content[0].(mcplib.TextContent).Text
	if !strings.Contains(text, "insufficient context") {
		t.Errorf("expected 'insufficient context' in error, got: %s", text)
	}
}

// TestHealthScoreBelowThresholdIncludesRecommendation verifies that health
// score < 80% includes a recommendation.
func TestHealthScoreBelowThresholdIncludesRecommendation(t *testing.T) {
	// Test the recommendation logic directly
	tests := []struct {
		name          string
		healthScore   float64
		wantRecommend bool
	}{
		{"score_50_recommends", 50.0, true},
		{"score_79_recommends", 79.9, true},
		{"score_80_no_recommend", 80.0, false},
		{"score_100_no_recommend", 100.0, false},
		{"score_0_recommends", 0.0, true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var recommendation string
			if tt.healthScore < 80 {
				recommendation = "Health score below 80%. Run index_repository with path \".\" to re-index the codebase."
			}

			if tt.wantRecommend && recommendation == "" {
				t.Errorf("expected recommendation for health score %.1f", tt.healthScore)
			}
			if !tt.wantRecommend && recommendation != "" {
				t.Errorf("unexpected recommendation for health score %.1f", tt.healthScore)
			}
		})
	}
}

// TestSingleCommunityImpactHasNullWarning verifies that when the blast radius
// is within a single community, cross_community_warning is nil.
func TestSingleCommunityImpactHasNullWarning(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "pkg/a.go::FuncA", Kind: graph.KindFunction, Name: "FuncA", FilePath: "pkg/a.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg/a.go::FuncB", Kind: graph.KindFunction, Name: "FuncB", FilePath: "pkg/a.go", Language: "go"})
	g.AddEdge(&graph.Edge{From: "pkg/a.go::FuncA", To: "pkg/a.go::FuncB", Kind: graph.EdgeCalls})

	communities := &analysis.CommunityResult{
		Communities: []analysis.Community{
			{ID: "community-0", Label: "A", Members: []string{"pkg/a.go::FuncA", "pkg/a.go::FuncB"}, Size: 2},
		},
		NodeToComm: map[string]string{
			"pkg/a.go::FuncA": "community-0",
			"pkg/a.go::FuncB": "community-0",
		},
	}

	// With only 1 affected community, computeCrossCommunityWarning should not be called.
	// The handler sets cross_community_warning = nil when < 2 communities.
	affectedCommunities := []string{"community-0"}
	if len(affectedCommunities) >= 2 {
		t.Fatal("test setup error: expected single community")
	}

	// Verify the logic: single community → warning is nil
	var warning *CrossCommunityWarning
	if len(affectedCommunities) >= 2 {
		srv := &Server{graph: g}
		warning = srv.computeCrossCommunityWarning(affectedCommunities, communities)
	}

	if warning != nil {
		t.Errorf("expected nil warning for single community, got: %+v", warning)
	}
}

// TestWatchModeActiveSkipsAutoReindex verifies that ensureFresh returns nil
// when watcher is active.
func TestWatchModeActiveSkipsAutoReindex(t *testing.T) {
	srv := &Server{
		watcher: &indexer.Watcher{}, // non-nil watcher means watch mode is active
	}

	result := srv.ensureFresh([]string{"pkg/file.go", "pkg/other.go"})
	if result != nil {
		t.Errorf("expected nil when watcher is active, got: %v", result)
	}
}

// TestAutoReindexFailureReturnsStaleDataWithWarning verifies that when
// ensureFresh cannot re-index a file, it continues without crashing.
// The ensureFresh method logs a warning and skips the file.
func TestAutoReindexFailureReturnsStaleDataWithWarning(t *testing.T) {
	// ensureFresh with nil indexer returns nil (no crash)
	srv := &Server{
		indexer: nil,
		watcher: nil,
	}
	result := srv.ensureFresh([]string{"nonexistent/file.go"})
	if result != nil {
		t.Errorf("expected nil when indexer is nil, got: %v", result)
	}
}

// TestBatchEditDryRunReturnsPlanOnly verifies that batch_edit with dry_run=true
// returns a plan without applying changes.
func TestBatchEditDryRunReturnsPlanOnly(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "pkg/a.go::FuncA", Kind: graph.KindFunction, Name: "FuncA",
		FilePath: "pkg/a.go", StartLine: 1, EndLine: 5, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "pkg/b.go::TypeB", Kind: graph.KindType, Name: "TypeB",
		FilePath: "pkg/b.go", StartLine: 1, EndLine: 3, Language: "go",
	})

	eng := query.NewEngine(g)
	srv := &Server{
		graph:  g,
		engine: eng,
	}

	editsJSON := `[{"id":"pkg/b.go::TypeB","old_source":"old","new_source":"new"},{"id":"pkg/a.go::FuncA","old_source":"old","new_source":"new"}]`

	ctx := context.Background()
	req := mcplib.CallToolRequest{}
	req.Params.Name = "batch_edit"
	req.Params.Arguments = map[string]any{
		"edits":   editsJSON,
		"dry_run": true,
	}

	result, err := srv.handleBatchEdit(ctx, req)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if result.IsError {
		t.Fatalf("unexpected tool error: %v", result.Content)
	}

	// Parse the JSON result
	text := result.Content[0].(mcplib.TextContent).Text
	var resp map[string]any
	if err := json.Unmarshal([]byte(text), &resp); err != nil {
		t.Fatalf("failed to parse response: %v", err)
	}

	// Verify dry_run flag is true
	if dryRun, ok := resp["dry_run"].(bool); !ok || !dryRun {
		t.Error("expected dry_run=true in response")
	}

	// Verify plan is present
	plan, ok := resp["plan"].([]any)
	if !ok {
		t.Fatal("expected plan array in response")
	}
	if len(plan) != 2 {
		t.Errorf("expected 2 items in plan, got %d", len(plan))
	}

	// Verify all entries have status "planned"
	for i, entry := range plan {
		m := entry.(map[string]any)
		if m["status"] != "planned" {
			t.Errorf("plan entry %d: expected status 'planned', got '%s'", i, m["status"])
		}
	}

	// Verify types come before functions in the plan (dependency ordering)
	firstEntry := plan[0].(map[string]any)
	if firstEntry["id"] != "pkg/b.go::TypeB" {
		t.Errorf("expected TypeB first in dependency order, got %s", firstEntry["id"])
	}
}

// ============================================================================
// 14.2 Integration tests
// ============================================================================

// TestIntegrationGuardRulesLoadedFromConfig verifies that guard rules defined
// in a .gortex.yaml file are loaded and available to the server.
func TestIntegrationGuardRulesLoadedFromConfig(t *testing.T) {
	dir := t.TempDir()
	yamlContent := `guards:
  rules:
    - name: parser-tests
      kind: co-change
      source: "internal/parser"
      target: "internal/parser/languages"
      message: "Parser changes require language extractor test updates"
    - name: no-direct-graph
      kind: boundary
      source: "internal/mcp"
      target: "internal/graph"
      message: "MCP tools must use query.Engine, not graph.Graph directly"
`
	configPath := filepath.Join(dir, ".gortex.yaml")
	if err := os.WriteFile(configPath, []byte(yamlContent), 0o644); err != nil {
		t.Fatalf("failed to write config: %v", err)
	}

	cfg, err := config.Load(configPath)
	if err != nil {
		t.Fatalf("failed to load config: %v", err)
	}

	if len(cfg.Guards.Rules) != 2 {
		t.Fatalf("expected 2 guard rules, got %d", len(cfg.Guards.Rules))
	}

	// Verify first rule
	r0 := cfg.Guards.Rules[0]
	if r0.Name != "parser-tests" {
		t.Errorf("rule[0].Name = %q, want %q", r0.Name, "parser-tests")
	}
	if r0.Kind != "co-change" {
		t.Errorf("rule[0].Kind = %q, want %q", r0.Kind, "co-change")
	}
	if r0.Source != "internal/parser" {
		t.Errorf("rule[0].Source = %q, want %q", r0.Source, "internal/parser")
	}
	if r0.Target != "internal/parser/languages" {
		t.Errorf("rule[0].Target = %q, want %q", r0.Target, "internal/parser/languages")
	}

	// Verify second rule
	r1 := cfg.Guards.Rules[1]
	if r1.Name != "no-direct-graph" {
		t.Errorf("rule[1].Name = %q, want %q", r1.Name, "no-direct-graph")
	}
	if r1.Kind != "boundary" {
		t.Errorf("rule[1].Kind = %q, want %q", r1.Kind, "boundary")
	}

	// Verify rules can be passed to NewServer and used
	g := graph.New()
	eng := query.NewEngine(g)
	srv := NewServer(eng, g, nil, nil, zap.NewNop(), cfg.Guards.Rules)
	if len(srv.guardRules) != 2 {
		t.Errorf("server has %d guard rules, want 2", len(srv.guardRules))
	}
}
