use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};

use std::rc::Rc;

use ratatui::style::Color;

use crate::command::cmd::CmdContext;
use crate::command::keymap::{Keymap, KeymapSet};
use crate::import::wizard::ImportWizard;
use crate::model::cell::CellValue;
use crate::persistence;
use crate::ui::grid::{
    compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
};
use crate::view::GridLayout;
use crate::workbook::Workbook;

/// A saved view+mode pair for the navigation stack.
#[derive(Debug, Clone, PartialEq)]
pub struct ViewFrame {
    pub view_name: String,
    pub mode: AppMode,
}

/// Drill-down state: frozen record snapshot + pending edits that have not
/// yet been applied to the model.
#[derive(Debug, Clone, Default)]
pub struct DrillState {
    /// Frozen snapshot of records shown in the drill view (Rc for cheap cloning).
    pub records: Rc<Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)>>,
    /// Pending edits keyed by (record_idx, column_name) → new string value.
    /// column_name is either "Value" or a category name.
    pub pending_edits: std::collections::HashMap<(usize, String), String>,
}

/// Display configuration for the bottom-bar minibuffer.
/// Carried structurally by text-entry `AppMode` variants.
#[derive(Debug, Clone, PartialEq)]
pub struct MinibufferConfig {
    pub buffer_key: &'static str,
    pub prompt: String,
    pub color: Color,
}

#[derive(Debug, Clone, PartialEq)]
pub enum AppMode {
    Normal,
    Editing {
        minibuf: MinibufferConfig,
    },
    FormulaEdit {
        minibuf: MinibufferConfig,
    },
    FormulaPanel,
    CategoryPanel,
    /// Quick-add a new category: Enter adds and stays open, Esc closes.
    CategoryAdd {
        minibuf: MinibufferConfig,
    },
    /// Quick-add items to `category`: Enter adds and stays open, Esc closes.
    ItemAdd {
        category: String,
        minibuf: MinibufferConfig,
    },
    ViewPanel,
    TileSelect,
    ImportWizard,
    ExportPrompt {
        minibuf: MinibufferConfig,
    },
    /// Vim-style `:` command line
    CommandMode {
        minibuf: MinibufferConfig,
    },
    Help,
    Quit,
    /// Records-mode normal: inherits from Normal with records-specific bindings.
    RecordsNormal,
    /// Records-mode editing: inherits from Editing with boundary-aware Tab/Enter.
    RecordsEditing {
        minibuf: MinibufferConfig,
    },
}

impl AppMode {
    /// Extract the minibuffer config from text-entry modes, if present.
    pub fn minibuffer(&self) -> Option<&MinibufferConfig> {
        match self {
            Self::Editing { minibuf, .. }
            | Self::RecordsEditing { minibuf, .. }
            | Self::FormulaEdit { minibuf, .. }
            | Self::CommandMode { minibuf, .. }
            | Self::CategoryAdd { minibuf, .. }
            | Self::ItemAdd { minibuf, .. }
            | Self::ExportPrompt { minibuf, .. } => Some(minibuf),
            _ => None,
        }
    }

    /// True for any cell-editing mode (normal or records).
    pub fn is_editing(&self) -> bool {
        matches!(self, Self::Editing { .. } | Self::RecordsEditing { .. })
    }

    pub fn editing() -> Self {
        Self::Editing {
            minibuf: MinibufferConfig {
                buffer_key: "edit",
                prompt: "edit: ".into(),
                color: Color::Green,
            },
        }
    }

    pub fn records_editing() -> Self {
        Self::RecordsEditing {
            minibuf: MinibufferConfig {
                buffer_key: "edit",
                prompt: "edit: ".into(),
                color: Color::Green,
            },
        }
    }

    /// True when this mode is a records-mode variant.
    pub fn is_records(&self) -> bool {
        matches!(self, Self::RecordsNormal | Self::RecordsEditing { .. })
    }

    pub fn formula_edit() -> Self {
        Self::FormulaEdit {
            minibuf: MinibufferConfig {
                buffer_key: "formula",
                prompt: "formula: ".into(),
                color: Color::Cyan,
            },
        }
    }

    pub fn command_mode() -> Self {
        Self::CommandMode {
            minibuf: MinibufferConfig {
                buffer_key: "command",
                prompt: ":".into(),
                color: Color::Yellow,
            },
        }
    }

    pub fn category_add() -> Self {
        Self::CategoryAdd {
            minibuf: MinibufferConfig {
                buffer_key: "category",
                prompt: "new category: ".into(),
                color: Color::Yellow,
            },
        }
    }

    pub fn item_add(category: String) -> Self {
        let prompt = format!("add item to {category}: ");
        Self::ItemAdd {
            category,
            minibuf: MinibufferConfig {
                buffer_key: "item",
                prompt,
                color: Color::Green,
            },
        }
    }

    pub fn export_prompt() -> Self {
        Self::ExportPrompt {
            minibuf: MinibufferConfig {
                buffer_key: "export",
                prompt: "export path: ".into(),
                color: Color::Yellow,
            },
        }
    }
}

/// Document state slice: the workbook and its IO bookkeeping. Distinct from
/// `Workbook` itself (which is pure document semantics in `improvise-core`)
/// because `file_path` and `dirty` are persistence-layer concerns.
#[derive(Debug)]
pub struct ModelState {
    pub workbook: Workbook,
    pub file_path: Option<PathBuf>,
    pub dirty: bool,
}

impl Default for ModelState {
    fn default() -> Self {
        Self {
            workbook: Workbook::new("Untitled"),
            file_path: None,
            dirty: false,
        }
    }
}

