//! Synchronized-output + clear-decision layer above [`LineDiff`].
//!
//! Pure port of ink's interactive frame-emit path
//! (`ink/src/ink.tsx` `renderInteractiveFrame` ~lines 1037-1102,
//! `shouldClearTerminalForFrame` ~lines 118-152, and the debug branch
//! ~lines 550-560) plus the DECSET-2026 synchronized-update wrap
//! (`ink/src/write-synchronized.ts`).
//!
//! Like [`LineDiff`](crate::LineDiff), this layer performs **no terminal IO and
//! no environment reads**: every ambient input ink derives from the stream or
//! `process.env` (`isTTY`, `viewportRows`, `interactive`, CI detection) is taken
//! as an explicit parameter, and the layer returns the bytes that ink would have
//! written rather than writing them.
//!
//! # Cursor precondition (inherited from M2-E)
//!
//! [`LineDiff::diff`](crate::LineDiff::diff) bytes assume the terminal cursor
//! sits at the bottom of the previously rendered frame. This layer is that
//! consumer: it only ever routes incremental diffs through that path after a
//! full frame (bootstrap, clear, or static erase) has re-homed the cursor to the
//! bottom of what is on screen, and it keeps [`LineDiff`]'s baseline in lockstep
//! with the emitted bytes (see [`FrameWriter::write_frame`]). Callers driving a
//! [`FrameWriter`] inherit the same precondition: the bytes returned must be
//! written verbatim, in order, with the cursor left where each write leaves it.

use crate::LineDiff;
use crate::escapes::{HIDE_CURSOR, SHOW_CURSOR, cursor_down, cursor_to, cursor_up};

/// A terminal cursor position, mirroring ink's `CursorPosition`
/// (`ink/src/cursor-helpers.ts:3-6`, `{x, y}`). `x`/`y` are 0-based cell
/// coordinates within the rendered frame; the napi boundary maps its `u32`
/// `CursorPos` onto this `usize` form for the escape arithmetic.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CursorPos {
    /// 0-based column. `buildCursorSuffix` emits `cursorTo(x)` (1-based on the
    /// wire — `escapes::cursor_to` adds the `+1`).
    pub x: usize,
    /// 0-based row within the rendered frame's visible lines.
    pub y: usize,
}

/// `cursorPositionChanged(a, b)` (`ink/src/cursor-helpers.ts:16-19`):
/// `a?.x !== b?.x || a?.y !== b?.y`. For `Option<CursorPos>` this is exactly
/// structural inequality, so it collapses to `a != b`. Spelled out as a named
/// helper to mirror the oracle and document the `None`-vs-`Some` semantics
/// (a present cursor differs from an absent one).
#[must_use]
fn cursor_position_changed(a: Option<CursorPos>, b: Option<CursorPos>) -> bool {
    a != b
}

/// `buildCursorSuffix(visibleLineCount, cursorPosition)`
/// (`ink/src/cursor-helpers.ts:25-39`). Moves the cursor from the bottom of the
/// output (col 0, line `visible_line_count`) up to the target row and across to
/// the target column, then shows it. `None` -> `""` (no cursor).
///
/// `move_up = visible_line_count - cursor.y`; a `cursorUp(move_up)` is emitted
/// only when `move_up > 0` (oracle: `moveUp > 0 ? cursorUp(moveUp) : ''`), then
/// always `cursorTo(x)` + `SHOW_CURSOR`.
#[must_use]
fn build_cursor_suffix(visible_line_count: usize, cursor: Option<CursorPos>) -> String {
    let Some(cursor) = cursor else {
        return String::new();
    };
    let mut out = String::new();
    // `visible_line_count - cursor.y` is non-negative for a cursor inside the
    // frame; saturating_sub mirrors the oracle's `moveUp > 0` guard (a `move_up`
    // that would be negative yields 0 -> no `cursorUp`, matching `moveUp > 0`).
    let move_up = visible_line_count.saturating_sub(cursor.y);
    if move_up > 0 {
        out.push_str(&cursor_up(move_up));
    }
    out.push_str(&cursor_to(cursor.x));
    out.push_str(SHOW_CURSOR);
    out
}

/// `buildReturnToBottom(previousLineCount, previousCursorPosition)`
/// (`ink/src/cursor-helpers.ts:45-59`): from a previously shown cursor, walk
/// back DOWN to the bottom of the output then to col 0. `None` -> `""`.
///
/// `down = previousLineCount - 1 - previous.y`; `cursorDown(down)` only when
/// `down > 0`, then always `cursorTo(0)`.
#[must_use]
fn build_return_to_bottom(previous_line_count: usize, previous: Option<CursorPos>) -> String {
    let Some(previous) = previous else {
        return String::new();
    };
    let mut out = String::new();
    // previousLineCount includes the trailing-newline empty slot, so the bottom
    // visible row index is previousLineCount - 1. saturating_sub mirrors the
    // oracle's `down > 0` guard.
    let down = previous_line_count
        .saturating_sub(1)
        .saturating_sub(previous.y);
    if down > 0 {
        out.push_str(&cursor_down(down));
    }
    out.push_str(&cursor_to(0));
    out
}

/// `buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previous)`
/// (`ink/src/cursor-helpers.ts:90-103`): `""` unless the cursor was shown, else
/// `HIDE_CURSOR + buildReturnToBottom(...)`. Hides the cursor and re-homes it
/// before an erase/rewrite.
#[must_use]
fn build_return_to_bottom_prefix(
    cursor_was_shown: bool,
    previous_line_count: usize,
    previous: Option<CursorPos>,
) -> String {
    if !cursor_was_shown {
        return String::new();
    }
    let mut out = String::from(HIDE_CURSOR);
    out.push_str(&build_return_to_bottom(previous_line_count, previous));
    out
}

