//! Ex-command parser + executor.
//!
//! Parses the text after a leading `:` in the command-line prompt and
//! returns an [`ExEffect`] describing what the caller should do. Only the
//! editor-local effects (substitute, goto-line, clear-highlight) are
//! applied in-place against `Editor`; quit / save / unknown are returned
//! to the caller so the TUI loop can run them.

use std::borrow::Cow;

use hjkl_engine::Editor;

/// Vim-style ex command name table. Each entry is a full canonical name
/// plus the minimum prefix length the user must type to disambiguate it.
/// Mirrors how vim resolves `:noh` → `:nohlsearch`, `:reg` →
/// `:registers`, etc. without enumerating every alias by hand.
struct CommandDef {
    full_name: &'static str,
    min_prefix: usize,
}

#[rustfmt::skip]
static COMMAND_NAMES: &[CommandDef] = &[
    CommandDef { full_name: "bNext",      min_prefix: 2 },
    CommandDef { full_name: "bdelete",    min_prefix: 2 },
    CommandDef { full_name: "bfirst",     min_prefix: 2 },
    CommandDef { full_name: "blast",      min_prefix: 2 },
    CommandDef { full_name: "bnext",      min_prefix: 2 },
    CommandDef { full_name: "bprevious",  min_prefix: 2 },
    CommandDef { full_name: "buffers",    min_prefix: 2 },
    CommandDef { full_name: "delete",     min_prefix: 1 },
    CommandDef { full_name: "edit",       min_prefix: 1 },
    CommandDef { full_name: "files",      min_prefix: 2 },
    CommandDef { full_name: "foldindent", min_prefix: 5 },
    CommandDef { full_name: "foldsyntax", min_prefix: 5 },
    CommandDef { full_name: "global",     min_prefix: 1 },
    CommandDef { full_name: "ls",         min_prefix: 2 },
    CommandDef { full_name: "changes",    min_prefix: 7 },
    CommandDef { full_name: "jumps",      min_prefix: 5 },
    CommandDef { full_name: "marks",      min_prefix: 5 },
    CommandDef { full_name: "nohlsearch", min_prefix: 3 },
    CommandDef { full_name: "qall",       min_prefix: 2 },
    CommandDef { full_name: "quit",       min_prefix: 1 },
    CommandDef { full_name: "read",       min_prefix: 1 },
    CommandDef { full_name: "redo",       min_prefix: 3 },
    CommandDef { full_name: "registers",  min_prefix: 3 },
    CommandDef { full_name: "set",        min_prefix: 2 },
    CommandDef { full_name: "sort",       min_prefix: 3 },
    CommandDef { full_name: "substitute", min_prefix: 1 },
    CommandDef { full_name: "undo",       min_prefix: 1 },
    CommandDef { full_name: "vglobal",    min_prefix: 1 },
    CommandDef { full_name: "wall",       min_prefix: 2 },
    CommandDef { full_name: "write",      min_prefix: 1 },
    CommandDef { full_name: "wq",         min_prefix: 2 },
    CommandDef { full_name: "wqall",      min_prefix: 3 },
    CommandDef { full_name: "x",          min_prefix: 1 },
];

fn resolve_name(input: &str) -> Option<&'static str> {
    if input.is_empty() {
        return None;
    }
    if let Some(exact) = COMMAND_NAMES
        .iter()
        .find(|d| d.full_name == input)
        .map(|d| d.full_name)
    {
        return Some(exact);
    }
    let candidates: Vec<&'static str> = COMMAND_NAMES
        .iter()
        .filter(|d| d.full_name.starts_with(input) && input.len() >= d.min_prefix)
        .map(|d| d.full_name)
        .collect();
    if candidates.len() == 1 {
        Some(candidates[0])
    } else {
        None
    }
}

fn split_name(cmd: &str) -> (&str, &str) {
    let split_at = cmd
        .char_indices()
        .find(|(_, c)| !c.is_ascii_alphabetic())
        .map(|(i, _)| i)
        .unwrap_or(cmd.len());
    cmd.split_at(split_at)
}

/// Expand the leading alpha-prefix of `cmd` to its canonical command
/// name via the prefix table. Returns the input unchanged when the
/// name doesn't resolve — the caller's existing fall-through arms
/// (e.g. range-only commands) still work.
///
/// Exposed so the umbrella binary's dispatch interceptors can match
/// canonical names without duplicating alias lists.
pub fn canonical_command_name(cmd: &str) -> Cow<'_, str> {
    let (name, rest) = split_name(cmd);
    if name.is_empty() {
        return Cow::Borrowed(cmd);
    }
    match resolve_name(name) {
        Some(canon) if canon != name => Cow::Owned(format!("{canon}{rest}")),
        _ => Cow::Borrowed(cmd),
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExEffect {
    /// Nothing happened (empty input or already-applied effect).
    None,
    /// Save the current buffer to the current filename.
    Save,
    /// Save to a specific path (`:w <path>`). The caller updates its
    /// `filename` field so future `:w` writes there.
    SaveAs(String),
    /// Quit (`:q`, `:q!`, `:wq`, `:x`).
    Quit { force: bool, save: bool },
    /// Unknown command — caller should surface as an error toast.
    Unknown(String),
    /// Substitution finished — report replacement count and lines changed.
    Substituted { count: usize, lines_changed: usize },
    /// A no-op response for successful commands that don't need a side
    /// effect but should not be reported as unknown (e.g. `:noh`).
    Ok,
    /// Surface an informational message.
    Info(String),
    /// Surface an error message (syntax error, bad pattern, …).
    Error(String),
}

/// Parse and execute `input` (without the leading `:`).
pub fn run<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
    input: &str,
) -> ExEffect {
    let cmd = input.trim();
    if cmd.is_empty() {
        return ExEffect::None;
    }

    // `:/pat` / `:?pat` — search-as-address with no following command.
    // Vim parses these as line addresses; with nothing trailing, the
    // cursor jumps to the matched line (forward for `/`, backward for
    // `?`). The trailing matching delimiter is optional: `:/foo`,
    // `:/foo/`, `:?foo`, and `:?foo?` are equivalent. An empty pattern
    // (`:/` / `:?`) reuses the last search like vim.
    if cmd.starts_with('/') || cmd.starts_with('?') {
        let forward = cmd.starts_with('/');
        let delim = if forward { '/' } else { '?' };
        let body = &cmd[1..];
        let pat_str: String = match body.strip_suffix(delim).unwrap_or(body) {
            "" => match editor.last_search() {
                Some(p) if !p.is_empty() => p.to_string(),
                _ => return ExEffect::Error("no previous search pattern".into()),
            },
            s => s.to_string(),
        };
        let s = editor.settings();
        let case_insensitive =
            s.ignore_case && !(s.smartcase && pat_str.chars().any(|c| c.is_uppercase()));
        let compile_src: std::borrow::Cow<'_, str> = if case_insensitive {
            std::borrow::Cow::Owned(format!("(?i){pat_str}"))
        } else {
            std::borrow::Cow::Borrowed(pat_str.as_str())
        };
        return match regex::Regex::new(&compile_src) {
            Ok(re) => {
                editor.set_search_pattern(Some(re));
                if forward {
                    editor.search_advance_forward(false);
                } else {
                    editor.search_advance_backward(true);
                }
                editor.ensure_cursor_in_scrolloff();
                editor.set_last_search(Some(pat_str), forward);
                ExEffect::Ok
            }
            Err(e) => ExEffect::Error(format!("bad search pattern: {e}")),
        };
    }

    // Strip a leading range (`5,10`, `.,$`, `'a,'b`, `%`). `range` is
    // None when the user typed no addresses; the handler defaults to
    // the command's natural scope (current line for `:s`, whole buffer
    // for `:sort` / `:g`). Resolution errors surface as ExEffect::Error.
    let (range, cmd) = match parse_range(cmd, editor) {
        Ok(pair) => pair,
        Err(e) => return ExEffect::Error(e),
    };

    // Bare line number — jump there. (Only when no range was parsed,
    // since `parse_range` already consumes a leading number as an
    // address; a bare `:5` falls through with `range = Some(5..=5)`
    // and an empty `cmd`.)
    if range.is_none() {
        if let Ok(line) = cmd.parse::<usize>() {
            editor.goto_line(line);
            return ExEffect::Ok;
        }
    } else if cmd.is_empty() {
        // `:5` jumps to line 5; `:5,10` lands on the start of the
        // range (vim's behaviour for a bare-range command).
        if let Some(r) = range {
            editor.goto_line(r.start_one_based());
            return ExEffect::Ok;
        }
    }

    // Expand the leading command-name prefix to its canonical full
    // name (`:noh` → `:nohlsearch`, `:red` → `:redo`, `:s/foo/bar/` →
    // `:substitute/foo/bar/`). The match arms below match canonical
    // names only; the table in `COMMAND_NAMES` is the single source of
    // truth for prefix resolution.
    let canon = canonical_command_name(cmd);
    let cmd: &str = canon.as_ref();

    // Bare-name terminals (`:quit`, `:write`, `:wq`, `:x`, …).
    match cmd {
        "quit" => {
            return ExEffect::Quit {
                force: false,
                save: false,
            };
        }
        "quit!" => {
            return ExEffect::Quit {
                force: true,
                save: false,
            };
        }
        "write" => return ExEffect::Save,
        "wq" | "x" => {
            return ExEffect::Quit {
                force: false,
                save: true,
            };
        }
        "qall" => {
            return ExEffect::Quit {
                force: false,
                save: false,
            };
        }
        "qall!" => {
            return ExEffect::Quit {
                force: true,
                save: false,
            };
        }
        "wqall" | "wqall!" => {
            return ExEffect::Quit {
                force: false,
                save: true,
            };
        }
        "nohlsearch" => {
            editor.set_search_pattern(None);
            return ExEffect::Ok;
        }
        "registers" => return ExEffect::Info(format_registers(editor)),
        "marks" => return ExEffect::Info(format_marks(editor)),
        "jumps" => return ExEffect::Info(format_jumps(editor)),
        "changes" => return ExEffect::Info(format_changes(editor)),
        "undo" => {
            editor.undo();
            return ExEffect::Ok;
        }
        "redo" => {
            editor.redo();
            return ExEffect::Ok;
        }
        "foldindent" => return apply_fold_indent(editor),
        "foldsyntax" => return apply_fold_syntax(editor),
        _ => {}
    }

    // `:write <path>` — save to a specific file. Caller updates filename.
    if let Some(path) = cmd.strip_prefix("write ") {
        let path = path.trim();
        if !path.is_empty() {
            return ExEffect::SaveAs(path.to_string());
        }
    }

    // `:[range]sort[!][iun]` — defaults to the whole buffer when no
    // range is given.
    if let Some(rest) = cmd.strip_prefix("sort") {
        return apply_sort(editor, range, rest);
    }

    // `:set [option ...]` — toggle / assign vim options. Range is
    // ignored (vim's `:set` doesn't accept one).
    if let Some(rest) = cmd
        .strip_prefix("set ")
        .or(if cmd == "set" { Some("") } else { None })
    {
        return apply_set(editor, rest);
    }

    // `:[range]g/pat/cmd` and inverse `:v/pat/cmd`. parse_global_prefix
    // handles canonical `global` / `vglobal` plus the bare `g` / `v`
    // single-letter forms, since canonicalization preserves a leading
    // `g`/`v` when the table can't disambiguate without a min_prefix.
    if let Some((negate, rest)) = parse_global_prefix(cmd) {
        return apply_global(editor, range, rest, negate);
    }

    // `:[range]substitute/...`. The legacy `:%s/...` form (no separate
    // range) still works because `%` is parsed by `parse_range` above.
    if let Some(rest) = cmd.strip_prefix("substitute") {
        return match parse_substitute_body(rest) {
            Ok(sub) => match apply_substitute(editor, range, sub) {
                Ok((count, lines_changed)) => ExEffect::Substituted {
                    count,
                    lines_changed,
                },
                Err(e) => ExEffect::Error(e),
            },
            Err(e) => ExEffect::Error(e),
        };
    }

    // `:[range]delete` — delete the range. Reuses :g/pat/d's row-drop loop.
    if cmd == "delete" {
        return apply_delete_range(editor, range);
    }

    // `:r path` / `:read path` — insert file contents below the
    // current line. Range is currently ignored; vim's `:Nr file`
    // semantics (insert below row N) can land later if needed.
    if let Some(path) = cmd.strip_prefix("read ") {
        return apply_read_file(editor, path.trim());
    }

    // `:[range]!cmd` — pipe rows through `cmd`, replace with stdout.
    // Without a range, `:!cmd` runs the command and surfaces stdout
    // as an Info toast (vim's `:!cmd` shows it in the message area).
    if let Some(shell_cmd) = cmd.strip_prefix('!') {
        return apply_shell_filter(editor, range, shell_cmd.trim());
    }

    ExEffect::Unknown(cmd.to_string())
}

