use std::future::Future;
use std::io;
use std::path::PathBuf;
use std::sync::{
    Arc,
    atomic::{AtomicBool, Ordering},
};
use std::thread;
use std::time::Duration;

use crossterm::event::{
    self, DisableMouseCapture, EnableMouseCapture, Event as TermEvent, KeyCode, KeyEvent,
    KeyModifiers, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use protocol::{Method, PodStatus};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use session_store::SegmentId;
use tokio::sync::mpsc;

use client::{PodClient, PodRuntimeCommand};

use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady};
use crate::{multi_pod, picker, spawn, ui};

type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;

fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
    if let Some(p) = override_path {
        return p;
    }
    manifest::paths::pod_socket_path(pod_name).unwrap_or_else(|| {
        PathBuf::from("/tmp")
            .join("yoi")
            .join(pod_name)
            .join("sock")
    })
}

pub(crate) async fn run_pod_name(
    pod_name: String,
    socket_override: Option<PathBuf>,
    runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
        let mut terminal = enter_fullscreen()?;
        run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?;
        return Ok(());
    }

    let ready = match spawn::run_pod_name(pod_name, runtime_command.clone()).await? {
        SpawnOutcome::Ready(r) => r,
        SpawnOutcome::Cancelled => return Ok(()),
    };
    let mut terminal = enter_fullscreen()?;
    terminal.clear()?;
    let result = run_ready_pod(&mut terminal, ready, runtime_command).await;
    let _ = leave_fullscreen(&mut terminal);
    result
}

async fn run_connected_pod(
    terminal: &mut FullscreenTerminal,
    pod_name: String,
    client: PodClient,
    runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut app = App::new(pod_name);
    app.connected = true;
    run_loop(terminal, &mut app, client, runtime_command).await
}

async fn run_pod_name_nested(
    terminal: &mut FullscreenTerminal,
    request: multi_pod::OpenPodRequest,
    runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    let multi_pod::OpenPodRequest {
        pod_name,
        socket_override,
    } = request;

    if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await {
        return run_connected_pod(terminal, pod_name, client, runtime_command.clone()).await;
    }

    let ready =
        spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command.clone()).await?;
    run_ready_pod(terminal, ready, runtime_command).await
}

async fn spawn_pod_name_from_fullscreen(
    terminal: &mut FullscreenTerminal,
    pod_name: &str,
    runtime_command: PodRuntimeCommand,
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
    leave_fullscreen(terminal)?;
    let outcome = spawn::run_pod_name(pod_name.to_string(), runtime_command).await;
    enter_fullscreen_existing(terminal)?;
    terminal.clear()?;

    match outcome? {
        SpawnOutcome::Ready(ready) => Ok(ready),
        SpawnOutcome::Cancelled => Err(Box::new(NestedOpenCancelled)),
    }
}

async fn try_connect_live_pod(
    pod_name: &str,
    socket_override: Option<PathBuf>,
) -> Option<PodClient> {
    let preferred_socket = resolve_socket(pod_name, socket_override.clone());
    connect_live_pod(pod_name, preferred_socket, socket_override.is_none())
        .await
        .map(|(_, client)| client)
}

#[derive(Debug)]
struct NestedOpenCancelled;

impl std::fmt::Display for NestedOpenCancelled {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("Pod open was cancelled")
    }
}

impl std::error::Error for NestedOpenCancelled {}

async fn run_ready_pod(
    terminal: &mut FullscreenTerminal,
    ready: SpawnReady,
    runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    let SpawnReady {
        pod_name,
        socket_path,
    } = ready;
    run(terminal, pod_name, &socket_path, runtime_command).await
}

async fn connect_live_pod(
    pod_name: &str,
    preferred_socket: PathBuf,
    allow_registry_fallback: bool,
) -> Option<(PathBuf, PodClient)> {
    if let Ok(client) = PodClient::connect(&preferred_socket).await {
        return Some((preferred_socket, client));
    }

    if !allow_registry_fallback {
        return None;
    }
    let registry_socket = picker::live_socket_for_pod(pod_name)?;
    if registry_socket == preferred_socket {
        return None;
    }
    PodClient::connect(&registry_socket)
        .await
        .ok()
        .map(|client| (registry_socket, client))
}

pub(crate) async fn run_resume(
    runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    // Pick a Pod in its own inline viewport, dropping the viewport before
    // attaching/restoring so each phase gets fresh vertical room.
    let (pod_name, socket_override) = match picker::run().await? {
        PickerOutcome::Picked {
            pod_name,
            socket_override,
        } => (pod_name, socket_override),
        PickerOutcome::Cancelled => return Ok(()),
    };
    run_pod_name(pod_name, socket_override, runtime_command).await
}

pub(crate) async fn run_panel(
    runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut app = multi_pod::load_app(runtime_command.clone()).await?;
    let mut terminal = enter_fullscreen()?;

    loop {
        match multi_pod::run(&mut terminal, &mut app).await? {
            multi_pod::MultiPodOutcome::Quit => {
                let _ = leave_fullscreen(&mut terminal);
                return Ok(());
            }
            multi_pod::MultiPodOutcome::Open(request) => {
                let pod_name = request.pod_name.clone();
                match run_pod_name_nested(&mut terminal, request, runtime_command.clone()).await {
                    Ok(()) => app.finish_open(&pod_name, Ok(())),
                    Err(error) if is_recoverable_multi_open_error(error.as_ref()) => {
                        app.finish_open(&pod_name, Err(error.as_ref()));
                    }
                    Err(error) => {
                        let _ = leave_fullscreen(&mut terminal);
                        return Err(error);
                    }
                }
                app.reload_or_notice().await;
            }
        }
    }
}

fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> bool {
    error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
}

pub(crate) async fn run_spawn(
    resume_from: Option<SegmentId>,
    profile: Option<String>,
    runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    let ready = match spawn::run(resume_from, profile, runtime_command.clone()).await? {
        SpawnOutcome::Ready(r) => r,
        SpawnOutcome::Cancelled => return Ok(()),
    };

    let SpawnReady {
        pod_name,
        socket_path,
    } = ready;

    let mut terminal = enter_fullscreen()?;
    let result = run(&mut terminal, pod_name, &socket_path, runtime_command).await;

    // Leave alt-screen explicitly before `main`'s terminal restore path.
    let _ = execute!(
        terminal.backend_mut(),
        DisableMouseCapture,
        LeaveAlternateScreen
    );

    result
}

fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    Ok(Terminal::new(backend)?)
}

fn enter_fullscreen_existing(
    terminal: &mut FullscreenTerminal,
) -> Result<(), Box<dyn std::error::Error>> {
    execute!(
        terminal.backend_mut(),
        EnterAlternateScreen,
        EnableMouseCapture
    )?;
    Ok(())
}

fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> {
    execute!(
        terminal.backend_mut(),
        DisableMouseCapture,
        LeaveAlternateScreen
    )
}

async fn run(
    terminal: &mut FullscreenTerminal,
    pod_name: String,
    socket_path: &std::path::Path,
    runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut app = App::new(pod_name);

    match PodClient::connect(socket_path).await {
        Ok(client) => {
            app.connected = true;
            // The Pod sends `Event::Snapshot` automatically on connect;
            // no explicit method call is required to fetch history.
            run_loop(terminal, &mut app, client, runtime_command).await?;
        }
        Err(e) => {
            app.push_error(format!(
                "Failed to connect to {}: {e}",
                socket_path.display()
            ));
            terminal.draw(|f| ui::draw(f, &mut app))?;
            run_disconnected(&mut app)?;
        }
    }
    Ok(())
}

type TerminalEventResult = io::Result<TermEvent>;

const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50);
const TERMINAL_EVENT_DRAIN_LIMIT: usize = 64;
const POD_EVENT_DRAIN_LIMIT: usize = 32;

struct TerminalEventReader {
    stop: Arc<AtomicBool>,
    thread: Option<thread::JoinHandle<()>>,
}

impl TerminalEventReader {
    fn spawn() -> io::Result<(Self, mpsc::UnboundedReceiver<TerminalEventResult>)> {
        let (tx, rx) = mpsc::unbounded_channel();
        let stop = Arc::new(AtomicBool::new(false));
        let thread_stop = Arc::clone(&stop);
        let thread = thread::Builder::new()
            .name("yoi-tui-terminal-reader".to_string())
            .spawn(move || read_terminal_events(thread_stop, tx))?;

        Ok((
            Self {
                stop,
                thread: Some(thread),
            },
            rx,
        ))
    }
}

impl Drop for TerminalEventReader {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);
        if let Some(thread) = self.thread.take() {
            let _ = thread.join();
        }
    }
}

fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<TerminalEventResult>) {
    while !stop.load(Ordering::Relaxed) {
        match event::poll(TERMINAL_POLL_INTERVAL) {
            Ok(false) => {}
            Ok(true) => {
                let event = event::read();
                let should_stop = event.is_err();
                if tx.send(event).is_err() || should_stop {
                    break;
                }
            }
            Err(e) => {
                let _ = tx.send(Err(e));
                break;
            }
        }
    }
}

enum LoopInput<P> {
    Terminal(TerminalEventResult),
    Pod(Option<P>),
}

async fn next_loop_input<P, F>(
    term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
    connected: bool,
    pod_next: F,
) -> LoopInput<P>
where
    F: Future<Output = Option<P>>,
{
    tokio::select! {
        biased;

        term_event = term_rx.recv() => {
            LoopInput::Terminal(term_event.unwrap_or_else(|| {
                Err(io::Error::new(
                    io::ErrorKind::UnexpectedEof,
                    "terminal event reader stopped",
                ))
            }))
        }
        event = pod_next, if connected => LoopInput::Pod(event),
    }
}

async fn drain_terminal_events(
    app: &mut App,
    client: &mut PodClient,
    term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
    runtime_command: &PodRuntimeCommand,
) -> Result<bool, Box<dyn std::error::Error>> {
    let mut handled = false;
    for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
        match term_rx.try_recv() {
            Ok(event) => {
                handled = true;
                handle_terminal_event(app, client, event?, runtime_command).await?;
                if app.quit {
                    break;
                }
            }
            Err(mpsc::error::TryRecvError::Empty) => break,
            Err(mpsc::error::TryRecvError::Disconnected) => {
                return Err(Box::new(io::Error::new(
                    io::ErrorKind::UnexpectedEof,
                    "terminal event reader stopped",
                )));
            }
        }
    }
    Ok(handled)
}

async fn drain_pod_events(
    app: &mut App,
    client: &mut PodClient,
) -> Result<bool, Box<dyn std::error::Error>> {
    let mut handled = false;
    for _ in 0..POD_EVENT_DRAIN_LIMIT {
        match client.try_next_event() {
            Some(ev) => {
                handled = true;
                if let Some(method) = app.handle_pod_event(ev) {
                    client.send(&method).await?;
                }
            }
            None => break,
        }
    }
    Ok(handled)
}

