use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Position;

use crate::app::{
    self, App, CommandCompletionState, ExpandDirection, FileTreeItem, FileTreePrompt, FocusedPanel,
    GapCursorHit, InputMode, TargetTab, VisualSelection,
};
use crate::forge::remote_comments::PrCommentsVisibility;
use crate::forge::submit::SubmitEvent;
use crate::input::Action;
use crate::model::{ClearScope, LineSide};
use crate::output::{export_to_clipboard, generate_export_content};
use crate::text_edit::{
    delete_char_before, delete_word_before, next_char_boundary, prev_char_boundary,
};

const WHEEL_LINES: usize = 3;
/// Columns scrolled per horizontal mouse wheel tick. Matches the default
/// step for keyboard arrow scrolling so the two input methods feel
/// interchangeable.
const WHEEL_COLS: usize = 4;

const COMMAND_SPECS: &[CommandSpec] = &[
    CommandSpec::new(&["q", "quit"], CommandKind::Quit),
    CommandSpec::new(&["q!", "quit!"], CommandKind::ForceQuit),
    CommandSpec::new(&["w", "write"], CommandKind::Write),
    CommandSpec::new(&["x", "wq"], CommandKind::WriteQuit),
    CommandSpec::new(&["e", "reload"], CommandKind::Reload),
    CommandSpec::new(&["edit"], CommandKind::Edit),
    CommandSpec::new(&["clip", "export"], CommandKind::Export),
    CommandSpec::new(
        &["clear"],
        CommandKind::Clear(ClearScope::CommentsAndReviewed),
    ),
    CommandSpec::new(&["clearc"], CommandKind::Clear(ClearScope::CommentsOnly)),
    CommandSpec::new(&["help", "h"], CommandKind::Help),
    CommandSpec::new(&["version"], CommandKind::Version),
    CommandSpec::new(&["update"], CommandKind::Update),
    CommandSpec::new(&["set wrap"], CommandKind::SetWrap),
    CommandSpec::new(&["set wrap!"], CommandKind::ToggleWrap),
    CommandSpec::new(&["wrap"], CommandKind::ToggleWrap),
    CommandSpec::new(
        &["set relativenumber"],
        CommandKind::SetRelativeLineNumbers(true),
    ),
    CommandSpec::new(
        &["set norelativenumber"],
        CommandKind::SetRelativeLineNumbers(false),
    ),
    CommandSpec::new(
        &["set relativenumber!"],
        CommandKind::ToggleRelativeLineNumbers,
    ),
    CommandSpec::new(&["vim", "set vim!"], CommandKind::ToggleVim),
    CommandSpec::new(&["set vim"], CommandKind::SetVim(true)),
    CommandSpec::new(&["novim", "set novim"], CommandKind::SetVim(false)),
    CommandSpec::new(&["set commits"], CommandKind::SetCommitsVisible(true)),
    CommandSpec::new(&["set nocommits"], CommandKind::SetCommitsVisible(false)),
    CommandSpec::new(&["set commits!"], CommandKind::ToggleCommits),
    CommandSpec::new(&["diff"], CommandKind::Diff),
    CommandSpec::new(&["focus", "f"], CommandKind::Focus),
    CommandSpec::new(&["stage"], CommandKind::Stage),
    CommandSpec::new(
        &["commits", "targets"],
        CommandKind::Targets(TargetTab::Local),
    ),
    CommandSpec::new(&["prs"], CommandKind::Targets(TargetTab::PullRequests)),
    CommandSpec::new(&["submit"], CommandKind::SubmitPicker),
    CommandSpec::new(
        &["submit comment"],
        CommandKind::Submit(SubmitEvent::Comment),
    ),
    CommandSpec::new(
        &["submit approve"],
        CommandKind::Submit(SubmitEvent::Approve),
    ),
    CommandSpec::new(
        &["submit request-changes"],
        CommandKind::Submit(SubmitEvent::RequestChanges),
    ),
    CommandSpec::new(&["submit draft"], CommandKind::Submit(SubmitEvent::Draft)),
    CommandSpec::new(
        &["comments unresolved"],
        CommandKind::Comments(PrCommentsVisibility::Unresolved),
    ),
    CommandSpec::new(
        &["comments all"],
        CommandKind::Comments(PrCommentsVisibility::All),
    ),
    CommandSpec::new(
        &["comments hide"],
        CommandKind::Comments(PrCommentsVisibility::Hide),
    ),
];

/// CommandSpec is the single registry entry used by both completion and
/// command-mode dispatch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CommandSpec {
    /// Accepted command strings, including aliases.
    names: &'static [&'static str],
    /// Behavior shared by every alias in `names`.
    kind: CommandKind,
}

impl CommandSpec {
    const fn new(names: &'static [&'static str], kind: CommandKind) -> Self {
        Self { names, kind }
    }
}

