use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;

use crossterm::event::{KeyCode, KeyEvent};
use log::{debug, warn};

use crate::app::{App, Screen};
use crate::clipboard;
use crate::event::AppEvent;
use crate::preferences;

pub(super) fn open_snippet_picker(app: &mut App, aliases: Vec<String>) {
    *app.snippets.store_mut() = crate::snippet::SnippetStore::load();
    *app.ui.snippet_picker_state_mut() = ratatui::widgets::ListState::default();
    if !app.snippets.store().snippets.is_empty() {
        app.ui.snippet_picker_state_mut().select(Some(0));
    }
    app.set_screen(Screen::SnippetPicker {
        target_aliases: aliases,
    });
}

pub(super) fn handle_picker_key(app: &mut App, key: KeyEvent, events_tx: &mpsc::Sender<AppEvent>) {
    let target_aliases = match &app.screen {
        Screen::SnippetPicker { target_aliases } => target_aliases.clone(),
        _ => return,
    };

    // Allow ? to open help even during search
    if key.code == KeyCode::Char('?') {
        let old = std::mem::replace(&mut app.screen, Screen::HostList);
        app.set_screen(Screen::Help {
            return_screen: Box::new(old),
        });
        return;
    }

    // Search mode dispatch
    if app.ui.snippet_search().is_some() {
        handle_snippet_picker_search(app, key, &target_aliases, events_tx);
        return;
    }

    // Handle pending snippet delete confirmation via the shared confirm router.
    if app.snippets.pending_delete().is_some() && key.code != KeyCode::Char('?') {
        match super::route_confirm_key(key) {
            super::ConfirmAction::Yes => {
                let Some(sel) = app.snippets.take_pending_delete() else {
                    return;
                };
                if sel < app.snippets.store().snippets.len() {
                    let removed = app.snippets.store_mut().snippets.remove(sel);
                    if let Err(e) = app.snippets.store_mut().save() {
                        app.snippets.store_mut().snippets.insert(sel, removed);
                        app.notify_error(crate::messages::failed_to_save(&e));
                    } else {
                        if app.snippets.store().snippets.is_empty() {
                            app.ui.snippet_picker_state_mut().select(None);
                        } else if sel >= app.snippets.store().snippets.len() {
                            app.ui
                                .snippet_picker_state_mut()
                                .select(Some(app.snippets.store().snippets.len() - 1));
                        }
                        app.notify(crate::messages::snippet_removed(&removed.name));
                    }
                }
            }
            super::ConfirmAction::No => {
                app.snippets.cancel_delete();
            }
            super::ConfirmAction::Ignored => {}
        }
        return;
    }

    match key.code {
        KeyCode::Esc | KeyCode::Char('q') => {
            app.ui.close_snippet_search();
            app.snippets.cancel_delete();
            app.set_screen(Screen::HostList);
        }
        KeyCode::Char('/') => {
            app.ui.open_snippet_search();
        }
        KeyCode::Char('j') | KeyCode::Down => {
            app.select_next_snippet();
        }
        KeyCode::Char('k') | KeyCode::Up => {
            app.select_prev_snippet();
        }
        KeyCode::PageDown => {
            crate::app::page_down(
                app.ui.snippet_picker_state_mut(),
                app.snippets.store().snippets.len(),
                10,
            );
        }
        KeyCode::PageUp => {
            crate::app::page_up(
                app.ui.snippet_picker_state_mut(),
                app.snippets.store().snippets.len(),
                10,
            );
        }
        KeyCode::Char('a') => {
            app.open_snippet_add_form(target_aliases.clone());
        }
        KeyCode::Char('e') => {
            if let Some(sel) = app.ui.snippet_picker_state().selected() {
                if let Some(snippet) = app.snippets.store().snippets.get(sel).cloned() {
                    app.open_snippet_edit_form(&snippet, target_aliases.clone(), sel);
                }
            }
        }
        KeyCode::Char('d') => {
            if let Some(sel) = app.ui.snippet_picker_state().selected() {
                if sel < app.snippets.store().snippets.len() {
                    app.snippets.request_delete(sel);
                }
            }
        }
        KeyCode::Enter => {
            if let Some(sel) = app.ui.snippet_picker_state().selected() {
                if let Some(snippet) = app.snippets.store().snippets.get(sel).cloned() {
                    run_or_prompt_params(app, snippet, target_aliases, false, events_tx);
                }
            }
        }
        KeyCode::Char('!') => {
            if let Some(sel) = app.ui.snippet_picker_state().selected() {
                if let Some(snippet) = app.snippets.store().snippets.get(sel).cloned() {
                    run_or_prompt_params(app, snippet, target_aliases, true, events_tx);
                }
            }
        }
        _ => {}
    }
}

