mod cells;
mod checkbox;
mod scrollbars;

use std::{collections::HashSet, rc::Rc};

use rs_grid_core::{
    row_check::CheckboxTriState, sort::SortDir, state::GridState,
};

use crate::{
    class_map::ClassResolver,
    frame::SceneFrame,
    primitives::{
        Color, LinePrimitive, PolygonPrimitive, RectPrimitive, ScenePrimitive,
        TextAlign, TextPrimitive,
    },
    theme::Theme,
};

// ── boundary-line rectangle border ───────────────────────────────────────────

/// Push a crisp 1px-per-edge rectangle border — used by both the
/// selection outer border and the invalid-cell border — as four
/// independent `Line` primitives rather than a stroked `Rect`, so it
/// draws with flush corners regardless of when/where each edge lands
/// relative to other primitives already in the frame.
///
/// Every edge sits at `boundary - 0.5` (the crisp-line convention
/// shared with grid lines/separators elsewhere in this file), which
/// consistently biases the top/left edges *outward* and the
/// bottom/right edges *inward* — so only the top and left edges'
/// leading (x1/y1) end fails to reach the other edge's stroke at a
/// corner; the trailing (x2/y2) end already overlaps it naturally.
/// Extending only that leading end by `width` closes the gap without
/// the trailing end overshooting past the perpendicular edge (which
/// would produce a small protruding nub at the other three corners).
fn push_boundary_lines(
    frame: &mut SceneFrame,
    x1: f64,
    y1: f64,
    x2: f64,
    y2: f64,
    color: Color,
    width: f64,
) {
    // top
    frame.push(ScenePrimitive::Line(LinePrimitive {
        x1: x1 - width,
        y1: y1 - 0.5,
        x2,
        y2: y1 - 0.5,
        color,
        width,
    }));
    // bottom
    frame.push(ScenePrimitive::Line(LinePrimitive {
        x1: x1 - width,
        y1: y2 - 0.5,
        x2,
        y2: y2 - 0.5,
        color,
        width,
    }));
    // left
    frame.push(ScenePrimitive::Line(LinePrimitive {
        x1: x1 - 0.5,
        y1: y1 - width,
        x2: x1 - 0.5,
        y2,
        color,
        width,
    }));
    // right
    frame.push(ScenePrimitive::Line(LinePrimitive {
        x1: x2 - 0.5,
        y1: y1 - width,
        x2: x2 - 0.5,
        y2,
        color,
        width,
    }));
}

// ── column drag hint ─────────────────────────────────────────────────────────

/// Transient render-time hint for column-drag visual
/// feedback.
///
/// This is **not** persistent state — it exists only for a
/// single frame render cycle. Computed by the web layer,
/// consumed by the scene builder to render a dimmed source
/// header, an insertion line, and a ghost.
#[derive(Debug, Clone)]
pub struct ColumnDragHint {
    /// Index of the column being dragged.
    pub source_col: usize,
    /// The column will be inserted *before* this index.
    /// Equal to `columns.len()` means "insert at end".
    pub insert_before: usize,
    /// Viewport-relative X of the cursor (positions the ghost).
    pub cursor_vx: f64,
    /// Viewport-relative Y of the cursor (positions the ghost).
    pub cursor_vy: f64,
    /// Animated column offsets (`col_idx → cumulative left offset`)
    /// for smooth lerp transitions. When non-empty the builder uses
    /// these directly instead of computing from `insert_before`.
    pub animated_offsets: Vec<f64>,
}

// ── flash hint ───────────────────────────────────────────────────────────────

/// Transient render-time hint for the paste-flash animation.
///
/// This is **not** persistent state — it exists only for a
/// single frame render cycle. Computed by the web layer
/// from elapsed time; consumed by the scene builder to
/// render a fading overlay on the cells that were actually
/// written by the paste (`cells`) — **not** the whole
/// selection rectangle, which may extend past cells skipped
/// for being locked or failing validation.
#[derive(Debug, Clone)]
pub struct FlashHint {
    /// Normalised intensity: 1.0 = full flash, 0.0 = invisible.
    pub alpha_factor: f64,
    /// Coordinates of the cells to flash.
    pub cells: HashSet<(u64, usize)>,
    /// When `true`, renders with `Theme::flash_error_fill` instead of
    /// `Theme::flash_fill` — used for cells a `CutSelection` copied but
    /// could not clear (locked or failing validation), to distinguish
    /// that outcome from a successful paste/clear flash.
    pub is_error: bool,
}

// ── builder
// ───────────────────────────────────────────────────────────────────

/// Transforms a `GridState` snapshot into a `SceneFrame`.
///
/// Instantiate once and reuse; all state is read from `GridState` on each
/// `build()` call.
pub struct SceneBuilder {
    /// Device pixel ratio — hardware property, not a theme property.
    pub dpr: f64,
    /// Active visual theme.
    pub theme: Theme,
    /// Optional CSS class → style resolver for `CellFormat::Styled`.
    ///
    /// When `None`, styled elements render as plain text (no background
    /// or padding). Set via [`SceneBuilder::set_class_resolver`].
    pub class_resolver: Option<Rc<ClassResolver>>,
}

impl Default for SceneBuilder {
    fn default() -> Self {
        Self {
            dpr: 1.0,
            theme: Theme::default(),
            class_resolver: None,
        }
    }
}

impl SceneBuilder {
    /// Create a builder with the given DPR and the default theme.
    pub fn new(dpr: f64) -> Self {
        Self {
            dpr,
            theme: Theme::default(),
            class_resolver: None,
        }
    }

    /// Create a builder with the given DPR and a custom theme.
    pub fn with_theme(dpr: f64, theme: Theme) -> Self {
        Self {
            dpr,
            theme,
            class_resolver: None,
        }
    }

    /// Set the class resolver used for `CellFormat::Styled` elements.
    pub fn set_class_resolver(&mut self, resolver: Rc<ClassResolver>) {
        self.class_resolver = Some(resolver);
    }

