package app

import (
	"fmt"
	"os"
	"sort"
	"strings"
	"time"

	tea "charm.land/bubbletea/v2"
	osc52 "github.com/aymanbagabas/go-osc52/v2"
	"github.com/correx/tui-go/internal/protocol"
	"github.com/correx/tui-go/internal/ws"
)

// --- Bubble Tea messages bridging the ws channels ---

type serverMsg struct{ m protocol.ServerMessage }
type connMsg struct{ s ws.Status }
type tickMsg struct{}

// copiedFlashFrames is how many ticks the "✓ copied" footer note lingers after a yank.
const copiedFlashFrames = 12

// fileListMax caps how many @-picker rows are filtered/rendered at once.
const fileListMax = 200

func readServer(c *ws.Client) tea.Cmd {
	return func() tea.Msg { return serverMsg{<-c.Incoming()} }
}

func readConn(c *ws.Client) tea.Cmd {
	return func() tea.Msg { return connMsg{<-c.Conn()} }
}

func tick() tea.Cmd {
	return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} })
}

// Init starts the channel readers. The animation tick is NOT started here — it is
// kicked lazily by tickKick once something actually needs to animate (an active
// session's spinner, a blinking caret), so an idle screen never auto-redraws.
func (m Model) Init() tea.Cmd {
	return tea.Batch(readServer(m.client), readConn(m.client))
}

// animating reports whether anything on screen needs the frame counter to advance:
// a spinning session, a blinking caret (any typing surface), or the reconnect state.
// When false, the tick loop stops and the screen holds still — so a native terminal
// text selection survives instead of being wiped by the next redraw.
func (m Model) animating() bool {
	// Note: ModeInsert is intentionally NOT here — the composer caret is now a real
	// terminal cursor (View.Cursor) that the terminal blinks itself, so insert mode no
	// longer needs the frame loop. The other typing surfaces keep their drawn carets.
	if m.steering || m.clarTyping || m.proposeTyping || m.configEditing {
		return true
	}
	if m.reconnecting {
		return true
	}
	if m.overlay == OverlayPalette || m.overlay == OverlayFiles { // blinking filter caret
		return true
	}
	if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
		return true // keep ticking briefly so the "copied" note animates out
	}
	for i := range m.sessions {
		if m.sessions[i].Active {
			return true
		}
	}
	return false
}

// tickKick starts the tick loop iff animation is needed and no loop is already
// running. Returns nil otherwise. The single-loop invariant: ticking is true exactly
// while a loop is scheduled (set here, cleared by tickMsg when it stops), so this
// never spawns a second concurrent loop.
func (m *Model) tickKick() tea.Cmd {
	if m.animating() && !m.ticking {
		m.ticking = true
		return tick()
	}
	return nil
}

// Update is the single reducer. View renders purely from the returned model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width, m.height = msg.Width, msg.Height
		return m, nil

	case tickMsg:
		m.frame++
		if m.animating() {
			return m, tick()
		}
		m.ticking = false // loop stops; tickKick will restart it when animation resumes
		return m, nil

	case connMsg:
		m.applyConn(msg.s)
		return m, tea.Batch(readConn(m.client), m.tickKick())

	case serverMsg:
		m.applyServerPhased(msg.m)
		return m, tea.Batch(readServer(m.client), m.tickKick())

	case sessionsLoadedMsg:
		m.applySessionsLoaded(msg)
		return m, nil

	case sessionResumeMsg:
		if msg.err != "" {
			m.sessionListErr = "resume: " + msg.err
		}
		return m, nil

	case tasksLoadedMsg:
		m.applyTasksLoaded(msg)
		return m, nil

	case tea.PasteMsg:
		// Bracketed paste (Ctrl+V / Ctrl+Shift+V / right-click): drop the clipboard text into
		// whichever composer is focused. Without this the paste is silently swallowed.
		return m.handlePaste(msg.Content), m.tickKick()

	case tea.KeyPressMsg:
		next, cmd := m.handleKey(toKeyMsg(msg))
		// A keypress may have entered a typing/active state (insert mode, steering, …);
		// kick the tick so the caret/spinner animates again.
		if nm, ok := next.(Model); ok {
			return nm, tea.Batch(cmd, nm.tickKick())
		}
		return next, cmd
	}
	return m, nil
}

// applySessionsLoaded folds a GET /sessions result into the browser, clamping the
// cursor and surfacing any fetch error in place of a crash.
func (m *Model) applySessionsLoaded(msg sessionsLoadedMsg) {
	m.sessionListLoading = false
	if msg.err != "" {
		m.sessionListErr = msg.err
		return
	}
	m.sessionListErr = ""
	m.sessionList = msg.sessions
	sort.Slice(m.sessionList, func(i, j int) bool {
		return m.sessionList[i].LastActivityAt > m.sessionList[j].LastActivityAt
	})
	if m.sessionListIndex >= len(m.sessionList) {
		m.sessionListIndex = 0
	}
}

func (m *Model) applyConn(s ws.Status) {
	switch {
	case s.Connected:
		m.connected = true
		m.reconnecting = false
		m.snapshotPhase = true
		m.pendingEvents = nil
	case s.Reconnecting:
		m.connected = false
		m.reconnecting = true
		m.snapshotPhase = true
	}
}

// applyServerPhased buffers event-bearing messages during the snapshot phase and
// drains them atomically on snapshot_complete.
func (m *Model) applyServerPhased(msg protocol.ServerMessage) {
	if msg.Type == protocol.TypeSnapshotComplete {
		m.snapshotPhase = false
		buffered := m.pendingEvents
		m.pendingEvents = nil
		for _, e := range buffered {
			m.applyServer(e)
		}
		return
	}
	if m.snapshotPhase && msg.IsEventBearing() {
		m.pendingEvents = append(m.pendingEvents, msg)
		return
	}
	m.applyServer(msg)
}

// --- key handling ---

func (m Model) handleKey(k keyMsg) (tea.Model, tea.Cmd) {
	debugLog("KEY type=%v runes=%q alt=%v | ds=%s input=%s edit=%s overlay=%d steering=%v steerBuf=%q dismissed=%v entered=%v sel=%s",
		k.Type, string(k.Runes), k.Alt, m.displayState(), m.inputMode, m.editMode, m.overlay,
		m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID)
	// Ctrl+C is a universal hard-quit safety; everything else is bare-key modal.
	if k.Type == keyCtrlC {
		m.quitting = true
		return m, tea.Quit
	}
	if m.overlay != OverlayNone {
		return m.handleOverlayKey(k)
	}
	if m.displayState() == StateClarification {
		m.clarEnsureState()
		return m.handleClarificationKey(k)
	}
	if m.displayState() == StateWorkflowPropose {
		return m.handleProposeKey(k)
	}
	// Transcript message-jump works in any edit mode while in-session, so you can scroll
	// back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer.
	if m.displayState() == StateInSession {
		switch k.Type {
		case keyCtrlUp:
			m.transcriptNavUser(-1)
			return m, nil
		case keyCtrlDown:
			m.transcriptNavUser(1)
			return m, nil
		}
	}
	// On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any
	// edit mode, so it works whether or not the input is focused.
	if m.displayState() == StateIdle && k.Type == keyTab {
		if k.Shift {
			m.cycleLauncherWfBack()
		} else {
			m.cycleLauncherWf()
		}
		return m, nil
	}
	if m.editMode == ModeInsert {
		return m.handleInsertKey(k)
	}
	return m.handleNormalKey(k)
}

