use super::*;
use crate::app::CmdLineKind;
use crate::app::event_loop::KeyOutcome;
use hjkl_ex::QfCommand;

// ── :write / :write! disk-state guard tests ─────────────────────────────

#[test]
fn colon_write_blocked_by_disk_state_guard_without_bang() {
    let path = std::env::temp_dir().join("hjkl_write_no_bang_guard.txt");
    std::fs::write(&path, "original\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    // Mark disk changed without reloading buffer, then dirty the buffer.
    app.active_mut().disk_state = DiskState::ChangedOnDisk;
    app.active_mut().dirty = true;
    seed_buffer(&mut app, "edited\n");
    // :write without bang must refuse.
    app.dispatch_ex("write");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("E13"),
        "expected E13 guard message, got: {msg}"
    );
    // File on disk must be unchanged.
    let on_disk = std::fs::read_to_string(&path).unwrap();
    assert_eq!(
        on_disk, "original\n",
        "disk must be unchanged after blocked :w"
    );
    let _ = std::fs::remove_file(&path);
}

#[test]
fn colon_write_bang_overrides_disk_state_guard() {
    let path = std::env::temp_dir().join("hjkl_write_bang_guard.txt");
    std::fs::write(&path, "original\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.active_mut().disk_state = DiskState::ChangedOnDisk;
    app.active_mut().dirty = true;
    seed_buffer(&mut app, "edited\n");
    // :write! must force-save.
    app.dispatch_ex("write!");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(!msg.contains("E13"), ":w! must not produce E13, got: {msg}");
    // disk_state must be reset to Synced.
    assert_eq!(
        app.active().disk_state,
        DiskState::Synced,
        "disk_state must be Synced after :w!"
    );
    // File on disk must contain the new content.
    let on_disk = std::fs::read_to_string(&path).unwrap();
    assert!(
        on_disk.contains("edited"),
        "disk must have new content after :w!"
    );
    let _ = std::fs::remove_file(&path);
}

// ── :e tests ────────────────────────────────────────────────────────────

#[test]
fn edit_percent_reloads_current_file() {
    let path = std::env::temp_dir().join("hjkl_edit_percent_reload.txt");
    std::fs::write(&path, "first\nsecond\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    std::fs::write(&path, "alpha\nbeta\ngamma\n").unwrap();
    app.dispatch_ex("e %");
    let lines = rope_to_lines_vec(&app.active_editor().buffer().rope());
    assert_eq!(lines, vec!["alpha", "beta", "gamma"]);
    let _ = std::fs::remove_file(&path);
}

#[test]
fn edit_literal_percent_filename_not_reexpanded() {
    // Regression: a path containing a literal `%` (as handed to `do_edit` by
    // the picker / quickfix / explorer) must be opened verbatim. `do_edit`
    // used to re-run `%` → current-filename expansion on top of the one
    // `dispatch_ex` already performs, mangling names like `100%.txt`.
    let dir = tempfile::tempdir().unwrap();
    let first = dir.path().join("hjkl_pct_first.txt");
    std::fs::write(&first, "first\n").unwrap();
    let pct = dir.path().join("100%.txt");
    std::fs::write(&pct, "pct-content\n").unwrap();
    let mut app = App::new(Some(first), false, None, None).unwrap();
    app.do_edit(pct.to_str().unwrap(), false);
    let active = app
        .active()
        .filename
        .clone()
        .expect("a file must be active after do_edit");
    assert!(
        active.to_string_lossy().ends_with("100%.txt"),
        "literal-% path must open verbatim; got {active:?}"
    );
}

#[test]
fn escape_ex_path_roundtrips_through_dispatch_ex() {
    // Opening a %-named file through the ex layer (explorer / picker route)
    // must land on that file, not expand `%` to the current filename.
    let dir = tempfile::tempdir().unwrap();
    let first = dir.path().join("hjkl_pct_dispatch_first.txt");
    std::fs::write(&first, "first\n").unwrap();
    let pct = dir.path().join("pct%name.txt");
    std::fs::write(&pct, "via-dispatch\n").unwrap();
    let mut app = App::new(Some(first), false, None, None).unwrap();
    let escaped = crate::app::ex_dispatch::escape_ex_path(&pct.to_string_lossy());
    app.dispatch_ex(&format!("edit {escaped}"));
    let active = app
        .active()
        .filename
        .clone()
        .expect("a file must be active after :edit");
    assert!(
        active.to_string_lossy().ends_with("pct%name.txt"),
        "escaped %-path must open verbatim through dispatch_ex; got {active:?}"
    );
}

#[test]
fn edit_no_arg_reloads_current_file() {
    let path = std::env::temp_dir().join("hjkl_edit_noarg_reload.txt");
    std::fs::write(&path, "v1\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    std::fs::write(&path, "v2\n").unwrap();
    app.dispatch_ex("e");
    assert_eq!(
        rope_to_lines_vec(&app.active_editor().buffer().rope()),
        vec!["v2".to_string()]
    );
    let _ = std::fs::remove_file(&path);
}

#[test]
fn edit_blocks_dirty_buffer_without_force() {
    let path = std::env::temp_dir().join("hjkl_edit_dirty_block.txt");
    std::fs::write(&path, "orig\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.active_mut().dirty = true;
    app.dispatch_ex("e %");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E37"), "expected E37, got: {msg}");
    let _ = std::fs::remove_file(&path);
}

#[test]
fn edit_force_reloads_dirty_buffer() {
    let path = std::env::temp_dir().join("hjkl_edit_force.txt");
    std::fs::write(&path, "disk\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.active_mut().dirty = true;
    app.dispatch_ex("e!");
    assert_eq!(
        rope_to_lines_vec(&app.active_editor().buffer().rope()),
        vec!["disk".to_string()]
    );
    assert!(!app.active().dirty);
    let _ = std::fs::remove_file(&path);
}

#[test]
fn undo_to_saved_state_clears_dirty() {
    let path = std::env::temp_dir().join("hjkl_undo_clears_dirty.txt");
    std::fs::write(&path, "alpha\nbravo\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    assert!(!app.active().dirty);
    hjkl_vim_tui::handle_key(app.active_editor_mut(), key(KeyCode::Char('i')));
    hjkl_vim_tui::handle_key(app.active_editor_mut(), key(KeyCode::Char('X')));
    if app.active_editor_mut().take_dirty() {
        app.active_mut().refresh_dirty_against_saved();
    }
    assert!(app.active().dirty, "edit should mark dirty");
    hjkl_vim_tui::handle_key(app.active_editor_mut(), key(KeyCode::Esc));
    hjkl_vim_tui::handle_key(app.active_editor_mut(), key(KeyCode::Char('u')));
    if app.active_editor_mut().take_dirty() {
        app.active_mut().refresh_dirty_against_saved();
    }
    assert!(
        !app.active().dirty,
        "undo to saved state should clear dirty"
    );
    let _ = std::fs::remove_file(&path);
}

// ── Phase C: multi-buffer tests ─────────────────────────────────────────
#[test]
fn edit_new_path_appends_slot_and_switches() {
    let path_a = std::env::temp_dir().join("hjkl_phc_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phc_b.txt");
    std::fs::write(&path_a, "alpha\n").unwrap();
    std::fs::write(&path_b, "beta\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    assert_eq!(app.slots.len(), 1);
    app.dispatch_ex(&format!("e {}", path_b.display()));
    assert_eq!(app.slots.len(), 2);
    assert_eq!(app.active_index(), 1);
    assert_eq!(
        rope_to_lines_vec(&app.active_editor().buffer().rope()),
        vec!["beta".to_string()]
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

#[test]
fn edit_existing_path_switches_to_open_slot() {
    let path_a = std::env::temp_dir().join("hjkl_phc_switch_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phc_switch_b.txt");
    std::fs::write(&path_a, "alpha\n").unwrap();
    std::fs::write(&path_b, "beta\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    assert_eq!(app.active_index(), 1);
    // Re-open path_a → switch back, no third slot.
    app.dispatch_ex(&format!("e {}", path_a.display()));
    assert_eq!(app.slots.len(), 2);
    assert_eq!(app.active_index(), 0);
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

/// Regression (audit A8 follow-up): the explorer's own scratch slot has
/// `filename == None && !dirty` — the exact same shape `:e <path>`'s
/// "drop the pristine default buffer" logic used to treat as discardable.
/// Without the `is_special` guard, opening a file while the explorer
/// window is focused deleted the explorer slot instead of the intended
/// throwaway default buffer.
///
/// #63 Phase 5 additionally routes the create path through
/// `focus_editor_window_for_open`, so the file lands in the regular window
/// rather than in the explorer's. The pristine default buffer that regular
/// window was showing is then the one discarded — which is the whole point
/// of the pristine check, and is why only two slots remain here.
#[test]
fn edit_while_explorer_focused_does_not_delete_the_explorer_slot() {
    use crate::keymap_actions::AppAction;

    let path = std::env::temp_dir().join("hjkl_a8_explorer_guard.txt");
    std::fs::write(&path, "hello\n").unwrap();

    // No file on the command line → slot 0 is the pristine default buffer.
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.slots.len(), 1);

    // Open the explorer: it gets its own slot and takes focus.
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert_eq!(app.slots.len(), 2);
    assert!(
        app.active().is_explorer(),
        "explorer window must be focused after opening it"
    );
    let explorer_win = app.focused_window();

    // `:e <path>` while the explorer is focused.
    app.dispatch_ex(&format!("e {}", path.display()));

    assert!(
        app.slots.iter().any(|s| s.is_explorer()),
        "the explorer slot must survive `:e` while it was the focused window"
    );
    assert_eq!(
        app.slots.len(),
        2,
        "explorer + newly opened file (the pristine default was discarded)"
    );
    assert_ne!(
        app.focused_window(),
        explorer_win,
        "`:e` must move focus off the explorer before opening the file"
    );
    let explorer_slot = app.windows[explorer_win]
        .as_ref()
        .expect("explorer window must still be open")
        .slot;
    assert!(
        app.slots[explorer_slot].is_explorer(),
        "the explorer window must still show its own scratch slot"
    );
    assert_eq!(
        app.active().filename.as_deref(),
        Some(path.as_path()),
        "the file must be open in the focused regular window"
    );

    let _ = std::fs::remove_file(&path);
}

#[test]
fn edit_other_open_path_does_not_block_on_dirty() {
    let path_a = std::env::temp_dir().join("hjkl_phc_dirty_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phc_dirty_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.active_mut().dirty = true;
    // Switching to a *different* file must not be gated on the
    // current slot's dirty flag — the slot isn't being destroyed.
    app.dispatch_ex(&format!("e {}", path_b.display()));
    assert_eq!(app.active_index(), 1);
    assert!(app.slots[0].dirty, "slot 0 should remain dirty");
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

#[test]
fn bnext_bprev_cycle_active() {
    let path_a = std::env::temp_dir().join("hjkl_phc_cycle_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phc_cycle_b.txt");
    let path_c = std::env::temp_dir().join("hjkl_phc_cycle_c.txt");
    for p in [&path_a, &path_b, &path_c] {
        std::fs::write(p, "x\n").unwrap();
    }
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    app.dispatch_ex(&format!("e {}", path_c.display()));
    assert_eq!(app.active_index(), 2);
    app.dispatch_ex("bn");
    assert_eq!(app.active_index(), 0, "wrap forward to 0");
    app.dispatch_ex("bn");
    assert_eq!(app.active_index(), 1);
    app.dispatch_ex("bp");
    assert_eq!(app.active_index(), 0);
    app.dispatch_ex("bp");
    assert_eq!(app.active_index(), 2, "wrap backward to last");
    for p in [&path_a, &path_b, &path_c] {
        let _ = std::fs::remove_file(p);
    }
}

#[test]
fn bnext_no_op_with_single_slot() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("bn");
    assert_eq!(app.active_index(), 0);
    assert_eq!(app.slots.len(), 1);
}

#[test]
fn bdelete_blocks_dirty_without_force() {
    let path_a = std::env::temp_dir().join("hjkl_phc_bd_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phc_bd_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    app.active_mut().dirty = true;
    app.dispatch_ex("bd");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E89"), "expected E89, got: {msg}");
    assert_eq!(app.slots.len(), 2);
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

#[test]
fn bdelete_force_removes_dirty_slot() {
    let path_a = std::env::temp_dir().join("hjkl_phc_bdforce_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phc_bdforce_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    app.active_mut().dirty = true;
    app.dispatch_ex("bd!");
    assert_eq!(app.slots.len(), 1);
    assert_eq!(app.active_index(), 0);
    assert_eq!(
        rope_to_lines_vec(&app.active_editor().buffer().rope()),
        vec!["a".to_string()]
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

/// Regression: closing a buffer in a multi-buffer session must delete that
/// buffer's swap file. The owning process stays alive (so the orphan scan never
/// reaps it) and the slot is gone (so cleanup_swaps_on_exit can't either) —
/// leaving it behind makes the next open of that file surface a spurious
/// recovery prompt.
#[test]
fn bdelete_removes_closed_buffers_swap() {
    let path_a = std::env::temp_dir().join("hjkl_bd_swap_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_bd_swap_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));

    // Inject a real swap file for the active (b) slot.
    let swap_b = std::env::temp_dir().join("hjkl_bd_swap_b.swp");
    std::fs::write(&swap_b, b"x").unwrap();
    app.active_mut().swap_path = Some(swap_b.clone());
    assert!(swap_b.exists(), "swap must exist before bdelete");

    app.dispatch_ex("bd");

    assert_eq!(app.slots.len(), 1, "one slot should remain after bdelete");
    assert!(
        !swap_b.exists(),
        "closed buffer's swap must be removed by bdelete"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
    let _ = std::fs::remove_file(&swap_b);
}

#[test]
fn bdelete_on_last_slot_resets_to_no_name() {
    let path = std::env::temp_dir().join("hjkl_phc_bd_last.txt");
    std::fs::write(&path, "content\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.dispatch_ex("bd");
    assert_eq!(app.slots.len(), 1);
    assert!(app.active().filename.is_none());
    let lines = rope_to_lines_vec(&app.active_editor().buffer().rope());
    assert!(
        lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()),
        "expected empty scratch buffer, got: {lines:?}"
    );
    let _ = std::fs::remove_file(&path);
}

// ── :bwipeout tests (#101) ──────────────────────────────────────────────

#[test]
fn bwipeout_clears_marks_on_last_slot() {
    let path = std::env::temp_dir().join("hjkl_bwipeout_marks_last.txt");
    std::fs::write(&path, "hello\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    // Set a mark on the buffer.
    app.active_editor_mut().set_mark('a', (0, 0));
    assert!(
        app.active_editor().mark('a').is_some(),
        "mark should be set before wipe"
    );
    app.dispatch_ex("bw");
    // After wipe the fresh scratch buffer must have no marks.
    assert!(
        app.active_editor().mark('a').is_none(),
        ":bwipeout on last slot must not carry marks into scratch buffer"
    );
    assert_eq!(app.slots.len(), 1);
    assert!(app.active().filename.is_none());
    let _ = std::fs::remove_file(&path);
}

#[test]
fn bwipeout_clears_jumplist_on_last_slot() {
    let path = std::env::temp_dir().join("hjkl_bwipeout_jumps_last.txt");
    std::fs::write(&path, "line1\nline2\nline3\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    // Push an entry into the jumplist.
    app.active_editor_mut().record_jump((2, 0));
    let (back, _) = app.active_editor().jump_list();
    assert!(
        !back.is_empty(),
        "jumplist should have an entry before wipe"
    );
    app.dispatch_ex("bw");
    // After wipe the fresh scratch buffer must have an empty jumplist.
    let (back, fwd) = app.active_editor().jump_list();
    assert!(
        back.is_empty() && fwd.is_empty(),
        ":bwipeout on last slot must not carry jumps into scratch buffer"
    );
    let _ = std::fs::remove_file(&path);
}

#[test]
fn bdelete_does_not_explicitly_wipe_marks_path() {
    // Verify :bdelete and :bwipeout both reach distinct code paths.
    // With two slots, :bdelete removes the active slot (different slot survives).
    let path_a = std::env::temp_dir().join("hjkl_bdelete_path_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_bdelete_path_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    assert_eq!(app.slots.len(), 2);
    // Set a mark on slot 1 (path_b).
    app.active_editor_mut().set_mark('z', (0, 0));
    // :bd removes slot 1; slot 0 (path_a) survives with its own editor.
    app.dispatch_ex("bd");
    assert_eq!(app.slots.len(), 1);
    // The surviving slot (path_a) never had mark 'z'.
    assert!(
        app.active_editor().mark('z').is_none(),
        "mark from removed slot must not survive after :bd"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

/// Regression: `:bwipe` in a multi-buffer session must also delete the closed
/// buffer's swap (same rationale as `bdelete_removes_closed_buffers_swap` — the
/// other slot-removal site in `buffer_ops.rs`).
#[test]
fn bwipe_removes_closed_buffers_swap() {
    let path_a = std::env::temp_dir().join("hjkl_bw_swap_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_bw_swap_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));

    // Inject a real swap file for the active (b) slot.
    let swap_b = std::env::temp_dir().join("hjkl_bw_swap_b.swp");
    std::fs::write(&swap_b, b"x").unwrap();
    app.active_mut().swap_path = Some(swap_b.clone());
    assert!(swap_b.exists(), "swap must exist before bwipe");

    app.dispatch_ex("bw");

    assert_eq!(app.slots.len(), 1, "one slot should remain after bwipe");
    assert!(
        !swap_b.exists(),
        "closed buffer's swap must be removed by bwipe"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
    let _ = std::fs::remove_file(&swap_b);
}

#[test]
fn bwipeout_multi_slot_removes_slot() {
    let path_a = std::env::temp_dir().join("hjkl_bwipeout_multi_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_bwipeout_multi_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    assert_eq!(app.slots.len(), 2);
    app.dispatch_ex("bw");
    assert_eq!(app.slots.len(), 1);
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("wiped"),
        "expected wipe status message, got: {msg}"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

#[test]
fn bwipeout_blocks_dirty_without_force() {
    let path_a = std::env::temp_dir().join("hjkl_bwipeout_dirty_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_bwipeout_dirty_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    app.active_mut().dirty = true;
    app.dispatch_ex("bw");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E89"), "expected E89, got: {msg}");
    assert_eq!(
        app.slots.len(),
        2,
        "slot must not be removed when dirty without force"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

// ── Alt-buffer (D2) tests ───────────────────────────────────────────────

#[test]
fn buffer_alt_swaps_with_prev_active() {
    let path_a = std::env::temp_dir().join("hjkl_d2_alt_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_d2_alt_b.txt");
    let path_c = std::env::temp_dir().join("hjkl_d2_alt_c.txt");
    for p in [&path_a, &path_b, &path_c] {
        std::fs::write(p, "x\n").unwrap();
    }
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display())); // active=1, prev=0
    app.dispatch_ex(&format!("e {}", path_c.display())); // active=2, prev=1
    assert_eq!(app.active_index(), 2);
    assert_eq!(app.prev_active, Some(1));

    // First alt: go back to 1, prev becomes 2.
    app.buffer_alt();
    assert_eq!(app.active_index(), 1);
    assert_eq!(app.prev_active, Some(2));

    // Second alt: go back to 2.
    app.buffer_alt();
    assert_eq!(app.active_index(), 2);

    for p in [&path_a, &path_b, &path_c] {
        let _ = std::fs::remove_file(p);
    }
}

#[test]
fn buffer_alt_with_single_slot_no_op_with_message() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.slots.len(), 1);
    app.buffer_alt();
    assert_eq!(app.active_index(), 0);
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("only one buffer"),
        "expected 'only one buffer' message, got: {msg}"
    );
}

#[test]
fn bd_clears_prev_active() {
    let path_a = std::env::temp_dir().join("hjkl_d2_bd_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_d2_bd_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display())); // active=1, prev=0
    assert_eq!(app.prev_active, Some(0));
    // Force-close the active slot (b.txt).
    app.dispatch_ex("bd!");
    // prev_active must be reset so the stale index is gone.
    assert!(
        app.prev_active.is_none(),
        "prev_active should be None after bd!"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

// ── Phase E: multi-buffer ex-command parity tests ──────────────────────

#[test]
fn b_num_switches_by_index() {
    let path_a = std::env::temp_dir().join("hjkl_phe_bnum_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phe_bnum_b.txt");
    let path_c = std::env::temp_dir().join("hjkl_phe_bnum_c.txt");
    for p in [&path_a, &path_b, &path_c] {
        std::fs::write(p, "x\n").unwrap();
    }
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    app.dispatch_ex(&format!("e {}", path_c.display()));
    assert_eq!(app.slots.len(), 3);
    app.dispatch_ex("b 2");
    assert_eq!(app.active_index(), 1, "`:b 2` should switch to index 1");
    for p in [&path_a, &path_b, &path_c] {
        let _ = std::fs::remove_file(p);
    }
}

#[test]
fn b_num_out_of_range_errors() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.slots.len(), 1);
    app.dispatch_ex("b 5");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E86"), "expected E86, got: {msg}");
}

#[test]
fn b_num_targeting_explorer_slot_errors_e86() {
    // A8: the explorer's scratch slot is appended as a normal-looking slot
    // (invisible in `:ls`), so its numeric position must NOT be a valid
    // `:b N` target — `switch_to` silently no-ops on it, so the bounds
    // check has to reject it explicitly rather than let it through.
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.slots.len(), 1, "starts with one real (scratch) slot");
    app.toggle_explorer();
    assert_eq!(app.slots.len(), 2, "explorer appended as slot index 1");
    assert!(app.slots[1].is_explorer());
    // Focus back on the real editor window (as a user would after opening
    // the explorer in a side split) before issuing `:b 2`.
    app.switch_to(0);
    assert!(!app.explorer_buf_focused());
    let before = app.active_index();
    app.dispatch_ex("b 2");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("E86"),
        "`:b <explorer index>` should error E86, got: {msg}"
    );
    assert_eq!(
        app.active_index(),
        before,
        "active slot must not change when targeting the explorer slot"
    );
    assert!(
        !app.explorer_buf_focused(),
        "explorer must not become focused via `:b N`"
    );
}

/// `:q` from the real editor window with one real buffer must quit the app,
/// even when the explorer is open. The explorer's slot is not a user buffer,
/// so it must not push the quit path into the multi-slot `:bdelete` branch —
/// which used to delete the real buffer and leave the explorer rendering in
/// the main window, requiring a second `:q`.
#[test]
fn q_from_main_window_quits_even_with_explorer_open() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.toggle_explorer();
    app.switch_to(0);
    assert!(!app.explorer_buf_focused(), "focus must be the real window");
    assert_eq!(app.slots.len(), 2, "real slot + explorer slot");

    app.dispatch_ex("q");

    assert!(
        app.exit_requested,
        "`:q` with only the explorer alongside one real buffer must quit"
    );
}

/// `:q` while the explorer is focused must close the explorer dock (same as
/// toggling it off), not delete its scratch buffer and not quit the app.
#[test]
fn q_on_focused_explorer_closes_the_dock() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.toggle_explorer();
    assert!(app.explorer_buf_focused(), "toggle focuses the explorer");

    app.dispatch_ex("q");

    assert!(!app.exit_requested, "`:q` in the explorer must not quit");
    assert!(
        app.tabs[app.active_tab].left_dock.is_none(),
        "the explorer dock must be closed"
    );
    assert_eq!(app.slots.len(), 1, "the explorer slot must be gone too");
    assert!(!app.explorer_buf_focused());
}

/// `<C-w>q` in the explorer must close the dock, not quit the editor. Docks
/// are not `LayoutTree` leaves, so the "only one window left" test used to be
/// true while the explorer was focused and took the quit branch.
#[test]
fn ctrl_w_q_on_focused_explorer_closes_the_dock_instead_of_quitting() {
    use hjkl_app::keymap_actions::AppAction;

    let mut app = App::new(None, false, None, None).unwrap();
    app.toggle_explorer();
    assert!(app.explorer_buf_focused());

    app.dispatch_action(AppAction::QuitOrClose, 1);

    assert!(
        !app.exit_requested,
        "`<C-w>q` in the explorer must not quit the editor"
    );
    assert!(
        app.tabs[app.active_tab].left_dock.is_none(),
        "the explorer dock must be closed"
    );
}

#[test]
fn b_num_still_switches_to_real_buffer_when_explorer_open() {
    // Companion to the E86 test above: a real file slot placed *after* the
    // explorer slot must still be a valid `:b N` target.
    let path_a = std::env::temp_dir().join("hjkl_a8_bnum_explorer_a.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.toggle_explorer();
    assert!(app.slots[1].is_explorer());
    // Focus back on the real editor window before opening a second file.
    app.switch_to(0);
    // Open a second real file so there's a real slot after the explorer.
    let path_b = std::env::temp_dir().join("hjkl_a8_bnum_explorer_b.txt");
    std::fs::write(&path_b, "b\n").unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    assert_eq!(app.slots.len(), 3);
    assert!(!app.slots[2].is_explorer());
    app.dispatch_ex("b 3");
    assert_eq!(
        app.active_index(),
        2,
        "`:b 3` should switch to the real slot"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

#[test]
fn b_name_substring_switches() {
    let path_foo = std::env::temp_dir().join("hjkl_phe_bname_foo.txt");
    let path_bar = std::env::temp_dir().join("hjkl_phe_bname_bar.txt");
    std::fs::write(&path_foo, "foo\n").unwrap();
    std::fs::write(&path_bar, "bar\n").unwrap();
    let mut app = App::new(Some(path_foo.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_bar.display()));
    assert_eq!(app.active_index(), 1);
    // Switch to the foo slot by substring
    app.dispatch_ex("b foo");
    assert_eq!(
        app.active_index(),
        0,
        "`:b foo` should switch to foo's slot"
    );
    let _ = std::fs::remove_file(&path_foo);
    let _ = std::fs::remove_file(&path_bar);
}

#[test]
fn b_name_ambiguous_errors() {
    let path_a = std::env::temp_dir().join("hjkl_phe_bamb_foo_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phe_bamb_foo_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    // Both filenames contain "foo" — ambiguous
    app.dispatch_ex("b foo");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("E93"),
        "expected E93 ambiguous error, got: {msg}"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

#[test]
fn bfirst_blast_jump_to_ends() {
    let path_a = std::env::temp_dir().join("hjkl_phe_bfl_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phe_bfl_b.txt");
    let path_c = std::env::temp_dir().join("hjkl_phe_bfl_c.txt");
    for p in [&path_a, &path_b, &path_c] {
        std::fs::write(p, "x\n").unwrap();
    }
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    app.dispatch_ex(&format!("e {}", path_c.display()));
    assert_eq!(app.slots.len(), 3);
    // Start in middle
    app.dispatch_ex("b 2");
    assert_eq!(app.active_index(), 1);
    app.dispatch_ex("bfirst");
    assert_eq!(app.active_index(), 0, "`:bfirst` should go to slot 0");
    app.dispatch_ex("blast");
    assert_eq!(app.active_index(), 2, "`:blast` should go to last slot");
    for p in [&path_a, &path_b, &path_c] {
        let _ = std::fs::remove_file(p);
    }
}

#[test]
fn wa_writes_dirty_named_slots() {
    let path_a = std::env::temp_dir().join("hjkl_phe_wa_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phe_wa_b.txt");
    std::fs::write(&path_a, "original a\n").unwrap();
    std::fs::write(&path_b, "original b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    // Mark both slots dirty with new content
    app.slots[0].dirty = true;
    BufferEdit::replace_all(app.slots[0].buffer_mut(), "edited a");
    app.slots[1].dirty = true;
    BufferEdit::replace_all(app.slots[1].buffer_mut(), "edited b");
    app.dispatch_ex("wa");
    assert!(!app.slots[0].dirty, "slot 0 should be clean after :wa");
    assert!(!app.slots[1].dirty, "slot 1 should be clean after :wa");
    let contents_a = std::fs::read_to_string(&path_a).unwrap_or_default();
    let contents_b = std::fs::read_to_string(&path_b).unwrap_or_default();
    assert!(
        contents_a.contains("edited a"),
        "file a should contain edited content, got: {contents_a}"
    );
    assert!(
        contents_b.contains("edited b"),
        "file b should contain edited content, got: {contents_b}"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

#[test]
fn qa_blocks_when_any_slot_dirty() {
    let path_a = std::env::temp_dir().join("hjkl_phe_qa_dirty_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phe_qa_dirty_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    app.slots[0].dirty = true;
    app.dispatch_ex("qa");
    assert!(
        !app.exit_requested,
        ":qa should not exit when dirty slot exists"
    );
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E37"), "expected E37, got: {msg}");
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

#[test]
fn qa_force_exits_with_dirty() {
    let path_a = std::env::temp_dir().join("hjkl_phe_qa_force_a.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.slots[0].dirty = true;
    app.dispatch_ex("qa!");
    assert!(app.exit_requested, ":qa! should exit even when dirty");
    let _ = std::fs::remove_file(&path_a);
}

/// `:qa` must not block on a dirty quickfix slot — it's a programmatic
/// scratch buffer, not a user file.
#[test]
fn qa_ignores_dirty_quickfix_slot() {
    let path = std::env::temp_dir().join("hjkl_qa_qf_dirty.txt");
    std::fs::write(&path, "a\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.handle_quickfix_command(QfCommand::Open);
    // Mark the quickfix scratch slot dirty — under the old is_explorer()
    // guard this would block :qa with E37.
    for s in app.slots.iter_mut() {
        if s.is_special() {
            s.dirty = true;
        }
    }
    app.dispatch_ex("qa");
    assert!(
        app.exit_requested,
        ":qa should exit when only special buffers are dirty"
    );
    let _ = std::fs::remove_file(&path);
}

/// `:qa` must not block on a dirty cmdline-window slot.
#[test]
fn qa_ignores_dirty_cmdline_slot() {
    let path = std::env::temp_dir().join("hjkl_qa_cl_dirty.txt");
    std::fs::write(&path, "a\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.open_cmdline_window(CmdLineKind::Ex, None);
    for s in app.slots.iter_mut() {
        if s.is_special() {
            s.dirty = true;
        }
    }
    app.dispatch_ex("qa");
    assert!(
        app.exit_requested,
        ":qa should exit when only cmdline buffer is dirty"
    );
    let _ = std::fs::remove_file(&path);
}

/// `write_swap_for_slot` on a special slot must not create a swap file.
/// Proof: `swap_path` stays `None` — the function returns before assigning
/// one.
#[test]
fn write_swap_skips_special_slots() {
    let dir = tempfile::tempdir().unwrap();
    let file = dir.path().join("qf.txt");
    std::fs::write(&file, "quickfix line\n").unwrap();

    let mut app = App::new(None, false, None, None).unwrap();
    app.quickfix.set(vec![hjkl_quickfix::QfEntry {
        path: file,
        row: 0,
        col: 0,
        kind: hjkl_quickfix::QfKind::Grep,
        message: "hit".into(),
    }]);
    app.handle_quickfix_command(QfCommand::Open);
    // Find the quickfix slot's index.
    let qf_idx = app
        .slots
        .iter()
        .position(|s| s.is_special())
        .expect("quickfix slot must exist");
    // Seed content so the empty-buffer short-circuit doesn't fire first.
    hjkl_engine::BufferEdit::replace_all(&mut app.slots[qf_idx].view, "quickfix content");
    app.write_swap_for_slot(qf_idx);
    assert!(
        app.slots[qf_idx].swap_path.is_none(),
        "special slot must not get a swap path"
    );
}

#[test]
fn q_on_multi_slot_closes_slot_not_app() {
    let path_a = std::env::temp_dir().join("hjkl_phe_q_multi_a.txt");
    let path_b = std::env::temp_dir().join("hjkl_phe_q_multi_b.txt");
    std::fs::write(&path_a, "a\n").unwrap();
    std::fs::write(&path_b, "b\n").unwrap();
    let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", path_b.display()));
    assert_eq!(app.slots.len(), 2);
    app.dispatch_ex("q!");
    assert_eq!(
        app.slots.len(),
        1,
        "`:q!` with 2 slots should close active slot"
    );
    assert!(
        !app.exit_requested,
        "app should remain open after closing one slot"
    );
    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

#[test]
fn q_on_last_slot_quits_app() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.slots.len(), 1);
    assert!(!app.active().dirty);
    app.dispatch_ex("q");
    assert!(app.exit_requested, "`:q` on clean last slot should exit");
}

/// Phase 1 of the hjkl-ex extraction (kryptic-sh/hjkl#73): `:q!` on a dirty
/// buffer must force-quit even though the buffer is unsaved. This exercises
/// the new `hjkl_ex::try_dispatch` → `bridge_ex_effect` path; if either side
/// regresses (registry stops resolving `q!` or the bridge drops `force=true`)
/// the dirty buffer would block the exit and the assertion fails.
#[test]
fn q_bang_force_quits_dirty_buffer_via_hjkl_ex() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "unsaved work");
    // Mark the buffer dirty so a plain `:q` would refuse with E37.
    app.active_mut().dirty = true;
    app.dispatch_ex("q!");
    assert!(
        app.exit_requested,
        "`:q!` must force-quit a dirty buffer (hjkl-ex Phase 1 routing)"
    );
}

// ── Bare <C-c> exit vs dirty buffers (audit R2 fix 4) ────────────────────
//
// A bare Ctrl-C used to break the event loop unconditionally, and the
// graceful-exit path (`cleanup_swaps_on_exit`) then deletes every slot's
// swap file. An accidental Ctrl-C with unsaved changes silently destroyed
// the only recovery copy of that work — a crash (which skips the cleanup)
// would have preserved it. Fixed by routing bare <C-c> through the same
// `quit_all` dirty check `:qa` uses.

/// Ctrl-C with a dirty buffer must NOT exit, and must show the same E37
/// message `:qa` (without `!`) shows.
#[test]
fn ctrl_c_with_dirty_buffer_does_not_exit() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "unsaved work");
    app.active_mut().dirty = true;

    let outcome = app.handle_keypress(ctrl_key('c'));

    assert!(
        !app.exit_requested,
        "bare <C-c> must not exit while a buffer is dirty"
    );
    assert!(
        matches!(outcome, KeyOutcome::Continue),
        "handle_keypress must consume the key, not fall through"
    );
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E37"), "expected E37, got: {msg}");
}

/// Ctrl-C with every buffer clean exits normally, exactly like a plain
/// `:qa` would.
#[test]
fn ctrl_c_with_clean_buffers_exits_normally() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert!(!app.active().dirty);

    let outcome = app.handle_keypress(ctrl_key('c'));

    assert!(
        app.exit_requested,
        "bare <C-c> must exit when no buffer is dirty"
    );
    assert!(
        matches!(outcome, KeyOutcome::Break),
        "handle_keypress must return Break so `run()` stops the loop"
    );
}

/// A dirty EXPLORER scratch buffer must not block Ctrl-C — explorer buffers
/// are programmatic and never user-saved, mirroring `:qa`'s own carve-out.
#[test]
fn ctrl_c_ignores_dirty_explorer_scratch_buffer() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.active_mut().dirty = true;
    app.active_mut().kind = crate::app::BufKind::Explorer;

    let outcome = app.handle_keypress(ctrl_key('c'));

    assert!(
        app.exit_requested,
        "a dirty explorer scratch buffer must not block bare <C-c>"
    );
    assert!(matches!(outcome, KeyOutcome::Break));
}

// ── checktime / disk-change detection tests ────────────────────────────

/// Helper: bump mtime by writing a file then sleeping briefly so the
/// filesystem timestamp advances past the stored baseline.
#[test]
fn checktime_reloads_clean_buffer_when_disk_changed() {
    let path = std::env::temp_dir().join("hjkl_ct_reload.txt");
    std::fs::write(&path, "line1\nline2\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    assert_eq!(
        rope_to_lines_vec(&app.active_editor().buffer().rope()),
        vec!["line1", "line2"]
    );

    write_and_wait(&path, "new content\n");
    app.checktime_all();

    assert_eq!(
        rope_to_lines_vec(&app.active_editor().buffer().rope()),
        vec!["new content"],
        "buffer should be reloaded from disk"
    );
    assert!(!app.active().dirty, "reloaded buffer must not be dirty");
    assert_eq!(app.active().disk_state, DiskState::Synced);
    let _ = std::fs::remove_file(&path);
}

#[test]
fn checktime_marks_dirty_buffer_as_changed_on_disk_no_reload() {
    let path = std::env::temp_dir().join("hjkl_ct_dirty.txt");
    std::fs::write(&path, "original\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    // Dirty the buffer without touching disk.
    app.active_mut().dirty = true;

    write_and_wait(&path, "changed on disk\n");
    app.checktime_all();

    // Buffer must NOT have changed.
    assert_eq!(
        rope_to_lines_vec(&app.active_editor().buffer().rope()),
        vec!["original"],
        "dirty buffer must not be reloaded"
    );
    assert_eq!(
        app.active().disk_state,
        DiskState::ChangedOnDisk,
        "disk_state must be ChangedOnDisk"
    );
    let _ = std::fs::remove_file(&path);
}

#[test]
fn checktime_with_autoreload_off_does_not_reload_clean_buffer() {
    let path = std::env::temp_dir().join("hjkl_ct_noar.txt");
    std::fs::write(&path, "original\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    // Disable autoreload (default is on).
    app.dispatch_ex("set noautoreload");
    assert!(!app.active_editor().settings().autoreload);

    write_and_wait(&path, "changed on disk\n");
    app.checktime_all();

    // Clean buffer but autoreload off → must NOT reload; flagged ChangedOnDisk.
    assert_eq!(
        app.active_editor()
            .buffer()
            .rope()
            .lines()
            .map(|s| s.to_string().trim_end_matches('\n').to_string())
            .collect::<Vec<_>>(),
        vec!["original"],
        "autoreload off must leave the clean buffer untouched"
    );
    assert_eq!(app.active().disk_state, DiskState::ChangedOnDisk);
    let _ = std::fs::remove_file(&path);
}

#[test]
fn checktime_reload_preserves_cursor_column() {
    let path = std::env::temp_dir().join("hjkl_ct_curcol.txt");
    std::fs::write(&path, "abcdefgh\nsecond\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    // Park the cursor mid-line (row 0, col 4).
    app.active_editor_mut().jump_cursor(0, 4);

    // External change keeps row 0 long enough that col 4 is still valid.
    write_and_wait(&path, "abXXdefgh longer\nsecond\n");
    app.checktime_all();

    let (row, col) = app.active_editor().cursor();
    assert_eq!(
        (row, col),
        (0, 4),
        "reload must preserve cursor row + column"
    );
    let _ = std::fs::remove_file(&path);
}

#[test]
fn checktime_marks_deleted_when_file_removed() {
    let path = std::env::temp_dir().join("hjkl_ct_deleted.txt");
    std::fs::write(&path, "content\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    std::fs::remove_file(&path).unwrap();
    app.checktime_all();

    assert_eq!(app.active().disk_state, DiskState::DeletedOnDisk);
    // View content preserved.
    assert_eq!(
        rope_to_lines_vec(&app.active_editor().buffer().rope()),
        vec!["content"]
    );
}

#[test]
fn checktime_recovers_after_file_recreated() {
    let path = std::env::temp_dir().join("hjkl_ct_recover.txt");
    std::fs::write(&path, "v1\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    // Delete → marks DeletedOnDisk.
    std::fs::remove_file(&path).unwrap();
    app.checktime_all();
    assert_eq!(app.active().disk_state, DiskState::DeletedOnDisk);

    // Recreate with new content — next checktime should reload (not dirty).
    write_and_wait(&path, "v2\n");
    app.checktime_all();

    assert_eq!(
        rope_to_lines_vec(&app.active_editor().buffer().rope()),
        vec!["v2"],
        "recreated file should be reloaded"
    );
    assert_eq!(app.active().disk_state, DiskState::Synced);
    let _ = std::fs::remove_file(&path);
}

// ── Event-driven autoreload (fs-watch, #242) ───────────────────────────────
//
// These drive `apply_fs_events` directly (the reconcile half of the watcher),
// so they need no live notify thread and stay hermetic. The watcher wiring
// (filter set, drain) is exercised end-to-end by the PTY test in
// tests/pty_harness/autoreload.rs.

/// Read a slot's lines with trailing newlines stripped.
fn fsw_lines(app: &App, idx: usize) -> Vec<String> {
    app.slots[idx]
        .buffer()
        .rope()
        .lines()
        .map(|s| s.to_string().trim_end_matches('\n').to_string())
        .collect()
}

/// A `Modified` event on a clean+autoreload buffer reloads it in place and
/// reports the reload via the return value.
#[test]
fn fs_event_modified_reloads_clean_buffer() {
    let path = std::env::temp_dir().join("hjkl_fsw_modify.txt");
    std::fs::write(&path, "old\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    write_and_wait(&path, "fresh\n");
    let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(path.clone())]);

    assert!(reloaded, "a clean buffer must report a reload");
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["fresh"]);
    assert!(!app.active().dirty);
    assert_eq!(app.active().disk_state, DiskState::Synced);
    let _ = std::fs::remove_file(&path);
}

/// A `Rescan` event (the watcher dropped events under load and can't say which
/// files changed) re-checks every open slot, so a clean+autoreload buffer whose
/// file changed on disk is still reloaded even though no path was reported.
#[test]
fn fs_event_rescan_reloads_clean_buffer() {
    let path = std::env::temp_dir().join(format!("hjkl_fsw_rescan_{}.txt", std::process::id()));
    std::fs::write(&path, "old\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    write_and_wait(&path, "fresh\n");
    let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Rescan]);

    assert!(
        reloaded,
        "Rescan must re-check open slots and reload the changed file"
    );
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["fresh"]);
    assert!(!app.active().dirty);
    let _ = std::fs::remove_file(&path);
}

/// A dirty, focused buffer is never clobbered by a watch event — content
/// preserved, flagged ChangedOnDisk, and the interactive keep/reload/diff
/// prompt (#241) is raised (repaint requested).
#[test]
fn fs_event_dirty_buffer_raises_prompt_no_reload() {
    let path = std::env::temp_dir().join("hjkl_fsw_dirty.txt");
    std::fs::write(&path, "original\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.active_mut().dirty = true;

    write_and_wait(&path, "external\n");
    let needs_repaint = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(path.clone())]);

    assert!(needs_repaint, "raising the prompt must request a repaint");
    assert!(
        app.pending_disk_change.is_some(),
        "focused dirty buffer must raise the keep/reload/diff prompt"
    );
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["original"]);
    assert_eq!(app.active().disk_state, DiskState::ChangedOnDisk);
    let _ = std::fs::remove_file(&path);
}

/// `:set noautoreload` makes a watch event flag the change without reloading.
#[test]
fn fs_event_autoreload_off_no_reload() {
    let path = std::env::temp_dir().join("hjkl_fsw_noar.txt");
    std::fs::write(&path, "original\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.dispatch_ex("set noautoreload");

    write_and_wait(&path, "external\n");
    let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(path.clone())]);

    assert!(!reloaded);
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["original"]);
    assert_eq!(app.active().disk_state, DiskState::ChangedOnDisk);
    let _ = std::fs::remove_file(&path);
}

/// A `Removed` event flags the buffer DeletedOnDisk without dropping content.
#[test]
fn fs_event_removed_flags_deleted() {
    let path = std::env::temp_dir().join("hjkl_fsw_removed.txt");
    std::fs::write(&path, "content\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    std::fs::remove_file(&path).unwrap();
    let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Removed(path)]);

    assert!(!reloaded);
    assert_eq!(app.active().disk_state, DiskState::DeletedOnDisk);
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["content"]);
}

/// An event for a path no buffer has open is a no-op.
#[test]
fn fs_event_unrelated_path_is_noop() {
    let path = std::env::temp_dir().join("hjkl_fsw_open.txt");
    std::fs::write(&path, "open\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    let other = std::env::temp_dir().join("hjkl_fsw_unrelated.txt");
    std::fs::write(&other, "noise\n").unwrap();
    let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(other.clone())]);

    assert!(!reloaded, "event for an unopened path must do nothing");
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["open"]);
    assert_eq!(app.active().disk_state, DiskState::Synced);
    let _ = std::fs::remove_file(&path);
    let _ = std::fs::remove_file(&other);
}

/// A `Renamed { to }` whose destination is an open buffer reloads that buffer
/// (the `to` end is matched, not just `from`).
#[test]
fn fs_event_rename_to_open_path_reloads() {
    let path = std::env::temp_dir().join("hjkl_fsw_rename_to.txt");
    std::fs::write(&path, "old\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    write_and_wait(&path, "renamed-in\n");
    let from = std::env::temp_dir().join("hjkl_fsw_rename_from.txt");
    let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Renamed {
        from,
        to: path.clone(),
    }]);

    assert!(reloaded);
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["renamed-in"]);
    let _ = std::fs::remove_file(&path);
}

/// Two `Modified` events for the same file in one batch reload exactly once
/// (canonicalized + deduped), emitting a single reload notice.
#[test]
fn fs_event_duplicate_events_reload_once() {
    let path = std::env::temp_dir().join("hjkl_fsw_dedup.txt");
    std::fs::write(&path, "old\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    write_and_wait(&path, "deduped\n");
    let reloaded = app.apply_fs_events(vec![
        hjkl_fs_watch::FsEvent::Modified(path.clone()),
        hjkl_fs_watch::FsEvent::Modified(path.clone()),
    ]);

    assert!(reloaded);
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["deduped"]);
    let reload_notices = app
        .bus
        .history()
        .filter(|h| h.display_body().contains("reloaded from disk"))
        .count();
    assert_eq!(reload_notices, 1, "duplicate events must reload only once");
    let _ = std::fs::remove_file(&path);
}

/// Reloading from a watch event preserves the cursor row + column.
#[test]
fn fs_event_reload_preserves_cursor_column() {
    let path = std::env::temp_dir().join("hjkl_fsw_cursor.txt");
    std::fs::write(&path, "abcdefgh\nsecond\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.active_editor_mut().jump_cursor(0, 4);

    write_and_wait(&path, "abXXdefgh longer\nsecond\n");
    app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(path.clone())]);

    assert_eq!(app.active_editor().cursor(), (0, 4));
    let _ = std::fs::remove_file(&path);
}

/// With two files open, an event for one reloads only that slot.
#[test]
fn fs_event_only_matching_slot_reloads() {
    let a = std::env::temp_dir().join("hjkl_fsw_multi_a.txt");
    let b = std::env::temp_dir().join("hjkl_fsw_multi_b.txt");
    std::fs::write(&a, "a-old\n").unwrap();
    std::fs::write(&b, "b-old\n").unwrap();
    let mut app = App::new(Some(a.clone()), false, None, None).unwrap();
    let b_idx = app.open_new_slot(b.clone()).unwrap();
    let a_idx = app.focused_slot_idx();

    write_and_wait(&a, "a-new\n");
    write_and_wait(&b, "b-new\n");
    // Only `a` changed as far as the watcher reports.
    let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(a.clone())]);

    assert!(reloaded);
    assert_eq!(fsw_lines(&app, a_idx), vec!["a-new"], "a reloads");
    assert_eq!(
        fsw_lines(&app, b_idx),
        vec!["b-old"],
        "b must be untouched (no event for it)"
    );
    let _ = std::fs::remove_file(&a);
    let _ = std::fs::remove_file(&b);
}

/// An empty event batch is a no-op.
#[test]
fn fs_event_empty_batch_is_noop() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert!(!app.apply_fs_events(Vec::new()));
}

/// The watched-set sync helper is a safe no-op when fs-watch isn't enabled
/// (the default in tests / `App::new`).
#[test]
fn fs_watch_helpers_noop_without_watcher() {
    let mut app = App::new(None, false, None, None).unwrap();
    // Must not panic; fs_watch is None.
    app.fs_watch_sync();
}

/// A `Removed` event flags DeletedOnDisk; a later `Created` event for a
/// recreated file reloads the new content (the delete→reappear branch reached
/// over the event channel, not just via `:checktime`).
#[test]
fn fs_event_recreate_after_delete_reloads() {
    let path = std::env::temp_dir().join("hjkl_fsw_recreate.txt");
    std::fs::write(&path, "v1\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    std::fs::remove_file(&path).unwrap();
    let r1 = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Removed(path.clone())]);
    assert!(!r1);
    assert_eq!(app.active().disk_state, DiskState::DeletedOnDisk);

    write_and_wait(&path, "v2-recreated\n");
    let r2 = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Created(path.clone())]);

    assert!(r2, "recreated file must reload");
    assert_eq!(
        fsw_lines(&app, app.focused_slot_idx()),
        vec!["v2-recreated"]
    );
    assert_eq!(app.active().disk_state, DiskState::Synced);
    let _ = std::fs::remove_file(&path);
}

/// A `Renamed` whose neither end is an open buffer is a no-op.
#[test]
fn fs_event_rename_unrelated_is_noop() {
    let path = std::env::temp_dir().join("hjkl_fsw_rename_noop_open.txt");
    std::fs::write(&path, "open\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    let from = std::env::temp_dir().join("hjkl_fsw_rn_from.txt");
    let to = std::env::temp_dir().join("hjkl_fsw_rn_to.txt");
    let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Renamed { from, to }]);

    assert!(!reloaded);
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["open"]);
    assert_eq!(app.active().disk_state, DiskState::Synced);
    let _ = std::fs::remove_file(&path);
}

/// A mixed batch (events for an open file + unrelated files) reloads only the
/// open file and ignores the rest.
#[test]
fn fs_event_mixed_batch_reloads_only_open() {
    let open = std::env::temp_dir().join("hjkl_fsw_mixed_open.txt");
    std::fs::write(&open, "old\n").unwrap();
    let mut app = App::new(Some(open.clone()), false, None, None).unwrap();

    write_and_wait(&open, "mixed-new\n");
    let noise_a = std::env::temp_dir().join("hjkl_fsw_mixed_noise_a.txt");
    let noise_b = std::env::temp_dir().join("hjkl_fsw_mixed_noise_b.txt");
    let reloaded = app.apply_fs_events(vec![
        hjkl_fs_watch::FsEvent::Created(noise_a),
        hjkl_fs_watch::FsEvent::Modified(open.clone()),
        hjkl_fs_watch::FsEvent::Removed(noise_b),
    ]);

    assert!(reloaded);
    assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["mixed-new"]);
    let _ = std::fs::remove_file(&open);
}

// ── Substitute ex-command tests ──────────────────────────────────────────────

/// `:%s/foo/bar/g` over a multi-line buffer replaces all occurrences.
#[test]
fn substitute_percent_global_multi_line() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "foo foo\nfoo");
    app.dispatch_ex("%s/foo/bar/g");
    let lines = rope_to_lines_vec(&app.active_editor().buffer().rope());
    assert_eq!(
        lines,
        vec!["bar bar", "bar"],
        "buffer should be fully substituted"
    );
    let msg = app.bus.last_body_or_empty().to_string();
    assert_eq!(msg, "3 substitutions on 2 lines", "status: {msg}");
}

/// `:s/foo/bar/` on the current line replaces only the first occurrence.
#[test]
fn substitute_current_line_first_only() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "foo foo\nfoo");
    app.dispatch_ex("s/foo/bar/");
    let lines = rope_to_lines_vec(&app.active_editor().buffer().rope());
    assert_eq!(lines[0], "bar foo", "only first occurrence on current line");
    assert_eq!(lines[1], "foo", "second line unchanged");
    let msg = app.bus.last_body_or_empty().to_string();
    assert_eq!(msg, "1 substitutions on 1 lines", "status: {msg}");
}

/// `:s//xxx/` after a `/foo` search reuses the last pattern.
#[test]
fn substitute_empty_pattern_reuses_last_search() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "hello world");
    // Simulate a prior search by setting last_search directly.
    app.active_editor_mut()
        .set_last_search(Some("world".to_string()), true);
    app.dispatch_ex("s//planet/");
    let lines = rope_to_lines_vec(&app.active_editor().buffer().rope());
    assert_eq!(
        lines[0], "hello planet",
        "should replace using last search pattern"
    );
    let msg = app.bus.last_body_or_empty().to_string();
    assert_eq!(msg, "1 substitutions on 1 lines", "status: {msg}");
}

/// `:s/foo/bar/` with no match leaves the buffer unchanged and shows "Pattern not found".
#[test]
fn substitute_no_match_shows_pattern_not_found() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "hello world");
    app.dispatch_ex("s/xyz/bar/");
    let lines = rope_to_lines_vec(&app.active_editor().buffer().rope());
    assert_eq!(lines[0], "hello world", "buffer should be unchanged");
    let msg = app.bus.last_body_or_empty().to_string();
    assert_eq!(msg, "Pattern not found", "status: {msg}");
}

// ── :Anvil ex command tests ───────────────────────────────────────────────────
#[test]
fn anvil_install_unknown_tool_sets_error_message() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("Anvil install definitely-not-a-real-tool-xyz");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("unknown tool"),
        "expected 'unknown tool' in status message, got: {msg:?}"
    );
}

