use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    widgets::{Block, Borders, Widget},
};
use unicode_width::UnicodeWidthStr;

use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::{AxisEntry, GridLayout, View};

/// Minimum column width — enough for short numbers/labels + 1 char gap.
const MIN_COL_WIDTH: u16 = 5;
const MAX_COL_WIDTH: u16 = 32;
const MIN_ROW_HEADER_W: u16 = 4;
const MAX_ROW_HEADER_W: u16 = 24;
/// Subtle dark-gray background used to highlight the row containing the cursor.
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
const GROUP_EXPANDED: &str = "▼";
const GROUP_COLLAPSED: &str = "▶";

pub struct GridWidget<'a> {
    pub model: &'a Model,
    pub view: &'a View,
    pub view_name: &'a str,
    pub layout: &'a GridLayout,
    pub mode: &'a AppMode,
    pub search_query: &'a str,
    pub buffers: &'a std::collections::HashMap<String, String>,
    pub drill_state: Option<&'a crate::ui::app::DrillState>,
}

impl<'a> GridWidget<'a> {
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        model: &'a Model,
        view: &'a View,
        view_name: &'a str,
        layout: &'a GridLayout,
        mode: &'a AppMode,
        search_query: &'a str,
        buffers: &'a std::collections::HashMap<String, String>,
        drill_state: Option<&'a crate::ui::app::DrillState>,
    ) -> Self {
        Self {
            model,
            view,
            view_name,
            layout,
            mode,
            search_query,
            buffers,
            drill_state,
        }
    }

    fn render_grid(&self, area: Rect, buf: &mut Buffer) {
        let view = self.view;
        let layout = self.layout;
        let (sel_row, sel_col) = view.selected;
        let row_offset = view.row_offset;
        let col_offset = view.col_offset;
        let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);

        let n_col_levels = layout.col_cats.len().max(1);
        let n_row_levels = layout.row_cats.len().max(1);

        let col_widths = compute_col_widths(self.model, layout, fmt_comma, fmt_decimals);

        // ── Adaptive row header widths ───────────────────────────────
        let data_row_items: Vec<&Vec<String>> = layout
            .row_items
            .iter()
            .filter_map(|e| {
                if let AxisEntry::DataItem(v) = e {
                    Some(v)
                } else {
                    None
                }
            })
            .collect();

        let sub_widths: Vec<u16> = (0..n_row_levels)
            .map(|d| {
                let max_label = data_row_items
                    .iter()
                    .filter_map(|v| v.get(d))
                    .map(|s| s.width() as u16)
                    .max()
                    .unwrap_or(0);
                (max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W)
            })
            .collect();
        let row_header_width: u16 = sub_widths.iter().sum();

        // Flat list of data-only column tuples for repeat-suppression in headers
        let data_col_items: Vec<&Vec<String>> = layout
            .col_items
            .iter()
            .filter_map(|e| {
                if let AxisEntry::DataItem(v) = e {
                    Some(v)
                } else {
                    None
                }
            })
            .collect();

        let has_col_groups = layout
            .col_items
            .iter()
            .any(|e| matches!(e, AxisEntry::GroupHeader { .. }));

        // Compute how many columns fit starting from col_offset.
        let data_area_width = area.width.saturating_sub(row_header_width);
        let mut acc = 0u16;
        let mut last = col_offset;
        for ci in col_offset..layout.col_count() {
            let w = *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH);
            if acc + w > data_area_width {
                break;
            }
            acc += w;
            last = ci + 1;
        }
        let visible_col_range = col_offset..last.max(col_offset + 1).min(layout.col_count());

        // x offset (relative to the data area start) for each column index.
        let col_x: Vec<u16> = {
            let mut v = vec![0u16; layout.col_count() + 1];
            for ci in 0..layout.col_count() {
                v[ci + 1] = v[ci] + *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH);
            }
            v
        };
        let col_x_at = |ci: usize| -> u16 {
            area.x + row_header_width + col_x[ci].saturating_sub(col_x[col_offset])
        };
        let col_w_at = |ci: usize| -> u16 { *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH) };

        let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };

        let visual_row_start = layout
            .data_row_to_visual(row_offset)
            .unwrap_or(layout.row_items.len());

        let mut y = area.y;

        // Optional column group header row
        if has_col_groups {
            let group_style = Style::default()
                .fg(Color::Green)
                .add_modifier(Modifier::BOLD);
            buf.set_string(
                area.x,
                y,
                format!("{:<width$}", "", width = row_header_width as usize),
                Style::default(),
            );
            let mut prev_group: Option<String> = None;
            for ci in visible_col_range.clone() {
                let x = col_x_at(ci);
                if x >= area.x + area.width {
                    break;
                }
                let cw = col_w_at(ci) as usize;
                let col_group = layout.col_group_for(ci);
                let group_name = col_group.as_ref().map(|(_, g)| g.clone());
                let label = if group_name != prev_group {
                    match &col_group {
                        Some((cat, g)) => {
                            let indicator = if view.is_group_collapsed(cat, g) {
                                GROUP_COLLAPSED
                            } else {
                                GROUP_EXPANDED
                            };
                            format!("{indicator} {g}")
                        }
                        None => String::new(),
                    }
                } else {
                    String::new()
                };
                prev_group = group_name;
                buf.set_string(
                    x,
                    y,
                    format!(
                        "{:<width$}",
                        truncate(&label, cw.saturating_sub(1)),
                        width = cw
                    ),
                    group_style,
                );
            }
            y += 1;
        }

        // Column headers — one row per level, with repeat suppression
        let header_style = Style::default()
            .fg(Color::Yellow)
            .add_modifier(Modifier::BOLD);
        for d in 0..n_col_levels {
            buf.set_string(
                area.x,
                y,
                format!("{:<width$}", "", width = row_header_width as usize),
                Style::default(),
            );
            for ci in visible_col_range.clone() {
                let x = col_x_at(ci);
                if x >= area.x + area.width {
                    break;
                }
                let cw = col_w_at(ci) as usize;
                let label = if layout.col_cats.is_empty() {
                    layout.col_label(ci)
                } else {
                    let show = ci == 0 || data_col_items[ci][..=d] != data_col_items[ci - 1][..=d];
                    if show {
                        data_col_items[ci][d].clone()
                    } else {
                        String::new()
                    }
                };
                // Underline columns that share the same ancestor group as
                // sel_col through level d. At the bottom level this matches
                // only sel_col; at higher levels it spans all sub-columns.
                let in_sel_group = if layout.col_cats.is_empty() {
                    ci == sel_col
                } else if sel_col < data_col_items.len() && ci < data_col_items.len() {
                    data_col_items[ci][..=d] == data_col_items[sel_col][..=d]
                } else {
                    false
                };
                let styled = if in_sel_group {
                    header_style.add_modifier(Modifier::UNDERLINED)
                } else {
                    header_style
                };
                buf.set_string(
                    x,
                    y,
                    format!(
                        "{:>width$}",
                        truncate(&label, cw.saturating_sub(1)),
                        width = cw
                    ),
                    styled,
                );
            }
            y += 1;
        }

        // Separator
        buf.set_string(
            area.x,
            y,
            "─".repeat(area.width as usize),
            Style::default().fg(Color::DarkGray),
        );
        y += 1;

        // Data rows — iterate visual entries, rendering group headers inline
        let group_header_style = Style::default()
            .fg(Color::Green)
            .add_modifier(Modifier::BOLD);
        let mut data_row_idx = row_offset;
        for entry in &layout.row_items[visual_row_start..] {
            if y >= area.y + area.height {
                break;
            }
            match entry {
                AxisEntry::GroupHeader {
                    cat_name,
                    group_name,
                } => {
                    let indicator = if view.is_group_collapsed(cat_name, group_name) {
                        GROUP_COLLAPSED
                    } else {
                        GROUP_EXPANDED
                    };
                    let label = format!("{indicator} {group_name}");
                    buf.set_string(
                        area.x,
                        y,
                        format!(
                            "{:<width$}",
                            truncate(&label, row_header_width as usize),
                            width = row_header_width as usize
                        ),
                        group_header_style,
                    );
                    for ci in visible_col_range.clone() {
                        let x = col_x_at(ci);
                        if x >= area.x + area.width {
                            break;
                        }
                        let cw = col_w_at(ci) as usize;
                        buf.set_string(
                            x,
                            y,
                            format!("{:─<width$}", "", width = cw),
                            Style::default().fg(Color::DarkGray),
                        );
                    }
                }
                AxisEntry::DataItem(_) => {
                    let ri = data_row_idx;
                    data_row_idx += 1;

                    let is_sel_row = ri == sel_row;
                    let row_style = if is_sel_row {
                        Style::default()
                            .fg(Color::Cyan)
                            .bg(ROW_HIGHLIGHT_BG)
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default()
                    };

                    // Paint row-highlight background across the entire row
                    // (data area + any trailing space) so gaps between columns
                    // and the margin after the last column share the highlight.
                    if is_sel_row {
                        let row_w = (area.x + area.width).saturating_sub(area.x);
                        buf.set_string(
                            area.x + row_header_width,
                            y,
                            " ".repeat(row_w.saturating_sub(row_header_width) as usize),
                            Style::default().bg(ROW_HIGHLIGHT_BG),
                        );
                    }

                    // Multi-level row header — one sub-column per row category
                    let mut hx = area.x;
                    for d in 0..n_row_levels {
                        let sw = sub_widths[d] as usize;
                        let label = if layout.row_cats.is_empty() {
                            layout.row_label(ri)
                        } else {
                            let show =
                                ri == 0 || data_row_items[ri][..=d] != data_row_items[ri - 1][..=d];
                            if show {
                                data_row_items[ri][d].clone()
                            } else {
                                String::new()
                            }
                        };
                        buf.set_string(
                            hx,
                            y,
                            format!("{:<width$}", truncate(&label, sw), width = sw),
                            row_style,
                        );
                        hx += sub_widths[d];
                    }

                    for ci in visible_col_range.clone() {
                        let x = col_x_at(ci);
                        if x >= area.x + area.width {
                            break;
                        }
                        let cw = col_w_at(ci) as usize;

                        // Check pending drill edits first, then use display_text
                        let cell_str = if let Some(ds) = self.drill_state {
                            let col_name = layout.col_label(ci);
                            ds.pending_edits
                                .get(&(ri, col_name))
                                .cloned()
                                .unwrap_or_else(|| {
                                    layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
                                })
                        } else {
                            layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
                        };
                        let is_selected = ri == sel_row && ci == sel_col;
                        let is_search_match = !self.search_query.is_empty()
                            && cell_str
                                .to_lowercase()
                                .contains(&self.search_query.to_lowercase());

                        // Aggregated cells (pivot view with hidden dims) are
                        // not directly editable — shown in italic to signal
                        // "drill to edit". Records mode cells are always
                        // directly editable, as are plain pivot cells.
                        let is_aggregated = !layout.is_records_mode()
                            && layout.none_cats.iter().any(|c| {
                                self.model
                                    .category(c)
                                    .map(|cat| cat.kind.is_regular())
                                    .unwrap_or(false)
                            });
                        let mut cell_style = if is_selected {
                            Style::default()
                                .fg(Color::Black)
                                .bg(Color::Cyan)
                                .add_modifier(Modifier::BOLD)
                        } else if is_search_match {
                            Style::default().fg(Color::Black).bg(Color::Yellow)
                        } else if is_sel_row {
                            let fg = if cell_str.is_empty() {
                                Color::DarkGray
                            } else {
                                Color::White
                            };
                            Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG)
                        } else if cell_str.is_empty() {
                            Style::default().fg(Color::DarkGray)
                        } else {
                            Style::default()
                        };
                        if is_aggregated {
                            cell_style = cell_style.add_modifier(Modifier::ITALIC);
                        }

                        buf.set_string(
                            x,
                            y,
                            format!(
                                "{:>width$}",
                                truncate(&cell_str, cw.saturating_sub(1)),
                                width = cw
                            ),
                            cell_style,
                        );
                    }

                    // Edit indicator
                    if self.mode.is_editing() && ri == sel_row {
                        {
                            let buffer = self.buffers.get("edit").map(|s| s.as_str()).unwrap_or("");
                            let edit_x = col_x_at(sel_col);
                            let cw = col_w_at(sel_col) as usize;
                            buf.set_string(
                                edit_x,
                                y,
                                truncate(&format!("{:<width$}", buffer, width = cw), cw),
                                Style::default()
                                    .fg(Color::Green)
                                    .add_modifier(Modifier::UNDERLINED),
                            );
                        }
                    }
                }
            }
            y += 1;
        }

        // Total row — numeric aggregation, only meaningful in pivot mode.
        if !layout.is_records_mode() && layout.row_count() > 0 && layout.col_count() > 0 {
            if y < area.y + area.height {
                buf.set_string(
                    area.x,
                    y,
                    "─".repeat(area.width as usize),
                    Style::default().fg(Color::DarkGray),
                );
                y += 1;
            }
            if y < area.y + area.height {
                buf.set_string(
                    area.x,
                    y,
                    format!("{:<width$}", "Total", width = row_header_width as usize),
                    Style::default()
                        .fg(Color::Yellow)
                        .add_modifier(Modifier::BOLD),
                );

                for ci in visible_col_range {
                    let x = col_x_at(ci);
                    if x >= area.x + area.width {
                        break;
                    }
                    let cw = col_w_at(ci) as usize;
                    let total: f64 = (0..layout.row_count())
                        .filter_map(|ri| layout.cell_key(ri, ci))
                        .map(|key| self.model.evaluate_aggregated_f64(&key, &layout.none_cats))
                        .sum();
                    let total_str = format_f64(total, fmt_comma, fmt_decimals);
                    buf.set_string(
                        x,
                        y,
                        format!(
                            "{:>width$}",
                            truncate(&total_str, cw.saturating_sub(1)),
                            width = cw
                        ),
                        Style::default()
                            .fg(Color::Yellow)
                            .add_modifier(Modifier::BOLD),
                    );
                }
            }
        }
    }
}

