package app

import (
	"fmt"
	"image/color"
	"sort"
	"strings"

	"charm.land/lipgloss/v2"
	"github.com/charmbracelet/x/ansi"
)

// center draws a modal centered over a *dimmed* copy of the screen behind it, so the
// transcript stays faintly visible around the modal (a transparent backdrop) rather
// than being hidden by an opaque scrim. Immediate-mode: recomputed from state every
// frame, so there is no show/restore path to desync.
//
// Idempotence guard: some modal builders call center() internally and then get centered
// again by renderOverlay. The inner call already produced a full-screen composite, so a
// second call (input already width×height) returns it unchanged instead of dimming the
// modal itself. Falls back to an opaque scrim when no base was stashed (defensive).
func (m Model) center(modal string) string {
	if m.lastBase == "" {
		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
			lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().
				Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep)))
	}
	if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width {
		return modal // already a full-screen composite; don't re-dim it
	}
	return m.compositeOverlay(m.lastBase, modal)
}

// compositeOverlay strips + dims base into a faint scrim, then splices the modal box
// over it centered. ANSI-aware so the side margins keep the (dimmed) content behind.
// base and the result are both m.width × m.height.
func (m Model) compositeOverlay(base, modal string) string {
	w, h := m.width, m.height
	dim := lipgloss.NewStyle().Foreground(m.theme.P.Faint).Background(m.theme.P.BgDeep)
	baseLines := strings.Split(base, "\n")
	scrim := make([]string, h)
	for i := 0; i < h; i++ {
		raw := ""
		if i < len(baseLines) {
			raw = ansi.Strip(baseLines[i])
		}
		scrim[i] = dim.Render(padRightRaw(raw, w))
	}
	modalLines := strings.Split(modal, "\n")
	mw := lipgloss.Width(modal)
	top := (h - len(modalLines)) / 2
	left := (w - mw) / 2
	if top < 0 {
		top = 0
	}
	if left < 0 {
		left = 0
	}
	for r, ml := range modalLines {
		row := top + r
		if row < 0 || row >= h {
			continue
		}
		leftPart := ansi.Truncate(scrim[row], left, "")
		rightPart := ansi.TruncateLeft(scrim[row], left+mw, "")
		scrim[row] = leftPart + ml + rightPart
	}
	return strings.Join(scrim, "\n")
}

// padRightRaw pads an unstyled string with spaces to exactly w columns (or truncates).
func padRightRaw(s string, w int) string {
	switch sw := ansi.StringWidth(s); {
	case sw > w:
		return ansi.Truncate(s, w, "")
	case sw < w:
		return s + strings.Repeat(" ", w-sw)
	default:
		return s
	}
}

func (m Model) modalWidth() int {
	w := m.width * 70 / 100
	if m.narrow() {
		w = m.width - 4 // slim terminals: give modals nearly the whole screen
	}
	if w > 92 {
		w = 92
	}
	if w < 24 {
		w = 24
	}
	return w
}

// clip truncates a (possibly ANSI-styled) line to w visible columns so modal content
// never wraps mid-word on a slim terminal. ANSI-aware — won't slice escape codes.
func clip(s string, w int) string {
	if w < 1 {
		w = 1
	}
	return ansi.Truncate(s, w, "…")
}

func (m Model) renderOverlay(base string) string {
	switch m.overlay {
	case OverlayPalette:
		return m.center(m.paletteModal())
	case OverlayEventInspector:
		return m.center(m.eventInspectorModal())
	case OverlayDiff:
		return m.center(m.diffModal())
	case OverlayToolPalette:
		return m.center(m.toolPaletteModal())
	case OverlayModels:
		return m.center(m.modelsModal())
	case OverlayArtifacts:
		return m.center(m.artifactsModal())
	case OverlayConfig:
		return m.center(m.configModal())
	case OverlayProjectProfile:
		return m.center(m.projectProfileModal())
	case OverlayOperatorProfile:
		return m.center(m.operatorProfileModal())
	case OverlayStats:
		return m.center(m.statsModal())
	case OverlayIdeas:
		return m.center(m.ideasModal())
	case OverlayHealth:
		return m.center(m.healthModal())
	case OverlaySessions:
		return m.center(m.sessionsModal())
	case OverlayTasks:
		return m.center(m.tasksModal())
	case OverlayFiles:
		return m.center(m.filesModal())
	case OverlayStatusbar:
		return m.center(m.statusbarModal())
	case OverlayGrants:
		return m.center(m.grantsModal())
	case OverlayGrantScope:
		return m.center(m.grantScopeModal())
	case OverlayPlan:
		return m.center(m.planModal())
	case OverlayHelp:
		return m.center(m.helpModal())
	}
	return base
}

func (m Model) titleLine(text string) string {
	t := m.theme
	return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text)
}

func mbg(t Theme, s string, fg color.Color) string {
	return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s)
}

// approvalBandHeight sizes the docked approval band to its diff, capped so it
// never crowds out the session above (longer diffs spill to the ^x fullscreen
// view). Layout: 2 borders + header + blank + diff rows + blank + action row.
func (m Model) approvalBandHeight() int {
	rows, rat := 0, 0
	if s := m.session(m.selectedID); s != nil && s.Pending != nil {
		rows = m.approvalContentRows(s.Pending)
		if n := len(s.Pending.Rationale); n > 0 {
			textW := m.width - 4 - 2 // box borders/padding + rationale marker
			for _, r := range s.Pending.Rationale {
				rat += len(wrapLine(r, textW))
			}
			rat++ // trailing blank
		}
	}
	steer := 0
	if m.steering || m.steerBuffer != "" {
		steer = 1
	}
	h := 2 + 1 + 1 + rat + rows + 1 + steer + 1
	if maxH := m.height * 55 / 100; h > maxH {
		h = maxH
	}
	if h < 10 {
		h = 10
	}
	if hardMax := m.height - statusH - footerH - 3; hardMax > 4 && h > hardMax {
		h = hardMax
	}
	return h
}