    /// Build a complete `SceneFrame` from the current
    /// `GridState`, with optional column-drag visual hints.
    pub fn build(
        &self,
        state: &GridState,
        col_drag: Option<&ColumnDragHint>,
        flash: Option<&FlashHint>,
        hovered_menu_col: Option<usize>,
    ) -> SceneFrame {
        let vp = &state.viewport;
        let model = &state.model;
        let sel = &state.selection;
        let t = &self.theme;

        let mut frame = SceneFrame::new(vp.width, vp.height, self.dpr);

        // ── background ───────────────────────────────────────────────────────
        frame.push(ScenePrimitive::Rect(RectPrimitive {
            x: 0.0,
            y: 0.0,
            width: vp.width,
            height: vp.height,
            fill: t.bg,
            stroke: None,
            stroke_width: 0.0,
            corner_radius: 0.0,
            clip: None,
        }));

        let col_widths: Vec<f64> =
            model.columns.iter().map(|c| c.width).collect();
        let pinned_count = model.pinned_count;
        let pinned_width = model.pinned_width();
        let ccw = model.effective_checkbox_column_width();

        // Scrollable columns — exclude pinned ones and account for
        // the narrower scrollable viewport band.
        let (col_start, col_end) = if pinned_count == 0 {
            vp.visible_columns(&model.column_offsets, &col_widths)
        } else {
            vp.visible_scrollable_columns(
                &model.column_offsets,
                &col_widths,
                pinned_count,
                pinned_width,
                model.effective_row_number_width(),
                ccw,
            )
        };
        let (row_start, row_end) = vp.visible_rows(
            model.display_row_count(),
            model.row_height,
            model.effective_header_height(),
        );

        let sx = vp.scroll_x;
        let sy = vp.scroll_y;
        let rnw = model.effective_row_number_width();
        // Offset added to *unpinned* columns' screen position to make
        // room for the checkbox column, which occupies the first slot
        // of the scrollable region and scrolls away with scroll_x
        // exactly like a real column. Pinned columns are unaffected —
        // the checkbox sits strictly after the pinned band, never
        // before it — so pinned rendering below uses `rnw` alone.
        let lgw = rnw + ccw;

        // Compute viewport-y for a row without large absolute
        // values.  row_top(ri) − sy  =  header + ri*rh − sy.
        // When ri and sy are both huge the subtraction loses
        // f64 precision.  Instead compute the offset from
        // row_start whose viewport-y we derive from the small
        // fractional remainder.
        let rh = model.row_height;
        // Guard: a zero row height would cause division by zero below.
        if rh <= 0.0 {
            return frame;
        }
        let hh = model.effective_header_height();
        // sy_content = scroll_y that falls inside the data
        // area (past the header).
        let sy_content = (sy - hh).max(0.0);
        // Use fmod to get the sub-row fractional offset
        // without subtracting two large f64 values.
        // visible_rows computes first_row = floor(sy_content / rh),
        // and row_start = first_row - overscan.
        // frac_first = sy_content mod rh (offset within
        //              the first fully visible row).
        let frac_first = sy_content % rh;
        // row_start may include overscan rows before
        // first_row, so adjust by the overscan distance.
        let first_no_os = (sy_content / rh) as u64;
        let os_rows = first_no_os.saturating_sub(row_start);
        let frac = frac_first + os_rows as f64 * rh;
        // ry of row_start in viewport coords:
        let ry_base = (hh - sy).max(0.0) - frac;

        let row_vy =
            |ri: u64| -> f64 { ry_base + (ri as f64 - row_start as f64) * rh };

        // During a drag, compute preview offsets: columns laid out
        // as-if the drop has already happened, so they render at
        // their target positions while dragging.
        // Prefer pre-computed animated offsets from the hint
        // (smooth lerp); fall back to instant computation.
        let preview_offsets: Option<Vec<f64>> = col_drag.and_then(|hint| {
            if !hint.animated_offsets.is_empty() {
                return Some(hint.animated_offsets.clone());
            }
            let cols = &model.columns;
            let src = hint.source_col;
            if src >= cols.len() {
                return None;
            }
            let dst = if hint.insert_before > src {
                hint.insert_before.saturating_sub(1)
            } else {
                hint.insert_before
            };
            if dst == src {
                return None;
            }
            let mut order: Vec<usize> =
                (0..cols.len()).filter(|&i| i != src).collect();
            let ins = dst.min(order.len());
            order.insert(ins, src);
            let mut offs = vec![0.0_f64; cols.len()];
            let mut cum = 0.0_f64;
            for &ci in &order {
                offs[ci] = cum;
                cum += cols[ci].width;
            }
            Some(offs)
        });

        // Helper: viewport x of the left edge of column `ci`.
        // Uses preview offsets when a drag is active so columns
        // render at their drop-target positions.
        // Pinned columns are not shifted by scroll_x.
        let col_vx = |ci: usize| -> f64 {
            let off = match &preview_offsets {
                Some(po) => po[ci],
                None => model.column_offsets.offsets[ci],
            };
            if ci < pinned_count {
                // Checkbox sits after the pinned band — unaffected by ccw.
                off + rnw
            } else {
                off - sx + lgw
            }
        };

        // ── search match set ─────────────────────────────────────────────────
        let search_set: HashSet<(u64, usize)> = state
            .search
            .matches
            .iter()
            .map(|c| (c.row, c.col))
            .collect();
        let search_current: Option<(u64, usize)> = state
            .search
            .matches
            .get(state.search.current)
            .map(|c| (c.row, c.col));

        // Invalid-cell border bounds (cx, ry, width, height), collected
        // across both the scrollable and pinned cell loops below and
        // drawn as boundary lines once every grid line / separator has
        // already been emitted — see the push site in `cells.rs` for why.
        let mut invalid_borders: Vec<(f64, f64, f64, f64)> = Vec::new();

        // ── data rows ────────────────────────────────────────────────────────
        for ri in row_start..row_end {
            let ry = row_vy(ri);

            // Skip rows that are fully outside the clip zone (overscan may
            // produce rows above the header).
            if ry + model.row_height < hh || ry > vp.height {
                continue;
            }

            let mid_y = ry + model.row_height * 0.5 + t.font_size * 0.35;

            // Alternating row background (odd rows, behind selection)
            if ri % 2 == 1 {
                frame.push(ScenePrimitive::Rect(RectPrimitive {
                    x: 0.0,
                    y: ry,
                    width: vp.width,
                    height: model.row_height,
                    fill: t.row_alt_bg,
                    stroke: None,
                    stroke_width: 0.0,
                    corner_radius: 0.0,
                    clip: None,
                }));
            }

            // Hover highlight (above alt-bg, below selection)
            if state.hovered_row == Some(ri) {
                frame.push(ScenePrimitive::Rect(RectPrimitive {
                    x: 0.0,
                    y: ry,
                    width: vp.width,
                    height: model.row_height,
                    fill: t.row_hover_bg,
                    stroke: None,
                    stroke_width: 0.0,
                    corner_radius: 0.0,
                    clip: None,
                }));
            }

            // Checked-row highlight (above hover, below selection) — keyed
            // by physical row id, same as the checkbox's own checked state.
            if state.checked_rows.contains(&model.logical_to_physical(ri)) {
                frame.push(ScenePrimitive::Rect(RectPrimitive {
                    x: 0.0,
                    y: ry,
                    width: vp.width,
                    height: model.row_height,
                    fill: t.checked_row_bg,
                    stroke: None,
                    stroke_width: 0.0,
                    corner_radius: 0.0,
                    clip: None,
                }));
            }

            // During a drag, columns may have animated into or out
            // of the viewport. Start from the first non-pinned column
            // (not just col_start) to catch those that animated
            // leftward, and cull each column individually by its
            // current animated viewport position.
            let (scan_start, scan_end) = if preview_offsets.is_some() {
                (pinned_count, model.columns.len())
            } else {
                (col_start, col_end)
            };
            for ci in scan_start..scan_end {
                let col = &model.columns[ci];
                let cx = col_vx(ci);
                // Skip columns that are fully outside the viewport.
                if preview_offsets.is_some()
                    && (cx + col.width <= lgw || cx >= vp.width)
                {
                    continue;
                }
                let status = model.cell_status(ri, &col.key);
                cells::emit_cell(
                    &mut frame,
                    col,
                    model,
                    ri,
                    ci,
                    cx,
                    ry,
                    mid_y,
                    model.row_height,
                    status,
                    sel,
                    &search_set,
                    search_current,
                    t,
                    flash,
                    self.class_resolver.as_deref(),
                    &mut invalid_borders,
                );
            }

            // Horizontal grid line
            frame.push(ScenePrimitive::Line(LinePrimitive {
                x1: 0.0,
                y1: ry + model.row_height - 0.5,
                x2: vp.width,
                y2: ry + model.row_height - 0.5,
                color: t.grid_line,
                width: t.grid_line_width,
            }));
        }

        // Vertical column separators (data rows only), drawn once per
        // column boundary rather than once per cell — column x-positions
        // don't vary by row, so this is O(columns) not O(rows × columns).
        if t.column_separator_width > 0.0 {
            let (sep_start, sep_end) = if preview_offsets.is_some() {
                (pinned_count, model.columns.len())
            } else {
                (col_start, col_end)
            };
            for ci in sep_start..sep_end {
                let col = &model.columns[ci];
                let cx = col_vx(ci);
                if preview_offsets.is_some()
                    && (cx + col.width <= lgw || cx >= vp.width)
                {
                    continue;
                }
                let sep_x = cx + col.width - 0.5;
                frame.push(ScenePrimitive::Line(LinePrimitive {
                    x1: sep_x,
                    y1: hh,
                    x2: sep_x,
                    y2: vp.height,
                    color: t.column_separator_color,
                    width: t.column_separator_width,
                }));
            }
        }

        // ── checkbox column (scrolls with the data — it's the first slot
        // of the scrollable/unpinned region, not a fixed gutter) ─────────
        if ccw > 0.0 {
            let ccx = rnw + pinned_width - sx;
            if ccx + ccw > rnw && ccx < vp.width {
                for ri in row_start..row_end {
                    let ry = row_vy(ri);
                    if ry + model.row_height < hh || ry > vp.height {
                        continue;
                    }
                    let clip_y = ry.max(hh);
                    let clip_h = (ry + model.row_height - clip_y).max(0.0);
                    if clip_h == 0.0 {
                        continue;
                    }
                    let physical = model.logical_to_physical(ri);
                    let tri = if state.checked_rows.contains(&physical) {
                        CheckboxTriState::Checked
                    } else {
                        CheckboxTriState::Unchecked
                    };
                    checkbox::emit_checkbox(
                        &mut frame, ccx, clip_y, ccw, clip_h, rnw, t, tri,
                    );
                }
            }
        }

        // ── pinned-column data overlay
        // ──────────────────────────────────────── Rendered after
        // scrollable rows so pinned cells appear on top.
        if pinned_count > 0 && pinned_count <= model.columns.len() {
            // Solid background covering the full pinned band.
            frame.push(ScenePrimitive::Rect(RectPrimitive {
                x: rnw,
                y: hh,
                width: pinned_width,
                height: vp.height - hh,
                fill: t.pinned_bg,
                stroke: None,
                stroke_width: 0.0,
                corner_radius: 0.0,
                clip: None,
            }));

            for ri in row_start..row_end {
                let ry = row_vy(ri);
                if ry + model.row_height < hh || ry > vp.height {
                    continue;
                }
                // Clamp alt/hover backgrounds to below the sticky
                // header — mirrors the row-number gutter's own clamp
                // further down ("Clamp everything to below the
                // sticky header").
                let clip_y = ry.max(hh);
                let clip_h = (ry + model.row_height - clip_y).max(0.0);
                let mid_y = ry + model.row_height * 0.5 + t.font_size * 0.35;

                if ri % 2 == 1 {
                    frame.push(ScenePrimitive::Rect(RectPrimitive {
                        x: rnw,
                        y: clip_y,
                        width: pinned_width,
                        height: clip_h,
                        fill: t.row_alt_bg,
                        stroke: None,
                        stroke_width: 0.0,
                        corner_radius: 0.0,
                        clip: None,
                    }));
                }
                if state.hovered_row == Some(ri) {
                    frame.push(ScenePrimitive::Rect(RectPrimitive {
                        x: rnw,
                        y: clip_y,
                        width: pinned_width,
                        height: clip_h,
                        fill: t.row_hover_bg,
                        stroke: None,
                        stroke_width: 0.0,
                        corner_radius: 0.0,
                        clip: None,
                    }));
                }

                for ci in 0..pinned_count {
                    let col = &model.columns[ci];
                    let cx = col_vx(ci);
                    let status = model.cell_status(ri, &col.key);
                    cells::emit_cell(
                        &mut frame,
                        col,
                        model,
                        ri,
                        ci,
                        cx,
                        ry,
                        mid_y,
                        model.row_height,
                        status,
                        sel,
                        &search_set,
                        search_current,
                        t,
                        flash,
                        self.class_resolver.as_deref(),
                        &mut invalid_borders,
                    );
                }
            }

            // Vertical separators between individual pinned columns (not
            // including the band's own right edge — that already has a
            // dedicated separator via pinned_separator_color/width below).
            if t.column_separator_width > 0.0 && pinned_count > 1 {
                for ci in 0..pinned_count - 1 {
                    let col = &model.columns[ci];
                    let cx = col_vx(ci);
                    let sep_x = cx + col.width - 0.5;
                    frame.push(ScenePrimitive::Line(LinePrimitive {
                        x1: sep_x,
                        y1: hh,
                        x2: sep_x,
                        y2: vp.height,
                        color: t.column_separator_color,
                        width: t.column_separator_width,
                    }));
                }
            }

            // Separator line on the right edge of the pinned band.
            frame.push(ScenePrimitive::Line(LinePrimitive {
                x1: rnw + pinned_width - 0.5,
                y1: hh,
                x2: rnw + pinned_width - 0.5,
                y2: vp.height,
                color: t.pinned_separator_color,
                width: t.pinned_separator_width,
            }));
        }

        // ── invalid-cell borders ─────────────────────────────────────────────
        // Pushed here — after grid lines, column separators, and the
        // pinned-column overlay, but *before* the header/gutter — so
        // it wins the collision against this row's own trailing grid
        // line (the original bug: the border used to be pushed inline
        // during cell emission, ahead of that line) while still being
        // masked by the header/gutter for a row/column that's only
        // partially scrolled into view (the sticky overlays are relied
        // on elsewhere to clip that overscanned content the same way).
        for &(bx, by, bw, bh) in &invalid_borders {
            push_boundary_lines(
                &mut frame,
                bx,
                by,
                bx + bw,
                by + bh,
                t.invalid_cell_border,
                t.invalid_cell_border_width,
            );
        }

        // ── selection outer border ───────────────────────────────────────────
        // Same placement rationale as the invalid-cell borders above.
        if let Some((tl, br)) = sel.range() {
            let x1 = col_vx(tl.col);
            let y1 = row_vy(tl.row);
            let x2 = col_vx(br.col) + model.columns[br.col].width;
            let y2 = row_vy(br.row) + model.row_height;

            // During a flash the border fades from the flash colour
            // (alpha_factor == 1.0) back to the normal selection border
            // colour (alpha_factor == 0.0) — interpolating toward
            // `selection_border` rather than toward transparent means
            // this exactly matches the `else` branch's color at the
            // animation's end, so there's no discontinuous "pop" when
            // the flash finishes and `flash` becomes `None` (this holds
            // even when `flash_border` and `selection_border` share the
            // same RGB, where only the alpha channel would otherwise
            // visibly jump).
            let border_color = if let Some(f) = flash {
                t.selection_border.lerp(t.flash_border, f.alpha_factor)
            } else {
                t.selection_border
            };

            push_boundary_lines(&mut frame, x1, y1, x2, y2, border_color, 1.0);
        }

        // ── header (sticky, drawn on top of scrolled data) ───────────────────
        if hh > 0.0 {
            frame.push(ScenePrimitive::Rect(RectPrimitive {
                x: 0.0,
                y: 0.0,
                width: vp.width,
                height: hh,
                fill: t.header_bg,
                stroke: None,
                stroke_width: 0.0,
                corner_radius: 0.0,
                clip: None,
            }));

            // Render column headers for a given index range.
            // When preview offsets are active (column drag), each
            // column is culled individually against the viewport.
            let render_col_headers =
                |frame: &mut SceneFrame, range: std::ops::Range<usize>| {
                    let mid_y = hh * 0.5 + t.header_font_size * 0.35;
                    for ci in range {
                        let col = &model.columns[ci];
                        let cx = col_vx(ci);
                        if preview_offsets.is_some()
                            && (cx + col.width <= lgw || cx >= vp.width)
                        {
                            continue;
                        }

                        let col_in_sel = sel.range().is_some_and(|(tl, br)| {
                            ci >= tl.col && ci <= br.col
                        });
                        if col_in_sel {
                            frame.push(ScenePrimitive::Rect(RectPrimitive {
                                x: cx,
                                y: 0.0,
                                width: col.width,
                                height: hh,
                                fill: t.header_selection_fill,
                                stroke: None,
                                stroke_width: 0.0,
                                corner_radius: 0.0,
                                clip: None,
                            }));
                        }

                        // Clamp to the gutter boundary, mirroring
                        // cells.rs's clip_x/clip_w clamp — otherwise a
                        // column scrolled partway under the row-number
                        // gutter bleeds its header text into the gutter
                        // whenever gutter_bg has any transparency.
                        let clip_x = cx.max(rnw);
                        let clip_w = (cx + col.width - clip_x).max(0.0);

                        // Text must end before the left edge of the
                        // menu icon button to prevent the ellipsis
                        // from overlapping the button.
                        let icon_btn_offset = t.header_menu_icon_margin_r
                            + t.header_menu_icon_btn_w;
                        let label_max_w =
                            (col.width - t.cell_padding - icon_btn_offset)
                                .max(0.0);
                        frame.push(ScenePrimitive::Text(TextPrimitive {
                            x: cx + t.cell_padding,
                            y: mid_y,
                            text: col.label.clone(),
                            color: t.header_text,
                            font_size: t.header_font_size,
                            bold: t.header_font_bold,
                            italic: t.header_font_italic,
                            clip: Some([clip_x, 0.0, clip_w, hh]),
                            align: TextAlign::Left,
                            max_width: Some(label_max_w),
                        }));

                        // Three-dot menu icon (⋮) — three small circles at
                        // the right edge of the header cell.
                        {
                            let mr = t.header_menu_icon_margin_r;
                            let btn_w = t.header_menu_icon_btn_w;
                            let btn_h = if t.header_menu_icon_btn_h > 0.0 {
                                t.header_menu_icon_btn_h
                            } else {
                                (hh - 12.0).max(8.0)
                            };
                            // Right edge of button, inset by margin_r.
                            let btn_rx = cx + col.width - mr;
                            let btn_lx = btn_rx - btn_w;
                            let btn_ty = (hh - btn_h) / 2.0;

                            // Hover background.
                            if hovered_menu_col == Some(ci) {
                                frame.push(ScenePrimitive::Rect(
                                    RectPrimitive {
                                        x: btn_lx,
                                        y: btn_ty,
                                        width: btn_w,
                                        height: btn_h,
                                        fill: t.header_menu_icon_hover_bg,
                                        stroke: None,
                                        stroke_width: 0.0,
                                        corner_radius: t
                                            .header_menu_icon_radius,
                                        clip: None,
                                    },
                                ));
                            }

                            let dot_r = t.header_menu_icon_dot_r;
                            let dot_gap = dot_r * 3.75;
                            // Icon center x = horizontal center of button.
                            let icon_cx = btn_lx + btn_w / 2.0;
                            let icon_mid_y = hh / 2.0;
                            for i in -1i32..=1 {
                                let dot_y = icon_mid_y + i as f64 * dot_gap;
                                frame.push(ScenePrimitive::Rect(
                                    RectPrimitive {
                                        x: icon_cx - dot_r,
                                        y: dot_y - dot_r,
                                        width: dot_r * 2.0,
                                        height: dot_r * 2.0,
                                        fill: t.header_menu_icon,
                                        stroke: None,
                                        stroke_width: 0.0,
                                        corner_radius: dot_r,
                                        clip: None,
                                    },
                                ));
                            }
                        }

                        // Sort indicator ▲ / ▼
                        if let Some(s) = &state.sort
                            && s.col_key == col.key
                        {
                            let aw = t.sort_arrow_width;
                            let ah = t.sort_arrow_height;
                            // Shifted left to leave room for menu icon.
                            let ax = cx + col.width
                                - t.header_menu_icon_margin_r
                                - t.header_menu_icon_btn_w
                                - t.cell_padding
                                - aw;
                            let ay = mid_y - t.header_font_size * 0.35;
                            let points = if s.dir == SortDir::Asc {
                                vec![
                                    [ax, ay - ah],
                                    [ax + aw, ay + ah * 0.6],
                                    [ax - aw, ay + ah * 0.6],
                                ]
                            } else {
                                vec![
                                    [ax, ay + ah],
                                    [ax + aw, ay - ah * 0.6],
                                    [ax - aw, ay - ah * 0.6],
                                ]
                            };
                            frame.push(ScenePrimitive::Polygon(
                                PolygonPrimitive {
                                    points,
                                    fill: t.header_text,
                                    corner_radius: 0.5,
                                },
                            ));
                        }

                        let sep_x = cx + col.width - 0.5;
                        frame.push(ScenePrimitive::Line(LinePrimitive {
                            x1: sep_x,
                            y1: t.header_separator_inset,
                            x2: sep_x,
                            y2: hh - t.header_separator_inset,
                            color: t.header_border,
                            width: t.header_separator_width,
                        }));
                    }
                };

            // Scrollable column headers.
            // During drag, scan all non-pinned columns so animated
            // headers are visible; culling happens inside the closure.
            let header_range = if preview_offsets.is_some() {
                pinned_count..model.columns.len()
            } else {
                col_start..col_end
            };
            render_col_headers(&mut frame, header_range);
            // Header checkbox (tri-state select-all) — scrolls with the
            // data like the row checkboxes below; drawn before the pinned
            // header band so it gets masked once scrolled underneath.
            if ccw > 0.0 {
                let ccx = rnw + pinned_width - sx;
                if ccx + ccw > rnw && ccx < vp.width {
                    checkbox::emit_checkbox(
                        &mut frame,
                        ccx,
                        0.0,
                        ccw,
                        hh,
                        rnw,
                        t,
                        state.checkbox_header_state(),
                    );
                }
            }
            // Pinned column headers (on top)
            if pinned_count > 0 {
                // Solid background masking scrollable headers underneath.
                frame.push(ScenePrimitive::Rect(RectPrimitive {
                    x: rnw,
                    y: 0.0,
                    width: pinned_width,
                    height: hh,
                    fill: t.pinned_header_bg,
                    stroke: None,
                    stroke_width: 0.0,
                    corner_radius: 0.0,
                    clip: None,
                }));
                render_col_headers(&mut frame, 0..pinned_count);
            }

            frame.push(ScenePrimitive::Line(LinePrimitive {
                x1: 0.0,
                y1: hh - 0.5,
                x2: vp.width,
                y2: hh - 0.5,
                color: t.header_border,
                width: 1.0,
            }));
        } // end if hh > 0.0

        // ── row-number gutter (sticky, drawn on top of scrolled data) ────────
        if rnw > 0.0 {
            // Header corner + gutter background
            frame.push(ScenePrimitive::Rect(RectPrimitive {
                x: 0.0,
                y: 0.0,
                width: rnw,
                height: vp.height,
                fill: t.gutter_bg,
                stroke: None,
                stroke_width: 0.0,
                corner_radius: 0.0,
                clip: None,
            }));

            for ri in row_start..row_end {
                let ry = row_vy(ri);
                if ry + model.row_height < hh || ry > vp.height {
                    continue;
                }

                let is_selected = sel
                    .range()
                    .is_some_and(|(tl, br)| ri >= tl.row && ri <= br.row);

                // Clamp everything to below the sticky header.
                let clip_y = ry.max(hh);
                let clip_h = (ry + model.row_height - clip_y).max(0.0);
                if clip_h == 0.0 {
                    continue;
                }

                if is_selected {
                    frame.push(ScenePrimitive::Rect(RectPrimitive {
                        x: 0.0,
                        y: clip_y,
                        width: rnw,
                        height: clip_h,
                        fill: t.gutter_selection_fill,
                        stroke: None,
                        stroke_width: 0.0,
                        corner_radius: 0.0,
                        clip: None,
                    }));
                }

                let mid_y =
                    ry + model.row_height * 0.5 + t.gutter_font_size * 0.35;
                frame.push(ScenePrimitive::Text(TextPrimitive {
                    x: rnw - t.cell_padding,
                    y: mid_y,
                    text: (ri + 1).to_string(),
                    color: t.gutter_text,
                    font_size: t.gutter_font_size,
                    bold: t.gutter_font_bold,
                    italic: t.gutter_font_italic,
                    clip: Some([0.0, clip_y, rnw, clip_h]),
                    align: TextAlign::Right,
                    max_width: None,
                }));

                // Horizontal grid line inside gutter — only below header.
                let line_y = ry + model.row_height - 0.5;
                if line_y > hh {
                    frame.push(ScenePrimitive::Line(LinePrimitive {
                        x1: 0.0,
                        y1: line_y,
                        x2: rnw,
                        y2: line_y,
                        color: t.grid_line,
                        width: t.grid_line_width,
                    }));
                }
            }

            // Gutter right border (full height)
            frame.push(ScenePrimitive::Line(LinePrimitive {
                x1: rnw - 0.5,
                y1: 0.0,
                x2: rnw - 0.5,
                y2: vp.height,
                color: t.gutter_border,
                width: 1.0,
            }));

            // Header bottom border re-drawn on top of gutter
            frame.push(ScenePrimitive::Line(LinePrimitive {
                x1: 0.0,
                y1: hh - 0.5,
                x2: rnw,
                y2: hh - 0.5,
                color: t.gutter_border,
                width: 1.0,
            }));
        }

        // ── scrollbars ───────────────────────────────────────────────────────
        // `rnw` alone — the checkbox column's width is already folded into
        // `model.total_width()` (it scrolls with the data), not into this
        // fixed reserve.
        scrollbars::emit_scrollbars(&mut frame, vp, model, rnw, t);

        // ── column drag preview ────────────────────────────────
        if let Some(hint) = col_drag {
            let cols = &model.columns;
            if hint.source_col < cols.len() {
                let src_w = cols[hint.source_col].width;
                let src_vx = col_vx(hint.source_col);

                // 1. Dim the source column (header + all rows)
                // — with preview offsets, src_vx is already the
                //   target position, so the overlay shows where
                //   the column will land.
                frame.push(ScenePrimitive::Rect(RectPrimitive {
                    x: src_vx,
                    y: 0.0,
                    width: src_w,
                    height: vp.height,
                    fill: t.drag_overlay,
                    stroke: None,
                    stroke_width: 0.0,
                    corner_radius: 0.0,
                    clip: None,
                }));

                // 3. Ghost badge (follows cursor in both X and Y)
                let ghost_w = src_w;
                let ghost_h = hh;
                let ghost_x = (hint.cursor_vx - ghost_w / 2.0)
                    .max(0.0)
                    .min(vp.width - ghost_w);
                let ghost_y = (hint.cursor_vy - ghost_h / 2.0)
                    .max(0.0)
                    .min(vp.height - ghost_h);
                frame.push(ScenePrimitive::Rect(RectPrimitive {
                    x: ghost_x,
                    y: ghost_y,
                    width: ghost_w,
                    height: ghost_h,
                    fill: t.drag_ghost_bg,
                    stroke: Some(t.header_border),
                    stroke_width: t.drag_ghost_border_width,
                    corner_radius: t.drag_ghost_radius,
                    clip: None,
                }));

                // Ghost label — vertically centred inside the badge
                let mid_y = ghost_y + ghost_h * 0.5 + t.header_font_size * 0.35;
                frame.push(ScenePrimitive::Text(TextPrimitive {
                    x: ghost_x + t.cell_padding,
                    y: mid_y,
                    text: cols[hint.source_col].label.clone(),
                    color: t.drag_ghost_text,
                    font_size: t.header_font_size,
                    bold: t.header_font_bold,
                    italic: t.header_font_italic,
                    clip: Some([ghost_x, ghost_y, ghost_w, ghost_h]),
                    align: TextAlign::Left,
                    max_width: None,
                }));
            }
        }

        frame
    }
}