impl<'a> Widget for GridWidget<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let block = Block::default()
            .borders(Borders::ALL)
            .title(format!(" View: {} ", self.view_name));
        let inner = block.inner(area);
        block.render(area, buf);

        // Page axis bar
        if !self.layout.page_coords.is_empty() && inner.height > 0 {
            let page_info: Vec<String> = self
                .layout
                .page_coords
                .iter()
                .map(|(cat, sel)| format!("{cat} = {sel}"))
                .collect();
            let page_str = format!(" [{}] ", page_info.join(" | "));
            buf.set_string(
                inner.x,
                inner.y,
                &page_str,
                Style::default().fg(Color::Magenta),
            );

            let grid_area = Rect {
                y: inner.y + 1,
                height: inner.height.saturating_sub(1),
                ..inner
            };
            self.render_grid(grid_area, buf);
        } else {
            self.render_grid(inner, buf);
        }
    }
}

/// Compute adaptive column widths for pivot mode (header labels + cell values).
/// Header widths use the widest *individual* level label (not the joined
/// multi-level string), matching how the grid renderer draws each level on
/// its own row with repeat-suppression.
pub fn compute_col_widths(
    model: &Model,
    layout: &GridLayout,
    fmt_comma: bool,
    fmt_decimals: u8,
) -> Vec<u16> {
    let n = layout.col_count();
    let mut widths = vec![0u16; n];
    // Measure individual header level labels
    let data_col_items: Vec<&Vec<String>> = layout
        .col_items
        .iter()
        .filter_map(|e| {
            if let AxisEntry::DataItem(v) = e {
                Some(v)
            } else {
                None
            }
        })
        .collect();
    for (ci, wref) in widths.iter_mut().enumerate().take(n) {
        if let Some(levels) = data_col_items.get(ci) {
            let max_level_w = levels.iter().map(|s| s.width() as u16).max().unwrap_or(0);
            if max_level_w > *wref {
                *wref = max_level_w;
            }
        }
    }
    // Measure cell content widths (works for both pivot and records modes)
    for ri in 0..layout.row_count() {
        for (ci, wref) in widths.iter_mut().enumerate().take(n) {
            let s = layout.display_text(model, ri, ci, fmt_comma, fmt_decimals);
            let w = s.width() as u16;
            if w > *wref {
                *wref = w;
            }
        }
    }
    // Measure total row (column sums) — pivot mode only
    if !layout.is_records_mode() && layout.row_count() > 0 {
        for (ci, wref) in widths.iter_mut().enumerate().take(n) {
            let total: f64 = (0..layout.row_count())
                .filter_map(|ri| layout.cell_key(ri, ci))
                .map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
                .sum();
            let s = format_f64(total, fmt_comma, fmt_decimals);
            let w = s.width() as u16;
            if w > *wref {
                *wref = w;
            }
        }
    }
    widths
        .into_iter()
        .map(|w| (w + 1).clamp(MIN_COL_WIDTH, MAX_COL_WIDTH))
        .collect()
}

