package ui

import (
	"fmt"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/unkn0wn-root/resterm/internal/bindings"
	"github.com/unkn0wn-root/resterm/internal/filesvc"
	"github.com/unkn0wn-root/resterm/internal/restfile"
	"github.com/unkn0wn-root/resterm/internal/ui/navigator"
	"github.com/unkn0wn-root/resterm/internal/ui/scroll"
	"github.com/unkn0wn-root/resterm/internal/ui/textarea"
	"github.com/unkn0wn-root/resterm/internal/util"
)

func (m Model) Init() tea.Cmd {
	cmds := []tea.Cmd{textarea.Blink}
	if cmd := m.latencyAnimTickCmd(); cmd != nil {
		cmds = append(cmds, cmd)
	}
	if m.updateEnabled {
		cmds = append(cmds, newUpdateTickCmd(0))
	}
	if cmd := m.nextFileWatchMsgCmd(); cmd != nil {
		cmds = append(cmds, cmd)
	}
	if cmd := m.initialGitStatusCmd(); cmd != nil {
		cmds = append(cmds, cmd)
	}
	if cmd := m.nextRunMsgCmd(); cmd != nil {
		cmds = append(cmds, cmd)
	}
	if cmd := m.nextStreamMsgCmd(); cmd != nil {
		cmds = append(cmds, cmd)
	}
	return tea.Batch(cmds...)
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmds []tea.Cmd
	mouseHandled := false

	switch typed := msg.(type) {
	case tea.WindowSizeMsg:
		m.frameWidth = typed.Width
		m.frameHeight = typed.Height
		m.width = max(typed.Width-2, 0)
		m.height = max(typed.Height-2, 0)
		if !m.ready {
			m.ready = true
		}
		if cmd := m.applyLayout(); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case editorEvent:
		if typed.status != nil {
			m.setStatusMessage(*typed.status)
		}
	case tea.KeyMsg:
		if !m.showSearchPrompt && !m.showEnvSelector && !m.showFileChangeModal {
			if cmd := m.handleKey(typed); cmd != nil {
				cmds = append(cmds, cmd)
			}
		}
	case tea.MouseMsg:
		var cmd tea.Cmd
		cmd, mouseHandled = m.handleMouse(typed)
		if cmd != nil {
			cmds = append(cmds, cmd)
		}
	case responseMsg:
		m.stopSending()
		m.sendCancel = nil
		if cmd := m.handleResponseMessage(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
		m.stopStatusPulseIfIdle()
	case runReqMsg:
		m.stopSending()
		m.sendCancel = nil
		if cmd := m.handleRunReqMsg(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
		m.stopStatusPulseIfIdle()
	case runEvtMsg:
		if cmd := m.handleRunEvt(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
		cmds = append(cmds, m.nextRunMsgCmd())
	case runWorkerDoneMsg:
		if cmd := m.handleRunWorkerDone(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
		cmds = append(cmds, m.nextRunMsgCmd())
	case statusMsg:
		m.setStatusMessage(typed)
	case statusPulseMsg:
		if cmd := m.handleStatusPulse(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case gitStatusMsg:
		m.handleGitStatusMsg(typed)
	case tabSpinMsg:
		if cmd := m.handleTabSpin(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case latencyAnimMsg:
		if cmd := m.handleLatencyAnim(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case responseRenderedMsg:
		if cmd := m.handleResponseRendered(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case responseLoadingTickMsg:
		if cmd := m.handleResponseLoadingTick(); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case responseReflowStartMsg:
		if cmd := m.handleResponseReflowStart(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case responseReflowDoneMsg:
		if cmd := m.handleResponseReflowDone(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case rawDumpLoadedMsg:
		if cmd := m.handleRawDumpLoaded(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case externalEditorMsg:
		if cmd := m.handleExternalEditorMsg(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case profileNextIterationMsg:
		if cmd := m.executeProfileIteration(); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case updateTickMsg:
		if cmd := m.enqueueUpdateCheck(); cmd != nil {
			cmds = append(cmds, cmd)
		}
	case updateCheckMsg:
		m.updateBusy = false
		m.updateLastCheck = time.Now()
		if m.updateEnabled {
			cmds = append(cmds, newUpdateTickCmd(updateInterval))
		}
		if typed.err != nil {
			errText := typed.err.Error()
			if errText != "" && errText != m.updateLastErr {
				m.updateLastErr = errText
				m.setStatusMessage(
					statusMsg{
						text:  fmt.Sprintf("update check failed: %s", errText),
						level: statusWarn,
					},
				)
			}
		} else {
			m.updateLastErr = ""
			if typed.res != nil {
				ver := strings.TrimSpace(typed.res.Info.Version)
				if ver != "" && ver != m.updateAnnounce {
					res := *typed.res
					m.updateInfo = &res
					m.updateAnnounce = ver
					m.setStatusMessage(
						statusMsg{
							text:  m.updateNotice(ver),
							level: statusInfo,
						},
					)
				}
			}
		}
	case streamEventMsg:
		m.handleStreamEvents(typed)
		cmds = append(cmds, m.nextStreamMsgCmd())
	case streamStateMsg:
		m.handleStreamState(typed)
		cmds = append(cmds, m.nextStreamMsgCmd())
	case streamCompleteMsg:
		m.handleStreamComplete(typed)
		cmds = append(cmds, m.nextStreamMsgCmd())
	case streamReadyMsg:
		m.handleStreamReady(typed)
		cmds = append(cmds, m.nextStreamMsgCmd())
	case fileChangedMsg:
		if cmd := m.handleFileChangeEvent(typed); cmd != nil {
			cmds = append(cmds, cmd)
		}
		cmds = append(cmds, m.nextFileWatchMsgCmd())
	case wsConsoleResultMsg:
		m.handleConsoleResult(typed)
		cmds = append(cmds, m.nextStreamMsgCmd())
	}

	if m.showErrorModal {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			switch keyMsg.String() {
			case "esc", "enter":
				m.closeErrorModal()
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			}
		}
		return m, batchCommands(cmds...)
	}

	if m.showFileChangeModal {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			if cmd, handled := m.handleReloadBinding(keyMsg); handled {
				return m, batchCommands(append(cmds, cmd)...)
			}
			switch keyMsg.String() {
			case "esc":
				m.closeFileChangeModal()
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			}
		}
		return m, batchCommands(cmds...)
	}

	if m.showHistoryPreview {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			vp := m.historyPreviewViewport
			switch keyMsg.String() {
			case "esc", "enter":
				m.closeHistoryPreview()
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			case "down", "j":
				if vp != nil {
					vp.ScrollDown(1)
				}
				return m, batchCommands(cmds...)
			case "up", "k":
				if vp != nil {
					vp.ScrollUp(1)
				}
				return m, batchCommands(cmds...)
			case "pgdown", "ctrl+f":
				if vp != nil {
					vp.ScrollDown(vp.Height)
				}
				return m, batchCommands(cmds...)
			case "pgup", "ctrl+b", "ctrl+u":
				if vp != nil {
					vp.ScrollUp(vp.Height)
				}
				return m, batchCommands(cmds...)
			case "home":
				if vp != nil {
					vp.GotoTop()
				}
				return m, batchCommands(cmds...)
			case "end":
				if vp != nil {
					vp.GotoBottom()
				}
				return m, batchCommands(cmds...)
			}
		}
		return m, batchCommands(cmds...)
	}

	if m.showRequestDetails {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			vp := m.requestDetailViewport
			switch keyMsg.String() {
			case "esc", "enter":
				m.closeRequestDetails()
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			case "down", "j":
				if vp != nil {
					vp.ScrollDown(1)
				}
				return m, batchCommands(cmds...)
			case "up", "k":
				if vp != nil {
					vp.ScrollUp(1)
				}
				return m, batchCommands(cmds...)
			case "pgdown", "ctrl+f":
				if vp != nil {
					vp.ScrollDown(vp.Height)
				}
				return m, batchCommands(cmds...)
			case "pgup", "ctrl+b", "ctrl+u":
				if vp != nil {
					vp.ScrollUp(vp.Height)
				}
				return m, batchCommands(cmds...)
			case "home":
				if vp != nil {
					vp.GotoTop()
				}
				return m, batchCommands(cmds...)
			case "end":
				if vp != nil {
					vp.GotoBottom()
				}
				return m, batchCommands(cmds...)
			}
		}
		return m, batchCommands(cmds...)
	}

	if m.showResponseSaveModal {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			if m.responseSaveJustOpened {
				m.responseSaveJustOpened = false
				return m, batchCommands(cmds...)
			}
			switch keyMsg.String() {
			case "esc":
				m.closeResponseSaveModal()
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			case "enter":
				cmd := m.submitResponseSave()
				return m, batchCommands(append(cmds, cmd)...)
			}
			var inputCmd tea.Cmd
			m.responseSaveInput, inputCmd = m.responseSaveInput.Update(msg)
			return m, batchCommands(append(cmds, inputCmd)...)
		}
		return m, batchCommands(cmds...)
	}

	if m.showOpenModal {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			switch keyMsg.String() {
			case "esc":
				m.closeOpenModal()
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			case "enter":
				cmd := m.submitOpenPath()
				return m, batchCommands(append(cmds, cmd)...)
			}
			var inputCmd tea.Cmd
			m.openPathInput, inputCmd = m.openPathInput.Update(msg)
			return m, batchCommands(append(cmds, inputCmd)...)
		}
		return m, batchCommands(cmds...)
	}

	if m.showNewFileModal {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			switch keyMsg.String() {
			case "esc":
				m.closeNewFileModal()
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			case "enter":
				cmd := m.submitNewFile()
				return m, batchCommands(append(cmds, cmd)...)
			case "tab", "shift+tab", "right", "left":
				if keyMsg.String() == "left" || keyMsg.String() == "shift+tab" {
					m.cycleNewFileExtension(-1)
				} else {
					m.cycleNewFileExtension(1)
				}
				return m, batchCommands(cmds...)
			}
			var inputCmd tea.Cmd
			m.newFileInput, inputCmd = m.newFileInput.Update(msg)
			return m, batchCommands(append(cmds, inputCmd)...)
		}
		return m, batchCommands(cmds...)
	}

	if m.showLayoutSaveModal {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			switch keyMsg.String() {
			case "y", "Y":
				cmd := m.saveLayoutSettings()
				return m, batchCommands(append(cmds, cmd)...)
			case "n", "N", "esc":
				m.closeLayoutSaveModal()
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			}
		}
		return m, batchCommands(cmds...)
	}

	if m.showSearchPrompt {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			keyStr := keyMsg.String()
			if m.searchJustOpened {
				m.searchJustOpened = false
				if isSearchTriggerKey(keyStr) {
					return m, batchCommands(cmds...)
				}
			}
			switch keyStr {
			case "esc":
				m.closeSearchPrompt()
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			case "ctrl+r":
				m.toggleSearchMode()
				cmd := m.applyLiveSearchPrompt()
				return m, batchCommands(append(cmds, cmd)...)
			case "enter":
				cmd := m.submitSearchPrompt()
				return m, batchCommands(append(cmds, cmd)...)
			}
			prevValue := m.searchInput.Value()
			var inputCmd tea.Cmd
			m.searchInput, inputCmd = m.searchInput.Update(msg)
			cmds = append(cmds, inputCmd)
			if m.searchInput.Value() != prevValue {
				cmds = append(cmds, m.applyLiveSearchPrompt())
			}
			return m, batchCommands(cmds...)
		}
		return m, batchCommands(cmds...)
	}

	if m.showHelp {
		if m.helpJustOpened {
			m.helpJustOpened = false
		}
		return m, tea.Batch(cmds...)
	}

	if m.showThemeSelector {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			switch keyMsg.String() {
			case "esc":
				m.showThemeSelector = false
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			case "enter":
				cmd := m.applyThemeSelection()
				return m, batchCommands(append(cmds, cmd)...)
			case "?", "shift+/":
				m.toggleHelp()
				return m, batchCommands(cmds...)
			}
			var themeCmd tea.Cmd
			m.themeList, themeCmd = m.themeList.Update(msg)
			return m, batchCommands(append(cmds, themeCmd)...)
		}
		return m, batchCommands(cmds...)
	}

	if m.showEnvSelector {
		if keyMsg, ok := msg.(tea.KeyMsg); ok {
			switch keyMsg.String() {
			case "esc":
				m.showEnvSelector = false
				return m, batchCommands(cmds...)
			case "ctrl+q", "ctrl+d":
				return m, tea.Quit
			case "enter":
				cmd := m.applyEnvironmentSelection()
				return m, batchCommands(append(cmds, cmd)...)
			case "?", "shift+/":
				m.toggleHelp()
				return m, batchCommands(cmds...)
			}
			var envCmd tea.Cmd
			m.envList, envCmd = m.envList.Update(msg)
			return m, batchCommands(append(cmds, envCmd)...)
		}
		return m, batchCommands(cmds...)
	}

	if _, ok := msg.(tea.WindowSizeMsg); ok {
		m.navigatorFilter, _ = m.navigatorFilter.Update(msg)
	} else if !mouseHandled {
		switch m.focus {
		case focusFile, focusRequests, focusWorkflows:
			if m.suppressListKey {
				m.suppressListKey = false
			} else {
				if navCmd := m.updateNavigator(msg); navCmd != nil {
					cmds = append(cmds, navCmd)
				}
			}
		}
	}

	if _, ok := msg.(tea.WindowSizeMsg); ok || (!mouseHandled && m.focus == focusEditor) {
		if m.suppressEditorKey {
			m.suppressEditorKey = false
		} else {
			cmds = append(cmds, m.updateEditor(m.filterEditorMessage(msg)))
		}
		if m.focus == focusEditor {
			m.syncNavigatorWithEditorCursor()
		}
	}

	if _, ok := msg.(tea.WindowSizeMsg); ok ||
		(!mouseHandled && m.focus == focusResponse && m.focusedPane() != nil && m.focusedPane().activeTab == responseTabHistory) {
		skipHist := false
		if _, ok := msg.(tea.KeyMsg); ok {
			if m.historyFilterActive || m.historyBlockKey {
				skipHist = true
			}
		}
		if !skipHist {
			var histCmd tea.Cmd
			m.historyList, histCmd = m.historyList.Update(msg)
			if m.historyJumpToLatest {
				m.selectNewestHistoryEntry()
				m.historyJumpToLatest = false
			}
			m.captureHistorySelection()
			cmds = append(cmds, histCmd)
		}
	}

	if winMsg, ok := msg.(tea.WindowSizeMsg); ok {
		for _, id := range m.visiblePaneIDs() {
			pane := m.pane(id)
			if pane == nil || pane.activeTab == responseTabHistory {
				continue
			}
			var paneCmd tea.Cmd
			pane.viewport, paneCmd = pane.viewport.Update(winMsg)
			if paneCmd != nil {
				cmds = append(cmds, paneCmd)
			}
		}
	} else if !mouseHandled && m.focus == focusResponse {
		pane := m.focusedPane()
		if pane != nil && pane.activeTab != responseTabHistory {
			skipViewport := false
			if keyMsg, ok := msg.(tea.KeyMsg); ok {
				switch keyMsg.String() {
				case "j", "k", "down", "up", "pgdown", "pgup":
					skipViewport = true
				}
			}
			if !skipViewport {
				var paneCmd tea.Cmd
				prevOffset := pane.viewport.YOffset
				pane.viewport, paneCmd = pane.viewport.Update(msg)
				if paneCmd != nil {
					cmds = append(cmds, paneCmd)
				}
				if pane.viewport.YOffset != prevOffset {
					if m.followRespCursorOnScroll(pane, prevOffset, pane.viewport.YOffset) {
						cmds = append(cmds, m.syncResponsePane(m.responsePaneFocus))
					}
				}
			}
		}
	}

	if _, ok := msg.(tea.KeyMsg); ok {
		m.historyBlockKey = false
	}
	return m, tea.Batch(cmds...)
}

func isSpaceKey(msg tea.KeyMsg) bool {
	if msg.Type == tea.KeySpace {
		return true
	}
	if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 && msg.Runes[0] == ' ' {
		return true
	}
	switch msg.String() {
	case " ", "space":
		return true
	default:
		return false
	}
}

func (m *Model) canPreviewOnSpace() bool {
	if len(m.requestItems) == 0 {
		return false
	}
	if m.showHelp || m.showEnvSelector {
		return false
	}
	switch m.focus {
	case focusRequests:
		return true
	case focusEditor:
		return !m.editorInsertMode
	case focusFile:
		return true
	default:
		return false
	}
}

func canonicalShortcutKey(msg tea.KeyMsg) string {
	key := msg.String()
	if key == "" {
		switch msg.Type {
		case tea.KeyCtrlJ:
			key = "ctrl+j"
		default:
			return ""
		}
	}
	return bindings.NormalizeKeyString(key)
}

func isPlainRuneKey(msg tea.KeyMsg) bool {
	return msg.Type == tea.KeyRunes && len(msg.Runes) == 1
}

func (m *Model) isNavigatorFiltering() bool {
	if !m.navigatorFilter.Focused() {
		return false
	}
	switch m.focus {
	case focusFile, focusRequests, focusWorkflows:
		return true
	default:
		return false
	}
}

func (m *Model) resetChordState() {
	m.hasPendingChord = false
	m.pendingChord = ""
	m.pendingChordMsg = tea.KeyMsg{}
	m.wsCommandChord = false
	m.clearRepeatChord()
}

func (m *Model) clearRepeatChord() {
	m.repeatChordActive = false
	m.repeatChordPrefix = ""
	m.repeatChordKey = ""
}

func (m *Model) navGate(kind navigator.Kind, warn string) bool {
	if m.navigator == nil {
		return true
	}
	sel := m.navigator.Selected()
	if sel == nil {
		return true
	}
	if sel.Kind != kind {
		return false
	}
	if !util.SamePath(sel.Payload.FilePath, m.currentFile) {
		if warn != "" {
			m.setStatusMessage(statusMsg{text: warn, level: statusInfo})
		}
		return false
	}
	return true
}

type pendingCrossFileNavigation struct {
	nodeID         string
	action         string
	sourcePath     string
	targetPath     string
	sourceRevision uint64
}

const (
	navActionJumpL          = "navigator:jump:l"
	navActionJumpR          = "navigator:jump:r"
	navActionOpenFile       = "navigator:file:open"
	navActionRequestSend    = "request:send"
	navActionRequestPreview = "request:preview"
	navActionWorkflowRun    = "workflow:run"
)

func (m *Model) clearPendingCrossFileNavigation() {
	m.pendingCrossFile = pendingCrossFileNavigation{}
}

func (m *Model) confirmCrossFileNavigation(
	n *navigator.Node[any],
	action string,
	retryHint string,
) bool {
	if n == nil {
		return true
	}
	path := n.Payload.FilePath
	if path == "" || util.SamePath(path, m.currentFile) || !m.dirty {
		m.clearPendingCrossFileNavigation()
		return true
	}
	sourceRevision := m.editor.Revision()
	pending := pendingCrossFileNavigation{
		nodeID:         n.ID,
		action:         action,
		sourcePath:     m.currentFile,
		targetPath:     path,
		sourceRevision: sourceRevision,
	}
	if m.pendingCrossFile.matches(pending) {
		m.clearPendingCrossFileNavigation()
		return true
	}
	m.pendingCrossFile = pending
	base := filepath.Base(path)
	if base == "" {
		base = path
	}
	if strings.TrimSpace(retryHint) == "" {
		retryHint = "Repeat the action to continue."
	}
	m.setStatusMessage(statusMsg{
		text: fmt.Sprintf(
			"Unsaved changes will be discarded when opening %s. %s",
			base,
			retryHint,
		),
		level: statusWarn,
	})
	return false
}

func (p pendingCrossFileNavigation) matches(other pendingCrossFileNavigation) bool {
	if p.nodeID != other.nodeID {
		return false
	}
	if p.action != other.action {
		return false
	}
	if p.sourceRevision != other.sourceRevision {
		return false
	}
	if !util.SamePathOrBothEmpty(p.sourcePath, other.sourcePath) {
		return false
	}
	return util.SamePathOrBothEmpty(p.targetPath, other.targetPath)
}

func (m *Model) ensureNavigatorFile(n *navigator.Node[any]) ([]tea.Cmd, bool) {
	if n == nil {
		return nil, true
	}
	path := n.Payload.FilePath
	if path == "" || util.SamePath(path, m.currentFile) {
		return nil, true
	}
	var cmds []tea.Cmd
	if cmd := m.openFile(path); cmd != nil {
		cmds = append(cmds, cmd)
	}
	if util.SamePath(path, m.currentFile) {
		return cmds, true
	}
	return cmds, false
}

func navRequestIndex(id string) int {
	parts := strings.Split(id, ":")
	if len(parts) == 0 {
		return -1
	}
	last := parts[len(parts)-1]
	idx, err := strconv.Atoi(last)
	if err != nil || idx < 0 {
		return -1
	}
	return idx
}

func (m *Model) selectRequestForNode(req *restfile.Request, id string) bool {
	if req == nil {
		return false
	}
	if key := requestKey(req); key != "" && m.selectRequestItemByKey(key) {
		return true
	}
	if idx := navRequestIndex(id); idx >= 0 && idx < len(m.requestItems) {
		m.requestList.Select(idx)
		return true
	}
	return false
}

type navReqPrep struct {
	request *restfile.Request
	cmds    []tea.Cmd
	ok      bool
}

func (m *Model) resolveNavReq(action string, retryHint string) navReqPrep {
	if m.navigator == nil {
		return navReqPrep{}
	}
	n := m.navigator.Selected()
	if n == nil || n.Kind != navigator.KindRequest {
		return navReqPrep{}
	}
	if !m.confirmCrossFileNavigation(n, action, retryHint) {
		return navReqPrep{cmds: []tea.Cmd{func() tea.Msg { return nil }}}
	}
	req, ok := n.Payload.Data.(*restfile.Request)
	if !ok || req == nil {
		return navReqPrep{}
	}
	cmds, okFile := m.ensureNavigatorFile(n)
	if !okFile {
		if len(cmds) == 0 {
			return navReqPrep{}
		}
		return navReqPrep{cmds: cmds}
	}
	if !m.selectRequestForNode(req, n.ID) {
		m.setStatusMessage(statusMsg{text: "Request not found in file", level: statusWarn})
		if len(cmds) == 0 {
			return navReqPrep{}
		}
		return navReqPrep{cmds: cmds}
	}
	return navReqPrep{request: req, cmds: cmds, ok: true}
}

func (m *Model) revealLineRangeInEditor(r restfile.LineRange) {
	if r.Start <= 0 {
		return
	}
	start := r.Start - 1
	end := r.End - 1
	if end < start {
		end = start
	}
	h := m.editor.Height()
	if h <= 0 {
		h = 1
	}
	total := m.editor.LineCount()
	offset := scroll.Reveal(start, end, m.editor.ViewStart(), h, total)
	m.editor.SetViewStart(offset)
}

func (m *Model) revealRequestInEditor(req *restfile.Request) {
	if req == nil {
		return
	}
	m.revealLineRangeInEditor(req.LineRange)
}

func (m *Model) jumpToNavigatorRequest(req *restfile.Request, moveCursor bool) {
	if req == nil {
		return
	}
	m.revealRequestInEditor(req)
	if moveCursor {
		m.syncEditorWithRequestSelection(-1)
	}
}

type navWfPrep struct {
	workflow *restfile.Workflow
	cmds     []tea.Cmd
	ok       bool
}

func (m *Model) resolveNavWf(action string, retryHint string) navWfPrep {
	if m.navigator == nil {
		return navWfPrep{}
	}
	n := m.navigator.Selected()
	if n == nil || n.Kind != navigator.KindWorkflow {
		return navWfPrep{}
	}
	if !m.confirmCrossFileNavigation(n, action, retryHint) {
		return navWfPrep{cmds: []tea.Cmd{func() tea.Msg { return nil }}}
	}
	wf, ok := n.Payload.Data.(*restfile.Workflow)
	if !ok || wf == nil {
		return navWfPrep{}
	}
	cmds, okFile := m.ensureNavigatorFile(n)
	if !okFile {
		if len(cmds) == 0 {
			return navWfPrep{}
		}
		return navWfPrep{cmds: cmds}
	}
	if !m.selectWorkflowForNode(wf, n.ID) {
		m.setStatusMessage(statusMsg{text: "Workflow not found in file", level: statusWarn})
		if len(cmds) == 0 {
			return navWfPrep{}
		}
		return navWfPrep{cmds: cmds}
	}
	if item, ok := m.workflowList.SelectedItem().(workflowListItem); ok && item.workflow != nil {
		wf = item.workflow
	}
	m.workflowSelectionKey = workflowKey(wf)
	return navWfPrep{workflow: wf, cmds: cmds, ok: true}
}

func (m *Model) jumpToNavigatorWorkflow(wf *restfile.Workflow, moveCursor bool) {
	if wf == nil {
		return
	}
	m.revealLineRangeInEditor(wf.LineRange)
	if moveCursor {
		m.moveCursorToLine(wf.LineRange.Start)
	}
}

func (m *Model) sendNavigatorRequest(execute bool) tea.Cmd {
	action := navActionRequestPreview
	hint := "Press Space again to preview."
	if execute {
		action = navActionRequestSend
		hint = "Press Enter again to send."
	}
	prep := m.resolveNavReq(action, hint)
	if !prep.ok {
		if len(prep.cmds) == 0 {
			return nil
		}
		return tea.Batch(prep.cmds...)
	}
	cmds := prep.cmds
	m.jumpToNavigatorRequest(prep.request, false)
	if cmd := m.setFocus(focusRequests); cmd != nil {
		cmds = append(cmds, cmd)
	}
	if cmd := m.sendRequestFromList(execute); cmd != nil {
		cmds = append(cmds, cmd)
	}
	if len(cmds) == 0 {
		return nil
	}
	return tea.Batch(cmds...)
}

func (m *Model) selectWorkflowForNode(wf *restfile.Workflow, id string) bool {
	if wf == nil {
		return false
	}
	if idx := navRequestIndex(id); idx >= 0 && idx < len(m.workflowItems) {
		m.workflowList.Select(idx)
		return true
	}
	if key := workflowKey(wf); key != "" && m.selectWorkflowItemByKey(key) {
		return true
	}
	return false
}

func (m *Model) sendNavigatorWorkflow() tea.Cmd {
	prep := m.resolveNavWf(navActionWorkflowRun, "Press Enter/Space again to run.")
	if !prep.ok {
		if len(prep.cmds) == 0 {
			return nil
		}
		return tea.Batch(prep.cmds...)
	}
	cmds := prep.cmds
	if cmd := m.setFocus(focusWorkflows); cmd != nil {
		cmds = append(cmds, cmd)
	}
	if cmd := m.runSelectedWorkflow(); cmd != nil {
		cmds = append(cmds, cmd)
	}
	if len(cmds) == 0 {
		return nil
	}
	return tea.Batch(cmds...)
}

func (m *Model) handleShortcutKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
	if key == "" || m.bindingsMap == nil {
		return nil, false
	}
	binding, ok := m.bindingsMap.MatchSingle(key)
	if !ok {
		return nil, false
	}
	if binding.Action == bindings.ActionSendRequest {
		return nil, false
	}
	cmd, handled := m.runShortcutBinding(binding, msg)
	if !handled {
		return nil, false
	}
	return cmd, true
}

func (m *Model) runShortcutBinding(binding bindings.Binding, msg tea.KeyMsg) (tea.Cmd, bool) {
	switch binding.Action {
	case bindings.ActionCycleFocusNext:
		if m.focus == focusEditor && m.editorInsertMode {
			return nil, true
		}
		prev := m.focus
		cmd := m.cycleFocus(true)
		if prev == focusEditor || m.focus == focusEditor {
			m.suppressEditorKey = true
		}
		return cmd, true
	case bindings.ActionCycleFocusPrev:
		if m.focus == focusEditor && m.editorInsertMode {
			return nil, true
		}
		prev := m.focus
		cmd := m.cycleFocus(false)
		if prev == focusEditor || m.focus == focusEditor {
			m.suppressEditorKey = true
		}
		return cmd, true
	case bindings.ActionOpenEnvSelector:
		if len(m.cfg.EnvironmentSet) == 0 {
			return func() tea.Msg {
				return statusMsg{text: "No environments configured", level: statusWarn}
			}, true
		}
		m.openEnvironmentSelector()
		return nil, true
	case bindings.ActionShowGlobals:
		return m.showGlobalSummary(), true
	case bindings.ActionClearGlobals:
		return m.clearGlobalValues(), true
	case bindings.ActionSaveFile:
		return m.saveFile(), true
	case bindings.ActionSaveLayout:
		m.openLayoutSaveModal()
		return nil, true
	case bindings.ActionToggleResponseSplitVert:
		m.responsePaneChord = false
		return m.toggleResponseSplitVertical(), true
	case bindings.ActionToggleResponseSplitHorz:
		m.responsePaneChord = false
		return m.toggleResponseSplitHorizontal(), true
	case bindings.ActionTogglePaneFollowLatest:
		m.responsePaneChord = false
		target := responsePanePrimary
		if m.focus == focusResponse {
			target = m.responsePaneFocus
		}
		return m.togglePaneFollowLatest(target), true
	case bindings.ActionToggleHelp:
		m.toggleHelp()
		return nil, true
	case bindings.ActionShowRequestDetails:
		m.openRequestDetails()
		return nil, true
	case bindings.ActionOpenPathModal:
		m.openOpenModal()
		return nil, true
	case bindings.ActionReloadWorkspace:
		return m.reloadWorkspace(), true
	case bindings.ActionOpenNewFileModal:
		m.openNewFileModal()
		return nil, true
	case bindings.ActionOpenFileInEditor:
		return m.openFileInEditor(), true
	case bindings.ActionOpenThemeSelector:
		m.openThemeSelector()
		return nil, true
	case bindings.ActionOpenTempDocument:
		return m.openTemporaryDocument(), true
	case bindings.ActionReparseDocument:
		m.suppressEditorKey = true
		return m.reparseDocument(), true
	case bindings.ActionReloadFileFromDisk:
		return m.reloadFileFromDisk(), true
	case bindings.ActionSelectTimelineTab:
		return m.selectTimelineTab(), true
	case bindings.ActionQuitApp:
		return tea.Quit, true
	case bindings.ActionCancelRun:
		return m.cancelActiveRuns(), true
	case bindings.ActionSidebarWidthDecrease:
		if m.navigatorPaneFocused() {
			return m.runSidebarWidthResize(-sidebarWidthStep), true
		}
		return m.runEditorResize(-editorSplitStep), true
	case bindings.ActionSidebarWidthIncrease:
		if m.navigatorPaneFocused() {
			return m.runSidebarWidthResize(sidebarWidthStep), true
		}
		return m.runEditorResize(editorSplitStep), true
	case bindings.ActionSidebarHeightDecrease:
		if m.focus == focusWorkflows && len(m.workflowItems) > 0 {
			return m.runWorkflowResize(-workflowSplitStep), true
		}
		return m.runSidebarResize(-sidebarSplitStep), true
	case bindings.ActionSidebarHeightIncrease:
		if m.focus == focusWorkflows && len(m.workflowItems) > 0 {
			return m.runWorkflowResize(workflowSplitStep), true
		}
		return m.runSidebarResize(sidebarSplitStep), true
	case bindings.ActionWorkflowHeightIncrease:
		return m.runWorkflowResize(workflowSplitStep), true
	case bindings.ActionWorkflowHeightDecrease:
		return m.runWorkflowResize(-workflowSplitStep), true
	case bindings.ActionFocusRequests:
		return m.setFocus(focusRequests), true
	case bindings.ActionFocusResponse:
		return m.setFocus(focusResponse), true
	case bindings.ActionFocusEditorNormal:
		return batchCommands(
			m.setFocus(focusEditor),
			m.setInsertMode(false, true),
		), true
	case bindings.ActionSetMainSplitHorizontal:
		return m.setMainSplitOrientation(mainSplitHorizontal), true
	case bindings.ActionSetMainSplitVertical:
		return m.setMainSplitOrientation(mainSplitVertical), true
	case bindings.ActionStartCompareRun:
		return m.startConfigCompareFromEditor(), true
	case bindings.ActionToggleWebsocketConsole:
		return m.armWebSocketCommandMode(), true
	case bindings.ActionToggleSidebarCollapse:
		return m.togglePaneCollapse(paneRegionSidebar), true
	case bindings.ActionToggleEditorCollapse:
		return m.togglePaneCollapse(paneRegionEditor), true
	case bindings.ActionToggleResponseCollapse:
		return m.togglePaneCollapse(paneRegionResponse), true
	case bindings.ActionToggleZoom:
		return m.toggleZoomForRegion(regionFromFocus(m.focus)), true
	case bindings.ActionClearZoom:
		return m.clearZoomCmd(), true
	case bindings.ActionCopyResponseTab:
		return m.copyResponseTab(), true
	case bindings.ActionExplainRequest:
		return m.explainActiveRequest(), true
	case bindings.ActionToggleHeaderPreview:
		return m.activateHeaderSubviewFromBinding(), true
	case bindings.ActionCycleRawView:
		return m.cycleRawViewMode(), true
	case bindings.ActionShowRawDump:
		return m.showRawDump(), true
	case bindings.ActionScrollResponseTop:
		return m.scrollShortcutToEdge(true)
	case bindings.ActionScrollResponseBottom:
		return m.scrollShortcutToEdge(false)
	case bindings.ActionSaveResponseBody:
		return m.saveResponseBody(), true
	case bindings.ActionOpenResponseExternally:
		return m.openResponseExternally(), true
	default:
		return nil, false
	}
}

func (m *Model) isSendShortcut(msg tea.KeyMsg) bool {
	if m.bindingsMap == nil {
		return false
	}
	key := canonicalShortcutKey(msg)
	if key == "" {
		return false
	}
	for _, binding := range m.bindingsMap.Bindings(bindings.ActionSendRequest) {
		if len(binding.Steps) == 1 && binding.Steps[0] == key {
			return true
		}
	}
	return false
}

func (m *Model) shouldSendEditorRequest(msg tea.KeyMsg, insertMode bool) bool {
	if m.isSendShortcut(msg) {
		return true
	}
	keyStr := msg.String()
	switch keyStr {
	case "enter":
		return !insertMode
	}
	switch msg.Type {
	case tea.KeyEnter:
		return !insertMode
	}
	return false
}

func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
	if m.modalCapturesGlobalKeys() {
		return nil
	}
	return m.handleKeyWithChord(msg, true)
}