// handleNormalKey processes vim-style bare-key commands.
func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) {
	ds := m.displayState()
	// The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm
	// gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the
	// general bindings (e=events, t=tools, …) from shadowing the decision keys.
	if ds == StateApproval {
		return m.handleApprovalKey(k)
	}
	switch k.Type {
	case keyUp:
		m.navUp()
		return m, nil
	case keyDown:
		m.navDown()
		return m, nil
	case keyEnter:
		return m.normalEnter()
	case keyEsc:
		// In-session, esc first drops a transcript selection or output scroll (back to
		// tail-follow); otherwise it clears the session-list filter.
		if m.transcriptSel >= 0 {
			m.transcriptSel = -1
			return m, nil
		}
		if m.outputScroll != 0 {
			m.outputScroll = 0
			return m, nil
		}
		m.filter = ""
		return m, nil
	case keyPgUp:
		m.scrollOutput(m.outputViewportPage())
		return m, nil
	case keyPgDown:
		m.scrollOutput(-m.outputViewportPage())
		return m, nil
	case keyCtrlU:
		m.scrollOutput(m.outputViewportPage() / 2)
		return m, nil
	case keyCtrlD:
		m.scrollOutput(-m.outputViewportPage() / 2)
		return m, nil
	case keyCtrlX:
		if m.currentDiff() != "" {
			m.overlay = OverlayDiff
			m.diffScrollOffset = 0
		}
		return m, nil
	case keyCtrlP:
		if ds == StateInSession {
			m.overlay = OverlayPlan
		}
		return m, nil
	}
	if k.Type != keyRunes {
		return m, nil
	}
	switch string(k.Runes) {
	case "i":
		m.enterInsert(ModeRouter)
	case "?":
		m.overlay = OverlayHelp
		m.modalScroll = 0
	case "/":
		m.enterInsert(ModeFilter)
	case "j":
		m.navDown()
	case "k":
		m.navUp()
	case "p":
		m.openPalette()
	case "e":
		m.openEventInspector()
	case "t":
		m.overlay = OverlayToolPalette
	case "v":
		m.openArtifacts()
	case "I":
		m.openIdeas()
	case "H":
		m.openHealth()
	case "R":
		// Resume browser: recent sessions over HTTP, reachable even after a server
		// restart + TUI reopen (the live stream only carries running sessions).
		return m, m.openSessions()
	case "T":
		// Task board: all tasks across projects over HTTP (GET /tasks).
		return m, m.openTasks()
	case "S":
		m.openStats()
	case "G":
		m.openGrants()
	case "g":
		m.openConfig()
	case "P":
		m.openProjectProfile()
	case "O":
		m.openOperatorProfile()
	case "m":
		m.openModelsOverlay()
	case "w":
		// Launcher: w is an alias for Tab — cycle the launch target (chat / workflow…).
		if ds == StateIdle {
			m.cycleLauncherWf()
		}
	case "d":
		// In-session: cycle the right panel (events → changes → off).
		if ds == StateInSession {
			m.cycleRightPanel()
		}
	case "l":
		if ds != StateIdle {
			m.sessionEntered = false
			m.approvalDismissed = false
		}
	case "s":
		m.cycleChatMode()
	case "y":
		// Copy the selected message (or the latest assistant turn if none is selected)
		// to the system clipboard over OSC52 — SSH-safe, unlike a mouse drag.
		if cmd := m.copyMessage(); cmd != nil {
			m.copiedFlash = m.frame
			return m, cmd
		}
	case "c":
		if m.selectedID != "" {
			m.client.Send(protocol.CancelSession(m.selectedID))
		}
	case "a":
		// In-session (gate peeked away with esc): `a` brings the band back.
		if ds == StateInSession {
			if s := m.session(m.selectedID); s != nil && s.Pending != nil {
				m.approvalDismissed = false
			}
		}
	case "q":
		m.quitting = true
		return m, tea.Quit
	}
	return m, nil
}

// handleApprovalKey owns the approval band's bare keys. Low tiers (T0–T2) act on a
// single keypress; a T3+ approve arms first and only a second confirming key sends
// it (any other key disarms). ↑/↓ walk the pending queue without resolving anything.
func (m Model) handleApprovalKey(k keyMsg) (tea.Model, tea.Cmd) {
	s := m.session(m.selectedID)
	if s == nil || s.Pending == nil {
		return m, nil
	}
	// Queue navigation never resolves a gate; it just changes which one is shown.
	switch {
	case k.Type == keyUp || runeIs(k, "k"):
		s.navApproval(-1)
		m.approvalArmed = false
		return m, nil
	case k.Type == keyDown || runeIs(k, "j"):
		s.navApproval(1)
		m.approvalArmed = false
		return m, nil
	}

	// Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of
	// sending; the same key pressed again (while armed) confirms.
	approveKey := k.Type == keyEnter || k.Type == keyCtrlA ||
		runeIs(k, "y") || runeIs(k, "a")
	if approveKey {
		if s.Pending.HighTier() && !m.approvalArmed {
			m.approvalArmed = true
			return m, nil
		}
		return m.decide("APPROVE")
	}

	// Any other key cancels a pending T3 arm rather than acting on it.
	m.approvalArmed = false

	switch {
	case k.Type == keyCtrlR || runeIs(k, "n") || runeIs(k, "r"):
		return m.decide("REJECT")
	case runeIs(k, "e") || runeIs(k, "s"):
		// Steer/edit flows into the existing steering-note input path.
		m.steering = true
		m.editMode = ModeInsert
		return m, nil
	case runeIs(k, "A"):
		return m.autoApprove()
	case runeIs(k, "l"):
		m.sessionEntered = false
		m.approvalDismissed = false
		return m, nil
	case k.Type == keyEsc:
		m.approvalDismissed = true
		return m, nil
	case k.Type == keyCtrlX:
		if m.currentDiff() != "" {
			m.overlay = OverlayDiff
			m.diffScrollOffset = 0
		}
		return m, nil
	case runeIs(k, "q"):
		m.quitting = true
		return m, tea.Quit
	}
	// A bare disarm (e.g. an unrelated key while armed) just falls through.
	return m, nil
}

