//! FSM-driving tests for the vim FSM via `hjkl_vim_tui::handle_key`.
//! Relocated from `hjkl-vim/tests/vim_fsm.rs` as part of #162 phase 3
//! (dropped `hjkl-vim`'s `crossterm` feature gate).

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use hjkl_engine::rope_util::rope_to_lines_vec;
use hjkl_engine::{Editor, Host, VimMode};
use hjkl_vim::VimEditorExt;

fn run_keys<H: hjkl_engine::types::Host>(e: &mut Editor<hjkl_buffer::View, H>, keys: &str) {
    // Minimal notation:
    //   <Esc> <CR> <BS> <Left/Right/Up/Down> <C-x>
    //   anything else = single char
    let mut iter = keys.chars();
    while let Some(c) = iter.next() {
        if c == '<' {
            let mut tag = String::new();
            for ch in iter.by_ref() {
                if ch == '>' {
                    break;
                }
                tag.push(ch);
            }
            let ev = match tag.as_str() {
                "Esc" => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
                "CR" => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
                "BS" => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
                "Space" => KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
                "Up" => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
                "Down" => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
                "Left" => KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
                "Right" => KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
                // Vim-style literal `<` escape so tests can type
                // the outdent operator without colliding with the
                // `<tag>` notation this helper uses for special keys.
                "lt" => KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE),
                s if s.starts_with("C-") => {
                    let ch = s.chars().nth(2).unwrap();
                    KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
                }
                _ => continue,
            };
            hjkl_vim_tui::handle_key(e, ev);
        } else {
            let mods = if c.is_uppercase() {
                KeyModifiers::SHIFT
            } else {
                KeyModifiers::NONE
            };
            hjkl_vim_tui::handle_key(e, KeyEvent::new(KeyCode::Char(c), mods));
        }
    }
}

fn editor_with(content: &str) -> Editor {
    // Tests historically assume shiftwidth=2 (sqeel-derived). The 0.1.0
    // SPEC default is shiftwidth=8 (vim-faithful). Keep these tests on
    // the legacy 2-space rhythm so the indent/outdent assertions don't
    // churn.
    let opts = hjkl_engine::Options {
        shiftwidth: 2,
        ..hjkl_engine::Options::default()
    };
    let mut e = hjkl_vim::vim_editor(
        hjkl_buffer::View::new(),
        hjkl_engine::DefaultHost::new(),
        opts,
    );
    e.set_content(content);
    e
}

#[test]
fn f_char_jumps_on_line() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "fw");
    assert_eq!(e.cursor(), (0, 6));
}

#[test]
fn cap_f_jumps_backward() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 10);
    run_keys(&mut e, "Fo");
    assert_eq!(e.cursor().1, 7);
}

#[test]
fn t_stops_before_char() {
    let mut e = editor_with("hello");
    run_keys(&mut e, "tl");
    assert_eq!(e.cursor(), (0, 1));
}

#[test]
fn semicolon_repeats_find() {
    let mut e = editor_with("aa.bb.cc");
    run_keys(&mut e, "f.");
    assert_eq!(e.cursor().1, 2);
    run_keys(&mut e, ";");
    assert_eq!(e.cursor().1, 5);
}

#[test]
fn comma_repeats_find_reverse() {
    let mut e = editor_with("aa.bb.cc");
    run_keys(&mut e, "f.");
    run_keys(&mut e, ";");
    run_keys(&mut e, ",");
    assert_eq!(e.cursor().1, 2);
}

#[test]
fn di_quote_deletes_content() {
    let mut e = editor_with("foo \"bar\" baz");
    e.jump_cursor(0, 6); // inside quotes
    run_keys(&mut e, "di\"");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "foo \"\" baz"
    );
}

#[test]
fn da_quote_deletes_with_quotes() {
    // `da"` eats the trailing space after the closing quote so the
    // result matches vim's "around" text-object whitespace rule.
    let mut e = editor_with("foo \"bar\" baz");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "da\"");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "foo baz");
}

#[test]
fn ci_paren_deletes_and_inserts() {
    let mut e = editor_with("fn(a, b, c)");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "ci(");
    assert_eq!(e.vim_mode(), VimMode::Insert);
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "fn()");
}

#[test]
fn diw_deletes_inner_word() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "diw");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), " world");
}

#[test]
fn daw_deletes_word_with_trailing_space() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "daw");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "world");
}

#[test]
fn percent_jumps_to_matching_bracket() {
    let mut e = editor_with("foo(bar)");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "%");
    assert_eq!(e.cursor().1, 7);
    run_keys(&mut e, "%");
    assert_eq!(e.cursor().1, 3);
}

#[test]
fn space_moves_right_like_l() {
    // `<Space>` is vim's right-motion (#197 follow-up: missing vim-compat motion).
    let mut e = editor_with("hello");
    run_keys(&mut e, " ");
    assert_eq!(e.cursor().1, 1, "<Space> should move right one column");
    run_keys(&mut e, "  ");
    assert_eq!(e.cursor().1, 3, "repeated <Space> advances");
}

#[test]
fn space_count_moves_right() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "3 ");
    assert_eq!(e.cursor().1, 3, "[count]<Space> moves right count cols");
}

#[test]
fn space_wraps_at_eol() {
    // nvim default `whichwrap=b,s` → `<Space>` wraps to the next line at EOL.
    let mut e = editor_with("ab\ncd");
    run_keys(&mut e, "l "); // at 'b' (col 1, last char), space wraps to row 1
    assert_eq!(e.cursor(), (1, 0), "<Space> wraps to next line at EOL");
}

#[test]
fn backspace_wraps_at_bol() {
    // nvim default `whichwrap=b,s` → `<BS>` wraps to the prev line's last char.
    let mut e = editor_with("ab\ncd");
    run_keys(&mut e, "j"); // row 1, col 0 ('c')
    run_keys(&mut e, "<BS>");
    assert_eq!(
        e.cursor(),
        (0, 1),
        "<BS> wraps to prev line's last char at BOL"
    );
}

#[test]
fn ctrl_h_moves_left_like_bs() {
    // Engine treats <C-h> as <BS> (wrapping left), vim-faithful. The hjkl app
    // rebinds <C-h> to window focus before the engine sees it, but engine
    // consumers without that override get the motion.
    let mut e = editor_with("hello");
    run_keys(&mut e, "ll"); // col 2
    run_keys(&mut e, "<C-h>");
    assert_eq!(e.cursor().1, 1, "<C-h> moves left like <BS>");
    let mut e2 = editor_with("ab\ncd");
    run_keys(&mut e2, "j<C-h>");
    assert_eq!(e2.cursor(), (0, 1), "<C-h> wraps to prev line at BOL");
}

#[test]
fn gm_screen_middle() {
    // `gm` → middle of the screen line (viewport_width/2, clamped to EOL).
    // editor_with uses the default 80-col host, so gm = min(40, last_col).
    let mut e = editor_with("hello");
    run_keys(&mut e, "gm");
    assert_eq!(e.cursor().1, 4, "gm clamps to last char on a short line");
    let mut e2 = editor_with("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz");
    run_keys(&mut e2, "gm");
    assert_eq!(e2.cursor().1, 40, "gm lands at width/2 on a long line");
}

#[test]
fn delete_space_deletes_char_right() {
    // `d<Space>` deletes the char under the cursor (charwise, like `dl`/`x`).
    let mut e = editor_with("hello");
    run_keys(&mut e, "d ");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "ello",
        "d<Space> should delete one char right"
    );
}

#[test]
fn dot_repeats_last_change() {
    let mut e = editor_with("aaa bbb ccc");
    run_keys(&mut e, "dw");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "bbb ccc");
    run_keys(&mut e, ".");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "ccc");
}

#[test]
fn dot_repeats_change_operator_with_text() {
    let mut e = editor_with("foo foo foo");
    run_keys(&mut e, "cwbar<Esc>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "bar foo foo"
    );
    // Move past the space.
    run_keys(&mut e, "w");
    run_keys(&mut e, ".");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "bar bar foo"
    );
}

#[test]
fn dot_repeats_x() {
    let mut e = editor_with("abcdef");
    run_keys(&mut e, "x");
    run_keys(&mut e, "..");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "def");
}

#[test]
fn cw_on_whitespace_acts_like_dw() {
    // Cursor on whitespace: `cw` changes just the whitespace run (like `dw`),
    // not through the following word (which `ce` would do).
    let mut e = editor_with("foo   bar");
    run_keys(&mut e, "3l"); // move onto the first space (col 3)
    run_keys(&mut e, "cwX<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "fooXbar");
}

#[test]
fn daw_on_whitespace_eats_following_word() {
    // `aw` on whitespace = the whitespace run plus the following word.
    let mut e = editor_with("foo   bar");
    run_keys(&mut e, "3l");
    run_keys(&mut e, "daw");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "foo");
}

#[test]
fn till_repeat_semicolon_skips_adjacent() {
    // `t,` sticks one before the first comma; `;` advances past it to just
    // before the second comma.
    let mut e = editor_with("a,b,c");
    run_keys(&mut e, "t,");
    assert_eq!(e.cursor(), (0, 0));
    run_keys(&mut e, ";");
    assert_eq!(e.cursor(), (0, 2));
}

#[test]
fn d2iw_deletes_word_and_following_space() {
    // `2iw` = two inner-word runs: the word plus the following whitespace run.
    let mut e = editor_with("foo bar baz");
    run_keys(&mut e, "d2iw");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "bar baz");
}

#[test]
fn d2aw_deletes_two_words() {
    // `2aw` = two words plus their trailing whitespace.
    let mut e = editor_with("foo bar baz");
    run_keys(&mut e, "d2aw");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "baz");
}

#[test]
fn d2ip_spans_paragraph_and_gap() {
    // `2ip` = the paragraph plus the following blank-line block.
    let mut e = editor_with("a\nb\n\nc\nd");
    run_keys(&mut e, "d2ip");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "c");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "d");
}

#[test]
fn dw_on_last_word_keeps_newline() {
    // vim `:h word` special case: `dw` on the last word of a line stops at
    // end-of-line, it does not eat the newline and join the next line.
    let mut e = editor_with("foo bar\nbaz");
    run_keys(&mut e, "wdw");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "foo ");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "baz");
}

#[test]
fn d2w_into_midline_word_not_clamped() {
    // The special case only applies when the LAST word moved over ends its
    // line. `d2w` whose last word is mid-line on a later row still spans it.
    let mut e = editor_with("foo\nbar baz");
    run_keys(&mut e, "d2w");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "baz");
}

#[test]
fn dot_repeat_count_replaces_not_multiplies() {
    // vim `[count].` replaces the stored count (`:h .`), it does not multiply.
    let mut e = editor_with("abcdefghij");
    run_keys(&mut e, "3x");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "defghij");
    run_keys(&mut e, "2.");
    // 2 more chars deleted, not 3*2=6.
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "fghij");
}

#[test]
fn dot_repeat_plain_reuses_stored_count() {
    // A plain `.` reuses the change's original count (3), not 1.
    let mut e = editor_with("abcdefghij");
    run_keys(&mut e, "3x");
    run_keys(&mut e, ".");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "ghij");
}

#[test]
fn count_operator_motion_compose() {
    let mut e = editor_with("one two three four five");
    run_keys(&mut e, "d3w");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "four five"
    );
}

#[test]
fn two_dd_deletes_two_lines() {
    let mut e = editor_with("a\nb\nc");
    run_keys(&mut e, "2dd");
    assert_eq!(e.buffer().row_count(), 1);
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "c");
}

/// Vim's `dd` leaves the cursor on the first non-blank of the line
/// that now sits at the deleted row — not at the end of the
/// previous line, which is where tui-textarea's raw cut would
/// park it.
#[test]
fn dd_in_middle_puts_cursor_on_first_non_blank_of_next() {
    let mut e = editor_with("one\ntwo\n    three\nfour");
    e.jump_cursor(1, 2);
    run_keys(&mut e, "dd");
    // View: ["one", "    three", "four"]
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1),
        "    three"
    );
    assert_eq!(e.cursor(), (1, 4));
}

#[test]
fn dd_on_last_line_puts_cursor_on_first_non_blank_of_prev() {
    let mut e = editor_with("one\n  two\nthree");
    e.jump_cursor(2, 0);
    run_keys(&mut e, "dd");
    // View: ["one", "  two"]
    assert_eq!(e.buffer().row_count(), 2);
    assert_eq!(e.cursor(), (1, 2));
}

#[test]
fn dd_on_only_line_leaves_empty_buffer_and_cursor_at_zero() {
    let mut e = editor_with("lonely");
    run_keys(&mut e, "dd");
    assert_eq!(e.buffer().row_count(), 1);
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "");
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn count_dd_puts_cursor_on_first_non_blank_of_remaining() {
    let mut e = editor_with("a\nb\nc\n   d\ne");
    // Cursor on row 1, "3dd" deletes b/c/   d → lines become [a, e].
    e.jump_cursor(1, 0);
    run_keys(&mut e, "3dd");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["a".to_string(), "e".to_string()]
    );
    assert_eq!(e.cursor(), (1, 0));
}

#[test]
fn dd_then_j_uses_first_non_blank_not_sticky_col() {
    // View: 3 lines with predictable widths.
    // Line 0: "    line one"   (12 chars, first-non-blank at col 4)
    // Line 1: "    line two"   (12 chars, first-non-blank at col 4)
    // Line 2: "  xy"           (4 chars, indices 0-3; last char at col 3)
    //
    // Cursor starts at col 8 on line 0.  After `dd`:
    //   - line 0 is deleted; cursor lands on first-non-blank of new line 0
    //     ("    line two") → col 4.
    //   - sticky_col must be updated to 4.
    //
    // Then `j` moves to "  xy" (4 chars, max col = 3).
    //   - With the fix   : sticky_col=4 → clamps to col 3 (last char).
    //   - Without the fix: sticky_col=8 → clamps to col 3 (same clamp).
    //
    // To make the two cases distinguishable we choose line 2 with
    // exactly 6 chars ("  xyz!") so max col = 5:
    //   - fix   : sticky_col=4 → lands at col 4.
    //   - no fix: sticky_col=8 → clamps to col 5.
    let mut e = editor_with("    line one\n    line two\n  xyz!");
    // Move to col 8 on line 0.
    e.jump_cursor(0, 8);
    assert_eq!(e.cursor(), (0, 8));
    // `dd` deletes line 0; cursor should land on first-non-blank of
    // the new line 0 ("    line two" → col 4).
    run_keys(&mut e, "dd");
    assert_eq!(
        e.cursor(),
        (0, 4),
        "dd must place cursor on first-non-blank"
    );
    // `j` moves to "  xyz!" (6 chars, cols 0-5).
    // Bug: stale sticky_col=8 clamps to col 5 (last char).
    // Fixed: sticky_col=4 → lands at col 4.
    run_keys(&mut e, "j");
    let (row, col) = e.cursor();
    assert_eq!(row, 1);
    assert_eq!(
        col, 4,
        "after dd, j should use the column dd established (4), not pre-dd sticky_col (8)"
    );
}

#[test]
fn gu_lowercases_motion_range() {
    let mut e = editor_with("HELLO WORLD");
    run_keys(&mut e, "guw");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "hello WORLD"
    );
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn g_u_uppercases_text_object() {
    let mut e = editor_with("hello world");
    // gUiw uppercases the word at the cursor.
    run_keys(&mut e, "gUiw");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "HELLO world"
    );
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn g_tilde_toggles_case_of_range() {
    let mut e = editor_with("Hello World");
    run_keys(&mut e, "g~iw");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "hELLO World"
    );
}

#[test]
fn g_uu_uppercases_current_line() {
    let mut e = editor_with("select 1\nselect 2");
    run_keys(&mut e, "gUU");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "SELECT 1"
    );
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1),
        "select 2"
    );
}

#[test]
fn gugu_lowercases_current_line() {
    let mut e = editor_with("FOO BAR\nBAZ");
    run_keys(&mut e, "gugu");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "foo bar");
}

#[test]
fn visual_u_uppercases_selection() {
    let mut e = editor_with("hello world");
    // v + e selects "hello" (inclusive of last char), U uppercases.
    run_keys(&mut e, "veU");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "HELLO world"
    );
}

#[test]
fn visual_line_u_lowercases_line() {
    let mut e = editor_with("HELLO WORLD\nOTHER");
    run_keys(&mut e, "Vu");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "hello world"
    );
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "OTHER");
}

#[test]
fn g_uu_with_count_uppercases_multiple_lines() {
    let mut e = editor_with("one\ntwo\nthree\nfour");
    // `3gUU` uppercases 3 lines starting from the cursor.
    run_keys(&mut e, "3gUU");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "ONE");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "TWO");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 2), "THREE");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 3), "four");
}

#[test]
fn double_gt_indents_current_line() {
    let mut e = editor_with("hello");
    run_keys(&mut e, ">>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "  hello");
    // Cursor lands on first non-blank.
    assert_eq!(e.cursor(), (0, 2));
}

#[test]
fn double_lt_outdents_current_line() {
    let mut e = editor_with("    hello");
    run_keys(&mut e, "<lt><lt>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "  hello");
    assert_eq!(e.cursor(), (0, 2));
}

#[test]
fn count_double_gt_indents_multiple_lines() {
    let mut e = editor_with("a\nb\nc\nd");
    // `3>>` indents 3 lines starting at cursor.
    run_keys(&mut e, "3>>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "  a");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "  b");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 2), "  c");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 3), "d");
}

#[test]
fn outdent_clips_ragged_leading_whitespace() {
    // Only one space of indent — outdent should strip what's
    // there, not leave anything negative.
    let mut e = editor_with(" x");
    run_keys(&mut e, "<lt><lt>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "x");
}

#[test]
fn indent_motion_is_always_linewise() {
    // `>w` indents the current line (linewise) — it doesn't
    // insert spaces into the middle of the word.
    let mut e = editor_with("foo bar");
    run_keys(&mut e, ">w");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "  foo bar"
    );
}

#[test]
fn indent_text_object_extends_over_paragraph() {
    let mut e = editor_with("a\nb\n\nc\nd");
    // `>ap` indents the whole paragraph (rows 0..=1).
    run_keys(&mut e, ">ap");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "  a");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "  b");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 2), "");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 3), "c");
}

#[test]
fn visual_line_indent_shifts_selected_rows() {
    let mut e = editor_with("x\ny\nz");
    // Vj selects rows 0..=1 linewise; `>` indents.
    run_keys(&mut e, "Vj>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "  x");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "  y");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 2), "z");
}

#[test]
fn outdent_empty_line_is_noop() {
    let mut e = editor_with("\nfoo");
    run_keys(&mut e, "<lt><lt>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "");
}

#[test]
fn indent_skips_empty_lines() {
    // Vim convention: `>>` on an empty line doesn't pad it with
    // trailing whitespace.
    let mut e = editor_with("");
    run_keys(&mut e, ">>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "");
}

#[test]
fn insert_ctrl_t_indents_current_line() {
    let mut e = editor_with("x");
    // Enter insert, Ctrl-t indents the line; cursor advances too.
    run_keys(&mut e, "i<C-t>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "  x");
    // After insert-mode start `i` cursor was at (0, 0); Ctrl-t
    // shifts it by SHIFTWIDTH=2.
    assert_eq!(e.cursor(), (0, 2));
}

#[test]
fn insert_ctrl_d_outdents_current_line() {
    let mut e = editor_with("    x");
    // Enter insert-at-end `A`, Ctrl-d outdents by shiftwidth.
    run_keys(&mut e, "A<C-d>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "  x");
}

#[test]
fn h_at_col_zero_does_not_wrap_to_prev_line() {
    let mut e = editor_with("first\nsecond");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "h");
    // Cursor must stay on row 1 col 0 — vim default doesn't wrap.
    assert_eq!(e.cursor(), (1, 0));
}

#[test]
fn l_at_last_char_does_not_wrap_to_next_line() {
    let mut e = editor_with("ab\ncd");
    // Move to last char of row 0 (col 1).
    e.jump_cursor(0, 1);
    run_keys(&mut e, "l");
    // Cursor stays on last char — no wrap.
    assert_eq!(e.cursor(), (0, 1));
}

#[test]
fn count_l_clamps_at_line_end() {
    let mut e = editor_with("abcde");
    // 20l starting at col 0 should land on last char (col 4),
    // not overflow / wrap.
    run_keys(&mut e, "20l");
    assert_eq!(e.cursor(), (0, 4));
}

#[test]
fn count_h_clamps_at_col_zero() {
    let mut e = editor_with("abcde");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "20h");
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn dl_on_last_char_still_deletes_it() {
    // `dl` / `x`-equivalent at EOL must delete the last char —
    // operator motion allows endpoint past-last even though bare
    // `l` stops before.
    let mut e = editor_with("ab");
    e.jump_cursor(0, 1);
    run_keys(&mut e, "dl");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "a");
}

#[test]
fn case_op_preserves_yank_register() {
    let mut e = editor_with("target");
    run_keys(&mut e, "yy");
    let yank_before = e.yank().to_string();
    // gUU changes the line but must not clobber the yank register.
    run_keys(&mut e, "gUU");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "TARGET");
    assert_eq!(
        e.yank(),
        yank_before,
        "case ops must preserve the yank buffer"
    );
}

#[test]
fn dap_deletes_paragraph() {
    let mut e = editor_with("a\nb\n\nc\nd");
    run_keys(&mut e, "dap");
    assert_eq!(
        Some(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0).as_str()),
        Some("c")
    );
}

#[test]
fn dit_deletes_inner_tag_content() {
    let mut e = editor_with("<b>hello</b>");
    // Cursor on `e`.
    e.jump_cursor(0, 4);
    run_keys(&mut e, "dit");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "<b></b>");
}

#[test]
fn dat_deletes_around_tag() {
    let mut e = editor_with("hi <b>foo</b> bye");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "dat");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hi  bye");
}

#[test]
fn dit_picks_innermost_tag() {
    let mut e = editor_with("<a><b>x</b></a>");
    // Cursor on `x`.
    e.jump_cursor(0, 6);
    run_keys(&mut e, "dit");
    // Inner of <b> is removed; <a> wrapping stays.
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "<a><b></b></a>"
    );
}

#[test]
fn dat_innermost_tag_pair() {
    let mut e = editor_with("<a><b>x</b></a>");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "dat");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "<a></a>");
}

#[test]
fn dit_outside_any_tag_no_op() {
    let mut e = editor_with("plain text");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "dit");
    // No tag pair surrounds the cursor — buffer unchanged.
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "plain text"
    );
}

#[test]
fn cit_changes_inner_tag_content() {
    let mut e = editor_with("<b>hello</b>");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "citNEW<Esc>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "<b>NEW</b>"
    );
}

#[test]
fn cat_changes_around_tag() {
    let mut e = editor_with("hi <b>foo</b> bye");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "catBAR<Esc>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "hi BAR bye"
    );
}

#[test]
fn yit_yanks_inner_tag_content() {
    let mut e = editor_with("<b>hello</b>");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "yit");
    assert_eq!(
        e.with_registers(|r| r.read('"').unwrap().text.clone()),
        "hello"
    );
}

#[test]
fn yat_yanks_full_tag_pair() {
    let mut e = editor_with("hi <b>foo</b> bye");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "yat");
    assert_eq!(
        e.with_registers(|r| r.read('"').unwrap().text.clone()),
        "<b>foo</b>"
    );
}

#[test]
fn vit_visually_selects_inner_tag() {
    let mut e = editor_with("<b>hello</b>");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "vit");
    assert_eq!(e.vim_mode(), VimMode::Visual);
    run_keys(&mut e, "y");
    assert_eq!(
        e.with_registers(|r| r.read('"').unwrap().text.clone()),
        "hello"
    );
}

#[test]
fn vat_visually_selects_around_tag() {
    let mut e = editor_with("x<b>foo</b>y");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "vat");
    assert_eq!(e.vim_mode(), VimMode::Visual);
    run_keys(&mut e, "y");
    assert_eq!(
        e.with_registers(|r| r.read('"').unwrap().text.clone()),
        "<b>foo</b>"
    );
}

// ─── Text-object coverage (d operator, inner + around) ───────────

#[test]
#[allow(non_snake_case)]
fn diW_deletes_inner_big_word() {
    let mut e = editor_with("foo.bar baz");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "diW");
    // Big word treats `foo.bar` as one token.
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), " baz");
}

#[test]
#[allow(non_snake_case)]
fn daW_deletes_around_big_word() {
    let mut e = editor_with("foo.bar baz");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "daW");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "baz");
}

#[test]
fn di_double_quote_deletes_inside() {
    let mut e = editor_with("a \"hello\" b");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "di\"");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "a \"\" b"
    );
}

#[test]
fn da_double_quote_deletes_around() {
    // `da"` eats the trailing space — matches vim's around-whitespace rule.
    let mut e = editor_with("a \"hello\" b");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "da\"");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "a b");
}

#[test]
fn di_single_quote_deletes_inside() {
    let mut e = editor_with("x 'foo' y");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "di'");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "x '' y");
}

#[test]
fn da_single_quote_deletes_around() {
    // `da'` eats the trailing space — matches vim's around-whitespace rule.
    let mut e = editor_with("x 'foo' y");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "da'");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "x y");
}