// renderApprovalBand draws the approval gate as a full-width band (in place of
// the input bar): tool/tier/risk header, a two-column old|new diff, and the
// action row. It never floats over the rest of the UI.
func (m Model) renderApprovalBand(h int) string {
	t := m.theme
	s := m.session(m.selectedID)
	if s == nil || s.Pending == nil {
		return m.renderInput()
	}
	a := s.Pending
	textW := m.width - 4 // box borders (2) + side padding (2)

	riskColor := t.P.Warn
	switch strings.ToUpper(a.Risk) {
	case "HIGH", "CRITICAL":
		riskColor = t.P.Bad
	case "LOW":
		riskColor = t.P.OK
	}

	leftHdr := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("→ ") +
		t.span(a.ToolName, t.P.FgStrong)
	if tgt := diffTarget(a.Preview); tgt != "" {
		leftHdr += t.span(" "+tgt, t.P.Dim)
	}
	rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) +
		t.span("  ·  risk ", t.P.Faint) +
		lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
	// When more than one gate is queued, show "approval i/n" so the operator knows
	// there are others behind this one (↑/↓ to walk them) — never stack modals.
	if n := len(s.PendingQueue); n > 1 {
		rightHdr = t.span("approval ", t.P.Faint) +
			t.span(itoa(s.PendingIdx+1)+"/"+itoa(n), t.P.Accent) +
			t.span("  ·  ", t.P.Faint) + rightHdr
	}

	innerH := h - 2
	ratBlock := 0
	if len(a.Rationale) > 0 {
		for _, r := range a.Rationale {
			ratBlock += len(wrapLine(r, textW-2))
		}
		ratBlock++ // trailing blank
	}
	steerBlock := 0
	if m.steering || m.steerBuffer != "" {
		steerBlock = 1
	}
	diffH := innerH - 4 - ratBlock - steerBlock // header, blank, blank, action row, + rationale/steer
	if diffH < 1 {
		diffH = 1
	}
	body := make([]string, 0, innerH)
	body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "")
	for _, r := range a.Rationale {
		marker := lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render("▲ ")
		for i, ln := range wrapLine(r, textW-2) {
			prefix := marker
			if i > 0 {
				prefix = "  "
			}
			body = append(body, prefix+t.span(ln, t.P.Dim))
		}
	}
	if len(a.Rationale) > 0 {
		body = append(body, "")
	}
	var content []string
	switch {
	case isCommandApproval(a):
		content = m.commandCardLines(a, textW)
		if len(content) > diffH {
			content = content[:diffH]
		}
	case isUnifiedDiff(a.Preview):
		content = m.renderSplitDiff(parseUnifiedDiff(a.Preview), textW, diffH, 0)
	default:
		content = m.renderPreviewLines(a.Preview, textW, diffH, 0)
	}
	diffStart := len(body)
	body = append(body, content...)
	for len(body) < diffStart+diffH {
		body = append(body, "")
	}
	body = append(body, "")
	if steerBlock == 1 {
		body = append(body, m.steerLine())
	}
	body = append(body, m.approvalActions())
	return t.box("permission required", body, m.width, h, true)
}

// steerLine renders the steering note inside the band: editable (with a caret)
// while the operator is typing it, or as a persistent reminder once set — so it
// stays visible after editing and it's clear it rides along with approve/reject.
func (m Model) steerLine() string {
	t := m.theme
	label := t.span("steer  ", t.P.Faint)
	caret := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
	if m.steering {
		if m.steerBuffer == "" {
			return label + caret + t.span(" type a note…", t.P.Faint)
		}
		return label + t.span(m.steerBuffer, t.P.FgStrong) + caret
	}
	return label + t.span(m.steerBuffer, t.P.Accent2) +
		t.span("   (sent with your decision · s to edit)", t.P.Faint)
}

// approvalActions renders the action row of the approval band. While the operator
// is composing a steer note the keys are different (typing is captured), so the
// hints switch to the editing controls.
func (m Model) approvalActions() string {
	t := m.theme
	hint := func(k, l string) string {
		return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(k) +
			t.span(" "+l, t.P.Faint)
	}
	// Tighten the inter-hint gap on a slim terminal so the action row isn't clipped —
	// the approval card's decision keys must stay fully visible.
	gapStr := "   "
	if m.narrow() {
		gapStr = " "
	}
	gap := t.span(gapStr, t.P.Faint)
	if m.steering {
		return strings.Join([]string{
			hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
		}, gap)
	}
	s := m.session(m.selectedID)
	// Armed T3+ approve: a single decisive line so an accidental keystroke can't slip
	// a destructive action through — the confirm key must be pressed again.
	if m.approvalArmed && s != nil && s.Pending != nil {
		warn := lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true).
			Render("press y again to confirm " + s.Pending.Tier + " approval")
		return warn + gap + hint("any other key", "cancel")
	}
	parts := []string{hint("y", "approve"), hint("n", "reject"), hint("e", "steer"), hint("A", "auto")}
	if s != nil && len(s.PendingQueue) > 1 {
		parts = append(parts, hint("↑↓", "queue"))
	}
	if s != nil && s.Pending != nil &&
		(isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) {
		parts = append(parts, hint("^x", "fullscreen"))
	}
	parts = append(parts, hint("esc", "later"))
	return strings.Join(parts, gap)
}

// approvalContentRows is the number of body rows the pending approval's content
// occupies — command-card lines for a command, diff/preview rows otherwise. Drives
// band sizing so the height matches what renderApprovalBand will draw.
func (m Model) approvalContentRows(a *Approval) int {
	if isCommandApproval(a) {
		w := m.width - 4
		if w < 8 {
			w = 8
		}
		return len(m.commandCardLines(a, w))
	}
	return previewRowCount(a.Preview)
}

// diffBodyHeight is the number of diff lines visible in the diff modal body.
func (m Model) diffBodyHeight() int {
	h := m.height * 70 / 100
	if h < 8 {
		h = 8
	}
	return h - 6
}

// diffMaxScroll is the largest scroll offset that still fills the body with
// diff rows, keeping the last page anchored to the bottom of the modal.
func (m Model) diffMaxScroll() int {
	rows := previewRowCount(m.currentDiff())
	if cmd := m.pendingCommand(); cmd != nil {
		rows = len(m.commandCardLines(cmd, m.modalWidth()-6))
	}
	max := rows - m.diffBodyHeight()
	if max < 0 {
		max = 0
	}
	return max
}

