package app

import (
	"fmt"
	"image/color"
	"os"
	"strings"
	"time"

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

const (
	statusH = 1
	footerH = 1
	inputH  = 4 // rounded box: 2 borders + 2 content lines

	// staleEventThresholdMs: past this gap since the last event, an *active* session's
	// last-event clock turns warn-colored — the "thinking vs hung" tell (§2).
	staleEventThresholdMs = 30_000

	// narrowWidth: below this the side-by-side panels become unreadable, so the main
	// area collapses (single column / vertical stack) and the chrome goes compact.
	narrowWidth = 96
)

// narrow reports whether the terminal is too slim for the full side-by-side layout
// and full-width chrome.
func (m Model) narrow() bool { return m.width < narrowWidth }

// cursorMarker is a private-use rune the composer drops into the caret cell so
// View() can locate it in the fully-composited frame and place a real terminal
// cursor there (see splitCursor). It measures one display column — same as the
// "▏" glyph it stands in for — so the layout is identical whether it resolves to
// a live cursor or the static glyph.
const cursorMarker = ""

// View renders the whole frame purely from the model (immediate-mode). In
// bubbletea v2 the View carries terminal state, so renderRaw() builds the string
// and View() resolves the caret to a real blinking cursor + sets the window title.
func (m Model) View() tea.View {
	content, cur := splitCursor(m.fillFrame(m.renderRaw()), " ")
	return tea.View{Content: content, AltScreen: true, Cursor: cur, WindowTitle: m.windowTitle()}
}

// fillFrame guarantees every cell of the width×height frame carries the theme backdrop, so a
// sub-view that leaves a line short or a row empty shows the theme background rather than the
// terminal-transparent backdrop bleeding through. Pads each line to full width and appends any
// missing bottom rows; content stays anchored top-left so caret coordinates are unaffected.
func (m Model) fillFrame(s string) string {
	if m.width <= 0 || m.height <= 0 {
		return s
	}
	pad := m.theme.Screen
	lines := strings.Split(s, "\n")
	out := make([]string, 0, m.height)
	for i := 0; i < m.height; i++ {
		ln := ""
		if i < len(lines) {
			ln = lines[i]
		}
		if gap := m.width - ansi.StringWidth(ln); gap > 0 {
			ln += pad.Render(strings.Repeat(" ", gap))
		}
		out = append(out, ln)
	}
	return strings.Join(out, "\n")
}

// render returns the frame as a plain string with the caret drawn as the static
// "▏" glyph — for the preview tool and golden tests, which have no live cursor.
func (m Model) render() string {
	content, _ := splitCursor(m.fillFrame(m.renderRaw()), "▏")
	return content
}

// splitCursor finds the caret marker in a composited frame, replaces it with repl
// (a space for the live cursor cell, "▏" for static), and returns the cursor's
// screen position — nil when no marker is present (e.g. vim normal mode), which
// tells bubbletea to hide the cursor.
func splitCursor(raw, repl string) (string, *tea.Cursor) {
	idx := strings.Index(raw, cursorMarker)
	if idx < 0 {
		return raw, nil
	}
	pre := raw[:idx]
	y := strings.Count(pre, "\n")
	lineStart := strings.LastIndex(pre, "\n") + 1 // 0 when the marker is on the first line
	x := ansi.StringWidth(raw[lineStart:idx])
	clean := strings.Replace(raw, cursorMarker, repl, 1)
	cur := tea.NewCursor(x, y)
	cur.Shape = tea.CursorBar
	return clean, cur
}

// windowTitle names the terminal window/tab after the active session, falling
// back to the model name and then the bare program name.
func (m Model) windowTitle() string {
	if m.displayState() == StateInSession {
		if s := m.session(m.selectedID); s != nil && s.Name != "" {
			return "correx — " + s.Name
		}
	}
	if m.currentModel != "" {
		return "correx — " + m.currentModel
	}
	return "correx"
}

// renderRaw builds the frame string (caret rendered as cursorMarker in insert mode).
func (m Model) renderRaw() string {
	if m.quitting {
		return ""
	}
	if m.width < 20 || m.height < 8 {
		return m.theme.Screen.Render("correx — terminal too small")
	}

	ds := m.displayState()
	bottom, bottomH := m.renderInput(), m.inputBarHeight()
	switch {
	case ds == StateApproval:
		bottomH = m.approvalBandHeight()
		bottom = m.renderApprovalBand(bottomH)
	case ds == StateIdle:
		// Launcher: the input is centered inside the main area, so there's no bottom bar.
		bottom, bottomH = "", 0
	}

	mainH := m.height - statusH - footerH - bottomH
	if mainH < 3 {
		mainH = 3
	}

	sections := []string{m.renderStatus(), m.renderMain(mainH)}
	if bottomH > 0 {
		sections = append(sections, bottom)
	}
	sections = append(sections, m.renderFooter())
	base := lipgloss.JoinVertical(lipgloss.Left, sections...)
	// Stash the base so center() can composite a modal over a dimmed copy of it.
	m.lastBase = base

	if m.displayState() == StateClarification {
		return m.center(m.clarificationModal())
	}
	if m.displayState() == StateWorkflowPropose {
		return m.center(m.proposeModal())
	}
	if m.overlay != OverlayNone {
		return m.renderOverlay(base)
	}
	return base
}

// --- top status bar ---

func (m Model) renderStatus() string {
	t := m.theme
	bg := t.P.BgPanel
	span := func(s string, fg color.Color) string {
		return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
	}
	nrw := m.narrow()
	sepStr := "  ·  "
	if nrw {
		sepStr = " · "
	}
	sep := span(sepStr, t.P.Faint)

	parts := []string{span("correx", t.P.Dim)}
	if m.connected {
		parts = append(parts, span("●", t.P.OK)+span(" connected", t.P.Dim))
	} else if m.reconnecting {
		parts = append(parts, span("◌", t.P.Warn)+span(" reconnecting", t.P.Warn))
	} else {
		parts = append(parts, span("○", t.P.Bad)+span(" disconnected", t.P.Bad))
	}

	if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
		parts = append(parts, span(s.Name, t.P.FgStrong))
		if s.CurrentStage != "" && m.sbShow("stage") {
			stageLabel := "⟐ " + s.CurrentStage
			if budget, ok := s.TokenBudgetByStage[s.CurrentStage]; ok && budget > 0 {
				stageLabel += fmt.Sprintf(" (%d/%d)", s.StageTokensUsed, budget)
			}
			parts = append(parts, span(stageLabel, t.P.Accent2))
		}
		if s.Status != "" && m.sbShow("status") {
			parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
		}
		if s.WorkspaceRoot != "" && !nrw && m.sbShow("workspace") {
			parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint))
		}
	}

	if m.sbShow("model") {
		model := m.currentModel
		if model == "" {
			model = "no model"
		}
		if nrw {
			parts = append(parts, span(model, t.P.Fg))
		} else {
			loc := "local"
			if m.providerType == "REMOTE" {
				loc = "remote"
			}
			parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint))
		}
	}
	left := strings.Join(parts, sep)

	var right []string
	// Last-event clock (§2): always visible in-session, so a silent agent is never
	// mistaken for a healthy one. Updates every frame tick; turns warn-colored when an
	// active session has gone quiet past the stale threshold.
	if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle && m.sbShow("clock") {
		gap := nowMillis() - s.LastEventAt
		clockFg := t.P.Faint
		if s.Active && gap > staleEventThresholdMs {
			clockFg = t.P.Warn
		}
		label := "last event " + agoLabel(gap)
		if nrw { // compact: "◷ 3s" instead of "last event 3s ago"
			label = "◷ " + strings.TrimSuffix(agoLabel(gap), " ago")
		}
		right = append(right, span(label, clockFg))
	}
	if g := m.gaugeText(); g != "" && !nrw && m.sbShow("gauge") {
		right = append(right, span(g, t.P.Faint))
	}
	if s := m.session(m.selectedID); s != nil && s.Active {
		spin := lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render(m.spinner())
		if nrw {
			right = append(right, spin)
		} else {
			right = append(right, spin+span(" active", t.P.Accent2))
		}
	}
	if m.bgUpdates > 0 {
		label := itoa(m.bgUpdates) + " elsewhere"
		if nrw {
			label = itoa(m.bgUpdates) + "↗"
		}
		right = append(right, span(label, t.P.Warn))
	}
	if len(right) == 0 {
		return m.fill(left, m.width, bg)
	}
	return m.justify(left, strings.Join(right, sep), m.width, bg)
}

