use super::*;

// ── Command palette (`:`) tests ─────────────────────────────────────────

#[test]
fn palette_open_and_submit_runs_dispatch_and_closes() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    assert!(app.command_field.is_some());
    // Type a unique command that has no ambiguity — "quit" → only "quit"
    // matches so popup has exactly one candidate, and we can dismiss
    // it cleanly.  With the new popup flow, Enter while popup is open
    // accepts the selected candidate; a second Enter executes.
    type_str(&mut app, "quit");
    // Dismiss popup (Esc), then the command is exactly "quit" with no popup.
    app.handle_command_field_key(key(KeyCode::Esc));
    assert!(app.completion.is_none(), "popup must be dismissed");
    // Now Enter executes "quit".
    app.handle_command_field_key(key(KeyCode::Enter));
    assert!(app.command_field.is_none());
    assert!(app.exit_requested);
}

#[test]
fn wq_no_filename_does_not_exit() {
    // :wq on a [No Name] buffer with content must NOT quit — the save
    // fails with E32 and the user would lose their work otherwise.
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "unsaved work");
    app.active_mut().dirty = true;
    app.dispatch_ex("wq");
    assert!(!app.exit_requested, "wq must not exit when save fails");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E32"), "expected E32, got: {msg}");
}

#[test]
fn wq_readonly_does_not_exit() {
    let mut app = App::new(None, true, None, None).unwrap();
    app.active_mut().filename = Some(tmp_path("hjkl_wq_ro_test.txt"));
    app.dispatch_ex("wq");
    assert!(!app.exit_requested, "wq must not exit when save fails");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E45"), "expected E45, got: {msg}");
}

#[test]
fn palette_esc_in_insert_drops_to_normal_then_motions_apply() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    // Use "xyz" — a prefix that matches no registered commands, so no popup opens.
    type_str(&mut app, "xyz");
    // First Esc: popup is open (typed "xyz" matches no commands → popup is None).
    // "xyz" has no matching commands so popup should be None.
    // If popup is Some, Esc dismisses popup. If None, Esc drops to Normal.
    // Either way we need Normal mode — press Esc until we get there.
    if app.completion.is_some() {
        // Dismiss popup first.
        app.handle_command_field_key(key(KeyCode::Esc));
        assert!(app.completion.is_none());
    }
    // Now Esc drops to Normal.
    app.handle_command_field_key(key(KeyCode::Esc));
    assert!(app.command_field.is_some());
    let f = app.command_field.as_ref().unwrap();
    assert_eq!(f.coarse_mode(), hjkl_form::CoarseMode::Normal);
    assert_eq!(f.text(), "xyz");
    app.handle_command_field_key(key(KeyCode::Char('b')));
    app.handle_command_field_key(key(KeyCode::Char('d')));
    app.handle_command_field_key(key(KeyCode::Char('w')));
    let f = app.command_field.as_ref().unwrap();
    assert_eq!(f.text(), "");
    app.handle_command_field_key(key(KeyCode::Esc));
    assert!(app.command_field.is_none());
}

#[test]
fn palette_ctrl_c_cancels_without_quitting_app() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "wq");
    let cc = ctrl_key('c');
    if app.command_field.is_some()
        && cc.code == KeyCode::Char('c')
        && cc.modifiers.contains(KeyModifiers::CONTROL)
    {
        app.command_field = None;
    }
    assert!(app.command_field.is_none());
    assert!(!app.exit_requested);
}

// ── Search prompt (`/` `?`) tests ───────────────────────────────────────

#[test]
fn search_open_and_type_drives_live_preview() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "foo bar foo baz");
    app.open_search_prompt(SearchDir::Forward);
    assert!(app.search_field.is_some());
    type_search(&mut app, "foo");
    assert_eq!(app.search_field.as_ref().unwrap().text(), "foo");
    assert!(app.active_editor().search_state().pattern.is_some());
}

#[test]
fn search_more_typing_updates_pattern() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "foobar foozle");
    app.open_search_prompt(SearchDir::Forward);
    type_search(&mut app, "foo");
    let p1 = app
        .active_editor()
        .search_state()
        .pattern
        .as_ref()
        .unwrap()
        .as_str()
        .to_string();
    type_search(&mut app, "z");
    let p2 = app
        .active_editor()
        .search_state()
        .pattern
        .as_ref()
        .unwrap()
        .as_str()
        .to_string();
    assert_ne!(p1, p2, "pattern must update on further typing");
}

