use anyhow::{Result, anyhow};
use serde_json::Value;

use super::analyzer::{
    DateComponent, FieldKind, FieldProposal, analyze_records, extract_array_at_path,
    extract_date_component, find_array_paths,
};
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::workbook::Workbook;

// ── Pipeline (no UI state) ────────────────────────────────────────────────────

/// Pure data + logic for turning a JSON value into a Model.
/// No cursor, no display messages — those live in [`ImportWizard`].
#[derive(Debug)]
pub struct ImportPipeline {
    pub raw: Value,
    pub array_paths: Vec<String>,
    pub selected_path: String,
    pub records: Vec<Value>,
    pub proposals: Vec<FieldProposal>,
    pub model_name: String,
    /// Raw formula strings to add to the model (e.g., "Profit = Revenue - Cost").
    pub formulas: Vec<String>,
}

impl ImportPipeline {
    pub fn new(raw: Value) -> Self {
        let array_paths = find_array_paths(&raw);
        let mut pipeline = Self {
            raw: raw.clone(),
            array_paths,
            selected_path: String::new(),
            records: vec![],
            proposals: vec![],
            model_name: "Imported Model".to_string(),
            formulas: vec![],
        };

        // Auto-select if root is an array or there is exactly one candidate path.
        if raw.is_array() {
            pipeline.select_path("");
        } else if pipeline.array_paths.len() == 1 {
            let path = pipeline.array_paths[0].clone();
            pipeline.select_path(&path);
        }

        pipeline
    }

    pub fn select_path(&mut self, path: &str) {
        self.selected_path = path.to_string();
        if let Some(arr) = extract_array_at_path(&self.raw, path) {
            self.records = arr.clone();
            self.proposals = analyze_records(&self.records);
        }
    }

    pub fn needs_path_selection(&self) -> bool {
        self.records.is_empty() && self.raw.is_object() && self.array_paths.len() != 1
    }

    pub fn preview_summary(&self) -> String {
        match &self.raw {
            Value::Array(arr) => format!(
                "Array of {} records. Sample keys: {}",
                arr.len(),
                arr.first()
                    .and_then(|r| r.as_object())
                    .map(|m| m.keys().take(5).cloned().collect::<Vec<_>>().join(", "))
                    .unwrap_or_default()
            ),
            Value::Object(map) => format!(
                "Object with {} top-level keys: {}",
                map.len(),
                map.keys().take(10).cloned().collect::<Vec<_>>().join(", ")
            ),
            _ => "Unknown JSON structure".to_string(),
        }
    }

    /// Build a Workbook from the current proposals. Pure — no side effects.
    pub fn build_model(&self) -> Result<Workbook> {
        let categories: Vec<&FieldProposal> = self
            .proposals
            .iter()
            .filter(|p| {
                p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory)
            })
            .collect();
        let measures: Vec<&FieldProposal> = self
            .proposals
            .iter()
            .filter(|p| p.accepted && p.kind == FieldKind::Measure)
            .collect();
        let labels: Vec<&FieldProposal> = self
            .proposals
            .iter()
            .filter(|p| p.accepted && p.kind == FieldKind::Label)
            .collect();

        if categories.is_empty() {
            return Err(anyhow!("At least one category must be accepted"));
        }

        // Collect date component extractions: (field_name, format, component, derived_cat_name)
        let date_extractions: Vec<(&str, &str, DateComponent, String)> = self
            .proposals
            .iter()
            .filter(|p| {
                p.accepted
                    && p.kind == FieldKind::TimeCategory
                    && p.date_format.is_some()
                    && !p.date_components.is_empty()
            })
            .flat_map(|p| {
                let fmt = p.date_format.as_deref().unwrap();
                p.date_components.iter().map(move |comp| {
                    let suffix = match comp {
                        DateComponent::Year => "Year",
                        DateComponent::Month => "Month",
                        DateComponent::Quarter => "Quarter",
                    };
                    let derived_name = format!("{}_{}", p.field, suffix);
                    (p.field.as_str(), fmt, *comp, derived_name)
                })
            })
            .collect();

        let mut wb = Workbook::new(&self.model_name);

        for cat_proposal in &categories {
            wb.add_category(&cat_proposal.field)?;
            if let Some(cat) = wb.model.category_mut(&cat_proposal.field) {
                for val in &cat_proposal.distinct_values {
                    cat.add_item(val);
                }
            }
        }

        // Create derived date-component categories
        for (_, _, _, derived_name) in &date_extractions {
            wb.add_category(derived_name)?;
        }

        // Create label categories (stored but not pivoted by default)
        for lab in &labels {
            wb.add_label_category(&lab.field)?;
        }