// handleInsertKey processes typing (chat/filter/steer note).
func (m Model) handleInsertKey(k keyMsg) (tea.Model, tea.Cmd) {
	if m.steering {
		switch k.Type {
		case keyEsc:
			m.steering = false
			m.editMode = ModeNormal
		case keyEnter:
			return m.submitApproval()
		case keyBackspace:
			if n := len(m.steerBuffer); n > 0 {
				m.steerBuffer = m.steerBuffer[:n-1]
			}
		case keyRunes, keySpace:
			m.steerBuffer += string(k.Runes)
		}
		return m, nil
	}

	switch k.Type {
	case keyEsc:
		m.editMode = ModeNormal
		if m.inputMode == ModeFilter {
			m.inputMode = ModeRouter
		}
		if m.inputMode == ModeIntent {
			m.wfPendingID = ""
			m.wfPendingName = ""
			m.clearInput()
			m.inputMode = ModeRouter
		}
	case keyEnter:
		// Shift+Enter (and Alt+Enter) insert a newline; a bare Enter submits. Shift+Enter
		// is now distinguishable thanks to bubbletea v2's kitty keyboard disambiguation;
		// Ctrl+J below stays as a fallback for terminals without kitty support.
		if (k.Shift || k.Alt) && m.inputMode != ModeFilter {
			m.appendRunes("\n")
			return m, nil
		}
		return m.submit()
	case keyCtrlJ:
		// Reliable "newline without sending" (Ctrl+J is the literal LF key) for the
		// single-line filter excluded.
		if m.inputMode != ModeFilter {
			m.appendRunes("\n")
		}
		return m, nil
	case keyBackspace:
		if k.Alt {
			m.deleteWordBack()
		} else {
			m.backspace()
		}
		m.syncFilter()
	case keyLeft:
		if m.inputCursor > 0 {
			m.inputCursor--
		}
	case keyRight:
		if m.inputCursor < len(m.inputBuffer) {
			m.inputCursor++
		}
	case keyUp:
		if m.inputMode == ModeFilter {
			m.listNav(-1)
		} else {
			m.historyPrev()
		}
	case keyDown:
		if m.inputMode == ModeFilter {
			m.listNav(1)
		} else {
			m.historyNext()
		}
	case keyRunes, keySpace:
		// opencode-style slash commands: `/` as the first char of a chat line opens the
		// command palette inline instead of typing a literal slash. Mid-line `/` is literal.
		if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" {
			m.editMode = ModeNormal
			m.clearInput()
			m.openPalette()
			return m, nil
		}
		// `@` at a token boundary opens the workspace file picker; the selection inserts
		// `@path`. Mid-word `@` (e.g. an email) is literal.
		if m.inputMode == ModeRouter && string(k.Runes) == "@" && m.atTokenBoundary() {
			m.openFiles()
			return m, nil
		}
		m.appendRunes(string(k.Runes))
		m.syncFilter()
	}
	return m, nil
}

// openPalette opens the command palette with a cleared filter. Shared by the `p`
// normal-mode key and the inline `/` trigger in the chat input.
func (m *Model) openPalette() {
	m.overlay = OverlayPalette
	m.paletteFilter = ""
	m.paletteIndex = 0
}

// atTokenBoundary reports whether the input cursor sits at the start of a word (start of
// line or just after a space) — where `@` should begin a file reference rather than be literal.
func (m Model) atTokenBoundary() bool {
	if m.inputCursor <= 0 {
		return true
	}
	if m.inputCursor <= len(m.inputBuffer) {
		return m.inputBuffer[m.inputCursor-1] == ' '
	}
	return false
}

// openFiles opens the `@` file picker for the selected session and requests its workspace
// paths (cached per session). Editing mode is left in Insert so closing returns to typing.
func (m *Model) openFiles() {
	m.overlay = OverlayFiles
	m.fileFilter = ""
	m.fileIndex = 0
	if m.filesFor != m.selectedID {
		m.files = nil
		m.filesLoading = true
	}
	m.client.Send(protocol.ListFiles(m.selectedID))
}

// insertFileRef inserts `@path ` at the input cursor (the `@` was consumed to open the picker).
func (m *Model) insertFileRef(path string) {
	ref := "@" + path + " "
	c := m.inputCursor
	if c > len(m.inputBuffer) {
		c = len(m.inputBuffer)
	}
	m.inputBuffer = m.inputBuffer[:c] + ref + m.inputBuffer[c:]
	m.inputCursor = c + len(ref)
	m.fileFilter = ""
}

// filteredFiles narrows the workspace paths by the typed filter (case-insensitive substring),
// capped for render.
func (m Model) filteredFiles() []string {
	if m.fileFilter == "" {
		if len(m.files) > fileListMax {
			return m.files[:fileListMax]
		}
		return m.files
	}
	f := strings.ToLower(m.fileFilter)
	out := make([]string, 0, fileListMax)
	for _, p := range m.files {
		if strings.Contains(strings.ToLower(p), f) {
			out = append(out, p)
			if len(out) >= fileListMax {
				break
			}
		}
	}
	return out
}

// beginIntent stashes the chosen workflow and switches to a text-entry prompt for the
// freeform request. Submitting sends StartSession(id, intent); an empty line starts with no
// intent (fixed-task workflows). Esc cancels.
func (m *Model) beginIntent(wf Workflow) {
	m.wfPendingID = wf.ID
	m.wfPendingName = wf.ID
	m.wfVisible = false
	m.wfIndex = -1
	m.clearInput()
	m.editMode = ModeInsert
	m.inputMode = ModeIntent
}

func (m *Model) enterInsert(mode InputMode) {
	m.editMode = ModeInsert
	m.inputMode = mode
	if mode == ModeFilter {
		m.inputBuffer = m.filter
		m.inputCursor = len(m.inputBuffer)
	}
}

// syncFilter keeps the live session filter in step with the input buffer.
func (m *Model) syncFilter() {
	if m.inputMode == ModeFilter {
		m.filter = m.inputBuffer
	}
}

func (m Model) normalEnter() (tea.Model, tea.Cmd) {
	if m.displayState() != StateIdle {
		return m, nil
	}
	if m.wfVisible && m.wfIndex >= 0 && m.wfIndex < len(m.workflows) {
		m.beginIntent(m.workflows[m.wfIndex])
		return m, nil
	}
	if m.selectedID != "" {
		m.sessionEntered = true
	}
	return m, nil
}

// autoApprove (A) opens the scope picker so the operator chooses how broadly to stop being
// asked: this session (the old behaviour), this project, or globally. The actual grant +
// approval are sent by applyGrantScope once a scope is picked.
func (m Model) autoApprove() (tea.Model, tea.Cmd) {
	s := m.session(m.selectedID)
	if s == nil || s.Pending == nil {
		return m, nil
	}
	m.grantFor = s.Pending
	m.grantScopeIndex = 0
	m.overlay = OverlayGrantScope
	return m, nil
}

