use std::fmt::Debug;
use std::path::PathBuf;

use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;

use super::app::{App, AppMode, ViewFrame};

pub(crate) const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates cannot be empty";

/// A discrete state change produced by a command.
/// Effects know how to apply themselves to the App.
pub trait Effect: Debug {
    fn apply(&self, app: &mut App);
    /// Whether this effect changes the app mode.
    fn changes_mode(&self) -> bool {
        false
    }
}

// ── Model mutations ──────────────────────────────────────────────────────────

#[derive(Debug)]
pub struct AddCategory(pub String);
impl Effect for AddCategory {
    fn apply(&self, app: &mut App) {
        let _ = app.model_state.workbook.add_category(&self.0);
    }
}

#[derive(Debug)]
pub struct AddItem {
    pub category: String,
    pub item: String,
}
impl Effect for AddItem {
    fn apply(&self, app: &mut App) {
        if let Some(cat) = app.model_state.workbook.model.category_mut(&self.category) {
            cat.add_item(&self.item);
        } else {
            app.view_state.status_msg = format!("Unknown category '{}'", self.category);
        }
    }
}

#[derive(Debug)]
pub struct AddItemInGroup {
    pub category: String,
    pub item: String,
    pub group: String,
}
impl Effect for AddItemInGroup {
    fn apply(&self, app: &mut App) {
        if let Some(cat) = app.model_state.workbook.model.category_mut(&self.category) {
            cat.add_item_in_group(&self.item, &self.group);
        } else {
            app.view_state.status_msg = format!("Unknown category '{}'", self.category);
        }
    }
}

#[derive(Debug)]
pub struct SortData;
impl Effect for SortData {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.model.data.sort_by_key();
    }
}

#[derive(Debug)]
pub struct SetCell(pub CellKey, pub CellValue);
impl Effect for SetCell {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.model.set_cell(self.0.clone(), self.1.clone());
    }
}

#[derive(Debug)]
pub struct ClearCell(pub CellKey);
impl Effect for ClearCell {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.model.clear_cell(&self.0);
    }
}

#[derive(Debug)]
pub struct AddFormula {
    pub raw: String,
    pub target_category: String,
}
impl Effect for AddFormula {
    fn apply(&self, app: &mut App) {
        match crate::formula::parse_formula(&self.raw, &self.target_category) {
            Ok(formula) => {
                // For non-_Measure targets, add the item to the category so it
                // appears in the grid. _Measure targets are dynamically included
                // via Model::measure_item_names().
                if formula.target_category != "_Measure"
                    && let Some(cat) = app.model_state.workbook.model.category_mut(&formula.target_category)
                {
                    cat.add_item(&formula.target);
                }
                app.model_state.workbook.model.add_formula(formula);
            }
            Err(e) => {
                app.view_state.status_msg = format!("Formula error: {e}");
            }
        }
    }
}

#[derive(Debug)]
pub struct RemoveFormula {
    pub target: String,
    pub target_category: String,
}
impl Effect for RemoveFormula {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook
            .model
            .remove_formula(&self.target, &self.target_category);
    }
}

/// Re-enter edit mode by reading the cell value at the current cursor.
/// Used after commit+advance to continue data entry.
///
/// `target_mode` is supplied by the caller (keymap binding via
/// `EnterEditAtCursorCmd`, or `CommitAndAdvance` from its own `edit_mode`
/// field). The effect itself never inspects `app.view_state.mode` — the mode is decided
/// statically by whoever invoked us.
#[derive(Debug)]
pub struct EnterEditAtCursor {
    pub target_mode: AppMode,
}
impl Effect for EnterEditAtCursor {
    fn apply(&self, app: &mut App) {
        // Layout may be stale relative to prior effects in this batch (e.g.
        // AddRecordRow added a row); rebuild before reading display_value.
        app.rebuild_layout();
        let ctx = app.cmd_context(
            crossterm::event::KeyCode::Null,
            crossterm::event::KeyModifiers::NONE,
        );
        let value = ctx.display_value.clone();
        drop(ctx);
        app.view_state.buffers.insert("edit".to_string(), value);
        app.view_state.mode = self.target_mode.clone();
    }
}

#[derive(Debug)]
pub struct TogglePruneEmpty;
impl Effect for TogglePruneEmpty {
    fn apply(&self, app: &mut App) {
        let v = app.model_state.workbook.active_view_mut();
        v.prune_empty = !v.prune_empty;
    }
}

#[derive(Debug)]
pub struct ToggleCatExpand(pub String);
impl Effect for ToggleCatExpand {
    fn apply(&self, app: &mut App) {
        if !app.view_state.expanded_cats.remove(&self.0) {
            app.view_state.expanded_cats.insert(self.0.clone());
        }
    }
}

#[derive(Debug)]
pub struct RemoveItem {
    pub category: String,
    pub item: String,
}
impl Effect for RemoveItem {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.model.remove_item(&self.category, &self.item);
    }
}

#[derive(Debug)]
pub struct RemoveCategory(pub String);
impl Effect for RemoveCategory {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.remove_category(&self.0);
    }
}

// ── View mutations ───────────────────────────────────────────────────────────

#[derive(Debug)]
pub struct CreateView(pub String);
impl Effect for CreateView {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.create_view(&self.0);
    }
}

#[derive(Debug)]
pub struct DeleteView(pub String);
impl Effect for DeleteView {
    fn apply(&self, app: &mut App) {
        let _ = app.model_state.workbook.delete_view(&self.0);
    }
}

#[derive(Debug)]
pub struct SwitchView(pub String);
impl Effect for SwitchView {
    fn apply(&self, app: &mut App) {
        let current = app.model_state.workbook.active_view.clone();
        if current != self.0 {
            app.view_state.view_back_stack.push(ViewFrame {
                view_name: current,
                mode: app.view_state.mode.clone(),
            });
            app.view_state.view_forward_stack.clear();
        }
        let _ = app.model_state.workbook.switch_view(&self.0);
    }
}

/// Go back in view history (pop back stack, push current to forward stack).
#[derive(Debug)]
pub struct ViewBack;
impl Effect for ViewBack {
    fn apply(&self, app: &mut App) {
        if let Some(frame) = app.view_state.view_back_stack.pop() {
            let current = app.model_state.workbook.active_view.clone();
            app.view_state.view_forward_stack.push(ViewFrame {
                view_name: current,
                mode: app.view_state.mode.clone(),
            });
            let _ = app.model_state.workbook.switch_view(&frame.view_name);
            app.view_state.mode = frame.mode;
        }
    }
}

