use std::rc::Rc;

use crate::model::Model;
use crate::model::category::CategoryKind;
use crate::model::cell::{CellKey, CellValue};
use crate::view::{Axis, View};

/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
/// Returns None for normal pivot-mode keys.
pub fn synthetic_record_info(key: &CellKey) -> Option<(usize, String)> {
    let idx: usize = key.get("_Index")?.parse().ok()?;
    let dim = key.get("_Dim")?.to_string();
    Some((idx, dim))
}

/// One entry on a grid axis: either a visual group header or a data-item tuple.
///
/// `GroupHeader` entries are always visible so the user can see the group label
/// and toggle its collapse state. `DataItem` entries are absent when their
/// group is collapsed.
#[derive(Debug, Clone, PartialEq)]
pub enum AxisEntry {
    GroupHeader {
        cat_name: String,
        group_name: String,
    },
    DataItem(Vec<String>),
}

/// The resolved 2-D layout of a view: which item tuples appear on each axis,
/// what page filter is active, and how to map (row, col) → CellKey.
///
/// This is the single authoritative place that converts the multi-dimensional
/// model into the flat grid consumed by both the terminal renderer and CSV exporter.
pub struct GridLayout {
    pub row_cats: Vec<String>,
    pub col_cats: Vec<String>,
    pub page_coords: Vec<(String, String)>,
    pub row_items: Vec<AxisEntry>,
    pub col_items: Vec<AxisEntry>,
    /// Categories on `Axis::None` — hidden, implicitly aggregated.
    pub none_cats: Vec<String>,
    /// In records mode: the filtered cell list, one per row.
    /// None for normal pivot views. Rc for cheap sharing.
    pub records: Option<Rc<Vec<(CellKey, CellValue)>>>,
}

impl GridLayout {
    /// Build a layout. When records-mode is active and `frozen_records`
    /// is provided, use that snapshot instead of re-querying the store.
    pub fn with_frozen_records(
        model: &Model,
        view: &View,
        frozen_records: Option<Rc<Vec<(CellKey, CellValue)>>>,
    ) -> Self {
        let mut layout = Self::new(model, view);
        if layout.is_records_mode()
            && let Some(records) = frozen_records
        {
            let row_items: Vec<AxisEntry> = (0..records.len())
                .map(|i| AxisEntry::DataItem(vec![i.to_string()]))
                .collect();
            layout.row_items = row_items;
            layout.records = Some(records);
        }
        if view.prune_empty {
            layout.prune_empty(model);
        }
        layout
    }

    pub fn new(model: &Model, view: &View) -> Self {
        let row_cats: Vec<String> = view
            .categories_on(Axis::Row)
            .into_iter()
            .map(String::from)
            .collect();
        let col_cats: Vec<String> = view
            .categories_on(Axis::Column)
            .into_iter()
            .map(String::from)
            .collect();
        let page_cats: Vec<String> = view
            .categories_on(Axis::Page)
            .into_iter()
            .map(String::from)
            .collect();
        let none_cats: Vec<String> = view
            .categories_on(Axis::None)
            .into_iter()
            .map(String::from)
            .collect();

        let page_coords = page_cats
            .iter()
            .filter_map(|cat| {
                let items: Vec<String> = model.effective_item_names(cat);
                let sel = view
                    .page_selection(cat)
                    .map(String::from)
                    .or_else(|| items.first().cloned())?;
                Some((cat.clone(), sel))
            })
            .collect();

        // Detect records mode: _Index on Row and _Dim on Col
        let is_records_mode =
            row_cats.iter().any(|c| c == "_Index") && col_cats.iter().any(|c| c == "_Dim");

        if is_records_mode {
            Self::build_records_mode(model, view, page_coords, none_cats)
        } else {
            let row_items = cross_product(model, view, &row_cats);
            let col_items = cross_product(model, view, &col_cats);
            Self {
                row_cats,
                col_cats,
                page_coords,
                row_items,
                col_items,
                none_cats,
                records: None,
            }
        }
    }