func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) {
	// Config overlay owns all its keys (esc cancels an in-progress edit rather than closing).
	if m.overlay == OverlayConfig {
		return m.handleConfigKey(k)
	}
	// Project/operator profile overlays own all their keys, same reason as config.
	if m.overlay == OverlayProjectProfile {
		return m.handleProjectProfileKey(k)
	}
	if m.overlay == OverlayOperatorProfile {
		return m.handleOperatorProfileKey(k)
	}
	// Sessions overlay owns all its keys: enter resumes (emits a cmd), so it can't
	// share the generic no-cmd esc path below.
	if m.overlay == OverlaySessions {
		return m.handleSessionsKey(k)
	}
	// Tasks overlay owns all its keys: r refresh emits a cmd and enter toggles a detail
	// pane, so it can't share the generic no-cmd esc path below.
	if m.overlay == OverlayTasks {
		return m.handleTasksKey(k)
	}
	if k.Type == keyEsc {
		// In the event inspector, esc first stops an in-progress filter edit (keeping the
		// query) rather than closing the overlay.
		if m.overlay == OverlayEventInspector && m.eventFilterTyping {
			m.eventFilterTyping = false
			return m, nil
		}
		m.overlay = OverlayNone
		return m, nil
	}
	switch m.overlay {
	case OverlayPalette:
		return m.handlePaletteKey(k)
	case OverlayDiff:
		// A long diff/preview is unreadable one line at a time, so the fullscreen
		// viewer pages like the OUTPUT transcript: ↑↓/jk by line, PgUp/PgDn by a
		// full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds.
		page := m.diffBodyHeight()
		switch {
		case k.Type == keyUp || runeIs(k, "k"):
			m.scrollDiff(-1)
		case k.Type == keyDown || runeIs(k, "j"):
			m.scrollDiff(1)
		case k.Type == keyPgUp:
			m.scrollDiff(-page)
		case k.Type == keyPgDown:
			m.scrollDiff(page)
		case k.Type == keyCtrlU:
			m.scrollDiff(-page / 2)
		case k.Type == keyCtrlD:
			m.scrollDiff(page / 2)
		case runeIs(k, "g"):
			m.diffScrollOffset = 0
		case runeIs(k, "G"):
			m.diffScrollOffset = m.diffMaxScroll()
		case k.Type == keyCtrlX:
			m.overlay = OverlayNone
		}
	case OverlayEventInspector:
		evs := m.currentEvents()
		if m.eventFilterTyping {
			switch {
			case k.Type == keyEnter:
				m.eventFilterTyping = false
			case k.Type == keyBackspace:
				if n := len(m.eventFilter); n > 0 {
					m.eventFilter = m.eventFilter[:n-1]
					m.overlayEventIdx = 0
				}
			case k.Type == keyRunes || k.Type == keySpace:
				m.eventFilter += string(k.Runes)
				m.overlayEventIdx = 0
			}
			return m, nil
		}
		switch {
		case runeIs(k, "/"):
			m.eventFilterTyping = true
		case runeIs(k, "c"):
			m.eventFilter = ""
			m.overlayEventIdx = 0
		case k.Type == keyUp || runeIs(k, "k"):
			if m.overlayEventIdx > 0 {
				m.overlayEventIdx--
			}
		case k.Type == keyDown || runeIs(k, "j"):
			if m.overlayEventIdx < len(evs)-1 {
				m.overlayEventIdx++
			}
		}
	case OverlayHelp:
		// The cheat-sheet scrolls when it's taller than the screen; any non-scroll key
		// dismisses it (esc handled above), keeping the quick-glance feel.
		page := m.modalBodyRows()
		switch {
		case k.Type == keyUp || runeIs(k, "k"):
			m.scrollModal(-1)
		case k.Type == keyDown || runeIs(k, "j"):
			m.scrollModal(1)
		case k.Type == keyPgUp:
			m.scrollModal(-page)
		case k.Type == keyPgDown:
			m.scrollModal(page)
		case k.Type == keyCtrlU:
			m.scrollModal(-page / 2)
		case k.Type == keyCtrlD:
			m.scrollModal(page / 2)
		case runeIs(k, "g"):
			m.modalScroll = 0
		case runeIs(k, "G"):
			m.modalScroll = m.scrollableModalMax(len(m.activeModalBody()))
		default:
			m.overlay = OverlayNone
		}
	case OverlayToolPalette:
		if runeIs(k, "t") {
			m.overlay = OverlayNone
		}
	case OverlayArtifacts:
		switch {
		case k.Type == keyUp || runeIs(k, "k"):
			if m.artifactsIndex > 0 {
				m.artifactsIndex--
				m.artifactScroll = 0
			}
		case k.Type == keyDown || runeIs(k, "j"):
			if m.artifactsIndex < len(m.artifacts)-1 {
				m.artifactsIndex++
				m.artifactScroll = 0
			}
		case k.Type == keyPgUp:
			m.scrollArtifact(-m.artifactContentBodyH())
		case k.Type == keyPgDown:
			m.scrollArtifact(m.artifactContentBodyH())
		case k.Type == keyCtrlU:
			m.scrollArtifact(-m.artifactContentBodyH() / 2)
		case k.Type == keyCtrlD:
			m.scrollArtifact(m.artifactContentBodyH() / 2)
		case runeIs(k, "g"):
			m.artifactScroll = 0
		case runeIs(k, "G"):
			m.artifactScroll = m.artifactContentMaxScroll(m.modalWidth() - 6)
		case runeIs(k, "v"):
			m.overlay = OverlayNone
		}
	case OverlayModels:
		switch {
		case k.Type == keyUp || runeIs(k, "k"):
			if m.modelsIndex > 0 {
				m.modelsIndex--
			}
		case k.Type == keyDown || runeIs(k, "j"):
			if m.modelsIndex < len(m.availableModels)-1 {
				m.modelsIndex++
			}
		case k.Type == keyEnter:
			if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) {
				m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex]))
				m.overlay = OverlayNone
			}
		case runeIs(k, "c"):
			m.client.Send(protocol.ClearModelPin())
			m.overlay = OverlayNone
		case runeIs(k, "m"):
			m.overlay = OverlayNone
		}
	case OverlayStats:
		page := m.modalBodyRows()
		switch {
		case k.Type == keyUp || runeIs(k, "k"):
			m.scrollModal(-1)
		case k.Type == keyDown || runeIs(k, "j"):
			m.scrollModal(1)
		case k.Type == keyPgUp:
			m.scrollModal(-page)
		case k.Type == keyPgDown:
			m.scrollModal(page)
		case k.Type == keyCtrlU:
			m.scrollModal(-page / 2)
		case k.Type == keyCtrlD:
			m.scrollModal(page / 2)
		case runeIs(k, "g"):
			m.modalScroll = 0
		case runeIs(k, "G"):
			m.modalScroll = m.scrollableModalMax(len(m.activeModalBody()))
		case runeIs(k, "S"):
			m.overlay = OverlayNone
		}
	case OverlayHealth:
		if runeIs(k, "H") {
			m.overlay = OverlayNone
		}
	case OverlayPlan:
		if k.Type == keyCtrlP || runeIs(k, "p") {
			m.overlay = OverlayNone
		}
	case OverlayIdeas:
		switch {
		case k.Type == keyUp || runeIs(k, "k"):
			if m.ideasIndex > 0 {
				m.ideasIndex--
			}
		case k.Type == keyDown || runeIs(k, "j"):
			if m.ideasIndex < len(m.ideas)-1 {
				m.ideasIndex++
			}
		case runeIs(k, "x") || runeIs(k, "d"):
			if m.ideasIndex >= 0 && m.ideasIndex < len(m.ideas) {
				m.client.Send(protocol.DiscardIdea(m.ideas[m.ideasIndex].IdeaID))
				// Optimistic removal; the server's fresh idea.list reply reconciles.
				m.ideas = append(m.ideas[:m.ideasIndex], m.ideas[m.ideasIndex+1:]...)
				if m.ideasIndex >= len(m.ideas) && m.ideasIndex > 0 {
					m.ideasIndex--
				}
			}
		case runeIs(k, "I"):
			m.overlay = OverlayNone
		}
	case OverlayFiles:
		files := m.filteredFiles()
		switch {
		case k.Type == keyUp:
			if m.fileIndex > 0 {
				m.fileIndex--
			}
		case k.Type == keyDown:
			if m.fileIndex < len(files)-1 {
				m.fileIndex++
			}
		case k.Type == keyEnter:
			if m.fileIndex >= 0 && m.fileIndex < len(files) {
				m.insertFileRef(files[m.fileIndex])
			}
			m.overlay = OverlayNone // back to typing (still in insert mode)
		case k.Type == keyBackspace:
			if n := len(m.fileFilter); n > 0 {
				m.fileFilter = m.fileFilter[:n-1]
				m.fileIndex = 0
			}
		case k.Type == keyRunes || k.Type == keySpace:
			m.fileFilter += string(k.Runes)
			m.fileIndex = 0
		}
	case OverlayStatusbar:
		switch {
		case k.Type == keyUp || runeIs(k, "k"):
			if m.sbIndex > 0 {
				m.sbIndex--
			}
		case k.Type == keyDown || runeIs(k, "j"):
			if m.sbIndex < len(statusSegments)-1 {
				m.sbIndex++
			}
		case k.Type == keySpace || k.Type == keyEnter || runeIs(k, "x"):
			if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) {
				m.toggleStatusSegment(statusSegments[m.sbIndex].id)
			}
		case runeIs(k, "g"):
			m.overlay = OverlayNone
		}
	case OverlayGrants:
		switch {
		case k.Type == keyUp || runeIs(k, "k"):
			if m.grantIndex > 0 {
				m.grantIndex--
			}
		case k.Type == keyDown || runeIs(k, "j"):
			if m.grantIndex < len(m.grants)-1 {
				m.grantIndex++
			}
		case k.Type == keyEnter || runeIs(k, "x"):
			if m.grantIndex >= 0 && m.grantIndex < len(m.grants) {
				g := m.grants[m.grantIndex]
				m.client.Send(protocol.RevokeGrant(g.GrantID))
				m.appendAction(m.selectedID, "⊟", "revoked "+g.ToolName+" · "+strings.ToLower(g.Scope))
				// The server replies with a fresh grant.list, which refreshes m.grants.
			}
		case runeIs(k, "G"):
			m.overlay = OverlayNone
		}
	case OverlayGrantScope:
		return m.handleGrantScopeKey(k)
	}
	return m, nil
}