/// Go forward in view history (pop forward stack, push current to back stack).
#[derive(Debug)]
pub struct ViewForward;
impl Effect for ViewForward {
    fn apply(&self, app: &mut App) {
        if let Some(frame) = app.view_state.view_forward_stack.pop() {
            let current = app.model_state.workbook.active_view.clone();
            app.view_state.view_back_stack.push(ViewFrame {
                view_name: current,
                mode: app.view_state.mode.clone(),
            });
            let _ = app.model_state.workbook.switch_view(&frame.view_name);
            app.view_state.mode = frame.mode;
        }
    }
}

#[derive(Debug)]
pub struct SetAxis {
    pub category: String,
    pub axis: Axis,
}
impl Effect for SetAxis {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook
            .active_view_mut()
            .set_axis(&self.category, self.axis);
    }
}

#[derive(Debug)]
pub struct SetPageSelection {
    pub category: String,
    pub item: String,
}
impl Effect for SetPageSelection {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook
            .active_view_mut()
            .set_page_selection(&self.category, &self.item);
    }
}

#[derive(Debug)]
pub struct ToggleGroup {
    pub category: String,
    pub group: String,
}
impl Effect for ToggleGroup {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook
            .active_view_mut()
            .toggle_group_collapse(&self.category, &self.group);
    }
}

#[derive(Debug)]
pub struct HideItem {
    pub category: String,
    pub item: String,
}
impl Effect for HideItem {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook
            .active_view_mut()
            .hide_item(&self.category, &self.item);
    }
}

#[derive(Debug)]
pub struct ShowItem {
    pub category: String,
    pub item: String,
}
impl Effect for ShowItem {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook
            .active_view_mut()
            .show_item(&self.category, &self.item);
    }
}

#[derive(Debug)]
pub struct TransposeAxes;
impl Effect for TransposeAxes {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.active_view_mut().transpose_axes();
    }
}

#[derive(Debug)]
pub struct CycleAxis(pub String);
impl Effect for CycleAxis {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.active_view_mut().cycle_axis(&self.0);
    }
}

#[derive(Debug)]
pub struct SetNumberFormat(pub String);
impl Effect for SetNumberFormat {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.active_view_mut().number_format = self.0.clone();
    }
}

// ── Navigation ───────────────────────────────────────────────────────────────

#[derive(Debug)]
pub struct SetSelected(pub usize, pub usize);
impl Effect for SetSelected {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.active_view_mut().selected = (self.0, self.1);
    }
}

#[derive(Debug)]
pub struct SetRowOffset(pub usize);
impl Effect for SetRowOffset {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.active_view_mut().row_offset = self.0;
    }
}

#[derive(Debug)]
pub struct SetColOffset(pub usize);
impl Effect for SetColOffset {
    fn apply(&self, app: &mut App) {
        app.model_state.workbook.active_view_mut().col_offset = self.0;
    }
}

// ── App state ────────────────────────────────────────────────────────────────

#[derive(Debug)]
pub struct ChangeMode(pub AppMode);
impl Effect for ChangeMode {
    fn apply(&self, app: &mut App) {
        app.view_state.mode = self.0.clone();
    }
    fn changes_mode(&self) -> bool {
        true
    }
}

#[derive(Debug)]
pub struct SetStatus(pub String);
impl Effect for SetStatus {
    fn apply(&self, app: &mut App) {
        app.view_state.status_msg = self.0.clone();
    }
}

#[derive(Debug)]
pub struct MarkDirty;
impl Effect for MarkDirty {
    fn apply(&self, app: &mut App) {
        app.model_state.dirty = true;
    }
}

#[derive(Debug)]
pub struct SetYanked(pub Option<CellValue>);
impl Effect for SetYanked {
    fn apply(&self, app: &mut App) {
        app.view_state.yanked = self.0.clone();
    }
}

#[derive(Debug)]
pub struct SetSearchQuery(pub String);
impl Effect for SetSearchQuery {
    fn apply(&self, app: &mut App) {
        app.view_state.search_query = self.0.clone();
    }
}

#[derive(Debug)]
pub struct SetSearchMode(pub bool);
impl Effect for SetSearchMode {
    fn apply(&self, app: &mut App) {
        app.view_state.search_mode = self.0;
    }
}

/// Set a named buffer's contents.
#[derive(Debug)]
pub struct SetBuffer {
    pub name: String,
    pub value: String,
}
impl Effect for SetBuffer {
    fn apply(&self, app: &mut App) {
        // "search" is special — it writes to search_query for backward compat
        if self.name == "search" {
            app.view_state.search_query = self.value.clone();
        } else {
            app.view_state.buffers.insert(self.name.clone(), self.value.clone());
        }
    }
}

#[derive(Debug)]
pub struct SetTileCatIdx(pub usize);
impl Effect for SetTileCatIdx {
    fn apply(&self, app: &mut App) {
        app.view_state.tile_cat_idx = self.0;
    }
}

/// Populate the drill state with a frozen snapshot of records.
/// Clears any previous drill state.
#[derive(Debug)]
pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
impl Effect for StartDrill {
    fn apply(&self, app: &mut App) {
        app.view_state.drill_state = Some(super::app::DrillState {
            records: std::rc::Rc::new(self.0.clone()),
            pending_edits: std::collections::HashMap::new(),
        });
    }
}

/// Apply any pending edits to the model and clear the drill state.
#[derive(Debug)]
pub struct ApplyAndClearDrill;
impl Effect for ApplyAndClearDrill {
    fn apply(&self, app: &mut App) {
        let Some(drill) = app.view_state.drill_state.take() else {
            return;
        };
        if drill.pending_edits.is_empty() {
            return;
        }
        // For each pending edit, update the cell
        for ((record_idx, col_name), new_value) in &drill.pending_edits {
            let Some((orig_key, _)) = drill.records.get(*record_idx) else {
                continue;
            };
            if col_name == "Value" {
                // Update the cell's value
                let value = if new_value.is_empty() {
                    app.model_state.workbook.model.clear_cell(orig_key);
                    continue;
                } else if let Ok(n) = new_value.parse::<f64>() {
                    CellValue::Number(n)
                } else {
                    CellValue::Text(new_value.clone())
                };
                app.model_state.workbook.model.set_cell(orig_key.clone(), value);
            } else {
                if new_value.is_empty() {
                    app.view_state.status_msg = RECORD_COORDS_CANNOT_BE_EMPTY.to_string();
                    continue;
                }
                // Rename a coordinate: remove old cell, insert new with updated coord
                let value = match app.model_state.workbook.model.get_cell(orig_key) {
                    Some(v) => v.clone(),
                    None => continue,
                };
                app.model_state.workbook.model.clear_cell(orig_key);
                // Build new key by replacing the coord
                let new_coords: Vec<(String, String)> = orig_key
                    .0
                    .iter()
                    .map(|(c, i)| {
                        if c == col_name {
                            (c.clone(), new_value.clone())
                        } else {
                            (c.clone(), i.clone())
                        }
                    })
                    .collect();
                let new_key = CellKey::new(new_coords);
                // Ensure the new item exists in that category
                if let Some(cat) = app.model_state.workbook.model.category_mut(col_name) {
                    cat.add_item(new_value.clone());
                }
                app.model_state.workbook.model.set_cell(new_key, value);
            }
        }
        app.model_state.dirty = true;
    }
}