/// Run a snippet (captured output) or open param form if it has parameters.
fn run_or_prompt_params(
    app: &mut App,
    snippet: crate::snippet::Snippet,
    target_aliases: Vec<String>,
    terminal_mode: bool,
    events_tx: &mpsc::Sender<AppEvent>,
) {
    if app.demo_mode {
        app.notify_warning(crate::messages::DEMO_EXECUTION_DISABLED);
        return;
    }
    let params = crate::snippet::parse_params(&snippet.command);
    if !params.is_empty() {
        app.snippets
            .set_param_form(Some(crate::app::SnippetParamFormState::new(&params)));
        app.snippets.set_pending_terminal(terminal_mode);
        app.set_screen(Screen::SnippetParamForm {
            snippet,
            target_aliases,
        });
    } else if terminal_mode {
        app.snippets.set_pending(Some((snippet, target_aliases)));
        app.hosts_state.clear_multi_select();
        app.set_screen(Screen::HostList);
    } else {
        app.hosts_state.clear_multi_select();
        start_snippet_output(app, &snippet, &target_aliases, events_tx);
    }
}

/// Monotonically increasing run ID to distinguish snippet execution runs.
static SNIPPET_RUN_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);

/// Start in-TUI snippet execution.
fn start_snippet_output(
    app: &mut App,
    snippet: &crate::snippet::Snippet,
    target_aliases: &[String],
    events_tx: &mpsc::Sender<AppEvent>,
) {
    let cancel = Arc::new(AtomicBool::new(false));

    let askpass_map: Vec<(String, Option<String>)> = target_aliases
        .iter()
        .map(|alias| {
            let askpass = app
                .hosts_state
                .list()
                .iter()
                .find(|h| h.alias == *alias)
                .and_then(|h| h.askpass.clone())
                .or_else(preferences::load_askpass_default);
            (alias.clone(), askpass)
        })
        .collect();

    let tunnel_aliases: std::collections::HashSet<String> =
        app.tunnels.active().keys().cloned().collect();

    let run_id = SNIPPET_RUN_COUNTER.fetch_add(1, Ordering::Relaxed);
    debug!(
        "[purple] snippet run started: run_id={} name={:?} hosts={}",
        run_id,
        snippet.name,
        target_aliases.len()
    );

    app.snippets
        .set_output(Some(crate::app::SnippetOutputState {
            run_id,
            results: Vec::new(),
            scroll_offset: 0,
            completed: 0,
            total: target_aliases.len(),
            all_done: false,
            cancel: cancel.clone(),
        }));

    app.set_screen(Screen::SnippetOutput {
        snippet_name: snippet.name.clone(),
        target_aliases: target_aliases.to_vec(),
    });

    crate::snippet::spawn_snippet_execution(
        run_id,
        askpass_map,
        app.reload.config_path().to_path_buf(),
        snippet.command.clone(),
        app.bw_session.clone(),
        tunnel_aliases,
        cancel,
        events_tx.clone(),
        target_aliases.len() > 1,
    );
}

/// Compute the line count for a snippet host result, matching the UI renderer.
fn snippet_result_lines(r: &crate::app::SnippetHostOutput) -> usize {
    let content = if r.stdout.is_empty() && r.stderr.is_empty() {
        1 // "[No output]" placeholder
    } else {
        let stdout_lines = if r.stdout.is_empty() {
            0
        } else {
            r.stdout.lines().count()
        };
        let stderr_lines = if r.stderr.is_empty() {
            0
        } else {
            r.stderr.lines().count()
        };
        stdout_lines + stderr_lines
    };
    // header + content + blank line
    1 + content + 1
}

