//! Goldens for the synchronized-output + clear-decision layer.
//!
//! The `clearTerminal`-count cases reproduce ink's issue-450 fixtures
//! (`ink/test/fixtures/issue-450-*.tsx`, driven by
//! `issue-450-fixture-helpers.tsx`) and assert the same counts ink's
//! `ink/test/render.tsx` pins via `clearTerminalCount`. The fixture renders a
//! `<Box height={heightForFrame(rows, frame)}>` whose three text rows plus a
//! growing spacer make `output_height == targetHeight`, so each scenario reduces
//! to a height schedule over `viewport_rows == rows`.
//!
//! BSU/ESU byte literals are pinned from `ink/src/write-synchronized.ts`
//! (oracle-confirmed); `clearTerminal` from the `ansi-escapes` oracle capture.

use super::*;

const CLEAR: &str = "\u{001B}[2J\u{001B}[3J\u{001B}[H";
const BSU: &str = "\u{001B}[?2026h";
const ESU: &str = "\u{001B}[?2026l";

/// Count non-overlapping occurrences of `needle` in `haystack`.
fn count(haystack: &[u8], needle: &str) -> usize {
    let n = needle.as_bytes();
    if n.is_empty() {
        return 0;
    }
    let mut hits = 0;
    let mut i = 0;
    while i + n.len() <= haystack.len() {
        if &haystack[i..i + n.len()] == n {
            hits += 1;
            i += n.len();
        } else {
            i += 1;
        }
    }
    hits
}

/// Build the rerender-fixture main output for a `targetHeight`: three labelled
/// rows plus a spacer grown to `height` rows total. The exact text is
/// immaterial to the clear decision (only `output_height` is), but using a
/// realistic distinct-per-frame body exercises the steady/diff path too.
fn rerender_output(frame: usize, height: usize) -> String {
    let mut lines = vec!["#450 top".to_owned(), format!("frame {frame}")];
    while lines.len() < height.saturating_sub(1) {
        lines.push(String::new());
    }
    lines.push("#450 bottom".to_owned());
    lines.join("\n")
}

/// Drive a height schedule through a fresh interactive TTY `FrameWriter` and
/// return the concatenated emitted bytes. `heights[i]` is `output_height` (and,
/// via the fixture, `targetHeight`) for frame `i`; `viewport` is `rows`.
fn run_rerender(viewport: usize, heights: &[usize]) -> Vec<u8> {
    let mut w = FrameWriter::new();
    let mut all = Vec::new();
    for (frame, &h) in heights.iter().enumerate() {
        let output = rerender_output(frame, h);
        let params = FrameParams {
            is_tty: true,
            viewport_rows: viewport,
            output: &output,
            output_height: h,
            static_output: "",
            is_unmounting: false,
            cursor_dirty: false,
            cursor: None,
            interactive: Some(true),
            is_in_ci: false,
            debug: false,
        };
        all.extend_from_slice(&w.write_frame(&params));
    }
    all
}

// --- issue-450 clearTerminalCount goldens (provenance: render.tsx asserts) ---

// render.tsx:500-504 — initial overflowing frame should not clear.
// Fixture issue-450-initial-overflow: lineCount=4, rows=3 -> single frame,
// height 4 > viewport 3, but prev_height 0 so hadPreviousFrame=false.
#[test]
fn initial_overflow_clears_zero() {
    let bytes = run_rerender(3, &[4]);
    assert_eq!(count(&bytes, CLEAR), 0);
}

// render.tsx:518-521 — initial full-height frame should not clear.
// Fixture issue-450-initial-fullscreen: lineCount=3, rows=3.
#[test]
fn initial_fullscreen_clears_zero() {
    let bytes = run_rerender(3, &[3]);
    assert_eq!(count(&bytes, CLEAR), 0);
}

// render.tsx:532 — rows-1 control rerenders avoid clearTerminal.
// Fixture issue-450-height-minus-one-rerender: every frame height = rows-1 = 5.
#[test]
fn height_minus_one_control_clears_zero() {
    let bytes = run_rerender(6, &[5, 5, 5, 5, 5, 5]);
    assert_eq!(count(&bytes, CLEAR), 0);
}

// render.tsx:582 — shrink from full-height to rows-1 clears exactly once.
// Fixture issue-450-shrink-from-fullscreen-rerender:
// frameCount < 2 ? rows : rows-1  -> 6,6,5,5,5,5  (one fullscreen->non-fs edge).
#[test]
fn shrink_from_fullscreen_clears_once() {
    let bytes = run_rerender(6, &[6, 6, 5, 5, 5, 5]);
    assert_eq!(count(&bytes, CLEAR), 1);
}

// render.tsx:594 — shrink from overflow to rows-1 clears exactly once.
// Fixture issue-450-shrink-from-overflow-rerender:
// frame 0 ? rows+1 : rows-1  -> 7,5,5,5  (frame1 fires on wasOverflowing).
#[test]
fn shrink_from_overflow_clears_once() {
    let bytes = run_rerender(6, &[7, 5, 5, 5]);
    assert_eq!(count(&bytes, CLEAR), 1);
}

// render.tsx:479-481 — full-height rerenders clear at most once.
// Fixture issue-450-full-height-rerender: every frame height = rows = 6.
// No fullscreen->non-fs edge and never overflowing -> zero clears (<=1 holds).
#[test]
fn full_height_rerender_clears_at_most_once() {
    let bytes = run_rerender(6, &[6, 6, 6, 6, 6, 6]);
    assert!(count(&bytes, CLEAR) <= 1);
    assert_eq!(count(&bytes, CLEAR), 0);
}

// render.tsx:553 — full-height rerenders should not clear before unmount.
// Fixture issue-450-full-height-rerender-with-marker: height = rows = 6.
#[test]
fn full_height_with_marker_clears_zero_before_unmount() {
    let bytes = run_rerender(6, &[6, 6, 6, 6]);
    assert_eq!(count(&bytes, CLEAR), 0);
}

// render.tsx:570 — grow from rows-1 to full-height should not clear.
// Fixture issue-450-grow-to-fullscreen-rerender:
// frameCount < 2 ? rows-1 : rows -> 5,5,6,6.
#[test]
fn grow_to_fullscreen_clears_zero() {
    let bytes = run_rerender(6, &[5, 5, 6, 6]);
    assert_eq!(count(&bytes, CLEAR), 0);
}

// render.tsx:732 — non-TTY grow-to-overflow never clears.
// Fixture issue-450-grow-to-overflow-rerender (rows=3): 2,4 but is_tty=false.
#[test]
fn non_tty_grow_to_overflow_clears_zero() {
    let mut w = FrameWriter::new();
    let mut all = Vec::new();
    for (frame, &h) in [2usize, 4].iter().enumerate() {
        let output = rerender_output(frame, h);
        let params = FrameParams {
            is_tty: false,
            viewport_rows: 3,
            output: &output,
            output_height: h,
            static_output: "",
            is_unmounting: false,
            cursor_dirty: false,
            cursor: None,
            interactive: Some(true),
            is_in_ci: false,
            debug: false,
        };
        all.extend_from_slice(&w.write_frame(&params));
    }
    assert_eq!(count(&all, CLEAR), 0);
}

