// crates/atomcode-tuix/src/test_term.rs
//
// In-process virtual terminal for retained-mode renderer tests.
//
// Problem this solves: we were testing "renderer emits the right
// ANSI bytes" (CountingSink / CapturingSink) and "cells contain
// the right glyph" (Screen::prev_cells_for_test), but the real
// question — "what does the terminal actually show after these
// bytes hit it?" — was only answered by user eyeballing a live
// terminal. The bot_rule-shortens / ghost-line / swallowed-char
// bugs all passed unit tests because the bytes and cells were
// correct, even when terminals rendered them wrong.
//
// `VirtualTerminal` closes that loop: it consumes the ANSI stream
// emitted by `RetainedRenderer` through the `vte` parser (the
// same one Alacritty uses) and reconstructs the on-screen 2D
// character grid exactly as a terminal would paint it. Tests can
// then assert on grid cells directly:
//
//   let (mut r, vterm) = new_vterm(80, 24);
//   r.render(UiLine::InputPrompt { buf: "hi".into(), .. });
//   r.flush_deferred();
//   vterm.feed_from(&r);
//   assert_eq!(vterm.char_at(22, 4), '❯');
//
// Coverage scope — only what `RetainedRenderer` actually emits:
//   * printable chars (including wide CJK / emoji — width-aware)
//   * LF `\n`  and CR `\r`
//   * CUP `\x1b[R;CH` absolute cursor position
//   * ED (erase display) `\x1b[2J` + cursor-home `\x1b[H`
//   * EL `\x1b[K` / `\x1b[2K` (in case we add clearing)
//   * SGR `\x1b[...m` bold / reverse / fg color (we only track
//     enough attributes to assert on them; bg / underline ignored)
//   * DECSET/DECRST `\x1b[?25h` / `\x1b[?25l` cursor visibility
//
// Sequences outside that set are silently absorbed — not an error,
// just "the terminal noticed but our model doesn't track it". When
// retained starts emitting something new, extend this parser.
//
// Not thread-safe, not `Send` — strictly a test helper.

#![cfg(test)]

use crossterm::style::Color;
use vte::{Params, Parser, Perform};

/// One cell of the reconstructed screen grid.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridCell {
    pub ch: char,
    pub bold: bool,
    pub faint: bool,
    pub reverse: bool,
    pub fg: Option<Color>,
}