    /// Build a records-mode layout: rows are individual cells, columns are
    /// category names + "Value". Cells matching the page filter are enumerated.
    fn build_records_mode(
        model: &Model,
        _view: &View,
        page_coords: Vec<(String, String)>,
        none_cats: Vec<String>,
    ) -> Self {
        let records: Vec<(CellKey, CellValue)> = model
            .data
            .matching_cells(&page_coords)
            .into_iter()
            .map(|(k, v)| (k, v.clone()))
            .collect();

        // Synthesize row items: one per record, labeled with its index
        let row_items: Vec<AxisEntry> = (0..records.len())
            .map(|i| AxisEntry::DataItem(vec![i.to_string()]))
            .collect();

        // Synthesize col items: one per non-virtual category + "Value"
        let mut cat_names: Vec<String> = model
            .category_names()
            .into_iter()
            .filter(|c| {
                let kind = model.category(c).map(|cat| cat.kind);
                !matches!(
                    kind,
                    Some(CategoryKind::VirtualIndex | CategoryKind::VirtualDim)
                )
            })
            .map(String::from)
            .collect();
        // _Measure goes last among category columns (right before Value)
        cat_names.sort_by_key(|c| {
            matches!(
                model.category(c).map(|cat| cat.kind),
                Some(CategoryKind::VirtualMeasure)
            )
        });
        let mut col_items: Vec<AxisEntry> = cat_names
            .iter()
            .map(|c| AxisEntry::DataItem(vec![c.clone()]))
            .collect();
        col_items.push(AxisEntry::DataItem(vec!["Value".to_string()]));

        Self {
            row_cats: vec!["_Index".to_string()],
            col_cats: vec!["_Dim".to_string()],
            page_coords,
            row_items,
            col_items,
            none_cats,
            records: Some(Rc::new(records)),
        }
    }

    /// Get the display string for the cell at (row, col) in records mode.
    /// Returns None for normal (non-records) layouts.
    pub fn records_display(&self, row: usize, col: usize) -> Option<String> {
        let records = self.records.as_ref()?;
        let record = records.get(row)?;
        let col_item = self.col_label(col);
        if col_item == "Value" {
            Some(record.1.to_string())
        } else {
            // col_item is a category name
            let found = record
                .0
                .0
                .iter()
                .find(|(c, _)| c == &col_item)
                .map(|(_, v)| v.clone());
            Some(found.unwrap_or_default())
        }
    }

    /// Remove data rows where every column is empty and data columns
    /// where every row is empty. Group headers are kept if at least one
    /// of their data items survives.
    ///
    /// In records mode every column is shown (the user drilled in to see
    /// all the raw data). In pivot mode, rows and columns where every
    /// cell is empty are hidden to reduce clutter.
    pub fn prune_empty(&mut self, model: &Model) {
        if self.is_records_mode() {
            return;
        }
        let rc = self.row_count();
        let cc = self.col_count();
        if rc == 0 || cc == 0 {
            return;
        }

        // Build a row×col grid of "has content?"
        let mut has_value = vec![vec![false; cc]; rc];
        for (ri, row) in has_value.iter_mut().enumerate() {
            for (ci, cell) in row.iter_mut().enumerate() {
                *cell = self
                    .cell_key(ri, ci)
                    .and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
                    .is_some();
            }
        }

        // Which data-row indices are non-empty?
        let keep_row: Vec<bool> = (0..rc)
            .map(|ri| (0..cc).any(|ci| has_value[ri][ci]))
            .collect();
        // Which data-col indices are non-empty?
        let keep_col: Vec<bool> = (0..cc)
            .map(|ci| (0..rc).any(|ri| has_value[ri][ci]))
            .collect();

        // Filter row_items, preserving group headers when at least one
        // subsequent data item survives.
        let mut new_rows = Vec::new();
        let mut pending_header: Option<AxisEntry> = None;
        let mut data_idx = 0usize;
        for entry in self.row_items.drain(..) {
            match &entry {
                AxisEntry::GroupHeader { .. } => {
                    pending_header = Some(entry);
                }
                AxisEntry::DataItem(_) => {
                    if data_idx < rc && keep_row[data_idx] {
                        if let Some(h) = pending_header.take() {
                            new_rows.push(h);
                        }
                        new_rows.push(entry);
                    }
                    data_idx += 1;
                }
            }
        }
        self.row_items = new_rows;

        // Filter col_items (same logic)
        let mut new_cols = Vec::new();
        let mut pending_header: Option<AxisEntry> = None;
        let mut data_idx = 0usize;
        for entry in self.col_items.drain(..) {
            match &entry {
                AxisEntry::GroupHeader { .. } => {
                    pending_header = Some(entry);
                }
                AxisEntry::DataItem(_) => {
                    if data_idx < cc && keep_col[data_idx] {
                        if let Some(h) = pending_header.take() {
                            new_cols.push(h);
                        }
                        new_cols.push(entry);
                    }
                    data_idx += 1;
                }
            }
        }
        self.col_items = new_cols;

        // If records mode, also prune the records vec and re-index row_items
        if let Some(records) = &self.records {
            let new_records: Vec<_> = keep_row
                .iter()
                .enumerate()
                .filter(|(_, keep)| **keep)
                .map(|(i, _)| records[i].clone())
                .collect();
            let new_row_items: Vec<AxisEntry> = (0..new_records.len())
                .map(|i| AxisEntry::DataItem(vec![i.to_string()]))
                .collect();
            self.row_items = new_row_items;
            self.records = Some(Rc::new(new_records));
        }
    }