// render.tsx:646 — non-TTY full-height rerenders never clear.
#[test]
fn non_tty_full_height_clears_zero() {
    let mut w = FrameWriter::new();
    let mut all = Vec::new();
    for (frame, &h) in [6usize, 6, 6].iter().enumerate() {
        let output = rerender_output(frame, h);
        let params = FrameParams {
            is_tty: false,
            viewport_rows: 6,
            output: &output,
            output_height: h,
            static_output: "",
            is_unmounting: false,
            cursor_dirty: false,
            cursor: None,
            interactive: Some(true),
            is_in_ci: false,
            debug: false,
        };
        all.extend_from_slice(&w.write_frame(&params));
    }
    assert_eq!(count(&all, CLEAR), 0);
}

// render.tsx:607 — <Static> with shrink from full-height clears exactly once.
// Fixture issue-450-static-shrink-from-fullscreen-rerender: includeStaticLine,
// heightForFrame = frameCount < 2 ? rows : rows-1 -> 6,6,5,5,5,5; the static
// line is emitted only on the first frame.
#[test]
fn static_shrink_from_fullscreen_clears_once() {
    let mut w = FrameWriter::new();
    let mut all = Vec::new();
    let heights = [6usize, 6, 5, 5, 5, 5];
    for (frame, &h) in heights.iter().enumerate() {
        let output = rerender_output(frame, h);
        let static_output = if frame == 0 { "#450 static line\n" } else { "" };
        let params = FrameParams {
            is_tty: true,
            viewport_rows: 6,
            output: &output,
            output_height: h,
            static_output,
            is_unmounting: false,
            cursor_dirty: false,
            cursor: None,
            interactive: Some(true),
            is_in_ci: false,
            debug: false,
        };
        all.extend_from_slice(&w.write_frame(&params));
    }
    assert_eq!(count(&all, CLEAR), 1);
    // The static line survives into the emitted bytes (clear branch replays
    // full_static_output; otherwise the static branch writes it directly).
    assert!(count(&all, "#450 static line") >= 1);
}

// render.tsx:719 — viewport shrink into overflow clears once. Output height is
// constant; viewport_rows drops 6 -> 5 between frames, so it must be a per-call
// param. Frame 2: prev_height 6 > viewport 5 -> wasOverflowing.
#[test]
fn viewport_shrink_into_overflow_clears_once() {
    let mut w = FrameWriter::new();
    let mut all = Vec::new();
    let frames: [(usize, usize); 2] = [(6, 6), (5, 6)]; // (viewport, height)
    for (frame, &(viewport, h)) in frames.iter().enumerate() {
        let output = rerender_output(frame, h);
        let params = FrameParams {
            is_tty: true,
            viewport_rows: viewport,
            output: &output,
            output_height: h,
            static_output: "",
            is_unmounting: false,
            cursor_dirty: false,
            cursor: None,
            interactive: Some(true),
            is_in_ci: false,
            debug: false,
        };
        all.extend_from_slice(&w.write_frame(&params));
    }
    assert_eq!(count(&all, CLEAR), 1);
}

// --- BSU/ESU presence/absence ---

/// A simple steady-branch params builder for sync tests.
fn steady_params<'a>(
    output: &'a str,
    interactive: Option<bool>,
    is_in_ci: bool,
    debug: bool,
) -> FrameParams<'a> {
    FrameParams {
        is_tty: true,
        viewport_rows: 100, // never fullscreen/overflow -> pure steady branch
        output,
        output_height: 1,
        static_output: "",
        is_unmounting: false,
        cursor_dirty: false,
        cursor: None,
        interactive,
        is_in_ci,
        debug,
    }
}

// sync + willRender -> the write is wrapped in exactly one BSU/ESU pair.
#[test]
fn sync_and_will_render_wraps_in_bsu_esu() {
    let mut w = FrameWriter::new();
    let bytes = w.write_frame(&steady_params("hello", Some(true), false, false));
    assert_eq!(count(&bytes, BSU), 1);
    assert_eq!(count(&bytes, ESU), 1);
    // BSU precedes ESU.
    let s = String::from_utf8(bytes).unwrap();
    assert!(s.find(BSU).unwrap() < s.find(ESU).unwrap());
}

// debug mode -> never any BSU/ESU, and the body is the plain full frame.
#[test]
fn debug_never_wraps() {
    let mut w = FrameWriter::new();
    let bytes = w.write_frame(&steady_params("hello", Some(true), false, true));
    assert_eq!(count(&bytes, BSU), 0);
    assert_eq!(count(&bytes, ESU), 0);
    assert_eq!(count(&bytes, CLEAR), 0);
    assert_eq!(bytes, b"hello");
}

// non-interactive (interactive=Some(false)) -> shouldSynchronize false -> no wrap
// even though a frame renders.
#[test]
fn non_interactive_never_wraps() {
    let mut w = FrameWriter::new();
    let bytes = w.write_frame(&steady_params("hello", Some(false), false, false));
    assert_eq!(count(&bytes, BSU), 0);
    assert_eq!(count(&bytes, ESU), 0);
    assert!(!bytes.is_empty(), "frame still renders, just unwrapped");
}

// no-op frame (output unchanged, cursor clean) -> ZERO bytes total: no diff,
// no empty BSU/ESU pair.
#[test]
fn noop_frame_emits_zero_bytes() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&steady_params("hello", Some(true), false, false));
    let second = w.write_frame(&steady_params("hello", Some(true), false, false));
    assert!(second.is_empty(), "unchanged + clean cursor emits nothing");
}

// cursor_dirty opens the gate but the diff is still a no-op (same output) ->
// still ZERO bytes, no empty BSU/ESU pair (willRender rule).
//
// Post-#41 note: the cursor-RENDER gate keys off `cursor` vs the writer's
// `previous_cursor_position`, NOT `cursor_dirty`. Here `cursor` is `None`
// (default) on both frames, so `cursor_changed` is false and the gate stays the
// `output != last_output` predicate — an unchanged "hello" emits nothing even
// with `cursor_dirty = true`. (The same-POSITION re-set with an ACTIVE cursor is
// the stronger discriminator pinned by `same_position_reset_emits_zero_bytes`.)
#[test]
fn cursor_dirty_noop_diff_emits_zero_bytes() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&steady_params("hello", Some(true), false, false));
    let mut p = steady_params("hello", Some(true), false, false);
    p.cursor_dirty = true;
    let second = w.write_frame(&p);
    assert!(
        second.is_empty(),
        "cursor_dirty gate opens but no-op diff must still emit nothing"
    );
}

// ─── #41 cursor byte-teeth ──────────────────────────────────────────────────
//
// Every expected literal below is DERIVED FROM THE ORACLE (the pure functions in
// `ink/src/cursor-helpers.ts` + `log-update.ts`), not from memory. A scratch
// node script (run with ink's tsx) imported `buildCursorOnlySequence` /
// `buildCursorSuffix` / `buildReturnToBottomPrefix` and fed them the exact
// `{cursorWasShown, previousLineCount, previousCursorPosition, visibleLineCount,
// cursorPosition}` these scenarios produce. The provenance for each literal is in
// its comment. BSU/ESU come from `write-synchronized.ts` (pinned above).