// gaugeText renders the live VRAM/GPU/RAM gauge, omitting any unavailable field.
func (m Model) gaugeText() string {
	var parts []string
	if m.gpuUsedMB != nil && m.gpuTotalMB != nil {
		parts = append(parts, "VRAM "+itoa(int(*m.gpuUsedMB))+"/"+itoa(int(*m.gpuTotalMB))+"M")
	}
	if m.gpuUtil != nil {
		parts = append(parts, "GPU "+itoa(*m.gpuUtil)+"%")
	}
	if m.sysRamUsedMB != nil && m.sysRamTotalMB != nil {
		parts = append(parts, "RAM "+itoa(int(*m.sysRamUsedMB))+"/"+itoa(int(*m.sysRamTotalMB))+"M")
	}
	if m.ramMB != nil {
		parts = append(parts, "proc "+itoa(int(*m.ramMB))+"M")
	}
	return strings.Join(parts, " ")
}

// agoLabel renders a millisecond age as a compact "Ns ago" / "Nm ago" / "Nh ago" /
// "Nd ago". Drives the last-event clock (§2) — the live signal that distinguishes a
// thinking agent from a hung one.
func agoLabel(deltaMs int64) string {
	if deltaMs < 0 {
		deltaMs = 0
	}
	s := deltaMs / 1000
	switch {
	case s < 60:
		return itoa(int(s)) + "s ago"
	case s < 3600:
		return itoa(int(s/60)) + "m ago"
	case s < 86400:
		return itoa(int(s/3600)) + "h ago"
	default:
		return itoa(int(s/86400)) + "d ago"
	}
}

var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}

func (m Model) spinner() string { return spinnerFrames[m.frame%len(spinnerFrames)] }

// caretVisible blinks the insert caret roughly twice a second.
func (m Model) caretVisible() bool { return (m.frame/4)%2 == 0 }

// --- bottom footer hints ---

