//! Terminal wire types (Vterm Stage 3, protocol v19).
//!
//! A terminal is inherently a cell protocol: its identity buffer is an
//! empty CRDT anchor, so a semantic frontend that receives only
//! `BufferSnapshot` has nothing to draw. [`TerminalFrame`] carries the
//! complete visible grid instead — cells, cursor, per-view selection and
//! scroll state, and sanitized process metadata.
//!
//! Every limit needed to validate a frame lives here rather than in the
//! `pmacs` crate, so the daemon's pre-emission check and a frontend's
//! post-decode check run the SAME policy. [`TerminalFrame::validate`] is
//! that single structural policy. A second implementation of these rules
//! in a frontend is a bug, not a convenience.
//!
//! Glyph column width and wide-continuation topology moved to
//! [`crate::wire_grid`] in bottom-panel Stage 2B, which is now the
//! crate's only non-test `unicode-width` use: those rules are shared
//! with [`crate::panel::PanelFrame`]. The 512 per-axis PTY caps,
//! metadata, selection spans, and the `at_bottom`/`scroll_offset`
//! coupling stay here, because a panel does not inherit them.

use crate::cell::{Cell, CellCoord, CellSize};
use crate::ids::BufferId;

#[cfg(test)]
use unicode_width::UnicodeWidthStr;

// ---------------------------------------------------------------------------
// Shared limits
// ---------------------------------------------------------------------------

/// Maximum terminal rows accepted at creation, resize, or on the wire.
pub const MAX_TERMINAL_ROWS: u16 = 512;

/// Maximum terminal columns accepted at creation, resize, or on the wire.
pub const MAX_TERMINAL_COLS: u16 = 512;

/// Maximum visible terminal cells accepted at creation, resize, or on
/// the wire.
///
/// An alias of the shared wire-grid bound: this is transport safety, not
/// a PTY policy, so it must not drift from the panel's.
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS;

/// Maximum UTF-8 bytes retained in one terminal grapheme cluster.
///
/// An **alias** of the shared wire-grid bound, not an independent value.
/// The terminal screen truncates clusters to this constant while
/// [`crate::wire_grid`] validates against its own; if the two were
/// separate literals, raising one would make the producer emit clusters
/// its own validator rejects — or, worse, accept clusters no frontend
/// budgeted for. Keeping this a re-export means they cannot drift.
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES;

/// Shared cap for terminal title and process-outcome metadata.
pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;

/// Aggregate cap on glyph bytes carried by one [`TerminalFrame`].
///
/// The visible-cell bound alone does not bound a frame's encoded size:
/// every cell may legally carry a [`MAX_TERMINAL_GRAPHEME_BYTES`]
/// cluster, and `262_144 * 256` is 64 MiB — four times the transport's
/// [`crate::MAX_FRAME_BYTES`]. Raising the transport cap would widen the
/// allocation ceiling for every pre-handshake and established message on
/// every connection, so v19 bounds the terminal payload instead. The
/// protocol test `maximum_legal_terminal_frame_encodes_below_the_transport_cap`
/// measures the largest legal frame this bound admits and pins it below
/// the unchanged 16 MiB cap.
pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GLYPH_BYTES;

// ---------------------------------------------------------------------------
// Payload types
// ---------------------------------------------------------------------------

/// Process outcome published with a terminal frame.
///
/// `Signaled` and `Crashed` text is sanitized metadata subject to
/// [`MAX_TERMINAL_METADATA_BYTES`] and the control-character rule; it is
/// display text, never a host control effect.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum TerminalProcessState {
    /// Child is running, or termination has only been requested.
    Running,
    /// Child exited with a status code.
    Exited(i32),
    /// Child was terminated by a sanitized symbolic signal.
    Signaled(String),
    /// Supervision failed after the session was published.
    Crashed(String),
}

/// One selected span on one visible terminal row.
///
/// `start_col` is inclusive, `end_col` exclusive. A frame carries at most
/// one span per row, in strictly increasing row order.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TerminalSelectionSpan {
    /// Visible row.
    pub row: u32,
    /// Inclusive starting column.
    pub start_col: u32,
    /// Exclusive ending column.
    pub end_col: u32,
}