#[test]
fn anvil_uninstall_not_installed_graceful() {
    // Uninstalling a tool that has no package dir must not panic.
    // It should set a success or no-op status message.
    let mut app = App::new(None, false, None, None).unwrap();
    // rust-analyzer is in the registry but not installed in CI.
    app.dispatch_ex("Anvil uninstall rust-analyzer");
    // Either "removed" or "failed to resolve" — should not panic.
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        !msg.is_empty(),
        "expected some status message after anvil uninstall"
    );
}

#[test]
fn anvil_update_all_with_zero_installed_tools() {
    // :Anvil update with no installed tools should reach the sweep-started toast.
    // In CI the XDG store is empty so read_rev returns None for all tools,
    // which means anvil_update_all skips all names and sets the sweep message.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("Anvil update");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("update sweep started"),
        "expected 'update sweep started', got: {msg:?}"
    );
}

#[test]
fn anvil_picker_source_builds_from_registry() {
    use crate::picker_sources::{AnvilPickerSource, AnvilState};

    let registry = hjkl_anvil::Registry::embedded().expect("embedded registry must load");
    let source = AnvilPickerSource::from_registry(&registry);

    // The embedded catalog has at least one tool (rust-analyzer).
    assert!(!source.items.is_empty(), "picker source must have items");

    // In CI nothing is installed, so every item should be Available.
    for item in &source.items {
        // State should be Available (no .rev files in CI).
        // We can't assert Available specifically in all environments, but
        // we can assert the item fields are consistent.
        let label = item.label();
        assert!(
            label.contains(&item.name),
            "label must contain tool name; got: {label:?}"
        );
        assert!(
            matches!(
                item.state,
                AnvilState::Available | AnvilState::Installed { .. } | AnvilState::Outdated { .. }
            ),
            "state must be one of the three variants"
        );
    }
}