#[test]
fn di_backtick_deletes_inside() {
    let mut e = editor_with("p `q` r");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "di`");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "p `` r");
}

#[test]
fn da_backtick_deletes_around() {
    // `da`` eats the trailing space — matches vim's around-whitespace rule.
    let mut e = editor_with("p `q` r");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "da`");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "p r");
}

#[test]
fn di_paren_deletes_inside() {
    let mut e = editor_with("f(arg)");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "di(");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "f()");
}

#[test]
fn di_paren_alias_b_works() {
    let mut e = editor_with("f(arg)");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "dib");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "f()");
}

#[test]
fn di_bracket_deletes_inside() {
    let mut e = editor_with("a[b,c]d");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "di[");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "a[]d");
}

#[test]
fn da_bracket_deletes_around() {
    let mut e = editor_with("a[b,c]d");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "da[");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "ad");
}

#[test]
fn di_brace_deletes_inside() {
    let mut e = editor_with("x{y}z");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "di{");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "x{}z");
}

#[test]
fn da_brace_deletes_around() {
    let mut e = editor_with("x{y}z");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "da{");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "xz");
}

#[test]
fn di_brace_alias_capital_b_works() {
    let mut e = editor_with("x{y}z");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "diB");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "x{}z");
}

#[test]
fn di_angle_deletes_inside() {
    let mut e = editor_with("p<q>r");
    e.jump_cursor(0, 2);
    // `<lt>` so run_keys doesn't treat `<` as the start of a special-key tag.
    run_keys(&mut e, "di<lt>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "p<>r");
}

#[test]
fn da_angle_deletes_around() {
    let mut e = editor_with("p<q>r");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "da<lt>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "pr");
}

#[test]
fn dip_deletes_inner_paragraph() {
    let mut e = editor_with("a\nb\nc\n\nd");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "dip");
    // Inner paragraph (rows 0..=2) drops; the trailing blank
    // separator + remaining paragraph stay.
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        vec!["".to_string(), "d".into()]
    );
}

// ─── Operator pipeline spot checks (non-tag text objects) ───────

#[test]
fn sentence_motion_close_paren_jumps_forward() {
    let mut e = editor_with("Alpha. Beta. Gamma.");
    e.jump_cursor(0, 0);
    run_keys(&mut e, ")");
    // Lands on the start of "Beta".
    assert_eq!(e.cursor(), (0, 7));
    run_keys(&mut e, ")");
    assert_eq!(e.cursor(), (0, 13));
}

#[test]
fn sentence_motion_open_paren_jumps_backward() {
    let mut e = editor_with("Alpha. Beta. Gamma.");
    e.jump_cursor(0, 13);
    run_keys(&mut e, "(");
    // Cursor was at start of "Gamma" (col 13); first `(` walks
    // back to the previous sentence's start.
    assert_eq!(e.cursor(), (0, 7));
    run_keys(&mut e, "(");
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn sentence_motion_count() {
    let mut e = editor_with("A. B. C. D.");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "3)");
    // 3 forward jumps land on "D".
    assert_eq!(e.cursor(), (0, 9));
}

#[test]
fn dis_deletes_inner_sentence() {
    let mut e = editor_with("First one. Second one. Third one.");
    e.jump_cursor(0, 13);
    run_keys(&mut e, "dis");
    // Removed "Second one." inclusive of its terminator.
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "First one.  Third one."
    );
}

#[test]
fn das_deletes_around_sentence_with_trailing_space() {
    let mut e = editor_with("Alpha. Beta. Gamma.");
    e.jump_cursor(0, 8);
    run_keys(&mut e, "das");
    // `as` swallows the trailing whitespace before the next
    // sentence — exactly one space here.
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "Alpha. Gamma."
    );
}

#[test]
fn dis_handles_double_terminator() {
    let mut e = editor_with("Wow!? Next.");
    e.jump_cursor(0, 1);
    run_keys(&mut e, "dis");
    // Run of `!?` collapses into one boundary; sentence body
    // including both terminators is removed.
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), " Next.");
}

#[test]
fn dis_first_sentence_from_cursor_at_zero() {
    let mut e = editor_with("Alpha. Beta.");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "dis");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), " Beta.");
}

#[test]
fn yis_yanks_inner_sentence() {
    let mut e = editor_with("Hello world. Bye.");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "yis");
    assert_eq!(
        e.with_registers(|r| r.read('"').unwrap().text.clone()),
        "Hello world."
    );
}

#[test]
fn vis_visually_selects_inner_sentence() {
    let mut e = editor_with("First. Second.");
    e.jump_cursor(0, 1);
    run_keys(&mut e, "vis");
    assert_eq!(e.vim_mode(), VimMode::Visual);
    run_keys(&mut e, "y");
    assert_eq!(
        e.with_registers(|r| r.read('"').unwrap().text.clone()),
        "First."
    );
}

#[test]
fn ciw_changes_inner_word() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 1);
    run_keys(&mut e, "ciwHEY<Esc>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "HEY world"
    );
}

#[test]
fn yiw_yanks_inner_word() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 1);
    run_keys(&mut e, "yiw");
    assert_eq!(
        e.with_registers(|r| r.read('"').unwrap().text.clone()),
        "hello"
    );
}

#[test]
fn viw_selects_inner_word() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "viw");
    assert_eq!(e.vim_mode(), VimMode::Visual);
    run_keys(&mut e, "y");
    assert_eq!(
        e.with_registers(|r| r.read('"').unwrap().text.clone()),
        "hello"
    );
}

#[test]
fn ci_paren_changes_inside() {
    let mut e = editor_with("f(old)");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "ci(NEW<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "f(NEW)");
}

#[test]
fn yi_double_quote_yanks_inside() {
    let mut e = editor_with("say \"hi there\" then");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "yi\"");
    assert_eq!(
        e.with_registers(|r| r.read('"').unwrap().text.clone()),
        "hi there"
    );
}

#[test]
fn vap_visual_selects_around_paragraph() {
    let mut e = editor_with("a\nb\n\nc");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "vap");
    assert_eq!(e.vim_mode(), VimMode::VisualLine);
    run_keys(&mut e, "y");
    // Linewise yank includes the paragraph rows + trailing blank.
    let text = e.with_registers(|r| r.read('"').unwrap().text.clone());
    assert!(text.starts_with("a\nb"));
}

#[test]
fn star_finds_next_occurrence() {
    let mut e = editor_with("foo bar foo baz");
    run_keys(&mut e, "*");
    assert_eq!(e.cursor().1, 8);
}

#[test]
fn star_skips_substring_match() {
    // `*` uses `\bfoo\b` so `foobar` is *not* a hit; cursor wraps
    // back to the original `foo` at col 0.
    let mut e = editor_with("foo foobar baz");
    run_keys(&mut e, "*");
    assert_eq!(e.cursor().1, 0);
}

#[test]
fn g_star_matches_substring() {
    // `g*` drops the boundary; from `foo` at col 0 the next hit is
    // inside `foobar` (col 4).
    let mut e = editor_with("foo foobar baz");
    run_keys(&mut e, "g*");
    assert_eq!(e.cursor().1, 4);
}

#[test]
fn g_pound_matches_substring_backward() {
    // Start on the last `foo`; `g#` walks backward and lands inside
    // `foobar` (col 4).
    let mut e = editor_with("foo foobar baz foo");
    run_keys(&mut e, "$b");
    assert_eq!(e.cursor().1, 15);
    run_keys(&mut e, "g#");
    assert_eq!(e.cursor().1, 4);
}

#[test]
fn n_repeats_last_search_forward() {
    let mut e = editor_with("foo bar foo baz foo");
    // `/foo<CR>` jumps past the cursor's current cell, so from
    // col 0 the first hit is the second `foo` at col 8.
    run_keys(&mut e, "/foo<CR>");
    assert_eq!(e.cursor().1, 8);
    run_keys(&mut e, "n");
    assert_eq!(e.cursor().1, 16);
}

#[test]
fn shift_n_reverses_search() {
    let mut e = editor_with("foo bar foo baz foo");
    run_keys(&mut e, "/foo<CR>");
    run_keys(&mut e, "n");
    assert_eq!(e.cursor().1, 16);
    run_keys(&mut e, "N");
    assert_eq!(e.cursor().1, 8);
}

#[test]
fn n_noop_without_pattern() {
    let mut e = editor_with("foo bar");
    run_keys(&mut e, "n");
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn visual_line_preserves_cursor_column() {
    // V should never drag the cursor off its natural column — the
    // highlight is painted as a post-render overlay instead.
    let mut e = editor_with("hello world\nanother one\nbye");
    run_keys(&mut e, "lllll"); // col 5
    run_keys(&mut e, "V");
    assert_eq!(e.vim_mode(), VimMode::VisualLine);
    assert_eq!(e.cursor(), (0, 5));
    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (1, 5));
}

#[test]
fn visual_line_yank_includes_trailing_newline() {
    let mut e = editor_with("aaa\nbbb\nccc");
    run_keys(&mut e, "Vjy");
    // Two lines yanked — must be `aaa\nbbb\n`, trailing newline preserved.
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("aaa\nbbb\n"));
}

#[test]
fn visual_line_yank_last_line_trailing_newline() {
    let mut e = editor_with("aaa\nbbb\nccc");
    // Move to the last line and yank with V (final buffer line).
    run_keys(&mut e, "jj");
    run_keys(&mut e, "Vy");
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("ccc\n"));
}

#[test]
fn yy_on_last_line_has_trailing_newline() {
    let mut e = editor_with("aaa\nbbb\nccc");
    run_keys(&mut e, "jj");
    run_keys(&mut e, "yy");
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("ccc\n"));
}

#[test]
fn yy_in_middle_has_trailing_newline() {
    let mut e = editor_with("aaa\nbbb\nccc");
    run_keys(&mut e, "j");
    run_keys(&mut e, "yy");
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("bbb\n"));
}

#[test]
fn di_single_quote() {
    let mut e = editor_with("say 'hello world' now");
    e.jump_cursor(0, 7);
    run_keys(&mut e, "di'");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "say '' now"
    );
}

#[test]
fn da_single_quote() {
    // `da'` eats the trailing space — matches vim's around-whitespace rule.
    let mut e = editor_with("say 'hello' now");
    e.jump_cursor(0, 7);
    run_keys(&mut e, "da'");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "say now");
}

#[test]
fn di_backtick() {
    let mut e = editor_with("say `hi` now");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "di`");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "say `` now"
    );
}

#[test]
fn di_brace() {
    let mut e = editor_with("fn { a; b; c }");
    e.jump_cursor(0, 7);
    run_keys(&mut e, "di{");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "fn {}");
}

#[test]
fn di_bracket() {
    let mut e = editor_with("arr[1, 2, 3]");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "di[");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "arr[]");
}

#[test]
fn dab_deletes_around_paren() {
    let mut e = editor_with("fn(a, b) + 1");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "dab");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "fn + 1");
}

#[test]
fn da_big_b_deletes_around_brace() {
    let mut e = editor_with("x = {a: 1}");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "daB");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "x = ");
}

#[test]
fn di_big_w_deletes_bigword() {
    let mut e = editor_with("foo-bar baz");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "diW");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), " baz");
}

#[test]
fn visual_select_inner_word() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "viw");
    assert_eq!(e.vim_mode(), VimMode::Visual);
    run_keys(&mut e, "y");
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("hello"));
}

#[test]
fn visual_select_inner_quote() {
    let mut e = editor_with("foo \"bar\" baz");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "vi\"");
    run_keys(&mut e, "y");
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("bar"));
}

#[test]
fn visual_select_inner_paren() {
    let mut e = editor_with("fn(a, b)");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "vi(");
    run_keys(&mut e, "y");
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("a, b"));
}

#[test]
fn visual_select_outer_brace() {
    let mut e = editor_with("{x}");
    e.jump_cursor(0, 1);
    run_keys(&mut e, "va{");
    run_keys(&mut e, "y");
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("{x}"));
}

#[test]
fn ci_paren_forward_scans_when_cursor_before_pair() {
    // targets.vim-style: cursor at start of `foo`, ci( jumps to next
    // `(...)` pair on the same line and replaces the contents.
    let mut e = editor_with("foo(bar)");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "ci(NEW<Esc>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "foo(NEW)"
    );
}

#[test]
fn ci_paren_forward_scans_across_lines() {
    let mut e = editor_with("first\nfoo(bar)\nlast");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "ci(NEW<Esc>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1),
        "foo(NEW)"
    );
}

#[test]
fn ci_brace_forward_scans_when_cursor_before_pair() {
    let mut e = editor_with("let x = {y};");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "ci{NEW<Esc>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "let x = {NEW};"
    );
}

#[test]
fn cit_forward_scans_when_cursor_before_tag() {
    // Cursor at column 0 (before `<b>`), cit jumps into the next tag
    // pair and replaces its contents.
    let mut e = editor_with("text <b>hello</b> rest");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "citNEW<Esc>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "text <b>NEW</b> rest"
    );
}

#[test]
fn dat_forward_scans_when_cursor_before_tag() {
    // dat = delete around tag — including the `<b>...</b>` markup.
    let mut e = editor_with("text <b>hello</b> rest");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "dat");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "text  rest"
    );
}

#[test]
fn ci_paren_still_works_when_cursor_inside() {
    // Regression: forward-scan fallback must not break the
    // canonical "cursor inside the pair" case.
    let mut e = editor_with("fn(a, b)");
    e.jump_cursor(0, 4);
    run_keys(&mut e, "ci(NEW<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "fn(NEW)");
}

#[test]
fn caw_changes_word_with_trailing_space() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "cawfoo<Esc>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "fooworld"
    );
}

#[test]
fn visual_char_yank_preserves_raw_text() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "vllly");
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("hell"));
}

#[test]
fn single_line_visual_line_selects_full_line_on_yank() {
    let mut e = editor_with("hello world\nbye");
    run_keys(&mut e, "V");
    // Yank the selection — should include the full line + trailing
    // newline (linewise yank convention).
    run_keys(&mut e, "y");
    assert_eq!(
        e.host_mut().read_clipboard().as_deref(),
        Some("hello world\n")
    );
}

#[test]
fn visual_line_extends_both_directions() {
    let mut e = editor_with("aaa\nbbb\nccc\nddd");
    run_keys(&mut e, "jjj"); // row 3, col 0
    run_keys(&mut e, "V");
    assert_eq!(e.cursor(), (3, 0));
    run_keys(&mut e, "k");
    // Cursor is free to sit on its natural column — no forced Jump.
    assert_eq!(e.cursor(), (2, 0));
    run_keys(&mut e, "k");
    assert_eq!(e.cursor(), (1, 0));
}

#[test]
fn visual_char_preserves_cursor_column() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "lllll"); // col 5
    run_keys(&mut e, "v");
    assert_eq!(e.cursor(), (0, 5));
    run_keys(&mut e, "ll");
    assert_eq!(e.cursor(), (0, 7));
}

#[test]
fn visual_char_highlight_bounds_order() {
    let mut e = editor_with("abcdef");
    run_keys(&mut e, "lll"); // col 3
    run_keys(&mut e, "v");
    run_keys(&mut e, "hh"); // col 1
    // Anchor (0, 3), cursor (0, 1). Bounds ordered: start=(0,1) end=(0,3).
    assert_eq!(e.char_highlight(), Some(((0, 1), (0, 3))));
}

#[test]
fn visual_line_highlight_bounds() {
    let mut e = editor_with("a\nb\nc");
    run_keys(&mut e, "V");
    assert_eq!(e.line_highlight(), Some((0, 0)));
    run_keys(&mut e, "j");
    assert_eq!(e.line_highlight(), Some((0, 1)));
    run_keys(&mut e, "j");
    assert_eq!(e.line_highlight(), Some((0, 2)));
}

// ─── Basic motions ─────────────────────────────────────────────────────

#[test]
fn h_moves_left() {
    let mut e = editor_with("hello");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "h");
    assert_eq!(e.cursor(), (0, 2));
}

#[test]
fn l_moves_right() {
    let mut e = editor_with("hello");
    run_keys(&mut e, "l");
    assert_eq!(e.cursor(), (0, 1));
}

#[test]
fn k_moves_up() {
    let mut e = editor_with("a\nb\nc");
    e.jump_cursor(2, 0);
    run_keys(&mut e, "k");
    assert_eq!(e.cursor(), (1, 0));
}

#[test]
fn zero_moves_to_line_start() {
    let mut e = editor_with("    hello");
    run_keys(&mut e, "$");
    run_keys(&mut e, "0");
    assert_eq!(e.cursor().1, 0);
}

#[test]
fn caret_moves_to_first_non_blank() {
    let mut e = editor_with("    hello");
    run_keys(&mut e, "0");
    run_keys(&mut e, "^");
    assert_eq!(e.cursor().1, 4);
}

#[test]
fn dollar_moves_to_last_char() {
    let mut e = editor_with("hello");
    run_keys(&mut e, "$");
    assert_eq!(e.cursor().1, 4);
}

#[test]
fn dollar_on_empty_line_stays_at_col_zero() {
    let mut e = editor_with("");
    run_keys(&mut e, "$");
    assert_eq!(e.cursor().1, 0);
}

#[test]
fn w_jumps_to_next_word() {
    let mut e = editor_with("foo bar baz");
    run_keys(&mut e, "w");
    assert_eq!(e.cursor().1, 4);
}

#[test]
fn b_jumps_back_a_word() {
    let mut e = editor_with("foo bar");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "b");
    assert_eq!(e.cursor().1, 4);
}

#[test]
fn e_jumps_to_word_end() {
    let mut e = editor_with("foo bar");
    run_keys(&mut e, "e");
    assert_eq!(e.cursor().1, 2);
}

// ─── Operators with line-edge and file-edge motions ───────────────────

#[test]
fn d_dollar_deletes_to_eol() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "d$");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hello");
}

#[test]
fn d_zero_deletes_to_line_start() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "d0");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "world");
}

#[test]
fn d_caret_deletes_to_first_non_blank() {
    let mut e = editor_with("    hello");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "d^");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "    llo");
}

#[test]
fn d_capital_g_deletes_to_end_of_file() {
    let mut e = editor_with("a\nb\nc\nd");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "dG");
    assert_eq!(rope_to_lines_vec(&e.buffer().rope()), &["a".to_string()]);
}

#[test]
fn d_gg_deletes_to_start_of_file() {
    let mut e = editor_with("a\nb\nc\nd");
    e.jump_cursor(2, 0);
    run_keys(&mut e, "dgg");
    assert_eq!(rope_to_lines_vec(&e.buffer().rope()), &["d".to_string()]);
}

#[test]
fn cw_is_ce_quirk() {
    // `cw` on a non-blank word must NOT eat the trailing whitespace;
    // it behaves like `ce` so the replacement lands before the space.
    let mut e = editor_with("foo bar");
    run_keys(&mut e, "cwxyz<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "xyz bar");
}

// ─── Single-char edits ────────────────────────────────────────────────

#[test]
fn big_d_deletes_to_eol() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "D");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hello");
}

#[test]
fn big_c_deletes_to_eol_and_inserts() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "C!<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hello!");
}

#[test]
fn j_joins_next_line_with_space() {
    let mut e = editor_with("hello\nworld");
    run_keys(&mut e, "J");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["hello world".to_string()]
    );
}

#[test]
fn j_strips_leading_whitespace_on_join() {
    let mut e = editor_with("hello\n    world");
    run_keys(&mut e, "J");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["hello world".to_string()]
    );
}

#[test]
fn big_x_deletes_char_before_cursor() {
    let mut e = editor_with("hello");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "X");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "helo");
}

#[test]
fn s_substitutes_char_and_enters_insert() {
    // Default is motion_sneak=true (#196) so `s` enters sneak. Disable
    // it for this test to exercise vim's stock substitute-char behaviour.
    let mut e = editor_with("hello");
    e.settings_mut().motion_sneak = false;
    run_keys(&mut e, "sX<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "Xello");
}

#[test]
fn count_x_deletes_many() {
    let mut e = editor_with("abcdef");
    run_keys(&mut e, "3x");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "def");
}

// ─── Paste ────────────────────────────────────────────────────────────

#[test]
fn p_pastes_charwise_after_cursor() {
    let mut e = editor_with("hello");
    run_keys(&mut e, "yw");
    run_keys(&mut e, "$p");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "hellohello"
    );
}

#[test]
fn capital_p_pastes_charwise_before_cursor() {
    let mut e = editor_with("hello");
    // Yank "he" (2 chars) then paste it before the cursor.
    run_keys(&mut e, "v");
    run_keys(&mut e, "l");
    run_keys(&mut e, "y");
    run_keys(&mut e, "$P");
    // After yank cursor is at 0; $ goes to end (col 4), P pastes
    // before cursor — "hell" + "he" + "o" = "hellheo".
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hellheo");
}

#[test]
fn p_pastes_linewise_below() {
    let mut e = editor_with("one\ntwo\nthree");
    run_keys(&mut e, "yy");
    run_keys(&mut e, "p");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &[
            "one".to_string(),
            "one".to_string(),
            "two".to_string(),
            "three".to_string()
        ]
    );
}

#[test]
fn capital_p_pastes_linewise_above() {
    let mut e = editor_with("one\ntwo");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "yy");
    run_keys(&mut e, "P");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["one".to_string(), "two".to_string(), "two".to_string()]
    );
}

// ─── Reverse word search ──────────────────────────────────────────────

#[test]
fn hash_finds_previous_occurrence() {
    let mut e = editor_with("foo bar foo baz foo");
    // Move to the third 'foo' then #.
    e.jump_cursor(0, 16);
    run_keys(&mut e, "#");
    assert_eq!(e.cursor().1, 8);
}

// ─── VisualLine delete / change ───────────────────────────────────────

#[test]
fn visual_line_delete_removes_full_lines() {
    let mut e = editor_with("a\nb\nc\nd");
    run_keys(&mut e, "Vjd");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["c".to_string(), "d".to_string()]
    );
}

#[test]
fn visual_line_change_leaves_blank_line() {
    let mut e = editor_with("a\nb\nc");
    run_keys(&mut e, "Vjc");
    assert_eq!(e.vim_mode(), VimMode::Insert);
    run_keys(&mut e, "X<Esc>");
    // `Vjc` wipes rows 0-1's contents and leaves a blank line in
    // their place (vim convention). Typing `X` lands on that blank
    // first line.
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["X".to_string(), "c".to_string()]
    );
}

#[test]
fn cc_leaves_blank_line() {
    let mut e = editor_with("a\nb\nc");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "ccX<Esc>");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["a".to_string(), "X".to_string(), "c".to_string()]
    );
}

#[test]
fn cc_preserves_indent_with_autoindent() {
    // autoindent defaults on → `cc` keeps the changed line's leading
    // whitespace and drops the cursor after it (vim parity).
    let mut e = editor_with("fn f() {\n    let x = 1;\n}");
    e.jump_cursor(1, 4);
    run_keys(&mut e, "ccbar<Esc>");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &[
            "fn f() {".to_string(),
            "    bar".to_string(),
            "}".to_string()
        ]
    );
}

/// Helper: collect rope lines (strip trailing `\n`) into a `Vec<String>`.
fn rope_lines(e: &Editor<hjkl_buffer::View, impl hjkl_engine::types::Host>) -> Vec<String> {
    rope_to_lines_vec(&e.buffer().rope())
}

#[test]
fn cj_preserves_indent_and_leaves_one_line() {
    // `cj` changes current line + line below (linewise). With autoindent on
    // the first changed line's indent (4 spaces) is preserved.
    // nvim --clean verified: ["fn f() {", "    bar", "}"]
    let mut e = editor_with("fn f() {\n    a;\n    b;\n}");
    e.jump_cursor(1, 4);
    run_keys(&mut e, "cjbar<Esc>");
    assert_eq!(
        rope_lines(&e),
        &[
            "fn f() {".to_string(),
            "    bar".to_string(),
            "}".to_string(),
        ]
    );
}

#[test]
fn ck_change_up_uses_top_line_indent() {
    // `ck` changes current line + line above (linewise). Cursor on line 2
    // (`    b;`); the top of the changed range is line 1 (`    a;`, 4-space
    // indent). With autoindent that indent is preserved.
    // nvim --clean verified (cursor(3,5) then `normal ckbar`):
    // ["fn f() {", "    bar", "}"]
    let mut e = editor_with("fn f() {\n    a;\n    b;\n}");
    e.jump_cursor(2, 4);
    run_keys(&mut e, "ckbar<Esc>");
    assert_eq!(
        rope_lines(&e),
        &[
            "fn f() {".to_string(),
            "    bar".to_string(),
            "}".to_string(),
        ]
    );
}

#[test]
fn visual_line_change_preserves_indent() {
    // `Vjc` selects lines 1-2 in visual-line mode then changes them.
    // With autoindent the first selected line's indent (4 spaces) is kept.
    // nvim --clean verified: ["fn f() {", "    bar", "}"]
    let mut e = editor_with("fn f() {\n    a;\n    b;\n}");
    e.jump_cursor(1, 4);
    run_keys(&mut e, "Vjcbar<Esc>");
    assert_eq!(
        rope_lines(&e),
        &[
            "fn f() {".to_string(),
            "    bar".to_string(),
            "}".to_string(),
        ]
    );
}