/// A complete visible terminal grid for one frontend/window view.
///
/// This is a whole-grid replacement, never a delta. Empty is NOT a clear
/// sentinel: valid dimensions are nonzero and `cells.len()` equals the
/// checked area exactly, so a receiver that decodes a valid frame always
/// has a complete picture.
///
/// `screen_generation` describes the published screen/title generation
/// only. Selection, scroll, viewport, and process state change WITHOUT
/// advancing it, so a producer must compare the complete ordered payload
/// — not that counter — when deciding whether to send.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TerminalFrame {
    /// Identity buffer whose session this frame projects.
    pub buffer_id: BufferId,
    /// Visible grid dimensions in cells.
    pub size: CellSize,
    /// Row-major visible cells; exactly `size.area()` entries.
    pub cells: Vec<Cell>,
    /// Visible child cursor, or `None` when hidden.
    pub cursor: Option<CellCoord>,
    /// Sanitized child title.
    pub title: Option<String>,
    /// Published terminal screen/title generation.
    pub screen_generation: u64,
    /// Per-row selection spans, strictly increasing by row.
    pub selection: Vec<TerminalSelectionSpan>,
    /// Physical retained rows between this viewport and the live tail.
    pub scroll_offset: u32,
    /// Whether this view reaches the live tail; always `scroll_offset == 0`.
    pub at_bottom: bool,
    /// Operating-system process id for this session generation.
    pub pid: u32,
    /// Latest observed process state.
    pub process: TerminalProcessState,
}

/// Why a [`TerminalFrame`] is not structurally valid.
///
/// Validation is atomic: the frame is rejected whole. A receiver retains
/// its previous valid frame rather than applying any part of this one.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum TerminalFrameError {
    /// Rows or columns are zero or above the shared bounds.
    #[error("terminal size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")]
    Size {
        /// Declared rows.
        rows: u32,
        /// Declared columns.
        cols: u32,
        /// Shared row bound.
        max_rows: u32,
        /// Shared column bound.
        max_cols: u32,
    },
    /// The checked area exceeds the shared visible-cell bound.
    #[error("terminal area {area} exceeds the visible-cell bound {max}")]
    Area {
        /// Checked `rows * cols`.
        area: usize,
        /// Shared visible-cell bound.
        max: usize,
    },
    /// `cells.len()` disagrees with the declared area.
    #[error("terminal frame carries {actual} cells for a {expected}-cell area")]
    CellCount {
        /// Declared area.
        expected: usize,
        /// Supplied cell count.
        actual: usize,
    },
    /// The cursor lies outside the declared grid.
    #[error("terminal cursor ({row},{col}) is outside the {rows}x{cols} grid")]
    Cursor {
        /// Cursor row.
        row: u32,
        /// Cursor column.
        col: u32,
        /// Declared rows.
        rows: u32,
        /// Declared columns.
        cols: u32,
    },
    /// Title or process metadata is too long or carries control characters.
    #[error("terminal {field} metadata is invalid: {reason}")]
    Metadata {
        /// Which metadata field failed.
        field: &'static str,
        /// Why it failed.
        reason: &'static str,
    },
    /// A cell's glyph is not a legal terminal glyph.
    #[error("terminal cell {index} has an invalid glyph: {reason}")]
    Glyph {
        /// Row-major cell index.
        index: usize,
        /// Why the glyph failed.
        reason: &'static str,
    },
    /// A cell carries a frontend attachment, which terminals never use.
    #[error("terminal cell {index} carries an attachment")]
    Attachment {
        /// Row-major cell index.
        index: usize,
    },
    /// Aggregate glyph bytes exceed [`MAX_TERMINAL_FRAME_GLYPH_BYTES`].
    #[error("terminal frame glyph bytes exceed the aggregate bound {max}")]
    GlyphBudget {
        /// Shared aggregate bound.
        max: usize,
    },
    /// A selection span is empty, out of order, duplicated, or out of bounds.
    #[error("terminal selection span {index} is invalid: {reason}")]
    Selection {
        /// Index into `selection`.
        index: usize,
        /// Why the span failed.
        reason: &'static str,
    },
    /// `at_bottom` disagrees with `scroll_offset == 0`.
    #[error("terminal at_bottom {at_bottom} disagrees with scroll_offset {scroll_offset}")]
    BottomState {
        /// Declared bottom state.
        at_bottom: bool,
        /// Declared scroll offset.
        scroll_offset: u32,
    },
}

