/// A parsed line range. 1-based, inclusive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineRange {
    start: usize,
    end: usize,
}

impl LineRange {
    pub fn new(start: usize, end: usize) -> Self {
        Self { start, end }
    }

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

    pub fn end_one_based(&self) -> usize {
        self.end
    }

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

// ---- address parsing -------------------------------------------------------

#[derive(Debug, Clone)]
pub enum Address {
    Number(usize), // 1-based, as the user typed
    Current,
    Last,
    Mark(char),
    /// `/pat/` (`forward = true`) or `?pat?` (`forward = false`) — B4: a
    /// search-pattern address. Resolved lazily in `resolve_address` since
    /// it needs to actually run the search, not just read editor state.
    Search {
        pattern: String,
        forward: bool,
    },
}

/// Strip a leading base address from `s`, return `(address, remainder)` or
/// `None`. Does NOT consume a trailing offset (`+3`, `-2`, ...) — that's
/// [`parse_offset_chain`]'s job. A leading `+`/`-` (no explicit base) is
/// treated as an implicit `.` (current line) base and left unconsumed so the
/// offset parser picks it up.
pub fn parse_base_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..])),
        '+' | '-' => Some((Address::Current, s)), // implicit `.`; leave sign for offset parsing
        '\'' => {
            // The mark char may be multibyte (e.g. `:'é`); slice at its real
            // byte boundary rather than a hard-coded `2` to avoid a
            // char-boundary panic that would crash the editor.
            let (mark_start, mark) = chars.next()?;
            Some((Address::Mark(mark), &s[mark_start + mark.len_utf8()..]))
        }
        '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..]))
        }
        // B4: `/pat/` (forward) / `?pat?` (backward) search-pattern address
        // (`:h search-offset` base form — offsets are picked up afterward by
        // the normal `parse_offset_chain` call in `parse_address`, same as
        // every other address kind). The pattern runs up to the first
        // UNESCAPED closing delimiter, or to the end of `s` if none is
        // found (`:h :range`: a missing closing delimiter is allowed — the
        // pattern just runs to the end of the command). Byte-level escape
        // scanning is UTF-8-safe here: `/`, `?`, and `\` are all single-byte
        // ASCII, which can never appear as a continuation byte of a
        // multi-byte character, so slicing at their positions never lands
        // mid-char.
        '/' | '?' => {
            let delim = first as u8;
            let body = &s[1..];
            let bytes = body.as_bytes();
            let mut close = None;
            let mut i = 0;
            while i < bytes.len() {
                if bytes[i] == delim && (i == 0 || bytes[i - 1] != b'\\') {
                    close = Some(i);
                    break;
                }
                i += 1;
            }
            let (pattern, rest) = match close {
                Some(idx) => (&body[..idx], &body[idx + 1..]),
                None => (body, ""),
            };
            Some((
                Address::Search {
                    pattern: pattern.to_string(),
                    forward: first == '/',
                },
                rest,
            ))
        }
        _ => None,
    }
}

/// Consume a chain of `+N` / `-N` / bare `+` / `-` offset terms from the
/// front of `s` (vim allows repeats like `++`, `+-`, `.+3-1`), summing them.
/// Bare `+`/`-` (no digits) count as 1. Returns `(total_offset, remainder)`;
/// `(0, s)` when `s` has no leading offset term.
fn parse_offset_chain(mut s: &str) -> (i64, &str) {
    let mut total: i64 = 0;
    loop {
        let sign: i64 = match s.chars().next() {
            Some('+') => 1,
            Some('-') => -1,
            _ => break,
        };
        let after_sign = &s[1..];
        let mut end = 0;
        for (i, c) in after_sign.char_indices() {
            if c.is_ascii_digit() {
                end = i + c.len_utf8();
            } else {
                break;
            }
        }
        let magnitude: i64 = if end == 0 {
            1
        } else {
            match after_sign[..end].parse() {
                Ok(v) => v,
                Err(_) => break,
            }
        };
        total = total.saturating_add(sign * magnitude);
        s = &after_sign[end..];
    }
    (total, s)
}

