// crates/atomcode-tuix/src/render/screen.rs
//
// Retained-mode screen buffer — the backbone of the Ink-style
// renderer. Owns two parallel `W × H` cell grids:
//
//   * `cells`      — the frame we are *currently building*. Widget
//                    draws (footer / body / menu) mutate this before
//                    `render_diff` is called.
//   * `prev_cells` — the frame we *last emitted to the terminal*.
//                    Diff basis for the next paint.
//
// `render_diff` computes the patch stream from (prev → current),
// serialises it to ANSI bytes, swaps the frames (current becomes
// prev, prev becomes the fresh scratch we'll next rebuild into) and
// blanks the new scratch so partial draws don't leave stale cells.
//
// Design notes vs. the previous immediate-mode path:
//
//   * **No DECSTBM scroll region**: footer and body share one grid.
//     Scrolling the body is `scroll_up(bottom, n)` — an O(bottom)
//     memcpy inside the grid; terminal-side scrolling happens only
//     via the diff (blank cells appear at the bottom, content that
//     was there now lives higher).
//
//   * **No separate cache invalidation path**: `invalidate()` fills
//     `prev_cells` with blanks so the next `render_diff` emits
//     everything currently in `cells` as if cold-starting. Covers
//     resume-from-external, resize, and any "terminal state is
//     unknown" situation uniformly.
//
//   * **Cursor and visibility** are frame-level state. Visibility is
//     hidden at the head of the diff and restored at the tail; cursor
//     position parks at the tail. Never interleaved with cell writes,
//     so the caret can't be seen jumping across the screen between
//     patches. Frames that emit no work skip the wrap entirely.

use std::io::Write as _;

use super::cell::{diff_cell_frames, serialize_patches, Cell};

/// Retained W×H cell grid + current/prev frames.
///
/// Indexing: `cells[row][col]` with `row ∈ 0..height`,
/// `col ∈ 0..width`. ANSI emit converts to 1-indexed at the
/// boundary.
pub struct Screen {
    cells: Vec<Vec<Cell>>,
    prev_cells: Vec<Vec<Cell>>,
    width: u16,
    height: u16,
    /// Where to park the terminal cursor after the frame emits.
    /// `None` means "leave it wherever the last patch left it" —
    /// typically only useful in tests.
    cursor: Option<(u16, u16)>,
    cursor_visible: bool,
    /// Set when the physical terminal state is known to be out of sync
    /// with `prev_cells` — typically because a caller invoked
    /// `invalidate()` after a direct-stdout write or session-state
    /// change. The next `render_diff` prepends a per-row CUP+EL so the
    /// terminal starts from a known-blank canvas before the diff
    /// patches put content back. Without this, the cell-diff alone
    /// can't fix the staleness: if the new frame's `cells` are blank
    /// at a column whose `prev_cells` is ALSO blank (post-invalidate),
    /// no patch is emitted and the stale physical glyph survives —
    /// the "right-edge ghost row tail" symptom.
    physical_dirty: bool,
}

impl Screen {
    pub fn new(width: u16, height: u16) -> Self {
        let row = vec![Cell::blank(); width as usize];
        let frame = vec![row; height as usize];
        Self {
            cells: frame.clone(),
            prev_cells: frame,
            width,
            height,
            cursor: None,
            cursor_visible: true,
            physical_dirty: false,
        }
    }

    pub fn width(&self) -> u16 {
        self.width
    }

    pub fn height(&self) -> u16 {
        self.height
    }

    /// Reset every cell of the current frame to a blank with default
    /// style. O(W·H). Typically called by `render_diff` after a swap
    /// so the next draw cycle starts from a clean scratch.
    pub fn clear(&mut self) {
        let blank = Cell::blank();
        for row in &mut self.cells {
            for c in row {
                *c = blank.clone();
            }
        }
    }

    /// Write `cells` starting at `(row, col)` in the current frame.
    /// Out-of-bounds rows are silently skipped (so callers don't
    /// need to clamp every time); cols beyond `width` are truncated
    /// to the right edge.
    ///
    /// Cells with `width == 2` (wide CJK / emoji) should have a
    /// following `Cell::continuation()` from the caller — this method
    /// itself doesn't auto-insert them. `push_str_cells` on the
    /// caller side handles that invariant.
    pub fn draw_row(&mut self, row: usize, col: usize, cells: &[Cell]) {
        if row >= self.cells.len() {
            return;
        }
        let target = &mut self.cells[row];
        for (i, cell) in cells.iter().enumerate() {
            let dst_col = col + i;
            if dst_col >= target.len() {
                break;
            }
            target[dst_col] = cell.clone();
        }
    }