    /// Whether this layout is in records mode.
    pub fn is_records_mode(&self) -> bool {
        self.records.is_some()
    }

    /// Number of data rows (group headers excluded).
    pub fn row_count(&self) -> usize {
        self.row_items
            .iter()
            .filter(|e| matches!(e, AxisEntry::DataItem(_)))
            .count()
    }

    /// Number of data columns (group headers excluded).
    pub fn col_count(&self) -> usize {
        self.col_items
            .iter()
            .filter(|e| matches!(e, AxisEntry::DataItem(_)))
            .count()
    }

    pub fn row_label(&self, row: usize) -> String {
        self.row_items
            .iter()
            .filter_map(|e| {
                if let AxisEntry::DataItem(v) = e {
                    Some(v)
                } else {
                    None
                }
            })
            .nth(row)
            .map(|r| r.join("/"))
            .unwrap_or_default()
    }

    pub fn col_label(&self, col: usize) -> String {
        self.col_items
            .iter()
            .filter_map(|e| {
                if let AxisEntry::DataItem(v) = e {
                    Some(v)
                } else {
                    None
                }
            })
            .nth(col)
            .map(|c| c.join("/"))
            .unwrap_or_default()
    }

    /// Resolve the display string for a synthetic records-mode CellKey.
    /// Returns None for non-synthetic (pivot) keys.
    pub fn resolve_display(&self, key: &CellKey) -> Option<String> {
        let (idx, dim) = synthetic_record_info(key)?;
        let records = self.records.as_ref()?;
        let (orig_key, value) = records.get(idx)?;
        if dim == "Value" {
            Some(value.to_string())
        } else {
            Some(orig_key.get(&dim).unwrap_or("").to_string())
        }
    }