/// Strip a leading address (base + optional offset chain) from `s`. Returns
/// `(base_address, offset, remainder)` or `None` when `s` doesn't start with
/// a valid address character.
fn parse_address(s: &str) -> Option<(Address, i64, &str)> {
    let (base, rest) = parse_base_address(s)?;
    let (offset, rest) = parse_offset_chain(rest);
    Some((base, offset, rest))
}

/// Resolve a parsed address (base + offset) against the current editor
/// state. Numbers are 1-based; the final `base + offset` is clamped to the
/// buffer. Bad marks return an error.
///
/// `from_row` is the 1-based "current line" the address resolves against for
/// `.` / bare-offset (`+N`/`-N`) bases and search anchors. It is `None` in the
/// normal case (use the real editor cursor); a `;`-separated range passes
/// `Some(first_addr_row)` so the SECOND address sees the first address's row as
/// the cursor, matching vim's `;` semantics (`:h :;`).
fn resolve_address<H: hjkl_engine::Host>(
    addr: Address,
    offset: i64,
    editor: &hjkl_engine::Editor<hjkl_buffer::View, H>,
    from_row: Option<usize>,
) -> Result<usize, String> {
    // 1-based last line (at least 1 so single-line buffers work). Must be the
    // last REAL content line — `row_count()` includes ropey's phantom
    // trailing empty row on newline-terminated buffers, which would make `$`
    // (and thus `:$d`, `:$y`, `$-1`, ...) land past vim's last line.
    let last = hjkl_engine::motions::content_row_count(editor.buffer()).max(1);
    // 1-based current line: the `;`-separator override when present, else the
    // real (0-based) editor cursor.
    let current = from_row.unwrap_or_else(|| editor.cursor().0 + 1);
    let base: i64 = match addr {
        Address::Number(n) => n as i64,
        Address::Current => current as i64,
        Address::Last => last as i64,
        Address::Mark(c) => editor
            .mark(c)
            .map(|(r, _)| r as i64 + 1) // 0-based → 1-based
            .ok_or_else(|| format!("mark `{c}` not set"))?,
        Address::Search { pattern, forward } => {
            resolve_search_address(&pattern, forward, editor, from_row)?
        }
    };
    Ok((base.saturating_add(offset)).clamp(1, last as i64) as usize)
}

