package mcp

import (
	"encoding/json"
	"os"
	"path/filepath"
	"testing"

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

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

// setupUntestedFixture builds an indexed repo with one covered symbol, one
// uncovered symbol, and a test file so reachableFromTests has something to
// walk. Isolated from setupTestServer because the base fixture has no test
// files — every symbol would be trivially uncovered there.
func setupUntestedFixture(t *testing.T) *Server {
	t.Helper()
	dir := t.TempDir()

	write := func(name, body string) {
		require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644))
	}

	write("lib.go", `package lib

// Covered is called by TestCovered — test-reachable.
func Covered() int { return 42 }

// Uncovered has no test reaching it; should be flagged.
func Uncovered() int { return 7 }

// FanInTarget is called by Covered and Uncovered; useful for fan_in assertions.
func FanInTarget() int { return 1 }
`)
	write("lib_helpers.go", `package lib

func caller1() int { return FanInTarget() }
func caller2() int { return FanInTarget() + Covered() }
`)
	write("lib_test.go", `package lib

import "testing"

func TestCovered(t *testing.T) {
	if Covered() != 42 {
		t.Fatal("bad")
	}
}
`)

	g := graph.New()
	reg := parser.NewRegistry()
	languages.RegisterAll(reg)
	cfg := config.Default()
	idx := indexer.New(g, reg, cfg.Index, zap.NewNop())
	_, err := idx.Index(dir)
	require.NoError(t, err)

	eng := query.NewEngine(g)
	srv := NewServer(eng, g, idx, nil, zap.NewNop(), nil)
	srv.RunAnalysis()
	return srv
}

type untestedEntry struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Kind     string `json:"kind"`
	FilePath string `json:"file_path"`
	Line     int    `json:"line"`
	FanIn    int    `json:"fan_in"`
}

type untestedResp struct {
	Untested        []untestedEntry `json:"untested"`
	TotalCandidates int             `json:"total_candidates"`
	TotalUncovered  int             `json:"total_uncovered"`
	CoverageRatio   float64         `json:"coverage_ratio"`
	Truncated       bool            `json:"truncated"`
}

func decodeUntested(t *testing.T, result *mcplib.CallToolResult) untestedResp {
	t.Helper()
	var resp untestedResp
	require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &resp))
	return resp
}

func TestGetUntestedSymbols_FlagsUncoveredSymbols(t *testing.T) {
	srv := setupUntestedFixture(t)

	result := callTool(t, srv, "get_untested_symbols", nil)
	require.False(t, result.IsError)
	resp := decodeUntested(t, result)

	// Uncovered must appear; Covered must NOT (TestCovered reaches it).
	names := make(map[string]bool)
	for _, e := range resp.Untested {
		names[e.Name] = true
	}
	assert.True(t, names["Uncovered"], "Uncovered function must be flagged; got: %+v", resp.Untested)
	assert.False(t, names["Covered"], "Covered function must NOT be flagged; got: %+v", resp.Untested)
}

func TestGetUntestedSymbols_ExcludesTestCodeItself(t *testing.T) {
	srv := setupUntestedFixture(t)

	result := callTool(t, srv, "get_untested_symbols", nil)
	resp := decodeUntested(t, result)

	// TestCovered is defined in a *_test.go file — it's test code, not a
	// candidate. It must never appear in the uncovered list.
	for _, e := range resp.Untested {
		assert.NotEqual(t, "TestCovered", e.Name,
			"test functions must be excluded from candidates")
		assert.NotContains(t, e.FilePath, "_test.go",
			"entries from test files leaked through: %+v", e)
	}
}

func TestGetUntestedSymbols_RankedByFanIn(t *testing.T) {
	srv := setupUntestedFixture(t)

	result := callTool(t, srv, "get_untested_symbols", nil)
	resp := decodeUntested(t, result)

	// Ranking contract: fan_in strictly non-increasing.
	for i := 0; i+1 < len(resp.Untested); i++ {
		assert.GreaterOrEqual(t,
			resp.Untested[i].FanIn,
			resp.Untested[i+1].FanIn,
			"entries must be sorted by fan_in descending")
	}
}

func TestGetUntestedSymbols_MinFanInFilter(t *testing.T) {
	srv := setupUntestedFixture(t)

	result := callTool(t, srv, "get_untested_symbols", map[string]any{
		"min_fan_in": 1,
	})
	resp := decodeUntested(t, result)

	for _, e := range resp.Untested {
		assert.GreaterOrEqual(t, e.FanIn, 1,
			"min_fan_in=1 must exclude symbols with fan_in=0")
	}
}

func TestGetUntestedSymbols_CoverageRatioInRange(t *testing.T) {
	srv := setupUntestedFixture(t)

	result := callTool(t, srv, "get_untested_symbols", nil)
	resp := decodeUntested(t, result)

	assert.GreaterOrEqual(t, resp.CoverageRatio, 0.0)
	assert.LessOrEqual(t, resp.CoverageRatio, 1.0)
	assert.Greater(t, resp.TotalCandidates, 0)
}

func TestGetUntestedSymbols_FilePrefix(t *testing.T) {
	srv := setupUntestedFixture(t)

	// Prefix that matches nothing should yield zero candidates and zero
	// uncovered — still a valid well-shaped response.
	result := callTool(t, srv, "get_untested_symbols", map[string]any{
		"file_prefix": "nonexistent/",
	})
	resp := decodeUntested(t, result)
	assert.Equal(t, 0, resp.TotalCandidates)
	assert.Equal(t, 0, resp.TotalUncovered)
	assert.Empty(t, resp.Untested)
}