/// rt cursor position, re-spelled here so the test names the {x,y} explicitly.
/// `CursorPos` is in scope via `use super::*` (it is `pub` in `frame.rs`).
fn pos(x: usize, y: usize) -> CursorPos {
    CursorPos { x, y }
}

/// `steady_params` with an active cursor for this frame.
fn cursor_params<'a>(output: &'a str, cursor: Option<CursorPos>) -> FrameParams<'a> {
    let mut p = steady_params(output, Some(true), false, false);
    p.cursor = cursor;
    p.cursor_dirty = cursor.is_some();
    p
}

// TOOTH 1 — cursor-only change on a clean baseline. Frame 0 renders "hi" with NO
// cursor (records previous_cursor=None, cursor_was_shown=false). Frame 1 sets a
// cursor at {x:3,y:0} with output UNCHANGED -> the cursor-only branch fires and
// `render_frame` returns Some.
//
// ORACLE (cursor-helpers.ts buildCursorOnlySequence, via tsx):
//   {cursorWasShown:false, previousLineCount:2, previousCursorPosition:undefined,
//    visibleLineCount:1, cursorPosition:{x:3,y:0}}
//   -> "\x1b[1A\x1b[4G\x1b[?25h"   (moveUp = visible(1)-y(0) = 1 -> cursorUp(1)="[1A";
//      cursorTo(3) = "[4G" (1-based); showCursor = "[?25h"; hidePrefix/returnToBottom = "")
// sync-wrapped: BSU + that + ESU.
#[test]
fn cursor_only_change_emits_oracle_sequence_and_returns_some() {
    let mut w = FrameWriter::new();
    // Frame 0: "hi" with no cursor (output_height 1, viewport 100 -> non-fs ->
    // output_to_render "hi\n", visible 1).
    let _ = w.write_frame(&cursor_params("hi", None));

    // Frame 1: same output, cursor at {x:3,y:0}.
    let bytes = w.write_frame(&cursor_params("hi", Some(pos(3, 0))));

    let expected = format!("{BSU}\u{001B}[1A\u{001B}[4G\u{001B}[?25h{ESU}");
    assert_eq!(
        String::from_utf8(bytes.clone()).unwrap(),
        expected,
        "cursor-only change must emit the oracle buildCursorOnlySequence, sync-wrapped"
    );
    assert!(
        !bytes.is_empty(),
        "a cursor-only change makes write_frame return Some (non-empty) on byte-identical output"
    );
}

// TOOTH 2 — same-position re-set on unchanged output -> ZERO bytes. The
// POSITION-change gate (`cursor_changed`) must suppress it even though
// `cursor_dirty` is set: re-asserting the SAME {x,y} is not a change.
#[test]
fn same_position_reset_emits_zero_bytes() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&cursor_params("hi", None));
    // Frame 1: establish the cursor at {3,0} (this one DOES emit).
    let first = w.write_frame(&cursor_params("hi", Some(pos(3, 0))));
    assert!(!first.is_empty(), "establishing the cursor emits a frame");

    // Frame 2: re-set the SAME position, output unchanged -> no change -> zero.
    let second = w.write_frame(&cursor_params("hi", Some(pos(3, 0))));
    assert!(
        second.is_empty(),
        "same-position re-set on unchanged output must emit nothing (position-change gate)"
    );
}

// TOOTH 3 — cursor clear (active None after a shown cursor) -> the hide sequence.
// Frame 0 "hi" no cursor; frame 1 "hi" cursor {x:3,y:0} (shown); frame 2 "hi"
// cursor None -> active None while previous Some -> cursorChanged -> the
// cursor-only branch emits the hide-and-return sequence (no suffix, cursor gone).
//
// ORACLE (buildCursorOnlySequence, via tsx):
//   {cursorWasShown:true, previousLineCount:2, previousCursorPosition:{x:3,y:0},
//    visibleLineCount:1, cursorPosition:undefined}
//   -> "\x1b[?25l\x1b[1B\x1b[1G"  (hidePrefix = hideCursor "[?25l";
//      returnToBottom: down = previousLineCount(2)-1-y(0) = 1 -> cursorDown(1)="[1B",
//      cursorTo(0)="[1G"; suffix = "" since cursor is None)
// sync-wrapped: BSU + that + ESU.
#[test]
fn cursor_clear_emits_oracle_hide_sequence() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&cursor_params("hi", None));
    let _ = w.write_frame(&cursor_params("hi", Some(pos(3, 0))));

    let bytes = w.write_frame(&cursor_params("hi", None));
    let expected = format!("{BSU}\u{001B}[?25l\u{001B}[1B\u{001B}[1G{ESU}");
    assert_eq!(
        String::from_utf8(bytes).unwrap(),
        expected,
        "clearing a shown cursor must emit the oracle hide-and-return sequence"
    );
}

// TOOTH 4 — output change WITH an active cursor -> diff bytes + cursor transport.
// Frame 0 "hi" cursor {x:1,y:0} (shown). Frame 1 output "ho" (changed) cursor
// {x:2,y:0}. The body is `returnPrefix + <line diff> + cursorSuffix`, sync-wrapped.
//
// The line-diff bytes themselves are cross-checked against an INDEPENDENT bare
// writer (same output schedule, NO cursor) whose frame-1 output is exactly the
// `LineDiff::diff("hi\n" -> "ho\n")` with no cursor prefix/suffix. The cursor
// prefix/suffix literals are oracle-derived (via tsx):
//   returnToBottomPrefix(cursorWasShown:true, previousLineCount:2, {x:1,y:0})
//     -> "\x1b[?25l\x1b[1B\x1b[1G"
//   buildCursorSuffix(visibleLineCount:1, {x:2,y:0})
//     -> "\x1b[1A\x1b[3G\x1b[?25h"  (moveUp = 1-0 = 1 -> "[1A"; cursorTo(2)="[3G"; show)
#[test]
fn output_change_with_active_cursor_wraps_diff_in_cursor_transport() {
    // Bare writer (no cursor) gives the pure line-diff bytes for "hi"->"ho".
    let mut bare = FrameWriter::new();
    let _ = bare.write_frame(&cursor_params("hi", None));
    let bare_diff = String::from_utf8(bare.write_frame(&cursor_params("ho", None))).unwrap();
    // The bare diff is itself sync-wrapped; strip BSU/ESU to get the inner diff.
    assert!(bare_diff.starts_with(BSU) && bare_diff.ends_with(ESU));
    let inner_diff = &bare_diff[BSU.len()..bare_diff.len() - ESU.len()];

    // Cursored writer: same schedule but with active cursors.
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&cursor_params("hi", Some(pos(1, 0))));
    let bytes = String::from_utf8(w.write_frame(&cursor_params("ho", Some(pos(2, 0))))).unwrap();

    let return_prefix = "\u{001B}[?25l\u{001B}[1B\u{001B}[1G";
    let cursor_suffix = "\u{001B}[1A\u{001B}[3G\u{001B}[?25h";
    let expected = format!("{BSU}{return_prefix}{inner_diff}{cursor_suffix}{ESU}");
    assert_eq!(
        bytes, expected,
        "an output change with an active cursor must be returnPrefix + diff + cursorSuffix, wrapped"
    );
}