    /// Unified display text for a cell at (row, col). Handles both pivot and
    /// records modes. In pivot mode, evaluates and formats the cell value.
    /// In records mode, resolves via the frozen records snapshot.
    pub fn display_text(
        &self,
        model: &Model,
        row: usize,
        col: usize,
        fmt_comma: bool,
        fmt_decimals: u8,
    ) -> String {
        if self.is_records_mode() {
            self.records_display(row, col).unwrap_or_default()
        } else {
            self.cell_key(row, col)
                .and_then(|key| model.evaluate_aggregated(&key, &self.none_cats))
                .map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals))
                .unwrap_or_default()
        }
    }

    /// Build the CellKey for the data cell at (row, col), including the active
    /// page-axis filter. Returns None if row or col is out of bounds.
    /// In records mode: returns a synthetic `(_Index, _Dim)` key for every column.
    pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
        if let Some(records) = &self.records {
            if row >= records.len() {
                return None;
            }
            let col_label = self.col_label(col);
            if col_label.is_empty() {
                return None;
            }
            return Some(CellKey::new(vec![
                ("_Index".to_string(), row.to_string()),
                ("_Dim".to_string(), col_label),
            ]));
        }
        let row_item = self
            .row_items
            .iter()
            .filter_map(|e| {
                if let AxisEntry::DataItem(v) = e {
                    Some(v)
                } else {
                    None
                }
            })
            .nth(row)?;
        let col_item = self
            .col_items
            .iter()
            .filter_map(|e| {
                if let AxisEntry::DataItem(v) = e {
                    Some(v)
                } else {
                    None
                }
            })
            .nth(col)?;
        let mut coords = self.page_coords.clone();
        for (cat, item) in self.row_cats.iter().zip(row_item.iter()) {
            coords.push((cat.clone(), item.clone()));
        }
        for (cat, item) in self.col_cats.iter().zip(col_item.iter()) {
            coords.push((cat.clone(), item.clone()));
        }
        Some(CellKey::new(coords))
    }

    /// Visual index of the nth data row (skipping group headers).
    pub fn data_row_to_visual(&self, data_row: usize) -> Option<usize> {
        let mut count = 0;
        for (vi, entry) in self.row_items.iter().enumerate() {
            if let AxisEntry::DataItem(_) = entry {
                if count == data_row {
                    return Some(vi);
                }
                count += 1;
            }
        }
        None
    }

    /// Visual index of the nth data column (skipping group headers).
    pub fn data_col_to_visual(&self, data_col: usize) -> Option<usize> {
        let mut count = 0;
        for (vi, entry) in self.col_items.iter().enumerate() {
            if let AxisEntry::DataItem(_) = entry {
                if count == data_col {
                    return Some(vi);
                }
                count += 1;
            }
        }
        None
    }

    /// Find the group containing the Nth data row.
    /// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
    pub fn row_group_for(&self, data_row: usize) -> Option<(String, String)> {
        let vi = self.data_row_to_visual(data_row)?;
        self.row_items[..vi].iter().rev().find_map(|e| {
            if let AxisEntry::GroupHeader {
                cat_name,
                group_name,
            } = e
            {
                Some((cat_name.clone(), group_name.clone()))
            } else {
                None
            }
        })
    }

    /// Find the group containing the Nth data column.
    /// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
    pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> {
        let vi = self.data_col_to_visual(data_col)?;
        self.col_items[..vi].iter().rev().find_map(|e| {
            if let AxisEntry::GroupHeader {
                cat_name,
                group_name,
            } = e
            {
                Some((cat_name.clone(), group_name.clone()))
            } else {
                None
            }
        })
    }
}

/// Expand a single category into `AxisEntry` values, given a coordinate prefix.
/// Emits a `GroupHeader` at each group boundary, then `DataItem` entries for
/// visible, non-collapsed items.
fn expand_category(
    model: &Model,
    view: &View,
    cat_name: &str,
    prefix: Vec<String>,
) -> Vec<AxisEntry> {
    let Some(cat) = model.category(cat_name) else {
        return vec![];
    };
    let mut result = Vec::new();
    let mut last_group: Option<&str> = None;

    // Use effective_item_names so _Measure includes formula targets dynamically
    let effective_names = model.effective_item_names(cat_name);
    for item_name in &effective_names {
        if view.is_hidden(cat_name, item_name) {
            continue;
        }
        let item_group = cat
            .items
            .get(item_name.as_str())
            .and_then(|i| i.group.as_deref());

        // Emit a group header at each group boundary.
        if item_group != last_group {
            if let Some(g) = item_group {
                result.push(AxisEntry::GroupHeader {
                    cat_name: cat_name.to_string(),
                    group_name: g.to_string(),
                });
            }
            last_group = item_group;
        }

        // Skip the data item if its group is collapsed.
        if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) {
            continue;
        }

        let mut row = prefix.clone();
        row.push(item_name.to_string());
        result.push(AxisEntry::DataItem(row));
    }
    result
}

/// Cartesian product of visible items across `cats`, in category order.
/// Hidden items and items in collapsed groups are excluded from `DataItem`
/// entries; group headers are always emitted.
/// Returns `vec![DataItem(vec![])]` when `cats` is empty.
fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry> {
    if cats.is_empty() {
        return vec![AxisEntry::DataItem(vec![])];
    }
    let mut result: Vec<AxisEntry> = vec![AxisEntry::DataItem(vec![])];
    for cat_name in cats {
        result = result
            .into_iter()
            .flat_map(|entry| match entry {
                AxisEntry::DataItem(prefix) => expand_category(model, view, cat_name, prefix),
                header @ AxisEntry::GroupHeader { .. } => vec![header],
            })
            .collect();
    }
    result
}

#[cfg(test)]
mod tests {
    use super::{AxisEntry, GridLayout, synthetic_record_info};
    use crate::model::cell::{CellKey, CellValue};
    use crate::view::Axis;
    use crate::workbook::Workbook;

