use anyhow::{Context, Result};
use flate2::Compression;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use pest::Parser;
use pest_derive::Parser;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::Path;

use crate::formula::parse_formula;
use crate::model::category::Group;
use crate::model::cell::{CellKey, CellValue};
use crate::view::{Axis, GridLayout};
use crate::workbook::Workbook;

#[derive(Parser)]
#[grammar = "persistence/improv.pest"]
struct ImprovParser;

// ── Pipe quoting (shared between format and parse) ───────────────────────────

/// Check whether a name is a valid bare identifier: `[A-Za-z_][A-Za-z0-9_-]*`
fn is_bare_name(name: &str) -> bool {
    let mut chars = name.chars();
    match chars.next() {
        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
        _ => return false,
    }
    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}

/// Escape a string for use inside pipe delimiters: `\|`, `\\`, `\n`.
fn escape_pipe(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '|' => out.push_str("\\|"),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            c => out.push(c),
        }
    }
    out
}

/// Unescape a pipe-quoted body: `\|` → `|`, `\\` → `\`, `\n` → newline.
fn unescape_pipe(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.next() {
                Some('|') => out.push('|'),
                Some('\\') => out.push('\\'),
                Some('n') => out.push('\n'),
                Some(other) => {
                    out.push('\\');
                    out.push(other);
                }
                None => out.push('\\'),
            }
        } else {
            out.push(c);
        }
    }
    out
}

/// CL-style `|...|` pipe quoting unless the name is a valid bare identifier.
fn quote_name(name: &str) -> String {
    if is_bare_name(name) {
        name.to_string()
    } else {
        format!("|{}|", escape_pipe(name))
    }
}

/// Pipe-quote unconditionally (for text cell values that must be distinguished
/// from numbers).
fn pipe_quote(s: &str) -> String {
    format!("|{}|", escape_pipe(s))
}

// ── Number formatting ────────────────────────────────────────────────────────

fn format_number(n: f64) -> String {
    if n.is_infinite() {
        return if n.is_sign_positive() {
            "inf".to_string()
        } else {
            "-inf".to_string()
        };
    }
    if n.is_nan() {
        return "nan".to_string();
    }
    if n.fract() == 0.0 && n.abs() < 1e15 {
        format!("{}", n as i64)
    } else {
        let display = format!("{n}");
        if display.parse::<f64>() == Ok(n) {
            display
        } else {
            format!("{n:?}")
        }
    }
}

// ── File I/O ─────────────────────────────────────────────────────────────────

fn is_gzip(path: &Path) -> bool {
    path.to_str().is_some_and(|s| s.ends_with(".gz"))
}

pub fn save(workbook: &Workbook, path: &Path) -> Result<()> {
    let text = format_md(workbook);
    if is_gzip(path) {
        let file = std::fs::File::create(path)
            .with_context(|| format!("Cannot create {}", path.display()))?;
        let mut enc = GzEncoder::new(BufWriter::new(file), Compression::default());
        enc.write_all(text.as_bytes())?;
        enc.finish()?;
    } else {
        std::fs::write(path, &text).with_context(|| format!("Cannot write {}", path.display()))?;
    }
    Ok(())
}

pub fn load(path: &Path) -> Result<Workbook> {
    let file =
        std::fs::File::open(path).with_context(|| format!("Cannot open {}", path.display()))?;
    let text = if is_gzip(path) {
        let mut s = String::new();
        GzDecoder::new(BufReader::new(file)).read_to_string(&mut s)?;
        s
    } else {
        let mut s = String::new();
        BufReader::new(file).read_to_string(&mut s)?;
        s
    };
    if text.trim_start().starts_with('{') {
        serde_json::from_str(&text).context("Failed to deserialize workbook")
    } else {
        parse_md(&text)
    }
}

pub fn autosave_path(path: &Path) -> std::path::PathBuf {
    let mut p = path.to_path_buf();
    let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("model");
    p.set_file_name(format!(".{name}.autosave"));
    p
}

/// Serialize a workbook to the markdown `.improv` format.
pub fn format_md(workbook: &Workbook) -> String {
    // writeln! to a String is infallible; this macro avoids .unwrap() noise.
    macro_rules! w {
        ($dst:expr, $($arg:tt)*) => { { use std::fmt::Write; writeln!($dst, $($arg)*).ok(); } }
    }

    let model = &workbook.model;

    let mut out = String::new();

    w!(out, "v2025-04-09");
    w!(out, "# {}", model.name);
    w!(out, "Initial View: {}", workbook.active_view);

    // ── Views (first: typically small, orients the reader) ───────────
    for (_view_name, view) in &workbook.views {
        w!(out, "\n## View: {}", view.name);
        for (cat, axis) in &view.category_axes {
            let qcat = quote_name(cat);
            if *axis == Axis::Page
                && let Some(sel) = view.page_selections.get(cat)
            {
                w!(out, "{qcat}: page, {}", quote_name(sel));
                continue;
            }
            let axis_str = match axis {
                Axis::Row => "row",
                Axis::Column => "column",
                Axis::Page => "page",
                Axis::None => "none",
            };
            w!(out, "{qcat}: {axis_str}");
        }
        if !view.number_format.is_empty() {
            w!(out, "format: {}", view.number_format);
        }
        for (prefix, map) in [
            ("hidden", &view.hidden_items),
            ("collapsed", &view.collapsed_groups),
        ] {
            let mut pairs: Vec<_> = map
                .iter()
                .flat_map(|(cat, items)| {
                    items.iter().map(move |item| (cat.as_str(), item.as_str()))
                })
                .collect();
            pairs.sort();
            for (cat, item) in pairs {
                w!(out, "{prefix}: {}/{}", quote_name(cat), quote_name(item));
            }
        }
    }

    // ── Formulas ─────────────────────────────────────────────────────
    if !model.formulas().is_empty() {
        w!(out, "\n## Formulas");
        for f in model.formulas() {
            if f.target_category == "_Measure" {
                w!(out, "- {}", f.raw);
            } else {
                w!(out, "- {} [{}]", f.raw, f.target_category);
            }
        }
    }

    // ── Categories (items comma-separated on one line) ───────────────
    // Collect formula targets so we can exclude them from _Measure items
    let formula_targets: std::collections::HashSet<&str> = model
        .formulas()
        .iter()
        .filter(|f| f.target_category == "_Measure")
        .map(|f| f.target.as_str())
        .collect();

    for cat in model.categories.values() {
        use crate::model::category::CategoryKind;
        // Skip _Index and _Dim — they are fully virtual, never persisted
        if matches!(
            cat.kind,
            CategoryKind::VirtualIndex | CategoryKind::VirtualDim
        ) {
            continue;
        }
        w!(out, "\n## Category: {}", cat.name);
        let mut bare: Vec<String> = Vec::new();
        let mut grouped: Vec<String> = Vec::new();
        for item in cat.items.values() {
            // For _Measure, skip items that are formula targets
            // (they'll be recreated from the ## Formulas section)
            if cat.kind == CategoryKind::VirtualMeasure
                && formula_targets.contains(item.name.as_str())
            {
                continue;
            }
            match &item.group {
                Some(g) => grouped.push(format!("{}[{}]", quote_name(&item.name), quote_name(g))),
                None => bare.push(quote_name(&item.name)),
            }
        }
        if !bare.is_empty() {
            w!(out, "- {}", bare.join(", "));
        }
        for g_item in &grouped {
            w!(out, "- {g_item}");
        }
        for g in &cat.groups {
            if let Some(parent) = &g.parent {
                w!(out, "> {}[{}]", quote_name(&g.name), quote_name(parent));
            }
        }
    }

    // ── Data (last: typically the largest section) ────────────────────
    let mut cells: Vec<_> = model.data.iter_cells().collect();
    cells.sort_by_key(|(k, _)| coord_str(k));
    if !cells.is_empty() {
        w!(out, "\n## Data");
        for (key, value) in cells {
            let val_str = match value {
                CellValue::Number(n) => format_number(*n),
                CellValue::Text(s) | CellValue::Error(s) => pipe_quote(s),
            };
            w!(out, "{} = {}", coord_str(&key), val_str);
        }
    }

    out
}

