package mcp

import (
	"context"
	"encoding/json"
	"testing"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/zzet/gortex/internal/analysis"
	"github.com/zzet/gortex/internal/graph"
)

// newGapsTestServer builds a server pre-seeded with a small synthetic
// graph the tests can layer over. Nodes are deliberately wired so each
// rollup category has a planted, predictable instance.
func newGapsTestServer(t *testing.T) *Server {
	t.Helper()
	g := graph.New()

	// A — disconnected function (no edges).
	g.AddNode(&graph.Node{ID: "p/lonely.go::A", Name: "A", Kind: graph.KindFunction, FilePath: "p/lonely.go", StartLine: 10})

	// B — hub function, high fan-in; will be untested when we omit coverage.
	g.AddNode(&graph.Node{ID: "p/hub.go::B", Name: "B", Kind: graph.KindFunction, FilePath: "p/hub.go", StartLine: 5})
	// C, D, E — call B (giving B fan_in=3).
	for _, id := range []string{"p/caller1.go::C", "p/caller2.go::D", "p/caller3.go::E"} {
		g.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction, FilePath: id})
		g.AddEdge(&graph.Edge{From: id, To: "p/hub.go::B", Kind: graph.EdgeCalls})
	}

	// F — function with coverage above threshold; should NOT be flagged.
	g.AddNode(&graph.Node{
		ID:        "p/covered.go::F",
		Name:      "F",
		Kind:      graph.KindFunction,
		FilePath:  "p/covered.go",
		StartLine: 12,
		Meta:      map[string]any{"coverage_pct": 90.0},
	})
	// Give F one caller so it's not also "disconnected".
	g.AddNode(&graph.Node{ID: "p/caller4.go::G", Name: "G", Kind: graph.KindFunction, FilePath: "p/caller4.go"})
	g.AddEdge(&graph.Edge{From: "p/caller4.go::G", To: "p/covered.go::F", Kind: graph.EdgeCalls})

	s := &Server{
		graph:      g,
		session:    newSessionState(),
		tokenStats: &tokenStats{},
		symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
		sessions:   newSessionMap(),
		toolScopes: newScopeRegistry(),
	}
	return s
}

func setCommunitiesForTest(s *Server, communities []analysis.Community) {
	s.analysisMu.Lock()
	defer s.analysisMu.Unlock()
	s.communities = &analysis.CommunityResult{
		Communities: communities,
	}
}

func callGapsHandler(t *testing.T, s *Server, args map[string]any) map[string]any {
	t.Helper()
	req := mcp.CallToolRequest{}
	req.Params.Arguments = args
	res, err := s.handleGetKnowledgeGaps(context.Background(), req)
	require.NoError(t, err)
	require.NotNil(t, res)
	require.False(t, res.IsError, "handler error: %+v", res.Content)
	tc, ok := res.Content[0].(mcp.TextContent)
	require.True(t, ok)
	var m map[string]any
	require.NoError(t, json.Unmarshal([]byte(tc.Text), &m))
	return m
}

func TestKnowledgeGaps_DisconnectedSurfaced(t *testing.T) {
	s := newGapsTestServer(t)
	out := callGapsHandler(t, s, map[string]any{})

	disc, _ := out["disconnected_nodes"].([]any)
	require.NotEmpty(t, disc)
	first := disc[0].(map[string]any)
	assert.Equal(t, "p/lonely.go::A", first["id"], "the only disconnected node should surface")
}

func TestKnowledgeGaps_UntestedHotspotFlagged(t *testing.T) {
	s := newGapsTestServer(t)
	out := callGapsHandler(t, s, map[string]any{})

	hotspots, _ := out["untested_hotspots"].([]any)
	require.NotEmpty(t, hotspots, "B has 3 callers and no coverage data — must be flagged")
	top := hotspots[0].(map[string]any)
	assert.Equal(t, "p/hub.go::B", top["id"])
	assert.EqualValues(t, 3, top["fan_in"].(float64))
	assert.Equal(t, false, top["has_coverage"])
}