#[test]
fn anvil_bad_subcommand_shows_usage() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("Anvil badsubcommand something else");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("usage"),
        "expected usage hint in status message, got: {msg:?}"
    );
}

#[test]
fn unbound_chord_tail_trie_returns_multi_key_replay() {
    // <leader>x: leader is bound (as a prefix), but <leader>x is not.
    // The trie returns Unbound([<leader>, x]) with replay.len() > 1.
    // The event_loop now always forwards multi-key Unbound replays to the
    // engine (so gg/gj/etc work). This test verifies the trie shape only.
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "abcdef");

    let leader = app.config.editor.leader;
    let mut replay: Vec<hjkl_keymap::KeyEvent> = Vec::new();

    // First key: leader. Should be Pending → consumed = true.
    let consumed1 = app.dispatch_keymap(
        hjkl_keymap::KeyEvent::new(
            hjkl_keymap::KeyCode::Char(leader),
            hjkl_keymap::KeyModifiers::NONE,
        ),
        1,
        &mut replay,
    );
    assert!(consumed1, "leader should be consumed as Pending prefix");

    // Second key: 'x' — unmapped. The dispatch returns consumed=false
    // and replay=[leader, x] (both keys buffered by the trie).
    replay.clear();
    let consumed2 = app.dispatch_keymap(
        hjkl_keymap::KeyEvent::new(
            hjkl_keymap::KeyCode::Char('x'),
            hjkl_keymap::KeyModifiers::NONE,
        ),
        1,
        &mut replay,
    );
    assert!(!consumed2, "<leader>x is unbound → consumed=false");
    assert!(
        replay.len() > 1,
        "replay should contain both keys, got {} keys",
        replay.len()
    );
    // Note: event_loop now forwards multi-key replays to the engine.
    // <leader>x with leader=space → space (move-right) + x (delete-char).
    // This is vim-compatible; users can `:nmap <leader> <Nop>` to stop.
}