// grantScopeOption is one breadth choice in the A (approve-always) scope picker. wire is the
// CreateGrant scope name: SESSION is per-session (dies with it), PROJECT covers any session on
// the same repo, GLOBAL every session on this machine.
type grantScopeOption struct{ key, wire, title, hint string }

func grantScopeOptions() []grantScopeOption {
	return []grantScopeOption{
		{"s", "SESSION", "this session", "until this session ends"},
		{"p", "PROJECT", "this project", "any session on this repo"},
		{"g", "GLOBAL", "everywhere", "any session on this machine"},
	}
}

// handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter
// confirms the highlighted scope. esc (handled upstream) cancels without granting.
func (m Model) handleGrantScopeKey(k keyMsg) (tea.Model, tea.Cmd) {
	opts := grantScopeOptions()
	switch {
	case k.Type == keyUp || runeIs(k, "k"):
		if m.grantScopeIndex > 0 {
			m.grantScopeIndex--
		}
	case k.Type == keyDown || runeIs(k, "j"):
		if m.grantScopeIndex < len(opts)-1 {
			m.grantScopeIndex++
		}
	case runeIs(k, "s"):
		m.grantScopeIndex = 0
		return m.applyGrantScope()
	case runeIs(k, "p"):
		m.grantScopeIndex = 1
		return m.applyGrantScope()
	case runeIs(k, "g"):
		m.grantScopeIndex = 2
		return m.applyGrantScope()
	case k.Type == keyEnter:
		return m.applyGrantScope()
	}
	return m, nil
}

// applyGrantScope creates the chosen-scope grant for the pending tool, approves the current
// call, and closes the picker. The SESSION choice is identical to the old A behaviour.
func (m Model) applyGrantScope() (tea.Model, tea.Cmd) {
	p := m.grantFor
	if p == nil {
		m.overlay = OverlayNone
		return m, nil
	}
	opts := grantScopeOptions()
	idx := m.grantScopeIndex
	if idx < 0 || idx >= len(opts) {
		idx = 0
	}
	scope := opts[idx].wire
	m.client.Send(protocol.CreateGrant(p.SessionID, scope, []string{p.Tier}, "granted via TUI ("+scope+")", p.ToolName))
	m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil))
	m.appendAction(p.SessionID, "⊞", "granted "+p.ToolName+" · "+strings.ToLower(scope))
	if s := m.session(p.SessionID); s != nil {
		s.removeApproval(p.RequestID)
	}
	m.grantFor = nil
	m.overlay = OverlayNone
	m.steerBuffer = ""
	m.steering = false
	m.approvalArmed = false
	m.approvalDismissed = false
	return m, nil
}

// openGrants opens the standing-grants viewer and requests the active PROJECT/GLOBAL grants.
// The board is global (not session-scoped), so it opens from anywhere.
func (m *Model) openGrants() {
	m.overlay = OverlayGrants
	m.grantIndex = 0
	m.grantsLoading = true
	m.client.Send(protocol.ListGrants())
}

// toggleStatusSegment flips a status-bar segment's visibility and persists the change.
func (m *Model) toggleStatusSegment(id string) {
	if m.sbHidden == nil {
		m.sbHidden = map[string]bool{}
	}
	if m.sbHidden[id] {
		delete(m.sbHidden, id)
	} else {
		m.sbHidden[id] = true
	}
	saveStatusbarHidden(m.sbHidden)
}

// sbShow reports whether a status-bar segment is currently visible.
func (m Model) sbShow(id string) bool { return !m.sbHidden[id] }

// openArtifacts opens the artifact viewer for the selected session and requests its
// artifacts from the server. No-op when no session is selected.
func (m *Model) openArtifacts() {
	if m.selectedID == "" {
		return
	}
	m.overlay = OverlayArtifacts
	m.artifactsIndex = 0
	m.artifactScroll = 0
	// Reuse a cached listing only if it's for this session; otherwise show a loading state.
	if m.artifactsFor != m.selectedID {
		m.artifacts = nil
		m.artifactsLoading = true
	}
	m.client.Send(protocol.ListArtifacts(m.selectedID))
}

// openIdeas opens the cross-session idea board and requests it from the server. The board
// is global (not session-scoped), so it opens from anywhere — including the idle list.
func (m *Model) openIdeas() {
	m.overlay = OverlayIdeas
	m.ideasIndex = 0
	if m.ideas == nil {
		m.ideasLoading = true
	}
	m.client.Send(protocol.ListIdeas())
}