#[test]
fn cip_preserves_indent_on_indented_paragraph() {
    // `cip` on a paragraph that starts with indented lines should keep the
    // first paragraph line's leading whitespace (4 spaces) on the inserted
    // line. `top` and `bot` lines are intact.
    // nvim --clean verified: blank lines contain a single space (autoindent
    // artefact); the changed line is "    XXX".
    let mut e = editor_with("top\n\n    a;\n    b;\n\nbot");
    e.jump_cursor(2, 4);
    run_keys(&mut e, "cipXXX<Esc>");
    let lines = rope_lines(&e);
    // "top" and "bot" must be untouched.
    assert_eq!(lines[0], "top");
    assert_eq!(*lines.last().unwrap(), "bot");
    // The paragraph collapsed to one line carrying the original indent.
    let changed_idx = lines.iter().position(|l| l.trim() == "XXX").unwrap();
    assert_eq!(lines[changed_idx], "    XXX");
}

#[test]
fn cc_no_indent_when_autoindent_off() {
    // With autoindent disabled `cc` wipes the whole line (cursor at col 0).
    let mut e = editor_with("    a;");
    e.settings_mut().autoindent = false;
    e.jump_cursor(0, 4);
    run_keys(&mut e, "ccbar<Esc>");
    assert_eq!(rope_lines(&e), &["bar".to_string()]);
}

// ─── Scrolling ────────────────────────────────────────────────────────

// ─── WORD motions (W/B/E) ─────────────────────────────────────────────

#[test]
fn big_w_skips_hyphens() {
    // `w` stops at `-`; `W` treats the whole `foo-bar` as one WORD.
    let mut e = editor_with("foo-bar baz");
    run_keys(&mut e, "W");
    assert_eq!(e.cursor().1, 8);
}

#[test]
fn big_w_crosses_lines() {
    let mut e = editor_with("foo-bar\nbaz-qux");
    run_keys(&mut e, "W");
    assert_eq!(e.cursor(), (1, 0));
}

#[test]
fn big_b_skips_hyphens() {
    let mut e = editor_with("foo-bar baz");
    e.jump_cursor(0, 9);
    run_keys(&mut e, "B");
    assert_eq!(e.cursor().1, 8);
    run_keys(&mut e, "B");
    assert_eq!(e.cursor().1, 0);
}

#[test]
fn big_e_jumps_to_big_word_end() {
    let mut e = editor_with("foo-bar baz");
    run_keys(&mut e, "E");
    assert_eq!(e.cursor().1, 6);
    run_keys(&mut e, "E");
    assert_eq!(e.cursor().1, 10);
}

#[test]
fn dw_with_big_word_variant() {
    // `dW` uses the WORD motion, so `foo-bar` deletes as a unit.
    let mut e = editor_with("foo-bar baz");
    run_keys(&mut e, "dW");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "baz");
}

// ─── Insert-mode Ctrl shortcuts ──────────────────────────────────────

#[test]
fn insert_ctrl_w_deletes_word_back() {
    let mut e = editor_with("");
    run_keys(&mut e, "i");
    for c in "hello world".chars() {
        hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
    }
    run_keys(&mut e, "<C-w>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hello ");
}

#[test]
fn insert_ctrl_w_at_col0_joins_with_prev_word() {
    // B2 (round 2b): Ctrl-W at the start of a row joins to the previous
    // line (deletes just the newline) and STOPS — it does not also eat
    // the previous line's last word. Verified against real nvim v0.12.4
    // (default `backspace=indent,eol,start`):
    // `nvim --headless -u NONE hello_world.txt` + `feedkeys("ji\<C-w>\<Esc>", 'x')`
    // → "helloworld", cursor (0, 5) before the Esc step-back.
    let mut e = editor_with("hello\nworld");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "i");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
    );
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        vec!["helloworld".to_string()]
    );
    assert_eq!(e.cursor(), (0, 5));
}

#[test]
fn insert_ctrl_w_at_col0_keeps_prefix_words() {
    // B2 (round 2b): same bare-join behaviour with a multi-word previous
    // line — "bar" must survive. Verified against real nvim v0.12.4:
    // `nvim --headless -u NONE foo_bar_baz.txt` + `feedkeys("ji\<C-w>\<Esc>", 'x')`
    // → "foo barbaz", cursor (0, 7) before the Esc step-back.
    let mut e = editor_with("foo bar\nbaz");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "i");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
    );
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        vec!["foo barbaz".to_string()]
    );
    assert_eq!(e.cursor(), (0, 7));
}

#[test]
fn insert_ctrl_u_deletes_to_line_start() {
    let mut e = editor_with("");
    run_keys(&mut e, "i");
    for c in "hello world".chars() {
        hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
    }
    run_keys(&mut e, "<C-u>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "");
}

#[test]
fn insert_ctrl_o_runs_one_normal_command() {
    let mut e = editor_with("hello world");
    // Enter insert, then Ctrl-o dw (delete a word while in insert).
    run_keys(&mut e, "A");
    assert_eq!(e.vim_mode(), VimMode::Insert);
    // Move cursor back to start of "hello" for the Ctrl-o dw.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "<C-o>");
    assert_eq!(e.vim_mode(), VimMode::Normal);
    run_keys(&mut e, "dw");
    // After the command completes, back in insert.
    assert_eq!(e.vim_mode(), VimMode::Insert);
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "world");
}

// ─── Sticky column across vertical motion ────────────────────────────

#[test]
fn j_through_empty_line_preserves_column() {
    let mut e = editor_with("hello world\n\nanother line");
    // Park cursor at col 6 on row 0.
    run_keys(&mut e, "llllll");
    assert_eq!(e.cursor(), (0, 6));
    // j into the empty line — cursor clamps to (1, 0) visually, but
    // sticky col stays at 6.
    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (1, 0));
    // j onto a longer row — sticky col restores us to col 6.
    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (2, 6));
}

#[test]
fn j_through_shorter_line_preserves_column() {
    let mut e = editor_with("hello world\nhi\nanother line");
    run_keys(&mut e, "lllllll"); // col 7
    run_keys(&mut e, "j"); // short line — clamps to col 1
    assert_eq!(e.cursor(), (1, 1));
    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (2, 7));
}

#[test]
fn esc_from_insert_sticky_matches_visible_cursor() {
    // Cursor at col 12, I (moves to col 4), type "X" (col 5), Esc
    // backs to col 4 — sticky must mirror that visible col so j
    // lands at col 4 of the next row, not col 5 or col 12.
    let mut e = editor_with("    this is a line\n    another one of a similar size");
    e.jump_cursor(0, 12);
    run_keys(&mut e, "I");
    assert_eq!(e.cursor(), (0, 4));
    run_keys(&mut e, "X<Esc>");
    assert_eq!(e.cursor(), (0, 4));
    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (1, 4));
}

#[test]
fn esc_from_insert_sticky_tracks_inserted_chars() {
    let mut e = editor_with("xxxxxxx\nyyyyyyy");
    run_keys(&mut e, "i");
    run_keys(&mut e, "abc<Esc>");
    assert_eq!(e.cursor(), (0, 2));
    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (1, 2));
}

#[test]
fn esc_from_insert_sticky_tracks_arrow_nav() {
    let mut e = editor_with("xxxxxx\nyyyyyy");
    run_keys(&mut e, "i");
    run_keys(&mut e, "abc");
    for _ in 0..2 {
        hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
    }
    run_keys(&mut e, "<Esc>");
    assert_eq!(e.cursor(), (0, 0));
    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (1, 0));
}

#[test]
fn esc_from_insert_at_col_14_followed_by_j() {
    // User-reported regression: cursor at col 14, i, type "test "
    // (5 chars → col 19), Esc → col 18. j must land at col 18.
    let line = "x".repeat(30);
    let buf = format!("{line}\n{line}");
    let mut e = editor_with(&buf);
    e.jump_cursor(0, 14);
    run_keys(&mut e, "i");
    for c in "test ".chars() {
        hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
    }
    run_keys(&mut e, "<Esc>");
    assert_eq!(e.cursor(), (0, 18));
    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (1, 18));
}

#[test]
fn linewise_paste_resets_sticky_column() {
    // yy then p lands the cursor on the first non-blank of the
    // pasted line; the next j must not drag back to the old
    // sticky column.
    let mut e = editor_with("    hello\naaaaaaaa\nbye");
    run_keys(&mut e, "llllll"); // col 6, sticky = 6
    run_keys(&mut e, "yy");
    run_keys(&mut e, "j"); // into row 1 col 6
    run_keys(&mut e, "p"); // paste below row 1 — cursor on "    hello"
    // Cursor should be at (2, 4) — first non-blank of the pasted line.
    assert_eq!(e.cursor(), (2, 4));
    // j should then preserve col 4, not jump back to 6.
    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (3, 2));
}

#[test]
fn horizontal_motion_resyncs_sticky_column() {
    // Starting col 6 on row 0, go back to col 3, then down through
    // an empty row. The sticky col should be 3 (from the last `h`
    // sequence), not 6.
    let mut e = editor_with("hello world\n\nanother line");
    run_keys(&mut e, "llllll"); // col 6
    run_keys(&mut e, "hhh"); // col 3
    run_keys(&mut e, "jj");
    assert_eq!(e.cursor(), (2, 3));
}

// ─── Visual block ────────────────────────────────────────────────────

#[test]
fn ctrl_v_enters_visual_block() {
    let mut e = editor_with("aaa\nbbb\nccc");
    run_keys(&mut e, "<C-v>");
    assert_eq!(e.vim_mode(), VimMode::VisualBlock);
}

#[test]
fn visual_block_esc_returns_to_normal() {
    let mut e = editor_with("aaa\nbbb\nccc");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "<Esc>");
    assert_eq!(e.vim_mode(), VimMode::Normal);
}

#[test]
fn backtick_lt_jumps_to_visual_start_mark() {
    // `` `< `` jumps the cursor to the start of the last visual selection.
    // Regression: pre-0.5.7, handle_goto_mark didn't recognise `<` / `>`
    // as targets even though set_mark stored them correctly.
    let mut e = editor_with("foo bar baz\n");
    run_keys(&mut e, "v");
    run_keys(&mut e, "w"); // cursor advances to col 4
    run_keys(&mut e, "<Esc>"); // sets `<` = (0,0), `>` = (0,4)
    assert_eq!(e.cursor(), (0, 4));
    // `<lt>` is the helper's literal-`<` escape (see run_keys docstring).
    run_keys(&mut e, "`<lt>");
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn backtick_gt_jumps_to_visual_end_mark() {
    let mut e = editor_with("foo bar baz\n");
    run_keys(&mut e, "v");
    run_keys(&mut e, "w"); // cursor at col 4
    run_keys(&mut e, "<Esc>");
    run_keys(&mut e, "0"); // cursor at col 0
    run_keys(&mut e, "`>");
    assert_eq!(e.cursor(), (0, 4));
}

#[test]
fn visual_exit_sets_lt_gt_marks() {
    // Vim sets `<` to the start and `>` to the end of the last visual
    // selection on every visual exit. Required for :'<,'> ex ranges.
    let mut e = editor_with("aaa\nbbb\nccc\nddd");
    // V<j><Esc> → selects rows 0..=1 in line-wise visual.
    run_keys(&mut e, "V");
    run_keys(&mut e, "j");
    run_keys(&mut e, "<Esc>");
    let lt = e.mark('<').expect("'<' mark must be set on visual exit");
    let gt = e.mark('>').expect("'>' mark must be set on visual exit");
    assert_eq!(lt.0, 0, "'< row should be the lower bound");
    assert_eq!(gt.0, 1, "'> row should be the upper bound");
}

#[test]
fn visual_exit_marks_use_lower_higher_order() {
    // Selecting upward (cursor < anchor) must still produce `<` = lower,
    // `>` = higher — vim's marks are position-ordered, not selection-
    // ordered.
    let mut e = editor_with("aaa\nbbb\nccc\nddd");
    run_keys(&mut e, "jjj"); // cursor at row 3
    run_keys(&mut e, "V");
    run_keys(&mut e, "k"); // anchor row 3, cursor row 2
    run_keys(&mut e, "<Esc>");
    let lt = e.mark('<').unwrap();
    let gt = e.mark('>').unwrap();
    assert_eq!(lt.0, 2);
    assert_eq!(gt.0, 3);
}

#[test]
fn visualline_exit_marks_snap_to_line_edges() {
    // VisualLine: `<` snaps to col 0, `>` snaps to last col of bot row.
    let mut e = editor_with("aaaaa\nbbbbb\ncc");
    run_keys(&mut e, "lll"); // cursor at row 0, col 3
    run_keys(&mut e, "V");
    run_keys(&mut e, "j"); // VisualLine over rows 0..=1
    run_keys(&mut e, "<Esc>");
    let lt = e.mark('<').unwrap();
    let gt = e.mark('>').unwrap();
    assert_eq!(lt, (0, 0), "'< should snap to (top_row, 0)");
    // Row 1 is "bbbbb" — last col is 4.
    assert_eq!(gt, (1, 4), "'> should snap to (bot_row, last_col)");
}

#[test]
fn visualblock_exit_marks_use_block_corners() {
    // VisualBlock with cursor moving left + down. Corners are not
    // tuple-ordered: top-left is (anchor_row, cursor_col), bottom-right
    // is (cursor_row, anchor_col). `<` must be top-left, `>` bottom-right.
    let mut e = editor_with("aaaaa\nbbbbb\nccccc");
    run_keys(&mut e, "llll"); // row 0, col 4
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "j"); // row 1, col 4
    run_keys(&mut e, "hh"); // row 1, col 2
    run_keys(&mut e, "<Esc>");
    let lt = e.mark('<').unwrap();
    let gt = e.mark('>').unwrap();
    // anchor=(0,4), cursor=(1,2) → corners are (0,2) and (1,4).
    assert_eq!(lt, (0, 2), "'< should be top-left corner");
    assert_eq!(gt, (1, 4), "'> should be bottom-right corner");
}

#[test]
fn visual_block_delete_removes_column_range() {
    let mut e = editor_with("hello\nworld\nhappy");
    // Move off col 0 first so the block starts mid-row.
    run_keys(&mut e, "l");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jj");
    run_keys(&mut e, "ll");
    run_keys(&mut e, "d");
    // Deletes cols 1-3 on every row — "ell" / "orl" / "app".
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["ho".to_string(), "wd".to_string(), "hy".to_string()]
    );
}

#[test]
fn visual_block_yank_joins_with_newlines() {
    let mut e = editor_with("hello\nworld\nhappy");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jj");
    run_keys(&mut e, "ll");
    run_keys(&mut e, "y");
    assert_eq!(
        e.host_mut().read_clipboard().as_deref(),
        Some("hel\nwor\nhap")
    );
}

#[test]
fn visual_block_replace_fills_block() {
    let mut e = editor_with("hello\nworld\nhappy");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jj");
    run_keys(&mut e, "ll");
    run_keys(&mut e, "rx");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &[
            "xxxlo".to_string(),
            "xxxld".to_string(),
            "xxxpy".to_string()
        ]
    );
}

#[test]
fn visual_block_insert_repeats_across_rows() {
    let mut e = editor_with("hello\nworld\nhappy");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jj");
    run_keys(&mut e, "I");
    run_keys(&mut e, "# <Esc>");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &[
            "# hello".to_string(),
            "# world".to_string(),
            "# happy".to_string()
        ]
    );
}

#[test]
fn block_highlight_returns_none_outside_block_mode() {
    let mut e = editor_with("abc");
    assert!(e.block_highlight().is_none());
    run_keys(&mut e, "v");
    assert!(e.block_highlight().is_none());
    run_keys(&mut e, "<Esc>V");
    assert!(e.block_highlight().is_none());
}

#[test]
fn block_highlight_bounds_track_anchor_and_cursor() {
    let mut e = editor_with("aaaa\nbbbb\ncccc");
    run_keys(&mut e, "ll"); // cursor (0, 2)
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jh"); // cursor (1, 1)
    // anchor = (0, 2), cursor = (1, 1) → top=0 bot=1 left=1 right=2.
    assert_eq!(e.block_highlight(), Some((0, 1, 1, 2)));
}

#[test]
fn visual_block_delete_handles_short_lines() {
    // Middle row is shorter than the block's right column.
    let mut e = editor_with("hello\nhi\nworld");
    run_keys(&mut e, "l"); // col 1
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jjll"); // cursor (2, 3)
    run_keys(&mut e, "d");
    // Row 0: delete cols 1-3 ("ell") → "ho".
    // Row 1: only 2 chars ("hi"); block starts at col 1, so just "i"
    //        gets removed → "h".
    // Row 2: delete cols 1-3 ("orl") → "wd".
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["ho".to_string(), "h".to_string(), "wd".to_string()]
    );
}

#[test]
fn visual_block_yank_pads_short_lines_with_empties() {
    let mut e = editor_with("hello\nhi\nworld");
    run_keys(&mut e, "l");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jjll");
    run_keys(&mut e, "y");
    // Row 0 chars 1-3 = "ell"; row 1 chars 1- (only "i"); row 2 "orl".
    assert_eq!(
        e.host_mut().read_clipboard().as_deref(),
        Some("ell\ni\norl")
    );
}

#[test]
fn visual_block_replace_skips_past_eol() {
    // Block extends past the end of every row in column range;
    // replace should leave lines shorter than `left` untouched.
    let mut e = editor_with("ab\ncd\nef");
    // Put cursor at col 1 (last char), extend block 5 columns right.
    run_keys(&mut e, "l");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jjllllll");
    run_keys(&mut e, "rX");
    // Every row had only col 0..=1; block covers col 1..=7 → only
    // col 1 is in range on each row, so just that cell changes.
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["aX".to_string(), "cX".to_string(), "eX".to_string()]
    );
}

#[test]
fn visual_block_with_empty_line_in_middle() {
    let mut e = editor_with("abcd\n\nefgh");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jjll"); // cursor (2, 2)
    run_keys(&mut e, "d");
    // Row 0 cols 0-2 removed → "d". Row 1 empty → untouched.
    // Row 2 cols 0-2 removed → "h".
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["d".to_string(), "".to_string(), "h".to_string()]
    );
}

#[test]
fn block_insert_skips_empty_lines_shorter_than_block_column() {
    // Regression (audit-r2 Fix 2): vim `v_b_I` (`:h v_b_I`) SKIPS rows that
    // don't reach the block's left column — it never pads them (padding
    // is `A`-only). Middle line is empty; block `I` at column 3 must leave
    // it untouched. Verified against `nvim --headless`, which leaves the
    // blank line exactly as-is.
    let mut e = editor_with("this is a line\n\nthis is a line");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jj");
    run_keys(&mut e, "I");
    run_keys(&mut e, "XX<Esc>");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &[
            "thiXXs is a line".to_string(),
            "".to_string(),
            "thiXXs is a line".to_string()
        ]
    );
}

#[test]
fn block_insert_skips_short_lines_shorter_than_block_column() {
    // Regression (audit-r2 Fix 2): same as above but with a non-empty
    // short row. Verified against `nvim --headless`, which leaves "bb"
    // untouched (no padding, no insert).
    let mut e = editor_with("aaaaa\nbb\naaaaa");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jj");
    run_keys(&mut e, "I");
    run_keys(&mut e, "Y<Esc>");
    // Row 1 "bb" is shorter than col 3 — skip it entirely.
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["aaaYaa".to_string(), "bb".to_string(), "aaaYaa".to_string()]
    );
}

#[test]
fn block_change_skips_short_lines_shorter_than_block_column() {
    // vim `v_b_c` (`:h v_b_c`) shares `I`'s skip-short-rows semantics, not
    // `A`'s pad semantics — verified against `nvim --headless`, which
    // leaves "x" completely untouched (no delete, no insert).
    let mut e = editor_with("aaaa\nx\nbbbb");
    e.jump_cursor(0, 2);
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jj");
    run_keys(&mut e, "c");
    run_keys(&mut e, "Z<Esc>");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["aaZa".to_string(), "x".to_string(), "bbZb".to_string()]
    );
}

#[test]
fn visual_block_append_repeats_across_rows() {
    let mut e = editor_with("foo\nbar\nbaz");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "jj");
    // Single-column block (anchor col = cursor col = 0); `A` appends
    // after column 0 on every row.
    run_keys(&mut e, "A");
    run_keys(&mut e, "!<Esc>");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["f!oo".to_string(), "b!ar".to_string(), "b!az".to_string()]
    );
}

#[test]
fn visual_block_append_pads_rows_shorter_than_the_top_row_to_the_block_edge() {
    // Regression (audit-r2 Fix 1): block `A`'s append column used to be
    // clamped by the TOP row's length alone (`(right+1).min(line_char_
    // count(top))`), so on rows LONGER than the top row the typed text
    // landed inside the block instead of past its right edge. vim `v_b_A`
    // (`:h v_b_A`) pads every row that's shorter than the block's right
    // edge to reach it, then appends there — verified against
    // `nvim --headless`.
    let mut e = editor_with("ab\nabcdef");
    e.jump_cursor(1, 5); // row 1, col 5 (the 'f')
    run_keys(&mut e, "<C-v>k"); // block spans rows 0..=1, col 5 (block_vcol preserved)
    run_keys(&mut e, "A");
    run_keys(&mut e, "X<Esc>");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["ab    X".to_string(), "abcdefX".to_string()]
    );
}

#[test]
fn visual_block_append_pad_and_replication_undo_in_one_step() {
    // The top-row pad, the typed text, and the replicated rows must all
    // land in the same undo group — vim reverts a block `A` in a single
    // `u` (verified against `nvim --headless`).
    let mut e = editor_with("ab\nabcdef");
    e.jump_cursor(1, 5);
    run_keys(&mut e, "<C-v>k");
    run_keys(&mut e, "A");
    run_keys(&mut e, "X<Esc>");
    run_keys(&mut e, "u");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["ab".to_string(), "abcdef".to_string()]
    );
}

#[test]
fn visual_block_dollar_delete_removes_to_each_rows_own_eol() {
    // Fix 3: `$` in VisualBlock makes the block ragged (`:h v_b_$`) —
    // every row deletes to ITS OWN EOL, not a fixed-width rectangle
    // capped by whichever row the cursor was on when `$` was pressed.
    // `$` fires on the SHORT row, THEN `j` extends down to the long row —
    // pre-fix this froze `right` at "short"'s own EOL and reused it for
    // "muchlongerline" too, leaving "mongerline" behind. Verified against
    // `nvim --headless`.
    let mut e = editor_with("short\nmuchlongerline");
    run_keys(&mut e, "l");
    run_keys(&mut e, "<C-v>");
    run_keys(&mut e, "$");
    run_keys(&mut e, "j");
    run_keys(&mut e, "d");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["s".to_string(), "m".to_string()]
    );
}

#[test]
fn visual_block_dollar_yank_captures_each_rows_own_tail() {
    // Same ragged (`:h v_b_$`) resolution for `y` — verified against
    // `nvim --headless`.
    let mut e = editor_with("short\nmuchlongerline");
    run_keys(&mut e, "l<C-v>$jy");
    assert_eq!(
        e.host_mut().read_clipboard().as_deref(),
        Some("hort\nuchlongerline")
    );
}

#[test]
fn visual_block_dollar_replace_fills_to_each_rows_own_eol() {
    // Same ragged (`:h v_b_$`) resolution for `r` — verified against
    // `nvim --headless`.
    let mut e = editor_with("short\nmuchlongerline");
    run_keys(&mut e, "l<C-v>$jrX");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["sXXXX".to_string(), "mXXXXXXXXXXXXX".to_string()]
    );
}

#[test]
fn visual_block_dollar_flag_survives_vertical_motion_clears_on_horizontal() {
    // `:h v_b_$`: the ragged flag survives j/k (vertical) but clears on
    // any motion that re-establishes a fixed column (here `0`). After
    // `$` then `0` then `j`, the block must behave like an ordinary
    // fixed-width rectangle again (col 0 only, both rows) — verified
    // against `nvim --headless`.
    let mut e = editor_with("short\nmuchlongerline");
    run_keys(&mut e, "<C-v>$0"); // ragged set by $, then cleared by 0
    run_keys(&mut e, "jd");
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["hort".to_string(), "uchlongerline".to_string()]
    );
}

// ─── `/` / `?` search prompt ─────────────────────────────────────────

#[test]
fn slash_opens_forward_search_prompt() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "/");
    let p = e.search_prompt().expect("prompt should be active");
    assert!(p.text.is_empty());
    assert!(p.forward);
}

#[test]
fn question_opens_backward_search_prompt() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "?");
    let p = e.search_prompt().expect("prompt should be active");
    assert!(!p.forward);
}

#[test]
fn search_prompt_typing_updates_pattern_live() {
    let mut e = editor_with("foo bar\nbaz");
    run_keys(&mut e, "/bar");
    assert_eq!(e.search_prompt().unwrap().text, "bar");
    // Pattern set on the engine search state for live highlight.
    assert!(e.search_state().pattern.is_some());
}

#[test]
fn search_prompt_backspace_and_enter() {
    let mut e = editor_with("hello world\nagain");
    run_keys(&mut e, "/worlx");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
    );
    assert_eq!(e.search_prompt().unwrap().text, "worl");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    // Prompt closed, last_search set, cursor advanced to match.
    assert!(e.search_prompt().is_none());
    assert_eq!(e.last_search(), Some("worl".to_string()));
    assert_eq!(e.cursor(), (0, 6));
}