/// `buildCursorOnlySequence(input)` (`ink/src/cursor-helpers.ts:73-84`): the
/// bytes for an output-unchanged, cursor-moved frame —
/// `hidePrefix + returnToBottom + cursorSuffix`. `hidePrefix` is `HIDE_CURSOR`
/// iff the cursor was previously shown; `returnToBottom` re-homes from the old
/// cursor; `cursorSuffix` repositions/shows the new cursor (or `""` when the new
/// cursor is `None`, i.e. a pure hide).
#[must_use]
fn build_cursor_only_sequence(
    cursor_was_shown: bool,
    previous_line_count: usize,
    previous: Option<CursorPos>,
    visible_line_count: usize,
    cursor: Option<CursorPos>,
) -> String {
    let mut out = String::new();
    if cursor_was_shown {
        out.push_str(HIDE_CURSOR);
    }
    out.push_str(&build_return_to_bottom(previous_line_count, previous));
    out.push_str(&build_cursor_suffix(visible_line_count, cursor));
    out
}

/// `visibleLineCount(lines, str)` (`ink/src/log-update.ts:28-29`): the number of
/// visible lines, ignoring the trailing empty element `split('\n')` yields when
/// `str` ends with `'\n'`.
#[must_use]
fn visible_line_count(line_count: usize, str_ends_with_newline: bool) -> usize {
    if str_ends_with_newline {
        line_count.saturating_sub(1)
    } else {
        line_count
    }
}

/// Visible line count of a frame string `s`, applying the same
/// trailing-newline rule [`visible_line_count`] does. Used by the full-repaint
/// branches of [`FrameWriter::write_frame`] to derive their changed-line
/// telemetry from the string they emit (every visible line is rewritten).
/// Telemetry only — never feeds the emitted bytes. `u32` to match the
/// `last_changed_lines` field; line counts never approach `u32::MAX`.
#[must_use]
fn visible_lines_of(s: &str) -> u32 {
    visible_line_count(s.split('\n').count(), s.ends_with('\n')) as u32
}

/// Begin Synchronized Update (DECSET 2026): `ESC [ ? 2026 h`.
///
/// Verbatim from `ink/src/write-synchronized.ts` line 4
/// (`export const bsu = '[?2026h'`).
#[allow(non_upper_case_globals)]
pub const bsu: &str = "\u{001B}[?2026h";

/// End Synchronized Update (DECRST 2026): `ESC [ ? 2026 l`.
///
/// Verbatim from `ink/src/write-synchronized.ts` line 5
/// (`export const esu = '[?2026l'`).
#[allow(non_upper_case_globals)]
pub const esu: &str = "\u{001B}[?2026l";

/// `ansiEscapes.clearTerminal` for the non-`isOldWindows` (POSIX) branch:
/// erase screen, erase scrollback, home cursor.
///
/// Provenance: oracle capture of `ansi-escapes` `clearTerminal` inside the ink
/// repo -> `"[2J[3J[H"`. The `isOldWindows()` branch
/// (`eraseScreen + ESC 0f`) is unreachable on the target platforms.
const CLEAR_TERMINAL: &str = "\u{001B}[2J\u{001B}[3J\u{001B}[H";

/// Whether synchronized output should wrap a write.
///
/// Pure port of `shouldSynchronize` (`ink/src/write-synchronized.ts` lines
/// 7-16): `isTTY && (interactive ?? !isInCi)`. The `is_in_ci` detection that ink
/// performs via the `is-in-ci` package is hoisted to a parameter so this crate
/// stays free of environment reads; pass `interactive = Some(..)` to override it
/// (mirroring TS's `interactive ?? !isInCi`).
#[must_use]
pub fn should_synchronize(is_tty: bool, interactive: Option<bool>, is_in_ci: bool) -> bool {
    is_tty && interactive.unwrap_or(!is_in_ci)
}

/// Whether the next frame must perform a full terminal clear rather than an
/// incremental diff.
///
/// Pure port of `shouldClearTerminalForFrame`
/// (`ink/src/ink.tsx` lines 118-152). Non-TTY output never clears.
#[must_use]
pub fn should_clear_terminal_for_frame(
    is_tty: bool,
    viewport_rows: usize,
    prev_height: usize,
    next_height: usize,
    is_unmounting: bool,
) -> bool {
    if !is_tty {
        return false;
    }

    let had_previous_frame = prev_height > 0;
    let was_fullscreen = prev_height >= viewport_rows;
    let was_overflowing = prev_height > viewport_rows;
    let is_overflowing = next_height > viewport_rows;
    let is_leaving_fullscreen = was_fullscreen && next_height < viewport_rows;
    let should_clear_on_unmount = is_unmounting && was_fullscreen;

    was_overflowing
        || (is_overflowing && had_previous_frame)
        || is_leaving_fullscreen
        || should_clear_on_unmount
}