// openStats opens the session-stats pane for the selected session and requests its
// metrics from the server. No-op when no session is selected.
func (m *Model) openStats() {
	if m.selectedID == "" {
		return
	}
	m.overlay = OverlayStats
	m.modalScroll = 0
	// Reuse a cached report only if it's for this session; otherwise show a loading state.
	if m.statsFor != m.selectedID {
		m.stats = nil
		m.statsLoading = true
	}
	m.client.Send(protocol.GetSessionStats(m.selectedID))
}

// openHealth opens the system health-checks pane and fetches the current report.
// Health is system-scoped (not session-scoped), so it opens from anywhere.
func (m *Model) openHealth() {
	m.overlay = OverlayHealth
	// Always re-fetch and show a loading state: health changes out from under us, so a
	// cached report would be stale (TUI spec §2 — render from the fetched report).
	m.health = nil
	m.healthLoading = true
	m.client.Send(protocol.GetHealthChecks())
}

// openModelsOverlay opens the model picker, pre-selecting the resident model.
func (m *Model) openModelsOverlay() {
	m.overlay = OverlayModels
	m.modelsIndex = 0
	for i, id := range m.availableModels {
		if id == m.currentModel {
			m.modelsIndex = i
			break
		}
	}
}

func runeIs(k keyMsg, s string) bool {
	return k.Type == keyRunes && string(k.Runes) == s
}

func (m Model) handlePaletteKey(k keyMsg) (tea.Model, tea.Cmd) {
	cmds := m.filteredPalette()
	switch k.Type {
	case keyUp:
		if m.paletteIndex > 0 {
			m.paletteIndex--
		}
	case keyDown:
		if m.paletteIndex < len(cmds)-1 {
			m.paletteIndex++
		}
	case keyEnter:
		if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) {
			return m.execPalette(cmds[m.paletteIndex].id)
		}
	case keyBackspace:
		if n := len(m.paletteFilter); n > 0 {
			m.paletteFilter = m.paletteFilter[:n-1]
			m.paletteIndex = 0
		}
	case keyRunes, keySpace:
		m.paletteFilter += string(k.Runes)
		m.paletteIndex = 0
	}
	return m, nil
}

// paletteCmd is one command-palette row. key is the bare-key shortcut that runs the same
// action directly (shown in the palette so the shortcuts are discoverable); empty when the
// command has no direct key.
type paletteCmd struct{ id, key, title, hint string }

func paletteCommands() []paletteCmd {
	return []paletteCmd{
		{"workflows", "w", "start workflow", "open the workflow picker"},
		{"tools", "t", "tool palette", "tools for the current stage"},
		{"models", "m", "swap model", "pick / pin the local model"},
		{"events", "e", "event inspector", "browse the event stream"},
		{"artifacts", "v", "view artifacts", "browse this session's artifacts"},
		{"stats", "S", "session stats", "metrics for the selected session"},
		{"health", "H", "health checks", "system health probe status"},
		{"sessions", "R", "resume session", "browse / resume recent sessions"},
		{"tasks", "T", "task board", "browse tasks across projects"},
		{"config", "g", "edit config", "view / change correx settings"},
		{"grants", "G", "grants", "view / revoke standing grants"},
		{"statusbar", "", "status bar", "show / hide status-bar segments"},
		{"rail", "", "idle rail", "show / hide the idle status + keys rail"},
		{"panel", "d", "right panel", "in-session: events → changes → off"},
		{"actions", "", "inline actions", "show / hide tool & action rows in output"},
		{"thinking", "", "thinking blocks", "show / hide model reasoning in output"},
		{"help", "?", "help", "keybinding cheat-sheet"},
		{"mode", "s", "toggle mode", "switch chat / steering"},
		{"cancel", "c", "cancel session", "stop the selected session"},
		{"back", "l", "back to list", "leave the current session"},
		{"quit", "q", "quit", "exit correx"},
	}
}

func (m Model) filteredPalette() []paletteCmd {
	f := strings.ToLower(m.paletteFilter)
	if f == "" {
		return paletteCommands()
	}
	var out []paletteCmd
	for _, c := range paletteCommands() {
		if strings.Contains(strings.ToLower(c.key+" "+c.title+" "+c.hint), f) {
			out = append(out, c)
		}
	}
	return out
}

func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
	m.overlay = OverlayNone
	switch id {
	case "workflows":
		m.wfVisible = true
		m.wfIndex = 0
	case "tools":
		m.overlay = OverlayToolPalette
	case "models":
		m.openModelsOverlay()
	case "events":
		m.openEventInspector()
	case "artifacts":
		m.openArtifacts()
	case "stats":
		m.openStats()
	case "health":
		m.openHealth()
	case "sessions":
		return m, m.openSessions()
	case "tasks":
		return m, m.openTasks()
	case "config":
		m.openConfig()
	case "grants":
		m.openGrants()
	case "statusbar":
		m.overlay = OverlayStatusbar
		m.sbIndex = 0
	case "rail":
		m.railHidden = !m.railHidden
	case "panel":
		m.cycleRightPanel()
	case "actions":
		m.toggleInlineActions()
	case "thinking":
		m.thinkingShown = !m.thinkingShown
		saveThinkingShown(m.thinkingShown)
	case "help":
		m.overlay = OverlayHelp
		m.modalScroll = 0
	case "mode":
		m.cycleChatMode()
	case "cancel":
		if m.selectedID != "" {
			m.client.Send(protocol.CancelSession(m.selectedID))
		}
	case "back":
		m.sessionEntered = false
		m.approvalDismissed = false
	case "quit":
		m.quitting = true
		return m, tea.Quit
	}
	return m, nil
}

// --- input editing ---

// handlePaste inserts pasted clipboard text into the focused composer (steering note or the
// main input). Newlines are preserved — the input buffer is multi-line and editorLines wraps it.
func (m Model) handlePaste(s string) tea.Model {
	switch {
	case s == "":
	case m.steering:
		m.steerBuffer += s
	case m.editMode == ModeInsert && m.overlay == OverlayNone:
		m.appendRunes(s)
		m.syncFilter()
	}
	return m
}

func (m *Model) appendRunes(s string) {
	b := m.inputBuffer
	c := m.inputCursor
	if c > len(b) {
		c = len(b)
	}
	m.inputBuffer = b[:c] + s + b[c:]
	m.inputCursor = c + len(s)
}

func (m *Model) backspace() {
	if m.inputCursor == 0 || len(m.inputBuffer) == 0 {
		return
	}
	c := m.inputCursor
	m.inputBuffer = m.inputBuffer[:c-1] + m.inputBuffer[c:]
	m.inputCursor = c - 1
}

// deleteWordBack removes the word before the cursor (Alt+Backspace): first any run of
// spaces, then the run of non-space chars, all left of the cursor.
func (m *Model) deleteWordBack() {
	c := m.inputCursor
	if c > len(m.inputBuffer) {
		c = len(m.inputBuffer)
	}
	i := c
	for i > 0 && m.inputBuffer[i-1] == ' ' {
		i--
	}
	for i > 0 && m.inputBuffer[i-1] != ' ' {
		i--
	}
	m.inputBuffer = m.inputBuffer[:i] + m.inputBuffer[c:]
	m.inputCursor = i
}