#[test]
fn search_motion_in_normal_edits_prompt_and_updates_highlight() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "alpha beta\ngamma");
    app.open_search_prompt(SearchDir::Forward);
    type_search(&mut app, "alpha beta");
    app.handle_search_field_key(key(KeyCode::Esc));
    assert_eq!(
        app.search_field.as_ref().unwrap().coarse_mode(),
        hjkl_form::CoarseMode::Normal
    );
    app.handle_search_field_key(key(KeyCode::Char('b')));
    app.handle_search_field_key(key(KeyCode::Char('d')));
    app.handle_search_field_key(key(KeyCode::Char('b')));
    let new_text = app.search_field.as_ref().unwrap().text();
    assert!(
        new_text.len() < "alpha beta".len(),
        "prompt text shrank: {new_text:?}"
    );
}

#[test]
fn search_enter_commits_and_advances_cursor() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "alpha\nbeta\nfoo here\ndone");
    app.open_search_prompt(SearchDir::Forward);
    type_search(&mut app, "foo");
    app.handle_search_field_key(key(KeyCode::Enter));
    assert!(app.search_field.is_none());
    let (row, col) = app.active_editor().cursor();
    assert_eq!(row, 2);
    assert_eq!(col, 0);
    assert_eq!(app.active_editor().last_search(), Some("foo".to_string()));
    assert!(app.active_editor().last_search_forward());
}

#[test]
fn search_esc_twice_cancels_and_clears_when_no_prior_search() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "abc def");
    app.open_search_prompt(SearchDir::Forward);
    type_search(&mut app, "abc");
    app.handle_search_field_key(key(KeyCode::Esc));
    assert!(app.search_field.is_some());
    app.handle_search_field_key(key(KeyCode::Esc));
    assert!(app.search_field.is_none());
    assert!(app.active_editor().search_state().pattern.is_none());
}

#[test]
fn search_backward_prompt_uses_question_dir() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "foo here\nbar there\nfoo again");
    app.active_editor_mut().goto_line(3);
    app.open_search_prompt(SearchDir::Backward);
    type_search(&mut app, "foo");
    app.handle_search_field_key(key(KeyCode::Enter));
    let (row, _) = app.active_editor().cursor();
    assert_eq!(row, 0);
    assert!(!app.active_editor().last_search_forward());
}

// ── :set background= tests ───────────────────────────────────────────────

#[test]
fn colon_set_background_dark_swaps_theme() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("set background=dark");
    let msg = app.bus.last_body_or_empty().to_string();
    assert_eq!(
        msg, "background=dark",
        "expected background=dark, got: {msg}"
    );
}

#[test]
fn colon_set_background_light_swaps_theme() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("set background=light");
    let msg = app.bus.last_body_or_empty().to_string();
    assert_eq!(
        msg, "background=light",
        "expected background=light, got: {msg}"
    );
}

#[test]
fn colon_set_background_unknown_errors() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("set background=mauve");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.starts_with("E:"),
        "expected E: error for unknown background, got: {msg}"
    );
}

#[test]
fn esc_on_empty_command_prompt_dismisses() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    assert!(app.command_field.is_some());
    // Empty field shows a popup with all commands. First Esc dismisses popup.
    if app.completion.is_some() {
        app.handle_command_field_key(key(KeyCode::Esc));
        assert!(app.completion.is_none(), "first Esc must dismiss popup");
        assert!(
            app.command_field.is_some(),
            "field stays open after popup Esc"
        );
    }
    // Second Esc (or first if no popup) closes the empty field.
    app.handle_command_field_key(key(KeyCode::Esc));
    assert!(
        app.command_field.is_none(),
        "empty : prompt should close on Esc"
    );
}

#[test]
fn esc_on_nonempty_command_drops_to_normal_then_closes() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    app.handle_command_field_key(key(KeyCode::Char('w')));
    // Esc1: a single press dismisses the popup AND leaves Insert mode.
    assert!(app.completion.is_some(), "popup must be open after 'w'");
    app.handle_command_field_key(key(KeyCode::Esc));
    assert!(app.completion.is_none(), "Esc must dismiss popup");
    assert!(app.command_field.is_some(), "field stays open after Esc");
    assert_eq!(
        app.command_field.as_ref().unwrap().coarse_mode(),
        hjkl_form::CoarseMode::Normal,
        "single Esc must also leave Insert mode"
    );
    // Esc2: from Normal, close the field.
    app.handle_command_field_key(key(KeyCode::Esc));
    assert!(app.command_field.is_none());
}

