package mcp

import (
	"context"
	"fmt"
	"strings"
	"time"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/zzet/gortex/internal/graph"
	"github.com/zzet/gortex/internal/persistence"
)

// registerNotesTools wires the session-memory triplet:
//
//   - save_note      — create or update a note (auto-linked to symbols
//                      mentioned in the body)
//   - query_notes    — list / search notes by symbol / file / tag /
//                      session / text
//   - distill_session — fold a session's notes into a digest the agent
//                      can paste back into context after compaction
//
// Each handler is scope-aware: notes inherit the session's workspace
// boundary, and `query_notes` never returns notes from a different
// workspace.
func (s *Server) registerNotesTools() {
	s.addTool(
		mcp.NewTool("save_note",
			mcp.WithDescription("Persist an agent-authored note for the current session. Optionally attach to a symbol or file; the body is scanned for symbol references and auto-linked. Use to record decisions, gotchas, follow-ups, or context the agent will need after a compaction. Pass `id` to update an existing note."),
			mcp.WithString("body", mcp.Description("Free-form note text. Symbol IDs (file/path.go::Name) and bare identifier names are auto-linked when they resolve in the graph.")),
			mcp.WithString("symbol_id", mcp.Description("Primary symbol the note attaches to (optional)")),
			mcp.WithString("file_path", mcp.Description("Primary file the note attaches to (optional)")),
			mcp.WithString("tags", mcp.Description("Comma-separated labels — e.g. 'decision,bug,follow-up'. Notes tagged 'decision' are surfaced separately in distill_session.")),
			mcp.WithString("links", mcp.Description("Comma-separated symbol IDs to attach explicitly (merged with auto-detected links)")),
			mcp.WithBoolean("pinned", mcp.Description("Pinned notes are always returned by distill_session and never evicted by the store cap.")),
			mcp.WithString("id", mcp.Description("Existing note ID — passing it switches the call from create to update.")),
			mcp.WithBoolean("no_autolink", mcp.Description("Skip the body→symbol auto-linker (set true when the body intentionally mentions identifiers that should not be linked).")),
			mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
		),
		s.handleSaveNote,
	)

	s.addTool(
		mcp.NewTool("query_notes",
			mcp.WithDescription("Search saved notes for the current session / workspace. Filter by symbol, file, tag, free-text body match, session, or recency."),
			mcp.WithString("symbol_id", mcp.Description("Return notes attached or auto-linked to this symbol")),
			mcp.WithString("file_path", mcp.Description("Return notes attached to this file path")),
			mcp.WithString("tag", mcp.Description("Return notes carrying this tag (case-insensitive)")),
			mcp.WithString("text", mcp.Description("Case-insensitive substring filter on the body")),
			mcp.WithString("session_id", mcp.Description("Limit to a specific session. Defaults to the current session; pass 'all' to query every session.")),
			mcp.WithString("since", mcp.Description("Only return notes updated at or after this RFC-3339 timestamp")),
			mcp.WithNumber("limit", mcp.Description("Cap the result set (default: 50)")),
			mcp.WithBoolean("pinned_only", mcp.Description("Return only pinned notes")),
			mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
		),
		s.handleQueryNotes,
	)

	s.addTool(
		mcp.NewTool("distill_session",
			mcp.WithDescription("Aggregate a session's saved notes into a digest: top symbols / files / tags, pinned notes, recent excerpts, and a short summary. Use after a context compaction to recover what the agent was doing."),
			mcp.WithString("session_id", mcp.Description("Session to distill. Defaults to the current session; pass 'all' to distill every session for the current workspace.")),
			mcp.WithNumber("max_symbols", mcp.Description("Cap on the top-symbols list (default: 10)")),
			mcp.WithNumber("max_files", mcp.Description("Cap on the top-files list (default: 10)")),
			mcp.WithNumber("max_tags", mcp.Description("Cap on the top-tags list (default: 10)")),
			mcp.WithNumber("max_recent", mcp.Description("Number of recent note excerpts to include (default: 8)")),
			mcp.WithNumber("excerpt_chars", mcp.Description("Body excerpt cap in bytes (default: 240)")),
			mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
		),
		s.handleDistillSession,
	)
}