func (m *Model) modalCapturesGlobalKeys() bool {
	switch {
	case m.showErrorModal,
		m.showOpenModal,
		m.showNewFileModal,
		m.showResponseSaveModal,
		m.showEnvSelector,
		m.showThemeSelector,
		m.showHistoryPreview,
		m.showRequestDetails,
		m.showLayoutSaveModal,
		m.showFileChangeModal:
		return true
	default:
		return false
	}
}

func (m *Model) handleKeyWithChord(msg tea.KeyMsg, allowChord bool) tea.Cmd {
	keyStr := msg.String()
	shortcutKey := canonicalShortcutKey(msg)
	var prefixCmd tea.Cmd
	combine := func(c tea.Cmd) tea.Cmd {
		if prefixCmd == nil {
			return c
		}
		if c == nil {
			return prefixCmd
		}
		return tea.Batch(prefixCmd, c)
	}

	if m.showHelp {
		return combine(m.handleHelpKey(msg))
	}

	if m.isNavigatorFiltering() {
		m.resetChordState()
		switch keyStr {
		case "ctrl+q", "ctrl+d":
			return combine(tea.Quit)
		default:
			return combine(nil)
		}
	}

	if cmd, handled := m.handleHistoryFilterKey(msg); handled {
		m.resetChordState()
		return combine(cmd)
	}

	if m.operator.active {
		m.suppressEditorKey = true
		cmd := m.handleOperatorKey(msg)
		return combine(cmd)
	}

	if m.focus == focusEditor && m.editor.awaitingFindTarget() {
		if updated, cmd, ok := m.editor.HandleMotion(keyStr); ok {
			m.editor = updated
			m.suppressEditorKey = true
			return combine(cmd)
		}
	}

	if m.focus != focusFile && m.focus != focusRequests && m.focus != focusWorkflows {
		m.suppressListKey = false
	}

	if cmd, handled := m.handleWebSocketCommandChord(msg); handled {
		return combine(cmd)
	}

	if allowChord {
		if !m.hasPendingChord && m.repeatChordActive && shortcutKey != "" {
			if shortcutKey == m.repeatChordKey {
				if handled, chordCmd := m.resolveChord(
					m.repeatChordPrefix,
					shortcutKey,
					msg,
				); handled {
					m.suppressListKey = true
					m.blockHistoryKey()
					return combine(chordCmd)
				}
			}
			m.clearRepeatChord()
		}
		if m.hasPendingChord {
			storedMsg := m.pendingChordMsg
			prefix := m.pendingChord
			m.pendingChord = ""
			m.hasPendingChord = false
			m.pendingChordMsg = tea.KeyMsg{}
			if shortcutKey != "" {
				if handled, chordCmd := m.resolveChord(prefix, shortcutKey, msg); handled {
					m.suppressListKey = true
					m.blockHistoryKey()
					return combine(chordCmd)
				}
			}
			prefixCmd = m.handleKeyWithChord(storedMsg, false)
			m.suppressListKey = true
			keyStr = msg.String()
		} else if m.canStartChord(msg, shortcutKey) {
			m.clearRepeatChord()
			m.pendingChord = shortcutKey
			m.pendingChordMsg = msg
			m.hasPendingChord = true
			m.suppressListKey = true
			m.blockHistoryKey()
			return combine(nil)
		}
	}

	if cmd, handled := m.handleStreamKey(msg); handled {
		return combine(cmd)
	}
	if cmd, handled := m.handleWebSocketConsoleKey(msg); handled {
		return combine(cmd)
	}

	if isSpaceKey(msg) && m.canPreviewOnSpace() {
		if m.focus == focusRequests {
			if cmd := m.sendNavigatorRequest(false); cmd != nil {
				return combine(cmd)
			}
		}
		if !m.navGate(navigator.KindRequest, navigatorRequestPreviewStatus) {
			return combine(nil)
		}
		if cmd := m.sendRequestFromList(false); cmd != nil {
			return combine(cmd)
		}
	}

	if shortcutKey != "" {
		// Let plain characters through in insert mode so they become text, not shortcuts
		if m.focus != focusEditor || !m.editorInsertMode || !isPlainRuneKey(msg) {
			if cmd, handled := m.handleShortcutKey(shortcutKey, msg); handled {
				return combine(cmd)
			}
		}
	}

	if m.focus == focusEditor {
		if !m.editorInsertMode {
			if isSearchTriggerKey(keyStr) {
				cmd := m.openSearchPrompt()
				m.suppressEditorKey = true
				return combine(cmd)
			}
			switch keyStr {
			case "n":
				var cmd tea.Cmd
				m.editor, cmd = m.editor.NextSearchMatch()
				m.suppressEditorKey = true
				return combine(cmd)
			case "p":
				if m.editor.SearchActive() {
					var cmd tea.Cmd
					m.editor, cmd = m.editor.PrevSearchMatch()
					m.suppressEditorKey = true
					return combine(cmd)
				}
				cmd := m.runPasteClipboard(true)
				m.suppressEditorKey = true
				return combine(cmd)
			case "u":
				cmd := m.runUndoLastChange()
				m.suppressEditorKey = true
				return combine(cmd)
			case "ctrl+r":
				cmd := m.runRedoLastChange()
				m.suppressEditorKey = true
				return combine(cmd)
			case "d":
				if m.editor.hasSelection() {
					cmd := m.runDeleteSelection()
					m.suppressEditorKey = true
					return combine(cmd)
				}
				m.clearRepeatChord()
				m.startOperator("d")
				m.suppressEditorKey = true
				m.suppressListKey = true
				return combine(nil)
			case "D":
				cmd := m.runDeleteToLineEnd()
				m.suppressEditorKey = true
				return combine(cmd)
			case "C":
				cmd := m.runEditorInsertAction(editorInsertChangeToLineEnd)
				return combine(cmd)
			case "x":
				cmd := m.runDeleteCharAtCursor()
				m.suppressEditorKey = true
				return combine(cmd)
			case "s":
				cmd := m.runEditorInsertAction(editorInsertSubstituteChar)
				return combine(cmd)
			case "S":
				cmd := m.runEditorInsertAction(editorInsertSubstituteLine)
				return combine(cmd)
			case "c":
				if m.editor.hasSelection() {
					cmd := m.runDeleteSelection()
					cmd = batchCommands(cmd, m.setInsertMode(true, true))
					m.suppressEditorKey = true
					return combine(cmd)
				}
				m.clearRepeatChord()
				m.startOperator("c")
				m.suppressEditorKey = true
				m.suppressListKey = true
				return combine(nil)
			case "P":
				cmd := m.runPasteClipboard(false)
				m.suppressEditorKey = true
				return combine(cmd)
			case "i":
				cmd := m.runEditorInsertAction(editorInsertAtCursor)
				return combine(cmd)
			case "I":
				cmd := m.runEditorInsertAction(editorInsertAtLineStartNonBlank)
				return combine(cmd)
			case "A":
				cmd := m.runEditorInsertAction(editorInsertAtLineEnd)
				return combine(cmd)
			case "o":
				cmd := m.runEditorInsertAction(editorInsertOpenLineBelow)
				return combine(cmd)
			case "O":
				cmd := m.runEditorInsertAction(editorInsertOpenLineAbove)
				return combine(cmd)
			case "esc":
				exitCmd := m.editor.ExitSearchMode()
				m.editor.ClearSelection()
				m.suppressEditorKey = true
				return combine(exitCmd)
			case "v":
				var cmd tea.Cmd
				m.editor, cmd = m.editor.ToggleVisual()
				m.suppressEditorKey = true
				return combine(cmd)
			case "V":
				var cmd tea.Cmd
				m.editor, cmd = m.editor.ToggleVisualLine()
				m.suppressEditorKey = true
				return combine(cmd)
			case "y":
				var cmd tea.Cmd
				m.editor, cmd = m.editor.YankSelection()
				m.suppressEditorKey = true
				return combine(cmd)
			case "a":
				cmd := m.runEditorInsertAction(editorInsertAfterCursor)
				return combine(cmd)
			}
			if updated, cmd, ok := m.editor.HandleMotion(keyStr); ok {
				m.editor = updated
				m.suppressEditorKey = true
				return combine(cmd)
			}
		} else {
			switch keyStr {
			case "esc":
				cmd := m.setInsertMode(false, true)
				m.suppressEditorKey = true
				return combine(cmd)
			}
		}
		if m.shouldSendEditorRequest(msg, m.editorInsertMode) {
			m.suppressEditorKey = true
			return combine(m.sendActiveRequest())
		}
	}

	if m.focus == focusFile {
		switch keyStr {
		case "enter":
			if m.navigator != nil {
				return combine(nil)
			}
			return combine(m.openSelectedFile())
		}
	}

	if m.focus == focusRequests {
		switch {
		case keyStr == "enter":
			if cmd := m.sendNavigatorRequest(true); cmd != nil {
				return combine(cmd)
			}
			if !m.navGate(navigator.KindRequest, navigatorRequestSendStatus) {
				return combine(nil)
			}
			return combine(m.sendRequestFromList(true))
		case isSpaceKey(msg):
			if cmd := m.sendNavigatorRequest(false); cmd != nil {
				return combine(cmd)
			}
			if !m.navGate(navigator.KindRequest, navigatorRequestPreviewStatus) {
				return combine(nil)
			}
			return combine(m.sendRequestFromList(false))
		}
	}

	if m.focus == focusWorkflows {
		switch {
		case keyStr == "enter":
			if cmd := m.sendNavigatorWorkflow(); cmd != nil {
				return combine(cmd)
			}
			if !m.navGate(navigator.KindWorkflow, navigatorWorkflowRunStatus) {
				return combine(nil)
			}
			return combine(m.runSelectedWorkflow())
		case isSpaceKey(msg):
			if cmd := m.sendNavigatorWorkflow(); cmd != nil {
				return combine(cmd)
			}
			if !m.navGate(navigator.KindWorkflow, navigatorWorkflowRunStatus) {
				return combine(nil)
			}
			return combine(m.runSelectedWorkflow())
		}
	}

	if m.focus == focusResponse {
		if m.responsePaneChord {
			switch keyStr {
			case "left", "h":
				m.responsePaneChord = false
				if m.responseSplit {
					m.focusResponsePane(responsePanePrimary)
				}
				return combine(nil)
			case "right", "l":
				m.responsePaneChord = false
				if m.responseSplit {
					m.focusResponsePane(responsePaneSecondary)
				}
				return combine(nil)
			case "ctrl+f", "ctrl+b":
				return combine(nil)
			default:
				m.responsePaneChord = false
				return combine(nil)
			}
		}
		if keyStr == "ctrl+f" || keyStr == "ctrl+b" {
			if m.responseSplit {
				m.responsePaneChord = true
				return combine(nil)
			}
			m.setStatusMessage(statusMsg{text: "Enable split to switch panes", level: statusInfo})
			return combine(nil)
		}
		pane := m.focusedPane()
		if cmd, handled := m.handleResponseSelectionKey(msg, pane); handled {
			return combine(cmd)
		}
		if pane != nil && pane.activeTab == responseTabCompare {
			if cmd := m.handleCompareTabKey(msg, pane); cmd != nil {
				return combine(cmd)
			}
		}
		if isSearchTriggerKey(keyStr) {
			if keyStr == "/" && pane != nil && pane.activeTab == responseTabHistory {
				m.openHistoryFilter()
				return combine(nil)
			}
			cmd := m.openSearchPrompt()
			return combine(cmd)
		}
		switch keyStr {
		case "esc":
			if pane != nil && pane.activeTab == responseTabStats {
				if stats := workflowStatsFromPane(pane); stats != nil && stats.detailFocus {
					return combine(m.blurWorkflowStatsDetail())
				}
			}
			if pane != nil && pane.activeTab == responseTabHistory {
				if m.clearHistoryFilter(false) {
					return combine(nil)
				}
			}
			return combine(m.clearResponseSearch())
		case "n":
			cmd := m.advanceResponseSearch()
			return combine(cmd)
		case "p":
			if pane != nil && pane.activeTab == responseTabHistory {
				if entry, ok := m.selectedHistoryEntry(); ok {
					m.openHistoryPreview(entry)
				} else {
					m.setStatusMessage(
						statusMsg{text: "No history entry selected", level: statusWarn},
					)
				}
				return combine(nil)
			}
			cmd := m.retreatResponseSearch()
			return combine(cmd)
		case "down", "j", "shift+j", "J":
			if pane == nil {
				return combine(nil)
			}
			if pane.activeTab == responseTabStats {
				if stats := workflowStatsFromPane(pane); stats != nil {
					if stats.detailFocus {
						return combine(m.scrollWorkflowStatsDetail(1))
					}
					return combine(m.jumpWorkflowStatsSelection(1))
				}
			}
			if pane.activeTab == responseTabHistory {
				return combine(nil)
			}
			return combine(m.scrollResponseViewport(pane, func() {
				pane.viewport.ScrollDown(1)
			}))
		case "up", "k", "shift+k", "K":
			if pane == nil {
				return combine(nil)
			}
			if pane.activeTab == responseTabStats {
				if stats := workflowStatsFromPane(pane); stats != nil {
					if stats.detailFocus {
						return combine(m.scrollWorkflowStatsDetail(-1))
					}
					return combine(m.jumpWorkflowStatsSelection(-1))
				}
			}
			if pane.activeTab == responseTabHistory {
				return combine(nil)
			}
			return combine(m.scrollResponseViewport(pane, func() {
				pane.viewport.ScrollUp(1)
			}))
		case "pgdown":
			if pane == nil {
				return combine(nil)
			}
			if pane.activeTab == responseTabStats {
				if workflowStatsFromPane(pane) != nil {
					page := max(pane.viewport.Height/2, 1)
					return combine(m.scrollWorkflowStatsDetail(page))
				}
			}
			if pane.activeTab == responseTabHistory {
				break
			}
			return combine(m.scrollResponseViewport(pane, func() {
				pane.viewport.PageDown()
			}))
		case "pgup":
			if pane == nil {
				return combine(nil)
			}
			if pane.activeTab == responseTabStats {
				if workflowStatsFromPane(pane) != nil {
					page := max(pane.viewport.Height/2, 1)
					return combine(m.scrollWorkflowStatsDetail(-page))
				}
			}
			if pane.activeTab == responseTabHistory {
				break
			}
			return combine(m.scrollResponseViewport(pane, func() {
				pane.viewport.PageUp()
			}))
		case "home":
			if pane == nil {
				return combine(nil)
			}
			if pane.activeTab == responseTabStats {
				if stats := workflowStatsFromPane(pane); stats != nil {
					if stats.detailFocus {
						return combine(m.scrollWorkflowStatsDetailEdge(true))
					}
					return combine(m.jumpWorkflowStatsEdge(true))
				}
			}
		case "end":
			if pane == nil {
				return combine(nil)
			}
			if pane.activeTab == responseTabStats {
				if stats := workflowStatsFromPane(pane); stats != nil {
					if stats.detailFocus {
						return combine(m.scrollWorkflowStatsDetailEdge(false))
					}
					return combine(m.jumpWorkflowStatsEdge(false))
				}
			}
		case "left", "ctrl+h", "h":
			return combine(m.activatePrevTabFor(m.responsePaneFocus))
		case "right", "ctrl+l", "l":
			return combine(m.activateNextTabFor(m.responsePaneFocus))
		case "enter":
			if pane != nil {
				switch pane.activeTab {
				case responseTabHeaders:
					return combine(m.cycleHeaderSubview())
				case responseTabHistory:
					return combine(m.loadHistorySelection(false))
				case responseTabStats:
					if workflowStatsFromPane(pane) != nil {
						return combine(m.toggleWorkflowStatsExpansion())
					}
				}
			}
		case " ", "space":
			if pane != nil && pane.activeTab == responseTabHeaders {
				return combine(m.cycleHeaderSubview())
			}
		}
		if pane != nil && pane.activeTab == responseTabHistory {
			switch keyStr := msg.String(); keyStr {
			case "c":
				m.cycleHistoryScope()
				m.blockHistoryKey()
				return combine(nil)
			case "s":
				m.toggleHistorySort()
				m.blockHistoryKey()
				return combine(nil)
			case " ", "space":
				m.toggleHistorySelection()
				m.blockHistoryKey()
				return combine(nil)
			case "d":
				if len(m.historySelected) > 0 {
					deleted, failed, err := m.deleteSelectedHistoryEntries()
					m.clearHistorySelections()
					if deleted > 0 || failed > 0 {
						m.syncHistory()
					}
					if err != nil {
						level := statusError
						if deleted > 0 {
							level = statusWarn
						}
						m.setStatusMessage(
							statusMsg{
								text: fmt.Sprintf(
									"Deleted %d history entries, failed %d: %v",
									deleted,
									failed,
									err,
								),
								level: level,
							},
						)
						return combine(nil)
					}
					if deleted == 0 {
						m.setStatusMessage(
							statusMsg{text: "History entries not found", level: statusWarn},
						)
						return combine(nil)
					}
					label := "entries"
					if deleted == 1 {
						label = "entry"
					}
					m.setStatusMessage(
						statusMsg{
							text:  fmt.Sprintf("Deleted %d history %s", deleted, label),
							level: statusInfo,
						},
					)
					return combine(nil)
				}
				if entry, ok := m.selectedHistoryEntry(); ok {
					if deleted, err := m.deleteHistoryEntry(entry.ID); err != nil {
						m.setStatusMessage(
							statusMsg{
								text:  fmt.Sprintf("history delete error: %v", err),
								level: statusError,
							},
						)
					} else if deleted {
						m.syncHistory()
						m.setStatusMessage(
							statusMsg{text: "History entry deleted", level: statusInfo},
						)
					} else {
						m.setStatusMessage(
							statusMsg{text: "History entry not found", level: statusWarn},
						)
					}
				} else {
					m.setStatusMessage(
						statusMsg{text: "No history entry selected", level: statusWarn},
					)
				}
				return combine(nil)
			case "r", "R", "ctrl+r", "ctrl+R":
				return combine(m.replayHistorySelection())
			case "enter":
				// handled above
			default:
				if m.shouldSendEditorRequest(msg, false) {
					if cmd := m.cancelActiveRuns(); cmd != nil {
						return combine(cmd)
					}
					return combine(m.replayHistorySelection())
				}
			}
		}
	}

	if m.isSendShortcut(msg) {
		return combine(m.sendActiveRequest())
	}

	if m.focus != focusFile && m.focus != focusRequests && m.focus != focusWorkflows {
		m.suppressListKey = false
	}

	return combine(nil)
}