#[test]
fn esc_on_empty_search_prompt_dismisses() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_search_prompt(SearchDir::Forward);
    assert!(app.search_field.is_some());
    app.handle_search_field_key(key(KeyCode::Esc));
    assert!(
        app.search_field.is_none(),
        "empty / prompt should close on Esc"
    );
}

#[test]
fn edit_no_arg_no_filename_e32() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("e");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E32"), "expected E32, got: {msg}");
}

// ── Command completion popup tests (new popup-based flow) ──────────────────

#[test]
fn colon_typing_opens_completion_popup() {
    // Typing in `:` prompt should open the completion popup live.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "w");
    // After typing "w", popup must be Some with at least one "w*" candidate.
    assert!(
        app.completion.is_some(),
        "completion popup must open after typing in : prompt"
    );
    let popup = app.completion.as_ref().unwrap();
    assert!(!popup.is_empty(), "popup must have candidates for 'w'");
    // All visible candidates must start with 'w'.
    for &idx in &popup.visible {
        let label = &popup.all_items[idx].label;
        assert!(
            label.starts_with('w'),
            "candidate {label:?} must start with 'w'"
        );
    }
}

#[test]
fn colon_popup_items_have_docs() {
    // Each candidate in the popup must have a detail string.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "e");
    let popup = app.completion.as_ref().expect("popup must be open");
    // Find the "edit" candidate and check it has a detail.
    let edit_item = popup
        .all_items
        .iter()
        .find(|i| i.label == "edit" || i.label.starts_with("edit"));
    assert!(edit_item.is_some(), "should find an 'edit*' candidate");
    let item = edit_item.unwrap();
    assert!(
        item.detail.is_some(),
        "command item must have a detail/doc string"
    );
}

#[test]
fn colon_colo_completes_to_colorscheme_command() {
    // `:colo<Tab>`-style: typing "colo" must surface the registered
    // `colorscheme` command as a candidate now that it lives in the host
    // registry.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "colo");
    let popup = app
        .completion
        .as_ref()
        .expect("popup must be open for 'colo'");
    assert!(
        popup.all_items.iter().any(|i| i.label == "colorscheme"),
        "typing 'colo' must offer the 'colorscheme' command candidate"
    );
}

#[test]
fn colon_map_completes_to_map_family_command() {
    // Issue #307: `:map` executes via a bespoke intercept (it lives in neither
    // ex registry), so command-name completion must inject it as a supplemental
    // candidate. Typing "map" must surface map-family verbs like `noremap`.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "map");
    let popup = app
        .completion
        .as_ref()
        .expect("popup must be open for 'map'");
    assert!(
        popup.all_items.iter().any(|i| i.label == "map"),
        "typing 'map' must offer the 'map' command candidate"
    );
}

#[test]
fn colon_debug_completes_to_debug_command() {
    // Issue #307: `:debug` is an app-intercepted, unregistered command; it must
    // still appear as a completion candidate.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "deb");
    let popup = app
        .completion
        .as_ref()
        .expect("popup must be open for 'deb'");
    assert!(
        popup.all_items.iter().any(|i| i.label == "debug"),
        "typing 'deb' must offer the 'debug' command candidate"
    );
}

#[test]
fn colon_colorscheme_arg_completes_theme_names() {
    // `:colorscheme tok` must complete to `tokyonight` (theme-name arg
    // completion driven by ArgSources.colorschemes).
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "colorscheme tok");
    let popup = app
        .completion
        .as_ref()
        .expect("popup must be open for 'colorscheme tok'");
    assert!(
        popup.all_items.iter().any(|i| i.label == "tokyonight"),
        "'colorscheme tok' must complete to 'tokyonight'"
    );
    assert!(
        popup.all_items.iter().all(|i| i.label.starts_with("tok")),
        "arg candidates must all match the typed prefix"
    );
}

#[test]
fn colon_colorscheme_arg_completes_all_bundled_names() {
    // `:colorscheme ` (empty arg prefix) must offer every bundled scheme.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "colorscheme ");
    let popup = app
        .completion
        .as_ref()
        .expect("popup must be open for 'colorscheme '");
    for name in crate::theme::bundled_theme_names() {
        assert!(
            popup.all_items.iter().any(|i| i.label == name),
            "'colorscheme ' must offer bundled scheme {name:?}"
        );
    }
}