/// Stage a pending edit in the drill state.
#[derive(Debug)]
pub struct SetDrillPendingEdit {
    pub record_idx: usize,
    pub col_name: String,
    pub new_value: String,
}
impl Effect for SetDrillPendingEdit {
    fn apply(&self, app: &mut App) {
        if let Some(drill) = &mut app.view_state.drill_state {
            drill.pending_edits.insert(
                (self.record_idx, self.col_name.clone()),
                self.new_value.clone(),
            );
        }
    }
}

// ── Side effects ─────────────────────────────────────────────────────────────

#[derive(Debug)]
pub struct Save;
impl Effect for Save {
    fn apply(&self, app: &mut App) {
        if let Some(ref path) = app.model_state.file_path {
            match crate::persistence::save(&app.model_state.workbook, path) {
                Ok(()) => {
                    app.model_state.dirty = false;
                    app.view_state.status_msg = format!("Saved to {}", path.display());
                }
                Err(e) => {
                    app.view_state.status_msg = format!("Save error: {e}");
                }
            }
        } else {
            app.view_state.status_msg = "No file path — use :w <path>".to_string();
        }
    }
}

#[derive(Debug)]
pub struct SaveAs(pub PathBuf);
impl Effect for SaveAs {
    fn apply(&self, app: &mut App) {
        match crate::persistence::save(&app.model_state.workbook, &self.0) {
            Ok(()) => {
                app.model_state.file_path = Some(self.0.clone());
                app.model_state.dirty = false;
                app.view_state.status_msg = format!("Saved to {}", self.0.display());
            }
            Err(e) => {
                app.view_state.status_msg = format!("Save error: {e}");
            }
        }
    }
}

/// Dispatch a key event to the import wizard.
/// The wizard has its own internal state machine; this effect handles
/// all wizard key interactions and App-level side effects.
#[derive(Debug)]
pub struct WizardKey {
    pub key_code: crossterm::event::KeyCode,
}
impl Effect for WizardKey {
    fn apply(&self, app: &mut App) {
        use crate::import::wizard::WizardStep;

        let Some(wizard) = &mut app.view_state.wizard else {
            return;
        };

        match &wizard.step.clone() {
            WizardStep::Preview => match self.key_code {
                crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Char(' ') => {
                    wizard.advance()
                }
                crossterm::event::KeyCode::Esc => {
                    app.view_state.mode = AppMode::Normal;
                    app.view_state.wizard = None;
                }
                _ => {}
            },
            WizardStep::SelectArrayPath => match self.key_code {
                crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
                    wizard.move_cursor(-1)
                }
                crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
                    wizard.move_cursor(1)
                }
                crossterm::event::KeyCode::Enter => wizard.confirm_path(),
                crossterm::event::KeyCode::Esc => {
                    app.view_state.mode = AppMode::Normal;
                    app.view_state.wizard = None;
                }
                _ => {}
            },
            WizardStep::ReviewProposals => match self.key_code {
                crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
                    wizard.move_cursor(-1)
                }
                crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
                    wizard.move_cursor(1)
                }
                crossterm::event::KeyCode::Char(' ') => wizard.toggle_proposal(),
                crossterm::event::KeyCode::Char('c') => wizard.cycle_proposal_kind(),
                crossterm::event::KeyCode::Enter => wizard.advance(),
                crossterm::event::KeyCode::Esc => {
                    app.view_state.mode = AppMode::Normal;
                    app.view_state.wizard = None;
                }
                _ => {}
            },
            WizardStep::ConfigureDates => match self.key_code {
                crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
                    wizard.move_cursor(-1)
                }
                crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
                    wizard.move_cursor(1)
                }
                crossterm::event::KeyCode::Char(' ') => wizard.toggle_date_component(),
                crossterm::event::KeyCode::Enter => wizard.advance(),
                crossterm::event::KeyCode::Esc => {
                    app.view_state.mode = AppMode::Normal;
                    app.view_state.wizard = None;
                }
                _ => {}
            },
            WizardStep::DefineFormulas => {
                if wizard.formula_editing {
                    match self.key_code {
                        crossterm::event::KeyCode::Enter => wizard.confirm_formula(),
                        crossterm::event::KeyCode::Esc => wizard.cancel_formula_edit(),
                        crossterm::event::KeyCode::Backspace => wizard.pop_formula_char(),
                        crossterm::event::KeyCode::Char(c) => wizard.push_formula_char(c),
                        _ => {}
                    }
                } else {
                    match self.key_code {
                        crossterm::event::KeyCode::Char('n') => wizard.start_formula_edit(),
                        crossterm::event::KeyCode::Char('d') => wizard.delete_formula(),
                        crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
                            wizard.move_cursor(-1)
                        }
                        crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
                            wizard.move_cursor(1)
                        }
                        crossterm::event::KeyCode::Enter => wizard.advance(),
                        crossterm::event::KeyCode::Esc => {
                            app.view_state.mode = AppMode::Normal;
                            app.view_state.wizard = None;
                        }
                        _ => {}
                    }
                }
            }
            WizardStep::NameModel => match self.key_code {
                crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
                crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
                crossterm::event::KeyCode::Enter => match wizard.build_model() {
                    Ok(mut workbook) => {
                        workbook.normalize_view_state();
                        app.model_state.workbook = workbook;
                        app.view_state.formula_cursor = 0;
                        app.model_state.dirty = true;
                        app.view_state.status_msg = "Import successful! Press :w <path> to save.".to_string();
                        app.view_state.mode = AppMode::Normal;
                        app.view_state.wizard = None;
                    }
                    Err(e) => {
                        if let Some(w) = &mut app.view_state.wizard {
                            w.message = Some(format!("Error: {e}"));
                        }
                    }
                },
                crossterm::event::KeyCode::Esc => {
                    app.view_state.mode = AppMode::Normal;
                    app.view_state.wizard = None;
                }
                _ => {}
            },
            WizardStep::Done => {
                app.view_state.mode = AppMode::Normal;
                app.view_state.wizard = None;
            }
        }
    }
}

/// Start the import wizard from a JSON file path.
#[derive(Debug)]
pub struct StartImportWizard(pub String);
impl Effect for StartImportWizard {
    fn apply(&self, app: &mut App) {
        match std::fs::read_to_string(&self.0) {
            Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
                Ok(json) => {
                    app.view_state.wizard = Some(crate::import::wizard::ImportWizard::new(json));
                    app.view_state.mode = AppMode::ImportWizard;
                }
                Err(e) => {
                    app.view_state.status_msg = format!("JSON parse error: {e}");
                }
            },
            Err(e) => {
                app.view_state.status_msg = format!("Cannot read file: {e}");
            }
        }
    }
}