/// Per-frame inputs to [`FrameWriter::write_frame`].
///
/// Each field is an explicit parameter for what ink reads from the stream, the
/// render result, or `process.env`, so the writer performs no IO.
#[derive(Debug, Clone)]
pub struct FrameParams<'a> {
    /// `stdout.isTTY` — gates fullscreen detection, clear decisions, and sync.
    pub is_tty: bool,
    /// Terminal height in rows (`getWindowSize(stdout).rows`). Per-call, not
    /// constructor state: a viewport resize changes it between frames.
    pub viewport_rows: usize,
    /// The rendered main output (no trailing newline added yet).
    pub output: &'a str,
    /// The rendered output's height in rows (`outputHeight`).
    pub output_height: usize,
    /// New `<Static>` output for this frame, or `""` when there is none. A bare
    /// `"\n"` is treated as no static content (`ink.tsx:548`
    /// `staticOutput !== '\n'`). Real static output is accumulated into the
    /// writer's full-static buffer.
    pub static_output: &'a str,
    /// Whether this is the final teardown render (`this.isUnmounting`).
    pub is_unmounting: bool,
    /// `log.isCursorDirty()` — retained on the params for the napi plumbing and
    /// the historical steady-gate contract, but the cursor-RENDER gate now keys
    /// off [`cursor`](Self::cursor) vs the writer's `previous_cursor_position`
    /// (the oracle's `hasChanges` is `str !== previousOutput || cursorChanged`,
    /// `log-update.ts:44-53` — `cursorDirty` itself is NOT in that predicate; it
    /// only selects `activeCursor` upstream). Defaults `false`; on every
    /// zero-flicker golden path it is `false`, so it never opens a gate.
    pub cursor_dirty: bool,
    /// The ACTIVE cursor for this frame — ink's
    /// `activeCursor = cursorDirty ? cursorPosition : undefined`
    /// (`log-update.ts:43`), resolved by the caller (napi) so the writer only
    /// sees the already-gated value. `None` mirrors no active cursor. The writer
    /// composes the ink-faithful cursor escape bytes (suffix / cursor-only
    /// sequence / hide) from this against its `previous_cursor_position`. On
    /// EVERY zero-flicker golden path this is `None`, which makes every new
    /// cursor branch a provable no-op (`cursor_changed = false`, all cursor
    /// builders return `""`), so the golden bytes are byte-identical.
    pub cursor: Option<CursorPos>,
    /// `interactive` option (`interactive ?? !isInCi` for sync). `None` defers
    /// to `is_in_ci`.
    pub interactive: Option<bool>,
    /// CI detection result (ink's `is-in-ci`), hoisted out of this crate.
    pub is_in_ci: bool,
    /// `options.debug` — plain full-frame mode: no diff, clear, or sync wrap.
    pub debug: bool,
}

/// Stateful frame emitter owning a [`LineDiff`] plus the small amount of
/// cross-frame state ink keeps on its `Ink` instance.
///
/// Port of the `renderInteractiveFrame` decision tree. Returns the bytes ink
/// would have written; performs no IO.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FrameWriter {
    diff: LineDiff,
    /// Raw `output` of the last frame (`this.lastOutput`). Distinct from the
    /// [`LineDiff`] baseline, which tracks `output_to_render`: the steady-branch
    /// gate and the clear-branch write use this raw string, while diff/sync
    /// operate on `output_to_render`.
    last_output: String,
    /// `this.lastOutputHeight` — the previous frame's height, fed to the clear
    /// decision as `prev_height`.
    last_output_height: usize,
    /// `this.lastOutputToRender` — the newline-policy-padded string the last
    /// frame actually fed to the [`LineDiff`] (`output` when fullscreen, else
    /// `output + "\n"`; see [`write_frame`](Self::write_frame) step 2). This is
    /// the string the [`LineDiff`] baseline tracks, so the K3 primitives that
    /// re-pin or repaint the baseline (`sync_baseline`, `restore_last_output`)
    /// operate on THIS, not the raw `last_output`. ink records it as
    /// `this.lastOutputToRender` next to `this.lastOutput`
    /// (`ink.tsx:555-556`/`567-568`/`616-617`).
    last_output_to_render: String,
    /// `this.fullStaticOutput` — every `<Static>` chunk seen so far, replayed in
    /// the clear branch.
    full_static_output: String,
    /// `previousCursorPosition` (`log-update.ts:183`/`362`): the active cursor of
    /// the LAST frame, used both to gate (`cursorPositionChanged`) and to
    /// re-home the terminal cursor (`buildReturnToBottom`) before an erase or a
    /// cursor-only move. `None` until a frame sets an active cursor; reset to
    /// `None` by `clear`/`reset_diff_state`. Defaults `None`, so on every golden
    /// path the cursor gate/branches are inert.
    previous_cursor_position: Option<CursorPos>,
    /// `cursorWasShown` (`log-update.ts:184`/`363`): whether the last frame had an
    /// active (shown) cursor. Drives the hide-prefix in `buildReturnToBottomPrefix`
    /// / `buildCursorOnlySequence` and the `!activeCursor && cursorWasShown` hide
    /// in `sync`. Defaults `false`; reset to `false` by `clear`/`reset_diff_state`.
    cursor_was_shown: bool,
    /// Count of visible lines the LAST [`write_frame`](Self::write_frame)
    /// rewrote. Pure additive telemetry for downstream pacing (P5.3): it rides
    /// alongside the byte computation and is NEVER consulted while building the
    /// emitted transport bytes, so it perturbs no byte. A no-op frame (empty
    /// write) records 0; a full repaint (clear/bootstrap/debug/non-TTY full
    /// frame) records the count of visible lines in the rendered frame; an
    /// incremental diff records the differ's own changed-line count. Read via
    /// [`last_changed_lines`](Self::last_changed_lines).
    last_changed_lines: u32,
}