/// CommandKind is the parsed meaning of one command-mode input.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommandKind {
    Quit,
    ForceQuit,
    Write,
    WriteQuit,
    Reload,
    Edit,
    Export,
    Clear(ClearScope),
    Help,
    Version,
    Update,
    SetWrap,
    ToggleWrap,
    SetRelativeLineNumbers(bool),
    ToggleRelativeLineNumbers,
    ToggleVim,
    SetVim(bool),
    SetCommitsVisible(bool),
    ToggleCommits,
    Diff,
    Focus,
    Stage,
    Targets(TargetTab),
    SubmitPicker,
    Submit(SubmitEvent),
    Comments(PrCommentsVisibility),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommandAfterDispatch {
    ExitCommandMode,
    KeepMode,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompletionDirection {
    Forward,
    Reverse,
}

pub fn handle_mouse_event(app: &mut App, event: MouseEvent) {
    let pos = Position::new(event.column, event.row);
    match event.kind {
        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
            let scroll_up = matches!(event.kind, MouseEventKind::ScrollUp);
            let action = if scroll_up {
                Action::MouseScrollUp(WHEEL_LINES)
            } else {
                Action::MouseScrollDown(WHEEL_LINES)
            };
            let over_file_list = app.file_list_area.is_some_and(|r| r.contains(pos));
            let over_diff = app.diff_area.is_some_and(|r| r.contains(pos));
            let over_commit_list = app.commit_list_inner_area.is_some_and(|r| r.contains(pos));
            match app.input_mode {
                InputMode::Help => handle_help_action(app, action),
                InputMode::CommitSelect | InputMode::Normal if over_commit_list => {
                    wheel_commit_list(app, scroll_up);
                }
                InputMode::Normal if over_file_list => handle_file_list_action(app, action),
                InputMode::Normal
                    if app.comment_navigator_area.is_some_and(|r| r.contains(pos)) =>
                {
                    handle_comment_navigator_action(app, action)
                }
                InputMode::Normal if over_diff => handle_diff_action(app, action),
                InputMode::VisualSelect if over_diff => handle_diff_action(app, action),
                _ => {}
            }
            clear_visual_if_cursor_offscreen(app);
        }
        MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {
            // Bypass the Action layer: trackpad swipes dispatch directly to
            // the diff viewport so they don't trigger keyboard focus-slide
            // (FEAT-0012) or abort an active VisualSelect.
            let scroll_left = matches!(event.kind, MouseEventKind::ScrollLeft);
            let over_diff = app.diff_area.is_some_and(|r| r.contains(pos));
            if over_diff && matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) {
                if scroll_left {
                    app.scroll_left(WHEEL_COLS);
                } else {
                    app.scroll_right(WHEEL_COLS);
                }
            }
        }
        MouseEventKind::Down(MouseButton::Left)
            if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) =>
        {
            // Set focus based on the outer panel area (includes the
            // border) so clicks on dead space still move focus.
            if app.file_list_area.is_some_and(|r| r.contains(pos)) {
                app.focused_panel = FocusedPanel::FileList;
            } else if app.diff_area.is_some_and(|r| r.contains(pos)) {
                app.focused_panel = FocusedPanel::Diff;
            }
            // Reset back to Normal so an Up without drag falls through to handle_left_click.
            if app.input_mode == InputMode::VisualSelect {
                app.exit_visual_mode();
            }
            app.mouse_drag_active = false;
            if app.diff_inner_area.is_some_and(|r| r.contains(pos))
                && let Some(point) = app.cell_to_sel_point(pos.x, pos.y)
            {
                app.visual_selection = Some(VisualSelection::collapsed(point));
                if let Some(idx) = app.diff_annotation_at_screen_row(pos.y) {
                    app.move_cursor_to_annotation(idx);
                }
            } else {
                app.visual_selection = None;
                handle_left_click(app, pos);
            }
        }
        MouseEventKind::Down(MouseButton::Left) if app.input_mode == InputMode::CommitSelect => {
            if let Some(idx) = app.commit_list_idx_at_screen_row(pos.y) {
                app.commit_list_cursor = idx;
                handle_commit_select_action(app, Action::ToggleCommitSelect);
            }
        }
        MouseEventKind::Drag(MouseButton::Left)
            if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) =>
        {
            let Some(sel) = app.visual_selection else {
                return;
            };
            let Some(mut head) = app.cell_to_sel_point(pos.x, pos.y) else {
                return;
            };
            // Pin head to the anchor's pane so cross-side drags don't escape.
            head.side = sel.anchor.side;
            if head == sel.head && app.mouse_drag_active {
                return;
            }
            let moved = head != sel.head;
            let promoted_now = moved && !app.mouse_drag_active;
            app.visual_selection = Some(VisualSelection {
                anchor: sel.anchor,
                head,
            });
            if moved {
                app.mouse_drag_active = true;
            }
            if promoted_now && app.input_mode == InputMode::Normal {
                app.input_mode = InputMode::VisualSelect;
            }
            if app.input_mode == InputMode::VisualSelect
                && head.annotation_idx != sel.head.annotation_idx
            {
                app.move_cursor_to_annotation(head.annotation_idx);
            }
        }
        MouseEventKind::Up(MouseButton::Left)
            if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) =>
        {
            if app.visual_selection.is_none() {
                return;
            }
            if !app.mouse_drag_active {
                app.visual_selection = None;
                if app.input_mode == InputMode::VisualSelect {
                    app.exit_visual_mode();
                }
                handle_left_click(app, pos);
            }
            app.mouse_drag_active = false;
        }
        _ => {}
    }
}

/// Helix-style: scrolling the cursor off-viewport drops the selection.
pub fn clear_visual_if_cursor_offscreen(app: &mut App) {
    if app.input_mode == InputMode::VisualSelect && !app.is_cursor_visible() {
        app.exit_visual_mode();
    }
}

/// Wheel scroll inside a commit list (full-screen picker or inline selector).
/// The list is short and selection-oriented, so each tick moves the cursor
/// rather than the viewport, matching how arrow keys behave.
fn wheel_commit_list(app: &mut App, scroll_up: bool) {
    // Match screen direction: the inline pane (Normal mode) reverses its
    // display in ascending order, so wheel-up moves toward newer (lower) data
    // indices there. The full-screen picker (CommitSelect) is always
    // newest-first.
    let scroll_up = if app.input_mode == InputMode::Normal && app.commits_ascending() {
        !scroll_up
    } else {
        scroll_up
    };
    for _ in 0..WHEEL_LINES {
        if scroll_up {
            app.commit_select_up();
        } else {
            app.commit_select_down();
        }
    }
}

fn handle_left_click(app: &mut App, pos: Position) {
    if app.file_list_inner_area.is_some_and(|r| r.contains(pos))
        && let Some(idx) = app.file_list_idx_at_screen_row(pos.y)
    {
        app.focused_panel = FocusedPanel::FileList;
        app.file_list_state.select(idx);
        if let Some(item) = app.build_visible_items().get(idx).cloned() {
            match item {
                FileTreeItem::Directory { path, .. } => app.toggle_directory(&path),
                FileTreeItem::File { file_idx, .. } => {
                    app.jump_to_file(file_idx);
                    app.focused_panel = FocusedPanel::Diff;
                }
            }
        }
        return;
    }

    if app
        .comment_navigator_inner_area
        .is_some_and(|r| r.contains(pos))
        && let Some(idx) = app.comment_navigator_idx_at_screen_row(pos.y)
    {
        app.focused_panel = FocusedPanel::Comments;
        app.comment_navigator_state.select(idx);
        app.jump_to_selected_comment();
        return;
    }

    if app.has_inline_commit_selector()
        && app.commit_list_inner_area.is_some_and(|r| r.contains(pos))
        && let Some(idx) = app.commit_list_idx_at_screen_row(pos.y)
    {
        app.focused_panel = FocusedPanel::CommitSelector;
        app.commit_list_cursor = idx;
        handle_commit_selector_action(app, Action::SelectFile);
        return;
    }

    if app.diff_inner_area.is_some_and(|r| r.contains(pos))
        && let Some(idx) = app.diff_annotation_at_screen_row(pos.y)
    {
        app.focused_panel = FocusedPanel::Diff;
        app.move_cursor_to_annotation(idx);
        handle_diff_action(app, Action::SelectFile);
    }
}

/// Export review: either to clipboard or set pending stdout output based on app.output_to_stdout.
/// When output_to_stdout is true, stores the content and sets should_quit.
fn handle_export(app: &mut App) {
    let slug = app.session_slug();
    if app.output_to_stdout {
        match generate_export_content(
            &app.session,
            &app.diff_source,
            &app.comment_types,
            &app.export,
            &app.forge_review_threads,
            slug.as_deref(),
        ) {
            Ok(content) => {
                app.pending_stdout_output = Some(content);
                app.should_quit = true;
            }
            Err(e) => app.set_warning(format!("{e}")),
        }
    } else {
        match export_to_clipboard(
            &app.session,
            &app.diff_source,
            &app.comment_types,
            &app.export,
            &app.forge_review_threads,
            slug.as_deref(),
        ) {
            Ok(msg) => app.set_message(msg),
            Err(e) => app.set_warning(format!("{e}")),
        }
    }
}

/// Export and quit (used by ZZ keybinding).
/// When --stdout is set, stores export content and quits.
/// Otherwise, exports to clipboard and quits.
pub fn handle_export_and_quit(app: &mut App) {
    handle_export(app);
    app.should_quit = true;
}

fn comment_line_start(buffer: &str, cursor: usize) -> usize {
    let cursor = cursor.min(buffer.len());
    match buffer[..cursor].rfind('\n') {
        Some(pos) => pos + 1,
        None => 0,
    }
}

fn comment_line_end(buffer: &str, cursor: usize) -> usize {
    let cursor = cursor.min(buffer.len());
    match buffer[cursor..].find('\n') {
        Some(pos) => cursor + pos,
        None => buffer.len(),
    }
}