// cycleLauncherWf advances the idle launch target: 0 = chat, 1..N = workflows[i-1], wrapping.
func (m *Model) cycleLauncherWf() {
	m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1)
}

// cycleLauncherWfBack is cycleLauncherWf in reverse, for Shift+Tab.
func (m *Model) cycleLauncherWfBack() {
	n := len(m.workflows) + 1
	m.launcherWf = (m.launcherWf - 1 + n) % n
}

// cycleRightPanel advances the in-session right panel: events → changes → off → events.
func (m *Model) cycleRightPanel() {
	m.rightPanel = (m.rightPanel + 1) % 3
}

func (m *Model) cycleChatMode() {
	if m.chatMode == ChatModeChat {
		m.chatMode = ChatModeSteering
	} else {
		m.chatMode = ChatModeChat
	}
}

func (m *Model) clearInput() {
	m.inputBuffer = ""
	m.inputCursor = 0
	m.historyIndex = -1
}

// --- submit (IDLE -> StartChat / StartSession, IN_SESSION -> ChatInput) ---

func (m Model) submit() (tea.Model, tea.Cmd) {
	ds := m.displayState()
	if m.inputMode == ModeFilter {
		m.filter = m.inputBuffer
		m.clearInput()
		m.inputMode = ModeRouter
		m.editMode = ModeNormal
		return m, nil
	}
	text := strings.TrimSpace(m.inputBuffer)

	switch ds {
	case StateIdle:
		// Intent line for a workflow being started.
		if m.wfPendingID != "" {
			id := m.wfPendingID
			m.recordInput(text)
			m.client.Send(protocol.StartSession(id, text))
			m.wfPendingID = ""
			m.wfPendingName = ""
			m.clearInput()
			m.inputMode = ModeRouter
			m.editMode = ModeNormal
			m.pendingWorkflowFocus = true
			return m, nil
		}
		// Workflow picker selection → prompt for the request first.
		if m.wfIndex >= 0 && m.wfIndex < len(m.workflows) {
			m.beginIntent(m.workflows[m.wfIndex])
			return m, nil
		}
		// Launcher: a workflow is Tab-selected → start it with the typed text as its brief
		// (empty brief is allowed, like the intent flow). The server makes the session; we
		// focus it when its events arrive (pendingWorkflowFocus).
		if m.launcherWf > 0 && m.launcherWf <= len(m.workflows) {
			wf := m.workflows[m.launcherWf-1]
			m.recordInput(text)
			m.client.Send(protocol.StartSession(wf.ID, text))
			m.clearInput()
			m.inputMode = ModeRouter
			m.editMode = ModeNormal
			m.pendingWorkflowFocus = true
			m.launcherWf = 0
			return m, nil
		}
		// Entering an already-selected session (blank submit).
		if text == "" && m.selectedID != "" {
			m.sessionEntered = true
			m.clearInput()
			return m, nil
		}
		if text == "" {
			return m, nil
		}
		// New chat session. The client owns the id, so create the entry locally and
		// focus it; the USER/ROUTER turns arrive as chat.turn events (no optimistic echo).
		id := newSessionID()
		s := m.ensureSession(id)
		s.WorkflowID = "chat"
		s.Name = "chat"
		m.selectedID = id
		m.sessionEntered = true
		m.recordInput(text)
		m.client.Send(protocol.StartChatSession(id, text))
		m.clearInput()
		return m, nil

	case StateInSession:
		if text == "" || m.selectedID == "" {
			return m, nil
		}
		sid := m.selectedID
		m.recordInput(text)
		// No optimistic echo — the USER turn arrives as a chat.turn event.
		m.client.Send(protocol.ChatInput(sid, text, m.chatMode))
		m.clearInput()
		return m, nil
	}
	return m, nil
}

func (m Model) decide(decision string) (tea.Model, tea.Cmd) {
	s := m.session(m.selectedID)
	if s == nil || s.Pending == nil {
		return m, nil
	}
	var note *string
	if strings.TrimSpace(m.steerBuffer) != "" {
		n := m.steerBuffer
		note = &n
	}
	m.client.Send(protocol.ApprovalResponse(s.Pending.RequestID, decision, note))
	s.removeApproval(s.Pending.RequestID)
	m.steerBuffer = ""
	m.steering = false
	m.editMode = ModeNormal
	m.approvalArmed = false
	m.approvalDismissed = false
	return m, nil
}

func (m Model) submitApproval() (tea.Model, tea.Cmd) {
	// Enter approves by default; steering note (if any) rides along.
	return m.decide("APPROVE")
}

// --- navigation (list / history) ---

func (m *Model) navUp() {
	if m.displayState() == StateInSession && m.inputMode == ModeRouter {
		m.historyPrev()
		return
	}
	if m.wfVisible {
		m.wfNav(-1)
		return
	}
	if m.displayState() == StateIdle {
		m.listNav(-1)
	}
}

func (m *Model) navDown() {
	if m.displayState() == StateInSession && m.inputMode == ModeRouter {
		m.historyNext()
		return
	}
	if m.wfVisible {
		m.wfNav(1)
		return
	}
	if m.displayState() == StateIdle {
		m.listNav(1)
	}
}

func (m *Model) wfNav(dir int) {
	n := len(m.workflows)
	if n == 0 {
		return
	}
	m.wfIndex = (m.wfIndex + dir + n) % n
}

func (m *Model) listNav(dir int) {
	list := m.filteredSessions()
	if len(list) == 0 {
		return
	}
	idx := -1
	for i, s := range list {
		if s.ID == m.selectedID {
			idx = i
			break
		}
	}
	// No current selection (e.g. nothing entered yet): start at the first
	// session rather than wrapping off the end into an arbitrary entry.
	if idx < 0 {
		m.selectedID = list[0].ID
		m.wfIndex = -1
		return
	}
	n := idx + dir
	if n < 0 {
		n = len(list) - 1
	}
	if n >= len(list) {
		n = 0
	}
	if m.selectedID != list[n].ID {
		m.bgUpdates = 0
		m.transcriptSel = -1 // a different session's transcript: drop the stale selection
	}
	m.selectedID = list[n].ID
	m.wfIndex = -1
}

// recordInput appends a submitted line to the global history ring (shared across
// free-chat, the workflow-intent line, and in-session chat), deduping the immediate
// repeat and capping the ring. Called from every submit path so ↑/↓ recalls anything
// you've sent, in any input context.
func (m *Model) recordInput(text string) {
	text = strings.TrimSpace(text)
	if text == "" {
		return
	}
	if n := len(m.inputHistory); n > 0 && m.inputHistory[n-1] == text {
		return
	}
	m.inputHistory = append(m.inputHistory, text)
	if len(m.inputHistory) > 100 {
		m.inputHistory = m.inputHistory[len(m.inputHistory)-100:]
	}
}