// ink.tsx:548 — a bare "\n" static_output is NOT real static content, so it is
// not accumulated. In debug mode the emitted body must be just `output`, with
// no stray newline folded into full_static_output.
#[test]
fn debug_bare_newline_static_is_ignored() {
    let mut w = FrameWriter::new();
    let mut p = steady_params("hello", Some(true), false, true);
    p.static_output = "\n";
    let first = w.write_frame(&p);
    assert_eq!(first, b"hello");
    // A subsequent debug frame must not replay an accumulated "\n".
    let mut p2 = steady_params("world", Some(true), false, true);
    p2.static_output = "";
    let second = w.write_frame(&p2);
    assert_eq!(second, b"world");
}

// --- pure predicate goldens ---

#[test]
fn should_synchronize_matches_oracle() {
    // TTY && (interactive ?? !isInCi)
    assert!(should_synchronize(true, Some(true), false));
    assert!(!should_synchronize(true, Some(false), false));
    assert!(!should_synchronize(false, Some(true), false));
    // interactive None defers to !is_in_ci.
    assert!(should_synchronize(true, None, false));
    assert!(!should_synchronize(true, None, true));
}

#[test]
fn should_clear_predicate_disjuncts() {
    // !is_tty -> always false.
    assert!(!should_clear_terminal_for_frame(false, 6, 7, 7, false));
    // wasOverflowing (prev 7 > 6).
    assert!(should_clear_terminal_for_frame(true, 6, 7, 5, false));
    // isOverflowing && hadPreviousFrame (prev 5>0, next 7>6).
    assert!(should_clear_terminal_for_frame(true, 6, 5, 7, false));
    // isOverflowing but NO previous frame -> false (initial overflow).
    assert!(!should_clear_terminal_for_frame(true, 3, 0, 4, false));
    // isLeavingFullscreen (was 6>=6, next 5<6).
    assert!(should_clear_terminal_for_frame(true, 6, 6, 5, false));
    // unmount + wasFullscreen.
    assert!(should_clear_terminal_for_frame(true, 6, 6, 6, true));
    // steady full-height rerender (6->6) -> false.
    assert!(!should_clear_terminal_for_frame(true, 6, 6, 6, false));
}

// --- bsu/esu literals pinned from write-synchronized.ts ---

#[test]
fn decset_literals_pinned() {
    assert_eq!(bsu, "\u{001B}[?2026h");
    assert_eq!(esu, "\u{001B}[?2026l");
    assert_eq!(bsu.as_bytes(), &[27, 91, 63, 50, 48, 50, 54, 104]);
    assert_eq!(esu.as_bytes(), &[27, 91, 63, 50, 48, 50, 54, 108]);
}

// --- reset_diff_state: mirrors render.reset() (no bytes, full state zeroed) ---

/// A `FrameParams` for an interactive TTY frame with the given dimensions and
/// static payload. Keeps the reset tests terse.
fn frame_params<'a>(
    output: &'a str,
    output_height: usize,
    viewport_rows: usize,
    static_output: &'a str,
) -> FrameParams<'a> {
    FrameParams {
        is_tty: true,
        viewport_rows,
        output,
        output_height,
        static_output,
        is_unmounting: false,
        cursor_dirty: false,
        cursor: None,
        interactive: Some(true),
        is_in_ci: false,
        debug: false,
    }
}

/// (1) THE load-bearing test: after `reset_diff_state`, the next frame's bytes
/// equal a fresh `FrameWriter`'s bytes for the same frame — state-equivalence.
///
/// The pre-reset history is built to dirty EVERY field whose staleness could
/// leak: a real `<Static>` payload grows `full_static_output`, and a tall frame
/// sets `last_output`/`last_output_height`. The comparison frame is then chosen
/// to hit the **clear branch** — the only branch that reads `full_static_output`
/// — by re-entering with a height that overflows the viewport (was_overflowing).
/// A stale `full_static_output` would prepend dead static content; a stale
/// `last_output_height` would flip the clear decision. Either bug diverges here.
#[test]
fn reset_diff_state_equals_fresh_writer() {
    // Dirty a writer: a static frame (grows full_static_output) then a tall
    // frame (sets last_output_height to an overflowing value).
    let mut dirty = FrameWriter::new();
    let _ = dirty.write_frame(&frame_params("line-a\nline-b", 2, 6, "STATIC-CHUNK\n"));
    let _ = dirty.write_frame(&frame_params("a\nb\nc\nd\ne\nf\ng", 7, 6, ""));

    dirty.reset_diff_state();

    // A frame that lands in the clear branch (prev_height 0 after reset means no
    // clear; so we drive a two-frame schedule on BOTH writers and compare the
    // full transcript — the second frame overflows after a real first frame,
    // forcing the clear branch that reads full_static_output).
    let schedule = |w: &mut FrameWriter| -> Vec<u8> {
        let mut all = Vec::new();
        all.extend_from_slice(&w.write_frame(&frame_params("x\ny\nz\nw", 4, 6, "")));
        all.extend_from_slice(&w.write_frame(&frame_params("p\nq\nr\ns\nt\nu\nv", 7, 6, "")));
        all
    };

    let after_reset = schedule(&mut dirty);
    let mut fresh = FrameWriter::new();
    let from_fresh = schedule(&mut fresh);

    assert_eq!(
        after_reset, from_fresh,
        "post-reset bytes must equal a fresh writer's for the same schedule"
    );
}

/// (2) Static output state (and all writer fields) are cleared. `frame_tests`
/// is an inline `#[path]` submodule, so it can read the private fields directly.
#[test]
fn reset_diff_state_clears_all_fields() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&frame_params("hello\nworld", 2, 6, "STATIC\n"));
    let _ = w.write_frame(&frame_params("a\nb\nc\nd\ne\nf\ng", 7, 6, "MORE-STATIC\n"));

    // Pre-reset: the writer is genuinely dirty in all three fields.
    assert!(!w.last_output.is_empty(), "precondition: last_output dirty");
    assert!(
        w.last_output_height != 0,
        "precondition: last_output_height dirty"
    );
    assert!(
        !w.full_static_output.is_empty(),
        "precondition: full_static_output dirty"
    );

    w.reset_diff_state();

    assert_eq!(w.last_output, "", "last_output not cleared");
    assert_eq!(w.last_output_height, 0, "last_output_height not cleared");
    assert_eq!(
        w.full_static_output, "",
        "full_static_output (static state) not cleared"
    );
    // And the LineDiff baseline is reset to as-constructed too.
    assert_eq!(w, FrameWriter::new(), "writer not equal to as-constructed");
}

/// reset_diff_state itself emits no bytes (it mirrors render.reset, not clear).
/// Bytes-as-side-effect would double-erase the M3-K3 width-shrink repaint.
#[test]
fn reset_diff_state_is_pure_state() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&frame_params("a\nb\nc", 3, 6, ""));
    // The accessor returns () — there is no byte stream to leak. This test
    // documents that the *next* frame after reset re-bootstraps (full repaint)
    // rather than diffing against the stale baseline.
    w.reset_diff_state();
    let bytes = w.write_frame(&frame_params("a\nb\nc", 3, 6, ""));
    // After reset, an identical frame is NOT a no-op diff (which a non-reset
    // writer would produce): the baseline is empty, so this is a bootstrap.
    assert!(
        !bytes.is_empty(),
        "post-reset identical frame must re-bootstrap, not skip as no-op"
    );
}