// scrollDiff moves the diff/preview/command modal offset by delta rows, clamped to
// [0, diffMaxScroll] so paging past either end lands exactly on the edge (no dead
// presses unwinding an overshoot — same contract as the OUTPUT free-scroll).
func (m *Model) scrollDiff(delta int) {
	off := m.diffScrollOffset + delta
	if mx := m.diffMaxScroll(); off > mx {
		off = mx
	}
	if off < 0 {
		off = 0
	}
	m.diffScrollOffset = off
}

func (m Model) diffModal() string {
	if cmd := m.pendingCommand(); cmd != nil {
		return m.commandModal(cmd)
	}
	t := m.theme
	w := m.modalWidth()
	bodyH := m.diffBodyHeight()
	diff := m.currentDiff()
	isDiff := isUnifiedDiff(diff)

	off := m.diffScrollOffset
	if off > m.diffMaxScroll() {
		off = m.diffMaxScroll()
	}
	if off < 0 {
		off = 0
	}

	var b strings.Builder
	title := "preview"
	if isDiff {
		title = "diff"
	}
	b.WriteString(m.titleLine(title))
	if tgt := diffTarget(diff); tgt != "" {
		b.WriteString(mbg(t, "  "+tgt, t.P.Dim))
	}
	b.WriteString(mbg(t, "   ("+itoa(previewRowCount(diff))+" rows)", t.P.Faint) + "\n\n")
	var lines []string
	if isDiff {
		lines = m.renderSplitDiff(parseUnifiedDiff(diff), w-6, bodyH, off)
	} else {
		lines = m.renderPreviewLines(diff, w-6, bodyH, off)
	}
	for _, ln := range lines {
		b.WriteString(ln + "\n")
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}}))

	modal := t.Overlay.Width(w).Render(b.String())
	return m.center(modal)
}

// commandModal is the ^x fullscreen view of a command approval: the same
// deterministic card as the band, scrollable, so a long command is fully readable
// without ever being truncated.
func (m Model) commandModal(a *Approval) string {
	t := m.theme
	w := m.modalWidth()
	bodyH := m.diffBodyHeight()
	lines := m.commandCardLines(a, w-6)

	off := m.diffScrollOffset
	if mx := m.diffMaxScroll(); off > mx {
		off = mx
	}
	if off < 0 {
		off = 0
	}

	var b strings.Builder
	b.WriteString(m.titleLine("command"))
	b.WriteString(mbg(t, "  "+a.ToolName, t.P.Dim))
	b.WriteString(mbg(t, "   ("+itoa(len(lines))+" rows)", t.P.Faint) + "\n\n")
	end := off + bodyH
	if end > len(lines) {
		end = len(lines)
	}
	for i := off; i < end; i++ {
		b.WriteString(lines[i] + "\n")
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}}))
	return m.center(t.Overlay.Width(w).Render(b.String()))
}

func (m Model) eventInspectorModal() string {
	t := m.theme
	w := m.modalWidth()
	evs := m.currentEvents()

	var b strings.Builder
	b.WriteString(m.titleLine("event inspector"))
	if len(evs) > 0 {
		b.WriteString(mbg(t, "   ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint))
	}
	b.WriteString("\n\n")
	// Filter line (shown while editing or when a query is set): a `/` prompt over the query.
	if m.eventFilterTyping || m.eventFilter != "" {
		caret := mbg(t, "", t.P.BgPanel)
		if m.eventFilterTyping && m.caretVisible() {
			caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
		}
		prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ")
		if m.eventFilter == "" {
			b.WriteString(prompt + caret + mbg(t, "type to filter events…", t.P.Faint) + "\n\n")
		} else {
			b.WriteString(prompt + mbg(t, m.eventFilter, t.P.FgStrong) + caret + "\n\n")
		}
	}
	if len(evs) == 0 {
		msg := "no events"
		if m.eventFilter != "" {
			msg = "no events match \"" + m.eventFilter + "\""
		}
		b.WriteString(mbg(t, msg, t.P.Faint))
		return m.center(t.Overlay.Width(w).Render(b.String()))
	}
	// Budget the plain detail so the styled row never needs ANSI-aware truncation
	// (truncating a styled string would cut through escape codes and corrupt it).
	detailBudget := (w - 6) - (2 + 9 + 11 + 1 + 18 + 1)
	if detailBudget < 4 {
		detailBudget = 4
	}
	// Window the (potentially long) list to a visible height, keeping the
	// selected row in view — the modal must not grow past the screen.
	bodyH := m.height*70/100 - 4
	if bodyH < 3 {
		bodyH = 3
	}
	off := 0
	if len(evs) > bodyH {
		off = m.overlayEventIdx - bodyH/2
		if off < 0 {
			off = 0
		}
		if off > len(evs)-bodyH {
			off = len(evs) - bodyH
		}
	}
	end := off + bodyH
	if end > len(evs) {
		end = len(evs)
	}
	for i := off; i < end; i++ {
		e := evs[i]
		cat := inferCategory(e.Type)
		marker := mbg(t, "  ", t.P.BgPanel)
		fg := t.P.Fg
		if i == m.overlayEventIdx {
			marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
			fg = t.P.FgStrong
		}
		row := marker + mbg(t, padRaw(e.Time, 8)+" ", t.P.Faint) +
			lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).Render(padRaw("["+cat+"]", 11)) + mbg(t, " ", t.P.Fg) +
			lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(e.Type, 18)) +
			mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim)
		b.WriteString(row + "\n")
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"/", "filter"}, {"c", "clear"}, {"esc", "close"}}))

	modal := t.Overlay.Width(w).Render(b.String())
	return m.center(modal)
}