func (m Model) renderFooter() string {
	t := m.theme
	bg := t.P.BgDeep
	hint := func(key, label string) string {
		k := lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Bold(true).Render(key)
		l := lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" " + label)
		return k + l
	}
	gap := lipgloss.NewStyle().Background(bg).Render("   ")

	nrw := m.narrow()
	var hints []string
	switch {
	case m.editMode == ModeInsert:
		hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")}
		if m.inputMode != ModeFilter {
			hints = append(hints, hint("^J", "newline"))
		}
		// The `/` command menu only triggers on an empty chat line (mid-line `/` is literal).
		if m.inputMode == ModeRouter && m.inputBuffer == "" {
			hints = append(hints, hint("/", "cmds"))
		}
		if m.inputMode == ModeRouter {
			hints = append(hints, hint("@", "files"))
		}
	case m.displayState() == StateApproval:
		// Approval actions live in the band itself; the footer offers navigation only.
		hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}
	case m.displayState() == StateClarification:
		// The question form owns its keys; the footer mirrors the essentials.
		hints = []string{hint("←→", "option"), hint("↑↓", "question"), hint("spc", "select"), hint("enter", "submit"), hint("esc", "later")}
	case m.displayState() == StateWorkflowPropose:
		// The picker owns its keys; the footer mirrors the essentials.
		hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
	case m.displayState() == StateIdle:
		if nrw {
			hints = []string{hint("i", "type"), hint("Tab", "wf"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
		} else {
			hints = []string{hint("i", "type"), hint("Tab", "workflow"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
		}
	default: // StateInSession
		if nrw {
			hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
		} else {
			// Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`,
			// so the footer keeps the high-traffic keys + the new transcript nav/copy.
			hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("d", "panel"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
		}
		if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
			hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied"))
		}
		if s := m.session(m.selectedID); s != nil && s.Pending != nil {
			hints = append(hints, hint("a", "approval (pending)"))
		}
	}
	left := strings.Join(hints, gap)

	// The "Soft · blue" badge is decoration — drop it when slim so the hints get the room.
	if nrw {
		return m.fill(left, m.width, bg)
	}
	right := lipgloss.NewStyle().Foreground(t.P.Dim).Background(bg).Render("Soft") +
		lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" · ") +
		lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render("blue")

	return m.justify(left, right, m.width, bg)
}

// --- main split ---

func (m Model) renderMain(h int) string {
	if m.displayState() == StateIdle {
		return m.renderLauncher(m.width, h)
	}
	if m.narrow() {
		return m.renderMainNarrow(h)
	}
	leftW := m.width * 63 / 100
	rightW := m.width - leftW

	// In-session / approval. The right panel cycles (d): events, changes (token usage +
	// changed files), or off (output takes the full width).
	if m.rightPanel == 2 {
		return m.theme.box("output", m.routerRows(m.width-4, h-2), m.width, h, true)
	}
	left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true)
	var right string
	if m.rightPanel == 1 {
		right = m.theme.box("changes", m.changesRows(rightW-4, h), rightW, h, false)
	} else {
		right = m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false)
	}
	return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}

// changesRows is the in-session "changes" panel: a token-usage line plus a git-status-style
// list of files the session has written (summed +/- from each write's diff). ^x opens the
// full diff. Drives the right panel when rightPanel == 1.
func (m Model) changesRows(w, h int) []string {
	t := m.theme
	turns, toks := 0, 0
	for _, e := range m.routerMessages[m.selectedID] {
		if (e.Role == "router" || e.Role == "narration_llm") && e.Metrics != nil {
			turns++
			toks += e.Metrics.TotalTokens
		}
	}
	rows := []string{
		t.span("tokens ", t.P.Faint) + t.span(itoa(toks), t.P.FgStrong) +
			t.span("  · turns ", t.P.Faint) + t.span(itoa(turns), t.P.Dim),
		"",
	}

	type chg struct{ add, del int }
	var order []string
	seen := map[string]*chg{}
	for _, e := range m.routerMessages[m.selectedID] {
		if e.Role != "tool" || !isUnifiedDiff(e.Content) {
			continue
		}
		p, a, d := diffSummary(e.Content)
		if seen[p] == nil {
			seen[p] = &chg{}
			order = append(order, p)
		}
		seen[p].add += a
		seen[p].del += d
	}
	if len(order) == 0 {
		return append(rows, t.span("no file changes yet", t.P.Faint))
	}
	rows = append(rows, t.span("changes  (^x for full diff)", t.P.Faint))
	for _, p := range order {
		c := seen[p]
		icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render("✎ ")
		delta := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("+"+itoa(c.add)) +
			t.span(" ", t.P.Bg) + lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("−"+itoa(c.del))
		rows = append(rows, icon+t.span(truncate(p, w-16), t.P.Fg)+"  "+delta)
	}
	return rows
}

// renderLauncher is the idle screen: a compact, opencode-style input centered in the main
// area, with a hideable right rail of status + quick keys. The session list isn't shown —
// it's a keypress away (R). The input's lower-right shows <workflow> · <model>, Tab-cycled.
func (m Model) renderLauncher(w, h int) string {
	t := m.theme
	railW := 26
	if m.railHidden || m.narrow() || w < 66 {
		railW = 0
	}
	leftW := w - railW

	inW := leftW * 82 / 100
	if inW > 84 {
		inW = 84
	}
	if inW < 24 {
		inW = 24
	}
	left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW),
		lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
	if railW == 0 {
		return left
	}
	rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW),
		lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
	return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
}

// maxInputLines caps how many lines a growing (multi-line) input box shows; beyond it the
// box scrolls to keep the cursor's tail in view.
const maxInputLines = 6