// --- M3-K3 log-update primitives: clear / sync_baseline / restore_last_output ---
//
// These are the rt-side, CARGO-mutation-testable teeth for the JS pins P1/P2/P3.
// Expected escape literals are spelled INDEPENDENTLY of `escapes.rs` here so the
// assertion is a cross-check, not a tautology.

/// `ansiEscapes.eraseLines(count)` spelled out independently of `escapes.rs`:
/// per line a `[2K`, a `[1A` between lines (all but the last), and a trailing
/// `[G` when `count > 0`. `count == 0` -> "". This mirrors the oracle capture
/// pinned in `escapes.rs` but is rebuilt here so the K3 tests do not just echo
/// the production builder.
fn expected_erase_lines(count: usize) -> String {
    if count == 0 {
        return String::new();
    }
    let mut out = String::new();
    for i in 0..count {
        out.push_str("\u{001B}[2K");
        if i < count - 1 {
            out.push_str("\u{001B}[1A");
        }
    }
    out.push_str("\u{001B}[G");
    out
}

/// P1 (rt): `clear()` emits EXACTLY `eraseLines(prevLineCount)` and zeros the
/// `LineDiff` baseline.
///
/// The baseline-zeroing is observed through `restore_last_output()` (which diffs
/// DIRECTLY, bypassing the steady-branch `output == last_output` gate that a
/// `write_frame` re-render would close): after `clear()`, the restore diff is a
/// full BOOTSTRAP (baseline empty) rather than an empty diff. Faithful to ink,
/// where `log.clear()` zeroes only the differ's `previousOutput`/`previousLines`
/// and the repaint is driven by `this.log(...)` (restore) / a `resized()` that
/// zeroes `lastOutput` — NOT by an auto-repaint of an unchanged steady render.
///
/// MUTATION (cargo): if `clear()` returned `Vec::new()` (emits nothing) or a
/// wrong erase count, the byte-equality flips; if it failed to zero the baseline,
/// the post-clear `restore_last_output()` would be a small diff against the still
/// non-empty baseline instead of the full-frame bootstrap — its byte-equality to
/// the bootstrap form (asserted in `restore_last_output_repaints_full_frame`)
/// flips.
#[test]
fn clear_emits_erase_lines_and_zeros_baseline() {
    let mut w = FrameWriter::new();
    // A 3-visible-line non-fullscreen frame: output_to_render = "a\nb\nc\n", so
    // previous_lines.len() == 4 (the trailing-newline empty slot). `eraseLines`
    // is taken over previous_lines.len(), so the expected count is 4.
    let _ = w.write_frame(&frame_params("a\nb\nc", 3, 100, ""));

    let bytes = w.clear();
    assert_eq!(
        String::from_utf8(bytes).unwrap(),
        expected_erase_lines(4),
        "clear() must emit exactly eraseLines(previous_lines.len)"
    );

    // Baseline zeroed: a DIRECT restore diff (not a steady re-render) is now a
    // full bootstrap. The bootstrap re-emits eraseLines(0) + the padded frame.
    let restore = String::from_utf8(w.restore_last_output()).unwrap();
    assert_eq!(
        restore,
        format!("{}{}", expected_erase_lines(0), "a\nb\nc\n"),
        "after clear(), restore diffs against an EMPTY baseline (full bootstrap)"
    );
}

/// P1-companion: `clear()` PRESERVES `last_output` / `last_output_to_render` /
/// `last_output_height` / `full_static_output` (ink's `log.clear()` touches only
/// the differ baseline). `frame_tests` is an inline `#[path]` submodule so it can
/// read the private fields directly.
///
/// MUTATION (cargo): a `clear()` that also zeroed `last_output_to_render` (a
/// full reset) would make `restore_last_output()`/`sync_baseline()` operate on an
/// empty string — these field assertions flip.
#[test]
fn clear_preserves_last_output_state() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&frame_params("hi\nthere", 2, 100, "STATIC\n"));

    let before_last_output = w.last_output.clone();
    let before_render = w.last_output_to_render.clone();
    let before_height = w.last_output_height;
    let before_static = w.full_static_output.clone();

    let _ = w.clear();

    assert_eq!(w.last_output, before_last_output, "last_output preserved");
    assert_eq!(
        w.last_output_to_render, before_render,
        "last_output_to_render preserved"
    );
    assert_eq!(
        w.last_output_height, before_height,
        "last_output_height preserved"
    );
    assert_eq!(
        w.full_static_output, before_static,
        "full_static_output preserved"
    );
    // And the preserved padded string is the newline-padded form (non-fullscreen).
    assert_eq!(w.last_output_to_render, "hi\nthere\n");
}

/// P2 (rt): `sync_baseline()` emits ZERO bytes (it has no return value at all)
/// and re-pins the `LineDiff` baseline to the current `last_output_to_render`, so
/// a subsequent CHANGED re-render produces an INCREMENTAL diff against the synced
/// full baseline — NOT a from-empty bootstrap.
///
/// The CHANGED-render probe is the load-bearing choice: an unchanged re-render
/// would no-op via the steady-branch `output == last_output` gate REGARDLESS of
/// the baseline state (vacuous), so it cannot discriminate a working sync from a
/// broken one. A changed render opens that gate and routes through `LineDiff::diff`,
/// whose output depends entirely on whether the baseline was synced (incremental)
/// or left empty by `clear()` (bootstrap). We assert the synced path is the
/// SMALLER incremental form by comparing against the no-sync bootstrap.
///
/// MUTATION (cargo): if `sync_baseline` synced to the WRONG string or failed to
/// sync, the post-clear+sync changed render would bootstrap-repaint (equal to the
/// no-sync transcript) instead of producing the smaller incremental diff — the
/// inequality below flips.
#[test]
fn sync_baseline_repins_to_incremental_diff() {
    // WITH sync: clear() then re-pin, then a CHANGED render -> incremental diff
    // that SKIPS the unchanged first line "alpha".
    let mut with = FrameWriter::new();
    let _ = with.write_frame(&frame_params("alpha\nbeta", 2, 100, ""));
    let _ = with.clear();
    with.sync_baseline();
    let with_changed =
        String::from_utf8(with.write_frame(&frame_params("alpha\nGAMMA", 2, 100, ""))).unwrap();

    // WITHOUT sync: clear() leaves the baseline empty, so the same changed render
    // is a from-empty BOOTSTRAP that re-writes the WHOLE frame (incl. "alpha").
    let mut without = FrameWriter::new();
    let _ = without.write_frame(&frame_params("alpha\nbeta", 2, 100, ""));
    let _ = without.clear();
    let without_changed =
        String::from_utf8(without.write_frame(&frame_params("alpha\nGAMMA", 2, 100, ""))).unwrap();

    assert!(
        !with_changed.is_empty(),
        "the changed render after sync still emits an incremental diff"
    );
    // Structural distinguisher (verified against the live diff bytes, not a
    // length heuristic): the no-sync bootstrap re-emits the WHOLE frame verbatim,
    // so it carries the unchanged first line as literal text "alpha\n". The synced
    // incremental diff SKIPS that line via cursor moves and never re-writes the
    // literal "alpha" at all — it writes only the changed "GAMMA" line.
    assert!(
        without_changed.contains("alpha\n"),
        "the no-sync bootstrap re-emits the unchanged 'alpha' line verbatim"
    );
    assert!(
        !with_changed.contains("alpha"),
        "sync_baseline() re-pins the baseline -> the changed render SKIPS the unchanged \
         'alpha' line entirely (incremental, not bootstrap)"
    );
    assert!(
        with_changed.contains("GAMMA") && without_changed.contains("GAMMA"),
        "both renders write the changed line"
    );
}

