package server

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"regexp"
	"slices"
	"sort"
	"strconv"
	"strings"
	"sync"

	"go.uber.org/zap"

	"github.com/zzet/gortex/internal/config"
	"github.com/zzet/gortex/internal/daemon"
	"github.com/zzet/gortex/internal/graph"
	"github.com/zzet/gortex/internal/indexer"
	"github.com/zzet/gortex/internal/server/hub"
)

// activityBuffer holds the last N graph-change events so the UI can
// backfill its activity feed without waiting for a fresh event.
//
// The buffer is intentionally small (default 100) — it is meant for
// "what just happened" feedback in the dashboard, not durable history.
// Events are preserved across reconnects but are lost on server restart.
type activityBuffer struct {
	mu     sync.RWMutex
	events []indexer.GraphChangeEvent
	cap    int
}

func newActivityBuffer(cap int) *activityBuffer {
	if cap <= 0 {
		cap = 100
	}
	return &activityBuffer{cap: cap, events: make([]indexer.GraphChangeEvent, 0, cap)}
}

func (b *activityBuffer) add(ev indexer.GraphChangeEvent) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.events = append(b.events, ev)
	if len(b.events) > b.cap {
		b.events = b.events[len(b.events)-b.cap:]
	}
}

func (b *activityBuffer) snapshot(limit int) []indexer.GraphChangeEvent {
	b.mu.RLock()
	defer b.mu.RUnlock()
	if limit <= 0 || limit > len(b.events) {
		limit = len(b.events)
	}
	out := make([]indexer.GraphChangeEvent, limit)
	for i := 0; i < limit; i++ {
		out[i] = b.events[len(b.events)-1-i]
	}
	return out
}

func (h *Handler) startActivityCollector(eh *hub.Hub) {
	if eh == nil || h.activity == nil {
		return
	}
	subID, ch := eh.Subscribe()
	go func() {
		defer eh.Unsubscribe(subID)
		for ev := range ch {
			h.activity.add(ev)
		}
	}()
}

// --- /v1/activity ---

func (h *Handler) handleActivity(w http.ResponseWriter, r *http.Request) {
	limit := 50
	if s := r.URL.Query().Get("limit"); s != "" {
		if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 500 {
			limit = n
		}
	}
	if h.activity == nil {
		WriteJSON(w, http.StatusOK, map[string]any{"events": []any{}})
		return
	}
	WriteJSON(w, http.StatusOK, map[string]any{"events": h.activity.snapshot(limit)})
}

// --- /v1/repos ---
//
// Returns a flat list of indexed repositories with the per-repo
// breakdown the dashboard's repo cards and the graph filter panel
// expect. All numbers are derived from graph.RepoStats() — no mock
// values. The "color" hint is chosen deterministically from the repo
// prefix so cards stay stable across reloads without storing state.

type repoEntry struct {
	ID         string `json:"id"`
	Owner      string `json:"owner"`
	Lang       string `json:"lang"`
	Nodes      int    `json:"nodes"`
	Edges      int    `json:"edges"`
	Funcs      int    `json:"funcs"`
	Methods    int    `json:"methods"`
	Types      int    `json:"types"`
	Interfaces int    `json:"interfaces"`
	Vars       int    `json:"vars"`
	Files      int    `json:"files"`
	Color      string `json:"color"`
}

// dominantLanguage returns the language with the highest byte/node share
// for a repo, ignoring config-only languages so a Go repo doesn't show
// up as "yaml".
func dominantLanguage(byLang map[string]int) string {
	skip := map[string]bool{"json": true, "yaml": true, "toml": true, "markdown": true, "makefile": true, "dockerfile": true, "bash": true, "hcl": true}
	best := ""
	bestN := -1
	for lang, n := range byLang {
		if skip[lang] {
			continue
		}
		if n > bestN {
			best = lang
			bestN = n
		}
	}
	if best != "" {
		return best
	}
	for lang, n := range byLang {
		if n > bestN {
			best = lang
			bestN = n
		}
	}
	return best
}

// hashColor maps a string to one of the design's accent OKLCH colors.
// Stable, deterministic, no per-repo seeding required.
var repoPalette = []string{
	"oklch(0.82 0.15 155)",
	"oklch(0.80 0.13 230)",
	"oklch(0.78 0.14 300)",
	"oklch(0.80 0.17 10)",
	"oklch(0.82 0.14 45)",
	"oklch(0.82 0.15 80)",
	"oklch(0.72 0.02 252)",
}

func paletteFor(s string) string {
	if s == "" {
		return repoPalette[0]
	}
	var sum uint32
	for _, c := range s {
		sum = sum*31 + uint32(c)
	}
	return repoPalette[int(sum)%len(repoPalette)]
}

// splitOwner pulls "owner/repo" out of a repo prefix when one exists,
// otherwise treats the whole prefix as the repo and leaves owner blank.
func splitOwner(prefix string) (owner, name string) {
	if before, after, ok := strings.Cut(prefix, "/"); ok {
		return before, after
	}
	return "", prefix
}

func reposFromGraph(g *graph.Graph) []repoEntry {
	stats := g.RepoStats()
	out := make([]repoEntry, 0, len(stats))
	for prefix, s := range stats {
		owner, name := splitOwner(prefix)
		out = append(out, repoEntry{
			ID:         name,
			Owner:      owner,
			Lang:       dominantLanguage(s.ByLanguage),
			Nodes:      s.TotalNodes,
			Edges:      s.TotalEdges,
			Funcs:      s.ByKind["function"],
			Methods:    s.ByKind["method"],
			Types:      s.ByKind["type"],
			Interfaces: s.ByKind["interface"],
			Vars:       s.ByKind["variable"],
			Files:      s.ByKind["file"],
			Color:      paletteFor(prefix),
		})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Nodes > out[j].Nodes })
	return out
}

func (h *Handler) handleRepos(w http.ResponseWriter, _ *http.Request) {
	WriteJSON(w, http.StatusOK, map[string]any{"repos": reposFromGraph(h.graph)})
}

// --- /v1/overlay/* ---

// handleOverlayRegister handles POST /v1/overlay/sessions.
//
// Body (all fields optional): {
//
//	"workspace_id": "<slug>",
//	"session_id":   "<explicit-id>"   // bind to a known MCP session
//
// }
//
// When `session_id` is supplied, the session is registered under that
// ID instead of a freshly minted one — this is how an MCP client binds
// its overlay session to its MCP session ID, so subsequent tools/call
// frames from the same MCP session automatically see the overlay
// (the MCP tool middleware reads SessionIDFromContext and resolves the
// overlay by that ID). Idempotent: registering twice with the same
// (id, workspace) tuple is a no-op; mismatched workspaces return 409.
//
// Response: {"session_id": "...", "workspace_id": "..."}.
func (h *Handler) handleOverlayRegister(w http.ResponseWriter, r *http.Request) {
	if h.overlays == nil {
		http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
		return
	}
	var body struct {
		WorkspaceID string `json:"workspace_id"`
		SessionID   string `json:"session_id"`
	}
	if r.ContentLength > 0 {
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
			return
		}
	}
	id := body.SessionID
	if id == "" {
		id = h.overlays.Register(body.WorkspaceID)
	} else {
		if err := h.overlays.RegisterWithID(id, body.WorkspaceID); err != nil {
			if errors.Is(err, daemon.ErrSessionExists) {
				http.Error(w, err.Error(), http.StatusConflict)
				return
			}
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
	}
	WriteJSON(w, http.StatusCreated, map[string]any{
		"session_id":   id,
		"workspace_id": body.WorkspaceID,
	})
}

