#![allow(non_snake_case)]
//! Bug reproduction tests for Vi mode
//!
//! Each test documents a specific known bug. Tests are written to demonstrate
//! the *expected* vim behavior — they will fail (or timeout) until the
//! underlying bug is fixed.

use crate::common::fixtures::TestFixture;
use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness};
use crate::common::tracing::init_tracing_from_env;
use crossterm::event::{KeyCode, KeyModifiers};
use fresh::input::keybindings::Action::PluginAction;
use std::fs;

/// Create a harness with vi mode plugin loaded
fn vi_mode_harness(width: u16, height: u16) -> (EditorTestHarness, tempfile::TempDir) {
    init_tracing_from_env();
    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project_root");
    fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "vi_mode");
    copy_plugin_lib(&plugins_dir);

    let mut harness = EditorTestHarness::with_config_and_working_dir(
        width,
        height,
        Default::default(),
        project_root.clone(),
    )
    .unwrap();

    harness.editor_mut().set_clipboard_for_test("".to_string());
    (harness, temp_dir)
}

/// Enable vi mode via command palette
fn enable_vi_mode(harness: &mut EditorTestHarness) {
    harness
        .wait_until(|h| {
            let commands = h.editor().command_registry().read().unwrap().get_all();
            commands
                .iter()
                .any(|c| c.action == PluginAction("vi_mode_toggle".to_string()))
        })
        .unwrap();

    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();

    harness.type_text("Toggle Vi").unwrap();
    harness.wait_for_screen_contains("Toggle Vi mode").unwrap();

    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.render().unwrap();

    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-normal".to_string()))
        .unwrap();
}

/// Helper: send a single normal-mode key and render
fn send_key(harness: &mut EditorTestHarness, c: char) {
    let mods = if c.is_ascii_uppercase() {
        KeyModifiers::SHIFT
    } else {
        KeyModifiers::NONE
    };
    harness.send_key(KeyCode::Char(c), mods).unwrap();
    harness.render().unwrap();
}

/// Helper: wait for operator-pending mode, then send the motion key
fn send_operator_motion(harness: &mut EditorTestHarness, op: char, motion: char) {
    send_key(harness, op);
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(harness, motion);
}

/// Helper: wait for vi-normal mode
fn wait_normal(harness: &mut EditorTestHarness) {
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-normal".to_string()))
        .unwrap();
}

/// Helper: wait for vi-insert mode
fn wait_insert(harness: &mut EditorTestHarness) {
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-insert".to_string()))
        .unwrap();
}

/// Helper: escape to normal mode
fn escape(harness: &mut EditorTestHarness) {
    harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
    harness.render().unwrap();
    wait_normal(harness);
}

// =============================================================================
// Bug #1: C (change to end of line) doesn't delete text
// =============================================================================

/// `C` should delete from cursor to end of line and enter insert mode.
/// BUG: cursor moves to EOL and enters insert, but text is not deleted.
#[test]
fn test_vi_bug_C_does_not_delete_to_eol() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Move to 'w' in "world"
    let pos0 = harness.cursor_position();
    send_key(&mut harness, 'w');
    harness.wait_until(|h| h.cursor_position() > pos0).unwrap();

    // Press C — should delete "world" and enter insert mode
    send_key(&mut harness, 'C');
    wait_insert(&mut harness);

    // Type replacement text
    harness.type_text("X").unwrap();
    harness.render().unwrap();
    escape(&mut harness);

    // Expected: "hello X\n"  (the "world" was deleted, "X" inserted)
    harness.wait_for_buffer_content("hello X\n").unwrap();
}

// =============================================================================
// Bug #2: D (delete to end of line) doesn't delete text
// =============================================================================

/// `D` should delete from cursor to end of line, stay in normal mode.
/// BUG: cursor moves to EOL but text is not deleted.
#[test]
fn test_vi_bug_D_does_not_delete_to_eol() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Move to 'w' in "world"
    let pos0 = harness.cursor_position();
    send_key(&mut harness, 'w');
    harness.wait_until(|h| h.cursor_position() > pos0).unwrap();

    // Press D — should delete "world", stay in normal mode
    send_key(&mut harness, 'D');
    wait_normal(&mut harness);

    harness.wait_for_buffer_content("hello \n").unwrap();
}

// =============================================================================
// Bug #3: cc / S (change whole line) doesn't delete text
// =============================================================================

/// `cc` should clear the current line content and enter insert mode.
/// BUG: cursor moves to EOL, enters insert, but line content remains.
#[test]
fn test_vi_bug_cc_does_not_clear_line() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world\nline two\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // cc = change line
    send_key(&mut harness, 'c');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, 'c');
    wait_insert(&mut harness);

    // Type replacement
    harness.type_text("REPLACED").unwrap();
    harness.render().unwrap();
    escape(&mut harness);

    harness
        .wait_for_buffer_content("REPLACED\nline two\n")
        .unwrap();
}

/// `S` should clear the current line content and enter insert mode (same as cc).
#[test]
fn test_vi_bug_S_does_not_clear_line() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world\nline two\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, 'S');
    wait_insert(&mut harness);

    harness.type_text("NEW").unwrap();
    harness.render().unwrap();
    escape(&mut harness);

    harness.wait_for_buffer_content("NEW\nline two\n").unwrap();
}

// =============================================================================
// Bug #4: Visual block mode (Ctrl+V) — I/A don't work, not true block ops
// =============================================================================

