// ansi.rs --- T M6.3 ANSI parser.

//! ANSI/VT100 escape-sequence parser for the REPL streaming pipeline.
//!
//! This module is a pure stream filter: bytes go in, [`AnsiEvent`]s
//! come out. The parser is responsible for the support/unsupport
//! split spelled out in spec §sec:ansi-scope --- SGR + intra-line
//! cursor motion + line-level erase + bracketed paste + OSC
//! title-setting are *supported*; alternate-screen, mouse tracking,
//! character-set switching, scrolling regions, and cursor save/restore
//! are *parsed but discarded*. Unknown sequences recover per the
//! per-state byte cap rule documented on [`AnsiParserConfig`].
//!
//! # State machine
//!
//! Hand-rolled, modeled on the canonical vt100.net / ECMA-48 parser
//! table. Hand-rolling (rather than vendoring a third-party crate
//! like `vte`) keeps the recovery rule from spec §sec:ansi-scope
//! --- 1 KiB cap, drop-to-ground on next `ESC`, never crash ---
//! directly inspectable as code instead of pinned to a dependency's
//! choices about failure modes.
//!
//! # Output stream split
//!
//! Style annotations are emitted as a separate stream from text
//! ([`AnsiEvent::SetStyle`] interleaved with [`AnsiEvent::Text`]):
//! the rope sees only literal text, and the consumer (the M6.4 REPL
//! view) maintains a single piece of state ("the active style is
//! whatever the most recent [`SetStyle`] said") and translates to
//! byte-range style annotations as it appends to the rope.
//!
//! # CSI parameters
//!
//! The parser collects CSI parameters as a list of *parameter
//! groups*, each a list of subparameters. This shape is committed
//! to from the start because retrofitting subparameter support
//! after the SGR mapper is written is painful: the modern variants
//! (`ESC [4:3 m` for curly underline, `ESC [38:2::R:G:B m` for
//! 24-bit color in the colon-separated form) require subparameter
//! parsing that the legacy `;`-only approach can't represent. With
//! [`CsiParam`] in hand both legacy `38;2;R;G;B` and modern
//! `38:2::R:G:B` become two cases of the same SGR loop.

use crate::cell::{Color, Style, UnderlineStyle};

// ---------------------------------------------------------------------------
// Public output
// ---------------------------------------------------------------------------

/// One observable effect of feeding bytes to an [`AnsiParser`].
///
/// One byte may produce zero or more events; one event is emitted
/// at the moment the parser has enough context to commit to it
/// (e.g., `Text` is emitted at every transition out of Ground, not
/// per byte).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AnsiEvent {
    /// Append literal text to the consumer's rope. Text never
    /// contains escape bytes (`0x1B`) --- that property is what makes
    /// stream-separation testable.
    Text(String),
    /// Set the active style. Subsequent `Text` events are rendered
    /// at this style until the next `SetStyle`. Spec §sec:ansi-scope.
    SetStyle(Style),
    /// Carriage return: move the input position to the start of the
    /// current input region. The REPL view interprets "input region"
    /// per spec §sec:repl-view.
    CarriageReturn,
    /// Backspace: move the input position one column left within
    /// the current input region.
    Backspace,
    /// Erase from the input position to the end of the line
    /// (`CSI K` or `CSI 0 K`).
    EraseToEol,
    /// Erase the entire current line (`CSI 2 K`).
    EraseLine,
    /// Set the window title (OSC 0 / OSC 2 with terminator).
    /// Exposed as a per-buffer attribute by the M6.4 REPL view.
    SetTitle(String),
    /// `OSC 133;A`: shell prompt begins.
    PromptStart,
    /// `OSC 133;B`: shell prompt ends.
    PromptEnd,
    /// `OSC 133;C`: command input begins.
    CommandStart,
    /// `OSC 133;D`: command output begins / command finished marker.
    OutputStart,
    /// `CSI 200 ~`: a process-emitted bracketed-paste begin marker.
    BracketedPasteBegin,
    /// `CSI 201 ~`: a process-emitted bracketed-paste end marker.
    BracketedPasteEnd,
    /// `CSI ? 1049 h`: alternate-screen entered. Subsequent `Text`
    /// and `SetStyle` events are *suppressed* until
    /// [`AnsiEvent::AlternateScreenExit`] --- the parser still
    /// advances state correctly but emits no text payload, per
    /// spec §sec:ansi-scope.
    AlternateScreenEnter,
    /// `CSI ? 1049 l`: alternate-screen exited. `Text` and
    /// `SetStyle` resume.
    AlternateScreenExit,
}

/// Tunable knobs for [`AnsiParser`].
#[derive(Clone, Copy, Debug)]
pub struct AnsiParserConfig {
    /// Per-state byte budget for `Ignore`/`OscIgnore`/`DcsIgnore`
    /// states. *Reset to zero on every entry to an Ignore state*
    /// --- this is a per-state cap, not a global counter, so a
    /// feed containing N malformed sequences each gets its own
    /// budget. At the limit, the parser force-returns to `Ground`
    /// and discards the malformed sequence; subsequent input is
    /// processed normally as ordinary text. Default 1 KiB per
    /// spec §sec:ansi-scope.
    pub unknown_sequence_byte_limit: usize,
}

impl Default for AnsiParserConfig {
    fn default() -> Self {
        Self {
            unknown_sequence_byte_limit: 1024,
        }
    }
}

// ---------------------------------------------------------------------------
// CSI parameter representation
// ---------------------------------------------------------------------------

/// One parameter group inside a CSI sequence.
///
/// `main` is the primary parameter value (digits before any `:`);
/// `sub` is the sequence of subparameters (digits between `:`s
/// after `main`, in order). Empty digits resolve to `0` per
/// ECMA-48 default-parameter convention. Empty `sub` is the common
/// case (any legacy `;`-only sequence).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CsiParam {
    /// Primary parameter. Empty input resolves to `0`.
    pub main: u32,
    /// Subparameters (the `:`-separated tail). Empty for legacy
    /// `;`-only sequences.
    pub sub: Vec<u32>,
}

/// Sequence of [`CsiParam`]s collected from a single CSI command.
pub type CsiParams = Vec<CsiParam>;

// ---------------------------------------------------------------------------
// Internal state machine
// ---------------------------------------------------------------------------

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(dead_code)] // DCS sub-states wire up in stage 3; stage 1 only enters DcsEntry.
enum State {
    Ground,
    Escape,
    EscapeIntermediate,
    CsiEntry,
    CsiParam,
    CsiIntermediate,
    CsiIgnore,
    OscString,
    OscIgnore,
    /// Sub-state of `OscString`: just saw `ESC` inside an OSC body;
    /// the next byte is the terminator-check (`\` ⇒ ST, else
    /// restart-from-Escape).
    OscEscPending,
    DcsEntry,
    DcsParam,
    DcsIntermediate,
    DcsPassthrough,
    DcsIgnore,
    SosPmApcString,
}

/// CSI accumulator. Holds in-progress parameters as they arrive
/// byte-by-byte, plus the private-marker prefix (`?`, `<`, `=`,
/// `>`) and any intermediate bytes seen before the final byte.
#[derive(Default)]
struct CsiCollector {
    params: CsiParams,
    /// In-progress `main` value. `None` until the first digit;
    /// flushed into `params` on `;` or final byte.
    current_main: Option<u32>,
    /// In-progress sub list for the current group.
    current_subs: Vec<u32>,
    /// In-progress sub digit accumulator. `None` after a `:` but
    /// before the next digit; some `(0)` after a `:` if no digits
    /// yet have been seen for that sub (the empty-sub case, e.g.
    /// `38:2::R:G:B`).
    current_sub_acc: Option<u32>,
    /// `true` once we've seen at least one `:` in the current
    /// group --- subsequent digits accumulate into a sub, not main.
    in_subs: bool,
    /// Private marker byte (`?`, `<`, `=`, `>`) if the CSI started
    /// with one; `None` for ordinary CSI.
    private_marker: Option<u8>,
    /// Intermediate bytes (range `0x20..=0x2F`) seen between params
    /// and the final byte.
    intermediates: Vec<u8>,
}

impl CsiCollector {
    fn reset(&mut self) {
        self.params.clear();
        self.current_main = None;
        self.current_subs.clear();
        self.current_sub_acc = None;
        self.in_subs = false;
        self.private_marker = None;
        self.intermediates.clear();
    }