func TestKnowledgeGaps_CoveredHotspotNotFlagged(t *testing.T) {
	s := newGapsTestServer(t)
	out := callGapsHandler(t, s, map[string]any{
		"min_coverage_pct": 50.0,
	})

	hotspots, _ := out["untested_hotspots"].([]any)
	for _, h := range hotspots {
		m := h.(map[string]any)
		assert.NotEqual(t, "p/covered.go::F", m["id"], "F's 90%% coverage clears the 50%% threshold")
	}
}

func TestKnowledgeGaps_ThinAndSingleFileCommunities(t *testing.T) {
	s := newGapsTestServer(t)
	setCommunitiesForTest(s, []analysis.Community{
		// Thin: 2 members.
		{ID: "c-tiny", Label: "tiny", Size: 2, Files: []string{"a.go", "b.go"}, Members: []string{"x", "y"}},
		// Single-file: 5 members all from one file.
		{ID: "c-mono", Label: "mono", Size: 5, Files: []string{"single.go"}, Members: []string{"m1", "m2", "m3", "m4", "m5"}},
		// Healthy: above threshold and multi-file — should NOT appear in either bucket.
		{ID: "c-fat", Label: "fat", Size: 10, Files: []string{"a.go", "b.go", "c.go"}, Members: []string{}},
	})

	out := callGapsHandler(t, s, map[string]any{})

	thin, _ := out["thin_communities"].([]any)
	require.Len(t, thin, 1)
	assert.Equal(t, "c-tiny", thin[0].(map[string]any)["id"])

	single, _ := out["single_file_communities"].([]any)
	require.Len(t, single, 1)
	assert.Equal(t, "c-mono", single[0].(map[string]any)["id"])
}

func TestKnowledgeGaps_PathPrefixFilter(t *testing.T) {
	s := newGapsTestServer(t)
	out := callGapsHandler(t, s, map[string]any{
		"path_prefix": "p/lonely",
	})

	// Only A matches path_prefix.
	disc, _ := out["disconnected_nodes"].([]any)
	require.Len(t, disc, 1)
	assert.Equal(t, "p/lonely.go::A", disc[0].(map[string]any)["id"])

	// Hotspot B is in p/hub.go — outside the prefix; must be empty.
	hotspots, _ := out["untested_hotspots"].([]any)
	assert.Empty(t, hotspots)
}

func TestKnowledgeGaps_ThresholdsEchoed(t *testing.T) {
	s := newGapsTestServer(t)
	out := callGapsHandler(t, s, map[string]any{
		"thin_community_size": 5,
		"min_coverage_pct":    75.0,
		"hotspot_limit":       5,
		"limit_per_category":  10,
	})

	th := out["thresholds"].(map[string]any)
	assert.EqualValues(t, 5, th["thin_community_size"].(float64))
	assert.EqualValues(t, 75, th["min_coverage_pct"].(float64))
	assert.EqualValues(t, 5, th["hotspot_limit"].(float64))
	assert.EqualValues(t, 10, th["limit_per_category"].(float64))
}

func TestKnowledgeGaps_LimitPerCategoryHonored(t *testing.T) {
	s := newGapsTestServer(t)
	// Add 5 disconnected nodes; cap at 3 should yield 3.
	for i := range 5 {
		id := "p/extra.go::Dx" + string(rune('A'+i))
		s.graph.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction, FilePath: "p/extra.go", StartLine: i + 1})
	}

	out := callGapsHandler(t, s, map[string]any{
		"limit_per_category": 3,
	})
	disc, _ := out["disconnected_nodes"].([]any)
	assert.LessOrEqual(t, len(disc), 3, "limit_per_category caps each rollup")
}

func TestKnowledgeGaps_SummaryCounts(t *testing.T) {
	s := newGapsTestServer(t)
	setCommunitiesForTest(s, []analysis.Community{
		{ID: "c1", Label: "x", Size: 2, Files: []string{"x.go"}},
	})

	out := callGapsHandler(t, s, map[string]any{})
	summary := out["summary"].(map[string]any)

	assert.EqualValues(t, 1, summary["disconnected_count"].(float64), "exactly one disconnected node")
	assert.EqualValues(t, 1, summary["thin_count"].(float64))
	assert.EqualValues(t, 1, summary["single_file_count"].(float64), "single-file community also matches")
	assert.GreaterOrEqual(t, summary["untested_count"].(float64), 1.0)
}