// editorLines renders the input buffer as styled display lines with the caret at the cursor.
// Explicit newlines (Ctrl+J / Alt+Enter) break rows; a logical line wider than wrapW soft-wraps
// onto continuation rows so long input never overflows the box. Tabs are flattened to a space.
func (m Model) editorLines(wrapW int) []string {
	t := m.theme
	if wrapW < 1 {
		wrapW = 1
	}
	// Only the focused composer (no modal over it) owns the live cursor; an open
	// overlay keeps editMode==Insert but draws its own filter caret instead.
	showCaret := m.editMode == ModeInsert && m.overlay == OverlayNone

	buf := m.inputBuffer
	cur := m.inputCursor
	if cur > len(buf) {
		cur = len(buf)
	}
	if cur < 0 {
		cur = 0
	}
	curRune := len([]rune(buf[:cur]))

	// First pass: fold the buffer into plain display rows (rune-count wrap), tracking which
	// row/column the cursor lands on so the caret marker can be slotted back in afterwards.
	runes := []rune(buf)
	var rowsRunes [][]rune
	var row []rune
	caretRow, caretCol := 0, 0
	push := func() { rowsRunes = append(rowsRunes, row); row = nil }
	for i := 0; i <= len(runes); i++ {
		if i == curRune {
			caretRow, caretCol = len(rowsRunes), len(row)
		}
		if i == len(runes) {
			break
		}
		if runes[i] == '\n' {
			push()
			continue
		}
		row = append(row, runes[i])
		if len(row) >= wrapW {
			push()
		}
	}
	push()

	style := func(s []rune) string { return t.span(strings.ReplaceAll(string(s), "\t", " "), t.P.FgStrong) }
	caret := ""
	if showCaret {
		caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
	}
	lines := make([]string, len(rowsRunes))
	for i, r := range rowsRunes {
		if showCaret && i == caretRow {
			c := min(caretCol, len(r))
			lines[i] = style(r[:c]) + caret + style(r[c:])
		} else {
			lines[i] = style(r)
		}
	}
	if len(lines) > maxInputLines {
		lines = lines[len(lines)-maxInputLines:]
	}
	return lines
}

// launcherInput renders the compact (growing) input box plus a status sub-line
// (<workflow> · <model> left, action hints right).
func (m Model) launcherInput(w int) string {
	t := m.theme
	insert := m.editMode == ModeInsert
	prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("› ")
	indent := t.span("  ", t.P.Bg)
	var content []string
	if m.inputBuffer == "" && !insert {
		content = []string{prompt + t.span("Ask anything…", t.P.Faint)}
	} else {
		for i, ln := range m.editorLines(w - 2) {
			if i == 0 {
				content = append(content, prompt+ln)
			} else {
				content = append(content, indent+ln)
			}
		}
	}
	// Borderless (see renderInput): top rule + flush text, so a terminal copy is just the prompt.
	ruleColor := t.P.Border
	if insert {
		ruleColor = t.P.BorderHi
	}
	rule := lipgloss.NewStyle().Foreground(ruleColor).Background(t.P.Bg).Render(strings.Repeat("─", w))
	boxLines := append([]string{rule}, content...)
	for i, ln := range boxLines {
		boxLines[i] = padTo(ln, w, t.P.Bg)
	}
	box := strings.Join(boxLines, "\n")

	wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
	leftSub := t.span("  ", t.P.Faint) + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint)
	hints := t.span("Tab wf · ⌥↵/^J newline · enter ↵  ", t.P.Faint)
	return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg)
}