func (m *Model) historyPrev() {
	hist := m.inputHistory
	if len(hist) == 0 {
		return
	}
	switch m.historyIndex {
	case -1:
		m.savedBuffer = m.inputBuffer
		m.historyIndex = len(hist) - 1
	case 0:
		return
	default:
		m.historyIndex--
	}
	m.inputBuffer = hist[m.historyIndex]
	m.inputCursor = len(m.inputBuffer)
}

func (m *Model) historyNext() {
	hist := m.inputHistory
	switch {
	case m.historyIndex == -1:
		return
	case m.historyIndex == len(hist)-1:
		m.historyIndex = -1
		m.inputBuffer = m.savedBuffer
	default:
		m.historyIndex++
		m.inputBuffer = hist[m.historyIndex]
	}
	m.inputCursor = len(m.inputBuffer)
}

func (m *Model) appendRouter(sid string, e RouterEntry) {
	m.routerMessages[sid] = append(m.routerMessages[sid], e)
}

// appendAction adds an inline "external feedback" row (a tool call, write/edit, approval, or
// grant) to a session's OUTPUT transcript, interleaved with chat turns by arrival order. The
// rows are always recorded; rendering is gated on actionsHidden so toggling reveals history.
func (m *Model) appendAction(sid, icon, text string) {
	if sid == "" {
		return
	}
	m.routerMessages[sid] = append(m.routerMessages[sid], RouterEntry{Role: "action", Icon: icon, Content: text})
}

// toggleInlineActions flips the inline-action-rows visibility and persists it.
func (m *Model) toggleInlineActions() {
	m.actionsHidden = !m.actionsHidden
	saveInlineActionsHidden(m.actionsHidden)
}

// scrollOutput moves the OUTPUT transcript by delta rows (positive = up / back through history),
// clamped to the transcript height against the same geometry the renderer uses. A 0 delta or a
// transcript shorter than the panel is a no-op (stays tail-following).
func (m *Model) scrollOutput(delta int) {
	if delta == 0 {
		return
	}
	w, h := m.outputViewport()
	rows, _ := m.buildTranscriptRows(w)
	max := len(rows) - h
	if max < 0 {
		max = 0
	}
	m.outputScroll += delta
	if m.outputScroll < 0 {
		m.outputScroll = 0
	}
	if m.outputScroll > max {
		m.outputScroll = max
	}
}

// outputViewport mirrors View/renderMain to give the OUTPUT transcript's inner (w, h), so the
// scroll handler clamps against the exact geometry the renderer windows to.
func (m Model) outputViewport() (w, h int) {
	bottomH := inputH
	if m.displayState() == StateApproval {
		bottomH = m.approvalBandHeight()
	}
	mainH := m.height - statusH - footerH - bottomH
	if mainH < 3 {
		mainH = 3
	}
	if m.narrow() {
		outH := mainH * 55 / 100
		if outH < 3 {
			outH = 3
		}
		evH := mainH - outH
		if evH < 3 {
			evH = 3
			outH = mainH - evH
		}
		return m.width - 4, outH - 2
	}
	leftW := m.width * 63 / 100
	return leftW - 4, mainH - 2
}

// outputViewportPage is the PgUp/PgDn step (a panel minus one row of overlap for context).
func (m Model) outputViewportPage() int {
	_, h := m.outputViewport()
	if h < 2 {
		return 1
	}
	return h - 1
}

// transcriptNavUser moves the transcript selection to the previous (dir<0) or next
// (dir>0) USER message — "go back to my previous message". From no selection, ctrl+↑
// grabs the most recent user message; ctrl+↓ past the last one drops back to tail-follow.
// Messages only ever append, so a stored index stays pointing at the same message.
func (m *Model) transcriptNavUser(dir int) {
	msgs := m.routerMessages[m.selectedID]
	n := len(msgs)
	if n == 0 {
		return
	}
	if dir < 0 {
		start := m.transcriptSel
		if start < 0 || start > n {
			start = n // searching upward from the bottom
		}
		for i := start - 1; i >= 0; i-- {
			if msgs[i].Role == "user" {
				m.transcriptSel = i
				return
			}
		}
		return // already at the oldest user message
	}
	if m.transcriptSel < 0 {
		return // already tailing the bottom
	}
	for i := m.transcriptSel + 1; i < n; i++ {
		if msgs[i].Role == "user" {
			m.transcriptSel = i
			return
		}
	}
	m.transcriptSel = -1 // past the last user message → resume tail-follow
}

// copyMessage returns a command that copies the selected message — or, with no
// selection, the latest assistant/router turn — to the system clipboard via OSC52.
// Returns nil when there is nothing to copy.
func (m Model) copyMessage() tea.Cmd {
	if text := m.copyText(); strings.TrimSpace(text) != "" {
		return osc52Copy(text)
	}
	return nil
}

// copyText is the content `y` will yank: the selected message, or — with no selection —
// the most recent user/router turn. Empty when there is nothing to copy.
func (m Model) copyText() string {
	msgs := m.routerMessages[m.selectedID]
	if len(msgs) == 0 {
		return ""
	}
	if m.transcriptSel >= 0 && m.transcriptSel < len(msgs) {
		return msgs[m.transcriptSel].Content
	}
	for i := len(msgs) - 1; i >= 0; i-- {
		if r := msgs[i].Role; r == "router" || r == "user" {
			return msgs[i].Content
		}
	}
	return ""
}

// osc52Copy writes the clipboard sequence to stderr (separate from the alt-screen on
// stdout). Wrapped for tmux passthrough when running inside tmux so the copy still
// reaches the outer terminal (e.g. over SSH / Termius).
func osc52Copy(text string) tea.Cmd {
	return func() tea.Msg {
		seq := osc52.New(text)
		if os.Getenv("TMUX") != "" {
			seq = seq.Tmux()
		}
		fmt.Fprint(os.Stderr, seq)
		return nil
	}
}

// currentDiff returns the most recent tool diff in the selected transcript.
func (m Model) currentDiff() string {
	if s := m.session(m.selectedID); s != nil && s.Pending != nil && s.Pending.Preview != "" {
		return s.Pending.Preview
	}
	msgs := m.routerMessages[m.selectedID]
	for i := len(msgs) - 1; i >= 0; i-- {
		if msgs[i].Role == "tool" {
			return msgs[i].Content
		}
	}
	return ""
}

// openEventInspector opens the event inspector with a fresh (cleared) filter.
func (m *Model) openEventInspector() {
	m.overlay = OverlayEventInspector
	m.overlayEventIdx = 0
	m.eventFilter = ""
	m.eventFilterTyping = false
}

// currentEvents is the selected session's events, narrowed by the inspector filter (a
// case-insensitive substring of type/detail) when one is set.
func (m Model) currentEvents() []EventEntry {
	s := m.session(m.selectedID)
	if s == nil {
		return nil
	}
	if m.eventFilter == "" {
		return s.Events
	}
	q := strings.ToLower(m.eventFilter)
	out := make([]EventEntry, 0, len(s.Events))
	for _, e := range s.Events {
		if strings.Contains(strings.ToLower(e.Type+" "+e.Detail), q) {
			out = append(out, e)
		}
	}
	return out
}