/// P2-discriminator: WITHOUT the sync, the same `clear()` leaves the baseline
/// zeroed, so a DIRECT `restore_last_output()` bootstraps (non-empty); WITH the
/// sync, the baseline equals the on-screen frame so the same restore diffs to a
/// no-op (empty). This proves the no-op in
/// `sync_baseline_repins_so_unchanged_render_noops` is genuinely caused by
/// `sync_baseline` re-pinning the baseline, not by the content being trivially
/// unchanged. (A `write_frame` re-render cannot be used as the probe: its
/// steady-branch `output == last_output` gate stays closed either way, since
/// `clear()` preserves `last_output` — only the direct `diff` of restore exposes
/// the baseline state.)
#[test]
fn clear_without_sync_repaints_but_with_sync_noops() {
    // Without sync: restore bootstraps (non-empty).
    let mut without = FrameWriter::new();
    let _ = without.write_frame(&frame_params("alpha\nbeta", 2, 100, ""));
    let _ = without.clear();
    assert!(
        !without.restore_last_output().is_empty(),
        "clear() without sync_baseline() leaves an empty baseline -> restore repaints"
    );

    // With sync: restore is a no-op (baseline already == the frame).
    let mut with = FrameWriter::new();
    let _ = with.write_frame(&frame_params("alpha\nbeta", 2, 100, ""));
    let _ = with.clear();
    with.sync_baseline();
    assert!(
        with.restore_last_output().is_empty(),
        "clear()+sync_baseline() re-pins the baseline -> restore is a no-op"
    );
}

/// P3 (rt): `restore_last_output()` repaints the FULL last frame from the cleared
/// baseline. After `clear()` zeroes the baseline, the restore diff is a bootstrap
/// that re-emits the whole frame: `eraseLines(0)` (baseline empty) + the padded
/// last frame.
///
/// MUTATION (cargo): if `restore_last_output` emitted nothing (e.g. diffed an
/// empty string) or a partial diff, the byte-equality flips; the bootstrap form
/// is spelled out independently below.
#[test]
fn restore_last_output_repaints_full_frame() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&frame_params("one\ntwo", 2, 100, ""));

    // The interactive writeToStdout composition: erase, write app data (JS-side),
    // then restore the live frame from the cleared baseline.
    let _ = w.clear();
    let restore = String::from_utf8(w.restore_last_output()).unwrap();

    // Bootstrap branch of LineDiff::diff against the (now empty) baseline:
    // eraseLines(0) == "" + the padded last frame string verbatim.
    let expected = format!("{}{}", expected_erase_lines(0), "one\ntwo\n");
    assert_eq!(
        restore, expected,
        "restore_last_output() must repaint the full padded last frame"
    );
    assert!(
        restore.contains("one") && restore.contains("two"),
        "the restored frame carries the last frame's content"
    );
}

/// P3-discriminator: restore is NON-EMPTY (it really repaints). A
/// `restore_last_output` that returned `Vec::new()` would flip this.
#[test]
fn restore_last_output_is_non_empty_after_clear() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&frame_params("payload", 1, 100, ""));
    let _ = w.clear();
    assert!(
        !w.restore_last_output().is_empty(),
        "restore_last_output() must emit the repaint bytes, not nothing"
    );
}

/// The full interactive `writeToStdout` round-trip at the rt boundary: after
/// `clear()` + (JS writes data) + `restore_last_output()`, the differ baseline is
/// back in lockstep with the on-screen frame. Probed with a CHANGED render: the
/// post-restore diff is INCREMENTAL (against the restored full baseline), proving
/// restore re-pinned the baseline — NOT a from-empty bootstrap. This is the
/// invariant the K3 spine protects: erase-emitting primitives keep "baseline ==
/// screen" true at every step.
///
/// (An unchanged re-render would no-op via the steady gate regardless of the
/// baseline, so it is vacuous; the changed render exposes the baseline state.)
#[test]
fn clear_then_restore_leaves_baseline_in_sync() {
    // After restore the baseline == "live\nCHANGED?"; the changed render shares
    // the unchanged FIRST line "live" with that baseline. An INCREMENTAL diff
    // SKIPS the unchanged "live" line (it emits a `cursorNextLine` and does NOT
    // re-write the literal "live"), then writes only the changed second line.
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&frame_params("live\nframe", 2, 100, ""));
    let _ = w.clear();
    let _ = w.restore_last_output();
    let after_restore =
        String::from_utf8(w.write_frame(&frame_params("live\nCHANGED", 2, 100, ""))).unwrap();

    // A from-EMPTY bootstrap (no restore) instead re-emits the WHOLE frame
    // verbatim, so it CONTAINS the unchanged "live" as literal written text.
    let mut bare = FrameWriter::new();
    let _ = bare.write_frame(&frame_params("live\nframe", 2, 100, ""));
    let _ = bare.clear();
    let bootstrap =
        String::from_utf8(bare.write_frame(&frame_params("live\nCHANGED", 2, 100, ""))).unwrap();

    // Structural distinguisher (verified against the live diff bytes, not a
    // length heuristic): the from-empty bootstrap re-emits the WHOLE frame
    // verbatim, so it carries the unchanged first line as literal text "live\n".
    // The incremental restore-baseline diff SKIPS that line via cursor moves and
    // never re-writes the literal "live".
    assert!(
        bootstrap.contains("live\n"),
        "the from-empty bootstrap re-emits the unchanged 'live' line verbatim"
    );
    assert!(
        !after_restore.contains("live"),
        "after restore the baseline is re-pinned, so the changed render SKIPS the \
         unchanged 'live' line entirely (incremental, not bootstrap)"
    );
    // Both still write the CHANGED line.
    assert!(
        after_restore.contains("CHANGED") && bootstrap.contains("CHANGED"),
        "both renders write the changed line"
    );
}

