package mcp

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

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

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

// ---------------------------------------------------------------------------
// notesManager — unit tests for the persistence + filter layer
// ---------------------------------------------------------------------------

func TestNotesManager_SaveQueryDelete(t *testing.T) {
	nm := newNotesManager("", "")

	id1, err := nm.Save(persistence.NoteEntry{
		SessionID: "s1",
		Body:      "init the cache for pkg/foo.go::Bar",
		SymbolID:  "pkg/foo.go::Bar",
		Tags:      []string{"decision"},
	})
	require.NoError(t, err)
	require.NotEmpty(t, id1)

	id2, err := nm.Save(persistence.NoteEntry{
		SessionID: "s1",
		Body:      "TODO: revisit timeout",
		FilePath:  "pkg/foo.go",
		Tags:      []string{"todo"},
	})
	require.NoError(t, err)

	all := nm.Query(NoteQueryFilter{SessionID: "s1"})
	require.Len(t, all, 2)
	// Newest-first ordering.
	assert.Equal(t, id2, all[0].ID)

	bySymbol := nm.Query(NoteQueryFilter{SymbolID: "pkg/foo.go::Bar"})
	require.Len(t, bySymbol, 1)
	assert.Equal(t, id1, bySymbol[0].ID)

	byFile := nm.Query(NoteQueryFilter{FilePath: "pkg/foo.go"})
	require.Len(t, byFile, 1)
	assert.Equal(t, id2, byFile[0].ID)

	byTag := nm.Query(NoteQueryFilter{Tag: "DECISION"})
	require.Len(t, byTag, 1)
	assert.Equal(t, id1, byTag[0].ID)

	byText := nm.Query(NoteQueryFilter{TextSearch: "TIMEOUT"})
	require.Len(t, byText, 1)
	assert.Equal(t, id2, byText[0].ID)

	require.NoError(t, nm.Delete(id1))
	assert.Equal(t, 1, nm.Count())
	require.NoError(t, nm.Delete(id1), "deleting twice is a noop")
}

func TestNotesManager_Update(t *testing.T) {
	nm := newNotesManager("", "")
	id, err := nm.Save(persistence.NoteEntry{
		SessionID: "s1",
		Body:      "first body",
		Tags:      []string{"draft"},
	})
	require.NoError(t, err)

	newBody := "second body"
	pinned := true
	updated, err := nm.Update(id, &newBody, []string{"final"}, &pinned, []string{"pkg/x.go::Bar"})
	require.NoError(t, err)

	assert.Equal(t, newBody, updated.Body)
	assert.Equal(t, []string{"final"}, updated.Tags)
	assert.True(t, updated.Pinned)
	assert.Contains(t, updated.AutoLinks, "pkg/x.go::Bar")

	// Re-fetch and confirm persisted state matches.
	got, ok := nm.Get(id)
	require.True(t, ok)
	assert.Equal(t, updated.Body, got.Body)
}

func TestNotesManager_UpdateMissingID(t *testing.T) {
	nm := newNotesManager("", "")
	_, err := nm.Update("nope", nil, nil, nil, nil)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "not found")
}

func TestNotesManager_PersistenceRoundTrip(t *testing.T) {
	cache := t.TempDir()
	repo := "/tmp/notes-test-repo"

	nm1 := newNotesManager(cache, repo)
	id, err := nm1.Save(persistence.NoteEntry{SessionID: "s1", Body: "first"})
	require.NoError(t, err)

	// New manager pointed at the same cache — simulates server restart.
	nm2 := newNotesManager(cache, repo)
	assert.True(t, nm2.HasData())
	got, ok := nm2.Get(id)
	require.True(t, ok)
	assert.Equal(t, "first", got.Body)
}