/// `I` in visual block mode should enter insert mode for block insertion.
///
/// Root cause: The editor has no block-insert mechanism. In vim, `I` in visual
/// block mode enters insert on the first selected line; after Escape, the typed
/// text is replicated on every line of the block. This requires multi-cursor
/// insert support tied to the block selection, which the editor doesn't have.
///
/// To fix: Add a new editor action (e.g. `BlockInsert`) that creates cursors at
/// the start of each line in the block selection, enters insert mode, and on
/// Escape replicates the inserted text to all cursor positions. Then bind `I` in
/// vi-visual-block mode to trigger it.
#[test]
#[ignore]
fn test_vi_bug_visual_block_I_ignored() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "aaaa\nbbbb\ncccc\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Enter visual block with Ctrl+V
    harness
        .send_key(KeyCode::Char('v'), KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual-block".to_string()))
        .unwrap();

    // Select down two lines
    send_key(&mut harness, 'j');
    send_key(&mut harness, 'j');

    // Press I — should enter insert mode for block insert
    send_key(&mut harness, 'I');

    // In vim, I in visual block enters insert mode.
    // After typing text and pressing Escape, the text is inserted on all selected lines.
    // For now, just verify we at least enter insert mode.
    wait_insert(&mut harness);
}

// =============================================================================
// Bug #5: `e` motion off-by-one and gets stuck
// =============================================================================

/// `e` should land on the last character of the current word, not after it.
/// BUG: lands on the space after the word (off by one), and repeating gets stuck.
#[test]
fn test_vi_bug_e_off_by_one() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    // "hello world\n" — 'o' in "hello" is at byte offset 4
    let fixture = TestFixture::new("test.txt", "hello world\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // From position 0 ('h'), press 'e' — should land on 'o' (offset 4)
    send_key(&mut harness, 'e');
    harness.wait_until(|h| h.cursor_position() > 0).unwrap();
    assert_eq!(
        harness.cursor_position(),
        4,
        "e should land on last char of 'hello' (offset 4), not after it"
    );
}

/// Pressing `e` twice should advance to the end of the second word.
/// BUG: second `e` gets stuck because first `e` lands on space.
#[test]
fn test_vi_bug_e_gets_stuck() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world end\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // First e: land on last char of "hello" (offset 4)
    send_key(&mut harness, 'e');
    harness.wait_until(|h| h.cursor_position() > 0).unwrap();
    let pos_after_first_e = harness.cursor_position();

    // Second e: should advance to last char of "world" (offset 10)
    send_key(&mut harness, 'e');
    harness
        .wait_until(|h| h.cursor_position() > pos_after_first_e)
        .unwrap();

    assert_eq!(
        harness.cursor_position(),
        10,
        "second e should land on last char of 'world' (offset 10)"
    );
}

// =============================================================================
// Bug #6: `e` missing from operator-pending mode
// =============================================================================

/// `de` should delete from cursor to end of current word.
/// BUG: `e` is not bound in operator-pending mode, so `de` doesn't work properly.
#[test]
fn test_vi_bug_de_not_working() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // de should delete "hello" (to end of word), leaving " world\n"
    send_operator_motion(&mut harness, 'd', 'e');

    harness.wait_for_buffer_content(" world\n").unwrap();
}

/// `ce` should change from cursor to end of current word.
#[test]
fn test_vi_bug_ce_not_working() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // ce should delete "hello" and enter insert
    send_operator_motion(&mut harness, 'c', 'e');
    wait_insert(&mut harness);

    harness.type_text("HI").unwrap();
    harness.render().unwrap();
    escape(&mut harness);

    harness.wait_for_buffer_content("HI world\n").unwrap();
}

// =============================================================================
// Bug #7: nG (go to line N) doesn't work — count ignored by G
// =============================================================================

/// `3G` should go to line 3.
/// BUG: G ignores count prefix, always goes to end of file.
#[test]
fn test_vi_bug_nG_ignores_count() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "line1\nline2\nline3\nline4\nline5\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Cursor starts at offset 0 (line 1)
    assert_eq!(harness.cursor_position(), 0);

    // 3G should go to line 3 (offset 12: "line1\nline2\n" = 12 bytes)
    send_key(&mut harness, '3');
    send_key(&mut harness, 'G');

    harness.wait_until(|h| h.cursor_position() > 0).unwrap();

    // Line 3 starts at byte offset 12
    assert_eq!(
        harness.cursor_position(),
        12,
        "3G should go to beginning of line 3 (offset 12)"
    );
}

// =============================================================================
// Bug #8: r (replace character) not implemented
// =============================================================================

/// `ra` should replace the character under the cursor with 'a'.
/// BUG: shows "Replace char not yet implemented".
#[test]
fn test_vi_bug_r_not_implemented() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // ra should replace 'h' with 'a'
    send_key(&mut harness, 'r');
    // Wait for the plugin to enter replace-char mode before sending the
    // replacement character (same pattern as send_operator_motion).
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-replace-char".to_string()))
        .unwrap();
    send_key(&mut harness, 'a');

    harness.wait_for_buffer_content("aello\n").unwrap();
}

// =============================================================================
// Bug #9: h/l wrap across lines
// =============================================================================