func (m Model) paletteModal() string {
	t := m.theme
	w := m.modalWidth()
	cmds := m.filteredPalette()

	var b strings.Builder
	b.WriteString(m.titleLine("command palette") + "\n\n")

	// filter input line
	filter := m.paletteFilter
	caret := mbg(t, " ", t.P.BgPanel)
	if m.caretVisible() {
		caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
	}
	prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("› ")
	if filter == "" {
		b.WriteString(prompt + caret + mbg(t, "type to filter…", t.P.Faint) + "\n\n")
	} else {
		b.WriteString(prompt + mbg(t, filter, t.P.FgStrong) + caret + "\n\n")
	}

	if len(cmds) == 0 {
		b.WriteString(mbg(t, "  no matching commands", t.P.Faint) + "\n")
	}
	for i, c := range cmds {
		marker := mbg(t, "  ", t.P.BgPanel)
		titleFg := t.P.Fg
		if i == m.paletteIndex {
			marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
			titleFg = t.P.FgStrong
		}
		// keybind column: the bare-key shortcut, so the palette teaches the shortcuts.
		keyCell := mbg(t, padRaw("", 4), t.P.BgPanel)
		if c.key != "" {
			keyCell = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(padRaw(c.key, 4))
		}
		b.WriteString(marker + keyCell +
			lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 16)) +
			mbg(t, "  "+c.hint, t.P.Faint) + "\n")
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}}))
	modal := t.Overlay.Width(w).Render(b.String())
	return m.center(modal)
}

// filesModal is the `@` file-reference picker: a filter line over the session workspace's
// paths, windowed around the selection; enter inserts `@path` into the chat input.
func (m Model) filesModal() string {
	t := m.theme
	w := m.modalWidth()
	files := m.filteredFiles()

	var b strings.Builder
	b.WriteString(m.titleLine("@ file reference") + "\n\n")

	caret := mbg(t, " ", t.P.BgPanel)
	if m.caretVisible() {
		caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
	}
	prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("@ ")
	if m.fileFilter == "" {
		b.WriteString(prompt + caret + mbg(t, "type to filter files…", t.P.Faint) + "\n\n")
	} else {
		b.WriteString(prompt + mbg(t, m.fileFilter, t.P.FgStrong) + caret + "\n\n")
	}

	switch {
	case m.filesLoading:
		b.WriteString(mbg(t, "  loading…", t.P.Faint) + "\n")
	case len(m.files) == 0:
		b.WriteString(mbg(t, "  no workspace files (is a workspace bound?)", t.P.Faint) + "\n")
	case len(files) == 0:
		b.WriteString(mbg(t, "  no matching files", t.P.Faint) + "\n")
	default:
		const maxRows = 12
		start := 0
		if m.fileIndex >= maxRows {
			start = m.fileIndex - maxRows + 1
		}
		end := start + maxRows
		if end > len(files) {
			end = len(files)
		}
		for i := start; i < end; i++ {
			marker := mbg(t, "  ", t.P.BgPanel)
			fg := t.P.Fg
			if i == m.fileIndex {
				marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
				fg = t.P.FgStrong
			}
			b.WriteString(marker +
				lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(clip(files[i], w-6)) + "\n")
		}
		if len(files) > maxRows {
			b.WriteString(mbg(t, "  "+itoa(len(files))+" matches", t.P.Faint) + "\n")
		}
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "insert"}, {"esc", "close"}}))
	modal := t.Overlay.Width(w).Render(b.String())
	return m.center(modal)
}

// statusbarModal toggles which status-bar segments are shown. Choices persist to the local
// tui-prefs.json so the bar stays as configured across launches.
func (m Model) statusbarModal() string {
	t := m.theme
	w := m.modalWidth()

	var b strings.Builder
	b.WriteString(m.titleLine("status bar") + "\n\n")
	b.WriteString(mbg(t, "  show / hide segments (correx, connection, session name stay)", t.P.Faint) + "\n\n")

	for i, seg := range statusSegments {
		marker := mbg(t, "  ", t.P.BgPanel)
		labelFg := t.P.Fg
		if i == m.sbIndex {
			marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
			labelFg = t.P.FgStrong
		}
		box := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render("[✓] ")
		if !m.sbShow(seg.id) {
			box = mbg(t, "[ ] ", t.P.Faint)
		}
		b.WriteString(marker + box +
			lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(seg.label) + "\n")
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"space", "toggle"}, {"esc", "close"}}))
	modal := t.Overlay.Width(w).Render(b.String())
	return m.center(modal)
}

// grantsModal lists the active standing (PROJECT/GLOBAL) grants and lets the operator revoke one.
// These are cross-session auto-approvals, so the board opens from anywhere.
func (m Model) grantsModal() string {
	t := m.theme
	w := m.modalWidth()

	var b strings.Builder
	b.WriteString(m.titleLine("standing grants") + "\n\n")
	b.WriteString(mbg(t, "  cross-session auto-approvals in force (tool-bound)", t.P.Faint) + "\n\n")

	switch {
	case m.grantsLoading:
		b.WriteString(mbg(t, "  loading…", t.P.Faint) + "\n")
	case len(m.grants) == 0:
		b.WriteString(mbg(t, "  none — press A on an approval to grant project / global", t.P.Faint) + "\n")
	default:
		for i, g := range m.grants {
			marker := mbg(t, "  ", t.P.BgPanel)
			fg := t.P.Fg
			if i == m.grantIndex {
				marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
				fg = t.P.FgStrong
			}
			scope := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(g.Scope, 8))
			tool := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(g.ToolName, 16))
			tiers := mbg(t, "≤"+strings.Join(g.Tiers, ","), t.P.Faint)
			b.WriteString(marker + scope + tool + tiers)
			if g.Scope == "PROJECT" && g.ProjectID != "" {
				b.WriteString(mbg(t, "  "+clip(g.ProjectID, w-52), t.P.Faint))
			}
			b.WriteString("\n")
		}
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"x", "revoke"}, {"G/esc", "close"}}))
	modal := t.Overlay.Width(w).Render(b.String())
	return m.center(modal)
}