#[test]
fn empty_search_prompt_enter_repeats_last_search() {
    let mut e = editor_with("foo bar foo baz foo");
    run_keys(&mut e, "/foo");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    assert_eq!(e.cursor().1, 8);
    // Empty `/<CR>` should advance to the next match, not clear last_search.
    run_keys(&mut e, "/");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    assert_eq!(e.cursor().1, 16);
    assert_eq!(e.last_search(), Some("foo".to_string()));
}

#[test]
fn search_history_records_committed_patterns() {
    let mut e = editor_with("alpha beta gamma");
    run_keys(&mut e, "/alpha<CR>");
    run_keys(&mut e, "/beta<CR>");
    // Newest entry at the back.
    let history = e.search_history().to_vec();
    assert_eq!(history, vec!["alpha", "beta"]);
}

#[test]
fn search_history_dedupes_consecutive_repeats() {
    let mut e = editor_with("foo bar foo");
    run_keys(&mut e, "/foo<CR>");
    run_keys(&mut e, "/foo<CR>");
    run_keys(&mut e, "/bar<CR>");
    run_keys(&mut e, "/bar<CR>");
    // Two distinct entries; the duplicates collapsed.
    assert_eq!(e.search_history().to_vec(), vec!["foo", "bar"]);
}

#[test]
fn ctrl_p_walks_history_backward() {
    let mut e = editor_with("alpha beta gamma");
    run_keys(&mut e, "/alpha<CR>");
    run_keys(&mut e, "/beta<CR>");
    // Open a fresh prompt; Ctrl-P pulls in the newest entry.
    run_keys(&mut e, "/");
    assert_eq!(e.search_prompt().unwrap().text, "");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
    );
    assert_eq!(e.search_prompt().unwrap().text, "beta");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
    );
    assert_eq!(e.search_prompt().unwrap().text, "alpha");
    // At the oldest entry; further Ctrl-P is a no-op.
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
    );
    assert_eq!(e.search_prompt().unwrap().text, "alpha");
}

#[test]
fn ctrl_n_walks_history_forward_after_ctrl_p() {
    let mut e = editor_with("a b c");
    run_keys(&mut e, "/a<CR>");
    run_keys(&mut e, "/b<CR>");
    run_keys(&mut e, "/c<CR>");
    run_keys(&mut e, "/");
    // Walk back to "a", then forward again.
    for _ in 0..3 {
        hjkl_vim_tui::handle_key(
            &mut e,
            KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
        );
    }
    assert_eq!(e.search_prompt().unwrap().text, "a");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
    );
    assert_eq!(e.search_prompt().unwrap().text, "b");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
    );
    assert_eq!(e.search_prompt().unwrap().text, "c");
    // Past the newest — stays at "c".
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
    );
    assert_eq!(e.search_prompt().unwrap().text, "c");
}

#[test]
fn typing_after_history_walk_resets_cursor() {
    let mut e = editor_with("foo");
    run_keys(&mut e, "/foo<CR>");
    run_keys(&mut e, "/");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
    );
    assert_eq!(e.search_prompt().unwrap().text, "foo");
    // User edits — append a char. Next Ctrl-P should restart from
    // the newest entry, not continue walking older.
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
    );
    assert_eq!(e.search_prompt().unwrap().text, "foox");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
    );
    assert_eq!(e.search_prompt().unwrap().text, "foo");
}

#[test]
fn empty_backward_search_prompt_enter_repeats_last_search() {
    let mut e = editor_with("foo bar foo baz foo");
    // Forward to col 8, then `?<CR>` should walk backward to col 0.
    run_keys(&mut e, "/foo");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    assert_eq!(e.cursor().1, 8);
    run_keys(&mut e, "?");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    assert_eq!(e.cursor().1, 0);
    assert_eq!(e.last_search(), Some("foo".to_string()));
}

#[test]
fn search_prompt_esc_cancels_but_keeps_last_search() {
    let mut e = editor_with("foo bar\nbaz");
    run_keys(&mut e, "/bar");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
    assert!(e.search_prompt().is_none());
    assert_eq!(e.last_search(), Some("bar".to_string()));
}

#[test]
fn search_then_n_and_shift_n_navigate() {
    let mut e = editor_with("foo bar foo baz foo");
    run_keys(&mut e, "/foo");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    // `/foo` + Enter jumps forward; we land on the next match after col 0.
    assert_eq!(e.cursor().1, 8);
    run_keys(&mut e, "n");
    assert_eq!(e.cursor().1, 16);
    run_keys(&mut e, "N");
    assert_eq!(e.cursor().1, 8);
}

#[test]
fn question_mark_searches_backward_on_enter() {
    let mut e = editor_with("foo bar foo baz");
    e.jump_cursor(0, 10);
    run_keys(&mut e, "?foo");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    // Cursor jumps backward to the closest match before col 10.
    assert_eq!(e.cursor(), (0, 8));
}

// ─── P6 quick wins (Y, gJ, ge / gE) ──────────────────────────────────

#[test]
fn big_y_yanks_to_end_of_line() {
    let mut e = editor_with("hello world");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "Y");
    assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("world"));
}

#[test]
fn big_y_from_line_start_yanks_full_line() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "Y");
    assert_eq!(
        e.host_mut().read_clipboard().as_deref(),
        Some("hello world")
    );
}

#[test]
fn gj_joins_without_inserting_space() {
    let mut e = editor_with("hello\n    world");
    run_keys(&mut e, "gJ");
    // No space inserted, leading whitespace preserved.
    assert_eq!(
        rope_to_lines_vec(&e.buffer().rope()),
        &["hello    world".to_string()]
    );
}

#[test]
fn gj_noop_on_last_line() {
    let mut e = editor_with("only");
    run_keys(&mut e, "gJ");
    assert_eq!(rope_to_lines_vec(&e.buffer().rope()), &["only".to_string()]);
}

#[test]
fn ge_jumps_to_previous_word_end() {
    let mut e = editor_with("foo bar baz");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "ge");
    assert_eq!(e.cursor(), (0, 2));
}

#[test]
fn ge_respects_word_class() {
    // Small-word `ge` treats `-` as its own word, so from mid-"bar"
    // it lands on the `-` rather than end of "foo".
    let mut e = editor_with("foo-bar baz");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "ge");
    assert_eq!(e.cursor(), (0, 3));
}

#[test]
fn big_ge_treats_hyphens_as_part_of_word() {
    // `gE` uses WORD (whitespace-delimited) semantics so it skips
    // over the `-` and lands on the end of "foo-bar".
    let mut e = editor_with("foo-bar baz");
    e.jump_cursor(0, 10);
    run_keys(&mut e, "gE");
    assert_eq!(e.cursor(), (0, 6));
}

#[test]
fn ge_crosses_line_boundary() {
    let mut e = editor_with("foo\nbar");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "ge");
    assert_eq!(e.cursor(), (0, 2));
}

#[test]
fn dge_deletes_to_end_of_previous_word() {
    let mut e = editor_with("foo bar baz");
    e.jump_cursor(0, 8);
    // d + ge from 'b' of "baz": range is ge → col 6 ('r' of bar),
    // inclusive, so cols 6-8 ("r b") are cut.
    run_keys(&mut e, "dge");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "foo baaz"
    );
}

#[test]
fn ctrl_scroll_keys_do_not_panic() {
    // Viewport-less test: just exercise the code paths so a regression
    // in the scroll dispatch surfaces as a panic or assertion failure.
    let mut e = editor_with(
        (0..50)
            .map(|i| format!("line{i}"))
            .collect::<Vec<_>>()
            .join("\n")
            .as_str(),
    );
    run_keys(&mut e, "<C-f>");
    run_keys(&mut e, "<C-b>");
    // No explicit assert beyond "didn't panic".
    assert!(e.buffer().row_count() > 0);
}

/// Regression: arrow-navigation during a count-insert session must
/// not pull unrelated rows into the "inserted" replay string.
/// Before the fix, `before_lines` only snapshotted the entry row,
/// so the diff at Esc spuriously saw the navigated-over row as
/// part of the insert — count-replay then duplicated cross-row
/// content across the buffer.
#[test]
fn count_insert_with_arrow_nav_does_not_leak_rows() {
    let mut e = hjkl_vim::vim_editor(
        hjkl_buffer::View::new(),
        hjkl_engine::DefaultHost::new(),
        hjkl_engine::Options::default(),
    );
    e.set_content("row0\nrow1\nrow2");
    // `3i`, type X, arrow down, Esc.
    run_keys(&mut e, "3iX<Down><Esc>");
    // Row 0 keeps the originally-typed X.
    assert!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0).contains('X'));
    // Row 1 must not contain a fragment of row 0 ("row0") — that
    // was the buggy leak from the before-diff window.
    assert!(
        !hjkl_buffer::rope_line_str(&e.buffer().rope(), 1).contains("row0"),
        "row1 leaked row0 contents: {:?}",
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1)
    );
    // View stays the same number of rows — no extra lines
    // injected by a multi-line "inserted" replay.
    assert_eq!(e.buffer().row_count(), 3);
}

// ─── Viewport scroll / jump tests ─────────────────────────────────

fn editor_with_rows(n: usize, viewport: u16) -> Editor {
    let mut e = hjkl_vim::vim_editor(
        hjkl_buffer::View::new(),
        hjkl_engine::DefaultHost::new(),
        hjkl_engine::Options::default(),
    );
    let body = (0..n)
        .map(|i| format!("  line{}", i))
        .collect::<Vec<_>>()
        .join("\n");
    e.set_content(&body);
    e.set_viewport_height(viewport);
    e
}

#[test]
fn ctrl_d_moves_cursor_half_page_down() {
    let mut e = editor_with_rows(100, 20);
    run_keys(&mut e, "<C-d>");
    assert_eq!(e.cursor().0, 10);
}

fn editor_with_wrap_lines(lines: &[&str], viewport: u16, text_width: u16) -> Editor {
    let mut e = hjkl_vim::vim_editor(
        hjkl_buffer::View::new(),
        hjkl_engine::DefaultHost::new(),
        hjkl_engine::Options::default(),
    );
    e.set_content(&lines.join("\n"));
    e.set_viewport_height(viewport);
    let v = e.host_mut().viewport_mut();
    v.height = viewport;
    v.width = text_width;
    v.text_width = text_width;
    v.wrap = hjkl_buffer::Wrap::Char;
    e.settings_mut().wrap = hjkl_buffer::Wrap::Char;
    e
}

#[test]
fn scrolloff_wrap_keeps_cursor_off_bottom_edge() {
    // 10 doc rows, each wraps to 3 segments → 30 screen rows.
    // Viewport height 12, margin = SCROLLOFF.min(11/2) = 5,
    // max bottom = 11 - 5 = 6. Plenty of headroom past row 4.
    let lines = ["aaaabbbbcccc"; 10];
    let mut e = editor_with_wrap_lines(&lines, 12, 4);
    e.jump_cursor(4, 0);
    e.ensure_cursor_in_scrolloff();
    let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
    assert!(csr <= 6, "csr={csr}");
}

#[test]
fn scrolloff_wrap_keeps_cursor_off_top_edge() {
    let lines = ["aaaabbbbcccc"; 10];
    let mut e = editor_with_wrap_lines(&lines, 12, 4);
    // Force top down then bring cursor up so the top-edge margin
    // path runs.
    e.jump_cursor(7, 0);
    e.ensure_cursor_in_scrolloff();
    e.jump_cursor(2, 0);
    e.ensure_cursor_in_scrolloff();
    let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
    // SCROLLOFF.min((height - 1) / 2) = 5.min(5) = 5.
    assert!(csr >= 5, "csr={csr}");
}

#[test]
fn scrolloff_wrap_clamps_top_at_buffer_end() {
    let lines = ["aaaabbbbcccc"; 5];
    let mut e = editor_with_wrap_lines(&lines, 12, 4);
    e.jump_cursor(4, 11);
    e.ensure_cursor_in_scrolloff();
    // max_top_for_height(12) on 15 screen rows: row 4 (3 segs) +
    // row 3 (3 segs) + row 2 (3 segs) + row 1 (3 segs) = 12 —
    // max_top = row 1. Margin can't be honoured at EOF (matches
    // vim's behaviour — scrolloff is a soft constraint).
    let top = e.host().viewport().top_row;
    assert_eq!(top, 1);
}

#[test]
fn ctrl_u_moves_cursor_half_page_up() {
    let mut e = editor_with_rows(100, 20);
    e.jump_cursor(50, 0);
    run_keys(&mut e, "<C-u>");
    assert_eq!(e.cursor().0, 40);
}

#[test]
fn ctrl_f_moves_cursor_full_page_down() {
    let mut e = editor_with_rows(100, 20);
    run_keys(&mut e, "<C-f>");
    // One full page ≈ h - 2 (overlap).
    assert_eq!(e.cursor().0, 18);
}

#[test]
fn ctrl_b_moves_cursor_full_page_up() {
    let mut e = editor_with_rows(100, 20);
    e.jump_cursor(50, 0);
    run_keys(&mut e, "<C-b>");
    assert_eq!(e.cursor().0, 32);
}

#[test]
fn ctrl_d_lands_on_first_non_blank() {
    let mut e = editor_with_rows(100, 20);
    run_keys(&mut e, "<C-d>");
    // "  line10" — first non-blank is col 2.
    assert_eq!(e.cursor().1, 2);
}

#[test]
fn ctrl_d_clamps_at_end_of_buffer() {
    let mut e = editor_with_rows(5, 20);
    run_keys(&mut e, "<C-d>");
    assert_eq!(e.cursor().0, 4);
}

#[test]
fn capital_h_jumps_to_viewport_top() {
    let mut e = editor_with_rows(100, 10);
    e.jump_cursor(50, 0);
    e.set_viewport_top(45);
    let top = e.host().viewport().top_row;
    run_keys(&mut e, "H");
    assert_eq!(e.cursor().0, top);
    assert_eq!(e.cursor().1, 2);
}

#[test]
fn capital_l_jumps_to_viewport_bottom() {
    let mut e = editor_with_rows(100, 10);
    e.jump_cursor(50, 0);
    e.set_viewport_top(45);
    let top = e.host().viewport().top_row;
    run_keys(&mut e, "L");
    assert_eq!(e.cursor().0, top + 9);
}

#[test]
fn capital_m_jumps_to_viewport_middle() {
    let mut e = editor_with_rows(100, 10);
    e.jump_cursor(50, 0);
    e.set_viewport_top(45);
    let top = e.host().viewport().top_row;
    run_keys(&mut e, "M");
    // 10-row viewport: middle is top + 4.
    assert_eq!(e.cursor().0, top + 4);
}

#[test]
fn g_capital_m_lands_at_line_midpoint() {
    let mut e = editor_with("hello world!"); // 12 chars
    run_keys(&mut e, "gM");
    // floor(12 / 2) = 6.
    assert_eq!(e.cursor(), (0, 6));
}

#[test]
fn g_capital_m_on_empty_line_stays_at_zero() {
    let mut e = editor_with("");
    run_keys(&mut e, "gM");
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn g_capital_m_uses_current_line_only() {
    // Each line's midpoint is independent of others.
    let mut e = editor_with("a\nlonglongline"); // line 1: 12 chars
    e.jump_cursor(1, 0);
    run_keys(&mut e, "gM");
    assert_eq!(e.cursor(), (1, 6));
}

#[test]
fn capital_h_count_offsets_from_top() {
    let mut e = editor_with_rows(100, 10);
    e.jump_cursor(50, 0);
    e.set_viewport_top(45);
    let top = e.host().viewport().top_row;
    run_keys(&mut e, "3H");
    assert_eq!(e.cursor().0, top + 2);
}

// ─── Jumplist tests ───────────────────────────────────────────────

#[test]
fn ctrl_o_returns_to_pre_g_position() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(5, 2);
    run_keys(&mut e, "G");
    assert_eq!(e.cursor().0, 49);
    run_keys(&mut e, "<C-o>");
    assert_eq!(e.cursor(), (5, 2));
}

#[test]
fn ctrl_i_redoes_jump_after_ctrl_o() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(5, 2);
    run_keys(&mut e, "G");
    let post = e.cursor();
    run_keys(&mut e, "<C-o>");
    run_keys(&mut e, "<C-i>");
    assert_eq!(e.cursor(), post);
}

#[test]
fn new_jump_clears_forward_stack() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(5, 2);
    run_keys(&mut e, "G");
    run_keys(&mut e, "<C-o>");
    run_keys(&mut e, "gg");
    run_keys(&mut e, "<C-i>");
    assert_eq!(e.cursor().0, 0);
}

#[test]
fn ctrl_o_on_empty_stack_is_noop() {
    let mut e = editor_with_rows(10, 20);
    e.jump_cursor(3, 1);
    run_keys(&mut e, "<C-o>");
    assert_eq!(e.cursor(), (3, 1));
}

#[test]
fn asterisk_search_pushes_jump() {
    let mut e = editor_with("foo bar\nbaz foo end");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "*");
    let after = e.cursor();
    assert_ne!(after, (0, 0));
    run_keys(&mut e, "<C-o>");
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn h_viewport_jump_is_recorded() {
    let mut e = editor_with_rows(100, 10);
    e.jump_cursor(50, 0);
    e.set_viewport_top(45);
    let pre = e.cursor();
    run_keys(&mut e, "H");
    assert_ne!(e.cursor(), pre);
    run_keys(&mut e, "<C-o>");
    assert_eq!(e.cursor(), pre);
}

#[test]
fn j_k_motion_does_not_push_jump() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(5, 0);
    run_keys(&mut e, "jjj");
    run_keys(&mut e, "<C-o>");
    assert_eq!(e.cursor().0, 8);
}

#[test]
fn jumplist_caps_at_100() {
    let mut e = editor_with_rows(200, 20);
    for i in 0..101 {
        e.jump_cursor(i, 0);
        run_keys(&mut e, "G");
    }
    assert!(e.jump_back_list().len() <= 100);
}

#[test]
fn tab_acts_as_ctrl_i() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(5, 2);
    run_keys(&mut e, "G");
    let post = e.cursor();
    run_keys(&mut e, "<C-o>");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
    assert_eq!(e.cursor(), post);
}

// ─── Mark tests ───────────────────────────────────────────────────

#[test]
fn ma_then_backtick_a_jumps_exact() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(5, 3);
    run_keys(&mut e, "ma");
    e.jump_cursor(20, 0);
    run_keys(&mut e, "`a");
    assert_eq!(e.cursor(), (5, 3));
}

#[test]
fn ma_then_apostrophe_a_lands_on_first_non_blank() {
    let mut e = editor_with_rows(50, 20);
    // "  line5" — first non-blank is col 2.
    e.jump_cursor(5, 6);
    run_keys(&mut e, "ma");
    e.jump_cursor(30, 4);
    run_keys(&mut e, "'a");
    assert_eq!(e.cursor(), (5, 2));
}

#[test]
fn goto_mark_pushes_jumplist() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(10, 2);
    run_keys(&mut e, "mz");
    e.jump_cursor(3, 0);
    run_keys(&mut e, "`z");
    assert_eq!(e.cursor(), (10, 2));
    run_keys(&mut e, "<C-o>");
    assert_eq!(e.cursor(), (3, 0));
}

#[test]
fn goto_missing_mark_is_noop() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(3, 1);
    run_keys(&mut e, "`q");
    assert_eq!(e.cursor(), (3, 1));
}

#[test]
fn uppercase_mark_stored_under_uppercase_key() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(5, 3);
    run_keys(&mut e, "mA");
    // Uppercase marks now live in global_marks (cross-buffer), not the
    // buffer-local `marks` map. The buffer_id defaults to 0 for a fresh editor.
    let gm = e.global_mark('A');
    assert!(gm.is_some(), "global mark 'A' should be set");
    let (_bid, row, col) = gm.unwrap();
    assert_eq!(row, 5);
    assert_eq!(col, 3);
    // Lowercase map is unaffected.
    assert!(e.mark('a').is_none());
}

#[test]
fn mark_survives_document_shrink_via_clamp() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(40, 4);
    run_keys(&mut e, "mx");
    // Shrink the buffer to 10 rows.
    e.set_content("a\nb\nc\nd\ne");
    run_keys(&mut e, "`x");
    // Mark clamped to last row, col 0 (short line).
    let (r, _) = e.cursor();
    assert!(r <= 4);
}

#[test]
fn g_semicolon_walks_back_through_edits() {
    // B7: the changelist records the PRE-edit cursor position (where the
    // change started), not wherever the cursor ended up after typing —
    // verified against real nvim (see the `mutate_edit` comment at the
    // changelist push site, and crates/hjkl-compat-oracle/corpus/
    // tier2_jumps.toml's `changelist_semicolon_*` cases). Both edits here
    // are single-char `i`-inserts starting at col 0, so the entry sits at
    // (row, 0), not (row, 1) — a `i`-insert doesn't shift the insertion
    // point itself, only the cursor after typing.
    let mut e = editor_with("alpha\nbeta\ngamma");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "iX<Esc>");
    e.jump_cursor(2, 0);
    run_keys(&mut e, "iY<Esc>");
    // First g; lands on the most recent entry's exact cell.
    run_keys(&mut e, "g;");
    assert_eq!(e.cursor(), (2, 0));
    // Second g; walks to the older entry.
    run_keys(&mut e, "g;");
    assert_eq!(e.cursor(), (0, 0));
    // Past the oldest — no-op.
    run_keys(&mut e, "g;");
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn g_comma_walks_forward_after_g_semicolon() {
    // See `g_semicolon_walks_back_through_edits` — entries record the
    // pre-edit (insertion) position, not the post-typing cursor.
    let mut e = editor_with("a\nb\nc");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "iX<Esc>");
    e.jump_cursor(2, 0);
    run_keys(&mut e, "iY<Esc>");
    run_keys(&mut e, "g;");
    run_keys(&mut e, "g;");
    assert_eq!(e.cursor(), (0, 0));
    run_keys(&mut e, "g,");
    assert_eq!(e.cursor(), (2, 0));
}

#[test]
fn new_edit_during_walk_trims_forward_entries() {
    // See `g_semicolon_walks_back_through_edits` — entries record the
    // pre-edit (insertion) position, not the post-typing cursor.
    let mut e = editor_with("a\nb\nc\nd");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "iX<Esc>"); // entry 0 → (0, 0)
    e.jump_cursor(2, 0);
    run_keys(&mut e, "iY<Esc>"); // entry 1 → (2, 0)
    // Walk back twice to land on entry 0.
    run_keys(&mut e, "g;");
    run_keys(&mut e, "g;");
    assert_eq!(e.cursor(), (0, 0));
    // New edit while walking discards entries forward of the cursor.
    run_keys(&mut e, "iZ<Esc>");
    // No newer entry left to walk to.
    run_keys(&mut e, "g,");
    // Cursor stays where the latest edit landed it.
    assert_ne!(e.cursor(), (2, 0));
}

// gq* tests moved to crates/hjkl-editor/tests/vim_ex_integration.rs
// — they exercise the vim FSM through ex commands which now live in
// a sibling crate. cargo dev-dep cycles produce duplicate type IDs
// so the integration must run from the editor side.

#[test]
fn capital_mark_set_and_jump() {
    let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
    e.jump_cursor(2, 1);
    run_keys(&mut e, "mA");
    // Move away.
    e.jump_cursor(0, 0);
    // Jump back via `'A`.
    run_keys(&mut e, "'A");
    // Linewise jump → row preserved, col first non-blank (here 0).
    assert_eq!(e.cursor().0, 2);
}

#[test]
fn capital_mark_survives_set_content() {
    let mut e = editor_with("first buffer line\nsecond");
    e.jump_cursor(1, 3);
    run_keys(&mut e, "mA");
    // Swap buffer content (host loading a different tab).
    e.set_content("totally different content\non many\nrows of text");
    // `'A` should still jump to (1, 3) — it survived the swap.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "'A");
    assert_eq!(e.cursor().0, 1);
}

// capital_mark_shows_in_marks_listing moved to
// crates/hjkl-editor/tests/vim_ex_integration.rs (depends on the
// ex `marks` command).

#[test]
fn capital_mark_shifts_with_edit() {
    let mut e = editor_with("a\nb\nc\nd");
    e.jump_cursor(3, 0);
    run_keys(&mut e, "mA");
    // Delete the first row — `A` should shift up to row 2.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "dd");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "'A");
    assert_eq!(e.cursor().0, 2);
}

// ── Global mark (cross-buffer) engine unit tests ───────────────────────────

#[test]
fn global_mark_same_buffer_jump_returns_same_buffer() {
    let mut e = editor_with("alpha\nbeta\ngamma");
    e.set_current_buffer_id(42);
    e.jump_cursor(2, 1);
    run_keys(&mut e, "mA");
    // Verify the mark is stored in global_marks with the correct buffer_id.
    let gm = e.global_mark('A');
    assert!(gm.is_some());
    let (bid, row, col) = gm.unwrap();
    assert_eq!(bid, 42);
    assert_eq!(row, 2);
    assert_eq!(col, 1);
    // Jump to same buffer — try_goto_mark_line returns SameBuffer.
    e.jump_cursor(0, 0);
    let jump = e.try_goto_mark_line('A');
    assert_eq!(jump, hjkl_engine::MarkJump::SameBuffer);
    assert_eq!(e.cursor().0, 2);
}