#[test]
fn colon_tab_navigates_popup_forward() {
    // "w" opens popup; Tab moves selection forward.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "w");
    assert!(app.completion.is_some());
    let initial_sel = app.completion.as_ref().unwrap().selected;
    app.handle_command_field_key(tab_key()); // select_next
    let after_tab = app.completion.as_ref().unwrap().selected;
    // selected should have moved
    let visible_len = app.completion.as_ref().unwrap().visible.len();
    if visible_len > 1 {
        assert_ne!(initial_sel, after_tab, "Tab must advance selection");
    }
}

#[test]
fn colon_shift_tab_navigates_popup_backward() {
    // "w" opens popup; Tab forward; S-Tab back.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "w");
    assert!(app.completion.is_some());
    let visible_len = app.completion.as_ref().unwrap().visible.len();
    if visible_len > 1 {
        app.handle_command_field_key(tab_key()); // move forward
        let after_tab = app.completion.as_ref().unwrap().selected;
        app.handle_command_field_key(shift_tab_key()); // move back
        let after_btab = app.completion.as_ref().unwrap().selected;
        assert_ne!(after_tab, after_btab, "S-Tab must move selection back");
    }
}

#[test]
fn colon_enter_accepts_selected_item_no_execute() {
    // Typing "e" resolves to a runnable command (`edit`), so a bare Enter would
    // execute it. But once the user NAVIGATES the popup, Enter must accept the
    // highlighted candidate (field stays open) rather than execute.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "e");
    assert!(
        app.completion.is_some(),
        "popup must be open after typing 'e'"
    );
    // Navigate off the auto-selected best match, then accept with Enter.
    app.handle_command_field_key(key(KeyCode::Tab));
    app.handle_command_field_key(key(KeyCode::Enter));
    // Popup must now be closed.
    assert!(
        app.completion.is_none(),
        "popup must close after Enter-accept"
    );
    // Command field must still be open (not yet executed).
    assert!(
        app.command_field.is_some(),
        "command field must stay open after Enter-accept"
    );
    // Field must now contain the accepted command name.
    let text = app.command_field.as_ref().unwrap().text();
    assert!(!text.is_empty(), "field must contain accepted text");
}

#[test]
fn colon_accept_arg_command_appends_space() {
    // Accepting an arg-taking command (`edit` → `ArgKind::Path`) appends a
    // trailing space so the user can type the argument. Drive the accept
    // mechanism directly (the Enter routing for a complete/runnable command is
    // covered separately by the alias/exact-match tests).
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "edit");
    assert!(app.completion.is_some(), "popup must be open");
    // Find "edit" in the popup and select it.
    let popup = app.completion.as_ref().unwrap();
    let edit_vis_idx = popup
        .visible
        .iter()
        .position(|&idx| popup.all_items[idx].label == "edit");
    if let Some(vis_idx) = edit_vis_idx {
        app.completion.as_mut().unwrap().selected = vis_idx;
        app.accept_command_completion();
        let text = app.command_field.as_ref().unwrap().text();
        assert!(
            text.ends_with(' '),
            "arg-taking command 'edit' must have trailing space after accept: {text:?}"
        );
    }
    // If "edit" isn't in the popup candidates, we skip the rest of the assertion.
}

#[test]
fn colon_enter_exact_match_executes_directly() {
    // Typing a full no-arg command name (e.g. "wq") that exactly matches the
    // selected candidate must EXECUTE on the first Enter — accepting would be a
    // no-op (line already == candidate, no trailing space), so we skip the
    // accept step and run directly rather than requiring a second Enter.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "wq");
    // Popup is open with "wq" highlighted (it's a real command). Ensure "wq" is
    // the selected candidate so accept would be a no-op.
    let wq_vis_idx = app.completion.as_ref().and_then(|popup| {
        popup
            .visible
            .iter()
            .position(|&idx| popup.all_items[idx].label == "wq")
    });
    if let (Some(vis_idx), Some(popup)) = (wq_vis_idx, app.completion.as_mut()) {
        popup.selected = vis_idx;
        // accept-would-change must be false for an exact match.
        assert!(
            !app.command_accept_would_change_line(),
            "exact-match 'wq' should not change the line on accept"
        );
    }
    app.handle_command_field_key(key(KeyCode::Enter));
    // Single Enter executed: the command field is closed (not still awaiting a
    // second Enter).
    assert!(
        app.command_field.is_none(),
        "exact-match command must execute on the first Enter (field closed)"
    );
}