pub(super) fn handle_output_key(app: &mut App, key: KeyEvent) {
    let total_lines = app
        .snippets
        .output()
        .map(|s| s.results.iter().map(snippet_result_lines).sum::<usize>())
        .unwrap_or(0);

    match key.code {
        KeyCode::Esc | KeyCode::Char('q') => {
            if let Some(state) = app.snippets.output() {
                if !state.all_done {
                    state.cancel.store(true, Ordering::Relaxed);
                }
            }
            app.snippets.set_output(None);
            app.set_screen(Screen::HostList);
        }
        KeyCode::Char('j') | KeyCode::Down => {
            if let Some(state) = app.snippets.output_mut() {
                state.scroll_offset = state.scroll_offset.saturating_add(1);
            }
        }
        KeyCode::Char('k') | KeyCode::Up => {
            if let Some(state) = app.snippets.output_mut() {
                state.scroll_offset = state.scroll_offset.saturating_sub(1);
            }
        }
        KeyCode::PageDown => {
            if let Some(state) = app.snippets.output_mut() {
                state.scroll_offset = state.scroll_offset.saturating_add(20);
            }
        }
        KeyCode::PageUp => {
            if let Some(state) = app.snippets.output_mut() {
                state.scroll_offset = state.scroll_offset.saturating_sub(20);
            }
        }
        KeyCode::Char('G') => {
            if let Some(state) = app.snippets.output_mut() {
                state.scroll_offset = total_lines.saturating_sub(1);
            }
        }
        KeyCode::Char('g') => {
            if let Some(state) = app.snippets.output_mut() {
                state.scroll_offset = 0;
            }
        }
        KeyCode::Char('n') => {
            // Jump to next host header
            if let Some(state) = app.snippets.output_mut() {
                let current = state.scroll_offset;
                let mut line = 0;
                for result in &state.results {
                    let section = snippet_result_lines(result);
                    if line > current {
                        state.scroll_offset = line;
                        return;
                    }
                    line += section;
                }
            }
        }
        KeyCode::Char('N') => {
            // Jump to previous host header
            if let Some(state) = app.snippets.output_mut() {
                let current = state.scroll_offset;
                let mut offsets = Vec::new();
                let mut line = 0;
                for result in &state.results {
                    offsets.push(line);
                    line += snippet_result_lines(result);
                }
                for &off in offsets.iter().rev() {
                    if off < current {
                        state.scroll_offset = off;
                        return;
                    }
                }
                state.scroll_offset = 0;
            }
        }
        KeyCode::Char('c') => {
            // Copy all output
            if let Some(state) = app.snippets.output() {
                let mut text = String::new();
                for result in &state.results {
                    text.push_str(&format!("-- {} --\n", result.alias));
                    if !result.stdout.is_empty() {
                        text.push_str(&result.stdout);
                        text.push('\n');
                    }
                    if !result.stderr.is_empty() {
                        text.push_str(&result.stderr);
                        text.push('\n');
                    }
                    text.push('\n');
                }
                match clipboard::copy_to_clipboard(&text) {
                    Ok(()) => app.notify(crate::messages::OUTPUT_COPIED),
                    Err(e) => app.notify_error(crate::messages::copy_failed(&e)),
                }
            }
        }
        KeyCode::Char('?') => {
            let old = std::mem::replace(&mut app.screen, Screen::HostList);
            app.set_screen(Screen::Help {
                return_screen: Box::new(old),
            });
        }
        _ => {}
    }
}

/// Reset snippet picker selection to first match after search query changes.
fn reset_snippet_search_selection(app: &mut App) {
    let filtered = app.filtered_snippet_indices();
    if filtered.is_empty() {
        app.ui.snippet_picker_state_mut().select(None);
    } else {
        app.ui.snippet_picker_state_mut().select(Some(0));
    }
}

