package mcp

import (
	"encoding/json"
	"strings"
	"testing"

	mcplib "github.com/mark3labs/mcp-go/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/zzet/gortex/internal/graph"
)

func TestPlanTurn_EmptyTask_Errors(t *testing.T) {
	srv, _ := setupTestServer(t)

	result := callTool(t, srv, "plan_turn", map[string]any{})
	assert.True(t, result.IsError, "expected error when task is missing")
}

func TestPlanTurn_AlwaysRecommendsSmartContextFirst(t *testing.T) {
	srv, _ := setupTestServer(t)

	result := callTool(t, srv, "plan_turn", map[string]any{
		"task": "refactor the helper function",
	})
	require.False(t, result.IsError)

	var resp struct {
		RecommendedCalls []recommendedCall `json:"recommended_calls"`
	}
	require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))

	require.Greater(t, len(resp.RecommendedCalls), 0)
	assert.Equal(t, "smart_context", resp.RecommendedCalls[0].Tool,
		"smart_context must be the first recommendation — the cheapest and broadest opening move")
	assert.NotEmpty(t, resp.RecommendedCalls[0].Why)
}

func TestPlanTurn_RespectsMaxCalls(t *testing.T) {
	srv, _ := setupTestServer(t)

	result := callTool(t, srv, "plan_turn", map[string]any{
		"task":      "add rate limit to helper",
		"max_calls": 2,
	})
	require.False(t, result.IsError)

	var resp struct {
		RecommendedCalls []recommendedCall `json:"recommended_calls"`
	}
	require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))
	assert.LessOrEqual(t, len(resp.RecommendedCalls), 2)
}

func TestPlanTurn_IncludesCandidates(t *testing.T) {
	srv, _ := setupTestServer(t)

	// The fixture defines "helper" and "main"; keyword "helper" must hit
	// at least one candidate.
	result := callTool(t, srv, "plan_turn", map[string]any{
		"task": "modify helper behavior",
	})
	require.False(t, result.IsError)

	var resp struct {
		TopCandidates    []string          `json:"top_candidates"`
		CandidateCount   int               `json:"candidate_count"`
		RecommendedCalls []recommendedCall `json:"recommended_calls"`
	}
	require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))

	assert.Greater(t, len(resp.TopCandidates), 0, "expected at least one BM25 candidate for 'helper'")
	assert.Equal(t, len(resp.TopCandidates), resp.CandidateCount)

	// With candidates present, we should see a get_editing_context call
	// for the top candidate's file.
	var sawEditingContext bool
	for _, rec := range resp.RecommendedCalls {
		if rec.Tool == "get_editing_context" {
			sawEditingContext = true
			_, hasPath := rec.Args["path"]
			assert.True(t, hasPath, "get_editing_context recommendation must include path arg")
		}
	}
	assert.True(t, sawEditingContext, "expected get_editing_context recommendation when candidates exist")
}

func TestPlanTurn_NoCandidates_StillEmitsFallbacks(t *testing.T) {
	srv, _ := setupTestServer(t)

	result := callTool(t, srv, "plan_turn", map[string]any{
		"task": "xyzzynothingmatchesthis",
	})
	require.False(t, result.IsError)

	var resp struct {
		TopCandidates    []string          `json:"top_candidates"`
		RecommendedCalls []recommendedCall `json:"recommended_calls"`
	}
	require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))

	// No candidates — but plan_turn must still suggest openings so the
	// agent doesn't hit a dead end.
	assert.GreaterOrEqual(t, len(resp.RecommendedCalls), 2)
	assert.Equal(t, "smart_context", resp.RecommendedCalls[0].Tool)

	// search_symbols fallback should appear somewhere in the recommendations.
	var sawSearch bool
	for _, rec := range resp.RecommendedCalls {
		if rec.Tool == "search_symbols" {
			sawSearch = true
		}
	}
	assert.True(t, sawSearch, "expected search_symbols fallback when no BM25 candidates")
}

// TestPlanTurn_ExactNameMatchLeadsCandidates pins the regression where
// a task naming a real graph symbol (e.g. "find every caller of
// renderToolsSearchResult") used to surface unrelated BM25 results
// (ts_lex from tree-sitter parser tables) because engine.SearchSymbols
// runs without the MCP search_symbols rerank pipeline. The fix
// prepends FindNodesByName hits for identifier-shape keywords so the
// user's actual target leads the candidate list.
func TestPlanTurn_ExactNameMatchLeadsCandidates(t *testing.T) {
	srv, _ := setupTestServer(t)
	// setupTestServer fixture already provides a `helper` function;
	// add a clearly-named identifier the keyword extractor will
	// classify as identifier-shape (camelCase) and the graph's name
	// index will find directly.
	srv.graph.AddNode(&graph.Node{
		ID:       "fake.go::renderToolsSearchResult",
		Name:     "renderToolsSearchResult",
		Kind:     graph.KindFunction,
		FilePath: "fake.go",
	})

	result := callTool(t, srv, "plan_turn", map[string]any{
		"task": "find every caller of renderToolsSearchResult",
	})
	require.False(t, result.IsError)

	var resp struct {
		TopCandidates []string `json:"top_candidates"`
	}
	require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))
	require.NotEmpty(t, resp.TopCandidates)
	require.Equal(t, "fake.go::renderToolsSearchResult", resp.TopCandidates[0],
		"exact-name match must lead the candidate list; got %v", resp.TopCandidates)
}

// TestExtractKeywords_PrioritisesIdentifierShape pins the regression
// where `find every caller of renderToolsSearchResult` produced
// recommendations centred on unrelated `findLocked` / `findDeclaration`
// methods because the BM25 budget filled on "find" before the actual
// identifier got a chance. After the fix, identifier-shape tokens
// surface first and English verbs ("find", "caller") are stopped.
func TestExtractKeywords_PrioritisesIdentifierShape(t *testing.T) {
	got := extractKeywords("find every caller of renderToolsSearchResult")
	require.NotEmpty(t, got)
	// Identifier-shape token must be first so the BM25 budget anchors
	// on what the user actually typed.
	require.Equal(t, "renderToolsSearchResult", got[0],
		"identifier-shape keyword must lead, got %v", got)
	// Verb-shape "find" / "every" / "caller" must be stop-listed.
	for _, kw := range got {
		require.NotEqual(t, "find", strings.ToLower(kw))
		require.NotEqual(t, "every", strings.ToLower(kw))
		require.NotEqual(t, "caller", strings.ToLower(kw))
	}
}

// TestExtractKeywords_PlainWordsStillSurfaceWhenNoIdentifier covers
// the fallback: when the task has no identifier-shape token, plain
// english keywords still come through (e.g. "trace error logger").
func TestExtractKeywords_PlainWordsStillSurfaceWhenNoIdentifier(t *testing.T) {
	got := extractKeywords("trace the error logger")
	require.Contains(t, got, "trace")
	require.Contains(t, got, "error")
	require.Contains(t, got, "logger")
}

func TestIsCallableKind(t *testing.T) {
	callable := []graph.NodeKind{
		graph.KindFunction, graph.KindMethod, graph.KindType, graph.KindInterface,
	}
	for _, k := range callable {
		assert.True(t, isCallableKind(k), "kind %s should be callable", k)
	}
	nonCallable := []graph.NodeKind{
		graph.KindFile, graph.KindImport, graph.KindVariable, graph.KindPackage,
	}
	for _, k := range nonCallable {
		assert.False(t, isCallableKind(k), "kind %s must not be callable", k)
	}
}