func (m *Model) canStartChord(msg tea.KeyMsg, key string) bool {
	if key == "" || m.bindingsMap == nil {
		return false
	}
	if m.websocketConsoleCapturesInput() {
		return false
	}
	if !m.bindingsMap.HasChordPrefix(key) {
		return false
	}
	if m.editor.awaitingFindTarget() {
		return false
	}
	if m.focus == focusEditor && m.editorInsertMode && isPlainRuneKey(msg) {
		return false
	}
	return true
}

func (m *Model) resolveChord(prefix string, next string, msg tea.KeyMsg) (bool, tea.Cmd) {
	if m.bindingsMap == nil || prefix == "" || next == "" {
		return false, nil
	}
	binding, ok := m.bindingsMap.ResolveChord(prefix, next)
	if !ok {
		return false, nil
	}
	if binding.Repeatable {
		m.repeatChordPrefix = prefix
		m.repeatChordKey = next
		m.repeatChordActive = true
	} else {
		m.clearRepeatChord()
	}
	cmd, handled := m.runShortcutBinding(binding, msg)
	if !handled {
		return false, nil
	}
	return true, cmd
}

func (m *Model) startOperator(op string) {
	m.operator.active = true
	m.operator.operator = op
	m.operator.anchor = m.editor.caretPosition()
	if m.operator.motionKeys != nil {
		m.operator.motionKeys = m.operator.motionKeys[:0]
	}
}