// grantScopeModal is the A (approve-always) breadth picker: choose how widely to stop being asked
// for the pending tool — this session, this project, or everywhere.
func (m Model) grantScopeModal() string {
	t := m.theme
	w := m.modalWidth()

	tool := ""
	if m.grantFor != nil {
		tool = m.grantFor.ToolName
	}
	var b strings.Builder
	b.WriteString(m.titleLine("approve always — choose scope") + "\n\n")
	b.WriteString(mbg(t, "  stop asking for ", t.P.Faint) +
		lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(tool) + "\n\n")

	for i, opt := range grantScopeOptions() {
		marker := mbg(t, "  ", t.P.BgPanel)
		labelFg := t.P.Fg
		if i == m.grantScopeIndex {
			marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
			labelFg = t.P.FgStrong
		}
		key := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(opt.key, 3))
		title := lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(padRaw(opt.title, 16))
		b.WriteString(marker + key + title + mbg(t, opt.hint, t.P.Faint) + "\n")
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"enter", "grant"}, {"esc", "cancel"}}))
	modal := t.Overlay.Width(w).Render(b.String())
	return m.center(modal)
}

// helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it.
// modalBodyRows is how many body lines a scrollable fixed-content modal shows: the
// screen height minus the modal chrome (border 2 + padding 2 + title block 2 + hint
// block 2). Sized so a modal that fits shows in full and a taller one scrolls rather
// than clipping its bottom off-screen (the old compositeOverlay behaviour).
func (m Model) modalBodyRows() int {
	if r := m.height - 8; r > 3 {
		return r
	}
	return 3
}

// scrollableModalMax is the largest scroll offset for a body of n lines, anchoring the
// last page to the bottom.
func (m Model) scrollableModalMax(n int) int {
	if max := n - m.modalBodyRows(); max > 0 {
		return max
	}
	return 0
}

// activeModalBody returns the body lines of whichever scrollable fixed-content modal is
// open, so the key handler can clamp m.modalScroll against the same content it renders.
func (m Model) activeModalBody() []string {
	switch m.overlay {
	case OverlayHelp:
		return m.helpBody()
	case OverlayStats:
		if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
			return nil
		}
		return m.statsBody()
	}
	return nil
}

// scrollModal moves the active fixed-content modal's body offset by delta lines, clamped
// to [0, scrollableModalMax] — same contract as the diff/output scrollers.
func (m *Model) scrollModal(delta int) {
	max := m.scrollableModalMax(len(m.activeModalBody()))
	off := m.modalScroll + delta
	if off > max {
		off = max
	}
	if off < 0 {
		off = 0
	}
	m.modalScroll = off
}

// renderScrollModal lays out a title + scrollable body + pinned hint inside the modal box,
// windowing body by m.modalScroll so the box never exceeds the screen. A position indicator
// appears in the title row whenever the body is taller than the viewport.
func (m Model) renderScrollModal(title, titleSuffix string, body []string, hint [][2]string) string {
	t := m.theme
	w := m.modalWidth()
	rows := m.modalBodyRows()
	off := m.modalScroll
	if mx := m.scrollableModalMax(len(body)); off > mx {
		off = mx
	}
	if off < 0 {
		off = 0
	}
	end := off + rows
	if end > len(body) {
		end = len(body)
	}
	var b strings.Builder
	b.WriteString(m.titleLine(title))
	if titleSuffix != "" {
		b.WriteString(mbg(t, titleSuffix, t.P.Faint))
	}
	if len(body) > rows {
		b.WriteString(mbg(t, "   ("+itoa(off+1)+"-"+itoa(end)+"/"+itoa(len(body))+")", t.P.Faint))
	}
	b.WriteString("\n\n")
	for i := off; i < end; i++ {
		b.WriteString(body[i] + "\n")
	}
	b.WriteString("\n" + modalHints(t, hint))
	return t.Overlay.Width(w).Render(b.String())
}

// helpBody is the keybinding cheat-sheet as discrete lines (so renderScrollModal can window it).
func (m Model) helpBody() []string {
	t := m.theme
	type kb struct{ key, desc string }
	var out []string
	section := func(title string, rows []kb) {
		out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title))
		for _, r := range rows {
			key := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(padRaw(r.key, 16))
			out = append(out, "  "+key+mbg(t, r.desc, t.P.Fg))
		}
		out = append(out, "")
	}
	section("navigate", []kb{
		{"↑↓ / j k", "move the session / list selection"},
		{"enter", "open the selected session"},
		{"Tab / w", "idle: cycle launch target (chat / workflow)"},
		{"/", "filter the session list"},
		{"^↑ / ^↓", "jump to your previous / next message"},
		{"PgUp / PgDn", "scroll output (^u / ^d half-page)"},
		{"esc", "drop selection / scroll → follow newest"},
		{"l", "back to the session list"},
	})
	section("session", []kb{
		{"i", "compose a message"},
		{"d", "right panel: events / changes / off"},
		{"s", "toggle chat / steering mode"},
		{"w", "show workflows (idle screen)"},
		{"y", "copy the selected message (OSC52)"},
		{"^x", "open the latest diff / preview"},
		{"c", "cancel the running session"},
		{"a", "re-open a dismissed approval gate"},
		{"q", "quit"},
	})
	section("overlays", []kb{
		{"p", "command palette (all shortcuts)"},
		{"?", "this keybindings help"},
		{"e", "event inspector (/ to filter)"},
		{"t / v", "tools / artifacts"},
		{"S / R", "session stats / resume past sessions"},
		{"T", "task board (across projects)"},
		{"m / g", "swap model / edit config"},
		{"G / I", "grants / idea board"},
	})
	section("approval band", []kb{
		{"y / a / enter", "approve once (T3+ asks again to confirm)"},
		{"n / r", "reject"},
		{"e / s", "steer (add a note)"},
		{"A", "approve-always — choose scope"},
		{"↑ / ↓", "walk the pending queue"},
		{"^x", "fullscreen the diff / command preview"},
		{"esc", "dismiss for now (a re-opens)"},
	})
	section("diff / preview viewer (^x)", []kb{
		{"↑ / ↓", "scroll a line"},
		{"PgUp / PgDn", "scroll a page (^u / ^d half)"},
		{"g / G", "jump to top / end"},
		{"^x / esc", "close"},
	})
	return out
}