        if !measures.is_empty()
            && let Some(cat) = wb.model.category_mut("_Measure")
        {
            for m in &measures {
                cat.add_item(&m.field);
            }
        }

        for record in &self.records {
            if let Value::Object(map) = record {
                let mut coords: Vec<(String, String)> = vec![];
                let mut valid = true;

                for cat_proposal in &categories {
                    let val = map
                        .get(&cat_proposal.field)
                        .and_then(|v| v.as_str())
                        .map(|s| s.to_string())
                        .or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));

                    if let Some(v) = val {
                        if let Some(cat) = wb.model.category_mut(&cat_proposal.field) {
                            cat.add_item(&v);
                        }
                        coords.push((cat_proposal.field.clone(), v.clone()));

                        // Extract date components from this field's value
                        for (field, fmt, comp, derived_name) in &date_extractions {
                            if *field == cat_proposal.field
                                && let Some(derived_val) = extract_date_component(&v, fmt, *comp)
                            {
                                if let Some(cat) = wb.model.category_mut(derived_name) {
                                    cat.add_item(&derived_val);
                                }
                                coords.push((derived_name.clone(), derived_val));
                            }
                        }
                    } else {
                        valid = false;
                        break;
                    }
                }

                if !valid {
                    continue;
                }

                // Attach label values as coords (missing labels become "").
                for lab in &labels {
                    let val = map
                        .get(&lab.field)
                        .and_then(|v| v.as_str())
                        .map(|s| s.to_string())
                        .or_else(|| {
                            map.get(&lab.field).and_then(|v| {
                                if v.is_null() {
                                    None
                                } else {
                                    Some(v.to_string())
                                }
                            })
                        })
                        .unwrap_or_default();
                    if let Some(cat) = wb.model.category_mut(&lab.field) {
                        cat.add_item(&val);
                    }
                    coords.push((lab.field.clone(), val));
                }

                for measure in &measures {
                    if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
                        let mut cell_coords = coords.clone();
                        cell_coords.push(("_Measure".to_string(), measure.field.clone()));
                        wb.model
                            .set_cell(CellKey::new(cell_coords), CellValue::Number(val));
                    }
                }
            }
        }

        // Parse and add formulas
        // Formulas target the "_Measure" category by default.
        let formula_cat: String = "_Measure".to_string();
        for raw in &self.formulas {
            if let Ok(formula) = parse_formula(raw, &formula_cat) {
                wb.model.add_formula(formula);
            }
        }

        Ok(wb)
    }
}

// ── Wizard (UI state wrapped around the pipeline) ─────────────────────────────

#[derive(Debug, Clone, PartialEq)]
pub enum WizardStep {
    Preview,
    SelectArrayPath,
    ReviewProposals,
    ConfigureDates,
    DefineFormulas,
    NameModel,
    Done,
}

/// Interactive state layered on top of [`ImportPipeline`] for the TUI wizard.
/// The pipeline holds all data; the wizard holds only what the UI needs to
/// drive the multi-step interaction (current step, list cursor, error message).
#[derive(Debug)]
pub struct ImportWizard {
    pub pipeline: ImportPipeline,
    pub step: WizardStep,
    /// Cursor within the current list (array paths or proposals).
    pub cursor: usize,
    /// One-line message to display at the bottom of the wizard panel.
    pub message: Option<String>,
    /// Whether we're in formula text-input mode.
    pub formula_editing: bool,
    /// Buffer for the formula being typed.
    pub formula_buffer: String,
}

impl ImportWizard {
    pub fn new(raw: Value) -> Self {
        let pipeline = ImportPipeline::new(raw);

        let step = if pipeline.needs_path_selection() {
            WizardStep::SelectArrayPath
        } else if pipeline.records.is_empty() {
            WizardStep::Preview
        } else {
            WizardStep::ReviewProposals
        };

        Self {
            pipeline,
            step,
            cursor: 0,
            message: None,
            formula_editing: false,
            formula_buffer: String::new(),
        }
    }

    // ── Step transitions ──────────────────────────────────────────────────────

    pub fn advance(&mut self) {
        self.step = match self.step {
            WizardStep::Preview => {
                if self.pipeline.array_paths.len() > 1 && self.pipeline.needs_path_selection() {
                    WizardStep::SelectArrayPath
                } else {
                    WizardStep::ReviewProposals
                }
            }
            WizardStep::SelectArrayPath => WizardStep::ReviewProposals,
            WizardStep::ReviewProposals => {
                if self.has_time_categories() {
                    WizardStep::ConfigureDates
                } else {
                    WizardStep::DefineFormulas
                }
            }
            WizardStep::ConfigureDates => WizardStep::DefineFormulas,
            WizardStep::DefineFormulas => WizardStep::NameModel,
            WizardStep::NameModel => WizardStep::Done,
            WizardStep::Done => WizardStep::Done,
        };
        self.cursor = 0;
        self.message = None;
    }