/// `l` at the end of a line should NOT wrap to the next line.
/// BUG: l wraps to the beginning of the next line.
#[test]
fn test_vi_bug_l_wraps_across_lines() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "ab\ncd\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Move to 'b' (offset 1) — last char of line 1
    send_key(&mut harness, 'l');
    harness.wait_until(|h| h.cursor_position() == 1).unwrap();

    // Press l again — should stay at offset 1 (vim doesn't wrap l)
    send_key(&mut harness, 'l');
    harness.render().unwrap();

    // Give a moment for any async processing
    harness.wait_until(|h| h.cursor_position() <= 1).unwrap();

    assert_eq!(
        harness.cursor_position(),
        1,
        "l at end of line should not wrap to next line"
    );
}

/// `h` at the beginning of a line should NOT wrap to the previous line.
/// BUG: h wraps to the end of the previous line.
#[test]
fn test_vi_bug_h_wraps_across_lines() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "ab\ncd\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Move to line 2 (offset 3 = 'c')
    send_key(&mut harness, 'j');
    harness.wait_until(|h| h.cursor_position() == 3).unwrap();

    // Press h — should stay at offset 3 (beginning of line 2, vim doesn't wrap h)
    send_key(&mut harness, 'h');
    harness.render().unwrap();

    harness.wait_until(|h| h.cursor_position() >= 3).unwrap();

    assert_eq!(
        harness.cursor_position(),
        3,
        "h at beginning of line should not wrap to previous line"
    );
}

// =============================================================================
// Bug #12: ^ doesn't skip whitespace (same as 0)
// =============================================================================

/// `^` should move to the first non-blank character on the line.
/// BUG: `^` calls move_line_start (same as `0`), doesn't skip whitespace.
#[test]
fn test_vi_bug_caret_same_as_zero() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    // 4 spaces of indentation before "hello"
    let fixture = TestFixture::new("test.txt", "    hello\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Move to end of line to start from a known position
    send_key(&mut harness, '$');
    harness.wait_until(|h| h.cursor_position() > 0).unwrap();

    // Press ^ — should go to offset 4 ('h' in "hello"), NOT offset 0
    send_key(&mut harness, '^');
    harness.wait_until(|h| h.cursor_position() < 8).unwrap();

    assert_eq!(
        harness.cursor_position(),
        4,
        "^ should move to first non-blank char (offset 4), not line start (offset 0)"
    );
}

// =============================================================================
// Bug #13: J (join lines) doesn't add space
// =============================================================================

/// `J` should join lines with a space between them.
/// BUG: J concatenates lines without inserting a space.
#[test]
fn test_vi_bug_J_no_space() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello\nworld\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // J should join "hello" and "world" with a space
    send_key(&mut harness, 'J');

    // Expected: "hello world\n"  (not "helloworld\n")
    harness.wait_for_buffer_content("hello world\n").unwrap();
}

// =============================================================================
// Bug #15: f/t/F/T find-char mode missing special characters
// =============================================================================

/// `f(` should find the next '(' on the current line.
/// BUG: '(' is not bound in vi-find-char mode, so find stays pending/fails.
#[test]
fn test_vi_bug_find_char_special_chars() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "foo(bar) baz\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // f( should find '(' at offset 3
    send_key(&mut harness, 'f');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-find-char".to_string()))
        .unwrap();

    harness
        .send_key(KeyCode::Char('('), KeyModifiers::NONE)
        .unwrap();
    harness.render().unwrap();

    // Should return to normal mode and cursor at offset 3
    wait_normal(&mut harness);
    assert_eq!(
        harness.cursor_position(),
        3,
        "f( should move to '(' at offset 3"
    );
}

/// `f.` should find the next '.' on the current line.
#[test]
fn test_vi_bug_find_char_dot() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello.world\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, 'f');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-find-char".to_string()))
        .unwrap();

    harness
        .send_key(KeyCode::Char('.'), KeyModifiers::NONE)
        .unwrap();
    harness.render().unwrap();

    wait_normal(&mut harness);
    assert_eq!(
        harness.cursor_position(),
        5,
        "f. should move to '.' at offset 5"
    );
}

/// `f/` should find '/' — common in file paths.
#[test]
fn test_vi_bug_find_char_slash() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "path/to/file\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, 'f');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-find-char".to_string()))
        .unwrap();

    harness
        .send_key(KeyCode::Char('/'), KeyModifiers::NONE)
        .unwrap();
    harness.render().unwrap();

    wait_normal(&mut harness);
    assert_eq!(
        harness.cursor_position(),
        4,
        "f/ should move to '/' at offset 4"
    );
}

// =============================================================================
// Bug #16: d operator doesn't yank deleted text
// =============================================================================

/// After `dw`, pasting with `p` should paste the deleted word.
///
/// Root cause: `dw` maps to the atomic action `delete_word_forward` (in
/// `atomicOperatorActions`). This Rust action deletes the text but does NOT
/// copy it to the clipboard. In vim, every `d` command populates the unnamed
/// register so the deleted text can be pasted with `p`.
///
/// To fix (option A — Rust side): Make `DeleteWordForward`, `DeleteWordBackward`,
/// `DeleteToLineEnd`, and `DeleteToLineStart` also copy the deleted text to the
/// internal clipboard before deleting.
///
/// To fix (option B — plugin side): Stop using atomic delete actions in
/// `applyOperatorWithMotion`. Instead, use the selection-based path for all
/// operators: `select_word_right` + `cut` (which copies to clipboard). This
/// avoids the stale-snapshot problem because `cut` operates on the current
/// selection, not on positions read via `getCursorPosition()`.
#[test]
#[ignore]
fn test_vi_bug_dw_does_not_yank() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // dw deletes "hello "
    send_operator_motion(&mut harness, 'd', 'w');
    harness.wait_for_buffer_content("world\n").unwrap();

    // Move to end of "world", then p should paste "hello " after cursor
    send_key(&mut harness, '$');
    harness.wait_until(|h| h.cursor_position() > 0).unwrap();

    send_key(&mut harness, 'p');

    // Expected: "worldhello \n" or similar — the key point is "hello " was pasted
    harness
        .wait_until(|h| {
            h.get_buffer_content()
                .map(|c| c.contains("hello "))
                .unwrap_or(false)
        })
        .unwrap();
}