func TestNotesManager_Distill(t *testing.T) {
	nm := newNotesManager("", "")
	g := graph.New()
	g.AddNode(&graph.Node{
		ID:       "pkg/foo.go::Bar",
		Name:     "Bar",
		Kind:     graph.KindFunction,
		FilePath: "pkg/foo.go",
	})

	for range 3 {
		_, err := nm.Save(persistence.NoteEntry{
			SessionID: "s1",
			Body:      "note about Bar",
			SymbolID:  "pkg/foo.go::Bar",
			Tags:      []string{"decision"},
		})
		require.NoError(t, err)
	}
	_, err := nm.Save(persistence.NoteEntry{
		SessionID: "s1",
		Body:      "side note",
		Pinned:    true,
		Tags:      []string{"note"},
	})
	require.NoError(t, err)

	res := nm.DistillSession("s1", "", "", defaultDistillOptions(), g.GetNode)

	assert.Equal(t, 4, res.NoteCount)
	require.NotEmpty(t, res.TopSymbols)
	assert.Equal(t, "pkg/foo.go::Bar", res.TopSymbols[0].ID)
	assert.Equal(t, "Bar", res.TopSymbols[0].Name)
	assert.Equal(t, 3, res.TopSymbols[0].Count)

	// Pinned should be surfaced.
	require.NotEmpty(t, res.PinnedNotes)
	// Decision excerpt present.
	require.NotEmpty(t, res.Decisions)
	// Summary non-empty.
	assert.NotEmpty(t, res.Summary)
}

func TestNotesManager_DistillEmpty(t *testing.T) {
	nm := newNotesManager("", "")
	res := nm.DistillSession("missing", "", "", defaultDistillOptions(), nil)
	assert.Equal(t, 0, res.NoteCount)
	assert.Empty(t, res.Summary)
}

func TestNotesManager_QueryLimit(t *testing.T) {
	nm := newNotesManager("", "")
	for range 5 {
		_, _ = nm.Save(persistence.NoteEntry{SessionID: "s1", Body: "n"})
	}
	out := nm.Query(NoteQueryFilter{SessionID: "s1", Limit: 2})
	assert.Len(t, out, 2)
}

func TestNotesManager_QuerySince(t *testing.T) {
	nm := newNotesManager("", "")
	id1, _ := nm.Save(persistence.NoteEntry{SessionID: "s1", Body: "old"})
	time.Sleep(5 * time.Millisecond)
	cutoff := time.Now().UTC()
	time.Sleep(5 * time.Millisecond)
	id2, _ := nm.Save(persistence.NoteEntry{SessionID: "s1", Body: "new"})

	out := nm.Query(NoteQueryFilter{Since: cutoff})
	require.Len(t, out, 1)
	assert.Equal(t, id2, out[0].ID)
	assert.NotEqual(t, id1, out[0].ID)
}

// ---------------------------------------------------------------------------
// Auto-linking — body→symbol extractor
// ---------------------------------------------------------------------------

func TestAutoLink_ResolvesByExactID(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "pkg/foo.go::Bar", Name: "Bar", FilePath: "pkg/foo.go"})
	g.AddNode(&graph.Node{ID: "pkg/other.go::Quux", Name: "Quux", FilePath: "pkg/other.go"})

	body := "Looked at pkg/foo.go::Bar — it depends on Quux."
	links := autoLinkBody(body, g, "", defaultAutoLinkOptions())
	assert.Contains(t, links, "pkg/foo.go::Bar")
	assert.Contains(t, links, "pkg/other.go::Quux")
}

func TestAutoLink_DropsAmbiguousNames(t *testing.T) {
	g := graph.New()
	// Four nodes named "Foo" — exceeds the precision cap (3).
	for i := range 4 {
		g.AddNode(&graph.Node{ID: "pkg/x.go::Foo" + string(rune('A'+i)), Name: "Foo", FilePath: "pkg/x.go"})
	}
	links := autoLinkBody("mentions Foo somewhere", g, "", defaultAutoLinkOptions())
	for _, l := range links {
		assert.False(t, strings.HasPrefix(l, "pkg/x.go::Foo"), "ambiguous Foo should be skipped, got: %s", l)
	}
}

func TestAutoLink_SkipsStopwords(t *testing.T) {
	g := graph.New()
	// "the" is in the stop-word list — even if a node existed, it
	// must not be auto-linked.
	g.AddNode(&graph.Node{ID: "pkg/x.go::the", Name: "the", FilePath: "pkg/x.go"})
	links := autoLinkBody("this is the thing", g, "", defaultAutoLinkOptions())
	assert.NotContains(t, links, "pkg/x.go::the")
}