#[cfg(test)]
mod tests {
    use rs_grid_core::{
        column::ColumnDef,
        commands::GridCommand,
        model::GridModel,
        row::RowRecord,
        selection::CellCoord,
        sort::{SortDir, SortState},
        state::GridState,
    };

    use super::*;
    use crate::primitives::{
        Color, LinePrimitive, PolygonPrimitive, RectPrimitive, ScenePrimitive,
        TextPrimitive,
    };

    // ── helpers ──────────────────────────────────────────────

    /// Build a small 3-column × 10-row grid state.
    fn make_state() -> GridState {
        let cols = vec![
            ColumnDef::new("a", "Alpha", 100.0),
            ColumnDef::new("b", "Beta", 150.0),
            ColumnDef::new("c", "Gamma", 200.0),
        ];
        let rows: Vec<RowRecord> = (0..10)
            .map(|i| {
                let mut r = RowRecord::new(i);
                r.set("a", format!("a{i}"));
                r.set("b", format!("b{i}"));
                r.set("c", format!("c{i}"));
                r
            })
            .collect();
        let model = GridModel::new(cols, rows, 30.0, 40.0);
        GridState::new(model, 800.0, 600.0)
    }

    /// Extract all Text primitives from a frame.
    fn text_primitives(
        frame: &crate::frame::SceneFrame,
    ) -> Vec<&TextPrimitive> {
        frame
            .primitives
            .iter()
            .filter_map(|p| match p {
                ScenePrimitive::Text(t) => Some(t),
                _ => None,
            })
            .collect()
    }