    fn records_workbook() -> Workbook {
        let mut wb = Workbook::new("T");
        wb.add_category("Region").unwrap();
        wb.add_category("_Measure").unwrap();
        wb.model.category_mut("Region").unwrap().add_item("North");
        wb.model
            .category_mut("_Measure")
            .unwrap()
            .add_item("Revenue");
        wb.model.category_mut("_Measure").unwrap().add_item("Cost");
        wb.model.set_cell(
            CellKey::new(vec![
                ("Region".into(), "North".into()),
                ("_Measure".into(), "Revenue".into()),
            ]),
            CellValue::Number(100.0),
        );
        wb.model.set_cell(
            CellKey::new(vec![
                ("Region".into(), "North".into()),
                ("_Measure".into(), "Cost".into()),
            ]),
            CellValue::Number(50.0),
        );
        // Records mode setup: _Measure should not filter
        wb.active_view_mut().set_axis("_Measure", Axis::None);
        wb
    }

    #[test]
    fn prune_empty_removes_all_empty_columns_in_pivot_mode() {
        let mut wb = Workbook::new("T");
        wb.add_category("Row").unwrap();
        wb.add_category("Col").unwrap();
        wb.model.category_mut("Row").unwrap().add_item("A");
        wb.model.category_mut("Col").unwrap().add_item("X");
        wb.model.category_mut("Col").unwrap().add_item("Y");
        // Only X has data; Y is entirely empty
        wb.model.set_cell(
            CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
            CellValue::Number(1.0),
        );

        let mut layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(layout.col_count(), 2); // X and Y before pruning
        layout.prune_empty(&wb.model);
        assert_eq!(layout.col_count(), 1); // only X after pruning
        assert_eq!(layout.col_label(0), "X");
    }

    #[test]
    fn records_mode_activated_when_index_and_dim_on_axes() {
        let mut wb = records_workbook();
        let v = wb.active_view_mut();
        v.set_axis("_Index", Axis::Row);
        v.set_axis("_Dim", Axis::Column);
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert!(layout.is_records_mode());
        assert_eq!(layout.row_count(), 2); // 2 cells
    }

    #[test]
    fn records_mode_cell_key_returns_synthetic_for_all_columns() {
        let mut wb = records_workbook();
        let v = wb.active_view_mut();
        v.set_axis("_Index", Axis::Row);
        v.set_axis("_Dim", Axis::Column);
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert!(layout.is_records_mode());
        let cols: Vec<String> = (0..layout.col_count())
            .map(|i| layout.col_label(i))
            .collect();
        // All columns return synthetic keys
        let value_col = cols.iter().position(|c| c == "Value").unwrap();
        let key = layout.cell_key(0, value_col).unwrap();
        assert_eq!(key.get("_Index"), Some("0"));
        assert_eq!(key.get("_Dim"), Some("Value"));

        let region_col = cols.iter().position(|c| c == "Region").unwrap();
        let key = layout.cell_key(0, region_col).unwrap();
        assert_eq!(key.get("_Index"), Some("0"));
        assert_eq!(key.get("_Dim"), Some("Region"));
    }

    #[test]
    fn records_mode_resolve_display_returns_values() {
        let mut wb = records_workbook();
        let v = wb.active_view_mut();
        v.set_axis("_Index", Axis::Row);
        v.set_axis("_Dim", Axis::Column);
        let layout = GridLayout::new(&wb.model, wb.active_view());
        let cols: Vec<String> = (0..layout.col_count())
            .map(|i| layout.col_label(i))
            .collect();

        // Value column resolves to the cell value
        let value_col = cols.iter().position(|c| c == "Value").unwrap();
        let key = layout.cell_key(0, value_col).unwrap();
        let display = layout.resolve_display(&key);
        assert!(display.is_some(), "Value column should resolve");

        // Category column resolves to the coordinate value
        let region_col = cols.iter().position(|c| c == "Region").unwrap();
        let key = layout.cell_key(0, region_col).unwrap();
        let display = layout.resolve_display(&key).unwrap();
        assert!(
            !display.is_empty(),
            "Region column should resolve to a value"
        );
    }