    fn has_time_categories(&self) -> bool {
        self.pipeline
            .proposals
            .iter()
            .any(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
    }

    /// Get accepted TimeCategory proposals (for ConfigureDates step).
    pub fn time_category_proposals(&self) -> Vec<&FieldProposal> {
        self.pipeline
            .proposals
            .iter()
            .filter(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
            .collect()
    }

    pub fn confirm_path(&mut self) {
        if self.cursor < self.pipeline.array_paths.len() {
            let path = self.pipeline.array_paths[self.cursor].clone();
            self.pipeline.select_path(&path);
            self.advance();
        }
    }

    // ── Cursor movement ───────────────────────────────────────────────────────

    pub fn move_cursor(&mut self, delta: i32) {
        let len = match self.step {
            WizardStep::SelectArrayPath => self.pipeline.array_paths.len(),
            WizardStep::ReviewProposals => self.pipeline.proposals.len(),
            WizardStep::ConfigureDates => self.date_config_item_count(),
            WizardStep::DefineFormulas => self.pipeline.formulas.len(),
            _ => 0,
        };
        if len == 0 {
            return;
        }
        if delta > 0 {
            self.cursor = (self.cursor + 1).min(len - 1);
        } else if self.cursor > 0 {
            self.cursor -= 1;
        }
    }

    // ── Proposal editing (delegates to pipeline) ──────────────────────────────

    pub fn toggle_proposal(&mut self) {
        if self.cursor < self.pipeline.proposals.len() {
            self.pipeline.proposals[self.cursor].accepted =
                !self.pipeline.proposals[self.cursor].accepted;
        }
    }

    pub fn cycle_proposal_kind(&mut self) {
        if self.cursor < self.pipeline.proposals.len() {
            let p = &mut self.pipeline.proposals[self.cursor];
            p.kind = match p.kind {
                FieldKind::Category => FieldKind::Measure,
                FieldKind::Measure => FieldKind::TimeCategory,
                FieldKind::TimeCategory => FieldKind::Label,
                FieldKind::Label => FieldKind::Category,
            };
        }
    }

    // ── Model name input ──────────────────────────────────────────────────────

    pub fn push_name_char(&mut self, c: char) {
        self.pipeline.model_name.push(c);
    }
    pub fn pop_name_char(&mut self) {
        self.pipeline.model_name.pop();
    }

    // ── Date config ────────────────────────────────────────────────────────────

    /// Total number of items in the ConfigureDates list.
    /// Each TimeCategory field gets 3 rows (Year, Month, Quarter).
    fn date_config_item_count(&self) -> usize {
        self.time_category_proposals().len() * 3
    }

    /// Get the (field_index, component) for the current cursor position.
    pub fn date_config_at_cursor(&self) -> Option<(usize, DateComponent)> {
        let tc_indices = self.time_category_indices();
        if tc_indices.is_empty() {
            return None;
        }
        let field_idx = self.cursor / 3;
        let comp_idx = self.cursor % 3;
        let component = match comp_idx {
            0 => DateComponent::Year,
            1 => DateComponent::Month,
            _ => DateComponent::Quarter,
        };
        tc_indices.get(field_idx).map(|&pi| (pi, component))
    }

    /// Indices into pipeline.proposals for accepted TimeCategory fields.
    fn time_category_indices(&self) -> Vec<usize> {
        self.pipeline
            .proposals
            .iter()
            .enumerate()
            .filter(|(_, p)| {
                p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some()
            })
            .map(|(i, _)| i)
            .collect()
    }

    /// Toggle a date component for the field at the current cursor.
    pub fn toggle_date_component(&mut self) {
        if let Some((pi, component)) = self.date_config_at_cursor() {
            let proposal = &mut self.pipeline.proposals[pi];
            if let Some(pos) = proposal
                .date_components
                .iter()
                .position(|c| *c == component)
            {
                proposal.date_components.remove(pos);
            } else {
                proposal.date_components.push(component);
            }
        }
    }

    // ── Formula editing ────────────────────────────────────────────────────────

    /// Buffer for typing a new formula in the DefineFormulas step.
    pub fn push_formula_char(&mut self, c: char) {
        if !self.formula_editing {
            self.formula_editing = true;
            self.formula_buffer.clear();
        }
        self.formula_buffer.push(c);
    }

    pub fn pop_formula_char(&mut self) {
        self.formula_buffer.pop();
    }

    /// Commit the current formula buffer to the pipeline's formula list.
    pub fn confirm_formula(&mut self) {
        let text = self.formula_buffer.trim().to_string();
        if !text.is_empty() {
            self.pipeline.formulas.push(text);
        }
        self.formula_buffer.clear();
        self.formula_editing = false;
        self.cursor = self.pipeline.formulas.len().saturating_sub(1);
    }

    /// Delete the formula at the current cursor position.
    pub fn delete_formula(&mut self) {
        if self.cursor < self.pipeline.formulas.len() {
            self.pipeline.formulas.remove(self.cursor);
            if self.cursor > 0 && self.cursor >= self.pipeline.formulas.len() {
                self.cursor -= 1;
            }
        }
    }

    /// Start editing a new formula.
    pub fn start_formula_edit(&mut self) {
        self.formula_editing = true;
        self.formula_buffer.clear();
    }

    /// Cancel formula editing.
    pub fn cancel_formula_edit(&mut self) {
        self.formula_editing = false;
        self.formula_buffer.clear();
    }

    /// Generate sample formulas based on accepted measures.
    pub fn sample_formulas(&self) -> Vec<String> {
        let measures: Vec<&str> = self
            .pipeline
            .proposals
            .iter()
            .filter(|p| p.accepted && p.kind == FieldKind::Measure)
            .map(|p| p.field.as_str())
            .collect();

        let mut samples = Vec::new();
        if measures.len() >= 2 {
            samples.push(format!("Diff = {} - {}", measures[0], measures[1]));
        }
        if !measures.is_empty() {
            samples.push(format!("Total = SUM({})", measures[0]));
        }
        if measures.len() >= 2 {
            samples.push(format!("Ratio = {} / {}", measures[0], measures[1]));
        }
        samples
    }

    // ── Delegate build to pipeline ────────────────────────────────────────────

    pub fn build_model(&self) -> Result<Workbook> {
        self.pipeline.build_model()
    }
}

#[cfg(test)]
mod tests {
    use super::ImportPipeline;
    use crate::import::analyzer::FieldKind;
    use serde_json::json;

    #[test]
    fn flat_array_auto_selected() {
        let raw = json!([
            {"region": "East", "product": "Shirts", "revenue": 100.0},
            {"region": "West", "product": "Shirts", "revenue": 200.0},
        ]);
        let p = ImportPipeline::new(raw);
        assert!(!p.records.is_empty());
        assert!(!p.proposals.is_empty());
        assert!(!p.needs_path_selection());
    }

    #[test]
    fn numeric_field_proposed_as_measure() {
        let raw = json!([{"region": "East", "revenue": 100.0, "cost": 50.0}]);
        let p = ImportPipeline::new(raw);
        let revenue = p.proposals.iter().find(|p| p.field == "revenue").unwrap();
        assert_eq!(revenue.kind, FieldKind::Measure);
    }

    #[test]
    fn low_cardinality_string_field_proposed_as_category() {
        let raw = json!([
            {"region": "East", "revenue": 100.0},
            {"region": "West", "revenue": 200.0},
            {"region": "East", "revenue": 150.0},
        ]);
        let p = ImportPipeline::new(raw);
        let region = p.proposals.iter().find(|p| p.field == "region").unwrap();
        assert_eq!(region.kind, FieldKind::Category);
    }

    #[test]
    fn nested_json_needs_path_selection_when_multiple_arrays() {
        let raw = json!({
            "orders":   [{"id": 1}, {"id": 2}],
            "products": [{"name": "A"}, {"name": "B"}],
        });
        let p = ImportPipeline::new(raw);
        assert!(p.needs_path_selection());
    }

    #[test]
    fn single_nested_array_auto_selected() {
        let raw = json!({
            "data": [
                {"region": "East", "revenue": 100.0},
                {"region": "West", "revenue": 200.0},
            ]
        });
        let p = ImportPipeline::new(raw);
        assert!(!p.records.is_empty());
        assert!(!p.needs_path_selection());
    }

    #[test]
    fn select_path_populates_records_and_proposals() {
        let raw = json!({
            "a": [{"x": 1}, {"x": 2}],
            "b": [{"y": "foo"}, {"y": "bar"}],
        });
        let mut p = ImportPipeline::new(raw);
        p.select_path("b");
        assert!(!p.records.is_empty());
        assert!(!p.proposals.is_empty());
    }

    #[test]
    fn build_model_fails_with_no_accepted_categories() {
        let raw = json!([{"revenue": 100.0, "cost": 50.0}]);
        let mut p = ImportPipeline::new(raw);
        for prop in &mut p.proposals {
            prop.accepted = false;
        }
        assert!(p.build_model().is_err());
    }

    #[test]
    fn build_model_creates_categories_and_measure_category() {
        let raw = json!([
            {"region": "East", "revenue": 100.0},
            {"region": "West", "revenue": 200.0},
        ]);
        let p = ImportPipeline::new(raw);
        let wb = p.build_model().unwrap();
        assert!(wb.model.category("region").is_some());
        assert!(wb.model.category("_Measure").is_some());
    }

    #[test]
    fn label_fields_imported_as_label_category_coords() {
        use crate::model::category::CategoryKind;
        // 25 unique descriptions → classified as Label (> CATEGORY_THRESHOLD=20)
        let records: Vec<serde_json::Value> = (0..25)
            .map(|i| json!({"region": "East", "desc": format!("row-{i}"), "revenue": i as f64}))
            .collect();
        let raw = serde_json::Value::Array(records);
        let p = ImportPipeline::new(raw);
        let desc = p.proposals.iter().find(|p| p.field == "desc").unwrap();
        assert_eq!(desc.kind, FieldKind::Label);
        assert!(desc.accepted, "labels should default to accepted");

        let wb = p.build_model().unwrap();
        // Label field exists as a category with Label kind
        let cat = wb.model.category("desc").expect("desc category exists");
        assert_eq!(cat.kind, CategoryKind::Label);
        // Each record's cell key carries the desc label coord
        use crate::model::cell::CellKey;
        let k = CellKey::new(vec![
            ("_Measure".to_string(), "revenue".to_string()),
            ("desc".to_string(), "row-7".to_string()),
            ("region".to_string(), "East".to_string()),
        ]);
        assert_eq!(wb.model.get_cell(&k).and_then(|v| v.as_f64()), Some(7.0));
    }

    #[test]
    fn label_category_defaults_to_none_axis() {
        use crate::view::Axis;
        let records: Vec<serde_json::Value> = (0..25)
            .map(|i| json!({"region": "East", "desc": format!("r{i}"), "n": 1.0}))
            .collect();
        let raw = serde_json::Value::Array(records);
        let p = ImportPipeline::new(raw);
        let wb = p.build_model().unwrap();
        let v = wb.active_view();
        assert_eq!(v.axis_of("desc"), Axis::None);
    }

    #[test]
    fn build_model_cells_match_source_data() {
        let raw = json!([
            {"region": "East", "revenue": 100.0},
            {"region": "West", "revenue": 200.0},
        ]);
        let p = ImportPipeline::new(raw);
        let wb = p.build_model().unwrap();
        use crate::model::cell::CellKey;
        let k_east = CellKey::new(vec![
            ("_Measure".to_string(), "revenue".to_string()),
            ("region".to_string(), "East".to_string()),
        ]);
        let k_west = CellKey::new(vec![
            ("_Measure".to_string(), "revenue".to_string()),
            ("region".to_string(), "West".to_string()),
        ]);
        assert_eq!(
            wb.model.get_cell(&k_east).and_then(|v| v.as_f64()),
            Some(100.0)
        );
        assert_eq!(
            wb.model.get_cell(&k_west).and_then(|v| v.as_f64()),
            Some(200.0)
        );
    }

    #[test]
    fn model_name_defaults_to_imported_model() {
        let raw = json!([{"x": 1.0}]);
        let p = ImportPipeline::new(raw);
        assert_eq!(p.model_name, "Imported Model");
    }

    #[test]
    fn build_model_adds_formulas_from_pipeline() {
        let raw = json!([
            {"region": "East", "revenue": 100.0, "cost": 40.0},
            {"region": "West", "revenue": 200.0, "cost": 80.0},
        ]);
        let mut p = ImportPipeline::new(raw);
        p.formulas.push("Profit = revenue - cost".to_string());
        let wb = p.build_model().unwrap();
        // The formula should produce Profit = 60 for East (100-40)
        use crate::model::cell::CellKey;
        let key = CellKey::new(vec![
            ("_Measure".to_string(), "Profit".to_string()),
            ("region".to_string(), "East".to_string()),
        ]);
        let val = wb.model.evaluate(&key).and_then(|v| v.as_f64());
        assert_eq!(val, Some(60.0));
    }

    #[test]
    fn build_model_extracts_date_month_component() {
        use crate::import::analyzer::DateComponent;

        let raw = json!([
            {"Date": "01/15/2025", "Amount": 100.0},
            {"Date": "01/20/2025", "Amount": 50.0},
            {"Date": "02/05/2025", "Amount": 200.0},
        ]);
        let mut p = ImportPipeline::new(raw);
        // Enable Month extraction on the Date field
        for prop in &mut p.proposals {
            if prop.field == "Date" && prop.kind == FieldKind::TimeCategory {
                prop.date_components.push(DateComponent::Month);
            }
        }
        let wb = p.build_model().unwrap();
        assert!(wb.model.category("Date_Month").is_some());
        let cat = wb.model.category("Date_Month").unwrap();
        let items: Vec<&str> = cat.items.keys().map(|s| s.as_str()).collect();
        assert!(items.contains(&"2025-01"));
        assert!(items.contains(&"2025-02"));
    }

    // ── ImportWizard tests ────────────────────────────────────────────────

    use super::ImportWizard;
    use super::WizardStep;
    use crate::import::analyzer::DateComponent;

    fn sample_wizard() -> ImportWizard {
        let raw = json!([
            {"region": "East", "product": "Shirts", "revenue": 100.0, "cost": 40.0},
            {"region": "West", "product": "Pants",  "revenue": 200.0, "cost": 80.0},
        ]);
        ImportWizard::new(raw)
    }

    #[test]
    fn wizard_starts_at_review_proposals_for_flat_array() {
        let w = sample_wizard();
        assert_eq!(w.step, WizardStep::ReviewProposals);
    }

    #[test]
    fn wizard_starts_at_select_array_path_for_multi_path_object() {
        let raw = json!({
            "orders":   [{"id": 1}],
            "products": [{"name": "A"}],
        });
        let w = ImportWizard::new(raw);
        assert_eq!(w.step, WizardStep::SelectArrayPath);
    }

    #[test]
    fn wizard_advance_from_review_proposals_skips_dates_when_none() {
        let mut w = sample_wizard();
        assert_eq!(w.step, WizardStep::ReviewProposals);
        w.advance();
        // No time categories → skip ConfigureDates → DefineFormulas
        assert_eq!(w.step, WizardStep::DefineFormulas);
    }

    #[test]
    fn wizard_advance_full_sequence() {
        let mut w = sample_wizard();
        // ReviewProposals → DefineFormulas → NameModel → Done
        w.advance();
        assert_eq!(w.step, WizardStep::DefineFormulas);
        w.advance();
        assert_eq!(w.step, WizardStep::NameModel);
        w.advance();
        assert_eq!(w.step, WizardStep::Done);
        // Done stays Done
        w.advance();
        assert_eq!(w.step, WizardStep::Done);
    }

    #[test]
    fn wizard_move_cursor_clamps() {
        let mut w = sample_wizard();
        // At ReviewProposals, cursor starts at 0
        w.move_cursor(-1);
        assert_eq!(w.cursor, 0); // can't go below 0
        w.move_cursor(1);
        assert_eq!(w.cursor, 1);
        // Move way past end
        for _ in 0..100 {
            w.move_cursor(1);
        }
        assert!(w.cursor < w.pipeline.proposals.len());
    }

    #[test]
    fn wizard_toggle_proposal() {
        let mut w = sample_wizard();
        let was_accepted = w.pipeline.proposals[0].accepted;
        w.toggle_proposal();
        assert_ne!(w.pipeline.proposals[0].accepted, was_accepted);
        w.toggle_proposal();
        assert_eq!(w.pipeline.proposals[0].accepted, was_accepted);
    }

    #[test]
    fn wizard_cycle_proposal_kind() {
        let mut w = sample_wizard();
        let original = w.pipeline.proposals[0].kind.clone();
        w.cycle_proposal_kind();
        assert_ne!(w.pipeline.proposals[0].kind, original);
        // Cycle through all 4 kinds back to original
        w.cycle_proposal_kind();
        w.cycle_proposal_kind();
        w.cycle_proposal_kind();
        assert_eq!(w.pipeline.proposals[0].kind, original);
    }

    #[test]
    fn wizard_model_name_editing() {
        let mut w = sample_wizard();
        w.pipeline.model_name.clear();
        w.push_name_char('H');
        w.push_name_char('i');
        assert_eq!(w.pipeline.model_name, "Hi");
        w.pop_name_char();
        assert_eq!(w.pipeline.model_name, "H");
    }

    #[test]
    fn wizard_confirm_path() {
        let raw = json!({
            "orders":   [{"id": 1, "region": "East", "amount": 10.0}],
            "products": [{"name": "A"}],
        });
        let mut w = ImportWizard::new(raw);
        assert_eq!(w.step, WizardStep::SelectArrayPath);
        w.confirm_path(); // selects first path
        // Should advance past SelectArrayPath
        assert_ne!(w.step, WizardStep::SelectArrayPath);
        assert!(!w.pipeline.records.is_empty());
    }

    // ── Formula editing in wizard ───────────────────────────────────────

    #[test]
    fn wizard_formula_lifecycle() {
        let mut w = sample_wizard();
        // Go to DefineFormulas
        w.advance();
        assert_eq!(w.step, WizardStep::DefineFormulas);

        // Start editing
        w.start_formula_edit();
        assert!(w.formula_editing);

        // Type formula
        for c in "Profit = revenue - cost".chars() {
            w.push_formula_char(c);
        }
        assert_eq!(w.formula_buffer, "Profit = revenue - cost");

        // Pop a char
        w.pop_formula_char();
        assert_eq!(w.formula_buffer, "Profit = revenue - cos");

        // Cancel
        w.cancel_formula_edit();
        assert!(!w.formula_editing);
        assert!(w.formula_buffer.is_empty());
        assert!(w.pipeline.formulas.is_empty()); // nothing committed

        // Start again and confirm
        w.start_formula_edit();
        for c in "Profit = revenue - cost".chars() {
            w.push_formula_char(c);
        }
        w.confirm_formula();
        assert!(!w.formula_editing);
        assert_eq!(w.pipeline.formulas.len(), 1);
        assert_eq!(w.pipeline.formulas[0], "Profit = revenue - cost");
    }

    #[test]
    fn wizard_delete_formula() {
        let mut w = sample_wizard();
        w.pipeline.formulas.push("A = B + C".to_string());
        w.pipeline.formulas.push("D = E + F".to_string());
        w.cursor = 1;
        w.delete_formula();
        assert_eq!(w.pipeline.formulas.len(), 1);
        assert_eq!(w.pipeline.formulas[0], "A = B + C");
        assert_eq!(w.cursor, 0); // adjusted down
    }

    #[test]
    fn wizard_delete_formula_at_zero() {
        let mut w = sample_wizard();
        w.pipeline.formulas.push("A = B + C".to_string());
        w.cursor = 0;
        w.delete_formula();
        assert!(w.pipeline.formulas.is_empty());
        assert_eq!(w.cursor, 0);
    }

    #[test]
    fn wizard_confirm_empty_formula_is_noop() {
        let mut w = sample_wizard();
        w.start_formula_edit();
        w.confirm_formula(); // empty buffer
        assert!(w.pipeline.formulas.is_empty());
    }

    // ── Sample formulas ─────────────────────────────────────────────────

    #[test]
    fn sample_formulas_with_two_measures() {
        let w = sample_wizard();
        let samples = w.sample_formulas();
        // Should have Diff, Total, and Ratio suggestions
        assert!(samples.len() >= 2);
        assert!(samples.iter().any(|s| s.contains("Diff")));
        assert!(samples.iter().any(|s| s.contains("Total")));
    }

    #[test]
    fn sample_formulas_with_one_measure() {
        let raw = json!([
            {"region": "East", "revenue": 100.0},
        ]);
        let w = ImportWizard::new(raw);
        let samples = w.sample_formulas();
        assert!(samples.iter().any(|s| s.contains("Total")));
        // No Diff or Ratio with only one measure
        assert!(!samples.iter().any(|s| s.contains("Diff")));
    }

    #[test]
    fn sample_formulas_with_no_measures() {
        let raw = json!([
            {"region": "East", "product": "Shirts"},
        ]);
        let w = ImportWizard::new(raw);
        let samples = w.sample_formulas();
        assert!(samples.is_empty());
    }

    // ── Preview summary ─────────────────────────────────────────────────

    #[test]
    fn preview_summary_for_array() {
        let raw = json!([
            {"region": "East", "revenue": 100.0},
        ]);
        let p = ImportPipeline::new(raw);
        let s = p.preview_summary();
        assert!(s.contains("1 records"));
        assert!(s.contains("region"));
    }

    #[test]
    fn preview_summary_for_object() {
        let raw = json!({
            "data": [{"x": 1}],
            "meta": {"version": 1},
        });
        let p = ImportPipeline::new(raw);
        let s = p.preview_summary();
        assert!(s.contains("Object"));
        assert!(s.contains("data"));
    }

    // ── Date config ─────────────────────────────────────────────────────

    #[test]
    fn wizard_date_config_toggle() {
        let raw = json!([
            {"Date": "01/15/2025", "Amount": 100.0},
            {"Date": "02/15/2025", "Amount": 200.0},
        ]);
        let mut w = ImportWizard::new(raw);
        // Enable the Date field as a TimeCategory (should already be detected)
        let has_time = w.has_time_categories();
        if has_time {
            // Advance to ConfigureDates
            w.advance();
            assert_eq!(w.step, WizardStep::ConfigureDates);

            // Toggle Year component (cursor 0 = Year of first time field)
            let had_year_before = {
                let tc = w.time_category_proposals();
                !tc.is_empty() && tc[0].date_components.contains(&DateComponent::Year)
            };
            w.toggle_date_component();
            let has_year_after = {
                let tc = w.time_category_proposals();
                !tc.is_empty() && tc[0].date_components.contains(&DateComponent::Year)
            };
            assert_ne!(had_year_before, has_year_after);
        }
    }

    #[test]
    fn wizard_date_config_at_cursor_mapping() {
        let raw = json!([
            {"Date": "01/15/2025", "Amount": 100.0},
            {"Date": "02/15/2025", "Amount": 200.0},
        ]);
        let mut w = ImportWizard::new(raw);
        if w.has_time_categories() {
            w.advance();
            // cursor 0 → Year, cursor 1 → Month, cursor 2 → Quarter
            w.cursor = 0;
            let (_, comp) = w.date_config_at_cursor().unwrap();
            assert_eq!(comp, DateComponent::Year);
            w.cursor = 1;
            let (_, comp) = w.date_config_at_cursor().unwrap();
            assert_eq!(comp, DateComponent::Month);
            w.cursor = 2;
            let (_, comp) = w.date_config_at_cursor().unwrap();
            assert_eq!(comp, DateComponent::Quarter);
        }
    }

    // ── Edge cases in build_model ───────────────────────────────────────

    #[test]
    fn build_model_record_with_missing_category_value_skipped() {
        // If a record is missing a category field, it should be skipped
        let raw = json!([
            {"region": "East", "revenue": 100.0},
            {"revenue": 200.0},  // missing "region"
        ]);
        let p = ImportPipeline::new(raw);
        let wb = p.build_model().unwrap();
        // Only one cell should exist (the East record)
        use crate::model::cell::CellKey;
        let k = CellKey::new(vec![
            ("_Measure".to_string(), "revenue".to_string()),
            ("region".to_string(), "East".to_string()),
        ]);
        assert!(wb.model.get_cell(&k).is_some());
    }

    #[test]
    fn build_model_with_integer_category_values() {
        // Non-string JSON values used as categories should be stringified.
        // Use many repeated string values so "id" gets classified as Category,
        // plus a numeric field that triggers Measure.
        let raw = json!([
            {"id": "A", "type": "x", "value": 100.0},
            {"id": "B", "type": "x", "value": 200.0},
            {"id": "A", "type": "y", "value": 150.0},
        ]);
        let p = ImportPipeline::new(raw);
        let wb = p.build_model().unwrap();
        let cat = wb.model.category("id").expect("id should be a category");
        let items: Vec<&str> = cat.ordered_item_names().into_iter().collect();
        assert!(items.contains(&"A"));
        assert!(items.contains(&"B"));
    }

    #[test]
    fn build_model_formulas_without_measure_category() {
        // NOTE: When there are no measures, formula_cat falls back to
        // the first category key, which may include virtual categories.
        // This mirrors the CommitFormula bug (improvise-79u).
        let raw = json!([
            {"region": "East", "product": "Shirts"},
            {"region": "West", "product": "Pants"},
        ]);
        let mut p = ImportPipeline::new(raw);
        p.formulas.push("Test = A + B".to_string());
        let wb = p.build_model().unwrap();
        // Formula should still be added (even if target category is suboptimal)
        // The formula may fail to parse against a non-measure category, which is OK
        // Just ensure build_model doesn't panic
        assert!(wb.model.category("region").is_some());
    }

    #[test]
    fn build_model_date_components_appear_in_cell_keys() {
        use crate::import::analyzer::DateComponent;
        use crate::model::cell::CellKey;

        let raw = json!([
            {"Date": "03/31/2026", "Amount": 100.0},
        ]);
        let mut p = ImportPipeline::new(raw);
        for prop in &mut p.proposals {
            if prop.field == "Date" {
                prop.date_components.push(DateComponent::Month);
            }
        }
        let wb = p.build_model().unwrap();
        let key = CellKey::new(vec![
            ("Date".to_string(), "03/31/2026".to_string()),
            ("Date_Month".to_string(), "2026-03".to_string()),
            ("_Measure".to_string(), "Amount".to_string()),
        ]);
        assert_eq!(
            wb.model.get_cell(&key).and_then(|v| v.as_f64()),
            Some(100.0)
        );
    }
}