/// B4: resolve `/pat/` (`forward = true`) / `?pat?` (`forward = false`) to a
/// 1-based line number. `/pat/` = first match STRICTLY BELOW the cursor;
/// `?pat?` = first match STRICTLY ABOVE it — verified against real nvim:
/// `/foo/d` with the cursor already ON a line containing "foo" deletes the
/// NEXT "foo" line, not the current one. Wraps past the buffer end/start
/// when `'wrapscan'` is on (the default), matching `/`/`?`/`n`/`N`.
///
/// `from_row` (1-based) overrides the search anchor for a `;`-separated second
/// address: the search starts from column 0 of that row instead of the live
/// cursor. (Sub-limitation vs vim: vim anchors at the row's first-non-blank
/// column, so a match earlier on the override row than that column could
/// differ; range resolution is line-granular so this is immaterial in
/// practice.)
fn resolve_search_address<H: hjkl_engine::Host>(
    pattern: &str,
    forward: bool,
    editor: &hjkl_engine::Editor<hjkl_buffer::View, H>,
    from_row: Option<usize>,
) -> Result<i64, String> {
    use hjkl_engine::search::{CaseMode, resolve_case_mode};
    use hjkl_engine::{Cursor, Query, Search};
    if pattern.is_empty() {
        // vim reuses the last search pattern for a bare `//`/`??` address;
        // not implemented here (no pin requires it) — error cleanly rather
        // than silently matching everything.
        return Err("E35: no previous regular expression".into());
    }
    let settings = editor.settings();
    let case_base = CaseMode::from_options(settings.ignore_case, settings.smartcase);
    let (stripped, mode) =
        resolve_case_mode(pattern, case_base, &editor.last_substitute_replacement());
    let src = if mode == CaseMode::Insensitive {
        format!("(?i){stripped}")
    } else {
        stripped
    };
    let re = regex::Regex::new(&src).map_err(|e| format!("bad pattern: {e}"))?;

    let buf = editor.buffer();
    // Anchor from the `;`-override row (col 0) when present, else the live
    // cursor.
    let cursor = match from_row {
        Some(line) => hjkl_engine::Pos::new((line - 1) as u32, 0),
        None => Cursor::cursor(buf),
    };
    let wrap = settings.wrapscan;
    // "Strictly after/before the cursor": advance the search anchor one
    // byte past (forward) / before (backward) the cursor first, mirroring
    // `hjkl_engine::search::search_forward`/`search_backward`'s
    // `skip_current` behavior — those functions mutate the live cursor as
    // a side effect (used by `/`/`n`), which range resolution must NOT do,
    // so the byte-offset math is inlined here against the read-only
    // `Search`/`Cursor` trait methods instead of calling them directly.
    let found = if forward {
        let from_byte = buf.byte_offset(cursor);
        let from = buf.pos_at_byte(from_byte.saturating_add(1));
        buf.find_next(from, &re).or_else(|| {
            wrap.then(|| buf.find_next(hjkl_engine::Pos::new(0, 0), &re))
                .flatten()
        })
    } else {
        let from_byte = buf.byte_offset(cursor);
        let before = (from_byte > 0).then(|| buf.pos_at_byte(from_byte - 1));
        before
            .and_then(|from| buf.find_prev(from, &re))
            .or_else(|| {
                wrap.then(|| {
                    let end = buf.pos_at_byte(buf.len_bytes());
                    buf.find_prev(end, &re)
                })
                .flatten()
            })
    };
    match found {
        Some(range) => Ok(range.start.line as i64 + 1), // 0-based → 1-based
        None => Err(format!("E486: Pattern not found: {pattern}")),
    }
}

// ---- public API ------------------------------------------------------------