async fn run_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    mut client: PodClient,
    runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;

    terminal.draw(|f| ui::draw(f, app))?;

    loop {
        if app.quit {
            break;
        }

        let handled_term_event =
            drain_terminal_events(app, &mut client, &mut term_rx, &runtime_command).await?;
        if app.quit {
            break;
        }
        let handled_pod_event = drain_pod_events(app, &mut client).await?;
        if handled_term_event || handled_pod_event {
            terminal.draw(|f| ui::draw(f, app))?;
            continue;
        }

        match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
            LoopInput::Terminal(term_event) => {
                handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?;
            }
            LoopInput::Pod(event) => match event {
                Some(ev) => {
                    if let Some(method) = app.handle_pod_event(ev) {
                        client.send(&method).await?;
                    }
                }
                None => {
                    app.connected = false;
                    app.mark_orphan_compacts_incomplete();
                    app.push_error("Connection lost");
                }
            },
        }

        terminal.draw(|f| ui::draw(f, app))?;
    }

    Ok(())
}

async fn handle_terminal_event(
    app: &mut App,
    client: &mut PodClient,
    event: TermEvent,
    _runtime_command: &PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
    match event {
        TermEvent::Key(key) => {
            if let Some(method) = handle_key(app, key) {
                client.send(&method).await?;
            }
        }
        TermEvent::Mouse(mouse) => {
            handle_mouse(app, mouse);
        }
        TermEvent::Paste(s) => {
            app.insert_paste(s);
        }
        TermEvent::Resize(_, _) => {
            // No-op: next draw repaints in full.
        }
        _ => {}
    }
    Ok(())
}

fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
    loop {
        if event::poll(std::time::Duration::from_millis(100))?
            && let TermEvent::Key(key) = event::read()?
            && let KeyCode::Char('c') = key.code
            && key.modifiers.contains(KeyModifiers::CONTROL)
        {
            break;
        }
    }
    Ok(())
}

/// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so
/// hand-rolling through long histories isn't tedious, but slow enough
/// that a single notch doesn't blow past the section the user is
/// looking for.
const WHEEL_LINES: usize = 3;

/// Lines to advance per PageUp / PageDown when the task side pane is
/// open. Calibrated so a couple of presses moves through one entry's
/// subject + description block.
const PANE_SCROLL_LINES: usize = 5;

fn handle_mouse(app: &mut App, mouse: MouseEvent) {
    match mouse.kind {
        MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES),
        MouseEventKind::ScrollDown => app.scroll.scroll_down(WHEEL_LINES),
        _ => {}
    }
}

fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
    let alt = key.modifiers.contains(KeyModifiers::ALT);

    // Modifier-key bindings.
    if let Some(method) = match key.code {
        KeyCode::Up if shift => {
            app.scroll.scroll_up(1);
            Some(None)
        }
        KeyCode::Down if shift => {
            app.scroll.scroll_down(1);
            Some(None)
        }
        KeyCode::Home if ctrl => {
            app.scroll.to_top();
            Some(None)
        }
        KeyCode::End if ctrl => {
            app.scroll.to_bottom();
            Some(None)
        }
        KeyCode::Char('[') if ctrl => {
            app.scroll.jump_prev_turn();
            Some(None)
        }
        KeyCode::Char(']') if ctrl => {
            app.scroll.jump_next_turn();
            Some(None)
        }
        KeyCode::Char('o') if ctrl => {
            app.mode = app.mode.cycle();
            Some(None)
        }
        KeyCode::Char('t') if ctrl => {
            app.toggle_task_pane();
            Some(None)
        }
        KeyCode::Char(c) if c.eq_ignore_ascii_case(&'r') && ctrl => {
            Some(app.request_rewind_picker())
        }
        KeyCode::Char('a') if ctrl => {
            app.move_cursor_start();
            Some(app.refresh_completion())
        }
        KeyCode::Left if ctrl || alt => {
            app.move_cursor_word_left();
            Some(app.refresh_completion())
        }
        KeyCode::Right if ctrl || alt => {
            app.move_cursor_word_right();
            Some(app.refresh_completion())
        }
        KeyCode::Backspace if ctrl || alt => {
            app.delete_word_before_cursor();
            Some(app.refresh_completion())
        }
        KeyCode::Char('w') if ctrl => {
            app.delete_word_before_cursor();
            Some(app.refresh_completion())
        }
        KeyCode::Char('u') if ctrl && app.is_command_mode() => {
            app.clear_command_input();
            Some(None)
        }
        KeyCode::Char(c)
            if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() =>
        {
            if app.restore_next_queued_input_to_composer() {
                Some(app.refresh_completion())
            } else {
                Some(None)
            }
        }
        KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => {
            app.clear_queued_inputs();
            Some(None)
        }
        KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
        KeyCode::Char('x') if ctrl => Some(match app.pod_status {
            PodStatus::Running => {
                app.clear_queued_inputs();
                Some(Method::Cancel)
            }
            PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown),
        }),
        KeyCode::Char('d') if ctrl => {
            app.quit = true;
            Some(None)
        }
        KeyCode::Enter if alt => {
            if app.is_command_mode() {
                Some(None)
            } else {
                app.insert_newline();
                Some(app.refresh_completion())
            }
        }
        _ => None,
    } {
        return method;
    }

    // Unbound Ctrl+Char keys are ignored before the text-input path so
    // holding Ctrl while typing never inserts control characters.
    if ctrl && matches!(key.code, KeyCode::Char(_)) {
        return None;
    }

    // Scroll / navigation. PageUp / PageDown defaults to history; while
    // the task pane is open it scrolls the pane instead so the user can
    // browse past entries without first closing the pane.
    match key.code {
        KeyCode::PageUp => {
            if app.task_pane_open {
                app.scroll_task_pane_up(PANE_SCROLL_LINES);
            } else {
                app.scroll.page_up();
            }
            return None;
        }
        KeyCode::PageDown => {
            if app.task_pane_open {
                app.scroll_task_pane_down(PANE_SCROLL_LINES);
            } else {
                app.scroll.page_down();
            }
            return None;
        }
        _ => {}
    }

    if app.is_command_mode() {
        return handle_command_key(app, key);
    }

    if app.rewind_picker.is_some() {
        match key.code {
            KeyCode::Esc => {
                app.close_rewind_picker();
                return None;
            }
            KeyCode::Enter => return app.submit_rewind_picker(),
            KeyCode::Up => {
                app.rewind_picker_up();
                return None;
            }
            KeyCode::Down => {
                app.rewind_picker_down();
                return None;
            }
            _ => {}
        }
    }

    // Completion popup overrides — only when there's something to
    // navigate / commit. An empty popup (request in flight) falls
    // through to the default behaviour.
    if app.completion.as_ref().is_some_and(|c| c.is_active()) {
        match key.code {
            KeyCode::Tab if !alt => {
                // Insert the selected entry as raw text and let the
                // re-triggered popup fetch fresh candidates (drill-in
                // for directories, narrow-to-exact for files).
                return app.apply_completion_text();
            }
            KeyCode::Enter if !alt => {
                // While the popup has selectable entries, Enter
                // commits the selection rather than submitting the
                // message. The selected entry wins regardless of how
                // much of its value the user has typed — Enter on a
                // popup entry is "accept this suggestion". Directory
                // entries are the exception: they fall through to
                // text insertion so the popup re-fetches children
                // for drill-in. After a successful chip we append a
                // trailing space so the user can keep writing without
                // a manual separator (the Space path already has the
                // space the user typed, so it's not needed there).
                if app.chipify_selected_completion_if_committable() {
                    app.insert_char(' ');
                    return None;
                }
                return app.apply_completion_text();
            }
            KeyCode::Up => {
                app.move_completion_up();
                return None;
            }
            KeyCode::Down => {
                app.move_completion_down();
                return None;
            }
            KeyCode::Esc => {
                app.cancel_completion();
                return None;
            }
            _ => {}
        }
    }

    match key.code {
        KeyCode::Esc => {
            // Close the popup if it's still showing (covers the
            // request-in-flight case where `is_active()` was false).
            app.cancel_completion();
            None
        }
        KeyCode::Enter => app.submit_input(),
        KeyCode::Backspace => {
            app.delete_char_before();
            app.refresh_completion()
        }
        KeyCode::Delete => {
            app.delete_char_after();
            app.refresh_completion()
        }
        KeyCode::Left => {
            app.move_cursor_left();
            app.refresh_completion()
        }
        KeyCode::Right => {
            app.move_cursor_right();
            app.refresh_completion()
        }
        KeyCode::Up => {
            if app.can_browse_input_history_older() && app.browse_input_history_older() {
                app.refresh_completion()
            } else {
                app.move_cursor_up();
                app.refresh_completion()
            }
        }
        KeyCode::Down => {
            if app.can_browse_input_history_newer() && app.browse_input_history_newer() {
                app.refresh_completion()
            } else {
                app.move_cursor_down();
                app.refresh_completion()
            }
        }
        KeyCode::Home => {
            app.move_cursor_home();
            app.refresh_completion()
        }
        KeyCode::End => {
            app.move_cursor_end();
            app.refresh_completion()
        }
        KeyCode::Char(':') if !alt && app.input.is_empty() => {
            app.enter_command_mode();
            None
        }
        KeyCode::Char(c) => {
            // Whitespace ends an in-flight completion token. Try the
            // auto-confirm path first so an exact match (e.g. typed
            // `@src/main.rs` matches the only popup entry) becomes a
            // chip on the way out. Directories also commit here —
            // ending with a space is an explicit "I want this dir"
            // signal, not a drill-in.
            if c.is_whitespace() {
                app.chipify_completion_if_exact_match();
            }
            app.insert_char(c);
            app.refresh_completion()
        }
        _ => None,
    }
}

fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
    match key.code {
        KeyCode::Esc => {
            app.exit_command_mode();
            None
        }
        KeyCode::Enter => app.submit_command_with_completion(),
        KeyCode::Backspace => {
            if app.command_text().is_empty() {
                app.exit_command_mode();
            } else {
                app.delete_char_before();
            }
            None
        }
        KeyCode::Delete => {
            app.delete_char_after();
            None
        }
        KeyCode::Left => {
            app.move_cursor_left();
            None
        }
        KeyCode::Right => {
            app.move_cursor_right();
            None
        }
        KeyCode::Up => {
            if app.command_completion_active() {
                app.move_command_completion_up();
            } else {
                app.move_cursor_up();
            }
            None
        }
        KeyCode::Down => {
            if app.command_completion_active() {
                app.move_command_completion_down();
            } else {
                app.move_cursor_down();
            }
            None
        }
        KeyCode::Home => {
            app.move_cursor_home();
            None
        }
        KeyCode::End => {
            app.move_cursor_end();
            None
        }
        KeyCode::Tab => {
            app.apply_command_completion();
            None
        }
        KeyCode::Char(c) => {
            if key
                .modifiers
                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
            {
                return None;
            }
            app.insert_char(c);
            None
        }
        _ => None,
    }
}

const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);