/// `:foldsyntax` / `:folds` — apply the host-supplied syntax-tree
/// block ranges as closed folds. the host calls
/// [`Editor::set_syntax_fold_ranges`] on every tree-sitter re-parse;
/// running this command consumes the latest snapshot. No-op when the
/// host hasn't pushed any ranges yet.
fn apply_fold_syntax<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
) -> ExEffect {
    let ranges = editor.syntax_fold_ranges().to_vec();
    if ranges.is_empty() {
        return ExEffect::Info("no syntax block ranges available".into());
    }
    let count = ranges.len();
    for (start, end) in ranges {
        editor.apply_fold_op(hjkl_engine::FoldOp::Add {
            start_row: start,
            end_row: end,
            closed: true,
        });
    }
    ExEffect::Info(format!("created {count} fold(s)"))
}

/// `:foldindent` / `:foldi` — derive folds from leading-whitespace runs
/// (vim's `foldmethod=indent`, fired manually because auto-fold-on-edit
/// is expensive). Each row whose successor is more deeply indented
/// becomes a fold opener; the fold extends to the row before indent
/// drops back to or below the opener's level.
fn apply_fold_indent<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
) -> ExEffect {
    let lines = editor.buffer().lines().to_vec();
    let total = lines.len();
    if total == 0 {
        return ExEffect::Ok;
    }
    let indent =
        |line: &str| -> usize { line.chars().take_while(|c| *c == ' ' || *c == '\t').count() };
    let indents: Vec<usize> = lines.iter().map(|l| indent(l)).collect();
    let blank: Vec<bool> = lines.iter().map(|l| l.trim().is_empty()).collect();
    let mut new_folds: Vec<(usize, usize)> = Vec::new();
    let mut i = 0;
    while i + 1 < total {
        if blank[i] {
            i += 1;
            continue;
        }
        let head_indent = indents[i];
        let mut j = i + 1;
        // Skip blanks adjacent to the head — they belong to the same
        // block so a fold can span across them.
        while j < total && blank[j] {
            j += 1;
        }
        if j >= total || indents[j] <= head_indent {
            i += 1;
            continue;
        }
        // We have a fold opener — walk forward until indent drops back
        // to <= head_indent on a non-blank row.
        let mut end = j;
        let mut k = j + 1;
        while k < total {
            if !blank[k] && indents[k] <= head_indent {
                break;
            }
            end = k;
            k += 1;
        }
        new_folds.push((i, end));
        // Step by one (not past `end`) so nested indented runs inside
        // the outer block also get their own fold.
        i += 1;
    }
    if new_folds.is_empty() {
        return ExEffect::Info("no indented blocks to fold".into());
    }
    let count = new_folds.len();
    for (start, end) in new_folds {
        editor.apply_fold_op(hjkl_engine::FoldOp::Add {
            start_row: start,
            end_row: end,
            closed: true,
        });
    }
    ExEffect::Info(format!("created {count} fold(s)"))
}

/// `:[range]!cmd` — pipe the range through `cmd` (or run bare with no
/// range). With a range, the rows are joined with `\n`, fed via
/// stdin to `sh -c cmd`, and replaced with stdout. Without a range
/// the command runs detached and stdout returns as an Info toast.
fn apply_shell_filter<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
    range: Option<Range>,
    cmd: &str,
) -> ExEffect {
    if cmd.is_empty() {
        return ExEffect::Error(":! needs a shell command".into());
    }
    use std::io::Write;
    use std::process::{Command, Stdio};

    if range.is_none() {
        // Bare `:!cmd` — run, no buffer change, surface stdout via Info.
        let output = Command::new("sh").arg("-c").arg(cmd).output();
        return match output {
            Ok(out) if out.status.success() => {
                let stdout = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
                if stdout.is_empty() {
                    ExEffect::Info(format!("`{cmd}` exited 0"))
                } else {
                    ExEffect::Info(stdout)
                }
            }
            Ok(out) => {
                let stderr = String::from_utf8_lossy(&out.stderr);
                let trimmed = stderr.trim();
                let label = if trimmed.is_empty() {
                    "no stderr".to_string()
                } else {
                    trimmed.to_string()
                };
                ExEffect::Error(format!(
                    "command exited {} ({label})",
                    out.status
                        .code()
                        .map(|c| c.to_string())
                        .unwrap_or_else(|| "?".into())
                ))
            }
            Err(e) => ExEffect::Error(format!("cannot run `{cmd}`: {e}")),
        };
    }

    // Range supplied — pipe the rows through the command.
    let scope = Range::or_default(range, Range::whole(editor));
    let mut all_lines: Vec<String> = editor.buffer().lines().to_vec();
    let total = all_lines.len();
    if total == 0 {
        return ExEffect::Ok;
    }
    let bot = scope.end.min(total - 1);
    if scope.start > bot {
        return ExEffect::Ok;
    }
    let payload = all_lines[scope.start..=bot].join("\n");
    let mut child = match Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
    {
        Ok(c) => c,
        Err(e) => return ExEffect::Error(format!("cannot spawn `{cmd}`: {e}")),
    };
    if let Some(stdin) = child.stdin.as_mut() {
        match stdin.write_all(payload.as_bytes()) {
            Ok(()) => {}
            // Child closed stdin before we finished writing (e.g. `exit 5`).
            // Fall through to wait_with_output() so its actual exit status
            // wins instead of masking it as a write error.
            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
            Err(e) => return ExEffect::Error(format!("cannot write to `{cmd}`: {e}")),
        }
    }
    let output = match child.wait_with_output() {
        Ok(o) => o,
        Err(e) => return ExEffect::Error(format!("`{cmd}` failed: {e}")),
    };
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let trimmed = stderr.trim();
        let label = if trimmed.is_empty() {
            "no stderr".to_string()
        } else {
            trimmed.to_string()
        };
        return ExEffect::Error(format!(
            "command exited {} ({label})",
            output
                .status
                .code()
                .map(|c| c.to_string())
                .unwrap_or_else(|| "?".into())
        ));
    }
    let stdout = match String::from_utf8(output.stdout) {
        Ok(s) => s,
        Err(_) => return ExEffect::Error("filter output was not UTF-8".into()),
    };
    let trimmed = stdout.strip_suffix('\n').unwrap_or(&stdout);
    let new_rows: Vec<String> = trimmed.split('\n').map(String::from).collect();

    editor.push_undo();
    let after: Vec<String> = all_lines.split_off(bot + 1);
    all_lines.truncate(scope.start);
    all_lines.extend(new_rows);
    all_lines.extend(after);
    editor.restore(all_lines, (scope.start, 0));
    mark_dirty_after_ex(editor);
    ExEffect::Ok
}

/// `:r file` — read `path` from disk and insert below the current
/// row. Cursor lands on the first row of the inserted content.
/// Failures (missing file, permission denied) surface as
/// `ExEffect::Error` toasts.
fn apply_read_file<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
    path: &str,
) -> ExEffect {
    use hjkl_buffer::{Edit, Position};
    if path.is_empty() {
        return ExEffect::Error(":r needs a file path or `!cmd`".into());
    }
    // `:r !cmd` runs `cmd` through `sh -c` and inserts stdout. Same
    // security posture as running anything from a shell — the user
    // typed the command themselves.
    let content = if let Some(cmd) = path.strip_prefix('!') {
        let cmd = cmd.trim();
        if cmd.is_empty() {
            return ExEffect::Error(":r ! needs a shell command".into());
        }
        match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
            Ok(out) if out.status.success() => match String::from_utf8(out.stdout) {
                Ok(s) => s,
                Err(_) => return ExEffect::Error("command output was not UTF-8".into()),
            },
            Ok(out) => {
                let stderr = String::from_utf8_lossy(&out.stderr);
                let trimmed = stderr.trim();
                let label = if trimmed.is_empty() {
                    "no stderr".to_string()
                } else {
                    trimmed.to_string()
                };
                return ExEffect::Error(format!(
                    "command exited {} ({label})",
                    out.status
                        .code()
                        .map(|c| c.to_string())
                        .unwrap_or_else(|| "?".into())
                ));
            }
            Err(e) => return ExEffect::Error(format!("cannot run `{cmd}`: {e}")),
        }
    } else {
        match std::fs::read_to_string(path) {
            Ok(s) => s,
            Err(e) => return ExEffect::Error(format!("cannot read `{path}`: {e}")),
        }
    };
    // Vim's `:r` inserts after the current row; trailing newline in
    // the file is dropped to avoid a stray blank tail (vim does the
    // same).
    let trimmed = content.strip_suffix('\n').unwrap_or(&content);
    editor.push_undo();
    let row = editor.cursor().0;
    let line_chars = editor
        .buffer()
        .line(row)
        .map(|l| l.chars().count())
        .unwrap_or(0);
    let insert_text = format!("\n{trimmed}");
    editor.mutate_edit(Edit::InsertStr {
        at: Position::new(row, line_chars),
        text: insert_text,
    });
    // Cursor lands on the first inserted row (row + 1) at col 0.
    editor.jump_cursor(row + 1, 0);
    mark_dirty_after_ex(editor);
    ExEffect::Ok
}

/// 0-based, inclusive line range over the buffer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Range {
    start: usize,
    end: usize,
}

impl Range {
    fn whole<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> Self {
        let last = editor.buffer().lines().len().saturating_sub(1);
        Self {
            start: 0,
            end: last,
        }
    }

    fn single(row: usize) -> Self {
        Self {
            start: row,
            end: row,
        }
    }