    /// Accumulate a digit into `main` (before any `:`) or into the
    /// current sub (after a `:`).
    fn push_digit(&mut self, d: u32) {
        if self.in_subs {
            let cur = self.current_sub_acc.unwrap_or(0);
            self.current_sub_acc = Some(cur.saturating_mul(10).saturating_add(d));
        } else {
            let cur = self.current_main.unwrap_or(0);
            self.current_main = Some(cur.saturating_mul(10).saturating_add(d));
        }
    }

    /// `:` between subparameters. Flush the in-progress sub digit
    /// (or `0` if empty) into `current_subs`.
    fn push_colon(&mut self) {
        if self.in_subs {
            // Flush prior sub.
            self.current_subs.push(self.current_sub_acc.unwrap_or(0));
            self.current_sub_acc = None;
        } else {
            // First `:` of this group: flush main if started, then
            // switch into sub mode. Note `main` may be `None` if the
            // group started with `:` (rare but legal).
            self.in_subs = true;
        }
    }

    /// `;` between parameter groups. Flush the in-progress group
    /// into `params` and reset for the next group.
    fn push_semicolon(&mut self) {
        // Close the in-progress sub if any.
        if self.in_subs {
            self.current_subs.push(self.current_sub_acc.unwrap_or(0));
            self.current_sub_acc = None;
        }
        let main = self.current_main.unwrap_or(0);
        let subs = std::mem::take(&mut self.current_subs);
        self.params.push(CsiParam { main, sub: subs });
        self.current_main = None;
        self.in_subs = false;
    }

    /// Final byte arrived: flush any in-progress group and return
    /// the collected parameters.
    fn finalize(&mut self) -> CsiParams {
        // Flush the last group, if any digits or subs are pending.
        let pending_main = self.current_main.is_some();
        let pending_subs = self.in_subs || !self.current_subs.is_empty();
        if pending_main || pending_subs {
            if self.in_subs {
                self.current_subs.push(self.current_sub_acc.unwrap_or(0));
                self.current_sub_acc = None;
            }
            let main = self.current_main.unwrap_or(0);
            let subs = std::mem::take(&mut self.current_subs);
            self.params.push(CsiParam { main, sub: subs });
        }
        std::mem::take(&mut self.params)
    }
}

// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------

/// Stateful ANSI parser. Feed bytes; collect [`AnsiEvent`]s.
///
/// State persists across `feed` calls --- a CSI started in one
/// chunk continues in the next. M6.2's coalescing makes per-tick
/// chunk boundaries common, so this property is load-bearing for
/// correctness, not just convenience.
pub struct AnsiParser {
    state: State,
    /// Current SGR state. Mutated by SGR parameters; emitted as
    /// [`AnsiEvent::SetStyle`] when it changes.
    current_style: Style,
    /// Byte count consumed in the current Ignore state. Reset to
    /// zero on every entry to an Ignore state: this is a per-state
    /// budget, not a global counter, so each malformed sequence
    /// gets its own [`AnsiParserConfig::unknown_sequence_byte_limit`].
    ignore_byte_count: usize,
    /// In-progress text run accumulator. Flushed as a single
    /// [`AnsiEvent::Text`] when we leave Ground for any non-Ground
    /// state. Holds only complete (valid UTF-8) characters; partial
    /// multi-byte sequences live in `utf8_buf` until they complete.
    text_run: String,
    /// Pending UTF-8 bytes that haven't yet decoded to a complete
    /// scalar. Cross-feed buffer: a multi-byte sequence split across
    /// `feed()` calls accumulates here until the trailing byte
    /// arrives (or until a non-continuation byte invalidates the
    /// sequence, at which point we emit U+FFFD for the malformed
    /// bytes and continue). The buffer holds at most a few bytes
    /// (the longest valid UTF-8 sequence is 4 bytes; we cap at 8
    /// defensively). This replaces the M6.3-stage-1 shortcut where
    /// every non-ASCII byte was emitted as U+FFFD regardless of
    /// whether it was actually malformed.
    utf8_buf: Vec<u8>,
    /// Suppress `Text` and `SetStyle` events while alternate-screen
    /// is active. Spec §sec:ansi-scope: parser advances state
    /// normally but emits no payload.
    alt_screen_active: bool,
    csi: CsiCollector,
    /// In-progress OSC body bytes.
    osc_body: Vec<u8>,
    /// Intermediate bytes for plain ESC sequences (`ESC` + 0x20..=0x2F).
    escape_intermediates: Vec<u8>,
    config: AnsiParserConfig,
}

impl Default for AnsiParser {
    fn default() -> Self {
        Self::new()
    }
}

impl AnsiParser {
    /// Construct a parser with default configuration.
    #[must_use]
    pub fn new() -> Self {
        Self::with_config(AnsiParserConfig::default())
    }

    /// Construct a parser with custom configuration.
    #[must_use]
    pub fn with_config(config: AnsiParserConfig) -> Self {
        Self {
            state: State::Ground,
            current_style: Style::default(),
            ignore_byte_count: 0,
            text_run: String::new(),
            utf8_buf: Vec::new(),
            alt_screen_active: false,
            csi: CsiCollector::default(),
            osc_body: Vec::new(),
            escape_intermediates: Vec::new(),
            config,
        }
    }

    /// Reset the parser to ground state. The running style is *not*
    /// reset --- callers that want a clean style should pair this
    /// with their own `SetStyle(Style::default())`.
    pub fn reset(&mut self) {
        self.state = State::Ground;
        self.ignore_byte_count = 0;
        self.text_run.clear();
        self.utf8_buf.clear();
        self.csi.reset();
        self.osc_body.clear();
        self.escape_intermediates.clear();
    }

    /// Feed a slice of bytes to the parser. Returns every event the
    /// bytes produced in order.
    pub fn feed(&mut self, bytes: &[u8]) -> Vec<AnsiEvent> {
        let mut events = Vec::new();
        for &b in bytes {
            self.feed_byte(b, &mut events);
        }
        // Flush any pending text run at the end of the feed, but
        // only if we're in Ground (mid-CSI/mid-OSC text shouldn't
        // be emitted yet --- the in-flight escape might still
        // resolve and the bytes belong to it). Since we only build
        // text_run while in Ground, this is safe to flush
        // unconditionally as a final step.
        //
        // We do NOT flush `utf8_buf` here: an incomplete multi-byte
        // sequence at the feed boundary is exactly the case the
        // cross-feed buffer was added to handle --- the trailing
        // bytes are expected to arrive in the next feed. The state
        // transition path (flush_text_run) does emit U+FFFD for
        // pending bytes because a non-text byte genuinely
        // interrupts the sequence; feed-boundary doesn't.
        if !self.text_run.is_empty() && !self.alt_screen_active {
            let run = std::mem::take(&mut self.text_run);
            events.push(AnsiEvent::Text(run));
        } else {
            self.text_run.clear();
        }
        events
    }

    fn feed_byte(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
        // ESC-anywhere rule: ECMA-48 §10.2 ("Cancel"). Aborts any
        // in-progress sequence and starts a fresh Escape state. The
        // exception is OscString, where ESC begins a two-byte ST
        // terminator check (handled in OscString below).
        if b == 0x1B && !matches!(self.state, State::OscString | State::OscEscPending) {
            self.flush_text_run(events);
            self.start_new_sequence();
            return;
        }
        // CAN (0x18) and SUB (0x1A) cancel any in-progress sequence
        // and return to Ground.
        if matches!(b, 0x18 | 0x1A) {
            self.flush_text_run(events);
            self.recover_to_ground();
            return;
        }

        // Per-state byte cap. The counter increments for every byte
        // consumed in any non-Ground state, and is reset to zero at
        // every transition into a fresh sequence (ESC-anywhere) or
        // back to Ground (normal dispatch / force-recover). At the
        // limit, the parser drops the in-flight sequence and
        // returns to Ground; the *current* byte is dropped on the
        // floor, but subsequent bytes are processed normally as
        // ordinary text. Spec §sec:ansi-scope: "drops back to ground
        // state at the next ESC or after a bounded number of bytes
        // (1 KiB), whichever comes first."
        if self.state != State::Ground {
            self.ignore_byte_count = self.ignore_byte_count.saturating_add(1);
            if self.ignore_byte_count > self.config.unknown_sequence_byte_limit {
                self.recover_to_ground();
                return;
            }
        }

        match self.state {
            State::Ground => self.feed_ground(b, events),
            State::Escape => self.feed_escape(b),
            State::EscapeIntermediate => self.feed_escape_intermediate(b),
            State::CsiEntry => self.feed_csi_entry(b, events),
            State::CsiParam => self.feed_csi_param(b, events),
            State::CsiIntermediate => self.feed_csi_intermediate(b, events),
            State::CsiIgnore => self.feed_csi_ignore(b),
            State::OscString => self.feed_osc_string(b, events),
            State::OscEscPending => self.feed_osc_esc_pending(b, events),
            State::OscIgnore => self.feed_osc_ignore(b),
            State::DcsEntry
            | State::DcsParam
            | State::DcsIntermediate
            | State::DcsPassthrough
            | State::DcsIgnore
            | State::SosPmApcString => {
                // DCS / SOS / PM / APC are parsed-and-discarded
                // per spec §sec:ansi-scope. The body bytes drop on
                // the floor; the ESC-anywhere rule terminates the
                // sequence on the next ESC, and the per-state byte
                // cap is a backstop.
            }
        }
    }