#[derive(Debug)]
pub struct ExportCsv(pub PathBuf);
impl Effect for ExportCsv {
    fn apply(&self, app: &mut App) {
        let view_name = app.model_state.workbook.active_view.clone();
        match crate::persistence::export_csv(&app.model_state.workbook, &view_name, &self.0) {
            Ok(()) => {
                app.view_state.status_msg = format!("Exported to {}", self.0.display());
            }
            Err(e) => {
                app.view_state.status_msg = format!("Export error: {e}");
            }
        }
    }
}

/// Load a model from a file, replacing the current one.
#[derive(Debug)]
pub struct LoadModel(pub PathBuf);
impl Effect for LoadModel {
    fn apply(&self, app: &mut App) {
        match crate::persistence::load(&self.0) {
            Ok(mut loaded) => {
                loaded.normalize_view_state();
                app.model_state.workbook = loaded;
                app.view_state.status_msg = format!("Loaded from {}", self.0.display());
            }
            Err(e) => {
                app.view_state.status_msg = format!("Load error: {e}");
            }
        }
    }
}

/// Headless JSON/CSV import: read file, analyze, build model, replace current.
#[derive(Debug)]
pub struct ImportJsonHeadless {
    pub path: PathBuf,
    pub model_name: Option<String>,
    pub array_path: Option<String>,
}
impl Effect for ImportJsonHeadless {
    fn apply(&self, app: &mut App) {
        use crate::import::analyzer::{
            FieldKind, analyze_records, extract_array_at_path, find_array_paths,
        };
        use crate::import::wizard::ImportPipeline;

        let is_csv = self
            .path
            .extension()
            .is_some_and(|ext| ext.eq_ignore_ascii_case("csv"));

        let records = if is_csv {
            match crate::import::csv_parser::parse_csv(&self.path) {
                Ok(recs) => recs,
                Err(e) => {
                    app.view_state.status_msg = format!("CSV error: {e}");
                    return;
                }
            }
        } else {
            let content = match std::fs::read_to_string(&self.path) {
                Ok(c) => c,
                Err(e) => {
                    app.view_state.status_msg = format!("Cannot read '{}': {e}", self.path.display());
                    return;
                }
            };
            let value: serde_json::Value = match serde_json::from_str(&content) {
                Ok(v) => v,
                Err(e) => {
                    app.view_state.status_msg = format!("JSON parse error: {e}");
                    return;
                }
            };

            if let Some(ap) = self.array_path.as_deref().filter(|s| !s.is_empty()) {
                match extract_array_at_path(&value, ap) {
                    Some(arr) => arr.clone(),
                    None => {
                        app.view_state.status_msg = format!("No array at path '{ap}'");
                        return;
                    }
                }
            } else if let Some(arr) = value.as_array() {
                arr.clone()
            } else {
                let paths = find_array_paths(&value);
                if let Some(first) = paths.first() {
                    match extract_array_at_path(&value, first) {
                        Some(arr) => arr.clone(),
                        None => {
                            app.view_state.status_msg = "Could not extract records array".to_string();
                            return;
                        }
                    }
                } else {
                    app.view_state.status_msg = "No array found in JSON".to_string();
                    return;
                }
            }
        };

        let proposals = analyze_records(&records);

        let raw = if is_csv {
            serde_json::Value::Array(records.clone())
        } else {
            serde_json::from_str(&std::fs::read_to_string(&self.path).unwrap_or_default())
                .unwrap_or(serde_json::Value::Array(records.clone()))
        };

        let pipeline = ImportPipeline {
            raw,
            array_paths: vec![],
            selected_path: self.array_path.as_deref().unwrap_or("").to_string(),
            records,
            proposals: proposals
                .into_iter()
                .map(|mut p| {
                    p.accepted = p.kind != FieldKind::Label;
                    p
                })
                .collect(),
            model_name: self
                .model_name
                .as_deref()
                .unwrap_or("Imported Model")
                .to_string(),
            formulas: vec![],
        };

        match pipeline.build_model() {
            Ok(new_workbook) => {
                app.model_state.workbook = new_workbook;
                app.view_state.status_msg = "Imported successfully".to_string();
            }
            Err(e) => {
                app.view_state.status_msg = format!("Import error: {e}");
            }
        }
    }
}

#[derive(Debug)]
pub struct SetPanelOpen {
    pub panel: Panel,
    pub open: bool,
}

#[derive(Debug, Clone, Copy)]
pub enum Panel {
    Formula,
    Category,
    View,
}

impl Panel {
    pub fn mode(self) -> AppMode {
        match self {
            Panel::Formula => AppMode::FormulaPanel,
            Panel::Category => AppMode::CategoryPanel,
            Panel::View => AppMode::ViewPanel,
        }
    }
}

impl Effect for SetPanelOpen {
    fn apply(&self, app: &mut App) {
        match self.panel {
            Panel::Formula => app.view_state.formula_panel_open = self.open,
            Panel::Category => app.view_state.category_panel_open = self.open,
            Panel::View => app.view_state.view_panel_open = self.open,
        }
    }
}

#[derive(Debug)]
pub struct SetPanelCursor {
    pub panel: Panel,
    pub cursor: usize,
}
impl Effect for SetPanelCursor {
    fn apply(&self, app: &mut App) {
        match self.panel {
            Panel::Formula => app.view_state.formula_cursor = self.cursor,
            Panel::Category => app.view_state.cat_panel_cursor = self.cursor,
            Panel::View => app.view_state.view_panel_cursor = self.cursor,
        }
    }
}

// ── Chain control ────────────────────────────────────────────────────────────

/// Signals `App::apply_effects` to skip the remaining effects in the batch.
/// The flag is reset at the start of every `apply_effects` call, so each
/// dispatch starts clean. Use this when a sequence's premise no longer
/// holds (e.g. "advance to next cell" at bottom-right) and later effects
/// (e.g. "re-enter editing there") should be short-circuited.
#[derive(Debug)]
pub struct AbortChain;
impl Effect for AbortChain {
    fn apply(&self, app: &mut App) {
        app.abort_effects = true;
    }
}

// ── Records hygiene ──────────────────────────────────────────────────────────