// ── Phase 2b hjkl-ex integration: :e <path> ────────────────────────────────

/// `:e <path>` dispatched via hjkl_ex::try_dispatch must open the file and
/// make its content visible in the active buffer.  This exercises the
/// EditFile early-intercept in dispatch_ex introduced in Phase 2b.
#[test]
fn colon_e_path_opens_file_via_hjkl_ex() {
    // Write a temp file with known content.
    let path = tmp_path("hjkl_ex_2b_edit_test.txt");
    std::fs::write(&path, "hello from hjkl-ex\n").unwrap();

    let mut app = App::new(None, false, None, None).unwrap();
    // dispatch_ex expects a command string without the leading `:`.
    app.dispatch_ex(&format!("e {}", path.display()));

    let lines = rope_to_lines_vec(&app.active_editor().buffer().rope());
    assert_eq!(
        lines,
        vec!["hello from hjkl-ex"],
        "`:e <path>` must load the file content into the active buffer; got {lines:?}"
    );
    let active_path = app
        .active()
        .filename
        .as_deref()
        .unwrap_or_else(|| std::path::Path::new(""));
    assert_eq!(
        active_path,
        path.as_path(),
        "`:e <path>` must set the active slot filename to the opened path"
    );

    let _ = std::fs::remove_file(&path);
}

/// `:bd` dispatched via hjkl_ex::try_dispatch on the only slot must reset
/// the buffer to an empty unnamed scratch (vim parity).
#[test]
fn colon_bd_via_hjkl_ex_clears_sole_buffer() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "some content");
    // Mark clean so buffer_delete doesn't refuse.
    app.active_mut().dirty = false;
    app.dispatch_ex("bd");
    let lines = rope_to_lines_vec(&app.active_editor().buffer().rope());
    // After :bd on the last slot the buffer is reset to empty unnamed scratch.
    assert_eq!(
        lines,
        vec![""],
        "`:bd` on sole buffer must leave an empty scratch; got {lines:?}"
    );
    assert!(
        app.active().filename.is_none(),
        "`:bd` on sole buffer must clear the filename"
    );
}

// ── Phase 7: filename expansion (`%`, `#`) integration tests ────────────────

/// `dispatch_ex("e %")` with a current filename set must re-open the same
/// file (no error) — the `%` expands to the current buffer path at dispatch
/// time.
#[test]
fn colon_e_percent_expands_to_current_file() {
    let path = tmp_path("hjkl_phase7_percent_test.txt");
    std::fs::write(&path, "phase7 percent\n").unwrap();

    let mut app = App::new(None, false, None, None).unwrap();
    // Open the file by path first so active().filename is set.
    app.dispatch_ex(&format!("e {}", path.display()));
    let active_after_first_open = app
        .active()
        .filename
        .as_deref()
        .unwrap_or_else(|| std::path::Path::new(""))
        .to_path_buf();

    // Now dispatch `e %` — should expand to the same path and re-open.
    app.dispatch_ex("e %");

    let active_after_percent = app
        .active()
        .filename
        .as_deref()
        .unwrap_or_else(|| std::path::Path::new(""))
        .to_path_buf();

    assert_eq!(
        active_after_percent, active_after_first_open,
        "`:e %%` must expand to the current file path; got {active_after_percent:?}"
    );
    // No error message — expansion and re-open succeeded.
    assert!(
        app.bus.last_body_or_empty().is_empty() || !app.bus.last_body_or_empty().starts_with('E'),
        "`:e %%` must not produce an error; got: {:?}",
        app.bus.last_body_or_empty()
    );

    let _ = std::fs::remove_file(&path);
}

/// `dispatch_ex("e #")` after opening two files must switch back to the
/// first file (alternate buffer expansion).
#[test]
fn colon_e_hash_expands_to_alt() {
    let path_a = tmp_path("hjkl_phase7_hash_a.txt");
    let path_b = tmp_path("hjkl_phase7_hash_b.txt");
    std::fs::write(&path_a, "file a\n").unwrap();
    std::fs::write(&path_b, "file b\n").unwrap();

    let mut app = App::new(None, false, None, None).unwrap();
    // Open file A.
    app.dispatch_ex(&format!("e {}", path_a.display()));
    // Open file B — now A becomes the alternate buffer (prev_active).
    app.dispatch_ex(&format!("e {}", path_b.display()));

    let active_before = app
        .active()
        .filename
        .as_deref()
        .map(|p| p.to_path_buf())
        .unwrap();
    assert!(
        active_before.ends_with("hjkl_phase7_hash_b.txt"),
        "sanity: active must be B; got {active_before:?}"
    );

    // `e #` must expand to file A and open it.
    app.dispatch_ex("e #");

    let active_after = app
        .active()
        .filename
        .as_deref()
        .map(|p| p.to_path_buf())
        .unwrap();
    assert!(
        active_after.ends_with("hjkl_phase7_hash_a.txt"),
        "`:e #` must expand to alt (file A); got {active_after:?}"
    );

    let _ = std::fs::remove_file(&path_a);
    let _ = std::fs::remove_file(&path_b);
}

// ── Phase 4b: host-registry window/tab command tests ────────────────────────

#[test]
fn colon_split_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "hello");
    let before = app.layout().leaves().len();
    app.dispatch_ex("split");
    assert_eq!(
        app.layout().leaves().len(),
        before + 1,
        ":split must add one leaf"
    );
}

#[test]
fn colon_sp_alias_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "hello");
    let before = app.layout().leaves().len();
    app.dispatch_ex("sp");
    assert_eq!(
        app.layout().leaves().len(),
        before + 1,
        ":sp alias must add one leaf"
    );
}

#[test]
fn colon_vsplit_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "hello");
    let before = app.layout().leaves().len();
    app.dispatch_ex("vsplit");
    assert_eq!(
        app.layout().leaves().len(),
        before + 1,
        ":vsplit must add one leaf"
    );
}

#[test]
fn colon_close_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "hello");
    app.dispatch_ex("split");
    assert_eq!(app.layout().leaves().len(), 2, "setup: need 2 leaves");
    app.dispatch_ex("close");
    assert_eq!(
        app.layout().leaves().len(),
        1,
        ":close must collapse back to 1 leaf"
    );
}

#[test]
fn colon_tabnew_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.tabs.len();
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), before + 1, ":tabnew must add a tab");
}

#[test]
fn colon_tabprev_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    // active_tab = 2; go back one.
    let before = app.active_tab;
    app.dispatch_ex("tabprev");
    assert_eq!(
        app.active_tab,
        before - 1,
        ":tabprev must decrement active_tab"
    );
}

#[test]
fn colon_tabclose_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 2, "setup: need 2 tabs");
    app.dispatch_ex("tabclose");
    assert_eq!(app.tabs.len(), 1, ":tabclose must remove a tab");
}

#[test]
fn colon_only_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    seed_buffer(&mut app, "data");
    app.dispatch_ex("split");
    app.dispatch_ex("split");
    assert!(
        app.layout().leaves().len() >= 2,
        "setup: need at least 2 leaves"
    );
    app.dispatch_ex("only");
    assert_eq!(
        app.layout().leaves().len(),
        1,
        ":only must leave exactly 1 leaf"
    );
}

// ── Phase 4c: buffer-nav host-registry tests ─────────────────────────────────

#[test]
fn colon_bnext_via_host_registry() {
    let mut app = setup_three_slot_app();
    assert_eq!(app.active_index(), 2);
    app.dispatch_ex("bnext");
    assert_eq!(app.active_index(), 0, ":bnext must wrap to first slot");
}

#[test]
fn colon_bn_alias_via_host_registry() {
    let mut app = setup_three_slot_app();
    assert_eq!(app.active_index(), 2);
    app.dispatch_ex("bn");
    assert_eq!(app.active_index(), 0, ":bn alias must wrap to first slot");
}

#[test]
fn colon_bprevious_via_host_registry() {
    let mut app = setup_three_slot_app();
    // Start at slot 2, go back to slot 1.
    app.dispatch_ex("bprevious");
    assert_eq!(app.active_index(), 1, ":bprevious must retreat one slot");
}

#[test]
fn colon_bp_alias_via_host_registry() {
    let mut app = setup_three_slot_app();
    app.dispatch_ex("bp");
    assert_eq!(app.active_index(), 1, ":bp alias must retreat one slot");
}

#[test]
fn colon_bfirst_via_host_registry() {
    let mut app = setup_three_slot_app();
    assert_eq!(app.active_index(), 2);
    app.dispatch_ex("bfirst");
    assert_eq!(app.active_index(), 0, ":bfirst must jump to slot 0");
}

#[test]
fn colon_blast_via_host_registry() {
    let mut app = setup_three_slot_app();
    // Switch to first so blast has work to do.
    app.dispatch_ex("bfirst");
    assert_eq!(app.active_index(), 0);
    app.dispatch_ex("blast");
    assert_eq!(
        app.active_index(),
        app.slots.len() - 1,
        ":blast must jump to the last slot"
    );
}

#[test]
fn colon_ls_via_host_registry() {
    let mut app = setup_three_slot_app();
    app.dispatch_ex("ls");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(!msg.is_empty(), ":ls must produce a status message");
}

#[test]
fn colon_buffers_via_host_registry() {
    let mut app = setup_three_slot_app();
    app.dispatch_ex("buffers");
    let msg = app.info_popup.as_ref().map_or_else(
        || app.bus.last_body_or_empty().to_string(),
        |p| p.content.clone(),
    );
    assert!(!msg.is_empty(), ":buffers must produce output");
}

#[test]
fn colon_clipboard_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("clipboard");
    let msg = app.info_popup.as_ref().map_or_else(
        || app.bus.last_body_or_empty().to_string(),
        |p| p.content.clone(),
    );
    assert!(!msg.is_empty(), ":clipboard must produce output");
}

// ── Phase 4d2: misc host-registry tests ──────────────────────────────────────

#[test]
fn colon_picker_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert!(app.picker.is_none(), "picker must start None");
    app.dispatch_ex("picker");
    assert!(app.picker.is_some(), ":picker must open the picker");
}

#[test]
fn colon_rg_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("rg");
    assert!(app.picker.is_some(), ":rg must open the grep picker");
}

#[test]
fn colon_rg_with_pattern_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("rg fn main");
    assert!(
        app.picker.is_some(),
        ":rg <pattern> must open the grep picker"
    );
}

#[test]
fn leader_slash_opens_grep_picker() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert!(app.picker.is_none(), "picker must start None");
    app.route_chord_key(key(KeyCode::Char(' ')));
    app.route_chord_key(key(KeyCode::Char('/')));
    assert!(
        app.picker.is_some(),
        "<leader>/ must open the grep picker; status={:?}",
        app.bus.last_body_or_empty()
    );
}

/// End-to-end leader-slash → type a query → confirm the rg-backed picker
/// enumerates items. Skips when rg / grep / findstr are all absent.
#[test]
fn leader_slash_grep_picker_populates_items() {
    if std::process::Command::new("rg")
        .arg("--version")
        .output()
        .is_err()
        && std::process::Command::new("grep")
            .arg("--version")
            .output()
            .is_err()
    {
        eprintln!("skipping: no rg or grep on PATH");
        return;
    }

    // Seed a tmp dir with a known match.
    let dir = std::env::temp_dir().join(format!(
        "hjkl_grep_picker_{}",
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    let file = dir.join("findme.txt");
    std::fs::write(&file, "alpha\nUNIQUE_NEEDLE_42\nomega\n").unwrap();

    // App's cwd drives the grep root.
    let cwd = crate::test_cwd::CwdGuard::enter(&dir);

    let mut app = App::new(None, false, None, None).unwrap();
    app.route_chord_key(key(KeyCode::Char(' ')));
    app.route_chord_key(key(KeyCode::Char('/')));
    assert!(app.picker.is_some(), "<leader>/ must open the picker");

    // Type query, then poll until rg returns the seeded match.
    for c in "UNIQUE_NEEDLE_42".chars() {
        app.handle_picker_key(key(KeyCode::Char(c)));
    }
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
    let mut got_match = false;
    while std::time::Instant::now() < deadline {
        // Drive both refresh (schedules requery) and tick (fires the
        // debounced rg spawn). In production, render.rs does this every frame.
        if let Some(p) = app.picker.as_mut() {
            let _ = p.refresh();
            p.tick(std::time::Instant::now());
            let _ = p.refresh();
        }
        let count = app.picker.as_ref().map_or(0, |p| p.matched());
        if count > 0 {
            got_match = true;
            break;
        }
        std::thread::sleep(std::time::Duration::from_millis(50));
    }

    // Restore cwd before removing the temp dir — Windows cannot remove a
    // directory that is any process's current working directory.
    drop(cwd);
    let _ = std::fs::remove_dir_all(&dir);

    assert!(
        got_match,
        "rg-backed grep picker must return at least one match for the seeded UNIQUE_NEEDLE_42; \
         status={:?}",
        app.bus.last_body_or_empty()
    );
}

#[test]
fn colon_b_numeric_via_host_registry() {
    let mut app = setup_three_slot_app();
    // slots are 0-indexed internally; :b 2 means slot index 1
    app.dispatch_ex("b 2");
    assert_eq!(app.active_index(), 1, ":b 2 must switch to slot index 1");
}

#[test]
fn colon_b_nonexistent_via_host_registry() {
    let mut app = setup_three_slot_app();
    app.dispatch_ex("b nonexistent_buffer_xyz");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("E94") || msg.contains("No matching"),
        ":b nonexistent must set error status"
    );
}

#[test]
fn colon_bpicker_via_host_registry() {
    let mut app = setup_three_slot_app();
    assert!(app.picker.is_none());
    app.dispatch_ex("bpicker");
    assert!(app.picker.is_some(), ":bpicker must open the buffer picker");
}

#[test]
fn colon_checktime_via_host_registry() {
    // checktime_all should not panic on a fresh app
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("checktime");
    // no assertion beyond no-panic; bus may or may not have entries
}

#[test]
fn colon_vnew_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.slots.len();
    app.dispatch_ex("vnew");
    assert!(app.slots.len() > before, ":vnew must add a new buffer slot");
}