    // -----------------------------------------------------------------------
    // Text run buffering
    // -----------------------------------------------------------------------

    fn flush_text_run(&mut self, events: &mut Vec<AnsiEvent>) {
        // Any pending UTF-8 prefix at this point is interrupted by
        // a non-text byte (control byte, CSI start, etc.); the
        // sequence can't continue across that boundary. Emit U+FFFD
        // for the incomplete bytes before committing the run.
        self.flush_pending_utf8_as_replacement();
        if self.text_run.is_empty() {
            return;
        }
        let run = std::mem::take(&mut self.text_run);
        if !self.alt_screen_active {
            events.push(AnsiEvent::Text(run));
        }
    }

    fn emit_set_style(&mut self, events: &mut Vec<AnsiEvent>) {
        if self.alt_screen_active {
            return;
        }
        events.push(AnsiEvent::SetStyle(self.current_style));
    }

    /// Helper: emit `ev` if alt-screen is not active; drop otherwise.
    /// Used for visible side-effects (CR / BS / Erase / Bracketed
    /// paste / `SetTitle`). The alt-screen markers themselves
    /// bypass this.
    fn push_visible(&self, ev: AnsiEvent, events: &mut Vec<AnsiEvent>) {
        if !self.alt_screen_active {
            events.push(ev);
        }
    }

    /// Begin a fresh escape sequence (called from ESC-anywhere).
    /// Resets the byte budget and all in-flight sequence state.
    fn start_new_sequence(&mut self) {
        self.csi.reset();
        self.osc_body.clear();
        self.escape_intermediates.clear();
        self.ignore_byte_count = 0;
        self.state = State::Escape;
    }

    /// Force-recover to Ground. Discards in-flight sequence state
    /// and resets the byte budget. Called on CAN/SUB, on byte-cap
    /// overflow, and after a successful sequence dispatch.
    fn recover_to_ground(&mut self) {
        self.csi.reset();
        self.osc_body.clear();
        self.escape_intermediates.clear();
        self.ignore_byte_count = 0;
        self.state = State::Ground;
    }

    // -----------------------------------------------------------------------
    // Ground
    // -----------------------------------------------------------------------

    fn feed_ground(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
        match b {
            // CR: flush text, emit CarriageReturn.
            0x0D => {
                self.flush_text_run(events);
                if !self.alt_screen_active {
                    events.push(AnsiEvent::CarriageReturn);
                }
            }
            // BS: flush text, emit Backspace.
            0x08 => {
                self.flush_text_run(events);
                if !self.alt_screen_active {
                    events.push(AnsiEvent::Backspace);
                }
            }
            // BEL (0x07), VT (0x0B), FF (0x0C), HT (0x09), LF
            // (0x0A): pass through to text alongside printable
            // ASCII (0x20..=0x7E). The REPL view treats LF as a
            // line break in the rope; HT as a literal tab. Other
            // C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL
            // (0x7F) are dropped silently.
            //
            // 0x80..=0xFF: UTF-8 lead or continuation byte. Goes
            // through `push_text_byte`'s stateful decoder so
            // multi-byte sequences across feeds are buffered until
            // complete.
            //
            // All text bytes route through `push_text_byte` (not
            // just non-ASCII): an ASCII byte arriving while a
            // partial UTF-8 sequence is pending invalidates that
            // sequence (the partial prefix's expected continuation
            // didn't arrive), and `push_text_byte` is the only
            // place that knows to flush the partial as `U+FFFD`.
            // The fast path inside `push_text_byte` keeps the
            // pure-ASCII case allocation-free.
            0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => {
                self.push_text_byte(b);
            }
            0x00..=0x1F | 0x7F => {}
        }
    }

    /// Append a single text byte, handling multi-byte UTF-8
    /// correctly across feed boundaries.
    ///
    /// ASCII bytes (0x00..=0x7F) take a fast path directly into
    /// `text_run` when no partial sequence is pending. Non-ASCII
    /// bytes (0x80..=0xFF) and any byte arriving while a partial
    /// sequence is pending go through `utf8_buf`, which is then
    /// greedily decoded by `try_decode_utf8_buf`. Complete scalars
    /// flush to `text_run`; an incomplete trailing sequence stays
    /// in `utf8_buf` for the next byte (whether next-feed or
    /// later in the same feed).
    ///
    /// Malformed sequences (lone continuation, invalid start byte,
    /// non-continuation arriving where one was expected, overlong
    /// encoding) emit `U+FFFD` for the offending bytes only and
    /// resume decoding the rest --- they do not corrupt subsequent
    /// valid UTF-8.
    fn push_text_byte(&mut self, b: u8) {
        if self.utf8_buf.is_empty() && b < 0x80 {
            self.text_run.push(b as char);
            return;
        }
        self.utf8_buf.push(b);
        self.try_decode_utf8_buf();
    }

    /// Greedy UTF-8 decoder over `utf8_buf`. Pushes complete chars
    /// to `text_run`; emits `U+FFFD` for malformed bytes; leaves
    /// the trailing incomplete prefix in `utf8_buf` for the next
    /// byte to complete.
    ///
    /// Caps the buffer at 8 bytes defensively: a valid UTF-8
    /// sequence is at most 4 bytes, so any trailing run longer
    /// than that is malformed (we'd have hit either a complete
    /// scalar or a `from_utf8` error before reaching 8). The cap
    /// bounds the pathological-input memory cost.
    fn try_decode_utf8_buf(&mut self) {
        loop {
            if self.utf8_buf.is_empty() {
                return;
            }
            match std::str::from_utf8(&self.utf8_buf) {
                Ok(s) => {
                    // Whole buffer is valid UTF-8: flush all of it.
                    self.text_run.push_str(s);
                    self.utf8_buf.clear();
                    return;
                }
                Err(e) => {
                    let valid = e.valid_up_to();
                    if valid > 0 {
                        // Push the valid prefix.
                        let prefix = std::str::from_utf8(&self.utf8_buf[..valid])
                            .expect("invariant: valid_up_to bytes are valid UTF-8");
                        self.text_run.push_str(prefix);
                        self.utf8_buf.drain(..valid);
                    }
                    match e.error_len() {
                        None => {
                            // Trailing incomplete sequence; wait for
                            // more bytes (next push_text_byte or next
                            // feed). The 8-byte defensive cap below
                            // guards against a pathological producer
                            // that never finishes a sequence.
                            if self.utf8_buf.len() >= 8 {
                                self.text_run.push('\u{FFFD}');
                                self.utf8_buf.clear();
                            }
                            return;
                        }
                        Some(n) => {
                            // n bytes after the valid prefix are an
                            // invalid sequence: emit U+FFFD for them
                            // and continue with whatever follows.
                            self.text_run.push('\u{FFFD}');
                            self.utf8_buf.drain(..n);
                            // Loop to retry.
                        }
                    }
                }
            }
        }
    }

    /// Drain any pending UTF-8 prefix as `U+FFFD`. Called from
    /// `flush_text_run` when the text run is being committed
    /// because we're transitioning out of Ground (a control byte,
    /// a CSI start, etc.). At that boundary, an unfinished
    /// multi-byte sequence is genuinely interrupted --- it can't
    /// continue across the non-text bytes --- so we emit the
    /// replacement character and clear.
    ///
    /// Not called at end-of-feed: a sequence interrupted by feed
    /// boundary may legitimately resume in the next feed.
    fn flush_pending_utf8_as_replacement(&mut self) {
        if !self.utf8_buf.is_empty() {
            self.text_run.push('\u{FFFD}');
            self.utf8_buf.clear();
        }
    }