// launcherRail is the idle status + quick-keys panel on the right.
func (m Model) launcherRail(w int) string {
	t := m.theme
	var rows []string
	if m.connected {
		rows = append(rows, lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● connected"))
	} else {
		rows = append(rows, lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("● offline"))
	}
	count := t.span(plural(len(m.sessions), "session"), t.P.Dim)
	if a := m.activeSessionCount(); a > 0 {
		count += t.span(" · "+itoa(a)+" active", t.P.Faint)
	}
	key := func(k, d string) string {
		return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(padRaw(k, 4)) + t.span(d, t.P.Dim)
	}
	rows = append(rows, count, "",
		key("i", "type"),
		key("Tab", "workflow"),
		key("R", "resume"),
		key("?", "keys"),
		key("p", "commands"),
	)
	return t.box("correx", rows, w, len(rows)+2, false)
}

// launcherWfName is the active launch target: "chat" (default) or a workflow id, Tab-cycled.
func (m Model) launcherWfName() string {
	if m.launcherWf <= 0 || m.launcherWf > len(m.workflows) {
		return "chat"
	}
	return m.workflows[m.launcherWf-1].ID
}

// activeSessionCount is the number of sessions not in a terminal (completed/failed) state.
func (m Model) activeSessionCount() int {
	n := 0
	for _, s := range m.sessions {
		u := strings.ToUpper(s.Status)
		if !strings.Contains(u, "COMPLETE") && !strings.Contains(u, "FAIL") {
			n++
		}
	}
	return n
}

// renderMainNarrow gives the main area a single full-width column when the terminal
// is too slim for side-by-side panels: the session list when idle, and the output
// transcript stacked over the live event strip when in-session (so both still fit
// — the strip is too valuable to drop, the constraint is width not height).
func (m Model) renderMainNarrow(h int) string {
	w := m.width
	// Idle is handled by renderLauncher (which drops the rail when narrow); this only
	// runs for in-session / approval: stack output over events.
	outH := h * 55 / 100
	if outH < 3 {
		outH = 3
	}
	evH := h - outH
	if evH < 3 {
		evH = 3
		outH = h - evH
	}
	out := m.theme.box("output", m.routerRows(w-4, outH-2), w, outH, true)
	ev := m.theme.box("events", m.eventRows(w-4, evH), w, evH, false)
	return lipgloss.JoinVertical(lipgloss.Left, out, ev)
}

// --- input bar ---

// flattenForDisplay collapses control characters (newlines from a multi-line paste, tabs)
// to a single visible line so the fixed-height input box can't overflow and smear stale cells
// (F-006). The underlying inputBuffer keeps its real characters for submission.
func flattenForDisplay(s string) string {
	repl := strings.NewReplacer("\r\n", "↵ ", "\r", "↵ ", "\n", "↵ ", "\t", " ")
	return repl.Replace(s)
}

func (m Model) renderInput() string {
	t := m.theme

	placeholder := "Ask anything…"
	switch {
	case m.inputMode == ModeIntent:
		placeholder = "Describe the task for " + m.wfPendingName + " (Enter to start, empty = none)…"
	case m.inputMode == ModeFilter:
		placeholder = "Filter sessions…"
	case m.displayState() == StateIdle:
		placeholder = "Session name…"
	}

	insert := m.editMode == ModeInsert
	caret := t.span(" ", t.P.Bg)
	if insert && m.overlay == OverlayNone {
		caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
	}
	var content []string
	switch {
	case m.inputBuffer == "" && !insert:
		content = []string{t.span(placeholder, t.P.Faint)}
	case m.inputBuffer == "":
		content = []string{caret + t.span(placeholder, t.P.Faint)}
	default:
		content = m.editorLines(m.width)
	}

	// status sub-line: <session/none> · <uuid> · <mode>
	name := "(no session)"
	if s := m.session(m.selectedID); s != nil {
		name = s.Name
	}
	mode := "chat"
	if m.chatMode == ChatModeSteering {
		mode = "steering"
	}
	if m.inputMode == ModeFilter {
		mode = "filter"
	}
	if m.inputMode == ModeIntent {
		mode = "task"
	}
	sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name)
	if m.selectedID != "" {
		sub += t.span(" · ", t.P.Faint) +
			t.span(m.selectedID, t.P.Faint)
	}
	sub += t.span(" · ", t.P.Faint) +
		t.span(mode, t.P.Dim)

	// Borderless composer: a faint top rule for separation, then the text lines flush-left with
	// no side borders and no right-padding. A bordered/filled box copies its `│` chrome and
	// trailing whitespace when terminal-selected; this keeps a drag-copy to just the typed text.
	ruleColor := t.P.Border
	if insert {
		ruleColor = t.P.BorderHi
	}
	rule := lipgloss.NewStyle().Foreground(ruleColor).Background(t.P.Bg).Render(strings.Repeat("─", m.width))
	lines := append([]string{rule}, content...)
	lines = append(lines, sub)
	// Pad (not just truncate) every line to full width with the panel bg so the terminal backdrop
	// can't bleed through the right of the placeholder / status sub-line (matches the frame fill).
	for i, ln := range lines {
		lines[i] = padTo(ln, m.width, t.P.Bg)
	}
	return strings.Join(lines, "\n")
}

// inputBarHeight is the bottom input bar's current height (it grows with multi-line input):
// top rule + content lines (capped at maxInputLines) + the status sub-line.
func (m Model) inputBarHeight() int {
	n := 1
	if m.inputBuffer != "" {
		n = len(m.editorLines(m.width))
	}
	return n + 2
}

// --- body row builders ---

// shortDateTime formats a millis-since-epoch instant as a compact local timestamp for the
// session list: "Jan 02 15:04" within the current year, "Jan 02 2006" for older years.
// Empty for a zero/absent time (renders as no date rather than a bogus epoch).
func shortDateTime(ms int64) string {
	if ms <= 0 {
		return ""
	}
	ts := time.UnixMilli(ms)
	if ts.Year() != time.Now().Year() {
		return ts.Format("Jan 02 2006")
	}
	return ts.Format("Jan 02 15:04")
}

func (m Model) routerRows(w, h int) []string {
	t := m.theme
	if len(m.routerMessages[m.selectedID]) == 0 {
		return []string{t.span("awaiting router response…", t.P.Faint)}
	}
	rows, msgStart := m.buildTranscriptRows(w)
	// With a selection, scroll so the selected message is visible (top-aligned, clamped to the
	// end); otherwise honour the free-scroll offset (0 = tail-follow the newest output).
	if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h {
		start := msgStart[m.transcriptSel]
		if start > len(rows)-h {
			start = len(rows) - h
		}
		if start < 0 {
			start = 0
		}
		return rows[start : start+h]
	}
	if len(rows) > h {
		off := m.outputScroll
		if max := len(rows) - h; off > max {
			off = max
		}
		if off < 0 {
			off = 0
		}
		end := len(rows) - off
		return rows[end-h : end]
	}
	return rows
}