fn comment_word_left(buffer: &str, cursor: usize) -> usize {
    let cursor = cursor.min(buffer.len());
    if cursor == 0 {
        return 0;
    }
    let before = &buffer[..cursor];
    let mut idx = 0;
    let mut found_word = false;
    for (pos, ch) in before.char_indices().rev() {
        if !ch.is_whitespace() {
            idx = pos;
            found_word = true;
            break;
        }
    }

    if !found_word {
        return 0;
    }

    for (pos, ch) in before[..idx].char_indices().rev() {
        if ch.is_whitespace() {
            return pos + ch.len_utf8();
        }
        idx = pos;
    }

    idx
}

fn comment_word_right(buffer: &str, cursor: usize) -> usize {
    let cursor = cursor.min(buffer.len());
    if cursor >= buffer.len() {
        return buffer.len();
    }

    let mut chars = buffer[cursor..].char_indices();
    if let Some((_, ch)) = chars.next()
        && ch.is_whitespace()
    {
        for (pos, ch) in buffer[cursor..].char_indices() {
            if !ch.is_whitespace() {
                return cursor + pos;
            }
        }
        return buffer.len();
    }

    let mut word_end = buffer.len();
    for (pos, ch) in buffer[cursor..].char_indices() {
        if ch.is_whitespace() {
            word_end = cursor + pos;
            break;
        }
    }

    if word_end >= buffer.len() {
        return buffer.len();
    }

    for (pos, ch) in buffer[word_end..].char_indices() {
        if !ch.is_whitespace() {
            return word_end + pos;
        }
    }

    buffer.len()
}

/// Append a pasted payload into a single-line buffer, dropping embedded
/// newlines so a multi-line paste doesn't smear across the prompt.
fn push_single_line(buffer: &mut String, text: &str) {
    for ch in text.chars() {
        if matches!(ch, '\n' | '\r') {
            continue;
        }
        buffer.push(ch);
    }
}

/// Handle actions in Help mode (scrolling only)
pub fn handle_help_action(app: &mut App, action: Action) {
    match action {
        Action::CursorDown(n) => app.help_scroll_down(n),
        Action::CursorUp(n) => app.help_scroll_up(n),
        Action::HalfPageDown => app.help_scroll_down(app.help_state.viewport_height / 2),
        Action::HalfPageUp => app.help_scroll_up(app.help_state.viewport_height / 2),
        Action::PageDown => app.help_scroll_down(app.help_state.viewport_height),
        Action::PageUp => app.help_scroll_up(app.help_state.viewport_height),
        Action::GoToTop => app.help_scroll_to_top(),
        Action::GoToBottom => app.help_scroll_to_bottom(),
        Action::MouseScrollDown(n) => app.help_scroll_down(n),
        Action::MouseScrollUp(n) => app.help_scroll_up(n),
        Action::EnterSearchMode => app.enter_search_mode(),
        Action::SearchNext => {
            app.search_next_in_help();
        }
        Action::SearchPrev => {
            app.search_prev_in_help();
        }
        Action::ToggleHelp => app.toggle_help(),
        Action::Quit => app.should_quit = true,
        _ => {}
    }
}

/// Handle actions in Command mode (text input for :commands)
pub fn handle_command_action(app: &mut App, action: Action) {
    match action {
        Action::InsertChar(c) => {
            app.command_completion = None;
            app.command_buffer.push(c);
        }
        Action::Paste(text) => {
            app.command_completion = None;
            push_single_line(&mut app.command_buffer, &text);
        }
        Action::DeleteChar => {
            app.command_completion = None;
            app.command_buffer.pop();
        }
        Action::DeleteWord => {
            app.command_completion = None;
            let cursor = app.command_buffer.len();
            delete_word_before(&mut app.command_buffer, cursor);
        }
        Action::ClearLine => {
            app.command_completion = None;
            app.command_buffer.clear();
        }
        Action::CompleteCommand => complete_command(app, CompletionDirection::Forward),
        Action::CompleteCommandReverse => complete_command(app, CompletionDirection::Reverse),
        Action::ExitMode => app.exit_command_mode(),
        Action::SubmitInput => {
            app.command_completion = None;
            let cmd = app.command_buffer.trim().to_string();
            let after_dispatch = if let Some(spec) = command_spec_for(&cmd) {
                dispatch_command(app, spec.kind)
            } else if let Some((lineno, side)) = parse_lineno_command(&cmd) {
                app.go_to_source_line(lineno, side);
                CommandAfterDispatch::ExitCommandMode
            } else {
                app.set_message(format!("Unknown command: {cmd}"));
                CommandAfterDispatch::ExitCommandMode
            };
            if matches!(after_dispatch, CommandAfterDispatch::ExitCommandMode) {
                app.exit_command_mode();
            }
        }
        Action::Quit => app.should_quit = true,
        _ => {}
    }
}

fn command_spec_for(cmd: &str) -> Option<&'static CommandSpec> {
    COMMAND_SPECS.iter().find(|spec| spec.names.contains(&cmd))
}

#[cfg(test)]
mod relative_line_number_command_tests {
    use super::{CommandKind, command_spec_for};

    #[test]
    fn parses_relative_line_number_commands() {
        assert_eq!(
            command_spec_for("set relativenumber").map(|spec| spec.kind),
            Some(CommandKind::SetRelativeLineNumbers(true))
        );
        assert_eq!(
            command_spec_for("set norelativenumber").map(|spec| spec.kind),
            Some(CommandKind::SetRelativeLineNumbers(false))
        );
        assert_eq!(
            command_spec_for("set relativenumber!").map(|spec| spec.kind),
            Some(CommandKind::ToggleRelativeLineNumbers)
        );
    }
}

/// CommandCompleter computes command-buffer replacements without mutating App.
struct CommandCompleter<'a> {
    /// Registry whose command names are exposed as completion candidates.
    specs: &'a [CommandSpec],
}

impl<'a> CommandCompleter<'a> {
    /// Build a completer over the command registry it should expose.
    pub fn new(specs: &'a [CommandSpec]) -> Self {
        Self { specs }
    }

    /// Return the next command-completion state for the current prompt state.
    ///
    /// `buffer` is the command text currently shown to the user. `active` is
    /// the previous completion cycle, if repeated Tab presses are cycling an
    /// existing match set. The result describes the next buffer contents and
    /// active completion cycle without mutating App.
    pub fn complete(
        &self,
        buffer: &str,
        active: Option<&CommandCompletionState>,
        direction: CompletionDirection,
    ) -> CompletionResult {
        if let Some(result) = self.cycle_existing(buffer, active, direction) {
            return result;
        }

        let prefix = buffer.trim_start().to_string();
        let matches: Vec<&'static str> = self
            .command_names()
            .filter(|command| command.starts_with(&prefix))
            .collect();

        match matches.len() {
            0 => CompletionResult::NoMatch,
            1 => CompletionResult::replace(matches[0], None),
            _ => {
                let common_prefix = Self::common_prefix(&matches);
                if common_prefix.len() > prefix.len() {
                    CompletionResult::replace(common_prefix, None)
                } else {
                    self.start_cycle(buffer, prefix, matches, direction)
                }
            }
        }
    }