    // -----------------------------------------------------------------------
    // Escape
    // -----------------------------------------------------------------------

    fn feed_escape(&mut self, b: u8) {
        match b {
            0x20..=0x2F => {
                self.escape_intermediates.push(b);
                self.state = State::EscapeIntermediate;
            }
            b'[' => {
                self.csi.reset();
                self.state = State::CsiEntry;
            }
            b']' => {
                self.osc_body.clear();
                self.state = State::OscString;
            }
            // DCS / SOS / PM / APC introducers --- parse and discard.
            b'P' => {
                self.state = State::DcsEntry;
            }
            b'X' | b'^' | b'_' => {
                self.state = State::SosPmApcString;
            }
            // ESC \ in Escape state is a stray ST; final byte for
            // a bare ESC sequence (0x30..=0x7E) lands here too. We
            // don't dispatch any single-byte ESC commands in v0.1
            // (cursor save/restore `ESC 7`/`ESC 8` are deliberately
            // unsupported per spec); both cases consume and return
            // to Ground.
            b'\\' | 0x30..=0x7E => {
                self.recover_to_ground();
            }
            // C0 controls inside Escape: drop, stay in Escape.
            _ => {}
        }
    }

    fn feed_escape_intermediate(&mut self, b: u8) {
        match b {
            0x20..=0x2F => {
                self.escape_intermediates.push(b);
            }
            // Final byte: drop the sequence (no ESC + intermediate
            // dispatches in v0.1 --- charsets are deliberately
            // unsupported per spec) and return to Ground.
            0x30..=0x7E => {
                self.recover_to_ground();
            }
            _ => {}
        }
    }

    // -----------------------------------------------------------------------
    // CSI
    // -----------------------------------------------------------------------

    fn feed_csi_entry(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
        match b {
            0x3C..=0x3F => {
                self.csi.private_marker = Some(b);
                self.state = State::CsiParam;
            }
            b'0'..=b'9' => {
                self.csi.push_digit(u32::from(b - b'0'));
                self.state = State::CsiParam;
            }
            b':' => {
                self.csi.push_colon();
                self.state = State::CsiParam;
            }
            b';' => {
                self.csi.push_semicolon();
                self.state = State::CsiParam;
            }
            0x20..=0x2F => {
                self.csi.intermediates.push(b);
                self.state = State::CsiIntermediate;
            }
            0x40..=0x7E => {
                self.dispatch_csi(b, events);
                self.recover_to_ground();
            }
            _ => self.state = State::CsiIgnore,
        }
    }

    fn feed_csi_param(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
        match b {
            b'0'..=b'9' => self.csi.push_digit(u32::from(b - b'0')),
            b':' => self.csi.push_colon(),
            b';' => self.csi.push_semicolon(),
            0x20..=0x2F => {
                self.csi.intermediates.push(b);
                self.state = State::CsiIntermediate;
            }
            0x40..=0x7E => {
                self.dispatch_csi(b, events);
                self.recover_to_ground();
            }
            _ => self.state = State::CsiIgnore,
        }
    }

    fn feed_csi_intermediate(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
        match b {
            0x20..=0x2F => self.csi.intermediates.push(b),
            0x40..=0x7E => {
                self.dispatch_csi(b, events);
                self.recover_to_ground();
            }
            _ => self.state = State::CsiIgnore,
        }
    }

    fn feed_csi_ignore(&mut self, b: u8) {
        // The per-state byte cap is enforced at the top of
        // `feed_byte`; here we only watch for the structural
        // terminator.
        if matches!(b, 0x40..=0x7E) {
            self.recover_to_ground();
        }
    }

    /// Dispatch a fully-collected CSI sequence. `final_byte` is the
    /// terminating byte (`0x40..=0x7E`). The collected parameters
    /// are taken from `self.csi`.
    fn dispatch_csi(&mut self, final_byte: u8, events: &mut Vec<AnsiEvent>) {
        let private_marker = self.csi.private_marker;
        let params = self.csi.finalize();

        match (private_marker, final_byte) {
            // SGR.
            (None, b'm') => self.dispatch_sgr(&params, events),
            // Erase in line: `CSI [n] K`. n=0 (default) →
            // EraseToEol; n=2 → EraseLine; n=1 (start to cursor)
            // and others: parsed and ignored.
            (None, b'K') => {
                let n = params.first().map_or(0, |p| p.main);
                match n {
                    0 => self.push_visible(AnsiEvent::EraseToEol, events),
                    2 => self.push_visible(AnsiEvent::EraseLine, events),
                    _ => {}
                }
            }
            // Bracketed paste markers: `CSI 200 ~` / `CSI 201 ~`.
            (None, b'~') => {
                let n = params.first().map_or(0, |p| p.main);
                match n {
                    200 => self.push_visible(AnsiEvent::BracketedPasteBegin, events),
                    201 => self.push_visible(AnsiEvent::BracketedPasteEnd, events),
                    _ => {}
                }
            }
            // DEC private mode set / reset: `CSI ? <num> h` / `l`.
            // Of these, only ?1049 (alternate screen) produces an
            // event; mouse modes (?1000, ?1006), bracketed-paste
            // mode (?2004), and the long tail are parsed and
            // discarded per spec §sec:ansi-scope.
            (Some(b'?'), b'h' | b'l') => {
                let set = final_byte == b'h';
                for p in &params {
                    if p.main == 1049 {
                        if set && !self.alt_screen_active {
                            self.alt_screen_active = true;
                            events.push(AnsiEvent::AlternateScreenEnter);
                        } else if !set && self.alt_screen_active {
                            self.alt_screen_active = false;
                            events.push(AnsiEvent::AlternateScreenExit);
                        }
                    }
                }
            }
            // Cursor motions (A/B/C/D/E/F/G/H/J/f) and other CSI
            // commands: parsed and discarded for M6.3. The M6.4
            // view layer handles intra-line motion (CR / BS) at
            // its own level; cross-region motion via CSI is in the
            // "parsed but ignored when it would cross region
            // boundaries" bucket from §sec:ansi-scope.
            _ => {}
        }

        self.csi.reset();
    }

    // -----------------------------------------------------------------------
    // SGR --- stage 1 subset (reset, bold/italic/reverse, basic 8-color)
    // -----------------------------------------------------------------------

    // Each SGR pattern carries its own per-pattern comment
    // documenting the spec policy (faint, blink-as-bold,
    // strikethrough, etc.); collapsing the no-op patterns into one
    // arm would erase that documentation.
    #[allow(clippy::match_same_arms)]
    fn dispatch_sgr(&mut self, params: &CsiParams, events: &mut Vec<AnsiEvent>) {
        // Empty parameter list = reset (CSI m).
        if params.is_empty() {
            self.current_style = Style::default();
            self.emit_set_style(events);
            return;
        }
        let mut i = 0;
        while i < params.len() {
            let p = &params[i];
            let mut consumed_extra = 0usize;
            match p.main {
                0 => self.current_style = Style::default(),
                1 => self.current_style.bold = true,
                // 2 (faint): no-op. The cell `Style` has no faint
                // field; advance state without modifying running
                // style. Spec lists faint among supported attrs but
                // our representation collapses it; preserved as a
                // possible future `Style` field per the M11
                // measurement-driven discipline.
                2 => {}
                3 => self.current_style.italic = true,
                4 => {
                    self.current_style.underline = match p.sub.first().copied() {
                        Some(0) => UnderlineStyle::None,
                        None | Some(1) => UnderlineStyle::Single,
                        Some(2) => UnderlineStyle::Double,
                        Some(3) => UnderlineStyle::Curly,
                        Some(4) => UnderlineStyle::Dotted,
                        Some(5) => UnderlineStyle::Dashed,
                        _ => UnderlineStyle::Single,
                    };
                }
                // 5/6 (blink, rapid blink): mapped to bold per spec
                // §sec:ansi-scope ("blink-as-bold").
                5 | 6 => self.current_style.bold = true,
                7 => self.current_style.reverse = true,
                // 8 (concealed/invisible): no-op. Out of scope.
                8 => {}
                // 9 (strikethrough): no-op. Spec §sec:ansi-scope
                // lists it among supported attrs; the cell `Style`
                // has no strikethrough field, so advance state
                // without modifying running style. Stage 4
                // regression test asserts the running style is
                // *unchanged* (not corrupted) by SGR 9.
                9 => {}
                22 => self.current_style.bold = false,
                23 => self.current_style.italic = false,
                24 => self.current_style.underline = UnderlineStyle::None,
                // 25 (blink off): no-op. Symmetry with 5/6 → bold:
                // we do not unset bold here, since that would also
                // unset bold acquired via SGR 1.
                25 => {}
                27 => self.current_style.reverse = false,
                28 => {}
                29 => {}
                30..=37 => {
                    self.current_style.fg = Color::Indexed(u8::try_from(p.main - 30).unwrap_or(0));
                }
                38 => {
                    if let Some((color, extra)) = parse_extended_color(p, &params[i + 1..]) {
                        self.current_style.fg = color;
                        consumed_extra = extra;
                    }
                }
                39 => self.current_style.fg = Color::Default,
                40..=47 => {
                    self.current_style.bg = Color::Indexed(u8::try_from(p.main - 40).unwrap_or(0));
                }
                48 => {
                    if let Some((color, extra)) = parse_extended_color(p, &params[i + 1..]) {
                        self.current_style.bg = color;
                        consumed_extra = extra;
                    }
                }
                49 => self.current_style.bg = Color::Default,
                // 58/59 (underline color, kitty/mintty extension):
                // same extended-color grammar as 38/48 (T M4.6).
                58 => {
                    if let Some((color, extra)) = parse_extended_color(p, &params[i + 1..]) {
                        self.current_style.underline_color = color;
                        consumed_extra = extra;
                    }
                }
                59 => self.current_style.underline_color = Color::Default,
                90..=97 => {
                    self.current_style.fg =
                        Color::Indexed(u8::try_from(p.main - 90 + 8).unwrap_or(0));
                }
                100..=107 => {
                    self.current_style.bg =
                        Color::Indexed(u8::try_from(p.main - 100 + 8).unwrap_or(0));
                }
                _ => {} // Unknown SGR: drop silently, advance state.
            }
            i += 1 + consumed_extra;
        }
        self.emit_set_style(events);
    }