pub(super) fn handle_snippet_picker_search(
    app: &mut App,
    key: KeyEvent,
    target_aliases: &[String],
    events_tx: &mpsc::Sender<AppEvent>,
) {
    match key.code {
        KeyCode::Esc => {
            app.ui.close_snippet_search();
            // Restore selection to full list
            if !app.snippets.store().snippets.is_empty()
                && app.ui.snippet_picker_state().selected().is_none()
            {
                app.ui.snippet_picker_state_mut().select(Some(0));
            }
        }
        KeyCode::Enter => {
            let filtered = app.filtered_snippet_indices();
            if let Some(sel) = app.ui.snippet_picker_state().selected() {
                if sel < filtered.len() {
                    let real_idx = filtered[sel];
                    if let Some(snippet) = app.snippets.store().snippets.get(real_idx).cloned() {
                        app.ui.close_snippet_search();
                        run_or_prompt_params(
                            app,
                            snippet,
                            target_aliases.to_vec(),
                            false,
                            events_tx,
                        );
                    }
                }
            }
        }
        KeyCode::Char(c) => {
            if let Some(query) = app.ui.snippet_search_mut() {
                query.push(c);
            }
            reset_snippet_search_selection(app);
        }
        KeyCode::Backspace => {
            if let Some(query) = app.ui.snippet_search_mut() {
                query.pop();
                if query.is_empty() {
                    app.ui.close_snippet_search();
                    if !app.snippets.store().snippets.is_empty() {
                        app.ui.snippet_picker_state_mut().select(Some(0));
                    }
                    return;
                }
            }
            reset_snippet_search_selection(app);
        }
        KeyCode::Down => {
            let count = app.filtered_snippet_indices().len();
            if let Some(sel) = app.ui.snippet_picker_state().selected() {
                if sel + 1 < count {
                    app.ui.snippet_picker_state_mut().select(Some(sel + 1));
                }
            }
        }
        KeyCode::Up => {
            if let Some(sel) = app.ui.snippet_picker_state().selected() {
                if sel > 0 {
                    app.ui.snippet_picker_state_mut().select(Some(sel - 1));
                }
            }
        }
        _ => {}
    }
}

pub(super) fn handle_param_form_key(
    app: &mut App,
    key: KeyEvent,
    events_tx: &mpsc::Sender<AppEvent>,
) {
    let (snippet, target_aliases) = match &app.screen {
        Screen::SnippetParamForm {
            snippet,
            target_aliases,
        } => (snippet.clone(), target_aliases.clone()),
        _ => return,
    };

    let form = match app.snippets.param_form_mut() {
        Some(f) => f,
        None => return,
    };

    // Handle discard confirmation dialog via the shared confirm router.
    if app.forms.is_discard_pending() {
        match super::route_confirm_key(key) {
            super::ConfirmAction::Yes => {
                app.forms.dismiss_discard_confirm();
                app.snippets.close_param_form();
                app.set_screen(Screen::SnippetPicker { target_aliases });
            }
            super::ConfirmAction::No => {
                app.forms.dismiss_discard_confirm();
            }
            super::ConfirmAction::Ignored => {}
        }
        return;
    }

    match key.code {
        KeyCode::Esc => {
            if form.is_dirty() {
                app.forms.request_discard_confirm();
            } else {
                app.snippets.close_param_form();
                app.set_screen(Screen::SnippetPicker { target_aliases });
            }
        }
        KeyCode::Tab | KeyCode::Down if form.focused_index + 1 < form.params.len() => {
            form.focused_index += 1;
            form.cursor_pos = form.values[form.focused_index].chars().count();
            let vis = form.visible_count.max(1);
            if form.focused_index >= form.scroll_offset + vis {
                form.scroll_offset = form.focused_index.saturating_sub(vis - 1);
            }
        }
        KeyCode::BackTab | KeyCode::Up if form.focused_index > 0 => {
            form.focused_index -= 1;
            form.cursor_pos = form.values[form.focused_index].chars().count();
            if form.focused_index < form.scroll_offset {
                form.scroll_offset = form.focused_index;
            }
        }
        KeyCode::Left if form.cursor_pos > 0 => {
            form.cursor_pos -= 1;
        }
        KeyCode::Right => {
            let len = form.values[form.focused_index].chars().count();
            if form.cursor_pos < len {
                form.cursor_pos += 1;
            }
        }
        KeyCode::Enter => {
            let values_map = form.values_map();
            let mut resolved = snippet.clone();
            resolved.command = crate::snippet::substitute_params(&snippet.command, &values_map);

            let terminal_mode = app.snippets.pending_terminal();
            app.snippets.close_param_form();

            if terminal_mode {
                app.snippets.set_pending(Some((resolved, target_aliases)));
                app.hosts_state.clear_multi_select();
                app.set_screen(Screen::HostList);
            } else {
                app.hosts_state.clear_multi_select();
                start_snippet_output(app, &resolved, &target_aliases, events_tx);
            }
        }
        KeyCode::Char(c) => {
            if c.is_control() {
                return;
            }
            form.insert_char(c);
        }
        KeyCode::Backspace => {
            form.delete_char_before_cursor();
        }
        _ => {}
    }
}