    fn start_one_based(&self) -> usize {
        self.start + 1
    }

    fn or_default(opt: Option<Self>, default: Self) -> Self {
        opt.unwrap_or(default)
    }
}

/// Single ex-mode address: `5`, `.`, `$`, `'a`. No `+/-` offset arith
/// yet — keeps the parser tight.
#[derive(Debug, Clone, Copy)]
enum Address {
    Number(usize), // 1-based, as the user typed it
    Current,
    Last,
    Mark(char),
}

/// Strip a leading address from `s` and return it plus the remainder.
/// Returns `None` when `s` doesn't start with one — the caller treats
/// that as "no range provided".
fn parse_address(s: &str) -> Option<(Address, &str)> {
    let mut chars = s.char_indices();
    let (_, first) = chars.next()?;
    match first {
        '.' => Some((Address::Current, &s[1..])),
        '$' => Some((Address::Last, &s[1..])),
        '\'' => {
            let (_, mark) = chars.next()?;
            Some((Address::Mark(mark), &s[2..]))
        }
        '0'..='9' => {
            let mut end = 1;
            for (i, c) in s.char_indices().skip(1) {
                if c.is_ascii_digit() {
                    end = i + c.len_utf8();
                } else {
                    break;
                }
            }
            let n: usize = s[..end].parse().ok()?;
            Some((Address::Number(n), &s[end..]))
        }
        _ => None,
    }
}

/// Resolve a parsed address against the current editor state. Numeric
/// addresses are clamped to the buffer; bad marks return an error.
fn resolve_address<H: hjkl_engine::Host>(
    addr: Address,
    editor: &Editor<hjkl_buffer::Buffer, H>,
) -> Result<usize, String> {
    let last = editor.buffer().lines().len().saturating_sub(1);
    match addr {
        Address::Number(n) => Ok(n.saturating_sub(1).min(last)),
        Address::Current => Ok(editor.cursor().0),
        Address::Last => Ok(last),
        Address::Mark(c) => editor
            .mark(c)
            .map(|(r, _)| r.min(last))
            .ok_or_else(|| format!("mark `{c}` not set")),
    }
}

/// Strip a leading range (`%`, `N`, `N,M`, `.,$`, `'a,'b`) from `cmd`.
/// Returns the resolved 0-based inclusive range plus the remainder.
fn parse_range<'a, H: hjkl_engine::Host>(
    cmd: &'a str,
    editor: &Editor<hjkl_buffer::Buffer, H>,
) -> Result<(Option<Range>, &'a str), String> {
    if let Some(rest) = cmd.strip_prefix('%') {
        return Ok((Some(Range::whole(editor)), rest));
    }
    let Some((start_addr, after_start)) = parse_address(cmd) else {
        return Ok((None, cmd));
    };
    let start = resolve_address(start_addr, editor)?;
    if let Some(after_comma) = after_start.strip_prefix(',') {
        let (end_addr, rest) =
            parse_address(after_comma).unwrap_or((Address::Number(start + 1), after_comma));
        let end = resolve_address(end_addr, editor)?;
        let (lo, hi) = if start <= end {
            (start, end)
        } else {
            (end, start)
        };
        return Ok((Some(Range { start: lo, end: hi }), rest));
    }
    Ok((Some(Range::single(start)), after_start))
}

/// `:[range]d` — drop every row in the range.
fn apply_delete_range<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
    range: Option<Range>,
) -> ExEffect {
    use hjkl_buffer::{Edit, MotionKind, Position};
    let r = Range::or_default(range, Range::single(editor.cursor().0));
    let total = editor.buffer().row_count();
    if total == 0 {
        return ExEffect::Ok;
    }
    let bot = r.end.min(total.saturating_sub(1));
    if r.start > bot {
        return ExEffect::Ok;
    }
    editor.push_undo();
    // Delete bottom-up so row indices stay valid.
    for row in (r.start..=bot).rev() {
        if editor.buffer().row_count() == 1 {
            let line_chars = editor
                .buffer()
                .line(0)
                .map(|l| l.chars().count())
                .unwrap_or(0);
            if line_chars > 0 {
                editor.mutate_edit(Edit::DeleteRange {
                    start: Position::new(0, 0),
                    end: Position::new(0, line_chars),
                    kind: MotionKind::Char,
                });
            }
            continue;
        }
        editor.mutate_edit(Edit::DeleteRange {
            start: Position::new(row, 0),
            end: Position::new(row, 0),
            kind: MotionKind::Line,
        });
    }
    mark_dirty_after_ex(editor);
    ExEffect::Ok
}

/// Detect a `:g/pat/cmd`, `:g!/pat/cmd`, or `:v/pat/cmd` prefix.
/// Returns `(negate, body_after_prefix)` where `body_after_prefix`
/// still has the leading separator + pattern + cmd attached.
fn parse_global_prefix(cmd: &str) -> Option<(bool, &str)> {
    // After canonicalization, `:g/pat/d` arrives as "global/pat/d" and
    // `:v/pat/d` as "vglobal/pat/d". `:g!` (negate) becomes "global!".
    if let Some(rest) = cmd.strip_prefix("global!") {
        return Some((true, rest));
    }
    if let Some(rest) = cmd.strip_prefix("vglobal") {
        return Some((true, rest));
    }
    if let Some(rest) = cmd.strip_prefix("global") {
        return Some((false, rest));
    }
    None
}

/// Run `:[range]g/pat/d` (or its negated variants). Walks the rows in
/// `range` (whole buffer when None), collects matches, then drops them
/// in reverse so row indices stay valid through the cascade of deletes.
fn apply_global<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
    range: Option<Range>,
    body: &str,
    negate: bool,
) -> ExEffect {
    use hjkl_buffer::{Edit, MotionKind, Position};
    let mut chars = body.chars();
    let sep = match chars.next() {
        Some(c) => c,
        None => return ExEffect::Error("empty :g pattern".into()),
    };
    if sep.is_alphanumeric() || sep == '\\' {
        return ExEffect::Error("global needs a separator, e.g. :g/foo/d".into());
    }
    let rest: String = chars.collect();
    let parts = split_unescaped(&rest, sep);
    if parts.len() < 2 {
        return ExEffect::Error("global needs /pattern/cmd".into());
    }
    let pattern = unescape(&parts[0], sep);
    let cmd = parts[1].trim();
    if cmd != "d" {
        return ExEffect::Error(format!(":g supports only `d` today, got `{cmd}`"));
    }
    let regex = match regex::Regex::new(&pattern) {
        Ok(r) => r,
        Err(e) => return ExEffect::Error(format!("bad pattern: {e}")),
    };

    editor.push_undo();
    // Identify rows to drop (newest-first so multi-line drops don't
    // shift indices under us). Default to the whole buffer when no
    // range was supplied — matches vim's `:g/pat/d` (no range = `%`).
    let scope = Range::or_default(range, Range::whole(editor));
    let row_count = editor.buffer().row_count();
    let bot = scope.end.min(row_count.saturating_sub(1));
    let mut targets: Vec<usize> = Vec::new();
    for row in scope.start..=bot {
        let line = editor.buffer().line(row).unwrap_or("");
        let matches = regex.is_match(line);
        if matches != negate {
            targets.push(row);
        }
    }
    if targets.is_empty() {
        editor.pop_last_undo();
        return ExEffect::Substituted {
            count: 0,
            lines_changed: 0,
        };
    }
    let count = targets.len();
    for row in targets.iter().rev() {
        let row = *row;
        // Last row in a 1-row buffer can't be removed (Buffer keeps
        // the one-empty-row invariant); just clear it instead.
        if editor.buffer().row_count() == 1 {
            let line_chars = editor
                .buffer()
                .line(0)
                .map(|l| l.chars().count())
                .unwrap_or(0);
            if line_chars > 0 {
                editor.mutate_edit(Edit::DeleteRange {
                    start: Position::new(0, 0),
                    end: Position::new(0, line_chars),
                    kind: MotionKind::Char,
                });
            }
            continue;
        }
        editor.mutate_edit(Edit::DeleteRange {
            start: Position::new(row, 0),
            end: Position::new(row, 0),
            kind: MotionKind::Line,
        });
    }
    mark_dirty_after_ex(editor);
    ExEffect::Substituted {
        count,
        lines_changed: count,
    }
}

/// `:set [opt ...]` body. Splits on whitespace and applies each token.
/// Bare `:set` reports the current values for the supported options.
fn apply_set<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
    body: &str,
) -> ExEffect {
    let trimmed = body.trim();
    if trimmed.is_empty() {
        let s = editor.settings();
        let wrap = match s.wrap {
            hjkl_buffer::Wrap::None => "off",
            hjkl_buffer::Wrap::Char => "char",
            hjkl_buffer::Wrap::Word => "word",
        };
        let scl = match s.signcolumn {
            hjkl_engine::types::SignColumnMode::Yes => "yes",
            hjkl_engine::types::SignColumnMode::No => "no",
            hjkl_engine::types::SignColumnMode::Auto => "auto",
        };
        return ExEffect::Info(format!(
            "shiftwidth={}  tabstop={}  softtabstop={}  textwidth={}  undolevels={}  timeoutlen={}  iskeyword=\"{}\"  expandtab={}  ignorecase={}  smartcase={}  wrapscan={}  autoindent={}  smartindent={}  undobreak={}  readonly={}  wrap={}  number={}  relativenumber={}  numberwidth={}  cursorline={}  cursorcolumn={}  signcolumn={}  foldcolumn={}  colorcolumn=\"{}\"",
            s.shiftwidth,
            s.tabstop,
            s.softtabstop,
            s.textwidth,
            s.undo_levels,
            s.timeout_len.as_millis(),
            s.iskeyword,
            if s.expandtab { "on" } else { "off" },
            if s.ignore_case { "on" } else { "off" },
            if s.smartcase { "on" } else { "off" },
            if s.wrapscan { "on" } else { "off" },
            if s.autoindent { "on" } else { "off" },
            if s.smartindent { "on" } else { "off" },
            if s.undo_break_on_motion { "on" } else { "off" },
            if s.readonly { "on" } else { "off" },
            wrap,
            if s.number { "on" } else { "off" },
            if s.relativenumber { "on" } else { "off" },
            s.numberwidth,
            if s.cursorline { "on" } else { "off" },
            if s.cursorcolumn { "on" } else { "off" },
            scl,
            s.foldcolumn,
            s.colorcolumn,
        ));
    }
    for token in trimmed.split_whitespace() {
        if let Err(e) = apply_set_token(editor, token) {
            return ExEffect::Error(e);
        }
    }
    ExEffect::Ok
}