#[test]
fn global_mark_cross_buffer_returns_cross_buffer() {
    let mut e = editor_with("alpha\nbeta\ngamma");
    // Set the mark with buffer_id=99.
    e.set_current_buffer_id(99);
    e.jump_cursor(1, 3);
    run_keys(&mut e, "mB");
    // Switch "current" buffer to a different id.
    e.set_current_buffer_id(7);
    // Jump should return CrossBuffer because 99 != 7.
    let jump = e.try_goto_mark_char('B');
    match jump {
        hjkl_engine::MarkJump::CrossBuffer {
            buffer_id,
            row,
            col,
        } => {
            assert_eq!(buffer_id, 99);
            assert_eq!(row, 1);
            assert_eq!(col, 3);
        }
        other => panic!("expected CrossBuffer, got {other:?}"),
    }
}

#[test]
fn global_mark_unset_returns_unset() {
    let mut e = editor_with("hello");
    let jump = e.try_goto_mark_line('Z');
    assert_eq!(jump, hjkl_engine::MarkJump::Unset);
}

#[test]
fn global_mark_shifts_after_edit_in_same_buffer() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    e.set_current_buffer_id(1);
    e.jump_cursor(4, 0);
    run_keys(&mut e, "mC"); // global mark C at row 4
    // Delete row 0 — mark should shift to row 3.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "dd");
    let (_, row, _) = e
        .global_mark('C')
        .expect("global mark C should still exist");
    assert_eq!(row, 3, "global mark should shift up by 1 after dd at row 0");
}

#[test]
fn mark_below_delete_shifts_up() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    // Set mark `a` on row 3 (the `d`).
    e.jump_cursor(3, 0);
    run_keys(&mut e, "ma");
    // Go back to row 0 and `dd`.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "dd");
    // Mark `a` should now point at row 2 — its content stayed `d`.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "'a");
    assert_eq!(e.cursor().0, 2);
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 2), "d");
}

#[test]
fn mark_on_deleted_row_is_dropped() {
    let mut e = editor_with("a\nb\nc\nd");
    // Mark `a` on row 1 (`b`).
    e.jump_cursor(1, 0);
    run_keys(&mut e, "ma");
    // Delete row 1.
    run_keys(&mut e, "dd");
    // The row that held `a` is gone; `'a` should be a no-op now.
    e.jump_cursor(2, 0);
    run_keys(&mut e, "'a");
    // Cursor stays on row 2 — `'a` no-ops on missing marks.
    assert_eq!(e.cursor().0, 2);
}

#[test]
fn mark_above_edit_unchanged() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    // Mark `a` on row 0.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "ma");
    // Delete row 3.
    e.jump_cursor(3, 0);
    run_keys(&mut e, "dd");
    // Mark `a` should still point at row 0.
    e.jump_cursor(2, 0);
    run_keys(&mut e, "'a");
    assert_eq!(e.cursor().0, 0);
}

#[test]
fn mark_shifts_down_after_insert() {
    let mut e = editor_with("a\nb\nc");
    // Mark `a` on row 2 (`c`).
    e.jump_cursor(2, 0);
    run_keys(&mut e, "ma");
    // Open a new line above row 0 with `O\nfoo<Esc>`.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "Onew<Esc>");
    // View is now ["new", "a", "b", "c"]; mark `a` should track
    // the original content row → 3.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "'a");
    assert_eq!(e.cursor().0, 3);
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 3), "c");
}

// ─── Search / jumplist interaction ───────────────────────────────

#[test]
fn forward_search_commit_pushes_jump() {
    let mut e = editor_with("alpha beta\nfoo target end\nmore");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "/target<CR>");
    // Cursor moved to the match.
    assert_ne!(e.cursor(), (0, 0));
    // Ctrl-o returns to the pre-search position.
    run_keys(&mut e, "<C-o>");
    assert_eq!(e.cursor(), (0, 0));
}

#[test]
fn search_commit_no_match_does_not_push_jump() {
    let mut e = editor_with("alpha beta\nfoo end");
    e.jump_cursor(0, 3);
    let pre_len = e.jump_back_list().len();
    run_keys(&mut e, "/zzznotfound<CR>");
    // No match → cursor stays, jumplist shouldn't grow.
    assert_eq!(e.jump_back_list().len(), pre_len);
}

// ─── Phase 7b: migration buffer cursor sync ──────────────────────

#[test]
fn buffer_cursor_mirrors_textarea_after_horizontal_motion() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "lll");
    let (row, col) = e.cursor();
    assert_eq!(e.buffer().cursor().row, row);
    assert_eq!(e.buffer().cursor().col, col);
}

#[test]
fn buffer_cursor_mirrors_textarea_after_vertical_motion() {
    let mut e = editor_with("aaaa\nbbbb\ncccc");
    run_keys(&mut e, "jj");
    let (row, col) = e.cursor();
    assert_eq!(e.buffer().cursor().row, row);
    assert_eq!(e.buffer().cursor().col, col);
}

#[test]
fn buffer_cursor_mirrors_textarea_after_word_motion() {
    let mut e = editor_with("foo bar baz");
    run_keys(&mut e, "ww");
    let (row, col) = e.cursor();
    assert_eq!(e.buffer().cursor().row, row);
    assert_eq!(e.buffer().cursor().col, col);
}

#[test]
fn buffer_cursor_mirrors_textarea_after_jump_motion() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    run_keys(&mut e, "G");
    let (row, col) = e.cursor();
    assert_eq!(e.buffer().cursor().row, row);
    assert_eq!(e.buffer().cursor().col, col);
}

#[test]
fn editor_sticky_col_tracks_horizontal_motion() {
    let mut e = editor_with("longline\nhi\nlongline");
    // `fl` from col 0 lands on the next `l` past the cursor —
    // "longline" → second `l` is at col 4. Horizontal motion
    // should refresh sticky to that column so the next `j`
    // picks it up across the short row.
    run_keys(&mut e, "fl");
    let landed = e.cursor().1;
    assert!(landed > 0, "fl should have moved");
    run_keys(&mut e, "j");
    // Editor is the single owner of sticky_col (0.0.28). The
    // sticky value was set from the post-`fl` column.
    assert_eq!(e.sticky_col(), Some(landed));
}

#[test]
fn buffer_content_mirrors_textarea_after_insert() {
    let mut e = editor_with("hello");
    run_keys(&mut e, "iXYZ<Esc>");
    let text = e
        .buffer()
        .rope()
        .to_string()
        .trim_end_matches('\n')
        .to_string();
    assert_eq!(e.buffer().as_string(), text);
}

#[test]
fn buffer_content_mirrors_textarea_after_delete() {
    let mut e = editor_with("alpha bravo charlie");
    run_keys(&mut e, "dw");
    let text = e
        .buffer()
        .rope()
        .to_string()
        .trim_end_matches('\n')
        .to_string();
    assert_eq!(e.buffer().as_string(), text);
}

#[test]
fn buffer_content_mirrors_textarea_after_dd() {
    let mut e = editor_with("a\nb\nc\nd");
    run_keys(&mut e, "jdd");
    let text = e
        .buffer()
        .rope()
        .to_string()
        .trim_end_matches('\n')
        .to_string();
    assert_eq!(e.buffer().as_string(), text);
}

#[test]
fn buffer_content_mirrors_textarea_after_open_line() {
    let mut e = editor_with("foo\nbar");
    run_keys(&mut e, "oNEW<Esc>");
    let text = e
        .buffer()
        .rope()
        .to_string()
        .trim_end_matches('\n')
        .to_string();
    assert_eq!(e.buffer().as_string(), text);
}

#[test]
fn buffer_content_mirrors_textarea_after_paste() {
    let mut e = editor_with("hello");
    run_keys(&mut e, "yy");
    run_keys(&mut e, "p");
    let text = e
        .buffer()
        .rope()
        .to_string()
        .trim_end_matches('\n')
        .to_string();
    assert_eq!(e.buffer().as_string(), text);
}

#[test]
fn buffer_selection_none_in_normal_mode() {
    let e = editor_with("foo bar");
    assert!(e.buffer_selection().is_none());
}

#[test]
fn buffer_selection_char_in_visual_mode() {
    use hjkl_buffer::{Position, Selection};
    let mut e = editor_with("hello world");
    run_keys(&mut e, "vlll");
    assert_eq!(
        e.buffer_selection(),
        Some(Selection::Char {
            anchor: Position::new(0, 0),
            head: Position::new(0, 3),
        })
    );
}

#[test]
fn buffer_selection_line_in_visual_line_mode() {
    use hjkl_buffer::Selection;
    let mut e = editor_with("a\nb\nc\nd");
    run_keys(&mut e, "Vj");
    assert_eq!(
        e.buffer_selection(),
        Some(Selection::Line {
            anchor_row: 0,
            head_row: 1,
        })
    );
}

#[test]
fn wrapscan_off_blocks_wrap_around() {
    let mut e = editor_with("first\nsecond\nthird\n");
    e.settings_mut().wrapscan = false;
    // Place cursor on row 2 ("third") and search for "first".
    e.jump_cursor(2, 0);
    run_keys(&mut e, "/first<CR>");
    // No wrap → cursor stays on row 2.
    assert_eq!(e.cursor().0, 2, "wrapscan off should block wrap");
    // Re-enable wrapscan and try again.
    e.settings_mut().wrapscan = true;
    run_keys(&mut e, "/first<CR>");
    assert_eq!(e.cursor().0, 0, "wrapscan on should wrap to row 0");
}

#[test]
fn smartcase_uppercase_pattern_stays_sensitive() {
    let mut e = editor_with("foo\nFoo\nBAR\n");
    e.settings_mut().ignore_case = true;
    e.settings_mut().smartcase = true;
    // All-lowercase pattern → ignorecase wins → compiled regex
    // is case-insensitive.
    run_keys(&mut e, "/foo<CR>");
    let r1 = e
        .search_state()
        .pattern
        .as_ref()
        .unwrap()
        .as_str()
        .to_string();
    assert!(r1.starts_with("(?i)"), "lowercase under smartcase: {r1}");
    // Uppercase letter → smartcase flips back to case-sensitive.
    run_keys(&mut e, "/Foo<CR>");
    let r2 = e
        .search_state()
        .pattern
        .as_ref()
        .unwrap()
        .as_str()
        .to_string();
    assert!(!r2.starts_with("(?i)"), "mixed-case under smartcase: {r2}");
}

#[test]
fn enter_with_autoindent_copies_leading_whitespace() {
    let mut e = editor_with("    foo");
    e.jump_cursor(0, 7);
    run_keys(&mut e, "i<CR>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "    ");
}

#[test]
fn enter_without_autoindent_inserts_bare_newline() {
    let mut e = editor_with("    foo");
    e.settings_mut().autoindent = false;
    e.jump_cursor(0, 7);
    run_keys(&mut e, "i<CR>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "");
}

#[test]
fn iskeyword_default_treats_alnum_underscore_as_word() {
    let mut e = editor_with("foo_bar baz");
    // `*` searches for the word at the cursor — picks up everything
    // matching iskeyword. With default spec, `foo_bar` is one word,
    // so the search pattern should bound that whole token.
    e.jump_cursor(0, 0);
    run_keys(&mut e, "*");
    let p = e
        .search_state()
        .pattern
        .as_ref()
        .unwrap()
        .as_str()
        .to_string();
    assert!(p.contains("foo_bar"), "default iskeyword: {p}");
}

#[test]
fn w_motion_respects_custom_iskeyword() {
    // `foo-bar baz`. With the default spec, `-` is NOT a word char,
    // so `foo` / `-` / `bar` / ` ` / `baz` are 5 transitions and a
    // single `w` from col 0 lands on `-` (col 3).
    let mut e = editor_with("foo-bar baz");
    run_keys(&mut e, "w");
    assert_eq!(e.cursor().1, 3, "default iskeyword: {:?}", e.cursor());
    // Re-set with `-` (45) treated as a word char. Now `foo-bar` is
    // one token; `w` from col 0 should jump to `baz` (col 8).
    let mut e2 = editor_with("foo-bar baz");
    e2.set_iskeyword("@,_,45");
    run_keys(&mut e2, "w");
    assert_eq!(e2.cursor().1, 8, "dash-as-word: {:?}", e2.cursor());
}

#[test]
fn iskeyword_with_dash_treats_dash_as_word_char() {
    let mut e = editor_with("foo-bar baz");
    e.settings_mut().iskeyword = "@,_,45".to_string();
    e.jump_cursor(0, 0);
    run_keys(&mut e, "*");
    let p = e
        .search_state()
        .pattern
        .as_ref()
        .unwrap()
        .as_str()
        .to_string();
    assert!(p.contains("foo-bar"), "dash-as-word: {p}");
}

#[test]
fn timeoutlen_drops_pending_g_prefix() {
    use std::time::{Duration, Instant};
    let mut e = editor_with("a\nb\nc");
    e.jump_cursor(2, 0);
    // First `g` lands us in g-pending state.
    run_keys(&mut e, "g");
    assert!(matches!(e.pending(), hjkl_engine::Pending::G));
    // Push last_input timestamps into the past beyond the configured
    // timeout. 0.0.29 drives `:set timeoutlen` off `Host::now()` (monotonic
    // Duration), so shrink the timeout window to a nanosecond and zero out
    // the host slot — any wall-clock progress between this line and the
    // next step exceeds it. The Instant-flavoured field is rewound for
    // snapshot tests that still observe it directly.
    let mut opts = e.current_options();
    opts.timeout_len = Duration::from_nanos(0);
    e.apply_options(&opts);
    e.set_last_input_at(Some(Instant::now() - Duration::from_secs(60)));
    e.set_last_input_host_at(Some(Duration::ZERO));
    // Second `g` arrives "late" — timeout fires, prefix is cleared,
    // and the bare `g` is re-dispatched: nothing happens at the
    // engine level because `g` alone isn't a complete command.
    run_keys(&mut e, "g");
    // Cursor must still be at row 2 — `gg` was NOT completed.
    assert_eq!(e.cursor().0, 2, "timeout must abandon g-prefix");
}

#[test]
fn undobreak_on_breaks_group_at_arrow_motion() {
    let mut e = editor_with("");
    // i a a a <Left> b b b <Esc> u
    run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
    // Default settings.undo_break_on_motion = true, so `u` only
    // reverses the `bbb` run; `aaa` remains.
    let line = hjkl_buffer::rope_line_str(&e.buffer().rope(), 0);
    assert!(line.contains("aaa"), "after undobreak: {line:?}");
    assert!(!line.contains("bbb"), "bbb should be undone: {line:?}");
}

#[test]
fn undobreak_off_keeps_full_run_in_one_group() {
    let mut e = editor_with("");
    e.settings_mut().undo_break_on_motion = false;
    run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
    // With undobreak off, the whole insert (aaa<Left>bbb) is one
    // group — `u` reverts back to empty.
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "");
}

#[test]
fn undobreak_round_trips_through_options() {
    let e = editor_with("");
    let opts = e.current_options();
    assert!(opts.undo_break_on_motion);
    let mut e2 = editor_with("");
    let mut new_opts = opts.clone();
    new_opts.undo_break_on_motion = false;
    e2.apply_options(&new_opts);
    assert!(!e2.current_options().undo_break_on_motion);
}

/// `c{motion}` enters insert through the no-push_undo path: the operator
/// already snapshotted before cutting the text. Cut + typed replacement
/// must therefore collapse into exactly ONE undo stop — a single `u`
/// restores the pre-operation line, and a second `u` finds nothing left.
#[test]
fn change_word_is_one_undo_stop() {
    let mut e = editor_with("foo bar");
    run_keys(&mut e, "cwbaz<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "baz bar");
    run_keys(&mut e, "u");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "foo bar",
        "one `u` must revert the whole change operation"
    );
    run_keys(&mut e, "u");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "foo bar",
        "the change must have left exactly one undo stop"
    );
}

/// `C` (delete-to-EOL then insert) shares the same no-push_undo entry —
/// one `u` restores the deleted tail together with the typed text.
#[test]
fn change_to_eol_is_one_undo_stop() {
    let mut e = editor_with("foo bar");
    run_keys(&mut e, "Cx<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "x");
    run_keys(&mut e, "u");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "foo bar");
}

/// `o` opens the line before entering insert (undo already pushed), so the
/// new line plus everything typed on it is a single undo unit.
#[test]
fn open_below_is_one_undo_stop() {
    let mut e = editor_with("one\ntwo");
    run_keys(&mut e, "onew<Esc>");
    assert_eq!(e.buffer().row_count(), 3);
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "new");
    run_keys(&mut e, "u");
    assert_eq!(
        e.buffer().row_count(),
        2,
        "one `u` must drop the opened line"
    );
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "one");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "two");
}

#[test]
fn undo_levels_cap_drops_oldest() {
    let mut e = editor_with("abcde");
    e.settings_mut().undo_levels = 3;
    run_keys(&mut e, "ra");
    run_keys(&mut e, "lrb");
    run_keys(&mut e, "lrc");
    run_keys(&mut e, "lrd");
    run_keys(&mut e, "lre");
    assert_eq!(e.undo_stack_len(), 3);
}

#[test]
fn tab_inserts_literal_tab_when_noexpandtab() {
    let mut e = editor_with("");
    // 0.2.0: expandtab now defaults on (modern). Opt out for the
    // literal-tab test.
    e.settings_mut().expandtab = false;
    e.settings_mut().softtabstop = 0;
    run_keys(&mut e, "i");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "\t");
}

#[test]
fn tab_inserts_spaces_when_expandtab() {
    let mut e = editor_with("");
    e.settings_mut().expandtab = true;
    e.settings_mut().tabstop = 4;
    run_keys(&mut e, "i");
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "    ");
}

#[test]
fn tab_with_softtabstop_fills_to_next_boundary() {
    // sts=4, cursor at col 2 → Tab inserts 2 spaces (to col 4).
    let mut e = editor_with("ab");
    e.settings_mut().expandtab = true;
    e.settings_mut().tabstop = 8;
    e.settings_mut().softtabstop = 4;
    run_keys(&mut e, "A"); // append at end (col 2)
    hjkl_vim_tui::handle_key(&mut e, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "ab  ");
}

#[test]
fn backspace_deletes_softtab_run() {
    // sts=4, line "    x" with cursor at col 4 → Backspace deletes
    // the whole 4-space run instead of one char.
    let mut e = editor_with("    x");
    e.settings_mut().softtabstop = 4;
    // Move to col 4 (start of 'x'), then enter insert.
    run_keys(&mut e, "fxi");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
    );
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "x");
}

#[test]
fn backspace_falls_back_to_single_char_when_run_not_aligned() {
    // sts=4, but cursor at col 5 (one space past the boundary) →
    // Backspace deletes only the one trailing space.
    let mut e = editor_with("     x");
    e.settings_mut().softtabstop = 4;
    run_keys(&mut e, "fxi");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
    );
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "    x");
}

#[test]
fn nomodifiable_blocks_insert_mutation() {
    // `nomodifiable` blocks edits AND entering insert mode.
    let mut e = editor_with("hello");
    e.settings_mut().modifiable = false;
    run_keys(&mut e, "iX<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hello");
}

#[test]
fn readonly_allows_insert_mutation() {
    // vim `readonly` keeps the buffer editable — it only errors on save. The
    // edit goes through; saving is gated separately at the app layer (E45).
    let mut e = editor_with("hello");
    e.settings_mut().readonly = true;
    run_keys(&mut e, "iX<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "Xhello");
}

// Tests for `install_ratatui_syntax_spans` moved to
// `hjkl-engine/src/editor.rs` (`mod tests`) — they exercise engine APIs, not
// the vim FSM. Relocation followed the drop of vim's dead `ratatui` feature
// passthrough.

#[test]
fn named_register_yank_into_a_then_paste_from_a() {
    let mut e = editor_with("hello world\nsecond");
    run_keys(&mut e, "\"ayw");
    // `yw` over "hello world" yanks "hello " (word + trailing space).
    assert_eq!(
        e.with_registers(|r| r.read('a').unwrap().text.clone()),
        "hello "
    );
    // Move to second line then paste from "a.
    run_keys(&mut e, "j0\"aP");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1),
        "hello second"
    );
}

#[test]
fn capital_r_overstrikes_chars() {
    let mut e = editor_with("hello");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "RXY<Esc>");
    // 'h' and 'e' replaced; 'llo' kept.
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "XYllo");
}

#[test]
fn capital_r_at_eol_appends() {
    let mut e = editor_with("hi");
    e.jump_cursor(0, 1);
    // Cursor on the final 'i'; replace it then keep typing past EOL.
    run_keys(&mut e, "RXYZ<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hXYZ");
}

#[test]
fn capital_r_count_does_not_repeat_overstrike_char_by_char() {
    // Vim's `2R` replays the *whole session* on Esc, not each char.
    // We don't model that fully, but the basic R should at least
    // not crash on empty session count handling.
    let mut e = editor_with("abc");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "RX<Esc>");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "Xbc");
}

#[test]
fn ctrl_r_in_insert_pastes_named_register() {
    let mut e = editor_with("hello world");
    // Yank "hello " into "a".
    run_keys(&mut e, "\"ayw");
    assert_eq!(
        e.with_registers(|r| r.read('a').unwrap().text.clone()),
        "hello "
    );
    // Open a fresh line, enter insert, Ctrl-R a.
    run_keys(&mut e, "o");
    assert_eq!(e.vim_mode(), VimMode::Insert);
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
    );
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
    );
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "hello ");
    // Cursor sits at end of inserted payload (col 6).
    assert_eq!(e.cursor(), (1, 6));
    // Stayed in insert mode; next char appends.
    assert_eq!(e.vim_mode(), VimMode::Insert);
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE),
    );
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "hello X");
}

#[test]
fn ctrl_r_with_unnamed_register() {
    let mut e = editor_with("foo");
    run_keys(&mut e, "yiw");
    run_keys(&mut e, "A ");
    // Unnamed register paste via `"`.
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
    );
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE),
    );
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "foo foo");
}

#[test]
fn ctrl_r_unknown_selector_is_no_op() {
    let mut e = editor_with("abc");
    run_keys(&mut e, "A");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
    );
    // `?` isn't a valid register selector — paste skipped, the
    // armed flag still clears so the next key types normally.
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
    );
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE),
    );
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "abcZ");
}

#[test]
fn ctrl_r_multiline_register_pastes_with_newlines() {
    let mut e = editor_with("alpha\nbeta\ngamma");
    // Yank two whole lines into "b".
    run_keys(&mut e, "\"byy");
    run_keys(&mut e, "j\"byy");
    // Linewise yanks include trailing \n; second yank into uppercase
    // would append, but lowercase "b" overwrote — ensure we have a
    // multi-line payload by yanking 2 lines linewise via V.
    run_keys(&mut e, "ggVj\"by");
    let payload = e.with_registers(|r| r.read('b').unwrap().text.clone());
    assert!(payload.contains('\n'));
    run_keys(&mut e, "Go");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
    );
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
    );
    // The buffer should now contain the original 3 lines plus the
    // pasted 2-line payload (with its own newline) on its own line.
    let total_lines = e.buffer().row_count();
    assert!(total_lines >= 5);
}

#[test]
fn yank_zero_holds_clipboard_after_delete() {
    let mut e = editor_with("hello world");
    run_keys(&mut e, "yw");
    let yanked = e.with_registers(|r| r.read('0').unwrap().text.clone());
    assert!(!yanked.is_empty());
    // Delete a word; "0 should still hold the original yank.
    run_keys(&mut e, "dw");
    assert_eq!(
        e.with_registers(|r| r.read('0').unwrap().text.clone()),
        yanked
    );
    // `dw` is a small (sub-line) delete → goes to the small-delete register
    // "-, not the numbered ring, which stays empty.
    assert!(!e.with_registers(|r| r.read('-').unwrap().text.is_empty()));
    assert!(e.with_registers(|r| r.read('1').unwrap().text.is_empty()));
}

#[test]
fn delete_ring_rotates_through_one_through_nine() {
    let mut e = editor_with("aaa\nbbb\nccc\nddd\n");
    // Line-sized deletes push onto "1, shifting older down the ring. (Small
    // sub-line deletes go to "- instead and never touch the ring.)
    for _ in 0..3 {
        run_keys(&mut e, "dd");
    }
    // Most recent delete is in "1.
    let r1 = e.with_registers(|r| r.read('1').unwrap().text.clone());
    let r2 = e.with_registers(|r| r.read('2').unwrap().text.clone());
    let r3 = e.with_registers(|r| r.read('3').unwrap().text.clone());
    assert!(!r1.is_empty() && !r2.is_empty() && !r3.is_empty());
    assert_ne!(r1, r2);
    assert_ne!(r2, r3);
}

#[test]
fn capital_register_appends_to_lowercase() {
    let mut e = editor_with("foo bar");
    run_keys(&mut e, "\"ayw");
    let first = e.with_registers(|r| r.read('a').unwrap().text.clone());
    assert!(first.contains("foo"));
    // Yank again into "A — appends to "a.
    run_keys(&mut e, "w\"Ayw");
    let combined = e.with_registers(|r| r.read('a').unwrap().text.clone());
    assert!(combined.starts_with(&first));
    assert!(combined.contains("bar"));
}