    // -----------------------------------------------------------------------
    // OSC (stage 2 will fill this in)
    // -----------------------------------------------------------------------

    fn feed_osc_string(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
        match b {
            // BEL: terminator (xterm convention).
            0x07 => {
                self.dispatch_osc(events);
                self.recover_to_ground();
            }
            // ESC: begin ST-terminator check (ESC \).
            0x1B => self.state = State::OscEscPending,
            // 0x20..=0x7F: body bytes. The per-state byte cap
            // (enforced at the top of `feed_byte`) bounds how many
            // bytes we'll accept before force-recovering.
            0x20..=0x7F => self.osc_body.push(b),
            // Other C0/C1 controls: drop silently, stay in OSC.
            _ => {}
        }
    }

    fn feed_osc_esc_pending(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
        if b == b'\\' {
            self.dispatch_osc(events);
            self.recover_to_ground();
        } else {
            // Not ST: restart from Escape with this byte as the
            // first byte of a fresh sequence.
            self.osc_body.clear();
            self.ignore_byte_count = 0;
            self.state = State::Escape;
            self.feed_byte(b, events);
        }
    }

    fn feed_osc_ignore(&mut self, b: u8) {
        // Per-state byte cap is enforced at the top of `feed_byte`;
        // here we only watch for the structural terminator.
        match b {
            0x07 => self.recover_to_ground(),
            0x1B => self.state = State::OscEscPending,
            _ => {}
        }
    }

    fn dispatch_osc(&mut self, events: &mut Vec<AnsiEvent>) {
        let body = std::mem::take(&mut self.osc_body);
        // OSC body is `<num>;<text>`. Parse the numeric prefix; the
        // tail (after the first `;`) is the OSC payload.
        let (num_part, text_part) = match body.iter().position(|&b| b == b';') {
            Some(idx) => (&body[..idx], &body[idx + 1..]),
            None => (body.as_slice(), &b""[..]),
        };
        let num: Option<u32> = std::str::from_utf8(num_part)
            .ok()
            .and_then(|s| s.parse().ok());
        if matches!(num, Some(133)) && !self.alt_screen_active {
            match text_part.first().copied() {
                Some(b'A') => events.push(AnsiEvent::PromptStart),
                Some(b'B') => events.push(AnsiEvent::PromptEnd),
                Some(b'C') => events.push(AnsiEvent::CommandStart),
                Some(b'D') => events.push(AnsiEvent::OutputStart),
                _ => {}
            }
            return;
        }

        // Only OSC 0 (set icon name + window title), OSC 2 (set
        // window title), and the OSC 133 shell integration markers
        // above produce events. Other OSC numbers are parsed and
        // discarded per spec §sec:ansi-scope, with the critical
        // guarantee that state alignment is preserved.
        if matches!(num, Some(0 | 2)) && !self.alt_screen_active {
            let title = String::from_utf8_lossy(text_part).into_owned();
            events.push(AnsiEvent::SetTitle(title));
        }
    }
}