/// UI session-state slice: mode, cursors, panels, buffers, navigation stacks,
/// and other per-session state that does not persist to disk.
#[derive(Debug)]
pub struct ViewState {
    pub mode: AppMode,
    pub status_msg: String,
    pub wizard: Option<ImportWizard>,
    pub search_query: String,
    pub search_mode: bool,
    pub formula_panel_open: bool,
    pub category_panel_open: bool,
    pub view_panel_open: bool,
    pub cat_panel_cursor: usize,
    pub view_panel_cursor: usize,
    pub formula_cursor: usize,
    /// Yanked cell value for `p` paste
    pub yanked: Option<CellValue>,
    /// Tile select cursor (which category index is highlighted)
    pub tile_cat_idx: usize,
    /// View navigation history: views visited before the current one.
    /// Pushed on SwitchView, popped by `<` (back).
    pub view_back_stack: Vec<ViewFrame>,
    /// Views that were "back-ed" from, available for forward navigation (`>`).
    pub view_forward_stack: Vec<ViewFrame>,
    /// Frozen records list for the drill view. When present, this is the
    /// snapshot that records-mode layouts iterate — records don't disappear
    /// when filters would change. Pending edits are stored alongside and
    /// applied to the model on commit/navigate-away.
    pub drill_state: Option<DrillState>,
    /// Current page index in the Help screen (0-based).
    pub help_page: usize,
    /// Categories expanded in the category panel tree view.
    pub expanded_cats: std::collections::HashSet<String>,
    /// Named text buffers for text-entry modes
    pub buffers: HashMap<String, String>,
    /// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
    pub transient_keymap: Option<Arc<Keymap>>,
}

impl Default for ViewState {
    fn default() -> Self {
        Self {
            mode: AppMode::Normal,
            status_msg: String::new(),
            wizard: None,
            search_query: String::new(),
            search_mode: false,
            formula_panel_open: false,
            category_panel_open: false,
            view_panel_open: false,
            cat_panel_cursor: 0,
            view_panel_cursor: 0,
            formula_cursor: 0,
            yanked: None,
            tile_cat_idx: 0,
            view_back_stack: Vec::new(),
            view_forward_stack: Vec::new(),
            drill_state: None,
            help_page: 0,
            expanded_cats: std::collections::HashSet::new(),
            buffers: HashMap::new(),
            transient_keymap: None,
        }
    }
}

/// Top-level TUI application state. Composed of two named slices and a
/// residue of fields that don't belong to either:
///
/// - `model_state` — document state (Workbook + IO bookkeeping). Persists.
/// - `view_state`  — UI session state (mode, cursors, panels, buffers, …).
///   Does not persist.
/// - The remaining direct fields are runtime / derived / config — see
///   per-field tags below. Each of them is documented as belonging to one
///   of these residue categories so the slice boundary stays explicit:
///
///   - **derived cache**: a pure function of the slices, recomputed on
///     demand. Owned by `App` because rebuilding belongs to the host.
///   - **runtime metadata**: data the runtime feeds in (terminal dims,
///     wall-clock instants). Not part of model or view state.
///   - **transient**: a flag whose lifetime is bounded to one method call
///     on `App`. Reset on entry, never observed across calls.
///   - **config**: data loaded once at startup and read but not written
///     during a session.
pub struct App {
    pub model_state: ModelState,
    pub view_state: ViewState,
    /// **Runtime metadata**: wall-clock instant of the last autosave. Used
    /// by `autosave_if_needed` to debounce writes. Not part of either slice
    /// because it's about IO timing, not document or session state.
    pub last_autosave: Instant,
    /// **Runtime metadata**: terminal dimensions, updated on resize events
    /// and at startup. Fed in by the host loop in `draw.rs`.
    pub term_width: u16,
    pub term_height: u16,
    /// **Derived cache**: pure function of `model_state.workbook`,
    /// `view_state.drill_state`, and the active view. Rebuilt via
    /// `rebuild_layout()` after any state change. Owned by `App` because
    /// it's the host's job to coordinate the rebuild, not the slices'.
    pub layout: GridLayout,
    /// **Transient**: when an effect sets this to `true` during
    /// `apply_effects`, the remaining effects in the batch are skipped.
    /// Reset to `false` at the start of every `apply_effects` call. Use
    /// via the `AbortChain` effect — this is the mechanism by which e.g.
    /// "advance at bottom-right" short-circuits the trailing
    /// `EnterEditAtCursor` in a `CommitAndAdvance` chain.
    pub abort_effects: bool,
    /// **Config**: keymap configuration assembled at startup by
    /// `KeymapSet::default_keymaps()`. Read every keypress but never
    /// mutated during a session.
    keymap_set: KeymapSet,
}