    #[test]
    fn synthetic_record_info_returns_none_for_pivot_keys() {
        let key = CellKey::new(vec![
            ("Region".to_string(), "East".to_string()),
            ("Product".to_string(), "Shoes".to_string()),
        ]);
        assert!(synthetic_record_info(&key).is_none());
    }

    #[test]
    fn synthetic_record_info_extracts_index_and_dim() {
        let key = CellKey::new(vec![
            ("_Index".to_string(), "3".to_string()),
            ("_Dim".to_string(), "Region".to_string()),
        ]);
        let (idx, dim) = synthetic_record_info(&key).unwrap();
        assert_eq!(idx, 3);
        assert_eq!(dim, "Region");
    }

    /// Regression test for improvise-rbv: records mode should include _Measure
    /// as a _Dim column so the measure name is visible per-record, and "Value"
    /// must remain the last column.
    #[test]
    fn records_mode_includes_measure_in_dim_columns() {
        let mut wb = records_workbook();
        let v = wb.active_view_mut();
        v.set_axis("_Index", Axis::Row);
        v.set_axis("_Dim", Axis::Column);
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert!(layout.is_records_mode());
        let cols: Vec<String> = (0..layout.col_count())
            .map(|i| layout.col_label(i))
            .collect();
        assert!(
            cols.contains(&"_Measure".to_string()),
            "records mode should include _Measure column; got {:?}",
            cols
        );
        assert!(cols.contains(&"Region".to_string()));
        assert!(cols.contains(&"Value".to_string()));
        assert_eq!(
            cols.last().unwrap(),
            "Value",
            "Value must be the last column so cursor defaults land on it; got {:?}",
            cols
        );
    }

    /// On initial entry into records mode, rows should be in a deterministic
    /// (CellKey-sorted) order regardless of insertion order.
    #[test]
    fn records_mode_initial_layout_is_sorted() {
        let mut wb = Workbook::new("T");
        wb.add_category("Region").unwrap();
        wb.model.category_mut("Region").unwrap().add_item("North");
        wb.model.category_mut("Region").unwrap().add_item("East");
        // Insert East before North — opposite of alphabetical
        wb.model.set_cell(
            CellKey::new(vec![("Region".into(), "East".into())]),
            CellValue::Number(2.0),
        );
        wb.model.set_cell(
            CellKey::new(vec![("Region".into(), "North".into())]),
            CellValue::Number(1.0),
        );
        // Sort the store before entering records mode, as ToggleRecordsMode would
        wb.model.data.sort_by_key();
        let v = wb.active_view_mut();
        v.set_axis("_Index", Axis::Row);
        v.set_axis("_Dim", Axis::Column);
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(layout.row_count(), 2);
        let region_col = (0..layout.col_count())
            .find(|&c| layout.col_label(c) == "Region")
            .unwrap();
        let row0_region = layout.records_display(0, region_col).unwrap();
        let row1_region = layout.records_display(1, region_col).unwrap();
        assert_eq!(
            row0_region, "East",
            "first row should be East (alphabetical)"
        );
        assert_eq!(
            row1_region, "North",
            "second row should be North (alphabetical)"
        );
    }

    /// New records added after initial layout should appear at the bottom,
    /// not re-sorted into the middle by CellKey order.
    #[test]
    fn records_mode_new_record_appends_at_bottom() {
        let mut wb = records_workbook();
        let v = wb.active_view_mut();
        v.set_axis("_Index", Axis::Row);
        v.set_axis("_Dim", Axis::Column);
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(layout.row_count(), 2);
        let first_value = layout.records_display(0, layout.col_count() - 1).unwrap();

        // Add a record whose CellKey sorts BEFORE the existing ones
        wb.model.set_cell(
            CellKey::new(vec![
                ("Region".into(), "AAA".into()),
                ("_Measure".into(), "Cost".into()),
            ]),
            CellValue::Number(999.0),
        );
        let layout2 = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(layout2.row_count(), 3);
        // First row should still be the same record — new one appends at bottom
        let first_value2 = layout2.records_display(0, layout2.col_count() - 1).unwrap();
        assert_eq!(
            first_value, first_value2,
            "first row should be unchanged after adding a new record; \
             new record should append at bottom, not re-sort"
        );
        // New record should be the last row
        let last_value = layout2.records_display(2, layout2.col_count() - 1).unwrap();
        assert_eq!(last_value, "999", "new record should be the last row");
    }

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