#[test]
fn colon_alias_command_executes_directly() {
    // ":w" is an alias of "write". The popup only lists canonical names
    // (write, wall, wnext…), so "w" is never an item — but typing ":w<Enter>"
    // must run write, NOT accept the top-ranked "wa*" candidate.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "w");
    // Popup is open and the typed line resolves to a runnable command.
    assert!(app.completion.is_some(), "popup should be open after 'w'");
    assert!(
        app.command_line_is_runnable(),
        "':w' must resolve as a runnable command via its alias"
    );
    app.handle_command_field_key(key(KeyCode::Enter));
    assert!(
        app.command_field.is_none(),
        "alias command ':w' must execute on the first Enter (field closed)"
    );
}

#[test]
fn colon_navigated_popup_still_accepts_over_runnable_alias() {
    // If the user actively navigates the popup away from the default, Enter
    // should accept the chosen candidate even when the typed prefix happens to
    // be a runnable alias.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "w");
    // Navigate off the auto-selected best match.
    app.handle_command_field_key(key(KeyCode::Tab));
    let selected_label = app
        .completion
        .as_ref()
        .and_then(|p| p.selected_item())
        .map(|i| i.label.clone());
    app.handle_command_field_key(key(KeyCode::Enter));
    // Accept replaces the line with the selected candidate; the field stays
    // open (awaiting the execute Enter) rather than running ":w".
    assert!(
        app.command_field.is_some(),
        "navigated accept must not execute the alias"
    );
    if let Some(label) = selected_label {
        let text = app.command_field.as_ref().unwrap().text();
        assert_eq!(
            text.trim_end(),
            label,
            "line must become the navigated candidate (modulo arg space)"
        );
    }
}

#[test]
fn colon_empty_prompt_has_no_popup_and_esc_closes_in_one_press() {
    // A bare `:` must not show the completion popup (it would dump every
    // command and force a second Esc). One Esc closes the empty prompt.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    assert!(app.command_field.is_some());
    assert!(
        app.completion.is_none(),
        "empty `:` prompt must not show a popup"
    );
    app.handle_command_field_key(key(KeyCode::Esc));
    assert!(
        app.command_field.is_none(),
        "single Esc must close an empty `:` prompt"
    );
}

#[test]
fn colon_esc_with_popup_open_clears_popup_and_propagates() {
    // "w" → popup opens → Esc → popup clears AND the Esc propagates to the
    // field's normal handling (a single Esc both dismisses the popup and steps
    // the prompt's vim mode out of Insert — it does NOT require a second Esc).
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "w");
    let text_before = app.command_field.as_ref().unwrap().text();
    assert!(app.completion.is_some());
    assert_eq!(
        app.command_field.as_ref().unwrap().coarse_mode(),
        hjkl_form::CoarseMode::Insert,
        "prompt starts in Insert mode"
    );
    app.handle_command_field_key(key(KeyCode::Esc));
    assert!(
        app.completion.is_none(),
        "completion must be cleared after Esc with popup open"
    );
    assert!(
        app.command_field.is_some(),
        "command field must stay open (non-empty text → Normal, not closed)"
    );
    // Esc propagated: the field left Insert mode (no second Esc needed).
    assert_eq!(
        app.command_field.as_ref().unwrap().coarse_mode(),
        hjkl_form::CoarseMode::Normal,
        "Esc must also leave Insert mode, not only dismiss the popup"
    );
    // Text must be unchanged (Esc dismissed popup + changed mode, didn't edit).
    let text_after = app.command_field.as_ref().unwrap().text();
    assert_eq!(
        text_before, text_after,
        "Esc must keep typed text when dismissing popup"
    );
}

#[test]
fn colon_popup_closes_when_no_match() {
    // Type something that has no matching command → popup must be None.
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "zzzzzzz_no_such_cmd");
    assert!(
        app.completion.is_none(),
        "popup must be None when no candidates match"
    );
}