// =============================================================================
// Bug #10/11: v/V cursor movement on mode entry
// =============================================================================

/// Entering visual mode with `v` selects the character under cursor.
/// In vim, the character under cursor is highlighted as part of the selection.
/// The cursor position advances by 1 (selection end), but conceptually
/// the cursor is "on" the original character.
#[test]
fn test_vi_bug_v_moves_cursor_on_entry() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "abcdef\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    let pos_before = harness.cursor_position();

    send_key(&mut harness, 'v');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual".to_string()))
        .unwrap();

    // select_right advances cursor by 1 to establish anchor+selection;
    // this is the expected behavior for character-wise visual mode
    assert!(
        harness.cursor_position() <= pos_before + 1,
        "v should select current char; pos={}, expected <= {}",
        harness.cursor_position(),
        pos_before + 1
    );

    escape(&mut harness);
}

/// Entering visual line mode with `V` should not move the cursor to the next line.
/// BUG: V jumps cursor down one line.
#[test]
fn test_vi_bug_V_moves_cursor_down() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "line1\nline2\nline3\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Cursor should be on line 1 (offset 0..4)
    let pos_before = harness.cursor_position();
    assert!(pos_before < 5, "should start on line 1");

    send_key(&mut harness, 'V');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual-line".to_string()))
        .unwrap();

    // In vim, V selects the entire line including newline.
    // select_line moves cursor to the start of the next line (byte 6).
    // This is acceptable — the line-wise selection spans the full line.
    // The original bug was V doing move_line_start + select_line which
    // moved DOWN from the current position first. Now we just do select_line
    // from the current position, which correctly selects line 1.
    assert!(
        harness.cursor_position() <= 6,
        "V should select current line; pos={}, expected <=6",
        harness.cursor_position()
    );

    escape(&mut harness);
}

// =============================================================================
// Bug #17: Visual mode switching (v -> V -> Ctrl+V) broken
// =============================================================================

/// Switching from visual line (V) to visual block (Ctrl+V) should work.
/// BUG: Ctrl+V from visual line mode doesn't switch to visual block.
#[test]
fn test_vi_bug_visual_mode_switching() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "aaaa\nbbbb\ncccc\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Enter visual mode
    send_key(&mut harness, 'v');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual".to_string()))
        .unwrap();

    // Switch to visual line with V
    send_key(&mut harness, 'V');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual-line".to_string()))
        .unwrap();

    // Switch to visual block with Ctrl+V
    harness
        .send_key(KeyCode::Char('v'), KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual-block".to_string()))
        .unwrap();

    escape(&mut harness);
}

// =============================================================================
// Bug #14: $ cursor goes past last character
// =============================================================================

/// In normal mode, `$` should place cursor on the last character, not past it.
/// BUG: cursor goes one position past the end.
#[test]
fn test_vi_bug_dollar_past_eol() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    // "abc\n" — last char 'c' is at offset 2
    let fixture = TestFixture::new("test.txt", "abc\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, '$');
    harness.wait_until(|h| h.cursor_position() > 0).unwrap();

    assert_eq!(
        harness.cursor_position(),
        2,
        "$ should land on last char 'c' (offset 2), not past it (offset 3)"
    );
}

// =============================================================================
// Bug #18-25: Missing features (not implemented)
// =============================================================================

/// `~` should toggle the case of the character under cursor.
/// BUG: `~` is not bound at all in vi-normal mode.
#[test]
fn test_vi_bug_tilde_not_implemented() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // ~ should toggle 'h' to 'H' and advance cursor
    send_key(&mut harness, '~');

    harness.wait_for_buffer_content("Hello\n").unwrap();
}

/// `*` should search for the word under cursor.
///
/// Root cause: `*` is not bound in vi-normal mode. The plugin has no handler
/// for it.
///
/// To fix: Add a `vi_search_word` handler that does `select_word` (to select
/// the word under cursor) followed by `find_selection_next` (to search for the
/// next occurrence). Bind `*` to this handler in vi-normal mode. Similarly,
/// add `#` bound to `find_selection_previous` for reverse search.
#[test]
#[ignore]
fn test_vi_bug_star_not_implemented() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world\nhello again\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // * should search for "hello" and move to next occurrence (line 2, offset 12)
    send_key(&mut harness, '*');

    harness.wait_until(|h| h.cursor_position() == 12).unwrap();
}

// =============================================================================
// Bug #26: Count display persists in status bar
// =============================================================================