#[test]
fn colon_new_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.slots.len();
    app.dispatch_ex("new");
    assert!(app.slots.len() > before, ":new must add a new buffer slot");
}

#[test]
fn colon_tabfirst_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    // add a second tab so tabfirst has work to do
    app.dispatch_ex("tabnew");
    assert!(app.active_tab > 0 || app.tabs.len() > 1);
    app.dispatch_ex("tabfirst");
    assert_eq!(app.active_tab, 0, ":tabfirst must jump to tab 0");
}

#[test]
fn colon_tablast_via_host_registry() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabfirst");
    assert_eq!(app.active_tab, 0);
    app.dispatch_ex("tablast");
    let last = app.tabs.len() - 1;
    assert_eq!(app.active_tab, last, ":tablast must jump to the last tab");
}

// ── Phase 4f: host-registry tests ────────────────────────────────────────────

#[test]
fn colon_tabonly_via_host_registry() {
    // Two-tab setup: tabonly must close all but the current tab.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 3);
    // Navigate to middle tab so we aren't on the last one.
    app.dispatch_ex("tabfirst");
    app.dispatch_ex("tabnext");
    assert_eq!(app.active_tab, 1);
    app.dispatch_ex("tabonly");
    assert_eq!(app.tabs.len(), 1, ":tabonly must leave exactly one tab");
    assert_eq!(app.active_tab, 0, ":tabonly must reset active_tab to 0");
}

#[test]
fn colon_tabs_via_host_registry() {
    // Multi-tab setup: :tabs must populate info_popup.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 2);
    app.info_popup = None;
    app.dispatch_ex("tabs");
    assert!(
        app.info_popup.is_some(),
        ":tabs must set info_popup with tab listing"
    );
    let popup = app.info_popup.as_ref().unwrap();
    assert!(
        popup.content.contains("Tab page 1"),
        "popup must list Tab page 1"
    );
    assert!(
        popup.content.contains("Tab page 2"),
        "popup must list Tab page 2"
    );
}

#[test]
fn colon_lnext_via_host_registry() {
    // No live LSP server — lnext_severity(None) must not panic on empty diag list.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("lnext");
    // no assertion beyond no-panic
}

#[test]
fn colon_lopen_via_host_registry() {
    // open_diag_picker with no diagnostics: routes through host registry and
    // pushes "no diagnostics" toast (empty-state path, no server needed).
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("lopen");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("no diagnostics"),
        ":lopen with empty diag list must set status 'no diagnostics', got: {msg}"
    );
}

#[test]
fn colon_resize_via_host_registry() {
    // Horizontal split: dispatch `resize +5` must grow the focused window.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    let rect = ratatui::layout::Rect {
        x: 0,
        y: 0,
        width: 80,
        height: 40,
    };
    let fw = app.focused_window();
    inject_split_rect(app.layout_mut(), fw, rect);

    let ratio_before = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };

    app.dispatch_ex("resize +5");

    let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };

    assert!(
        ratio_after > ratio_before,
        ":resize +5 must grow focused window ratio: before={ratio_before} after={ratio_after}"
    );
}

#[test]
fn colon_vertical_resize_via_host_registry() {
    // Vertical split: dispatch `vertical resize +5` must grow focused window width.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("vsp");
    let rect = ratatui::layout::Rect {
        x: 0,
        y: 0,
        width: 80,
        height: 24,
    };
    let fw = app.focused_window();
    inject_split_rect(app.layout_mut(), fw, rect);

    let ratio_before = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };

    app.dispatch_ex("vertical resize +5");

    let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };

    assert!(
        ratio_after > ratio_before,
        ":vertical resize +5 must grow focused window width ratio: before={ratio_before} after={ratio_after}"
    );
}

// ── Swap file / crash-recovery tests (issue #185) ─────────────────────────

/// `:w` must delete the swap file after a successful save.
/// Uses an injected swap_path to avoid mutating XDG env vars in parallel tests.
#[test]
fn colon_write_removes_swap_file() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("test_write_swap.txt");
    std::fs::write(&file_path, "hello\n").unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;

    // Inject a swap path directly into the slot.
    let swap_path = td.path().join("test_write_swap.swp");
    app.active_mut().swap_path = Some(swap_path.clone());

    // Seed some dirty content so dirty_gen advances.
    seed_buffer(&mut app, "changed content");
    app.active_mut().dirty = true;

    // Force-write swap.
    let idx = app.focused_slot_idx();
    app.write_swap_for_slot(idx);
    assert!(
        swap_path.exists(),
        "swap file must exist after write_swap_for_slot"
    );

    // Now save the file — should delete the swap.
    app.dispatch_ex("write");
    assert!(!swap_path.exists(), "swap file must be deleted after :w");
}

/// `:preserve` must write the swap immediately.
#[test]
fn colon_preserve_writes_swap() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("test_preserve_swap.txt");
    std::fs::write(&file_path, "initial\n").unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;

    // Inject a swap path directly into the slot.
    let swap_path = td.path().join("test_preserve_swap.swp");
    app.active_mut().swap_path = Some(swap_path.clone());

    seed_buffer(&mut app, "modified content");
    app.active_mut().dirty = true;

    // `:preserve` should write the swap.
    app.dispatch_ex("preserve");

    // Verify that the swap file was actually written.
    assert!(swap_path.exists(), "swap file must exist after :preserve");
}

/// Opening a file whose swap is newer than the on-disk version enters
/// recovery state (`pending_recovery.is_some()`).
///
/// This test writes the swap directly to a temp directory and injects the
/// swap_path onto the slot to avoid racing with other tests that mutate
/// XDG_CACHE_HOME.
#[test]
fn open_file_with_newer_swap_enters_recovery_state() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("test_recovery.txt");
    std::fs::write(&file_path, "on disk content\n").unwrap();

    let canonical = std::fs::canonicalize(&file_path).unwrap();

    let file_mtime_ms = std::fs::metadata(&file_path)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;

    // Write the swap file directly into the temp dir (bypasses XDG).
    let swap_path = td.path().join("test_recovery.swp");
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canonical.to_string_lossy().into_owned(),
        file_mtime_unix_ms: file_mtime_ms,
        // Write time 10s after mtime → swap is "newer".
        write_time_unix_ms: file_mtime_ms + 10_000,
        cursor: (0, 0),
        writer_pid: std::process::id(),
    };
    let rope = ropey::Rope::from_str("swap body content");
    hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();

    // Create app without a pre-existing swap so App::new doesn't interfere.
    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    // Clear any pending_recovery from App::new, then inject our swap path
    // directly and re-check.
    app.pending_recovery = None;
    app.active_mut().swap_path = Some(swap_path);
    let idx = app.focused_slot_idx();
    let recovery_needed = app.check_recovery_on_open(idx);
    assert!(
        recovery_needed,
        "check_recovery_on_open must return true when swap is newer"
    );
    assert!(
        app.pending_recovery.is_some(),
        "pending_recovery must be set when swap is newer than disk"
    );
}

/// Choosing 'y' in the recovery prompt loads the swap body.
#[test]
fn recovery_y_loads_swap_body() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("test_recovery_y.txt");
    std::fs::write(&file_path, "on disk\n").unwrap();

    let canonical = std::fs::canonicalize(&file_path).unwrap();

    let file_mtime_ms = std::fs::metadata(&file_path)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;

    let swap_path = td.path().join("test_recovery_y.swp");
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canonical.to_string_lossy().into_owned(),
        file_mtime_unix_ms: file_mtime_ms,
        write_time_unix_ms: file_mtime_ms + 10_000,
        cursor: (0, 0),
        writer_pid: std::process::id(),
    };
    let rope = ropey::Rope::from_str("recovered content");
    hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;
    app.active_mut().swap_path = Some(swap_path);
    let idx = app.focused_slot_idx();
    app.check_recovery_on_open(idx);
    assert!(app.pending_recovery.is_some(), "must be in recovery state");

    // Simulate pressing 'y'.
    app.handle_recovery_key(key(crossterm::event::KeyCode::Char('y')));
    assert!(
        app.pending_recovery.is_none(),
        "pending_recovery must be cleared after 'y'"
    );
    let content = app.active_editor().buffer().content_joined();
    assert!(
        content.contains("recovered content"),
        "buffer must contain swap body after 'y', got: {content:?}"
    );
}

/// Choosing 'q' in the recovery prompt on the SOLE open slot (the common
/// `hjkl foo.txt` launch after a crash) must abort with a visible message and
/// reset the buffer to an empty scratch, not silently dismiss the prompt and
/// leave the on-disk content displayed as if nothing happened (audit A6).
///
/// Unlike the multi-slot case, the only slot can't be removed outright, so
/// this exercises the fallback path added alongside [`App::reset_slot_to_scratch`].
#[test]
fn recovery_q_on_sole_slot_aborts_with_message_and_resets_to_scratch() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("test_recovery_q.txt");
    std::fs::write(&file_path, "on disk content\n").unwrap();

    let canonical = std::fs::canonicalize(&file_path).unwrap();
    let file_mtime_ms = std::fs::metadata(&file_path)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;

    let swap_path = td.path().join("test_recovery_q.swp");
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canonical.to_string_lossy().into_owned(),
        file_mtime_unix_ms: file_mtime_ms,
        write_time_unix_ms: file_mtime_ms + 10_000,
        cursor: (0, 0),
        writer_pid: 999_999_999, // almost certainly dead — not a live-lock
    };
    let rope = ropey::Rope::from_str("swap body that must NOT survive abort");
    hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;
    app.active_mut().swap_path = Some(swap_path);
    let idx = app.focused_slot_idx();
    app.check_recovery_on_open(idx);
    assert!(app.pending_recovery.is_some(), "must be in recovery state");
    assert_eq!(app.slots.len(), 1, "this test targets the sole-slot launch");

    // Simulate pressing 'q'.
    app.handle_recovery_key(key(crossterm::event::KeyCode::Char('q')));

    assert!(
        app.pending_recovery.is_none(),
        "pending_recovery must be cleared after 'q'"
    );
    assert_eq!(
        app.slots.len(),
        1,
        "the sole slot must not be removed (there'd be nothing left to show)"
    );
    let msgs: Vec<&str> = app.bus.history().map(|h| h.body.as_str()).collect();
    assert!(
        msgs.iter().any(|m| m.contains("Aborted file open")),
        "'q' must emit a visible abort message on the sole-slot path too; got: {msgs:?}"
    );
    assert_eq!(
        app.active().filename,
        None,
        "aborted slot must fall back to an unnamed scratch buffer, not stay on the file"
    );
    let content = app.active_editor().buffer().content_joined();
    assert!(
        content.is_empty(),
        "aborted slot must be empty, not the silently-opened on-disk content \
         or the swap body; got: {content:?}"
    );
}

// ── #185 recovery must reset syntax (content_reset) ──────────────────────────

#[test]
fn recovery_y_resets_syntax_spans() {
    // Repro for the syntax-highlight-breaks-after-recovery bug: the recovery
    // accept path must signal a content reset (like a normal file open) so the
    // syntax layer drops its stale tree + spans. We assert this via the proxy
    // that `handle_active_content_reset` clears `styled_spans` to empty — which
    // only happens when the content-install flags `pending_content_reset`.
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("recover_syntax.txt");
    std::fs::write(&file_path, "on disk\n").unwrap();
    let canonical = std::fs::canonicalize(&file_path).unwrap();
    let file_mtime_ms = std::fs::metadata(&file_path)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;

    let swap_path = td.path().join("recover_syntax.swp");
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canonical.to_string_lossy().into_owned(),
        file_mtime_unix_ms: file_mtime_ms,
        write_time_unix_ms: file_mtime_ms + 10_000,
        cursor: (0, 0),
        writer_pid: std::process::id(),
    };
    let rope = ropey::Rope::from_str("recovered body line one\nline two\n");
    hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;
    app.active_mut().swap_path = Some(swap_path);

    let idx = app.focused_slot_idx();

    // The slot's syntax tree was parsed against the on-disk bytes in
    // build_slot. Recovering must force a fresh parse_initial — i.e. signal a
    // FULL content reset (pending_content_reset), not an incremental ContentEdit
    // — or the retained tree drifts against the swapped-in bytes and
    // highlighting breaks (#185). Drain build_slot's own reset first, then
    // install the recovered body and assert the reset fired.
    let _ = app.active_editor_mut().take_content_reset();
    app.recover_install_content(idx, "recovered body line one\nline two\n", 0, 0, None);
    assert!(
        app.active_editor_mut().take_content_reset(),
        "recovery content install must signal a full content reset (set_content), \
         not an incremental edit (replace_all) — else the syntax tree drifts"
    );
    let content = app.active_editor().buffer().content_joined();
    assert!(
        content.contains("recovered body line one"),
        "buffer must hold the recovered body, got: {content:?}"
    );
}

// ── PID-lock multi-instance tests (issue #185) ────────────────────────────────

/// Opening a file whose swap was written by a LIVE process (pid 1 = init/launchd,
/// always alive on unix, always != our pid) must be refused with E325.
///
/// Gated `#[cfg(unix)]` because the liveness check uses kill(2) which is
/// unix-only; on non-unix pid_is_alive always returns false (no enforcement).
#[test]
#[cfg(unix)]
fn open_locked_file_refused_when_pid_alive() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("locked_file.txt");
    std::fs::write(&file_path, "on disk\n").unwrap();

    let canonical = std::fs::canonicalize(&file_path).unwrap();
    let file_mtime_ms = std::fs::metadata(&file_path)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;

    let swap_path = td.path().join("locked_file.swp");
    // Use pid=1 (init/launchd) — always alive and always != our pid.
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canonical.to_string_lossy().into_owned(),
        file_mtime_unix_ms: file_mtime_ms,
        write_time_unix_ms: file_mtime_ms + 10_000,
        cursor: (0, 0),
        writer_pid: 1, // pid 1 = init/launchd, always alive, never our pid
    };
    let rope = ropey::Rope::from_str("locked content");
    hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;
    let slot_count_before = app.slots.len();
    app.active_mut().swap_path = Some(swap_path);
    let idx = app.focused_slot_idx();
    let recovery = app.check_recovery_on_open(idx);

    // Must be refused: check_recovery_on_open returns false AND removes the slot.
    assert!(!recovery, "locked file must not enter recovery state");
    assert!(
        app.pending_recovery.is_none(),
        "pending_recovery must remain None for a locked file"
    );
    // The error message must contain E325.
    let msgs: Vec<&str> = app.bus.history().map(|h| h.body.as_str()).collect();
    assert!(
        msgs.iter().any(|m| m.contains("E325")),
        "E325 error must be reported; got: {msgs:?}"
    );
    // The slot count must have decreased (slot removed on refusal) or stayed
    // the same if there was only one slot (we never drop the last slot).
    if slot_count_before > 1 {
        assert!(
            app.slots.len() < slot_count_before,
            "refused slot must be removed"
        );
    }
}

/// Opening a file whose swap was written by a DEAD process must proceed to
/// the normal recovery prompt (not refused).
///
/// Gated `#[cfg(unix)]` because on non-unix pid_is_alive always returns false,
/// which means liveness is never enforced — but that means the dead-pid path
/// should also work correctly there (no lock; proceed to recovery).
#[test]
#[cfg(unix)]
fn open_with_dead_pid_enters_recovery() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("dead_pid_file.txt");
    std::fs::write(&file_path, "on disk\n").unwrap();

    let canonical = std::fs::canonicalize(&file_path).unwrap();
    let file_mtime_ms = std::fs::metadata(&file_path)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;

    let swap_path = td.path().join("dead_pid_file.swp");
    // Use a very high pid that is almost certainly not a live process.
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canonical.to_string_lossy().into_owned(),
        file_mtime_unix_ms: file_mtime_ms,
        write_time_unix_ms: file_mtime_ms + 10_000,
        cursor: (0, 0),
        writer_pid: 999_999_999, // almost certainly dead
    };
    let rope = ropey::Rope::from_str("recovered from dead pid");
    hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;
    app.active_mut().swap_path = Some(swap_path);
    let idx = app.focused_slot_idx();
    let recovery = app.check_recovery_on_open(idx);

    assert!(
        recovery,
        "stale (dead-pid) swap must enter recovery state, not be refused"
    );
    assert!(
        app.pending_recovery.is_some(),
        "pending_recovery must be set for a dead-pid swap"
    );
    // Must NOT have an E325 error.
    let msgs: Vec<&str> = app.bus.history().map(|h| h.body.as_str()).collect();
    assert!(
        !msgs.iter().any(|m| m.contains("E325")),
        "E325 must NOT fire for a dead-pid swap; got: {msgs:?}"
    );
}