// handleSaveNote — create-or-update entry point. Update mode is
// selected by passing an existing `id`; everything else is a create.
func (s *Server) handleSaveNote(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
	if s.notes == nil {
		return mcp.NewToolResultError("notes storage not initialised"), nil
	}

	id := strings.TrimSpace(req.GetString("id", ""))
	body := req.GetString("body", "")
	symbolID := strings.TrimSpace(req.GetString("symbol_id", ""))
	filePath := strings.TrimSpace(req.GetString("file_path", ""))
	tags := splitCSV(req.GetString("tags", ""))
	links := splitCSV(req.GetString("links", ""))
	pinned := req.GetBool("pinned", false)
	noAutolink := req.GetBool("no_autolink", false)

	if id == "" && body == "" && symbolID == "" && filePath == "" {
		return mcp.NewToolResultError("save_note requires at least one of: body, symbol_id, file_path"), nil
	}

	// Update path. body / tags / links default to "leave alone" when
	// the caller didn't supply them. pinned is always re-applied
	// (agents should pass it explicitly on updates that care).
	if id != "" {
		var bodyPtr *string
		if body != "" {
			bodyPtr = &body
		}
		pinnedPtr := &pinned
		var addLinks []string
		if !noAutolink && body != "" {
			addLinks = append(addLinks, autoLinkBody(body, s.graph, sessionWorkspaceIDOrEmpty(s, ctx), defaultAutoLinkOptions())...)
		}
		addLinks = append(addLinks, links...)
		updated, err := s.notes.Update(id, bodyPtr, tags, pinnedPtr, addLinks)
		if err != nil {
			return mcp.NewToolResultError(fmt.Sprintf("update note: %v", err)), nil
		}
		return s.respondJSONOrTOON(ctx, req, noteEntryToWire(updated))
	}

	// Create path. Stamp scope from session + (when available) the
	// attached symbol's node, so notes inherit the workspace boundary
	// without forcing the caller to think about it.
	workspaceID, projectID, _ := s.sessionScope(ctx)
	repoPrefix, _ := s.sessionLocality(ctx)

	if symbolID != "" {
		if node := s.graph.GetNode(symbolID); node != nil {
			if workspaceID == "" {
				workspaceID = node.WorkspaceID
			}
			if projectID == "" {
				projectID = node.ProjectID
			}
			if repoPrefix == "" {
				repoPrefix = node.RepoPrefix
			}
		}
	}

	autoLinks := links
	if !noAutolink && body != "" {
		autoLinks = append(autoLinks, autoLinkBody(body, s.graph, workspaceID, defaultAutoLinkOptions())...)
	}
	// Always ensure the explicit symbol_id ends up on the link list
	// so query_notes by symbol_id matches even when the body did not
	// mention it.
	if symbolID != "" {
		autoLinks = append(autoLinks, symbolID)
	}

	entry := persistence.NoteEntry{
		SessionID:   SessionIDFromContext(ctx),
		ClientName:  sessionClientName(s, ctx),
		Body:        body,
		SymbolID:    symbolID,
		FilePath:    filePath,
		RepoPrefix:  repoPrefix,
		WorkspaceID: workspaceID,
		ProjectID:   projectID,
		Tags:        tags,
		AutoLinks:   autoLinks,
		Pinned:      pinned,
	}

	newID, err := s.notes.Save(entry)
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("save note: %v", err)), nil
	}
	entry.ID = newID
	saved, _ := s.notes.Get(newID)
	return s.respondJSONOrTOON(ctx, req, noteEntryToWire(saved))
}

// handleQueryNotes — multi-filter listing. Honours the session
// workspace boundary: every result is required to live inside the
// session's workspace (or carry no workspace when the session is
// unbound).
func (s *Server) handleQueryNotes(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
	if s.notes == nil {
		return mcp.NewToolResultError("notes storage not initialised"), nil
	}

	limit := req.GetInt("limit", 50)
	filter := NoteQueryFilter{
		SymbolID:   strings.TrimSpace(req.GetString("symbol_id", "")),
		FilePath:   strings.TrimSpace(req.GetString("file_path", "")),
		Tag:        strings.TrimSpace(req.GetString("tag", "")),
		TextSearch: req.GetString("text", ""),
		Limit:      limit,
	}

	sess := strings.TrimSpace(req.GetString("session_id", ""))
	switch sess {
	case "":
		filter.SessionID = SessionIDFromContext(ctx)
	case "all", "*":
		filter.SessionID = ""
	default:
		filter.SessionID = sess
	}

	if since := strings.TrimSpace(req.GetString("since", "")); since != "" {
		if ts, err := time.Parse(time.RFC3339, since); err == nil {
			filter.Since = ts
		} else {
			return mcp.NewToolResultError(fmt.Sprintf("invalid `since` timestamp: %v", err)), nil
		}
	}
	if req.GetBool("pinned_only", false) {
		yes := true
		filter.Pinned = &yes
	}

	if workspaceID, _, bound := s.sessionScope(ctx); bound {
		filter.WorkspaceID = workspaceID
	}

	notes := s.notes.Query(filter)
	wire := make([]map[string]any, 0, len(notes))
	for _, n := range notes {
		wire = append(wire, noteEntryToWire(n))
	}
	return s.respondJSONOrTOON(ctx, req, map[string]any{
		"notes": wire,
		"total": len(wire),
	})
}