func TestAutoLink_HonorsWorkspaceScope(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID:          "pkg/a.go::Bar",
		Name:        "Bar",
		FilePath:    "pkg/a.go",
		WorkspaceID: "ws-a",
	})
	g.AddNode(&graph.Node{
		ID:          "pkg/b.go::Baz",
		Name:        "Baz",
		FilePath:    "pkg/b.go",
		WorkspaceID: "ws-b",
	})

	links := autoLinkBody("see Bar and Baz", g, "ws-a", defaultAutoLinkOptions())
	assert.Contains(t, links, "pkg/a.go::Bar")
	assert.NotContains(t, links, "pkg/b.go::Baz", "ws-b symbol must not leak into ws-a auto-link result")
}

// TestAutoLink_DoesNotMatchBareEnglishWords pins the regression: a
// note body containing the word "memory" used to auto-link to
// `daemon_status_tui.go::repoItem.memory` (a field whose Name is
// literally "memory"), and "MCP" auto-linked to `Config.MCP`. The
// auto-linker now requires a code-shape signal on each token
// (uppercase, underscore, or dot) and a single unambiguous graph
// match, so plain English words and short caps acronyms with
// multiple matches no longer pollute the link set.
func TestAutoLink_DoesNotMatchBareEnglishWords(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "pkg/tui.go::repoItem.memory", Name: "memory", FilePath: "pkg/tui.go"})
	g.AddNode(&graph.Node{ID: "pkg/config.go::Config.MCP", Name: "MCP", FilePath: "pkg/config.go"})
	g.AddNode(&graph.Node{ID: "pkg/cli.go::otherMCP", Name: "MCP", FilePath: "pkg/cli.go"})

	body := "Validation memory: MCP integration tests exercising store_memory"
	links := autoLinkBody(body, g, "", defaultAutoLinkOptions())

	// bare "memory" / "MCP" with multiple-name-match must not link.
	require.NotContains(t, links, "pkg/tui.go::repoItem.memory",
		"plain English `memory` must not pull in a field of the same name")
	require.NotContains(t, links, "pkg/config.go::Config.MCP",
		"short caps `MCP` with multiple matches is ambiguous; must not auto-link")
	require.NotContains(t, links, "pkg/cli.go::otherMCP",
		"short caps `MCP` with multiple matches must not auto-link")

	// A signal-bearing token like store_memory still works when the
	// graph has a uniquely-named node for it.
	g.AddNode(&graph.Node{ID: "pkg/mem.go::store_memory", Name: "store_memory", FilePath: "pkg/mem.go"})
	links = autoLinkBody(body, g, "", defaultAutoLinkOptions())
	require.Contains(t, links, "pkg/mem.go::store_memory",
		"snake_case tokens carry the code signal and must still link when unambiguous")
}

func TestAutoLink_CapsAtMaxLinks(t *testing.T) {
	g := graph.New()
	body := strings.Builder{}
	for i := range 30 {
		name := "Sym" + string(rune('A'+i))
		g.AddNode(&graph.Node{ID: "pkg/x.go::" + name, Name: name, FilePath: "pkg/x.go"})
		body.WriteString(name + " ")
	}
	opts := defaultAutoLinkOptions()
	opts.MaxLinks = 5
	links := autoLinkBody(body.String(), g, "", opts)
	assert.LessOrEqual(t, len(links), 5)
}

// ---------------------------------------------------------------------------
// Handler tests — end-to-end against an embedded Server
// ---------------------------------------------------------------------------

func newTestServer(t *testing.T) *Server {
	t.Helper()
	g := graph.New()
	g.AddNode(&graph.Node{ID: "pkg/foo.go::Bar", Name: "Bar", Kind: graph.KindFunction, FilePath: "pkg/foo.go"})
	g.AddNode(&graph.Node{ID: "pkg/foo.go::Baz", Name: "Baz", Kind: graph.KindMethod, FilePath: "pkg/foo.go"})

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

func callHandler(t *testing.T, h func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error), args map[string]any) *mcp.CallToolResult {
	t.Helper()
	req := mcp.CallToolRequest{}
	req.Params.Arguments = args
	res, err := h(context.Background(), req)
	require.NoError(t, err)
	require.NotNil(t, res)
	return res
}