/// Parse a leading range prefix from `cmd`. Supports:
/// - `5`        → single line 5
/// - `5,10`     → 5 through 10
/// - `5;+2`     → 5 through 5+2 (`;` moves the cursor to line 5 first, so the
///   second address's `.`/offset/search anchors resolve from there)
/// - `.,$`      → current line through last line
/// - `'a,'b`    → mark a through mark b
/// - `%`        → whole buffer (1 through line_count)
///
/// Returns `(parsed_range, remainder)`. `parsed_range` is `None` when the
/// command starts with a non-range character (typical case for `:w`, `:e`).
pub fn parse_range<'a, H: hjkl_engine::Host>(
    cmd: &'a str,
    editor: &hjkl_engine::Editor<hjkl_buffer::View, H>,
) -> Result<(Option<LineRange>, &'a str), String> {
    // `%` — whole buffer (1 through the last REAL content line; see
    // `hjkl_engine::motions::content_row_count` — `row_count()` includes the
    // phantom trailing row)
    if let Some(rest) = cmd.strip_prefix('%') {
        let line_count = hjkl_engine::motions::content_row_count(editor.buffer()).max(1);
        return Ok((Some(LineRange::new(1, line_count)), rest));
    }

    let Some((start_addr, start_offset, after_start)) = parse_address(cmd) else {
        return Ok((None, cmd));
    };

    // nvim rejects a literal numeric address past EOF with E16 when a command
    // follows the range (`:5d` on a 3-line buffer errors and deletes nothing),
    // but the bare `:N` goto clamps silently. Derived bases (`.`, `$`, marks,
    // search) always clamp — `resolve_address` keeps doing that. Whether a
    // command follows is only known once the whole range is parsed (rest), so
    // the flag is computed here and applied at each path below.
    let last_i = hjkl_engine::motions::content_row_count(editor.buffer()).max(1) as i64;
    let number_past_eof = |addr: &Address, offset: i64| matches!(addr, Address::Number(n) if (*n as i64 + offset) > last_i);
    let start_past_eof = number_past_eof(&start_addr, start_offset);

    let start = resolve_address(start_addr, start_offset, editor, None)?;

    // A `,` or `;` separator introduces a second (end) address. They differ
    // ONLY in cursor threading (`:h :;`):
    //   `,` — both addresses resolve from the ORIGINAL cursor (`from_row: None`
    //         for both). Unchanged.
    //   `;` — the cursor MOVES to the first address's row before the second is
    //         resolved, so the second address's `.` / bare-offset math AND
    //         search anchors (`/pat/`, `?pat?`) resolve from `start`. Threaded
    //         via `from_row: Some(start)`.
    // Scope: hjkl's range grammar is two-address (a single separator); we match
    // that exactly for `;` too — chaining (`a;b;c`, `a,b,c`) is not supported,
    // same as the pre-existing `,`-only limit. A leading separator (`:;+1`,
    // `:,5`) is likewise unsupported (parse_base_address rejects a leading
    // `;`/`,`, so the whole thing falls through to command parsing), preserving
    // the existing `,` scope rather than adding new behavior.
    let separator = after_start.chars().next();
    if let Some(after_sep) = after_start
        .strip_prefix(',')
        .or_else(|| after_start.strip_prefix(';'))
    {
        let semicolon = separator == Some(';');
        // Expect a second address after the separator. If absent, error.
        if after_sep.is_empty() {
            return Err(format!(
                "missing end address after '{}'",
                if semicolon { ';' } else { ',' }
            ));
        }
        let Some((end_addr, end_offset, rest)) = parse_address(after_sep) else {
            // Something like `5,x` where `x` is not an address character
            return Err(format!("invalid end address in range: `{after_sep}`"));
        };
        // `;` moves the cursor to `start` (1-based) before the end address
        // resolves; `,` resolves it from the real cursor (no override).
        let end_from = semicolon.then_some(start);
        if (start_past_eof || number_past_eof(&end_addr, end_offset)) && !rest.trim().is_empty() {
            return Err("E16: Invalid range".into());
        }
        let end = resolve_address(end_addr, end_offset, editor, end_from)?;
        if start > end {
            // Vim parity: a backward range (`:8,3d`) is rejected outright in
            // non-interactive use — real vim only offers to swap it via an
            // interactive "OK to swap" prompt, which hjkl (headless/keystroke
            // driven) has no equivalent of, so the plain error is the correct
            // match here. Previously this silently swapped to `(end, start)`,
            // which let e.g. `:8,3d` delete lines 3-8 instead of erroring.
            return Err("E493: Backwards range given".into());
        }
        return Ok((Some(LineRange::new(start, end)), rest));
    }

    // A literal numeric start past EOF with a command following is E16 (the
    // bare `:N` goto and the two-address gate above keep the clamping
    // behavior — see the comment at the top of this function).
    if start_past_eof && !after_start.trim().is_empty() {
        return Err("E16: Invalid range".into());
    }
    Ok((Some(LineRange::single(start)), after_start))
}

/// Parse a single destination address for `:move` / `:copy` — `$`, `.`, a
/// number, a mark, or `0` (meaning "before the first line"). Returns a 1-based
/// line number, or `0` for the top-of-buffer destination. Trailing text after
/// the address is an error.
pub fn parse_dest_address<H: hjkl_engine::Host>(
    s: &str,
    editor: &hjkl_engine::Editor<hjkl_buffer::View, H>,
) -> Result<usize, String> {
    let s = s.trim();
    if s.is_empty() {
        return Err("expected a destination address".into());
    }
    let (addr, offset, rest) = parse_address(s).ok_or_else(|| format!("invalid address: `{s}`"))?;
    if !rest.trim().is_empty() {
        return Err(format!("trailing characters after address: `{rest}`"));
    }
    // `0` is a legal destination (place before line 1); resolve_address would
    // clamp it to 1, so special-case it here. Matches on `&addr` (not `addr`)
    // since `Address` isn't `Copy` (the `Search` variant owns a `String`).
    if matches!(addr, Address::Number(0)) && offset == 0 {
        return Ok(0);
    }
    resolve_address(addr, offset, editor, None)
}