impl App {
    pub fn new(mut workbook: Workbook, file_path: Option<PathBuf>) -> Self {
        // Recompute formula cache before building the initial layout so
        // formula-derived values are available on the first frame. The
        // cache is keyed by the active view's None-axis categories, so
        // the caller must gather them explicitly.
        let none_cats = workbook.active_view().none_cats();
        workbook.model.recompute_formulas(&none_cats);
        let layout = {
            let view = workbook.active_view();
            GridLayout::with_frozen_records(&workbook.model, view, None)
        };
        Self {
            model_state: ModelState {
                workbook,
                file_path,
                dirty: false,
            },
            view_state: ViewState::default(),
            last_autosave: Instant::now(),
            term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80),
            term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24),
            layout,
            abort_effects: false,
            keymap_set: KeymapSet::default_keymaps(),
        }
    }

    /// Rebuild the grid layout from current workbook, active view, and drill
    /// state. Note: `with_frozen_records` already handles pruning internally.
    pub fn rebuild_layout(&mut self) {
        let none_cats = self.model_state.workbook.active_view().none_cats();
        self.model_state.workbook.model.recompute_formulas(&none_cats);
        let view = self.model_state.workbook.active_view();
        let frozen = self.view_state.drill_state.as_ref().map(|s| Rc::clone(&s.records));
        self.layout = GridLayout::with_frozen_records(&self.model_state.workbook.model, view, frozen);
    }

    pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
        let view = self.model_state.workbook.active_view();
        let layout = &self.layout;
        let (sel_row, sel_col) = view.selected;
        CmdContext {
            model: &self.model_state.workbook.model,
            workbook: &self.model_state.workbook,
            view,
            layout,
            registry: self.keymap_set.registry(),
            mode: &self.view_state.mode,
            selected: view.selected,
            row_offset: view.row_offset,
            col_offset: view.col_offset,
            search_query: &self.view_state.search_query,
            yanked: &self.view_state.yanked,
            dirty: self.model_state.dirty,
            search_mode: self.view_state.search_mode,
            formula_panel_open: self.view_state.formula_panel_open,
            category_panel_open: self.view_state.category_panel_open,
            view_panel_open: self.view_state.view_panel_open,
            buffers: &self.view_state.buffers,
            formula_cursor: self.view_state.formula_cursor,
            cat_panel_cursor: self.view_state.cat_panel_cursor,
            view_panel_cursor: self.view_state.view_panel_cursor,
            tile_cat_idx: self.view_state.tile_cat_idx,
            view_back_stack: &self.view_state.view_back_stack,
            view_forward_stack: &self.view_state.view_forward_stack,
            has_drill_state: self.view_state.drill_state.is_some(),
            display_value: {
                let key = layout.cell_key(sel_row, sel_col);
                if let Some(k) = &key {
                    if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
                        self.view_state.drill_state
                            .as_ref()
                            .and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
                            .or_else(|| layout.resolve_display(k))
                            .unwrap_or_default()
                    } else {
                        self.model_state.workbook
                            .model
                            .get_cell(k)
                            .map(|v| v.to_string())
                            .unwrap_or_default()
                    }
                } else {
                    String::new()
                }
            },
            visible_rows: (self.term_height as usize).saturating_sub(8),
            visible_cols: {
                let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
                let col_widths =
                    compute_col_widths(&self.model_state.workbook.model, layout, fmt_comma, fmt_decimals);
                let row_header_width = compute_row_header_width(layout);
                compute_visible_cols(
                    &col_widths,
                    row_header_width,
                    self.term_width,
                    view.col_offset,
                )
            },
            expanded_cats: &self.view_state.expanded_cats,
            key_code: key,
        }
    }

    pub fn apply_effects(&mut self, effects: Vec<Box<dyn super::effect::Effect>>) {
        self.abort_effects = false;
        for effect in effects {
            effect.apply(self);
            if self.abort_effects {
                // AbortChain (or another abort-setting effect) requested
                // that the rest of this batch be skipped. Reset the flag so
                // the next dispatch starts clean.
                self.abort_effects = false;
                break;
            }
        }
        self.rebuild_layout();
    }

    /// True when the model has no user-defined categories (show welcome/help).
    /// Virtual categories (_Index, _Dim, _Measure) are always present and don't count.
    pub fn is_empty_model(&self) -> bool {
        use crate::model::category::CategoryKind;
        self.model_state.workbook.model.categories.values().all(|c| {
            matches!(
                c.kind,
                CategoryKind::VirtualIndex
                    | CategoryKind::VirtualDim
                    | CategoryKind::VirtualMeasure
            )
        })
    }

    pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
        self.rebuild_layout();

        // Transient keymap (prefix key sequence) takes priority
        if let Some(transient) = self.view_state.transient_keymap.take() {
            let effects = {
                let ctx = self.cmd_context(key.code, key.modifiers);
                self.keymap_set
                    .dispatch_transient(&transient, &ctx, key.code, key.modifiers)
            };
            if let Some(effects) = effects {
                self.apply_effects(effects);
            }
            // Whether matched or not, transient is consumed
            return Ok(());
        }

        // Try mode keymap — if a binding matches, apply effects and return
        let effects = {
            let ctx = self.cmd_context(key.code, key.modifiers);
            self.keymap_set.dispatch(&ctx, key.code, key.modifiers)
        };
        if let Some(effects) = effects {
            self.apply_effects(effects);
        }
        Ok(())
    }

    pub fn autosave_if_needed(&mut self) {
        if self.model_state.dirty
            && self.last_autosave.elapsed() > Duration::from_secs(30)
            && let Some(path) = &self.model_state.file_path.clone()
        {
            let ap = persistence::autosave_path(path);
            let _ = persistence::save(&self.model_state.workbook, &ap);
            self.last_autosave = Instant::now();
        }
    }

    pub fn start_import_wizard(&mut self, json: serde_json::Value) {
        self.view_state.wizard = Some(ImportWizard::new(json));
        self.view_state.mode = AppMode::ImportWizard;
    }

    /// Hint text for the status bar (context-sensitive)
    pub fn hint_text(&self) -> &'static str {
        match &self.view_state.mode {
            AppMode::Normal => {
                "hjkl:nav  i:edit  R:records  P:prune  F/C/V:panels  T:tiles  [:]:page  >:drill  ::cmd"
            }
            AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => {
                "Enter:commit  Tab:commit+right  Esc:cancel"
            }
            AppMode::RecordsNormal => {
                "hjkl:nav  i:edit  o:add-row  R:pivot  P:prune  <:back  ::cmd"
            }
            AppMode::FormulaPanel => "n:new  d:delete  jk:nav  Esc:back",
            AppMode::FormulaEdit { .. } => "Enter:save  Esc:cancel  — type: Name = expression",
            AppMode::CategoryPanel => {
                "jk:nav  Space:cycle-axis  n:new-cat  a:add-items  d:delete  Esc:back"
            }
            AppMode::CategoryAdd { .. } => {
                "Enter:add & continue  Tab:same  Esc:done — type a category name"
            }
            AppMode::ItemAdd { .. } => {
                "Enter:add & continue  Tab:same  Esc:done — type an item name"
            }
            AppMode::ViewPanel => "jk:nav  Enter:switch  n:new  d:delete  Esc:back",
            AppMode::TileSelect => "hl:select  Enter:cycle  r/c/p/n:set-axis  Esc:back",
            AppMode::CommandMode { .. } => {
                ":q quit  :w save  :import  :add-cat  :formula  :show-item  :help"
            }
            AppMode::ImportWizard => "Space:toggle  c:cycle  Enter:next  Esc:cancel",
            AppMode::Help => "h/l:pages  q/Esc:close",
            _ => "",
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// improvise-3vr: ModelState and ViewState are the named slices of App
    /// state introduced by the vb4 refactor. Step 1 only requires that the
    /// types exist and are constructible; subsequent steps move fields in.
    #[test]
    fn model_state_and_view_state_are_constructible() {
        let _: ModelState = ModelState::default();
        let _: ViewState = ViewState::default();
    }

    /// improvise-x2c: ModelState owns the document slice — workbook,
    /// file_path, and dirty. App accesses them through model_state.
    #[test]
    fn app_model_state_owns_workbook_file_path_and_dirty() {
        let app = App::new(Workbook::new("T"), Some(PathBuf::from("/tmp/x")));
        let _: &Workbook = &app.model_state.workbook;
        let _: &Option<PathBuf> = &app.model_state.file_path;
        let _: bool = app.model_state.dirty;
    }

    /// improvise-ew0: ViewState owns the UI session slice — mode, status,
    /// search, panels, navigation, drill, yanked, buffers, etc. App
    /// accesses them through view_state.
    #[test]
    fn app_view_state_owns_ui_session_fields() {
        let app = App::new(Workbook::new("T"), None);
        let _: &AppMode = &app.view_state.mode;
        let _: &str = &app.view_state.status_msg;
        let _: &Option<ImportWizard> = &app.view_state.wizard;
        let _: &str = &app.view_state.search_query;
        let _: bool = app.view_state.search_mode;
        let _: bool = app.view_state.formula_panel_open;
        let _: bool = app.view_state.category_panel_open;
        let _: bool = app.view_state.view_panel_open;
        let _: usize = app.view_state.cat_panel_cursor;
        let _: usize = app.view_state.view_panel_cursor;
        let _: usize = app.view_state.formula_cursor;
        let _: &Option<CellValue> = &app.view_state.yanked;
        let _: usize = app.view_state.tile_cat_idx;
        let _: &Vec<ViewFrame> = &app.view_state.view_back_stack;
        let _: &Vec<ViewFrame> = &app.view_state.view_forward_stack;
        let _: &Option<DrillState> = &app.view_state.drill_state;
        let _: usize = app.view_state.help_page;
        let _: &std::collections::HashSet<String> = &app.view_state.expanded_cats;
        let _: &HashMap<String, String> = &app.view_state.buffers;
        let _: &Option<Arc<Keymap>> = &app.view_state.transient_keymap;
    }

    fn two_col_model() -> App {
        let mut wb = Workbook::new("T");
        wb.add_category("Row").unwrap(); // → Row axis
        wb.add_category("Col").unwrap(); // → Column axis
        wb.model.category_mut("Row").unwrap().add_item("A");
        wb.model.category_mut("Row").unwrap().add_item("B");
        wb.model.category_mut("Row").unwrap().add_item("C");
        wb.model.category_mut("Col").unwrap().add_item("X");
        wb.model.category_mut("Col").unwrap().add_item("Y");
        App::new(wb, None)
    }

    fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) {
        let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
        let effects = cmd.execute(&ctx);
        drop(ctx);
        app.apply_effects(effects);
    }

    fn enter_advance_cmd(app: &App) -> crate::command::cmd::navigation::EnterAdvance {
        use crate::command::cmd::navigation::CursorState;
        let view = app.model_state.workbook.active_view();
        let cursor = CursorState {
            row: view.selected.0,
            col: view.selected.1,
            row_count: 3,
            col_count: 2,
            row_offset: 0,
            col_offset: 0,
            visible_rows: 20,
            visible_cols: 8,
        };
        crate::command::cmd::navigation::EnterAdvance { cursor }
    }

    #[test]
    fn enter_advance_moves_down_within_column() {
        let mut app = two_col_model();
        app.model_state.workbook.active_view_mut().selected = (0, 0);
        let cmd = enter_advance_cmd(&app);
        run_cmd(&mut app, &cmd);
        assert_eq!(app.model_state.workbook.active_view().selected, (1, 0));
    }

    #[test]
    fn enter_advance_wraps_to_top_of_next_column() {
        let mut app = two_col_model();
        // row_max = 2 (A,B,C), col 0 → should wrap to (0, 1)
        app.model_state.workbook.active_view_mut().selected = (2, 0);
        let cmd = enter_advance_cmd(&app);
        run_cmd(&mut app, &cmd);
        assert_eq!(app.model_state.workbook.active_view().selected, (0, 1));
    }

    #[test]
    fn enter_advance_stays_at_bottom_right() {
        let mut app = two_col_model();
        app.model_state.workbook.active_view_mut().selected = (2, 1);
        let cmd = enter_advance_cmd(&app);
        run_cmd(&mut app, &cmd);
        assert_eq!(app.model_state.workbook.active_view().selected, (2, 1));
    }

    #[test]
    fn import_command_switches_to_import_wizard_mode() {
        // Regression: execute_command was resetting mode to Normal after
        // :import set it to ImportWizard, so the wizard never appeared.
        let mut app = two_col_model();
        let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]);
        app.start_import_wizard(json);
        assert!(
            matches!(app.view_state.mode, AppMode::ImportWizard),
            "mode should be ImportWizard after start_import_wizard"
        );
    }

    #[test]
    fn execute_import_command_leaves_mode_as_import_wizard() {
        let mut app = two_col_model();
        // Inject JSON via start_import_wizard to simulate what :import does
        app.start_import_wizard(serde_json::json!([{"x": 1}]));
        // After the command the mode must NOT be reset to Normal
        assert!(
            !matches!(app.view_state.mode, AppMode::Normal),
            "mode must not be Normal after import wizard is opened"
        );
    }

    #[test]
    fn command_mode_typing_appends_to_buffer() {
        use crossterm::event::KeyEvent;
        let mut app = two_col_model();
        // Enter command mode with ':'
        app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
            .unwrap();
        assert!(matches!(app.view_state.mode, AppMode::CommandMode { .. }));
        assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some(""));

        // Type 'q'
        app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("q"));
    }

    #[test]
    fn col_offset_scrolls_when_cursor_moves_past_visible_columns() {
        use crate::model::cell::{CellKey, CellValue};
        // Create a model with 8 wide columns. Column item names are 30 chars
        // each → column widths ~31 chars. With term_width=80, row header ~4,
        // data area ~76 → only ~2 columns actually fit. But the rough estimate
        // (80−30)/12 = 4 over-counts, so viewport_effects never scrolls.
        let mut wb = Workbook::new("T");
        wb.add_category("Row").unwrap();
        wb.add_category("Col").unwrap();
        wb.model.category_mut("Row").unwrap().add_item("R1");
        for i in 0..8 {
            let name = format!("VeryLongColumnItemName_{i:03}");
            wb.model.category_mut("Col").unwrap().add_item(&name);
        }
        // Populate a value so the workbook isn't empty
        let key = CellKey::new(vec![
            ("Row".to_string(), "R1".to_string()),
            ("Col".to_string(), "VeryLongColumnItemName_000".to_string()),
        ]);
        wb.model.set_cell(key, CellValue::Number(1.0));

        let mut app = App::new(wb, None);
        app.term_width = 80;

        // Press 'l' (right) 3 times to move cursor to column 3.
        // Only ~2 columns fit in 76 chars of data area (each col ~26 chars wide),
        // so column 3 is well off-screen. The buggy estimate (80−30)/12 = 4
        // thinks 4 columns fit, so it won't scroll until col 4.
        for _ in 0..3 {
            app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
                .unwrap();
        }

        assert_eq!(
            app.model_state.workbook.active_view().selected.1,
            3,
            "cursor should be at column 3"
        );
        assert!(
            app.model_state.workbook.active_view().col_offset > 0,
            "col_offset should scroll when cursor moves past visible area (only ~2 cols fit \
             in 80-char terminal with 26-char-wide columns), but col_offset is {}",
            app.model_state.workbook.active_view().col_offset
        );
    }

    #[test]
    fn home_jumps_to_first_col() {
        let mut app = two_col_model();
        app.model_state.workbook.active_view_mut().selected = (1, 1);
        app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.model_state.workbook.active_view().selected, (1, 0));
    }

    #[test]
    fn end_jumps_to_last_col() {
        let mut app = two_col_model();
        app.model_state.workbook.active_view_mut().selected = (1, 0);
        app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.model_state.workbook.active_view().selected, (1, 1));
    }

    #[test]
    fn page_down_scrolls_by_three_quarters_visible() {
        let mut app = two_col_model();
        // Add enough rows
        for i in 0..30 {
            app.model_state.workbook
                .model
                .category_mut("Row")
                .unwrap()
                .add_item(format!("R{i}"));
        }
        app.term_height = 28; // ~20 visible rows → delta = 15
        app.model_state.workbook.active_view_mut().selected = (0, 0);
        app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.model_state.workbook.active_view().selected.1, 0, "column preserved");
        assert!(
            app.model_state.workbook.active_view().selected.0 > 0,
            "row should advance on PageDown"
        );
        // 3/4 of ~20 = 15
        assert_eq!(app.model_state.workbook.active_view().selected.0, 15);
    }

    #[test]
    fn page_up_scrolls_backward() {
        let mut app = two_col_model();
        for i in 0..30 {
            app.model_state.workbook
                .model
                .category_mut("Row")
                .unwrap()
                .add_item(format!("R{i}"));
        }
        app.term_height = 28;
        app.model_state.workbook.active_view_mut().selected = (20, 0);
        app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.model_state.workbook.active_view().selected.0, 5);
    }

    #[test]
    fn jump_last_row_scrolls_with_small_terminal() {
        let mut app = two_col_model();
        // Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
        for i in 0..10 {
            app.model_state.workbook
                .model
                .category_mut("Row")
                .unwrap()
                .add_item(format!("R{i}"));
        }
        app.term_height = 13; // ~5 visible rows
        app.model_state.workbook.active_view_mut().selected = (0, 0);
        // G jumps to last row (row 12)
        app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
            .unwrap();
        let last = app.model_state.workbook.active_view().selected.0;
        assert_eq!(last, 12, "should be at last row");
        // With only ~5 visible rows and 13 rows, offset should scroll.
        // Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll.
        let offset = app.model_state.workbook.active_view().row_offset;
        assert!(
            offset > 0,
            "row_offset should scroll when last row is beyond visible area, but is {offset}"
        );
    }

    #[test]
    fn ctrl_d_scrolls_viewport_with_small_terminal() {
        let mut app = two_col_model();
        for i in 0..30 {
            app.model_state.workbook
                .model
                .category_mut("Row")
                .unwrap()
                .add_item(format!("R{i}"));
        }
        app.term_height = 13; // ~5 visible rows
        app.model_state.workbook.active_view_mut().selected = (0, 0);
        // Ctrl+d scrolls by 5 rows
        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
            .unwrap();
        assert_eq!(app.model_state.workbook.active_view().selected.0, 5);
        // Press Ctrl+d again — now at row 10 with only 5 visible rows,
        // row_offset should have scrolled (not stay at 0 due to hardcoded 20)
        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
            .unwrap();
        assert_eq!(app.model_state.workbook.active_view().selected.0, 10);
        assert!(
            app.model_state.workbook.active_view().row_offset > 0,
            "row_offset should scroll with small terminal, but is {}",
            app.model_state.workbook.active_view().row_offset
        );
    }

    #[test]
    fn tab_in_edit_mode_commits_and_moves_right() {
        let mut app = two_col_model();
        app.model_state.workbook.active_view_mut().selected = (0, 0);
        // Enter edit mode
        app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
            .unwrap();
        assert!(matches!(app.view_state.mode, AppMode::Editing { .. }));
        // Type a digit
        app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
            .unwrap();
        // Press Tab — should commit, move right, re-enter edit mode
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();
        // Should be in edit mode on column 1
        assert!(
            matches!(app.view_state.mode, AppMode::Editing { .. }),
            "should be in edit mode after Tab, but mode is {:?}",
            app.view_state.mode
        );
        assert_eq!(
            app.model_state.workbook.active_view().selected.1,
            1,
            "should have moved to column 1"
        );
    }

    /// Regression: pressing `o` in an empty records view should create the
    /// Pressing R to enter records mode should sort existing data by CellKey
    /// so display order is deterministic regardless of insertion order.
    #[test]
    fn entering_records_mode_sorts_existing_data() {
        use crate::model::cell::{CellKey, CellValue};
        let mut wb = Workbook::new("T");
        wb.add_category("Region").unwrap();
        wb.model.category_mut("Region").unwrap().add_item("North");
        wb.model.category_mut("Region").unwrap().add_item("East");
        // Insert in reverse-alphabetical order
        wb.model.set_cell(
            CellKey::new(vec![("Region".into(), "North".into())]),
            CellValue::Number(1.0),
        );
        wb.model.set_cell(
            CellKey::new(vec![("Region".into(), "East".into())]),
            CellValue::Number(2.0),
        );
        let mut app = App::new(wb, None);
        app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
            .unwrap();
        assert!(app.layout.is_records_mode());
        let region_col = (0..app.layout.col_count())
            .find(|&c| app.layout.col_label(c) == "Region")
            .unwrap();
        let row0 = app.layout.records_display(0, region_col).unwrap();
        let row1 = app.layout.records_display(1, region_col).unwrap();
        assert_eq!(
            row0, "East",
            "R should sort existing data: first row should be East"
        );
        assert_eq!(
            row1, "North",
            "R should sort existing data: second row should be North"
        );
    }

    /// first synthetic row instead of only entering edit mode on empty space.
    #[test]
    fn add_record_row_in_empty_records_view_creates_first_row() {
        let mut wb = Workbook::new("T");
        wb.add_category("Region").unwrap();
        wb.model.category_mut("Region").unwrap().add_item("East");
        let mut app = App::new(wb, None);

        app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
            .unwrap();
        assert!(app.layout.is_records_mode(), "R should enter records mode");
        assert_eq!(app.layout.row_count(), 0, "fresh records view starts empty");

        app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))
            .unwrap();

        assert_eq!(
            app.layout.row_count(),
            1,
            "o should create the first record row in an empty records view"
        );
        assert!(
            app.view_state.mode.is_editing(),
            "o should leave the app in edit mode, got {:?}",
            app.view_state.mode
        );
    }

    /// Regression: editing the first row in a blank model's records view
    /// should persist the typed value even though plain records mode does not
    /// use drill state. With _Measure as the first column, `o` lands on it;
    /// type a measure name, Tab to Value, type the number, Enter to commit.
    #[test]
    fn edit_record_row_in_blank_model_persists_value() {
        use crate::model::cell::CellKey;

        let mut app = App::new(Workbook::new("T"), None);

        app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
            .unwrap();
        // `o` adds a record row and enters edit at (0, 0) = _Measure column
        app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))
            .unwrap();
        // Type a measure name
        app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE))
            .unwrap();
        // Tab to commit _Measure and move to Value column
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();
        // Type the value
        app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
            .unwrap();
        // Enter to commit
        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        assert_eq!(
            app.model_state.workbook.model.get_cell(&CellKey::new(vec![(
                "_Measure".to_string(),
                "Rev".to_string(),
            )])),
            Some(&crate::model::cell::CellValue::Number(5.0)),
            "editing a synthetic row in plain records mode should write the value"
        );
    }

    /// Build a records-mode app with two data rows for testing Tab/Enter
    /// behavior at boundaries. Row 0 has _Measure=meas2, row 1 has _Measure=meas1.
    fn records_model_with_two_rows() -> App {
        use crate::model::cell::{CellKey, CellValue};
        let mut wb = Workbook::new("T");
        wb.add_category("Region").unwrap();
        wb.model.category_mut("Region").unwrap().add_item("North");
        wb.model.category_mut("_Measure").unwrap().add_item("meas1");
        wb.model.category_mut("_Measure").unwrap().add_item("meas2");
        wb.model.set_cell(
            CellKey::new(vec![
                ("Region".into(), "North".into()),
                ("_Measure".into(), "meas2".into()),
            ]),
            CellValue::Number(10.0),
        );
        wb.model.set_cell(
            CellKey::new(vec![
                ("Region".into(), "North".into()),
                ("_Measure".into(), "meas1".into()),
            ]),
            CellValue::Number(20.0),
        );
        let mut app = App::new(wb, None);
        app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
            .unwrap();
        assert!(
            app.layout.is_records_mode(),
            "setup: should be records mode"
        );
        assert_eq!(app.layout.row_count(), 2, "setup: should have 2 records");
        let cols: Vec<String> = (0..app.layout.col_count())
            .map(|i| app.layout.col_label(i))
            .collect();
        assert!(
            cols.contains(&"Region".to_string()),
            "setup: should have Region column; got {:?}",
            cols
        );
        assert!(
            cols.contains(&"_Measure".to_string()),
            "setup: should have _Measure column; got {:?}",
            cols
        );
        assert_eq!(
            cols.last().unwrap(),
            "Value",
            "setup: Value must be last column; got {:?}",
            cols
        );
        app
    }

    /// improvise-3zq (bug #2): `AddRecordRow` creates a cell with an empty
    /// `CellKey` when no Page-axis categories supply coords — that cell
    /// serialises as ` = 0` in .improv and re-appears on every records
    /// toggle. Leaving records mode must clean up any such meaningless
    /// records (inverse of the `SortData` that runs on entry).
    #[test]
    fn leaving_records_mode_cleans_empty_key_cells() {
        use crate::model::cell::{CellKey, CellValue};
        let mut app = records_model_with_two_rows();
        // Simulate Tab-at-bottom-right having produced an empty-key cell.
        app.model_state.workbook
            .model
            .set_cell(CellKey::new(vec![]), CellValue::Number(0.0));
        assert!(
            app.model_state.workbook
                .model
                .data
                .iter_cells()
                .any(|(k, _)| k.0.is_empty()),
            "setup: empty-key cell should be present"
        );
        // Leave records mode via R.
        app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
            .unwrap();
        assert!(
            !app.layout.is_records_mode(),
            "setup: should have left records mode"
        );
        assert!(
            !app.model_state.workbook
                .model
                .data
                .iter_cells()
                .any(|(k, _)| k.0.is_empty()),
            "empty-key records should be cleaned when leaving records mode"
        );
    }

    /// improvise-3zq (bug #1): Enter on the bottom-right cell of records
    /// view should commit and leave edit mode. Previously `CommitAndAdvance`
    /// pushed an `EnterEditAtCursor` effect unconditionally, so the cursor
    /// stayed put and we re-entered editing on the same cell.
    #[test]
    fn enter_at_bottom_right_of_records_view_exits_editing() {
        let mut app = records_model_with_two_rows();
        let last_row = app.layout.row_count() - 1;
        let last_col = app.layout.col_count() - 1;
        app.model_state.workbook.active_view_mut().selected = (last_row, last_col);

        app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
            .unwrap();
        assert!(app.view_state.mode.is_editing(), "setup: should be editing");

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();
        assert!(
            !app.view_state.mode.is_editing(),
            "Enter at bottom-right should exit editing, got {:?}",
            app.view_state.mode
        );
        assert!(
            matches!(app.view_state.mode, AppMode::RecordsNormal),
            "should return to RecordsNormal, got {:?}",
            app.view_state.mode
        );
    }

    /// improvise-hmu: TAB on the bottom-right cell of records view should
    /// insert a new record below and move to the first cell of the new row
    /// in edit mode.
    #[test]
    fn tab_on_bottom_right_of_records_inserts_below() {
        let mut app = records_model_with_two_rows();
        let initial_rows = app.layout.row_count();
        assert!(initial_rows >= 1, "setup: need at least 1 record");

        let last_row = initial_rows - 1;
        let last_col = app.layout.col_count() - 1;
        app.model_state.workbook.active_view_mut().selected = (last_row, last_col);

        // Enter edit mode on the bottom-right cell
        app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
            .unwrap();
        assert!(app.view_state.mode.is_editing(), "setup: should be editing");

        // TAB should commit, insert below, move to first cell of new row
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();

        assert_eq!(
            app.layout.row_count(),
            initial_rows + 1,
            "TAB on bottom-right should insert a record below"
        );
        assert_eq!(
            app.model_state.workbook.active_view().selected,
            (initial_rows, 0),
            "TAB should move to first cell of the new row"
        );
        assert!(
            app.view_state.mode.is_editing(),
            "should enter edit mode on the new cell, got {:?}",
            app.view_state.mode
        );
    }

    /// Drill-view edits should stay staged in drill state until the user
    /// navigates back, at which point ApplyAndClearDrill writes them through.
    #[test]
    fn drill_edit_is_staged_until_view_back() {
        use crate::model::cell::{CellKey, CellValue};

        let mut wb = Workbook::new("T");
        wb.add_category("Region").unwrap();
        wb.add_category("Month").unwrap();
        wb.model.category_mut("Region").unwrap().add_item("East");
        wb.model.category_mut("Month").unwrap().add_item("Jan");
        let record_key = CellKey::new(vec![
            ("Month".to_string(), "Jan".to_string()),
            ("Region".to_string(), "East".to_string()),
        ]);
        wb.model
            .set_cell(record_key.clone(), CellValue::Number(1.0));
        let mut app = App::new(wb, None);

        app.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE))
            .unwrap();
        assert!(app.view_state.drill_state.is_some(), "drill should create drill state");
        let value_col = (0..app.layout.col_count())
            .find(|&col| app.layout.col_label(col) == "Value")
            .expect("drill view should include a Value column");
        app.model_state.workbook.active_view_mut().selected = (0, value_col);
        app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('9'), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        assert_eq!(
            app.model_state.workbook.model.get_cell(&record_key),
            Some(&CellValue::Number(1.0)),
            "drill edit should remain staged until leaving the drill view"
        );
        assert_eq!(
            app.view_state.drill_state
                .as_ref()
                .and_then(|s| s.pending_edits.get(&(0, "Value".to_string()))),
            Some(&"9".to_string()),
            "drill edit should be recorded in pending_edits"
        );

        app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE))
            .unwrap();

        assert_eq!(
            app.model_state.workbook.model.get_cell(&record_key),
            Some(&CellValue::Number(9.0)),
            "leaving drill view should apply the staged edit"
        );
    }

    /// Suspected bug: blanking a records-mode category coordinate should not
    /// create an item with an empty name.
    #[test]
    fn blanking_records_category_does_not_create_empty_item() {
        use crate::model::cell::{CellKey, CellValue};

        let mut wb = Workbook::new("T");
        wb.add_category("Region").unwrap();
        wb.model.category_mut("Region").unwrap().add_item("East");
        wb.model.set_cell(
            CellKey::new(vec![("Region".to_string(), "East".to_string())]),
            CellValue::Number(1.0),
        );
        let mut app = App::new(wb, None);

        app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
            .unwrap();
        for _ in 0..4 {
            app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE))
                .unwrap();
        }
        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        assert!(
            !app.model_state.workbook
                .model
                .category("Region")
                .unwrap()
                .items
                .contains_key(""),
            "records-mode edits should not create empty category items"
        );
    }

    #[test]
    fn command_mode_buffer_cleared_on_reentry() {
        use crossterm::event::KeyEvent;
        let mut app = two_col_model();
        // Enter command mode, type something, escape
        app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("x"));
        app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
            .unwrap();

        // Re-enter command mode — buffer should be cleared
        app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some(""));
    }

    // ── is_empty_model ──────────────────────────────────────────────────

    #[test]
    fn fresh_model_is_empty() {
        let app = App::new(Workbook::new("T"), None);
        assert!(
            app.is_empty_model(),
            "a brand-new model with only virtual categories should be empty"
        );
    }

    #[test]
    fn model_with_user_category_is_not_empty() {
        let mut wb = Workbook::new("T");
        wb.add_category("Sales").unwrap();
        let app = App::new(wb, None);
        assert!(
            !app.is_empty_model(),
            "a model with a user-defined category should not be empty"
        );
    }

    // ── Help mode navigation ────────────────────────────────────────────

    #[test]
    fn help_page_next_advances_page() {
        let mut app = App::new(Workbook::new("T"), None);
        app.view_state.mode = AppMode::Help;
        app.view_state.help_page = 0;

        app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_state.help_page, 1, "l should advance to page 1");
    }

    #[test]
    fn help_page_prev_goes_back() {
        let mut app = App::new(Workbook::new("T"), None);
        app.view_state.mode = AppMode::Help;
        app.view_state.help_page = 2;

        app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_state.help_page, 1, "h should go back to page 1");
    }

    #[test]
    fn help_page_clamps_at_zero() {
        let mut app = App::new(Workbook::new("T"), None);
        app.view_state.mode = AppMode::Help;
        app.view_state.help_page = 0;

        app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.view_state.help_page, 0, "page should not go below 0");
    }

    #[test]
    fn help_page_clamps_at_max() {
        use crate::ui::help::HELP_PAGE_COUNT;

        let mut app = App::new(Workbook::new("T"), None);
        app.view_state.mode = AppMode::Help;
        app.view_state.help_page = HELP_PAGE_COUNT - 1;

        app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
            .unwrap();
        assert_eq!(
            app.view_state.help_page,
            HELP_PAGE_COUNT - 1,
            "page should not exceed the last page"
        );
    }

    // ── Help mode exits ─────────────────────────────────────────────────

    #[test]
    fn help_q_returns_to_normal() {
        let mut app = App::new(Workbook::new("T"), None);
        app.view_state.mode = AppMode::Help;

        app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
            .unwrap();
        assert!(
            matches!(app.view_state.mode, AppMode::Normal),
            "q should return to Normal mode"
        );
    }

    #[test]
    fn help_esc_returns_to_normal() {
        let mut app = App::new(Workbook::new("T"), None);
        app.view_state.mode = AppMode::Help;

        app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
            .unwrap();
        assert!(
            matches!(app.view_state.mode, AppMode::Normal),
            "Esc should return to Normal mode"
        );
    }

    #[test]
    fn help_colon_enters_command_mode() {
        let mut app = App::new(Workbook::new("T"), None);
        app.view_state.mode = AppMode::Help;

        app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
            .unwrap();
        assert!(
            matches!(app.view_state.mode, AppMode::CommandMode { .. }),
            "colon in Help mode should enter CommandMode, got {:?}",
            app.view_state.mode
        );
    }

    // ── Effect error feedback ───────────────────────────────────────────

    #[test]
    fn add_item_to_nonexistent_category_sets_status() {
        use crate::ui::effect::Effect;
        let mut app = App::new(Workbook::new("T"), None);
        let effect = crate::ui::effect::AddItem {
            category: "Nonexistent".to_string(),
            item: "x".to_string(),
        };
        effect.apply(&mut app);
        assert!(
            app.view_state.status_msg.contains("Unknown category"),
            "should report unknown category, got: {:?}",
            app.view_state.status_msg
        );
    }

    #[test]
    fn add_formula_with_bad_syntax_sets_status() {
        use crate::ui::effect::Effect;
        let mut app = App::new(Workbook::new("T"), None);
        let effect = crate::ui::effect::AddFormula {
            raw: "!!!invalid".to_string(),
            target_category: "X".to_string(),
        };
        effect.apply(&mut app);
        assert!(
            app.view_state.status_msg.contains("Formula error"),
            "should report formula error, got: {:?}",
            app.view_state.status_msg
        );
    }

    // ── Tile select stays in mode ───────────────────────────────────────

    #[test]
    fn tile_axis_change_stays_in_tile_select() {
        let mut app = two_col_model();
        app.view_state.mode = AppMode::TileSelect;
        app.view_state.tile_cat_idx = 0;

        // Press 'r' to set axis to Row — should stay in TileSelect
        app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
            .unwrap();
        assert!(
            matches!(app.view_state.mode, AppMode::TileSelect),
            "should stay in TileSelect after axis change, got {:?}",
            app.view_state.mode
        );
        assert!(
            !app.view_state.status_msg.is_empty(),
            "should show status feedback after axis change"
        );
    }

    // ── Panel colon bindings ────────────────────────────────────────────

    #[test]
    fn category_panel_colon_enters_command_mode() {
        let mut app = two_col_model();
        app.view_state.mode = AppMode::CategoryPanel;

        app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
            .unwrap();
        assert!(
            matches!(app.view_state.mode, AppMode::CommandMode { .. }),
            "colon in CategoryPanel should enter CommandMode, got {:?}",
            app.view_state.mode
        );
    }

    #[test]
    fn view_panel_colon_enters_command_mode() {
        let mut app = two_col_model();
        app.view_state.mode = AppMode::ViewPanel;

        app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
            .unwrap();
        assert!(
            matches!(app.view_state.mode, AppMode::CommandMode { .. }),
            "colon in ViewPanel should enter CommandMode, got {:?}",
            app.view_state.mode
        );
    }

    #[test]
    fn tile_select_colon_enters_command_mode() {
        let mut app = two_col_model();
        app.view_state.mode = AppMode::TileSelect;

        app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
            .unwrap();
        assert!(
            matches!(app.view_state.mode, AppMode::CommandMode { .. }),
            "colon in TileSelect should enter CommandMode, got {:?}",
            app.view_state.mode
        );
    }
}