/// Remove cells whose `CellKey` has no coordinates — these are meaningless
/// records that can only be produced by `AddRecordRow` when no page
/// filters are set. Pushed by `ToggleRecordsMode` when leaving records
/// mode, as the inverse of the `SortData` that runs on entry.
#[derive(Debug)]
pub struct CleanEmptyRecords;
impl Effect for CleanEmptyRecords {
    fn apply(&self, app: &mut App) {
        let empties: Vec<CellKey> = app
            .model_state

            .workbook
            .model
            .data
            .iter_cells()
            .filter_map(|(k, _)| if k.0.is_empty() { Some(k) } else { None })
            .collect();
        for key in empties {
            app.model_state.workbook.model.clear_cell(&key);
        }
    }
}

// ── Convenience constructors ─────────────────────────────────────────────────

pub fn mark_dirty() -> Box<dyn Effect> {
    Box::new(MarkDirty)
}

pub fn set_status(msg: impl Into<String>) -> Box<dyn Effect> {
    Box::new(SetStatus(msg.into()))
}

pub fn change_mode(mode: AppMode) -> Box<dyn Effect> {
    Box::new(ChangeMode(mode))
}

pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {
    Box::new(SetSelected(row, col))
}

// ── Help page navigation ────────────────────────────────────────────────────

#[derive(Debug)]
pub struct HelpPageNext;
impl Effect for HelpPageNext {
    fn apply(&self, app: &mut App) {
        let max = crate::ui::help::HELP_PAGE_COUNT.saturating_sub(1);
        app.view_state.help_page = app.view_state.help_page.saturating_add(1).min(max);
    }
}

#[derive(Debug)]
pub struct HelpPagePrev;
impl Effect for HelpPagePrev {
    fn apply(&self, app: &mut App) {
        app.view_state.help_page = app.view_state.help_page.saturating_sub(1);
    }
}

#[derive(Debug)]
pub struct HelpPageSet(pub usize);
impl Effect for HelpPageSet {
    fn apply(&self, app: &mut App) {
        app.view_state.help_page = self.0;
    }
}

pub fn help_page_next() -> Box<dyn Effect> {
    Box::new(HelpPageNext)
}

pub fn help_page_prev() -> Box<dyn Effect> {
    Box::new(HelpPagePrev)
}

