//! Vim FSM: visual ops.
//!
//! Split out of the monolithic `vim.rs` (#267 follow-up).

use hjkl_vim_types::{InsertReason, LastChange, Mode, Motion, Operator, RangeKind, VisualExtent};

use hjkl_engine::rope_util::rope_line_to_str;

use super::*;
use crate::vim_state::{vim, vim_mut};
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{
    buf_cursor_pos, buf_line, buf_line_chars, buf_row_count, buf_set_cursor_rc,
};

pub fn apply_visual_operator<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    op: Operator,
    count: usize,
) {
    // `count` is the number of indent levels for `>` / `<` (vim `2>` = two
    // shiftwidths); other visual operators ignore it.
    let levels = count.max(1);
    // Read the explicit register before an operator consumes it, so the
    // dot-repeat entry can name it (`:h redo-register`).
    let register = vim(ed).pending_register;
    match vim(ed).mode {
        Mode::VisualLine => {
            let cursor_row = buf_cursor_pos(ed.buffer()).row;
            let top = cursor_row.min(vim(ed).visual_line_anchor);
            let bot = cursor_row.max(vim(ed).visual_line_anchor);
            ed.set_yank_linewise(true);
            match op {
                Operator::Yank => {
                    let text = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
                    if !text.is_empty() {
                        ed.record_yank_to_host(text.clone());
                        let target = vim_mut(ed).pending_register.take();
                        ed.record_yank(text, true, target);
                    }
                    buf_set_cursor_rc(ed.buffer_mut(), top, 0);
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::Delete => {
                    ed.push_undo();
                    // Capture `total` (incl. ropey's phantom trailing row)
                    // before the cut so the phantom-row clamp below can decide
                    // where the cursor lands. Same reasoning as the `dd`
                    // (linewise.rs) and `run_operator_over_range` (op_motion.rs)
                    // delete paths — a linewise Visual delete reaching the true
                    // last row must not park the cursor on the phantom empty
                    // final line.
                    let total = buf_row_count(ed.buffer());
                    let deleted_through_last = bot + 1 >= total;
                    cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
                    let total_after = buf_row_count(ed.buffer());
                    let raw_target = if deleted_through_last {
                        top.saturating_sub(1).min(total_after.saturating_sub(1))
                    } else {
                        top.min(total_after.saturating_sub(1))
                    };
                    // Pull back off the trailing phantom empty row (a buffer
                    // ending in `\n` is stored as [..., ""]) — same clamp as the
                    // `dd` path. Non-EOF deletes never satisfy this (the join
                    // row still has content below it), so their cursor is
                    // byte-for-byte unchanged.
                    let target_row = if raw_target > 0
                        && raw_target + 1 == total_after
                        && buf_line(ed.buffer(), raw_target).is_some_and(|s| s.is_empty())
                    {
                        raw_target - 1
                    } else {
                        raw_target
                    };
                    buf_set_cursor_rc(ed.buffer_mut(), target_row, 0);
                    record_visual_last_change(
                        ed,
                        op,
                        VisualExtent::Line {
                            lines: bot - top + 1,
                        },
                        register,
                    );
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::Change => {
                    // Vim `Vc` / `Vjc`: same linewise-change semantics as
                    // `cc` — preserve first line's indent, enter insert.
                    // Record BEFORE entering insert: `AfterChange` patches
                    // `inserted` into this entry when the user hits Esc.
                    record_visual_last_change(
                        ed,
                        op,
                        VisualExtent::Line {
                            lines: bot - top + 1,
                        },
                        register,
                    );
                    change_linewise_rows(ed, top, bot);
                }
                Operator::Uppercase
                | Operator::Lowercase
                | Operator::ToggleCase
                | Operator::Rot13 => {
                    let bot = buf_cursor_pos(ed.buffer())
                        .row
                        .max(vim(ed).visual_line_anchor);
                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), RangeKind::Linewise);
                    buf_set_cursor_rc(ed.buffer_mut(), top, 0);
                    record_visual_last_change(
                        ed,
                        op,
                        VisualExtent::Line {
                            lines: bot - top + 1,
                        },
                        register,
                    );
                }
                Operator::Indent | Operator::Outdent => {
                    ed.push_undo();
                    let (cursor_row, _) = ed.cursor();
                    let bot = cursor_row.max(vim(ed).visual_line_anchor);
                    if op == Operator::Indent {
                        indent_rows(ed, top, bot, levels);
                    } else {
                        outdent_rows(ed, top, bot, levels);
                    }
                    record_visual_last_change(
                        ed,
                        op,
                        VisualExtent::Line {
                            lines: bot - top + 1,
                        },
                        register,
                    );
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::Reflow => {
                    ed.push_undo();
                    let (cursor_row, _) = ed.cursor();
                    let bot = cursor_row.max(vim(ed).visual_line_anchor);
                    reflow_rows(ed, top, bot);
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::ReflowKeepCursor => {
                    let saved = ed.cursor();
                    ed.push_undo();
                    let (cursor_row, _) = ed.cursor();
                    let bot = cursor_row.max(vim(ed).visual_line_anchor);
                    let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
                    let (new_row, new_col) =
                        reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
                    buf_set_cursor_rc(ed.buffer_mut(), new_row, new_col);
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::AutoIndent => {
                    ed.push_undo();
                    let (cursor_row, _) = ed.cursor();
                    let bot = cursor_row.max(vim(ed).visual_line_anchor);
                    auto_indent_rows(ed, top, bot);
                    vim_mut(ed).mode = Mode::Normal;
                }
                // Filter is dispatched through Editor::filter_range, not here.
                Operator::Filter => {}
                // Comment is dispatched through the app layer (engine_actions.rs), not here.
                Operator::Comment => {}
                // Visual `zf` is handled inline in `handle_after_z`,
                // never routed through this dispatcher.
                Operator::Fold => unreachable!("Visual zf takes its own path"),
            }
        }
        Mode::Visual => {
            ed.set_yank_linewise(false);
            let anchor = vim(ed).visual_anchor;
            let cursor = ed.cursor();
            let (top, bot) = order(anchor, cursor);
            match op {
                Operator::Yank => {
                    let text = read_vim_range(ed, top, bot, RangeKind::Inclusive);
                    if !text.is_empty() {
                        ed.record_yank_to_host(text.clone());
                        let target = vim_mut(ed).pending_register.take();
                        ed.record_yank(text, false, target);
                    }
                    buf_set_cursor_rc(ed.buffer_mut(), top.0, top.1);
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::Delete => {
                    ed.push_undo();
                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
                    record_visual_last_change(ed, op, charwise_extent(top, bot), register);
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::Change => {
                    ed.push_undo();
                    // Record BEFORE entering insert: `AfterChange` patches
                    // `inserted` into this entry when the user hits Esc.
                    record_visual_last_change(ed, op, charwise_extent(top, bot), register);
                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
                }
                Operator::Uppercase
                | Operator::Lowercase
                | Operator::ToggleCase
                | Operator::Rot13 => {
                    // Anchor stays where the visual selection started.
                    let anchor = vim(ed).visual_anchor;
                    let cursor = ed.cursor();
                    let (top, bot) = order(anchor, cursor);
                    apply_case_op_to_selection(ed, op, top, bot, RangeKind::Inclusive);
                    record_visual_last_change(ed, op, charwise_extent(top, bot), register);
                }
                Operator::Indent | Operator::Outdent => {
                    ed.push_undo();
                    let anchor = vim(ed).visual_anchor;
                    let cursor = ed.cursor();
                    let (top, bot) = order(anchor, cursor);
                    if op == Operator::Indent {
                        indent_rows(ed, top.0, bot.0, levels);
                    } else {
                        outdent_rows(ed, top.0, bot.0, levels);
                    }
                    // Indent/outdent always act linewise regardless of the
                    // originating charwise selection (`:h v_>` — columns are
                    // ignored), so the dot-repeat extent is Line, not Char.
                    record_visual_last_change(
                        ed,
                        op,
                        VisualExtent::Line {
                            lines: bot.0 - top.0 + 1,
                        },
                        register,
                    );
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::Reflow => {
                    ed.push_undo();
                    let anchor = vim(ed).visual_anchor;
                    let cursor = ed.cursor();
                    let (top, bot) = order(anchor, cursor);
                    reflow_rows(ed, top.0, bot.0);
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::ReflowKeepCursor => {
                    let saved = ed.cursor();
                    ed.push_undo();
                    let anchor = vim(ed).visual_anchor;
                    let cursor = ed.cursor();
                    let (top, bot) = order(anchor, cursor);
                    let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
                    let (new_row, new_col) =
                        reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
                    buf_set_cursor_rc(ed.buffer_mut(), new_row, new_col);
                    vim_mut(ed).mode = Mode::Normal;
                }
                Operator::AutoIndent => {
                    ed.push_undo();
                    let anchor = vim(ed).visual_anchor;
                    let cursor = ed.cursor();
                    let (top, bot) = order(anchor, cursor);
                    auto_indent_rows(ed, top.0, bot.0);
                    vim_mut(ed).mode = Mode::Normal;
                }
                // Filter is dispatched through Editor::filter_range, not here.
                Operator::Filter => {}
                // Comment is dispatched through the app layer (engine_actions.rs), not here.
                Operator::Comment => {}
                Operator::Fold => unreachable!("Visual zf takes its own path"),
            }
        }
        Mode::VisualBlock => apply_block_operator(ed, op, levels),
        _ => {}
    }
}
/// B1: record `LastChange::VisualOp` for dot-repeat, unless this call is
/// itself a replay (`.` must not overwrite the entry it's replaying).
fn record_visual_last_change<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    op: Operator,
    extent: VisualExtent,
    register: Option<char>,
) {
    if !vim(ed).replaying {
        vim_mut(ed).last_change = Some(LastChange::VisualOp {
            op,
            extent,
            inserted: None,
            register,
        });
    }
}
/// B1: derive the charwise dot-repeat extent from an ordered `(top, bot)`
/// INCLUSIVE range (`:h v_.`). Single-line selections store the raw
/// selected char count as `width` (replay selects that many chars starting
/// at the cursor); multi-line selections store the last line's char count
/// measured from column 0 (replay's last line always starts at column 0 —
/// only the first line tracks the cursor's column).
fn charwise_extent(top: (usize, usize), bot: (usize, usize)) -> VisualExtent {
    if top.0 == bot.0 {
        VisualExtent::Char {
            lines: 1,
            width: bot.1 - top.1 + 1,
        }
    } else {
        VisualExtent::Char {
            lines: bot.0 - top.0 + 1,
            width: bot.1 + 1,
        }
    }
}
/// B-block: derive the blockwise dot-repeat extent (`:h v_.` for blocks)
/// from an ordered inclusive rectangle. `rows`/`cols` are the height/width;
/// `to_eol` carries the `$`-ragged flag so replay re-runs to each row's own
/// EOL (`:h v_b_$`).
fn block_extent(top: usize, bot: usize, left: usize, right: usize, to_eol: bool) -> VisualExtent {
    VisualExtent::Block {
        rows: bot - top + 1,
        cols: right + 1 - left,
        to_eol,
    }
}
/// Compute `(top_row, bot_row, left_col, right_col)` for the current
/// VisualBlock selection. Columns are inclusive on both ends. Uses the
/// tracked virtual column (updated by h/l, preserved across j/k) so
/// ragged / empty rows don't collapse the block's width.
pub fn block_bounds<H: hjkl_engine::types::Host>(
    ed: &Editor<hjkl_buffer::View, H>,
) -> (usize, usize, usize, usize) {
    let (ar, ac) = vim(ed).block_anchor;
    let (cr, _) = ed.cursor();
    let cc = vim(ed).block_vcol;
    let top = ar.min(cr);
    let bot = ar.max(cr);
    let left = ac.min(cc);
    let right = ac.max(cc);
    (top, bot, left, right)
}
/// Update the virtual column after a motion in VisualBlock mode.
/// Horizontal motions sync `block_vcol` to the new cursor column;
/// vertical / non-h/l motions leave it alone so the intended column
/// survives clamping to shorter lines.
///
/// `$` (`Motion::LineEnd`) additionally marks the block "ragged" (`:h
/// v_b_$`, [`crate::vim_state::VimState::block_to_eol`]) — every row then
/// resolves its own right edge to its own EOL until a DIFFERENT
/// horizontal motion re-establishes a fixed column, at which point the
/// flag clears. Vertical motions (the `_` arm) preserve it, matching vim.
pub fn update_block_vcol<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    motion: &Motion,
) {
    match motion {
        Motion::LineEnd => {
            vim_mut(ed).block_vcol = ed.cursor().1;
            vim_mut(ed).block_to_eol = true;
        }
        Motion::Left
        | Motion::Right
        | Motion::SpaceFwd
        | Motion::BackspaceBack
        | Motion::WordFwd
        | Motion::BigWordFwd
        | Motion::WordBack
        | Motion::BigWordBack
        | Motion::WordEnd
        | Motion::BigWordEnd
        | Motion::WordEndBack
        | Motion::BigWordEndBack
        | Motion::LineStart
        | Motion::FirstNonBlank
        | Motion::Find { .. }
        | Motion::FindRepeat { .. }
        | Motion::MatchBracket => {
            vim_mut(ed).block_vcol = ed.cursor().1;
            vim_mut(ed).block_to_eol = false;
        }
        // Up / Down / FileTop / FileBottom / Search — preserve vcol AND
        // the ragged flag.
        _ => {}
    }
}
/// Yank / delete / change / replace a rectangular selection. Yanked text
/// is stored as one string per row joined with `\n`, but the register is
/// flagged BLOCKWISE (with the block's column width) so `p`/`P` re-insert
/// it as columns at the cursor — vim's true block-paste geometry — rather
/// than spilling each row onto its own new line (see the `blockwise` slot
/// flag and `do_block_paste`). The joined-`\n` string is also what the RPC
/// / charwise-fallback paths read.
pub fn apply_block_operator<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    op: Operator,
    count: usize,
) {
    // Same capture as `apply_visual_operator` — this is also reached
    // directly from `range_ops`, so it cannot rely on that one.
    let register = vim(ed).pending_register;
    let (top, bot, left, right) = block_bounds(ed);
    // `$` (`:h v_b_$`) makes the block ragged: every row resolves its own
    // right edge to its own EOL instead of the fixed `right` above. Read
    // once here and thread through — vim's `$`-block affects every
    // operator, not just `d`/`y`/`A`.
    let to_eol = vim(ed).block_to_eol;
    // Snapshot the block text for yank / clipboard.
    let yank = block_yank(ed, top, bot, left, right, to_eol);
    // Column width the register pads every row segment to on paste. A
    // ragged (`$`) block has no fixed right edge, so its width is the
    // widest row segment; a normal block spans `left..=right` inclusive.
    let block_width = if to_eol {
        yank.split('\n')
            .map(|s| s.chars().count())
            .max()
            .unwrap_or(0)
    } else {
        right + 1 - left
    };

    match op {
        Operator::Yank => {
            if !yank.is_empty() {
                ed.record_yank_to_host(yank.clone());
                let target = vim_mut(ed).pending_register.take();
                ed.record_yank_block(yank, block_width, target);
            }
            vim_mut(ed).mode = Mode::Normal;
            ed.jump_cursor(top, left);
        }
        Operator::Delete => {
            ed.push_undo();
            delete_block_contents(ed, top, bot, left, right, to_eol);
            if !yank.is_empty() {
                ed.record_yank_to_host(yank.clone());
                let target = vim_mut(ed).pending_register.take();
                ed.record_delete_block(yank, block_width, target);
            }
            vim_mut(ed).mode = Mode::Normal;
            // Cursor lands on the block's left column, clamped to the row's
            // new last valid column — a ragged (`D` / `$`) delete can leave
            // the top row shorter than `left`, and nvim parks on its EOL
            // rather than past it (`<C-v>jjD` on "abcd…" → (0,0), not (0,1)).
            let line_len = buf_line_chars(ed.buffer(), top);
            ed.jump_cursor(top, left.min(line_len.saturating_sub(1)));
            record_visual_last_change(
                ed,
                op,
                block_extent(top, bot, left, right, to_eol),
                register,
            );
        }
        Operator::Change => {
            ed.push_undo();
            // Record BEFORE entering insert: the `BlockChange` finish site
            // (comment.rs) patches `inserted` into this entry on Esc, exactly
            // as `AfterChange` does for charwise/linewise `c`.
            record_visual_last_change(
                ed,
                op,
                block_extent(top, bot, left, right, to_eol),
                register,
            );
            // `c`'s replicated typed text always lands at the LEFT column
            // on every row (`:h v_b_c`) — unaffected by a ragged right
            // edge, so only the delete side needs `to_eol`.
            delete_block_contents(ed, top, bot, left, right, to_eol);
            if !yank.is_empty() {
                ed.record_yank_to_host(yank.clone());
                let target = vim_mut(ed).pending_register.take();
                ed.record_delete_block(yank, block_width, target);
            }
            ed.jump_cursor(top, left);
            begin_insert_noundo(
                ed,
                1,
                InsertReason::BlockChange {
                    top,
                    bot,
                    col: left,
                },
            );
        }
        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
            ed.push_undo();
            transform_block_case(ed, op, top, bot, left, right, to_eol);
            vim_mut(ed).mode = Mode::Normal;
            ed.jump_cursor(top, left);
            record_visual_last_change(
                ed,
                op,
                block_extent(top, bot, left, right, to_eol),
                register,
            );
        }
        Operator::Indent | Operator::Outdent => {
            // VisualBlock `>` / `<` shifts at the BLOCK's left column, not at
            // the start of the line. This used to fall through to the
            // linewise `indent_rows` / `outdent_rows` under a comment
            // claiming vim did the same; it does not — `<C-v>jl>` with the
            // block starting at column 2 turns "abcdef" into "ab    cdef",
            // where the linewise fallback produced "    abcdef".
            ed.push_undo();
            if op == Operator::Indent {
                indent_block(ed, top, bot, left, count.max(1));
            } else {
                outdent_block(ed, top, bot, left, count.max(1));
            }
            vim_mut(ed).mode = Mode::Normal;
            record_visual_last_change(
                ed,
                op,
                block_extent(top, bot, left, right, to_eol),
                register,
            );
        }
        Operator::Fold => unreachable!("Visual zf takes its own path"),
        Operator::Reflow => {
            // Reflow over the block falls back to linewise reflow over
            // the row range — column slicing for `gq` doesn't make
            // sense.
            ed.push_undo();
            reflow_rows(ed, top, bot);
            vim_mut(ed).mode = Mode::Normal;
        }
        Operator::ReflowKeepCursor => {
            // `gw` over a block: same fallback as `gq` but restore cursor.
            let saved = ed.cursor();
            ed.push_undo();
            let (before, after) = reflow_rows_keep_cursor(ed, top, bot);
            let (new_row, new_col) = reflow_keep_cursor(top, saved.0, saved.1, &before, &after);
            buf_set_cursor_rc(ed.buffer_mut(), new_row, new_col);
            vim_mut(ed).mode = Mode::Normal;
        }
        Operator::AutoIndent => {
            // AutoIndent over the block falls back to linewise
            // auto-indent over the row range.
            ed.push_undo();
            auto_indent_rows(ed, top, bot);
            vim_mut(ed).mode = Mode::Normal;
        }
        // Filter is dispatched through Editor::filter_range, not here.
        Operator::Filter => {}
        // Comment is dispatched through the app layer (engine_actions.rs), not here.
        Operator::Comment => {}
    }
}
/// In-place case transform over the rectangular block
/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
/// untouched — vim behaves the same way (ragged blocks). When `to_eol` is
/// set (`:h v_b_$`), each row's right edge is ITS OWN EOL instead of the
/// fixed `right`.
pub fn transform_block_case<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    op: Operator,
    top: usize,
    bot: usize,
    left: usize,
    right: usize,
    to_eol: bool,
) {
    use hjkl_buffer::{Edit, Position};
    let rope = hjkl_engine::types::Query::rope(ed.buffer());
    for r in top..=bot.min(rope.len_lines().saturating_sub(1)) {
        let chars: Vec<char> = rope_line_to_str(&rope, r).chars().collect();
        if left >= chars.len() {
            continue;
        }
        let row_right = if to_eol { chars.len() } else { right };
        let end = (row_right + 1).min(chars.len());
        let mid: String = chars[left..end].iter().collect();
        let transformed = match op {
            Operator::Uppercase => mid.to_uppercase(),
            Operator::Lowercase => mid.to_lowercase(),
            Operator::ToggleCase => toggle_case_str(&mid),
            Operator::Rot13 => rot13_str(&mid),
            _ => mid,
        };
        // Replace only the block's cell span — the row's head/tail are
        // untouched, so this is byte-identical to rebuilding the whole row.
        ed.mutate_edit(Edit::Replace {
            start: Position::new(r, left),
            end: Position::new(r, end),
            with: transformed,
        });
    }
    let saved_yank = ed.yank();
    let saved_linewise = ed.yank_linewise();
    buf_set_cursor_rc(ed.buffer_mut(), top, left);
    ed.set_yank(saved_yank);
    ed.set_yank_linewise(saved_linewise);
}
/// Yank the rectangular block `(top..=bot, left..=right)`. When `to_eol`
/// is set (`:h v_b_$`), each row's right edge is ITS OWN EOL instead of
/// the fixed `right`.
pub fn block_yank<H: hjkl_engine::types::Host>(
    ed: &Editor<hjkl_buffer::View, H>,
    top: usize,
    bot: usize,
    left: usize,
    right: usize,
    to_eol: bool,
) -> String {
    let rope = hjkl_engine::types::Query::rope(ed.buffer());
    let n = rope.len_lines();
    let mut rows: Vec<String> = Vec::new();
    for r in top..=bot {
        if r >= n {
            break;
        }
        let line = rope_line_to_str(&rope, r);
        let chars: Vec<char> = line.chars().collect();
        let row_right = if to_eol { chars.len() } else { right };
        let end = (row_right + 1).min(chars.len());
        if !to_eol && left > chars.len() {
            // Fixed-width block whose left column is STRICTLY past this
            // row's EOL: nvim fills the whole cell span with `width` spaces
            // rather than an empty segment. A row reaching EXACTLY to `left`
            // (`left == len`) still yields an empty segment. Verified against
            // nvim v0.12.4 (`<C-v>jjy` at col 3 over a 2-char row → " ").
            rows.push(" ".repeat(right + 1 - left));
        } else if left >= chars.len() {
            rows.push(String::new());
        } else {
            rows.push(chars[left..end].iter().collect());
        }
    }
    rows.join("\n")
}
/// Delete the rectangular block `(top..=bot, left..=right)`. When
/// `to_eol` is set (`:h v_b_$`), each row deletes to ITS OWN EOL instead
/// of the fixed `right` — this can't be expressed as a single
/// [`hjkl_buffer::Edit::DeleteRange`] (which applies one column pair to
/// every row), so ragged deletes loop one single-row `DeleteRange` per
/// row instead. Both paths land in the caller's undo group (no
/// `push_undo` here).
pub fn delete_block_contents<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    top: usize,
    bot: usize,
    left: usize,
    right: usize,
    to_eol: bool,
) {
    use hjkl_buffer::{Edit, MotionKind, Position};
    ed.sync_buffer_content_from_textarea();
    let last_row = bot.min(buf_row_count(ed.buffer()).saturating_sub(1));
    if last_row < top {
        return;
    }
    if to_eol {
        for row in top..=last_row {
            let row_right = buf_line_chars(ed.buffer(), row);
            ed.mutate_edit(Edit::DeleteRange {
                start: Position::new(row, left),
                end: Position::new(row, row_right),
                kind: MotionKind::Block,
            });
        }
    } else {
        ed.mutate_edit(Edit::DeleteRange {
            start: Position::new(top, left),
            end: Position::new(last_row, right),
            kind: MotionKind::Block,
        });
    }
}
/// Replace each character cell in the block with `ch`. Ragged (`:h
/// v_b_$`) per row when `vim(ed).block_to_eol` is set.
pub fn block_replace<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>, ch: char) {
    let (top, bot, left, right) = block_bounds(ed);
    let to_eol = vim(ed).block_to_eol;
    // `r` isn't an `Operator`, so record via its own dot-repeat variant.
    if !vim(ed).replaying {
        vim_mut(ed).last_change = Some(LastChange::VisualBlockReplace {
            ch,
            rows: bot - top + 1,
            cols: right + 1 - left,
            to_eol,
        });
    }
    block_replace_bounds(ed, ch, top, bot, left, right, to_eol);
}
/// Core of block `r`: replace every cell in `(top..=bot, left..=right)`
/// with `ch`. Split out so dot-repeat can drive it with a rectangle
/// reconstructed at the cursor rather than the live block selection.
pub fn block_replace_bounds<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    ch: char,
    top: usize,
    bot: usize,
    left: usize,
    right: usize,
    to_eol: bool,
) {
    ed.push_undo();
    ed.sync_buffer_content_from_textarea();
    use hjkl_buffer::{Edit, Position};
    let rope = hjkl_engine::types::Query::rope(ed.buffer());
    for r in top..=bot.min(rope.len_lines().saturating_sub(1)) {
        let chars: Vec<char> = rope_line_to_str(&rope, r).chars().collect();
        if left >= chars.len() {
            continue;
        }
        let row_right = if to_eol { chars.len() } else { right };
        let end = (row_right + 1).min(chars.len());
        // Replace only the block's cell span, as in `transform_block_case`.
        let middle: String = std::iter::repeat_n(ch, end - left).collect();
        ed.mutate_edit(Edit::Replace {
            start: Position::new(r, left),
            end: Position::new(r, end),
            with: middle,
        });
    }
    vim_mut(ed).mode = Mode::Normal;
    ed.jump_cursor(top, left);
}
/// Replicate `inserted` text across block rows `(top + 1)..=bot` at `col`.
/// `pad` distinguishes vim's two block-edge behaviours (`:h v_b_I` vs `:h
/// v_b_A`): when `pad` is true, rows shorter than `col` are padded with
/// spaces first so the text still lands at `col` (`A`); when false, rows
/// shorter than `col` are skipped entirely — no padding, no insert on that
/// row (`I`). `to_eol` (`:h v_b_$`, `A` only) overrides `col` per row with
/// that row's own current EOL — always exactly reachable, so it never pads
/// or skips. Leaves the cursor untouched — callers position it afterward.
///
/// Shared by the live `I`/`A`/`c` finish path (`finish_insert_session`) and
/// their dot-repeat replays.
pub fn replicate_block_text<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    inserted: &str,
    top: usize,
    bot: usize,
    col: usize,
    pad: bool,
    to_eol: bool,
) {
    use hjkl_buffer::{Edit, Position};
    for r in (top + 1)..=bot {
        let line_len = buf_line_chars(ed.buffer(), r);
        let row_col = if to_eol { line_len } else { col };
        if row_col > line_len {
            if !pad {
                // vim `v_b_I`: row doesn't reach the block's left
                // column — skip it, no padding, no insert.
                continue;
            }
            let pad_str: String = std::iter::repeat_n(' ', row_col - line_len).collect();
            ed.mutate_edit(Edit::InsertStr {
                at: Position::new(r, line_len),
                text: pad_str,
            });
        }
        ed.mutate_edit(Edit::InsertStr {
            at: Position::new(r, row_col),
            text: inserted.to_string(),
        });
    }
}
/// Dot-repeat replay of a block `d` / case op / `c` (`:h v_.` for blocks).
/// Reconstructs a `rows` × `cols` rectangle with its TOP-LEFT corner at the
/// current cursor (restoring the `$`-ragged `to_eol` flag) and re-runs `op`.
/// `inserted` carries the retyped `c` text (`None` for `d` / case ops).
pub fn replay_block_visual_op<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    op: Operator,
    rows: usize,
    cols: usize,
    to_eol: bool,
    inserted: Option<String>,
) {
    let (top, left) = ed.cursor();
    let bot = top + rows.saturating_sub(1);
    let right = left + cols.saturating_sub(1);
    // `d` / `c` yank the deleted rectangle into the unnamed register just
    // like the live block delete (`:h v_.` re-does the whole change,
    // register included) — verified against nvim (`.` after a block `d`
    // leaves the SECOND rectangle in `"`, not the first).
    let record_delete = |ed: &mut Editor<hjkl_buffer::View, H>| {
        let yank = block_yank(ed, top, bot, left, right, to_eol);
        if !yank.is_empty() {
            let block_width = if to_eol {
                yank.split('\n')
                    .map(|s| s.chars().count())
                    .max()
                    .unwrap_or(0)
            } else {
                right + 1 - left
            };
            ed.record_yank_to_host(yank.clone());
            // The replay's register comes from the change being repeated —
            // `replay_last_change` puts it back in `pending_register` before
            // calling here, so this consumes it exactly like the live path.
            let target = vim_mut(ed).pending_register.take();
            ed.record_delete_block(yank, block_width, target);
        }
    };
    match op {
        Operator::Delete => {
            ed.push_undo();
            record_delete(ed);
            delete_block_contents(ed, top, bot, left, right, to_eol);
            let line_len = buf_line_chars(ed.buffer(), top);
            ed.jump_cursor(top, left.min(line_len.saturating_sub(1)));
        }
        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
            ed.push_undo();
            transform_block_case(ed, op, top, bot, left, right, to_eol);
            ed.jump_cursor(top, left);
        }
        Operator::Change => {
            ed.push_undo();
            record_delete(ed);
            delete_block_contents(ed, top, bot, left, right, to_eol);
            let text = inserted.unwrap_or_default();
            if text.is_empty() {
                ed.jump_cursor(top, left);
            } else {
                use hjkl_buffer::{Edit, Position};
                // Top row directly, remaining rows via `replicate_block_text`
                // (skips rows too short to reach `left`, `:h v_b_c`).
                ed.mutate_edit(Edit::InsertStr {
                    at: Position::new(top, left),
                    text: text.clone(),
                });
                replicate_block_text(ed, &text, top, bot, left, false, false);
                let ins_chars = text.chars().count();
                let line_len = buf_line_chars(ed.buffer(), top);
                ed.jump_cursor(
                    top,
                    (left + ins_chars)
                        .saturating_sub(1)
                        .min(line_len.saturating_sub(1)),
                );
            }
        }
        _ => {}
    }
    vim_mut(ed).mode = Mode::Normal;
}
/// Dot-repeat replay of block `r{ch}` — reconstruct the rectangle at the
/// cursor and re-replace.
pub fn replay_block_replace<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    ch: char,
    rows: usize,
    cols: usize,
    to_eol: bool,
) {
    let (top, left) = ed.cursor();
    let bot = top + rows.saturating_sub(1);
    let right = left + cols.saturating_sub(1);
    block_replace_bounds(ed, ch, top, bot, left, right, to_eol);
}
/// Dot-repeat replay of block `I` / `A` — re-insert `text` at the block's
/// left (`append == false`) or right (`append == true`) edge over a
/// rectangle reconstructed at the cursor. Ragged `A` (`to_eol`) appends at
/// each row's own EOL. Cursor lands on the block's LEFT edge, matching the
/// live finish path.
pub fn replay_block_insert<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    text: &str,
    rows: usize,
    cols: usize,
    to_eol: bool,
    append: bool,
) {
    use hjkl_buffer::{Edit, Position};
    let (top, left) = ed.cursor();
    let bot = top + rows.saturating_sub(1);
    ed.push_undo();
    ed.sync_buffer_content_from_textarea();
    // `A` appends `cols` columns past the left edge (or each row's own EOL
    // when ragged); `I` inserts at the left edge.
    let top_col = if append {
        if to_eol {
            buf_line_chars(ed.buffer(), top)
        } else {
            left + cols
        }
    } else {
        left
    };
    let line_len = buf_line_chars(ed.buffer(), top);
    if append && top_col > line_len {
        let pad_str: String = std::iter::repeat_n(' ', top_col - line_len).collect();
        ed.mutate_edit(Edit::InsertStr {
            at: Position::new(top, line_len),
            text: pad_str,
        });
    }
    // Top row (cursor row) always reaches `top_col` — `left` for `I`, or a
    // just-padded column for `A`.
    ed.mutate_edit(Edit::InsertStr {
        at: Position::new(top, top_col),
        text: text.to_string(),
    });
    replicate_block_text(ed, text, top, bot, top_col, append, to_eol);
    ed.jump_cursor(top, left);
    vim_mut(ed).mode = Mode::Normal;
}
/// B2: `r{ch}` in charwise (`v`) or linewise (`V`) Visual mode — replace
/// every character in the selection with `ch`; newlines are NOT replaced
/// (a linewise selection keeps its original line count). Registers are
/// left untouched (`r` doesn't yank/delete). Cursor lands at the start of
/// the selection — verified against real nvim: charwise `llvjrX` on
/// `"aaaa\nbbbb\n..."` lands on `(0, 2)` (the selection's top), linewise
/// `VjrZ` lands on `(top, 0)`.
pub fn visual_replace_char<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    ch: char,
) {
    let linewise = matches!(vim(ed).mode, Mode::VisualLine);
    let (top, bot) = if linewise {
        let cursor_row = buf_cursor_pos(ed.buffer()).row;
        let anchor_row = vim(ed).visual_line_anchor;
        (
            (cursor_row.min(anchor_row), 0),
            (cursor_row.max(anchor_row), 0),
        )
    } else {
        let anchor = vim(ed).visual_anchor;
        let cursor = ed.cursor();
        order(anchor, cursor)
    };
    // `r` isn't an `Operator`, so record via its own dot-repeat variant
    // (mirrors block `r` / `VisualBlockReplace`). Charwise stores the same
    // `charwise_extent` the visual OPERATORS use; linewise stores the row
    // count — `.` then re-replaces a same-size region at the cursor.
    if !vim(ed).replaying {
        let extent = if linewise {
            VisualExtent::Line {
                lines: bot.0 - top.0 + 1,
            }
        } else {
            charwise_extent(top, bot)
        };
        vim_mut(ed).last_change = Some(LastChange::VisualReplace { ch, extent });
    }
    replace_range_with_char(ed, linewise, top, bot, ch);
}
/// Core of charwise/linewise Visual `r`: replace every character in
/// `(top..=bot)` with `ch`, preserving newlines. Split out so dot-repeat can
/// drive it with a region reconstructed at the cursor (`replay_visual_replace`)
/// rather than the live selection. Cursor lands at the selection start
/// (`top.1` charwise, column 0 linewise).
fn replace_range_with_char<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    linewise: bool,
    top: (usize, usize),
    bot: (usize, usize),
    ch: char,
) {
    ed.push_undo();
    ed.sync_buffer_content_from_textarea();
    use hjkl_buffer::{Edit, Position};
    let rope = hjkl_engine::types::Query::rope(ed.buffer());
    let last_row = bot.0.min(rope.len_lines().saturating_sub(1));
    for row in top.0..=last_row {
        let chars: Vec<char> = rope_line_to_str(&rope, row).chars().collect();
        let lo = if !linewise && row == top.0 { top.1 } else { 0 };
        let hi = if !linewise && row == bot.0 {
            (bot.1 + 1).min(chars.len())
        } else {
            chars.len()
        };
        if lo >= hi {
            continue;
        }
        // Replace only the selection's span — the row's head/tail stay put.
        let middle: String = std::iter::repeat_n(ch, hi - lo).collect();
        ed.mutate_edit(Edit::Replace {
            start: Position::new(row, lo),
            end: Position::new(row, hi),
            with: middle,
        });
    }
    vim_mut(ed).mode = Mode::Normal;
    ed.jump_cursor(top.0, if linewise { 0 } else { top.1 });
}
/// Dot-repeat replay of charwise/linewise Visual `r{ch}` — reconstruct a
/// same-size region anchored at the cursor from the stored `extent` (mirrors
/// the `VisualExtent::Char`/`Line` shape the visual operators replay, `:h
/// v_.`), then re-replace. `VisualExtent::Block` never reaches here (block `r`
/// rides `VisualBlockReplace`).
pub fn replay_visual_replace<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    ch: char,
    extent: VisualExtent,
) {
    let (r, c) = ed.cursor();
    match extent {
        VisualExtent::Line { lines } => {
            let top = (r, 0);
            let bot = (r + lines.saturating_sub(1), 0);
            replace_range_with_char(ed, true, top, bot, ch);
        }
        VisualExtent::Char { lines, width } => {
            let top = (r, c);
            // Same reconstruction the charwise operator replay uses: single
            // line keeps its raw `width` from the cursor; multi-line runs the
            // first line cursor-to-EOL, middle lines whole, last line's first
            // `width` chars (`bot.1` measured from column 0).
            let bot = if lines <= 1 {
                (r, c + width.saturating_sub(1))
            } else {
                (r + lines - 1, width.saturating_sub(1))
            };
            replace_range_with_char(ed, false, top, bot, ch);
        }
        VisualExtent::Block { .. } => {}
    }
}