impl Default for GridCell {
    fn default() -> Self {
        Self {
            ch: ' ',
            bold: false,
            faint: false,
            reverse: false,
            fg: None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Style {
    bold: bool,
    faint: bool,
    reverse: bool,
    fg: Option<Color>,
}

impl Default for Style {
    fn default() -> Self {
        Self {
            bold: false,
            faint: false,
            reverse: false,
            fg: None,
        }
    }
}

/// In-process VT terminal model — advance ANSI bytes, expose the
/// resulting 2D char grid + cursor + visibility state.
pub struct VirtualTerminal {
    width: u16,
    height: u16,
    grid: Vec<Vec<GridCell>>,
    /// 0-indexed (row, col) current cursor position. Advances on
    /// print, jumps on CUP.
    cursor_row: u16,
    cursor_col: u16,
    /// `\x1b[?25h/l` cursor visibility flag.
    cursor_visible: bool,
    style: Style,
    /// 0-indexed DECSTBM scroll region (inclusive). Defaults to the
    /// full screen; `\x1b[top;bottom r` updates it so LF at the
    /// bottom row scrolls only this strip (used by retained's body
    /// scrollback-push path).
    scroll_top: u16,
    scroll_bottom: u16,
    /// Rows that scrolled off the top of the DECSTBM region — these
    /// would live in the real terminal's scrollback buffer. Oldest
    /// first. Only grows when `scroll_top == 0` (region anchored to
    /// screen top, which is retained's shape), mirroring xterm: a
    /// non-top-anchored region drops exiting lines instead of
    /// promoting them to scrollback. Also grows via `ed_promotes_to_scrollback`.
    scrollback: Vec<Vec<GridCell>>,
    /// Model the macOS Terminal.app / iTerm2 style "ED copies visible
    /// content to scrollback before clearing" behaviour: `\x1b[2J`
    /// promotes every non-blank row into scrollback before blanking
    /// the grid. Flip on to reproduce the "welcome ends up in
    /// scrollback after footer-height transitions" user report.
    /// Default is off (matches xterm).
    ed_promotes_to_scrollback: bool,
}

impl VirtualTerminal {
    pub fn new(width: u16, height: u16) -> Self {
        let row = vec![GridCell::default(); width as usize];
        let grid = vec![row; height as usize];
        Self {
            width,
            height,
            grid,
            cursor_row: 0,
            cursor_col: 0,
            cursor_visible: true,
            style: Style::default(),
            scroll_top: 0,
            scroll_bottom: height.saturating_sub(1),
            scrollback: Vec::new(),
            ed_promotes_to_scrollback: false,
        }
    }

    /// Opt into the Terminal.app / iTerm2 "ED promotes to scrollback"
    /// behaviour. Call once after construction. Used by regression
    /// tests that need to reproduce behaviour-sensitive bugs like the
    /// 2J-on-footer-transition scrollback pollution.
    pub fn set_ed_promotes_to_scrollback(&mut self, on: bool) {
        self.ed_promotes_to_scrollback = on;
    }

    /// Scroll the current DECSTBM region up by one line: the row at
    /// `scroll_top` shifts out of the region. When the region is
    /// anchored to the screen top (`scroll_top == 0`) — xterm's
    /// contract and retained's exclusive shape — the exiting row is
    /// promoted to scrollback so tests can assert on duplicate or
    /// lost history. Other configurations drop the row, matching
    /// real terminal behaviour for mid-screen regions.
    fn scroll_region_up(&mut self) {
        let top = self.scroll_top as usize;
        let bot = self.scroll_bottom as usize;
        if top >= bot || bot >= self.grid.len() {
            return;
        }
        if top == 0 {
            self.scrollback.push(self.grid[0].clone());
        }
        for r in top..bot {
            self.grid[r] = self.grid[r + 1].clone();
        }
        let blank = vec![GridCell::default(); self.width as usize];
        self.grid[bot] = blank;
    }

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

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

    pub fn cursor(&self) -> (u16, u16) {
        (self.cursor_row, self.cursor_col)
    }

    pub fn cursor_visible(&self) -> bool {
        self.cursor_visible
    }

    /// Feed a slice of ANSI bytes into the vte parser and apply
    /// their effects to the grid.
    pub fn feed(&mut self, bytes: &[u8]) {
        // vte::Parser is stateless across `advance` calls only if
        // we create a fresh one each time, but that would drop
        // escape sequences split across feeds. We keep one parser
        // per terminal instance inside `feed_with_parser`.
        // Simplification: allocate a throwaway Parser — retained
        // emits each frame atomically and we feed one frame at a
        // time, so split sequences don't happen in practice.
        let mut parser: Parser = Parser::new();
        parser.advance(self, bytes);
    }

    /// 0-indexed (row, col) grid cell. Out-of-bounds returns a
    /// blank — callers generally pre-check dimensions.
    pub fn cell_at(&self, row: usize, col: usize) -> GridCell {
        self.grid
            .get(row)
            .and_then(|r| r.get(col))
            .cloned()
            .unwrap_or_default()
    }

    /// Reconstruct the text content of a single row (drops style).
    pub fn row_text(&self, row: usize) -> String {
        self.grid
            .get(row)
            .map(|r| r.iter().map(|c| c.ch).collect())
            .unwrap_or_default()
    }

    /// Scan every row and return true iff any row's text satisfies the
    /// predicate. Used by retained body tests where the exact row
    /// depends on the push ordering (scrollback-push model) but the
    /// body content should be present on-screen somewhere.
    pub fn any_row<F: FnMut(&str) -> bool>(&self, mut f: F) -> bool {
        (0..self.height as usize).any(|r| f(&self.row_text(r)))
    }

    /// Trailing-trimmed text of each row that has been pushed into
    /// scrollback (oldest first). Blank rows are preserved so row
    /// counts match what the terminal actually scrolled off — tests
    /// that care only about content can `.filter(|s| !s.is_empty())`.
    pub fn scrollback_texts(&self) -> Vec<String> {
        self.scrollback
            .iter()
            .map(|row| {
                row.iter()
                    .map(|c| c.ch)
                    .collect::<String>()
                    .trim_end()
                    .to_string()
            })
            .collect()
    }

    /// Total rows that have ever scrolled off the top of the DECSTBM
    /// region. Grows monotonically; used by regression tests that
    /// need to bound how many rows a footer-geometry change is
    /// allowed to push into scrollback (answer should be 0 — the
    /// repaint path must not re-scroll cached body rows).
    pub fn scrollback_len(&self) -> usize {
        self.scrollback.len()
    }

    /// Handy multi-line dump of the whole grid — useful inside
    /// assertion error messages so failures show what was painted.
    pub fn dump(&self) -> String {
        self.grid
            .iter()
            .enumerate()
            .map(|(r, row)| {
                let text: String = row.iter().map(|c| c.ch).collect();
                format!("{:>3} │{}│", r, text.trim_end_matches(' '))
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    // ── internal helpers ──

    fn put_char(&mut self, ch: char) {
        if self.cursor_row as usize >= self.grid.len() {
            return;
        }
        let row = &mut self.grid[self.cursor_row as usize];
        if (self.cursor_col as usize) < row.len() {
            row[self.cursor_col as usize] = GridCell {
                ch,
                bold: self.style.bold,
                faint: self.style.faint,
                reverse: self.style.reverse,
                fg: self.style.fg,
            };
        }
        // Advance cursor by display width (1 for narrow, 2 for
        // wide). Retained emits a wide glyph once and we account
        // for both cells; terminal auto-wrap is off in retained
        // (we never exceed the right edge on purpose).
        let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
        self.cursor_col = self.cursor_col.saturating_add(w);
    }

    fn apply_sgr(&mut self, params: &Params) {
        // `\x1b[m` (no params) is SGR 0 per ECMA-48.
        if params.is_empty() {
            self.style = Style::default();
            return;
        }
        // Flatten to a linear code stream — SGR 38/48 are
        // compound (38;5;N or 38;2;R;G;B) and need a sliding
        // window read. Each param in vte's `Params` is a
        // sub-group (semicolon-separated in CSI), and we only
        // ever use its first element (crossterm never emits
        // colon-separated sub-params).
        let codes: Vec<u16> = params.iter().filter_map(|p| p.first().copied()).collect();
        let mut i = 0;
        while i < codes.len() {
            let code = codes[i];
            match code {
                0 => self.style = Style::default(),
                1 => self.style.bold = true,
                2 => self.style.faint = true,
                // SGR 22 ("normal intensity") clears BOTH bold and faint —
                // there is no per-attribute toggle for faint (matches the
                // serializer in render/cell.rs).
                22 => {
                    self.style.bold = false;
                    self.style.faint = false;
                }
                7 => self.style.reverse = true,
                27 => self.style.reverse = false,
                39 => self.style.fg = None,
                30..=37 => self.style.fg = Some(ansi16_color(code - 30)),
                90..=97 => self.style.fg = Some(ansi16_color((code - 90) + 8)),
                38 => {
                    // Extended fg. `38;5;N` = 256-color indexed,
                    // `38;2;R;G;B` = truecolor. crossterm emits
                    // 38;5;N for basic Color variants (Red, Cyan,
                    // etc.) rather than the short 91/96 form.
                    if i + 2 < codes.len() && codes[i + 1] == 5 {
                        self.style.fg = Some(ansi16_color(codes[i + 2]));
                        i += 2;
                    } else if i + 4 < codes.len() && codes[i + 1] == 2 {
                        let r = codes[i + 2] as u8;
                        let g = codes[i + 3] as u8;
                        let b = codes[i + 4] as u8;
                        self.style.fg = Some(Color::Rgb { r, g, b });
                        i += 4;
                    }
                }
                // Italic (3/23), underline (4/24), bg (40-47, 100-107)
                // and other SGR codes retained doesn't emit — no-op.
                _ => {}
            }
            i += 1;
        }
    }
}

/// 0..=15 → crossterm basic Color enum. Values beyond 15 become
/// `Color::AnsiValue(n)` so the caller can still distinguish them
/// in assertions without us dragging in a 256-color palette.
fn ansi16_color(idx: u16) -> Color {
    match idx {
        0 => Color::Black,
        1 => Color::DarkRed,
        2 => Color::DarkGreen,
        3 => Color::DarkYellow,
        4 => Color::DarkBlue,
        5 => Color::DarkMagenta,
        6 => Color::DarkCyan,
        7 => Color::Grey,
        8 => Color::DarkGrey,
        9 => Color::Red,
        10 => Color::Green,
        11 => Color::Yellow,
        12 => Color::Blue,
        13 => Color::Magenta,
        14 => Color::Cyan,
        15 => Color::White,
        n => Color::AnsiValue(n as u8),
    }
}

impl Perform for VirtualTerminal {
    fn print(&mut self, c: char) {
        self.put_char(c);
    }

    fn execute(&mut self, byte: u8) {
        match byte {
            b'\n' => {
                // LF at the scroll-region bottom triggers a region
                // scroll-up; otherwise advance the cursor. This is
                // what DECSTBM does in a real terminal — our body
                // emit path relies on it to push rows into what
                // would be scrollback.
                if self.cursor_row == self.scroll_bottom {
                    self.scroll_region_up();
                } else if self.cursor_row + 1 < self.height {
                    self.cursor_row += 1;
                }
            }
            b'\r' => {
                self.cursor_col = 0;
            }
            // Tab / BEL / other C0 — no-op for our purposes.
            _ => {}
        }
    }

    fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
        match action {
            // CUP / HVP: absolute cursor position `\x1b[R;CH`
            'H' | 'f' => {
                let mut it = params.iter();
                let row = it.next().and_then(|p| p.first().copied()).unwrap_or(1);
                let col = it.next().and_then(|p| p.first().copied()).unwrap_or(1);
                self.cursor_row = (row.saturating_sub(1) as u16).min(self.height.saturating_sub(1));
                self.cursor_col = (col.saturating_sub(1) as u16).min(self.width.saturating_sub(1));
            }
            // ED: erase in display. `\x1b[2J` = whole screen,
            // `\x1b[J` / `\x1b[0J` = cursor to end of display,
            // `\x1b[1J` = start of display to cursor.
            'J' => {
                let mode = params
                    .iter()
                    .next()
                    .and_then(|p| p.first().copied())
                    .unwrap_or(0);
                let blank = GridCell::default();
                let blank_row = vec![blank; self.width as usize];
                match mode {
                    0 => {
                        // Cursor to end of display: erase from cursor to
                        // end of current row, then blank every row below.
                        let row_idx = self.cursor_row as usize;
                        let col_idx = self.cursor_col as usize;
                        if let Some(row) = self.grid.get_mut(row_idx) {
                            for col in col_idx..row.len() {
                                row[col] = GridCell::default();
                            }
                        }
                        for row in self.grid.iter_mut().skip(row_idx + 1) {
                            *row = blank_row.clone();
                        }
                    }
                    1 => {
                        // Start to cursor: blank every row above, then
                        // erase from start of current row up to and
                        // including the cursor column.
                        let row_idx = self.cursor_row as usize;
                        let col_idx = self.cursor_col as usize;
                        for row in self.grid.iter_mut().take(row_idx) {
                            *row = blank_row.clone();
                        }
                        if let Some(row) = self.grid.get_mut(row_idx) {
                            let end = (col_idx + 1).min(row.len());
                            for col in 0..end {
                                row[col] = GridCell::default();
                            }
                        }
                    }
                    2 => {
                        if self.ed_promotes_to_scrollback {
                            // Terminal.app / iTerm2 style: copy every
                            // non-blank visible row into scrollback before
                            // blanking. Preserves oldest-first order.
                            for row in &self.grid {
                                let non_blank = row.iter().any(|c| c.ch != ' ');
                                if non_blank {
                                    self.scrollback.push(row.clone());
                                }
                            }
                        }
                        for row in &mut self.grid {
                            *row = blank_row.clone();
                        }
                    }
                    _ => {}
                }
            }
            // EL: erase in line.
            'K' => {
                let mode = params
                    .iter()
                    .next()
                    .and_then(|p| p.first().copied())
                    .unwrap_or(0);
                if let Some(row) = self.grid.get_mut(self.cursor_row as usize) {
                    match mode {
                        0 => {
                            // cursor to end
                            for col in (self.cursor_col as usize)..row.len() {
                                row[col] = GridCell::default();
                            }
                        }
                        1 => {
                            // start to cursor
                            for col in
                                0..=(self.cursor_col as usize).min(row.len().saturating_sub(1))
                            {
                                row[col] = GridCell::default();
                            }
                        }
                        2 => {
                            // whole line
                            for cell in row.iter_mut() {
                                *cell = GridCell::default();
                            }
                        }
                        _ => {}
                    }
                }
            }
            // DECSTBM: `\x1b[top;bottom r` — inclusive, 1-indexed.
            // `\x1b[r` with no params resets to full screen.
            'r' => {
                let mut it = params.iter();
                let top = it.next().and_then(|p| p.first().copied()).unwrap_or(1);
                let bot = it
                    .next()
                    .and_then(|p| p.first().copied())
                    .unwrap_or(self.height as u16);
                let top0 = top.saturating_sub(1).min(self.height.saturating_sub(1));
                let bot0 = bot.saturating_sub(1).min(self.height.saturating_sub(1));
                if top0 < bot0 {
                    self.scroll_top = top0;
                    self.scroll_bottom = bot0;
                }
            }
            // SGR: `\x1b[...m`
            'm' => self.apply_sgr(params),
            // DECSET / DECRST: `\x1b[?...h` / `\x1b[?...l`
            'h' | 'l' if intermediates == b"?" => {
                let on = action == 'h';
                let code = params
                    .iter()
                    .next()
                    .and_then(|p| p.first().copied())
                    .unwrap_or(0);
                match code {
                    25 => self.cursor_visible = on,
                    // 7 (autowrap), 1049 (alt-screen), 2004 (bracketed
                    // paste) — retained is agnostic to these, no-op.
                    _ => {}
                }
            }
            _ => {
                // Everything else (cursor up/down/left/right, save,
                // restore, DECSTBM, etc.) — retained doesn't emit,
                // no-op is safe.
            }
        }
    }
}

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

    #[test]
    fn vt_prints_to_grid_at_cursor() {
        let mut vt = VirtualTerminal::new(10, 3);
        vt.feed(b"hello");
        assert_eq!(vt.row_text(0), "hello     ");
        assert_eq!(vt.cursor(), (0, 5));
    }

    #[test]
    fn vt_cup_jumps_cursor() {
        let mut vt = VirtualTerminal::new(10, 5);
        vt.feed(b"\x1b[3;5Habc");
        // ANSI row 3 col 5 → grid row 2 col 4 (both 0-indexed).
        assert_eq!(vt.row_text(2), "    abc   ");
        // After printing 3 chars, cursor sits at col 7 (4 + 3).
        assert_eq!(vt.cursor(), (2, 7));
    }

    #[test]
    fn vt_clear_screen_blanks_all_rows() {
        let mut vt = VirtualTerminal::new(5, 3);
        vt.feed(b"abc\r\nxyz\x1b[2J");
        assert!(vt.row_text(0).chars().all(|c| c == ' '));
        assert!(vt.row_text(1).chars().all(|c| c == ' '));
    }

    #[test]
    fn vt_sgr_bold_reverse_tracked_per_cell() {
        let mut vt = VirtualTerminal::new(10, 1);
        vt.feed(b"a\x1b[1mb\x1b[7mc\x1b[0md");
        assert!(!vt.cell_at(0, 0).bold); // 'a' plain
        assert!(vt.cell_at(0, 1).bold); // 'b' bold
        assert!(vt.cell_at(0, 2).bold); // 'c' bold + reverse
        assert!(vt.cell_at(0, 2).reverse);
        assert!(!vt.cell_at(0, 3).bold); // 'd' reset
    }

    #[test]
    fn vt_cjk_advances_two_cols() {
        let mut vt = VirtualTerminal::new(10, 1);
        vt.feed("你好".as_bytes());
        // Wide glyphs occupy cols 0,2 — col 1 / 3 stay blank in our
        // model (retained emits continuation cells as no-op, matching
        // terminal behaviour where col 1 is the right half of 你 and
        // not an addressable cell).
        assert_eq!(vt.cell_at(0, 0).ch, '你');
        assert_eq!(vt.cell_at(0, 2).ch, '好');
        assert_eq!(vt.cursor(), (0, 4));
    }

    #[test]
    fn vt_cursor_visibility_toggles() {
        let mut vt = VirtualTerminal::new(5, 1);
        assert!(vt.cursor_visible());
        vt.feed(b"\x1b[?25l");
        assert!(!vt.cursor_visible());
        vt.feed(b"\x1b[?25h");
        assert!(vt.cursor_visible());
    }

    /// Rows that scroll off the top of a top-anchored DECSTBM region
    /// are promoted to scrollback in oldest-first order. Retained's
    /// body emit path relies on this exact shape: the whole screen
    /// region (or `\x1b[1;Nr`) with LF at the bottom scrolling the
    /// top row into the real terminal's scrollback.
    #[test]
    fn vt_scrollback_captures_rows_exiting_top_anchored_region() {
        let mut vt = VirtualTerminal::new(6, 3);
        // Default region is full screen (top-anchored at row 0).
        // Fill 3 rows, then LF at bottom twice to push 2 more.
        vt.feed(b"row0\r\nrow1\r\nrow2");
        assert_eq!(vt.scrollback_len(), 0, "no scroll yet");
        // Cursor is at end of row2; LF at scroll_bottom triggers
        // scroll-up, pushing row0 into scrollback.
        vt.feed(b"\x1b[3;1H\nrow3");
        vt.feed(b"\x1b[3;1H\nrow4");
        let sb = vt.scrollback_texts();
        assert_eq!(sb, vec!["row0", "row1"]);
    }

    /// Mid-screen regions (`\x1b[2;5r`) don't promote exiting rows
    /// to scrollback — that matches xterm: only the screen-top region
    /// feeds the scrollback buffer.
    #[test]
    fn vt_scrollback_ignored_for_non_top_anchored_region() {
        let mut vt = VirtualTerminal::new(6, 5);
        vt.feed(b"\x1b[2;4r"); // region rows 2..4, not anchored at row 1
        vt.feed(b"\x1b[4;1H\n");
        vt.feed(b"\x1b[4;1H\n");
        assert_eq!(vt.scrollback_len(), 0);
    }

    /// Terminal.app / iTerm2 style ED promotion: opting in makes
    /// `\x1b[2J` copy every non-blank visible row into scrollback
    /// before clearing. Default (off) leaves scrollback untouched —
    /// this is the switch regression tests use to model the specific
    /// terminal behaviour that caused the "first-startup welcome
    /// appears twice" user report.
    #[test]
    fn vt_ed_promotes_visible_rows_to_scrollback_when_enabled() {
        let mut vt = VirtualTerminal::new(6, 3);
        vt.set_ed_promotes_to_scrollback(true);
        vt.feed(b"abc\r\nxyz");
        assert_eq!(vt.scrollback_len(), 0, "no ED yet");
        vt.feed(b"\x1b[2J");
        assert_eq!(
            vt.scrollback_texts(),
            vec!["abc", "xyz"],
            "ED should have promoted both non-blank rows"
        );
        // Grid is blank after the clear.
        assert!(vt.row_text(0).chars().all(|c| c == ' '));
        assert!(vt.row_text(1).chars().all(|c| c == ' '));
    }
}