/// Bounds a terminal frame enforces on its cell grid.
///
/// The per-axis caps are the PTY-specific half of the split: a panel
/// frame shares every other rule but not these, because a panel is
/// sized by the frontend's surface rather than by a pty window size.
const TERMINAL_GRID_LIMITS: crate::wire_grid::WireGridLimits = crate::wire_grid::WireGridLimits {
    max_rows: MAX_TERMINAL_ROWS as u32,
    max_cols: MAX_TERMINAL_COLS as u32,
    max_visible_cells: MAX_TERMINAL_VISIBLE_CELLS,
    max_glyph_bytes: MAX_TERMINAL_FRAME_GLYPH_BYTES,
};

/// Map a shared wire-grid failure onto this message's error type.
///
/// The variants and their text are unchanged by the Stage 2B factoring:
/// every existing terminal-frame assertion still observes exactly what
/// it observed before.
fn terminal_grid_error(error: crate::wire_grid::WireGridError) -> TerminalFrameError {
    use crate::wire_grid::WireGridError;
    match error {
        WireGridError::Size {
            rows,
            cols,
            max_rows,
            max_cols,
        } => TerminalFrameError::Size {
            rows,
            cols,
            max_rows,
            max_cols,
        },
        WireGridError::Area { area, max } => TerminalFrameError::Area { area, max },
        WireGridError::CellCount { expected, actual } => {
            TerminalFrameError::CellCount { expected, actual }
        }
        WireGridError::Cursor {
            row,
            col,
            rows,
            cols,
        } => TerminalFrameError::Cursor {
            row,
            col,
            rows,
            cols,
        },
        WireGridError::Glyph { index, reason } => TerminalFrameError::Glyph { index, reason },
        WireGridError::Attachment { index } => TerminalFrameError::Attachment { index },
        WireGridError::GlyphBudget { max } => TerminalFrameError::GlyphBudget { max },
    }
}

impl TerminalFrame {
    /// Check every structural rule a terminal frame must satisfy.
    ///
    /// This is the single policy used by the daemon before emission and
    /// by a frontend after decode. It is pure: a rejected frame mutates
    /// nothing, so callers get atomic rejection for free.
    pub fn validate(&self) -> Result<(), TerminalFrameError> {
        crate::wire_grid::validate_wire_grid(
            self.size,
            &self.cells,
            self.cursor,
            TERMINAL_GRID_LIMITS,
        )
        .map_err(terminal_grid_error)?;
        if let Some(title) = &self.title {
            validate_metadata("title", title)?;
        }
        match &self.process {
            TerminalProcessState::Signaled(text) => validate_metadata("signal", text)?,
            TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?,
            TerminalProcessState::Running | TerminalProcessState::Exited(_) => {}
        }
        self.validate_selection()?;
        if self.at_bottom != (self.scroll_offset == 0) {
            return Err(TerminalFrameError::BottomState {
                at_bottom: self.at_bottom,
                scroll_offset: self.scroll_offset,
            });
        }
        Ok(())
    }

    /// One nonempty in-bounds span per row, strictly increasing by row.
    fn validate_selection(&self) -> Result<(), TerminalFrameError> {
        let mut previous_row: Option<u32> = None;
        for (index, span) in self.selection.iter().enumerate() {
            if span.row >= self.size.rows {
                return Err(TerminalFrameError::Selection {
                    index,
                    reason: "row is outside the frame",
                });
            }
            if span.start_col >= span.end_col {
                return Err(TerminalFrameError::Selection {
                    index,
                    reason: "span is empty or reversed",
                });
            }
            if span.end_col > self.size.cols {
                return Err(TerminalFrameError::Selection {
                    index,
                    reason: "span extends past the final column",
                });
            }
            if previous_row.is_some_and(|previous| previous >= span.row) {
                return Err(TerminalFrameError::Selection {
                    index,
                    reason: "rows are not strictly increasing",
                });
            }
            previous_row = Some(span.row);
        }
        Ok(())
    }
}