    fn command_names(&self) -> impl Iterator<Item = &'static str> + '_ {
        self.specs
            .iter()
            .flat_map(|spec| spec.names.iter().copied())
    }

    fn cycle_existing(
        &self,
        buffer: &str,
        active: Option<&CommandCompletionState>,
        direction: CompletionDirection,
    ) -> Option<CompletionResult> {
        let completion = active?;
        let current = completion.matches.get(completion.selected)?;
        if buffer != *current {
            return None;
        }

        let selected = Self::next_index(completion.selected, completion.matches.len(), direction);
        Some(CompletionResult::replace(
            completion.matches[selected],
            Some(CommandCompletionState {
                prefix: completion.prefix.clone(),
                matches: completion.matches.clone(),
                selected,
            }),
        ))
    }

    fn start_cycle(
        &self,
        buffer: &str,
        prefix: String,
        matches: Vec<&'static str>,
        direction: CompletionDirection,
    ) -> CompletionResult {
        let selected = matches
            .iter()
            .position(|command| *command == buffer)
            .map(|idx| Self::next_index(idx, matches.len(), direction))
            .unwrap_or_else(|| match direction {
                CompletionDirection::Forward => 0,
                CompletionDirection::Reverse => matches.len() - 1,
            });

        CompletionResult::replace(
            matches[selected],
            Some(CommandCompletionState {
                prefix,
                matches,
                selected,
            }),
        )
    }

    fn next_index(selected: usize, match_count: usize, direction: CompletionDirection) -> usize {
        match direction {
            CompletionDirection::Forward => (selected + 1) % match_count,
            CompletionDirection::Reverse => (selected + match_count - 1) % match_count,
        }
    }

    fn common_prefix(matches: &[&str]) -> String {
        let mut prefix = matches
            .first()
            .map(|command| (*command).to_string())
            .unwrap_or_default();
        for command in &matches[1..] {
            while !command.starts_with(&prefix) {
                prefix.pop();
            }
        }
        prefix
    }
}

/// CompletionResult tells the handler how to update command-mode state.
enum CompletionResult {
    /// Leave the buffer unchanged and surface a no-match message.
    NoMatch,
    /// Replace the buffer and optionally keep a cycling state active.
    Replace {
        /// New command-buffer contents.
        buffer: String,
        /// Completion state used by the next Tab press, if cycling started.
        completion: Option<CommandCompletionState>,
    },
}

impl CompletionResult {
    fn replace(
        buffer: impl Into<String>,
        completion: Option<CommandCompletionState>,
    ) -> CompletionResult {
        CompletionResult::Replace {
            buffer: buffer.into(),
            completion,
        }
    }
}