#[test]
fn zf_in_visual_line_creates_closed_fold() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    // VisualLine over rows 1..=3 then zf.
    e.jump_cursor(1, 0);
    run_keys(&mut e, "Vjjzf");
    assert_eq!(e.buffer().folds().len(), 1);
    let f = e.buffer().folds()[0];
    assert_eq!(f.start_row, 1);
    assert_eq!(f.end_row, 3);
    assert!(f.closed);
}

#[test]
fn zfj_in_normal_creates_two_row_fold() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "zfj");
    assert_eq!(e.buffer().folds().len(), 1);
    let f = e.buffer().folds()[0];
    assert_eq!(f.start_row, 1);
    assert_eq!(f.end_row, 2);
    assert!(f.closed);
    // Cursor stays where it started.
    assert_eq!(e.cursor().0, 1);
}

#[test]
fn zf_with_count_folds_count_rows() {
    let mut e = editor_with("a\nb\nc\nd\ne\nf");
    e.jump_cursor(0, 0);
    // `zf3j` — fold rows 0..=3.
    run_keys(&mut e, "zf3j");
    assert_eq!(e.buffer().folds().len(), 1);
    let f = e.buffer().folds()[0];
    assert_eq!(f.start_row, 0);
    assert_eq!(f.end_row, 3);
}

#[test]
fn zfk_folds_upward_range() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    e.jump_cursor(3, 0);
    run_keys(&mut e, "zfk");
    let f = e.buffer().folds()[0];
    // start_row = min(3, 2) = 2, end_row = max(3, 2) = 3.
    assert_eq!(f.start_row, 2);
    assert_eq!(f.end_row, 3);
}

#[test]
fn zf_capital_g_folds_to_bottom() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    e.jump_cursor(1, 0);
    // `G` is a single-char motion; folds rows 1..=4.
    run_keys(&mut e, "zfG");
    let f = e.buffer().folds()[0];
    assert_eq!(f.start_row, 1);
    assert_eq!(f.end_row, 4);
}

#[test]
fn zfgg_folds_to_top_via_operator_pipeline() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    e.jump_cursor(3, 0);
    // `gg` is a 2-key chord (Pending::OpG path) — `zfgg` works
    // because `zf` arms `Pending::Op { Fold }` which already knows
    // how to wait for `g` then `g`.
    run_keys(&mut e, "zfgg");
    let f = e.buffer().folds()[0];
    assert_eq!(f.start_row, 0);
    assert_eq!(f.end_row, 3);
}

#[test]
fn zfip_folds_paragraph_via_text_object() {
    let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta\nepsilon");
    e.jump_cursor(1, 0);
    // `ip` is a text object — same operator pipeline routes it.
    run_keys(&mut e, "zfip");
    assert_eq!(e.buffer().folds().len(), 1);
    let f = e.buffer().folds()[0];
    assert_eq!(f.start_row, 0);
    assert_eq!(f.end_row, 2);
}

#[test]
fn zfap_folds_paragraph_with_trailing_blank() {
    let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta");
    e.jump_cursor(0, 0);
    // `ap` includes the trailing blank line.
    run_keys(&mut e, "zfap");
    let f = e.buffer().folds()[0];
    assert_eq!(f.start_row, 0);
    assert_eq!(f.end_row, 3);
}

#[test]
fn zf_paragraph_motion_folds_to_blank() {
    let mut e = editor_with("alpha\nbeta\n\ngamma");
    e.jump_cursor(0, 0);
    // `}` lands ON the blank line (row 2) but is EXCLUSIVE, and its end sits at
    // column 0, so `:h exclusive` pulls the operated range back to the end of
    // the previous line: the fold covers rows 0..=1 and the blank line stays
    // visible. Measured in neovim 0.12.4 on this exact buffer —
    // `normal! zf}` then `foldclosed`/`foldclosedend` report 0..1 (0-based),
    // with `}` itself landing at (2,0). This test previously asserted 2, which
    // is where the CURSOR goes, not where the operator stops.
    run_keys(&mut e, "zf}");
    let f = e.buffer().folds()[0];
    assert_eq!(f.start_row, 0);
    assert_eq!(f.end_row, 1);
}

#[test]
fn za_toggles_fold_under_cursor() {
    let mut e = editor_with("a\nb\nc\nd");
    e.buffer_mut().add_fold(1, 2, true);
    e.jump_cursor(1, 0);
    run_keys(&mut e, "za");
    assert!(!e.buffer().folds()[0].closed);
    run_keys(&mut e, "za");
    assert!(e.buffer().folds()[0].closed);
}

#[test]
fn zr_opens_all_folds_zm_closes_all() {
    let mut e = editor_with("a\nb\nc\nd\ne\nf");
    e.buffer_mut().add_fold(0, 1, true);
    e.buffer_mut().add_fold(2, 3, true);
    e.buffer_mut().add_fold(4, 5, true);
    run_keys(&mut e, "zR");
    assert!(e.buffer().folds().iter().all(|f| !f.closed));
    run_keys(&mut e, "zM");
    assert!(e.buffer().folds().iter().all(|f| f.closed));
}

#[test]
fn ze_clears_all_folds() {
    let mut e = editor_with("a\nb\nc\nd");
    e.buffer_mut().add_fold(0, 1, true);
    e.buffer_mut().add_fold(2, 3, false);
    run_keys(&mut e, "zE");
    assert!(e.buffer().folds().is_empty());
}

#[test]
fn g_underscore_jumps_to_last_non_blank() {
    let mut e = editor_with("hello world   ");
    run_keys(&mut e, "g_");
    // Last non-blank is 'd' at col 10.
    assert_eq!(e.cursor().1, 10);
}

#[test]
fn gj_and_gk_alias_j_and_k() {
    let mut e = editor_with("a\nb\nc");
    run_keys(&mut e, "gj");
    assert_eq!(e.cursor().0, 1);
    run_keys(&mut e, "gk");
    assert_eq!(e.cursor().0, 0);
}

#[test]
fn paragraph_motions_walk_blank_lines() {
    let mut e = editor_with("first\nblock\n\nsecond\nblock\n\nthird");
    run_keys(&mut e, "}");
    assert_eq!(e.cursor().0, 2);
    run_keys(&mut e, "}");
    assert_eq!(e.cursor().0, 5);
    run_keys(&mut e, "{");
    assert_eq!(e.cursor().0, 2);
}

#[test]
fn gv_reenters_last_visual_selection() {
    let mut e = editor_with("alpha\nbeta\ngamma");
    run_keys(&mut e, "Vj");
    // Exit visual.
    run_keys(&mut e, "<Esc>");
    assert_eq!(e.vim_mode(), VimMode::Normal);
    // gv re-enters VisualLine.
    run_keys(&mut e, "gv");
    assert_eq!(e.vim_mode(), VimMode::VisualLine);
}

#[test]
fn o_in_visual_swaps_anchor_and_cursor() {
    let mut e = editor_with("hello world");
    // v then move right 4 — anchor at col 0, cursor at col 4.
    run_keys(&mut e, "vllll");
    assert_eq!(e.cursor().1, 4);
    // o swaps; cursor jumps to anchor (col 0).
    run_keys(&mut e, "o");
    assert_eq!(e.cursor().1, 0);
    // Anchor now at original cursor (col 4).
    assert_eq!(e.visual_anchor(), (0, 4));
}

#[test]
fn editing_inside_fold_invalidates_it() {
    let mut e = editor_with("a\nb\nc\nd");
    e.buffer_mut().add_fold(1, 2, true);
    e.jump_cursor(1, 0);
    // Insert a char on a row covered by the fold.
    run_keys(&mut e, "iX<Esc>");
    // Fold should be gone — vim opens (drops) folds on edit.
    assert!(e.buffer().folds().is_empty());
}

#[test]
fn zd_removes_fold_under_cursor() {
    let mut e = editor_with("a\nb\nc\nd");
    e.buffer_mut().add_fold(1, 2, true);
    e.jump_cursor(2, 0);
    run_keys(&mut e, "zd");
    assert!(e.buffer().folds().is_empty());
}

#[test]
fn take_fold_ops_observes_z_keystroke_dispatch() {
    // 0.0.38 (Patch C-δ.4): every `z…` keystroke routes through
    // `Editor::apply_fold_op`, which queues a `FoldOp` for hosts to
    // observe via `take_fold_ops` AND applies the op locally so
    // buffer fold storage stays in sync.
    use hjkl_engine::FoldOp;
    let mut e = editor_with("a\nb\nc\nd");
    e.buffer_mut().add_fold(1, 2, true);
    e.jump_cursor(1, 0);
    // Drain any queue from the buffer setup above (none expected,
    // but be defensive).
    let _ = e.take_fold_ops();
    run_keys(&mut e, "zo");
    run_keys(&mut e, "zM");
    let ops = e.take_fold_ops();
    assert_eq!(ops.len(), 2);
    assert!(matches!(ops[0], FoldOp::OpenAt(1)));
    assert!(matches!(ops[1], FoldOp::CloseAll));
    // Second drain returns empty.
    assert!(e.take_fold_ops().is_empty());
}

#[test]
fn edit_pipeline_emits_invalidate_fold_op() {
    // The edit pipeline routes its fold invalidation through
    // `apply_fold_op` so hosts can observe + dedupe.
    use hjkl_engine::FoldOp;
    let mut e = editor_with("a\nb\nc\nd");
    e.buffer_mut().add_fold(1, 2, true);
    e.jump_cursor(1, 0);
    let _ = e.take_fold_ops();
    run_keys(&mut e, "iX<Esc>");
    let ops = e.take_fold_ops();
    assert!(
        ops.iter().any(|op| matches!(op, FoldOp::Invalidate { .. })),
        "expected at least one Invalidate op, got {ops:?}"
    );
}

// ─── Fold subsystem regression tests (issue #244) ─────────────────────────

/// BUG 2 regression: closing a fold with the cursor inside the fold body
/// must snap the cursor to the fold's start_row (vim behaviour).
#[test]
fn close_fold_snaps_cursor_to_start_row() {
    // View: rows 0-4. Fold covers rows 1-3. Cursor starts on row 2
    // (inside the fold body). Closing the fold must snap cursor to row 1.
    let mut e = editor_with("a\nb\nc\nd\ne");
    e.buffer_mut().add_fold(1, 3, false); // open fold, rows 1-3
    e.jump_cursor(2, 0); // cursor inside fold body
    run_keys(&mut e, "zc"); // close the fold
    let (row, _col) = e.cursor();
    assert_eq!(
        row, 1,
        "cursor should snap to fold start_row=1, got row={row}"
    );
    // Row 2 and 3 must now be hidden.
    assert!(e.buffer().is_row_hidden(2), "row 2 should be hidden");
    assert!(e.buffer().is_row_hidden(3), "row 3 should be hidden");
}

/// BUG 2 regression: toggling (za) a fold closed with the cursor on a
/// hidden row snaps cursor to start_row.
#[test]
fn toggle_fold_closed_snaps_cursor_to_start_row() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    e.buffer_mut().add_fold(1, 3, false); // open fold
    e.jump_cursor(3, 0); // cursor on last hidden row
    run_keys(&mut e, "za"); // toggle to closed
    let (row, _col) = e.cursor();
    assert_eq!(
        row, 1,
        "cursor should snap to fold start_row=1 after toggle-close, got row={row}"
    );
}

/// BUG 2 regression: after closing a fold that strands the cursor, j/k
/// should step over the fold as a single visual unit.
#[test]
fn j_after_fold_close_steps_over_fold() {
    // View: rows 0-4. Fold rows 1-3 (closed). Cursor at start_row=1.
    // j from row 1 should go to row 4 (first visible row after fold).
    let mut e = editor_with_rows(5, 10);
    e.buffer_mut().add_fold(1, 3, true); // already closed
    e.jump_cursor(1, 0); // at fold start (visible)
    run_keys(&mut e, "j"); // step down
    let (row, _) = e.cursor();
    assert_eq!(
        row, 4,
        "j from fold start_row=1 should land on row 4 (after fold), got row={row}"
    );
}

/// BUG 2 regression: k from the row after a closed fold should jump back
/// to the fold's start_row.
#[test]
fn k_before_fold_jumps_to_fold_start() {
    let mut e = editor_with_rows(5, 10);
    e.buffer_mut().add_fold(1, 3, true); // closed, rows 1-3 hidden
    e.jump_cursor(4, 0); // row after fold
    run_keys(&mut e, "k"); // step up
    let (row, _) = e.cursor();
    assert_eq!(
        row, 1,
        "k from row 4 should land on fold start_row=1, got row={row}"
    );
}

/// BUG 2 regression: zo (open fold) with cursor on start_row must NOT
/// move the cursor (it was already visible).
#[test]
fn open_fold_keeps_cursor_on_start_row() {
    let mut e = editor_with("a\nb\nc\nd");
    e.buffer_mut().add_fold(1, 2, true); // closed
    e.jump_cursor(1, 0); // cursor at fold start (visible)
    run_keys(&mut e, "zo"); // open fold
    let (row, _) = e.cursor();
    assert_eq!(
        row, 1,
        "zo from fold start_row=1 must keep cursor at row 1, got row={row}"
    );
}

#[test]
fn dot_mark_jumps_to_last_edit_position() {
    let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
    e.jump_cursor(2, 0);
    // Insert at line 2 — sets last_edit_pos.
    run_keys(&mut e, "iX<Esc>");
    let after_edit = e.cursor();
    // Move away.
    run_keys(&mut e, "gg");
    assert_eq!(e.cursor().0, 0);
    // `'.` jumps back to the edit's row (linewise variant).
    run_keys(&mut e, "'.");
    assert_eq!(e.cursor().0, after_edit.0);
}

#[test]
fn quote_quote_returns_to_pre_jump_position() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(10, 2);
    let before = e.cursor();
    // `G` is a big jump — pushes (10, 2) onto jump_back.
    run_keys(&mut e, "G");
    assert_ne!(e.cursor(), before);
    // `''` jumps back to the pre-jump position (linewise).
    run_keys(&mut e, "''");
    assert_eq!(e.cursor().0, before.0);
}

#[test]
fn backtick_backtick_restores_exact_pre_jump_pos() {
    let mut e = editor_with_rows(50, 20);
    e.jump_cursor(7, 3);
    let before = e.cursor();
    run_keys(&mut e, "G");
    run_keys(&mut e, "``");
    assert_eq!(e.cursor(), before);
}

#[test]
fn macro_record_and_replay_basic() {
    let mut e = editor_with("foo\nbar\nbaz");
    // Record into "a": insert "X" at line start, exit insert.
    run_keys(&mut e, "qaIX<Esc>jq");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "Xfoo");
    // Replay on the next two lines.
    run_keys(&mut e, "@a");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "Xbar");
    // @@ replays the last-played macro.
    run_keys(&mut e, "j@@");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 2), "Xbaz");
}

#[test]
fn macro_count_replays_n_times() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    // Record "j" — move down once.
    run_keys(&mut e, "qajq");
    assert_eq!(e.cursor().0, 1);
    // Replay 3 times via 3@a.
    run_keys(&mut e, "3@a");
    assert_eq!(e.cursor().0, 4);
}

#[test]
fn macro_capital_q_appends_to_lowercase_register() {
    let mut e = editor_with("hello");
    run_keys(&mut e, "qall<Esc>q");
    run_keys(&mut e, "qAhh<Esc>q");
    // Macros + named registers share storage now: register `a`
    // holds the encoded keystrokes from both recordings.
    let text = e.with_registers(|r| r.read('a').unwrap().text.clone());
    assert!(text.contains("ll<Esc>"));
    assert!(text.contains("hh<Esc>"));
}

#[test]
fn buffer_selection_block_in_visual_block_mode() {
    use hjkl_buffer::{Position, Selection};
    let mut e = editor_with("aaaa\nbbbb\ncccc");
    run_keys(&mut e, "<C-v>jl");
    assert_eq!(
        e.buffer_selection(),
        Some(Selection::Block {
            anchor: Position::new(0, 0),
            head: Position::new(1, 1),
        })
    );
}

// ─── Audit batch: lock in known-good behaviour ───────────────────────

#[test]
fn n_after_question_mark_keeps_walking_backward() {
    // After committing a `?` search, `n` should continue in the
    // backward direction; `N` flips forward.
    let mut e = editor_with("foo bar foo baz foo end");
    e.jump_cursor(0, 22);
    run_keys(&mut e, "?foo<CR>");
    assert_eq!(e.cursor().1, 16);
    run_keys(&mut e, "n");
    assert_eq!(e.cursor().1, 8);
    run_keys(&mut e, "N");
    assert_eq!(e.cursor().1, 16);
}

#[test]
fn nested_macro_chord_records_literal_keys() {
    // `qa@bq` should capture `@` and `b` as literal keys in `a`,
    // not as a macro-replay invocation. Replay then re-runs them.
    let mut e = editor_with("alpha\nbeta\ngamma");
    // First record `b` as a noop-ish macro: just `l` (move right).
    run_keys(&mut e, "qblq");
    // Now record `a` as: enter insert, type X, exit, then trigger
    // `@b` which should run the macro inline during recording too.
    run_keys(&mut e, "qaIX<Esc>q");
    // `@a` re-runs the captured key sequence on a different line.
    e.jump_cursor(1, 0);
    run_keys(&mut e, "@a");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "Xbeta");
}

#[test]
fn shift_gt_motion_indents_one_line() {
    // `>w` over a single-line buffer should indent that line by
    // one shiftwidth — operator routes through the operator
    // pipeline like `dw` / `cw`.
    let mut e = editor_with("hello world");
    run_keys(&mut e, ">w");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "  hello world"
    );
}

#[test]
fn shift_lt_motion_outdents_one_line() {
    let mut e = editor_with("    hello world");
    run_keys(&mut e, "<lt>w");
    // Outdent strips up to one shiftwidth (default 2).
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "  hello world"
    );
}

#[test]
fn shift_gt_text_object_indents_paragraph() {
    let mut e = editor_with("alpha\nbeta\ngamma\n\nrest");
    e.jump_cursor(0, 0);
    run_keys(&mut e, ">ip");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "  alpha");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "  beta");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 2), "  gamma");
    // Blank separator + the next paragraph stay untouched.
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 4), "rest");
}

#[test]
fn ctrl_o_runs_exactly_one_normal_command() {
    // `Ctrl-O dw` returns to insert after the single `dw`. A
    // second `Ctrl-O` is needed for another normal command.
    let mut e = editor_with("alpha beta gamma");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "i");
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL),
    );
    run_keys(&mut e, "dw");
    // First `dw` ran in normal; we're back in insert.
    assert_eq!(e.vim_mode(), VimMode::Insert);
    // Typing a char now inserts.
    run_keys(&mut e, "X");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "Xbeta gamma"
    );
}

#[test]
fn macro_replay_respects_mode_switching() {
    // Recording `iX<Esc>0` should leave us in normal mode at col 0
    // after replay — the embedded Esc in the macro must drop the
    // replayed insert session.
    let mut e = editor_with("hi");
    run_keys(&mut e, "qaiX<Esc>0q");
    assert_eq!(e.vim_mode(), VimMode::Normal);
    // Replay on a fresh line.
    e.set_content("yo");
    run_keys(&mut e, "@a");
    assert_eq!(e.vim_mode(), VimMode::Normal);
    assert_eq!(e.cursor().1, 0);
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "Xyo");
}

#[test]
fn macro_recorded_text_round_trips_through_register() {
    // After the macros-in-registers unification, recording into
    // `a` writes the encoded keystroke text into register `a`'s
    // slot. `@a` decodes back to inputs and replays.
    let mut e = editor_with("");
    run_keys(&mut e, "qaiX<Esc>q");
    let text = e.with_registers(|r| r.read('a').unwrap().text.clone());
    assert!(text.starts_with("iX"));
    // Replay inserts another X at the cursor.
    run_keys(&mut e, "@a");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "XX");
}

#[test]
fn dot_after_macro_replays_macros_last_change() {
    // After `@a` runs a macro whose last mutation was an insert,
    // `.` should repeat that final change, not the whole macro.
    let mut e = editor_with("ab\ncd\nef");
    // Record: insert 'X' at line start, then move down. The last
    // mutation is the insert — `.` should re-apply just that.
    run_keys(&mut e, "qaIX<Esc>jq");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "Xab");
    run_keys(&mut e, "@a");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "Xcd");
    // `.` from the new cursor row repeats the last edit (the
    // insert `X`), not the whole macro (which would also `j`).
    let row_before_dot = e.cursor().0;
    run_keys(&mut e, ".");
    assert!(hjkl_buffer::rope_line_str(&e.buffer().rope(), row_before_dot).starts_with('X'));
}

// ── smartindent tests ────────────────────────────────────────────────

/// Build an editor with 4-space settings (expandtab, shiftwidth=4,
/// softtabstop=4) for smartindent tests. Does NOT inherit the
/// shiftwidth=2 override from `editor_with`.
fn si_editor(content: &str) -> Editor {
    let opts = hjkl_engine::Options {
        shiftwidth: 4,
        softtabstop: 4,
        expandtab: true,
        smartindent: true,
        autoindent: true,
        ..hjkl_engine::Options::default()
    };
    let mut e = hjkl_vim::vim_editor(
        hjkl_buffer::View::new(),
        hjkl_engine::DefaultHost::new(),
        opts,
    );
    e.set_content(content);
    e
}

#[test]
fn smartindent_bumps_indent_after_open_brace() {
    // "fn foo() {" + Enter → new line has 4 spaces of indent
    let mut e = si_editor("fn foo() {");
    e.jump_cursor(0, 10); // after the `{`
    run_keys(&mut e, "i<CR>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1),
        "    ",
        "smartindent should bump one shiftwidth after {{"
    );
}

#[test]
fn smartindent_no_bump_when_off() {
    // Same input but smartindent=false → just copies prev leading ws
    // (which is empty on "fn foo() {"), so new line is empty.
    let mut e = si_editor("fn foo() {");
    e.settings_mut().smartindent = false;
    e.jump_cursor(0, 10);
    run_keys(&mut e, "i<CR>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1),
        "",
        "without smartindent, no bump: new line copies empty leading ws"
    );
}

#[test]
fn smartindent_uses_tab_when_noexpandtab() {
    // noexpandtab + prev line ends in `{` → new line starts with `\t`
    let opts = hjkl_engine::Options {
        shiftwidth: 4,
        softtabstop: 0,
        expandtab: false,
        smartindent: true,
        autoindent: true,
        ..hjkl_engine::Options::default()
    };
    let mut e = hjkl_vim::vim_editor(
        hjkl_buffer::View::new(),
        hjkl_engine::DefaultHost::new(),
        opts,
    );
    e.set_content("fn foo() {");
    e.jump_cursor(0, 10);
    run_keys(&mut e, "i<CR>");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1),
        "\t",
        "noexpandtab: smartindent bump inserts a literal tab"
    );
}

#[test]
fn smartindent_dedent_on_close_brace() {
    // Line is "    " (4 spaces), cursor at col 4, type `}` →
    // leading spaces stripped, `}` at col 0.
    let mut e = si_editor("fn foo() {");
    // Add a second line with only indentation.
    e.set_content("fn foo() {\n    ");
    e.jump_cursor(1, 4); // end of "    "
    run_keys(&mut e, "i}");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1),
        "}",
        "close brace on whitespace-only line should dedent"
    );
    assert_eq!(e.cursor(), (1, 1), "cursor should be after the `}}`");
}

#[test]
fn smartindent_no_dedent_when_off() {
    // Same setup but smartindent=false → `}` appended normally.
    let mut e = si_editor("fn foo() {\n    ");
    e.settings_mut().smartindent = false;
    e.jump_cursor(1, 4);
    run_keys(&mut e, "i}");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 1),
        "    }",
        "without smartindent, `}}` just appends at cursor"
    );
}

#[test]
fn smartindent_no_dedent_mid_line() {
    // Line has "    let x = 1", cursor after `1`; type `}` → no
    // dedent because chars before cursor aren't all whitespace.
    let mut e = si_editor("    let x = 1");
    e.jump_cursor(0, 13); // after `1`
    run_keys(&mut e, "i}");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "    let x = 1}",
        "mid-line `}}` should not dedent"
    );
}

// ─── Vim-compat divergence fixes (issue #24) ─────────────────────

// Fix #1: x/X populate the unnamed register.
#[test]
fn count_5x_fills_unnamed_register() {
    let mut e = editor_with("hello world\n");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "5x");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), " world");
    assert_eq!(e.cursor(), (0, 0));
    assert_eq!(e.yank(), "hello");
}

#[test]
fn x_fills_unnamed_register_single_char() {
    let mut e = editor_with("abc\n");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "x");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "bc");
    assert_eq!(e.yank(), "a");
}

#[test]
fn big_x_fills_unnamed_register() {
    let mut e = editor_with("hello\n");
    e.jump_cursor(0, 3);
    run_keys(&mut e, "X");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "helo");
    assert_eq!(e.yank(), "l");
}

// Fix #2: G lands on last content row, not phantom trailing-empty row.
#[test]
fn g_motion_trailing_newline_lands_on_last_content_row() {
    let mut e = editor_with("foo\nbar\nbaz\n");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "G");
    // buffer is stored as ["foo","bar","baz",""] — G must land on row 2 ("baz").
    assert_eq!(
        e.cursor().0,
        2,
        "G should land on row 2 (baz), not row 3 (phantom empty)"
    );
}