    /// Park the terminal cursor at `(row, col)` (1-indexed ANSI
    /// coords) at the end of the next `render_diff`. Typically
    /// pointed at the input prompt's insertion cell.
    pub fn set_cursor(&mut self, row: u16, col: u16) {
        self.cursor = Some((row, col));
    }

    /// Toggle DECTCEM cursor visibility for the next `render_diff`.
    /// Used to hide the cursor while a live body spinner is animating
    /// (otherwise it sits at the end of "Pondering… · 5s" and blinks).
    /// `render_diff` re-emits this every frame, so flipping the flag
    /// once is enough — every subsequent paint reasserts it.
    pub fn set_cursor_visible(&mut self, visible: bool) {
        self.cursor_visible = visible;
    }

    /// Read the currently-set cursor position (if any). Used by callers
    /// that emitted out-of-band writes after the last render_diff and
    /// want to re-anchor the terminal cursor without doing another full
    /// diff cycle.
    pub fn peek_cursor(&self) -> Option<(u16, u16)> {
        self.cursor
    }

    /// Scroll the top `bottom` rows up by `n`. Rows `[0..n)` are
    /// dropped; rows `[n..bottom)` slide to `[0..bottom-n)`; rows
    /// `[bottom-n..bottom)` become blank, ready for new content.
    /// Rows `[bottom..height)` (typically the fixed footer) are
    /// untouched.
    ///
    /// Used for body "append a line" semantics in retained mode:
    /// scroll the whole body region up by one, then draw the new
    /// line at `bottom - 1`.
    pub fn scroll_up(&mut self, bottom: usize, n: usize) {
        if n == 0 || bottom == 0 {
            return;
        }
        let n = n.min(bottom);
        let blank_row = vec![Cell::blank(); self.width as usize];
        // `rotate_left` on the `[0..bottom)` slice slides the first
        // `n` rows to the end of the slice — logically "scroll up".
        // `Vec<Cell>` isn't `Copy`, so `copy_within` won't work;
        // `rotate_left` moves (not copies) so it's valid for owned
        // row vectors.
        self.cells[0..bottom].rotate_left(n);
        // The rows we just rotated to the end of the window hold
        // stale content (what was at the top). Blank them for new
        // content to land into.
        for row_idx in (bottom - n)..bottom {
            self.cells[row_idx] = blank_row.clone();
        }
    }

    /// Produce the ANSI patch stream for (prev → current). Swaps
    /// frames at the end so the `cells` we just rendered becomes
    /// the next diff's `prev_cells`. Scratches `cells` to blank so
    /// the next draw cycle starts clean — callers must re-draw
    /// every widget every frame (retained-mode invariant).
    pub fn render_diff(&mut self) -> Vec<u8> {
        let mut body = Vec::new();
        let cold_start = self.physical_dirty;
        if self.physical_dirty {
            // Cold-start the physical terminal: per-row CUP+EL across
            // every screen row, then `\x1b[H` to home. The subsequent
            // diff patches put cells's content back. This costs ~8
            // bytes per row plus the home — only paid when callers
            // explicitly signal that physical state is unknown via
            // `invalidate()`. Without this, body rows whose cells go
            // from "long content" (last frame) to "short content"
            // (this frame, with `prev_cells` blanked by invalidate)
            // leave the long row's tail on the physical terminal
            // forever — the cell-diff sees `cells=blank == prev=blank`
            // for the trailing columns and emits no patch, so the
            // stale glyphs survive.
            let h = self.cells.len();
            body.reserve(h * 8 + 4);
            for row in 1..=h {
                let _ = write!(&mut body, "\x1b[{};1H\x1b[K", row);
            }
            body.extend_from_slice(b"\x1b[H");
            self.physical_dirty = false;
        }
        let patches = diff_cell_frames(&self.prev_cells, &self.cells);
        let patch_bytes = serialize_patches(&patches);
        body.extend_from_slice(&patch_bytes);
        // Anti-flicker wrap around any frame that moves the caret.
        // `serialize_patches` walks the cursor across every changed
        // cell via CUP+glyph; the next frame parks it back at the
        // input prompt. With DECTCEM on throughout, on streaming
        // bodies the user perceives the caret zooming across the
        // screen at 50fps and snapping back — looks like the input
        // box is "blinking". `?25l` at frame start keeps the caret
        // invisible during the patch walk; DECSET 2026 (synchronized
        // output) lets capable hosts — Windows Terminal, iTerm2,
        // kitty, wezterm, alacritty — defer paint until the whole
        // patch lands, hiding intermediate state entirely. Older
        // terminals ignore unknown DEC private modes per spec.
        let has_visible_work = cold_start || !patch_bytes.is_empty();
        let mut out = Vec::with_capacity(body.len() + 32);
        if has_visible_work {
            out.extend_from_slice(b"\x1b[?2026h\x1b[?25l");
        }
        out.extend_from_slice(&body);
        if let Some((r, c)) = self.cursor {
            let _ = write!(&mut out, "\x1b[{};{}H", r, c);
        }
        if self.cursor_visible {
            out.extend_from_slice(b"\x1b[?25h");
        } else {
            out.extend_from_slice(b"\x1b[?25l");
        }
        if has_visible_work {
            out.extend_from_slice(b"\x1b[?2026l");
        }
        std::mem::swap(&mut self.prev_cells, &mut self.cells);
        // Clear the new scratch. Without this, stale cells from
        // N frames ago would be diffed against next frame and
        // generate patches that erase content that actually
        // belongs on screen.
        self.clear();
        out
    }