func (m Model) helpModal() string {
	return m.renderScrollModal("keybindings", "", m.helpBody(),
		[][2]string{{"↑↓", "scroll"}, {"esc", "close"}})
}

func (m Model) toolPaletteModal() string {
	t := m.theme
	w := m.modalWidth()
	s := m.session(m.selectedID)

	var b strings.Builder
	b.WriteString(m.titleLine("tool palette") + "\n\n")
	if s == nil || len(s.ToolsByStage) == 0 {
		b.WriteString(mbg(t, "no tool manifest for this session", t.P.Faint) + "\n")
	} else {
		stages := make([]string, 0, len(s.ToolsByStage))
		for st := range s.ToolsByStage {
			stages = append(stages, st)
		}
		sort.Strings(stages)
		for _, st := range stages {
			label := mbg(t, st, t.P.Accent2)
			if st == s.CurrentStage {
				label += lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("  ◂ current")
			}
			b.WriteString(label + "\n")
			for _, tool := range s.ToolsByStage[st] {
				tier := lipgloss.NewStyle().Foreground(tierColor(t, tool.Tier)).Background(t.P.BgPanel).Render("T" + itoa(tool.Tier))
				b.WriteString(mbg(t, "    "+padRaw(tool.Name, 22), t.P.Fg) + tier + "\n")
			}
		}
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"t/esc", "close"}}))
	modal := t.Overlay.Width(w).Render(b.String())
	return m.center(modal)
}

func (m Model) planModal() string {
	t := m.theme
	w := m.modalWidth()
	s := m.session(m.selectedID)

	var b strings.Builder
	b.WriteString(m.titleLine("execution plan"))
	if s != nil {
		b.WriteString(mbg(t, "  —  "+s.WorkflowID, t.P.Dim))
	}
	b.WriteString("\n\n")

	if s == nil || len(s.PlanStages) == 0 {
		b.WriteString(mbg(t, "  no execution plan yet — stages appear once the plan is locked", t.P.Faint) + "\n")
		if s != nil && s.CurrentStage != "" {
			b.WriteString(mbg(t, "  current stage: "+s.CurrentStage, t.P.Accent2) + "\n")
		}
	} else {
		goal := strings.TrimSpace(s.PlanGoal)
		if goal != "" {
			b.WriteString(mbg(t, "  goal", t.P.Faint) + "\n")
			for _, ln := range wrap(goal, w-8) {
				b.WriteString(mbg(t, "    "+ln, t.P.Fg) + "\n")
			}
			b.WriteString("\n")
		}

		b.WriteString(mbg(t, "  stages  ("+itoa(len(s.PlanStages))+" total)", t.P.Faint) + "\n")
		idW := 0
		for _, ps := range s.PlanStages {
			if len(ps.ID) > idW {
				idW = len(ps.ID)
			}
		}
		for _, ps := range s.PlanStages {
			var badge string
			switch ps.Status {
			case PlanPending:
				badge = mbg(t, "  ○ pending", t.P.Faint)
			case PlanRunning:
				badge = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render("  ● running")
			case PlanCompleted:
				badge = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render("  ✓ done")
			case PlanFailed:
				badge = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render("  ✗ failed")
			}
			stageLine := mbg(t, "  ", t.P.BgPanel) + badge + mbg(t, "  "+padRaw(ps.ID, idW), t.P.Fg)
			if s.ToolsByStage != nil {
				if tools, ok := s.ToolsByStage[ps.ID]; ok && len(tools) > 0 {
					stageLine += mbg(t, "   ["+itoa(len(tools))+" tools]", t.P.Faint)
				}
			}
			if budget, ok := s.TokenBudgetByStage[ps.ID]; ok && budget > 0 {
				used := 0
				if ps.ID == s.CurrentStage {
					used = s.StageTokensUsed
				}
				stageLine += mbg(t, "  ("+itoa(used)+"/"+itoa(budget)+" tok)", t.P.Faint)
			}
			b.WriteString(stageLine + "\n")
		}
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
	return t.Overlay.Width(w).Render(b.String())
}

func (m Model) modelsModal() string {
	t := m.theme
	w := m.modalWidth()

	var b strings.Builder
	b.WriteString(m.titleLine("models") + "\n\n")

	if g := m.gaugeText(); g != "" {
		b.WriteString(mbg(t, g, t.P.Faint) + "\n\n")
	}

	if len(m.availableModels) == 0 {
		b.WriteString(mbg(t, "  model management not enabled", t.P.Faint) + "\n")
	}
	for i, id := range m.availableModels {
		marker := mbg(t, "  ", t.P.BgPanel)
		nameFg := t.P.Fg
		if i == m.modelsIndex {
			marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
			nameFg = t.P.FgStrong
		}
		line := marker + lipgloss.NewStyle().Foreground(nameFg).Background(t.P.BgPanel).Render(padRaw(id, 28))
		if id == m.currentModel {
			line += lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render("  ◂ resident")
		}
		b.WriteString(line + "\n")
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "swap"}, {"c", "clear pin"}, {"esc", "close"}}))
	modal := t.Overlay.Width(w).Render(b.String())
	return m.center(modal)
}

// artifactListRows is the number of artifact entries shown in the list portion of
// the viewer (the rest of the modal height goes to the selected artifact's content).
func (m Model) artifactListRows() int {
	n := len(m.artifacts)
	if n > 6 {
		n = 6
	}
	if n < 1 {
		n = 1
	}
	return n
}

// artifactContentBodyH is the number of content lines visible for the selected artifact.
func (m Model) artifactContentBodyH() int {
	h := m.height*70/100 - m.artifactListRows() - 7 // title, blanks, content header, hints
	if h < 3 {
		h = 3
	}
	return h
}

// artifactContentLines splits the selected artifact's content into display lines,
// wrapping each line to the specified width.
func (m Model) artifactContentLines(w int) []string {
	if m.artifactsIndex < 0 || m.artifactsIndex >= len(m.artifacts) {
		return nil
	}
	c := m.artifacts[m.artifactsIndex].Content
	if c == nil {
		return []string{"(no content stored)"}
	}
	lines := strings.Split(strings.ReplaceAll(*c, "\r\n", "\n"), "\n")
	return wrapLines(lines, w)
}