// Fix #3: dd on last line clamps cursor to new last content row.
#[test]
fn dd_last_line_clamps_cursor_to_new_last_row() {
    let mut e = editor_with("foo\nbar\n");
    e.jump_cursor(1, 0);
    run_keys(&mut e, "dd");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "foo");
    assert_eq!(
        e.cursor(),
        (0, 0),
        "cursor should clamp to row 0 after dd on last content line"
    );
}

// Fix #4: d$ cursor lands on last char, not one past.
#[test]
fn d_dollar_cursor_on_last_char() {
    let mut e = editor_with("hello world\n");
    e.jump_cursor(0, 5);
    run_keys(&mut e, "d$");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hello");
    assert_eq!(
        e.cursor(),
        (0, 4),
        "d$ should leave cursor on col 4, not col 5"
    );
}

// Fix #5: undo clamps cursor to last valid normal-mode col.
#[test]
fn undo_insert_clamps_cursor_to_last_valid_col() {
    let mut e = editor_with("hello\n");
    e.jump_cursor(0, 5); // one-past-last, as in oracle initial_cursor
    run_keys(&mut e, "a world<Esc>u");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "hello");
    assert_eq!(
        e.cursor(),
        (0, 4),
        "undo should clamp cursor to col 4 on 'hello'"
    );
}

// Fix #6: da" eats trailing whitespace when present.
#[test]
fn da_doublequote_eats_trailing_whitespace() {
    let mut e = editor_with("say \"hello\" there\n");
    e.jump_cursor(0, 6);
    run_keys(&mut e, "da\"");
    assert_eq!(
        hjkl_buffer::rope_line_str(&e.buffer().rope(), 0),
        "say there"
    );
    assert_eq!(e.cursor().1, 4, "cursor should be at col 4 after da\"");
}

// Fix #7: daB cursor off-by-one — clamp to new last col.
#[test]
fn dab_cursor_col_clamped_after_delete() {
    let mut e = editor_with("fn x() {\n    body\n}\n");
    e.jump_cursor(1, 4);
    run_keys(&mut e, "daB");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "fn x() ");
    assert_eq!(
        e.cursor(),
        (0, 6),
        "daB should leave cursor at col 6, not 7"
    );
}

// Fix #8: diB preserves surrounding newlines on multi-line block.
#[test]
fn dib_preserves_surrounding_newlines() {
    let mut e = editor_with("{\n    body\n}\n");
    e.jump_cursor(1, 4);
    run_keys(&mut e, "diB");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "{");
    assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 1), "}");
    assert_eq!(e.cursor().0, 1, "cursor should be on the '}}' line");
}

#[test]
fn is_chord_pending_tracks_replace_state() {
    let mut e = editor_with("abc\n");
    assert!(!e.is_chord_pending());
    // Press `r` — engine enters Pending::Replace.
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
    );
    assert!(e.is_chord_pending(), "engine should be pending after r");
    // Press a char to complete — pending clears.
    hjkl_vim_tui::handle_key(
        &mut e,
        KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
    );
    assert!(
        !e.is_chord_pending(),
        "engine pending should clear after replace"
    );
}

// ─── Special marks `[` / `]` (vim `:h '[` / `:h ']`) ────────────────────

#[test]
fn yiw_sets_lbr_rbr_marks_around_word() {
    // `yiw` on "hello" — charwise exclusive range. `[` = col 0,
    // `]` = col 4 (last char of "hello").
    let mut e = editor_with("hello world");
    run_keys(&mut e, "yiw");
    let lo = e.mark('[').expect("'[' must be set after yiw");
    let hi = e.mark(']').expect("']' must be set after yiw");
    assert_eq!(lo, (0, 0), "'[ should be first char of yanked word");
    assert_eq!(hi, (0, 4), "'] should be last char of yanked word");
}

#[test]
fn yj_linewise_sets_marks_at_line_edges() {
    // `yj` yanks 2 lines linewise. `[` = (0, 0), `]` = (1, last_col).
    // "bbbbb" is 5 chars — last_col = 4.
    let mut e = editor_with("aaaaa\nbbbbb\nccc");
    run_keys(&mut e, "yj");
    let lo = e.mark('[').expect("'[' must be set after yj");
    let hi = e.mark(']').expect("']' must be set after yj");
    assert_eq!(lo, (0, 0), "'[ snaps to (top_row, 0) for linewise yank");
    assert_eq!(
        hi,
        (1, 4),
        "'] snaps to (bot_row, last_col) for linewise yank"
    );
}

#[test]
fn dd_sets_lbr_rbr_marks_to_cursor() {
    // `dd` on the first of two lines — post-delete cursor is row 0.
    // Both marks must park there (vim `:h '[` delete rule).
    let mut e = editor_with("aaa\nbbb");
    run_keys(&mut e, "dd");
    let lo = e.mark('[').expect("'[' must be set after dd");
    let hi = e.mark(']').expect("']' must be set after dd");
    assert_eq!(lo, hi, "after delete both marks are at the same position");
    assert_eq!(lo.0, 0, "post-delete cursor row should be 0");
}

#[test]
fn dw_sets_lbr_rbr_marks_to_cursor() {
    // `dw` on "hello world" — deletes "hello ". Post-delete cursor
    // stays at col 0. Both marks land there.
    let mut e = editor_with("hello world");
    run_keys(&mut e, "dw");
    let lo = e.mark('[').expect("'[' must be set after dw");
    let hi = e.mark(']').expect("']' must be set after dw");
    assert_eq!(lo, hi, "after delete both marks are at the same position");
    assert_eq!(lo, (0, 0), "post-dw cursor is at col 0");
}

#[test]
fn cw_then_esc_sets_lbr_at_start_rbr_at_inserted_text_end() {
    // `cw` on "hello world" → deletes "hello", enters insert, types
    // "foo", then Esc. `[` = start of change = (0,0). `]` = last
    // typed char = (0,2) ("foo" spans cols 0-2; cursor is at col 2
    // during finish_insert_session, before the Esc step-back).
    let mut e = editor_with("hello world");
    run_keys(&mut e, "cwfoo<Esc>");
    let lo = e.mark('[').expect("'[' must be set after cw");
    let hi = e.mark(']').expect("']' must be set after cw");
    assert_eq!(lo, (0, 0), "'[ should be start of change");
    // "foo" is 3 chars; cursor was at col 3 (past end) at finish_insert_session
    // before step-back. `]` = col 3 (the position during finish).
    assert_eq!(hi.0, 0, "'] should be on row 0");
    assert!(hi.1 >= 2, "'] should be at or past last char of 'foo'");
}

#[test]
fn cw_with_no_insertion_sets_marks_at_change_start() {
    // `cw<Esc>` with no chars typed. Both marks land at the change
    // start (cursor parks at col 0 after cut).
    let mut e = editor_with("hello world");
    run_keys(&mut e, "cw<Esc>");
    let lo = e.mark('[').expect("'[' must be set after cw<Esc>");
    let hi = e.mark(']').expect("']' must be set after cw<Esc>");
    assert_eq!(lo.0, 0, "'[ should be on row 0");
    assert_eq!(hi.0, 0, "'] should be on row 0");
    // Both marks at the same position when nothing was typed.
    assert_eq!(lo, hi, "marks coincide when insert is empty");
}

#[test]
fn p_charwise_sets_marks_around_pasted_text() {
    // `yiw` yanks "abc", then `p` pastes after the cursor.
    // `[` = first pasted char position, `]` = last pasted char.
    let mut e = editor_with("abc xyz");
    run_keys(&mut e, "yiw"); // yank "abc" (exclusive, last yanked = col 2)
    run_keys(&mut e, "p"); // paste after cursor (at col 1, the 'b')
    let lo = e.mark('[').expect("'[' set after charwise paste");
    let hi = e.mark(']').expect("']' set after charwise paste");
    assert!(lo <= hi, "'[ must not exceed ']'");
    // The pasted text is "abc" (3 chars). Marks bracket exactly 3 cols.
    assert_eq!(
        hi.1.wrapping_sub(lo.1),
        2,
        "'] - '[ should span 2 cols for a 3-char paste"
    );
}

#[test]
fn p_linewise_sets_marks_at_line_edges() {
    // Yank 2 lines linewise (`yj`), paste below (`p`).
    // `[` = (target_row, 0), `]` = (target_row+1, last_col_of_second_line).
    let mut e = editor_with("aaa\nbbb\nccc");
    run_keys(&mut e, "yj"); // yank rows 0-1 linewise
    run_keys(&mut e, "j"); // cursor to row 1
    run_keys(&mut e, "p"); // paste below row 1
    let lo = e.mark('[').expect("'[' set after linewise paste");
    let hi = e.mark(']').expect("']' set after linewise paste");
    assert_eq!(lo.1, 0, "'[ col must be 0 for linewise paste");
    assert!(hi.0 > lo.0, "'] row must be below '[ row for 2-line paste");
    assert_eq!(hi.0 - lo.0, 1, "exactly 1 row gap for a 2-line payload");
}

#[test]
fn backtick_lbr_v_backtick_rbr_reselects_yanked_text() {
    // Vim idiom: after `yiw`, `` `[v`] `` re-selects exactly the
    // yanked word in charwise visual. The marks must bracket the
    // yanked text end-to-end for this idiom to work.
    let mut e = editor_with("hello world");
    run_keys(&mut e, "yiw"); // yank "hello"
    // Jump to `[`, enter visual, jump to `]`.
    // run_keys uses backtick as a plain char in goto-mark-char path.
    run_keys(&mut e, "`[v`]");
    // Cursor should now be on col 4 (last char of "hello").
    assert_eq!(
        e.cursor(),
        (0, 4),
        "visual `[v`] should land on last yanked char"
    );
    // The mode should be Visual (selection active).
    assert_eq!(
        e.vim_mode(),
        hjkl_engine::VimMode::Visual,
        "should be in Visual mode"
    );
}

// ── Vim-compat divergence regression tests (kryptic-sh/hjkl#83) ──────────

/// Bug 1: `` `. `` after `iX<Esc>` should land at the *start* of the
/// insert (col 0), not one past the last inserted char. vim's `:h '.`
/// says the mark is the position where the last change was made.
#[test]
fn mark_dot_jump_to_last_edit_pre_edit_cursor() {
    // "hello\nworld\n", cursor (0,0). `iX<Esc>` inserts "X" at col 0;
    // dot mark should land on col 0 (change start), not col 1 (post-insert).
    let mut e = editor_with("hello\nworld\n");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "iX<Esc>j`.");
    assert_eq!(
        e.cursor(),
        (0, 0),
        "dot mark should jump to the change-start (col 0), not post-insert col"
    );
}

/// Bug 2: `100G` on a buffer with a trailing newline should clamp to the
/// last content row, not land on the phantom empty row after the `\n`.
#[test]
fn count_100g_clamps_to_last_content_row() {
    // "foo\nbar\nbaz\n" has 4 rows in the buffer (row 3 is the phantom
    // empty row after the trailing \n). `100G` should land on row 2.
    let mut e = editor_with("foo\nbar\nbaz\n");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "100G");
    assert_eq!(
        e.cursor(),
        (2, 0),
        "100G on trailing-newline buffer must clamp to row 2 (last content row)"
    );
}

/// Bug 3: `gi` should return to the row *and* column where insert mode
/// was last active (the pre-step-back position), then enter insert.
#[test]
fn gi_resumes_last_insert_position() {
    // "world\nhello\n", cursor (0,0).
    // `iHi<Esc>` inserts "Hi" at (0,0); Esc steps back to (0,1).
    // `j` moves to row 1. `gi` should jump back to (0,2) — the position
    // that was live during insert — and enter insert. `<Esc>` then steps
    // back to (0,1), leaving the cursor at (0,1) in Normal mode.
    let mut e = editor_with("world\nhello\n");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "iHi<Esc>jgi<Esc>");
    assert_eq!(
        e.vim_mode(),
        hjkl_engine::VimMode::Normal,
        "should be in Normal mode after gi<Esc>"
    );
    assert_eq!(
        e.cursor(),
        (0, 1),
        "gi<Esc> cursor should be at (0,1) — the insert row, step-back col"
    );
}

/// Bug 4: `<C-v>jlc<text><Esc>` — after blockwise change the cursor
/// should sit on the last char of the inserted text (`col 1` for "ZZ"),
/// not at the block start (`col 0`). View result must still be correct.
#[test]
fn visual_block_change_cursor_on_last_inserted_char() {
    // "foo\nbar\nbaz\n", cursor (0,0). Block covers rows 0-1, cols 0-1.
    // `cZZ` replaces cols 0-1 on each row with "ZZ". View becomes
    // "ZZo\nZZr\nbaz\n". Cursor should be at (0,1) — last char of "ZZ".
    let mut e = editor_with("foo\nbar\nbaz\n");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "<C-v>jlcZZ<Esc>");
    let lines = rope_to_lines_vec(&e.buffer().rope());
    assert_eq!(lines[0], "ZZo", "row 0 should be 'ZZo'");
    assert_eq!(lines[1], "ZZr", "row 1 should be 'ZZr'");
    assert_eq!(
        e.cursor(),
        (0, 1),
        "cursor should be on last char of inserted 'ZZ' (col 1)"
    );
}

/// Bug 5: `"_dw` (black-hole delete) must not overwrite the unnamed
/// register. After `yiw` the unnamed register holds "foo". A subsequent
/// `"_dw` discards "bar " into the void, leaving "foo" intact. `b p`
/// then pastes "foo" to produce "ffoooo baz\n".
#[test]
fn register_blackhole_delete_preserves_unnamed_register() {
    // "foo bar baz\n", cursor (0,0).
    // `yiw` — yank "foo" into " and "0.
    // `w`   — cursor to (0,4) = 'b'.
    // `"_dw` — black-hole delete "bar "; unnamed must still be "foo".
    // `b`   — back to (0,0).
    // `p`   — paste "foo" after 'f' → "ffoooo baz\n".
    let mut e = editor_with("foo bar baz\n");
    e.jump_cursor(0, 0);
    run_keys(&mut e, "yiww\"_dwbp");
    let lines = rope_to_lines_vec(&e.buffer().rope());
    assert_eq!(
        lines[0], "ffoooo baz",
        "black-hole delete must not corrupt unnamed register"
    );
    assert_eq!(
        e.cursor(),
        (0, 3),
        "cursor should be on last pasted char (col 3)"
    );
}

// ── after_z controller API (Phase 2b-iii) ───────────────────────────────

#[test]
fn after_z_zz_sets_viewport_pinned() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    e.jump_cursor(2, 0);
    e.after_z('z', 1);
    assert!(e.viewport_pinned(), "zz must set viewport_pinned");
}

#[test]
fn after_z_zo_opens_fold_at_cursor() {
    let mut e = editor_with("a\nb\nc\nd");
    e.buffer_mut().add_fold(1, 2, true);
    e.jump_cursor(1, 0);
    e.after_z('o', 1);
    assert!(
        !e.buffer().folds()[0].closed,
        "zo must open the fold at the cursor row"
    );
}

#[test]
fn after_z_zm_closes_all_folds() {
    let mut e = editor_with("a\nb\nc\nd\ne\nf");
    e.buffer_mut().add_fold(0, 1, false);
    e.buffer_mut().add_fold(4, 5, false);
    e.after_z('M', 1);
    assert!(
        e.buffer().folds().iter().all(|f| f.closed),
        "zM must close all folds"
    );
}

#[test]
fn after_z_zd_removes_fold_at_cursor() {
    let mut e = editor_with("a\nb\nc\nd");
    e.buffer_mut().add_fold(1, 2, true);
    e.jump_cursor(1, 0);
    e.after_z('d', 1);
    assert!(
        e.buffer().folds().is_empty(),
        "zd must remove the fold at the cursor row"
    );
}

#[test]
fn after_z_zf_in_visual_creates_fold() {
    let mut e = editor_with("a\nb\nc\nd\ne");
    // Enter visual mode spanning rows 1..=3.
    e.jump_cursor(1, 0);
    run_keys(&mut e, "V2j");
    // Now call after_z('f') — reads visual mode + anchors internally.
    e.after_z('f', 1);
    let folds = e.buffer().folds();
    assert_eq!(folds.len(), 1, "zf in visual must create exactly one fold");
    assert_eq!(folds[0].start_row, 1);
    assert_eq!(folds[0].end_row, 3);
    assert!(folds[0].closed);
}

// ── apply_op_motion_key / apply_op_double / enter_op_* unit tests ─────────

#[test]
fn apply_op_motion_dw_deletes_word() {
    // "hello world" — dw should delete "hello ".
    let mut e = editor_with("hello world");
    e.apply_op_motion(hjkl_engine::Operator::Delete, 'w', 1);
    assert_eq!(
        e.buffer()
            .rope()
            .lines()
            .next()
            .map(|s| {
                let s = s.to_string();
                s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
            })
            .unwrap_or_default(),
        "world"
    );
}

#[test]
fn apply_op_motion_cw_quirk_leaves_trailing_space() {
    // "hello world" — cw uses ce quirk: deletes "hello" not "hello ".
    let mut e = editor_with("hello world");
    e.apply_op_motion(hjkl_engine::Operator::Change, 'w', 1);
    // After ce, cursor is at 0; mode enters Insert. Line should be " world"
    // (trailing space from original gap preserved).
    let line = e
        .buffer()
        .rope()
        .lines()
        .next()
        .map(|s| {
            let s = s.to_string();
            s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
        })
        .unwrap_or_default();
    assert!(
        line.starts_with(' ') || line == " world",
        "cw quirk: got {line:?}"
    );
    assert_eq!(e.vim_mode(), VimMode::Insert);
}

#[test]
fn apply_op_double_dd_deletes_line() {
    let mut e = editor_with("line1\nline2\nline3");
    // dd on first line.
    e.apply_op_double(hjkl_engine::Operator::Delete, 1);
    let lines: Vec<_> = rope_to_lines_vec(&e.buffer().rope());
    assert_eq!(lines, vec!["line2", "line3"], "dd should delete line1");
}

#[test]
fn apply_op_double_yy_does_not_modify_buffer() {
    let mut e = editor_with("hello");
    e.apply_op_double(hjkl_engine::Operator::Yank, 1);
    assert_eq!(
        e.buffer()
            .rope()
            .lines()
            .next()
            .map(|s| {
                let s = s.to_string();
                s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
            })
            .unwrap_or_default(),
        "hello"
    );
}

#[test]
fn apply_op_double_dd_count2_deletes_two_lines() {
    let mut e = editor_with("line1\nline2\nline3");
    e.apply_op_double(hjkl_engine::Operator::Delete, 2);
    let lines: Vec<_> = rope_to_lines_vec(&e.buffer().rope());
    assert_eq!(lines, vec!["line3"], "2dd should delete two lines");
}

#[test]
fn apply_op_motion_unknown_key_is_noop() {
    // A key that parse_motion returns None for — should be a no-op.
    let mut e = editor_with("hello");
    let before = e.cursor();
    e.apply_op_motion(hjkl_engine::Operator::Delete, 'X', 1); // 'X' is not a motion
    assert_eq!(e.cursor(), before);
    assert_eq!(
        e.buffer()
            .rope()
            .lines()
            .next()
            .map(|s| {
                let s = s.to_string();
                s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
            })
            .unwrap_or_default(),
        "hello"
    );
}

// ── apply_op_find tests ──────────────────────────────────────────────────

#[test]
fn apply_op_find_dfx_deletes_to_x() {
    // `dfx` in "hello x world" from col 0 → deletes "hello x" (inclusive).
    let mut e = editor_with("hello x world");
    e.apply_op_find(hjkl_engine::Operator::Delete, 'x', true, false, 1);
    assert_eq!(
        e.buffer()
            .rope()
            .lines()
            .next()
            .map(|s| {
                let s = s.to_string();
                s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
            })
            .unwrap_or_default(),
        " world",
        "dfx must delete 'hello x'"
    );
}

#[test]
fn apply_op_find_dtx_deletes_up_to_x() {
    // `dtx` in "hello x world" from col 0 → deletes up to but not including 'x'.
    let mut e = editor_with("hello x world");
    e.apply_op_find(hjkl_engine::Operator::Delete, 'x', true, true, 1);
    assert_eq!(
        e.buffer()
            .rope()
            .lines()
            .next()
            .map(|s| {
                let s = s.to_string();
                s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
            })
            .unwrap_or_default(),
        "x world",
        "dtx must delete 'hello ' leaving 'x world'"
    );
}

#[test]
fn apply_op_find_records_last_find() {
    // After apply_op_find, vim.last_find should be set for ;/, repeat.
    let mut e = editor_with("hello x world");
    e.apply_op_find(hjkl_engine::Operator::Delete, 'x', true, false, 1);
    // Access last_find via find_char with a repeat (semicolon motion).
    // We verify indirectly: the engine is not chord-pending and the
    // method completed without panic. Directly inspecting vim.last_find
    // is not on the public surface, so use a `;` repeat to confirm.
    // (If last_find were not set, the `;` would be a no-op and not panic.)
    let _ = e.cursor(); // just ensure the editor is still valid
}

// ── apply_op_text_obj tests ──────────────────────────────────────────────

#[test]
fn apply_op_text_obj_diw_deletes_word() {
    // `diw` in "hello world" with cursor on 'h' (col 0) → deletes "hello".
    let mut e = editor_with("hello world");
    e.apply_op_text_obj(hjkl_engine::Operator::Delete, 'w', true, 1);
    let line = e
        .buffer()
        .rope()
        .lines()
        .next()
        .map(|s| {
            let s = s.to_string();
            s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
        })
        .unwrap_or_default();
    // `diw` on "hello" leaves " world" or "world" depending on whitespace handling.
    // The engine's word text-object for 'inner' removes the word itself; the
    // surrounding space behaviour is covered by the engine's text-object logic.
    // We just assert "hello" is gone.
    assert!(
        !line.contains("hello"),
        "diw must delete 'hello', remaining: {line:?}"
    );
}

#[test]
fn apply_op_text_obj_daw_deletes_around_word() {
    // `daw` in "hello world" with cursor on 'h' (col 0) → deletes "hello " (with space).
    let mut e = editor_with("hello world");
    e.apply_op_text_obj(hjkl_engine::Operator::Delete, 'w', false, 1);
    let line = e
        .buffer()
        .rope()
        .lines()
        .next()
        .map(|s| {
            let s = s.to_string();
            s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
        })
        .unwrap_or_default();
    assert!(
        !line.contains("hello"),
        "daw must delete 'hello' and surrounding space, remaining: {line:?}"
    );
}

#[test]
fn apply_op_text_obj_invalid_char_no_op() {
    // An unrecognised char (e.g. 'X') should be a no-op — buffer unchanged.
    let mut e = editor_with("hello world");
    let before = e.buffer().as_string();
    e.apply_op_text_obj(hjkl_engine::Operator::Delete, 'X', true, 1);
    assert_eq!(
        e.buffer().as_string(),
        before,
        "unknown text-object char must be a no-op"
    );
}

// ── apply_op_g tests ─────────────────────────────────────────────────────

#[test]
fn apply_op_g_dgg_deletes_to_top() {
    // `dgg` in 3-line buffer with cursor on row 1 → deletes rows 0..=1,
    // leaving only "line3".
    //
    // Before the Phase 4e linewise guard fix, `run_operator_over_range`
    // bailed unconditionally when `top == bot`. This test was originally
    // written using `apply_op_motion(Delete, 'j', 1)` to "move" the
    // cursor (which actually deleted rows 0..=1 via `dj`, leaving only
    // "line3"), then called `dgg` from row 0 → `top == bot == (0,0)` →
    // old guard bailed → buffer stayed `["line3"]`. The assertion passed
    // for the wrong reason. Now we use `jump_cursor` to position without
    // deleting, and the guard is conditioned on non-Linewise so `dgg`
    // from row 1 deletes rows 0..=1 correctly.
    let mut e = editor_with("line1\nline2\nline3");
    // Position cursor on row 1 without deleting anything.
    e.jump_cursor(1, 0);
    // dgg: Delete from current row to FileTop (row 0). Motion is Linewise,
    // so rows 0..=1 are deleted. "line3" remains.
    e.apply_op_g(hjkl_engine::Operator::Delete, 'g', 1);
    let lines: Vec<_> = rope_to_lines_vec(&e.buffer().rope());
    assert_eq!(lines, vec!["line3"], "dgg must delete to file top");
}

#[test]
fn apply_op_g_dge_deletes_word_end_back() {
    // `dge` — WordEndBack motion. Test that apply_op_g with 'e' fires a
    // deletion that changes the buffer when cursor is positioned mid-line.
    // Use a two-line buffer: start cursor on line 1, col 0. `dge` on line 1
    // col 0 is a no-op (nothing behind), so we first jump to line 0 col 4
    // by using dgg trick in reverse:  just verify unknown char is a no-op,
    // and 'e' with cursor past col 0 actually fires.
    //
    // Simplest shape: "ab cd" with cursor at col 3 ('c').
    // ge → end of "ab" = col 1. Delete [col 1 .. col 3] inclusive → "a cd".
    // We position cursor using jump_cursor (internal), but that's not public.
    // Instead use the fact that apply_op_g with a completely unknown char
    // should be a no-op, ensuring the function is reachable and safe.
    let mut e = editor_with("hello world");
    let before = e.buffer().as_string();
    // Unknown char → no-op.
    e.apply_op_g(hjkl_engine::Operator::Delete, 'X', 1);
    assert_eq!(
        e.buffer().as_string(),
        before,
        "apply_op_g with unknown char must be a no-op"
    );
    // 'e' at col 0 with no previous word → no-op (nothing to go back to).
    e.apply_op_g(hjkl_engine::Operator::Delete, 'e', 1);
    // View may or may not change; just assert no panic.
}