// handleOverlayPush handles PUT /v1/overlay/sessions/{id}/files.
// Body: OverlayFile JSON. Path is required; Content or Deleted=true.
func (h *Handler) handleOverlayPush(w http.ResponseWriter, r *http.Request) {
	if h.overlays == nil {
		http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
		return
	}
	id := r.PathValue("id")
	if id == "" {
		http.Error(w, "session id required", http.StatusBadRequest)
		return
	}
	var overlay daemon.OverlayFile
	if err := json.NewDecoder(r.Body).Decode(&overlay); err != nil {
		http.Error(w, "invalid OverlayFile JSON: "+err.Error(), http.StatusBadRequest)
		return
	}
	if err := h.overlays.Push(id, overlay, nil); err != nil {
		switch {
		case errors.Is(err, daemon.ErrSessionNotFound):
			http.Error(w, err.Error(), http.StatusNotFound)
		case errors.Is(err, daemon.ErrOverlayDrift):
			http.Error(w, err.Error(), http.StatusConflict)
		default:
			http.Error(w, err.Error(), http.StatusBadRequest)
		}
		return
	}
	WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
}

// handleOverlayDelete handles DELETE /v1/overlay/sessions/{id}/files.
// Body: {"path": "..."}. Removes one overlay file from the session.
func (h *Handler) handleOverlayDelete(w http.ResponseWriter, r *http.Request) {
	if h.overlays == nil {
		http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
		return
	}
	id := r.PathValue("id")
	if id == "" {
		http.Error(w, "session id required", http.StatusBadRequest)
		return
	}
	var body struct {
		Path string `json:"path"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
		return
	}
	if body.Path == "" {
		http.Error(w, "path is required", http.StatusBadRequest)
		return
	}
	if err := h.overlays.Delete(id, body.Path); err != nil {
		if errors.Is(err, daemon.ErrSessionNotFound) {
			http.Error(w, err.Error(), http.StatusNotFound)
		} else {
			http.Error(w, err.Error(), http.StatusBadRequest)
		}
		return
	}
	WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
}

// handleOverlayDrop handles DELETE /v1/overlay/sessions/{id}.
// Drops the entire session, discarding every overlay it held.
func (h *Handler) handleOverlayDrop(w http.ResponseWriter, r *http.Request) {
	if h.overlays == nil {
		http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
		return
	}
	id := r.PathValue("id")
	if id == "" {
		http.Error(w, "session id required", http.StatusBadRequest)
		return
	}
	h.overlays.Drop(id)
	w.WriteHeader(http.StatusNoContent)
}

// handleOverlayList handles GET /v1/overlay/sessions/{id}/files.
// Returns the current overlay snapshot for the session.
func (h *Handler) handleOverlayList(w http.ResponseWriter, r *http.Request) {
	if h.overlays == nil {
		http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable)
		return
	}
	id := r.PathValue("id")
	if id == "" {
		http.Error(w, "session id required", http.StatusBadRequest)
		return
	}
	files, err := h.overlays.Files(id)
	if err != nil {
		if errors.Is(err, daemon.ErrSessionNotFound) {
			http.Error(w, err.Error(), http.StatusNotFound)
		} else {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
		return
	}
	out := make([]daemon.OverlayFile, 0, len(files))
	for _, f := range files {
		out = append(out, f)
	}
	WriteJSON(w, http.StatusOK, map[string]any{"files": out})
}

// handleWorkspaceRoster serves `GET /v1/workspaces/{ws}/repos`.
// Returns the deduplicated list of repo prefixes for the requested
// workspace slug, or 404 when no node in
// the graph carries that workspace. The daemon-side
// WorkspaceRosterCache calls this once per (slug, workspace) and
// caches the result for ~1 minute (see daemon.NewWorkspaceRosterCache).
func (h *Handler) handleWorkspaceRoster(w http.ResponseWriter, r *http.Request) {
	ws := r.PathValue("ws")
	if ws == "" {
		http.Error(w, "workspace slug required", http.StatusBadRequest)
		return
	}
	seen := make(map[string]struct{})
	for _, n := range h.graph.AllNodes() {
		// Effective workspace match — match on either the explicit
		// WorkspaceID or the RepoPrefix fallback. Either way the
		// result list reports RepoPrefix so callers can address the
		// underlying repo without translating slugs.
		nodeWS := n.WorkspaceID
		if nodeWS == "" {
			nodeWS = n.RepoPrefix
		}
		if nodeWS != ws {
			continue
		}
		if n.RepoPrefix == "" {
			continue
		}
		seen[n.RepoPrefix] = struct{}{}
	}
	if len(seen) == 0 {
		http.Error(w, "workspace not found", http.StatusNotFound)
		return
	}
	repos := make([]string, 0, len(seen))
	for p := range seen {
		repos = append(repos, p)
	}
	WriteJSON(w, http.StatusOK, map[string]any{
		"workspace": ws,
		"repos":     repos,
	})
}

// --- /v1/processes ---
//
// Thin wrapper around the get_processes MCP tool. Returns processes in
// a UI-friendly shape: each process gets a "crosses" array (unique repo
// prefixes touched) and a "risk" rating derived from the score. When
// the underlying tool isn't registered (analyze-only build), returns
// an empty list rather than 404 so the page can render its empty state.

type processEntry struct {
	ID       string   `json:"id"`
	Name     string   `json:"name"`
	Entry    string   `json:"entry"`
	Steps    int      `json:"steps"`
	Files    int      `json:"files"`
	Repos    int      `json:"repos"`
	Score    int      `json:"score"`
	Risk     string   `json:"risk"`     // ok | warn | risk
	Crosses  []string `json:"crosses"`  // repo prefixes this flow touches
	Category string   `json:"category"` // product | tests | internal
}

// rawProcessSummary mirrors the MCP get_processes list response. Files
// and Steps are intentionally omitted — the list now carries precomputed
// step_count / file_count / repo_prefixes so the dashboard shape doesn't
// require a second call per process.
type rawProcessSummary struct {
	ID           string   `json:"id"`
	Name         string   `json:"name"`
	EntryPoint   string   `json:"entry_point"`
	StepCount    int      `json:"step_count"`
	FileCount    int      `json:"file_count"`
	Score        float64  `json:"score"`
	RepoPrefixes []string `json:"repo_prefixes"`
}

func processEntryFromRaw(p rawProcessSummary) processEntry {
	risk := "ok"
	switch {
	case p.Score > 1000:
		risk = "risk"
	case p.Score > 500:
		risk = "warn"
	}
	crosses := p.RepoPrefixes
	if crosses == nil {
		crosses = []string{}
	}
	return processEntry{
		ID:       p.ID,
		Name:     p.Name,
		Entry:    p.EntryPoint,
		Steps:    p.StepCount,
		Files:    p.FileCount,
		Repos:    len(crosses),
		Score:    int(p.Score),
		Risk:     risk,
		Crosses:  crosses,
		Category: categorizeProcess(p.EntryPoint),
	}
}

// categorizeProcess buckets a flow by its entry point path so the
// Investigations UI can tab between production code, test entry points,
// and Go `internal/` package flows.
func categorizeProcess(entry string) string {
	// Split "<repo>/<path>::<sym>" into the path half.
	path, sym, hasSym := strings.Cut(entry, "::")
	if hasSym {
		if strings.HasPrefix(sym, "Test") || strings.HasPrefix(sym, "Benchmark") || strings.HasPrefix(sym, "Example") || strings.HasPrefix(sym, "Fuzz") {
			return "tests"
		}
	}
	lower := strings.ToLower(path)
	if strings.HasSuffix(lower, "_test.go") || strings.Contains(lower, "_test.") {
		return "tests"
	}
	if strings.Contains(path, "/internal/") || strings.HasPrefix(path, "internal/") {
		return "internal"
	}
	return "product"
}

func (h *Handler) handleProcesses(w http.ResponseWriter, r *http.Request) {
	raw, err := h.CallToolStrict(r.Context(), "get_processes", map[string]any{})
	if err != nil {
		WriteJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}
	if raw == "" {
		WriteJSON(w, http.StatusOK, map[string]any{"processes": []processEntry{}})
		return
	}
	type wrap struct {
		Processes []rawProcessSummary `json:"processes"`
	}
	var w0 wrap
	if err := json.Unmarshal([]byte(raw), &w0); err != nil {
		WriteJSON(w, http.StatusOK, map[string]any{"processes": []processEntry{}})
		return
	}
	out := make([]processEntry, 0, len(w0.Processes))
	for _, p := range w0.Processes {
		out = append(out, processEntryFromRaw(p))
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
	WriteJSON(w, http.StatusOK, map[string]any{"processes": out})
}

// --- /v1/contracts ---
//
// Flattens the contracts MCP tool's by_repo grouping into a single list
// keyed by canonical contract ID. The UI shows kind / producer /
// consumers / breaking flag; we fold provider+consumer rows into a
// single entry per contract ID so users see one row per route, not two.

type contractEntry struct {
	ID        string             `json:"id"`
	Name      string             `json:"name"`
	Kind      string             `json:"kind"`  // REST | EVENT | URL | ENV | DEP (coarse, for badge)
	Type      string             `json:"type"`  // http | grpc | graphql | topic | ws | env | openapi | dependency
	Scope     string             `json:"scope"` // own | external
	Producer  string             `json:"producer"`
	Consumers []string           `json:"consumers"`
	Version   string             `json:"version"`
	Breaking  bool               `json:"breaking"`
	Callers   int                `json:"callers"`
	Last      string             `json:"last"`
	Locations []contractLocation `json:"locations"`

	// IsTestOnly is true when every location of this contract was
	// extracted from a synthetic test/bench fixture (testdata/,
	// bench/fixtures/, __fixtures__/). The UI uses this as a single
	// boolean to hide synthetic rows from the production view by
	// default, while still allowing a "show tests" toggle so drift
	// checks (test pinned to a stale provider contract) stay visible.
	// Mixed contracts — at least one production location plus one
	// test location — keep IsTestOnly=false so they remain in the
	// production view; the per-location Meta carries the granular
	// is_test flag for callers that want to render badges per row.
	IsTestOnly bool `json:"is_test_only,omitempty"`

	// Schema-shape fields promoted from the primary location's meta so
	// the UI can render a structured schema card instead of the raw
	// per-location JSON. Populated from the provider when present,
	// otherwise from the first consumer.
	Schema *contractSchema `json:"schema,omitempty"`
	// ProviderSchema / ConsumerSchema expose each side's meta
	// independently so the UI can render side-by-side comparison.
	// Both are nil when no location of that role has schema info.
	ProviderSchema *contractSchema `json:"provider_schema,omitempty"`
	ConsumerSchema *contractSchema `json:"consumer_schema,omitempty"`
}

// contractSchema summarises the request/response shape of a contract
// for UI consumption. All fields are optional and reflect whatever
// the extractor and post-pass could pin down. `Source` is one of
// "extracted" | "partial" | "none".
type contractSchema struct {
	RequestType    string `json:"request_type,omitempty"`
	ResponseType   string `json:"response_type,omitempty"`
	RequestExpr    string `json:"request_expr,omitempty"`
	ResponseExpr   string `json:"response_expr,omitempty"`
	RequestStream  bool   `json:"request_stream,omitempty"`
	ResponseStream bool   `json:"response_stream,omitempty"`
	// ResponseRepeated is true when the response value's declared
	// type was a slice (e.g. `[]Foo` from `make([]Foo, …)`). The UI
	// renders the response as `[Foo]` instead of `Foo`.
	ResponseRepeated bool `json:"response_repeated,omitempty"`
	// ResponseShape and RequestShape are the snapshotted struct
	// definitions of ResponseType / RequestType — fields, JSON tags,
	// types — so the UI can render the body as an actual JSON
	// object instead of a bare type-symbol-ID. Empty when the type
	// isn't a graph-known struct (primitives, external types).
	ResponseShape map[string]any `json:"response_shape,omitempty"`
	RequestShape  map[string]any `json:"request_shape,omitempty"`
	// ResponseEnvelope is the structured form of an inline map
	// response like `map[string]any{"files": out}`. When present,
	// the UI prefers it over ResponseExpr so the schema view shows
	// the JSON shape (a list of {name, type, expr} fields) instead
	// of the raw helper-call text.
	ResponseEnvelope []envelopeFieldDTO `json:"response_envelope,omitempty"`
	PathParams       []string           `json:"path_params,omitempty"`
	// PathParamNames preserves the developer-written parameter names
	// in the same positional order as PathParams. Contract IDs use
	// positional p1/p2/... so cross-repo matching is naming-agnostic;
	// this field surfaces the actual source-side names (e.g. ["id"])
	// for display, drift detection, and OpenAPI export.
	PathParamNames []string `json:"path_param_names,omitempty"`
	QueryParams    []string `json:"query_params,omitempty"`
	StatusCodes    []int    `json:"status_codes,omitempty"`
	Source         string   `json:"source,omitempty"`
}

// envelopeFieldDTO mirrors one entry of contracts.envelopeField on the
// HTTP wire. Type is best-effort and may be empty when the source
// expression couldn't be traced to a typed binding; Expr is always
// the trimmed source expression that fed the JSON key. Shape is the
// snapshotted struct definition for Type — fields, JSON tags, etc. —
// so the UI can render the response as an actual JSON object instead
// of a bare type-symbol-ID. Empty when the type couldn't be expanded
// (external types, untyped expressions).
type envelopeFieldDTO struct {
	Name string `json:"name"`
	Type string `json:"type,omitempty"`
	Expr string `json:"expr,omitempty"`
	// Repeated is true when the originally-declared type was a slice
	// (e.g. `[]Repo` or `make([]string, …)`). The UI renders the field
	// as an array — `repos: [Repo]` instead of `repos: Repo`.
	Repeated bool           `json:"repeated,omitempty"`
	Shape    map[string]any `json:"shape,omitempty"`
}

// contractLocation is a single on-disk occurrence of a contract —
// either a provider handler or a consumer call site. The UI uses this
// to expand a contract row into a jump-list of file:line entries and
// to resolve symbol IDs back to source via /v1/tools/get_symbol_source.
type contractLocation struct {
	Role       string         `json:"role"` // provider | consumer
	RepoPrefix string         `json:"repo_prefix"`
	SymbolID   string         `json:"symbol_id"`
	FilePath   string         `json:"file_path"`
	Line       int            `json:"line"`
	Meta       map[string]any `json:"meta,omitempty"`
}

func (h *Handler) handleContracts(w http.ResponseWriter, r *http.Request) {
	raw, err := h.CallToolStrict(r.Context(), "contracts", map[string]any{"action": "list"})
	if err != nil {
		WriteJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}
	if raw == "" {
		WriteJSON(w, http.StatusOK, map[string]any{"contracts": []contractEntry{}})
		return
	}
	type rawContract struct {
		ID         string         `json:"id"`
		Type       string         `json:"type"`
		Role       string         `json:"role"`
		SymbolID   string         `json:"symbol_id"`
		FilePath   string         `json:"file_path"`
		Line       int            `json:"line"`
		RepoPrefix string         `json:"repo_prefix"`
		Meta       map[string]any `json:"meta"`
		Confidence float64        `json:"confidence"`
	}
	type wrap struct {
		ByRepo map[string]struct {
			Contracts map[string][]rawContract `json:"contracts"`
			Total     int                      `json:"total"`
		} `json:"by_repo"`
	}
	var w0 wrap
	if err := json.Unmarshal([]byte(raw), &w0); err != nil {
		WriteJSON(w, http.StatusOK, map[string]any{"contracts": []contractEntry{}})
		return
	}
	merged := make(map[string]*contractEntry)
	for _, group := range w0.ByRepo {
		for kind, items := range group.Contracts {
			for _, c := range items {
				// Upstream NormalizeHTTPPath preserves parameter names,
				// so a provider's "/v1/workspaces/{wid}/tags/{id}" and
				// a consumer's "/v1/workspaces/{workspaceId}/tags/{id}"
				// hash to different contract IDs for the same route.
				// Re-canonicalize by positional params so they merge.
				key := canonicalContractKey(c.ID)
				e, ok := merged[key]
				if !ok {
					e = &contractEntry{
						ID:        key,
						Name:      key,
						Kind:      uiContractKind(kind),
						Type:      kind,
						Consumers: []string{},
						Locations: []contractLocation{},
					}
					merged[key] = e
				}
				if c.Role == "provider" && e.Producer == "" {
					e.Producer = c.RepoPrefix
				}
				if c.Role == "consumer" && c.RepoPrefix != "" && !slices.Contains(e.Consumers, c.RepoPrefix) {
					e.Consumers = append(e.Consumers, c.RepoPrefix)
				}
				e.Locations = append(e.Locations, contractLocation{
					Role:       c.Role,
					RepoPrefix: c.RepoPrefix,
					SymbolID:   c.SymbolID,
					FilePath:   c.FilePath,
					Line:       c.Line,
					Meta:       c.Meta,
				})
				e.Callers++
			}
		}
	}
	out := make([]contractEntry, 0, len(merged))
	for _, e := range merged {
		e.Scope = contractScope(e.Type, e.Producer)
		sort.SliceStable(e.Locations, func(i, j int) bool {
			a, b := e.Locations[i], e.Locations[j]
			if a.Role != b.Role {
				return a.Role == "provider" // providers first
			}
			if a.RepoPrefix != b.RepoPrefix {
				return a.RepoPrefix < b.RepoPrefix
			}
			if a.FilePath != b.FilePath {
				return a.FilePath < b.FilePath
			}
			return a.Line < b.Line
		})
		e.Schema = promoteSchemaFromLocations(e.Locations)
		e.ProviderSchema = promoteSchemaForRole(e.Locations, "provider")
		e.ConsumerSchema = promoteSchemaForRole(e.Locations, "consumer")
		e.IsTestOnly = locationsAllTagged(e.Locations, "is_test")
		out = append(out, *e)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Producer != out[j].Producer {
			return out[i].Producer < out[j].Producer
		}
		return out[i].Name < out[j].Name
	})
	WriteJSON(w, http.StatusOK, map[string]any{"contracts": out})
}

// --- /v1/contracts/validate ---
//
// Passes through to the contracts MCP tool's `validate` action and
// returns its JSON payload unchanged: `{issues: [...], summary: {...}}`.
// The UI consumes this to badge contract rows with breaking-change
// counts and render a per-contract diff panel.

func (h *Handler) handleContractsValidate(w http.ResponseWriter, r *http.Request) {
	raw, err := h.CallToolStrict(r.Context(), "contracts", map[string]any{"action": "validate"})
	if err != nil {
		WriteJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}
	if raw == "" {
		WriteJSON(w, http.StatusOK, map[string]any{
			"issues":  []any{},
			"summary": map[string]int{"total": 0, "breaking": 0, "warning": 0, "info": 0},
		})
		return
	}
	// The tool returns JSON we can relay verbatim — re-decoding to a
	// Go map would lose the severity enum's string form. Only thing
	// we do is verify it parses as JSON; malformed input falls back
	// to an empty payload rather than propagating a server error.
	var probe any
	if err := json.Unmarshal([]byte(raw), &probe); err != nil {
		WriteJSON(w, http.StatusOK, map[string]any{
			"issues":  []any{},
			"summary": map[string]int{"total": 0, "breaking": 0, "warning": 0, "info": 0},
		})
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_, _ = w.Write([]byte(raw))
}

func uiContractKind(raw string) string {
	switch raw {
	case "topic":
		return "EVENT"
	case "ws":
		return "URL"
	case "http", "grpc", "graphql", "openapi":
		return "REST"
	case "env":
		return "ENV"
	case "dependency":
		return "DEP"
	default:
		return strings.ToUpper(raw)
	}
}

// promoteSchemaForRole extracts a schema summary from every location
// matching `role` (provider | consumer). Multiple locations for the
// same role are merged additively — first non-empty value wins for
// type fields, lists union. Returns nil when no matching location has
// any schema info.
func promoteSchemaForRole(locs []contractLocation, role string) *contractSchema {
	var primary *contractLocation
	for i := range locs {
		if locs[i].Role != role || locs[i].Meta == nil {
			continue
		}
		primary = &locs[i]
		break
	}
	if primary == nil {
		return nil
	}
	s := &contractSchema{
		RequestType:      metaString(primary.Meta, "request_type"),
		ResponseType:     metaString(primary.Meta, "response_type"),
		RequestExpr:      metaString(primary.Meta, "request_expr"),
		ResponseExpr:     metaString(primary.Meta, "response_expr"),
		ResponseRepeated: metaBool(primary.Meta, "response_repeated"),
		ResponseShape:    normaliseShape(primary.Meta["response_shape"]),
		RequestShape:     normaliseShape(primary.Meta["request_shape"]),
		ResponseEnvelope: metaEnvelope(primary.Meta, "response_envelope"),
		Source:           metaString(primary.Meta, "schema_source"),
		PathParams:       metaStrings(primary.Meta, "path_params"),
		PathParamNames:   metaStrings(primary.Meta, "path_param_names"),
		QueryParams:      metaStrings(primary.Meta, "query_params"),
		StatusCodes:      metaInts(primary.Meta, "status_codes"),
	}
	if v, _ := primary.Meta["request_stream"].(bool); v {
		s.RequestStream = true
	}
	if v, _ := primary.Meta["response_stream"].(bool); v {
		s.ResponseStream = true
	}
	// Merge secondary same-role locations (multi-consumer case).
	for i := range locs {
		l := &locs[i]
		if l == primary || l.Role != role || l.Meta == nil {
			continue
		}
		if s.RequestType == "" {
			s.RequestType = metaString(l.Meta, "request_type")
		}
		if s.ResponseType == "" {
			s.ResponseType = metaString(l.Meta, "response_type")
		}
		if len(s.StatusCodes) == 0 {
			s.StatusCodes = metaInts(l.Meta, "status_codes")
		}
		if len(s.QueryParams) == 0 {
			s.QueryParams = metaStrings(l.Meta, "query_params")
		}
		if len(s.ResponseEnvelope) == 0 {
			s.ResponseEnvelope = metaEnvelope(l.Meta, "response_envelope")
		}
	}
	if schemaIsEmpty(s) {
		return nil
	}
	return s
}

// locationsAllTagged reports whether every location carries the named
// boolean meta flag (e.g. "is_test"). Empty location list returns
// false — there's nothing to be uniformly tagged. Used to roll up the
// per-location is_test stamp into a single is_test_only contractEntry
// flag.
func locationsAllTagged(locs []contractLocation, key string) bool {
	if len(locs) == 0 {
		return false
	}
	for _, l := range locs {
		if v, _ := l.Meta[key].(bool); !v {
			return false
		}
	}
	return true
}

// promoteSchemaFromLocations folds schema-shape meta from the primary
// location (provider first, consumer fallback) into a flat
// contractSchema for UI rendering. Returns nil when nothing useful
// was pinned down so the wire shape stays compact.
func promoteSchemaFromLocations(locs []contractLocation) *contractSchema {
	var primary contractLocation
	found := false
	for _, l := range locs {
		if l.Role == "provider" {
			primary = l
			found = true
			break
		}
	}
	if !found && len(locs) > 0 {
		primary = locs[0]
		found = true
	}
	if !found || primary.Meta == nil {
		return nil
	}
	s := &contractSchema{
		RequestType:      metaString(primary.Meta, "request_type"),
		ResponseType:     metaString(primary.Meta, "response_type"),
		RequestExpr:      metaString(primary.Meta, "request_expr"),
		ResponseExpr:     metaString(primary.Meta, "response_expr"),
		ResponseRepeated: metaBool(primary.Meta, "response_repeated"),
		ResponseShape:    normaliseShape(primary.Meta["response_shape"]),
		RequestShape:     normaliseShape(primary.Meta["request_shape"]),
		ResponseEnvelope: metaEnvelope(primary.Meta, "response_envelope"),
		Source:           metaString(primary.Meta, "schema_source"),
		PathParams:       metaStrings(primary.Meta, "path_params"),
		PathParamNames:   metaStrings(primary.Meta, "path_param_names"),
		QueryParams:      metaStrings(primary.Meta, "query_params"),
		StatusCodes:      metaInts(primary.Meta, "status_codes"),
	}
	if v, _ := primary.Meta["request_stream"].(bool); v {
		s.RequestStream = true
	}
	if v, _ := primary.Meta["response_stream"].(bool); v {
		s.ResponseStream = true
	}
	// If a provider had nothing but a consumer also has meta, try
	// filling gaps from it — useful for contracts where the consumer
	// (e.g. a generated SDK) carries types the provider didn't
	// annotate.
	for _, l := range locs {
		if l.Role == primary.Role || l.Meta == nil {
			continue
		}
		if s.RequestType == "" {
			s.RequestType = metaString(l.Meta, "request_type")
		}
		if s.ResponseType == "" {
			s.ResponseType = metaString(l.Meta, "response_type")
		}
		if len(s.StatusCodes) == 0 {
			s.StatusCodes = metaInts(l.Meta, "status_codes")
		}
		if len(s.QueryParams) == 0 {
			s.QueryParams = metaStrings(l.Meta, "query_params")
		}
		if len(s.ResponseEnvelope) == 0 {
			s.ResponseEnvelope = metaEnvelope(l.Meta, "response_envelope")
		}
	}
	if schemaIsEmpty(s) {
		return nil
	}
	return s
}

func schemaIsEmpty(s *contractSchema) bool {
	if s == nil {
		return true
	}
	return s.RequestType == "" && s.ResponseType == "" && s.RequestExpr == "" && s.ResponseExpr == "" &&
		len(s.ResponseEnvelope) == 0 &&
		len(s.ResponseShape) == 0 && len(s.RequestShape) == 0 &&
		len(s.PathParams) == 0 && len(s.QueryParams) == 0 && len(s.StatusCodes) == 0 &&
		!s.RequestStream && !s.ResponseStream && !s.ResponseRepeated
}

// metaEnvelope decodes the upstream "response_envelope" meta entry —
// emitted by the contract enrichers as a []map[string]any with name /
// type / expr keys per row. The intermediate JSON pass between
// daemon and dashboard turns the slice into []any of map[string]any,
// so we accept both shapes.
func metaEnvelope(m map[string]any, key string) []envelopeFieldDTO {
	raw := m[key]
	if raw == nil {
		return nil
	}
	var rows []map[string]any
	switch v := raw.(type) {
	case []map[string]any:
		rows = v
	case []any:
		rows = make([]map[string]any, 0, len(v))
		for _, x := range v {
			if row, ok := x.(map[string]any); ok {
				rows = append(rows, row)
			}
		}
	}
	if len(rows) == 0 {
		return nil
	}
	out := make([]envelopeFieldDTO, 0, len(rows))
	for _, row := range rows {
		f := envelopeFieldDTO{}
		if s, ok := row["name"].(string); ok {
			f.Name = s
		}
		if s, ok := row["type"].(string); ok {
			f.Type = s
		}
		if s, ok := row["expr"].(string); ok {
			f.Expr = s
		}
		if v, ok := row["repeated"].(bool); ok {
			f.Repeated = v
		}
		// Shape arrives either as a *contracts.Shape (in-process call)
		// or as map[string]any (post-JSON-roundtrip on the wire). The
		// dashboard re-emits it as JSON either way, so we normalise to
		// map[string]any here.
		f.Shape = normaliseShape(row["shape"])
		if f.Name != "" {
			out = append(out, f)
		}
	}
	return out
}

// normaliseShape coerces whatever the contracts MCP tool emitted for
// a "shape" Meta value into a plain map[string]any so the dashboard
// can re-emit it as JSON. Already-decoded JSON arrives as
// map[string]any; gob-decoded paths produce *contracts.Shape (or its
// equivalent struct) which we marshal+unmarshal once.
func normaliseShape(raw any) map[string]any {
	if raw == nil {
		return nil
	}
	if m, ok := raw.(map[string]any); ok {
		return m
	}
	b, err := json.Marshal(raw)
	if err != nil {
		return nil
	}
	var out map[string]any
	if json.Unmarshal(b, &out) != nil {
		return nil
	}
	return out
}

func metaString(m map[string]any, key string) string {
	if v, ok := m[key].(string); ok {
		return v
	}
	return ""
}

func metaBool(m map[string]any, key string) bool {
	if v, ok := m[key].(bool); ok {
		return v
	}
	return false
}

func metaStrings(m map[string]any, key string) []string {
	switch v := m[key].(type) {
	case []string:
		return v
	case []any:
		out := make([]string, 0, len(v))
		for _, x := range v {
			if s, ok := x.(string); ok {
				out = append(out, s)
			}
		}
		return out
	}
	return nil
}

func metaInts(m map[string]any, key string) []int {
	switch v := m[key].(type) {
	case []int:
		return v
	case []any:
		out := make([]int, 0, len(v))
		for _, x := range v {
			switch n := x.(type) {
			case int:
				out = append(out, n)
			case float64:
				out = append(out, int(n))
			}
		}
		return out
	}
	return nil
}

// contractScope decides whether a merged contract row represents something
// defined in the active project ("own") or something it only consumes
// ("external"). Go-module / package dependency contracts are always
// external by construction; for the rest, a row is own iff we found a
// provider inside the active-project repo set.
func contractScope(rawType, producer string) string {
	if rawType == "dependency" {
		return "external"
	}
	if producer == "" {
		return "external"
	}
	return "own"
}

// canonicalContractIDParam matches any {name} path-parameter placeholder
// in a contract ID. We rewrite the name to a positional {p1}, {p2}, …
// so the same HTTP route matches regardless of whether the provider
// called the param {id} and the consumer called it {entityId}.
var canonicalContractIDParam = regexp.MustCompile(`\{[^}]+\}`)

// canonicalContractKey returns a merge-stable key for a raw contract
// ID. Only IDs whose first segment is "http" are rewritten; other
// contract types (grpc, topic, env, dependency) already have structural
// IDs that don't embed param names.
func canonicalContractKey(id string) string {
	if !strings.HasPrefix(id, "http::") {
		return id
	}
	i := 0
	return canonicalContractIDParam.ReplaceAllStringFunc(id, func(string) string {
		i++
		return fmt.Sprintf("{p%d}", i)
	})
}

// --- /v1/communities ---
//
// Returns the community detection result reshaped for the dashboard
// communities card. The MCP get_communities list summary already
// carries the majority repo prefix and file count so we don't need a
// second call per community.

type communityEntry struct {
	ID       string  `json:"id"`
	Name     string  `json:"name"`
	Repo     string  `json:"repo"`
	Symbols  int     `json:"symbols"`
	Files    int     `json:"files"`
	Cohesion float64 `json:"cohesion"`
	// ParentID points at the phase-2 super-community this leaf
	// belongs to. Sibling clusters under the same ParentID get
	// grouped under a shared header in the dashboard UI.
	ParentID string `json:"parent_id,omitempty"`
}

func (h *Handler) handleCommunities(w http.ResponseWriter, r *http.Request) {
	raw, err := h.CallToolStrict(r.Context(), "get_communities", map[string]any{})
	if err != nil {
		WriteJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}
	if raw == "" {
		WriteJSON(w, http.StatusOK, map[string]any{"communities": []communityEntry{}, "modularity": 0.0})
		return
	}
	type rawComm struct {
		ID         string  `json:"id"`
		Label      string  `json:"label"`
		Size       int     `json:"size"`
		FileCount  int     `json:"file_count"`
		Cohesion   float64 `json:"cohesion"`
		RepoPrefix string  `json:"repo_prefix"`
		ParentID   string  `json:"parent_id,omitempty"`
	}
	type wrap struct {
		Communities []rawComm `json:"communities"`
		Modularity  float64   `json:"modularity"`
	}
	var w0 wrap
	if err := json.Unmarshal([]byte(raw), &w0); err != nil {
		WriteJSON(w, http.StatusOK, map[string]any{"communities": []communityEntry{}, "modularity": 0.0})
		return
	}
	out := make([]communityEntry, 0, len(w0.Communities))
	for _, c := range w0.Communities {
		out = append(out, communityEntry{
			ID:       c.ID,
			Name:     c.Label,
			Repo:     c.RepoPrefix,
			Symbols:  c.Size,
			Files:    c.FileCount,
			Cohesion: c.Cohesion,
			ParentID: c.ParentID,
		})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Symbols > out[j].Symbols })
	WriteJSON(w, http.StatusOK, map[string]any{
		"communities": out,
		"modularity":  w0.Modularity,
	})
}

// --- /v1/guards ---
//
// Wraps check_guards into the table shape used by the Guards page. The
// MCP tool returns per-rule violations; we group by rule and report a
// status (ok | warn | violated) with the hit count.

type guardEntry struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Kind   string `json:"kind"`
	Status string `json:"status"`
	Hits   int    `json:"hits"`
	Scope  string `json:"scope"`
}

// handleGuards lists guard rules from configuration. Status is "ok" by
// default — rules don't have a runtime "violated" state until evaluated
// against a change set, which is the job of the check_guards MCP tool
// (callable via /v1/tools/check_guards with an `ids` argument). The
// page shows what's configured; the IDE / agent gets violations
// per-change.
func (h *Handler) handleGuards(w http.ResponseWriter, _ *http.Request) {
	out := make([]guardEntry, 0)
	seen := make(map[string]bool)
	add := func(rules []struct {
		Name, Kind, Source, Target string
	}) {
		for i, r := range rules {
			key := r.Name + "::" + r.Source + "::" + r.Target
			if seen[key] {
				continue
			}
			seen[key] = true
			out = append(out, guardEntry{
				ID:     fmt.Sprintf("%s-%d", r.Name, i),
				Name:   r.Name,
				Kind:   r.Kind,
				Status: "ok",
				Hits:   0,
				Scope:  r.Source + " → " + r.Target,
			})
		}
	}
	if h.configManager != nil {
		// Workspace overrides per active repo + global defaults at "".
		repos := append([]string{""}, repoNames(h.configManager.ActiveRepos())...)
		for _, name := range repos {
			rules := h.configManager.EffectiveGuardRules(name)
			compact := make([]struct {
				Name, Kind, Source, Target string
			}, 0, len(rules))
			for _, r := range rules {
				compact = append(compact, struct {
					Name, Kind, Source, Target string
				}{r.Name, r.Kind, r.Source, r.Target})
			}
			add(compact)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	WriteJSON(w, http.StatusOK, map[string]any{"guards": out})
}

func repoNames(repos []config.RepoEntry) []string {
	out := make([]string, 0, len(repos))
	for _, r := range repos {
		if r.Name != "" {
			out = append(out, r.Name)
		}
	}
	return out
}

// --- /v1/caveats ---

type caveatEntry struct {
	ID       string `json:"id"`
	Severity string `json:"severity"` // risk | hot | cycle | unowned | deprecated | boundary
	Symbol   string `json:"symbol"`
	Title    string `json:"title"`
	Desc     string `json:"desc"`
	Owner    string `json:"owner"`
	Age      string `json:"age"`
	// Graph-enriched fields. Populated in handleCaveats after parsing,
	// by looking up the symbol in the graph — all optional because cycle
	// entries and synthetic caveats may have no resolvable node.
	FilePath        string   `json:"file_path,omitempty"`
	RepoPrefix      string   `json:"repo_prefix,omitempty"`
	Kind            string   `json:"kind,omitempty"`
	FanIn           int      `json:"fan_in,omitempty"`
	ExternalCallers int      `json:"external_callers,omitempty"`
	CallerRepos     []string `json:"caller_repos,omitempty"`
}

func (h *Handler) handleCaveats(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	out := make([]caveatEntry, 0, 32)

	// check_guards is intentionally NOT called here — the MCP tool
	// requires a `changed symbol IDs` argument and only returns
	// violations against that change set. The Caveats page is supposed
	// to surface persistent landmines, not what would fire if a
	// specific commit ran. Boundary/ownership violations come from the
	// /v1/guards endpoint instead.
	for _, kind := range []string{"hotspots", "dead_code", "cycles"} {
		raw, err := h.CallToolStrict(ctx, "analyze", map[string]any{"kind": kind, "limit": 20})
		if err != nil {
			h.logger.Warn("caveats: analyze sub-call failed; section will be empty",
				zap.String("kind", kind), zap.Error(err))
			continue
		}
		if raw == "" {
			continue
		}
		switch kind {
		case "hotspots":
			out = append(out, parseHotspots(raw)...)
		case "dead_code":
			out = append(out, parseDeadCode(raw)...)
		case "cycles":
			out = append(out, parseCycles(raw)...)
		}
	}

	severityRank := map[string]int{
		"risk":       0,
		"hot":        1,
		"cycle":      2,
		"boundary":   3,
		"unowned":    4,
		"deprecated": 5,
	}
	sortByRank(out, severityRank)
	enrichCaveats(h.Graph(), out)
	WriteJSON(w, http.StatusOK, map[string]any{"caveats": out})
}

// enrichCaveats fills in file_path, repo_prefix, kind, fan_in, and
// cross-repo caller counts by looking each caveat's symbol up in the
// graph. Entries with an unresolvable symbol (e.g. cycle placeholders
// or stale IDs from a prior index) are left untouched so the caller can
// detect the gap instead of rendering zeros that look like real data.
func enrichCaveats(g *graph.Graph, caveats []caveatEntry) {
	if g == nil {
		return
	}
	for i := range caveats {
		sym := caveats[i].Symbol
		if sym == "" {
			continue
		}
		node := g.GetNode(sym)
		if node == nil {
			continue
		}
		caveats[i].FilePath = node.FilePath
		caveats[i].RepoPrefix = node.RepoPrefix
		caveats[i].Kind = string(node.Kind)

		inEdges := g.GetInEdges(sym)
		repoSet := make(map[string]struct{}, len(inEdges))
		var fanIn, external int
		for _, e := range inEdges {
			if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
				continue
			}
			fanIn++
			caller := g.GetNode(e.From)
			if caller == nil {
				continue
			}
			if caller.RepoPrefix != "" && caller.RepoPrefix != node.RepoPrefix {
				external++
				repoSet[caller.RepoPrefix] = struct{}{}
			}
		}
		caveats[i].FanIn = fanIn
		caveats[i].ExternalCallers = external
		if len(repoSet) > 0 {
			repos := make([]string, 0, len(repoSet))
			for r := range repoSet {
				repos = append(repos, r)
			}
			sort.Strings(repos)
			caveats[i].CallerRepos = repos
		}
	}
}

func sortByRank(in []caveatEntry, rank map[string]int) {
	for i := 1; i < len(in); i++ {
		j := i
		for j > 0 && rank[in[j-1].Severity] > rank[in[j].Severity] {
			in[j-1], in[j] = in[j], in[j-1]
			j--
		}
	}
}

// All four parsers below produce IDs that combine a per-parser prefix
// with the entry index. The index is essential — the underlying tools
// can return entries with empty IDs (e.g. cycles, where the natural ID
// is the path), and the React UI uses the ID as a list key. Without
// the index suffix, multiple empty-ID entries collapse to the same key
// and React warns about duplicates.

func parseHotspots(raw string) []caveatEntry {
	// Mirrors analysis.HotspotEntry.
	type hotspot struct {
		ID       string `json:"id"`
		Name     string `json:"name"`
		Kind     string `json:"kind"`
		FilePath string `json:"file_path"`
		Line     int    `json:"start_line"`
		FanIn    int    `json:"fan_in"`
	}
	type wrap struct {
		Hotspots []hotspot `json:"hotspots"`
	}
	var w wrap
	if err := json.Unmarshal([]byte(raw), &w); err != nil || len(w.Hotspots) == 0 {
		return nil
	}
	out := make([]caveatEntry, 0, len(w.Hotspots))
	for i, h := range w.Hotspots {
		if i >= 10 {
			break
		}
		name := h.Name
		if name == "" {
			name = h.ID
		}
		out = append(out, caveatEntry{
			ID:       fmt.Sprintf("hs-%d-%s", i, h.ID),
			Severity: "hot",
			Symbol:   h.ID,
			Title:    "Hot path · " + name,
			Desc:     fmt.Sprintf("Fan-in %d — touched by many call sites.", h.FanIn),
			Age:      "ongoing",
		})
	}
	return out
}

func parseDeadCode(raw string) []caveatEntry {
	// Mirrors analysis.DeadCodeEntry.
	type entry struct {
		ID       string `json:"id"`
		Name     string `json:"name"`
		Kind     string `json:"kind"`
		FilePath string `json:"file_path"`
	}
	type wrap struct {
		DeadCode []entry `json:"dead_code"`
	}
	var w wrap
	if err := json.Unmarshal([]byte(raw), &w); err != nil || len(w.DeadCode) == 0 {
		return nil
	}
	out := make([]caveatEntry, 0, len(w.DeadCode))
	for i, e := range w.DeadCode {
		if i >= 10 {
			break
		}
		name := e.Name
		if name == "" {
			name = e.ID
		}
		out = append(out, caveatEntry{
			ID:       fmt.Sprintf("dc-%d-%s", i, e.ID),
			Severity: "deprecated",
			Symbol:   e.ID,
			Title:    "Likely unreachable · " + name,
			Desc:     "No incoming references in the indexed graph.",
		})
	}
	return out
}

func parseCycles(raw string) []caveatEntry {
	// Mirrors analysis.Cycle: { path[], kind, severity }. Earlier
	// versions of this parser looked for `id` and `members`, which the
	// real type doesn't have — every cycle ended up with an empty ID,
	// and the dashboard's React keys collided. Now the entry index is
	// part of the ID so duplicate or empty paths still render distinctly.
	type cycle struct {
		Path     []string `json:"path"`
		Kind     string   `json:"kind"`
		Severity int      `json:"severity"`
	}
	type wrap struct {
		Cycles []cycle `json:"cycles"`
	}
	var w wrap
	if err := json.Unmarshal([]byte(raw), &w); err != nil || len(w.Cycles) == 0 {
		return nil
	}
	out := make([]caveatEntry, 0, len(w.Cycles))
	for i, c := range w.Cycles {
		if i >= 10 {
			break
		}
		title := "Circular dependency"
		symbol := ""
		if len(c.Path) > 0 {
			symbol = c.Path[0]
			title = "Cycle: " + symbol
		}
		desc := fmt.Sprintf("%d symbols form a %s cycle.", len(c.Path), nonEmpty(c.Kind, "dependency"))
		out = append(out, caveatEntry{
			ID:       fmt.Sprintf("cy-%d-%s", i, symbol),
			Severity: "cycle",
			Symbol:   symbol,
			Title:    title,
			Desc:     desc,
		})
	}
	return out
}

func nonEmpty(s, fallback string) string {
	if s == "" {
		return fallback
	}
	return s
}

// --- /v1/dashboard ---
//
// Bundles every datum the dashboard hero card needs into one round-trip:
// totals, kinds + languages (as ordered arrays so the UI doesn't sort),
// repo cards, recent activity, and aggregated caveats. Designed to be
// cheap (one pass through stats + cached analyze tool results).

type dashboardSnapshot struct {
	Stats struct {
		TotalNodes int    `json:"total_nodes"`
		TotalEdges int    `json:"total_edges"`
		Repos      int    `json:"repos"`
		Caveats    int    `json:"caveats"`
		Version    string `json:"version"`
	} `json:"stats"`
	Kinds     []kvEntry                  `json:"kinds"`
	Languages []kvEntry                  `json:"languages"`
	Repos     []repoEntry                `json:"repos"`
	Activity  []indexer.GraphChangeEvent `json:"activity"`
	Caveats   []caveatEntry              `json:"caveats"`
	Processes []processEntry             `json:"processes"`
}

type kvEntry struct {
	Name  string `json:"name"`
	Count int    `json:"count"`
}

func mapToOrderedKV(m map[string]int) []kvEntry {
	out := make([]kvEntry, 0, len(m))
	for k, v := range m {
		out = append(out, kvEntry{Name: k, Count: v})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
	return out
}

func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) {
	stats := h.graph.Stats()
	snap := dashboardSnapshot{}
	snap.Stats.TotalNodes = stats.TotalNodes
	snap.Stats.TotalEdges = stats.TotalEdges
	snap.Stats.Version = h.version
	snap.Kinds = mapToOrderedKV(stats.ByKind)
	snap.Languages = mapToOrderedKV(stats.ByLanguage)
	snap.Repos = reposFromGraph(h.graph)
	snap.Stats.Repos = len(snap.Repos)

	if h.activity != nil {
		snap.Activity = h.activity.snapshot(20)
	} else {
		snap.Activity = []indexer.GraphChangeEvent{}
	}

	ctx, cancel := context.WithCancel(r.Context())
	defer cancel()

	// Reuse the caveats aggregator so the count and the inline preview
	// both come from the same data — no chance of the dashboard's
	// number disagreeing with the Caveats page on first load.
	cavs := make([]caveatEntry, 0, 32)
	for _, kind := range []string{"hotspots", "dead_code", "cycles"} {
		raw, err := h.CallToolStrict(ctx, "analyze", map[string]any{"kind": kind, "limit": 20})
		if err != nil {
			h.logger.Warn("dashboard: analyze sub-call failed; caveats section will be partial",
				zap.String("kind", kind), zap.Error(err))
			continue
		}
		if raw == "" {
			continue
		}
		switch kind {
		case "hotspots":
			cavs = append(cavs, parseHotspots(raw)...)
		case "dead_code":
			cavs = append(cavs, parseDeadCode(raw)...)
		case "cycles":
			cavs = append(cavs, parseCycles(raw)...)
		}
	}
	snap.Caveats = cavs
	snap.Stats.Caveats = len(cavs)

	// Top processes for the inline preview. The full list is on the
	// Processes page; here we cap at 6 so the dashboard stays compact.
	if raw, err := h.CallToolStrict(ctx, "get_processes", map[string]any{}); err != nil {
		h.logger.Warn("dashboard: get_processes failed; processes section will be empty",
			zap.Error(err))
	} else if raw != "" {
		type wrap struct {
			Processes []rawProcessSummary `json:"processes"`
		}
		var w0 wrap
		if json.Unmarshal([]byte(raw), &w0) == nil {
			procs := make([]processEntry, 0, len(w0.Processes))
			for _, p := range w0.Processes {
				procs = append(procs, processEntryFromRaw(p))
			}
			sort.Slice(procs, func(i, j int) bool { return procs[i].Score > procs[j].Score })
			if len(procs) > 6 {
				procs = procs[:6]
			}
			snap.Processes = procs
		}
	}
	if snap.Processes == nil {
		snap.Processes = []processEntry{}
	}

	WriteJSON(w, http.StatusOK, snap)
}