// ---- tests -----------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_util::make_editor_with_lines;
    use hjkl_engine::{DefaultHost, Editor};

    /// Five-line fixture — `range.rs`'s own default, not the crate-wide
    /// empty-buffer `test_util::make_editor`.
    fn make_editor() -> Editor<hjkl_buffer::View, DefaultHost> {
        make_editor_with_lines(&["line1", "line2", "line3", "line4", "line5"])
    }

    // Helper: parse range on a 5-line editor, check start/end (1-based).
    fn parse(cmd: &str) -> Result<(Option<(usize, usize)>, String), String> {
        let e = make_editor();
        parse_range(cmd, &e).map(|(r, rest)| (r.map(|lr| (lr.start, lr.end)), rest.to_owned()))
    }

    #[test]
    fn multibyte_mark_does_not_panic() {
        // Regression: `&s[2..]` assumed a 1-byte mark; a multibyte mark char
        // (`:'é`) sliced mid-char and crashed the editor. Must resolve
        // gracefully (unknown mark → error) rather than panic.
        let _ = parse("'é");
        let _ = parse("'あx");
        let _ = parse("'😀,5");
    }

    #[test]
    fn bare_number() {
        let (r, rest) = parse("5").unwrap();
        assert_eq!(r, Some((5, 5)));
        assert_eq!(rest, "");
    }

    #[test]
    fn comma_separated() {
        let (r, rest) = parse("5,10").unwrap();
        // editor has 5 lines so 10 is clamped to 5
        assert_eq!(r, Some((5, 5)));
        assert_eq!(rest, "");
    }

    #[test]
    fn comma_separated_within_range() {
        let (r, rest) = parse("2,4").unwrap();
        assert_eq!(r, Some((2, 4)));
        assert_eq!(rest, "");
    }

    #[test]
    fn percent_whole_buffer() {
        let (r, rest) = parse("%").unwrap();
        assert_eq!(r, Some((1, 5)));
        assert_eq!(rest, "");
    }

    #[test]
    fn dot_dollar() {
        // cursor starts at row 0 (1-based: 1), last line is 5
        let (r, rest) = parse(".,$").unwrap();
        assert_eq!(r, Some((1, 5)));
        assert_eq!(rest, "");
    }

    // ---- phantom trailing row: `$` / `%` resolve to REAL content lines ----
    //
    // ropey's `len_lines()` synthesizes one phantom empty trailing row when
    // the text ends in `\n`: "a\nb\n" → rows ["a", "b", ""]. `$` must mean
    // vim's last REAL line (2), not the phantom (3), or `:$d` / `:$y` /
    // `:$-1` operate on the wrong line.

    fn make_editor_from_str(content: &str) -> Editor<hjkl_buffer::View, DefaultHost> {
        hjkl_vim::vim_editor(
            hjkl_buffer::View::from_str(content),
            DefaultHost::new(),
            hjkl_engine::Options::default(),
        )
    }

    #[test]
    fn dollar_is_last_real_line_on_newline_terminated_buffer() {
        // Regression: `$` resolved to ropey's phantom trailing empty row (3)
        // instead of vim's last real line (2), so `:$d` deleted the phantom
        // (stripping the trailing newline) instead of line "b".
        let e = make_editor_from_str("a\nb\n");
        let (r, rest) = parse_range("$", &e).unwrap();
        assert_eq!(r, Some(LineRange::new(2, 2)));
        assert_eq!(rest, "");
    }

    #[test]
    fn dollar_minus_one_is_second_to_last_real_line() {
        // `$-1` from `$` = 2 → 1 (line "a"); with the phantom it resolved to
        // 2 (line "b"), so `:$-1d` deleted the wrong line.
        let e = make_editor_from_str("a\nb\n");
        let (r, rest) = parse_range("$-1", &e).unwrap();
        assert_eq!(r, Some(LineRange::new(1, 1)));
        assert_eq!(rest, "");
    }

    #[test]
    fn percent_is_real_content_lines_on_newline_terminated_buffer() {
        // `%` = 1,$ must cover rows 1-2, not ropey's phantom row 3.
        let e = make_editor_from_str("a\nb\n");
        let (r, rest) = parse_range("%", &e).unwrap();
        assert_eq!(r, Some(LineRange::new(1, 2)));
        assert_eq!(rest, "");
    }

    #[test]
    fn dollar_on_real_empty_last_line_still_resolves_to_it() {
        // "a\n\n" has a REAL empty last line — ropey rows ["a", "", ""];
        // `$` must resolve to 2 (the real empty line), not 3 or 1.
        let e = make_editor_from_str("a\n\n");
        let (r, _) = parse_range("$", &e).unwrap();
        assert_eq!(r, Some(LineRange::new(2, 2)));
    }

    #[test]
    fn dollar_on_single_row_newline_terminated_buffer_is_one() {
        // "\n" is a single (empty) line in vim — `$` = 1, not ropey's 2.
        let e = make_editor_from_str("\n");
        let (r, _) = parse_range("$", &e).unwrap();
        assert_eq!(r, Some(LineRange::new(1, 1)));
    }

    #[test]
    fn mark_range() {
        use hjkl_buffer::View;
        use hjkl_engine::{DefaultHost, Options};
        let buf = View::from_str("a\nb\nc\nd\ne");
        let host = DefaultHost::new();
        let mut editor = hjkl_vim::vim_editor(buf, host, Options::default());
        // marks are 0-based internally; 1-based in range results
        editor.set_mark('a', (0, 0)); // line 1
        editor.set_mark('b', (2, 0)); // line 3
        let (r, rest) = parse_range("'a,'b", &editor).unwrap();
        assert_eq!(r, Some(LineRange::new(1, 3)));
        assert_eq!(rest, "");
    }

    #[test]
    fn range_followed_by_command() {
        // `:5,10w` on the 5-line fixture: the end address is past EOF and a
        // command follows, so nvim errors E16 (verified against nvim 0.12.4)
        // — it must not silently clamp to (5, 5). `numeric_address_past_eof_
        // errors_when_command_follows` pins the same rule for `:d`.
        let result = parse("5,10w");
        let err = result.expect_err("range past EOF with a command must error E16");
        assert!(err.contains("E16"), "got: {err}");
    }

    #[test]
    fn range_2_4_followed_by_command() {
        let (r, rest) = parse("2,4w").unwrap();
        assert_eq!(r, Some((2, 4)));
        assert_eq!(rest, "w");
    }

    #[test]
    fn no_range() {
        let (r, rest) = parse("w").unwrap();
        assert_eq!(r, None);
        assert_eq!(rest, "w");
    }

    #[test]
    fn invalid_end_address() {
        let result = parse("5,x");
        assert!(result.is_err(), "expected error for invalid end address");
    }

    #[test]
    fn mark_not_set_returns_error() {
        let result = parse("'z");
        assert!(result.is_err());
    }

    // ---- audit A3: backward ranges error (E493) instead of silently swapping

    #[test]
    fn numeric_address_past_eof_errors_when_command_follows() {
        // nvim parity (verified against nvim 0.12.4): a literal number past
        // EOF is E16 when a command follows the range (`:5d` on a 3-line
        // buffer errors and deletes nothing), while the bare `:N` goto clamps.
        let ed = make_editor_with_lines(&["a", "b", "c"]);

        let err = parse_range("5d", &ed).unwrap_err();
        assert!(err.contains("E16"), "got: {err}");

        let err = parse_range("3,10d", &ed).unwrap_err();
        assert!(err.contains("E16"), "got: {err}");

        // Bare goto still clamps to the last line.
        let (r, rest) = parse_range("100", &ed).unwrap();
        assert_eq!(r, Some(LineRange::new(3, 3)));
        assert!(rest.trim().is_empty());

        // In-range numbers unaffected.
        let (r, _) = parse_range("2d", &ed).unwrap();
        assert_eq!(r, Some(LineRange::new(2, 2)));
    }

    #[test]
    fn backward_numeric_range_returns_e493() {
        // `:4,2` — start (4) > end (2) — must error, not silently become
        // `(2, 4)`.
        let result = parse("4,2");
        let err = result.expect_err("backward range must be rejected, not swapped");
        assert!(
            err.contains("E493"),
            "expected E493 backwards-range error, got: {err}"
        );
    }

    #[test]
    fn backward_numeric_range_does_not_produce_swapped_range() {
        // Belt-and-suspenders on top of the message check: confirm the `Err`
        // path is actually taken (no `Ok((Some(range), _))` sneaking through
        // with the swapped bounds).
        let e = make_editor();
        let result = parse_range("4,2", &e);
        assert!(
            result.is_err(),
            "backward range must not resolve to a swapped LineRange, got: {result:?}"
        );
    }

    #[test]
    fn backward_mark_range_returns_e493() {
        use hjkl_buffer::View;
        use hjkl_engine::{DefaultHost, Options};
        let buf = View::from_str("a\nb\nc\nd\ne");
        let host = DefaultHost::new();
        let mut editor = hjkl_vim::vim_editor(buf, host, Options::default());
        // mark 'a' on line 4, mark 'b' on line 2 — 'a,'b is backward.
        editor.set_mark('a', (3, 0)); // line 4
        editor.set_mark('b', (1, 0)); // line 2
        let result = parse_range("'a,'b", &editor);
        let err = result.expect_err("backward mark range must be rejected, not swapped");
        assert!(
            err.contains("E493"),
            "expected E493 backwards-range error, got: {err}"
        );
    }

    #[test]
    fn forward_range_still_works_after_e493_change() {
        // Forward ranges (start <= end) must be completely unaffected.
        let (r, rest) = parse("2,4").unwrap();
        assert_eq!(r, Some((2, 4)));
        assert_eq!(rest, "");
    }

    #[test]
    fn equal_range_is_fine() {
        // start == end is not "backward" — must parse fine, not error.
        let (r, rest) = parse("5,5").unwrap();
        assert_eq!(r, Some((5, 5)));
        assert_eq!(rest, "");
    }

    #[test]
    fn line_range_single_start_equals_end() {
        let r = LineRange::single(5);
        assert_eq!(r.start_one_based(), 5);
        assert_eq!(r.end_one_based(), 5);
    }

    // ---- audit A4: `+N` / `-N` cursor-relative offset addresses -----------

    /// Parse `cmd` on a 5-line editor whose cursor starts on 1-based
    /// `cursor_line`. Returns `(start, end)` (1-based) like `parse`.
    fn parse_from_line(cmd: &str, cursor_line: usize) -> Result<(usize, usize), String> {
        let mut e = make_editor();
        e.set_cursor_quiet(cursor_line - 1, 0);
        let (r, _rest) = parse_range(cmd, &e)?;
        Ok(r.map(|lr| (lr.start_one_based(), lr.end_one_based()))
            .expect("expected a parsed range"))
    }

    #[test]
    fn plus_n_is_cursor_relative_not_absolute() {
        // Audit A4: `:+3` from cursor line 1 must land on line 4 (cursor + 3),
        // NOT absolute line 3 (the old bug: it fell through to the bare
        // line-number fallback, whose `usize::from_str` silently accepts a
        // leading `+`).
        let (start, end) = parse_from_line("+3", 1).unwrap();
        assert_eq!((start, end), (4, 4));
    }

    #[test]
    fn minus_n_is_cursor_relative() {
        // `:-2` from cursor line 5 → line 3.
        let (start, end) = parse_from_line("-2", 5).unwrap();
        assert_eq!((start, end), (3, 3));
    }

    #[test]
    fn bare_plus_is_cursor_plus_one() {
        let (start, end) = parse_from_line("+", 2).unwrap();
        assert_eq!((start, end), (3, 3));
    }

    #[test]
    fn bare_minus_is_cursor_minus_one() {
        let (start, end) = parse_from_line("-", 4).unwrap();
        assert_eq!((start, end), (3, 3));
    }

    #[test]
    fn dot_plus_n_offset() {
        // `.+2` — explicit current-line base with an offset.
        let (start, end) = parse_from_line(".+2", 2).unwrap();
        assert_eq!((start, end), (4, 4));
    }

    #[test]
    fn plus_offset_clamps_to_last_line() {
        // `:+999` on a 5-line buffer clamps to the last line, it doesn't error.
        let (start, end) = parse_from_line("+999", 1).unwrap();
        assert_eq!((start, end), (5, 5));
    }

    #[test]
    fn minus_offset_clamps_to_first_line() {
        // `:-999` on a 5-line buffer clamps to line 1.
        let (start, end) = parse_from_line("-999", 5).unwrap();
        assert_eq!((start, end), (1, 1));
    }

    #[test]
    fn repeated_sign_offsets_accumulate() {
        // vim allows repeated offset terms: `++` = +2, `+-` = 0.
        let (start, end) = parse_from_line("++", 1).unwrap();
        assert_eq!((start, end), (3, 3));
        let (start, end) = parse_from_line("+-", 3).unwrap();
        assert_eq!((start, end), (3, 3));
    }

    // ---- V2: `;` range separator (address-resolution level) ---------------
    //
    // Two-address scope only: a single `;` (or `,`) separator, matching the
    // pre-existing `,` grammar. `;` threads a "virtual cursor" (the first
    // address's row) into the second address's `.`/offset/search resolution;
    // `,` resolves both from the real cursor. nvim v0.12.4-verified.

    #[test]
    fn semicolon_relative_end_uses_first_address_as_cursor() {
        // `:2;+2` from cursor line 1 → start 2, then cursor moves to 2 so
        // `+2` = 4. Range 2-4. (`:2;+2d` deletes l2-l4 in nvim → "l1\nl5".)
        let (start, end) = parse_from_line("2;+2", 1).unwrap();
        assert_eq!((start, end), (2, 4));
    }

    #[test]
    fn comma_relative_end_uses_real_cursor() {
        // `:2,+2` from cursor line 1 → both resolve from the real cursor, so
        // `+2` = 3. Range 2-3. Proves `,` is NOT affected by `;` threading.
        let (start, end) = parse_from_line("2,+2", 1).unwrap();
        assert_eq!((start, end), (2, 3));
    }

    #[test]
    fn semicolon_dot_relative_end() {
        // `:3;.+1` — `.` in the second address is the moved cursor (row 3), so
        // `.+1` = 4. Range 3-4 (same as `:3;+1`).
        let (start, end) = parse_from_line("3;.+1", 1).unwrap();
        assert_eq!((start, end), (3, 4));
    }

    #[test]
    fn semicolon_missing_end_address_errors_with_semicolon_in_message() {
        let e = make_editor();
        let err = parse_range("5;", &e).expect_err("missing end address must error");
        assert!(err.contains(';'), "error should mention ';': {err}");
    }

    #[test]
    fn semicolon_single_address_number_unchanged() {
        // A lone address with no separator is still a single-line range.
        let (r, rest) = parse("3").unwrap();
        assert_eq!(r, Some((3, 3)));
        assert_eq!(rest, "");
    }

    #[test]
    fn existing_address_tests_unaffected_by_offset_support() {
        // Sanity: plain numbers, `.`, `$`, and marks with no offset still
        // resolve exactly as before (offset chain parses to 0 and is a
        // no-op).
        let (r, rest) = parse("5").unwrap();
        assert_eq!(r, Some((5, 5)));
        assert_eq!(rest, "");
        let (r, rest) = parse("%").unwrap();
        assert_eq!(r, Some((1, 5)));
        assert_eq!(rest, "");
    }
}