pub(super) fn handle_form_key(app: &mut App, key: KeyEvent) {
    let (target_aliases, editing) = match &app.screen {
        Screen::SnippetForm {
            target_aliases,
            editing,
        } => (target_aliases.clone(), *editing),
        _ => return,
    };

    // Handle discard confirmation dialog via the shared confirm router.
    if app.forms.is_discard_pending() {
        match super::route_confirm_key(key) {
            super::ConfirmAction::Yes => {
                app.forms.dismiss_discard_confirm();
                app.close_snippet_form(target_aliases.clone());
            }
            super::ConfirmAction::No => {
                app.forms.dismiss_discard_confirm();
            }
            super::ConfirmAction::Ignored => {}
        }
        return;
    }

    match key.code {
        KeyCode::Esc => {
            if app.snippet_form_is_dirty() {
                app.forms.request_discard_confirm();
            } else {
                app.close_snippet_form(target_aliases.clone());
            }
        }
        KeyCode::Tab | KeyCode::Down => {
            app.snippets.form_mut().focus_next();
        }
        KeyCode::BackTab | KeyCode::Up => {
            app.snippets.form_mut().focus_prev();
        }
        KeyCode::Left if app.snippets.form_mut().cursor_pos > 0 => {
            app.snippets.form_mut().cursor_pos -= 1;
        }
        KeyCode::Right => {
            let len = app.snippets.form_mut().focused_value().chars().count();
            if app.snippets.form_mut().cursor_pos < len {
                app.snippets.form_mut().cursor_pos += 1;
            }
        }
        KeyCode::Home => {
            app.snippets.form_mut().cursor_pos = 0;
        }
        KeyCode::End => {
            app.snippets.form_mut().sync_cursor_to_end();
        }
        KeyCode::Enter => {
            submit_snippet_form(app, &target_aliases, editing);
        }
        KeyCode::Char(c) => {
            app.snippets.form_mut().insert_char(c);
        }
        KeyCode::Backspace => {
            app.snippets.form_mut().delete_char_before_cursor();
        }
        _ => {}
    }
}