pub fn help_page_set(page: usize) -> Box<dyn Effect> {
    Box::new(HelpPageSet(page))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::cell::{CellKey, CellValue};
    use crate::workbook::Workbook;

    fn test_app() -> App {
        let mut wb = Workbook::new("Test");
        wb.add_category("Type").unwrap();
        wb.add_category("Month").unwrap();
        wb.model.category_mut("Type").unwrap().add_item("Food");
        wb.model.category_mut("Type").unwrap().add_item("Clothing");
        wb.model.category_mut("Month").unwrap().add_item("Jan");
        wb.model.category_mut("Month").unwrap().add_item("Feb");
        App::new(wb, None)
    }

    // ── Model mutation effects ──────────────────────────────────────────

    #[test]
    fn add_category_effect() {
        let mut app = test_app();
        AddCategory("Region".to_string()).apply(&mut app);
        assert!(app.model_state.workbook.model.category("Region").is_some());
    }

    #[test]
    fn add_item_to_existing_category() {
        let mut app = test_app();
        AddItem {
            category: "Type".to_string(),
            item: "Electronics".to_string(),
        }
        .apply(&mut app);
        let items: Vec<&str> = app
            .model_state

            .workbook
            .model
            .category("Type")
            .unwrap()
            .ordered_item_names()
            .into_iter()
            .collect();
        assert!(items.contains(&"Electronics"));
    }

    #[test]
    fn add_item_to_nonexistent_category_sets_status() {
        let mut app = test_app();
        AddItem {
            category: "Nonexistent".to_string(),
            item: "X".to_string(),
        }
        .apply(&mut app);
        assert!(app.view_state.status_msg.contains("Unknown category"));
    }

    #[test]
    fn set_cell_and_clear_cell() {
        let mut app = test_app();
        let key = CellKey::new(vec![
            ("Type".into(), "Food".into()),
            ("Month".into(), "Jan".into()),
        ]);
        SetCell(key.clone(), CellValue::Number(42.0)).apply(&mut app);
        assert_eq!(
            app.model_state.workbook.model.get_cell(&key),
            Some(&CellValue::Number(42.0))
        );

        ClearCell(key.clone()).apply(&mut app);
        assert_eq!(app.model_state.workbook.model.get_cell(&key), None);
    }

    #[test]
    fn add_formula_valid() {
        let mut app = test_app();
        AddFormula {
            raw: "Clothing = Food * 2".to_string(),
            target_category: "Type".to_string(),
        }
        .apply(&mut app);
        assert!(!app.model_state.workbook.model.formulas().is_empty());
    }

    /// Regression: AddFormula must add the target item to the target category
    /// so the grid layout includes cells for it. Without this, a formula like
    /// `Margin = Profit / Revenue` would be registered but invisible in the grid.
    #[test]
    fn add_formula_adds_target_item_to_category() {
        let mut app = test_app();
        // "Margin" does not exist as an item in "Type" before adding the formula
        assert!(
            !app.model_state.workbook
                .model
                .category("Type")
                .unwrap()
                .ordered_item_names()
                .contains(&"Margin")
        );
        AddFormula {
            raw: "Margin = Food * 2".to_string(),
            target_category: "Type".to_string(),
        }
        .apply(&mut app);
        let items: Vec<&str> = app
            .model_state

            .workbook
            .model
            .category("Type")
            .unwrap()
            .ordered_item_names()
            .into_iter()
            .collect();
        assert!(
            items.contains(&"Margin"),
            "formula target 'Margin' should be added as item to 'Type' category, got: {:?}",
            items
        );
    }

    /// Formula targets in _Measure should appear in measure_item_names()
    /// dynamically, without being added to the category's own items.
    #[test]
    fn add_formula_to_measure_shows_in_effective_items() {
        let mut app = test_app();
        AddFormula {
            raw: "Margin = Food * 2".to_string(),
            target_category: "_Measure".to_string(),
        }
        .apply(&mut app);
        // Should appear in effective_item_names (used by layout)
        let effective = app.model_state.workbook.model.effective_item_names("_Measure");
        assert!(
            effective.contains(&"Margin".to_string()),
            "formula target 'Margin' should appear in effective _Measure items, got: {:?}",
            effective
        );
        // Should NOT be in the category's own items
        assert!(
            !app.model_state.workbook
                .model
                .category("_Measure")
                .unwrap()
                .ordered_item_names()
                .contains(&"Margin"),
            "formula target should not be added directly to _Measure category items"
        );
    }

    #[test]
    fn add_formula_invalid_sets_error_status() {
        let mut app = test_app();
        AddFormula {
            raw: "this is not valid".to_string(),
            target_category: "Type".to_string(),
        }
        .apply(&mut app);
        assert!(app.view_state.status_msg.contains("Formula error"));
    }

    #[test]
    fn remove_formula_effect() {
        let mut app = test_app();
        AddFormula {
            raw: "Clothing = Food * 2".to_string(),
            target_category: "Type".to_string(),
        }
        .apply(&mut app);
        assert!(!app.model_state.workbook.model.formulas().is_empty());
        RemoveFormula {
            target: "Clothing".to_string(),
            target_category: "Type".to_string(),
        }
        .apply(&mut app);
        assert!(app.model_state.workbook.model.formulas().is_empty());
    }

    // ── View effects ────────────────────────────────────────────────────

    #[test]
    fn switch_view_pushes_to_back_stack() {
        let mut app = test_app();
        app.model_state.workbook.create_view("View 2");
        assert!(app.view_state.view_back_stack.is_empty());

        SwitchView("View 2".to_string()).apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view.as_str(), "View 2");
        assert_eq!(app.view_state.view_back_stack.len(), 1);
        assert_eq!(app.view_state.view_back_stack[0].view_name, "Default");
        // Forward stack should be cleared
        assert!(app.view_state.view_forward_stack.is_empty());
    }

    #[test]
    fn switch_view_to_same_does_not_push_stack() {
        let mut app = test_app();
        SwitchView("Default".to_string()).apply(&mut app);
        assert!(app.view_state.view_back_stack.is_empty());
    }

    #[test]
    fn view_back_and_forward() {
        let mut app = test_app();
        app.model_state.workbook.create_view("View 2");
        SwitchView("View 2".to_string()).apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view.as_str(), "View 2");

        // Go back
        ViewBack.apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view.as_str(), "Default");
        assert_eq!(app.view_state.view_forward_stack.len(), 1);
        assert_eq!(app.view_state.view_forward_stack[0].view_name, "View 2");
        assert!(app.view_state.view_back_stack.is_empty());

        // Go forward
        ViewForward.apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view.as_str(), "View 2");
        assert_eq!(app.view_state.view_back_stack.len(), 1);
        assert_eq!(app.view_state.view_back_stack[0].view_name, "Default");
        assert!(app.view_state.view_forward_stack.is_empty());
    }

    #[test]
    fn view_back_with_empty_stack_is_noop() {
        let mut app = test_app();
        let before = app.model_state.workbook.active_view.clone();
        ViewBack.apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view, before);
    }

    #[test]
    fn create_and_delete_view() {
        let mut app = test_app();
        CreateView("View 2".to_string()).apply(&mut app);
        assert!(app.model_state.workbook.views.contains_key("View 2"));

        DeleteView("View 2".to_string()).apply(&mut app);
        assert!(!app.model_state.workbook.views.contains_key("View 2"));
    }

    #[test]
    fn set_axis_effect() {
        let mut app = test_app();
        SetAxis {
            category: "Type".to_string(),
            axis: Axis::Page,
        }
        .apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view().axis_of("Type"), Axis::Page);
    }

    #[test]
    fn transpose_axes_effect() {
        let mut app = test_app();
        let row_before: Vec<String> = app
            .model_state

            .workbook
            .active_view()
            .categories_on(Axis::Row)
            .into_iter()
            .map(String::from)
            .collect();
        let col_before: Vec<String> = app
            .model_state

            .workbook
            .active_view()
            .categories_on(Axis::Column)
            .into_iter()
            .map(String::from)
            .collect();
        TransposeAxes.apply(&mut app);
        let row_after: Vec<String> = app
            .model_state

            .workbook
            .active_view()
            .categories_on(Axis::Row)
            .into_iter()
            .map(String::from)
            .collect();
        let col_after: Vec<String> = app
            .model_state

            .workbook
            .active_view()
            .categories_on(Axis::Column)
            .into_iter()
            .map(String::from)
            .collect();
        assert_eq!(row_before, col_after);
        assert_eq!(col_before, row_after);
    }

    // ── Navigation effects ──────────────────────────────────────────────

    #[test]
    fn set_selected_effect() {
        let mut app = test_app();
        SetSelected(3, 5).apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view().selected, (3, 5));
    }

    #[test]
    fn set_row_and_col_offset() {
        let mut app = test_app();
        SetRowOffset(10).apply(&mut app);
        SetColOffset(5).apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view().row_offset, 10);
        assert_eq!(app.model_state.workbook.active_view().col_offset, 5);
    }

    // ── App state effects ───────────────────────────────────────────────

    #[test]
    fn change_mode_effect() {
        let mut app = test_app();
        assert!(ChangeMode(AppMode::Help).changes_mode());
        ChangeMode(AppMode::Help).apply(&mut app);
        assert_eq!(app.view_state.mode, AppMode::Help);
    }

    /// `AbortChain` must cause subsequent effects in the same
    /// `apply_effects` batch to be skipped, and the flag must reset so the
    /// next dispatch starts clean.
    #[test]
    fn abort_chain_short_circuits_apply_effects() {
        let mut app = test_app();
        app.view_state.status_msg = String::new();
        let effects: Vec<Box<dyn Effect>> = vec![
            Box::new(SetStatus("before".into())),
            Box::new(AbortChain),
            Box::new(SetStatus("after".into())),
        ];
        app.apply_effects(effects);
        assert_eq!(
            app.view_state.status_msg, "before",
            "effects after AbortChain must not apply"
        );
        assert!(
            !app.abort_effects,
            "abort flag should reset at end of apply_effects"
        );
        // A subsequent batch must not be affected by the prior abort.
        app.apply_effects(vec![Box::new(SetStatus("next-batch".into()))]);
        assert_eq!(app.view_state.status_msg, "next-batch");
    }

    /// `CleanEmptyRecords` removes cells whose `CellKey` has no
    /// coordinates, and leaves all other cells untouched.
    #[test]
    fn clean_empty_records_removes_only_empty_key_cells() {
        let mut app = test_app();
        // An empty-key cell (the bug: produced by AddRecordRow when no page
        // filters are set).
        app.model_state.workbook
            .model
            .set_cell(CellKey::new(vec![]), CellValue::Number(0.0));
        // Plus a well-formed cell that must survive.
        let valid = CellKey::new(vec![
            ("Type".to_string(), "Food".to_string()),
            ("Month".to_string(), "Jan".to_string()),
        ]);
        app.model_state.workbook
            .model
            .set_cell(valid.clone(), CellValue::Number(42.0));
        assert_eq!(app.model_state.workbook.model.data.iter_cells().count(), 2);

        CleanEmptyRecords.apply(&mut app);

        assert!(
            !app.model_state.workbook
                .model
                .data
                .iter_cells()
                .any(|(k, _)| k.0.is_empty()),
            "empty-key cell should be gone"
        );
        assert_eq!(
            app.model_state.workbook.model.get_cell(&valid),
            Some(&CellValue::Number(42.0)),
            "valid cell must survive"
        );
    }

    /// `EnterEditAtCursor` must use its `target_mode` field, *not* whatever
    /// `app.view_state.mode` happens to be when applied. Previous implementation
    /// branched on `app.view_state.mode.is_records()` — the parameterized version
    /// trusts the caller (keymap or composing command).
    #[test]
    fn enter_edit_at_cursor_uses_target_mode_not_app_mode() {
        let mut app = test_app();
        // App starts in Normal mode — but caller has decided we want
        // RecordsEditing (e.g. records-mode `o` sequence).
        assert_eq!(app.view_state.mode, AppMode::Normal);
        EnterEditAtCursor {
            target_mode: AppMode::records_editing(),
        }
        .apply(&mut app);
        assert!(
            matches!(app.view_state.mode, AppMode::RecordsEditing { .. }),
            "Expected RecordsEditing, got {:?}",
            app.view_state.mode
        );

        // Same effect with editing target — should land in plain Editing
        // even if app.view_state.mode was something else.
        let mut app2 = test_app();
        app2.view_state.mode = AppMode::RecordsNormal;
        EnterEditAtCursor {
            target_mode: AppMode::editing(),
        }
        .apply(&mut app2);
        assert!(
            matches!(app2.view_state.mode, AppMode::Editing { .. }),
            "Expected Editing, got {:?}",
            app2.view_state.mode
        );
    }

    /// SetBuffer with empty value clears the buffer (used by clear-buffer command
    /// in keymap sequences after commit).
    #[test]
    fn set_buffer_empty_clears() {
        let mut app = test_app();
        app.view_state.buffers
            .insert("formula".to_string(), "old text".to_string());
        SetBuffer {
            name: "formula".to_string(),
            value: String::new(),
        }
        .apply(&mut app);
        assert_eq!(app.view_state.buffers.get("formula").map(|s| s.as_str()), Some(""),);
    }

    #[test]
    fn set_status_effect() {
        let mut app = test_app();
        SetStatus("hello".to_string()).apply(&mut app);
        assert_eq!(app.view_state.status_msg, "hello");
    }

    #[test]
    fn mark_dirty_effect() {
        let mut app = test_app();
        assert!(!app.model_state.dirty);
        MarkDirty.apply(&mut app);
        assert!(app.model_state.dirty);
    }

    #[test]
    fn set_yanked_effect() {
        let mut app = test_app();
        SetYanked(Some(CellValue::Number(42.0))).apply(&mut app);
        assert_eq!(app.view_state.yanked, Some(CellValue::Number(42.0)));
    }

    #[test]
    fn set_search_query_and_mode() {
        let mut app = test_app();
        SetSearchQuery("foo".to_string()).apply(&mut app);
        assert_eq!(app.view_state.search_query, "foo");
        SetSearchMode(true).apply(&mut app);
        assert!(app.view_state.search_mode);
        SetSearchMode(false).apply(&mut app);
        assert!(!app.view_state.search_mode);
    }

    // ── SetBuffer special behavior ──────────────────────────────────────

    #[test]
    fn set_buffer_normal_key() {
        let mut app = test_app();
        SetBuffer {
            name: "edit".to_string(),
            value: "hello".to_string(),
        }
        .apply(&mut app);
        assert_eq!(app.view_state.buffers.get("edit").unwrap(), "hello");
    }

    #[test]
    fn set_buffer_search_writes_to_search_query() {
        let mut app = test_app();
        SetBuffer {
            name: "search".to_string(),
            value: "query".to_string(),
        }
        .apply(&mut app);
        // "search" buffer is special — writes to app.view_state.search_query
        assert_eq!(app.view_state.search_query, "query");
    }

    // ── Panel effects ───────────────────────────────────────────────────

    #[test]
    fn set_panel_open_and_cursor() {
        let mut app = test_app();
        SetPanelOpen {
            panel: Panel::Formula,
            open: true,
        }
        .apply(&mut app);
        assert!(app.view_state.formula_panel_open);

        SetPanelCursor {
            panel: Panel::Formula,
            cursor: 3,
        }
        .apply(&mut app);
        assert_eq!(app.view_state.formula_cursor, 3);

        SetPanelOpen {
            panel: Panel::Category,
            open: true,
        }
        .apply(&mut app);
        assert!(app.view_state.category_panel_open);

        SetPanelOpen {
            panel: Panel::View,
            open: true,
        }
        .apply(&mut app);
        assert!(app.view_state.view_panel_open);
    }

    #[test]
    fn set_tile_cat_idx_effect() {
        let mut app = test_app();
        SetTileCatIdx(2).apply(&mut app);
        assert_eq!(app.view_state.tile_cat_idx, 2);
    }

    // ── Help page effects ───────────────────────────────────────────────

    #[test]
    fn help_page_navigation() {
        let mut app = test_app();
        assert_eq!(app.view_state.help_page, 0);
        HelpPageNext.apply(&mut app);
        assert_eq!(app.view_state.help_page, 1);
        HelpPageNext.apply(&mut app);
        assert_eq!(app.view_state.help_page, 2);
        HelpPagePrev.apply(&mut app);
        assert_eq!(app.view_state.help_page, 1);
        HelpPageSet(0).apply(&mut app);
        assert_eq!(app.view_state.help_page, 0);
    }

    #[test]
    fn help_page_prev_clamps_at_zero() {
        let mut app = test_app();
        HelpPagePrev.apply(&mut app);
        assert_eq!(app.view_state.help_page, 0);
    }

    // ── Drill effects ───────────────────────────────────────────────────

    #[test]
    fn start_drill_and_apply_clear_drill_with_no_edits() {
        let mut app = test_app();
        let key = CellKey::new(vec![
            ("Type".into(), "Food".into()),
            ("Month".into(), "Jan".into()),
        ]);
        let records = vec![(key, CellValue::Number(42.0))];
        StartDrill(records).apply(&mut app);
        assert!(app.view_state.drill_state.is_some());

        // Apply with no pending edits — should just clear state
        ApplyAndClearDrill.apply(&mut app);
        assert!(app.view_state.drill_state.is_none());
        assert!(!app.model_state.dirty); // no edits → not dirty
    }

    #[test]
    fn apply_and_clear_drill_with_value_edit() {
        let mut app = test_app();
        let key = CellKey::new(vec![
            ("Type".into(), "Food".into()),
            ("Month".into(), "Jan".into()),
        ]);
        // Set original cell
        app.model_state.workbook
            .model
            .set_cell(key.clone(), CellValue::Number(42.0));

        let records = vec![(key.clone(), CellValue::Number(42.0))];
        StartDrill(records).apply(&mut app);

        // Stage a pending edit: change value at record 0
        SetDrillPendingEdit {
            record_idx: 0,
            col_name: "Value".to_string(),
            new_value: "99".to_string(),
        }
        .apply(&mut app);

        ApplyAndClearDrill.apply(&mut app);
        assert!(app.view_state.drill_state.is_none());
        assert!(app.model_state.dirty);
        assert_eq!(
            app.model_state.workbook.model.get_cell(&key),
            Some(&CellValue::Number(99.0))
        );
    }

    #[test]
    fn apply_and_clear_drill_with_coord_rename() {
        let mut app = test_app();
        let key = CellKey::new(vec![
            ("Type".into(), "Food".into()),
            ("Month".into(), "Jan".into()),
        ]);
        app.model_state.workbook
            .model
            .set_cell(key.clone(), CellValue::Number(42.0));

        let records = vec![(key.clone(), CellValue::Number(42.0))];
        StartDrill(records).apply(&mut app);

        // Rename "Type" coord from "Food" to "Drink"
        SetDrillPendingEdit {
            record_idx: 0,
            col_name: "Type".to_string(),
            new_value: "Drink".to_string(),
        }
        .apply(&mut app);

        ApplyAndClearDrill.apply(&mut app);
        assert!(app.model_state.dirty);
        // Old cell should be gone
        assert_eq!(app.model_state.workbook.model.get_cell(&key), None);
        // New cell should exist
        let new_key = CellKey::new(vec![
            ("Type".into(), "Drink".into()),
            ("Month".into(), "Jan".into()),
        ]);
        assert_eq!(
            app.model_state.workbook.model.get_cell(&new_key),
            Some(&CellValue::Number(42.0))
        );
        // "Drink" should have been added as an item
        let items: Vec<&str> = app
            .model_state

            .workbook
            .model
            .category("Type")
            .unwrap()
            .ordered_item_names()
            .into_iter()
            .collect();
        assert!(items.contains(&"Drink"));
    }

    #[test]
    fn apply_and_clear_drill_empty_value_clears_cell() {
        let mut app = test_app();
        let key = CellKey::new(vec![
            ("Type".into(), "Food".into()),
            ("Month".into(), "Jan".into()),
        ]);
        app.model_state.workbook
            .model
            .set_cell(key.clone(), CellValue::Number(42.0));

        let records = vec![(key.clone(), CellValue::Number(42.0))];
        StartDrill(records).apply(&mut app);

        // Edit value to empty string → should clear cell
        SetDrillPendingEdit {
            record_idx: 0,
            col_name: "Value".to_string(),
            new_value: "".to_string(),
        }
        .apply(&mut app);

        ApplyAndClearDrill.apply(&mut app);
        assert_eq!(app.model_state.workbook.model.get_cell(&key), None);
    }

    // ── Toggle effects ──────────────────────────────────────────────────

    #[test]
    fn toggle_prune_empty_effect() {
        let mut app = test_app();
        let before = app.model_state.workbook.active_view().prune_empty;
        TogglePruneEmpty.apply(&mut app);
        assert_ne!(app.model_state.workbook.active_view().prune_empty, before);
        TogglePruneEmpty.apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view().prune_empty, before);
    }

    #[test]
    fn toggle_cat_expand_effect() {
        let mut app = test_app();
        assert!(!app.view_state.expanded_cats.contains("Type"));
        ToggleCatExpand("Type".to_string()).apply(&mut app);
        assert!(app.view_state.expanded_cats.contains("Type"));
        ToggleCatExpand("Type".to_string()).apply(&mut app);
        assert!(!app.view_state.expanded_cats.contains("Type"));
    }

    #[test]
    fn remove_item_and_category() {
        let mut app = test_app();
        RemoveItem {
            category: "Type".to_string(),
            item: "Food".to_string(),
        }
        .apply(&mut app);
        let items: Vec<&str> = app
            .model_state

            .workbook
            .model
            .category("Type")
            .unwrap()
            .ordered_item_names()
            .into_iter()
            .collect();
        assert!(!items.contains(&"Food"));

        RemoveCategory("Month".to_string()).apply(&mut app);
        assert!(app.model_state.workbook.model.category("Month").is_none());
    }

    // ── Number format ───────────────────────────────────────────────────

    #[test]
    fn set_number_format_effect() {
        let mut app = test_app();
        SetNumberFormat(",.2f".to_string()).apply(&mut app);
        assert_eq!(app.model_state.workbook.active_view().number_format, ",.2f");
    }

    // ── Page selection ──────────────────────────────────────────────────

    #[test]
    fn set_page_selection_effect() {
        let mut app = test_app();
        SetPageSelection {
            category: "Type".to_string(),
            item: "Food".to_string(),
        }
        .apply(&mut app);
        assert_eq!(
            app.model_state.workbook.active_view().page_selection("Type"),
            Some("Food")
        );
    }

    // ── Hide/show items ─────────────────────────────────────────────────

    #[test]
    fn hide_and_show_item_effects() {
        let mut app = test_app();
        HideItem {
            category: "Type".to_string(),
            item: "Food".to_string(),
        }
        .apply(&mut app);
        assert!(app.model_state.workbook.active_view().is_hidden("Type", "Food"));

        ShowItem {
            category: "Type".to_string(),
            item: "Food".to_string(),
        }
        .apply(&mut app);
        assert!(!app.model_state.workbook.active_view().is_hidden("Type", "Food"));
    }

    // ── Toggle group ────────────────────────────────────────────────────

    #[test]
    fn toggle_group_effect() {
        let mut app = test_app();
        ToggleGroup {
            category: "Type".to_string(),
            group: "MyGroup".to_string(),
        }
        .apply(&mut app);
        assert!(
            app.model_state.workbook
                .active_view()
                .is_group_collapsed("Type", "MyGroup")
        );
        ToggleGroup {
            category: "Type".to_string(),
            group: "MyGroup".to_string(),
        }
        .apply(&mut app);
        assert!(
            !app.model_state.workbook
                .active_view()
                .is_group_collapsed("Type", "MyGroup")
        );
    }

    // ── Cycle axis ──────────────────────────────────────────────────────

    #[test]
    fn cycle_axis_effect() {
        let mut app = test_app();
        let before = app.model_state.workbook.active_view().axis_of("Type");
        CycleAxis("Type".to_string()).apply(&mut app);
        let after = app.model_state.workbook.active_view().axis_of("Type");
        assert_ne!(before, after);
    }

    // ── Save without file path ──────────────────────────────────────────

    #[test]
    fn save_without_file_path_shows_status() {
        let mut app = test_app();
        Save.apply(&mut app);
        assert!(app.view_state.status_msg.contains("No file path"));
    }

    // ── Panel mode helper ───────────────────────────────────────────────

    #[test]
    fn panel_mode_mapping() {
        assert_eq!(Panel::Formula.mode(), AppMode::FormulaPanel);
        assert_eq!(Panel::Category.mode(), AppMode::CategoryPanel);
        assert_eq!(Panel::View.mode(), AppMode::ViewPanel);
    }
}