// handleDistillSession — assemble the per-session digest.
func (s *Server) handleDistillSession(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
	if s.notes == nil {
		return mcp.NewToolResultError("notes storage not initialised"), nil
	}

	opts := defaultDistillOptions()
	if v := req.GetInt("max_symbols", 0); v > 0 {
		opts.MaxSymbols = v
	}
	if v := req.GetInt("max_files", 0); v > 0 {
		opts.MaxFiles = v
	}
	if v := req.GetInt("max_tags", 0); v > 0 {
		opts.MaxTags = v
	}
	if v := req.GetInt("max_recent", 0); v > 0 {
		opts.MaxRecent = v
	}
	if v := req.GetInt("excerpt_chars", 0); v > 0 {
		opts.ExcerptCap = v
	}

	sessionID := ""
	switch strings.TrimSpace(req.GetString("session_id", "")) {
	case "":
		sessionID = SessionIDFromContext(ctx)
	case "all", "*":
		sessionID = ""
	default:
		sessionID = req.GetString("session_id", "")
	}

	var workspaceID, projectID string
	if ws, proj, bound := s.sessionScope(ctx); bound {
		workspaceID = ws
		projectID = proj
	}

	res := s.notes.DistillSession(sessionID, workspaceID, projectID, opts, func(id string) *graph.Node {
		if s.graph == nil {
			return nil
		}
		return s.graph.GetNode(id)
	})
	return s.respondJSONOrTOON(ctx, req, res)
}

// noteEntryToWire shapes a stored note for inclusion in JSON / GCX /
// TOON responses.
func noteEntryToWire(e persistence.NoteEntry) map[string]any {
	m := map[string]any{
		"id":         e.ID,
		"timestamp":  e.Timestamp.UTC().Format(time.RFC3339),
		"updated_at": e.UpdatedAt.UTC().Format(time.RFC3339),
		"body":       e.Body,
	}
	if e.SessionID != "" {
		m["session_id"] = e.SessionID
	}
	if e.ClientName != "" {
		m["client_name"] = e.ClientName
	}
	if e.SymbolID != "" {
		m["symbol_id"] = e.SymbolID
	}
	if e.FilePath != "" {
		m["file_path"] = e.FilePath
	}
	if e.WorkspaceID != "" {
		m["workspace_id"] = e.WorkspaceID
	}
	if e.ProjectID != "" {
		m["project_id"] = e.ProjectID
	}
	if e.RepoPrefix != "" {
		m["repo_prefix"] = e.RepoPrefix
	}
	if len(e.Tags) > 0 {
		m["tags"] = e.Tags
	}
	if len(e.AutoLinks) > 0 {
		m["links"] = e.AutoLinks
	}
	if e.Pinned {
		m["pinned"] = true
	}
	return m
}

// sessionWorkspaceIDOrEmpty returns the session's workspace ID,
// falling back to "" for unbound sessions. Helper because the
// autoLinkBody signature takes a single string.
func sessionWorkspaceIDOrEmpty(s *Server, ctx context.Context) string {
	if s == nil {
		return ""
	}
	ws, _, bound := s.sessionScope(ctx)
	if !bound {
		return ""
	}
	return ws
}

// sessionClientName pulls the captured MCP clientInfo.name for the
// current session. Empty until the dispatcher snoops `initialize`.
func sessionClientName(s *Server, ctx context.Context) string {
	if s == nil {
		return ""
	}
	sess := s.sessionFor(ctx)
	if sess == nil {
		return ""
	}
	return sess.snapshotClientName()
}