/// After a counted motion like `3j`, the count should not persist in the status bar.
///
/// Root cause: `consumeCount()` calls `editor.setStatus()` to clear the "(3)"
/// display, but `setStatus` sends the update via an async channel. The test
/// checks the rendered screen before the status bar update is processed, so
/// "(3)" is still visible.
///
/// To fix: Use `wait_until_stable` with a condition that checks the screen
/// does NOT contain "(3)". Alternatively, add a render cycle after the motion
/// completes and before checking. The underlying plugin fix (clearing count in
/// `consumeCount`) is correct — only the test timing is the issue.
#[test]
#[ignore]
fn test_vi_bug_count_persists_in_status() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "a\nb\nc\nd\ne\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Send 3j (move down 3 lines)
    send_key(&mut harness, '3');
    send_key(&mut harness, 'j');
    harness.wait_until(|h| h.cursor_position() > 0).unwrap();

    // The status bar should show "-- NORMAL --" without "(3)"
    // Check that the screen contains clean NORMAL indicator
    harness.wait_for_screen_contains("NORMAL").unwrap();

    // Render and check the screen does NOT contain "(3)"
    harness.render().unwrap();
    let screen = harness.screen_to_string();
    assert!(
        !screen.contains("(3)"),
        "Count '(3)' should not persist in status bar after motion completes"
    );
}

// =============================================================================
// Bug: 2dd (count with dd) — verify it works
// =============================================================================

/// `2dd` should delete 2 lines.
#[test]
fn test_vi_2dd_delete_two_lines() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "line1\nline2\nline3\nline4\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // 2dd should delete first 2 lines
    send_key(&mut harness, '2');
    send_key(&mut harness, 'd');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, 'd');

    harness.wait_for_buffer_content("line3\nline4\n").unwrap();
}

// =============================================================================
// Bug: d% / c% / y% (operator + matching-bracket motion) does nothing
// =============================================================================

/// `d%` should delete from the cursor through the matching bracket (inclusive),
/// e.g. on the `(` of "foo(bar)baz" it should leave "foobaz".
///
/// Root cause: `applyOperatorWithMotion` resolves a motion to either an atomic
/// operator action (`atomicOperatorActions`) or a selection-extending action
/// (`motionToSelection`). The `goto_matching_bracket` motion is in neither map,
/// so the operator bails out of the "no selection equivalent" branch without
/// deleting anything. (The normal-mode `%` motion itself works.)
///
/// To fix: add a selection-extending action (e.g. `select_to_matching_bracket`)
/// in the core editor and a `motionToSelection["goto_matching_bracket"]` entry in
/// the vi_mode plugin, then remove the `#[ignore]`.
#[test]
#[ignore]
fn test_vi_bug_d_percent_ignored() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "foo(bar)baz\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Move cursor onto the '(' at byte 3.
    send_key(&mut harness, 'l');
    send_key(&mut harness, 'l');
    send_key(&mut harness, 'l');
    harness.wait_until(|h| h.cursor_position() == 3).unwrap();

    // d% should delete "(bar)" inclusive, leaving "foobaz\n".
    send_operator_motion(&mut harness, 'd', '%');

    harness.wait_for_buffer_content("foobaz\n").unwrap();
}

// =============================================================================
// Bug #2442: j/k onto a shorter line park the cursor one past the last char,
// so `x` deletes the newline and joins lines.
// =============================================================================

/// In NORMAL mode the cursor must never rest past the last character of a line.
/// Moving down (`j`) from a long line onto a *shorter* line must clamp the
/// cursor to the shorter line's last character — like Vim — not the insert
/// position one past it. Otherwise `x` deletes the line-ending newline and
/// joins the following line (silent corruption from navigate-then-delete).
#[test]
fn test_vi_bug_2442_j_onto_shorter_line_then_x_joins_lines() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new(
        "motions.txt",
        "hello world foo bar baz\nshort\na longer line with many words here\n",
    )
    .unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // $ -> last char of line 1 (the 'z'). j -> down onto "short".
    send_key(&mut harness, '$');
    let pos_eol = harness.cursor_position();
    send_key(&mut harness, 'j');
    harness
        .wait_until(|h| h.cursor_position() > pos_eol)
        .unwrap();

    // x deletes one character. In Vim the cursor sits on the 't' of "short",
    // so x gives "shor" and never joins line 3.
    send_key(&mut harness, 'x');

    harness
        .wait_for_buffer_content(
            "hello world foo bar baz\nshor\na longer line with many words here\n",
        )
        .unwrap();
}

/// Same off-by-one for upward motion (`k`) onto a shorter line.
#[test]
fn test_vi_bug_2442_k_onto_shorter_line_then_x_joins_lines() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new(
        "motions.txt",
        "hello world foo bar baz\nshort\na longer line with many words here\n",
    )
    .unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Go to line 3, end of line, then k up onto "short".
    send_key(&mut harness, 'j');
    send_key(&mut harness, 'j');
    send_key(&mut harness, '$');
    let pos_eol = harness.cursor_position();
    send_key(&mut harness, 'k');
    harness
        .wait_until(|h| h.cursor_position() < pos_eol)
        .unwrap();

    send_key(&mut harness, 'x');

    harness
        .wait_for_buffer_content(
            "hello world foo bar baz\nshor\na longer line with many words here\n",
        )
        .unwrap();
}