/// Apply a single `:set` token. Supports `name=value`, bare `name`
/// (turns booleans on), and `noname` (turns booleans off).
fn apply_set_token<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
    token: &str,
) -> Result<(), String> {
    if let Some((name, value)) = token.split_once('=') {
        // String-valued options short-circuit the numeric parse.
        if matches!(name, "iskeyword" | "isk") {
            editor.set_iskeyword(value);
            return Ok(());
        }
        if matches!(name, "signcolumn" | "scl") {
            editor.settings_mut().signcolumn = match value {
                "yes" => hjkl_engine::types::SignColumnMode::Yes,
                "no" => hjkl_engine::types::SignColumnMode::No,
                "auto" => hjkl_engine::types::SignColumnMode::Auto,
                other => {
                    return Err(format!(
                        "signcolumn must be `yes`, `no`, or `auto`, got `{other}`"
                    ));
                }
            };
            return Ok(());
        }
        if matches!(name, "colorcolumn" | "cc") {
            editor.settings_mut().colorcolumn = value.to_string();
            return Ok(());
        }
        let parsed: usize = value
            .parse()
            .map_err(|_| format!("bad value `{value}` for :set {name}"))?;
        match name {
            "shiftwidth" | "sw" => {
                if parsed == 0 {
                    return Err("shiftwidth must be > 0".into());
                }
                editor.settings_mut().shiftwidth = parsed;
            }
            "tabstop" | "ts" => {
                if parsed == 0 {
                    return Err("tabstop must be > 0".into());
                }
                editor.settings_mut().tabstop = parsed;
            }
            "textwidth" | "tw" => {
                if parsed == 0 {
                    return Err("textwidth must be > 0".into());
                }
                editor.settings_mut().textwidth = parsed;
            }
            "undolevels" | "ul" => {
                editor.settings_mut().undo_levels = parsed.min(u32::MAX as usize) as u32;
            }
            "timeoutlen" | "tm" => {
                editor.settings_mut().timeout_len =
                    core::time::Duration::from_millis(parsed as u64);
            }
            "numberwidth" | "nuw" => {
                if !(1..=20).contains(&parsed) {
                    return Err(format!("numberwidth must be in range 1..=20, got {parsed}"));
                }
                editor.settings_mut().numberwidth = parsed;
            }
            "foldcolumn" | "fdc" => {
                if parsed > 12 {
                    return Err(format!("foldcolumn must be in range 0..=12, got {parsed}"));
                }
                editor.settings_mut().foldcolumn = parsed as u32;
            }
            other => return Err(format!("unknown :set option `{other}`")),
        }
        return Ok(());
    }
    // Handle toggle (name!) — must check before the `no` strip.
    if let Some(name) = token.strip_suffix('!') {
        match name {
            "number" | "nu" => {
                editor.settings_mut().number = !editor.settings().number;
            }
            "relativenumber" | "rnu" => {
                editor.settings_mut().relativenumber = !editor.settings().relativenumber;
            }
            "cursorline" | "cul" => {
                editor.settings_mut().cursorline = !editor.settings().cursorline;
            }
            "cursorcolumn" | "cuc" => {
                editor.settings_mut().cursorcolumn = !editor.settings().cursorcolumn;
            }
            other => return Err(format!("unknown :set option `{other}`")),
        }
        return Ok(());
    }
    let (name, value) = if let Some(rest) = token.strip_prefix("no") {
        (rest, false)
    } else {
        (token, true)
    };
    match name {
        "ignorecase" | "ic" => editor.settings_mut().ignore_case = value,
        "smartcase" | "scs" => editor.settings_mut().smartcase = value,
        "wrapscan" | "ws" => editor.settings_mut().wrapscan = value,
        "expandtab" | "et" => editor.settings_mut().expandtab = value,
        "autoindent" | "ai" => editor.settings_mut().autoindent = value,
        "smartindent" | "si" => editor.settings_mut().smartindent = value,
        "undobreak" => editor.settings_mut().undo_break_on_motion = value,
        "readonly" | "ro" => editor.settings_mut().readonly = value,
        "number" | "nu" => editor.settings_mut().number = value,
        "relativenumber" | "rnu" => editor.settings_mut().relativenumber = value,
        "cursorline" | "cul" => editor.settings_mut().cursorline = value,
        "cursorcolumn" | "cuc" => editor.settings_mut().cursorcolumn = value,
        "wrap" => {
            editor.settings_mut().wrap = if value {
                // Preserve `Wrap::Word` if `linebreak` already flipped
                // word-mode on; otherwise default `set wrap` to char.
                match editor.settings().wrap {
                    hjkl_buffer::Wrap::Word => hjkl_buffer::Wrap::Word,
                    _ => hjkl_buffer::Wrap::Char,
                }
            } else {
                hjkl_buffer::Wrap::None
            };
        }
        "linebreak" | "lbr" => {
            editor.settings_mut().wrap = if value {
                hjkl_buffer::Wrap::Word
            } else {
                // `nolinebreak` drops back to char wrap when wrap is on,
                // otherwise stays off.
                match editor.settings().wrap {
                    hjkl_buffer::Wrap::None => hjkl_buffer::Wrap::None,
                    _ => hjkl_buffer::Wrap::Char,
                }
            };
        }
        // Booleans we don't (yet) honour: accept silently so :set lines
        // copied from a vimrc don't error out. `foldenable` falls here.
        "foldenable" | "fen" => {}
        other => return Err(format!("unknown :set option `{other}`")),
    }
    Ok(())
}

/// `:[range]sort[!][iun]` body — `flags` is whatever followed the
/// command name (e.g. `!u`, ` un`, `i`). Sorts only the rows in `range`
/// (or the whole buffer when None).
fn apply_sort<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
    range: Option<Range>,
    flags: &str,
) -> ExEffect {
    let trimmed = flags.trim();
    let mut reverse = false;
    let mut unique = false;
    let mut numeric = false;
    let mut ignore_case = false;
    for c in trimmed.chars() {
        match c {
            '!' => reverse = true,
            'u' => unique = true,
            'n' => numeric = true,
            'i' => ignore_case = true,
            ' ' | '\t' => {}
            other => return ExEffect::Error(format!("bad :sort flag `{other}`")),
        }
    }

    let mut all_lines: Vec<String> = editor.buffer().lines().to_vec();
    let total = all_lines.len();
    if total == 0 {
        return ExEffect::Ok;
    }
    let scope = Range::or_default(range, Range::whole(editor));
    let bot = scope.end.min(total - 1);
    if scope.start > bot {
        return ExEffect::Ok;
    }
    // Sort only the slice in range; keep the rest of the buffer intact.
    let mut slice: Vec<String> = all_lines[scope.start..=bot].to_vec();
    if numeric {
        // Vim's `:sort n`: extract the first decimal integer (with
        // optional leading `-`) on each line; lines with no number
        // sort first, in original order.
        slice.sort_by_key(|l| extract_leading_number(l));
    } else if ignore_case {
        slice.sort_by_key(|s| s.to_lowercase());
    } else {
        slice.sort();
    }
    if reverse {
        slice.reverse();
    }
    if unique {
        let cmp_key = |s: &str| -> String {
            if ignore_case {
                s.to_lowercase()
            } else {
                s.to_string()
            }
        };
        let mut seen = std::collections::HashSet::new();
        slice.retain(|line| seen.insert(cmp_key(line)));
    }
    // Splice the sorted slice back. `unique` may have shortened it.
    let after: Vec<String> = all_lines.split_off(bot + 1);
    all_lines.truncate(scope.start);
    all_lines.extend(slice);
    all_lines.extend(after);

    editor.push_undo();
    editor.restore(all_lines, (scope.start, 0));
    mark_dirty_after_ex(editor);
    ExEffect::Ok
}

/// Parse the first signed decimal integer from `line` for `:sort n`.
/// Lines with no leading number sort as `i64::MIN` so they cluster at
/// the top, matching vim's behaviour.
fn extract_leading_number(line: &str) -> i64 {
    let bytes = line.as_bytes();
    let mut i = 0;
    while i < bytes.len() && !bytes[i].is_ascii_digit() && bytes[i] != b'-' {
        i += 1;
    }
    if i >= bytes.len() {
        return i64::MIN;
    }
    let mut j = i;
    if bytes[j] == b'-' {
        j += 1;
    }
    let start = j;
    while j < bytes.len() && bytes[j].is_ascii_digit() {
        j += 1;
    }
    if j == start {
        return i64::MIN;
    }
    line[i..j].parse().unwrap_or(i64::MIN)
}

/// `:reg` / `:registers` — tabular dump of every non-empty register slot.
fn format_registers<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> String {
    let r = editor.registers();
    let mut lines = vec!["--- Registers ---".to_string()];
    let mut push = |sel: &str, text: &str, linewise: bool| {
        if text.is_empty() {
            return;
        }
        let marker = if linewise { "L" } else { " " };
        lines.push(format!("{sel:<3} {marker} {}", display_register(text)));
    };
    push("\"\"", &r.unnamed.text, r.unnamed.linewise);
    push("\"0", &r.yank_zero.text, r.yank_zero.linewise);
    for (i, slot) in r.delete_ring.iter().enumerate() {
        let sel = format!("\"{}", i + 1);
        push(&sel, &slot.text, slot.linewise);
    }
    for (i, slot) in r.named.iter().enumerate() {
        let sel = format!("\"{}", (b'a' + i as u8) as char);
        push(&sel, &slot.text, slot.linewise);
    }
    if lines.len() == 1 {
        lines.push("(no registers set)".to_string());
    }
    lines.join("\n")
}

/// Escape control chars + truncate so a multi-line register fits a single row
/// of the toast table.
fn display_register(text: &str) -> String {
    let escaped: String = text
        .chars()
        .map(|c| match c {
            '\n' => "\\n".to_string(),
            '\t' => "\\t".to_string(),
            '\r' => "\\r".to_string(),
            c => c.to_string(),
        })
        .collect();
    const MAX: usize = 60;
    if escaped.chars().count() > MAX {
        let head: String = escaped.chars().take(MAX - 3).collect();
        format!("{head}...")
    } else {
        escaped
    }
}

/// `:marks` — list every set mark with `(line, col)`. Lines are 1-based to
/// match vim; cols are 0-based.
fn format_marks<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> String {
    let mut lines = vec!["--- Marks ---".to_string(), "mark  line  col".to_string()];
    // 0.0.36: lowercase + uppercase marks share the unified
    // `Editor::marks` map. `marks()` already iterates in BTreeMap
    // (char-ordered) sequence, so no extra sort needed.
    let entries: Vec<(char, usize, usize)> =
        editor.marks().map(|(c, (r, col))| (c, r, col)).collect();
    for (c, r, col) in entries {
        lines.push(format!(" {c}    {:>4}  {col:>3}", r + 1));
    }
    if let Some((r, col)) = editor.last_jump_back() {
        lines.push(format!(" '    {:>4}  {col:>3}", r + 1));
    }
    if let Some((r, col)) = editor.last_edit_pos() {
        lines.push(format!(" .    {:>4}  {col:>3}", r + 1));
    }
    if lines.len() == 2 {
        lines.push("(no marks set)".to_string());
    }
    lines.join("\n")
}