// buildTranscriptRows renders the selected session's transcript to display rows (and records each
// message's first row index for scroll-to-selection). Split out so the scroll handler can measure
// the full height against the same content the renderer windows.
func (m Model) buildTranscriptRows(w int) ([]string, []int) {
	t := m.theme
	msgs := m.routerMessages[m.selectedID]
	var rows []string
	// Prepend the live execution plan card when the session has locked plan stages.
	if planRows := m.planCardLines(); len(planRows) > 0 {
		rows = append(rows, planRows...)
	}
	msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection
	for mi, e := range msgs {
		msgStart[mi] = len(rows)
		switch e.Role {
		case "user":
			if mi == m.transcriptSel {
				// Selected message: an inverted tag + a highlighted background bar so it's
				// unmistakable which one ctrl+↑/↓ landed on (and what `y` will copy).
				tag := lipgloss.NewStyle().Foreground(t.P.BgDeep).Background(t.P.Accent).Bold(true).Render(" › ")
				rows = append(rows, tag+" "+lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Sel).Render(e.Content))
				break
			}
			tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("›")
			rows = append(rows, tag+t.span(" ", t.P.Bg)+t.span(e.Content, t.P.FgStrong))
		case "router":
			// The router turn is model output: render it as markdown (bold,
			// lists, headings, code) wrapped to the panel width. glamour applies
			// its own inline foreground colors; we keep the opaque panel by
			// fixing each line's background. On any failure renderMarkdown hands
			// back the plain content, which still flows through this path fine.
			rendered := renderMarkdown(e.Content, w)
			for _, ln := range strings.Split(rendered, "\n") {
				rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln))
			}
			if s := metricsSuffix(e.Metrics); s != "" {
				rows = append(rows, t.span(s, t.P.Faint))
			}
		case "tool":
			if e.Reasoning != "" {
				if m.thinkingShown {
					for _, ln := range strings.Split(strings.TrimRight(e.Reasoning, "\n"), "\n") {
						rows = append(rows, t.span("  ✼ "+ln, t.P.Faint))
					}
				} else {
					rows = append(rows, t.span("  ✼ reasoning — palette: thinking", t.P.Faint))
				}
			} else if m.lastReasoning != "" {
				if m.thinkingShown {
					for _, ln := range strings.Split(strings.TrimRight(m.lastReasoning, "\n"), "\n") {
						rows = append(rows, t.span("  ✼ "+ln, t.P.Faint))
					}
				} else {
					rows = append(rows, t.span("  ✼ reasoning — palette: thinking", t.P.Faint))
				}
			}
			gutter := t.span(" ┈", t.P.Faint)
			summary := toolRowSummary(e.Content)
			avail := w - 4
			if avail < 10 {
				avail = 10
			}
			prefixW := lipgloss.Width(gutter) + 1
			contentW := avail - prefixW
			if contentW < 4 {
				contentW = 4
			}
			parts := wrapContent(summary, contentW)
			for i, ln := range parts {
				if i == 0 {
					rows = append(rows, gutter+" "+t.span(ln, t.P.Dim))
				} else {
					indent := t.span(strings.Repeat(" ", prefixW+1), t.P.Bg)
					rows = append(rows, indent+t.span(ln, t.P.Dim))
				}
			}
		case "thinking":
			// Collapsed by default to a single muted line so the reasoning trace doesn't bury
			// the answer; the palette "thinking" toggle reveals the full dimmed block.
			brain := lipgloss.NewStyle().Foreground(t.P.Faint).Background(t.P.Bg).Render("✼")
			if !m.thinkingShown {
				n := strings.Count(strings.TrimRight(e.Content, "\n"), "\n") + 1
				rows = append(rows, brain+t.span(" thinking ("+plural(n, "line")+") — palette: thinking", t.P.Faint))
				break
			}
			rendered := renderMarkdown(e.Content, w-2)
			lines := strings.Split(rendered, "\n")
			for i, ln := range lines {
				if i == 0 {
					rows = append(rows, brain+t.span(" ", t.P.Bg)+lipgloss.NewStyle().Foreground(t.P.Faint).Background(t.P.Bg).Render(ln))
				} else {
					rows = append(rows, t.span("  ", t.P.Bg)+lipgloss.NewStyle().Foreground(t.P.Faint).Background(t.P.Bg).Render(ln))
				}
			}
		case "narration_llm":
			diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆")
			lines := wrap(e.Content, w-2)
			for i, ln := range lines {
				if i == 0 {
					rows = append(rows, diamond+t.span(" ", t.P.Bg)+t.span(ln, t.P.FgStrong))
				} else {
					rows = append(rows, t.span("  ", t.P.Bg)+t.span(ln, t.P.FgStrong))
				}
			}
			if s := metricsSuffix(e.Metrics); s != "" {
				rows = append(rows, t.span(s, t.P.Faint))
			}
		case "narration", "stage":
			rows = append(rows, t.span(e.Content, t.P.Dim))
		case "action":
			// Inline "external feedback" row: a side-effecting action (tool call, write/edit,
			// approval, grant) surfaced in the conversation flow. Muted via actionsHidden.
			if m.actionsHidden {
				break
			}
			iconFg := t.P.Accent2
			switch e.Icon {
			case "✓":
				iconFg = t.P.OK
			case "✗":
				iconFg = t.P.Bad
			case "✕":
				iconFg = t.P.Warn
			}
			gutter := t.span(" ┈", t.P.Faint)
			icon := lipgloss.NewStyle().Foreground(iconFg).Background(t.P.Bg).Bold(true).Render(e.Icon)
			prefix := gutter + " " + icon + " "
			avail := w - 4 // box inner padding
			if avail < 10 {
				avail = 10
			}
			contentW := avail - lipgloss.Width(prefix)
			if contentW < 4 {
				contentW = 4
			}
			parts := wrapContent(e.Content, contentW)
			for i, ln := range parts {
				if i == 0 {
					rows = append(rows, prefix+t.span(ln, t.P.FgStrong))
				} else {
					indent := t.span(strings.Repeat(" ", lipgloss.Width(prefix)), t.P.Bg)
					rows = append(rows, indent+t.span(ln, t.P.FgStrong))
				}
			}
		}
	}
	return rows, msgStart
}