func unmarshalResult(t *testing.T, res *mcp.CallToolResult) map[string]any {
	t.Helper()
	require.False(t, res.IsError, "handler returned an error result: %+v", res.Content)
	require.NotEmpty(t, res.Content)
	tc, ok := res.Content[0].(mcp.TextContent)
	require.True(t, ok, "expected TextContent, got %T", res.Content[0])
	var m map[string]any
	require.NoError(t, json.Unmarshal([]byte(tc.Text), &m))
	return m
}

func TestHandleSaveNote_CreateAndAutoLink(t *testing.T) {
	s := newTestServer(t)
	res := callHandler(t, s.handleSaveNote, map[string]any{
		"body":      "look at pkg/foo.go::Bar and update Baz",
		"symbol_id": "pkg/foo.go::Bar",
		"tags":      "decision, follow-up",
	})
	out := unmarshalResult(t, res)
	require.NotEmpty(t, out["id"])
	links, _ := out["links"].([]any)
	require.NotEmpty(t, links)

	idSet := map[string]bool{}
	for _, l := range links {
		idSet[l.(string)] = true
	}
	assert.True(t, idSet["pkg/foo.go::Bar"], "primary symbol must be linked")
	assert.True(t, idSet["pkg/foo.go::Baz"], "auto-linker should pick up Baz from body")

	tags, _ := out["tags"].([]any)
	assert.ElementsMatch(t, []any{"decision", "follow-up"}, tags)
}

func TestHandleSaveNote_RejectEmpty(t *testing.T) {
	s := newTestServer(t)
	req := mcp.CallToolRequest{}
	req.Params.Arguments = map[string]any{}
	res, err := s.handleSaveNote(context.Background(), req)
	require.NoError(t, err)
	require.NotNil(t, res)
	assert.True(t, res.IsError, "empty save should return error result")
}

func TestHandleSaveNote_NoAutolink(t *testing.T) {
	s := newTestServer(t)
	res := callHandler(t, s.handleSaveNote, map[string]any{
		"body":        "Bar is mentioned but should not auto-link",
		"no_autolink": true,
	})
	out := unmarshalResult(t, res)
	_, hasLinks := out["links"]
	assert.False(t, hasLinks, "no_autolink should produce no links field")
}

func TestHandleSaveNote_Update(t *testing.T) {
	s := newTestServer(t)
	// Create.
	res := callHandler(t, s.handleSaveNote, map[string]any{
		"body": "draft",
	})
	out := unmarshalResult(t, res)
	id := out["id"].(string)

	// Update.
	res2 := callHandler(t, s.handleSaveNote, map[string]any{
		"id":     id,
		"body":   "final body referring to Bar",
		"tags":   "final",
		"pinned": true,
	})
	out2 := unmarshalResult(t, res2)
	assert.Equal(t, id, out2["id"])
	assert.Equal(t, "final body referring to Bar", out2["body"])
	assert.Equal(t, true, out2["pinned"])
	links, _ := out2["links"].([]any)
	require.NotEmpty(t, links)
}

func TestHandleQueryNotes_FilterBySymbolAndTag(t *testing.T) {
	s := newTestServer(t)
	_ = callHandler(t, s.handleSaveNote, map[string]any{
		"body": "alpha", "symbol_id": "pkg/foo.go::Bar", "tags": "decision",
	})
	_ = callHandler(t, s.handleSaveNote, map[string]any{
		"body": "beta", "symbol_id": "pkg/foo.go::Baz", "tags": "todo",
	})

	res := callHandler(t, s.handleQueryNotes, map[string]any{
		"symbol_id":  "pkg/foo.go::Bar",
		"session_id": "all",
	})
	out := unmarshalResult(t, res)
	total := int(out["total"].(float64))
	assert.Equal(t, 1, total)

	res2 := callHandler(t, s.handleQueryNotes, map[string]any{
		"tag":        "todo",
		"session_id": "all",
	})
	out2 := unmarshalResult(t, res2)
	total2 := int(out2["total"].(float64))
	assert.Equal(t, 1, total2)
}