    /// Force the next `render_diff` to emit every non-blank cell as
    /// if prev were all-blank. Called after `resume_from_external`,
    /// `resize`, or any other event that leaves terminal state
    /// unknown. Safe to call even when prev is already blank
    /// (just produces no additional emit).
    pub fn invalidate(&mut self) {
        // Sentinel (not Cell::blank) so the next diff sees EVERY cell as
        // changed — including positions where both the stale frame and
        // the upcoming next frame happen to hold default-style spaces.
        // The blank-match-blank suppression was the leak source for the
        // win10 + pwsh7 + zh_CN char-doubling bug: direct-write left
        // stale glyphs at columns the diff then declined to repaint.
        // See `Cell::sentinel` for the full write-up.
        let sentinel_row = vec![Cell::sentinel(); self.width as usize];
        for row in &mut self.prev_cells {
            *row = sentinel_row.clone();
        }
        // Mark physical state unknown so the next render_diff begins
        // with a cold-start per-row CUP+EL — see `Screen::physical_dirty`
        // for the failure mode this guards against (right-edge ghost
        // tail after invalidate + shrink).
        self.physical_dirty = true;
    }

    /// Blank `prev_cells` for rows `[start_row, height)`. Used by callers
    /// that wrote `\x1b[0J` (erase-to-end-of-display) directly to stdout
    /// from `start_row` down: the physical terminal is now blank in that
    /// region, but `prev_cells` still holds the cells of whatever was
    /// there last frame. Without resyncing, the next `render_diff` may
    /// suppress a patch for a row whose new cells COINCIDENTALLY match
    /// the stale `prev_cells` (e.g. a top_rule full of `─` lining up
    /// with a stale bot_rule full of the same `─`) — and the row stays
    /// physically blank because the diff thinks no change is needed.
    ///
    /// Mirrors `invalidate()` (which blanks everything) and
    /// `shift_prev_up()` (which scrolls then blanks the bottom n rows)
    /// — same shape, different region. The companion direct-stdout
    /// write is the caller's responsibility; this method only updates
    /// the diff cache to match what the caller already told the
    /// terminal to do.
    pub fn invalidate_rows_from(&mut self, start_row: usize) {
        // Sentinel — see `invalidate` and `Cell::sentinel` for why this
        // is NOT `Cell::blank`. This is the main hot path for the bug:
        // every push_body_row → emit_body_line_inner direct-write hits
        // this. With Cell::blank prev, the follow-up cell-diff treated
        // a row of `   X   Y   ` as "only patch X and Y, the spaces
        // already match" — letting stale wide-char right-halves from a
        // 1-col-off direct-write linger. Sentinel forces every column
        // through the diff, including the spaces.
        let sentinel_row = vec![Cell::sentinel(); self.width as usize];
        let h = self.prev_cells.len();
        for r in start_row.min(h)..h {
            self.prev_cells[r] = sentinel_row.clone();
        }
    }