// planCardLines renders the live execution plan card for the selected session.
func (m Model) planCardLines() []string {
	t := m.theme
	s := m.session(m.selectedID)
	if s == nil || len(s.PlanStages) == 0 {
		return nil
	}
	goal := strings.TrimSpace(s.PlanGoal)
	var out []string
	if goal != "" {
		out = append(out, t.span(goal, t.P.Faint))
	}
	stageLabel := "stage"
	if len(s.PlanStages) != 1 {
		stageLabel = "stages"
	}
	out = append(out, t.span("Plan \u00b7 "+itoa(len(s.PlanStages))+" "+stageLabel, t.P.Accent))
	idW := 0
	for _, ps := range s.PlanStages {
		if len(ps.ID) > idW {
			idW = len(ps.ID)
		}
	}
	for _, ps := range s.PlanStages {
		var badge, badgeStyle string
		var badgeColor color.Color
		switch ps.Status {
		case PlanPending:
			badge = "\u25cb"
			badgeColor = t.P.Faint
		case PlanRunning:
			badge = "\u25cf"
			badgeColor = t.P.Accent
		case PlanCompleted:
			badge = "\u2713"
			badgeColor = t.P.OK
		case PlanFailed:
			badge = "\u2717"
			badgeColor = t.P.Bad
		}
		badgeStyle = t.span(badge, badgeColor)
		rest := t.span("  "+padRaw(ps.ID, idW), t.P.Fg)
		out = append(out, "  "+badgeStyle+rest)
	}
	return out
}

func (m Model) eventRows(w, h int) []string {
	t := m.theme
	header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
		t.span("   ", t.P.Bg) + m.eventStreamBadge()

	s := m.session(m.selectedID)
	if s == nil || len(s.Events) == 0 {
		return []string{header, "", t.span("no events yet", t.P.Faint)}
	}
	evRows := make([]string, 0, len(s.Events))
	for _, e := range s.Events {
		cat := inferCategory(e.Type)
		catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).
			Render(" " + padRaw(cat, 9) + " ")
		evRows = append(evRows,
			t.span(e.Time+" ", t.P.Faint)+catCell+t.span(" ", t.P.Bg)+
				t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim))
	}
	// Show the latest events that fit under the 2-line header (box inner = h-2).
	if avail := h - 4; avail >= 1 && len(evRows) > avail {
		evRows = evRows[len(evRows)-avail:]
	}
	return append([]string{header, ""}, evRows...)
}

// eventStreamBadge reflects whether the *selected session's* stream is still live, not
// just whether the socket is connected: a completed / failed / paused session emits no
// more events, so the old connection-only "● live" went stale the moment a session ended.
func (m Model) eventStreamBadge() string {
	t := m.theme
	if !m.connected {
		return t.span("○ idle", t.P.Faint)
	}
	if s := m.session(m.selectedID); s != nil {
		badge := func(fg color.Color, text string) string {
			return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text)
		}
		switch u := strings.ToUpper(s.Status); {
		case strings.Contains(u, "FAIL"):
			return badge(t.P.Bad, "✗ failed")
		case strings.Contains(u, "COMPLETE"):
			return badge(t.P.OK, "✓ done")
		case strings.Contains(u, "PAUSE"):
			return badge(t.P.Warn, "‖ paused")
		}
	}
	return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live")
}

// --- width helpers ---

// shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar.
func shortPath(p string) string {
	if home := os.Getenv("HOME"); home != "" && strings.HasPrefix(p, home) {
		return "~" + p[len(home):]
	}
	return p
}

// fill left-justifies a styled line and pads with bg to width w.
func (m Model) fill(s string, w int, bg color.Color) string {
	return lipgloss.NewStyle().Background(bg).Render(" ") + padTo(s, w-1, bg)
}

// justify places left and right segments on a bg-filled line of width w, truncating
// so the line never overflows w. The left segment is shrunk first (the right holds
// the high-signal status — connection clock / active spinner); the right is clamped
// only as a last resort. ANSI-aware so truncation can't slice through escape codes.
func (m Model) justify(left, right string, w int, bg color.Color) string {
	avail := w - 2 // leading + trailing space
	lw := lipgloss.Width(left)
	rw := lipgloss.Width(right)
	if lw+rw+1 > avail {
		maxLeft := avail - rw - 1
		if maxLeft < 0 {
			maxLeft = 0
		}
		left = ansi.Truncate(left, maxLeft, "…")
		lw = lipgloss.Width(left)
		if lw+rw+1 > avail {
			right = ansi.Truncate(right, max(0, avail-lw-1), "…")
			rw = lipgloss.Width(right)
		}
	}
	mid := avail - lw - rw
	if mid < 1 {
		mid = 1
	}
	pad := lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", mid))
	edge := lipgloss.NewStyle().Background(bg).Render(" ")
	return edge + left + pad + right + edge
}

// padTo pads (or truncates) a possibly-styled string to visible width w, filling
// with the given background so the panel stays opaque.
func padTo(s string, w int, bg color.Color) string {
	vw := lipgloss.Width(s)
	if vw == w {
		return s
	}
	if vw > w {
		return ansi.Truncate(s, w, "")
	}
	return s + lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", w-vw))
}

// padRaw pads a plain (unstyled) string to width w with spaces, truncating with
// an ellipsis only when it genuinely overflows.
func padRaw(s string, w int) string {
	n := len([]rune(s))
	if n == w {
		return s
	}
	if n > w {
		if w <= 1 {
			return string([]rune(s)[:w])
		}
		return string([]rune(s)[:w-1]) + "…"
	}
	return s + strings.Repeat(" ", w-n)
}