/// Parse the `.improv` format into a Model using the pest grammar.
///
/// Sections may appear in any order; a two-pass approach registers categories
/// before configuring views.
pub fn parse_md(text: &str) -> Result<Workbook> {
    use anyhow::bail;
    use pest::iterators::{Pair, Pairs};

    let file = ImprovParser::parse(Rule::file, text)
        .map_err(|e| anyhow::anyhow!("Parse error: {e}"))?
        .next()
        .ok_or_else(|| anyhow::anyhow!("Empty parse result"))?;

    // ── Intermediate collectors ──────────────────────────────────────────────

    struct PCategory {
        name: String,
        items: Vec<(String, Option<String>)>,
        group_parents: Vec<(String, String)>,
    }
    struct PView {
        name: String,
        axes: Vec<(String, Axis)>,
        page_selections: Vec<(String, String)>,
        format: String,
        hidden: Vec<(String, String)>,
        collapsed: Vec<(String, String)>,
    }

    let mut model_name: Option<String> = None;
    let mut initial_view: Option<String> = None;
    let mut categories: Vec<PCategory> = Vec::new();
    let mut formulas: Vec<(String, String)> = Vec::new();
    let mut data: Vec<(CellKey, CellValue)> = Vec::new();
    let mut views: Vec<PView> = Vec::new();

    // ── Helpers for walking the pest parse tree ──────────────────────────────

    /// Advance an iterator, returning an error if empty.
    fn next<'a>(pairs: &mut Pairs<'a, Rule>, ctx: &str) -> Result<Pair<'a, Rule>> {
        pairs
            .next()
            .ok_or_else(|| anyhow::anyhow!("Expected child in {ctx}"))
    }

    /// Extract the first child's text content, trimmed.
    fn first_str(pair: Pair<'_, Rule>) -> Result<String> {
        Ok(next(&mut pair.into_inner(), "first_str")?
            .as_str()
            .trim()
            .to_string())
    }

    fn extract_name(pair: Pair<'_, Rule>) -> Result<String> {
        match pair.as_rule() {
            Rule::bare_name => Ok(pair.as_str().to_string()),
            Rule::pipe_quoted => {
                let inner = next(&mut pair.into_inner(), "pipe_quoted")?;
                Ok(unescape_pipe(inner.as_str()))
            }
            _ => Ok(pair.as_str().to_string()),
        }
    }

    /// Extract two names from a pair's children.
    fn extract_name_pair(pair: Pair<'_, Rule>) -> Result<(String, String)> {
        let ctx = format!("{:?}", pair.as_rule());
        let mut parts = pair.into_inner();
        let a = extract_name(next(&mut parts, &ctx)?)?;
        let b = extract_name(next(&mut parts, &ctx)?)?;
        Ok((a, b))
    }

    // ── Pass 1: walk the parse tree ─────────────────────────────────────────

    for pair in file.into_inner() {
        match pair.as_rule() {
            Rule::version_line | Rule::EOI => {}
            Rule::model_name => {
                model_name = Some(first_str(pair)?);
            }
            Rule::initial_view => {
                initial_view = Some(first_str(pair)?);
            }
            Rule::category_section => {
                let mut inner = pair.into_inner();
                let cname = next(&mut inner, "category_section")?
                    .as_str()
                    .trim()
                    .to_string();
                let mut pc = PCategory {
                    name: cname,
                    items: Vec::new(),
                    group_parents: Vec::new(),
                };
                for entry in inner {
                    match entry.as_rule() {
                        Rule::item_list => {
                            for name_pair in entry.into_inner() {
                                pc.items.push((extract_name(name_pair)?, None));
                            }
                        }
                        Rule::grouped_item => {
                            let (name, group) = extract_name_pair(entry)?;
                            pc.items.push((name, Some(group)));
                        }
                        Rule::group_hierarchy => {
                            pc.group_parents.push(extract_name_pair(entry)?);
                        }
                        _ => {}
                    }
                }
                categories.push(pc);
            }
            Rule::formulas_section => {
                for fl in pair.into_inner() {
                    if fl.as_rule() == Rule::formula_line {
                        let raw = first_str(fl)?;
                        if let Some(i) = raw.rfind(" [")
                            && raw.ends_with(']')
                        {
                            formulas.push((
                                raw[..i].to_string(),
                                raw[i + 2..raw.len() - 1].to_string(),
                            ));
                            continue;
                        }
                        // No [Category] suffix — default to _Measure
                        if !raw.is_empty() && raw.contains('=') {
                            formulas.push((raw, "_Measure".to_string()));
                        }
                    }
                }
            }
            Rule::data_section => {
                for dl in pair.into_inner() {
                    if dl.as_rule() == Rule::data_line {
                        let mut dl_inner = dl.into_inner();
                        let coord_list = next(&mut dl_inner, "data_line coords")?;
                        let value_pair = next(&mut dl_inner, "data_line value")?;

                        let coords: Vec<_> = coord_list
                            .into_inner()
                            .filter(|p| p.as_rule() == Rule::coord)
                            .map(extract_name_pair)
                            .collect::<Result<_>>()?;

                        let value = match value_pair.as_rule() {
                            Rule::number => {
                                CellValue::Number(value_pair.as_str().parse().unwrap_or(0.0))
                            }
                            Rule::pipe_quoted => {
                                let inner = next(&mut value_pair.into_inner(), "pipe_quoted")?;
                                CellValue::Text(unescape_pipe(inner.as_str()))
                            }
                            Rule::bare_value => match value_pair.as_str().trim() {
                                "inf" => CellValue::Number(f64::INFINITY),
                                "-inf" => CellValue::Number(f64::NEG_INFINITY),
                                "nan" => CellValue::Number(f64::NAN),
                                s => CellValue::Text(s.to_string()),
                            },
                            _ => CellValue::Text(value_pair.as_str().to_string()),
                        };

                        data.push((CellKey::new(coords), value));
                    }
                }
            }
            Rule::view_section => {
                let mut inner = pair.into_inner();
                let vname = next(&mut inner, "view_section")?
                    .as_str()
                    .trim()
                    .to_string();
                let mut pv = PView {
                    name: vname,
                    axes: Vec::new(),
                    page_selections: Vec::new(),
                    format: String::new(),
                    hidden: Vec::new(),
                    collapsed: Vec::new(),
                };
                for entry in inner {
                    match entry.as_rule() {
                        Rule::axis_line => {
                            let mut parts = entry.into_inner();
                            let cat = extract_name(next(&mut parts, "axis cat")?)?;
                            let kind_str = next(&mut parts, "axis kind")?.as_str();
                            let axis = match kind_str {
                                "row" => Axis::Row,
                                "column" => Axis::Column,
                                "page" => Axis::Page,
                                "none" => Axis::None,
                                _ => bail!("Unknown axis kind: {kind_str}"),
                            };
                            pv.axes.push((cat.clone(), axis));
                            if axis == Axis::Page
                                && let Some(sel_pair) = parts.next()
                            {
                                pv.page_selections.push((cat, extract_name(sel_pair)?));
                            }
                        }
                        Rule::format_line => pv.format = first_str(entry)?,
                        Rule::hidden_line => pv.hidden.push(extract_name_pair(entry)?),
                        Rule::collapsed_line => pv.collapsed.push(extract_name_pair(entry)?),
                        _ => {}
                    }
                }
                views.push(pv);
            }
            _ => {}
        }
    }

    // ── Pass 2: build the Workbook ──────────────────────────────────────────

    let name = model_name.ok_or_else(|| anyhow::anyhow!("Missing model title (# Name)"))?;
    let mut wb = Workbook::new(&name);

    for pc in &categories {
        wb.add_category(&pc.name)?;
        let cat = wb
            .model
            .category_mut(&pc.name)
            .ok_or_else(|| anyhow::anyhow!("Category '{}' not found after add", pc.name))?;
        for (item_name, group) in &pc.items {
            match group {
                Some(g) => {
                    cat.add_item_in_group(item_name, g);
                    if !cat.groups.iter().any(|e| &e.name == g) {
                        cat.add_group(Group::new(g));
                    }
                }
                None => {
                    cat.add_item(item_name);
                }
            }
        }
        for (group_name, parent) in &pc.group_parents {
            match cat.groups.iter_mut().find(|g| &g.name == group_name) {
                Some(g) => g.parent = Some(parent.clone()),
                None => cat.add_group(Group::new(group_name).with_parent(parent)),
            }
        }
    }

    for pv in &views {
        if !wb.views.contains_key(&pv.name) {
            wb.create_view(&pv.name);
        }
        let view = wb
            .views
            .get_mut(&pv.name)
            .ok_or_else(|| anyhow::anyhow!("View '{}' not found after create", pv.name))?;
        for (cat, axis) in &pv.axes {
            view.set_axis(cat, *axis);
        }
        for (cat, sel) in &pv.page_selections {
            view.set_page_selection(cat, sel);
        }
        if !pv.format.is_empty() {
            view.number_format = pv.format.clone();
        }
        for (cat, item) in &pv.hidden {
            view.hide_item(cat, item);
        }
        for (cat, grp) in &pv.collapsed {
            view.toggle_group_collapse(cat, grp);
        }
    }

    if let Some(iv) = &initial_view
        && wb.views.contains_key(iv)
    {
        wb.active_view = iv.clone();
    }

    for (raw, cat_name) in &formulas {
        wb.model
            .add_formula(parse_formula(raw, cat_name).with_context(|| format!("Formula: {raw}"))?);
    }
    for (key, value) in data {
        wb.model.set_cell(key, value);
    }

    Ok(wb)
}