impl FrameWriter {
    /// Create an empty writer (nothing rendered yet).
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Emit the bytes for one interactive frame and advance internal state.
    ///
    /// Mirrors `renderInteractiveFrame` exactly:
    ///
    /// 1. **debug**: write `full_static_output + output` raw — no diff, clear, or
    ///    BSU/ESU ever (`ink.tsx` 550-560).
    /// 2. Compute `is_fullscreen = is_tty && output_height >= viewport_rows` and
    ///    `output_to_render = is_fullscreen ? output : output + "\n"`.
    /// 3. Accumulate `static_output` into `full_static_output`.
    /// 4. **clear branch** (`should_clear_terminal_for_frame`): write
    ///    `clearTerminal + full_static_output + output` (raw `output`, *not*
    ///    `output_to_render` — `ink.tsx:1066`), then `LineDiff::sync` the
    ///    baseline to `output_to_render`. Always writes, so wrap in BSU/ESU when
    ///    synchronizing.
    /// 5. **static branch** (`static_output != ""`): `LineDiff::clear` erase +
    ///    `static_output` + `LineDiff::diff(output_to_render)` (a fresh
    ///    bootstrap). Always writes, so wrap when synchronizing.
    /// 6. **steady branch** (`output != last_output || cursor_dirty`):
    ///    `LineDiff::diff(output_to_render)`, wrapped in BSU/ESU **only when the
    ///    diff is non-empty** (the `willRender` rule: a no-op diff — including
    ///    one whose gate was opened solely by `cursor_dirty` — emits nothing, so
    ///    no empty BSU/ESU pair).
    /// 7. else emit nothing.
    ///
    /// Every branch records `last_output = output` and
    /// `last_output_height = output_height`.
    pub fn write_frame(&mut self, params: &FrameParams<'_>) -> Vec<u8> {
        let FrameParams {
            is_tty,
            viewport_rows,
            output,
            output_height,
            static_output,
            is_unmounting,
            // `cursor_dirty` is intentionally unused in the cursor-render gate:
            // the oracle's `hasChanges` keys off `cursorChanged`, not
            // `cursorDirty` (see the field docs). Bound with `_` so it stays part
            // of the destructure without an unused-variable warning.
            cursor_dirty: _,
            cursor,
            interactive,
            is_in_ci,
            debug,
        } = *params;

        // `hasStaticOutput = staticOutput && staticOutput !== '\n'`
        // (`ink.tsx:548`): a bare newline is not real static content. ink's
        // caller passes `hasStaticOutput ? staticOutput : ''` into
        // `renderInteractiveFrame` (`ink.tsx:632-635`) and gates the debug
        // accumulation on the same flag (`ink.tsx:551`), so the guard is applied
        // here once for both the debug and interactive branches.
        let has_static_output = !static_output.is_empty() && static_output != "\n";

        // 1. Debug mode: plain full frame, no erase/diff/BSU ever.
        if debug {
            let mut out = String::new();
            if has_static_output {
                self.full_static_output.push_str(static_output);
            }
            out.push_str(&self.full_static_output);
            out.push_str(output);
            // Debug mode never feeds the differ, so it has no padded
            // `output_to_render`; ink records `lastOutputToRender = output` raw in
            // the debug branch (`ink.tsx:556`). Mirror that.
            self.record_frame(output, output, output_height);
            // Full frame: every visible line of `output` is (re)written. Derived
            // from the raw `output` this branch emits. Telemetry only — byte-inert.
            self.last_changed_lines = visible_lines_of(output);
            return out.into_bytes();
        }

        // 2. Fullscreen detection drives the trailing-newline policy.
        let is_fullscreen = is_tty && output_height >= viewport_rows;
        let output_to_render = if is_fullscreen {
            output.to_owned()
        } else {
            format!("{output}\n")
        };
        // Line bookkeeping for the cursor transport, computed from the PADDED
        // string the differ actually sees (`output_to_render`), exactly as ink's
        // createIncremental does (`nextLines = str.split('\n')`,
        // `log-update.ts:217`). `output_to_render_line_count` mirrors
        // `nextLines.length`; `ends_with_newline` mirrors `str.endsWith('\n')` and
        // feeds `visibleLineCount` (`log-update.ts:28-29`). Using the padded
        // string is load-bearing: a non-fullscreen frame is padded with a trailing
        // `\n`, so `visibleLineCount` is `len - 1`, matching the row the differ
        // leaves the cursor on (otherwise the cursorUp count drifts a row).
        let ends_with_newline = output_to_render.ends_with('\n');
        let output_to_render_line_count = output_to_render.split('\n').count();

        // 3. Accumulate static output for the clear-branch replay.
        if has_static_output {
            self.full_static_output.push_str(static_output);
        }

        let sync = should_synchronize(is_tty, interactive, is_in_ci);

        // 4. Clear branch.
        let should_clear = should_clear_terminal_for_frame(
            is_tty,
            viewport_rows,
            self.last_output_height,
            output_height,
            is_unmounting,
        );
        if should_clear {
            let mut body = String::new();
            body.push_str(CLEAR_TERMINAL);
            body.push_str(&self.full_static_output);
            // Raw `output`, intentionally not `output_to_render`: clearTerminal
            // has already homed the cursor, and ink writes the unpadded frame
            // here while syncing the diff baseline to the padded one (ink.tsx
            // 1066/1071). This asymmetry is preserved, not "fixed".
            body.push_str(output);
            self.diff.sync(&output_to_render);
            // Cursor half of the clear path: clearTerminal homed the cursor, so
            // there is no `returnToBottom` to do — only the new active cursor's
            // suffix is appended (ink's clear-frame routes through `log.sync`,
            // `log-update.ts:344-364`, whose cursor half over a freshly-homed
            // screen reduces to the suffix). When `cursor` is `None` this is `""`
            // (no `cursorWasShown` to hide either, since the screen was wiped), so
            // the golden clear path is byte-identical.
            body.push_str(&build_cursor_suffix(
                visible_line_count(output_to_render_line_count, ends_with_newline),
                cursor,
            ));
            self.record_frame(output, &output_to_render, output_height);
            self.record_cursor(cursor);
            // Clear/bootstrap full repaint: every visible line of the rendered
            // frame is rewritten. Derived from `output_to_render` (the string the
            // baseline is synced to). Telemetry only — byte-inert.
            self.last_changed_lines = visible_lines_of(&output_to_render);
            return wrap(body, sync);
        }

        // 5. Static (non-clear) branch: erase main output, write static, then
        //    re-render the frame as a fresh bootstrap diff.
        if has_static_output {
            let mut body = String::from_utf8(self.diff.clear()).expect("erase sequences are ascii");
            body.push_str(static_output);
            body.push_str(
                &String::from_utf8(self.diff.diff(&output_to_render))
                    .expect("diff bytes are ascii/utf8"),
            );
            // Append the new active cursor's suffix over the freshly bootstrapped
            // frame (the bootstrap diff leaves the cursor at the bottom). `None`
            // -> `""`, so the golden static path is byte-identical.
            body.push_str(&build_cursor_suffix(
                visible_line_count(output_to_render_line_count, ends_with_newline),
                cursor,
            ));
            self.record_frame(output, &output_to_render, output_height);
            self.record_cursor(cursor);
            // The static branch re-renders as a fresh bootstrap diff, so the
            // differ's own count is the full visible-line repaint. Telemetry only.
            self.last_changed_lines = self.diff.last_changed_lines();
            return wrap(body, sync);
        }

        // 6. Steady branch. Oracle gate = `hasChanges` (`log-update.ts:44-53`):
        //    `output != previousOutput || cursorChanged`. The faithful predicate
        //    is the OUTPUT delta OR the CURSOR-POSITION delta — NOT `cursorDirty`
        //    (which only selects `activeCursor` upstream, in napi). On a golden
        //    path `cursor`/`previous` are both `None`, so `cursor_changed` is
        //    `false` and this collapses to the original `output != last_output`.
        let cursor_changed = cursor_position_changed(cursor, self.previous_cursor_position);
        if output != self.last_output || cursor_changed {
            let visible = visible_line_count(output_to_render_line_count, ends_with_newline);

            // ── createIncremental cursor-only branch (`log-update.ts:221-234`):
            //    output unchanged AND the cursor moved -> emit ONLY the
            //    cursor-only sequence (hidePrefix + returnToBottom + suffix),
            //    returned DIRECTLY (the differ baseline is untouched).
            if output == self.last_output && cursor_changed {
                let seq = build_cursor_only_sequence(
                    self.cursor_was_shown,
                    self.previous_line_count(),
                    self.previous_cursor_position,
                    visible,
                    cursor,
                );
                self.record_frame(output, &output_to_render, output_height);
                self.record_cursor(cursor);
                // Cursor-only move: the differ baseline is untouched, so NO frame
                // line was rewritten. Telemetry only — byte-inert.
                self.last_changed_lines = 0;
                // `seq` is non-empty here (cursor_changed => at least a hide or a
                // suffix), so there is always something to wrap.
                return wrap(seq, sync);
            }

            // ── Output changed: the per-line diff, with the cursor transport
            //    wrapped around it (`log-update.ts:236` returnPrefix + ... +
            //    `:300-301` cursorSuffix). The differ's own bytes assume the
            //    cursor is at the bottom; `returnPrefix` re-homes a previously
            //    shown cursor first, the suffix repositions the new one after.
            let return_prefix = build_return_to_bottom_prefix(
                self.cursor_was_shown,
                self.previous_line_count(),
                self.previous_cursor_position,
            );
            let d = self.diff.diff(&output_to_render);
            // Incremental diff: the differ's own changed-line count (0 for an
            // unchanged-output no-op). Telemetry only — byte-inert; recorded for
            // both the no-op early return and the wrapped-body path below.
            self.last_changed_lines = self.diff.last_changed_lines();
            let suffix = build_cursor_suffix(visible, cursor);
            self.record_frame(output, &output_to_render, output_height);
            self.record_cursor(cursor);
            // willRender: when output is unchanged the diff is empty; with no
            // cursor prefix/suffix (the golden case: cursor_was_shown=false,
            // cursor=None) the whole body is empty -> emit NOTHING, no empty
            // BSU/ESU pair. With an active/previous cursor the prefix/suffix make
            // the body non-empty even on an empty diff.
            if return_prefix.is_empty() && d.is_empty() && suffix.is_empty() {
                return Vec::new();
            }
            let mut body = return_prefix.into_bytes();
            body.extend_from_slice(&d);
            body.extend_from_slice(suffix.as_bytes());
            return wrap_bytes(body, sync);
        }

        // 7. Nothing changed and the cursor is unchanged: emit nothing, but still
        //    advance the recorded frame (ink updates lastOutput unconditionally).
        //    `record_cursor` re-pins `previous_cursor_position`/`cursor_was_shown`
        //    to the (unchanged) active cursor, matching ink's trailing assignment.
        self.record_frame(output, &output_to_render, output_height);
        self.record_cursor(cursor);
        // No-op frame: nothing emitted, nothing rewritten. Telemetry only.
        self.last_changed_lines = 0;
        Vec::new()
    }