    fn two_cat_workbook() -> Workbook {
        let mut wb = Workbook::new("T");
        wb.add_category("Type").unwrap();
        wb.add_category("Month").unwrap();
        for item in ["Food", "Clothing"] {
            wb.model.category_mut("Type").unwrap().add_item(item);
        }
        for item in ["Jan", "Feb"] {
            wb.model.category_mut("Month").unwrap().add_item(item);
        }
        wb
    }

    #[test]
    fn row_and_col_counts_match_item_counts() {
        let wb = two_cat_workbook();
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(layout.row_count(), 2); // Food, Clothing
        assert_eq!(layout.col_count(), 2); // Jan, Feb
    }

    #[test]
    fn cell_key_encodes_correct_coordinates() {
        let wb = two_cat_workbook();
        let layout = GridLayout::new(&wb.model, wb.active_view());
        // row 0 = Food, col 1 = Feb
        let key = layout.cell_key(0, 1).unwrap();
        assert_eq!(key, coord(&[("Month", "Feb"), ("Type", "Food")]));
    }

    #[test]
    fn cell_key_out_of_bounds_returns_none() {
        let wb = two_cat_workbook();
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert!(layout.cell_key(99, 0).is_none());
        assert!(layout.cell_key(0, 99).is_none());
    }

    #[test]
    fn cell_key_includes_page_coords() {
        let mut wb = Workbook::new("T");
        wb.add_category("Type").unwrap();
        wb.add_category("Month").unwrap();
        wb.add_category("Region").unwrap();
        wb.model.category_mut("Type").unwrap().add_item("Food");
        wb.model.category_mut("Month").unwrap().add_item("Jan");
        wb.model.category_mut("Region").unwrap().add_item("East");
        wb.model.category_mut("Region").unwrap().add_item("West");
        wb.active_view_mut().set_page_selection("Region", "West");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        let key = layout.cell_key(0, 0).unwrap();
        assert_eq!(key.get("Region"), Some("West"));
    }