/// The desired (goal) column must survive the clamp: long -> short -> long
/// returns to the original column, matching Vim's remembered column.
#[test]
fn test_vi_bug_2442_goal_column_preserved_through_short_line() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    // Column 10 (0-indexed byte 10) is 'd' of "world" on line 1 and 'l' of
    // "longer" on line 3; line 2 "short" is only 5 chars.
    let fixture = TestFixture::new(
        "motions.txt",
        "hello world foo bar baz\nshort\na longer line with many words here\n",
    )
    .unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Move to byte column 10 on line 1 via 10 'l' presses.
    for _ in 0..10 {
        send_key(&mut harness, 'l');
    }
    harness.wait_until(|h| h.cursor_position() == 10).unwrap();

    // j onto "short" clamps to its last char ('t', byte 4 on the line ->
    // absolute byte 24+4 = 28), then j onto line 3 must restore column 10
    // (absolute byte 24 + 6 + 10 = 40).
    send_key(&mut harness, 'j');
    harness.wait_until(|h| h.cursor_position() == 28).unwrap();
    send_key(&mut harness, 'j');
    harness.wait_until(|h| h.cursor_position() == 40).unwrap();
}

// =============================================================================
// Bug #2439: quote text-objects i"/a" don't search forward on the line
// =============================================================================

/// Helper: send an operator + inner/around text-object (e.g. `di"`).
fn send_operator_text_object(
    harness: &mut EditorTestHarness,
    op: char,
    modifier: char,
    object: char,
) {
    send_key(harness, op);
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(harness, modifier);
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-text-object".to_string()))
        .unwrap();
    send_key(harness, object);
}

/// `di"` from the start of a line (before the opening quote) should, like Vim,
/// search forward on the current line and delete the contents of the quoted
/// string — `the "quick" brown fox` → `the "" brown fox`.
///
/// BUG: the quote text object only selected the pair the cursor was already
/// *inside*, so `di"`/`ci"` were silent no-ops when the cursor sat before the
/// quotes (the common case, e.g. right after `0`).
#[test]
fn test_vi_bug_di_quote_searches_forward_on_line() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "the \"quick\" brown fox\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Cursor is at column 0 (before the opening quote).
    harness.wait_until(|h| h.cursor_position() == 0).unwrap();

    // di" should delete inside the forward quoted string.
    send_operator_text_object(&mut harness, 'd', 'i', '"');

    harness
        .wait_for_buffer_content("the \"\" brown fox\n")
        .unwrap();
}

/// `ci"` from before the quotes should delete inside the quoted string, enter
/// insert mode, and let you type the replacement — same forward-search rule as
/// `di"`.
#[test]
fn test_vi_bug_ci_quote_searches_forward_on_line() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "the \"quick\" brown fox\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    harness.wait_until(|h| h.cursor_position() == 0).unwrap();

    // ci" should clear inside the quotes and drop into insert mode.
    send_operator_text_object(&mut harness, 'c', 'i', '"');
    wait_insert(&mut harness);

    harness.type_text("WXYZ").unwrap();
    harness.render().unwrap();
    escape(&mut harness);

    harness
        .wait_for_buffer_content("the \"WXYZ\" brown fox\n")
        .unwrap();
}

/// Bug #2604: `da"` should also delete the trailing whitespace after the
/// closing quote, like Vim (`:help aquote`): `the "quick" brown fox` →
/// `the brown fox` (single space), not `the  brown fox` (double space).
#[test]
fn test_vi_bug_da_quote_includes_trailing_whitespace() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "the \"quick\" brown fox\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Cursor is at column 0 (before the opening quote).
    harness.wait_until(|h| h.cursor_position() == 0).unwrap();

    // da" deletes `"quick"` plus the trailing space.
    send_operator_text_object(&mut harness, 'd', 'a', '"');

    harness.wait_for_buffer_content("the brown fox\n").unwrap();
}

/// Bug #2604 (leading-whitespace fallback): when there is no trailing
/// whitespace after the closing quote, `da"` includes the leading whitespace
/// before the opening quote instead — `foo "bar"` → `foo`.
#[test]
fn test_vi_bug_da_quote_falls_back_to_leading_whitespace() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "foo \"bar\"\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    harness.wait_until(|h| h.cursor_position() == 0).unwrap();

    // No trailing whitespace after the closing quote, so the leading space is
    // consumed instead.
    send_operator_text_object(&mut harness, 'd', 'a', '"');

    harness.wait_for_buffer_content("foo\n").unwrap();
}

// =============================================================================
// Bug #2438: vi mode indent operators >>/<< (and visual >/<) do nothing
// =============================================================================
//
// `>>`/`<<` in normal mode and `>`/`<` in visual mode were never bound, so they
// were silent no-ops. They now shift whole lines by one indent level (the
// default `text` buffer uses 4 spaces) and leave the cursor on the first
// non-blank of the first line, matching Vim.

/// `>>` indents the current line by one shiftwidth.
#[test]
fn test_vi_indent_line() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "alpha\nbeta\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // >> on line 1
    send_key(&mut harness, '>');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, '>');

    harness
        .wait_for_buffer_content("    alpha\nbeta\n")
        .unwrap();
}

/// `<<` dedents the current line by one shiftwidth.
#[test]
fn test_vi_dedent_line() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "        alpha\nbeta\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // << on line 1 removes one 4-space level.
    send_key(&mut harness, '<');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, '<');

    harness
        .wait_for_buffer_content("    alpha\nbeta\n")
        .unwrap();
}