func TestHandleQueryNotes_TextFilter(t *testing.T) {
	s := newTestServer(t)
	_ = callHandler(t, s.handleSaveNote, map[string]any{"body": "timeout bug in worker"})
	_ = callHandler(t, s.handleSaveNote, map[string]any{"body": "unrelated note"})

	res := callHandler(t, s.handleQueryNotes, map[string]any{
		"text":       "TIMEOUT",
		"session_id": "all",
	})
	out := unmarshalResult(t, res)
	total := int(out["total"].(float64))
	assert.Equal(t, 1, total)
}

func TestHandleDistillSession_FullCycle(t *testing.T) {
	s := newTestServer(t)
	for range 3 {
		_ = callHandler(t, s.handleSaveNote, map[string]any{
			"body": "decision about Bar", "symbol_id": "pkg/foo.go::Bar", "tags": "decision",
		})
	}
	_ = callHandler(t, s.handleSaveNote, map[string]any{
		"body": "pin this", "pinned": true,
	})

	res := callHandler(t, s.handleDistillSession, map[string]any{
		"session_id": "all",
	})
	out := unmarshalResult(t, res)
	noteCount := int(out["note_count"].(float64))
	assert.Equal(t, 4, noteCount)

	topSyms, _ := out["top_symbols"].([]any)
	require.NotEmpty(t, topSyms)
	first, _ := topSyms[0].(map[string]any)
	assert.Equal(t, "pkg/foo.go::Bar", first["id"])

	pinned, _ := out["pinned_notes"].([]any)
	assert.NotEmpty(t, pinned)

	decisions, _ := out["decisions"].([]any)
	assert.NotEmpty(t, decisions)

	summary, _ := out["summary"].(string)
	assert.Contains(t, summary, "Top symbols")
}

// TestNotes_RegisteredOnNewServer asserts the three session-memory
// tools land in the registered tool set after NewServer + InitNotes
// — protects against accidental removal of the registerNotesTools
// call.
func TestNotes_RegisteredOnNewServer(t *testing.T) {
	srv, _ := setupTestServer(t)
	srv.InitNotes("", "")

	// Round-trip a save → query → distill through the wired handlers
	// to confirm the entire family works end-to-end on a real Server
	// built by NewServer.
	saveRes := callHandler(t, srv.handleSaveNote, map[string]any{
		"body": "decision: use fastpath",
		"tags": "decision",
	})
	out := unmarshalResult(t, saveRes)
	require.NotEmpty(t, out["id"])

	queryRes := callHandler(t, srv.handleQueryNotes, map[string]any{
		"tag":        "decision",
		"session_id": "all",
	})
	queryOut := unmarshalResult(t, queryRes)
	assert.Equal(t, 1.0, queryOut["total"])

	distillRes := callHandler(t, srv.handleDistillSession, map[string]any{
		"session_id": "all",
	})
	distillOut := unmarshalResult(t, distillRes)
	assert.Equal(t, 1.0, distillOut["note_count"])
}

func TestNotes_HandlersWithoutInit(t *testing.T) {
	// Server with no notes manager — every handler must surface a
	// clear error rather than panic.
	s := &Server{
		graph:      graph.New(),
		session:    newSessionState(),
		tokenStats: &tokenStats{},
		symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
		sessions:   newSessionMap(),
		toolScopes: newScopeRegistry(),
	}
	req := mcp.CallToolRequest{}
	req.Params.Arguments = map[string]any{"body": "x"}
	res, err := s.handleSaveNote(context.Background(), req)
	require.NoError(t, err)
	assert.True(t, res.IsError)

	res2, err := s.handleQueryNotes(context.Background(), req)
	require.NoError(t, err)
	assert.True(t, res2.IsError)

	res3, err := s.handleDistillSession(context.Background(), req)
	require.NoError(t, err)
	assert.True(t, res3.IsError)
}