    #[test]
    fn cell_key_round_trips_through_model_evaluate() {
        let mut wb = two_cat_workbook();
        wb.model.set_cell(
            coord(&[("Month", "Feb"), ("Type", "Clothing")]),
            CellValue::Number(42.0),
        );
        let layout = GridLayout::new(&wb.model, wb.active_view());
        // Clothing = row 1, Feb = col 1
        let key = layout.cell_key(1, 1).unwrap();
        assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(42.0)));
    }

    #[test]
    fn labels_join_with_slash_for_multi_cat_axis() {
        let mut wb = Workbook::new("T");
        wb.add_category("Type").unwrap();
        wb.add_category("Month").unwrap();
        wb.add_category("Year").unwrap();
        wb.model.category_mut("Type").unwrap().add_item("Food");
        wb.model.category_mut("Month").unwrap().add_item("Jan");
        wb.model.category_mut("Year").unwrap().add_item("2025");
        wb.active_view_mut()
            .set_axis("Year", crate::view::Axis::Column);
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(layout.col_label(0), "Jan/2025");
    }

    #[test]
    fn row_count_excludes_group_headers() {
        let mut wb = Workbook::new("T");
        wb.add_category("Month").unwrap();
        wb.add_category("Type").unwrap();
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Feb", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Apr", "Q2");
        wb.model.category_mut("Type").unwrap().add_item("Food");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
    }

    #[test]
    fn group_header_emitted_at_group_boundary() {
        let mut wb = Workbook::new("T");
        wb.add_category("Month").unwrap();
        wb.add_category("Type").unwrap();
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Apr", "Q2");
        wb.model.category_mut("Type").unwrap().add_item("Food");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        let headers: Vec<_> = layout
            .row_items
            .iter()
            .filter(|e| matches!(e, AxisEntry::GroupHeader { .. }))
            .collect();
        assert_eq!(headers.len(), 2);
        assert!(
            matches!(&headers[0], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1")
        );
        assert!(
            matches!(&headers[1], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2")
        );
    }

    #[test]
    fn collapsed_group_has_header_but_no_data_items() {
        let mut wb = Workbook::new("T");
        wb.add_category("Month").unwrap();
        wb.add_category("Type").unwrap();
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Feb", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Apr", "Q2");
        wb.model.category_mut("Type").unwrap().add_item("Food");
        wb.active_view_mut().toggle_group_collapse("Month", "Q1");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        // Q1 collapsed: header present, Jan and Feb absent; Q2 intact
        assert_eq!(layout.row_count(), 1); // only Apr
        let q1_header = layout
            .row_items
            .iter()
            .find(|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1"));
        assert!(q1_header.is_some());
        let jan = layout
            .row_items
            .iter()
            .find(|e| matches!(e, AxisEntry::DataItem(v) if v.contains(&"Jan".to_string())));
        assert!(jan.is_none());
    }

    #[test]
    fn ungrouped_items_produce_no_headers() {
        let wb = two_cat_workbook();
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert!(
            !layout
                .row_items
                .iter()
                .any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
        );
        assert!(
            !layout
                .col_items
                .iter()
                .any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
        );
    }

    #[test]
    fn cell_key_correct_with_grouped_items() {
        let mut wb = Workbook::new("T");
        wb.add_category("Month").unwrap();
        wb.add_category("Type").unwrap();
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Apr", "Q2");
        wb.model.category_mut("Type").unwrap().add_item("Food");
        wb.model.set_cell(
            coord(&[("Month", "Apr"), ("Type", "Food")]),
            CellValue::Number(99.0),
        );
        let layout = GridLayout::new(&wb.model, wb.active_view());
        // data row 0 = Jan, data row 1 = Apr
        let key = layout.cell_key(1, 0).unwrap();
        assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(99.0)));
    }

    #[test]
    fn data_row_to_visual_skips_headers() {
        let mut wb = Workbook::new("T");
        wb.add_category("Month").unwrap();
        wb.add_category("Type").unwrap();
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Apr", "Q2");
        wb.model.category_mut("Type").unwrap().add_item("Food");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        // visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
        assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1
        assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
        assert_eq!(layout.data_row_to_visual(2), None);
    }

    #[test]
    fn data_col_to_visual_skips_headers() {
        let mut wb = Workbook::new("T");
        wb.add_category("Type").unwrap(); // Row
        wb.add_category("Month").unwrap(); // Column
        wb.model.category_mut("Type").unwrap().add_item("Food");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Apr", "Q2");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        // col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
        assert_eq!(layout.data_col_to_visual(0), Some(1));
        assert_eq!(layout.data_col_to_visual(1), Some(3));
        assert_eq!(layout.data_col_to_visual(2), None);
    }

    #[test]
    fn row_group_for_finds_enclosing_group() {
        let mut wb = Workbook::new("T");
        wb.add_category("Month").unwrap();
        wb.add_category("Type").unwrap();
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Apr", "Q2");
        wb.model.category_mut("Type").unwrap().add_item("Food");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(
            layout.row_group_for(0),
            Some(("Month".to_string(), "Q1".to_string()))
        );
        assert_eq!(
            layout.row_group_for(1),
            Some(("Month".to_string(), "Q2".to_string()))
        );
    }

    #[test]
    fn row_group_for_returns_none_for_ungrouped() {
        let mut wb = Workbook::new("T");
        wb.add_category("Type").unwrap();
        wb.add_category("Month").unwrap();
        wb.model.category_mut("Type").unwrap().add_item("Food");
        wb.model.category_mut("Month").unwrap().add_item("Jan");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(layout.row_group_for(0), None);
    }

    #[test]
    fn col_group_for_finds_enclosing_group() {
        let mut wb = Workbook::new("T");
        wb.add_category("Type").unwrap(); // Row
        wb.add_category("Month").unwrap(); // Column
        wb.model.category_mut("Type").unwrap().add_item("Food");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        wb.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Apr", "Q2");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(
            layout.col_group_for(0),
            Some(("Month".to_string(), "Q1".to_string()))
        );
        assert_eq!(
            layout.col_group_for(1),
            Some(("Month".to_string(), "Q2".to_string()))
        );
    }

    #[test]
    fn col_group_for_returns_none_for_ungrouped() {
        let mut wb = Workbook::new("T");
        wb.add_category("Type").unwrap();
        wb.add_category("Month").unwrap();
        wb.model.category_mut("Type").unwrap().add_item("Food");
        wb.model.category_mut("Month").unwrap().add_item("Jan");
        let layout = GridLayout::new(&wb.model, wb.active_view());
        assert_eq!(layout.col_group_for(0), None);
    }
}