func statusColor(t Theme, status string) color.Color {
	u := strings.ToUpper(status)
	switch {
	case strings.Contains(u, "FAIL"):
		return t.P.Bad
	case strings.Contains(u, "COMPLETE"):
		return t.P.Dim
	case strings.Contains(u, "PAUSE"), strings.Contains(u, "STARTING"):
		return t.P.Warn
	case strings.Contains(u, "CHAT"), strings.Contains(u, "ACTIVE"):
		return t.P.Accent
	default:
		return t.P.Dim
	}
}

func statusLabel(status string) string {
	u := strings.ToUpper(status)
	if i := strings.Index(u, " "); i > 0 {
		return u[:i]
	}
	return u
}

func statusGlyph(t Theme, status string) string {
	u := strings.ToUpper(status)
	switch {
	case strings.Contains(u, "FAIL"):
		return lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("✗")
	case strings.Contains(u, "COMPLETE"):
		return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("✓")
	default:
		return t.span("·", t.P.Faint)
	}
}

// span renders text with foreground fg over the panel background.
func (t Theme) span(s string, fg color.Color) string {
	return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
}

// box draws a rounded, opaque panel with the title embedded in the top border.
func (t Theme) box(title string, body []string, w, h int, active bool) string {
	if w < 4 {
		w = 4
	}
	if h < 2 {
		h = 2
	}
	inner := w - 2
	textW := inner - 2
	bc := t.P.Border
	if active {
		bc = t.P.BorderHi
	}
	bs := lipgloss.NewStyle().Foreground(bc).Background(t.P.Bg)
	cell := lipgloss.NewStyle().Background(t.P.Bg)

	var top string
	if title == "" {
		top = bs.Render("╭" + strings.Repeat("─", inner) + "╮")
	} else {
		ts := t.PanelTitle
		if active {
			ts = t.TitleHi
		}
		label := strings.ToUpper(title)
		fill := inner - (2 + lipgloss.Width(label) + 1)
		if fill < 0 {
			fill = 0
		}
		top = bs.Render("╭─ ") + ts.Render(label) + bs.Render(" "+strings.Repeat("─", fill)) + bs.Render("╮")
	}
	lines := make([]string, 0, h)
	lines = append(lines, top)
	for i := 0; i < h-2; i++ {
		content := ""
		if i < len(body) {
			content = body[i]
		}
		padded := padTo(content, textW, t.P.Bg)
		lines = append(lines, bs.Render("│")+cell.Render(" ")+padded+cell.Render(" ")+bs.Render("│"))
	}
	lines = append(lines, bs.Render("╰"+strings.Repeat("─", inner)+"╯"))
	return strings.Join(lines, "\n")
}

func plural(n int, word string) string {
	s := itoa(n) + " " + word
	if n != 1 {
		s += "s"
	}
	return s
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	neg := n < 0
	if neg {
		n = -n
	}
	var b [20]byte
	i := len(b)
	for n > 0 {
		i--
		b[i] = byte('0' + n%10)
		n /= 10
	}
	if neg {
		i--
		b[i] = '-'
	}
	return string(b[i:])
}

// toolRowSummary collapses a tool-output transcript entry into a one-line summary. For
// write/edit entries (unified diffs) it shows the file path and row count; non-diff output
// shows a character count. The leading badge (▾ diff / ▾ output) tells the kind at a glance.
func toolRowSummary(content string) string {
	if isUnifiedDiff(content) {
		n := itoa(previewRowCount(content))
		if p := diffTarget(content); p != "" {
			return "▾ diff  ·  " + p + "  ·  " + n + " rows  ·  ^x"
		}
		return "▾ diff  ·  " + n + " rows  ·  ^x"
	}
	return "▾ output  ·  " + itoa(len([]rune(content))) + " chars  ·  ^x"
}

// metricsSuffix formats the faint latency+token annotation for a ROUTER turn.
func metricsSuffix(mtr *TurnMetrics) string {
	if mtr == nil {
		return ""
	}
	return fmt.Sprintf("  %.1fs · %d tokens", float64(mtr.LatencyMs)/1000.0, mtr.TotalTokens)
}

// wrap splits s into lines no wider than w (rune-based, whitespace-greedy).
func wrap(s string, w int) []string {
	if w < 1 {
		w = 1
	}
	words := strings.Fields(s)
	if len(words) == 0 {
		return []string{""}
	}
	var lines []string
	cur := ""
	for _, word := range words {
		if cur == "" {
			cur = word
		} else if len(cur)+1+len(word) <= w {
			cur += " " + word
		} else {
			lines = append(lines, cur)
			cur = word
		}
	}
	if cur != "" {
		lines = append(lines, cur)
	}
	return lines
}

// wrapContent splits plain text into lines no wider than w. Words that fit whole
// are kept together; a word longer than w is character-wrapped at w. Returns at
// least one line.
func wrapContent(s string, w int) []string {
	if w < 1 {
		w = 1
	}
	if len(s) <= w {
		return []string{s}
	}
	words := strings.Fields(s)
	if len(words) == 0 {
		return []string{""}
	}
	var lines []string
	cur := ""
	flush := func() {
		if cur != "" {
			lines = append(lines, cur)
			cur = ""
		}
	}
	for _, word := range words {
		// Does the word itself overflow the width? If so, flush current line,
		// then character-wrap the word.
		if len(word) > w {
			flush()
			for len(word) > w {
				lines = append(lines, word[:w])
				word = word[w:]
			}
			if word != "" {
				cur = word
			}
			continue
		}
		if cur == "" {
			cur = word
		} else if len(cur)+1+len(word) <= w {
			cur += " " + word
		} else {
			lines = append(lines, cur)
			cur = word
		}
	}
	flush()
	if len(lines) == 0 {
		return []string{""}
	}
	return lines
}