fn submit_snippet_form(app: &mut App, target_aliases: &[String], editing: Option<usize>) {
    if let Err(msg) = app.snippets.form_mut().validate() {
        app.notify_error(msg);
        return;
    }

    let new_name = app.snippets.form_mut().name.trim().to_string();
    let new_command = app.snippets.form_mut().command.trim().to_string();
    let new_description = app.snippets.form_mut().description.trim().to_string();

    // Check for duplicate name (skip the snippet being edited)
    let old_name = editing.and_then(|idx| {
        app.snippets
            .store()
            .snippets
            .get(idx)
            .map(|s| s.name.clone())
    });
    let name_taken = app
        .snippets
        .store()
        .snippets
        .iter()
        .any(|s| s.name == new_name && Some(&s.name) != old_name.as_ref());
    if name_taken {
        app.notify_warning(crate::messages::snippet_exists(&new_name));
        return;
    }

    let snippet = crate::snippet::Snippet {
        name: new_name,
        command: new_command,
        description: new_description,
    };

    // Save a snapshot for rollback
    let snapshot = app.snippets.store().snippets.clone();

    // If editing and name changed, remove the old one
    if let Some(old_name) = &old_name {
        if *old_name != snippet.name {
            app.snippets.store_mut().remove(old_name);
        }
    }

    let is_new = editing.is_none();
    app.snippets.store_mut().set(snippet);

    if let Err(e) = app.snippets.store_mut().save() {
        warn!("[config] snippet store save failed, rolling back: {e}");
        app.snippets.store_mut().snippets = snapshot;
        app.notify_error(crate::messages::failed_to_save(&e));
        return;
    }

    // Re-select in picker
    let name = app.snippets.form_mut().name.trim().to_string();
    let new_idx = app
        .snippets
        .store()
        .snippets
        .iter()
        .position(|s| s.name == name);
    app.ui.snippet_picker_state_mut().select(new_idx);

    if is_new {
        app.notify(crate::messages::snippet_added(&name));
    } else {
        app.notify(crate::messages::snippet_updated(&name));
    }
    app.close_snippet_form(target_aliases.to_vec());
}

#[cfg(test)]
mod param_form_tests {
    use super::*;
    use crate::app::SnippetParamFormState;
    use crate::ssh_config::model::SshConfigFile;
    use crossterm::event::KeyModifiers;

    fn make_app() -> App {
        let scratch = tempfile::tempdir().expect("tempdir").keep();
        crate::preferences::set_path_override(scratch.join("preferences"));
        let config = SshConfigFile {
            elements: SshConfigFile::parse_content(""),
            path: scratch.join("test_config"),
            crlf: false,
            bom: false,
        };
        let mut app = App::new(config);
        let snippet = crate::snippet::Snippet {
            name: "test".to_string(),
            command: "echo hi".to_string(),
            description: String::new(),
        };
        app.screen = Screen::SnippetParamForm {
            snippet,
            target_aliases: vec!["h1".to_string()],
        };
        app.snippets
            .set_param_form(Some(SnippetParamFormState::new(&[])));
        app
    }

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

    #[test]
    fn esc_on_clean_form_returns_to_snippet_picker() {
        let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
        let mut app = make_app();
        let (tx, _rx) = mpsc::channel();
        handle_param_form_key(&mut app, k(KeyCode::Esc), &tx);
        assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
        assert!(app.snippets.param_form().is_none());
    }

    #[test]
    fn typing_a_char_inserts_into_focused_param() {
        let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
        let mut app = make_app();
        let params = vec![crate::snippet::SnippetParam {
            name: "name".to_string(),
            default: None,
        }];
        app.snippets
            .set_param_form(Some(SnippetParamFormState::new(&params)));
        if let Screen::SnippetParamForm { snippet, .. } = &mut app.screen {
            snippet.command = "echo {{name}}".to_string();
        }
        let (tx, _rx) = mpsc::channel();
        handle_param_form_key(&mut app, k(KeyCode::Char('h')), &tx);
        handle_param_form_key(&mut app, k(KeyCode::Char('i')), &tx);
        let state = app.snippets.param_form().expect("form state");
        assert_eq!(state.values[0], "hi");
    }

    fn make_dirty_app() -> App {
        let mut app = make_app();
        let params = vec![crate::snippet::SnippetParam {
            name: "name".to_string(),
            default: None,
        }];
        app.snippets
            .set_param_form(Some(SnippetParamFormState::new(&params)));
        if let Screen::SnippetParamForm { snippet, .. } = &mut app.screen {
            snippet.command = "echo {{name}}".to_string();
        }
        app.snippets
            .param_form_mut()
            .expect("form state")
            .insert_char('x');
        // Pre-set pending_terminal so the y-branch reset is observable;
        // without this the assertion in discard_confirm_y_... would pass
        // vacuously against the default false value.
        app.snippets.set_pending_terminal(true);
        app
    }