/// A count applies the indent to N lines: `2>>` indents two lines.
#[test]
fn test_vi_indent_line_with_count() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "one\ntwo\nthree\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // 2>> indents lines 1 and 2, leaving line 3 untouched.
    send_key(&mut harness, '2');
    send_key(&mut harness, '>');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, '>');

    harness
        .wait_for_buffer_content("    one\n    two\nthree\n")
        .unwrap();
}

/// `>` plus a linewise motion (`>j`) indents the spanned lines.
#[test]
fn test_vi_indent_motion() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "one\ntwo\nthree\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // >j indents the current line and the one below.
    send_operator_motion(&mut harness, '>', 'j');

    harness
        .wait_for_buffer_content("    one\n    two\nthree\n")
        .unwrap();
}

/// `.` repeats the last `>>`.
#[test]
fn test_vi_indent_line_repeat() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "alpha\nbeta\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, '>');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, '>');
    harness
        .wait_for_buffer_content("    alpha\nbeta\n")
        .unwrap();

    // The buffer matches as soon as the indent lands, but `vi_indent_line` is
    // still finishing up (placing the cursor, then switching back to normal
    // mode). A `.` sent while the editor is still in operator-pending mode is
    // unbound there and gets swallowed, so wait for normal mode first —
    // matching the sibling repeat tests in vi_mode.rs.
    wait_normal(&mut harness);

    // `.` indents the same line again -> two levels (8 spaces).
    send_key(&mut harness, '.');
    harness
        .wait_for_buffer_content("        alpha\nbeta\n")
        .unwrap();
}

/// Visual-line `>` indents the selected line and returns to normal mode.
#[test]
fn test_vi_visual_line_indent() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "alpha\nbeta\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // V selects line 1, > indents it.
    harness
        .send_key(KeyCode::Char('V'), KeyModifiers::SHIFT)
        .unwrap();
    harness.render().unwrap();
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual-line".to_string()))
        .unwrap();

    send_key(&mut harness, '>');

    harness
        .wait_for_buffer_content("    alpha\nbeta\n")
        .unwrap();
    // Returns to normal mode after the operation (Vim behavior).
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-normal".to_string()))
        .unwrap();
}

/// Visual-line `<` dedents the selected lines.
#[test]
fn test_vi_visual_line_dedent() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "    alpha\n    beta\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // V then j to select both lines, then <.
    harness
        .send_key(KeyCode::Char('V'), KeyModifiers::SHIFT)
        .unwrap();
    harness.render().unwrap();
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual-line".to_string()))
        .unwrap();
    send_key(&mut harness, 'j');
    send_key(&mut harness, '<');

    harness.wait_for_buffer_content("alpha\nbeta\n").unwrap();
}

/// Visual-line `>` indents exactly the highlighted lines and nothing past them.
/// It operates on the same live selection as visual-mode `d`/`y`, so it stays
/// consistent with them; the regression this guards against is the indent
/// bleeding into the line below the selection.
#[test]
fn test_vi_visual_line_indent_stops_at_selection() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "one\ntwo\nthree\nfour\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // V then j highlights the first three lines (vi visual-line selection);
    // `four` is not selected and must stay unindented.
    harness
        .send_key(KeyCode::Char('V'), KeyModifiers::SHIFT)
        .unwrap();
    harness.render().unwrap();
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual-line".to_string()))
        .unwrap();
    send_key(&mut harness, 'j');
    send_key(&mut harness, '>');

    harness
        .wait_for_buffer_content("    one\n    two\n    three\nfour\n")
        .unwrap();
}

// =============================================================================
// Bug #2606: visual-block (Ctrl+V) indent operators >/< do nothing
// =============================================================================
//
// `>`/`<` were bound in visual and visual-line mode (bug #2438) but never in
// visual-block mode, so the keys were silently swallowed and the editor stayed
// stuck in `-- VISUAL BLOCK --`. Like Vim, they now shift every line the block
// touches by one shiftwidth and return to normal mode.

/// Visual-block `>` indents every line the block spans and returns to normal.
#[test]
fn test_vi_visual_block_indent() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "alpha\nbeta\ngamma\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    // Ctrl+V enters visual block, j extends it down to the second line.
    harness
        .send_key(KeyCode::Char('v'), KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual-block".to_string()))
        .unwrap();
    send_key(&mut harness, 'j');
    send_key(&mut harness, '>');

    // Both spanned lines shift right by one 4-space level; gamma is untouched.
    harness
        .wait_for_buffer_content("    alpha\n    beta\ngamma\n")
        .unwrap();
    // Returns to normal mode after the operation (Vim behavior).
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-normal".to_string()))
        .unwrap();
}

/// Visual-block `<` dedents every line the block spans and returns to normal.
#[test]
fn test_vi_visual_block_dedent() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "    alpha\n    beta\ngamma\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    harness
        .send_key(KeyCode::Char('v'), KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-visual-block".to_string()))
        .unwrap();
    send_key(&mut harness, 'j');
    send_key(&mut harness, '<');

    harness
        .wait_for_buffer_content("alpha\nbeta\ngamma\n")
        .unwrap();
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-normal".to_string()))
        .unwrap();
}

// =============================================================================
// Bug #2441: find-char motions (f/t/F/T) broken with operators and ; / , repeat
// =============================================================================