// artifactContentMaxScroll keeps the last page of content anchored to the body bottom.
func (m Model) artifactContentMaxScroll(w int) int {
	max := len(m.artifactContentLines(w)) - m.artifactContentBodyH()
	if max < 0 {
		max = 0
	}
	return max
}

// scrollArtifact moves the selected artifact's content offset by delta lines, clamped to
// the scrollable range (same contract as the diff/output scrollers).
func (m *Model) scrollArtifact(delta int) {
	max := m.artifactContentMaxScroll(m.modalWidth() - 6)
	off := m.artifactScroll + delta
	if off > max {
		off = max
	}
	if off < 0 {
		off = 0
	}
	m.artifactScroll = off
}

func (m Model) artifactsModal() string {
	t := m.theme
	w := m.modalWidth()

	var b strings.Builder
	b.WriteString(m.titleLine("artifacts"))
	if len(m.artifacts) > 0 {
		b.WriteString(mbg(t, "   ("+itoa(m.artifactsIndex+1)+"/"+itoa(len(m.artifacts))+")", t.P.Faint))
	}
	b.WriteString("\n\n")

	if m.artifactsLoading {
		b.WriteString(mbg(t, "  loading…", t.P.Faint) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
		return t.Overlay.Width(w).Render(b.String())
	}
	if len(m.artifacts) == 0 {
		b.WriteString(mbg(t, "  no artifacts for this session", t.P.Faint) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
		return t.Overlay.Width(w).Render(b.String())
	}

	// Windowed artifact list, keeping the selected row in view.
	rows := m.artifactListRows()
	off := 0
	if len(m.artifacts) > rows {
		off = m.artifactsIndex - rows/2
		if off < 0 {
			off = 0
		}
		if off > len(m.artifacts)-rows {
			off = len(m.artifacts) - rows
		}
	}
	end := off + rows
	if end > len(m.artifacts) {
		end = len(m.artifacts)
	}
	for i := off; i < end; i++ {
		a := m.artifacts[i]
		marker := mbg(t, "  ", t.P.BgPanel)
		idFg := t.P.Fg
		if i == m.artifactsIndex {
			marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
			idFg = t.P.FgStrong
		}
		row := marker +
			lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(shortID(a.ArtifactID), 14)) + " " +
			mbg(t, padRaw(a.StageID, 18), t.P.Accent2) + " " +
			mbg(t, a.Phase, t.P.Faint)
		b.WriteString(row + "\n")
	}

	// Selected artifact content, scrollable with PgUp/PgDn.
	// Width available for content: w - 2 (left padding) - 2 (box borders) - 2 (right padding) = w - 6
	contentW := w - 6
	lines := m.artifactContentLines(contentW)
	bodyH := m.artifactContentBodyH()
	coff := m.artifactScroll
	if coff > m.artifactContentMaxScroll(contentW) {
		coff = m.artifactContentMaxScroll(contentW)
	}
	if coff < 0 {
		coff = 0
	}
	b.WriteString(mbg(t, "  ── content", t.P.Faint))
	if len(lines) > bodyH {
		b.WriteString(mbg(t, "  ("+itoa(coff+1)+"-"+itoa(min(coff+bodyH, len(lines)))+"/"+itoa(len(lines))+")", t.P.Faint))
	}
	b.WriteString("\n")
	cend := coff + bodyH
	if cend > len(lines) {
		cend = len(lines)
	}
	for i := coff; i < cend; i++ {
		b.WriteString(mbg(t, "  "+lines[i], t.P.Fg) + "\n")
	}

	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"v/esc", "close"}}))
	return t.Overlay.Width(w).Render(b.String())
}

// statsBreakdownRows caps how many per-provider / per-tool / per-tier rows the pane
// shows; the server already sorts each list by descending cost, so the cap keeps the
// heaviest contributors and the modal bounded.
const statsBreakdownRows = 6

// humanDurMs renders a millisecond span as a compact "1h 2m 3s" string.
func humanDurMs(ms int64) string {
	if ms <= 0 {
		return "0s"
	}
	total := ms / 1000
	h := total / 3600
	mn := (total / 60) % 60
	s := total % 60
	out := ""
	if h > 0 {
		out += itoa(int(h)) + "h "
	}
	if h > 0 || mn > 0 {
		out += itoa(int(mn)) + "m "
	}
	return out + itoa(int(s)) + "s"
}

func (m Model) statsModal() string {
	t := m.theme
	w := m.modalWidth()

	// Loading / empty: a small fixed modal, no scroll needed.
	if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
		var b strings.Builder
		b.WriteString(m.titleLine("session stats"))
		if m.selectedID != "" {
			b.WriteString(mbg(t, "   ("+shortID(m.selectedID)+")", t.P.Faint))
		}
		b.WriteString("\n\n")
		msg := "loading…"
		if !m.statsLoading && m.stats == nil {
			msg = "no stats for this session"
		}
		b.WriteString(mbg(t, "  "+msg, t.P.Faint) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
		return t.Overlay.Width(w).Render(b.String())
	}

	suffix := ""
	if m.selectedID != "" {
		suffix = "   (" + shortID(m.selectedID) + ")"
	}
	return m.renderScrollModal("session stats", suffix, m.statsBody(),
		[][2]string{{"↑↓", "scroll"}, {"S/esc", "close"}})
}