/// Compute the total row header width from the layout's row items.
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
    let n_row_levels = layout.row_cats.len().max(1);
    let data_row_items: Vec<&Vec<String>> = layout
        .row_items
        .iter()
        .filter_map(|e| {
            if let AxisEntry::DataItem(v) = e {
                Some(v)
            } else {
                None
            }
        })
        .collect();
    let sub_widths: Vec<u16> = (0..n_row_levels)
        .map(|d| {
            let max_label = data_row_items
                .iter()
                .filter_map(|v| v.get(d))
                .map(|s| s.width() as u16)
                .max()
                .unwrap_or(0);
            (max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W)
        })
        .collect();
    sub_widths.iter().sum()
}

/// Count how many columns fit starting from `col_offset` given the available width.
pub fn compute_visible_cols(
    col_widths: &[u16],
    row_header_width: u16,
    term_width: u16,
    col_offset: usize,
) -> usize {
    // Account for grid border (2 chars)
    let data_area_width = term_width
        .saturating_sub(2)
        .saturating_sub(row_header_width);
    let mut acc = 0u16;
    let mut count = 0usize;
    for w in &col_widths[col_offset..] {
        let w = *w;
        if acc + w > data_area_width {
            break;
        }
        acc += w;
        count += 1;
    }
    count.max(1)
}