/// Parse a CSI 38/48 extended-color suffix into a `Color` plus
/// the number of *additional* params consumed (legacy form only;
/// the modern subparam form keeps everything inside `p.sub` so
/// `consumed_extra` is 0).
///
/// - Modern: `38:5:N` → sub = `[5, N]`, `38:2::R:G:B` → sub =
///   `[2, 0, R, G, B]` (the empty colorspace ID at index 1 is the
///   distinguishing feature of the canonical form).
/// - Legacy: `38;5;N` → next param `main=5`, then param `main=N`;
///   `38;2;R;G;B` → next params `main=2`, `R`, `G`, `B`.
///
/// Returns `None` if the suffix is malformed (truncated, unknown
/// kind byte). The caller silently drops the SGR token in that
/// case --- state stays aligned.
fn parse_extended_color(p: &CsiParam, rest: &[CsiParam]) -> Option<(Color, usize)> {
    if !p.sub.is_empty() {
        let kind = p.sub[0];
        return match kind {
            5 => {
                let n = *p.sub.get(1)?;
                Some((Color::Indexed(u8::try_from(n).unwrap_or(255)), 0))
            }
            2 => {
                // 5-element form has the colorspace ID at index 1
                // (typically 0); 4-element form omits it.
                let (r, g, b) = if p.sub.len() >= 5 {
                    (p.sub[2], p.sub[3], p.sub[4])
                } else if p.sub.len() >= 4 {
                    (p.sub[1], p.sub[2], p.sub[3])
                } else {
                    return None;
                };
                Some((
                    Color::Rgb(
                        u8::try_from(r).unwrap_or(255),
                        u8::try_from(g).unwrap_or(255),
                        u8::try_from(b).unwrap_or(255),
                    ),
                    0,
                ))
            }
            _ => None,
        };
    }
    let kind = rest.first()?.main;
    match kind {
        5 => {
            let n = rest.get(1)?.main;
            Some((Color::Indexed(u8::try_from(n).unwrap_or(255)), 2))
        }
        2 => {
            let r = rest.get(1)?.main;
            let g = rest.get(2)?.main;
            let b = rest.get(3)?.main;
            Some((
                Color::Rgb(
                    u8::try_from(r).unwrap_or(255),
                    u8::try_from(g).unwrap_or(255),
                    u8::try_from(b).unwrap_or(255),
                ),
                4,
            ))
        }
        _ => None,
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    /// Helper: collect every text-payload byte across `Text` events
    /// in `evs`. Used to assert acceptance bullet 3 ("rope stays
    /// clean text") --- the rope-bound stream must never contain
    /// escape bytes.
    fn collect_text(evs: &[AnsiEvent]) -> String {
        let mut out = String::new();
        for ev in evs {
            if let AnsiEvent::Text(s) = ev {
                out.push_str(s);
            }
        }
        out
    }

    /// Helper: locate the indices of all `SetStyle` events in `evs`,
    /// in arrival order, paired with their `Style` payloads.
    fn collect_styles(evs: &[AnsiEvent]) -> Vec<Style> {
        evs.iter()
            .filter_map(|e| {
                if let AnsiEvent::SetStyle(s) = e {
                    Some(*s)
                } else {
                    None
                }
            })
            .collect()
    }

    // -----------------------------------------------------------------
    // Acceptance bullet 1: ls/git diff/grep --color render correctly
    // -----------------------------------------------------------------

    /// `ls --color=always` emits SGR with bold + indexed color
    /// (e.g. directory = bold blue, executable = bold green).
    /// Source pattern captured 2026-05-03 from
    /// `/bin/ls --color=always /tmp` on a stock GNU ls runner.
    #[test]
    fn m6_3_sgr_ls_color_render() {
        let mut p = AnsiParser::new();
        // \x1b[01;34m = bold + blue fg; "dir" text; reset; newline.
        let evs = p.feed(b"\x1b[01;34mdir\x1b[0m\n\x1b[01;31mfile\x1b[0m\n");
        let styles = collect_styles(&evs);
        // Expected order: bold+blue, reset, bold+red, reset.
        assert_eq!(styles.len(), 4, "expected 4 SetStyle events, got {evs:?}");
        assert!(
            styles[0].bold && styles[0].fg == Color::Indexed(4),
            "first style should be bold + blue, got {:?}",
            styles[0]
        );
        assert_eq!(styles[1], Style::default(), "second style should be reset");
        assert!(
            styles[2].bold && styles[2].fg == Color::Indexed(1),
            "third style should be bold + red, got {:?}",
            styles[2]
        );
        assert_eq!(styles[3], Style::default(), "fourth style should be reset");
        assert_eq!(collect_text(&evs), "dir\nfile\n");
    }

    /// `git diff --color` emits red for deletions and green for
    /// additions, with surrounding context in default style.
    #[test]
    fn m6_3_sgr_git_diff_render() {
        let mut p = AnsiParser::new();
        let input = b"\
            \x1b[31m-old line\x1b[0m\n\
            \x1b[32m+new line\x1b[0m\n\
            context\n";
        let evs = p.feed(input);
        let styles = collect_styles(&evs);
        assert!(
            styles.iter().any(|s| s.fg == Color::Indexed(1)),
            "must observe red SGR for deletion line; got {styles:?}"
        );
        assert!(
            styles.iter().any(|s| s.fg == Color::Indexed(2)),
            "must observe green SGR for addition line; got {styles:?}"
        );
        let text = collect_text(&evs);
        assert!(text.contains("-old line"));
        assert!(text.contains("+new line"));
        assert!(text.contains("context"));
    }

    /// `grep --color=always` highlights matches with bold reverse-or-red.
    #[test]
    fn m6_3_sgr_grep_color_render() {
        let mut p = AnsiParser::new();
        // \x1b[01;31m\x1b[Khello\x1b[m = bold red + erase-to-eol +
        // text + SGR reset (the final `\x1b[m` is empty-param SGR).
        let evs = p.feed(b"prefix \x1b[01;31m\x1b[Khello\x1b[m suffix");
        let styles = collect_styles(&evs);
        assert!(
            styles.iter().any(|s| s.bold && s.fg == Color::Indexed(1)),
            "grep match should be bold red; got {styles:?}"
        );
        // The \x1b[K (erase-to-eol) must surface as its own event.
        assert!(
            evs.iter().any(|e| matches!(e, AnsiEvent::EraseToEol)),
            "must observe EraseToEol from \\x1b[K; got {evs:?}"
        );
        let text = collect_text(&evs);
        assert!(text.contains("prefix "));
        assert!(text.contains("hello"));
        assert!(text.contains(" suffix"));
    }

    /// 24-bit truecolor: `\x1b[38;2;R;G;B m`.
    #[test]
    fn m6_3_truecolor_24bit() {
        let mut p = AnsiParser::new();
        let evs = p.feed(b"\x1b[38;2;255;128;0morange");
        let styles = collect_styles(&evs);
        assert_eq!(
            styles.first().map(|s| s.fg),
            Some(Color::Rgb(255, 128, 0)),
            "expected truecolor RGB(255,128,0); got {styles:?}"
        );
        assert_eq!(collect_text(&evs), "orange");
    }

    /// 256-color indexed: `\x1b[38;5;N m`.
    #[test]
    fn m6_3_color_256_indexed() {
        let mut p = AnsiParser::new();
        let evs = p.feed(b"\x1b[38;5;124m"); // 256-color shade of red.
        let styles = collect_styles(&evs);
        assert_eq!(
            styles.first().map(|s| s.fg),
            Some(Color::Indexed(124)),
            "expected 256-color index 124; got {styles:?}"
        );
    }

    /// Underline variants via subparam: bare 4 (Single), 4:2
    /// (Double), 4:3 (Curly), 4:4 (Dotted), 4:5 (Dashed), 24 (off).
    #[test]
    fn m6_3_underline_styles() {
        let cases: &[(&[u8], UnderlineStyle)] = &[
            (b"\x1b[4m", UnderlineStyle::Single),
            (b"\x1b[4:2m", UnderlineStyle::Double),
            (b"\x1b[4:3m", UnderlineStyle::Curly),
            (b"\x1b[4:4m", UnderlineStyle::Dotted),
            (b"\x1b[4:5m", UnderlineStyle::Dashed),
        ];
        for (input, expected) in cases {
            let mut p = AnsiParser::new();
            let evs = p.feed(input);
            let style = collect_styles(&evs);
            assert_eq!(
                style.first().map(|s| s.underline),
                Some(*expected),
                "input {input:?} should produce underline {expected:?}; got {evs:?}"
            );
        }
        // 24 must reset underline to None.
        let mut p = AnsiParser::new();
        let evs = p.feed(b"\x1b[4:3m\x1b[24m");
        let styles = collect_styles(&evs);
        assert_eq!(
            styles.last().map(|s| s.underline),
            Some(UnderlineStyle::None)
        );
    }

    /// Underline color via SGR 58 (both colon-subparam and semicolon
    /// grammars, mirroring 38/48), reset via SGR 59 (T M4.6).
    #[test]
    fn m4_6_underline_color() {
        let cases: &[(&[u8], Color)] = &[
            (b"\x1b[58:5:1m", Color::Indexed(1)),
            (b"\x1b[58;5;124m", Color::Indexed(124)),
            (b"\x1b[58:2::255:0:0m", Color::Rgb(255, 0, 0)),
        ];
        for (input, expected) in cases {
            let mut p = AnsiParser::new();
            let evs = p.feed(input);
            let styles = collect_styles(&evs);
            assert_eq!(
                styles.first().map(|s| s.underline_color),
                Some(*expected),
                "input {input:?} should produce underline color {expected:?}; got {evs:?}"
            );
        }
        // 59 resets to follow-text-color.
        let mut p = AnsiParser::new();
        let evs = p.feed(b"\x1b[58:5:1m\x1b[59m");
        let styles = collect_styles(&evs);
        assert_eq!(
            styles.last().map(|s| s.underline_color),
            Some(Color::Default)
        );
    }

    // -----------------------------------------------------------------
    // Intra-line motion and erase
    // -----------------------------------------------------------------

    /// `progress\rdone\x1b[K` --- a typical shell progress-bar
    /// pattern: print "progress", carriage-return to overwrite,
    /// print "done", erase rest of line.
    #[test]
    fn m6_3_carriage_return_and_erase_to_eol() {
        let mut p = AnsiParser::new();
        let evs = p.feed(b"progress\rdone\x1b[K");
        // Expected event sequence:
        //   Text("progress"), CarriageReturn, Text("done"), EraseToEol
        let mut iter = evs.into_iter();
        match iter.next() {
            Some(AnsiEvent::Text(s)) => assert_eq!(s, "progress"),
            other => panic!("expected Text(\"progress\"), got {other:?}"),
        }
        assert_eq!(iter.next(), Some(AnsiEvent::CarriageReturn));
        match iter.next() {
            Some(AnsiEvent::Text(s)) => assert_eq!(s, "done"),
            other => panic!("expected Text(\"done\"), got {other:?}"),
        }
        assert_eq!(iter.next(), Some(AnsiEvent::EraseToEol));
        assert!(iter.next().is_none(), "no extra events expected");
    }

    // -----------------------------------------------------------------
    // Acceptance bullet 2 + 5: malformed input recovery
    // -----------------------------------------------------------------

    /// An unknown CSI is consumed silently; subsequent legitimate
    /// SGR still parses correctly. Validates ESC-anywhere recovery
    /// without state corruption.
    #[test]
    fn m6_3_unknown_csi_recovers() {
        let mut p = AnsiParser::new();
        let evs = p.feed(b"\x1b[?123x\x1b[31mhi\x1b[0m");
        let text = collect_text(&evs);
        let styles = collect_styles(&evs);
        assert_eq!(text, "hi", "unknown CSI must not leak bytes into text");
        assert!(
            styles.iter().any(|s| s.fg == Color::Indexed(1)),
            "post-unknown SGR red must be observed; got {styles:?}"
        );
    }

    /// A CSI split across two `feed` calls produces the same events
    /// as the single-call version. M6.2 chunk-boundary safety.
    #[test]
    fn m6_3_truncated_csi_resumes_across_feed_calls() {
        let mut p = AnsiParser::new();
        let mut evs = p.feed(b"\x1b[3");
        evs.extend(p.feed(b"1mhi\x1b[0m"));
        let mut p2 = AnsiParser::new();
        let evs_joined = p2.feed(b"\x1b[31mhi\x1b[0m");
        assert_eq!(
            evs, evs_joined,
            "split-feed must produce identical events to single-feed"
        );
    }

    /// An OSC with no terminator beyond the per-state byte cap
    /// triggers force-recovery; subsequent input parses normally.
    /// Validates the spec's per-state 1 KiB cap.
    #[test]
    fn m6_3_osc_no_terminator_recovers_at_byte_cap() {
        let mut p = AnsiParser::new();
        // Open OSC, emit 5 KiB of body, no terminator.
        let mut input = Vec::new();
        input.extend_from_slice(b"\x1b]0;");
        input.extend(std::iter::repeat_n(b'A', 5 * 1024));
        // Then a fresh SGR sequence after the malformed OSC.
        input.extend_from_slice(b"\x1b[31mhi");
        let evs = p.feed(&input);
        let styles = collect_styles(&evs);
        let text = collect_text(&evs);
        // The OSC produced no SetTitle event (malformed, discarded).
        assert!(
            !evs.iter().any(|e| matches!(e, AnsiEvent::SetTitle(_))),
            "malformed OSC must not produce SetTitle; got {evs:?}"
        );
        // Post-recovery: the SGR red parses; "hi" arrives as Text.
        assert!(
            styles.iter().any(|s| s.fg == Color::Indexed(1)),
            "post-recovery SGR red must be observed; got {styles:?}"
        );
        assert!(text.ends_with("hi"));
    }

    /// OSC 0 (set window title) with BEL terminator → `SetTitle`.
    #[test]
    fn m6_3_osc_title_set() {
        let mut p = AnsiParser::new();
        let evs = p.feed(b"\x1b]0;mytitle\x07hello");
        let titles: Vec<&str> = evs
            .iter()
            .filter_map(|e| {
                if let AnsiEvent::SetTitle(t) = e {
                    Some(t.as_str())
                } else {
                    None
                }
            })
            .collect();
        assert_eq!(titles, vec!["mytitle"]);
        assert_eq!(collect_text(&evs), "hello");
    }

    #[test]
    fn m6_3_osc_133_prompt_markers_are_structured_events() {
        let mut p = AnsiParser::new();
        let evs = p.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07\x1b]133;C\x07\x1b]133;D;0\x07");
        let kinds: Vec<&str> = evs
            .iter()
            .map(|ev| match ev {
                AnsiEvent::PromptStart => "prompt_start",
                AnsiEvent::Text(s) if s == "$ " => "text",
                AnsiEvent::PromptEnd => "prompt_end",
                AnsiEvent::CommandStart => "command_start",
                AnsiEvent::OutputStart => "output_start",
                other => panic!("unexpected event: {other:?}"),
            })
            .collect();
        assert_eq!(
            kinds,
            vec![
                "prompt_start",
                "text",
                "prompt_end",
                "command_start",
                "output_start"
            ]
        );
    }

    // -----------------------------------------------------------------
    // Acceptance bullet 4: alternate-screen suppression
    // -----------------------------------------------------------------

    #[test]
    fn m6_3_alt_screen_suppresses_output() {
        let mut p = AnsiParser::new();
        let mut input = Vec::new();
        input.extend_from_slice(b"before");
        // Enter alt-screen.
        input.extend_from_slice(b"\x1b[?1049h");
        // Garbage TUI output that must NOT surface as events.
        input.extend_from_slice(b"\x1b[31mblah\x1b[2J\x1b[Hsome stuff");
        // Exit alt-screen.
        input.extend_from_slice(b"\x1b[?1049l");
        // Resume normal output.
        input.extend_from_slice(b"after");
        let evs = p.feed(&input);
        let text = collect_text(&evs);
        assert!(
            text.contains("before") && text.contains("after"),
            "outside-alt-screen text must surface; got {text:?}"
        );
        assert!(
            !text.contains("blah") && !text.contains("some stuff"),
            "alt-screen text must be suppressed; got {text:?}"
        );
        assert!(
            evs.iter()
                .any(|e| matches!(e, AnsiEvent::AlternateScreenEnter)),
            "AlternateScreenEnter event must be emitted"
        );
        assert!(
            evs.iter()
                .any(|e| matches!(e, AnsiEvent::AlternateScreenExit)),
            "AlternateScreenExit event must be emitted"
        );
    }

    // -----------------------------------------------------------------
    // Bracketed paste markers
    // -----------------------------------------------------------------

    #[test]
    fn m6_3_bracketed_paste_markers_emitted() {
        let mut p = AnsiParser::new();
        let evs = p.feed(b"\x1b[200~hello\x1b[201~");
        let mut iter = evs.into_iter();
        assert_eq!(iter.next(), Some(AnsiEvent::BracketedPasteBegin));
        match iter.next() {
            Some(AnsiEvent::Text(s)) => assert_eq!(s, "hello"),
            other => panic!("expected Text(\"hello\"), got {other:?}"),
        }
        assert_eq!(iter.next(), Some(AnsiEvent::BracketedPasteEnd));
        assert!(iter.next().is_none());
    }

    // -----------------------------------------------------------------
    // Acceptance bullet 3: rope stream stays clean text
    // -----------------------------------------------------------------

    /// Across a corpus of valid + malformed sequences, no `Text`
    /// event ever contains an escape byte.
    #[test]
    fn m6_3_text_stream_carries_no_escape_bytes() {
        let corpus: &[&[u8]] = &[
            b"\x1b[31mhello\x1b[0m world",
            b"\x1b[?123x\x1b[m unknown CSI ignored",
            b"\x1b]0;title\x07ordinary",
            b"\x1b[200~paste\x1b[201~",
            b"a\x1b[A\x1b[B\x1b[Cb cursor motions ignored",
            b"trunc\x1b[3", // no terminator; test asserts safety
        ];
        for input in corpus {
            let mut p = AnsiParser::new();
            let evs = p.feed(input);
            for ev in &evs {
                if let AnsiEvent::Text(s) = ev {
                    assert!(
                        !s.bytes().any(|b| b == 0x1B),
                        "Text event leaked an ESC byte for input {input:?}: {ev:?}"
                    );
                }
            }
        }
    }

    /// Pathological inputs all return promptly with the parser in
    /// a sane state (subsequent input parses normally).
    #[test]
    fn m6_3_parser_terminates_on_malformed_input() {
        let pathological: &[Vec<u8>] = &[
            // Bare ESC repeated.
            std::iter::repeat_n(0x1B, 1024).collect(),
            // Half-CSI followed by ESC repeatedly.
            (0..512).flat_map(|_| b"\x1b[3".iter().copied()).collect(),
            // OSC with no terminator and no ESC for 5 KiB.
            {
                let mut v = b"\x1b]0;".to_vec();
                v.extend(std::iter::repeat_n(b'X', 5 * 1024));
                v
            },
        ];
        for input in pathological {
            let mut p = AnsiParser::new();
            let _ = p.feed(input);
            // Post-pathology, force the parser back to Ground via
            // an explicit ST sequence (ESC \), since the pathology
            // may have left it mid-escape (e.g., a trailing bare
            // ESC waits for its final byte). Then assert ordinary
            // text comes through cleanly --- the goal of this test
            // is that the parser doesn't panic, hang, or corrupt
            // future input, not that any one byte aligns with a
            // particular state.
            let evs = p.feed(b"\x1b\\recovered");
            let text = collect_text(&evs);
            assert!(
                text.contains("recovered"),
                "parser failed to recover after pathological input; got {evs:?}"
            );
        }
    }

    // -----------------------------------------------------------------
    // Strikethrough advances state without corrupting running style
    // -----------------------------------------------------------------

    // ---- UTF-8 cross-feed handling (post-M6.10 audit fix) -----------------

    /// Multi-byte UTF-8 in a single feed decodes to the correct
    /// scalar, not U+FFFD. Catches the M6.3-stage-1 shortcut that
    /// emitted U+FFFD for every non-ASCII byte regardless of whether
    /// it was actually malformed.
    #[test]
    fn ansi_utf8_multibyte_in_single_feed_decodes_correctly() {
        let mut p = AnsiParser::new();
        // "café" --- the é is U+00E9, encoded as 0xC3 0xA9 in UTF-8.
        let evs = p.feed("café".as_bytes());
        assert_eq!(collect_text(&evs), "café");
    }

    /// Three-byte UTF-8 (e.g., CJK) decodes correctly. Same gate as
    /// above but exercises the 1110xxxx start byte + two
    /// continuations path.
    #[test]
    fn ansi_utf8_three_byte_sequence_decodes_correctly() {
        let mut p = AnsiParser::new();
        let evs = p.feed("日本語".as_bytes());
        assert_eq!(collect_text(&evs), "日本語");
    }

    /// Four-byte UTF-8 (e.g., emoji past U+FFFF) decodes correctly.
    /// Exercises the 11110xxx start byte + three continuations path.
    #[test]
    fn ansi_utf8_four_byte_sequence_decodes_correctly() {
        let mut p = AnsiParser::new();
        // U+1F600 = 😀 = 0xF0 0x9F 0x98 0x80
        let evs = p.feed("😀".as_bytes());
        assert_eq!(collect_text(&evs), "😀");
    }

    /// A multi-byte sequence split across two feeds completes
    /// correctly. This is the load-bearing cross-feed behavior:
    /// pre-fix, the first feed would emit U+FFFD for the start byte
    /// and the second feed would emit U+FFFDs for the continuation
    /// bytes; post-fix, the partial bytes wait in `utf8_buf` and
    /// flush as one correct char when the trailing byte arrives.
    #[test]
    fn ansi_utf8_split_across_two_feeds() {
        let mut p = AnsiParser::new();
        let bytes = "café".as_bytes();
        // Split between the start byte (0xC3) and continuation
        // (0xA9) of the é.
        let split = bytes.len() - 1;
        let evs1 = p.feed(&bytes[..split]);
        let evs2 = p.feed(&bytes[split..]);
        let combined: String = collect_text(&evs1) + &collect_text(&evs2);
        assert_eq!(combined, "café");
    }

    /// A four-byte sequence split across three feeds completes
    /// correctly. Exercises the cross-feed buffer holding 2 bytes
    /// across one feed and 1 byte across the next.
    #[test]
    fn ansi_utf8_four_byte_split_across_three_feeds() {
        let mut p = AnsiParser::new();
        // U+1F600 = 0xF0 0x9F 0x98 0x80
        let evs1 = p.feed(&[0xF0]);
        let evs2 = p.feed(&[0x9F, 0x98]);
        let evs3 = p.feed(&[0x80]);
        let combined = collect_text(&evs1) + &collect_text(&evs2) + &collect_text(&evs3);
        assert_eq!(combined, "😀");
    }

    /// Mixed ASCII and multi-byte text decodes correctly.
    #[test]
    fn ansi_utf8_mixed_ascii_and_multibyte() {
        let mut p = AnsiParser::new();
        let evs = p.feed("hello, café 日本 😀!".as_bytes());
        assert_eq!(collect_text(&evs), "hello, café 日本 😀!");
    }

    /// A lone continuation byte (no preceding start) emits U+FFFD
    /// for that byte only; subsequent ASCII still decodes to ASCII.
    #[test]
    fn ansi_utf8_lone_continuation_byte_emits_replacement() {
        let mut p = AnsiParser::new();
        // 0x80 alone is a continuation byte with no start.
        // ASCII follows; it should decode normally.
        let evs = p.feed(&[b'a', 0x80, b'b']);
        assert_eq!(collect_text(&evs), "a\u{FFFD}b");
    }

    /// An invalid start byte (0xFF) emits U+FFFD; subsequent ASCII
    /// decodes normally.
    #[test]
    fn ansi_utf8_invalid_start_byte_emits_replacement() {
        let mut p = AnsiParser::new();
        let evs = p.feed(&[b'a', 0xFF, b'b']);
        assert_eq!(collect_text(&evs), "a\u{FFFD}b");
    }

    /// A start byte expecting two continuations followed by ASCII
    /// (one continuation, then ASCII) emits U+FFFD for the
    /// truncated sequence and decodes the ASCII normally.
    #[test]
    fn ansi_utf8_truncated_sequence_then_ascii() {
        let mut p = AnsiParser::new();
        // 0xE6 (start of 3-byte) + 0x97 (continuation) + 'X' (ASCII,
        // not a continuation). The sequence is malformed: 0xE6 0x97
        // followed by ASCII.
        let evs = p.feed(&[0xE6, 0x97, b'X']);
        // The 0xE6 0x97 prefix is malformed; std::str::from_utf8
        // reports error_len() = 2, so we emit one U+FFFD covering
        // both bytes, then ASCII.
        let text = collect_text(&evs);
        assert!(
            text.contains('\u{FFFD}') && text.ends_with('X'),
            "expected U+FFFD then 'X', got {text:?}"
        );
    }

    /// A multi-byte sequence interrupted by a control byte (CSI
    /// start) flushes pending bytes as U+FFFD, then resumes
    /// processing the control byte normally.
    #[test]
    fn ansi_utf8_partial_interrupted_by_csi_emits_replacement() {
        let mut p = AnsiParser::new();
        // 0xC3 (start of 2-byte) then ESC [ 31 m (red SGR).
        // The 0xC3 is interrupted by the ESC; should emit U+FFFD
        // for the partial, then process the SGR.
        let evs = p.feed(&[b'a', 0xC3, 0x1B, b'[', b'3', b'1', b'm', b'b']);
        let text = collect_text(&evs);
        assert!(
            text.starts_with("a\u{FFFD}") && text.ends_with('b'),
            "expected a + U+FFFD + b, got {text:?}"
        );
        // The SGR should still have produced a SetStyle event.
        let styles = collect_styles(&evs);
        assert_eq!(styles.len(), 1, "expected one SetStyle from the SGR");
        assert_eq!(styles[0].fg, Color::Indexed(1));
    }

    /// Pending UTF-8 prefix that exceeds the 8-byte defensive cap
    /// (pathological producer that never finishes a sequence)
    /// flushes as U+FFFD and recovers. Ensures the buffer can't
    /// grow unbounded.
    #[test]
    fn ansi_utf8_pathological_pending_caps_at_8_bytes() {
        let mut p = AnsiParser::new();
        // Feed 8 start bytes in a row. Each is a "start of 2-byte"
        // marker; the next one arriving where a continuation is
        // expected is malformed. The first one accumulates; each
        // subsequent one emits U+FFFD for the malformed prefix.
        // After 8 bytes accumulated without completing, the cap
        // forces a flush.
        let evs = p.feed(&[0xC3; 9]);
        let text = collect_text(&evs);
        // Every byte should have been replaced; no panic, no
        // unbounded growth.
        assert!(
            text.chars().all(|c| c == '\u{FFFD}'),
            "all 9 bytes should be replaced; got {text:?}"
        );
    }

    /// `reset()` clears any pending UTF-8 prefix.
    #[test]
    fn ansi_utf8_reset_clears_pending_buffer() {
        let mut p = AnsiParser::new();
        let _ = p.feed(&[0xC3]); // partial é prefix pending
        p.reset();
        // Subsequent valid input must decode cleanly --- the stale
        // prefix should not contaminate it.
        let evs = p.feed("hello".as_bytes());
        assert_eq!(collect_text(&evs), "hello");
    }

    /// SGR 9 (strikethrough) is a no-op for the running style ---
    /// the cell `Style` has no strikethrough field, so the running
    /// style must be *unchanged* after SGR 9. A future regression
    /// that maps SGR 9 to another field (e.g., flips bold by
    /// accident) is caught here.
    #[test]
    fn m6_3_strikethrough_sgr_9_does_not_corrupt_running_style() {
        let mut p = AnsiParser::new();
        // Set bold + italic + red, then SGR 9.
        let _ = p.feed(b"\x1b[1;3;31m");
        let evs_after_9 = p.feed(b"\x1b[9m");
        let after = collect_styles(&evs_after_9);
        assert_eq!(after.len(), 1, "SGR 9 should still emit a SetStyle");
        let s = after[0];
        assert!(
            s.bold && s.italic && s.fg == Color::Indexed(1),
            "SGR 9 must leave bold + italic + red intact; got {s:?}"
        );
        // Subsequent text uses the unchanged style.
        let evs_text = p.feed(b"hi");
        assert_eq!(collect_text(&evs_text), "hi");
    }
}