#[test]
fn colon_ctrl_n_navigates_popup_when_open() {
    // When popup is open, C-n must move selection forward (not history nav).
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "w");
    assert!(app.completion.is_some());
    let sel_before = app.completion.as_ref().unwrap().selected;
    let visible_len = app.completion.as_ref().unwrap().visible.len();
    app.handle_command_field_key(ctrl_key('n'));
    if visible_len > 1 {
        let sel_after = app.completion.as_ref().unwrap().selected;
        assert_ne!(sel_before, sel_after, "C-n must advance selection in popup");
    }
}

#[test]
fn tab_expands_percent_at_caret_not_eol() {
    // `%` must expand at the REAL caret, not end-of-line. Build a command line
    // with the caret parked in the MIDDLE (right after `%`), then Tab: the
    // `%` under the caret expands and the trailing text is preserved.
    let mut app = App::new(None, false, None, None).unwrap();
    app.active_mut().filename = Some(std::path::PathBuf::from("src/main.rs"));
    app.open_command_prompt();
    // Line "e % x"; caret lands at end (col 5) after typing.
    type_str(&mut app, "e % x");
    // Move caret left twice: col 5 → 3, i.e. onto the space right after `%`,
    // so the token under the caret is `%` (mid-line, NOT at EOL where it'd be `x`).
    app.handle_command_field_key(key(KeyCode::Left));
    app.handle_command_field_key(key(KeyCode::Left));
    assert_eq!(
        app.command_field.as_ref().unwrap().cursor().1,
        3,
        "caret must be parked mid-line before Tab"
    );
    app.handle_command_field_key(key(KeyCode::Tab));
    // `%` spliced at the caret; the trailing " x" is preserved verbatim.
    assert_eq!(
        app.command_field.as_ref().unwrap().text(),
        "e src/main.rs x",
        "mid-line % must expand at the caret, leaving the tail intact"
    );
}

#[test]
fn tab_expands_cfile_from_buffer_cursor() {
    // End-to-end: `<cfile>` on the command line expands to the path under the
    // ACTIVE BUFFER's cursor (not the command line's caret).
    let mut app = App::new(None, false, None, None).unwrap();
    let content = "see src/main.rs here";
    seed_buffer(&mut app, content);
    // Park the buffer cursor on the path (char col == byte col; line is ASCII).
    let col = content.find("main").unwrap();
    app.active_editor_mut().jump_cursor(0, col);
    app.open_command_prompt();
    type_str(&mut app, "e <cfile>");
    app.handle_command_field_key(key(KeyCode::Tab));
    assert_eq!(
        app.command_field.as_ref().unwrap().text(),
        "e src/main.rs",
        "<cfile> must expand to the filename under the buffer cursor"
    );
}

#[test]
fn tab_expands_cword_from_buffer_cursor() {
    // End-to-end: `<cword>` expands to the keyword-class word under the buffer
    // cursor — stopping at punctuation (here the path's `/` and `.`).
    let mut app = App::new(None, false, None, None).unwrap();
    let content = "let foo_bar = 1";
    seed_buffer(&mut app, content);
    let col = content.find("foo").unwrap();
    app.active_editor_mut().jump_cursor(0, col);
    app.open_command_prompt();
    type_str(&mut app, "e <cword>");
    app.handle_command_field_key(key(KeyCode::Tab));
    assert_eq!(
        app.command_field.as_ref().unwrap().text(),
        "e foo_bar",
        "<cword> must expand to the word under the buffer cursor"
    );
}

#[test]
fn tab_cfile_on_whitespace_leaves_token() {
    // Cursor on whitespace → `<cfile>` yields nothing, so the token is left in
    // place unchanged (vim parity).
    let mut app = App::new(None, false, None, None).unwrap();
    let content = "word   other";
    seed_buffer(&mut app, content);
    // Column 5 sits in the run of spaces.
    app.active_editor_mut().jump_cursor(0, 5);
    app.open_command_prompt();
    type_str(&mut app, "e <cfile>");
    app.handle_command_field_key(key(KeyCode::Tab));
    assert_eq!(
        app.command_field.as_ref().unwrap().text(),
        "e <cfile>",
        "off-token <cfile> must be left in place"
    );
}

// ── Enter over an ARGUMENT completion ───────────────────────────────────