fn coord_str(key: &CellKey) -> String {
    key.0
        .iter()
        .map(|(c, i)| format!("{}={}", quote_name(c), quote_name(i)))
        .collect::<Vec<_>>()
        .join(", ")
}

pub fn export_csv(workbook: &Workbook, view_name: &str, path: &Path) -> Result<()> {
    let view = workbook
        .views
        .get(view_name)
        .ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?;

    let layout = GridLayout::new(&workbook.model, view);
    let model = &workbook.model;

    let mut out = String::new();

    // Header row
    let row_header = layout.row_cats.join("/");
    let page_label: Vec<String> = layout
        .page_coords
        .iter()
        .map(|(c, v)| format!("{c}={v}"))
        .collect();
    let header_prefix = if page_label.is_empty() {
        row_header
    } else {
        format!("{} ({})", row_header, page_label.join(", "))
    };
    if !header_prefix.is_empty() {
        out.push_str(&header_prefix);
        out.push(',');
    }
    let col_labels: Vec<String> = (0..layout.col_count())
        .map(|ci| layout.col_label(ci))
        .collect();
    out.push_str(&col_labels.join(","));
    out.push('\n');

    // Data rows
    for ri in 0..layout.row_count() {
        let row_label = layout.row_label(ri);
        if !row_label.is_empty() {
            out.push_str(&row_label);
            out.push(',');
        }
        let row_values: Vec<String> = (0..layout.col_count())
            .map(|ci| layout.display_text(model, ri, ci, false, 0))
            .collect();
        out.push_str(&row_values.join(","));
        out.push('\n');
    }

    std::fs::write(path, out)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{format_md, parse_md};
    use crate::formula::parse_formula;
    use crate::model::category::Group;
    use crate::model::cell::{CellKey, CellValue};
    use crate::view::Axis;
    use crate::workbook::Workbook;

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

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

    // ── format_md ────────────────────────────────────────────────────────────

    #[test]
    fn format_md_contains_model_name() {
        let m = Workbook::new("My Model");
        assert!(format_md(&m).contains("# My Model"));
    }

    #[test]
    fn format_md_contains_category_and_items() {
        let m = two_cat_model();
        let text = format_md(&m);
        assert!(text.contains("## Category: Type"));
        // Bare items are now comma-separated on one line
        assert!(
            text.contains("- Food, Gas"),
            "expected comma-separated items:\n{text}"
        );
        assert!(text.contains("## Category: Month"));
        assert!(text.contains("Jan"));
    }

    #[test]
    fn format_md_item_with_group_uses_brackets() {
        let mut m = Workbook::new("T");
        m.add_category("Month").unwrap();
        m.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        let text = format_md(&m);
        assert!(text.contains("- Jan[Q1]"), "got:\n{text}");
    }

    #[test]
    fn format_md_group_hierarchy_uses_angle_prefix() {
        let mut m = Workbook::new("T");
        m.add_category("Month").unwrap();
        m.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        m.model
            .category_mut("Month")
            .unwrap()
            .add_group(Group::new("Q1").with_parent("2025"));
        let text = format_md(&m);
        assert!(text.contains("> Q1[|2025|]"), "got:\n{text}");
    }

    #[test]
    fn format_md_data_is_sorted_and_quoted() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Month", "Feb"), ("Type", "Food")]),
            CellValue::Number(200.0),
        );
        m.model.set_cell(
            coord(&[("Month", "Jan"), ("Type", "Gas")]),
            CellValue::Text("N/A".into()),
        );
        let text = format_md(&m);
        let data_pos = text.find("## Data").unwrap();
        let feb_pos = text.find("Month=Feb").unwrap();
        let jan_pos = text.find("Month=Jan").unwrap();
        assert!(
            data_pos < feb_pos && feb_pos < jan_pos,
            "expected sorted order Feb < Jan:\n{text}"
        );
        assert!(text.contains("= 200"), "number not quoted:\n{text}");
        assert!(
            text.contains("= |N/A|"),
            "text should be pipe-quoted:\n{text}"
        );
    }

    #[test]
    fn format_md_view_axes() {
        let m = two_cat_model();
        let text = format_md(&m);
        assert!(text.contains("## View: Default"));
        assert!(text.contains("Type: row"));
        assert!(text.contains("Month: column"));
    }

    #[test]
    fn format_md_page_axis_with_selection() {
        let mut m = Workbook::new("T");
        m.add_category("Type").unwrap();
        m.add_category("Month").unwrap();
        m.add_category("Region").unwrap();
        m.model.category_mut("Type").unwrap().add_item("Food");
        m.model.category_mut("Month").unwrap().add_item("Jan");
        for r in ["East", "West"] {
            m.model.category_mut("Region").unwrap().add_item(r);
        }
        m.active_view_mut().set_page_selection("Region", "West");
        let text = format_md(&m);
        assert!(text.contains("Region: page, West"), "got:\n{text}");
    }

    #[test]
    fn format_md_formula_includes_category() {
        let mut m = two_cat_model();
        m.model.category_mut("Type").unwrap().add_item("Total");
        m.model
            .add_formula(parse_formula("Total = Food + Gas", "Type").unwrap());
        let text = format_md(&m);
        assert!(text.contains("- Total = Food + Gas [Type]"), "got:\n{text}");
    }

    // ── parse_md ─────────────────────────────────────────────────────────────

    #[test]
    fn parse_md_round_trips_model_name() {
        let m = Workbook::new("My Budget");
        assert_eq!(parse_md(&format_md(&m)).unwrap().model.name, "My Budget");
    }

    #[test]
    fn parse_md_round_trips_categories_and_items() {
        let m = two_cat_model();
        let m2 = parse_md(&format_md(&m)).unwrap();
        assert!(
            m2.model
                .category("Type")
                .and_then(|c| c.items.get("Food"))
                .is_some()
        );
        assert!(
            m2.model
                .category("Month")
                .and_then(|c| c.items.get("Feb"))
                .is_some()
        );
    }

    #[test]
    fn parse_md_round_trips_item_group() {
        let mut m = Workbook::new("T");
        m.add_category("Month").unwrap();
        m.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        let m2 = parse_md(&format_md(&m)).unwrap();
        assert_eq!(
            m2.model
                .category("Month")
                .and_then(|c| c.items.get("Jan"))
                .and_then(|i| i.group.as_deref()),
            Some("Q1")
        );
    }

    #[test]
    fn parse_md_round_trips_group_hierarchy() {
        let mut m = Workbook::new("T");
        m.add_category("Month").unwrap();
        m.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1");
        m.model
            .category_mut("Month")
            .unwrap()
            .add_group(Group::new("Q1").with_parent("2025"));
        let m2 = parse_md(&format_md(&m)).unwrap();
        let groups = &m2.model.category("Month").unwrap().groups;
        let q1 = groups.iter().find(|g| g.name == "Q1").unwrap();
        assert_eq!(q1.parent.as_deref(), Some("2025"));
    }

    #[test]
    fn parse_md_round_trips_data_cells() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Month", "Jan"), ("Type", "Food")]),
            CellValue::Number(100.0),
        );
        m.model.set_cell(
            coord(&[("Month", "Feb"), ("Type", "Gas")]),
            CellValue::Text("N/A".into()),
        );
        let m2 = parse_md(&format_md(&m)).unwrap();
        assert_eq!(
            m2.model
                .get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])),
            Some(&CellValue::Number(100.0))
        );
        assert_eq!(
            m2.model
                .get_cell(&coord(&[("Month", "Feb"), ("Type", "Gas")])),
            Some(&CellValue::Text("N/A".into()))
        );
    }

    #[test]
    fn parse_md_round_trips_view_axes() {
        let m = two_cat_model();
        let m2 = parse_md(&format_md(&m)).unwrap();
        let v = m2.active_view();
        assert_eq!(v.axis_of("Type"), Axis::Row);
        assert_eq!(v.axis_of("Month"), Axis::Column);
    }

    #[test]
    fn parse_md_round_trips_page_selection() {
        let mut m = Workbook::new("T");
        m.add_category("Type").unwrap();
        m.add_category("Month").unwrap();
        m.add_category("Region").unwrap();
        m.model.category_mut("Type").unwrap().add_item("Food");
        m.model.category_mut("Month").unwrap().add_item("Jan");
        for r in ["East", "West"] {
            m.model.category_mut("Region").unwrap().add_item(r);
        }
        m.active_view_mut().set_page_selection("Region", "West");
        let m2 = parse_md(&format_md(&m)).unwrap();
        assert_eq!(m2.active_view().page_selection("Region"), Some("West"));
        assert_eq!(m2.active_view().axis_of("Region"), Axis::Page);
    }

    // active_view is no longer persisted — it's runtime state

    #[test]
    fn parse_md_round_trips_formula() {
        let mut m = two_cat_model();
        m.model.category_mut("Type").unwrap().add_item("Total");
        m.model
            .add_formula(parse_formula("Total = Food + Gas", "Type").unwrap());
        let m2 = parse_md(&format_md(&m)).unwrap();
        let f = &m2.model.formulas()[0];
        assert_eq!(f.raw, "Total = Food + Gas");
        assert_eq!(f.target_category, "Type");
    }

    #[test]
    fn parse_md_round_trips_hidden_item() {
        let _ = two_cat_model();
        {
            let m = &mut two_cat_model();
            m.active_view_mut().hide_item("Type", "Gas");
            let m2 = parse_md(&format_md(m)).unwrap();
            assert!(m2.active_view().is_hidden("Type", "Gas"));
            assert!(!m2.active_view().is_hidden("Type", "Food"));
        }
    }

    #[test]
    fn parse_md_order_independent_view_before_categories() {
        // A hand-edited file with the view section before the category sections.
        // The parser must still produce correct axis assignments.
        let text = "v2025-04-09\n# Test\n\
                    ## View: Default\n\
                    Type: row\n\
                    Month: column\n\
                    ## Category: Type\n\
                    - Food\n\
                    ## Category: Month\n\
                    - Jan\n";
        let m = parse_md(text).unwrap();
        assert_eq!(m.active_view().axis_of("Type"), Axis::Row);
        assert_eq!(m.active_view().axis_of("Month"), Axis::Column);
    }

    #[test]
    fn parse_md_order_independent_new_view_before_categories() {
        let text = "v2025-04-09\n# Test\n\
                    ## View: Transposed\n\
                    Type: column\n\
                    Month: row\n\
                    ## View: Default\n\
                    Type: row\n\
                    Month: column\n\
                    ## Category: Type\n\
                    - Food\n\
                    ## Category: Month\n\
                    - Jan\n";
        let m = parse_md(text).unwrap();
        let transposed = m.views.get("Transposed").unwrap();
        assert_eq!(transposed.axis_of("Type"), Axis::Column);
        assert_eq!(transposed.axis_of("Month"), Axis::Row);
        let default = m.views.get("Default").unwrap();
        assert_eq!(default.axis_of("Type"), Axis::Row);
        assert_eq!(default.axis_of("Month"), Axis::Column);
    }

    #[test]
    fn parse_md_order_independent_data_before_categories() {
        let text = "v2025-04-09\n# Test\n\
                    ## Data\n\
                    Month=Jan, Type=Food = 42\n\
                    ## Category: Type\n\
                    - Food\n\
                    ## Category: Month\n\
                    - Jan\n\
                    ## View: Default\n\
                    Type: row\n\
                    Month: column\n";
        let m = parse_md(text).unwrap();
        assert_eq!(
            m.model
                .get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])),
            Some(&CellValue::Number(42.0))
        );
    }

    #[test]
    fn load_dispatcher_detects_legacy_json_by_brace() {
        // The load() function routes to JSON deserializer when text starts with '{'
        let m = two_cat_model();
        let json = serde_json::to_string_pretty(&m).unwrap();
        assert!(json.trim_start().starts_with('{'), "sanity check");
        // Deserialise via the JSON path
        let m2: Workbook = serde_json::from_str(&json).unwrap();
        assert_eq!(m2.model.name, "Budget");
    }

    // ── save/load roundtrip via file ────────────────────────────────────

    #[test]
    fn save_and_load_roundtrip_plain() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Number(42.0),
        );
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.improv");
        super::save(&m, &path).unwrap();
        let loaded = super::load(&path).unwrap();
        assert_eq!(loaded.model.name, "Budget");
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Number(42.0))
        );
    }

    #[test]
    fn save_and_load_roundtrip_gzip() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Gas"), ("Month", "Feb")]),
            CellValue::Number(99.0),
        );
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.improv.gz");
        super::save(&m, &path).unwrap();
        let loaded = super::load(&path).unwrap();
        assert_eq!(loaded.model.name, "Budget");
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Gas"), ("Month", "Feb")])),
            Some(&CellValue::Number(99.0))
        );
    }

    // ── autosave_path ───────────────────────────────────────────────────

    #[test]
    fn autosave_path_inserts_dot_prefix() {
        let p = std::path::Path::new("/home/user/data/budget.improv");
        let auto = super::autosave_path(p);
        assert_eq!(
            auto.file_name().unwrap().to_str().unwrap(),
            ".budget.improv.autosave"
        );
    }

    // ── format_md: collapsed groups ─────────────────────────────────────

    #[test]
    fn format_md_collapsed_group() {
        let mut m = two_cat_model();
        m.active_view_mut().toggle_group_collapse("Type", "MyGroup");
        let text = format_md(&m);
        assert!(text.contains("collapsed: Type/MyGroup"));
    }

    // ── format_md: page axis without selection ──────────────────────────

    #[test]
    fn format_md_page_without_selection() {
        let mut m = two_cat_model();
        m.active_view_mut().set_axis("Month", Axis::Page);
        // Don't set a page selection
        let text = format_md(&m);
        assert!(text.contains("Month: page"));
        // Should NOT have a comma after "page" (no selection)
        let line = text.lines().find(|l| l.starts_with("Month:")).unwrap();
        assert!(!line.contains(','), "Expected no selection, got: {line}");
    }

    // ── format_md: none axis ────────────────────────────────────────────

    #[test]
    fn format_md_none_axis() {
        let mut m = two_cat_model();
        m.active_view_mut().set_axis("Month", Axis::None);
        let text = format_md(&m);
        assert!(text.contains("Month: none"));
    }

    // ── format_md: number format ────────────────────────────────────────

    #[test]
    fn format_md_includes_number_format() {
        let mut m = two_cat_model();
        m.active_view_mut().number_format = ",.2f".to_string();
        let text = format_md(&m);
        assert!(text.contains("format: ,.2f"));
    }

    // ── parse_md: comments and blank lines ──────────────────────────────

    #[test]
    fn parse_md_ignores_blank_and_comment_lines() {
        let text = r#"v2025-04-09
# Test Model

## Category: Type
- Food, Gas

## Data
Type=Food = 42
"#;
        let m = parse_md(text).unwrap();
        assert_eq!(m.model.name, "Test Model");
        assert!(m.model.category("Type").is_some());
    }

    // ── parse_md: text values ───────────────────────────────────────────

    #[test]
    fn parse_md_round_trips_text_cell_values() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Text("pending".to_string()),
        );
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Text("pending".to_string()))
        );
    }

    // ── parse_md: collapsed groups roundtrip ────────────────────────────

    #[test]
    fn parse_md_round_trips_collapsed_group() {
        let mut m = two_cat_model();
        m.active_view_mut().toggle_group_collapse("Type", "MyGroup");
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert!(loaded.active_view().is_group_collapsed("Type", "MyGroup"));
    }

    // ── parse_md: number format roundtrip ───────────────────────────────

    #[test]
    fn parse_md_round_trips_number_format() {
        let mut m = two_cat_model();
        m.active_view_mut().number_format = ",.2f".to_string();
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(loaded.active_view().number_format, ",.2f");
    }

    // ── parse_md: none axis roundtrip ───────────────────────────────────

    #[test]
    fn parse_md_round_trips_none_axis() {
        let mut m = two_cat_model();
        m.active_view_mut().set_axis("Month", Axis::None);
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(loaded.active_view().axis_of("Month"), Axis::None);
    }

    // ── parse_md: multiple views ────────────────────────────────────────

    #[test]
    fn parse_md_round_trips_multiple_views() {
        let mut m = two_cat_model();
        m.create_view("Alternate");
        {
            let v = m.views.get_mut("Alternate").unwrap();
            v.set_axis("Type", Axis::Column);
            v.set_axis("Month", Axis::Row);
        }
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert!(loaded.views.contains_key("Default"));
        assert!(loaded.views.contains_key("Alternate"));
        let alt = loaded.views.get("Alternate").unwrap();
        assert_eq!(alt.axis_of("Type"), Axis::Column);
        assert_eq!(alt.axis_of("Month"), Axis::Row);
    }

    // ── export_csv ──────────────────────────────────────────────────────

    #[test]
    fn export_csv_produces_valid_output() {
        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", "Gas"), ("Month", "Feb")]),
            CellValue::Number(50.0),
        );
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("export.csv");
        super::export_csv(&m, "Default", &path).unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        // Should have a header and data rows
        let lines: Vec<&str> = content.lines().collect();
        assert!(lines.len() >= 2, "Expected header + data, got: {content}");
        // Header should contain column labels
        assert!(
            lines[0].contains(','),
            "Expected CSV header, got: {}",
            lines[0]
        );
    }

    #[test]
    fn export_csv_unknown_view_returns_error() {
        let m = two_cat_model();
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("export.csv");
        assert!(super::export_csv(&m, "Nonexistent", &path).is_err());
    }

    // ── Full save/load/format roundtrip with all features ───────────────

    #[test]
    fn full_roundtrip_preserves_all_features() {
        let mut m = two_cat_model();
        // Add data
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Number(100.0),
        );
        m.model.set_cell(
            coord(&[("Type", "Gas"), ("Month", "Feb")]),
            CellValue::Text("pending".to_string()),
        );
        // Add formula
        let f = parse_formula("Gas = Food * 2", "Type").unwrap();
        m.model.add_formula(f);
        // Configure view
        m.active_view_mut().set_axis("Month", Axis::Page);
        m.active_view_mut().set_page_selection("Month", "Jan");
        m.active_view_mut().hide_item("Type", "Gas");
        m.active_view_mut().toggle_group_collapse("Type", "G1");
        m.active_view_mut().number_format = ",.0".to_string();

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();

        // Verify everything roundtripped
        assert_eq!(loaded.model.name, "Budget");
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Number(100.0))
        );
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Gas"), ("Month", "Feb")])),
            Some(&CellValue::Text("pending".to_string()))
        );
        assert!(!loaded.model.formulas().is_empty());
        let v = loaded.active_view();
        assert_eq!(v.axis_of("Month"), Axis::Page);
        assert_eq!(v.page_selection("Month"), Some("Jan"));
        assert!(v.is_hidden("Type", "Gas"));
        assert!(v.is_group_collapsed("Type", "G1"));
        assert_eq!(v.number_format, ",.0");
    }

    // ── Stress tests: special characters in values ──────────────────────

    #[test]
    fn text_value_with_embedded_comma() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Text("Smith, Jr.".into()),
        );
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Text("Smith, Jr.".into())),
            "Comma inside quoted text was corrupted.\n{text}"
        );
    }

    #[test]
    fn text_value_with_embedded_double_quote() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Text(r#"He said "hello""#.into()),
        );
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Text(r#"He said "hello""#.into())),
            "Embedded double quotes were corrupted.\n{text}"
        );
    }

    #[test]
    fn text_value_with_equals_space_sequence() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Text("x = y".into()),
        );
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Text("x = y".into())),
            "Text containing ' = ' was corrupted.\n{text}"
        );
    }

    #[test]
    fn text_value_is_single_double_quote() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Text("\"".into()),
        );
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Text("\"".into())),
            "Single double-quote text was corrupted.\n{text}"
        );
    }

    #[test]
    fn text_value_is_empty_string() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Text("".into()),
        );
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Text("".into())),
            "Empty string was corrupted.\n{text}"
        );
    }

    #[test]
    fn text_value_with_newline() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Text("line1\nline2".into()),
        );
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Text("line1\nline2".into())),
            "Newline in text was corrupted.\n{text}"
        );
    }

    #[test]
    fn text_value_looks_like_number() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Text("42".into()),
        );
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Text("42".into())),
            "Numeric-looking text was converted to Number.\n{text}"
        );
    }

    #[test]
    fn text_value_with_hash_prefix() {
        let mut m = two_cat_model();
        m.model.set_cell(
            coord(&[("Type", "Food"), ("Month", "Jan")]),
            CellValue::Text("#NotAHeader".into()),
        );
        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])),
            Some(&CellValue::Text("#NotAHeader".into())),
            "Hash-prefixed text was misinterpreted.\n{text}"
        );
    }

    #[test]
    fn item_name_with_brackets_misinterpreted_as_group() {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.model
            .category_mut("Type")
            .unwrap()
            .add_item("Item [special]");
        m.add_category("Month").unwrap();
        m.model.category_mut("Month").unwrap().add_item("Jan");

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        let cat = loaded.model.category("Type").unwrap();
        let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect();
        assert!(
            item_names.contains(&"Item [special]"),
            "Item name with brackets was misinterpreted as having a group.\n\
             Got items: {item_names:?}\n{text}"
        );
    }

    #[test]
    fn category_name_with_comma_space_in_data() {
        let mut m = Workbook::new("Test");
        m.add_category("Income, Gross").unwrap();
        m.model.category_mut("Income, Gross").unwrap().add_item("A");
        m.add_category("Month").unwrap();
        m.model.category_mut("Month").unwrap().add_item("Jan");
        m.model.set_cell(
            coord(&[("Income, Gross", "A"), ("Month", "Jan")]),
            CellValue::Number(100.0),
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Income, Gross", "A"), ("Month", "Jan")])),
            Some(&CellValue::Number(100.0)),
            "Category name with comma-space broke coord parsing.\n{text}"
        );
    }

    #[test]
    fn item_name_with_equals_sign_in_data() {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.model.category_mut("Type").unwrap().add_item("A=B");
        m.add_category("Month").unwrap();
        m.model.category_mut("Month").unwrap().add_item("Jan");
        m.model.set_cell(
            coord(&[("Type", "A=B"), ("Month", "Jan")]),
            CellValue::Number(50.0),
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Type", "A=B"), ("Month", "Jan")])),
            Some(&CellValue::Number(50.0)),
            "Item name with '=' broke coord parsing.\n{text}"
        );
    }

    #[test]
    fn view_name_with_parentheses() {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.model.category_mut("Type").unwrap().add_item("A");
        m.create_view("My View (v2)");

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert!(
            loaded.views.contains_key("My View (v2)"),
            "View with parens was corrupted.\nViews: {:?}\n{text}",
            loaded.views.keys().collect::<Vec<_>>()
        );
    }

    #[test]
    fn multiple_tricky_text_cells() {
        let mut m = Workbook::new("EdgeCases");
        m.add_category("Dim").unwrap();
        for item in ["A", "B", "C", "D"] {
            m.model.category_mut("Dim").unwrap().add_item(item);
        }
        m.add_category("Msr").unwrap();
        m.model.category_mut("Msr").unwrap().add_item("Val");

        let cases: Vec<(&str, CellValue)> = vec![
            ("A", CellValue::Text("hello, world".into())),
            ("B", CellValue::Text(r#"a "quoted" thing"#.into())),
            ("C", CellValue::Text("x = y = z".into())),
            ("D", CellValue::Text("".into())),
        ];

        for (item, value) in &cases {
            m.model
                .set_cell(coord(&[("Dim", item), ("Msr", "Val")]), value.clone());
        }

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();

        for (item, expected) in &cases {
            let got = loaded
                .model
                .get_cell(&coord(&[("Dim", item), ("Msr", "Val")]));
            assert_eq!(
                got,
                Some(expected),
                "Cell Dim={item} round-trip failed.\nExpected: {expected:?}\nGot: {got:?}\n{text}"
            );
        }
    }
}

// ── Property-based parser tests ──────────────────────────────────────────────

#[cfg(test)]
mod parser_prop_tests {
    use super::{format_md, parse_md};
    use crate::model::cell::{CellKey, CellValue};
    use crate::workbook::Workbook;
    use proptest::prelude::*;

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

    /// Safe identifier: won't collide with format grammar.
    fn safe_ident() -> impl Strategy<Value = String> {
        "[A-Za-z][A-Za-z0-9_]{0,9}"
    }

    /// Text values designed to exercise parser edge cases.
    fn tricky_text() -> impl Strategy<Value = String> {
        prop_oneof![
            "[a-zA-Z0-9 ]{0,20}",
            "[a-zA-Z]+(, [a-zA-Z]+)*",
            Just(r#"say "hi""#.to_string()),
            Just("\"".to_string()),
            Just("\"\"".to_string()),
            Just("a = b".to_string()),
            Just(" = ".to_string()),
            Just("# not a header".to_string()),
            Just("[bracketed]".to_string()),
            // printable ASCII range
            "[ -~]{0,30}",
            Just("".to_string()),
        ]
    }

    fn cell_value() -> impl Strategy<Value = CellValue> {
        prop_oneof![
            prop::num::f64::NORMAL
                .prop_filter("finite", |f| f.is_finite())
                .prop_map(CellValue::Number),
            (-1000i64..1000).prop_map(|n| CellValue::Number(n as f64)),
            tricky_text().prop_map(CellValue::Text),
        ]
    }

    fn arbitrary_model() -> impl Strategy<Value = Workbook> {
        let items1 = prop::collection::hash_set(safe_ident(), 1..=4);
        let items2 = prop::collection::hash_set(safe_ident(), 1..=4);
        let values = prop::collection::vec(cell_value(), 1..=8);

        (safe_ident(), items1, items2, values).prop_map(|(name, items1, items2, values)| {
            let mut m = Workbook::new(&name);
            m.add_category("CatA").unwrap();
            m.add_category("CatB").unwrap();

            let items1: Vec<_> = items1.into_iter().collect();
            let items2: Vec<_> = items2.into_iter().collect();

            for item in &items1 {
                m.model.category_mut("CatA").unwrap().add_item(item);
            }
            for item in &items2 {
                m.model.category_mut("CatB").unwrap().add_item(item);
            }

            for (i, value) in values.into_iter().enumerate() {
                let a = &items1[i % items1.len()];
                let b = &items2[i % items2.len()];
                m.model.set_cell(coord(&[("CatA", a), ("CatB", b)]), value);
            }

            m
        })
    }

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(500))]

        #[test]
        fn roundtrip_preserves_model_name(name in safe_ident()) {
            let m = Workbook::new(&name);
            let text = format_md(&m);
            let loaded = parse_md(&text).unwrap();
            prop_assert_eq!(loaded.model.name, name);
        }

        #[test]
        fn roundtrip_preserves_categories_and_items(
            items1 in prop::collection::hash_set(safe_ident(), 1..=5),
            items2 in prop::collection::hash_set(safe_ident(), 1..=5),
        ) {
            let mut m = Workbook::new("Test");
            m.add_category("Alpha").unwrap();
            m.add_category("Beta").unwrap();
            for item in &items1 {
                m.model.category_mut("Alpha").unwrap().add_item(item);
            }
            for item in &items2 {
                m.model.category_mut("Beta").unwrap().add_item(item);
            }

            let text = format_md(&m);
            let loaded = parse_md(&text).unwrap();

            let loaded_alpha: std::collections::HashSet<String> = loaded
                .model.category("Alpha").unwrap()
                .items.values().map(|i| i.name.clone()).collect();
            let loaded_beta: std::collections::HashSet<String> = loaded
                .model.category("Beta").unwrap()
                .items.values().map(|i| i.name.clone()).collect();

            prop_assert_eq!(loaded_alpha, items1);
            prop_assert_eq!(loaded_beta, items2);
        }

        /// Double round-trip: format → parse → format should be idempotent.
        #[test]
        fn double_roundtrip_is_idempotent(model in arbitrary_model()) {
            let text1 = format_md(&model);
            let loaded = parse_md(&text1).unwrap();
            let text2 = format_md(&loaded);
            prop_assert_eq!(&text1, &text2,
                "format→parse→format was not idempotent");
        }

        /// Tricky text values survive round-trip as cell values.
        #[test]
        fn tricky_text_value_roundtrips(text_val in tricky_text()) {
            let mut m = Workbook::new("Test");
            m.add_category("Dim").unwrap();
            m.model.category_mut("Dim").unwrap().add_item("A");
            m.add_category("Msr").unwrap();
            m.model.category_mut("Msr").unwrap().add_item("V");
            m.model.set_cell(
                coord(&[("Dim", "A"), ("Msr", "V")]),
                CellValue::Text(text_val.clone()),
            );

            let formatted = format_md(&m);
            let loaded = parse_md(&formatted).unwrap();
            let got = loaded.model.get_cell(&coord(&[("Dim", "A"), ("Msr", "V")]));
            prop_assert_eq!(
                got,
                Some(&CellValue::Text(text_val.clone())),
                "Text value {:?} did not round-trip.\n{}",
                text_val, formatted
            );
        }

        /// Cell count is preserved across round-trip.
        #[test]
        fn roundtrip_preserves_cell_count(model in arbitrary_model()) {
            let original_count = model.model.data.iter_cells().count();
            let text = format_md(&model);
            let loaded = parse_md(&text).unwrap();
            let loaded_count = loaded.model.data.iter_cells().count();
            prop_assert_eq!(original_count, loaded_count,
                "Cell count changed after round-trip");
        }

        /// Item names with special characters (brackets, backslashes) round-trip.
        #[test]
        fn tricky_item_names_roundtrip(
            name in prop_oneof![
                "[a-zA-Z]{1,8}",
                Just("[bracketed]".to_string()),
                Just("a\\b".to_string()),
                Just("x [y]".to_string()),
                Just("\\[escaped\\]".to_string()),
                Just("name[0]".to_string()),
                "[ -~]{1,15}",
            ]
        ) {
            // Item names must not be empty/whitespace or start with markdown syntax
            prop_assume!(!name.is_empty());
            prop_assume!(name.trim() == name); // no leading/trailing whitespace
            prop_assume!(!name.starts_with('#'));
            prop_assume!(!name.starts_with('>'));
            prop_assume!(!name.starts_with('-'));

            let mut m = Workbook::new("Test");
            m.add_category("Cat").unwrap();
            m.model.category_mut("Cat").unwrap().add_item(&name);
            m.add_category("Dim").unwrap();
            m.model.category_mut("Dim").unwrap().add_item("X");

            let text = format_md(&m);
            let loaded = parse_md(&text).unwrap();
            let cat = loaded.model.category("Cat").unwrap();
            let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect();
            prop_assert!(item_names.contains(&name.as_str()),
                "Item name {:?} did not round-trip.\nGot: {:?}\n{}",
                name, item_names, text);
        }

        /// Category names with special characters used in data coordinates.
        #[test]
        fn tricky_category_names_in_data_roundtrip(
            cat_name in prop_oneof![
                "[a-zA-Z]{1,8}",
                Just("Type, Sub".to_string()),
                Just("A=B".to_string()),
                Just("Cat`Name".to_string()),
                Just("Income, Gross".to_string()),
            ]
        ) {
            prop_assume!(!cat_name.is_empty());
            prop_assume!(!cat_name.starts_with('#'));

            let mut m = Workbook::new("Test");
            m.add_category(&cat_name).unwrap();
            m.model.category_mut(&cat_name).unwrap().add_item("X");
            m.add_category("Other").unwrap();
            m.model.category_mut("Other").unwrap().add_item("Y");
            m.model.set_cell(
                coord(&[(&cat_name, "X"), ("Other", "Y")]),
                CellValue::Number(42.0),
            );

            let text = format_md(&m);
            let loaded = parse_md(&text).unwrap();
            let got = loaded.model.get_cell(&coord(&[(&cat_name, "X"), ("Other", "Y")]));
            prop_assert_eq!(got, Some(&CellValue::Number(42.0)),
                "Category name {:?} broke data round-trip.\n{}",
                cat_name, text);
        }

        /// Item names with special characters used in data coordinates.
        #[test]
        fn tricky_item_names_in_data_roundtrip(
            item_name in prop_oneof![
                "[a-zA-Z]{1,8}",
                Just("A=B".to_string()),
                Just("X, Y".to_string()),
                Just("Item`s".to_string()),
            ]
        ) {
            prop_assume!(!item_name.is_empty());
            prop_assume!(!item_name.starts_with('#'));
            prop_assume!(!item_name.starts_with('-'));
            prop_assume!(!item_name.starts_with('>'));

            let mut m = Workbook::new("Test");
            m.add_category("Cat").unwrap();
            m.model.category_mut("Cat").unwrap().add_item(&item_name);
            m.add_category("Dim").unwrap();
            m.model.category_mut("Dim").unwrap().add_item("V");
            m.model.set_cell(
                coord(&[("Cat", &item_name), ("Dim", "V")]),
                CellValue::Number(99.0),
            );

            let text = format_md(&m);
            let loaded = parse_md(&text).unwrap();
            let got = loaded.model.get_cell(&coord(&[("Cat", &item_name), ("Dim", "V")]));
            prop_assert_eq!(got, Some(&CellValue::Number(99.0)),
                "Item name {:?} broke data round-trip.\n{}",
                item_name, text);
        }
    }
}