/// Length and control-character rules shared by title and process text.
fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFrameError> {
    if text.len() > MAX_TERMINAL_METADATA_BYTES {
        return Err(TerminalFrameError::Metadata {
            field,
            reason: "exceeds the metadata byte limit",
        });
    }
    if text.chars().any(char::is_control) {
        return Err(TerminalFrameError::Metadata {
            field,
            reason: "carries a control character",
        });
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    // `Glyph` is no longer used by this module's production code — the
    // glyph rules moved to `crate::wire_grid` — but these tests still
    // construct frames cell by cell.
    use crate::cell::{Color, Glyph, Style, UnderlineStyle};
    use crate::message::InstanceMessage;
    use crate::transport::MAX_FRAME_BYTES;

    /// The style whose postcard encoding is as long as a legal `Style`
    /// gets: three truecolor components, a non-`None` underline, and
    /// every boolean set.
    fn maximal_style() -> Style {
        Style {
            fg: Color::Rgb(0xff, 0xee, 0xdd),
            bg: Color::Rgb(0x11, 0x22, 0x33),
            bold: true,
            italic: true,
            underline: UnderlineStyle::Dashed,
            reverse: true,
            underline_color: Color::Rgb(0x44, 0x55, 0x66),
        }
    }

    /// A legal one-column cluster of exactly `len` UTF-8 bytes.
    ///
    /// A combining mark is two bytes, so parity comes from the base:
    /// a one-byte space for odd lengths, a two-byte `é` for even ones.
    /// Width stays 1 no matter how many marks are appended.
    fn cluster_of_len(len: usize) -> Vec<u8> {
        assert!((1..=MAX_TERMINAL_GRAPHEME_BYTES).contains(&len));
        let mut text = String::with_capacity(len);
        if len % 2 == 1 {
            text.push(' ');
        } else {
            text.push('\u{e9}');
        }
        while text.len() < len {
            text.push('\u{301}');
        }
        assert_eq!(text.len(), len);
        assert_eq!(UnicodeWidthStr::width(text.as_str()), 1);
        text.into_bytes()
    }

    fn cell_with(glyph: Glyph) -> Cell {
        Cell {
            glyph,
            style: maximal_style(),
            attachment: None,
        }
    }

    /// A minimal valid frame: `rows * cols` single-space cells.
    fn frame(rows: u32, cols: u32) -> TerminalFrame {
        let area = (rows * cols) as usize;
        TerminalFrame {
            buffer_id: BufferId::from_raw(7),
            size: CellSize::new(rows, cols),
            cells: vec![cell_with(Glyph::Char(' ')); area],
            cursor: Some(CellCoord::new(0, 0)),
            title: Some("sh".into()),
            screen_generation: 3,
            selection: Vec::new(),
            scroll_offset: 0,
            at_bottom: true,
            pid: 4321,
            process: TerminalProcessState::Running,
        }
    }

    #[test]
    fn exact_shared_boundaries_are_accepted() {
        let mut f = frame(2, 4);
        // A wide lead plus its continuation, a maximal-length cluster,
        // and a maximal metadata string are all exactly legal.
        f.cells[0] = cell_with(Glyph::Char('\u{4e00}'));
        f.cells[1] = cell_with(Glyph::Continuation);
        f.cells[2] = cell_with(Glyph::Cluster(
            cluster_of_len(MAX_TERMINAL_GRAPHEME_BYTES).into_boxed_slice(),
        ));
        f.title = Some("t".repeat(MAX_TERMINAL_METADATA_BYTES));
        f.process = TerminalProcessState::Signaled("s".repeat(MAX_TERMINAL_METADATA_BYTES));
        f.cursor = Some(CellCoord::new(1, 3));
        f.selection = vec![
            TerminalSelectionSpan {
                row: 0,
                start_col: 0,
                end_col: 4,
            },
            TerminalSelectionSpan {
                row: 1,
                start_col: 3,
                end_col: 4,
            },
        ];
        assert_eq!(f.validate(), Ok(()));
    }

    #[test]
    #[allow(clippy::too_many_lines, reason = "one case per structural rule")]
    fn every_structural_violation_is_rejected() {
        // Each case mutates one axis of an otherwise valid frame, so a
        // failure names exactly the rule that stopped enforcing.
        let zero_rows = {
            let mut f = frame(1, 1);
            f.size = CellSize::new(0, 1);
            f
        };
        assert!(matches!(
            zero_rows.validate(),
            Err(TerminalFrameError::Size { .. })
        ));

        let over_rows = {
            let mut f = frame(1, 1);
            f.size = CellSize::new(u32::from(MAX_TERMINAL_ROWS) + 1, 1);
            f
        };
        assert!(matches!(
            over_rows.validate(),
            Err(TerminalFrameError::Size { .. })
        ));

        let over_area = {
            let mut f = frame(1, 1);
            f.size = CellSize::new(u32::from(MAX_TERMINAL_ROWS), u32::from(MAX_TERMINAL_COLS));
            f
        };
        // 512 x 512 is exactly the visible-cell bound, so this frame
        // fails on its cell count, not its area — the area bound is
        // reached only if a later limit change makes them disagree.
        assert!(matches!(
            over_area.validate(),
            Err(TerminalFrameError::CellCount { .. })
        ));

        let bad_count = {
            let mut f = frame(2, 2);
            f.cells.pop();
            f
        };
        assert!(matches!(
            bad_count.validate(),
            Err(TerminalFrameError::CellCount {
                expected: 4,
                actual: 3
            })
        ));

        let bad_cursor = {
            let mut f = frame(2, 2);
            f.cursor = Some(CellCoord::new(2, 0));
            f
        };
        assert!(matches!(
            bad_cursor.validate(),
            Err(TerminalFrameError::Cursor { .. })
        ));

        let long_title = {
            let mut f = frame(1, 1);
            f.title = Some("t".repeat(MAX_TERMINAL_METADATA_BYTES + 1));
            f
        };
        assert!(matches!(
            long_title.validate(),
            Err(TerminalFrameError::Metadata { field: "title", .. })
        ));

        let control_title = {
            let mut f = frame(1, 1);
            f.title = Some("ok\u{7}".into());
            f
        };
        assert!(matches!(
            control_title.validate(),
            Err(TerminalFrameError::Metadata { field: "title", .. })
        ));

        let long_process = {
            let mut f = frame(1, 1);
            f.process = TerminalProcessState::Crashed("c".repeat(MAX_TERMINAL_METADATA_BYTES + 1));
            f
        };
        assert!(matches!(
            long_process.validate(),
            Err(TerminalFrameError::Metadata { field: "crash", .. })
        ));

        let control_glyph = {
            let mut f = frame(1, 1);
            f.cells[0] = cell_with(Glyph::Char('\u{7}'));
            f
        };
        assert!(matches!(
            control_glyph.validate(),
            Err(TerminalFrameError::Glyph { index: 0, .. })
        ));

        let malformed_cluster = {
            let mut f = frame(1, 1);
            f.cells[0] = cell_with(Glyph::Cluster(vec![0xff, 0xfe].into_boxed_slice()));
            f
        };
        assert!(matches!(
            malformed_cluster.validate(),
            Err(TerminalFrameError::Glyph { index: 0, .. })
        ));

        let empty_cluster = {
            let mut f = frame(1, 1);
            f.cells[0] = cell_with(Glyph::Cluster(Vec::new().into_boxed_slice()));
            f
        };
        assert!(matches!(
            empty_cluster.validate(),
            Err(TerminalFrameError::Glyph { index: 0, .. })
        ));

        let overlong_cluster = {
            let mut f = frame(1, 1);
            let mut bytes = cluster_of_len(MAX_TERMINAL_GRAPHEME_BYTES);
            bytes.extend_from_slice("\u{301}".as_bytes());
            f.cells[0] = cell_with(Glyph::Cluster(bytes.into_boxed_slice()));
            f
        };
        assert!(matches!(
            overlong_cluster.validate(),
            Err(TerminalFrameError::Glyph { index: 0, .. })
        ));

        let orphan_continuation = {
            let mut f = frame(1, 2);
            f.cells[1] = cell_with(Glyph::Continuation);
            f.cells[0] = cell_with(Glyph::Char('a'));
            f
        };
        assert!(matches!(
            orphan_continuation.validate(),
            Err(TerminalFrameError::Glyph { index: 1, .. })
        ));

        let missing_continuation = {
            let mut f = frame(1, 2);
            f.cells[0] = cell_with(Glyph::Char('\u{4e00}'));
            f
        };
        assert!(matches!(
            missing_continuation.validate(),
            Err(TerminalFrameError::Glyph { index: 1, .. })
        ));

        // A wide lead in the final column has nowhere to put its
        // continuation: the next cell belongs to the next row.
        let wide_at_row_end = {
            let mut f = frame(2, 2);
            f.cells[1] = cell_with(Glyph::Char('\u{4e00}'));
            f
        };
        assert!(matches!(
            wide_at_row_end.validate(),
            Err(TerminalFrameError::Glyph { index: 1, .. })
        ));

        let attachment = {
            let mut f = frame(1, 1);
            f.cells[0].attachment = Some(crate::cell::Attachment::ImageCell {
                image_id: 1,
                sub_x: 0,
                sub_y: 0,
            });
            f
        };
        assert!(matches!(
            attachment.validate(),
            Err(TerminalFrameError::Attachment { index: 0 })
        ));

        let reversed_span = {
            let mut f = frame(1, 4);
            f.selection = vec![TerminalSelectionSpan {
                row: 0,
                start_col: 3,
                end_col: 1,
            }];
            f
        };
        assert!(matches!(
            reversed_span.validate(),
            Err(TerminalFrameError::Selection { index: 0, .. })
        ));

        let empty_span = {
            let mut f = frame(1, 4);
            f.selection = vec![TerminalSelectionSpan {
                row: 0,
                start_col: 2,
                end_col: 2,
            }];
            f
        };
        assert!(matches!(
            empty_span.validate(),
            Err(TerminalFrameError::Selection { index: 0, .. })
        ));

        let duplicate_row = {
            let mut f = frame(2, 4);
            f.selection = vec![
                TerminalSelectionSpan {
                    row: 1,
                    start_col: 0,
                    end_col: 1,
                },
                TerminalSelectionSpan {
                    row: 1,
                    start_col: 1,
                    end_col: 2,
                },
            ];
            f
        };
        assert!(matches!(
            duplicate_row.validate(),
            Err(TerminalFrameError::Selection { index: 1, .. })
        ));

        let out_of_order = {
            let mut f = frame(2, 4);
            f.selection = vec![
                TerminalSelectionSpan {
                    row: 1,
                    start_col: 0,
                    end_col: 1,
                },
                TerminalSelectionSpan {
                    row: 0,
                    start_col: 0,
                    end_col: 1,
                },
            ];
            f
        };
        assert!(matches!(
            out_of_order.validate(),
            Err(TerminalFrameError::Selection { index: 1, .. })
        ));

        let span_past_end = {
            let mut f = frame(1, 4);
            f.selection = vec![TerminalSelectionSpan {
                row: 0,
                start_col: 0,
                end_col: 5,
            }];
            f
        };
        assert!(matches!(
            span_past_end.validate(),
            Err(TerminalFrameError::Selection { index: 0, .. })
        ));

        let span_off_frame = {
            let mut f = frame(1, 4);
            f.selection = vec![TerminalSelectionSpan {
                row: 1,
                start_col: 0,
                end_col: 1,
            }];
            f
        };
        assert!(matches!(
            span_off_frame.validate(),
            Err(TerminalFrameError::Selection { index: 0, .. })
        ));

        let inconsistent_bottom = {
            let mut f = frame(1, 1);
            f.scroll_offset = 4;
            f.at_bottom = true;
            f
        };
        assert!(matches!(
            inconsistent_bottom.validate(),
            Err(TerminalFrameError::BottomState { .. })
        ));

        let inconsistent_top = {
            let mut f = frame(1, 1);
            f.scroll_offset = 0;
            f.at_bottom = false;
            f
        };
        assert!(matches!(
            inconsistent_top.validate(),
            Err(TerminalFrameError::BottomState { .. })
        ));
    }

    #[test]
    fn aggregate_glyph_budget_accepts_the_exact_bound_and_rejects_one_byte_over() {
        // Reaching the aggregate bound exactly requires the whole legal
        // grid — no smaller frame can spend 8 MiB of glyph — so the
        // boundary pair is built once and asserted in both directions.
        let (exact, over) = budget_boundary_frames();
        assert_eq!(exact.validate(), Ok(()));
        assert_eq!(
            over.validate(),
            Err(TerminalFrameError::GlyphBudget {
                max: MAX_TERMINAL_FRAME_GLYPH_BYTES
            })
        );
    }

    /// The exact-budget frame and its one-byte-over twin.
    ///
    /// Cluster lengths are chosen to maximize serialized length-prefix
    /// overhead: postcard spends two bytes on any length `>= 128`, so as
    /// many cells as the budget allows carry a 128-byte cluster and the
    /// rest carry the one-byte minimum, rather than packing the budget
    /// into a few maximal clusters that would each cost one prefix.
    fn budget_boundary_frames() -> (TerminalFrame, TerminalFrame) {
        /// Shortest cluster length postcard encodes with a two-byte
        /// length prefix.
        const WIDE_PREFIX_LEN: usize = 128;
        let rows = u32::from(MAX_TERMINAL_ROWS);
        let cols = u32::from(MAX_TERMINAL_COLS);
        let area = (rows * cols) as usize;
        assert_eq!(area, MAX_TERMINAL_VISIBLE_CELLS);

        let spare = MAX_TERMINAL_FRAME_GLYPH_BYTES - area;
        let wide_cells = spare / (WIDE_PREFIX_LEN - 1);
        let remainder = spare % (WIDE_PREFIX_LEN - 1);
        assert!(wide_cells + usize::from(remainder > 0) <= area);

        let wide = cluster_of_len(WIDE_PREFIX_LEN).into_boxed_slice();
        let single = cluster_of_len(1).into_boxed_slice();
        let mut cells = Vec::with_capacity(area);
        for index in 0..area {
            let glyph = if index < wide_cells {
                Glyph::Cluster(wide.clone())
            } else if index == wide_cells && remainder > 0 {
                Glyph::Cluster(cluster_of_len(remainder + 1).into_boxed_slice())
            } else {
                Glyph::Cluster(single.clone())
            };
            cells.push(cell_with(glyph));
        }

        let selection = (0..rows)
            .map(|row| TerminalSelectionSpan {
                row,
                start_col: 0,
                end_col: cols,
            })
            .collect();

        let exact = TerminalFrame {
            buffer_id: BufferId::from_raw(u64::MAX),
            size: CellSize::new(rows, cols),
            cells,
            cursor: Some(CellCoord::new(rows - 1, cols - 1)),
            title: Some("t".repeat(MAX_TERMINAL_METADATA_BYTES)),
            screen_generation: u64::MAX,
            selection,
            scroll_offset: u32::MAX,
            at_bottom: false,
            pid: u32::MAX,
            process: TerminalProcessState::Crashed("c".repeat(MAX_TERMINAL_METADATA_BYTES)),
        };

        let mut over = exact.clone();
        // One more byte of glyph, nothing else changed.
        let last = over.cells.len() - 1;
        over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(2).into_boxed_slice()));

        (exact, over)
    }

    #[test]
    fn maximum_legal_terminal_frame_encodes_below_the_transport_cap() {
        let (exact, over) = budget_boundary_frames();
        assert_eq!(exact.validate(), Ok(()));

        let mut glyph_bytes = 0usize;
        for cell in &exact.cells {
            glyph_bytes += match &cell.glyph {
                Glyph::Char(ch) => ch.len_utf8(),
                Glyph::Cluster(bytes) => bytes.len(),
                Glyph::Continuation => 0,
            };
        }
        assert_eq!(
            glyph_bytes, MAX_TERMINAL_FRAME_GLYPH_BYTES,
            "the measured fixture must spend the whole aggregate budget"
        );
        let over_glyph_bytes = over
            .cells
            .iter()
            .map(|cell| match &cell.glyph {
                Glyph::Char(ch) => ch.len_utf8(),
                Glyph::Cluster(bytes) => bytes.len(),
                Glyph::Continuation => 0,
            })
            .sum::<usize>();
        assert_eq!(
            over_glyph_bytes,
            MAX_TERMINAL_FRAME_GLYPH_BYTES + 1,
            "the rejecting twin must be exactly one byte over the aggregate budget"
        );

        let msg = InstanceMessage::TerminalFrame(exact);
        let bytes = postcard::to_allocvec(&msg).expect("encode");
        assert!(
            bytes.len() < MAX_FRAME_BYTES,
            "largest legal terminal frame encodes to {} bytes, at or above the \
             {MAX_FRAME_BYTES}-byte transport cap; the aggregate glyph bound no \
             longer keeps terminal traffic inside the existing transport limit",
            bytes.len()
        );
    }

    #[test]
    fn frames_round_trip_through_postcard() {
        let mut f = frame(3, 5);
        f.cells[0] = cell_with(Glyph::Char('\u{4e00}'));
        f.cells[1] = cell_with(Glyph::Continuation);
        f.cells[2] = cell_with(Glyph::Cluster(cluster_of_len(9).into_boxed_slice()));
        f.selection = vec![TerminalSelectionSpan {
            row: 2,
            start_col: 1,
            end_col: 4,
        }];
        f.scroll_offset = 6;
        f.at_bottom = false;
        f.process = TerminalProcessState::Exited(-1);
        assert_eq!(f.validate(), Ok(()));

        let msg = InstanceMessage::TerminalFrame(f);
        let bytes = postcard::to_allocvec(&msg).expect("encode");
        let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
        assert_eq!(decoded, msg);
    }
}