/// `:recover` with no arg forces the recovery prompt even when the swap's
/// write_time is OLDER than the file on disk (which would normally cause the
/// swap to be deleted as stale).
#[test]
fn colon_recover_forces_prompt_even_when_file_newer() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("force_recover.txt");
    std::fs::write(&file_path, "on disk content\n").unwrap();

    let canonical = std::fs::canonicalize(&file_path).unwrap();
    let file_mtime_ms = std::fs::metadata(&file_path)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;

    let swap_path = td.path().join("force_recover.swp");
    // write_time OLDER than file mtime → normally stale-deleted; :recover must override.
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canonical.to_string_lossy().into_owned(),
        file_mtime_unix_ms: file_mtime_ms,
        write_time_unix_ms: file_mtime_ms.saturating_sub(5_000), // 5s BEFORE file mtime
        cursor: (0, 0),
        writer_pid: std::process::id(), // our own pid → not a lock
    };
    let rope = ropey::Rope::from_str("stale swap body");
    hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;
    app.active_mut().swap_path = Some(swap_path);

    // :recover (no arg) → must force recovery even though swap is "stale".
    app.dispatch_ex("recover");

    assert!(
        app.pending_recovery.is_some(),
        ":recover must enter recovery state even when swap is older than file"
    );
}

/// `:recover` with no arg when no swap file exists reports a "not found" info
/// message and does NOT enter recovery state.
#[test]
fn colon_recover_no_swap_reports_not_found() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("no_swap.txt");
    std::fs::write(&file_path, "some content\n").unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;

    // Point swap_path at a non-existent file.
    let nonexistent_swap = td.path().join("no_swap.swp");
    app.active_mut().swap_path = Some(nonexistent_swap);

    app.dispatch_ex("recover");

    assert!(
        app.pending_recovery.is_none(),
        ":recover with no swap must not enter recovery state"
    );
    let msgs: Vec<&str> = app.bus.history().map(|h| h.body.as_str()).collect();
    assert!(
        msgs.iter().any(|m| m.contains("No swap file found")),
        "must report 'No swap file found'; got: {msgs:?}"
    );
}

// ── Write-on-open / graceful-exit swap tests (#185 multi-instance) ─────────────

/// Opening a named file must write the swap immediately (arm the PID lock) so
/// that a concurrent second instance opening the same unmodified file finds a
/// swap and is refused.
///
/// Verifies via `arm_swap_on_open` called directly against an injected
/// swap_path in a tempdir (avoids XDG env mutation, which is unsafe in the
/// multi-threaded test harness).
#[test]
fn open_writes_swap_immediately() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("arm_on_open.txt");
    std::fs::write(&file_path, "initial content\n").unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;

    // Inject a swap path in the tempdir (controlled location).
    let swap_path = td.path().join("arm_on_open.swp");
    app.active_mut().swap_path = Some(swap_path.clone());
    // Reset last_swap_dirty_gen so arm_swap_on_open actually writes.
    app.active_mut().last_swap_dirty_gen = None;

    // The swap must NOT exist yet (we just set the path, haven't written it).
    assert!(
        !swap_path.exists(),
        "swap must not exist before arm_swap_on_open"
    );

    // Arm: simulates what App::new now does after a clean open.
    let idx = app.focused_slot_idx();
    app.arm_swap_on_open(idx);

    assert!(
        swap_path.exists(),
        "swap file must exist on disk immediately after arm_swap_on_open (PID lock)"
    );
}

/// `cleanup_swaps_on_exit` must delete swap files and clear swap_path on every
/// slot. This simulates the graceful-exit path so clean sessions leave no swap.
#[test]
fn graceful_exit_removes_swap() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("cleanup_exit.txt");
    std::fs::write(&file_path, "content\n").unwrap();

    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;

    // Inject a swap path in the tempdir and write a real swap file.
    let swap_path = td.path().join("cleanup_exit.swp");
    app.active_mut().swap_path = Some(swap_path.clone());
    let idx = app.focused_slot_idx();
    // seed a dirty gen so write_swap_for_slot writes.
    seed_buffer(&mut app, "dirty content");
    app.write_swap_for_slot(idx);
    assert!(swap_path.exists(), "swap must exist before cleanup");

    // Simulate graceful exit.
    app.cleanup_swaps_on_exit();

    assert!(
        !swap_path.exists(),
        "swap file must be removed after cleanup_swaps_on_exit"
    );
    assert!(
        app.active().swap_path.is_none(),
        "swap_path must be None after cleanup_swaps_on_exit"
    );
}

/// The second-instance lock works end-to-end when the first instance arms the
/// swap at open. Mirrors `open_locked_file_refused_when_pid_alive` but asserts
/// the comment that write-on-open is what makes this scenario real: the swap
/// with writer_pid == our-own-pid simulates a live first instance that just
/// opened (not edited) the file.
///
/// Gated `#[cfg(unix)]` because `pid_is_alive` uses kill(2).
#[test]
#[cfg(unix)]
fn second_instance_refused_after_first_opens_unmodified() {
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("multi_instance.txt");
    std::fs::write(&file_path, "shared content\n").unwrap();

    let canonical = std::fs::canonicalize(&file_path).unwrap();
    let file_mtime_ms = std::fs::metadata(&file_path)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;

    // Write a swap stamped with pid=1 (init/launchd — always alive, never our
    // pid) to simulate the first instance having armed the lock at open.
    let swap_path = td.path().join("multi_instance.swp");
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canonical.to_string_lossy().into_owned(),
        file_mtime_unix_ms: file_mtime_ms,
        write_time_unix_ms: file_mtime_ms + 1, // just after open, no edits
        cursor: (0, 0),
        writer_pid: 1, // pid 1 = init/launchd, always alive, never our pid
    };
    let rope = ropey::Rope::from_str("shared content");
    hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();

    // Second instance opens the file and injects the pre-existing swap.
    let mut app = App::new(Some(file_path), false, None, None).unwrap();
    app.pending_recovery = None;
    app.active_mut().swap_path = Some(swap_path);
    let idx = app.focused_slot_idx();
    let recovery = app.check_recovery_on_open(idx);

    // Must be refused with E325.
    assert!(
        !recovery,
        "second instance must be refused when first holds PID lock"
    );
    assert!(
        app.pending_recovery.is_none(),
        "pending_recovery must remain None on refusal"
    );
    let msgs: Vec<&str> = app.bus.history().map(|h| h.body.as_str()).collect();
    assert!(
        msgs.iter().any(|m| m.contains("E325")),
        "E325 must be reported; got: {msgs:?}"
    );
}

#[cfg(unix)]
#[test]
fn open_locked_sole_buffer_is_readonly() {
    // When the locked file is the ONLY buffer (e.g. `hjkl <file>` startup),
    // we can't drop the sole slot — so it opens READ-ONLY with its swap_path
    // cleared, so this process never clobbers the owner's swap or file.
    let td = tempfile::tempdir().unwrap();
    let file_path = td.path().join("locked_sole.txt");
    std::fs::write(&file_path, "owned by another\n").unwrap();
    let canonical = std::fs::canonicalize(&file_path).unwrap();
    let file_mtime_ms = std::fs::metadata(&file_path)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;

    let swap_path = td.path().join("locked_sole.swp");
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canonical.to_string_lossy().into_owned(),
        file_mtime_unix_ms: file_mtime_ms,
        write_time_unix_ms: file_mtime_ms + 10_000,
        cursor: (0, 0),
        // pid 1 (init/launchd): always alive, never our pid → live lock.
        writer_pid: 1,
    };
    hjkl_app::swap::write_swap(&swap_path, &header, &ropey::Rope::from_str("x")).unwrap();

    let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
    app.pending_recovery = None;
    let idx = app.focused_slot_idx();
    assert_eq!(app.slots.len(), 1, "precondition: sole buffer");
    app.active_mut().swap_path = Some(swap_path);

    let recovery = app.check_recovery_on_open(idx);
    assert!(!recovery, "locked sole buffer must not enter recovery");
    assert!(
        app.active_editor().is_readonly(),
        "locked sole buffer must open read-only"
    );
    assert!(
        app.active().swap_path.is_none(),
        "read-only viewer must not own a swap path"
    );

    // The danger the read-only fallback guards against: `:w` from the locked
    // viewer must NOT overwrite the file the owning instance holds. Dirty the
    // buffer and attempt a write — it must be refused (E45 readonly) and the
    // on-disk content must be unchanged.
    seed_buffer(&mut app, "clobbered by viewer\n");
    app.dispatch_ex("write");
    let on_disk = std::fs::read_to_string(&file_path).unwrap();
    assert_eq!(
        on_disk, "owned by another\n",
        "locked read-only viewer must not be able to :w over the owner's file"
    );
    let msgs: Vec<String> = app.bus.history().map(|h| h.body.clone()).collect();
    assert!(
        msgs.iter()
            .any(|m| m.contains("E45") || m.contains("readonly")),
        "write must be refused with a readonly error; got: {msgs:?}"
    );
}

#[cfg(unix)]
#[test]
fn locked_secondary_slot_is_readonly_not_removed() {
    // `hjkl file1 file2` where file2 is locked by another live instance:
    // file2's slot must open READ-ONLY and stay present — not be removed
    // (silently dropping a file the user explicitly asked for) nor left
    // editable. Repro for the multi-file open lock-handling bug.
    let td = tempfile::tempdir().unwrap();
    let file1 = td.path().join("ms_file1.txt");
    let file2 = td.path().join("ms_file2.txt");
    std::fs::write(&file1, "first\n").unwrap();
    std::fs::write(&file2, "second owned\n").unwrap();

    let canon2 = std::fs::canonicalize(&file2).unwrap();
    let mtime2 = std::fs::metadata(&file2)
        .unwrap()
        .modified()
        .unwrap()
        .duration_since(std::time::SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;
    let swap2 = td.path().join("ms_file2.swp");
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: canon2.to_string_lossy().into_owned(),
        file_mtime_unix_ms: mtime2,
        write_time_unix_ms: mtime2 + 10_000,
        cursor: (0, 0),
        writer_pid: 1, // alive, not us → live lock
    };
    hjkl_app::swap::write_swap(&swap2, &header, &ropey::Rope::from_str("x")).unwrap();

    let mut app = App::new(Some(file1), false, None, None).unwrap();
    app.pending_recovery = None;
    // Open file2 as a second slot (mirrors CLI `hjkl file1 file2`).
    let idx2 = app.open_new_slot(file2).unwrap();
    assert_eq!(app.slots.len(), 2, "precondition: two slots");
    app.slots[idx2].swap_path = Some(swap2);

    let recovery = app.check_recovery_on_open(idx2);
    assert!(!recovery, "locked secondary slot must not enter recovery");
    assert_eq!(
        app.slots.len(),
        2,
        "locked secondary slot must NOT be removed — the file was requested"
    );
    assert!(
        app.slots[idx2].is_readonly(),
        "locked secondary slot must open read-only"
    );
    assert!(
        app.slots[idx2].swap_path.is_none(),
        "locked read-only slot must not own a swap path"
    );
}

// ── Scratch-buffer swap tests (issue #185) ────────────────────────────────────

/// A scratch buffer with content gets a swap file written lazily under an
/// injected [`hjkl_app::swap::SwapRoot`] — no `XDG_CACHE_HOME` override, so
/// the swap lands in the temp dir, never the real cache.
#[test]
fn scratch_buffer_writes_swap_when_dirty() {
    let td = tempfile::tempdir().unwrap();
    let mut app = App::new(None, false, None, None).unwrap();
    app.swap_root = hjkl_app::swap::SwapRoot::At(td.path().to_path_buf());
    seed_buffer(&mut app, "unsaved work");

    app.write_swap_for_slot(0);

    let swap_path = app.slots[0]
        .swap_path
        .clone()
        .expect("swap_path must be Some after write for non-empty scratch");
    assert!(
        swap_path.starts_with(td.path()),
        "swap must live under the injected root, got {swap_path:?}"
    );
    assert!(swap_path.exists(), "swap file must exist at {swap_path:?}");

    let (header, body) = hjkl_app::swap::read_swap(&swap_path).unwrap();
    assert!(
        header.canonical_path.is_empty(),
        "scratch swap must have empty canonical_path, got {:?}",
        header.canonical_path
    );
    assert_eq!(body, "unsaved work", "swap body must match buffer content");

    // Clean up the swap we wrote into the injected temp root.
    let _ = hjkl_app::swap::remove_swap(&swap_path);
}

/// An empty scratch buffer produces no swap file. `swap_path` staying `None`
/// is the proof — `write_swap_for_slot` returns before choosing a path when
/// the buffer is empty, so nothing is written (no env / filesystem scan
/// needed, hence no cross-test races).
#[test]
fn empty_scratch_buffer_writes_no_swap() {
    let mut app = App::new(None, false, None, None).unwrap();
    // View is empty by default — do not seed any content.

    app.write_swap_for_slot(0);

    assert!(
        app.slots[0].swap_path.is_none(),
        "empty scratch buffer must not get a swap_path (so nothing was written)"
    );
}

/// A dead-pid orphan scratch swap is loaded into a recovered unnamed buffer.
#[test]
fn recover_orphan_scratch_loads_buffer() {
    let td = tempfile::tempdir().unwrap();

    // Write a scratch swap with a dead pid into the tempdir.
    let orphan_path = td.path().join("scratch_999999999_42.swp");
    let header = hjkl_app::swap::SwapHeader {
        magic: hjkl_app::swap::SwapHeader::MAGIC,
        version: hjkl_app::swap::SwapHeader::VERSION,
        canonical_path: String::new(),
        file_mtime_unix_ms: 0,
        write_time_unix_ms: 1_700_000_000_000,
        cursor: (0, 0),
        writer_pid: 999_999_999,
    };
    let rope = ropey::Rope::from_str("unsaved work\n");
    hjkl_app::swap::write_swap(&orphan_path, &header, &rope).unwrap();

    let mut app = App::new(None, false, None, None).unwrap();
    let initial_slot_count = app.slots.len();

    // Use dir-injected variant — no XDG mutation needed.
    let recovered = app.recover_orphan_scratch_buffers_from(td.path());

    assert_eq!(recovered, 1, "must recover exactly 1 buffer");
    assert_eq!(
        app.slots.len(),
        initial_slot_count + 1,
        "a new slot must have been added"
    );

    let new_slot = &app.slots[app.slots.len() - 1];
    assert!(
        new_slot.filename.is_none(),
        "recovered slot must be unnamed"
    );
    assert!(new_slot.dirty, "recovered slot must be dirty");

    // Check content contains the recovered text.
    let content = new_slot.buffer().rope().to_string();
    assert!(
        content.contains("unsaved work"),
        "recovered buffer content must contain 'unsaved work', got {content:?}"
    );

    // Orphan swap must be deleted.
    assert!(
        !orphan_path.exists(),
        "orphan swap must be deleted after recovery"
    );
}

/// Two dirty buffers, only one focused — an idle tick must refresh BOTH
/// swap files, not just the focused slot's (audit-r2 fix 3). Pre-fix, the
/// idle writer only ever wrote `focused_slot_idx()`, so a defocused dirty
/// buffer's edits had no crash protection at all until it was refocused.
#[test]
fn idle_swap_sweep_writes_all_dirty_slots_not_just_focused() {
    let td = tempfile::tempdir().unwrap();
    let path_a = td.path().join("sweep_a.txt");
    let path_b = td.path().join("sweep_b.txt");
    std::fs::write(&path_a, "alpha\n").unwrap();
    std::fs::write(&path_b, "beta\n").unwrap();

    let mut app = App::new(Some(path_a), false, None, None).unwrap();
    app.pending_recovery = None;

    // Slot 0 (path_a): inject a swap path, dirty it — then defocus by
    // opening path_b.
    let swap_path_a = td.path().join("sweep_a.swp");
    app.active_mut().swap_path = Some(swap_path_a.clone());
    seed_buffer(&mut app, "changed alpha");
    app.active_mut().dirty = true;
    assert_eq!(app.active_index(), 0);

    app.dispatch_ex(&format!("e {}", path_b.display()));
    assert_eq!(app.slots.len(), 2);
    assert_eq!(app.active_index(), 1, "path_b must now be focused");

    // Slot 1 (path_b, focused): inject a swap path, dirty it too.
    let swap_path_b = td.path().join("sweep_b.swp");
    app.active_mut().swap_path = Some(swap_path_b.clone());
    seed_buffer(&mut app, "changed beta");
    app.active_mut().dirty = true;

    assert!(!swap_path_a.exists(), "setup: no swap files written yet");
    assert!(!swap_path_b.exists(), "setup: no swap files written yet");

    // Simulate updatetime (default 4000ms) having elapsed since the last
    // keystroke, then run the idle sweep.
    app.last_input_at = std::time::Instant::now() - std::time::Duration::from_millis(5000);
    app.tick_idle_swap_write(std::time::Instant::now());

    assert!(
        swap_path_b.exists(),
        "focused slot's swap must be written by the idle sweep"
    );
    assert!(
        swap_path_a.exists(),
        "defocused dirty slot's swap must ALSO be written by the idle sweep"
    );
}