func (m *Model) clearOperatorState() {
	m.operator.active = false
	m.operator.operator = ""
	m.operator.anchor = cursorPosition{}
	m.operator.motionKeys = nil
}

func (m *Model) handleOperatorKey(msg tea.KeyMsg) tea.Cmd {
	keyStr := msg.String()
	m.suppressListKey = true
	switch keyStr {
	case "esc", "ctrl+c", "ctrl+g":
		m.clearOperatorState()
		return nil
	}

	op := m.operator.operator
	// Avoid treating "d" as dd when it's the target for a pending find motion.
	if op == "d" && keyStr == "d" && !m.editor.awaitingFindTarget() {
		m.clearOperatorState()
		return m.runDeleteCurrentLine()
	}
	// Same guard for cc when a find target is pending.
	if op == "c" && keyStr == "c" && !m.editor.awaitingFindTarget() {
		m.clearOperatorState()
		return batchCommands(
			m.runChangeCurrentLine(),
			m.setInsertMode(true, true),
		)
	}

	rawKey := keyStr
	motionKey := keyStr
	if op == "c" && !m.editor.awaitingFindTarget() {
		// Map cw/cW to ce/cE so change doesn't eat trailing whitespace.
		motionKey = mapChangeMotionKey(keyStr)
	}

	updated, motionCmd, handled := m.editor.HandleMotion(motionKey)
	if !handled {
		m.clearOperatorState()
		action := "Delete"
		if op == "c" {
			action = "Change"
		}
		status := statusMsg{text: action + " requires a motion", level: statusWarn}
		return toEditorEventCmd(editorEvent{status: &status})
	}

	m.operator.motionKeys = append(m.operator.motionKeys, rawKey)
	m.editor = updated

	if m.editor.pendingMotion != "" || m.editor.awaitingFindTarget() {
		return motionCmd
	}

	var (
		spec deleteMotionSpec
		err  error
	)
	switch op {
	case "c":
		spec, err = classifyChangeMotion(m.operator.motionKeys)
	default:
		spec, err = classifyDeleteMotion(m.operator.motionKeys)
	}
	if err != nil {
		anchor := m.operator.anchor
		editorPtr := &m.editor
		editorPtr.moveCursorTo(anchor.Line, anchor.Column)
		editorPtr.applySelectionHighlight()
		m.clearOperatorState()
		status := statusMsg{text: err.Error(), level: statusWarn}
		return batchCommands(motionCmd, toEditorEventCmd(editorEvent{status: &status}))
	}

	anchor := m.operator.anchor
	var actionCmd tea.Cmd
	switch op {
	case "c":
		actionCmd = batchCommands(
			m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
				return ed.ChangeMotion(anchor, spec)
			}),
			m.setInsertMode(true, true),
		)
	default:
		actionCmd = m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
			return ed.DeleteMotion(anchor, spec)
		})
	}
	m.clearOperatorState()
	return batchCommands(motionCmd, actionCmd)
}