    /// How many visible lines the LAST [`write_frame`](Self::write_frame)
    /// rewrote. 0 for a no-op (empty-write) frame; the visible line count for a
    /// clear/bootstrap/debug full repaint; the differ's changed-line count for
    /// an incremental diff. Pure additive telemetry — reading it never affects
    /// the emitted transport bytes. Intended for downstream pacing (P5.3) to
    /// distinguish a real-change frame from a no-op timer fire.
    #[must_use]
    pub fn last_changed_lines(&self) -> u32 {
        self.last_changed_lines
    }

    /// Reset the writer to its as-constructed diff state so the next
    /// [`write_frame`](Self::write_frame) emits a full repaint.
    ///
    /// # Mirrors `render.reset()`, NOT `render.clear()`
    ///
    /// ink has two state-clearing entry points (`log-update.ts`):
    ///
    /// * `render.clear()` (`log-update.ts:312-323`) writes bytes — it emits a
    ///   return-to-bottom prefix + `eraseLines(previousLines.length)` to the
    ///   stream **before** zeroing `previousOutput`/`previousLines`. It is the
    ///   visible erase.
    /// * `render.reset()` (`log-update.ts:337-342`) emits **nothing** — it only
    ///   zeroes `previousOutput`, `previousLines`, `previousCursorPosition`, and
    ///   `cursorWasShown`.
    ///
    /// This accessor mirrors `render.reset()`. The M3-K3 width-shrink invariant
    /// is the reason: M3-K3 calls `reset_diff_state` FIRST and *then* re-renders.
    /// The full repaint comes from the subsequent `write_frame` bootstrap branch,
    /// so this call must emit no bytes — a `clear()`-style erase would double-erase
    /// (the bootstrap diff already re-homes the cursor and repaints). Hence we
    /// delegate to [`LineDiff::reset`](crate::LineDiff::reset) (the no-byte reset),
    /// never `LineDiff::clear` (the erase-emitting one).
    ///
    /// Crucially, `LineDiff::reset` alone is **insufficient**: it only zeroes the
    /// [`LineDiff`] baseline (`previous_output`/`previous_lines`), leaving this
    /// writer's own `last_output`, `last_output_height`, and `full_static_output`
    /// stale. A stale `last_output_height` corrupts the clear decision on the next
    /// frame (the width-shrink path), and a stale `full_static_output` would replay
    /// dead static content into the clear branch. So we reset the FULL writer state
    /// to as-constructed: `*self = Self::default()` is provably equal to a freshly
    /// constructed [`FrameWriter`] and stays correct if a field is added later.
    pub fn reset_diff_state(&mut self) {
        *self = Self::default();
    }