/// Running → send `Method::Pause`.
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
    if app.pod_status == PodStatus::Running {
        app.clear_queued_inputs();
        return Some(Method::Pause);
    }
    if let Some(t) = app.quit_confirm
        && t.elapsed() < CONFIRM_TIMEOUT
    {
        app.quit_confirm = None;
        app.quit = true;
        return None;
    }
    app.quit_confirm = Some(std::time::Instant::now());
    app.flash_actionbar_notice(
        "Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).",
        ActionbarNoticeLevel::Warn,
        ActionbarNoticeSource::Tui,
        CONFIRM_TIMEOUT,
    );
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use protocol::{Event, RewindTarget, RewindTargetId, Segment};

    #[tokio::test]
    async fn terminal_event_is_selected_before_ready_pod_event() {
        let (tx, mut rx) = mpsc::unbounded_channel();
        tx.send(Ok(TermEvent::Key(KeyEvent::new(
            KeyCode::Char('x'),
            KeyModifiers::NONE,
        ))))
        .unwrap();

        match next_loop_input(&mut rx, true, std::future::ready(Some(()))).await {
            LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
                assert_eq!(key.code, KeyCode::Char('x'));
            }
            _ => panic!("ready terminal input should win over a ready Pod event"),
        }
    }

    #[tokio::test]
    async fn terminal_event_is_preserved_after_pod_event_wins() {
        let (tx, mut rx) = mpsc::unbounded_channel();

        match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await {
            LoopInput::Pod(Some(1)) => {}
            _ => panic!("expected the first ready Pod event to win before any terminal input"),
        }

        tx.send(Ok(TermEvent::Key(KeyEvent::new(
            KeyCode::Char('y'),
            KeyModifiers::NONE,
        ))))
        .unwrap();

        match next_loop_input(&mut rx, true, std::future::ready(Some(2_u8))).await {
            LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
                assert_eq!(key.code, KeyCode::Char('y'));
            }
            _ => panic!("queued terminal input should not be lost to subsequent Pod events"),
        }
    }

    #[test]
    fn running_status_still_allows_text_editing() {
        let mut app = App::new("agent".to_string());
        app.set_pod_status(PodStatus::Running);

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)).is_none());
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
            )
            .is_none()
        );

        assert_eq!(
            protocol::Segment::flatten_to_text(&app.input.submit_segments()),
            "abc"
        );
    }

    #[test]
    fn running_enter_queues_instead_of_sending_run() {
        let mut app = App::new("agent".to_string());
        app.set_pod_status(PodStatus::Running);
        for c in "queued".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }

        assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());

        assert_eq!(app.queued_input_count(), 1);
        assert_eq!(app.next_queued_input_preview(), Some("queued"));
        assert_eq!(input_text(&app), "");
    }

    #[test]
    fn queued_input_keybindings_restore_and_clear() {
        let mut app = App::new("agent".to_string());
        app.set_pod_status(PodStatus::Running);
        for c in "edit queued".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }
        assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT)
            )
            .is_none()
        );
        assert_eq!(app.queued_input_count(), 0);
        assert_eq!(input_text(&app), "edit queued");

        app.input.clear();
        for c in "clear queued".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }
        assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
        assert_eq!(app.queued_input_count(), 1);

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT)
            )
            .is_none()
        );
        assert_eq!(app.queued_input_count(), 0);
    }

    #[test]
    fn pause_and_cancel_clear_queued_input() {
        let mut app = App::new("agent".to_string());
        app.set_pod_status(PodStatus::Running);
        for c in "queued".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }
        assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
        assert_eq!(app.queued_input_count(), 1);

        let pause = handle_key(
            &mut app,
            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
        );
        assert!(matches!(pause, Some(Method::Pause)));
        assert_eq!(app.queued_input_count(), 0);

        for c in "queued again".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }
        assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
        assert_eq!(app.queued_input_count(), 1);

        let cancel = handle_key(
            &mut app,
            KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
        );
        assert!(matches!(cancel, Some(Method::Cancel)));
        assert_eq!(app.queued_input_count(), 0);
    }

    #[test]
    fn word_navigation_keys_edit_composer() {
        let mut app = App::new("agent".to_string());
        for c in "foo bar".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)
            )
            .is_none()
        );
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('_'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert_eq!(input_text(&app), "foo _bar");

        assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)).is_none());
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert_eq!(input_text(&app), "foo _bar!");

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)
            )
            .is_none()
        );
        assert_eq!(input_text(&app), "foo ");
    }

    #[test]
    fn ctrl_w_deletes_word_before_cursor() {
        let mut app = App::new("agent".to_string());
        for c in "foo bar baz".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)
            )
            .is_none()
        );
        assert_eq!(input_text(&app), "foo bar ");
    }

    #[test]
    fn word_navigation_keys_edit_command_input() {
        let mut app = App::new("agent".to_string());
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
            )
            .is_none()
        );
        for c in "peer alpha beta".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)
            )
            .is_none()
        );
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('_'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert_eq!(app.command_text(), "peer alpha _beta");

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)
            )
            .is_none()
        );
        assert_eq!(app.command_text(), "peer alpha beta");
        assert_eq!(input_text(&app), "");
    }

    #[test]
    fn command_mode_enters_with_colon_and_esc_restores_composer() {
        let mut app = App::new("agent".to_string());
        app.insert_char('d');
        app.insert_char('r');
        app.insert_char('a');
        app.insert_char('f');
        app.insert_char('t');
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert!(!app.is_command_mode());
        assert_eq!(input_text(&app), "draft:");

        app.input.clear();
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert!(app.is_command_mode());
        for c in "help".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }
        assert_eq!(input_text(&app), "");
        assert_eq!(app.command_text(), "help");

        assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_none());
        assert!(!app.is_command_mode());
        assert_eq!(input_text(&app), "");
    }

    #[test]
    fn command_mode_empty_backspace_restores_composer() {
        let mut app = App::new("agent".to_string());
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert!(app.is_command_mode());
        assert_eq!(app.command_text(), "");

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
            )
            .is_none()
        );
        assert!(!app.is_command_mode());
        assert_eq!(input_text(&app), "");
    }

    #[test]
    fn command_mode_non_empty_backspace_keeps_command_mode() {
        let mut app = App::new("agent".to_string());
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)
            )
            .is_none()
        );

        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
            )
            .is_none()
        );
        assert!(app.is_command_mode());
        assert_eq!(app.command_text(), "");
    }

    #[test]
    fn unknown_command_is_not_sent_as_user_message() {
        let mut app = App::new("agent".to_string());
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
            )
            .is_none()
        );
        for c in "does-not-exist".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }

        let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        assert!(method.is_none());
        assert!(app.is_command_mode());
        assert_eq!(input_text(&app), "");
        assert_eq!(app.queued_input_count(), 0);
        assert!(app.blocks.iter().any(|block| match block {
            crate::block::Block::Alert { message, .. } => message.contains("Unknown command"),
            _ => false,
        }));
    }

    #[test]
    fn command_enter_dispatches_registry_without_run() {
        let mut app = App::new("agent".to_string());
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
            )
            .is_none()
        );
        for c in "noop".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }

        let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        assert!(method.is_none());
        assert!(!app.is_command_mode());
        assert_eq!(input_text(&app), "");
        assert!(app.blocks.iter().any(|block| match block {
            crate::block::Block::Alert { message, .. } => message.contains("noop: no action"),
            _ => false,
        }));
    }

    #[test]
    fn compact_command_sends_compact_method_without_run() {
        let mut app = App::new("agent".to_string());
        app.connected = true;
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
            )
            .is_none()
        );
        for c in "compact".chars() {
            assert!(
                handle_key(
                    &mut app,
                    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
                )
                .is_none()
            );
        }

        let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        assert!(matches!(method, Some(protocol::Method::Compact)));
        assert!(!app.is_command_mode());
        assert_eq!(input_text(&app), "");
        assert_eq!(app.queued_input_count(), 0);
        assert!(app.blocks.iter().any(|block| match block {
            crate::block::Block::Alert { message, .. } => message.contains("compact requested"),
            _ => false,
        }));
    }

    #[test]
    fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() {
        let mut app = App::new("agent".to_string());
        app.set_pod_status(PodStatus::Idle);

        let method = handle_key(
            &mut app,
            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
        );

        assert!(method.is_none());
        assert!(!app.quit);
        let notice = app
            .current_actionbar_notice(std::time::Instant::now())
            .expect("quit guard notice is active");
        assert!(notice.text.contains("Pod keeps running"));
        assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
        assert_eq!(notice.source, ActionbarNoticeSource::Tui);
        assert!(!has_alert(&app, "Pod keeps running"));

        let method = handle_key(
            &mut app,
            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
        );
        assert!(method.is_none());
        assert!(app.quit);
    }

    #[test]
    fn ctrl_r_requests_rewind_picker_when_idle_or_paused() {
        let mut app = App::new("agent".to_string());
        app.connected = true;
        let idle = handle_key(
            &mut app,
            KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
        );
        assert!(matches!(idle, Some(Method::ListRewindTargets)));

        app.set_pod_status(PodStatus::Paused);
        let paused = handle_key(
            &mut app,
            KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
        );
        assert!(matches!(paused, Some(Method::ListRewindTargets)));
    }

    #[test]
    fn ctrl_r_is_rejected_while_running() {
        let mut app = App::new("agent".to_string());
        app.connected = true;
        app.set_pod_status(PodStatus::Running);

        let method = handle_key(
            &mut app,
            KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
        );

        assert!(method.is_none());
        assert!(has_alert(&app, "cannot rewind while the Pod is running"));
    }

    #[test]
    fn rewind_picker_close_returns_to_history_view() {
        let mut app = App::new("agent".to_string());
        app.connected = true;
        app.handle_pod_event(Event::RewindTargets {
            head_entries: 1,
            targets: vec![],
        });
        assert!(app.rewind_picker.is_none());

        let method = handle_key(
            &mut app,
            KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
        );
        assert!(matches!(method, Some(Method::ListRewindTargets)));
        app.handle_pod_event(Event::RewindTargets {
            head_entries: 1,
            targets: vec![],
        });
        assert!(app.rewind_picker.is_some());

        let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

        assert!(method.is_none());
        assert!(app.rewind_picker.is_none());
    }

    #[test]
    fn rewind_applied_reseeds_display_and_restores_composer() {
        let mut app = App::new("agent".to_string());
        app.handle_pod_event(Event::Snapshot {
            greeting: test_greeting(),
            entries: vec![],
            status: PodStatus::Idle,
        });
        app.handle_pod_event(Event::RewindApplied {
            entries: vec![],
            input: vec![Segment::Text {
                content: "retry this".into(),
            }],
            summary: protocol::RewindSummary {
                truncated_to_entries: 0,
                discarded_entries: 2,
                tool_side_effect_warning: true,
            },
        });

        assert_eq!(input_text(&app), "retry this");
        assert!(app.rewind_picker.is_none());
        assert!(has_alert(&app, "tool side effects"));
    }

    #[test]
    fn rewind_applied_keeps_non_empty_composer() {
        let mut app = App::new("agent".to_string());
        app.handle_pod_event(Event::Snapshot {
            greeting: test_greeting(),
            entries: vec![],
            status: PodStatus::Idle,
        });
        type_keys(&mut app, "draft");

        app.handle_pod_event(Event::RewindApplied {
            entries: vec![],
            input: vec![Segment::Text {
                content: "retry this".into(),
            }],
            summary: protocol::RewindSummary {
                truncated_to_entries: 0,
                discarded_entries: 2,
                tool_side_effect_warning: false,
            },
        });

        assert_eq!(input_text(&app), "draft");
        assert!(has_alert(
            &app,
            "composer not overwritten because it was not empty"
        ));
    }

    #[test]
    fn rewind_apply_rejects_non_empty_composer_and_paused_status() {
        let mut app = App::new("agent".to_string());
        app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
        type_keys(&mut app, "draft");
        assert!(app.submit_rewind_picker().is_none());
        assert!(has_alert(&app, "composer is not empty"));

        let mut app = App::new("agent".to_string());
        app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
        app.set_pod_status(PodStatus::Paused);
        assert!(app.submit_rewind_picker().is_none());
        assert!(has_alert(
            &app,
            "cannot apply rewind while the Pod is paused"
        ));
    }

    #[test]
    fn rewind_picker_draw_does_not_overwrite_history_scroll_state() {
        let mut app = App::new("agent".to_string());
        app.scroll.top_offset = 3;
        app.scroll.turn_starts = vec![0, 5, 9];
        app.scroll.total_lines = 42;
        app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
        let original_top_offset = app.scroll.top_offset;
        let original_turn_starts = app.scroll.turn_starts.clone();
        let original_total_lines = app.scroll.total_lines;

        let backend = ratatui::backend::TestBackend::new(80, 24);
        let mut terminal = ratatui::Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| crate::ui::draw(frame, &mut app))
            .unwrap();
        app.close_rewind_picker();

        assert_eq!(app.scroll.top_offset, original_top_offset);
        assert_eq!(app.scroll.turn_starts, original_turn_starts);
        assert_eq!(app.scroll.total_lines, original_total_lines);
    }

    fn rewind_target() -> RewindTarget {
        RewindTarget {
            id: RewindTargetId {
                segment_id: uuid::Uuid::nil(),
                user_input_entry_index: 0,
            },
            expected_head_entries: 1,
            truncate_entries: 0,
            turn_index: 1,
            timestamp_ms: Some(1),
            preview: "retry this".into(),
            eligible: true,
            disabled_reason: None,
            warning: None,
        }
    }

    fn test_greeting() -> protocol::Greeting {
        protocol::Greeting {
            pod_name: "agent".into(),
            cwd: "/tmp".into(),
            provider: "test".into(),
            model: "test".into(),
            scope_summary: "".into(),
            tools: vec![],
            context_window: 0,
            context_tokens: 0,
        }
    }

    #[test]
    fn command_registry_suggestions_are_available() {
        let mut app = App::new("agent".to_string());
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
            )
            .is_none()
        );
        assert!(
            app.command_suggestions()
                .iter()
                .any(|candidate| candidate.name == "help")
        );
        assert!(
            handle_key(
                &mut app,
                KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)
            )
            .is_none()
        );
        let suggestions = app.command_suggestions();
        assert_eq!(suggestions.len(), 1);
        assert_eq!(suggestions[0].name, "noop");
    }

    #[test]
    fn command_completion_tab_applies_unambiguous_candidate() {
        let mut app = App::new("agent".to_string());
        enter_command_mode(&mut app);
        type_keys(&mut app, "no");

        assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());

        assert!(app.is_command_mode());
        assert_eq!(app.command_text(), "noop ");
        assert_eq!(input_text(&app), "");
    }

    #[test]
    fn command_completion_enter_applies_and_executes_unambiguous_candidate() {
        let mut app = App::new("agent".to_string());
        enter_command_mode(&mut app);
        type_keys(&mut app, "no");

        let method = handle_key(&mut app, key(KeyCode::Enter));

        assert!(method.is_none());
        assert!(!app.is_command_mode());
        assert_eq!(input_text(&app), "");
        assert!(has_alert(&app, "noop: no action"));
    }

    #[test]
    fn command_completion_ambiguous_candidate_requires_selection_or_more_input() {
        let mut app = App::new("agent".to_string());
        register_test_command(&mut app, "open", "open", parse_no_args, "open executed");
        register_test_command(
            &mut app,
            "options",
            "options",
            parse_no_args,
            "options executed",
        );
        enter_command_mode(&mut app);
        type_keys(&mut app, "o");

        assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
        assert_eq!(app.command_text(), "o");
        assert!(app.is_command_mode());
        assert!(has_alert(&app, "Ambiguous command completion"));

        let before = app.blocks.len();
        let method = handle_key(&mut app, key(KeyCode::Enter));
        assert!(method.is_none());
        assert_eq!(app.command_text(), "o");
        assert!(app.is_command_mode());
        assert!(app.blocks.len() > before);
        assert!(!has_alert(&app, "open executed"));
        assert!(!has_alert(&app, "options executed"));
    }

    #[test]
    fn command_completion_selected_candidate_applies_on_enter() {
        let mut app = App::new("agent".to_string());
        register_test_command(&mut app, "open", "open", parse_no_args, "open executed");
        register_test_command(
            &mut app,
            "options",
            "options",
            parse_no_args,
            "options executed",
        );
        enter_command_mode(&mut app);
        type_keys(&mut app, "o");

        assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
        let method = handle_key(&mut app, key(KeyCode::Enter));

        assert!(method.is_none());
        assert!(!app.is_command_mode());
        assert!(has_alert(&app, "open executed"));
        assert!(!has_alert(&app, "options executed"));
    }

    #[test]
    fn command_completion_argument_required_keeps_command_mode_after_name_completion() {
        let mut app = App::new("agent".to_string());
        register_test_command(
            &mut app,
            "open",
            "open <path>",
            parse_required_arg,
            "open executed",
        );
        enter_command_mode(&mut app);
        type_keys(&mut app, "op");

        let method = handle_key(&mut app, key(KeyCode::Enter));

        assert!(method.is_none());
        assert!(app.is_command_mode());
        assert_eq!(app.command_text(), "open ");
        assert!(has_alert(&app, "Invalid arguments. Usage: open <path>"));
        assert!(!has_alert(&app, "open executed"));
        assert_eq!(input_text(&app), "");
    }

    #[test]
    fn command_completion_does_not_affect_normal_composer_without_popup() {
        let mut app = App::new("agent".to_string());
        type_keys(&mut app, "hello");

        assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());

        assert!(!app.is_command_mode());
        assert_eq!(input_text(&app), "hello");
    }

    #[test]
    fn up_at_start_with_empty_history_preserves_draft_without_browsing() {
        let mut app = App::new("agent".to_string());
        type_keys(&mut app, "draft");
        app.move_cursor_start();

        assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());

        assert_eq!(input_text(&app), "draft");
        assert!(!app.input_history_is_browsing());
    }

    #[test]
    fn up_from_empty_composer_recalls_history_and_down_restores_empty_draft() {
        let mut app = App::new("agent".to_string());
        type_keys(&mut app, "first");
        assert!(matches!(
            handle_key(&mut app, key(KeyCode::Enter)),
            Some(Method::Run { .. })
        ));
        type_keys(&mut app, "second");
        assert!(matches!(
            handle_key(&mut app, key(KeyCode::Enter)),
            Some(Method::Run { .. })
        ));

        assert_eq!(input_text(&app), "");
        assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
        assert_eq!(input_text(&app), "second");
        assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
        assert_eq!(input_text(&app), "first");
        assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
        assert_eq!(input_text(&app), "second");
        assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
        assert_eq!(input_text(&app), "");
    }

    #[test]
    fn up_inside_multiline_preserves_existing_cursor_up_behavior() {
        let mut app = App::new("agent".to_string());
        type_keys(&mut app, "ab\ncd");

        assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
        assert!(handle_key(&mut app, key(KeyCode::Char('X'))).is_none());

        assert_eq!(input_text(&app), "abX\ncd");
    }

    #[test]
    fn up_at_start_of_multiline_recalls_history() {
        let mut app = App::new("agent".to_string());
        type_keys(&mut app, "sent");
        assert!(matches!(
            handle_key(&mut app, key(KeyCode::Enter)),
            Some(Method::Run { .. })
        ));
        type_keys(&mut app, "draft\nbody");
        app.move_cursor_start();

        assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());

        assert_eq!(input_text(&app), "sent");
    }

    fn enter_command_mode(app: &mut App) {
        assert!(handle_key(app, key(KeyCode::Char(':'))).is_none());
        assert!(app.is_command_mode());
    }

    fn type_keys(app: &mut App, text: &str) {
        for c in text.chars() {
            assert!(handle_key(app, key(KeyCode::Char(c))).is_none());
        }
    }

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    fn has_alert(app: &App, needle: &str) -> bool {
        app.blocks.iter().any(|block| match block {
            crate::block::Block::Alert { message, .. } => message.contains(needle),
            _ => false,
        })
    }

    fn register_test_command(
        app: &mut App,
        name: &'static str,
        usage: &'static str,
        argument_parser: crate::command::ArgumentParser,
        message: &'static str,
    ) {
        app.command_registry.register(crate::command::CommandSpec {
            name,
            aliases: &[],
            usage,
            description: "test command",
            argument_parser,
            can_execute: test_command_available,
            executor: test_command_executor,
        });
        TEST_COMMAND_MESSAGES.with(|messages| messages.borrow_mut().push((name, message)));
    }

    thread_local! {
        static TEST_COMMAND_MESSAGES: std::cell::RefCell<Vec<(&'static str, &'static str)>> =
            const { std::cell::RefCell::new(Vec::new()) };
    }

    fn parse_no_args(
        raw: &str,
    ) -> Result<crate::command::CommandArgs, crate::command::CommandDiagnostic> {
        Ok(crate::command::CommandArgs::parse_whitespace(raw))
    }

    fn parse_required_arg(
        raw: &str,
    ) -> Result<crate::command::CommandArgs, crate::command::CommandDiagnostic> {
        let args = crate::command::CommandArgs::parse_whitespace(raw);
        if args.argv().is_empty() {
            return Err(crate::command::CommandDiagnostic::new(
                "Invalid arguments. Usage: open <path>",
            ));
        }
        Ok(args)
    }

    fn test_command_available(
        _environment: &crate::command::CommandEnvironment,
    ) -> Result<(), crate::command::CommandDiagnostic> {
        Ok(())
    }

    fn test_command_executor(
        invocation: crate::command::CommandInvocation<'_>,
    ) -> crate::command::CommandExecution {
        let message = TEST_COMMAND_MESSAGES
            .with(|messages| {
                messages
                    .borrow()
                    .iter()
                    .find(|(name, _)| *name == invocation.command.name)
                    .map(|(_, message)| *message)
            })
            .unwrap_or("test command executed");
        crate::command::CommandExecution::notice(message)
    }

    fn input_text(app: &App) -> String {
        protocol::Segment::flatten_to_text(&app.input.submit_segments())
    }
}