    /// Shift the recorded `prev_cells` up by `n` rows, blanking the
    /// freed rows at the bottom. Used when the caller has triggered
    /// a TERMINAL-side scroll (e.g. emitted `CUP(h,1) + LF` to push
    /// the visible top into native scrollback): the physical terminal
    /// state has shifted but our prev-frame cache hasn't. Re-syncing
    /// here lets the next `render_diff` compute correct deltas
    /// against reality instead of emitting a full-frame repaint.
    ///
    /// Mirrors `scroll_up`'s rotate-then-blank shape but operates on
    /// `prev_cells` (the diff basis), not `cells` (the next-frame
    /// scratch). `cells` is left untouched because the standard paint
    /// cycle clears it at the end of every `render_diff` anyway —
    /// the next `paint_frame` re-populates it from scratch.
    pub fn shift_prev_up(&mut self, n: usize) {
        let h = self.prev_cells.len();
        if n == 0 || h == 0 {
            return;
        }
        let n = n.min(h);
        // Sentinel for the freed-by-scroll rows — same reasoning as the
        // other invalidate paths. The caller just told the terminal "LF
        // at the bottom row" which promoted the top into native
        // scrollback and left the bottom slot blank; we sentinel-fill
        // that slot so the next render_diff repaints every cell of it,
        // not just the non-space ones.
        let sentinel_row = vec![Cell::sentinel(); self.width as usize];
        self.prev_cells.rotate_left(n);
        for row_idx in (h - n)..h {
            self.prev_cells[row_idx] = sentinel_row.clone();
        }
    }

    /// Rebuild for new dimensions. Current and prev frames are
    /// discarded — the caller must re-draw every widget before
    /// the next `render_diff`.
    pub fn resize(&mut self, width: u16, height: u16) {
        *self = Self::new(width, height);
    }