fn dispatch_command(app: &mut App, kind: CommandKind) -> CommandAfterDispatch {
    match kind {
        CommandKind::Quit => {
            if app.dirty && app.session.has_comments() {
                app.set_error("No write since last change (add ! to override)");
            } else if app.dirty {
                // Dirty from reviewed-file markers only: discard the state and
                // quit instead of requiring `:q!`.
                app.discard_session_and_quit();
            } else {
                app.should_quit = true;
            }
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::ForceQuit => {
            app.should_quit = true;
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Write => {
            match app.save_current_session_merging_external() {
                Ok(path) => {
                    app.set_message(format!("Saved to {}", path.display()));
                }
                Err(e) => app.set_error(format!("Save failed: {e}")),
            }
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::WriteQuit => {
            match app.save_current_session_merging_external() {
                Ok(_) => {
                    if app.session.has_comments() {
                        if app.output_to_stdout {
                            // Skip confirmation dialog, export directly.
                            handle_export(app);
                        } else {
                            app.exit_command_mode();
                            app.enter_confirm_mode(app::ConfirmAction::CopyAndQuit);
                            return CommandAfterDispatch::KeepMode;
                        }
                    } else {
                        app.should_quit = true;
                    }
                }
                Err(e) => app.set_error(format!("Save failed: {e}")),
            }
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Reload => {
            reload_review(app);
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Edit => {
            app.queue_editor_for_focused_item();
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Export => {
            handle_export(app);
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Clear(scope) => {
            app.clear_comments(scope);
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Help => {
            // Leave command mode by hand: the dispatch loop's ExitCommandMode
            // path resets `input_mode` to Normal, which would clobber Help.
            app.exit_command_mode();
            app.toggle_help();
            CommandAfterDispatch::KeepMode
        }
        CommandKind::Version => {
            app.set_message(format!("tuicr v{}", env!("CARGO_PKG_VERSION")));
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Update => {
            check_for_updates(app);
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::SetWrap => {
            app.set_diff_wrap(true);
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::ToggleWrap => {
            app.toggle_diff_wrap();
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::SetRelativeLineNumbers(enabled) => {
            app.relative_line_numbers = enabled;
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::ToggleRelativeLineNumbers => {
            app.relative_line_numbers = !app.relative_line_numbers;
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::ToggleVim => {
            app.toggle_comment_vim();
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::SetVim(enabled) => {
            app.set_comment_vim(enabled);
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::SetCommitsVisible(visible) => {
            set_commit_selector_visible(app, visible);
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::ToggleCommits => {
            app.toggle_commit_selector();
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Diff => {
            app.toggle_diff_view_mode();
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Focus => {
            app.toggle_single_file_view();
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Stage => {
            app.stage_reviewed_files();
            CommandAfterDispatch::ExitCommandMode
        }
        CommandKind::Targets(tab) => {
            let result = app.enter_target_selector(tab);
            match (tab, result) {
                (_, Ok(())) => CommandAfterDispatch::KeepMode,
                (TargetTab::Local, Err(e)) => {
                    app.set_error(format!("Failed to load commits: {e}"));
                    CommandAfterDispatch::ExitCommandMode
                }
                (TargetTab::PullRequests, Err(e)) => {
                    app.set_error(format!("Failed to open PR selector: {e}"));
                    CommandAfterDispatch::ExitCommandMode
                }
            }
        }
        CommandKind::SubmitPicker => {
            app.exit_command_mode();
            app.start_submit_action_picker();
            CommandAfterDispatch::KeepMode
        }
        CommandKind::Submit(event) => {
            app.exit_command_mode();
            app.start_submit(event);
            CommandAfterDispatch::KeepMode
        }
        CommandKind::Comments(visibility) => {
            set_remote_comments_visibility(app, visibility);
            CommandAfterDispatch::ExitCommandMode
        }
    }
}

fn reload_review(app: &mut App) {
    let comment_reload = app.reload_persisted_session_if_changed(true);
    if matches!(app.diff_source, app::DiffSource::PullRequest(_)) {
        if let Err(e) = comment_reload {
            app.set_warning(format!("Comment reload failed: {e}"));
        }
        // Async: shows a spinner in the status bar; result is applied in
        // `poll_pr_reload_events` and the cursor is restored to the captured
        // anchor.
        if let Err(e) = app.spawn_pr_reload() {
            app.set_error(format!("Reload failed: {e}"));
        }
    } else {
        match app.reload_diff_files() {
            Ok((count, invalidated)) => {
                let comment_suffix = match comment_reload {
                    Ok(added) if added > 0 => {
                        format!(", loaded {added} external comments")
                    }
                    Ok(_) => String::new(),
                    Err(e) => format!(", comment reload failed: {e}"),
                };
                if invalidated > 0 {
                    app.set_message(format!(
                        "Reloaded {count} files, {invalidated} changed since last review{comment_suffix}"
                    ));
                } else {
                    app.set_message(format!("Reloaded {count} files{comment_suffix}"));
                }
            }
            Err(e) => app.set_error(format!("Reload failed: {e}")),
        }
    }
}

fn check_for_updates(app: &mut App) {
    match crate::update::check_for_updates() {
        crate::update::UpdateCheckResult::UpdateAvailable(info) => {
            app.set_message(format!(
                "Update available: v{} -> v{}",
                info.current_version, info.latest_version
            ));
        }
        crate::update::UpdateCheckResult::UpToDate(info) => {
            app.set_message(format!("tuicr v{} is up to date", info.current_version));
        }
        crate::update::UpdateCheckResult::AheadOfRelease(info) => {
            app.set_message(format!(
                "You're from the future! v{} > v{}",
                info.current_version, info.latest_version
            ));
        }
        crate::update::UpdateCheckResult::Failed(err) => {
            app.set_warning(format!("Update check failed: {err}"));
        }
    }
}

fn set_commit_selector_visible(app: &mut App, visible: bool) {
    app.show_commit_selector = visible;
    if !visible && app.focused_panel == FocusedPanel::CommitSelector {
        app.focused_panel = FocusedPanel::Diff;
    }
    let status = if visible { "visible" } else { "hidden" };
    app.set_message(format!("Commit selector: {status}"));
}

fn set_remote_comments_visibility(app: &mut App, visibility: PrCommentsVisibility) {
    if !matches!(app.diff_source, app::DiffSource::PullRequest(_)) {
        app.set_warning(":comments only applies in PR mode");
        return;
    }

    let changed = app.set_remote_comments_visibility(visibility);
    let label = visibility.label();
    if changed {
        app.set_message(format!("Remote comments: {label}"));
    } else {
        app.set_message(format!("Remote comments: already {label}"));
    }
}

fn complete_command(app: &mut App, direction: CompletionDirection) {
    let completer = CommandCompleter::new(COMMAND_SPECS);
    match completer.complete(
        &app.command_buffer,
        app.command_completion.as_ref(),
        direction,
    ) {
        CompletionResult::NoMatch => {
            app.command_completion = None;
            app.set_message("No command matches");
        }
        CompletionResult::Replace { buffer, completion } => {
            app.command_buffer = buffer;
            app.command_completion = completion;
        }
    }
}

/// Parse `:<n>` (new-side) or `:o<n>` (old-side) jump targets. The leading `:`
/// has already been stripped by the time we get here.
fn parse_lineno_command(cmd: &str) -> Option<(u32, LineSide)> {
    if let Some(rest) = cmd.strip_prefix('o') {
        rest.parse::<u32>().ok().map(|n| (n, LineSide::Old))
    } else {
        cmd.parse::<u32>().ok().map(|n| (n, LineSide::New))
    }
}

/// Handle actions in Search mode (text input for /pattern)
pub fn handle_search_action(app: &mut App, action: Action) {
    match action {
        Action::InsertChar(c) => app.search_buffer.push(c),
        Action::Paste(text) => push_single_line(&mut app.search_buffer, &text),
        Action::DeleteChar => {
            app.search_buffer.pop();
        }
        Action::DeleteWord if !app.search_buffer.is_empty() => {
            while app
                .search_buffer
                .chars()
                .last()
                .map(|c| c.is_whitespace())
                .unwrap_or(false)
            {
                app.search_buffer.pop();
            }
            while app
                .search_buffer
                .chars()
                .last()
                .map(|c| !c.is_whitespace())
                .unwrap_or(false)
            {
                app.search_buffer.pop();
            }
        }
        Action::ClearLine => {
            app.search_buffer.clear();
        }
        Action::ExitMode => app.exit_search_mode(),
        Action::SubmitInput => {
            if app.searching_help() {
                app.search_in_help_from_scroll();
            } else {
                app.search_in_diff_from_cursor();
            }
            app.exit_search_mode();
        }
        Action::Quit => app.should_quit = true,
        _ => {}
    }
}

/// Handle actions in Comment mode (text input for comments)
pub fn handle_comment_action(app: &mut App, action: Action) {
    match action {
        Action::InsertChar(c) => {
            app.comment_buffer.insert(app.comment_cursor, c);
            app.comment_cursor += c.len_utf8();
        }
        Action::Paste(text) => {
            let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
            app.comment_buffer
                .insert_str(app.comment_cursor, &normalized);
            app.comment_cursor += normalized.len();
        }
        Action::DeleteChar => {
            app.comment_cursor = delete_char_before(&mut app.comment_buffer, app.comment_cursor);
        }
        Action::ExitMode => app.exit_comment_mode(),
        Action::SubmitInput => app.save_comment(),
        Action::CycleCommentType => app.cycle_comment_type(),
        Action::CycleCommentTypeReverse => app.cycle_comment_type_reverse(),
        Action::TextCursorLeft => {
            app.comment_cursor = prev_char_boundary(&app.comment_buffer, app.comment_cursor);
        }
        Action::TextCursorRight => {
            app.comment_cursor = next_char_boundary(&app.comment_buffer, app.comment_cursor);
        }
        Action::TextCursorLineStart => {
            app.comment_cursor = comment_line_start(&app.comment_buffer, app.comment_cursor);
        }
        Action::TextCursorLineEnd => {
            app.comment_cursor = comment_line_end(&app.comment_buffer, app.comment_cursor);
        }
        Action::TextCursorWordLeft => {
            app.comment_cursor = comment_word_left(&app.comment_buffer, app.comment_cursor);
        }
        Action::TextCursorWordRight => {
            app.comment_cursor = comment_word_right(&app.comment_buffer, app.comment_cursor);
        }
        Action::DeleteWord => {
            app.comment_cursor = delete_word_before(&mut app.comment_buffer, app.comment_cursor);
        }
        Action::ClearLine => {
            app.comment_buffer.clear();
            app.comment_cursor = 0;
        }
        Action::Quit => app.should_quit = true,
        _ => {}
    }
}

/// Handle actions in Confirm mode (Y/N prompts)
pub fn handle_confirm_action(app: &mut App, action: Action) {
    match action {
        Action::ConfirmYes => {
            if let Some(app::ConfirmAction::CopyAndQuit) = app.pending_confirm {
                let slug = app.session_slug();
                if app.output_to_stdout {
                    match generate_export_content(
                        &app.session,
                        &app.diff_source,
                        &app.comment_types,
                        &app.export,
                        &app.forge_review_threads,
                        slug.as_deref(),
                    ) {
                        Ok(content) => app.pending_stdout_output = Some(content),
                        Err(e) => app.set_warning(format!("{e}")),
                    }
                } else {
                    match export_to_clipboard(
                        &app.session,
                        &app.diff_source,
                        &app.comment_types,
                        &app.export,
                        &app.forge_review_threads,
                        slug.as_deref(),
                    ) {
                        Ok(msg) => app.set_message(msg),
                        Err(e) => app.set_warning(format!("{e}")),
                    }
                }
            }
            app.exit_confirm_mode();
            app.should_quit = true;
        }
        Action::ConfirmNo => {
            app.exit_confirm_mode();
            app.should_quit = true;
        }
        Action::Quit => app.should_quit = true,
        _ => {}
    }
}

/// Handle actions in CommitSelect mode.
///
/// CommitSelect actually drives the review target selector, which has two
/// tabs (Local and Pull Requests). Tab-shared actions (switch tab, quit) are
/// handled first; per-tab dispatch follows.
pub fn handle_commit_select_action(app: &mut App, action: Action) {
    // Filter-editing sub-state on the PR tab. Routed before tab dispatch
    // because typed characters must go to the filter buffer rather than
    // to local-commit movement.
    if app.pr_filter_editing() {
        handle_pr_filter_action(app, action);
        return;
    }

    match action {
        Action::TargetSelectorTabNext => app.cycle_target_tab(true),
        Action::TargetSelectorTabPrev => app.cycle_target_tab(false),
        Action::Quit => app.should_quit = true,
        Action::ExitMode => {
            // Esc during an in-flight PR open aborts the load and stays
            // in the selector. Takes precedence over the
            // commit-selection-range exit so the user isn't stuck staring
            // at a spinner.
            if app.cancel_pr_open() {
                return;
            }
            if app.commit_selection_range.is_none() {
                return;
            }
            if let Err(e) = app.exit_commit_select_mode() {
                app.set_error(format!("Failed to reload changes: {e}"));
            }
        }
        other => match app.target_tab {
            TargetTab::Local => handle_local_target_action(app, other),
            TargetTab::PullRequests => handle_pr_target_action(app, other),
        },
    }
}

fn handle_local_target_action(app: &mut App, action: Action) {
    match action {
        Action::CommitSelectUp => app.commit_select_up(),
        Action::CommitSelectDown => app.commit_select_down(),
        Action::ToggleCommitSelect => {
            // If on expand row, expand commits instead of toggling selection
            if app.is_on_expand_row() {
                if let Err(e) = app.expand_commit() {
                    app.set_error(format!("Failed to load commits: {e}"));
                }
            } else {
                // Toggle + auto-advance so repeated Space sweeps a range.
                app.toggle_commit_selection_and_advance();
            }
        }
        Action::ConfirmCommitSelect => {
            // if on expand row, expand commit instead of confirming
            if app.is_on_expand_row() {
                if let Err(e) = app.expand_commit() {
                    app.set_error(format!("Failed to load commits: {e}"));
                }
            } else if let Err(e) = app.confirm_commit_selection() {
                app.set_error(format!("Failed to load commits: {e}"));
            }
        }
        _ => {}
    }
}

fn handle_pr_target_action(app: &mut App, action: Action) {
    match action {
        Action::CommitSelectUp => app.pr_tab_cursor_up(),
        Action::CommitSelectDown => app.pr_tab_cursor_down(),
        Action::ConfirmCommitSelect => {
            app.pr_tab_select();
        }
        Action::ToggleCommitSelect => {
            // Space is a no-op on the PR tab (spec).
        }
        Action::BeginTargetFilter => {
            app.begin_pr_filter();
        }
        Action::TogglePrReviewRequestedFilter => {
            app.toggle_pr_review_requested_filter();
        }
        _ => {}
    }
}

fn handle_pr_filter_action(app: &mut App, action: Action) {
    match action {
        Action::InsertChar(c) => app.pr_filter_insert_char(c),
        Action::Paste(text) => {
            for ch in text.chars().filter(|c| !matches!(*c, '\n' | '\r')) {
                app.pr_filter_insert_char(ch);
            }
        }
        Action::DeleteChar => app.pr_filter_delete_char(),
        Action::DeleteWord => {
            // Soft word-delete: collapses to clear-line for the v1 cut.
            app.pr_filter_clear();
        }
        Action::ClearLine => app.pr_filter_clear(),
        Action::SubmitInput => app.commit_pr_filter(),
        Action::ExitMode => app.cancel_pr_filter(),
        Action::Quit => app.should_quit = true,
        _ => {}
    }
}

/// Handle actions when inline commit selector panel is focused
pub fn handle_commit_selector_action(app: &mut App, action: Action) {
    match action {
        // `j`/`k` track screen direction: in ascending (oldest-first) order the
        // display is reversed, so moving down the screen moves toward newer
        // (lower) data indices.
        Action::CursorDown(_) => {
            if app.commits_ascending() {
                app.commit_select_up();
            } else {
                app.commit_select_down();
            }
        }
        Action::CursorUp(_) => {
            if app.commits_ascending() {
                app.commit_select_down();
            } else {
                app.commit_select_up();
            }
        }
        // Toggle + auto-advance so repeated presses sweep a contiguous run.
        Action::ToggleExpand | Action::ToggleCommitSelect | Action::SelectFile => {
            app.toggle_commit_selection_and_advance();
            // PR mode reloads via the forge `compare` API on a background
            // thread (persisting the new range so it survives a restart);
            // local reviews reload from the VCS.
            if let Err(e) = app.reload_inline_selection_for_source() {
                app.set_error(format!("Failed to load diff: {e}"));
            }
        }
        Action::ExitMode => {
            app.focused_panel = FocusedPanel::Diff;
        }
        _ => handle_shared_normal_action(app, action),
    }
}

/// Handle actions in VisualSelect mode
pub fn handle_visual_action(app: &mut App, action: Action) {
    match action {
        Action::CursorDown(n) => {
            app.cursor_down(n);
            app.extend_visual_to_cursor();
        }
        Action::CursorUp(n) => {
            app.cursor_up(n);
            app.extend_visual_to_cursor();
        }
        Action::AddRangeComment => {
            if app.visual_selection_line_range().is_some() {
                app.enter_comment_from_visual();
            } else {
                app.set_warning("Invalid selection - move cursor to a diff line");
                app.exit_visual_mode();
            }
        }
        Action::ExportToClipboard => {
            match app.copy_visual_selection() {
                Ok(0) => app.set_message("Nothing to copy"),
                Ok(n) => app.set_message(format!("Copied {n} chars")),
                Err(e) => app.set_warning(format!("{e}")),
            }
            app.exit_visual_mode();
        }
        Action::ExitMode => app.exit_visual_mode(),
        Action::Quit => app.should_quit = true,
        Action::ScrollViewDown(n) | Action::MouseScrollDown(n) => app.scroll_view_down(n),
        Action::ScrollViewUp(n) | Action::MouseScrollUp(n) => app.scroll_view_up(n),
        Action::HalfPageDown => app.page_down(app.diff_state.viewport_height / 2),
        Action::HalfPageUp => app.page_up(app.diff_state.viewport_height / 2),
        Action::PageDown => app.page_down(app.diff_state.viewport_height),
        Action::PageUp => app.page_up(app.diff_state.viewport_height),
        _ => {}
    }
    clear_visual_if_cursor_offscreen(app);
}

/// Handle actions when file list panel is focused
pub fn handle_file_list_action(app: &mut App, action: Action) {
    // An open `i`/`e`/`/` prompt owns keyboard input until it is submitted or
    // cancelled. The wheel still scrolls the list underneath it.
    if app.file_tree_prompt_editing()
        && !matches!(
            action,
            Action::MouseScrollDown(_) | Action::MouseScrollUp(_)
        )
    {
        handle_file_tree_prompt_action(app, action);
        return;
    }
    match action {
        Action::FileTreeFilterInclude => app.begin_file_tree_prompt(FileTreePrompt::Include),
        Action::FileTreeFilterExclude => app.begin_file_tree_prompt(FileTreePrompt::Exclude),
        Action::FileTreeClearInclude => app.clear_include_filter(),
        Action::FileTreeClearExclude => app.clear_exclude_filter(),
        Action::FileTreeSearch => app.begin_file_tree_prompt(FileTreePrompt::Search),
        // With a tree search active, n/N step file matches. Otherwise they
        // fall through to the diff search so the shared binding still works.
        Action::SearchNext if app.file_tree_search_active() => app.file_tree_search_next(),
        Action::SearchPrev if app.file_tree_search_active() => app.file_tree_search_prev(),
        Action::CursorDown(n) => app.file_list_down(n),
        Action::CursorUp(n) => app.file_list_up(n),
        Action::ScrollLeft(n) => app.file_list_state.scroll_left(n),
        Action::ScrollRight(n) => app.file_list_state.scroll_right(n),
        Action::MouseScrollDown(n) => app.file_list_viewport_scroll_down(n),
        Action::MouseScrollUp(n) => app.file_list_viewport_scroll_up(n),
        Action::SelectFile | Action::ToggleExpand => {
            if let Some(item) = app.get_selected_tree_item() {
                match item {
                    FileTreeItem::Directory { path, .. } => app.toggle_directory(&path),
                    FileTreeItem::File { file_idx, .. } => {
                        app.jump_to_file(file_idx);
                        app.focused_panel = FocusedPanel::Diff;
                    }
                }
            }
        }
        Action::ToggleReviewed => {
            if let Some(FileTreeItem::File { file_idx, .. }) = app.get_selected_tree_item() {
                app.toggle_reviewed_for_file_idx(file_idx, false);
            } else {
                app.set_warning("Select a file to toggle reviewed");
            }
        }
        _ => handle_shared_normal_action(app, action),
    }
}

/// Handle input while a file-tree prompt (`i` include, `e` exclude, `/`
/// search) is open. Mirrors `handle_pr_filter_action` for the target selector.
fn handle_file_tree_prompt_action(app: &mut App, action: Action) {
    match action {
        Action::InsertChar(c) => app.file_tree_prompt_insert_char(c),
        Action::Paste(text) => app.file_tree_prompt_insert_str(&text),
        Action::DeleteChar => app.file_tree_prompt_delete_char(),
        Action::DeleteWord => app.file_tree_prompt_delete_word(),
        Action::ClearLine => app.file_tree_prompt_clear_line(),
        Action::SubmitInput => app.commit_file_tree_prompt(),
        Action::ExitMode => app.cancel_file_tree_prompt(),
        _ => {}
    }
}

/// Handle actions when the comment navigator panel is focused
pub fn handle_comment_navigator_action(app: &mut App, action: Action) {
    match action {
        Action::CursorDown(n) => {
            app.comment_navigator_down(n);
            // Auto-follow: scroll the diff to the selected comment without
            // stealing focus from the navigator (Enter still jumps + focuses).
            let saved_focus = app.focused_panel;
            app.jump_to_selected_comment();
            app.focused_panel = saved_focus;
        }
        Action::CursorUp(n) => {
            app.comment_navigator_up(n);
            let saved_focus = app.focused_panel;
            app.jump_to_selected_comment();
            app.focused_panel = saved_focus;
        }
        Action::ScrollLeft(n) => app.comment_navigator_state.scroll_left(n),
        Action::ScrollRight(n) => app.comment_navigator_state.scroll_right(n),
        Action::MouseScrollDown(n) => app.comment_navigator_viewport_scroll_down(n),
        Action::MouseScrollUp(n) => app.comment_navigator_viewport_scroll_up(n),
        Action::SelectFile => {
            app.jump_to_selected_comment();
        }
        _ => handle_shared_normal_action(app, action),
    }
}

/// Enter edit mode for the comment under the cursor, placing the text cursor at
/// the end (vim `A` / non-vim default) or beginning (vim `i`). Surfaces the
/// right message when the comment is read-only or absent.
fn edit_comment_at_cursor(app: &mut App, cursor_at_end: bool) {
    if app.cursor_on_locked_comment() {
        let forge = app.forge_display_name();
        app.set_message(format!(
            "Comment already pushed to {forge} — read only in tuicr"
        ));
    } else if !app.enter_edit_mode(cursor_at_end) {
        if app.cursor_on_remote_thread() {
            let forge = app.forge_display_name();
            app.set_message(format!("{forge} comment — read only in tuicr"));
        } else {
            app.set_message("No comment at cursor");
        }
    }
}

/// Handle actions when diff panel is focused
pub fn handle_diff_action(app: &mut App, action: Action) {
    match action {
        Action::CursorDown(n) => app.cursor_down(n),
        Action::CursorUp(n) => app.cursor_up(n),
        Action::ScrollViewDown(n) => app.scroll_view_down(n),
        Action::ScrollViewUp(n) => app.scroll_view_up(n),
        Action::ScrollLeft(n) => app.scroll_left(n),
        Action::ScrollRight(n) => app.scroll_right(n),
        Action::MouseScrollDown(n) => app.scroll_view_down(n),
        Action::MouseScrollUp(n) => app.scroll_view_up(n),
        Action::SelectFile => {
            if let Some(hit) = app.get_gap_at_cursor() {
                match hit {
                    GapCursorHit::Expander(gap_id, dir) => {
                        let limit = if dir == ExpandDirection::Both {
                            None
                        } else {
                            Some(20)
                        };
                        if let Err(e) = app.expand_gap(gap_id, dir, limit) {
                            app.set_error(format!("Failed to expand: {e}"));
                        }
                    }
                    GapCursorHit::HiddenLines(gap_id) => {
                        if let Err(e) = app.expand_gap(gap_id, ExpandDirection::Both, None) {
                            app.set_error(format!("Failed to expand: {e}"));
                        }
                    }
                    GapCursorHit::ExpandedContent(gap_id) => {
                        app.collapse_gap(gap_id);
                    }
                }
            }
        }
        Action::SelectFileFull => {
            if let Some(hit) = app.get_gap_at_cursor() {
                match hit {
                    GapCursorHit::Expander(gap_id, _) | GapCursorHit::HiddenLines(gap_id) => {
                        if let Err(e) = app.expand_gap(gap_id, ExpandDirection::Both, None) {
                            app.set_error(format!("Failed to expand: {e}"));
                        }
                    }
                    GapCursorHit::ExpandedContent(gap_id) => {
                        app.collapse_gap(gap_id);
                    }
                }
            }
        }
        _ => handle_shared_normal_action(app, action),
    }
}

/// Handle actions shared between file list and diff panels in Normal mode
fn handle_shared_normal_action(app: &mut App, action: Action) {
    // Reset quit_warned on any non-quit action
    if !matches!(action, Action::Quit) {
        app.quit_warned = false;
    }

    match action {
        Action::Quit => {
            if app.dirty && app.session.has_comments() && !app.quit_warned {
                app.set_sticky_warning("Unsaved changes. Press q again to quit.");
                app.quit_warned = true;
            } else if app.dirty && !app.session.has_comments() {
                // Dirty from reviewed-file markers only: discard the state and
                // quit instead of warning about unsaved changes.
                app.discard_session_and_quit();
            } else {
                app.should_quit = true;
            }
        }
        Action::ExitMode => {
            app.show_file_list = false;
            app.focused_panel = FocusedPanel::Diff;
        }
        Action::HalfPageDown => app.page_down(app.diff_state.viewport_height / 2),
        Action::HalfPageUp => app.page_up(app.diff_state.viewport_height / 2),
        Action::PageDown => app.page_down(app.diff_state.viewport_height),
        Action::PageUp => app.page_up(app.diff_state.viewport_height),
        Action::GoToTop => app.jump_to_file(0),
        Action::GoToBottom => app.jump_to_bottom(),
        Action::NextFile => app.next_file(),
        Action::PrevFile => app.prev_file(),
        Action::NextHunk => app.next_hunk(),
        Action::PrevHunk => app.prev_hunk(),
        Action::NextComment => app.next_comment(),
        Action::PrevComment => app.prev_comment(),
        Action::ToggleReviewed => app.toggle_reviewed(),
        Action::ToggleHunkReviewed => app.toggle_hunk_reviewed(),
        Action::ToggleFocus => {
            let has_selector = app.has_inline_commit_selector();
            let has_comments = app.has_comment_navigator_items();
            // Cycle: FileList -> Diff -> CommitSelector -> Comments -> FileList,
            // skipping panels that aren't present.
            app.focused_panel = match (app.focused_panel, has_selector, has_comments) {
                (FocusedPanel::FileList, _, _) => FocusedPanel::Diff,
                (FocusedPanel::Diff, true, _) => FocusedPanel::CommitSelector,
                (FocusedPanel::Diff, false, true) => FocusedPanel::Comments,
                (FocusedPanel::Diff, false, false) => FocusedPanel::FileList,
                (FocusedPanel::CommitSelector, _, true) => FocusedPanel::Comments,
                (FocusedPanel::CommitSelector, _, false) => FocusedPanel::FileList,
                (FocusedPanel::Comments, _, _) => FocusedPanel::FileList,
            };
            if matches!(
                app.focused_panel,
                FocusedPanel::FileList | FocusedPanel::Comments
            ) {
                app.show_file_list = true;
            }
        }
        Action::ToggleFocusReverse => {
            let has_selector = app.has_inline_commit_selector();
            let has_comments = app.has_comment_navigator_items();
            app.focused_panel = match (app.focused_panel, has_selector, has_comments) {
                (FocusedPanel::FileList, _, true) => FocusedPanel::Comments,
                (FocusedPanel::FileList, true, false) => FocusedPanel::CommitSelector,
                (FocusedPanel::FileList, false, false) => FocusedPanel::Diff,
                (FocusedPanel::Comments, true, _) => FocusedPanel::CommitSelector,
                (FocusedPanel::Comments, false, _) => FocusedPanel::Diff,
                (FocusedPanel::Diff, _, _) => FocusedPanel::FileList,
                (FocusedPanel::CommitSelector, _, _) => FocusedPanel::Diff,
            };
            if matches!(
                app.focused_panel,
                FocusedPanel::FileList | FocusedPanel::Comments
            ) {
                app.show_file_list = true;
            }
        }
        Action::ExpandAll => {
            app.expand_all_dirs();
            app.set_message("All directories expanded");
        }
        Action::CollapseAll => {
            app.collapse_all_dirs();
            app.set_message("All directories collapsed");
        }
        Action::ToggleHelp => app.toggle_help(),
        Action::EnterCommandMode => app.enter_command_mode(),
        Action::EnterSearchMode => app.enter_search_mode(),
        Action::AddLineComment => {
            let line = app.get_line_at_cursor();
            if line.is_some() {
                app.enter_comment_mode(false, line);
            } else {
                app.set_message("Move cursor to a diff line to add a line comment");
            }
        }
        Action::AddFileComment => app.enter_comment_mode(true, None),
        // `i` edits the comment at cursor. In vim mode the text cursor starts at
        // the beginning; otherwise (and for `A`) it starts at the end.
        Action::EditComment => edit_comment_at_cursor(app, !app.comment_vim_enabled),
        // `A` (vim only) edits with the text cursor at end-of-line.
        Action::EditCommentAtEnd if app.comment_vim_enabled => edit_comment_at_cursor(app, true),
        Action::ExportToClipboard => handle_export(app),
        Action::SearchNext => {
            app.search_next_in_diff();
        }
        Action::SearchPrev => {
            app.search_prev_in_diff();
        }
        Action::EnterVisualMode => {
            if app.get_line_at_cursor().is_some() {
                app.enter_visual_mode_at_cursor();
            } else {
                app.set_message("Move cursor to a diff line to start visual selection");
            }
        }
        // `(` moves toward the top row, `)` toward the bottom row. The cycle
        // methods walk data indices (newest-first), so ascending display order
        // swaps which one runs to keep the on-screen direction stable.
        Action::CycleCommitNext if app.has_review_commits() => {
            if app.commits_ascending() {
                app.cycle_commit_prev();
            } else {
                app.cycle_commit_next();
            }
            if let Err(e) = app.reload_inline_selection_for_source() {
                app.set_error(format!("Failed to load diff: {e}"));
            }
        }
        Action::CycleCommitPrev if app.has_review_commits() => {
            if app.commits_ascending() {
                app.cycle_commit_next();
            } else {
                app.cycle_commit_prev();
            }
            if let Err(e) = app.reload_inline_selection_for_source() {
                app.set_error(format!("Failed to load diff: {e}"));
            }
        }
        Action::EditFile => app.queue_editor_for_focused_item(),
        _ => {}
    }
}

/// Handle actions in the submit resolver modal: pick what to do with each
/// comment that did not map to an inline GitHub review comment.
pub fn handle_submit_resolver_action(app: &mut App, action: Action) {
    match action {
        Action::SubmitResolverDown => app.submit_resolver_cursor_down(),
        Action::SubmitResolverUp => app.submit_resolver_cursor_up(),
        Action::SubmitResolverToggle => app.submit_resolver_toggle(),
        Action::SubmitResolverAdvance => app.submit_resolver_advance(),
        Action::ExitMode => app.cancel_submit(),
        Action::Quit => app.should_quit = true,
        _ => {}
    }
}

/// Handle actions in the bare-`:submit` action picker. Up/down move the
/// cursor through Comment/Approve/Request changes/Draft; Enter dispatches
/// preflight with the picked event (skipping the confirmation modal); Esc
/// cancels back to normal.
pub fn handle_submit_action_picker_action(app: &mut App, action: Action) {
    match action {
        Action::SubmitPickerDown => app.submit_picker_cursor_down(),
        Action::SubmitPickerUp => app.submit_picker_cursor_up(),
        Action::SubmitPickerConfirm => app.submit_picker_confirm(),
        Action::ExitMode => app.cancel_submit_action_picker(),
        Action::Quit => app.should_quit = true,
        _ => {}
    }
}

/// Handle actions in the final submit confirmation modal.
pub fn handle_submit_confirm_action(app: &mut App, action: Action) {
    match action {
        Action::ConfirmYes => app.confirm_submit(),
        Action::ConfirmNo => app.cancel_submit(),
        // Only meaningful when the stale-head warning is visible.
        Action::SubmitReloadPr
            if app.submit_head_is_stale()
                && matches!(app.diff_source, app::DiffSource::PullRequest(_)) =>
        {
            app.cancel_submit();
            if let Err(e) = app.spawn_pr_reload() {
                app.set_error(format!("Reload failed: {e}"));
            }
        }
        Action::Quit => app.should_quit = true,
        _ => {}
    }
}