#[test]
fn apply_op_g_dgj_deletes_screen_down() {
    // `dgj` on first line of a 3-line buffer → deletes current + next
    // screen line (which is the same as buffer line in non-wrapped content).
    let mut e = editor_with("line1\nline2\nline3");
    e.apply_op_g(hjkl_engine::Operator::Delete, 'j', 1);
    let lines: Vec<_> = rope_to_lines_vec(&e.buffer().rope());
    // dgj deletes current line plus the line below it.
    assert_eq!(lines, vec!["line3"], "dgj must delete current+next line");
}

// ── set_pending_register unit tests ─────────────────────────────────────

fn blank_editor() -> Editor {
    hjkl_vim::vim_editor(
        hjkl_buffer::View::new(),
        hjkl_engine::DefaultHost::new(),
        hjkl_engine::Options::default(),
    )
}

#[test]
fn set_pending_register_valid_letter_sets_field() {
    let mut e = blank_editor();
    assert!(e.pending_register().is_none());
    e.set_pending_register('a');
    assert_eq!(e.pending_register(), Some('a'));
}

#[test]
fn set_pending_register_invalid_char_no_op() {
    let mut e = blank_editor();
    e.set_pending_register('!');
    assert!(
        e.pending_register().is_none(),
        "invalid register char must not set pending_register"
    );
}

#[test]
fn set_pending_register_special_plus_sets_field() {
    // '+' is the system clipboard register.
    let mut e = blank_editor();
    e.set_pending_register('+');
    assert_eq!(e.pending_register(), Some('+'));
}

#[test]
fn set_pending_register_star_sets_field() {
    // '*' is the primary clipboard register.
    let mut e = blank_editor();
    e.set_pending_register('*');
    assert_eq!(e.pending_register(), Some('*'));
}

#[test]
fn set_pending_register_underscore_sets_field() {
    // '_' is the black-hole register.
    let mut e = blank_editor();
    e.set_pending_register('_');
    assert_eq!(e.pending_register(), Some('_'));
}

// ── AutoIndent (`=`) FSM tests ───────────────────────────────────────────────

/// Helper: build an editor with shiftwidth=4 expandtab=true.
fn indent_editor_vim(content: &str) -> Editor {
    let opts = hjkl_engine::Options {
        shiftwidth: 4,
        expandtab: true,
        ..hjkl_engine::Options::default()
    };
    let mut e = hjkl_vim::vim_editor(
        hjkl_buffer::View::new(),
        hjkl_engine::DefaultHost::new(),
        opts,
    );
    e.set_content(content);
    e
}

#[test]
fn equal_then_motion_emits_auto_indent_via_fsm() {
    // `=j` — `=` enters op-pending, `j` completes the motion.
    // The FSM should reindent rows 0 and 1 of `{\nbody\n}`.
    let mut e = indent_editor_vim("{\nbody\n}");
    // Start on row 0; `=j` covers rows 0..=1.
    run_keys(&mut e, "=j");
    let lines = rope_to_lines_vec(&e.buffer().rope());
    // Row 0 (`{`) — depth 0 at start, no indent.
    assert_eq!(lines[0], "{");
    // Row 1 (`body`) — depth 1 after `{`, should be indented 4 spaces.
    assert_eq!(lines[1], "    body");
}

#[test]
fn double_equal_reindents_current_line() {
    // `==` — doubled form reindents the current line only.
    let mut e = indent_editor_vim("{\nbody\n}");
    // Move to row 1 then `==`.
    run_keys(&mut e, "j==");
    let lines = rope_to_lines_vec(&e.buffer().rope());
    // Row 1 is now properly indented under the `{`.
    assert_eq!(lines[1], "    body");
    // Row 0 and 2 should be unchanged.
    assert_eq!(lines[0], "{");
    assert_eq!(lines[2], "}");
}

// ── sticky_col (curswant) invariant tests ─────────────────────────────────────

/// `gg` to a line with leading indent, then `j` — cursor should aim for the
/// first-non-blank col of the destination, not col 0.
#[test]
fn gg_then_j_preserves_first_non_blank_col() {
    // Row 0: "hello" (col 0 is 'h')
    // Row 1: "    world" (first-non-blank at col 4)
    // Row 2: "    end"   (first-non-blank at col 4)
    let mut e = editor_with("hello\n    world\n    end\n");
    // Start deep in the buffer so gg actually moves.
    e.jump_cursor(2, 0);
    // gg → row 0, first-non-blank = col 0; sticky_col must be set to 0.
    run_keys(&mut e, "gg");
    assert_eq!(e.cursor().0, 0, "gg must land on row 0");
    // j → row 1; sticky_col=0 so cursor should be at col 0 (clamped to 0
    // since "    world" has content from col 0 onward and want=0 is valid).
    run_keys(&mut e, "j");
    let (row, col) = e.cursor();
    assert_eq!(row, 1, "j after gg must move to row 1");
    // sticky_col was reset by gg to the first-non-blank col (0 for "hello"),
    // so j lands at col 0 on row 1.
    assert_eq!(
        col, 0,
        "j after gg must aim for col 0 (first-non-blank of row 0)"
    );
}

/// `G` to the last line, then `k` — cursor should aim for the column where
/// `G` landed (first-non-blank of last row), not a stale column.
#[test]
fn g_cap_then_k_preserves_landed_col() {
    // Row 0: "hello world" (11 chars)
    // Row 1: "short"       (5 chars)
    // Row 2: "    end"     (first-non-blank col 4)
    let mut e = editor_with("hello world\nshort\n    end\n");
    // Cursor at (0, 10) — far right of row 0.
    e.jump_cursor(0, 10);
    // G → row 2, first-non-blank = col 4; jump_cursor resets sticky_col.
    run_keys(&mut e, "G");
    let (row_g, col_g) = e.cursor();
    assert_eq!(row_g, 2, "G must land on last row");
    // k → row 1; sticky_col was set to col_g by G's jump_cursor call, so
    // k should aim for col_g (clamped to len("short")-1 = 4).
    run_keys(&mut e, "k");
    let (row_k, col_k) = e.cursor();
    assert_eq!(row_k, 1, "k after G must move to row 1");
    let expected = col_g.min(4); // "short" has 5 chars, max col = 4
    assert_eq!(
        col_k, expected,
        "k after G must aim for col {col_g} (clamped to {expected} on 'short'); \
         got col {col_k} — sticky_col may be stale"
    );
}

/// `jump_cursor(2, 5)` then `j` — cursor should land at (3, 5) or clamped.
#[test]
fn jump_cursor_then_j_preserves_jumped_col() {
    // Row 0: "hello"      (5 chars, max col 4)
    // Row 1: "world test" (10 chars, max col 9)
    // Row 2: "abcdefgh"   (8 chars, max col 7)
    // Row 3: "0123456789" (10 chars, max col 9)
    let mut e = editor_with("hello\nworld test\nabcdefgh\n0123456789\n");
    // Use the engine API directly to set cursor + sticky_col.
    e.jump_cursor(2, 5);
    assert_eq!(e.cursor(), (2, 5), "jump_cursor must land at (2,5)");
    assert_eq!(
        e.sticky_col(),
        Some(5),
        "jump_cursor must set sticky_col to 5"
    );
    // j → row 3; sticky_col=5, "0123456789" has 10 chars so target = 5.
    run_keys(&mut e, "j");
    let (row, col) = e.cursor();
    assert_eq!(row, 3, "j must move to row 3");
    assert_eq!(col, 5, "j must aim for col 5 set by jump_cursor; got {col}");
}

/// Core curswant invariant: cursor at col 10, `j` through a 3-char line
/// (clamps to col 2), `j` again to a 12-char line — must land col 10, NOT
/// col 2. Validates that `apply_sticky_col` in vim.rs preserves the
/// un-clamped `want` and that the raw `buf_set_cursor_rc` call there does
/// NOT reset `sticky_col`.
#[test]
fn j_through_short_line_preserves_want() {
    // Row 0: "0123456789X" (11 chars, max col 10)
    // Row 1: "abc"         (3 chars, max col 2)  — short line
    // Row 2: "0123456789AB" (12 chars, max col 11) — long line
    let mut e = editor_with("0123456789X\nabc\n0123456789AB\n");
    // Park at (0, 10).
    e.jump_cursor(0, 10);
    assert_eq!(e.cursor(), (0, 10));
    // j → row 1 (short line, clamps to col 2); sticky_col must STAY 10.
    run_keys(&mut e, "j");
    let (row1, col1) = e.cursor();
    assert_eq!(row1, 1, "j must reach row 1");
    assert_eq!(col1, 2, "j on short line must clamp to col 2");
    assert_eq!(
        e.sticky_col(),
        Some(10),
        "sticky_col must remain 10 after clamping through short line"
    );
    // j → row 2 (long line); sticky_col=10 so cursor must land at col 10.
    run_keys(&mut e, "j");
    let (row2, col2) = e.cursor();
    assert_eq!(row2, 2, "j must reach row 2");
    assert_eq!(
        col2, 10,
        "j on long line must restore to col 10 (curswant); got col {col2} — \
         apply_sticky_col may have reset sticky_col with jump_cursor"
    );
}

#[test]
fn j_through_empty_line_preserves_want() {
    // Row 0: "0123456789X" (11 chars, max col 10)
    // Row 1: ""            (empty)
    // Row 2: "0123456789AB" (12 chars, max col 11)
    let mut e = editor_with("0123456789X\n\n0123456789AB\n");
    // Park at (0, 10).
    e.jump_cursor(0, 10);
    assert_eq!(e.cursor(), (0, 10));
    // j → row 1 (empty line, clamps to col 0); sticky_col must STAY 10.
    run_keys(&mut e, "j");
    let (row1, col1) = e.cursor();
    assert_eq!(row1, 1, "j must reach row 1");
    assert_eq!(col1, 0, "j on empty line must clamp to col 0");
    assert_eq!(
        e.sticky_col(),
        Some(10),
        "sticky_col must remain 10 after clamping through empty line"
    );
    // j → row 2 (long line); sticky_col=10 so cursor must land at col 10.
    run_keys(&mut e, "j");
    let (row2, col2) = e.cursor();
    assert_eq!(row2, 2, "j must reach row 2");
    assert_eq!(
        col2, 10,
        "j on long line must restore to col 10 (curswant); got col {col2}"
    );
}

#[test]
fn j_through_multiple_empty_lines_preserves_want() {
    // Row 0: "0123456789X" (11 chars, max col 10)
    // Rows 1-3: empty
    // Row 4: "0123456789AB" (12 chars, max col 11)
    let mut e = editor_with("0123456789X\n\n\n\n0123456789AB\n");
    // Park at (0, 10).
    e.jump_cursor(0, 10);
    assert_eq!(e.cursor(), (0, 10));
    // j j j → row 3 (last empty row); sticky_col must STAY 10.
    run_keys(&mut e, "jjj");
    let (row3, col3) = e.cursor();
    assert_eq!(row3, 3, "jjj must reach row 3");
    assert_eq!(col3, 0, "jjj on empty line must clamp to col 0");
    assert_eq!(
        e.sticky_col(),
        Some(10),
        "sticky_col must remain 10 after clamping through multiple empty lines"
    );
    // j → row 4 (long line); sticky_col=10 so cursor must land at col 10.
    run_keys(&mut e, "j");
    let (row4, col4) = e.cursor();
    assert_eq!(row4, 4, "j must reach row 4");
    assert_eq!(
        col4, 10,
        "j on long line must restore to col 10 (curswant); got col {col4}"
    );
}

#[test]
fn k_through_empty_line_preserves_want() {
    // Row 0: "0123456789X" (11 chars, max col 10)
    // Row 1: ""            (empty)
    // Row 2: "0123456789AB" (12 chars, max col 11)
    let mut e = editor_with("0123456789X\n\n0123456789AB\n");
    // Park at (2, 10).
    e.jump_cursor(2, 10);
    assert_eq!(e.cursor(), (2, 10));
    // k → row 1 (empty line, clamps to col 0); sticky_col must STAY 10.
    run_keys(&mut e, "k");
    let (row1, col1) = e.cursor();
    assert_eq!(row1, 1, "k must reach row 1");
    assert_eq!(col1, 0, "k on empty line must clamp to col 0");
    assert_eq!(
        e.sticky_col(),
        Some(10),
        "sticky_col must remain 10 after clamping through empty line going up"
    );
    // k → row 0 (long line); sticky_col=10 so cursor must land at col 10.
    run_keys(&mut e, "k");
    let (row0, col0) = e.cursor();
    assert_eq!(row0, 0, "k must reach row 0");
    assert_eq!(
        col0, 10,
        "k on long line must restore to col 10 (curswant); got col {col0}"
    );
}

#[test]
fn gj_through_empty_line_preserves_want_in_wrap_mode() {
    // In wrap mode with a narrow viewport, gj/gk should preserve sticky_col
    // across visual lines, including empty doc lines.
    // Row 0: "0123456789X"  (11 chars, wraps at width 8: "01234567" + "89X")
    // Row 1: ""             (empty doc line → one empty visual line)
    // Row 2: "0123456789AB" (12 chars, wraps at width 8: "01234567" + "89AB")
    let mut e = editor_with_wrap_lines(&["0123456789X", "", "0123456789AB"], 20, 8);
    // Park at (0, 10) — col 10 on "0123456789X".
    e.jump_cursor(0, 10);
    assert_eq!(e.cursor(), (0, 10));
    // gj moves one visual line down within doc row 0 (from seg 1 "89X" to row 1 empty).
    // After crossing into the empty row, sticky_col must remain 10.
    run_keys(&mut e, "gj");
    let (row1, _col1) = e.cursor();
    assert_eq!(row1, 1, "gj must reach doc row 1 (empty)");
    assert_eq!(
        e.sticky_col(),
        Some(10),
        "sticky_col must remain 10 after gj through empty line in wrap mode"
    );
    // gj from empty row → doc row 2; sticky_col=10 so col 10 restored.
    run_keys(&mut e, "gj");
    let (row2, col2) = e.cursor();
    assert_eq!(row2, 2, "gj must reach doc row 2");
    assert_eq!(
        col2, 10,
        "gj must restore col 10 on long line; got col {col2}"
    );
}

// ─── Issue #31: new motions [[, ]], [], ][, +/<CR>, -, _ ─────────────────

// ── + / <CR> (FirstNonBlankNextLine) ─────────────────────────────────────

#[test]
fn plus_moves_to_first_non_blank_next_line() {
    let mut e = editor_with("foo\n    bar\nbaz");
    // cursor at (0,0); `+` → row 1, first non-blank col 4.
    run_keys(&mut e, "+");
    assert_eq!(e.cursor(), (1, 4));
}

#[test]
fn cr_moves_to_first_non_blank_next_line() {
    let mut e = editor_with("foo\n    bar\nbaz");
    run_keys(&mut e, "<CR>");
    assert_eq!(e.cursor(), (1, 4));
}

#[test]
fn plus_count_moves_multiple_lines() {
    let mut e = editor_with("a\nb\n  c\nd");
    // 3+ from row 0 → row 3, first non-blank.
    run_keys(&mut e, "3+");
    assert_eq!(e.cursor(), (3, 0));
}

#[test]
fn plus_at_last_line_stays() {
    // At the last line `+` should not panic; stays on last line.
    let mut e = editor_with("foo\nbar");
    // Move to last row first.
    run_keys(&mut e, "G");
    let pre = e.cursor().0;
    run_keys(&mut e, "+");
    assert_eq!(e.cursor().0, pre, "should not move past last line");
}

#[test]
fn d_plus_deletes_linewise() {
    // `d+` from row 0 should delete rows 0 and 1 (linewise).
    let mut e = editor_with("one\ntwo\nthree");
    run_keys(&mut e, "d+");
    // Rows 0 and 1 deleted; cursor on "three".
    assert!(e.content().starts_with("three"));
}

// ── - (FirstNonBlankPrevLine) ─────────────────────────────────────────────

#[test]
fn minus_moves_to_first_non_blank_prev_line() {
    let mut e = editor_with("  hello\nworld\nfoo");
    // Jump to row 2, then `-`.
    run_keys(&mut e, "G");
    run_keys(&mut e, "-");
    assert_eq!(e.cursor(), (1, 0)); // "world" first non-blank = 0
}

#[test]
fn minus_count_moves_multiple_lines_back() {
    let mut e = editor_with("  a\nb\nc\n  d");
    run_keys(&mut e, "G");
    run_keys(&mut e, "3-");
    // 3 lines up from row 3 → row 0, first non-blank col 2.
    assert_eq!(e.cursor(), (0, 2));
}

#[test]
fn minus_at_first_line_stays() {
    let mut e = editor_with("  foo\nbar");
    // cursor already at row 0.
    run_keys(&mut e, "-");
    assert_eq!(e.cursor().0, 0, "should not go above first line");
}

#[test]
fn d_minus_deletes_linewise() {
    let mut e = editor_with("one\ntwo\nthree");
    // Jump to row 1 then `d-` should delete rows 0–1 linewise.
    run_keys(&mut e, "j");
    run_keys(&mut e, "d-");
    assert!(e.content().starts_with("three"));
}

// ── _ (FirstNonBlankLine) ─────────────────────────────────────────────────

#[test]
fn underscore_lands_on_first_non_blank_of_current_line() {
    let mut e = editor_with("    hello\nworld");
    // count=1: stay on current line, first non-blank.
    run_keys(&mut e, "_");
    assert_eq!(e.cursor(), (0, 4));
}

#[test]
fn underscore_count_moves_count_minus_1_lines_down() {
    let mut e = editor_with("a\n  b\nc\n  d");
    // 3_ from row 0: count-1=2 lines down → row 2 first non-blank.
    run_keys(&mut e, "3_");
    assert_eq!(e.cursor(), (2, 0));
}

#[test]
fn d_underscore_deletes_current_line() {
    // `d_` (count=1) → linewise delete of current line.
    let mut e = editor_with("one\ntwo\nthree");
    run_keys(&mut e, "d_");
    assert!(!e.content().contains("one"));
    assert!(e.content().contains("two"));
}

#[test]
fn d_2_underscore_deletes_current_and_next() {
    // `d2_` → linewise delete from current row through count-1=1 lines down.
    let mut e = editor_with("one\ntwo\nthree");
    run_keys(&mut e, "d2_");
    assert!(!e.content().contains("one"));
    assert!(!e.content().contains("two"));
    assert!(e.content().contains("three"));
}

// ── [[ and ]] (SectionBackward / SectionForward) ─────────────────────────

const SECTION_BUF: &str = "preamble\n{\nfoo\n}\nbar\n{\nbaz\n}\nepilogue";

#[test]
fn double_bracket_open_backward_finds_brace_at_col0() {
    // Start at last row, `[[` should jump to the second `{` (row 5).
    let mut e = editor_with(SECTION_BUF);
    run_keys(&mut e, "G");
    run_keys(&mut e, "[[");
    assert_eq!(e.cursor().0, 5);
}

#[test]
fn double_bracket_open_backward_count_finds_nth_brace() {
    // `2[[` from last row should jump to row 1 (first `{`).
    let mut e = editor_with(SECTION_BUF);
    run_keys(&mut e, "G");
    run_keys(&mut e, "2[[");
    assert_eq!(e.cursor().0, 1);
}

#[test]
fn double_bracket_open_backward_at_top_clamps() {
    // `[[` on row 0 (no `{` above) should not panic; stays at row 0.
    let mut e = editor_with(SECTION_BUF);
    run_keys(&mut e, "[[");
    assert_eq!(e.cursor().0, 0);
}

#[test]
fn double_bracket_close_forward_finds_brace_at_col0() {
    // From row 0, `]]` should jump to row 1 (first `{`).
    let mut e = editor_with(SECTION_BUF);
    run_keys(&mut e, "]]");
    assert_eq!(e.cursor().0, 1);
}

#[test]
fn double_bracket_close_forward_count_finds_nth_brace() {
    // `2]]` from row 0 should jump to row 5 (second `{`).
    let mut e = editor_with(SECTION_BUF);
    run_keys(&mut e, "2]]");
    assert_eq!(e.cursor().0, 5);
}

#[test]
fn double_bracket_close_forward_at_bottom_clamps() {
    // `]]` on last row (no more `{` below) should not panic.
    let mut e = editor_with(SECTION_BUF);
    run_keys(&mut e, "G");
    let row_before = e.cursor().0;
    run_keys(&mut e, "]]");
    assert_eq!(e.cursor().0, row_before);
}

#[test]
fn d_double_bracket_open_deletes_range() {
    // `d[[` from row 5 should delete to the nearest `{` at col 0 (charwise exclusive).
    let mut e = editor_with(SECTION_BUF);
    // Jump to row 5 (second `{`).
    run_keys(&mut e, "G");
    run_keys(&mut e, "[["); // row 5
    // d[[ from row 5 → nearest { backward is row 1; charwise exclusive.
    run_keys(&mut e, "d[[");
    // Buffer between rows 1..5 is deleted; row 1 `{` itself stays (exclusive).
    assert!(e.content().contains('{'), "opening brace should remain");
}

// ── [] and ][ (SectionEndBackward / SectionEndForward) ────────────────────

const SECTION_BUF2: &str = "a\n{\nfoo\n}\nb\n{\nbar\n}\nc";

#[test]
fn bracket_close_open_backward_finds_brace_at_col0() {
    // `[]` from last row: backward to `}` at col 0 → row 7.
    let mut e = editor_with(SECTION_BUF2);
    run_keys(&mut e, "G");
    run_keys(&mut e, "[]");
    assert_eq!(e.cursor().0, 7);
}

#[test]
fn bracket_close_open_backward_count() {
    // `2[]` from last row: two `}` backward → row 3.
    let mut e = editor_with(SECTION_BUF2);
    run_keys(&mut e, "G");
    run_keys(&mut e, "2[]");
    assert_eq!(e.cursor().0, 3);
}

#[test]
fn bracket_close_open_backward_at_top_clamps() {
    // `[]` from row 0: no `}` above → stays at row 0.
    let mut e = editor_with(SECTION_BUF2);
    run_keys(&mut e, "[]");
    assert_eq!(e.cursor().0, 0);
}

#[test]
fn bracket_open_close_forward_finds_brace_at_col0() {
    // `][` from row 0: forward to `}` at col 0 → row 3.
    let mut e = editor_with(SECTION_BUF2);
    run_keys(&mut e, "][");
    assert_eq!(e.cursor().0, 3);
}

#[test]
fn bracket_open_close_forward_count() {
    // `2][` from row 0: two `}` forward → row 7.
    let mut e = editor_with(SECTION_BUF2);
    run_keys(&mut e, "2][");
    assert_eq!(e.cursor().0, 7);
}

#[test]
fn bracket_open_close_forward_at_bottom_clamps() {
    // `][` from last row: no `}` below → stays at last row.
    let mut e = editor_with(SECTION_BUF2);
    run_keys(&mut e, "G");
    let row_before = e.cursor().0;
    run_keys(&mut e, "][");
    assert_eq!(e.cursor().0, row_before);
}

/// A search hit is an explicit jump: vim resets `curswant` to the column the
/// cursor landed on, so the next `j`/`k` aims at the match's column rather
/// than at whatever column a previous vertical motion established.
///
/// `n`/`N` always got this right (they run through the motion dispatch, which
/// ends in `apply_sticky_col`); the `/` and `?` prompt commits did not, since
/// they call `search_advance_*` on the engine directly.
#[test]
fn search_commit_sets_sticky_col_to_the_match_column() {
    let mut e = editor_with("ab\n0123456789target0123\nzzzzzzzzzzzzzzzzzzzzzz");
    run_keys(&mut e, "j");
    assert_eq!(e.sticky_col(), Some(0), "j establishes the sticky column");

    run_keys(&mut e, "/target<CR>");
    assert_eq!(e.cursor(), (1, 10), "search lands on the match");
    assert_eq!(
        e.sticky_col(),
        Some(10),
        "the search hit must reset the sticky column to the match column"
    );

    run_keys(&mut e, "j");
    assert_eq!(
        e.cursor(),
        (2, 10),
        "j aims at the match column, not the old one"
    );
}

/// Backward `?` commit takes the same path and must behave identically.
/// Landing on a SHORT row proves which sticky value is live: clamped from 10
/// gives col 1, a stale 0 gives col 0.
#[test]
fn backward_search_commit_sets_sticky_col() {
    let mut e = editor_with("0123456789target0123\nab\nzzzzzzzzzzzzzzzzzzzzzz");
    run_keys(&mut e, "j");
    run_keys(&mut e, "?target<CR>");
    assert_eq!(e.cursor(), (0, 10));
    assert_eq!(e.sticky_col(), Some(10));

    run_keys(&mut e, "j");
    assert_eq!(e.cursor(), (1, 1), "clamped to the short row from col 10");
}