#[test]
fn active_swap_pending_true_for_dirty_scratch_buffer() {
    // Regression: scratch slots start with swap_path = None, so the idle
    // writer's slot_swap_pending() gate must still fire for a non-empty
    // unnamed buffer — otherwise the scratch swap is never written at runtime.
    let mut app = App::new(None, false, None, None).unwrap();
    assert!(
        app.active().filename.is_none(),
        "precondition: scratch buffer"
    );
    let idx = app.focused_slot_idx();
    assert!(
        !app.slot_swap_pending(idx),
        "empty scratch buffer has nothing to protect yet"
    );
    seed_buffer(&mut app, "unsaved scratch work\n");
    assert!(
        app.slot_swap_pending(idx),
        "dirty non-empty scratch buffer must arm the idle swap writer"
    );
}

// ── :colorscheme (#35) ───────────────────────────────────────────────────────

#[test]
fn colorscheme_switches_and_reports() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.colorscheme, "dark", "default colorscheme is dark");

    app.dispatch_ex("colorscheme light");
    assert_eq!(app.colorscheme, "light");
    assert!(
        app.bus.last_body_or_empty().contains("colorscheme light"),
        "switch must report the new scheme"
    );

    // Bare query reports current.
    app.dispatch_ex("colorscheme");
    assert!(app.bus.last_body_or_empty().contains("colorscheme light"));

    // `:colo` abbreviation switches back.
    app.dispatch_ex("colo dark");
    assert_eq!(app.colorscheme, "dark");

    // A bundled single-file theme switches and reports too (#303).
    app.dispatch_ex("colorscheme tokyonight");
    assert_eq!(app.colorscheme, "tokyonight");
    assert!(
        app.bus
            .last_body_or_empty()
            .contains("colorscheme tokyonight"),
        "switching to a bundled single-file theme must report it"
    );
}

/// #303: `:colorscheme <name>` must update the UI CHROME (`self.theme.ui`), not
/// just the syntax layer — the historical bug was that chrome stayed on the old
/// palette. Proven by the editor background changing to tokyonight's `bg`.
#[test]
fn colorscheme_updates_ui_chrome_not_just_syntax() {
    use ratatui::style::Color;
    let mut app = App::new(None, false, None, None).unwrap();
    // Default (dark) editor background = ui-dark.toml `#0b0d10`.
    assert_eq!(app.theme.ui.background, Color::Rgb(0x0b, 0x0d, 0x10));

    app.dispatch_ex("colorscheme tokyonight");
    assert_eq!(app.colorscheme, "tokyonight");
    // tokyonight Night bg = #1a1b26 — the UI theme now follows the scheme.
    assert_eq!(
        app.theme.ui.background,
        Color::Rgb(0x1a, 0x1b, 0x26),
        "UI chrome background must follow the colorscheme, not stay on the old palette"
    );
}

/// #303 Slice B: a second bundled single-file theme switches its chrome too —
/// proving the registry generalises past the first entry. `:colorscheme gruvbox`
/// flips the editor background to gruvbox dark-medium `bg` (#282828).
#[test]
fn colorscheme_gruvbox_switches_chrome() {
    use ratatui::style::Color;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("colorscheme gruvbox");
    assert_eq!(app.colorscheme, "gruvbox");
    assert_eq!(
        app.theme.ui.background,
        Color::Rgb(0x28, 0x28, 0x28),
        "gruvbox bg #282828 must drive the UI chrome background"
    );
}

#[test]
fn colorscheme_unknown_errors_and_keeps_current() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("colorscheme solarized");
    assert_eq!(
        app.colorscheme, "dark",
        "unknown scheme must not change current"
    );
    assert!(
        app.bus.last_body_or_empty().contains("E185"),
        "unknown colorscheme must report E185"
    );
}

#[test]
fn set_background_tracks_colorscheme() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("set background=light");
    assert_eq!(
        app.colorscheme, "light",
        ":set background= must track colorscheme"
    );
}

/// App-level indentation surface coverage, driven through the real keypress
/// path (`macro_key_seq`). Expected outputs match `nvim --headless` with
/// `expandtab` / `shiftwidth=4`. Covers operator+motion, text objects, dot
/// repeat, visual-mode counts (the regression this suite was added for), and
/// insert-mode `Ctrl-T` / `Ctrl-D`.
fn indent_app(seed: &str, keys: &[KeyEvent]) -> String {
    let mut app = App::new(None, false, None, None).unwrap();
    app.active_editor_mut().settings_mut().expandtab = true;
    app.active_editor_mut().settings_mut().shiftwidth = 4;
    seed_buffer(&mut app, seed);
    app.active_editor_mut().jump_cursor(0, 0);
    macro_key_seq(&mut app, keys);
    (*app.active_editor().buffer().content_joined()).clone()
}

#[test]
fn indent_operator_motion_forms() {
    assert_eq!(
        indent_app("a\nb\nc\n", &[ck('>'), ck('j')]),
        "    a\n    b\nc\n"
    );
    assert_eq!(
        indent_app("a\nb\nc\n", &[ck('>'), ck('G')]),
        "    a\n    b\n    c\n"
    );
    // count between op and motion, and before op — both indent 3 lines.
    assert_eq!(
        indent_app("a\nb\nc\nd\n", &[ck('>'), ck('2'), ck('j')]),
        "    a\n    b\n    c\nd\n"
    );
    assert_eq!(
        indent_app("a\nb\nc\nd\n", &[ck('2'), ck('>'), ck('j')]),
        "    a\n    b\n    c\nd\n"
    );
}

#[test]
fn indent_text_objects() {
    // `>ip` indents the paragraph (up to the blank line).
    assert_eq!(
        indent_app("a\nb\n\nc\n", &[ck('>'), ck('i'), ck('p')]),
        "    a\n    b\n\nc\n"
    );
    // `>iB` indents inside the surrounding `{ }` block.
    assert_eq!(
        indent_app("{\nx\ny\n}\n", &[ck('j'), ck('>'), ck('i'), ck('B')]),
        "{\n    x\n    y\n}\n"
    );
}

#[test]
fn indent_dot_repeat() {
    // `>>` then `j.` repeats the indent on the next line.
    assert_eq!(
        indent_app("a\nb\nc\n", &[ck('>'), ck('>'), ck('j'), ck('.')]),
        "    a\n    b\nc\n"
    );
}

#[test]
fn visual_indent_count_applies_levels() {
    // `Vj2>` indents the two selected lines by TWO shiftwidths (8 spaces).
    assert_eq!(
        indent_app("a\nb\nc\n", &[ck('V'), ck('j'), ck('2'), ck('>')]),
        "        a\n        b\nc\n"
    );
    // `Vj2<` outdents the two selected lines by TWO shiftwidths (8 → 0).
    assert_eq!(
        indent_app(
            "        a\n        b\n        c\n",
            &[ck('V'), ck('j'), ck('2'), ck('<')]
        ),
        "a\nb\n        c\n"
    );
    // `V3>` indents the single selected line by THREE shiftwidths (12 spaces).
    assert_eq!(
        indent_app("a\nb\nc\n", &[ck('V'), ck('3'), ck('>')]),
        "            a\nb\nc\n"
    );
}

#[test]
fn visual_motion_count_extends_selection() {
    // `V2j>` — the count on the motion extends the selection 2 lines down, so
    // three lines are indented once.
    assert_eq!(
        indent_app("a\nb\nc\nd\ne\n", &[ck('V'), ck('2'), ck('j'), ck('>')]),
        "    a\n    b\n    c\nd\ne\n"
    );
}

#[test]
fn insert_ctrl_t_and_ctrl_d() {
    // `i` then Ctrl-T indents the current line by one shiftwidth.
    assert_eq!(indent_app("abc\n", &[ck('i'), ctrl_key('t')]), "    abc\n");
    // Ctrl-D outdents it back.
    assert_eq!(
        indent_app("abc\n", &[ck('i'), ctrl_key('t'), ctrl_key('d')]),
        "abc\n"
    );
}

/// Reproduce `==` through the REAL keypress path (handle_keypress →
/// route_chord_key → chord_routing → submit_external_format), not a direct
/// call. Skipped where taplo isn't on PATH.
#[test]
fn double_equals_keypress_formats_toml() {
    if !hjkl_mangler::is_tool_installed("taplo") {
        return;
    }
    let td = tempfile::tempdir().unwrap();
    let path = td.path().join("k.toml");
    std::fs::write(&path, "[a]\nx=1\n").unwrap();
    let mut app = App::new(Some(path), false, None, None).unwrap();
    seed_buffer(&mut app, "[a]\nx=1\n");
    app.active_editor_mut().jump_cursor(0, 0);

    // Press `==` exactly as the live loop would.
    macro_key_seq(&mut app, &[ck('='), ck('=')]);

    let mut installed = false;
    for _ in 0..200 {
        if app.poll_format_results() {
            installed = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(20));
    }
    assert!(
        installed,
        "`==` keypress must submit a format job whose result installs"
    );
    let out = app.active_editor().buffer().content_joined();
    assert!(
        out.contains("x = 1"),
        "`==` must reformat `x=1` → `x = 1`, got: {out:?}"
    );
}

/// End-to-end `==`/`=` external-format path: `submit_external_format` (what the
/// `=` operator calls) must dispatch the async worker, and `poll_format_results`
/// must install taplo's reformatted output into the buffer. Skipped where taplo
/// isn't on PATH (CI) so it never flakes there.
#[test]
fn external_format_toml_installs_worker_result() {
    if !hjkl_mangler::is_tool_installed("taplo") {
        return; // taplo not available — nothing to exercise
    }
    let td = tempfile::tempdir().unwrap();
    let path = td.path().join("messy.toml");
    std::fs::write(&path, "[a]\nx=1\n").unwrap();
    let mut app = App::new(Some(path), false, None, None).unwrap();
    seed_buffer(&mut app, "[a]\nx=1\n");

    let submitted = app.submit_external_format(Some(hjkl_mangler::RangeSpec {
        start_row: 0,
        end_row: 1,
    }));
    assert!(
        submitted,
        "taplo is installed → submit_external_format must dispatch a job"
    );

    let mut installed = false;
    for _ in 0..200 {
        if app.poll_format_results() {
            installed = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(20));
    }
    assert!(installed, "the async format result must install within ~4s");

    let out = app.active_editor().buffer().content_joined();
    assert!(
        out.contains("x = 1"),
        "taplo must reformat `x=1` → `x = 1`, got: {out:?}"
    );
}

// ── trailing-newline (EOL) model: vim-consistent ─────────────────────────────
//
// vim's line model excludes the file-final newline: a file `a\nb\nc\n` is 3
// lines, not 4. hjkl strips one trailing `\n` on load and re-adds it on save
// (matching vim's `fixendofline`), so the buffer never shows a phantom empty
// last line. Formatter output (prettier appends `\n`) is normalised the same
// way at the install site.

#[test]
fn trailing_newline_file_loads_without_phantom_line() {
    use hjkl_engine::Query;
    let td = tempfile::tempdir().unwrap();
    let path = td.path().join("eol.txt");
    std::fs::write(&path, "a\nb\nc\n").unwrap();
    let app = App::new(Some(path), false, None, None).unwrap();
    assert_eq!(
        app.active_editor().buffer().line_count(),
        3,
        "a file ending in `\\n` must load as 3 lines, not 4 (no phantom empty line)"
    );
}