func batchCommands(cmds ...tea.Cmd) tea.Cmd {
	var nonNil []tea.Cmd
	for _, cmd := range cmds {
		if cmd != nil {
			nonNil = append(nonNil, cmd)
		}
	}
	switch len(nonNil) {
	case 0:
		return nil
	case 1:
		return nonNil[0]
	default:
		return tea.Batch(nonNil...)
	}
}

func (m *Model) runEditorResize(delta float64) tea.Cmd {
	if m.zoomActive {
		m.setStatusMessage(statusMsg{text: "Disable zoom to resize panes", level: statusInfo})
		return nil
	}
	if m.collapseState(paneRegionEditor) || m.collapseState(paneRegionResponse) {
		m.setStatusMessage(statusMsg{text: "Expand panes before resizing", level: statusInfo})
		return nil
	}
	changed, bounded, cmd := m.adjustEditorSplit(delta)
	if changed {
		return cmd
	}
	if bounded {
		if delta < 0 {
			m.setStatusMessage(
				statusMsg{text: "Editor already at minimum width", level: statusInfo},
			)
		} else if delta > 0 {
			m.setStatusMessage(
				statusMsg{text: "Editor already at maximum width", level: statusInfo},
			)
		}
	}
	return nil
}

func (m *Model) runSidebarWidthResize(delta float64) tea.Cmd {
	changed, bounded, cmd := m.adjustSidebarWidth(delta)
	if changed {
		return cmd
	}
	if bounded {
		if delta < 0 {
			m.setStatusMessage(
				statusMsg{text: "Sidebar already at minimum width", level: statusInfo},
			)
		} else if delta > 0 {
			m.setStatusMessage(
				statusMsg{text: "Sidebar already at maximum width", level: statusInfo},
			)
		}
	}
	return nil
}