    /// Peek at the last-emitted frame. Used by tests and the
    /// diagnostic trace path (`tuix_trace!("FOOT", ...)`) to
    /// inspect "what is actually on screen right now" without
    /// reconstructing state from the ANSI byte stream. Not meant
    /// for normal rendering — that goes through `render_diff`.
    pub fn prev_cells_for_test(&self) -> &[Vec<Cell>] {
        &self.prev_cells
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::render::cell::{push_str_cells, CellStyle};

    #[test]
    fn new_screen_empty_frame_produces_no_content_patches() {
        // Two all-blank frames → diff emits zero cell patches. Only
        // trailing cursor-visibility control survives (the SGR reset
        // also does NOT emit because serialize_patches skips it when
        // no SGR was ever turned on).
        let mut s = Screen::new(10, 3);
        let bytes = s.render_diff();
        let out = String::from_utf8(bytes).unwrap();
        // Expect exactly the cursor-show sequence, nothing else.
        assert_eq!(out, "\x1b[?25h", "unexpected bytes: {:?}", out);
    }

    #[test]
    fn draw_row_emits_content_at_1_indexed_coords() {
        let mut s = Screen::new(20, 5);
        let mut cells = Vec::new();
        push_str_cells(&mut cells, "hello", &CellStyle::default());
        s.draw_row(2, 3, &cells);
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        assert!(out.contains("hello"), "missing content: {:?}", out);
        // Row 2 (0-indexed) → ANSI row 3; col 3 → ANSI col 4.
        assert!(out.contains("\x1b[3;4H"), "wrong cursor target: {:?}", out);
    }

    #[test]
    fn second_frame_with_same_content_emits_no_cells() {
        let mut s = Screen::new(20, 5);
        let mut cells = Vec::new();
        push_str_cells(&mut cells, "x", &CellStyle::default());
        s.draw_row(0, 0, &cells);
        let _ = s.render_diff(); // first frame emits 'x'
                                 // Redraw identical content — the render_diff above cleared
                                 // the scratch to blank, so we need to re-push.
        s.draw_row(0, 0, &cells);
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        assert!(
            !out.contains('x'),
            "identical re-draw should be a no-op diff: {:?}",
            out
        );
    }

    #[test]
    fn scroll_up_shifts_rows_drops_top() {
        let mut s = Screen::new(10, 5);
        let mut a = Vec::new();
        push_str_cells(&mut a, "AAA", &CellStyle::default());
        let mut b = Vec::new();
        push_str_cells(&mut b, "BBB", &CellStyle::default());
        // Populate rows 0, 1.
        s.draw_row(0, 0, &a);
        s.draw_row(1, 0, &b);
        let _ = s.render_diff(); // swaps into prev, clears scratch
                                 // Re-draw the same content then scroll.
        s.draw_row(0, 0, &a);
        s.draw_row(1, 0, &b);
        s.scroll_up(2, 1);
        // After scroll_up(bottom=2, n=1):
        //   cells[0] = what was cells[1] = "BBB"
        //   cells[1] = blank
        // Diff against prev (row0="AAA", row1="BBB") →
        //   row 0: prev "AAA" vs now "BBB" → patches
        //   row 1: prev "BBB" vs now blank → blank patches
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        assert!(out.contains("BBB"), "row 0 should now show BBB");
    }

    /// `invalidate()` is the documented way for callers to signal
    /// "physical terminal state is unknown — repaint cold". The diff
    /// alone cannot restore correctness in that case: if the new frame's
    /// `cells` are blank at a column whose `prev_cells` was ALSO blanked
    /// by the invalidate, no patch is emitted and the stale physical
    /// glyph survives. The next render_diff after invalidate MUST
    /// therefore prepend a per-row CUP+EL so the terminal cold-starts.
    #[test]
    fn invalidate_prepends_per_row_clear_on_next_diff() {
        let mut s = Screen::new(10, 3);
        // Warm the prev_cells with content so the test mirrors the
        // "previous frame had content, now we invalidate" flow.
        let mut cells = Vec::new();
        push_str_cells(&mut cells, "hi", &CellStyle::default());
        s.draw_row(0, 0, &cells);
        let _ = s.render_diff();
        // Caller invalidates because physical state is now unknown.
        s.invalidate();
        // Next paint cycle: redraw the same content.
        s.draw_row(0, 0, &cells);
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        // Per-row CUP+EL must appear for every row before the content
        // patches — that's the cold-start cue for the physical
        // terminal.
        for row in 1..=3 {
            let needle = format!("\x1b[{};1H\x1b[K", row);
            assert!(
                out.contains(&needle),
                "invalidate should prepend CUP+EL for row {} on next render_diff; got: {:?}",
                row,
                out
            );
        }
        // Content must still emit.
        assert!(
            out.contains("hi"),
            "invalidated content should still re-emit: {:?}",
            out
        );
    }

    /// `invalidate()` is "one-shot": the cold-start cue fires on the
    /// NEXT render_diff and clears the flag, so subsequent frames go
    /// back to the cheap incremental diff path.
    #[test]
    fn invalidate_cold_start_does_not_repeat_across_frames() {
        let mut s = Screen::new(10, 3);
        s.invalidate();
        let _ = s.render_diff(); // consumes the cold-start cue
        // Second frame: no invalidate, no cold start.
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        assert!(
            !out.contains("\x1b[1;1H\x1b[K"),
            "no cold-start CUP+EL on second frame after one-shot invalidate: {:?}",
            out
        );
    }

    #[test]
    fn invalidate_forces_cold_start_on_next_diff() {
        let mut s = Screen::new(10, 3);
        let mut cells = Vec::new();
        push_str_cells(&mut cells, "hi", &CellStyle::default());
        s.draw_row(0, 0, &cells);
        let _ = s.render_diff();
        // Same content, but invalidate → next diff emits full
        // cold-start patches for every non-blank cell.
        s.draw_row(0, 0, &cells);
        s.invalidate();
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        assert!(
            out.contains("hi"),
            "invalidate must force re-emit: {:?}",
            out
        );
    }

    #[test]
    fn resize_blanks_both_frames() {
        let mut s = Screen::new(10, 3);
        let mut cells = Vec::new();
        push_str_cells(&mut cells, "stuff", &CellStyle::default());
        s.draw_row(0, 0, &cells);
        let _ = s.render_diff();
        s.resize(20, 5);
        assert_eq!(s.width(), 20);
        assert_eq!(s.height(), 5);
        // After resize, drawing no content → empty diff.
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        assert!(
            !out.contains("stuff"),
            "old content must be gone after resize: {:?}",
            out
        );
    }

    #[test]
    fn shift_prev_up_drops_top_blanks_bottom() {
        // Populate two distinct rows then commit (swap into prev).
        let mut s = Screen::new(10, 4);
        let mut a = Vec::new();
        push_str_cells(&mut a, "AAA", &CellStyle::default());
        let mut b = Vec::new();
        push_str_cells(&mut b, "BBB", &CellStyle::default());
        s.draw_row(0, 0, &a);
        s.draw_row(1, 0, &b);
        let _ = s.render_diff();
        // prev_cells now: row0="AAA", row1="BBB", row2=blank, row3=blank.
        s.shift_prev_up(1);
        // Expect: row0="BBB", row1=blank, row2=blank, row3=blank.
        let prev = s.prev_cells_for_test();
        let row0_text: String = prev[0]
            .iter()
            .filter(|c| c.width > 0)
            .map(|c| c.ch)
            .collect::<String>();
        let row1_text: String = prev[1]
            .iter()
            .filter(|c| c.width > 0)
            .map(|c| c.ch)
            .collect::<String>();
        assert!(
            row0_text.trim_end().starts_with("BBB"),
            "prev row 0 should now be the old row 1 (BBB), got {:?}",
            row0_text
        );
        assert_eq!(
            row1_text.trim_end(),
            "",
            "prev row 1 should be blank after shift, got {:?}",
            row1_text
        );
    }

    #[test]
    fn shift_prev_up_zero_is_noop() {
        let mut s = Screen::new(10, 3);
        let mut a = Vec::new();
        push_str_cells(&mut a, "KEEP", &CellStyle::default());
        s.draw_row(0, 0, &a);
        let _ = s.render_diff();
        s.shift_prev_up(0);
        let row0_text: String = s.prev_cells_for_test()[0]
            .iter()
            .filter(|c| c.width > 0)
            .map(|c| c.ch)
            .collect::<String>();
        assert!(
            row0_text.trim_end().starts_with("KEEP"),
            "shift_prev_up(0) must be a no-op, got {:?}",
            row0_text
        );
    }

    #[test]
    fn set_cursor_emits_final_position() {
        let mut s = Screen::new(10, 3);
        s.set_cursor(2, 5);
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        assert!(out.contains("\x1b[2;5H"), "cursor park missing: {:?}", out);
    }

    /// Frames with cell work get wrapped in DECSET 2026 + DECTCEM
    /// hide/show so the caret doesn't visibly bounce across the screen
    /// during the patch walk. Empty frames skip the wrap to avoid
    /// wasting bytes on terminals that don't need it.
    #[test]
    fn nonempty_frame_wraps_in_sync_output_and_hides_cursor() {
        let mut s = Screen::new(10, 3);
        let mut cells = Vec::new();
        push_str_cells(&mut cells, "x", &CellStyle::default());
        s.draw_row(0, 0, &cells);
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        // BSU/ESU wrap exists and order is correct.
        let bsu = out.find("\x1b[?2026h").expect("BSU present");
        let esu = out.find("\x1b[?2026l").expect("ESU present");
        assert!(bsu < esu, "BSU must precede ESU: {:?}", out);
        // Hide-cursor sits immediately after BSU, before any cell write.
        assert!(
            out.starts_with("\x1b[?2026h\x1b[?25l"),
            "frame must open with BSU+hide: {:?}",
            out
        );
        // Visibility restored before ESU.
        assert!(
            out.ends_with("\x1b[?25h\x1b[?2026l"),
            "frame must close with show+ESU: {:?}",
            out
        );
        // Cell content lands inside the wrap.
        let x_pos = out.find('x').expect("x rendered");
        assert!(bsu < x_pos && x_pos < esu, "cell write must sit inside wrap: {:?}", out);
    }

    #[test]
    fn empty_frame_skips_sync_output_wrap() {
        let mut s = Screen::new(10, 3);
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        assert!(!out.contains("\x1b[?2026h"), "no BSU on empty frame: {:?}", out);
        assert!(!out.contains("\x1b[?2026l"), "no ESU on empty frame: {:?}", out);
        assert!(!out.contains("\x1b[?25l"), "no hide on empty frame: {:?}", out);
    }

    /// Invalidate triggers a cold-start CUP+EL walk across every row —
    /// the cursor moves a lot. That work must also be wrapped in the
    /// sync/hide envelope.
    #[test]
    fn invalidate_cold_start_is_wrapped() {
        let mut s = Screen::new(10, 3);
        s.invalidate();
        let bytes = s.render_diff();
        let out = String::from_utf8_lossy(&bytes);
        assert!(out.starts_with("\x1b[?2026h\x1b[?25l"), "cold-start frame must open with BSU+hide: {:?}", out);
        assert!(out.ends_with("\x1b[?25h\x1b[?2026l"), "cold-start frame must close with show+ESU: {:?}", out);
    }
}