/// `last_output_to_render` is recorded as the NEWLINE-PADDED form for a
/// non-fullscreen frame and the RAW form for a fullscreen frame, mirroring ink's
/// `this.lastOutputToRender` (`output + '\n'` vs `output`).
///
/// MUTATION (cargo): if `record_frame` stored the raw `output` for the
/// non-fullscreen case (dropping the pad), `sync_baseline`/`restore_last_output`
/// would re-pin/repaint an UNPADDED frame and the steady-branch no-op gate would
/// drift — these equalities flip.
#[test]
fn last_output_to_render_padding_matches_fullscreen_policy() {
    // Non-fullscreen (height 2 < viewport 100): padded with a trailing newline.
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&frame_params("x\ny", 2, 100, ""));
    assert_eq!(
        w.last_output_to_render, "x\ny\n",
        "non-fullscreen frame records the newline-padded output_to_render"
    );

    // Fullscreen (height 3 >= viewport 3): raw, no trailing newline.
    let mut fs = FrameWriter::new();
    let _ = fs.write_frame(&frame_params("a\nb\nc", 3, 3, ""));
    assert_eq!(
        fs.last_output_to_render, "a\nb\nc",
        "fullscreen frame records the raw output as output_to_render"
    );
}

/// `forget_last_output()` zeroes ONLY `last_output` + `last_output_to_render`
/// (mirroring ink `resized()` `lastOutput = ''; lastOutputToRender = ''`),
/// PRESERVING `last_output_height` and `full_static_output`. The narrowness is
/// the discriminator vs `reset_diff_state` (which zeroes everything): a
/// `forget_last_output` that over-zeroed `full_static_output`/`last_output_height`
/// would flip the preservation assertions.
#[test]
fn forget_last_output_zeroes_only_last_output_fields() {
    let mut w = FrameWriter::new();
    // A static frame grows full_static_output; a tall frame sets last_output_height.
    let _ = w.write_frame(&frame_params("hi\nthere", 2, 100, "STATIC\n"));

    assert!(!w.last_output.is_empty(), "precondition: last_output dirty");
    assert!(
        !w.last_output_to_render.is_empty(),
        "precondition: last_output_to_render dirty"
    );
    let before_height = w.last_output_height;
    let before_static = w.full_static_output.clone();
    assert!(
        before_height != 0 && !before_static.is_empty(),
        "preconditions"
    );

    w.forget_last_output();

    assert_eq!(w.last_output, "", "last_output zeroed");
    assert_eq!(w.last_output_to_render, "", "last_output_to_render zeroed");
    assert_eq!(
        w.last_output_height, before_height,
        "last_output_height PRESERVED (narrower than reset_diff_state)"
    );
    assert_eq!(
        w.full_static_output, before_static,
        "full_static_output PRESERVED (narrower than reset_diff_state)"
    );
}

/// The resize-shrink composition (ink `resized()`): `clear()` erases + zeroes the
/// LineDiff baseline, `forget_last_output()` zeroes `last_output`, then the
/// re-render of a BYTE-IDENTICAL reflow is forced to REPAINT (no longer a steady
/// no-op via the `output == last_output` gate). Without `forget_last_output`, the
/// identical re-render would be a no-op (the bug P5 caught).
#[test]
fn forget_last_output_forces_repaint_on_identical_reflow() {
    let mut w = FrameWriter::new();
    let _ = w.write_frame(&frame_params("aa\nbb", 2, 100, ""));

    // Resize-shrink gesture: clear() (erase + zero diff baseline) then forget.
    let _ = w.clear();
    w.forget_last_output();

    // Re-render the SAME content: must NOT be a no-op — it bootstraps a full
    // repaint (baseline empty AND last_output empty).
    let repaint = w.write_frame(&frame_params("aa\nbb", 2, 100, ""));
    assert!(
        !repaint.is_empty(),
        "after clear()+forget_last_output(), an identical reflow must repaint, not no-op"
    );

    // Control: WITHOUT forget_last_output, the same clear()+identical re-render is
    // a steady no-op (last_output still == output), proving forget is load-bearing.
    let mut control = FrameWriter::new();
    let _ = control.write_frame(&frame_params("aa\nbb", 2, 100, ""));
    let _ = control.clear();
    let control_repaint = control.write_frame(&frame_params("aa\nbb", 2, 100, ""));
    assert!(
        control_repaint.is_empty(),
        "without forget_last_output, the identical re-render no-ops (last_output unchanged)"
    );
}

/// #118: `reset_static_output()` — the `<Static>` IDENTITY-change reset (ink's
/// `handleStaticChange`, `ink.tsx:522-525`: `fullStaticOutput = ''`). Zeroes
/// ONLY `full_static_output`, so the clear branch — the sole consumer of the
/// accumulator (`clearTerminal + full_static_output + output`) — never replays
/// a dead `<Static>` instance's chunks, while content accumulated AFTER the
/// reset (the NEW instance) still replays. Everything else (`last_output*`,
/// `last_output_height`, diff baseline) must SURVIVE: the identity change
/// happens mid-stream against a live on-screen frame.
///
/// Mutation-discriminating: deleting the `reset_static_output()` call flips the
/// `S:dead` assertion (the dead chunk replays); over-widening the reset to
/// `reset_diff_state`-style zeroing flips the preservation assertions and the
/// clear-branch engagement (a zeroed `last_output_height` changes the clear
/// decision's `had_previous_frame`).
#[test]
fn reset_static_output_drops_dead_static_only() {
    let mut w = FrameWriter::new();
    // The dead <Static> instance emits, accumulating into the writer.
    let _ = w.write_frame(&frame_params("live-1", 1, 6, "S:dead\n"));
    assert_eq!(
        w.full_static_output, "S:dead\n",
        "precondition: the dead instance's chunk accumulated"
    );
    let before_last = w.last_output.clone();
    let before_to_render = w.last_output_to_render.clone();
    let before_height = w.last_output_height;

    // The identity change fires (JS handleStaticChange → napi → here).
    w.reset_static_output();

    assert_eq!(w.full_static_output, "", "full_static_output zeroed");
    assert_eq!(w.last_output, before_last, "last_output PRESERVED");
    assert_eq!(
        w.last_output_to_render, before_to_render,
        "last_output_to_render PRESERVED"
    );
    assert_eq!(
        w.last_output_height, before_height,
        "last_output_height PRESERVED"
    );

    // The NEW instance emits and re-accumulates normally...
    let _ = w.write_frame(&frame_params("live-2", 1, 6, "S:fresh\n"));
    assert_eq!(
        w.full_static_output, "S:fresh\n",
        "post-reset accumulation restarts from the new instance only"
    );

    // ...and the next overflowing frame hits the CLEAR branch (7 > 6 rows with a
    // previous frame), replaying ONLY the fresh chunk.
    let bytes = w.write_frame(&frame_params("a\nb\nc\nd\ne\nf\ng", 7, 6, ""));
    let s = String::from_utf8(bytes).expect("frame bytes are utf8");
    assert!(
        s.contains("\u{001B}[2J"),
        "the overflow frame engages the clear branch: {s:?}"
    );
    assert!(
        !s.contains("S:dead"),
        "the dead <Static> instance's chunk must NOT replay: {s:?}"
    );
    assert!(
        s.contains("S:fresh"),
        "the new <Static> instance's chunk MUST replay: {s:?}"
    );
}