func (m *Model) runSidebarResize(delta float64) tea.Cmd {
	if m.navigator == nil {
		return nil
	}
	if delta > 0 {
		if n := m.navigator.Selected(); n != nil {
			switch n.Kind {
			case navigator.KindFile:
				if filesvc.IsRequestFile(n.Payload.FilePath) {
					m.navExpandFile(n, false)
				}
			case navigator.KindDir:
				m.navExpandDir(n, false)
			}
		}
	} else if delta < 0 {
		if n := m.navigator.Selected(); n != nil && n.Expanded {
			n.Expanded = false
			m.navigator.Refresh()
		}
	}
	return nil
}

func (m *Model) runWorkflowResize(delta float64) tea.Cmd {
	if m.navigator == nil {
		return nil
	}
	if delta > 0 {
		m.navigator.CollapseAll()
	} else if delta < 0 {
		m.navigator.ExpandAll()
	}
	return nil
}

func (m *Model) updateEditor(msg tea.Msg) tea.Cmd {
	beforeRevision := m.editor.Revision()
	var cmd tea.Cmd
	m.editor, cmd = m.editor.Update(msg)
	if m.editor.Revision() != beforeRevision {
		m.markDirty()
	}
	return cmd
}

func (m *Model) mutateEditor(op func(requestEditor) (requestEditor, tea.Cmd)) tea.Cmd {
	beforeRevision := m.editor.Revision()
	editor, cmd := op(m.editor)
	m.editor = editor
	if editor.Revision() != beforeRevision {
		m.markDirty()
	}
	return cmd
}

