package mcp

import (
	"context"
	"fmt"
	"strconv"
	"strings"
	"sync"

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

// navCursor is the per-session stateful navigation cursor. It tracks a
// current symbol and a back-history so the nav tool can move through the
// graph one hop at a time without the caller re-passing the symbol ID.
type navCursor struct {
	mu      sync.Mutex
	current string   // current symbol ID; "" when the cursor is unset
	history []string // previous positions, oldest first; back pops the tail
}

// cursorFor returns the calling session's navigation cursor, allocating
// it on first use. Mirrors responseBufferFor — the cursor is bound to
// the MCP session lifecycle and freed with the rest of sessionState on
// disconnect.
func (s *Server) cursorFor(ctx context.Context) *navCursor {
	sess := s.sessionFor(ctx)
	sess.mu.Lock()
	defer sess.mu.Unlock()
	if sess.cursor == nil {
		sess.cursor = &navCursor{}
	}
	return sess.cursor
}

// registerNavTool wires nav — a verb-dispatched stateful navigation
// tool. One tool with an `action` selector (the same shape as analyze's
// `kind` dispatcher) moves a per-session cursor through the graph:
// goto / into / up / sibling / back / where / read.
func (s *Server) registerNavTool() {
	s.addTool(
		mcp.NewTool("nav",
			mcp.WithDescription("Cursor-based stateful graph navigation. Holds a per-session cursor so you can move one hop at a time without re-passing the symbol ID. Actions:\n"+
				"  goto    — set the cursor to `id`\n"+
				"  into    — move to a callee of the current symbol\n"+
				"  up      — move to a caller of the current symbol\n"+
				"  sibling — move to another member of the current symbol's parent\n"+
				"  back    — pop the previous position off the history\n"+
				"  where   — report the current symbol without moving\n"+
				"  read    — return the current symbol's source\n"+
				"Every response echoes the new cursor and an adjacency preview (callee / caller / sibling counts). For into / up / sibling, `select` picks which neighbour (numeric index or a name substring; default first)."),
			mcp.WithString("action", mcp.Required(), mcp.Description("One of: goto, into, up, sibling, back, where, read.")),
			mcp.WithString("id", mcp.Description("Target symbol node ID — required for action=goto.")),
			mcp.WithString("select", mcp.Description("For into / up / sibling: which neighbour to move to — a 0-based numeric index or a name substring. Defaults to the first neighbour.")),
			mcp.WithString("format", mcp.Description("Output format: json (default) or toon.")),
		),
		s.handleNav,
	)
}

// handleNav dispatches a nav action against the session cursor.
func (s *Server) handleNav(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
	action := strings.ToLower(strings.TrimSpace(req.GetString("action", "")))
	if action == "" {
		return mcp.NewToolResultError("nav: action is required (goto, into, up, sibling, back, where, read)"), nil
	}

	eng := s.engineFor(ctx)
	cur := s.cursorFor(ctx)
	cur.mu.Lock()
	defer cur.mu.Unlock()

	// Stale-cursor guard: a re-index may have removed the node the
	// cursor points at. Detect it once, up front, and reset gracefully
	// so no action operates on a dangling ID.
	staleReset := false
	if cur.current != "" && eng.GetSymbol(cur.current) == nil {
		cur.current = ""
		cur.history = nil
		staleReset = true
	}

	switch action {
	case "goto":
		id, err := req.RequireString("id")
		if err != nil || strings.TrimSpace(id) == "" {
			return mcp.NewToolResultError("nav goto: id is required"), nil
		}
		if eng.GetSymbol(id) == nil {
			return mcp.NewToolResultError("nav goto: symbol not found: " + id), nil
		}
		if cur.current != "" {
			cur.history = append(cur.history, cur.current)
		}
		cur.current = id
		return s.navRespond(ctx, req, eng, cur, "goto", staleReset, "")

	case "into":
		return s.navMove(ctx, req, eng, cur, "into", staleReset)

	case "up":
		return s.navMove(ctx, req, eng, cur, "up", staleReset)

	case "sibling":
		return s.navMove(ctx, req, eng, cur, "sibling", staleReset)

	case "back":
		if len(cur.history) == 0 {
			return mcp.NewToolResultError("nav back: history is empty"), nil
		}
		prev := cur.history[len(cur.history)-1]
		cur.history = cur.history[:len(cur.history)-1]
		// Skip history entries that a re-index has since removed.
		for eng.GetSymbol(prev) == nil && len(cur.history) > 0 {
			prev = cur.history[len(cur.history)-1]
			cur.history = cur.history[:len(cur.history)-1]
		}
		if eng.GetSymbol(prev) == nil {
			cur.current = ""
			return mcp.NewToolResultError("nav back: no live position left in history"), nil
		}
		cur.current = prev
		return s.navRespond(ctx, req, eng, cur, "back", staleReset, "")

	case "where":
		if cur.current == "" {
			return mcp.NewToolResultError("nav where: cursor is unset — use action=goto first"), nil
		}
		return s.navRespond(ctx, req, eng, cur, "where", staleReset, "")

	case "read":
		if cur.current == "" {
			return mcp.NewToolResultError("nav read: cursor is unset — use action=goto first"), nil
		}
		return s.navRead(ctx, req, eng, cur, staleReset)

	default:
		return mcp.NewToolResultError("nav: unknown action " + action +
			" (expected goto, into, up, sibling, back, where, read)"), nil
	}
}

// navMove handles the into / up / sibling actions: it builds the
// candidate neighbour list, picks one via the `select` disambiguator,
// and advances the cursor.
func (s *Server) navMove(ctx context.Context, req mcp.CallToolRequest, eng engineLike, cur *navCursor, action string, staleReset bool) (*mcp.CallToolResult, error) {
	if cur.current == "" {
		return mcp.NewToolResultError("nav " + action + ": cursor is unset — use action=goto first"), nil
	}

	var candidates []*graph.Node
	switch action {
	case "into":
		candidates = navCallees(eng, cur.current)
	case "up":
		candidates = navCallers(eng, cur.current)
	case "sibling":
		candidates = navSiblings(eng, cur.current)
	}
	if len(candidates) == 0 {
		return mcp.NewToolResultError(fmt.Sprintf("nav %s: no %s available from %s",
			action, navNeighbourNoun(action), cur.current)), nil
	}

	pick, perr := navSelect(candidates, req.GetString("select", ""))
	if perr != nil {
		return mcp.NewToolResultError("nav " + action + ": " + perr.Error()), nil
	}

	cur.history = append(cur.history, cur.current)
	cur.current = pick.ID
	return s.navRespond(ctx, req, eng, cur, action, staleReset, "")
}

// navSelect resolves the `select` disambiguator against a candidate
// list. An empty selector picks the first candidate. A numeric selector
// is a 0-based index. Any other value is a case-insensitive name
// substring; the first match wins (candidates are already deterministically
// ordered by their producers).
func navSelect(candidates []*graph.Node, selector string) (*graph.Node, error) {
	selector = strings.TrimSpace(selector)
	if selector == "" {
		return candidates[0], nil
	}
	if idx, err := strconv.Atoi(selector); err == nil {
		if idx < 0 || idx >= len(candidates) {
			return nil, fmt.Errorf("select index %d out of range (have %d)", idx, len(candidates))
		}
		return candidates[idx], nil
	}
	lower := strings.ToLower(selector)
	for _, c := range candidates {
		if strings.Contains(strings.ToLower(c.Name), lower) {
			return c, nil
		}
	}
	return nil, fmt.Errorf("select %q matched none of the %d candidates", selector, len(candidates))
}

// navNeighbourNoun maps an action to the noun used in its error text.
func navNeighbourNoun(action string) string {
	switch action {
	case "into":
		return "callees"
	case "up":
		return "callers"
	case "sibling":
		return "siblings"
	}
	return "neighbours"
}

// engineLike is the slice of *query.Engine the nav helpers need. Keeping
// it as an interface lets the helpers stay decoupled from the concrete
// engine and keeps them trivially unit-testable.
type engineLike interface {
	GetSymbol(id string) *graph.Node
	GetOutEdges(id string) []*graph.Edge
	GetInEdges(id string) []*graph.Edge
}

// navCallees returns the distinct call targets of id, ordered by edge
// line so the list is deterministic across calls.
func navCallees(eng engineLike, id string) []*graph.Node {
	return navNeighbours(eng, eng.GetOutEdges(id), graph.EdgeCalls, true)
}

// navCallers returns the distinct callers of id.
func navCallers(eng engineLike, id string) []*graph.Node {
	return navNeighbours(eng, eng.GetInEdges(id), graph.EdgeCalls, false)
}

// navSiblings returns the other members of id's parent. The parent is
// reached via id's own EdgeMemberOf out-edge; the siblings are the
// parent's EdgeMemberOf in-edges, with id itself removed.
func navSiblings(eng engineLike, id string) []*graph.Node {
	var parentID string
	for _, e := range eng.GetOutEdges(id) {
		if e.Kind == graph.EdgeMemberOf {
			parentID = e.To
			break
		}
	}
	if parentID == "" {
		return nil
	}
	all := navNeighbours(eng, eng.GetInEdges(parentID), graph.EdgeMemberOf, false)
	out := all[:0]
	for _, n := range all {
		if n.ID != id {
			out = append(out, n)
		}
	}
	return out
}

// navNeighbours walks edges of the given kind and returns the distinct
// nodes on the far end. forward selects the To endpoint (out-edges);
// otherwise the From endpoint (in-edges). Unresolved / external targets
// are skipped.
func navNeighbours(eng engineLike, edges []*graph.Edge, kind graph.EdgeKind, forward bool) []*graph.Node {
	seen := make(map[string]bool)
	var out []*graph.Node
	for _, e := range edges {
		if e.Kind != kind {
			continue
		}
		var id string
		if forward {
			id = e.To
		} else {
			id = e.From
		}
		if seen[id] || strings.HasPrefix(id, "unresolved::") || strings.HasPrefix(id, "external::") {
			continue
		}
		n := eng.GetSymbol(id)
		if n == nil {
			continue
		}
		seen[id] = true
		out = append(out, n)
	}
	return out
}

// navAdjacency is the short neighbour-count preview attached to every
// nav response so the caller knows which moves are available next.
type navAdjacency struct {
	Callees  int `json:"callees"`
	Callers  int `json:"callers"`
	Siblings int `json:"siblings"`
}

// navCursorView is the JSON shape of the cursor echoed on every response.
type navCursorView struct {
	Current     string `json:"current"`
	Kind        string `json:"kind,omitempty"`
	Name        string `json:"name,omitempty"`
	FilePath    string `json:"file_path,omitempty"`
	StartLine   int    `json:"start_line,omitempty"`
	HistorySize int    `json:"history_size"`
}

// navRespond builds the standard nav response: the action taken, the new
// cursor, and an adjacency preview.
func (s *Server) navRespond(ctx context.Context, req mcp.CallToolRequest, eng engineLike, cur *navCursor, action string, staleReset bool, _ string) (*mcp.CallToolResult, error) {
	payload := s.navBasePayload(eng, cur, action, staleReset)
	return s.respondJSONOrTOON(ctx, req, payload)
}

// navBasePayload assembles the cursor + adjacency fields shared by every
// nav response.
func (s *Server) navBasePayload(eng engineLike, cur *navCursor, action string, staleReset bool) map[string]any {
	node := eng.GetSymbol(cur.current)
	view := navCursorView{Current: cur.current, HistorySize: len(cur.history)}
	if node != nil {
		view.Kind = string(node.Kind)
		view.Name = node.Name
		view.FilePath = node.FilePath
		view.StartLine = node.StartLine
	}
	adj := navAdjacency{
		Callees:  len(navCallees(eng, cur.current)),
		Callers:  len(navCallers(eng, cur.current)),
		Siblings: len(navSiblings(eng, cur.current)),
	}
	payload := map[string]any{
		"action":    action,
		"cursor":    view,
		"adjacency": adj,
	}
	if staleReset {
		payload["stale_cursor_reset"] = true
	}
	return payload
}

// navRead returns the current symbol's source alongside the standard
// cursor + adjacency fields.
func (s *Server) navRead(ctx context.Context, req mcp.CallToolRequest, eng engineLike, cur *navCursor, staleReset bool) (*mcp.CallToolResult, error) {
	node := eng.GetSymbol(cur.current)
	if node == nil {
		return mcp.NewToolResultError("nav read: cursor symbol not found: " + cur.current), nil
	}
	payload := s.navBasePayload(eng, cur, "read", staleReset)

	if node.StartLine == 0 || node.EndLine == 0 {
		payload["source"] = ""
		payload["note"] = "symbol has no line range"
		return s.respondJSONOrTOON(ctx, req, payload)
	}
	absPath, resolveErr := s.resolveNodePath(node)
	if resolveErr != nil {
		return mcp.NewToolResultError(resolveErr.Error()), nil
	}
	source, startLine, _, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, 0)
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("nav read: could not read source: %v", err)), nil
	}
	payload["source"] = source
	payload["start_line"] = startLine
	payload["end_line"] = node.EndLine
	return s.respondJSONOrTOON(ctx, req, payload)
}