/// `clear()` ZEROES the cursor state (`previous_cursor_position` /
/// `cursor_was_shown`), mirroring ink `render.clear()` (`log-update.ts:319-322`:
/// `previousCursorPosition = undefined; cursorWasShown = false`). This is the
/// resize+cursor coherence pin: after a shown cursor, a resize-shrink `clear()`
/// must leave NO stale cursor — so the post-resize re-render (with a possibly
/// reflowed frame at the new width) composes a FRESH cursor from `previous=None`
/// (no stale `buildReturnToBottom` re-home against an old-width row).
#[test]
fn clear_zeroes_cursor_state() {
    let mut w = FrameWriter::new();
    // Establish a shown cursor: frame 0 no cursor, frame 1 cursor {x:1,y:0}.
    let _ = w.write_frame(&cursor_params("hi", None));
    let _ = w.write_frame(&cursor_params("hi", Some(pos(1, 0))));
    assert_eq!(
        w.previous_cursor_position,
        Some(pos(1, 0)),
        "precondition: a cursor is recorded as shown"
    );
    assert!(w.cursor_was_shown, "precondition: cursor_was_shown is true");

    let _ = w.clear();

    assert_eq!(
        w.previous_cursor_position, None,
        "clear() zeroes previous_cursor_position (no stale re-home after resize)"
    );
    assert!(
        !w.cursor_was_shown,
        "clear() zeroes cursor_was_shown (no stale hide-prefix after resize)"
    );
}

// ── compose_console_write / compose_console_prefix / compose_console_suffix ───

/// Helper: render one interactive TTY frame and return the writer.
fn rendered_writer(text: &str) -> FrameWriter {
    let mut w = FrameWriter::new();
    let params = FrameParams {
        is_tty: true,
        viewport_rows: 24,
        output: text,
        output_height: 1,
        static_output: "",
        is_unmounting: false,
        cursor_dirty: false,
        cursor: None,
        interactive: Some(true),
        is_in_ci: false,
        debug: false,
    };
    let _ = w.write_frame(&params);
    w
}

/// compose_console_write with sync=true produces bytes identical to manual
/// clear() + data + restoreLastOutput(), BSU/ESU-wrapped.
#[test]
fn compose_console_write_rendered_sync() {
    let mut w = rendered_writer("hello");
    let data = b"APP DATA";
    let composed = w.compose_console_write(data, true);

    // Manual concatenation: bsu + clear + data + restore + esu.
    let mut w2 = rendered_writer("hello");
    let clear = w2.clear();
    let restore = w2.restore_last_output();
    let mut expected = Vec::new();
    expected.extend_from_slice(bsu.as_bytes());
    expected.extend_from_slice(&clear);
    expected.extend_from_slice(data);
    expected.extend_from_slice(&restore);
    expected.extend_from_slice(esu.as_bytes());

    assert_eq!(
        composed, expected,
        "compose_console_write(sync=true) is byte-identical to manual bsu+clear+data+restore+esu"
    );
}

/// compose_console_write with sync=false omits BSU/ESU.
#[test]
fn compose_console_write_rendered_nosync() {
    let mut w = rendered_writer("hello");
    let data = b"APP DATA";
    let composed = w.compose_console_write(data, false);

    let mut w2 = rendered_writer("hello");
    let clear = w2.clear();
    let restore = w2.restore_last_output();
    let mut expected = Vec::new();
    expected.extend_from_slice(&clear);
    expected.extend_from_slice(data);
    expected.extend_from_slice(&restore);

    assert_eq!(
        composed, expected,
        "compose_console_write(sync=false) is byte-identical to manual clear+data+restore (no BSU/ESU)"
    );
}

/// In the nothing-rendered-yet state, clear() and restoreLastOutput() are
/// empty, so compose_console_write yields bsu? + data + esu?.
#[test]
fn compose_console_write_nothing_rendered_sync() {
    let mut w = FrameWriter::new();
    let data = b"EARLY DATA";
    let composed = w.compose_console_write(data, true);

    let mut expected = Vec::new();
    expected.extend_from_slice(bsu.as_bytes());
    expected.extend_from_slice(data);
    expected.extend_from_slice(esu.as_bytes());

    assert_eq!(
        composed, expected,
        "nothing-rendered sync: bsu+data+esu (clear/restore empty)"
    );
}

#[test]
fn compose_console_write_nothing_rendered_nosync() {
    let mut w = FrameWriter::new();
    let data = b"EARLY DATA";
    let composed = w.compose_console_write(data, false);

    assert_eq!(
        composed,
        data.to_vec(),
        "nothing-rendered nosync: just data"
    );
}

/// compose_console_prefix returns bsu? + clear(), byte-identical to manual.
#[test]
fn compose_console_prefix_rendered() {
    let mut w = rendered_writer("hello");
    let prefix = w.compose_console_prefix(true);

    let mut w2 = rendered_writer("hello");
    let clear = w2.clear();
    let mut expected = Vec::new();
    expected.extend_from_slice(bsu.as_bytes());
    expected.extend_from_slice(&clear);

    assert_eq!(
        prefix, expected,
        "compose_console_prefix(sync=true) is bsu+clear"
    );
}

#[test]
fn compose_console_prefix_nosync() {
    let mut w = rendered_writer("hello");
    let prefix = w.compose_console_prefix(false);

    let mut w2 = rendered_writer("hello");
    let clear = w2.clear();

    assert_eq!(
        prefix, clear,
        "compose_console_prefix(sync=false) is just clear"
    );
}

/// compose_console_suffix returns restoreLastOutput() + esu?, byte-identical
/// to manual.
#[test]
fn compose_console_suffix_rendered() {
    // Precondition: a clear() was called first (prefix before suffix).
    let mut w = rendered_writer("hello");
    let _ = w.compose_console_prefix(true);
    let suffix = w.compose_console_suffix(true);

    let mut w2 = rendered_writer("hello");
    let _ = w2.clear();
    let restore = w2.restore_last_output();
    let mut expected = Vec::new();
    expected.extend_from_slice(&restore);
    expected.extend_from_slice(esu.as_bytes());

    assert_eq!(
        suffix, expected,
        "compose_console_suffix(sync=true) is restore+esu"
    );
}

#[test]
fn compose_console_suffix_nosync() {
    let mut w = rendered_writer("hello");
    let _ = w.compose_console_prefix(false);
    let suffix = w.compose_console_suffix(false);

    let mut w2 = rendered_writer("hello");
    let _ = w2.clear();
    let restore = w2.restore_last_output();

    assert_eq!(
        suffix, restore,
        "compose_console_suffix(sync=false) is just restore"
    );
}

/// Full prefix→data→suffix roundtrip produces the same bytes as compose_console_write.
#[test]
fn prefix_data_suffix_concatenation_matches_fused() {
    let data = b"STDERR DATA";
    // Fused path.
    let mut w1 = rendered_writer("world");
    let fused = w1.compose_console_write(data, true);

    // 3-write path: prefix → data → suffix, concatenated.
    let mut w2 = rendered_writer("world");
    let prefix = w2.compose_console_prefix(true);
    let suffix = w2.compose_console_suffix(true);
    let mut three_write = Vec::new();
    three_write.extend_from_slice(&prefix);
    three_write.extend_from_slice(data);
    three_write.extend_from_slice(&suffix);

    assert_eq!(
        fused, three_write,
        "prefix+data+suffix byte-identical to fused compose_console_write"
    );
}