/// `:jumps` — list the jump-back and jump-forward lists.
/// Format mirrors vim: `jump  line  col  file` columns. Newest items
/// have the smallest `jump` number; current position is `0`.
fn format_jumps<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> String {
    let (back, fwd) = editor.jump_list();
    if back.is_empty() && fwd.is_empty() {
        return "(no jumps recorded)".to_string();
    }
    let mut lines = vec![
        "--- Jump list ---".to_string(),
        " jump  line   col".to_string(),
    ];
    // jump_back: oldest at index 0, newest at the end.
    // Display as descending jump numbers: back.len() at oldest, 1 at newest, 0 = current.
    // Then jump_fwd (forward history) at negative-1, -2, …  vim shows >0 for fwd.
    // We keep it simple: back list reversed with ascending index, then fwd list.
    let back_len = back.len();
    for (i, &(row, col)) in back.iter().rev().enumerate() {
        let jump_num = i + 1;
        lines.push(format!("{jump_num:>5}  {:>4}  {:>4}", row + 1, col));
    }
    // Mark current position (not in list — just a separator).
    lines.push(format!("{:>5}  (current position)", 0));
    for (i, &(row, col)) in fwd.iter().enumerate() {
        let jump_num = -(i as isize + 1);
        lines.push(format!("{jump_num:>5}  {:>4}  {:>4}", row + 1, col));
    }
    let _ = back_len; // used above
    lines.join("\n")
}

/// `:changes` — list the change list (bounded ring of recent edit positions).
/// Newest entries have lower index numbers, matching vim's `:changes` output.
fn format_changes<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> String {
    let (list, cursor) = editor.change_list();
    if list.is_empty() {
        return "(no changes recorded)".to_string();
    }
    let mut lines = vec![
        "--- Change list ---".to_string(),
        "change  line   col".to_string(),
    ];
    let len = list.len();
    // List is oldest-at-front, newest-at-back; display newest first (change 1 = most recent).
    for (display_idx, &(row, col)) in list.iter().rev().enumerate() {
        let change_num = display_idx + 1;
        // Mark the current walk position, if any. `change_list_cursor` is an
        // index into the original (oldest-first) vec; invert to display-index.
        let marker = match cursor {
            Some(c) if c == len - 1 - display_idx => " <",
            _ => "",
        };
        lines.push(format!(
            "{change_num:>6}  {:>4}  {:>4}{marker}",
            row + 1,
            col
        ));
    }
    lines.join("\n")
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct Substitute {
    /// Empty string means "reuse last_search".
    pattern: String,
    replacement: String,
    global: bool,
    case_insensitive: bool,
    /// `I` flag — force case-sensitive (overrides editor ignorecase).
    case_sensitive: bool,
    /// `c` flag — confirm. Parsed, silently ignored (v1 limitation).
    #[allow(dead_code)]
    confirm: bool,
}

/// Parse the `/pat/repl/flags` tail of a substitute command. The leading
/// `s` or `%s` has already been stripped. The separator is the first
/// character after the optional scope (typically `/`), matching vim.
fn parse_substitute_body(body: &str) -> Result<Substitute, String> {
    let mut chars = body.chars();
    let sep = chars.next().ok_or_else(|| "empty substitute".to_string())?;
    if sep.is_alphanumeric() || sep == '\\' {
        return Err("substitute needs a separator, e.g. :s/foo/bar/".into());
    }
    let rest: String = chars.collect();
    let parts = split_unescaped(&rest, sep);
    if parts.len() < 2 {
        return Err("substitute needs /pattern/replacement/".into());
    }
    let pattern = unescape(&parts[0], sep);
    let replacement = unescape(&parts[1], sep);
    let flags = parts.get(2).cloned().unwrap_or_default();
    let mut global = false;
    let mut case_insensitive = false;
    let mut case_sensitive = false;
    let mut confirm = false;
    for f in flags.chars() {
        match f {
            'g' => global = true,
            'i' => case_insensitive = true,
            'I' => case_sensitive = true,
            'c' => confirm = true, // parsed, silently ignored (v1)
            other => return Err(format!("unknown substitute flag: {other}")),
        }
    }
    Ok(Substitute {
        pattern,
        replacement,
        global,
        case_insensitive,
        case_sensitive,
        confirm,
    })
}

/// Split `s` by `sep`, treating `\<sep>` as a literal occurrence.
fn split_unescaped(s: &str, sep: char) -> Vec<String> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\\' {
            if let Some(&next) = chars.peek() {
                // Preserve the escape so regex metachars survive; only
                // collapse an escaped separator into a literal separator.
                if next == sep {
                    cur.push(sep);
                    chars.next();
                } else {
                    cur.push('\\');
                    cur.push(next);
                    chars.next();
                }
            } else {
                cur.push('\\');
            }
        } else if c == sep {
            out.push(std::mem::take(&mut cur));
        } else {
            cur.push(c);
        }
    }
    out.push(cur);
    out
}

/// Remove our `\<sep>` → `<sep>` escape. Other `\x` sequences pass
/// through so regex escape syntax (`\b`, `\d`, …) still works.
fn unescape(s: &str, _sep: char) -> String {
    s.to_string()
}

fn apply_substitute<H: hjkl_engine::Host>(
    editor: &mut Editor<hjkl_buffer::Buffer, H>,
    range: Option<Range>,
    sub: Substitute,
) -> Result<(usize, usize), String> {
    // Resolve effective pattern.  Empty string means "reuse last_search".
    let effective_pat: String = if sub.pattern.is_empty() {
        editor
            .last_search()
            .map(str::to_owned)
            .ok_or_else(|| "no previous regular expression".to_string())?
    } else {
        sub.pattern.clone()
    };

    // Case-sensitivity: `I` (force case-sensitive) > `i` > editor setting.
    let case_insensitive = if sub.case_sensitive {
        false
    } else if sub.case_insensitive {
        true
    } else {
        editor.settings().ignore_case
    };
    let pattern = if case_insensitive {
        format!("(?i){effective_pat}")
    } else {
        effective_pat.clone()
    };
    let regex = regex::Regex::new(&pattern).map_err(|e| format!("bad pattern: {e}"))?;

    editor.push_undo();

    // No range = current line only — matches vim's `:s` default.
    let scope = Range::or_default(range, Range::single(editor.cursor().0));
    let (range_start, range_end) = (scope.start, scope.end);

    let mut new_lines: Vec<String> = editor.buffer().lines().to_vec();
    let mut count = 0usize;
    let mut lines_changed = 0usize;
    let mut last_changed_row = range_start;
    let clamp = range_end.min(new_lines.len().saturating_sub(1));
    for (i, line) in new_lines[range_start..=clamp].iter_mut().enumerate() {
        let (replaced, n) = regex_replace(&regex, line, &sub.replacement, sub.global);
        if n > 0 {
            *line = replaced;
            count += n;
            lines_changed += 1;
            last_changed_row = range_start + i;
        }
    }

    if count == 0 {
        // Undo the undo push so the user doesn't see an empty undo step.
        editor.pop_last_undo();
        return Ok((0, 0));
    }

    // Apply the new content. Yank survives across loads since it's
    // owned by Editor now (was previously held by the textarea).
    editor.buffer_mut().replace_all(&new_lines.join("\n"));
    editor
        .buffer_mut()
        .set_cursor(hjkl_buffer::Position::new(last_changed_row, 0));
    mark_dirty_after_ex(editor);

    // Update last_search so n/N can repeat the same pattern.
    editor.set_last_search(Some(effective_pat), true);

    Ok((count, lines_changed))
}

/// Count-returning variant of `Regex::replace` / `replace_all`. The
/// replacement is first translated from vim's notation (`&`) to the
/// regex crate's (`$0`) so `$n` interpolation still runs.
fn regex_replace(
    regex: &regex::Regex,
    text: &str,
    replacement: &str,
    global: bool,
) -> (String, usize) {
    let matches = regex.find_iter(text).count();
    if matches == 0 {
        return (text.to_string(), 0);
    }
    let rep = expand_vim_replacement(replacement);
    let replaced = if global {
        regex.replace_all(text, rep.as_str()).into_owned()
    } else {
        regex.replace(text, rep.as_str()).into_owned()
    };
    let count = if global { matches } else { 1 };
    (replaced, count)
}

/// Translate vim-ish replacement placeholders to regex ones. For now only
/// `&` → the whole match; vim also supports `\0-\9` which the `regex`
/// crate already honours, so we leave those alone.
fn expand_vim_replacement(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\\' {
            if let Some(&next) = chars.peek() {
                out.push('\\');
                out.push(next);
                chars.next();
            } else {
                out.push('\\');
            }
        } else if c == '&' {
            // `&` in vim replacement == whole match, same as `$0` for `regex`.
            out.push_str("$0");
        } else {
            out.push(c);
        }
    }
    out
}

/// Called by ex-command handlers after they rewrite the buffer. Ensures
/// dirty tracking and undo bookkeeping stay consistent.
///
/// Free function rather than an inherent method because Rust's orphan
/// rules forbid `impl Editor` from outside the engine crate. Callers
/// that previously wrote `editor.mark_dirty_after_ex()` now write
/// `mark_dirty_after_ex(editor)`.
fn mark_dirty_after_ex<H: hjkl_engine::Host>(editor: &mut Editor<hjkl_buffer::Buffer, H>) {
    editor.mark_content_dirty();
}

#[cfg(test)]
mod resolver_tests {
    use super::*;

    #[test]
    fn resolves_unique_prefix_to_canonical() {
        assert_eq!(canonical_command_name("noh"), "nohlsearch");
        assert_eq!(canonical_command_name("nohl"), "nohlsearch");
        assert_eq!(canonical_command_name("nohls"), "nohlsearch");
        assert_eq!(canonical_command_name("nohlsearch"), "nohlsearch");
        assert_eq!(canonical_command_name("reg"), "registers");
        assert_eq!(canonical_command_name("red"), "redo");
        assert_eq!(canonical_command_name("u"), "undo");
        assert_eq!(canonical_command_name("e"), "edit");
        assert_eq!(canonical_command_name("se"), "set");
    }

    #[test]
    fn keeps_args_intact() {
        assert_eq!(canonical_command_name("e foo.txt"), "edit foo.txt");
        assert_eq!(canonical_command_name("w /tmp/x"), "write /tmp/x");
        assert_eq!(canonical_command_name("s/foo/bar/"), "substitute/foo/bar/");
        assert_eq!(canonical_command_name("e!"), "edit!");
        assert_eq!(canonical_command_name("q!"), "quit!");
    }

    #[test]
    fn ambiguous_prefix_falls_through() {
        // `f` matches foldindent + foldsyntax, both min_prefix=5, so the
        // shorter input doesn't reach either's threshold and falls
        // through unchanged. `fold` likewise.
        assert_eq!(canonical_command_name("f"), "f");
        assert_eq!(canonical_command_name("fold"), "fold");
        // `r` is min_prefix=1 for read; `red` is exactly redo's
        // min_prefix; `reg` is registers'.
        assert_eq!(canonical_command_name("r"), "read");
        assert_eq!(canonical_command_name("red"), "redo");
        assert_eq!(canonical_command_name("reg"), "registers");
    }

    #[test]
    fn unknown_command_passes_through_unchanged() {
        assert_eq!(canonical_command_name("foobar"), "foobar");
        assert_eq!(canonical_command_name(""), "");
    }

    #[test]
    fn resolves_multi_buffer_commands() {
        assert_eq!(canonical_command_name("bd"), "bdelete");
        assert_eq!(canonical_command_name("bn"), "bnext");
        assert_eq!(canonical_command_name("ls"), "ls");
        assert_eq!(canonical_command_name("bd!"), "bdelete!");
        assert_eq!(canonical_command_name("bp"), "bprevious");
        assert_eq!(canonical_command_name("wa"), "wall");
        assert_eq!(canonical_command_name("qa"), "qall");
        assert_eq!(canonical_command_name("wqa"), "wqall");
    }
}