// statsBody is the session-stats report as discrete lines (so renderScrollModal can window it).
// Callers must ensure m.stats is loaded for the selected session.
func (m Model) statsBody() []string {
	t := m.theme
	s := m.stats
	cw := m.modalWidth() - 4 // modal inner content width (borders + padding)
	var out []string
	section := func(label string) string {
		return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
	}
	// put clips each data line to the inner width so it never wraps on a slim modal.
	put := func(styled string) { out = append(out, clip(styled, cw)) }
	line := func(s string) string { return mbg(t, s, t.P.Fg) }
	faint := func(s string) string { return mbg(t, s, t.P.Faint) }
	blank := func() { out = append(out, "") }
	// nameW shrinks the breakdown name column on slim terminals.
	nameW := 20
	if cw < 52 {
		nameW = 12
	}

	// header row: duration + event count
	put(faint("  duration ") + line(humanDurMs(s.SessionDurationMs)) +
		faint("    events ") + line(itoa(int(s.EventCount))))
	blank()

	// inference
	out = append(out, section("Inference"))
	put(line(fmt.Sprintf("  %d calls · %d+%d tok · %.1f tok/s",
		s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond)))
	for i, p := range s.PerProvider {
		if i >= statsBreakdownRows {
			put(faint(fmt.Sprintf("    … +%d more", len(s.PerProvider)-statsBreakdownRows)))
			break
		}
		put(faint(fmt.Sprintf("    %-*s %d×  %d tok  %.1f t/s",
			nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond)))
	}
	blank()

	// tools
	out = append(out, section("Tools"))
	put(line(fmt.Sprintf("  %d calls · %d ms", s.ToolCount, s.ToolMs)))
	for i, tl := range s.PerTool {
		if i >= statsBreakdownRows {
			put(faint(fmt.Sprintf("    … +%d more", len(s.PerTool)-statsBreakdownRows)))
			break
		}
		put(faint(fmt.Sprintf("    %-*s %d×  %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs)))
	}
	blank()

	// approvals
	out = append(out, section("Approvals"))
	put(line(fmt.Sprintf("  %d req · %d res · %d pending · avg %d ms",
		s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)))
	for _, tr := range s.PerTier {
		put(faint(fmt.Sprintf("    %-3s req %d  res %d  avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)))
	}
	blank()

	// failures
	f := s.Failures
	out = append(out, section("Failures"))
	put(line(fmt.Sprintf("  inf %d · t/o %d · tool %d · rej %d · stg %d · wf %d",
		f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)))
	blank()

	// time accounting
	other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
	if other < 0 {
		other = 0
	}
	out = append(out, section("Time"))
	put(line(fmt.Sprintf("  inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%",
		s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)))

	return out
}

func (m Model) healthModal() string {
	t := m.theme
	w := m.modalWidth()

	var b strings.Builder
	b.WriteString(m.titleLine("health checks"))
	b.WriteString("\n\n")

	if m.healthLoading || m.health == nil {
		msg := "loading…"
		if !m.healthLoading && m.health == nil {
			msg = "no health report available"
		}
		b.WriteString(mbg(t, "  "+msg, t.P.Faint) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"H/esc", "close"}}))
		return t.Overlay.Width(w).Render(b.String())
	}

	h := m.health
	cw := w - 4

	// overall status header
	overallFg := t.P.OK
	if h.Overall == "DEGRADED" {
		overallFg = t.P.Bad
	}
	overall := lipgloss.NewStyle().Foreground(overallFg).Background(t.P.BgPanel).Bold(true).Render(h.Overall)
	b.WriteString(clip(mbg(t, "  overall  ", t.P.Faint)+overall+mbg(t, "    checked "+h.CheckedAt, t.P.Faint), cw) + "\n\n")

	if len(h.Subjects) == 0 {
		b.WriteString(mbg(t, "  no health probes recorded — health monitoring may be disabled", t.P.Faint) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"H/esc", "close"}}))
		return t.Overlay.Width(w).Render(b.String())
	}

	section := func(label string) string {
		return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
	}
	put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") }
	faint := func(s string) string { return mbg(t, s, t.P.Faint) }

	for _, sub := range h.Subjects {
		statusFg := t.P.OK
		if sub.Status == "DEGRADED" {
			statusFg = t.P.Bad
		}
		statusLabel := lipgloss.NewStyle().Foreground(statusFg).Background(t.P.BgPanel).Bold(sub.Status == "DEGRADED").Render(sub.Status)
		b.WriteString(section(sub.Subject) + mbg(t, "  ", t.P.BgPanel) + statusLabel + "\n")
		put(faint(fmt.Sprintf("    metric %-18s value %d", sub.Metric, sub.ObservedValue)))
		if sub.Detail != "" {
			put(faint("    " + truncate(sub.Detail, cw-4)))
		}
		put(faint("    since  " + sub.Since))
		b.WriteString("\n")
	}

	b.WriteString(modalHints(t, [][2]string{{"H/esc", "close"}}))
	return t.Overlay.Width(w).Render(b.String())
}

// shortID trims a long artifact id for the list column while staying identifiable.
func shortID(id string) string {
	if len(id) <= 14 {
		return id
	}
	return id[:13] + "…"
}

func tierColor(t Theme, tier int) color.Color {
	switch {
	case tier >= 3:
		return t.P.Bad
	case tier == 2:
		return t.P.Warn
	case tier == 1:
		return t.P.Accent2
	default:
		return t.P.OK
	}
}

func modalHints(t Theme, pairs [][2]string) string {
	parts := make([]string, 0, len(pairs))
	for _, p := range pairs {
		parts = append(parts,
			lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(p[0])+
				mbg(t, " "+p[1], t.P.Faint))
	}
	return strings.Join(parts, mbg(t, "   ", t.P.Faint))
}

func truncate(s string, w int) string {
	if len([]rune(s)) <= w {
		return s
	}
	r := []rune(s)
	if w < 1 {
		return ""
	}
	return string(r[:w-1]) + "…"
}

// wrapLines wraps each line in the input slice to the specified width using hard rune-boundary wrapping.
func wrapLines(lines []string, w int) []string {
	if w < 1 {
		w = 1
	}
	var result []string
	for _, line := range lines {
		wrapped := wrapLine(line, w)
		result = append(result, wrapped...)
	}
	return result
}

// wrapLine wraps a single line to the specified width using hard rune-boundary wrapping.
func wrapLine(s string, w int) []string {
	if w < 1 {
		w = 1
	}
	runes := []rune(s)
	if len(runes) <= w {
		return []string{s}
	}
	var lines []string
	for len(runes) > 0 {
		if len(runes) <= w {
			lines = append(lines, string(runes))
			break
		}
		lines = append(lines, string(runes[:w]))
		runes = runes[w:]
	}
	return lines
}