    /// Extract all Rect primitives from a frame.
    fn rect_primitives(
        frame: &crate::frame::SceneFrame,
    ) -> Vec<&RectPrimitive> {
        frame
            .primitives
            .iter()
            .filter_map(|p| match p {
                ScenePrimitive::Rect(r) => Some(r),
                _ => None,
            })
            .collect()
    }

    /// Extract all Line primitives from a frame.
    fn line_primitives(
        frame: &crate::frame::SceneFrame,
    ) -> Vec<&LinePrimitive> {
        frame
            .primitives
            .iter()
            .filter_map(|p| match p {
                ScenePrimitive::Line(l) => Some(l),
                _ => None,
            })
            .collect()
    }

    /// Extract all Polygon primitives from a frame.
    fn polygon_primitives(
        frame: &crate::frame::SceneFrame,
    ) -> Vec<&PolygonPrimitive> {
        frame
            .primitives
            .iter()
            .filter_map(|p| match p {
                ScenePrimitive::Polygon(pg) => Some(pg),
                _ => None,
            })
            .collect()
    }

    // ── SceneBuilder construction ────────────────────────────

    #[test]
    fn builder_default() {
        let b = SceneBuilder::default();
        assert_eq!(b.dpr, 1.0);
        assert_eq!(b.theme, Theme::default());
    }

    #[test]
    fn builder_new_stores_dpr() {
        let b = SceneBuilder::new(2.0);
        assert_eq!(b.dpr, 2.0);
    }

    #[test]
    fn builder_with_theme() {
        let dark = Theme::dark();
        let b = SceneBuilder::with_theme(1.5, dark.clone());
        assert_eq!(b.dpr, 1.5);
        assert_eq!(b.theme, dark);
    }

    // ── basic build ──────────────────────────────────────────