#[cfg(all(test, feature = "crossterm"))]
mod tests {
    use super::*;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use hjkl_engine::Editor;
    use hjkl_engine::types::{DefaultHost, Options};

    fn new(content: &str) -> Editor {
        let mut e = Editor::new(
            hjkl_buffer::Buffer::new(),
            DefaultHost::new(),
            Options::default(),
        );
        e.set_content(content);
        e
    }

    fn type_keys<H: hjkl_engine::Host>(e: &mut Editor<hjkl_buffer::Buffer, H>, keys: &str) {
        for c in keys.chars() {
            let ev = match c {
                '\n' => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
                '\x1b' => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
                ch => KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
            };
            e.handle_key(ev);
        }
    }

    #[test]
    fn substitute_current_line() {
        let mut e = new("foo foo\nfoo foo");
        let effect = run(&mut e, "s/foo/bar/");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 1,
                lines_changed: 1
            }
        );
        assert_eq!(e.buffer().lines()[0], "bar foo");
        assert_eq!(e.buffer().lines()[1], "foo foo");
    }

    #[test]
    fn substitute_current_line_global() {
        let mut e = new("foo foo\nfoo");
        run(&mut e, "s/foo/bar/g");
        assert_eq!(e.buffer().lines()[0], "bar bar");
        assert_eq!(e.buffer().lines()[1], "foo");
    }

    #[test]
    fn substitute_whole_buffer_global() {
        let mut e = new("foo\nfoo foo\nbar");
        let effect = run(&mut e, "%s/foo/xyz/g");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 3,
                lines_changed: 2
            }
        );
        assert_eq!(e.buffer().lines()[0], "xyz");
        assert_eq!(e.buffer().lines()[1], "xyz xyz");
        assert_eq!(e.buffer().lines()[2], "bar");
    }

    #[test]
    fn substitute_zero_matches_reports_zero() {
        let mut e = new("hello");
        let effect = run(&mut e, "s/xyz/abc/");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 0,
                lines_changed: 0
            }
        );
        assert_eq!(e.buffer().lines()[0], "hello");
    }

    #[test]
    fn substitute_respects_case_insensitive_flag() {
        let mut e = new("Foo");
        let effect = run(&mut e, "s/foo/bar/i");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 1,
                lines_changed: 1
            }
        );
        assert_eq!(e.buffer().lines()[0], "bar");
    }

    #[test]
    fn substitute_accepts_case_sensitive_flag() {
        let mut e = new("Foo foo");
        e.settings_mut().ignore_case = true;
        // `I` overrides editor's ignorecase — only lowercase matches.
        let effect = run(&mut e, "s/foo/bar/I");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 1,
                lines_changed: 1
            }
        );
        assert_eq!(e.buffer().lines()[0], "Foo bar");
    }

    #[test]
    fn substitute_confirm_flag_accepted_not_error() {
        let mut e = new("foo");
        // `c` was previously an error; now it's silently ignored.
        let effect = run(&mut e, "s/foo/bar/c");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 1,
                lines_changed: 1
            }
        );
        assert_eq!(e.buffer().lines()[0], "bar");
    }

    #[test]
    fn substitute_empty_pattern_reuses_last_search() {
        let mut e = new("hello world");
        e.set_last_search(Some("world".to_string()), true);
        let effect = run(&mut e, "s//planet/");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 1,
                lines_changed: 1
            }
        );
        assert_eq!(e.buffer().lines()[0], "hello planet");
    }

    #[test]
    fn substitute_accepts_alternate_separator() {
        let mut e = new("/usr/local/bin");
        run(&mut e, "s#/usr#/opt#");
        assert_eq!(e.buffer().lines()[0], "/opt/local/bin");
    }

    #[test]
    fn substitute_ampersand_in_replacement() {
        let mut e = new("foo");
        run(&mut e, "s/foo/[&]/");
        assert_eq!(e.buffer().lines()[0], "[foo]");
    }

    #[test]
    fn goto_line() {
        let mut e = new("a\nb\nc\nd");
        run(&mut e, "3");
        assert_eq!(e.cursor().0, 2);
    }

    #[test]
    fn goto_line_100() {
        let buf: String = (1..=120)
            .map(|n| format!("line{n}"))
            .collect::<Vec<_>>()
            .join("\n");
        let mut e = new(&buf);
        run(&mut e, "100");
        assert_eq!(e.cursor().0, 99, "':100' must land on row 99 (line 100)");
    }

    #[test]
    fn quit_and_force_quit() {
        let mut e = new("");
        assert_eq!(
            run(&mut e, "q"),
            ExEffect::Quit {
                force: false,
                save: false
            }
        );
        assert_eq!(
            run(&mut e, "q!"),
            ExEffect::Quit {
                force: true,
                save: false
            }
        );
        assert_eq!(
            run(&mut e, "wq"),
            ExEffect::Quit {
                force: false,
                save: true
            }
        );
    }

    #[test]
    fn qall_and_wqall_dispatch() {
        let mut e = new("");
        // :qa (prefix → qall) — quit all, no force, no save
        assert_eq!(
            run(&mut e, "qa"),
            ExEffect::Quit {
                force: false,
                save: false
            }
        );
        // :qa! (prefix → qall!) — quit all, force, no save
        assert_eq!(
            run(&mut e, "qa!"),
            ExEffect::Quit {
                force: true,
                save: false
            }
        );
        // :qall — same as :qa
        assert_eq!(
            run(&mut e, "qall"),
            ExEffect::Quit {
                force: false,
                save: false
            }
        );
        // :qall! — same as :qa!
        assert_eq!(
            run(&mut e, "qall!"),
            ExEffect::Quit {
                force: true,
                save: false
            }
        );
        // :wqa (prefix → wqall) — quit all, no force, save
        assert_eq!(
            run(&mut e, "wqa"),
            ExEffect::Quit {
                force: false,
                save: true
            }
        );
        // :wqall — same as :wqa
        assert_eq!(
            run(&mut e, "wqall"),
            ExEffect::Quit {
                force: false,
                save: true
            }
        );
        // :wqall! — force=false, save=true (mirrors :wq! behaviour; force not
        // threaded through ExEffect::Quit for the write path)
        assert_eq!(
            run(&mut e, "wqall!"),
            ExEffect::Quit {
                force: false,
                save: true
            }
        );
    }

    #[test]
    fn write_returns_save() {
        let mut e = new("");
        assert_eq!(run(&mut e, "w"), ExEffect::Save);
    }

    #[test]
    fn write_path_returns_save_as() {
        let mut e = new("");
        assert_eq!(
            run(&mut e, "w /tmp/foo.txt"),
            ExEffect::SaveAs("/tmp/foo.txt".to_string())
        );
    }

    #[test]
    fn noh_is_ok() {
        let mut e = new("");
        assert_eq!(run(&mut e, "noh"), ExEffect::Ok);
    }

    #[test]
    fn registers_lists_unnamed_and_named() {
        let mut e = new("hello world");
        // `yw` populates `"` and `"0`; `"ayw` also fills `"a`.
        type_keys(&mut e, "yw");
        type_keys(&mut e, "\"ayw");
        let info = match run(&mut e, "reg") {
            ExEffect::Info(s) => s,
            other => panic!("expected Info, got {other:?}"),
        };
        assert!(info.starts_with("--- Registers ---"));
        assert!(info.contains("\"\""));
        assert!(info.contains("\"0"));
        assert!(info.contains("\"a"));
        // Alias resolves to same command.
        assert_eq!(run(&mut e, "registers"), ExEffect::Info(info));
    }

    #[test]
    fn registers_empty_state() {
        let mut e = new("hi");
        let info = match run(&mut e, "reg") {
            ExEffect::Info(s) => s,
            other => panic!("expected Info, got {other:?}"),
        };
        assert!(info.contains("(no registers set)"));
    }

    #[test]
    fn marks_lists_user_and_special() {
        let mut e = new("alpha\nbeta\ngamma");
        type_keys(&mut e, "ma");
        type_keys(&mut e, "jjmb");
        // `iX<Esc>` produces a last_edit_pos.
        type_keys(&mut e, "iX");
        let info = match run(&mut e, "marks") {
            ExEffect::Info(s) => s,
            other => panic!("expected Info, got {other:?}"),
        };
        assert!(info.starts_with("--- Marks ---"));
        assert!(info.contains(" a "));
        assert!(info.contains(" b "));
        assert!(info.contains(" . "));
    }

    #[test]
    fn undo_alias_reverses_last_change() {
        let mut e = new("hello");
        type_keys(&mut e, "Aworld\x1b");
        assert_eq!(e.buffer().lines()[0], "helloworld");
        assert_eq!(run(&mut e, "undo"), ExEffect::Ok);
        assert_eq!(e.buffer().lines()[0], "hello");
        // Short alias.
        type_keys(&mut e, "Awow\x1b");
        assert_eq!(e.buffer().lines()[0], "hellowow");
        assert_eq!(run(&mut e, "u"), ExEffect::Ok);
        assert_eq!(e.buffer().lines()[0], "hello");
    }

    #[test]
    fn redo_alias_reapplies_undone_change() {
        let mut e = new("hi");
        type_keys(&mut e, "Athere\x1b");
        assert_eq!(e.buffer().lines()[0], "hithere");
        run(&mut e, "undo");
        assert_eq!(e.buffer().lines()[0], "hi");
        assert_eq!(run(&mut e, "redo"), ExEffect::Ok);
        assert_eq!(e.buffer().lines()[0], "hithere");
        // Short alias.
        run(&mut e, "u");
        assert_eq!(run(&mut e, "red"), ExEffect::Ok);
        assert_eq!(e.buffer().lines()[0], "hithere");
    }

    #[test]
    fn marks_empty_state() {
        let mut e = new("hi");
        let info = match run(&mut e, "marks") {
            ExEffect::Info(s) => s,
            other => panic!("expected Info, got {other:?}"),
        };
        assert!(info.contains("(no marks set)"));
    }

    #[test]
    fn sort_alphabetical() {
        let mut e = new("banana\napple\ncherry");
        assert_eq!(run(&mut e, "sort"), ExEffect::Ok);
        assert_eq!(
            e.buffer().lines(),
            vec!["apple".to_string(), "banana".into(), "cherry".into()]
        );
    }

    #[test]
    fn sort_reverse_with_bang() {
        let mut e = new("apple\nbanana\ncherry");
        run(&mut e, "sort!");
        assert_eq!(
            e.buffer().lines(),
            vec!["cherry".to_string(), "banana".into(), "apple".into()]
        );
    }

    #[test]
    fn sort_unique() {
        let mut e = new("foo\nbar\nfoo\nbaz\nbar");
        run(&mut e, "sort u");
        assert_eq!(
            e.buffer().lines(),
            vec!["bar".to_string(), "baz".into(), "foo".into()]
        );
    }

    #[test]
    fn sort_numeric() {
        let mut e = new("10\n2\n100\n7");
        run(&mut e, "sort n");
        assert_eq!(
            e.buffer().lines(),
            vec!["2".to_string(), "7".into(), "10".into(), "100".into()]
        );
    }

    #[test]
    fn sort_ignore_case() {
        let mut e = new("Banana\napple\nCherry");
        run(&mut e, "sort i");
        assert_eq!(
            e.buffer().lines(),
            vec!["apple".to_string(), "Banana".into(), "Cherry".into()]
        );
    }

    #[test]
    fn sort_undo_restores_original_order() {
        let mut e = new("c\nb\na");
        run(&mut e, "sort");
        assert_eq!(e.buffer().lines()[0], "a");
        e.undo();
        assert_eq!(
            e.buffer().lines(),
            vec!["c".to_string(), "b".into(), "a".into()]
        );
    }

    #[test]
    fn sort_rejects_unknown_flag() {
        let mut e = new("a\nb");
        match run(&mut e, "sortz") {
            ExEffect::Error(msg) => assert!(msg.contains("z")),
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn range_sort_partial() {
        // `:2,4sort` sorts rows 1..=3 (1-based 2..=4) only.
        let mut e = new("z\nc\nb\na\nx");
        run(&mut e, "2,4sort");
        assert_eq!(
            e.buffer().lines(),
            vec![
                "z".to_string(),
                "a".into(),
                "b".into(),
                "c".into(),
                "x".into(),
            ]
        );
    }

    #[test]
    fn range_substitute_partial() {
        let mut e = new("foo\nfoo\nfoo\nfoo");
        // `:2,3s/foo/bar/` only replaces lines 2 and 3.
        let effect = run(&mut e, "2,3s/foo/bar/");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 2,
                lines_changed: 2
            }
        );
        assert_eq!(
            e.buffer().lines(),
            vec!["foo".to_string(), "bar".into(), "bar".into(), "foo".into(),]
        );
    }

    #[test]
    fn range_delete_drops_lines() {
        let mut e = new("a\nb\nc\nd\ne");
        run(&mut e, "2,4d");
        assert_eq!(e.buffer().lines(), vec!["a".to_string(), "e".into()]);
    }

    #[test]
    fn percent_substitute_still_works() {
        let mut e = new("foo\nfoo");
        let effect = run(&mut e, "%s/foo/bar/");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 2,
                lines_changed: 2
            }
        );
        assert_eq!(e.buffer().lines(), vec!["bar".to_string(), "bar".into()]);
    }

    #[test]
    fn dot_dollar_addresses_resolve() {
        let mut e = new("a\nb\nc\nd");
        e.jump_cursor(1, 0);
        // `.,$d` deletes from the current row to the bottom.
        run(&mut e, ".,$d");
        assert_eq!(e.buffer().lines(), vec!["a".to_string()]);
    }

    #[test]
    fn mark_address_resolves() {
        let mut e = new("a\nb\nc\nd\ne");
        // Set marks `a` on row 1, `b` on row 3.
        e.jump_cursor(1, 0);
        type_keys(&mut e, "ma");
        e.jump_cursor(3, 0);
        type_keys(&mut e, "mb");
        run(&mut e, "'a,'bd");
        assert_eq!(e.buffer().lines(), vec!["a".to_string(), "e".into()]);
    }

    #[test]
    fn range_global_partial() {
        let mut e = new("foo\nfoo\nbar\nfoo\nfoo");
        // Only delete `foo` lines in rows 2..=4.
        run(&mut e, "2,4g/foo/d");
        assert_eq!(
            e.buffer().lines(),
            vec!["foo".to_string(), "bar".into(), "foo".into()]
        );
    }

    #[test]
    fn bare_line_number_jumps() {
        let mut e = new("a\nb\nc\nd");
        run(&mut e, "3");
        assert_eq!(e.cursor().0, 2);
    }

    #[test]
    fn set_shiftwidth_changes_indent_step() {
        let mut e = new("hello");
        // Default: shiftwidth = 2.
        run(&mut e, "set sw=4");
        assert_eq!(e.settings().shiftwidth, 4);
        // Indent uses the new value: `>>` prepends 4 spaces now.
        type_keys(&mut e, ">>");
        assert_eq!(e.buffer().lines()[0], "    hello");
    }

    #[test]
    fn set_tabstop_stored() {
        let mut e = new("");
        run(&mut e, "set tabstop=4");
        assert_eq!(e.settings().tabstop, 4);
    }

    #[test]
    fn set_ignorecase_affects_substitute() {
        let mut e = new("Hello");
        // Plain :s/h/X/ misses on the lowercase pattern.
        let effect = run(&mut e, "s/h/X/");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 0,
                lines_changed: 0
            }
        );
        run(&mut e, "set ignorecase");
        assert!(e.settings().ignore_case);
        let effect = run(&mut e, "s/h/X/");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 1,
                lines_changed: 1
            }
        );
        assert_eq!(e.buffer().lines()[0], "Xello");
    }

    #[test]
    fn set_no_prefix_disables_boolean() {
        let mut e = new("x");
        run(&mut e, "set ic");
        assert!(e.settings().ignore_case);
        run(&mut e, "set noic");
        assert!(!e.settings().ignore_case);
    }

    #[test]
    fn set_zero_shiftwidth_errors() {
        let mut e = new("x");
        match run(&mut e, "set sw=0") {
            ExEffect::Error(msg) => assert!(msg.contains("shiftwidth")),
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn set_unknown_option_errors() {
        let mut e = new("x");
        match run(&mut e, "set bogus") {
            ExEffect::Error(msg) => assert!(msg.contains("bogus")),
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn bare_set_reports_current_values() {
        // 0.1.0: `Editor::new(buf, host, Options::default())` uses
        // SPEC-faithful vim defaults (shiftwidth=8, ts=8). The pre-0.1.0
        // `Editor::new(KeybindingMode)` defaulted to `Settings::default`
        // (sqeel-derived shiftwidth=2). Asserting against the new
        // SPEC defaults.
        let mut e = new("x");
        match run(&mut e, "set") {
            ExEffect::Info(msg) => {
                assert!(msg.contains("shiftwidth=4"), "got: {msg}");
                assert!(msg.contains("ignorecase=off"), "got: {msg}");
                assert!(msg.contains("wrap=off"), "got: {msg}");
            }
            other => panic!("expected Info, got {other:?}"),
        }
    }

    #[test]
    fn set_wrap_flips_to_char_mode() {
        let mut e = new("x");
        run(&mut e, "set wrap");
        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Char);
    }

    #[test]
    fn set_nowrap_resets() {
        let mut e = new("x");
        run(&mut e, "set wrap");
        run(&mut e, "set nowrap");
        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::None);
    }

    #[test]
    fn set_linebreak_flips_to_word_mode() {
        let mut e = new("x");
        run(&mut e, "set linebreak");
        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Word);
    }

    #[test]
    fn set_wrap_after_linebreak_keeps_word_mode() {
        let mut e = new("x");
        run(&mut e, "set linebreak");
        run(&mut e, "set wrap");
        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Word);
    }

    #[test]
    fn set_nolinebreak_drops_to_char_when_wrap_on() {
        let mut e = new("x");
        run(&mut e, "set linebreak");
        run(&mut e, "set nolinebreak");
        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Char);
    }

    #[test]
    fn foldsyntax_applies_host_supplied_ranges() {
        let mut e = new("a\nb\nc\nd\ne");
        e.set_syntax_fold_ranges(vec![(0, 2), (3, 4)]);
        match run(&mut e, "foldsyntax") {
            ExEffect::Info(msg) => assert!(msg.contains("2 fold")),
            other => panic!("expected Info, got {other:?}"),
        }
        let folds = e.buffer().folds();
        assert_eq!(folds.len(), 2);
        assert!(folds.iter().any(|f| f.start_row == 0 && f.end_row == 2));
        assert!(folds.iter().any(|f| f.start_row == 3 && f.end_row == 4));
    }

    #[test]
    fn foldsyntax_no_ranges_reports_info() {
        let mut e = new("a\nb");
        match run(&mut e, "foldsyntax") {
            ExEffect::Info(msg) => assert!(msg.contains("no syntax block")),
            other => panic!("expected Info, got {other:?}"),
        }
    }

    #[test]
    fn foldsyntax_short_alias() {
        let mut e = new("a\nb\nc");
        e.set_syntax_fold_ranges(vec![(0, 2)]);
        assert!(matches!(run(&mut e, "folds"), ExEffect::Info(_)));
        assert_eq!(e.buffer().folds().len(), 1);
    }

    #[test]
    fn foldindent_creates_fold_for_indented_block() {
        let mut e = new("SELECT *\n  FROM t\n  WHERE x = 1\nORDER BY id");
        match run(&mut e, "foldindent") {
            ExEffect::Info(msg) => assert!(msg.contains("1 fold")),
            other => panic!("expected Info, got {other:?}"),
        }
        let folds = e.buffer().folds();
        assert_eq!(folds.len(), 1);
        assert_eq!(folds[0].start_row, 0);
        assert_eq!(folds[0].end_row, 2);
        assert!(folds[0].closed);
    }

    #[test]
    fn foldindent_no_blocks_reports_info() {
        let mut e = new("a\nb\nc");
        match run(&mut e, "foldindent") {
            ExEffect::Info(msg) => assert!(msg.contains("no indented blocks")),
            other => panic!("expected Info, got {other:?}"),
        }
        assert!(e.buffer().folds().is_empty());
    }

    #[test]
    fn foldindent_handles_nested_blocks() {
        let mut e = new("outer\n  mid\n    inner1\n    inner2\n  back\noutmost");
        run(&mut e, "foldindent");
        let folds = e.buffer().folds();
        // Outer block 0..=4 + inner block 1..=3 (mid → inner runs).
        assert_eq!(folds.len(), 2);
        assert_eq!(folds[0].start_row, 0);
        assert_eq!(folds[0].end_row, 4);
        assert_eq!(folds[1].start_row, 1);
        assert_eq!(folds[1].end_row, 3);
    }

    #[test]
    fn foldindent_skips_blanks_inside_block() {
        let mut e = new("head\n  body1\n\n  body2\nfoot");
        run(&mut e, "foldindent");
        let folds = e.buffer().folds();
        assert_eq!(folds.len(), 1);
        assert_eq!(folds[0].start_row, 0);
        assert_eq!(folds[0].end_row, 3);
    }

    #[test]
    fn foldindent_short_alias() {
        let mut e = new("a\n  b\nc");
        assert!(matches!(run(&mut e, "foldi"), ExEffect::Info(_)));
        assert_eq!(e.buffer().folds().len(), 1);
    }

    #[test]
    fn read_file_inserts_below_current_row() {
        // Write a temp file with two rows.
        let dir = std::env::temp_dir();
        let path = dir.join(format!("hjkl_read_{}.sql", std::process::id()));
        std::fs::write(&path, "SELECT 1;\nSELECT 2;\n").unwrap();
        let mut e = new("alpha\nbeta");
        e.jump_cursor(0, 0);
        let cmd = format!("r {}", path.display());
        assert_eq!(run(&mut e, &cmd), ExEffect::Ok);
        assert_eq!(
            e.buffer().lines(),
            vec![
                "alpha".to_string(),
                "SELECT 1;".into(),
                "SELECT 2;".into(),
                "beta".into(),
            ]
        );
        // Cursor sits on the first inserted row.
        assert_eq!(e.cursor(), (1, 0));
        std::fs::remove_file(&path).ok();
    }

    #[test]
    fn shell_filter_replaces_range() {
        let mut e = new("c\nb\na");
        // `:%!sort` reorders the whole buffer alphabetically.
        assert_eq!(run(&mut e, "%!sort"), ExEffect::Ok);
        assert_eq!(
            e.buffer().lines(),
            vec!["a".to_string(), "b".into(), "c".into()]
        );
    }

    #[test]
    fn shell_filter_partial_range() {
        let mut e = new("head\ngamma\nbeta\nalpha\ntail");
        // `:2,4!sort` should reorder rows 2..=4 only.
        run(&mut e, "2,4!sort");
        assert_eq!(
            e.buffer().lines(),
            vec![
                "head".to_string(),
                "alpha".into(),
                "beta".into(),
                "gamma".into(),
                "tail".into(),
            ]
        );
    }

    #[test]
    fn shell_filter_undo_restores() {
        let mut e = new("c\nb\na");
        let before: Vec<String> = e.buffer().lines().to_vec();
        run(&mut e, "%!sort");
        e.undo();
        assert_eq!(e.buffer().lines(), before);
    }

    #[test]
    fn shell_command_no_range_returns_info() {
        let mut e = new("buffer stays put");
        match run(&mut e, "!echo from-shell") {
            ExEffect::Info(msg) => assert!(msg.contains("from-shell")),
            other => panic!("expected Info, got {other:?}"),
        }
        // Buffer unchanged.
        assert_eq!(e.buffer().lines()[0], "buffer stays put");
    }

    #[test]
    fn shell_filter_failing_command_errors() {
        let mut e = new("a\nb");
        match run(&mut e, "%!exit 5") {
            ExEffect::Error(msg) => assert!(msg.contains("exited 5"), "msg was: {msg}"),
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn shell_bang_empty_command_errors() {
        let mut e = new("a");
        match run(&mut e, "!") {
            ExEffect::Error(msg) => assert!(msg.contains("shell command")),
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn read_bang_inserts_command_stdout() {
        let mut e = new("alpha\nbeta");
        e.jump_cursor(0, 0);
        // `echo` is portable — outputs a trailing newline that
        // apply_read_file strips.
        assert_eq!(run(&mut e, "r !echo hello"), ExEffect::Ok);
        assert_eq!(
            e.buffer().lines(),
            vec!["alpha".to_string(), "hello".into(), "beta".into()]
        );
    }

    #[test]
    fn read_bang_failing_command_errors() {
        let mut e = new("hi");
        match run(&mut e, "r !exit 7") {
            ExEffect::Error(msg) => assert!(msg.contains("exited 7")),
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn read_bang_empty_command_errors() {
        let mut e = new("hi");
        match run(&mut e, "r !") {
            ExEffect::Error(msg) => assert!(msg.contains("shell command")),
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn read_file_alias_read_works() {
        let dir = std::env::temp_dir();
        let path = dir.join(format!("hjkl_read_alias_{}.sql", std::process::id()));
        std::fs::write(&path, "x").unwrap();
        let mut e = new("");
        let cmd = format!("read {}", path.display());
        run(&mut e, &cmd);
        assert_eq!(e.buffer().lines(), vec!["".to_string(), "x".into()]);
        std::fs::remove_file(&path).ok();
    }

    #[test]
    fn read_file_missing_path_errors() {
        let mut e = new("a");
        match run(&mut e, "r /nonexistent/path/sqeel_test_xyzzy") {
            ExEffect::Error(msg) => assert!(msg.contains("cannot read")),
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn read_file_undo_restores() {
        let dir = std::env::temp_dir();
        let path = dir.join(format!("hjkl_read_undo_{}.sql", std::process::id()));
        std::fs::write(&path, "ins\n").unwrap();
        let mut e = new("a\nb");
        e.jump_cursor(0, 0);
        run(&mut e, &format!("r {}", path.display()));
        assert_eq!(e.buffer().lines().len(), 3);
        e.undo();
        assert_eq!(e.buffer().lines(), vec!["a".to_string(), "b".into()]);
        std::fs::remove_file(&path).ok();
    }

    #[test]
    fn unknown_command() {
        let mut e = new("");
        match run(&mut e, "blargh") {
            ExEffect::Unknown(cmd) => assert_eq!(cmd, "blargh"),
            other => panic!("expected Unknown, got {other:?}"),
        }
    }

    #[test]
    fn bad_substitute_pattern() {
        let mut e = new("hi");
        match run(&mut e, "s/[unterminated/foo/") {
            ExEffect::Error(_) => {}
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn substitute_escaped_separator() {
        let mut e = new("a/b/c");
        let effect = run(&mut e, "s/\\//-/g");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 2,
                lines_changed: 1
            }
        );
        assert_eq!(e.buffer().lines()[0], "a-b-c");
    }

    #[test]
    fn global_delete_drops_matching_rows() {
        let mut e = new("keep1\nDROP1\nkeep2\nDROP2\nkeep3");
        let effect = run(&mut e, "g/DROP/d");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 2,
                lines_changed: 2
            }
        );
        assert_eq!(
            e.buffer().lines(),
            &[
                "keep1".to_string(),
                "keep2".to_string(),
                "keep3".to_string()
            ]
        );
    }

    #[test]
    fn global_negated_drops_non_matching_rows() {
        let mut e = new("keep1\nother\nkeep2");
        let effect = run(&mut e, "v/keep/d");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 1,
                lines_changed: 1
            }
        );
        assert_eq!(
            e.buffer().lines(),
            &["keep1".to_string(), "keep2".to_string()]
        );
    }

    #[test]
    fn global_with_regex_pattern() {
        let mut e = new("foo bar\nbaz qux\nfoo baz\nbaz");
        // Drop lines starting with "foo".
        let effect = run(&mut e, r"g/^foo/d");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 2,
                lines_changed: 2
            }
        );
        assert_eq!(
            e.buffer().lines(),
            &["baz qux".to_string(), "baz".to_string()]
        );
    }

    #[test]
    fn global_no_matches_reports_zero() {
        let mut e = new("hello\nworld");
        let effect = run(&mut e, "g/xyz/d");
        assert_eq!(
            effect,
            ExEffect::Substituted {
                count: 0,
                lines_changed: 0
            }
        );
        assert_eq!(e.buffer().lines().len(), 2);
    }

    #[test]
    fn global_unsupported_command_errors_out() {
        let mut e = new("foo\nbar");
        let effect = run(&mut e, "g/foo/p");
        assert!(matches!(effect, ExEffect::Error(_)));
    }

    #[test]
    fn format_jumps_empty() {
        let e = new("hello");
        let info = match run(&mut { e }, "jumps") {
            ExEffect::Info(s) => s,
            other => panic!("expected Info, got {other:?}"),
        };
        assert_eq!(info, "(no jumps recorded)");
    }

    #[test]
    fn format_jumps_with_entries() {
        let mut e = new("line1\nline2\nline3\nline4\nline5");
        // `gg` and `G` are "big jump" motions that push the jumplist.
        type_keys(&mut e, "gg");
        type_keys(&mut e, "G");
        type_keys(&mut e, "gg");
        let info = match run(&mut e, "jumps") {
            ExEffect::Info(s) => s,
            other => panic!("expected Info, got {other:?}"),
        };
        assert!(info.contains("--- Jump list ---"), "header missing: {info}");
        assert!(info.contains("jump"), "column header missing: {info}");
    }

    #[test]
    fn format_changes_empty() {
        let e = new("hello");
        let info = match run(&mut { e }, "changes") {
            ExEffect::Info(s) => s,
            other => panic!("expected Info, got {other:?}"),
        };
        assert_eq!(info, "(no changes recorded)");
    }

    #[test]
    fn format_changes_with_edits() {
        let mut e = new("hello\nworld");
        // Insert something to populate the change list.
        type_keys(&mut e, "Afoo\x1b");
        type_keys(&mut e, "jAbar\x1b");
        let info = match run(&mut e, "changes") {
            ExEffect::Info(s) => s,
            other => panic!("expected Info, got {other:?}"),
        };
        assert!(
            info.contains("--- Change list ---"),
            "header missing: {info}"
        );
        assert!(info.contains("change"), "column header missing: {info}");
    }

    #[test]
    fn slash_pat_search_address_jumps_forward() {
        // `:/foo` is a search-as-address with no following command —
        // vim moves the cursor to the next forward match.
        let mut e = new("alpha\nbeta\nfoo here\ndelta\nfoo also");
        // cursor starts at (0, 0); first forward match is row 2.
        let effect = run(&mut e, "/foo");
        assert_eq!(effect, ExEffect::Ok);
        assert_eq!(e.cursor().0, 2);
        // Trailing delimiter is optional but accepted.
        e.goto_line(1);
        let effect = run(&mut e, "/foo/");
        assert_eq!(effect, ExEffect::Ok);
        assert_eq!(e.cursor().0, 2);
    }

    #[test]
    fn question_pat_search_address_jumps_backward() {
        // `:?foo` searches strictly backward from the cursor.
        let mut e = new("foo first\nbeta\nfoo middle\ndelta\nlast");
        e.goto_line(5); // cursor at row 4
        let effect = run(&mut e, "?foo");
        assert_eq!(effect, ExEffect::Ok);
        assert_eq!(
            e.cursor().0,
            2,
            "?foo from row 4 must land on previous match (row 2)"
        );
        // Trailing matching delimiter accepted.
        e.goto_line(5);
        let effect = run(&mut e, "?foo?");
        assert_eq!(effect, ExEffect::Ok);
        assert_eq!(e.cursor().0, 2);
    }

    #[test]
    fn search_address_persists_direction_for_n() {
        // After `:?foo`, `last_search_forward` must be false so a
        // subsequent `n` keeps moving backward.
        let mut e = new("foo a\nbeta\nfoo b\ndelta\nfoo c");
        e.goto_line(5); // cursor row 4
        run(&mut e, "?foo");
        assert_eq!(e.last_search(), Some("foo"));
        assert!(!e.last_search_forward(), "?foo must persist backward dir");

        // And `:/foo` flips it back to forward.
        run(&mut e, "/foo");
        assert!(e.last_search_forward(), "/foo must persist forward dir");
    }

    #[test]
    fn empty_search_address_reuses_last_pattern() {
        let mut e = new("alpha\nfoo a\nbar\nfoo b");
        // Seed last_search by jumping to row 1 with `/foo`.
        run(&mut e, "/foo");
        assert_eq!(e.cursor().0, 1);
        // `:/` with empty pattern repeats — should advance past row 1
        // to the next match at row 3.
        e.search_advance_forward(true); // step off the current match
        e.goto_line(1); // back to row 0
        let effect = run(&mut e, "/");
        assert_eq!(effect, ExEffect::Ok);
        assert_eq!(e.cursor().0, 1, "/ alone should re-find first match");
    }

    #[test]
    fn empty_search_address_with_no_history_errors() {
        let mut e = new("foo");
        let effect = run(&mut e, "?");
        assert!(matches!(effect, ExEffect::Error(_)));
    }
}