/// Enter must accept the highlighted argument candidate, exactly like it does
/// for a command name. It used to run the half-typed line instead: the
/// "line is already runnable → execute" exit is there so `:w<Enter>` writes
/// rather than accepting `wall`, but an argument's leading word (`set`,
/// `colorscheme`, `e`) is runnable by construction, so that exit fired for
/// every argument popup and threw the selection away.
#[test]
fn enter_accepts_the_selected_option_argument() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "set foldmet");
    assert_eq!(
        app.completion
            .as_ref()
            .and_then(|p| p.selected_item().map(|i| i.label.clone())),
        Some("foldmethod".to_string()),
        "precondition: the popup must offer `foldmethod`"
    );
    assert_eq!(
        app.completion.as_ref().map(|p| p.selected),
        Some(0),
        "precondition: the first item is selected — the case that was broken"
    );

    app.handle_command_field_key(key(KeyCode::Enter));
    assert_eq!(
        app.command_field.as_ref().map(|f| f.text()),
        Some("set foldmethod".to_string()),
        "Enter must accept the candidate and keep the prompt open"
    );
    assert!(app.completion.is_none(), "accepting consumes the popup");

    // The second Enter runs it, as with command-name completion.
    app.handle_command_field_key(key(KeyCode::Enter));
    assert!(app.command_field.is_none(), "second Enter executes");
}

/// Same for a value argument sourced from the app (`:colorscheme <name>`).
#[test]
fn enter_accepts_the_selected_colorscheme_argument() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "colorscheme drac");
    assert_eq!(
        app.completion
            .as_ref()
            .and_then(|p| p.selected_item().map(|i| i.label.clone())),
        Some("dracula".to_string()),
        "precondition: the popup must offer `dracula`"
    );

    app.handle_command_field_key(key(KeyCode::Enter));
    assert_eq!(
        app.command_field.as_ref().map(|f| f.text()),
        Some("colorscheme dracula".to_string())
    );
}

/// The command-name rule is unchanged: a runnable line executes on the first
/// Enter instead of accepting a longer candidate off the popup.
#[test]
fn enter_still_runs_a_runnable_command_name_instead_of_accepting() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    // `w` is an alias for `write`; the popup lists `wall`, `wq`, … but Enter
    // must run `:w`, not accept one of them.
    type_str(&mut app, "w");
    assert!(app.completion.is_some(), "precondition: popup is open");
    app.handle_command_field_key(key(KeyCode::Enter));
    assert!(
        app.command_field.is_none(),
        "a runnable command name executes on the first Enter"
    );
}

/// `command_completion_is_arg` keys off the replace range, so it must not be
/// fooled by a leading range prefix: `:8,3d` completes a command NAME.
#[test]
fn command_completion_is_arg_is_false_for_a_ranged_command_name() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.open_command_prompt();
    type_str(&mut app, "8,3d");
    assert!(
        app.completion.is_some(),
        "precondition: ranged command names complete"
    );
    assert!(
        !app.command_completion_is_arg(),
        "the token after a range prefix is still the command name"
    );
}

// ── `:colorscheme` live preview ─────────────────────────────────────────

/// Open `:colorscheme ` and walk the popup until the highlighted candidate is
/// a scheme other than the active one, so the assertions do not depend on
/// where the current theme happens to sit in the alphabetical list. Returns
/// the scheme now highlighted.
fn preview_a_different_scheme(app: &mut App) -> String {
    let before = app.colorscheme.clone();
    app.open_command_prompt();
    type_str(app, "colorscheme ");
    assert!(
        app.completion.is_some(),
        "precondition: the popup must list the bundled schemes"
    );
    for _ in 0..16 {
        app.handle_command_field_key(key(KeyCode::Down));
        let sel = app
            .completion
            .as_ref()
            .and_then(|p| p.selected_item().map(|i| i.label.clone()))
            .expect("popup stays open while navigating");
        if sel != before {
            return sel;
        }
    }
    panic!("popup never highlighted a scheme other than {before}");
}

/// Moving through the `:colorscheme` popup applies each theme, so it can be
/// judged on the real buffer rather than by its name.
#[test]
fn colorscheme_popup_navigation_previews_the_theme() {
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.colorscheme.clone();
    let previewed = preview_a_different_scheme(&mut app);

    assert_ne!(previewed, before, "sanity: a different scheme is selected");
    assert_eq!(
        app.colorscheme, previewed,
        "the highlighted candidate must be the applied theme"
    );
    // The chrome follows too, not just the recorded name.
    assert_eq!(
        app.theme.ui.background,
        crate::theme::load_named(&previewed).unwrap().ui.background,
        "the UI theme must be the previewed scheme's"
    );
}