    #[test]
    fn build_produces_nonempty_frame() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        assert!(frame.primitive_count() > 0);
    }

    #[test]
    fn build_frame_dimensions_match_viewport() {
        let state = make_state();
        let b = SceneBuilder::new(2.0);
        let frame = b.build(&state, None, None, None);
        assert_eq!(frame.viewport_width, 800.0);
        assert_eq!(frame.viewport_height, 600.0);
        assert_eq!(frame.dpr, 2.0);
    }

    #[test]
    fn build_first_primitive_is_background_rect() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        match &frame.primitives[0] {
            ScenePrimitive::Rect(r) => {
                assert_eq!(r.x, 0.0);
                assert_eq!(r.y, 0.0);
                assert_eq!(r.width, 800.0);
                assert_eq!(r.height, 600.0);
                assert_eq!(r.fill, Theme::light().bg);
            }
            _ => panic!("first primitive should be background Rect"),
        }
    }

    // ── header ───────────────────────────────────────────────

    #[test]
    fn build_contains_header_background() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let t = Theme::light();
        let header_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.y == 0.0 && r.height == 40.0 && r.fill == t.header_bg)
            .collect();
        assert!(
            !header_rects.is_empty(),
            "should have a header background rect"
        );
    }

    #[test]
    fn build_contains_column_header_labels() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let texts = text_primitives(&frame);
        let labels: Vec<&str> = texts.iter().map(|t| t.text.as_str()).collect();
        assert!(labels.contains(&"Alpha"), "missing header Alpha");
        assert!(labels.contains(&"Beta"), "missing header Beta");
        assert!(labels.contains(&"Gamma"), "missing header Gamma");
    }

    #[test]
    fn build_header_bottom_border() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let lines = line_primitives(&frame);
        let hh = 40.0;
        let header_border = lines.iter().any(|l| {
            (l.y1 - (hh - 0.5)).abs() < 0.01
                && (l.y2 - (hh - 0.5)).abs() < 0.01
                && l.x1 == 0.0
        });
        assert!(header_border, "should have header bottom border");
    }

    // ── data rows ────────────────────────────────────────────

    #[test]
    fn build_contains_cell_text() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let texts = text_primitives(&frame);
        // Row 0 data should appear.
        assert!(
            texts.iter().any(|t| t.text == "a0"),
            "should render cell a0"
        );
        assert!(
            texts.iter().any(|t| t.text == "b0"),
            "should render cell b0"
        );
    }

    #[test]
    fn build_alternating_row_backgrounds() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let t = Theme::light();
        let alt_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == t.row_alt_bg && r.height == 30.0)
            .collect();
        // Odd rows should have alt-bg (rows 1, 3, 5, 7, 9).
        assert!(
            !alt_rects.is_empty(),
            "should have alternating row backgrounds"
        );
    }

    #[test]
    fn build_horizontal_grid_lines() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let lines = line_primitives(&frame);
        // Horizontal grid lines span full viewport width.
        let h_lines: Vec<_> = lines
            .iter()
            .filter(|l| {
                l.x1 == 0.0
                    && (l.x2 - 800.0).abs() < 0.01
                    && (l.y1 - l.y2).abs() < 0.01
                    && l.y1 > 40.0 // below header
            })
            .collect();
        assert!(!h_lines.is_empty(), "should have horizontal grid lines");
    }

    #[test]
    fn build_horizontal_grid_lines_use_themed_width() {
        let state = make_state();
        let mut t = Theme::light();
        t.grid_line_width = 3.0;
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let lines = line_primitives(&frame);
        let h_lines: Vec<_> = lines
            .iter()
            .filter(|l| {
                l.x1 == 0.0
                    && (l.x2 - 800.0).abs() < 0.01
                    && (l.y1 - l.y2).abs() < 0.01
                    && l.y1 > 40.0
            })
            .collect();
        assert!(!h_lines.is_empty(), "should have horizontal grid lines");
        assert!(
            h_lines.iter().all(|l| l.width == 3.0),
            "horizontal grid lines should use Theme::grid_line_width"
        );
    }

    // ── vertical column separators (data rows) ───────────────

    #[test]
    fn build_vertical_column_separators_in_data_rows() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let lines = line_primitives(&frame);
        // 3 columns (Alpha 100, Beta 150, Gamma 200) → 3 boundary lines,
        // one per column right edge, drawn once (not once per row).
        let seps: Vec<_> = lines
            .iter()
            .filter(|l| {
                (l.x1 - l.x2).abs() < 0.01
                    && l.color == t.column_separator_color
                    && l.width == t.column_separator_width
                    && (l.y1 - 40.0).abs() < 0.01
                    && (l.y2 - 600.0).abs() < 0.01
            })
            .collect();
        assert_eq!(
            seps.len(),
            3,
            "should have exactly one column separator per column boundary, not per cell"
        );
    }

    #[test]
    fn build_vertical_column_separators_disabled_when_width_zero() {
        let state = make_state();
        let mut t = Theme::light();
        t.column_separator_width = 0.0;
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let lines = line_primitives(&frame);
        let seps: Vec<_> = lines
            .iter()
            .filter(|l| {
                (l.x1 - l.x2).abs() < 0.01
                    && l.color == t.column_separator_color
            })
            .collect();
        assert!(
            seps.is_empty(),
            "column separators should be skipped when width is 0"
        );
    }

    // ── column separators ────────────────────────────────────

    #[test]
    fn build_column_separators_in_header() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let lines = line_primitives(&frame);
        // Vertical separators in header (x1 == x2, within header
        // height range, with inset).
        let seps: Vec<_> = lines
            .iter()
            .filter(|l| {
                (l.x1 - l.x2).abs() < 0.01
                    && l.y1 >= t.header_separator_inset - 0.01
                    && l.y2 <= 40.0 - t.header_separator_inset + 0.01
                    && l.y1 < l.y2
            })
            .collect();
        // 3 columns → 3 separators.
        assert_eq!(
            seps.len(),
            3,
            "expected 3 column separators, got {}",
            seps.len()
        );
    }

    // ── menu icon dots ───────────────────────────────────────

    #[test]
    fn build_menu_icon_dots_per_column() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        // Each column header has 3 dots (small rects with
        // corner_radius == dot_r).
        let dot_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| {
                (r.corner_radius - t.header_menu_icon_dot_r).abs() < 0.01
                    && r.fill == t.header_menu_icon
            })
            .collect();
        // 3 columns × 3 dots = 9.
        assert_eq!(
            dot_rects.len(),
            9,
            "expected 9 menu dots, got {}",
            dot_rects.len()
        );
    }

    // ── hovered menu icon ────────────────────────────────────

    #[test]
    fn build_hovered_menu_shows_hover_bg() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame_no_hover = b.build(&state, None, None, None);
        let frame_hover = b.build(&state, None, None, Some(1));

        let hover_rects = |f: &crate::frame::SceneFrame| {
            rect_primitives(f)
                .into_iter()
                .filter(|r| r.fill == t.header_menu_icon_hover_bg)
                .count()
        };
        assert_eq!(hover_rects(&frame_no_hover), 0);
        assert_eq!(hover_rects(&frame_hover), 1);
    }

    // ── selection ────────────────────────────────────────────

    #[test]
    fn build_selection_adds_fill_and_border() {
        let mut state = make_state();
        state.apply(GridCommand::SelectCell(CellCoord { row: 1, col: 1 }));
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        // Selection fill rect.
        let sel_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == t.selection_fill)
            .collect();
        assert!(!sel_rects.is_empty(), "should have selection fill rects");

        // Selection border (4 lines).
        let border_lines: Vec<_> = line_primitives(&frame)
            .into_iter()
            .filter(|l| l.color == t.selection_border)
            .collect();
        assert_eq!(
            border_lines.len(),
            4,
            "selection should produce 4 border lines"
        );
    }

    #[test]
    fn build_no_selection_no_border_lines() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let border_lines: Vec<_> = line_primitives(&frame)
            .into_iter()
            .filter(|l| l.color == t.selection_border)
            .collect();
        assert!(border_lines.is_empty(), "no selection → no border lines");
    }

    #[test]
    fn build_multi_cell_selection() {
        let mut state = make_state();
        state.apply(GridCommand::SelectCell(CellCoord { row: 0, col: 0 }));
        state.apply(GridCommand::ExtendSelection(CellCoord { row: 2, col: 1 }));
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        // Multiple cells selected → multiple fill rects.
        let sel_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == t.selection_fill)
            .collect();
        // 3 rows × 2 cols = 6 cell fills + possibly header
        // highlight.
        assert!(
            sel_rects.len() >= 6,
            "multi-cell selection should produce ≥6 fill rects, \
             got {}",
            sel_rects.len()
        );
    }

    // ── hover row ────────────────────────────────────────────

    #[test]
    fn build_hovered_row_overlay() {
        let mut state = make_state();
        state.hovered_row = Some(2);
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let hover_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == t.row_hover_bg)
            .collect();
        assert!(
            !hover_rects.is_empty(),
            "hovered row should produce hover overlay"
        );
    }

    #[test]
    fn build_no_hover_no_overlay() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let hover_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == t.row_hover_bg)
            .collect();
        assert!(hover_rects.is_empty(), "no hover → no hover overlay");
    }

    // ── checked row ──────────────────────────────────────────

    #[test]
    fn build_checked_row_overlay() {
        let mut state = make_state();
        state.checked_rows.insert(2);
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let checked_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == t.checked_row_bg)
            .collect();
        assert!(
            !checked_rects.is_empty(),
            "checked row should produce checked-row overlay"
        );
    }

    #[test]
    fn build_no_checked_rows_no_overlay() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let checked_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == t.checked_row_bg)
            .collect();
        assert!(
            checked_rects.is_empty(),
            "no checked rows → no checked-row overlay"
        );
    }

    // ── sort indicator ───────────────────────────────────────

    #[test]
    fn build_sort_indicator_polygon() {
        let mut state = make_state();
        state.sort = Some(SortState {
            col_key: "a".into(),
            dir: SortDir::Asc,
        });
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let polys = polygon_primitives(&frame);
        // At least one polygon for the sort arrow.
        assert!(!polys.is_empty(), "sort should produce a polygon arrow");
    }

    #[test]
    fn build_no_sort_no_polygon() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let polys = polygon_primitives(&frame);
        // No sort → no polygons in data area (scrollbar arrows
        // may still produce polygons).
        // Check: no polygon has the header_text fill.
        let t = Theme::light();
        let sort_polys: Vec<_> =
            polys.iter().filter(|p| p.fill == t.header_text).collect();
        assert!(sort_polys.is_empty(), "no sort → no sort indicator polygon");
    }

    #[test]
    fn build_sort_desc_different_points() {
        let mut state = make_state();
        state.sort = Some(SortState {
            col_key: "b".into(),
            dir: SortDir::Desc,
        });
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let t = Theme::light();
        let polys: Vec<_> = polygon_primitives(&frame)
            .into_iter()
            .filter(|p| p.fill == t.header_text)
            .collect();
        assert_eq!(polys.len(), 1, "one sort polygon");
        // Desc: tip points downward (max y among vertices is
        // the tip).
        let pts = &polys[0].points;
        assert_eq!(pts.len(), 3);
    }

    // ── flash ────────────────────────────────────────────────

    #[test]
    fn build_flash_changes_selection_border_color() {
        let mut state = make_state();
        state.apply(GridCommand::SelectCell(CellCoord { row: 0, col: 0 }));
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let flash = FlashHint {
            alpha_factor: 0.5,
            cells: [(0, 0)].into_iter().collect(),
            is_error: false,
        };
        let frame = b.build(&state, None, Some(&flash), None);

        // Border lines should be interpolated halfway between
        // selection_border (at alpha_factor 0.0) and flash_border (at
        // alpha_factor 1.0), not flash_border-with-fading-alpha.
        let expected_color = t.selection_border.lerp(t.flash_border, 0.5);
        let flash_borders: Vec<_> = line_primitives(&frame)
            .into_iter()
            .filter(|l| l.color == expected_color)
            .collect();
        assert_eq!(
            flash_borders.len(),
            4,
            "flash should produce 4 border lines with the interpolated color"
        );
    }

    #[test]
    fn build_flash_border_color_continuous_at_animation_boundaries() {
        // Regression test for the "color pops back" bug: the flash
        // border must converge exactly to `selection_border` as
        // alpha_factor -> 0.0 (matching the color used once `flash`
        // becomes `None`), and exactly to `flash_border` at
        // alpha_factor == 1.0 — no discontinuity at either end, even
        // when flash_border and selection_border share the same RGB.
        let mut state = make_state();
        state.apply(GridCommand::SelectCell(CellCoord { row: 0, col: 0 }));
        let mut t = Theme::light();
        t.flash_border = Color::rgba(
            t.selection_border.r,
            t.selection_border.g,
            t.selection_border.b,
            255,
        );
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let count_border_lines =
            |frame: &crate::frame::SceneFrame, color: Color| {
                line_primitives(frame)
                    .into_iter()
                    .filter(|l| l.color == color)
                    .count()
            };

        // At alpha_factor 0.0, the flash border must already be exactly
        // `selection_border` — matching what renders once `flash`
        // becomes `None` a frame later, so there's no visible pop.
        let flash_end = FlashHint {
            alpha_factor: 0.0,
            cells: [(0, 0)].into_iter().collect(),
            is_error: false,
        };
        let frame_end = b.build(&state, None, Some(&flash_end), None);
        assert_eq!(
            count_border_lines(&frame_end, t.selection_border),
            4,
            "at alpha_factor 0.0 the border should already be selection_border"
        );

        let frame_none = b.build(&state, None, None, None);
        assert_eq!(
            count_border_lines(&frame_none, t.selection_border),
            4,
            "with no flash, the border should be selection_border"
        );

        // At alpha_factor 1.0, the flash border must be exactly
        // `flash_border`.
        let flash_start = FlashHint {
            alpha_factor: 1.0,
            cells: [(0, 0)].into_iter().collect(),
            is_error: false,
        };
        let frame_start = b.build(&state, None, Some(&flash_start), None);
        assert_eq!(
            count_border_lines(&frame_start, t.flash_border),
            4,
            "at alpha_factor 1.0 the border should be flash_border"
        );
    }

    #[test]
    fn build_flash_overlay_on_selected_cells() {
        let mut state = make_state();
        state.apply(GridCommand::SelectCell(CellCoord { row: 0, col: 0 }));
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let flash = FlashHint {
            alpha_factor: 1.0,
            cells: [(0, 0)].into_iter().collect(),
            is_error: false,
        };
        let frame = b.build(&state, None, Some(&flash), None);

        // Should have a flash fill rect on the selected cell.
        let flash_fill = Color::rgba(
            t.flash_fill.r,
            t.flash_fill.g,
            t.flash_fill.b,
            t.flash_fill.a,
        );
        let flash_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == flash_fill)
            .collect();
        assert!(
            !flash_rects.is_empty(),
            "flash should produce overlay rects on selected cells"
        );
    }

    // ── zero row height early return ─────────────────────────

    #[test]
    fn build_zero_row_height_returns_early() {
        let cols = vec![ColumnDef::new("a", "A", 100.0)];
        let rows = vec![];
        let model = GridModel::new(cols, rows, 0.0, 40.0);
        let state = GridState::new(model, 800.0, 600.0);
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        // Only the background rect should be present.
        assert_eq!(
            frame.primitive_count(),
            1,
            "zero row_height → only background"
        );
    }

    // ── empty grid ───────────────────────────────────────────

    #[test]
    fn build_empty_grid_no_data_rows() {
        let cols = vec![ColumnDef::new("x", "X", 100.0)];
        let rows: Vec<RowRecord> = vec![];
        let model = GridModel::new(cols, rows, 30.0, 40.0);
        let state = GridState::new(model, 800.0, 600.0);
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        // Should still have background + header, but no cell text.
        let texts = text_primitives(&frame);
        let data_texts: Vec<_> =
            texts.iter().filter(|t| t.text != "X").collect();
        assert!(
            data_texts.is_empty(),
            "empty grid should have no data cell text"
        );
    }

    // ── header/gutter clip clamping ──────────────────────────

    #[test]
    fn scrolled_row_straddling_header_has_no_text_bleeding_above_header() {
        // make_state() uses row_height=30.0, header_height=40.0.
        // scroll_y=25.0 puts row 0's ry at 40-25=15, i.e. straddling
        // the header boundary (15 < hh=40).
        let mut state = make_state();
        state.apply(GridCommand::ScrollTo { x: 0.0, y: 25.0 });
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let hh = state.model.effective_header_height();
        // Column header labels ("Alpha"/"Beta"/"Gamma") legitimately
        // have clip.y == 0 — they're the header's own content, not
        // scrolled data. Only data-cell text (values like "a0", "b3")
        // must stay clipped below the header boundary.
        let data_texts = text_primitives(&frame)
            .into_iter()
            .filter(|t| !matches!(t.text.as_str(), "Alpha" | "Beta" | "Gamma"));
        for txt in data_texts {
            if let Some(clip) = txt.clip {
                assert!(
                    clip[1] >= hh,
                    "Text primitive clip.y ({}) must not start above \
                     the header boundary ({hh}) — cell text is \
                     bleeding into the sticky header",
                    clip[1]
                );
            }
        }
    }

    #[test]
    fn body_clip_tracks_header_height_after_resize() {
        let mut state = make_state();
        state.model.header_height = 90.0;
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let hh = state.model.effective_header_height();
        let data_texts = text_primitives(&frame)
            .into_iter()
            .filter(|t| !matches!(t.text.as_str(), "Alpha" | "Beta" | "Gamma"));
        for txt in data_texts {
            if let Some(clip) = txt.clip {
                assert!(
                    clip[1] >= hh,
                    "clip.y ({}) must track the resized header_height ({hh})",
                    clip[1]
                );
            }
        }
    }

    #[test]
    fn body_clip_tracks_row_number_width_after_resize() {
        let mut state = make_state();
        state.model.row_number_width = 120.0;
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);
        let rnw = state.model.effective_row_number_width();
        // Exclude the gutter's own row-number labels ("1".."10") — like
        // the header's column labels, they legitimately have clip.x == 0
        // since they *are* the gutter content, not scrolled data.
        let data_texts = text_primitives(&frame).into_iter().filter(|t| {
            !matches!(t.text.as_str(), "Alpha" | "Beta" | "Gamma")
                && !t.text.chars().all(|c| c.is_ascii_digit())
        });
        for txt in data_texts {
            if let Some(clip) = txt.clip {
                assert!(
                    clip[0] >= rnw,
                    "clip.x ({}) must track the resized row_number_width ({rnw})",
                    clip[0]
                );
            }
        }
    }

    // ── column drag ──────────────────────────────────────────

    #[test]
    fn build_column_drag_shows_ghost() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let drag = ColumnDragHint {
            source_col: 0,
            insert_before: 2,
            cursor_vx: 300.0,
            cursor_vy: 20.0,
            animated_offsets: vec![],
        };
        let frame = b.build(&state, Some(&drag), None, None);

        // Ghost badge rect (drag_ghost_bg fill + rounded corners).
        let ghost_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == t.drag_ghost_bg && r.corner_radius > 0.0)
            .collect();
        assert_eq!(ghost_rects.len(), 1, "drag should show one ghost badge");

        // Ghost label text.
        let texts = text_primitives(&frame);
        let ghost_labels: Vec<_> = texts
            .iter()
            .filter(|t| t.color == Theme::light().drag_ghost_text)
            .collect();
        assert!(!ghost_labels.is_empty(), "ghost badge should have a label");
    }

    #[test]
    fn build_column_drag_overlay() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let drag = ColumnDragHint {
            source_col: 1,
            insert_before: 0,
            cursor_vx: 100.0,
            cursor_vy: 20.0,
            animated_offsets: vec![],
        };
        let frame = b.build(&state, Some(&drag), None, None);

        // Dim overlay on source column.
        let overlay_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| {
                r.fill == t.drag_overlay
                    && r.y == 0.0
                    && (r.height - 600.0).abs() < 0.01
            })
            .collect();
        assert!(
            !overlay_rects.is_empty(),
            "drag should show dim overlay on source column"
        );
    }

    #[test]
    fn build_column_drag_out_of_bounds_no_panic() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let drag = ColumnDragHint {
            source_col: 999,
            insert_before: 0,
            cursor_vx: 100.0,
            cursor_vy: 20.0,
            animated_offsets: vec![],
        };
        // Should not panic.
        let _ = b.build(&state, Some(&drag), None, None);
    }

    #[test]
    fn build_column_drag_with_animated_offsets() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        let drag = ColumnDragHint {
            source_col: 0,
            insert_before: 2,
            cursor_vx: 200.0,
            cursor_vy: 20.0,
            animated_offsets: vec![150.0, 0.0, 250.0],
        };
        let frame = b.build(&state, Some(&drag), None, None);
        // Should not panic and produce valid output.
        assert!(frame.primitive_count() > 0);
    }

    // ── scrolled viewport ────────────────────────────────────

    #[test]
    fn build_scrolled_viewport_still_has_header() {
        let mut state = make_state();
        state.apply(GridCommand::ScrollTo { x: 0.0, y: 200.0 });
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        // Header is sticky — should always be present.
        let header_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| {
                r.y == 0.0
                    && (r.height - 40.0).abs() < 0.01
                    && r.fill == t.header_bg
            })
            .collect();
        assert!(
            !header_rects.is_empty(),
            "scrolled viewport should still show header"
        );
    }

    // ── row number gutter ────────────────────────────────────

    #[test]
    fn build_with_row_numbers() {
        let cols = vec![ColumnDef::new("a", "A", 100.0)];
        let rows: Vec<RowRecord> = (0..5)
            .map(|i| {
                let mut r = RowRecord::new(i);
                r.set("a", format!("v{i}"));
                r
            })
            .collect();
        let mut model = GridModel::new(cols, rows, 30.0, 40.0);
        model.row_number_width = 50.0;
        let state = GridState::new(model, 800.0, 600.0);
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        // Gutter background rect.
        let gutter_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| {
                r.x == 0.0
                    && (r.width - 50.0).abs() < 0.01
                    && r.fill == t.gutter_bg
            })
            .collect();
        assert!(!gutter_rects.is_empty(), "should have gutter background");

        // Row numbers as text (1-indexed).
        let texts = text_primitives(&frame);
        assert!(
            texts.iter().any(|t| t.text == "1"),
            "should show row number 1"
        );
        assert!(
            texts.iter().any(|t| t.text == "5"),
            "should show row number 5"
        );
    }

    #[test]
    fn build_without_row_numbers_no_gutter() {
        let cols = vec![ColumnDef::new("a", "A", 100.0)];
        let mut model = GridModel::new(cols, vec![], 30.0, 40.0);
        model.row_number_width = 0.0;
        let state = GridState::new(model, 800.0, 600.0);
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        // Gutter border line at x ≈ rnw-0.5 should not exist.
        let lines = line_primitives(&frame);
        let gutter_border = lines.iter().any(|l| {
            l.color == t.gutter_border
                && (l.x1 - l.x2).abs() < 0.01
                && l.y1 == 0.0
        });
        assert!(!gutter_border, "no row numbers → no gutter border");
    }

    // ── checkbox column ──────────────────────────────────────

    #[test]
    fn checkbox_column_hidden_by_default() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        // No rect uses the checked-fill color when the column is off.
        let rects = rect_primitives(&frame);
        assert!(
            !rects.iter().any(|r| r.fill == t.checkbox_checked_bg),
            "checkbox column disabled by default → no checkbox rects"
        );
    }

    #[test]
    fn checkbox_column_enabled_renders_boxes() {
        use rs_grid_core::commands::GridCommand;

        let mut state = make_state();
        state.apply(GridCommand::SetShowCheckboxColumn(true));
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        let rects = rect_primitives(&frame);
        // One box per visible row + one header box, all sized
        // checkbox_size with the themed border.
        let boxes: Vec<_> = rects
            .iter()
            .filter(|r| {
                r.width == t.checkbox_size
                    && r.height == t.checkbox_size
                    && r.stroke == Some(t.checkbox_border)
            })
            .collect();
        assert!(
            boxes.len() >= 11,
            "expected 10 row boxes + 1 header box, got {}",
            boxes.len()
        );
    }

    #[test]
    fn checkbox_row_checked_fills_box() {
        use rs_grid_core::commands::GridCommand;

        let mut state = make_state();
        state.apply(GridCommand::SetShowCheckboxColumn(true));
        state.apply(GridCommand::ToggleRowChecked(0));
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        let rects = rect_primitives(&frame);
        let checked_boxes = rects
            .iter()
            .filter(|r| {
                r.width == t.checkbox_size && r.fill == t.checkbox_checked_bg
            })
            .count();
        // Row 0's box is filled; the header (indeterminate — 1 of 10
        // checked) is also drawn with the checked-bg fill.
        assert_eq!(checked_boxes, 2);
    }

    #[test]
    fn checkbox_column_adds_left_gutter_offset() {
        use rs_grid_core::commands::GridCommand;

        let mut state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let before = b.build(&state, None, None, None);

        state.apply(GridCommand::SetShowCheckboxColumn(true));
        let after = b.build(&state, None, None, None);

        // The vertical column-separator right after the gutter must
        // shift right by exactly CHECKBOX_COLUMN_WIDTH. Filter to
        // vertical lines (x1 == x2) so this doesn't accidentally match
        // horizontal grid lines that share the same themed color.
        let first_col_sep = |frame: &crate::frame::SceneFrame| -> f64 {
            line_primitives(frame)
                .iter()
                .filter(|l| l.color == t.column_separator_color && l.x1 == l.x2)
                .map(|l| l.x1)
                .fold(f64::MAX, f64::min)
        };
        assert_eq!(
            first_col_sep(&after) - first_col_sep(&before),
            rs_grid_core::model::GridModel::CHECKBOX_COLUMN_WIDTH,
        );
    }

    #[test]
    fn checkbox_column_scrolls_away_with_data() {
        let mut state = make_state();
        state.apply(GridCommand::SetShowCheckboxColumn(true));
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());

        let has_checkbox_box = |frame: &crate::frame::SceneFrame| {
            rect_primitives(frame).iter().any(|r| {
                r.width == t.checkbox_size && r.height == t.checkbox_size
            })
        };

        // At rest, checkbox boxes are drawn (row 0 + header).
        assert!(has_checkbox_box(&b.build(&state, None, None, None)));

        // Scrolled past its own width, the checkbox column (the first slot
        // of the scrollable region) is fully off-screen to the left —
        // unlike the row-number gutter, it does not stay fixed.
        state.viewport.scroll_x = GridModel::CHECKBOX_COLUMN_WIDTH + 50.0;
        assert!(!has_checkbox_box(&b.build(&state, None, None, None)));
    }

    #[test]
    fn checkbox_column_does_not_shift_pinned_columns() {
        let mut state = make_state();
        state.model.pinned_count = 1;
        state.model.rebuild_offsets();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let before = b.build(&state, None, None, None);

        state.apply(GridCommand::SetShowCheckboxColumn(true));
        let after = b.build(&state, None, None, None);

        // The pinned band's right-edge separator must NOT move — the
        // checkbox column sits after the pinned band (in the scrollable
        // region), never between the gutter and the pinned columns.
        let pinned_sep_x = |frame: &crate::frame::SceneFrame| -> f64 {
            line_primitives(frame)
                .iter()
                .find(|l| l.color == t.pinned_separator_color)
                .map(|l| l.x1)
                .expect("pinned separator line present")
        };
        assert_eq!(pinned_sep_x(&before), pinned_sep_x(&after));
    }

    // ── pinned columns ───────────────────────────────────────

    #[test]
    fn build_pinned_columns_solid_background() {
        let cols = vec![
            ColumnDef::new("a", "A", 100.0),
            ColumnDef::new("b", "B", 150.0),
            ColumnDef::new("c", "C", 200.0),
        ];
        let rows: Vec<RowRecord> = (0..5)
            .map(|i| {
                let mut r = RowRecord::new(i);
                r.set("a", format!("a{i}"));
                r.set("b", format!("b{i}"));
                r.set("c", format!("c{i}"));
                r
            })
            .collect();
        let mut model = GridModel::new(cols, rows, 30.0, 40.0);
        model.pinned_count = 1;
        let rnw = model.row_number_width;
        let state = GridState::new(model, 800.0, 600.0);
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        // Pinned data overlay background at x=rnw,
        // y=header_height, width=100 (first col width).
        let pinned_bg: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| {
                (r.x - rnw).abs() < 0.01
                    && (r.y - 40.0).abs() < 0.01
                    && (r.width - 100.0).abs() < 0.01
                    && r.fill == t.bg
            })
            .collect();
        assert!(
            !pinned_bg.is_empty(),
            "pinned columns should have solid background overlay"
        );
    }

    #[test]
    fn build_pinned_columns_separator_line() {
        let cols = vec![
            ColumnDef::new("a", "A", 100.0),
            ColumnDef::new("b", "B", 150.0),
        ];
        let rows: Vec<RowRecord> = (0..3)
            .map(|i| {
                let mut r = RowRecord::new(i);
                r.set("a", format!("a{i}"));
                r.set("b", format!("b{i}"));
                r
            })
            .collect();
        let mut model = GridModel::new(cols, rows, 30.0, 40.0);
        model.pinned_count = 1;
        let rnw = model.row_number_width;
        let state = GridState::new(model, 800.0, 600.0);
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);

        // Separator line at x ≈ rnw + pinned_width - 0.5.
        let sep_x = rnw + 100.0 - 0.5;
        let lines = line_primitives(&frame);
        let sep = lines.iter().any(|l| {
            (l.x1 - sep_x).abs() < 0.01
                && (l.x2 - sep_x).abs() < 0.01
                && l.y1 >= 39.0
        });
        assert!(sep, "should have pinned column separator line");
    }

    #[test]
    fn build_pinned_column_separators_between_pinned_columns() {
        let cols = vec![
            ColumnDef::new("a", "A", 100.0),
            ColumnDef::new("b", "B", 150.0),
            ColumnDef::new("c", "C", 200.0),
        ];
        let rows: Vec<RowRecord> = (0..3)
            .map(|i| {
                let mut r = RowRecord::new(i);
                r.set("a", format!("a{i}"));
                r.set("b", format!("b{i}"));
                r.set("c", format!("c{i}"));
                r
            })
            .collect();
        let mut model = GridModel::new(cols, rows, 30.0, 40.0);
        model.pinned_count = 2;
        let rnw = model.row_number_width;
        let t = Theme::light();
        let state = GridState::new(model, 800.0, 600.0);
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        // Boundary between the 2 pinned columns (a: 0..100, b: 100..250).
        let sep_x = rnw + 100.0 - 0.5;
        let lines = line_primitives(&frame);
        let sep = lines.iter().any(|l| {
            (l.x1 - sep_x).abs() < 0.01
                && (l.x2 - sep_x).abs() < 0.01
                && l.color == t.column_separator_color
                && l.width == t.column_separator_width
        });
        assert!(sep, "should have a separator between the pinned columns");
    }

    #[test]
    fn build_pinned_column_separator_not_duplicated_at_band_edge() {
        let cols = vec![
            ColumnDef::new("a", "A", 100.0),
            ColumnDef::new("b", "B", 150.0),
        ];
        let rows: Vec<RowRecord> = (0..3)
            .map(|i| {
                let mut r = RowRecord::new(i);
                r.set("a", format!("a{i}"));
                r.set("b", format!("b{i}"));
                r
            })
            .collect();
        let mut model = GridModel::new(cols, rows, 30.0, 40.0);
        model.pinned_count = 1;
        let rnw = model.row_number_width;
        let state = GridState::new(model, 800.0, 600.0);
        let b = SceneBuilder::new(1.0);
        let frame = b.build(&state, None, None, None);

        // Only the dedicated pinned-band-boundary line should exist at
        // this x in the data-row area (y1 >= header height) — the new
        // inter-pinned-column loop must not also draw one here
        // (pinned_count == 1 means no inter-pinned boundary). Restrict to
        // the data-row y-range so the header's own column-separator line
        // (drawn at the same x for every header column) isn't counted.
        let sep_x = rnw + 100.0 - 0.5;
        let lines = line_primitives(&frame);
        let seps: Vec<_> = lines
            .iter()
            .filter(|l| {
                (l.x1 - sep_x).abs() < 0.01
                    && (l.x2 - sep_x).abs() < 0.01
                    && l.y1 >= 39.0
            })
            .collect();
        assert_eq!(
            seps.len(),
            1,
            "should have exactly one line at the pinned-band boundary, not duplicated"
        );
    }

    // ── scrollbars ───────────────────────────────────────────

    #[test]
    fn build_scrollbar_primitives_present_when_content_overflows() {
        // Make content taller than viewport.
        let cols = vec![ColumnDef::new("a", "A", 100.0)];
        let rows: Vec<RowRecord> = (0..100)
            .map(|i| {
                let mut r = RowRecord::new(i);
                r.set("a", format!("v{i}"));
                r
            })
            .collect();
        let model = GridModel::new(cols, rows, 30.0, 40.0);
        // Viewport height 600 < total height = 40 + 100*30 = 3040.
        let state = GridState::new(model, 800.0, 600.0);
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        // Scrollbar thumb (a rect with scrollbar_thumb fill and
        // scrollbar_radius corner_radius).
        let thumb_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| {
                r.fill == t.scrollbar_thumb
                    && (r.corner_radius - t.scrollbar_radius).abs() < 0.01
            })
            .collect();
        assert!(
            !thumb_rects.is_empty(),
            "overflowing content should show scrollbar thumb"
        );
    }

    #[test]
    fn build_scrollbar_arrows_as_polygons() {
        let cols = vec![ColumnDef::new("a", "A", 100.0)];
        let rows: Vec<RowRecord> = (0..100)
            .map(|i| {
                let mut r = RowRecord::new(i);
                r.set("a", format!("v{i}"));
                r
            })
            .collect();
        let model = GridModel::new(cols, rows, 30.0, 40.0);
        let state = GridState::new(model, 800.0, 600.0);
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        // Scrollbar arrows are polygons with scrollbar_thumb fill.
        let arrow_polys: Vec<_> = polygon_primitives(&frame)
            .into_iter()
            .filter(|p| p.fill == t.scrollbar_thumb)
            .collect();
        // At least 2 arrows (up + down) for vertical scrollbar.
        assert!(
            arrow_polys.len() >= 2,
            "should have scrollbar arrow polygons, got {}",
            arrow_polys.len()
        );
    }

    // ── dark theme produces different colors ─────────────────

    #[test]
    fn build_dark_theme_different_background() {
        let state = make_state();
        let b_light = SceneBuilder::new(1.0);
        let b_dark = SceneBuilder::with_theme(1.0, Theme::dark());
        let f_light = b_light.build(&state, None, None, None);
        let f_dark = b_dark.build(&state, None, None, None);

        // Background rects should differ.
        let bg_light = match &f_light.primitives[0] {
            ScenePrimitive::Rect(r) => r.fill,
            _ => panic!("expected Rect"),
        };
        let bg_dark = match &f_dark.primitives[0] {
            ScenePrimitive::Rect(r) => r.fill,
            _ => panic!("expected Rect"),
        };
        assert_ne!(bg_light, bg_dark);
    }

    // ── high DPR ─────────────────────────────────────────────

    #[test]
    fn build_high_dpr_frame_stores_dpr() {
        let state = make_state();
        let b = SceneBuilder::new(3.0);
        let frame = b.build(&state, None, None, None);
        assert_eq!(frame.dpr, 3.0);
    }

    // ── ColumnDragHint / FlashHint constructors ──────────────

    #[test]
    fn column_drag_hint_debug() {
        let h = ColumnDragHint {
            source_col: 0,
            insert_before: 1,
            cursor_vx: 50.0,
            cursor_vy: 25.0,
            animated_offsets: vec![],
        };
        let s = format!("{:?}", h);
        assert!(s.contains("ColumnDragHint"));
    }

    #[test]
    fn flash_hint_debug_and_clone() {
        let f = FlashHint {
            alpha_factor: 0.75,
            cells: [(0, 0)].into_iter().collect(),
            is_error: false,
        };
        let f2 = f.clone();
        assert_eq!(f.alpha_factor, f2.alpha_factor);
        assert_eq!(f.cells, f2.cells);
        let s = format!("{:?}", f);
        assert!(s.contains("FlashHint"));
    }

    // ── drag same position is no-op offset ───────────────────

    #[test]
    fn build_drag_same_position_no_preview_offset() {
        let state = make_state();
        let b = SceneBuilder::new(1.0);
        // source_col == insert_before → no reorder needed.
        let drag = ColumnDragHint {
            source_col: 1,
            insert_before: 1,
            cursor_vx: 100.0,
            cursor_vy: 20.0,
            animated_offsets: vec![],
        };
        let frame = b.build(&state, Some(&drag), None, None);
        // Should still render without panic.
        assert!(frame.primitive_count() > 0);
    }

    // ── search highlights ────────────────────────────────────

    #[test]
    fn build_search_highlights_matching_cells() {
        let mut state = make_state();
        state.apply(GridCommand::Search { query: "a0".into() });
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);

        // Should have search highlight rects.
        let highlight_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| {
                r.fill == t.search_highlight || r.fill == t.search_current
            })
            .collect();
        assert!(
            !highlight_rects.is_empty(),
            "search should highlight matching cells"
        );
    }

    #[test]
    fn build_no_search_no_highlights() {
        let state = make_state();
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        let highlight_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| {
                r.fill == t.search_highlight || r.fill == t.search_current
            })
            .collect();
        assert!(highlight_rects.is_empty(), "no search → no highlights");
    }

    // ── pinned columns + hover ──────────────────────────

    #[test]
    fn build_pinned_hover_overlay() {
        let cols = vec![
            ColumnDef::new("a", "A", 100.0),
            ColumnDef::new("b", "B", 150.0),
        ];
        let rows: Vec<RowRecord> = (0..5)
            .map(|i| {
                let mut r = RowRecord::new(i);
                r.set("a", format!("a{i}"));
                r.set("b", format!("b{i}"));
                r
            })
            .collect();
        let mut model = GridModel::new(cols, rows, 30.0, 40.0);
        model.pinned_count = 1;
        let mut state = GridState::new(model, 800.0, 600.0);
        state.hovered_row = Some(1);
        let t = Theme::light();
        let b = SceneBuilder::with_theme(1.0, t.clone());
        let frame = b.build(&state, None, None, None);
        // Should have hover overlay in pinned zone
        let hover_rects: Vec<_> = rect_primitives(&frame)
            .into_iter()
            .filter(|r| r.fill == t.row_hover_bg)
            .collect();
        assert!(
            hover_rects.len() >= 2,
            "pinned + scrollable hover overlays; got {}",
            hover_rects.len()
        );
    }
}