    fn record_frame(&mut self, output: &str, output_to_render: &str, output_height: usize) {
        self.last_output.clear();
        self.last_output.push_str(output);
        self.last_output_to_render.clear();
        self.last_output_to_render.push_str(output_to_render);
        self.last_output_height = output_height;
    }

    /// Re-pin the cursor state after a frame, mirroring ink's trailing
    /// `previousCursorPosition = activeCursor ? {...activeCursor} : undefined;
    /// cursorWasShown = activeCursor !== undefined;`
    /// (`log-update.ts:305-306`/`362-363`). Called by EVERY non-debug branch
    /// (including the no-op and cursor-only branches) so the next frame's
    /// `cursorPositionChanged`/`buildReturnToBottom` see the correct prior cursor.
    fn record_cursor(&mut self, cursor: Option<CursorPos>) {
        self.previous_cursor_position = cursor;
        self.cursor_was_shown = cursor.is_some();
    }

    /// `previousLines.length` for the cursor transport: the number of `\n`-split
    /// segments of the LAST recorded padded frame (`last_output_to_render`),
    /// including the trailing-newline empty slot. Mirrors ink's `previousLines`
    /// (`str.split('\n')` of the prior `previousOutput`). MUST be read BEFORE the
    /// frame's `record_frame` overwrites `last_output_to_render`.
    ///
    /// The empty-baseline case (nothing rendered yet) is unreachable for the
    /// callers that use this value: they only build a non-empty
    /// `buildReturnToBottom`/`buildReturnToBottomPrefix` when
    /// `previous_cursor_position` is `Some`, which never holds on the first frame.
    fn previous_line_count(&self) -> usize {
        self.last_output_to_render.split('\n').count()
    }

    /// `log.clear()` (ink `log-update.ts:312-323`, the incremental variant).
    ///
    /// Returns the erase bytes that wipe the previously rendered frame
    /// (`eraseLines(previousLines.len)`, the pure-path collapse of
    /// `returnPrefix + eraseLines(...)`) and ZEROES the [`LineDiff`] baseline, so
    /// the NEXT [`diff`](crate::LineDiff::diff) (via [`restore_last_output`] or a
    /// fresh [`write_frame`]) repaints the full frame.
    ///
    /// PRESERVES `last_output`, `last_output_to_render`, `last_output_height`, and
    /// `full_static_output`: ink's `log.clear()` touches ONLY log-update's own
    /// `previousOutput`/`previousLines` (the [`LineDiff`] baseline here), NOT
    /// `Ink.lastOutput*`. The Ink class zeroes `lastOutput`/`lastOutputToRender`
    /// SEPARATELY in `resized()` (`ink.tsx:465-466`) when it needs to; the
    /// interactive `writeToStdout` erase+restore and `instance.clear()` both rely
    /// on `last_output_to_render` SURVIVING this call so they can repaint / re-pin
    /// it afterwards. This is the one shared erase-emitting gesture; it is NOT a
    /// full state reset (contrast [`reset_diff_state`](Self::reset_diff_state)).
    ///
    /// Cursor half (ink `log-update.ts:312-323`): `clear()` prepends the
    /// `buildReturnToBottomPrefix` (hide + return-to-bottom) when the last frame
    /// had a shown cursor, BEFORE the `eraseLines`, then zeroes
    /// `previousCursorPosition`/`cursorWasShown`. On the pure path (no cursor ever
    /// shown — `cursor_was_shown == false`) the prefix is `""`, so the returned
    /// bytes are byte-identical to the bare `eraseLines` the M2 path emitted, and
    /// the cursor-state zeroing is a no-op (already `None`/`false`). The
    /// `LineDiff` baseline is still the sole thing the differ-erase touches.
    ///
    /// [`restore_last_output`]: Self::restore_last_output
    pub fn clear(&mut self) -> Vec<u8> {
        // The return-to-bottom prefix uses the PREVIOUS frame's line count and
        // cursor; capture it before the differ erase (which does not touch
        // `last_output_to_render`, but read it here for clarity/order-safety).
        let prefix = build_return_to_bottom_prefix(
            self.cursor_was_shown,
            self.previous_line_count(),
            self.previous_cursor_position,
        );
        let erase = self.diff.clear();
        // ink's `render.clear()` zeroes the cursor state alongside the baseline.
        self.previous_cursor_position = None;
        self.cursor_was_shown = false;
        if prefix.is_empty() {
            return erase;
        }
        let mut out = prefix.into_bytes();
        out.extend_from_slice(&erase);
        out
    }