/// Leaving the prompt without running the command puts the original theme
/// back — a preview must never become permanent by accident.
#[test]
fn escaping_the_prompt_restores_the_pre_preview_theme() {
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.colorscheme.clone();
    preview_a_different_scheme(&mut app);
    assert_ne!(app.colorscheme, before, "precondition: preview applied");

    // First Esc dismisses the popup and leaves Insert; second closes the prompt.
    app.handle_command_field_key(key(KeyCode::Esc));
    app.handle_command_field_key(key(KeyCode::Esc));
    assert!(app.command_field.is_none(), "prompt must be closed");
    assert_eq!(app.colorscheme, before, "the original theme is restored");
    assert_eq!(
        app.theme.ui.background,
        crate::theme::load_named(&before).unwrap().ui.background,
        "the UI theme is restored too, not just the recorded name"
    );
    assert!(
        app.theme_preview_restore.is_none(),
        "the saved scheme is consumed"
    );
}

/// Running the command keeps the previewed theme — the accepted candidate is
/// what `:colorscheme` then applies for real.
#[test]
fn running_colorscheme_keeps_the_previewed_theme() {
    let mut app = App::new(None, false, None, None).unwrap();
    let picked = preview_a_different_scheme(&mut app);
    assert_eq!(
        app.colorscheme, picked,
        "precondition: the preview is applied before anything is run"
    );

    // Enter accepts the candidate into the line, the second Enter runs it.
    app.handle_command_field_key(key(KeyCode::Enter));
    assert_eq!(
        app.command_field.as_ref().map(|f| f.text()),
        Some(format!("colorscheme {picked}"))
    );
    app.handle_command_field_key(key(KeyCode::Enter));
    assert!(app.command_field.is_none(), "second Enter runs it");
    assert_eq!(app.colorscheme, picked, "the chosen scheme stays applied");
    assert!(app.theme_preview_restore.is_none());
}

/// Typing a DIFFERENT command after previewing must not leave the preview
/// applied: the theme goes back before the typed command runs.
#[test]
fn running_another_command_after_a_preview_restores_the_theme() {
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.colorscheme.clone();
    preview_a_different_scheme(&mut app);
    assert_ne!(app.colorscheme, before, "precondition: preview applied");

    // Replace the line with an unrelated command and run it.
    app.handle_command_field_key(key(KeyCode::Esc)); // dismiss popup, leave Insert
    app.command_field
        .as_mut()
        .expect("prompt still open")
        .set_text("nohlsearch");
    app.handle_command_field_key(key(KeyCode::Enter));
    assert!(app.command_field.is_none());
    assert_eq!(
        app.colorscheme, before,
        "a preview must not survive an unrelated command"
    );
}

/// <C-c> abandons the prompt outright; the preview goes with it.
#[test]
fn ctrl_c_on_the_prompt_restores_the_pre_preview_theme() {
    use crossterm::event::{KeyEvent, KeyModifiers};
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.colorscheme.clone();
    preview_a_different_scheme(&mut app);
    assert_ne!(app.colorscheme, before, "precondition: preview applied");

    app.handle_keypress(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
    assert!(app.command_field.is_none(), "<C-c> closes the prompt");
    assert_eq!(app.colorscheme, before, "the original theme is restored");
}

/// The abbreviation vim users actually type (`:colo`) resolves to the same
/// command, so it previews too.
#[test]
fn colo_abbreviation_previews_as_well() {
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.colorscheme.clone();
    app.open_command_prompt();
    type_str(&mut app, "colo ");
    assert!(app.completion.is_some(), "precondition: popup is open");
    for _ in 0..16 {
        app.handle_command_field_key(key(KeyCode::Down));
        if app.colorscheme != before {
            return;
        }
    }
    panic!("`:colo ` never previewed a scheme (current {before})");
}

/// Only `:colorscheme` arguments preview. A `:set` value popup must not touch
/// the theme, however its candidates are named.
#[test]
fn other_argument_popups_do_not_preview_a_theme() {
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.colorscheme.clone();
    app.open_command_prompt();
    type_str(&mut app, "set foldm");
    assert!(app.completion.is_some(), "precondition: popup is open");
    app.handle_command_field_key(key(KeyCode::Down));
    assert_eq!(
        app.colorscheme, before,
        "no theme change from a `:set` popup"
    );
    assert!(app.theme_preview_restore.is_none());
}