    #[test]
    fn dirty_esc_arms_discard_confirmation() {
        let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
        let mut app = make_dirty_app();
        let (tx, _rx) = mpsc::channel();
        handle_param_form_key(&mut app, k(KeyCode::Esc), &tx);
        assert!(app.forms.is_discard_pending());
        assert!(matches!(app.screen, Screen::SnippetParamForm { .. }));
    }

    #[test]
    fn discard_confirm_y_closes_form_and_returns_to_picker() {
        let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
        let mut app = make_dirty_app();
        let (tx, _rx) = mpsc::channel();
        handle_param_form_key(&mut app, k(KeyCode::Esc), &tx);
        handle_param_form_key(&mut app, k(KeyCode::Char('y')), &tx);
        assert!(!app.forms.is_discard_pending());
        assert!(app.snippets.param_form().is_none());
        assert!(!app.snippets.pending_terminal());
        assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
    }

    #[test]
    fn discard_confirm_n_clears_pending_and_keeps_form() {
        let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
        let mut app = make_dirty_app();
        let (tx, _rx) = mpsc::channel();
        handle_param_form_key(&mut app, k(KeyCode::Esc), &tx);
        handle_param_form_key(&mut app, k(KeyCode::Char('n')), &tx);
        assert!(!app.forms.is_discard_pending());
        assert!(app.snippets.param_form().is_some());
        assert!(matches!(app.screen, Screen::SnippetParamForm { .. }));
    }

    // Pins the route_confirm_key Ignored contract: a stray key must NOT
    // silently cancel the discard prompt (no false positive on Yes, no
    // false negative on No that would dismiss the discard confirm).
    #[test]
    fn discard_confirm_unrelated_key_keeps_pending() {
        let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
        let mut app = make_dirty_app();
        let (tx, _rx) = mpsc::channel();
        handle_param_form_key(&mut app, k(KeyCode::Esc), &tx);
        handle_param_form_key(&mut app, k(KeyCode::Char('x')), &tx);
        assert!(app.forms.is_discard_pending());
        assert!(app.snippets.param_form().is_some());
    }
}

#[cfg(test)]
mod output_tests {
    use super::*;
    use crate::app::{SnippetHostOutput, SnippetOutputState};
    use crate::ssh_config::model::SshConfigFile;
    use crossterm::event::KeyModifiers;
    use std::sync::Arc;
    use std::sync::atomic::AtomicBool;

    fn make_app_on_output(line_count: usize) -> App {
        let scratch = tempfile::tempdir().expect("tempdir").keep();
        crate::preferences::set_path_override(scratch.join("preferences"));
        let config = SshConfigFile {
            elements: SshConfigFile::parse_content(""),
            path: scratch.join("test_config"),
            crlf: false,
            bom: false,
        };
        let mut app = App::new(config);
        let results = (0..line_count)
            .map(|i| SnippetHostOutput {
                alias: format!("h{i}"),
                stdout: "ok".to_string(),
                stderr: String::new(),
                exit_code: Some(0),
            })
            .collect();
        app.snippets.set_output(Some(SnippetOutputState {
            run_id: 1,
            results,
            scroll_offset: 0,
            completed: line_count,
            total: line_count,
            all_done: true,
            cancel: Arc::new(AtomicBool::new(false)),
        }));
        app.screen = Screen::SnippetOutput {
            snippet_name: "echo".to_string(),
            target_aliases: vec!["h0".to_string()],
        };
        app
    }

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

    #[test]
    fn j_scrolls_output_down_one_line() {
        let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
        let mut app = make_app_on_output(5);
        handle_output_key(&mut app, k(KeyCode::Char('j')));
        let state = app.snippets.output().expect("output state");
        assert_eq!(state.scroll_offset, 1);
    }

    #[test]
    fn esc_closes_overlay_and_clears_output_state() {
        let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
        let mut app = make_app_on_output(3);
        handle_output_key(&mut app, k(KeyCode::Esc));
        assert!(matches!(app.screen, Screen::HostList));
        assert!(app.snippets.output().is_none());
    }
}