// ── Additional parser edge-case tests ────────────────────────────────────────

#[cfg(test)]
mod parser_edge_cases {
    use super::{format_md, parse_md};
    use crate::model::category::Group;
    use crate::model::cell::{CellKey, CellValue};
    use crate::workbook::Workbook;

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

    // ── Backtick quoting in coordinates ─────────────────────────────────

    #[test]
    fn backtick_in_category_name() {
        let mut m = Workbook::new("Test");
        m.add_category("Cat`s").unwrap();
        m.model.category_mut("Cat`s").unwrap().add_item("A");
        m.add_category("Dim").unwrap();
        m.model.category_mut("Dim").unwrap().add_item("X");
        m.model.set_cell(
            coord(&[("Cat`s", "A"), ("Dim", "X")]),
            CellValue::Number(1.0),
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Cat`s", "A"), ("Dim", "X")])),
            Some(&CellValue::Number(1.0)),
            "Backtick in category name broke round-trip.\n{text}"
        );
    }

    #[test]
    fn item_name_with_both_equals_and_comma() {
        let mut m = Workbook::new("Test");
        m.add_category("Cat").unwrap();
        m.model.category_mut("Cat").unwrap().add_item("a=1, b=2");
        m.add_category("Dim").unwrap();
        m.model.category_mut("Dim").unwrap().add_item("X");
        m.model.set_cell(
            coord(&[("Cat", "a=1, b=2"), ("Dim", "X")]),
            CellValue::Number(7.0),
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded
                .model
                .get_cell(&coord(&[("Cat", "a=1, b=2"), ("Dim", "X")])),
            Some(&CellValue::Number(7.0)),
            "Item with '=' and ', ' broke round-trip.\n{text}"
        );
    }

    // ── View section edge cases ─────────────────────────────────────────

    #[test]
    fn hidden_item_with_slash_in_name() {
        // hidden: Cat/Item — but what if item name contains '/'?
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.model.category_mut("Type").unwrap().add_item("A/B");
        m.active_view_mut().hide_item("Type", "A/B");

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        // This will likely fail — the parser splits on '/'
        assert!(
            loaded.active_view().is_hidden("Type", "A/B"),
            "Hidden item with '/' in name was corrupted.\n{text}"
        );
    }

    #[test]
    fn collapsed_group_with_slash_in_name() {
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.active_view_mut().toggle_group_collapse("Type", "Q1/Q2");

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert!(
            loaded.active_view().is_group_collapsed("Type", "Q1/Q2"),
            "Collapsed group with '/' in name was corrupted.\n{text}"
        );
    }

    #[test]
    fn view_name_ending_with_active_string() {
        // View name "Not (active)" could be confused with the active marker
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.model.category_mut("Type").unwrap().add_item("A");
        m.create_view("Not (active)");

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert!(
            loaded.views.contains_key("Not (active)"),
            "View name ending with '(active)' was misinterpreted.\nViews: {:?}\n{text}",
            loaded.views.keys().collect::<Vec<_>>()
        );
    }

    // ── Group hierarchy edge cases ──────────────────────────────────────

    #[test]
    fn group_name_with_brackets() {
        let mut m = Workbook::new("Test");
        m.add_category("Month").unwrap();
        m.model
            .category_mut("Month")
            .unwrap()
            .add_item_in_group("Jan", "Q1 [2025]");
        m.model
            .category_mut("Month")
            .unwrap()
            .add_group(Group::new("Q1 [2025]").with_parent("Year"));

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        let cat = loaded.model.category("Month").unwrap();
        let jan = cat.items.values().find(|i| i.name == "Jan");
        assert!(jan.is_some(), "Jan item missing after round-trip.\n{text}");
        assert_eq!(
            jan.unwrap().group.as_deref(),
            Some("Q1 [2025]"),
            "Group name with brackets was corrupted.\n{text}"
        );
    }

    #[test]
    fn item_in_group_where_item_has_brackets() {
        // Item "Data [raw]" in group "Input" — the item name has brackets
        // AND the item has a group.
        let mut m = Workbook::new("Test");
        m.add_category("Type").unwrap();
        m.model
            .category_mut("Type")
            .unwrap()
            .add_item_in_group("Data [raw]", "Input");

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        let cat = loaded.model.category("Type").unwrap();
        let item = cat.items.values().find(|i| i.name == "Data [raw]");
        assert!(
            item.is_some(),
            "Item 'Data [raw]' with group not found.\nItems: {:?}\n{text}",
            cat.items
                .values()
                .map(|i| (&i.name, &i.group))
                .collect::<Vec<_>>()
        );
        assert_eq!(item.unwrap().group.as_deref(), Some("Input"));
    }

    // ── Malformed input resilience ──────────────────────────────────────

    #[test]
    fn parse_empty_string() {
        let result = parse_md("");
        assert!(
            result.is_err() || result.unwrap().model.name.is_empty(),
            "Empty input should either error or produce empty model"
        );
    }

    #[test]
    fn parse_just_model_name() {
        let m = parse_md("v2025-04-09\n# MyModel\n").unwrap();
        assert_eq!(m.model.name, "MyModel");
    }

    #[test]
    fn parse_data_without_value() {
        // Malformed data line: no " = " separator — pest rejects it
        let text = "v2025-04-09\n# Test\n## Data\nType=Food\n";
        assert!(parse_md(text).is_err());
    }

    #[test]
    fn parse_data_with_empty_coords() {
        // Data line with only value, no coordinates — pest rejects it
        let text = "v2025-04-09\n# Test\n## Data\n = 42\n";
        assert!(parse_md(text).is_err());
    }

    #[test]
    fn parse_duplicate_categories() {
        let text = "v2025-04-09\n# Test\n## Category: Type\n- A\n## Category: Type\n- B\n";
        let m = parse_md(text).unwrap();
        let cat = m.model.category("Type").unwrap();
        let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect();
        assert!(!item_names.is_empty());
    }

    #[test]
    fn parse_category_with_no_items() {
        let text = "v2025-04-09\n# Test\n## Category: Empty\n## Category: Full\n- A\n";
        let m = parse_md(text).unwrap();
        assert!(m.model.category("Empty").is_some());
        assert_eq!(m.model.category("Empty").unwrap().items.len(), 0);
        assert_eq!(m.model.category("Full").unwrap().items.len(), 1);
    }

    // ── Number formatting edge cases ────────────────────────────────────

    #[test]
    fn number_negative_zero_roundtrips() {
        let mut m = Workbook::new("Test");
        m.add_category("A").unwrap();
        m.model.category_mut("A").unwrap().add_item("X");
        m.add_category("B").unwrap();
        m.model.category_mut("B").unwrap().add_item("Y");
        m.model
            .set_cell(coord(&[("A", "X"), ("B", "Y")]), CellValue::Number(-0.0));

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")]));
        match got {
            Some(CellValue::Number(n)) => assert!(n.abs() == 0.0),
            other => panic!("Expected Number(0.0), got {other:?}"),
        }
    }

    #[test]
    fn number_very_large_roundtrips() {
        let mut m = Workbook::new("Test");
        m.add_category("A").unwrap();
        m.model.category_mut("A").unwrap().add_item("X");
        m.add_category("B").unwrap();
        m.model.category_mut("B").unwrap().add_item("Y");
        m.model.set_cell(
            coord(&[("A", "X"), ("B", "Y")]),
            CellValue::Number(1.7976931348623157e308),
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")]));
        assert_eq!(
            got,
            Some(&CellValue::Number(1.7976931348623157e308)),
            "f64::MAX did not round-trip.\n{text}"
        );
    }

    #[test]
    fn number_very_small_roundtrips() {
        let mut m = Workbook::new("Test");
        m.add_category("A").unwrap();
        m.model.category_mut("A").unwrap().add_item("X");
        m.add_category("B").unwrap();
        m.model.category_mut("B").unwrap().add_item("Y");
        m.model.set_cell(
            coord(&[("A", "X"), ("B", "Y")]),
            CellValue::Number(5e-324), // f64::MIN_POSITIVE subnormal
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")]));
        assert_eq!(
            got,
            Some(&CellValue::Number(5e-324)),
            "Subnormal float did not round-trip.\n{text}"
        );
    }

    #[test]
    fn number_pi_roundtrips() {
        let mut m = Workbook::new("Test");
        m.add_category("A").unwrap();
        m.model.category_mut("A").unwrap().add_item("X");
        m.add_category("B").unwrap();
        m.model.category_mut("B").unwrap().add_item("Y");
        m.model.set_cell(
            coord(&[("A", "X"), ("B", "Y")]),
            CellValue::Number(std::f64::consts::PI),
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")]));
        assert_eq!(
            got,
            Some(&CellValue::Number(std::f64::consts::PI)),
            "PI did not round-trip.\n{text}"
        );
    }

    // ── Whitespace edge cases ───────────────────────────────────────────

    #[test]
    fn model_name_with_leading_trailing_spaces() {
        let text = "v2025-04-09\n#   Spaced Model   \n";
        let m = parse_md(text).unwrap();
        // rest_of_line captures everything after "# "; we trim in the builder
        assert_eq!(m.model.name, "Spaced Model");
    }

    #[test]
    fn category_name_with_trailing_spaces() {
        let text = "v2025-04-09\n# Test\n## Category: Trailing   \n- Item\n";
        let m = parse_md(text).unwrap();
        // rest_of_line includes trailing spaces; we trim in the builder
        assert!(m.model.category("Trailing").is_some());
    }

    #[test]
    fn data_line_with_extra_whitespace() {
        // With the pest grammar, extra whitespace in data lines is rejected
        let text = "v2025-04-09\n# Test\n## Category: T\n- A\n## Category: M\n- J\n## Data\n  T=A ,  M=J  =  42  \n";
        // pest grammar is strict about whitespace — this should fail
        assert!(parse_md(text).is_err());
    }

    // ── Three-category model ────────────────────────────────────────────

    #[test]
    fn three_categories_round_trip() {
        let mut m = Workbook::new("3D");
        for cat in ["Region", "Product", "Year"] {
            m.add_category(cat).unwrap();
        }
        m.model.category_mut("Region").unwrap().add_item("East");
        m.model.category_mut("Region").unwrap().add_item("West");
        m.model.category_mut("Product").unwrap().add_item("Widget");
        m.model.category_mut("Year").unwrap().add_item("2025");
        m.model.set_cell(
            coord(&[("Region", "East"), ("Product", "Widget"), ("Year", "2025")]),
            CellValue::Number(1000.0),
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded.model.get_cell(&coord(&[
                ("Region", "East"),
                ("Product", "Widget"),
                ("Year", "2025")
            ])),
            Some(&CellValue::Number(1000.0)),
            "3-category cell did not round-trip.\n{text}"
        );
    }

    // ── Text value with backslash ───────────────────────────────────────

    #[test]
    fn text_value_with_backslash() {
        let mut m = Workbook::new("Test");
        m.add_category("A").unwrap();
        m.model.category_mut("A").unwrap().add_item("X");
        m.add_category("B").unwrap();
        m.model.category_mut("B").unwrap().add_item("Y");
        m.model.set_cell(
            coord(&[("A", "X"), ("B", "Y")]),
            CellValue::Text("C:\\Users\\file.txt".into()),
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])),
            Some(&CellValue::Text("C:\\Users\\file.txt".into())),
            "Backslash in text was corrupted.\n{text}"
        );
    }

    #[test]
    fn text_value_with_backslash_n_literal() {
        // The literal string "\n" (two chars) should not become a newline
        let mut m = Workbook::new("Test");
        m.add_category("A").unwrap();
        m.model.category_mut("A").unwrap().add_item("X");
        m.add_category("B").unwrap();
        m.model.category_mut("B").unwrap().add_item("Y");
        m.model.set_cell(
            coord(&[("A", "X"), ("B", "Y")]),
            CellValue::Text("literal \\n not newline".into()),
        );

        let text = format_md(&m);
        let loaded = parse_md(&text).unwrap();
        assert_eq!(
            loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])),
            Some(&CellValue::Text("literal \\n not newline".into())),
            "Literal backslash-n was corrupted.\n{text}"
        );
    }
}

// ── Grammar-walking file generator ───────────────────────────────────────────
//
// Parses `improv.pest` at test time and walks the AST to generate random valid
// files.  The generator and parser share a single source of truth: the grammar.

#[cfg(test)]
mod generator {
    use pest_meta::ast::{Expr, RuleType};
    use pest_meta::parser;
    use proptest::prelude::*;
    use std::collections::HashMap;

    /// Parse the grammar file and return rules keyed by name.
    fn load_grammar() -> HashMap<String, (RuleType, Expr)> {
        let grammar = include_str!("improv.pest");
        let pairs = parser::parse(parser::Rule::grammar_rules, grammar)
            .unwrap_or_else(|e| panic!("Bad grammar: {e}"));
        let rules = parser::consume_rules(pairs).unwrap_or_else(|e| panic!("{e:?}"));
        rules
            .into_iter()
            .map(|r| (r.name.clone(), (r.ty, r.expr)))
            .collect()
    }

    /// Recursive string generator driven by a pest `Expr`.
    ///
    /// `choices` is consumed left-to-right for every decision point (Choice,
    /// Opt, Rep).  If it runs out we pick the "smallest" alternative (first
    /// branch, no repetition, skip optional).
    struct Gen<'g> {
        rules: &'g HashMap<String, (RuleType, Expr)>,
        choices: Vec<u8>,
        pos: usize,
    }

    impl<'g> Gen<'g> {
        fn new(rules: &'g HashMap<String, (RuleType, Expr)>, choices: Vec<u8>) -> Self {
            Self {
                rules,
                choices,
                pos: 0,
            }
        }

        /// Consume one byte of entropy, defaulting to 0.
        fn pick(&mut self) -> u8 {
            let v = self.choices.get(self.pos).copied().unwrap_or(0);
            self.pos += 1;
            v
        }

        fn emit(&mut self, expr: &Expr, out: &mut String) {
            match expr {
                Expr::Str(s) => out.push_str(s),
                Expr::Range(lo, hi) => {
                    let lo = lo.chars().next().unwrap() as u32;
                    let hi = hi.chars().next().unwrap() as u32;
                    let range = hi - lo + 1;
                    let ch = char::from_u32(lo + (self.pick() as u32 % range)).unwrap();
                    out.push(ch);
                }
                Expr::Ident(name) => {
                    // Built-in pest rules
                    match name.as_str() {
                        "ANY" => {
                            let ch = (b'a' + self.pick() % 26) as char;
                            out.push(ch);
                        }
                        "NEWLINE" => out.push('\n'),
                        "SOI" | "EOI" => {}
                        "ASCII_DIGIT" => {
                            let d = (b'0' + self.pick() % 10) as char;
                            out.push(d);
                        }
                        _ => {
                            // Look up user-defined rule
                            if let Some((_ty, expr)) = self.rules.get(name) {
                                self.emit(expr, out);
                            }
                        }
                    }
                }
                Expr::Seq(a, b) => {
                    self.emit(a, out);
                    self.emit(b, out);
                }
                Expr::Choice(a, b) => {
                    // Collect all choices (right-associated)
                    let mut alts: Vec<&Expr> = vec![a.as_ref()];
                    let mut cur = b.as_ref();
                    while let Expr::Choice(l, r) = cur {
                        alts.push(l.as_ref());
                        cur = r.as_ref();
                    }
                    alts.push(cur);
                    let idx = self.pick() as usize % alts.len();
                    self.emit(alts[idx], out);
                }
                Expr::Opt(inner) => {
                    if !self.pick().is_multiple_of(3) {
                        // ~66% chance of emitting
                        self.emit(inner, out);
                    }
                }
                Expr::Rep(inner) => {
                    // 0..N repetitions
                    let count = self.pick() % 4;
                    for _ in 0..count {
                        self.emit(inner, out);
                    }
                }
                Expr::RepOnce(inner) => {
                    // 1..N repetitions
                    let count = 1 + self.pick() % 3;
                    for _ in 0..count {
                        self.emit(inner, out);
                    }
                }
                Expr::NegPred(_) | Expr::PosPred(_) => {
                    // Lookaheads don't produce output
                }
                _ => {
                    // Skip unsupported expressions
                }
            }
        }

        fn generate(&mut self, rule_name: &str) -> String {
            let mut out = String::new();
            if let Some((_ty, expr)) = self.rules.get(rule_name).cloned() {
                self.emit(&expr, &mut out);
            }
            out
        }
    }

    /// Proptest strategy: generate a valid `.improv` file by walking the grammar.
    pub fn improv_file() -> impl Strategy<Value = String> {
        // Use random bytes as entropy for choices in the grammar walk
        prop::collection::vec(any::<u8>(), 64..=256).prop_map(|choices| {
            let rules = load_grammar();
            let mut g = Gen::new(&rules, choices);
            g.generate("file")
        })
    }
}

#[cfg(test)]
mod grammar_prop_tests {
    use super::{format_md, generator, parse_md};
    use proptest::prelude::*;

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(500))]

        /// parse(generate()) — every generated file parses without error.
        #[test]
        fn generated_file_parses(file in generator::improv_file()) {
            let result = parse_md(&file);
            prop_assert!(result.is_ok(),
                "Generated file failed to parse:\n{}\nError: {}",
                file, result.unwrap_err());
        }

        /// parse(print(parse(generate()))) — round-trip through format is stable.
        #[test]
        fn generated_file_roundtrips(file in generator::improv_file()) {
            let result1 = parse_md(&file);
            // Skip inputs that don't parse (the grammar walk may produce
            // degenerate inputs like empty model names)
            prop_assume!(result1.is_ok());
            let model1 = result1.unwrap();
            let printed = format_md(&model1);
            let model2_result = parse_md(&printed);
            prop_assert!(model2_result.is_ok(),
                "Re-formatted file failed to parse:\n{}\nError: {}",
                printed, model2_result.unwrap_err());

            let model2 = model2_result.unwrap();

            // Model name preserved
            prop_assert_eq!(&model1.model.name, &model2.model.name);

            // Category count preserved
            prop_assert_eq!(
                model1.model.categories.len(),
                model2.model.categories.len(),
                "Category count changed"
            );

            // Cell count preserved
            let count1 = model1.model.data.iter_cells().count();
            let count2 = model2.model.data.iter_cells().count();
            prop_assert_eq!(count1, count2,
                "Cell count changed: {} → {}\nOriginal:\n{}\nRe-formatted:\n{}",
                count1, count2, file, printed);

            // Double round-trip: format(parse(format(parse(gen)))) == format(parse(gen))
            let printed2 = format_md(&model2);
            prop_assert_eq!(&printed, &printed2,
                "format→parse→format not idempotent");
        }
    }
}