func (m *Model) runEditorInsertAction(action editorInsertAction) tea.Cmd {
	actionCmd := m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
		return ed.ApplyInsertAction(action)
	})
	m.suppressEditorKey = true
	return batchCommands(actionCmd, m.setInsertMode(true, true))
}

func (m *Model) runDeleteSelection() tea.Cmd {
	return m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
		return ed.DeleteSelection()
	})
}

func (m *Model) runUndoLastChange() tea.Cmd {
	return m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
		return ed.UndoLastChange()
	})
}

func (m *Model) runDeleteCurrentLine() tea.Cmd {
	return m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
		return ed.DeleteCurrentLine()
	})
}

func (m *Model) runDeleteToLineEnd() tea.Cmd {
	return m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
		return ed.DeleteToLineEnd()
	})
}

func (m *Model) runDeleteCharAtCursor() tea.Cmd {
	return m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
		return ed.DeleteCharAtCursor()
	})
}

func (m *Model) runChangeCurrentLine() tea.Cmd {
	return m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
		return ed.ChangeCurrentLine()
	})
}

func (m *Model) runPasteClipboard(after bool) tea.Cmd {
	return m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
		return ed.PasteClipboard(after)
	})
}

func (m *Model) runRedoLastChange() tea.Cmd {
	return m.mutateEditor(func(ed requestEditor) (requestEditor, tea.Cmd) {
		return ed.RedoLastChange()
	})
}

func (m *Model) updateNotice(ver string) string {
	cmd := strings.TrimSpace(m.updateCmd)
	if cmd == "" {
		cmd = "resterm --update"
	}
	return fmt.Sprintf("Update available: %s (run `%s`)", ver, cmd)
}