    /// `log.sync(lastOutputToRender || lastOutput + '\n')` (ink `ink.tsx:940`,
    /// `log-update.ts:344-364` for the pure path).
    ///
    /// Re-pins the [`LineDiff`] baseline to the CURRENT on-screen frame
    /// (`last_output_to_render`) WITHOUT emitting any bytes. After an
    /// `instance.clear()` erase, this tells the differ "the screen now shows the
    /// last frame again" so a subsequent UNCHANGED re-render diffs to a no-op
    /// (ink's comment: "so that unmount's final onRender sees it as unchanged and
    /// log-update skips it", `ink.tsx:938-939`).
    ///
    /// inkferro divergence from ink's literal `lastOutputToRender || lastOutput +
    /// '\n'`: `last_output_to_render` is ALREADY the padded string every
    /// non-debug `write_frame` recorded (it equals `output` fullscreen /
    /// `output + "\n"` otherwise), so the `||` fallback is unreachable here —
    /// the empty `last_output_to_render` (no frame written yet) is itself the
    /// correct baseline (an empty screen), and syncing to `""` is a no-op
    /// against the already-empty baseline.
    ///
    /// Cursor half of ink's `sync` (`log-update.ts:349-363`): with no active
    /// cursor passed in (this re-pin is only used on the `instance.clear()` /
    /// unmount path, which the preceding [`clear`](Self::clear) already left with
    /// `cursor_was_shown == false`), `!activeCursor && cursorWasShown` is `false`
    /// (no hide) and there is no suffix, and the trailing
    /// `previousCursorPosition = undefined; cursorWasShown = false` re-pin is a
    /// no-op against the already-zeroed state. So this emits NOTHING, matching the
    /// M2 [`LineDiff::sync`](crate::LineDiff::sync) contract.
    pub fn sync_baseline(&mut self) {
        self.diff.sync(&self.last_output_to_render);
    }

    /// `restoreLastOutput()` (ink `ink.tsx:499-508`): repaint the last frame from
    /// the cleared baseline.
    ///
    /// Returns `LineDiff::diff(last_output_to_render)`. After a [`clear`](Self::clear)
    /// has zeroed the baseline, this diff is a fresh bootstrap (the bootstrap
    /// branch: `previousOutput.is_empty()`), so it re-emits the FULL last frame —
    /// exactly ink's `this.log(this.lastOutputToRender || this.lastOutput + '\n')`
    /// after an interactive `console.log` erased the live region. The cursor
    /// replay ink does first (`log.setCursorPosition(this.cursorPosition)`,
    /// `ink.tsx:506`) collapses to nothing in the pure path here: this primitive
    /// takes no active-cursor input, so it re-emits only the frame body (a
    /// `useCursor` consumer re-asserts its position on the NEXT real render via
    /// `write_frame`'s cursor path). So this is the byte-faithful pure-path
    /// restore.
    ///
    /// `last_output_to_render` is the padded string, mirroring ink passing
    /// `lastOutputToRender` (the wrapped form) into `this.log(...)`. With an empty
    /// `last_output_to_render` (nothing rendered yet) the diff against the empty
    /// baseline is itself empty — nothing to restore.
    pub fn restore_last_output(&mut self) -> Vec<u8> {
        self.diff.diff(&self.last_output_to_render)
    }

    /// Zero ONLY `last_output` + `last_output_to_render`, mirroring ink's
    /// `resized()` (`ink.tsx:466-467`): after `this.log.clear()` it sets
    /// `this.lastOutput = ''; this.lastOutputToRender = '';` so the post-clear
    /// re-render of the reflowed (possibly byte-IDENTICAL) frame is forced to
    /// repaint — `output != lastOutput` opens the steady gate.
    ///
    /// This is the oracle-faithful replacement for the pre-#41 `setCursor(None)`
    /// hack the inkferro resize path used to open the gate: with the cursor gate
    /// now keyed on POSITION change (not `cursor_dirty`), a `set_cursor(None)`
    /// no longer forces a repaint, so the resize-shrink path zeroes `last_output`
    /// directly, exactly as ink does.
    ///
    /// Deliberately NARROWER than [`reset_diff_state`](Self::reset_diff_state)
    /// (`*self = Self::default()`): ink's `resized()` touches ONLY `lastOutput`/
    /// `lastOutputToRender`. It must NOT zero `full_static_output` (would drop
    /// `<Static>` content) or `last_output_height` (would skew the next frame's
    /// clear decision). The [`LineDiff`] baseline is left UNTOUCHED here — the
    /// resize path calls [`clear`](Self::clear) first, which already zeroed it.
    ///
    /// # Precondition
    /// When a cursor may be shown, call [`clear`](Self::clear) FIRST — it zeroes
    /// `previous_cursor_position`/`cursor_was_shown`. Calling this standalone with a
    /// live cursor would strand a stale `previous_cursor_position` against an emptied
    /// `last_output_to_render`, emitting a spurious return-prefix on the next frame.
    /// ink's `resized()` satisfies this by always calling `clear()` first.
    ///
    /// Emits no bytes.
    pub fn forget_last_output(&mut self) {
        self.last_output.clear();
        self.last_output_to_render.clear();
    }