// Re-export shared formatting functions
pub use crate::format::{format_f64, parse_number_format};

fn truncate(s: &str, max_width: usize) -> String {
    let w = s.width();
    if w <= max_width {
        s.to_string()
    } else if max_width > 1 {
        let mut result = String::new();
        let mut width = 0;
        for c in s.chars() {
            let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
            if width + cw + 1 > max_width {
                break;
            }
            result.push(c);
            width += cw;
        }
        result.push('…');
        result
    } else {
        s.chars().take(max_width).collect()
    }
}

#[cfg(test)]
mod tests {
    use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};

    use super::GridWidget;
    use crate::formula::parse_formula;
    use crate::model::cell::{CellKey, CellValue};
    use crate::ui::app::AppMode;
    use crate::view::GridLayout;
    use crate::workbook::Workbook;

    // ── Helpers ───────────────────────────────────────────────────────────────

    /// Render a GridWidget into a fresh buffer of the given size.
    fn render(wb: &mut Workbook, width: u16, height: u16) -> Buffer {
        let none_cats = wb.active_view().none_cats();
        wb.model.recompute_formulas(&none_cats);
        let area = Rect::new(0, 0, width, height);
        let mut buf = Buffer::empty(area);
        let bufs = std::collections::HashMap::new();
        let layout = GridLayout::new(&wb.model, wb.active_view());
        let view_name = wb.active_view.clone();
        GridWidget::new(
            &wb.model,
            wb.active_view(),
            &view_name,
            &layout,
            &AppMode::Normal,
            "",
            &bufs,
            None,
        )
        .render(area, &mut buf);
        buf
    }

    /// Flatten the buffer to a multiline string, trailing spaces trimmed per row.
    fn buf_text(buf: &Buffer) -> String {
        let w = buf.area().width as usize;
        buf.content()
            .chunks(w)
            .map(|row| {
                row.iter()
                    .map(|c| c.symbol())
                    .collect::<String>()
                    .trim_end()
                    .to_string()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn coord(pairs: &[(&str, &str)]) -> CellKey {
        CellKey::new(
            pairs
                .iter()
                .map(|(c, i)| (c.to_string(), i.to_string()))
                .collect(),
        )
    }

    /// Minimal workbook: Type on Row, Month on Column.
    /// Every cell has a value so rows/cols survive pruning.
    fn two_cat_model() -> Workbook {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap(); // → Row
        m.add_category("Month").unwrap(); // → Column
        if let Some(c) = m.model.category_mut("Type") {
            c.add_item("Food");
            c.add_item("Clothing");
        }
        if let Some(c) = m.model.category_mut("Month") {
            c.add_item("Jan");
            c.add_item("Feb");
        }
        // Fill every cell so nothing is pruned as empty.
        for t in ["Food", "Clothing"] {
            for mo in ["Jan", "Feb"] {
                m.model
                    .set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0));
            }
        }
        m
    }

    // ── Column headers ────────────────────────────────────────────────────────

    #[test]
    fn column_headers_appear() {
        let mut m = two_cat_model();
        let text = buf_text(&render(&mut m, 80, 24));
        assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
        assert!(text.contains("Feb"), "expected 'Feb' in:\n{text}");
    }

    // ── Row headers ───────────────────────────────────────────────────────────

    #[test]
    fn row_headers_appear() {
        let mut m = two_cat_model();
        let text = buf_text(&render(&mut m, 80, 24));
        assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
        assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
    }

    // ── Cell values ───────────────────────────────────────────────────────────

    #[test]
    fn cell_value_appears_in_correct_position() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Number(123.0),
        );
        let text = buf_text(&render(&mut m, 80, 24));
        assert!(text.contains("123"), "expected '123' in:\n{text}");
    }

    #[test]
    fn multiple_cell_values_all_appear() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Number(100.0),
        );
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Feb")]),
            CellValue::Number(200.0),
        );
        m.model.set_cell(
            coord(&[("Type", "Clothing"), ("Month", "Jan")]),
            CellValue::Number(50.0),
        );
        let text = buf_text(&render(&mut m, 80, 24));
        assert!(text.contains("100"), "expected '100' in:\n{text}");
        assert!(text.contains("200"), "expected '200' in:\n{text}");
        assert!(text.contains("50"), "expected '50' in:\n{text}");
    }

    #[test]
    fn unset_cells_show_no_value() {
        // Build a model without the two_cat_model helper (which fills every cell).
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.add_category("Month").unwrap();
        m.model.category_mut("Type").unwrap().add_item("Food");
        m.model.category_mut("Month").unwrap().add_item("Jan");
        // Set one cell so the row/col isn't pruned
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Number(1.0),
        );
        let text = buf_text(&render(&mut m, 80, 24));
        // Should not contain large numbers that weren't set
        assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
    }

    // ── Total row ─────────────────────────────────────────────────────────────

    #[test]
    fn total_row_label_appears() {
        let mut m = two_cat_model();
        let text = buf_text(&render(&mut m, 80, 24));
        assert!(text.contains("Total"), "expected 'Total' in:\n{text}");
    }

    #[test]
    fn total_row_sums_column_correctly() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Number(100.0),
        );
        m.model.set_cell(
            coord(&[("Type", "Clothing"), ("Month", "Jan")]),
            CellValue::Number(50.0),
        );
        let text = buf_text(&render(&mut m, 80, 24));
        // Food(100) + Clothing(50) = 150 for Jan
        assert!(
            text.contains("150"),
            "expected '150' (total for Jan) in:\n{text}"
        );
    }

    // ── Page filter bar ───────────────────────────────────────────────────────

    #[test]
    fn page_filter_bar_shows_category_and_selection() {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap(); // → Row
        m.add_category("Month").unwrap(); // → Column
        m.add_category("Payer").unwrap(); // → Page
        if let Some(c) = m.model.category_mut("Type") {
            c.add_item("Food");
        }
        if let Some(c) = m.model.category_mut("Month") {
            c.add_item("Jan");
        }
        if let Some(c) = m.model.category_mut("Payer") {
            c.add_item("Alice");
            c.add_item("Bob");
        }
        m.active_view_mut().set_page_selection("Payer", "Bob");
        let text = buf_text(&render(&mut m, 80, 24));
        assert!(
            text.contains("Payer = Bob"),
            "expected 'Payer = Bob' in:\n{text}"
        );
    }

    #[test]
    fn page_filter_defaults_to_first_item() {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.add_category("Month").unwrap();
        m.add_category("Payer").unwrap();
        if let Some(c) = m.model.category_mut("Type") {
            c.add_item("Food");
        }
        if let Some(c) = m.model.category_mut("Month") {
            c.add_item("Jan");
        }
        if let Some(c) = m.model.category_mut("Payer") {
            c.add_item("Alice");
            c.add_item("Bob");
        }
        // No explicit selection — should default to first item
        let text = buf_text(&render(&mut m, 80, 24));
        assert!(
            text.contains("Payer = Alice"),
            "expected 'Payer = Alice' in:\n{text}"
        );
    }

    // ── Formula evaluation ────────────────────────────────────────────────────

    #[test]
    fn formula_cell_renders_computed_value() {
        let mut m = Workbook::new("Test");
        m.add_category("Region").unwrap(); // → Column
        m.model
            .category_mut("_Measure")
            .unwrap()
            .add_item("Revenue");
        m.model.category_mut("_Measure").unwrap().add_item("Cost");
        // Profit is a formula target — dynamically included in _Measure
        m.model.category_mut("Region").unwrap().add_item("East");
        m.model.set_cell(
            coord(&[("_Measure", "Revenue"), ("Region", "East")]),
            CellValue::Number(1000.0),
        );
        m.model.set_cell(
            coord(&[("_Measure", "Cost"), ("Region", "East")]),
            CellValue::Number(600.0),
        );
        m.model
            .add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
        m.active_view_mut()
            .set_axis("_Index", crate::view::Axis::None);
        m.active_view_mut()
            .set_axis("_Dim", crate::view::Axis::None);
        m.active_view_mut()
            .set_axis("_Measure", crate::view::Axis::Row);
        m.active_view_mut()
            .set_axis("Region", crate::view::Axis::Column);

        let text = buf_text(&render(&mut m, 80, 24));
        assert!(text.contains("400"), "expected '400' (Profit) in:\n{text}");
    }

    // ── Multiple categories on same axis (cross-product) ─────────────────────

    #[test]
    fn two_row_categories_produce_cross_product_labels() {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap(); // → Row
        m.add_category("Month").unwrap(); // → Column
        m.add_category("Recipient").unwrap(); // → Page by default; move to Row
        if let Some(c) = m.model.category_mut("Type") {
            c.add_item("Food");
            c.add_item("Clothing");
        }
        if let Some(c) = m.model.category_mut("Month") {
            c.add_item("Jan");
        }
        if let Some(c) = m.model.category_mut("Recipient") {
            c.add_item("Alice");
            c.add_item("Bob");
        }
        m.active_view_mut()
            .set_axis("Recipient", crate::view::Axis::Row);
        // Populate cells so rows/cols survive pruning
        for t in ["Food", "Clothing"] {
            for r in ["Alice", "Bob"] {
                m.model.set_cell(
                    coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]),
                    CellValue::Number(1.0),
                );
            }
        }

        let text = buf_text(&render(&mut m, 80, 24));
        // Multi-level row headers: category values shown separately, not joined with /
        assert!(
            !text.contains("Food/Alice"),
            "slash-joined labels should be gone:\n{text}"
        );
        assert!(
            !text.contains("Clothing/Bob"),
            "slash-joined labels should be gone:\n{text}"
        );
        // Each item name appears on its own
        assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
        assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
        assert!(text.contains("Alice"), "expected 'Alice' in:\n{text}");
        assert!(text.contains("Bob"), "expected 'Bob' in:\n{text}");
    }

    #[test]
    fn two_row_categories_include_all_coords_in_cell_lookup() {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.add_category("Month").unwrap();
        m.add_category("Recipient").unwrap();
        if let Some(c) = m.model.category_mut("Type") {
            c.add_item("Food");
        }
        if let Some(c) = m.model.category_mut("Month") {
            c.add_item("Jan");
        }
        if let Some(c) = m.model.category_mut("Recipient") {
            c.add_item("Alice");
            c.add_item("Bob");
        }
        m.active_view_mut()
            .set_axis("Recipient", crate::view::Axis::Row);
        // Set data at the full 3-coordinate key
        m.model.set_cell(
            coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]),
            CellValue::Number(77.0),
        );
        let text = buf_text(&render(&mut m, 80, 24));
        assert!(text.contains("77"), "expected '77' in:\n{text}");
    }

    #[test]
    fn two_column_categories_produce_cross_product_headers() {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap(); // → Row
        m.add_category("Month").unwrap(); // → Column
        m.add_category("Year").unwrap(); // → Page by default; move to Column
        if let Some(c) = m.model.category_mut("Type") {
            c.add_item("Food");
        }
        if let Some(c) = m.model.category_mut("Month") {
            c.add_item("Jan");
        }
        if let Some(c) = m.model.category_mut("Year") {
            c.add_item("2024");
            c.add_item("2025");
        }
        m.active_view_mut()
            .set_axis("Year", crate::view::Axis::Column);
        // Populate cells so cols survive pruning
        for y in ["2024", "2025"] {
            m.model.set_cell(
                coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]),
                CellValue::Number(1.0),
            );
        }

        let text = buf_text(&render(&mut m, 80, 24));
        // Multi-level column headers: category values shown separately, not joined with /
        assert!(
            !text.contains("Jan/2024"),
            "slash-joined headers should be gone:\n{text}"
        );
        assert!(
            !text.contains("Jan/2025"),
            "slash-joined headers should be gone:\n{text}"
        );
        assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
        assert!(text.contains("2024"), "expected '2024' in:\n{text}");
        assert!(text.contains("2025"), "expected '2025' in:\n{text}");
    }
}