#[test]
fn save_round_trips_trailing_newline_exactly_once() {
    let td = tempfile::tempdir().unwrap();
    let path = td.path().join("eol_rt.txt");
    std::fs::write(&path, "a\nb\nc\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    app.active_mut().dirty = true; // force the write
    app.dispatch_ex("w");
    let on_disk = std::fs::read_to_string(&path).unwrap();
    assert_eq!(
        on_disk, "a\nb\nc\n",
        "save must preserve exactly one trailing newline, not drop or double it"
    );
}

#[test]
fn save_adds_trailing_newline_when_missing() {
    use hjkl_engine::Query;
    let td = tempfile::tempdir().unwrap();
    let path = td.path().join("noeol.txt");
    std::fs::write(&path, "a\nb\nc").unwrap(); // no trailing newline
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    // Still 3 lines (load strips nothing here — there's no trailing newline).
    assert_eq!(app.active_editor().buffer().line_count(), 3);
    app.active_mut().dirty = true;
    app.dispatch_ex("w");
    let on_disk = std::fs::read_to_string(&path).unwrap();
    assert_eq!(
        on_disk, "a\nb\nc\n",
        "save must add a trailing newline when missing (vim `fixendofline`)"
    );
}

// ── vim `'endofline'` / `'fixendofline'` parity, byte-for-byte ───────────────
//
// Ground truth measured against nvim 0.12 — `nvim --clean --headless -c wq
// FILE`, then the same with `-c 'set nofixeol'`. These drive the REAL `:w`
// path (`save_slot` → `save::save_file_durable`); the rule itself is unit
// tested in `save::eol_state_tests`, and `apps/hjkl/tests/headless.rs` runs
// the identical rows through the `--headless` binary.
//
// The interesting row is `"\n"`: hjkl's line model strips the file-final
// newline, so a 1-byte `"\n"` file and a 0-byte file both join back to an
// empty body. Deriving the trailing newline from the body alone (as `:w` used
// to) truncated the former to 0 bytes.

/// Load `input` from a temp file, run `setup` (e.g. `:set nofixeol`), `:w`,
/// and return the exact bytes on disk. The tempdir is dropped at the end —
/// nothing outlives the assertion.
fn eol_write_round_trip(input: &str, setup: &[&str]) -> Vec<u8> {
    let td = tempfile::tempdir().unwrap();
    let path = td.path().join("eol_case.txt");
    std::fs::write(&path, input).unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    for cmd in setup {
        app.dispatch_ex(cmd);
    }
    // `:w` on a clean buffer is a no-op write in some paths; force it so the
    // bytes on disk are the ones this save produced.
    app.active_mut().dirty = true;
    app.dispatch_ex("w");
    std::fs::read(&path).unwrap()
}

#[test]
fn write_matches_nvim_with_fixendofline_on() {
    // (loaded bytes, bytes nvim writes back with the default `fixeol`)
    for (input, want) in [
        ("", ""),
        ("\n", "\n"),
        ("abc", "abc\n"),
        ("abc\n", "abc\n"),
        ("a\n\n", "a\n\n"),
    ] {
        let got = eol_write_round_trip(input, &[]);
        assert_eq!(
            got,
            want.as_bytes(),
            "fixeol: {input:?} ({} bytes in) must save as {want:?} ({} bytes), got {:?}",
            input.len(),
            want.len(),
            String::from_utf8_lossy(&got)
        );
    }
}

#[test]
fn write_matches_nvim_with_nofixendofline() {
    // (loaded bytes, bytes nvim writes back under `:set nofixeol`)
    for (input, want) in [
        ("", ""),
        ("\n", "\n"),
        ("abc", "abc"),
        ("abc\n", "abc\n"),
        ("a\n\n", "a\n\n"),
    ] {
        let got = eol_write_round_trip(input, &["set nofixeol"]);
        assert_eq!(
            got,
            want.as_bytes(),
            "nofixeol: {input:?} ({} bytes in) must save as {want:?} ({} bytes), got {:?}",
            input.len(),
            want.len(),
            String::from_utf8_lossy(&got)
        );
    }
}

/// The regression this feature exists for: `:w` on a file holding exactly one
/// newline used to write 0 bytes, silently truncating it.
#[test]
fn lone_newline_file_is_not_truncated_by_save() {
    let got = eol_write_round_trip("\n", &[]);
    assert_eq!(got, b"\n", "a 1-byte `\\n` file must stay 1 byte");
}

/// …and `:w <other>` writes the same bytes as an in-place `:w` (nvim does).
#[test]
fn lone_newline_file_writes_one_byte_to_a_new_path_too() {
    let td = tempfile::tempdir().unwrap();
    let src = td.path().join("src.txt");
    let dst = td.path().join("dst.txt");
    std::fs::write(&src, "\n").unwrap();
    let mut app = App::new(Some(src), false, None, None).unwrap();
    app.dispatch_ex(&format!("w {}", dst.display()));
    assert_eq!(std::fs::read(&dst).unwrap(), b"\n");
}

/// `'endofline'` is derived from the file at load, matching what nvim
/// reports for `&eol` — and `:set eol?` reports it.
#[test]
fn set_eol_query_reports_the_loaded_endofline_state() {
    for (input, want) in [
        ("", "endofline=on"),
        ("\n", "endofline=on"),
        ("abc", "endofline=off"),
        ("abc\n", "endofline=on"),
        ("a\n\n", "endofline=on"),
    ] {
        let td = tempfile::tempdir().unwrap();
        let path = td.path().join("q.txt");
        std::fs::write(&path, input).unwrap();
        let mut app = App::new(Some(path), false, None, None).unwrap();
        app.dispatch_ex("set eol?");
        assert_eq!(
            app.bus.last_body_or_empty(),
            want,
            "`:set eol?` after loading {input:?}"
        );
        // The long spelling answers identically.
        app.dispatch_ex("set endofline?");
        assert_eq!(app.bus.last_body_or_empty(), want);
    }
}

/// `:set noeol` / `:set eol` override the derived state, and `:set eol!`
/// toggles — all three change what `:w` writes (verified against nvim).
#[test]
fn set_eol_overrides_change_the_written_bytes() {
    // `abc\n` + `:set noeol nofixeol` → nvim writes 3 bytes.
    assert_eq!(
        eol_write_round_trip("abc\n", &["set noeol nofixeol"]),
        b"abc"
    );
    // `abc` + `:set nofixeol eol` → nvim writes 4 bytes.
    assert_eq!(eol_write_round_trip("abc", &["set nofixeol eol"]), b"abc\n");
    // `\n` + `:set noeol nofixeol` → nvim writes 0 bytes (the single empty
    // line contributes nothing and no EOL is appended).
    assert_eq!(
        eol_write_round_trip("\n", &["set noeol nofixeol"]),
        b"" as &[u8]
    );
    // `:set eol!` toggles back on.
    assert_eq!(
        eol_write_round_trip("abc", &["set nofixeol", "set eol!"]),
        b"abc\n"
    );
}

/// A `:set` line mixing host-owned `eol` tokens with engine options must
/// apply BOTH — the residual tokens have to reach hjkl-ex.
#[test]
fn set_line_mixing_eol_and_engine_options_applies_both() {
    let td = tempfile::tempdir().unwrap();
    let path = td.path().join("mixed.txt");
    std::fs::write(&path, "abc\n").unwrap();
    let mut app = App::new(Some(path), false, None, None).unwrap();
    app.dispatch_ex("set noeol number nofixeol");
    assert!(!app.active().eol.endofline, "`noeol` must apply");
    assert!(
        app.active_editor().settings().number,
        "`number` must still reach the engine"
    );
    assert!(
        !app.active_editor().settings().fixendofline,
        "`nofixeol` must still reach the engine"
    );
}

/// `'endofline'` is buffer-local (vim semantics): setting it on one buffer
/// must not touch another.
#[test]
fn endofline_is_per_buffer_not_global() {
    let td = tempfile::tempdir().unwrap();
    let a = td.path().join("a.txt");
    let b = td.path().join("b.txt");
    std::fs::write(&a, "aaa\n").unwrap();
    std::fs::write(&b, "bbb\n").unwrap();
    let mut app = App::new(Some(a), false, None, None).unwrap();
    app.dispatch_ex(&format!("e {}", b.display()));
    app.dispatch_ex("set noeol");
    assert!(!app.active().eol.endofline, "`noeol` applies to b.txt");
    app.dispatch_ex("bprevious");
    assert!(
        app.active().eol.endofline,
        "a.txt must keep its own 'endofline' — the option is buffer-local"
    );
}

/// `:e` re-derives `'endofline'` from the bytes on disk (vim resets it on
/// reload), so a rewritten file doesn't keep a stale flag.
#[test]
fn reload_re_derives_endofline() {
    let td = tempfile::tempdir().unwrap();
    let path = td.path().join("reload.txt");
    std::fs::write(&path, "abc").unwrap(); // no trailing newline
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    assert!(!app.active().eol.endofline);
    std::fs::write(&path, "abc\n").unwrap();
    app.dispatch_ex("e!");
    assert!(
        app.active().eol.endofline,
        ":e must re-derive 'endofline' from the reloaded bytes"
    );
}

// ── sed-style ex commands: vim-compat (range + %/# literal-in-body) ───────────
//
// These run through the full `dispatch_ex` path WITH A CURRENT FILE so the
// command-line `%`/`#` filename expansion is live. The bug class: the leading
// range `%` (`:%s/…`) and any `%`/`#` inside a substitute/global body were
// being rewritten to the current filename, breaking the command. Vim expands
// `%`/`#` only in file-argument/shell commands — never the range, never the
// substitute/global body.

/// Open an app on a temp file seeded with `content`; returns (app, tempdir).
/// The tempdir must outlive the app (kept by the caller).
fn app_with_file(content: &str) -> (App, tempfile::TempDir) {
    let td = tempfile::tempdir().unwrap();
    let path = td.path().join("sed_compat.txt");
    std::fs::write(&path, content).unwrap();
    let mut app = App::new(Some(path), false, None, None).unwrap();
    seed_buffer(&mut app, content);
    (app, td)
}

#[test]
fn percent_substitute_replaces_all_lines() {
    // The reported bug: `:%s/foo/bar/g` did nothing because the leading `%`
    // range was expanded to the current filename before dispatch.
    let (mut app, _td) = app_with_file("foo foo\nbar\nfoo\n");
    app.dispatch_ex("%s/foo/bar/g");
    let content = app.active_editor().buffer().content_joined();
    assert_eq!(
        &**content, "bar bar\nbar\nbar\n",
        "`:%s/foo/bar/g` must replace every match on every line, got: {content:?}"
    );
}

#[test]
fn substitute_literal_percent_in_pattern() {
    // `%` inside the substitute pattern is a literal char, NOT the current-file
    // token — vim never expands `%`/`#` in a substitute body.
    let (mut app, _td) = app_with_file("50% done\n");
    // Cursor is on line 0; no range → current line.
    app.dispatch_ex("s/50%/half/");
    let content = app.active_editor().buffer().content_joined();
    assert_eq!(
        &**content, "half done\n",
        "`%` in the substitute pattern must stay literal, got: {content:?}"
    );
}

#[test]
fn substitute_literal_hash_in_replacement() {
    // `#` (alternate-file token) must also stay literal in a substitute body.
    let (mut app, _td) = app_with_file("count: N\n");
    app.dispatch_ex(r"s/N/#42/");
    let content = app.active_editor().buffer().content_joined();
    assert_eq!(
        &**content, "count: #42\n",
        "`#` in the replacement must stay literal, got: {content:?}"
    );
}

#[test]
fn percent_global_delete_matching_lines() {
    // `:%g/pat/d` — range `%` + global; the leading `%` must not expand.
    let (mut app, _td) = app_with_file("keep\ndrop me\nkeep2\ndrop again\n");
    app.dispatch_ex("%g/drop/d");
    let content = app.active_editor().buffer().content_joined();
    assert_eq!(
        &**content, "keep\nkeep2\n",
        "`:%g/drop/d` must delete every matching line, got: {content:?}"
    );
}

#[test]
fn numeric_range_substitute_still_works() {
    // Control: a numeric range (no `%`) was never affected; guard it stays so.
    let (mut app, _td) = app_with_file("a\nx\nx\nx\nb\n");
    app.dispatch_ex("2,3s/x/y/");
    let content = app.active_editor().buffer().content_joined();
    assert_eq!(
        &**content, "a\ny\ny\nx\nb\n",
        "`:2,3s/x/y/` must touch only lines 2-3, got: {content:?}"
    );
}

#[test]
fn edit_percent_still_expands_to_filename() {
    // Guard the OTHER side: `%` as a FILE ARGUMENT (`:e %`) must still expand —
    // the fix must not over-suppress expansion for file commands.
    let (mut app, _td) = app_with_file("hello\n");
    // `:e %` reloads the current file; it must not error with an unexpanded
    // `%` path. Dirty the buffer first so the reload is observable, then
    // force-edit to discard.
    seed_buffer(&mut app, "changed\n");
    app.active_mut().dirty = true;
    app.dispatch_ex("e! %");
    let content = app.active_editor().buffer().content_joined();
    assert_eq!(
        content.trim_end(),
        "hello",
        "`:e! %` must expand `%` to the current file and reload it, got: {content:?}"
    );
    assert!(
        !content.contains("changed"),
        "reload must replace the dirty buffer, got: {content:?}"
    );
}

// ── :DiffOrig (#208) ──────────────────────────────────────────────────────

#[test]
fn diff_orig_opens_readonly_diff_split_with_hunks() {
    let path = std::env::temp_dir().join("hjkl_diff_orig_hunks.txt");
    std::fs::write(&path, "alpha\nbeta\ngamma\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    let original_win = app.focused_window();

    // Edit the buffer in memory only (do not save).
    seed_buffer(&mut app, "alpha\nBETA\ngamma\n");
    app.active_mut().dirty = true;

    app.dispatch_ex("DiffOrig");

    // A new split opened and is focused.
    assert_eq!(
        app.windows.iter().filter(|w| w.is_some()).count(),
        2,
        "expected 2 open windows after :DiffOrig"
    );
    let new_win = app.focused_window();
    assert_ne!(new_win, original_win, ":DiffOrig must focus the new split");

    let new_slot = app.windows[new_win].as_ref().unwrap().slot;
    let slot = &app.slots[new_slot];
    assert!(
        slot.filename.is_none(),
        "diff buffer must be an unnamed scratch slot"
    );
    assert_eq!(
        slot.settings().filetype,
        "diff",
        "diff buffer filetype must be `diff`"
    );
    assert!(!slot.is_modifiable(), "diff buffer must be read-only");

    let content = slot.buffer().rope().to_string();
    assert!(
        content.contains("@@"),
        "diff must contain a hunk header: {content}"
    );
    assert!(
        content.contains("-beta"),
        "diff must show removed line: {content}"
    );
    assert!(
        content.contains("+BETA"),
        "diff must show added line: {content}"
    );

    let _ = std::fs::remove_file(&path);
}

#[test]
fn diff_orig_no_changes_shows_no_difference_message() {
    let path = std::env::temp_dir().join("hjkl_diff_orig_clean.txt");
    std::fs::write(&path, "same\ncontent\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    // View matches disk exactly.
    app.dispatch_ex("DiffOrig");

    let new_win = app.focused_window();
    let new_slot = app.windows[new_win].as_ref().unwrap().slot;
    let content = app.slots[new_slot].buffer().rope().to_string();
    assert!(
        content.contains("no differences"),
        "clean buffer must report no differences: {content}"
    );
    assert!(
        !content.contains("@@"),
        "clean buffer must not produce a hunk: {content}"
    );

    let _ = std::fs::remove_file(&path);
}

#[test]
fn diff_orig_without_filename_errors_and_opens_no_split() {
    let mut app = App::new(None, false, None, None).unwrap();
    let before = app.windows.iter().filter(|w| w.is_some()).count();

    app.dispatch_ex("DiffOrig");

    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E32"), "expected E32 no-file-name, got: {msg}");
    assert_eq!(
        app.windows.iter().filter(|w| w.is_some()).count(),
        before,
        ":DiffOrig on an unnamed buffer must not open a split"
    );
}

#[test]
fn diff_orig_file_deleted_on_disk_shows_all_additions() {
    let path = std::env::temp_dir().join("hjkl_diff_orig_deleted.txt");
    std::fs::write(&path, "placeholder\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();

    // View has content, but the file vanishes from disk (old side empty).
    seed_buffer(&mut app, "line one\nline two\n");
    app.active_mut().dirty = true;
    std::fs::remove_file(&path).unwrap();

    app.dispatch_ex("DiffOrig");

    let new_win = app.focused_window();
    let new_slot = app.windows[new_win].as_ref().unwrap().slot;
    let content = app.slots[new_slot].buffer().rope().to_string();
    assert!(
        content.contains("+line one"),
        "expected added line: {content}"
    );
    assert!(
        content.contains("+line two"),
        "expected added line: {content}"
    );
    assert!(
        !content.contains("no differences"),
        "deleted-on-disk buffer must produce a real diff: {content}"
    );
}

#[test]
fn diff_orig_leaves_original_buffer_untouched() {
    let path = std::env::temp_dir().join("hjkl_diff_orig_untouched.txt");
    std::fs::write(&path, "disk\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    let original_win = app.focused_window();
    let original_slot = app.windows[original_win].as_ref().unwrap().slot;

    seed_buffer(&mut app, "edited\n");
    app.active_mut().dirty = true;

    app.dispatch_ex("DiffOrig");

    // The source buffer keeps its (still-unsaved) content and stays modifiable.
    let src = &app.slots[original_slot];
    assert_eq!(
        src.buffer().rope().to_string(),
        "edited\n",
        "source buffer content must be unchanged by :DiffOrig"
    );
    assert!(src.is_modifiable(), "source buffer must remain modifiable");
    assert_eq!(
        src.filename.as_deref(),
        Some(path.as_path()),
        "source buffer must keep its filename"
    );

    let _ = std::fs::remove_file(&path);
}

// ── Dirty-buffer disk-change prompt (#241) ────────────────────────────────

/// Set up a focused, dirty buffer whose file then changes on disk, and drive
/// the fs-watch path so the keep/reload/diff prompt is raised. Returns the app
/// and the file path (caller removes it).
fn raise_disk_change_prompt(tag: &str) -> (App, std::path::PathBuf) {
    let path = std::env::temp_dir().join(format!("hjkl_dc_{tag}.txt"));
    std::fs::write(&path, "disk one\n").unwrap();
    let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
    seed_buffer(&mut app, "buffer edit\n");
    app.active_mut().dirty = true;

    write_and_wait(&path, "disk changed\n");
    let repaint = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(path.clone())]);
    assert!(repaint, "raising the prompt must request a repaint");
    assert!(
        app.pending_disk_change.is_some(),
        "dirty focused buffer must raise the disk-change prompt"
    );
    (app, path)
}

#[test]
fn disk_change_prompt_raised_and_buffer_intact() {
    let (app, path) = raise_disk_change_prompt("raise");
    // View content untouched while the prompt is up.
    assert_eq!(
        app.active_editor().buffer().rope().to_string(),
        "buffer edit\n"
    );
    let _ = std::fs::remove_file(&path);
}

#[test]
fn disk_change_prompt_keep_preserves_buffer() {
    let (mut app, path) = raise_disk_change_prompt("keep");
    app.handle_disk_change_key(key(KeyCode::Char('k')));

    assert!(
        app.pending_disk_change.is_none(),
        "keep dismisses the prompt"
    );
    let content = app.active_editor().buffer().rope().to_string();
    assert!(
        content.contains("buffer edit"),
        "keep must preserve buffer: {content}"
    );
    // Still flagged changed so the :w guard stays armed.
    assert_eq!(app.active().disk_state, DiskState::ChangedOnDisk);
    let _ = std::fs::remove_file(&path);
}

#[test]
fn disk_change_prompt_reload_discards_changes() {
    let (mut app, path) = raise_disk_change_prompt("reload");
    app.handle_disk_change_key(key(KeyCode::Char('r')));

    assert!(
        app.pending_disk_change.is_none(),
        "reload dismisses the prompt"
    );
    let content = app.active_editor().buffer().rope().to_string();
    assert!(
        content.contains("disk changed"),
        "reload must load disk content: {content}"
    );
    assert!(
        !content.contains("buffer edit"),
        "reload must discard buffer edits: {content}"
    );
    assert!(!app.active().dirty, "reloaded buffer must be clean");
    assert_eq!(app.active().disk_state, DiskState::Synced);
    let _ = std::fs::remove_file(&path);
}

#[test]
fn disk_change_prompt_diff_opens_readonly_split() {
    let (mut app, path) = raise_disk_change_prompt("diff");
    let before = app.windows.iter().filter(|w| w.is_some()).count();
    app.handle_disk_change_key(key(KeyCode::Char('d')));

    assert!(
        app.pending_disk_change.is_none(),
        "diff dismisses the prompt"
    );
    assert_eq!(
        app.windows.iter().filter(|w| w.is_some()).count(),
        before + 1,
        "diff must open a new split"
    );
    let win = app.focused_window();
    let slot = app.windows[win].as_ref().unwrap().slot;
    assert_eq!(app.slots[slot].settings().filetype, "diff");
    assert!(!app.slots[slot].is_modifiable());
    let _ = std::fs::remove_file(&path);
}

#[test]
fn disk_change_prompt_esc_keeps_buffer() {
    let (mut app, path) = raise_disk_change_prompt("esc");
    app.handle_disk_change_key(key(KeyCode::Esc));
    assert!(
        app.pending_disk_change.is_none(),
        "Esc dismisses the prompt"
    );
    assert!(
        app.active_editor()
            .buffer()
            .rope()
            .to_string()
            .contains("buffer edit"),
        "Esc must keep the buffer"
    );
    let _ = std::fs::remove_file(&path);
}

#[test]
fn disk_change_no_prompt_for_background_dirty_slot() {
    // Two files: `a` is edited+dirty, then we focus `b`. A disk change to the
    // background `a` must only warn (no interactive prompt — you can't answer
    // it for an unfocused buffer).
    let a = std::env::temp_dir().join("hjkl_dc_bg_a.txt");
    let b = std::env::temp_dir().join("hjkl_dc_bg_b.txt");
    std::fs::write(&a, "a one\n").unwrap();
    std::fs::write(&b, "b one\n").unwrap();
    let mut app = App::new(Some(a.clone()), false, None, None).unwrap();
    seed_buffer(&mut app, "a edited\n");
    app.active_mut().dirty = true;
    // Open + focus b.
    app.dispatch_ex(&format!("e {}", b.display()));
    assert_ne!(
        app.active().filename.as_deref(),
        Some(a.as_path()),
        "b must be focused"
    );

    write_and_wait(&a, "a changed\n");
    app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(a.clone())]);

    assert!(
        app.pending_disk_change.is_none(),
        "a background dirty slot must not raise the interactive prompt"
    );
    let _ = std::fs::remove_file(&a);
    let _ = std::fs::remove_file(&b);
}