    /// Zero ONLY `full_static_output`, mirroring ink's `handleStaticChange`
    /// (`ink.tsx:522-525`): when the `<Static>` node's IDENTITY changes (key
    /// remount / replacement, detected in the reconciler's `resetAfterCommit`,
    /// ink `reconciler.ts:167-175`), ink sets `this.fullStaticOutput = ''` so
    /// the clear-branch replay (`clearTerminal + fullStaticOutput + output`,
    /// `ink.tsx:1066`) never re-emits a DEAD `<Static>` instance's accumulated
    /// items. Without this, the writer's accumulator — fed by every non-debug
    /// [`write_frame`](Self::write_frame) — keeps the dead node's chunks and
    /// replays them on the next overflow/leaving-fullscreen clear frame.
    ///
    /// Deliberately NARROWER than [`reset_diff_state`](Self::reset_diff_state):
    /// the identity change happens mid-stream with a live on-screen frame, so
    /// `last_output*`, `last_output_height`, the [`LineDiff`] baseline, and the
    /// cursor state must all SURVIVE (ink's `handleStaticChange` touches only
    /// `fullStaticOutput`). New `<Static>` content rendered AFTER the identity
    /// change re-accumulates normally.
    ///
    /// Emits no bytes.
    pub fn reset_static_output(&mut self) {
        self.full_static_output.clear();
    }

    /// Fused interactive `writeToStdout` console-interleave (P1.2 / #1): one
    /// buffer carrying `bsu? + clear() + data + restoreLastOutput() + esu?`.
    ///
    /// Byte-identical to the old 5-write path that composed the same
    /// `clear()` / `restore_last_output()` primitives sequentially. The
    /// `clear()` zeros the diff baseline; the `restore_last_output()` then
    /// bootstraps the full last frame from that cleared baseline — exactly
    /// what the JS `Ink` class used to orchestrate as separate `.write()` calls.
    ///
    /// `data` is the app text (`console.log` payload) encoded as UTF-8 bytes.
    /// `sync` gates the BSU/ESU DECSET-2026 synchronized-update wrap
    /// (`shouldSync()` resolved JS-side).
    ///
    /// In the nothing-rendered-yet state: `clear()` and `restore_last_output()`
    /// both return empty (no frame to erase/repaint), so the result is
    /// `bsu? + data + esu?` — byte-identical to what the old path produced.
    pub fn compose_console_write(&mut self, data: &[u8], sync: bool) -> Vec<u8> {
        let clear = self.clear();
        let restore = self.restore_last_output();
        let capacity = bsu.len() + clear.len() + data.len() + restore.len() + esu.len();
        let mut out = Vec::with_capacity(capacity);
        if sync {
            out.extend_from_slice(bsu.as_bytes());
        }
        out.extend_from_slice(&clear);
        out.extend_from_slice(data);
        out.extend_from_slice(&restore);
        if sync {
            out.extend_from_slice(esu.as_bytes());
        }
        out
    }

    /// The stdout-side OPENING half of the fused interactive `writeToStderr`
    /// interleave: `bsu? + clear()`. Paired with
    /// [`compose_console_suffix`](Self::compose_console_suffix) so the JS path
    /// is exactly 3 writes: prefix→stdout, data→stderr, suffix→stdout.
    pub fn compose_console_prefix(&mut self, sync: bool) -> Vec<u8> {
        let clear = self.clear();
        if !sync {
            return clear;
        }
        let mut out = Vec::with_capacity(bsu.len() + clear.len());
        out.extend_from_slice(bsu.as_bytes());
        out.extend_from_slice(&clear);
        out
    }

    /// The stdout-side CLOSING half of the fused interactive `writeToStderr`
    /// interleave: `restoreLastOutput() + esu?`. MUST follow a matching
    /// [`compose_console_prefix`](Self::compose_console_prefix) (whose
    /// `clear()` zeroed the diff baseline) so the restore is the full-frame
    /// bootstrap.
    pub fn compose_console_suffix(&mut self, sync: bool) -> Vec<u8> {
        let restore = self.restore_last_output();
        if !sync {
            return restore;
        }
        let mut out = Vec::with_capacity(restore.len() + esu.len());
        out.extend_from_slice(&restore);
        out.extend_from_slice(esu.as_bytes());
        out
    }
}

/// Wrap a UTF-8 body in BSU/ESU when `sync`, returning bytes.
fn wrap(body: String, sync: bool) -> Vec<u8> {
    wrap_bytes(body.into_bytes(), sync)
}

/// Wrap raw bytes in BSU/ESU when `sync`.
fn wrap_bytes(body: Vec<u8>, sync: bool) -> Vec<u8> {
    if !sync {
        return body;
    }
    let mut out = Vec::with_capacity(body.len() + bsu.len() + esu.len());
    out.extend_from_slice(bsu.as_bytes());
    out.extend_from_slice(&body);
    out.extend_from_slice(esu.as_bytes());
    out
}

#[cfg(test)]
#[path = "frame_tests.rs"]
mod frame_tests;
