package mcp

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

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

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

func TestApplyPathFilter_AnchoredPrefix(t *testing.T) {
	mk := func(path string) *graph.Node {
		return &graph.Node{ID: path + "::S", Kind: graph.KindFunction, Name: "S", FilePath: path}
	}
	nodes := []*graph.Node{
		mk("services/billing/invoice.go"),   // under services/billing
		mk("services/billing/v2/refund.go"), // under services/billing (deeper)
		mk("services/billingX/other.go"),    // NOT under -- the prefix must align on a boundary
		mk("other/services/billing/x.go"),   // NOT under -- prefix is anchored at the start
		mk("libs/auth/token.go"),            // under libs/auth
	}

	got := applyPathFilter(nodes, []string{"services/billing"})
	gotPaths := map[string]bool{}
	for _, n := range got {
		gotPaths[n.FilePath] = true
	}
	require.True(t, gotPaths["services/billing/invoice.go"])
	require.True(t, gotPaths["services/billing/v2/refund.go"])
	require.False(t, gotPaths["services/billingX/other.go"], "billingX must not match the billing prefix")
	require.False(t, gotPaths["other/services/billing/x.go"], "the prefix is anchored at the path start")
	require.False(t, gotPaths["libs/auth/token.go"])

	// Multi-path: union of two prefixes.
	multi := applyPathFilter(nodes, []string{"services/billing", "libs/auth"})
	require.Len(t, multi, 3)

	// Slash normalisation: leading "./", trailing slash, back-slashes.
	norm := applyPathFilter(nodes, []string{"./services/billing/"})
	require.Len(t, norm, 2)

	// Empty filter is a no-op.
	require.Len(t, applyPathFilter(nodes, nil), len(nodes))
}

func TestApplyPathFilter_StripsRepoPrefix(t *testing.T) {
	// In multi-repo mode FilePath is repo-prefixed; the filter is
	// expressed relative to the repo root and must still match.
	n := &graph.Node{
		ID: "x", Kind: graph.KindFunction, Name: "S",
		FilePath:   "billing-repo/services/billing/invoice.go",
		RepoPrefix: "billing-repo",
	}
	got := applyPathFilter([]*graph.Node{n}, []string{"services/billing"})
	require.Len(t, got, 1, "repo prefix should be stripped before the anchored match")
}

func TestSavedScope_PathsJSONRoundTrip(t *testing.T) {
	path := filepath.Join(t.TempDir(), "scopes.json")
	st := newScopeStore(path)
	require.NoError(t, st.put(SavedScope{
		Name:  "billing",
		Repos: []string{"monorepo"},
		Paths: []string{"services/billing", "libs/money"},
	}))
	got, ok := newScopeStore(path).get("billing")
	require.True(t, ok)
	require.Equal(t, []string{"services/billing", "libs/money"}, got.Paths)

	// A scope with no Paths round-trips with the field omitted (the
	// back-compat shape) -- decoding an old scopes.json still works.
	require.NoError(t, st.put(SavedScope{Name: "repo-only", Repos: []string{"r"}}))
	plain, ok := newScopeStore(path).get("repo-only")
	require.True(t, ok)
	require.Nil(t, plain.Paths)
}

func TestResolvePathFilter_Sources(t *testing.T) {
	t.Setenv("GORTEX_SCOPES_PATH", filepath.Join(t.TempDir(), "scopes.json"))
	g := graph.New()
	eng := query.NewEngine(g)
	eng.SetSearch(search.NewBM25())
	srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil)
	require.NoError(t, srv.scopeStoreOrInit().put(SavedScope{
		Name: "billing", Repos: []string{"r"}, Paths: []string{"services/billing"},
	}))

	// From the explicit path argument.
	req := mcplib.CallToolRequest{}
	req.Params.Arguments = map[string]any{"path": "libs/auth, libs/money"}
	got := srv.resolvePathFilter(req, fieldQuery{})
	require.ElementsMatch(t, []string{"libs/auth", "libs/money"}, got)

	// From an inline path: clause.
	got = srv.resolvePathFilter(mcplib.CallToolRequest{}, fieldQuery{Path: "internal/auth"})
	require.Equal(t, []string{"internal/auth"}, got)

	// From a path-bearing saved scope.
	req2 := mcplib.CallToolRequest{}
	req2.Params.Arguments = map[string]any{"scope": "billing"}
	got = srv.resolvePathFilter(req2, fieldQuery{})
	require.Equal(t, []string{"services/billing"}, got)

	// All three union together.
	req3 := mcplib.CallToolRequest{}
	req3.Params.Arguments = map[string]any{"path": "a/b", "scope": "billing"}
	got = srv.resolvePathFilter(req3, fieldQuery{Path: "c/d"})
	require.ElementsMatch(t, []string{"a/b", "c/d", "services/billing"}, got)
}

// pathScopeServer builds a single-repo server with symbols spread
// across distinct sub-paths so a path filter is observable.
func pathScopeServer(t *testing.T) *Server {
	t.Helper()
	g := graph.New()
	bm := search.NewBM25()
	files := map[string]string{
		"services/billing/Invoice.go": "BillingInvoice",
		"services/auth/Login.go":      "AuthLogin",
		"libs/money/Amount.go":        "MoneyAmount",
	}
	for path, name := range files {
		id := path + "::" + name
		g.AddNode(&graph.Node{
			ID: id, Kind: graph.KindFunction, Name: name,
			FilePath: path, StartLine: 1, EndLine: 5, Language: "go",
		})
		bm.Add(id, name, path, "")
	}
	eng := query.NewEngine(g)
	eng.SetSearch(bm)
	return NewServer(eng, g, nil, nil, zap.NewNop(), nil)
}

// TestSearchSymbols_PathScoping confirms the path argument confines
// search_symbols results to the named sub-path.
func TestSearchSymbols_PathScoping(t *testing.T) {
	srv := pathScopeServer(t)
	req := mcplib.CallToolRequest{}
	req.Params.Name = "search_symbols"
	// Query a token every symbol's path shares -- only the path
	// filter should confine the result.
	req.Params.Arguments = map[string]any{"query": "BillingInvoice OR AuthLogin OR MoneyAmount", "path": "services/billing"}
	res, err := srv.handleSearchSymbols(context.Background(), req)
	require.NoError(t, err)
	require.False(t, res.IsError)
	var resp map[string]any
	require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp))
	for _, r := range resp["results"].([]any) {
		fp := r.(map[string]any)["file_path"].(string)
		require.Contains(t, fp, "services/billing", "path scoping leaked a result outside services/billing: %s", fp)
	}
	require.NotEmpty(t, resp["results"], "the in-scope billing symbol should still be found")
}

// TestSmartContext_PathScoping confirms smart_context honours the
// path argument.
func TestSmartContext_PathScoping(t *testing.T) {
	srv := pathScopeServer(t)
	req := mcplib.CallToolRequest{}
	req.Params.Name = "smart_context"
	req.Params.Arguments = map[string]any{"task": "work on billing invoice login amount", "path": "services/billing"}
	res, err := srv.handleSmartContext(context.Background(), req)
	require.NoError(t, err)
	require.False(t, res.IsError)
	var resp map[string]any
	require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp))
	if syms, ok := resp["relevant_symbols"].([]any); ok {
		for _, s := range syms {
			if fp, ok := s.(map[string]any)["file_path"].(string); ok && fp != "" {
				require.Contains(t, fp, "services/billing",
					"smart_context path scoping leaked %s", fp)
			}
		}
	}
}