/// `dfr` should delete from the cursor up to and including the first `r`,
/// leaving "ld foo bar baz". BUG: stuck in operator-pending, nothing deleted.
#[test]
fn test_vi_bug_2441_dfr_deletes_through_target() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world foo bar baz\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, 'd');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, 'f');
    // `f` enters vi-find-char and consumes the *next* key as the target.
    // Wait for that mode before sending 'r': without it, a slow-CI race
    // delivers 'r' while still in vi-operator-pending, the find never
    // happens, and the wait below hangs to the external timeout. Same guard
    // as the normal-mode f-find tests above.
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-find-char".to_string()))
        .unwrap();
    send_key(&mut harness, 'r');

    harness.wait_for_buffer_content("ld foo bar baz\n").unwrap();
}

/// `dfw` should delete up to and including the first `w` ("hello w"),
/// leaving "orld foo bar baz". BUG: `f` is dropped and `w` runs as a plain
/// word motion, so `dfw` silently degrades to `dw`.
#[test]
fn test_vi_bug_2441_dfw_target_is_motion_key() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world foo bar baz\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, 'd');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, 'f');
    // `f` enters vi-find-char and consumes the *next* key as the target.
    // Wait for that mode before sending 'w': without it, a slow-CI race
    // delivers 'w' while still in vi-operator-pending, the find never
    // happens, and the wait below hangs to the external timeout. Same guard
    // as the normal-mode f-find tests above.
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-find-char".to_string()))
        .unwrap();
    send_key(&mut harness, 'w');

    harness
        .wait_for_buffer_content("orld foo bar baz\n")
        .unwrap();
}

/// `dtr` (till) should delete up to but NOT including the first `r`,
/// leaving "rld foo bar baz".
#[test]
fn test_vi_bug_2441_dtr_deletes_until_target() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world foo bar baz\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, 'd');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, 't');
    // `t` enters vi-find-char and consumes the *next* key as the target.
    // Wait for that mode before sending 'r': without it, a slow-CI race
    // delivers 'r' while still in vi-operator-pending, the find never
    // happens, and the wait below hangs to the external timeout. Same guard
    // as the normal-mode f-find tests above.
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-find-char".to_string()))
        .unwrap();
    send_key(&mut harness, 'r');

    harness
        .wait_for_buffer_content("rld foo bar baz\n")
        .unwrap();
}

/// `cfr` should delete up to and including the first `r` and enter insert mode,
/// then typed text replaces it. Leaves "Xld foo bar baz".
#[test]
fn test_vi_bug_2441_cfr_changes_through_target() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world foo bar baz\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, 'c');
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-operator-pending".to_string()))
        .unwrap();
    send_key(&mut harness, 'f');
    // `f` enters vi-find-char and consumes the *next* key as the target.
    // Wait for that mode before sending 'r': without it, a slow-CI race
    // delivers 'r' while still in vi-operator-pending, the find never
    // happens, and the wait below hangs to the external timeout. Same guard
    // as the normal-mode f-find tests above.
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-find-char".to_string()))
        .unwrap();
    send_key(&mut harness, 'r');
    wait_insert(&mut harness);
    harness.type_text("X").unwrap();
    harness.render().unwrap();
    escape(&mut harness);

    harness
        .wait_for_buffer_content("Xld foo bar baz\n")
        .unwrap();
}

/// `;` should repeat the last `f`/`t`. After `fo` (lands on first 'o', col 4),
/// `;` should advance to the next 'o' (col 7). BUG: `;` is a no-op.
#[test]
fn test_vi_bug_2441_semicolon_repeats_find() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world foo bar baz\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, 'f');
    // `f` enters vi-find-char and consumes the *next* key as the target.
    // Wait for that mode before sending 'o'; otherwise a fast/slow-CI race
    // delivers 'o' while still in vi-normal, where it means "open line below"
    // (→ insert mode), the find never happens, and the cursor wait below hangs
    // to the external timeout. Matches the f-find sibling tests above.
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-find-char".to_string()))
        .unwrap();
    send_key(&mut harness, 'o');
    harness.wait_until(|h| h.cursor_position() == 4).unwrap();

    send_key(&mut harness, ';');
    harness.wait_until(|h| h.cursor_position() == 7).unwrap();
}

/// `,` should repeat the last find in the opposite direction. After `fo;`
/// (cursor at col 7), `,` should move back to the previous 'o' (col 4).
#[test]
fn test_vi_bug_2441_comma_repeats_find_reverse() {
    let (mut harness, _td) = vi_mode_harness(80, 24);
    let fixture = TestFixture::new("test.txt", "hello world foo bar baz\n").unwrap();
    harness.open_file(&fixture.path).unwrap();
    harness.render().unwrap();
    enable_vi_mode(&mut harness);

    send_key(&mut harness, 'f');
    // `f` enters vi-find-char and consumes the *next* key as the target.
    // Wait for that mode before sending 'o'; otherwise a fast/slow-CI race
    // delivers 'o' while still in vi-normal, where it means "open line below"
    // (→ insert mode), the find never happens, and the cursor waits below hang
    // to the external timeout. Gate each subsequent step on the cursor landing
    // so `;`/`,` repeat a completed find rather than racing an in-flight one.
    harness
        .wait_until(|h| h.editor().editor_mode() == Some("vi-find-char".to_string()))
        .unwrap();
    send_key(&mut harness, 'o');
    harness.wait_until(|h| h.cursor_position() == 4).unwrap();
    send_key(&mut harness, ';');
    harness.wait_until(|h| h.cursor_position() == 7).unwrap();

    send_key(&mut harness, ',');
    harness.wait_until(|h| h.cursor_position() == 4).unwrap();
}
