//! Runtime state and control types.
//!
//! This module defines all the state types used for controlling the simulation,
//! including RuntimeState, ControlAction, and various parameter types.

pub use crate::cli::num_palettes;
use crate::cli::Palette;
use crate::cli::PauseStyle;
pub use crate::cli::ALL_PALETTES;
pub use crate::cli::NUM_PALETTES;
use crate::config_defaults::dither as dither_consts;
use crate::config_defaults::intensity::DEFAULT_PERLIN_SEED as PERLIN_SEED;
use crate::config_defaults::{agent as agent_consts, environment as env_consts, trail};
use crate::food_image::FOOD_IMAGE_PNG;
use crate::overlay::{OverlayState, OverlayType};
use crate::render::charset::Charset;
pub use crate::render::charset::ALL_CHARSETS;
use crate::render::dither::{DitherMatrix, DitherMode};
use crate::render::palette::IntensityMapping;
use crate::render::theme::{PanelStyle, ALL_THEMES, GRUVBOX_DARK};
use crate::simulation::config::{
    Aspect, ChromeStyle, DiffusionKernel, InitMode, Preset, SimConfig, TerminalSizeThreshold,
    TerrainType, TransitionStyle, Wind, WindowFrame, WindowPadding,
};
use crate::simulation::food::load_logo_from_memory;
use rand::Rng;

#[derive(Debug, Clone, Copy, PartialEq)]
/// Represents a mouse cursor position in the terminal grid.
pub struct MousePosition {
    /// X coordinate (column).
    pub x: usize,
    /// Y coordinate (row).
    pub y: usize,
}

#[derive(Debug, Clone, Copy, PartialEq)]
/// Defines how mouse interaction affects the simulation.
pub enum MouseInteractionMode {
    /// Mouse interaction disabled.
    Disabled,
    /// Mouse click creates an attractor.
    Attract,
    /// Mouse click creates a repeller.
    Repel,
}

/// Returns the number of available charsets.
pub fn num_charsets() -> usize {
    ALL_CHARSETS.len()
}

/// A swap the user requested while live edits were dirty, parked behind the
/// dirty-state guard overlay until confirmed or cancelled. Both the clean path and
/// the post-confirm path route through the SAME `do_swap` in the runner so they can
/// never diverge.
// Not `Eq`: `NamedProfile` carries f32 overrides, which are `PartialEq` only.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum PendingSwap {
    /// Switch to a preset (via `switch_preset`).
    Preset(Preset),
    /// Load an already-resolved saved config (through the apply seam +
    /// transactional provenance). Carrying the resolved [`NamedProfile`] avoids a
    /// redundant disk re-read in `do_swap`; it survives the dirty guard intact.
    /// Boxed to keep the enum small (the profile is far larger than other variants).
    Config(Box<crate::config_manager::NamedProfile>),
    /// Re-apply `active_overrides` (the reset path).
    Reset,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Speed of automatic palette hue shifting.
pub enum PaletteShiftSpeed {
    /// No shift.
    Off,
    /// Slow shift (5 degrees/sec).
    Slow,
    /// Medium shift (15 degrees/sec).
    Medium,
    /// Fast shift (45 degrees/sec).
    Fast,
}

impl PaletteShiftSpeed {
    /// Returns the shift speed in degrees per second.
    pub fn degrees_per_second(&self) -> f32 {
        match self {
            PaletteShiftSpeed::Off => 0.0,
            PaletteShiftSpeed::Slow => 5.0,
            PaletteShiftSpeed::Medium => 15.0,
            PaletteShiftSpeed::Fast => 45.0,
        }
    }
}

/// Buckets a resolved `hue_shift` (deg/s) into the observable [`PaletteShiftSpeed`].
///
/// Single source of truth shared by `apply_render_config` (which sets the live
/// `rs.palette_shift_speed`) and the dirty-state canonical projection. Both MUST
/// use the same buckets so a clean swap never reads dirty: the runtime only ever
/// exposes the bucket, so the raw `hue_shift` value must be compared via this map.
pub fn palette_shift_speed_of(hue_shift: f32) -> PaletteShiftSpeed {
    if hue_shift <= 0.0 {
        PaletteShiftSpeed::Off
    } else if hue_shift <= 10.0 {
        PaletteShiftSpeed::Slow
    } else if hue_shift <= 30.0 {
        PaletteShiftSpeed::Medium
    } else {
        PaletteShiftSpeed::Fast
    }
}

/// Urgency level for toast notifications.
///
/// Controls the icon prefix and accent color of a notification.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NotificationLevel {
    /// Neutral informational message (teal accent).
    Info,
    /// Positive confirmation (green accent).
    Success,
    /// Non-critical caution (amber accent).
    Warning,
    /// Critical error (red accent).
    Error,
}

impl NotificationLevel {
    /// Returns a single-character icon prefix for the notification.
    pub fn icon(self) -> &'static str {
        match self {
            NotificationLevel::Info => "ℹ",
            NotificationLevel::Success => "✓",
            NotificationLevel::Warning => "⚠",
            NotificationLevel::Error => "✗",
        }
    }

    /// Returns the ANSI 256-color index for this level's accent background.
    pub fn bg_color_256(self) -> u8 {
        match self {
            NotificationLevel::Info => 23,    // Dark teal
            NotificationLevel::Success => 22, // Dark green
            NotificationLevel::Warning => 94, // Dark amber/orange
            NotificationLevel::Error => 52,   // Dark red
        }
    }

    /// Returns an RGB accent color for this level.
    pub fn accent_rgb(self) -> crate::render::palette::RgbColor {
        use crate::render::palette::RgbColor;
        match self {
            NotificationLevel::Info => RgbColor {
                r: 69,
                g: 192,
                b: 191,
            }, // aqua
            NotificationLevel::Success => RgbColor {
                r: 142,
                g: 192,
                b: 124,
            }, // green
            NotificationLevel::Warning => RgbColor {
                r: 215,
                g: 153,
                b: 33,
            }, // amber
            NotificationLevel::Error => RgbColor {
                r: 251,
                g: 73,
                b: 52,
            }, // red bright
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Predefined wind directions for easy control.
pub enum WindDirection {
    /// No wind.
    None,
    /// Wind blowing North (up).
    North,
    /// Wind blowing Northeast.
    Northeast,
    /// Wind blowing East (right).
    East,
    /// Wind blowing Southeast.
    Southeast,
    /// Wind blowing South (down).
    South,
    /// Wind blowing Southwest.
    Southwest,
    /// Wind blowing West (left).
    West,
    /// Wind blowing Northwest.
    Northwest,
}

impl WindDirection {
    #[allow(clippy::wrong_self_convention)]
    /// Converts the enum variant to a `Wind` vector.
    pub fn to_wind(&self) -> Option<Wind> {
        match self {
            WindDirection::None => None,
            WindDirection::North => Some(Wind::new(0.0, -1.0)),
            WindDirection::Northeast => Some(Wind::new(0.7, -0.7)),
            WindDirection::East => Some(Wind::new(1.0, 0.0)),
            WindDirection::Southeast => Some(Wind::new(0.7, 0.7)),
            WindDirection::South => Some(Wind::new(0.0, 1.0)),
            WindDirection::Southwest => Some(Wind::new(-0.7, 0.7)),
            WindDirection::West => Some(Wind::new(-1.0, 0.0)),
            WindDirection::Northwest => Some(Wind::new(-0.7, -0.7)),
        }
    }

    /// Derive the nearest coarse direction label from a precise wind vector.
    ///
    /// Display-only: snaps the (dx, dy) vector to one of the eight compass
    /// directions for the dashboard label. The precise vector itself lives in
    /// `RuntimeState::wind` and is never reconstructed from this label.
    pub fn from_wind(wind: Option<Wind>) -> Self {
        let Some(w) = wind else {
            return WindDirection::None;
        };
        if w.dx == 0.0 && w.dy == 0.0 {
            return WindDirection::None;
        }
        // Angle measured with +x = East, +y = South (screen coords).
        let angle = w.dy.atan2(w.dx).to_degrees();
        // Normalize to [0, 360): 0=E, 90=S, 180=W, 270=N.
        let norm = ((angle % 360.0) + 360.0) % 360.0;
        match (norm / 45.0).round() as i32 % 8 {
            0 => WindDirection::East,
            1 => WindDirection::Southeast,
            2 => WindDirection::South,
            3 => WindDirection::Southwest,
            4 => WindDirection::West,
            5 => WindDirection::Northwest,
            6 => WindDirection::North,
            _ => WindDirection::Northeast,
        }
    }

    /// Returns the display name of the direction.
    pub fn name(&self) -> &'static str {
        match self {
            WindDirection::None => "None",
            WindDirection::North => "N",
            WindDirection::Northeast => "NE",
            WindDirection::East => "E",
            WindDirection::Southeast => "SE",
            WindDirection::South => "S",
            WindDirection::Southwest => "SW",
            WindDirection::West => "W",
            WindDirection::Northwest => "NW",
        }
    }
}

/// Tracks whether window chrome (title + footer) is currently visible.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ChromeState {
    /// Only the frame visible (default).
    #[default]
    Minimal,
    /// Title block + footer visible.
    Expanded,
    /// Expanded + a modal overlay is open over the sim interior.
    ModalPane,
    /// Collapsing: `progress` goes from 1.0 → 0.0 over 500ms.
    FadingOut(f32),
}

#[derive(Debug, Clone, Copy, PartialEq)]
/// Actions triggered by keyboard or other input events.
pub enum ControlAction {
    /// Pause/resume simulation.
    TogglePause,
    /// Restart simulation with new seed.
    Restart,
    /// Resolve a number-row quick-key (`1`-`7`) against the merged keybind map.
    QuickKey(char),
    /// Resolve a shifted number key for A/B comparison (carries the base digit).
    CompareQuickKey(char),
    /// Adjust simulation speed.
    AdjustTimeScale(f32),
    /// Cycle to next color palette.
    CyclePalette,
    /// Cycle to previous color palette.
    CyclePaletteReverse,
    /// Cycle to next charset.
    CycleCharset,
    /// Cycle to previous charset.
    CycleCharsetReverse,
    /// Cycle color anti-aliasing strength for the active charset.
    CycleColorAa,
    /// Toggle controls overlay.
    ToggleControls,
    /// Toggle keyboard shortcuts overlay.
    ToggleKeyboardHints,
    /// Close all open overlays.
    CloseOverlays,
    /// Toggle dithering on/off.
    ToggleDither,
    /// Cycle through dithering modes.
    CycleDitherMode,
    /// Adjust dithering intensity.
    AdjustDitherIntensity(f32),
    /// Quit application.
    Quit,
    /// Adjust sensor angle.
    AdjustSensorAngle(f32),
    /// Adjust sensor distance.
    AdjustSensorDistance(f32),
    /// Adjust turn angle.
    AdjustTurnAngle(f32),
    /// Adjust step size.
    AdjustStepSize(f32),
    /// Adjust decay factor.
    AdjustDecay(f32),
    /// Adjust deposit amount.
    AdjustDeposit(f32),
    /// Cycle diffusion kernel type.
    CycleDiffusionKernel,
    /// Adjust diffusion sigma.
    AdjustDiffusionSigma(f32),
    /// Adjust attractor strength.
    AdjustAttractorStrength(f32),
    /// Cycle mouse interaction mode.
    CycleMouseMode,
    /// Cycle wind direction.
    CycleWindDirection,
    /// Cycle wind direction in reverse.
    CycleWindDirectionReverse,
    /// Adjust terrain strength.
    AdjustTerrainStrength(f32),
    /// Cycle terrain type.
    CycleTerrainType,
    /// Toggle auto-normalization.
    ToggleAutoNormalize,
    /// Cycle motion blur amount.
    CycleMotionBlur,
    /// Adjust the brightness white-point divisor (positive delta = dimmer;
    /// the user-facing control is inverted to read as gain).
    AdjustMaxBrightness(f32),
    /// Save current frame to PNG.
    SaveFrameToPng,
    /// Toggle fast rendering mode.
    ToggleFastMode,
    /// Cycle palette shift speed.
    CyclePaletteShiftSpeed,
    /// Toggle inverted palette.
    ToggleInvertPalette,
    /// Toggle reversed palette.
    ToggleReversePalette,
    /// Toggle the always-on status line (ambient base row).
    ToggleStatusBar,
    /// Cycle to next intensity mapping.
    CycleIntensityMapping,
    /// Cycle to previous intensity mapping.
    CycleIntensityMappingReverse,
    /// Set specific intensity mapping.
    SetIntensityMapping(usize),
    /// Reset parameters to defaults.
    ResetToDefaults,
    /// Cycle controls category forward.
    CycleOptionsCategory,
    /// Cycle controls category backward.
    CycleOptionsCategoryReverse,
    /// Toggle dashboard overlay (merged stats + info).
    ToggleDashboard,
    /// Toggle transient notifications (toasts + ambient param readouts).
    ToggleNotifications,
    /// Show configuration browser.
    ShowConfigBrowser,
    /// Show configuration save dialog.
    ShowConfigSaveDialog,
    /// Randomize all parameters.
    RandomizeParams,
    /// Undo last parameter change.
    Undo,
    /// Redo last undone change.
    Redo,
    /// Cycle to next UI theme.
    CycleTheme,
    /// Cycle to previous UI theme.
    CycleThemeReverse,
    /// Show palette editor.
    ShowPaletteEditor,
    /// Toggle trail age hue shifting.
    ToggleTrailAge,
    /// Toggle temporal delta brightness boost.
    ToggleTrailDelta,
    /// Toggle gradient magnitude edge glow.
    ToggleGradientMagnitude,
    /// Cycle to next window frame mode.
    CycleWindowFrame,
    /// Cycle to previous window frame mode.
    CycleWindowFrameReverse,
    /// Toggle between windowed and fullscreen mode.
    ToggleFullscreen,
    /// Cycle chrome style: Minimal → Expanded → Fullscreen → Minimal.
    CycleChrome,
    /// Toggle choir-mode audio sonification on/off.
    #[cfg(feature = "audio")]
    ToggleChoir,
    /// Cycle controls overlay depth: Closed → Console → Tuner → Closed.
    ToggleControlsDepth,
    /// Move focus to the next control in the controls overlay.
    ControlsFocusNext,
    /// Move focus to the previous control in the controls overlay.
    ControlsFocusPrev,
    /// Adjust the currently focused control by the given signed delta (−1.0 / +1.0).
    ControlsAdjustFocused(f32),
    /// Activate (press) the currently focused control in the controls overlay.
    ControlsActivateFocused,
    /// No action.
    None,
}

#[derive(Debug, Clone, PartialEq)]
/// Snapshot of all simulation parameters for undo/redo.
pub struct ParameterState {
    /// Sensor angle.
    pub sensor_angle: f32,
    /// Sensor distance.
    pub sensor_distance: f32,
    /// Rotation angle (max turn per step).
    pub rotation_angle: f32,
    /// Step size.
    pub step_size: f32,
    /// Decay factor.
    pub decay_factor: f32,
    /// Deposit amount.
    pub deposit_amount: f32,
    /// Diffusion kernel.
    pub diffusion_kernel: DiffusionKernel,
    /// Diffusion sigma.
    pub diffusion_sigma: f32,
    /// Attractor strength.
    pub attractor_strength: f32,
    /// Wind direction.
    pub wind_direction: WindDirection,
    /// Terrain type.
    pub terrain_type: TerrainType,
    /// Terrain strength.
    pub terrain_strength: f32,
    /// Brightness white-point divisor (higher = dimmer).
    pub max_brightness: f32,
    /// Palette index.
    pub palette_index: usize,
    /// Charset index.
    pub charset_index: usize,
    /// Invert palette flag.
    pub invert_palette: bool,
    /// Reverse palette flag.
    pub reverse_palette: bool,
    /// Dither mode.
    pub dither_mode: DitherMode,
    /// Motion blur frames.
    pub motion_blur_frames: usize,
    /// Window frame display mode.
    pub window_frame: WindowFrame,
    /// Per-charset color-AA strength, indexed by `charset_index`.
    pub color_aa: [crate::render::antialiasing::AaStrength; crate::render::charset::NUM_CHARSETS],
}

#[derive(Debug, Clone, Copy, PartialEq)]
/// Default parameter values for reset functionality.
pub struct DefaultValues {
    /// Sensor angle.
    pub sensor_angle: f32,
    /// Sensor distance.
    pub sensor_distance: f32,
    /// Rotation angle (max turn per step).
    pub rotation_angle: f32,
    /// Step size.
    pub step_size: f32,
    /// Decay factor.
    pub decay_factor: f32,
    /// Deposit amount.
    pub deposit_amount: f32,
    /// Diffusion kernel.
    pub diffusion_kernel: DiffusionKernel,
    /// Diffusion sigma.
    pub diffusion_sigma: f32,
    /// Attractor strength.
    pub attractor_strength: f32,
    /// Wind direction.
    pub wind_direction: WindDirection,
    /// Terrain type.
    pub terrain_type: TerrainType,
    /// Terrain strength.
    pub terrain_strength: f32,
    /// Auto normalize enabled.
    pub auto_normalize: bool,
    /// Motion blur frames.
    pub motion_blur_frames: usize,
    /// Brightness white-point divisor (higher = dimmer).
    pub max_brightness: f32,
}

impl DefaultValues {
    /// Create default values from a preset.
    pub fn from_preset(preset: Preset) -> Self {
        let config = SimConfig::from(preset);
        let auto_normalize = crate::render_art_defaults::RenderArtDefaults::from(preset)
            .auto_normalize
            .unwrap_or(false);
        Self::from_sim_config(&config, auto_normalize)
    }

    /// Create default values from a saved config (resolves its overrides).
    pub fn from_config(profile: &crate::config_manager::NamedProfile) -> Self {
        match profile.overrides.resolve() {
            Ok(p) => Self::from_sim_config(&p.sim, p.render.auto_normalize),
            Err(_) => Self::from_preset(Preset::Organic),
        }
    }

    /// Shared core: build display defaults from a resolved `SimConfig`.
    pub(crate) fn from_sim_config(config: &SimConfig, auto_normalize: bool) -> Self {
        Self {
            sensor_angle: config.sensor_angle,
            sensor_distance: config.sensor_distance,
            rotation_angle: config.rotation_angle,
            step_size: config.step_size,
            decay_factor: config.decay_factor,
            deposit_amount: config.deposit_amount,
            diffusion_kernel: config.diffusion_kernel,
            diffusion_sigma: config.diffusion_sigma,
            attractor_strength: config.attractor_strength,
            wind_direction: match config.wind {
                None => WindDirection::None,
                Some(w) => {
                    if w.dx > 0.0 && w.dy == 0.0 {
                        WindDirection::East
                    } else if w.dx < 0.0 && w.dy == 0.0 {
                        WindDirection::West
                    } else if w.dx == 0.0 && w.dy < 0.0 {
                        WindDirection::North
                    } else if w.dx == 0.0 && w.dy > 0.0 {
                        WindDirection::South
                    } else {
                        WindDirection::None
                    }
                }
            },
            terrain_type: config.terrain,
            terrain_strength: config.terrain_strength,
            auto_normalize,
            motion_blur_frames: 0,
            max_brightness: config.max_brightness,
        }
    }
}

/// What the A/B comparison overlay compares the live state against.
#[derive(Debug, Clone, PartialEq)]
pub enum ComparisonTarget {
    /// A built-in preset.
    Preset(Preset),
    /// A saved config (carries its resolved overrides + label).
    Config(Box<crate::config_manager::NamedProfile>),
}

/// A playing figlet/type preset-switch transition.
#[derive(Debug, Clone)]
pub struct TransitionAnim {
    /// The preset name to display.
    pub name: String,
    /// Optional tagline shown beneath the name (when enabled).
    pub tagline: Option<String>,
    /// The style to render (figlet or type; never toast).
    pub style: TransitionStyle,
    /// `phase_clock` value when the switch happened.
    pub start: f32,
}

#[derive(Debug, Clone)]
/// Global runtime state managing simulation parameters and UI state.
pub struct RuntimeState {
    /// Whether simulation is paused.
    pub is_paused: bool,
    /// Flag set when pause is toggled, cleared after immediate re-render
    pub pause_just_toggled: bool,
    /// Centralized open/close state for all overlays (controls, hints,
    /// dashboard, config browser/save, preset comparison, palette editor).
    pub overlay_state: OverlayState,
    /// Target being compared against in the A/B overlay.
    pub comparison_target: ComparisonTarget,
    /// Current category tab in controls overlay.
    pub controls_category_idx: usize,
    /// Which depth of the Controls surface is showing (Closed / Tuner / Console).
    pub controls_depth: crate::render::controls::ControlsDepth,
    /// Index of the focused parameter row within the Controls surface.
    pub controls_focus: usize,
    /// Time scale multiplier.
    pub time_scale: f32,
    /// Currently active preset.
    pub current_preset: Preset,
    /// Provenance of the currently-applied profile (preset / saved config / CLI).
    pub(crate) active_source: crate::profile::ProfileSource,
    /// Index of current palette.
    pub palette_index: usize,
    /// Index of current charset.
    pub charset_index: usize,
    /// Per-charset color-AA strength, indexed by `charset_index`.
    pub color_aa: [crate::render::antialiasing::AaStrength; crate::render::charset::NUM_CHARSETS],
    /// Random seed used for initialization.
    pub original_seed: u64,
    /// Initialization mode used.
    pub original_init_mode: InitMode,
    /// Current dithering mode.
    pub dither_mode: DitherMode,
    /// Last used dither mode (for toggling).
    pub last_dither_mode: Option<DitherMode>,
    /// Current mouse interaction mode.
    pub mouse_mode: MouseInteractionMode,
    /// Timeout for mouse effects.
    pub mouse_timeout: f32,
    /// Sensor angle.
    pub sensor_angle: f32,
    /// Sensor distance.
    pub sensor_distance: f32,
    /// Rotation angle (max turn per step).
    pub rotation_angle: f32,
    /// Step size.
    pub step_size: f32,
    /// Decay factor.
    pub decay_factor: f32,
    /// Deposit amount.
    pub deposit_amount: f32,
    /// Diffusion kernel.
    pub diffusion_kernel: DiffusionKernel,
    /// Diffusion sigma.
    pub diffusion_sigma: f32,
    /// Attractor strength.
    pub attractor_strength: f32,
    /// Wind direction.
    pub wind_direction: WindDirection,
    /// Terrain type.
    pub terrain_type: TerrainType,
    /// Terrain strength.
    pub terrain_strength: f32,
    /// Auto normalize enabled.
    pub auto_normalize: bool,
    /// Motion blur frames.
    pub motion_blur_frames: usize,
    /// Window frame display mode.
    pub window_frame: WindowFrame,
    /// Chrome display style (minimal, expanded, fullscreen).
    pub chrome_style: ChromeStyle,
    /// How a runtime preset switch is announced (toast/figlet/type).
    pub transition_style: TransitionStyle,
    /// Whether figlet/type transitions show the preset tagline.
    pub transition_tagline: bool,
    /// Active figlet/type transition animation, if one is playing.
    pub active_transition: Option<TransitionAnim>,
    /// User's configured chrome level (sticky base state).
    pub base_chrome_state: ChromeState,
    /// Current transient chrome state.
    pub chrome_state: ChromeState,
    /// Visual aspect ratio of the simulation window.
    pub aspect: Aspect,
    /// Outer padding between terminal edge and window frame.
    pub window_padding: WindowPadding,
    /// Show legacy status bar in windowed mode.
    pub show_status_bar: bool,
    /// Minimum sim size before dropping padding.
    pub min_sim_size: TerminalSizeThreshold,
    /// Minimum sim size before dropping the frame.
    pub min_frame_size: TerminalSizeThreshold,
    /// Brightness white-point divisor (higher = dimmer).
    pub max_brightness: f32,
    /// Fast mode enabled.
    pub fast_mode_enabled: bool,
    /// Palette shift speed.
    pub palette_shift_speed: PaletteShiftSpeed,
    /// Invert palette.
    pub invert_palette: bool,
    /// Reverse palette.
    pub reverse_palette: bool,
    /// Intensity mapping for non-linear color distribution.
    pub intensity_mapping: IntensityMapping,
    /// Index of current intensity mapping preset.
    pub intensity_mapping_index: usize,
    /// Saved palette name (if loaded from saved palette).
    pub saved_palette_name: Option<String>,
    /// Frame counter for entropy collapse detection.
    pub collapse_frame_counter: usize,
    /// Frame counter for stagnation collapse detection (a near-static pattern
    /// that no longer changes, even when entropy is healthy).
    pub stagnation_frame_counter: usize,
    /// Sampled trail values from the previous frame, used to measure
    /// frame-to-frame change for stagnation detection.
    pub prev_trail_sample: Vec<f32>,
    /// Warmup frame counter.
    pub warmup_counter: usize,
    /// Food persistence counter.
    pub food_persist_counter: usize,
    /// Food persistence enabled.
    pub food_persist_enabled: bool,
    /// Initial food attractors.
    pub initial_food_attractors: Vec<crate::simulation::config::Attractor>,
    /// Selected index in config browser.
    pub config_browser_selected_index: usize,
    /// Input buffer for save dialog. `tui_input::Input` tracks value + cursor so the
    /// field supports mid-string editing (arrows/Home/End/Delete), not just append/pop.
    pub config_save_name_input: tui_input::Input,
    /// Default values for reset.
    pub default_values: DefaultValues,
    /// Application-runtime config (warmup / auto-reset / grid / food-persist).
    /// The single live source of truth for restart-only levers the runner reads.
    pub(crate) app: crate::app_config::AppRuntimeConfig,
    /// Precise wind vector mirrored from the sim config. LOSSLESS — preserves
    /// diagonals and magnitude that the coarse `wind_direction` label drops.
    pub wind: Option<Wind>,
    /// Exact active palette (incl. `Custom`/`CustomAscii`). Drives the renderer so
    /// custom palettes survive a load instead of falling back to the index palette.
    pub live_palette: Palette,
    /// Exact active charset (incl. `Custom`/`CustomAscii`).
    pub live_charset: Charset,
    /// The authored overrides describing the currently-active profile. Committed
    /// only after a successful apply so failed loads never leave stale provenance.
    pub(crate) active_overrides: crate::profile_overrides::ProfileOverrides,
    /// A swap the user requested while live edits were dirty, parked until the
    /// dirty-state guard overlay is confirmed (Enter → do the swap) or cancelled
    /// (Esc → cleared). `None` whenever the guard is not pending.
    pub(crate) pending_swap: Option<PendingSwap>,
    /// Render config snapshot captured at launch and after every baseline change;
    /// restored by `apply_render_config` in the `ResetToDefaults` handler.
    pub(crate) baseline_render: crate::render_art_defaults::ResolvedRenderConfig,
    /// Undo history stack.
    pub undo_stack: std::collections::VecDeque<ParameterState>,
    /// Redo history stack.
    pub redo_stack: std::collections::VecDeque<ParameterState>,
    /// Time of last undo checkpoint.
    pub last_checkpoint_time: std::time::Instant,
    /// Recent FPS history.
    pub fps_history: std::collections::VecDeque<f32>,
    /// Recent entropy history.
    pub entropy_history: std::collections::VecDeque<f32>,
    /// Recent density history.
    pub density_history: std::collections::VecDeque<f32>,
    /// Current panel theme/style.
    pub panel_style: PanelStyle,
    /// Index into `ALL_THEMES` for the active UI theme.
    pub theme_index: usize,
    /// Whether shift key is currently held down.
    pub shift_held: bool,
    /// Cached logo brightness map for VCR pause effect: (logo_w, pixel_h, data).
    /// Invalidated when `logo_w` changes (terminal resize).
    pub pause_logo_cache: Option<(usize, usize, Vec<f32>)>,
    /// Frame counter used for badge blink while paused.
    pub pause_frame_counter: u64,
    /// Pause screen visual effect style.
    pub pause_style: PauseStyle,
    /// Whether to show the logo image during pause state.
    pub pause_logo_enabled: bool,
    /// Debug mode: draw wave rings on empty cells in pulse pause effect.
    pub pause_pulse_draw_mode: bool,
    /// Trail age hue shifting enabled.
    pub trail_age_enabled: bool,
    /// Temporal delta brightness boost enabled.
    pub trail_delta_enabled: bool,
    /// Gradient magnitude edge glow enabled.
    pub gradient_magnitude_enabled: bool,
    /// Gradient magnitude strength for edge glow.
    pub gradient_strength: f32,
    /// Trail age hue shift range in degrees.
    pub trail_age_hue_range: f32,
    /// Trail age blend factor between original and age-modified colors.
    pub trail_age_blend: f32,
    /// Trail age hue shift mode (bidirectional or alternating).
    pub trail_age_mode: crate::config_defaults::TrailAgeMode,
    /// Reverse trail age bidirectional hue shift.
    pub trail_age_reverse: bool,
    /// Trail delta brightness boost strength.
    pub trail_delta_strength: f32,
    /// Temporal-color modulation strength (0.0 = off).
    pub temporal_color: f32,
    /// Temporal lag in frames (larger = longer-lived front color).
    pub temporal_lag_frames: f32,
    /// Temporal color modulation mode.
    pub temporal_mode: crate::render::palette::TemporalMode,
    /// Hand-picked front accent color (Accent mode). None = derive from palette hot-end.
    pub temporal_accent: Option<crate::render::palette::RgbColor>,
    /// Afterglow strength (0.0 = off).
    pub afterglow: f32,
    /// Afterglow EMA rate.
    pub afterglow_rate: f32,
    /// Value-dependent decay exponent (1.0 = uniform, <1.0 = faint tails persist longer).
    pub decay_gamma: f32,
    /// Lague diffuse-weight blend factor (1.0 = full blur; 0.0 = no diffusion).
    pub diffuse_weight: f32,
    /// Nonlinear deposit curve.
    pub deposit_curve: crate::simulation::config::DepositCurve,
    /// Deposit scale (post-curve multiplier).
    pub deposit_scale: f32,
    /// Deposit gamma (Pow exponent).
    pub deposit_gamma: f32,
    /// Deposit cap (0 = off).
    pub deposit_cap: f32,
    /// Spatial palette-repeat config (lever 6). Default identity.
    pub palette_cycle: crate::render::palette::PaletteCycle,
    /// Glyph-selection config (lever 10). Default identity (no override).
    pub glyph: crate::render::charset::GlyphConfig,
    /// Monotonic seconds for overlay motion (breath, eases). Advanced each frame by dt.
    pub phase_clock: f32,
    /// Ambient instrument surface states (Task 13+). Always contains a `Base`
    /// sentinel at index 0; `Tune`/`Msg` entries override it while live. The
    /// runner resolves the highest-priority live entry each frame via
    /// [`crate::render::ambient::resolve`].
    pub ambient_states: Vec<crate::render::ambient::AmbientState>,
    /// When `false`, transient notifications are suppressed: toast messages
    /// ([`push_msg`](Self::push_msg)) and the ambient `Tune` param readouts are
    /// not surfaced, leaving the field clean. User-opened overlays (controls,
    /// palette editor, dashboard, preset-transition) are unaffected. Toggled at
    /// runtime with `Ctrl+N`; set false at launch via `--no-notifications`.
    pub notifications_enabled: bool,
}

impl RuntimeState {
    /// Creates a new runtime state with CLI-provided config values.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        seed: u64,
        init_mode: InitMode,
        initial_preset: Preset,
        mouse_mode: MouseInteractionMode,
        mouse_timeout: f32,
        cli_config: &SimConfig,
        pause_style: PauseStyle,
        pause_logo_enabled: bool,
        pause_pulse_draw_mode: bool,
    ) -> Self {
        let default_values = DefaultValues::from_preset(initial_preset);
        Self {
            is_paused: false,
            pause_just_toggled: false,
            overlay_state: OverlayState::default(),
            comparison_target: ComparisonTarget::Preset(initial_preset),
            controls_category_idx: 0,
            controls_depth: crate::render::controls::ControlsDepth::Closed,
            controls_focus: 0,
            time_scale: cli_config.time_scale,
            current_preset: initial_preset,
            active_source: crate::profile::ProfileSource::StartupCli,
            palette_index: 0,
            charset_index: 0,
            color_aa: crate::config_defaults::DEFAULT_COLOR_AA,
            original_seed: seed,
            original_init_mode: init_mode,
            dither_mode: DitherMode::None,
            last_dither_mode: None,
            mouse_mode,
            mouse_timeout,
            sensor_angle: cli_config.sensor_angle,
            sensor_distance: cli_config.sensor_distance,
            rotation_angle: cli_config.rotation_angle,
            step_size: cli_config.step_size,
            decay_factor: cli_config.decay_factor,
            deposit_amount: cli_config.deposit_amount,
            diffusion_kernel: cli_config.diffusion_kernel,
            diffusion_sigma: cli_config.diffusion_sigma,
            attractor_strength: cli_config.attractor_strength,
            wind_direction: match cli_config.wind {
                None => WindDirection::None,
                Some(w) => {
                    if w.dx > 0.0 && w.dy == 0.0 {
                        WindDirection::East
                    } else if w.dx < 0.0 && w.dy == 0.0 {
                        WindDirection::West
                    } else if w.dx == 0.0 && w.dy < 0.0 {
                        WindDirection::North
                    } else if w.dx == 0.0 && w.dy > 0.0 {
                        WindDirection::South
                    } else {
                        WindDirection::None
                    }
                }
            },
            terrain_type: cli_config.terrain,
            terrain_strength: cli_config.terrain_strength,
            auto_normalize: false,
            motion_blur_frames: 0,
            window_frame: cli_config.window_frame,
            chrome_style: cli_config.chrome_style,
            transition_style: cli_config.transition_style,
            transition_tagline: cli_config.transition_tagline,
            active_transition: None,
            base_chrome_state: match cli_config.chrome_style {
                ChromeStyle::Expanded => ChromeState::Expanded,
                ChromeStyle::Minimal => ChromeState::Minimal,
                ChromeStyle::Fullscreen => ChromeState::Minimal,
            },
            chrome_state: match cli_config.chrome_style {
                ChromeStyle::Expanded => ChromeState::Expanded,
                ChromeStyle::Minimal => ChromeState::Minimal,
                ChromeStyle::Fullscreen => ChromeState::Minimal,
            },
            aspect: cli_config.aspect,
            window_padding: cli_config.window_padding,
            show_status_bar: cli_config.show_status_bar,
            min_sim_size: cli_config.min_sim_size,
            min_frame_size: cli_config.min_frame_size,
            max_brightness: cli_config.max_brightness,
            fast_mode_enabled: false,
            palette_shift_speed: PaletteShiftSpeed::Off,
            invert_palette: false,
            reverse_palette: false,
            intensity_mapping: crate::render::palette::IntensityMapping::linear(),
            intensity_mapping_index: 0,
            saved_palette_name: None,
            collapse_frame_counter: 0,
            stagnation_frame_counter: 0,
            prev_trail_sample: Vec::new(),
            warmup_counter: 0,
            food_persist_counter: 0,
            food_persist_enabled: false,
            initial_food_attractors: Vec::new(),
            config_browser_selected_index: 0,
            config_save_name_input: tui_input::Input::default(),
            default_values,
            app: crate::app_config::AppRuntimeConfig::default(),
            wind: cli_config.wind,
            live_palette: Palette::Organic,
            live_charset: Charset::HalfBlock,
            active_overrides: crate::profile_overrides::ProfileOverrides::default(),
            pending_swap: None,
            baseline_render: crate::render_art_defaults::ResolvedRenderConfig::default(),
            undo_stack: std::collections::VecDeque::with_capacity(50),
            redo_stack: std::collections::VecDeque::with_capacity(50),
            last_checkpoint_time: std::time::Instant::now(),
            fps_history: std::collections::VecDeque::with_capacity(60),
            entropy_history: std::collections::VecDeque::with_capacity(60),
            density_history: std::collections::VecDeque::with_capacity(60),
            panel_style: GRUVBOX_DARK,
            theme_index: 0,
            shift_held: false,
            pause_logo_cache: None,
            pause_frame_counter: 0,
            pause_style,
            pause_logo_enabled,
            pause_pulse_draw_mode,
            trail_age_enabled: false,
            trail_delta_enabled: false,
            gradient_magnitude_enabled: false,
            gradient_strength: 0.3,
            trail_age_hue_range: 15.0,
            trail_age_blend: 0.5,
            trail_age_mode: crate::config_defaults::TrailAgeMode::Bidirectional,
            trail_age_reverse: true,
            trail_delta_strength: 0.5,
            temporal_color: 0.0,
            temporal_lag_frames: 8.0,
            temporal_mode: crate::render::palette::TemporalMode::Hue,
            temporal_accent: None,
            afterglow: 0.0,
            afterglow_rate: 0.05,
            decay_gamma: cli_config.decay_gamma,
            diffuse_weight: cli_config.diffuse_weight,
            deposit_curve: cli_config.deposit_curve,
            deposit_scale: cli_config.deposit_scale,
            deposit_gamma: cli_config.deposit_gamma,
            deposit_cap: cli_config.deposit_cap,
            palette_cycle: crate::render::palette::PaletteCycle::default(),
            glyph: crate::render::charset::GlyphConfig::default(),
            phase_clock: 0.0,
            ambient_states: vec![crate::render::ambient::AmbientState::Base],
            notifications_enabled: true,
        }
    }

    /// Stores the resolved render config as the baseline for reset.
    pub(crate) fn set_render_baseline(
        &mut self,
        r: crate::render_art_defaults::ResolvedRenderConfig,
    ) {
        self.baseline_render = r;
    }

    /// Mirror the sim-side levers from a `SimConfig` into runtime state.
    ///
    /// This is the single source of truth for "what RuntimeState reflects from the
    /// sim config" — used by `apply_overrides` so a load/swap pushes every sim lever
    /// into the live-adjustable mirror. It writes ONLY sim-mirror fields.
    ///
    /// Wind is preserved LOSSLESSLY: `self.wind = sim.wind` verbatim (the coarse
    /// `wind_direction` label is derived for display only and never round-trips the
    /// precise vector).
    pub(crate) fn sync_sim_levers(&mut self, sim: &SimConfig) {
        self.sensor_angle = sim.sensor_angle;
        self.sensor_distance = sim.sensor_distance;
        self.rotation_angle = sim.rotation_angle;
        self.step_size = sim.step_size;
        self.decay_factor = sim.decay_factor;
        self.deposit_amount = sim.deposit_amount;
        self.diffusion_kernel = sim.diffusion_kernel;
        self.diffusion_sigma = sim.diffusion_sigma;
        self.attractor_strength = sim.attractor_strength;
        self.terrain_type = sim.terrain;
        self.terrain_strength = sim.terrain_strength;
        self.max_brightness = sim.max_brightness;
        self.decay_gamma = sim.decay_gamma;
        self.diffuse_weight = sim.diffuse_weight;
        self.deposit_curve = sim.deposit_curve;
        self.deposit_scale = sim.deposit_scale;
        self.deposit_gamma = sim.deposit_gamma;
        self.deposit_cap = sim.deposit_cap;
        self.time_scale = sim.time_scale;
        self.window_frame = sim.window_frame;
        self.chrome_style = sim.chrome_style;
        self.transition_style = sim.transition_style;
        self.transition_tagline = sim.transition_tagline;
        self.aspect = sim.aspect;
        self.window_padding = sim.window_padding;
        self.show_status_bar = sim.show_status_bar;
        self.min_sim_size = sim.min_sim_size;
        self.min_frame_size = sim.min_frame_size;
        // LOSSLESS wind: store the precise vector; derive the coarse label for display.
        self.wind = sim.wind;
        self.wind_direction = WindDirection::from_wind(sim.wind);
    }

    /// Captures the current state of parameters for undo.
    pub fn capture_parameter_state(&self) -> ParameterState {
        ParameterState {
            sensor_angle: self.sensor_angle,
            sensor_distance: self.sensor_distance,
            rotation_angle: self.rotation_angle,
            step_size: self.step_size,
            decay_factor: self.decay_factor,
            deposit_amount: self.deposit_amount,
            diffusion_kernel: self.diffusion_kernel,
            diffusion_sigma: self.diffusion_sigma,
            attractor_strength: self.attractor_strength,
            wind_direction: self.wind_direction,
            terrain_type: self.terrain_type,
            terrain_strength: self.terrain_strength,
            max_brightness: self.max_brightness,
            palette_index: self.palette_index,
            charset_index: self.charset_index,
            invert_palette: self.invert_palette,
            reverse_palette: self.reverse_palette,
            dither_mode: self.dither_mode,
            motion_blur_frames: self.motion_blur_frames,
            window_frame: self.window_frame,
            color_aa: self.color_aa,
        }
    }

    /// Restores parameters from a saved state.
    pub fn apply_parameter_state(&mut self, state: ParameterState) {
        self.sensor_angle = state.sensor_angle;
        self.sensor_distance = state.sensor_distance;
        self.rotation_angle = state.rotation_angle;
        self.step_size = state.step_size;
        self.decay_factor = state.decay_factor;
        self.deposit_amount = state.deposit_amount;
        self.diffusion_kernel = state.diffusion_kernel;
        self.diffusion_sigma = state.diffusion_sigma;
        self.attractor_strength = state.attractor_strength;
        self.wind_direction = state.wind_direction;
        self.terrain_type = state.terrain_type;
        self.terrain_strength = state.terrain_strength;
        self.max_brightness = state.max_brightness;
        self.palette_index = state.palette_index;
        self.charset_index = state.charset_index;
        self.invert_palette = state.invert_palette;
        self.reverse_palette = state.reverse_palette;
        self.dither_mode = state.dither_mode;
        self.motion_blur_frames = state.motion_blur_frames;
        self.window_frame = state.window_frame;
        self.color_aa = state.color_aa;
        // Keep the exact live palette/charset in step with the restored indices.
        self.sync_live_from_index();
    }

    /// Creates an undo checkpoint if enough time has passed.
    pub fn checkpoint(&mut self) {
        if self.last_checkpoint_time.elapsed().as_millis() < 500 {
            return;
        }

        let current = self.capture_parameter_state();
        if let Some(last) = self.undo_stack.back() {
            if last == &current {
                return;
            }
        }

        self.undo_stack.push_back(current);
        if self.undo_stack.len() > 50 {
            self.undo_stack.pop_front();
        }
        self.redo_stack.clear();
        self.last_checkpoint_time = std::time::Instant::now();
    }

    /// Forces creation of an undo checkpoint regardless of time.
    pub fn force_checkpoint(&mut self) {
        let current = self.capture_parameter_state();
        self.undo_stack.push_back(current);
        if self.undo_stack.len() > 50 {
            self.undo_stack.pop_front();
        }
        self.redo_stack.clear();
        self.last_checkpoint_time = std::time::Instant::now();
    }

    /// Undoes the last parameter change.
    pub fn undo(&mut self) -> Option<ParameterState> {
        if self.undo_stack.is_empty() {
            return None;
        }

        let current = self.capture_parameter_state();
        self.redo_stack.push_back(current);

        let previous = self.undo_stack.pop_back().unwrap();
        self.apply_parameter_state(previous.clone());
        Some(previous)
    }

    /// Redoes the last undone change.
    pub fn redo(&mut self) -> Option<ParameterState> {
        if self.redo_stack.is_empty() {
            return None;
        }

        let current = self.capture_parameter_state();
        self.undo_stack.push_back(current);

        let next = self.redo_stack.pop_back().unwrap();
        self.apply_parameter_state(next.clone());
        Some(next)
    }

    /// Toggles the paused state.
    pub fn toggle_pause(&mut self) {
        self.is_paused = !self.is_paused;
    }

    /// Pre-loads the pause logo to avoid delay on first pause.
    ///
    /// Calculates logo size from terminal dimensions and loads the embedded
    /// PNG using Lanczos3 filtering, then caches the result.
    pub fn preload_pause_logo(&mut self, term_width: usize, _term_height: usize) {
        // Use same percentage logic as pause rendering to ensure cache hits
        let pct = if term_width < 80 {
            0.90
        } else if term_width < 120 {
            0.75
        } else {
            0.60
        };
        let logo_w = ((term_width as f32 * pct) as usize).clamp(30, 180);
        let logo_h = ((logo_w as f32 / 2.67) as usize).max(6);
        let pixel_w = logo_w * 2;
        let pixel_h = logo_h * 2;

        let map = load_logo_from_memory(FOOD_IMAGE_PNG, pixel_w, pixel_h, true)
            .unwrap_or_else(|_| vec![0.0; pixel_w * pixel_h]);
        self.pause_logo_cache = Some((logo_w, pixel_h, map));
    }

    /// Toggles the controls overlay.
    pub fn toggle_controls(&mut self) {
        self.overlay_state.toggle(OverlayType::Controls);
    }

    /// Toggles the keyboard shortcuts overlay.
    pub fn toggle_keyboard_hints(&mut self) {
        self.overlay_state.toggle(OverlayType::KeyboardHints);
    }

    /// Toggles the comparison overlay against the given target.
    pub fn toggle_comparison(&mut self, target: ComparisonTarget) {
        if self.overlay_state.is_open(OverlayType::PresetComparison)
            && self.comparison_target == target
        {
            self.overlay_state.close();
        } else {
            self.overlay_state.open(OverlayType::PresetComparison);
            self.comparison_target = target;
        }
    }

    /// Checks if any overlay window is currently open.
    pub fn any_overlay_open(&self) -> bool {
        self.overlay_state.any_open()
    }

    /// Closes all open overlay windows.
    ///
    /// Also resets PaletteEditor sub-state so no stale `palette_editor` Option
    /// survives a programmatic close (e.g. preset swap, config save/load/reset).
    pub fn close_all_overlays(&mut self) {
        self.overlay_state.palette_editor = None;
        self.overlay_state.close();
    }

    /// Toggles palette editor (mutually exclusive with other overlays).
    pub fn toggle_palette_editor(&mut self) {
        self.overlay_state.toggle(OverlayType::PaletteEditor);
    }

    /// Returns whether the given overlay type is currently active.
    pub fn is_overlay_active(&self, overlay: OverlayType) -> bool {
        self.overlay_state.is_open(overlay)
    }

    /// Cycles through control categories.
    pub fn cycle_controls_category(&mut self, forward: bool) {
        let total = crate::render::controls::registry::CATEGORY_NAMES.len();
        if forward {
            self.controls_category_idx = (self.controls_category_idx + 1) % total;
        } else {
            self.controls_category_idx = if self.controls_category_idx == 0 {
                total - 1
            } else {
                self.controls_category_idx - 1
            };
        }
    }

    /// Applies a preset configuration.
    pub fn set_preset(&mut self, preset: Preset) {
        self.force_checkpoint();
        self.current_preset = preset;
        self.default_values = DefaultValues::from_preset(preset);
    }

    /// Adjusts simulation time scale.
    pub fn adjust_time_scale(&mut self, delta: f32) {
        self.checkpoint();
        let new_scale = (self.time_scale + delta).clamp(0.5, 10.0);
        self.time_scale = new_scale;
    }

    /// Available intensity mapping presets.
    #[allow(clippy::type_complexity)]
    pub const INTENSITY_MAPPINGS: &'static [(&'static str, fn() -> IntensityMapping)] = &[
        ("Linear", || IntensityMapping::linear()),
        ("Logarithmic", || IntensityMapping::logarithmic(10.0)),
        ("Exponential", || IntensityMapping::exponential(10.0)),
        ("Square Root", || {
            IntensityMapping::new(vec![crate::render::palette::MappingSegment {
                start: 0.0,
                end: 1.0,
                function: crate::render::palette::MappingFunction::SquareRoot,
            }])
            .unwrap()
        }),
        ("Smoothstep", || IntensityMapping::smoothstep()),
        ("Split (Lin/Log)", || {
            IntensityMapping::linear_log_split(10.0)
        }),
        ("Quantize 6", || IntensityMapping::quantize(6)),
        ("Perlin", || {
            IntensityMapping::perlin(0.15, 4.0, PERLIN_SEED)
        }),
    ];

    /// Finds the index of a given intensity mapping by comparing with presets.
    /// Returns 0 if no match found (falls back to Linear).
    pub(crate) fn find_intensity_mapping_index(mapping: &IntensityMapping) -> usize {
        for (i, (_, factory)) in Self::INTENSITY_MAPPINGS.iter().enumerate() {
            if &factory() == mapping {
                return i;
            }
        }
        0
    }

    /// Cycles through the available intensity mapping presets.
    pub fn cycle_intensity_mapping(&mut self, reverse: bool) {
        let count = Self::INTENSITY_MAPPINGS.len();
        if reverse {
            self.intensity_mapping_index = (self.intensity_mapping_index + count - 1) % count;
        } else {
            self.intensity_mapping_index = (self.intensity_mapping_index + 1) % count;
        }
        let (_, factory) = Self::INTENSITY_MAPPINGS[self.intensity_mapping_index];
        self.intensity_mapping = factory();
    }

    /// Returns the name of the current intensity mapping preset.
    pub fn intensity_mapping_name(&self) -> &'static str {
        Self::INTENSITY_MAPPINGS[self.intensity_mapping_index].0
    }

    /// Sync the exact `live_palette`/`live_charset` from the current indices.
    ///
    /// Cycling moves to a built-in palette/charset, so the live value tracks the
    /// index. Custom values are only ever set by the apply path, never by cycling.
    fn sync_live_from_index(&mut self) {
        self.live_palette = ALL_PALETTES[self.palette_index].clone();
        self.live_charset = ALL_CHARSETS[self.charset_index].clone();
    }

    /// Cycles to the next color palette.
    pub fn cycle_palette(&mut self, num_palettes: usize) {
        self.force_checkpoint();
        self.palette_index = (self.palette_index + 1) % num_palettes;
        self.sync_live_from_index();
    }

    /// Cycles to the previous color palette.
    pub fn cycle_palette_reverse(&mut self, num_palettes: usize) {
        self.force_checkpoint();
        if self.palette_index == 0 {
            self.palette_index = num_palettes - 1;
        } else {
            self.palette_index -= 1;
        }
        self.sync_live_from_index();
    }

    /// Cycles to the next charset.
    pub fn cycle_charset(&mut self) {
        self.force_checkpoint();
        self.charset_index = (self.charset_index + 1) % ALL_CHARSETS.len();
        self.sync_live_from_index();
    }

    /// Cycles to the previous charset.
    pub fn cycle_charset_reverse(&mut self) {
        self.force_checkpoint();
        if self.charset_index == 0 {
            self.charset_index = ALL_CHARSETS.len() - 1;
        } else {
            self.charset_index -= 1;
        }
        self.sync_live_from_index();
    }

    /// Cycles to the next window frame mode.
    pub fn cycle_window_frame(&mut self) {
        self.force_checkpoint();
        self.window_frame = match self.window_frame {
            WindowFrame::None => WindowFrame::Accented,
            WindowFrame::Accented => WindowFrame::Glow,
            WindowFrame::Glow => WindowFrame::Frame,
            WindowFrame::Frame => WindowFrame::None,
        };
    }

    /// Cycles to the previous window frame mode.
    pub fn cycle_window_frame_reverse(&mut self) {
        self.force_checkpoint();
        self.window_frame = match self.window_frame {
            WindowFrame::None => WindowFrame::Frame,
            WindowFrame::Accented => WindowFrame::None,
            WindowFrame::Glow => WindowFrame::Accented,
            WindowFrame::Frame => WindowFrame::Glow,
        };
    }

    /// Cycles the chrome style Minimal → Expanded → Fullscreen → Minimal and
    /// syncs the chrome state. Setting `base_chrome_state = Expanded` is what
    /// makes the sticky title+footer reachable at runtime (see the pause/overlay
    /// logic that reads `base_chrome_state`).
    pub fn cycle_chrome_style(&mut self) {
        use crate::simulation::config::ChromeStyle;
        self.chrome_style = match self.chrome_style {
            ChromeStyle::Minimal => ChromeStyle::Expanded,
            ChromeStyle::Expanded => ChromeStyle::Fullscreen,
            ChromeStyle::Fullscreen => ChromeStyle::Minimal,
        };
        let cs = match self.chrome_style {
            ChromeStyle::Expanded => ChromeState::Expanded,
            ChromeStyle::Minimal => ChromeState::Minimal,
            ChromeStyle::Fullscreen => ChromeState::Minimal,
        };
        self.base_chrome_state = cs;
        self.chrome_state = cs;
    }

    /// Gets the currently active charset.
    pub fn current_charset(&self) -> Charset {
        ALL_CHARSETS[self.charset_index].clone()
    }

    /// Color-AA strength for the active charset.
    pub fn current_color_aa(&self) -> crate::render::antialiasing::AaStrength {
        self.color_aa[self.charset_index % self.color_aa.len()]
    }

    /// Apply a CLI `--color-aa` override to the launch charset's slot.
    pub fn apply_cli_color_aa(&mut self, aa: crate::render::antialiasing::AaStrength) {
        let i = self.charset_index;
        self.color_aa[i] = aa;
    }

    /// Restore per-charset color-AA from a `ProfileOverrides`.
    ///
    /// Priority: `color_aa_all` (full array) > `color_aa` (active-slot scalar) > leave defaults.
    /// The caller must then push `rs.current_color_aa()` into the renderer.
    pub fn apply_color_aa_all(&mut self, ov: &crate::profile_overrides::ProfileOverrides) {
        if let Some(ref all) = ov.color_aa_all {
            for (slot, aa) in self.color_aa.iter_mut().zip(all.iter()) {
                *slot = *aa;
            }
        } else if let Some(aa) = ov.color_aa {
            let i = self.charset_index % self.color_aa.len();
            self.color_aa[i] = aa;
        }
        // else: leave all slots at their current defaults
    }

    /// Returns true when the live session has diverged from `active_overrides`
    /// in a way that would be lost by an unguarded swap (preset/config/reset).
    ///
    /// Dirty is a CANONICAL-PROJECTION compare ([P1]), NOT resolved-`Profile`
    /// equality: the live state is captured into a [`ProfileOverrides`] and both it
    /// and `active_overrides` are run through
    /// [`project`](crate::profile_overrides::project), which folds in the apply-only
    /// flags (reverse/invert/food_persist), reproduces the per-charset color-AA
    /// array, and buckets `hue_shift`. A resolve error on EITHER side is treated as
    /// dirty so a swap prompts rather than silently discarding edits.
    pub(crate) fn is_dirty(&self, sim: &SimConfig, palette: Palette, charset: Charset) -> bool {
        let live = crate::config_manager::capture_overrides(sim, palette, charset, self);
        use crate::profile_overrides::project;
        match (project(&live), project(&self.active_overrides)) {
            (Ok(a), Ok(b)) => a != b,
            // resolve error → prompt rather than silently discard
            _ => true,
        }
    }

    /// Cycle the active charset's color-AA strength. No-op (returns false) when
    /// the active charset is AA-ineligible.
    pub fn cycle_color_aa(&mut self) -> bool {
        if !crate::render::antialiasing::charset_aa_eligible(&self.current_charset()) {
            return false;
        }
        let i = self.charset_index % self.color_aa.len();
        self.color_aa[i] = self.color_aa[i].cycle();
        true
    }

    /// Gets the currently active palette.
    pub fn current_palette(&self, palettes: &[Palette; NUM_PALETTES]) -> Palette {
        palettes[self.palette_index].clone()
    }

    /// Toggles dithering on/off.
    pub fn toggle_dither(&mut self) {
        self.force_checkpoint();
        self.dither_mode = match self.dither_mode {
            DitherMode::None => {
                if let Some(last) = self.last_dither_mode {
                    last
                } else {
                    DitherMode::Ordered {
                        intensity: dither_consts::DEFAULT_INTENSITY,
                        matrix: DitherMatrix::Bayer4x4,
                    }
                }
            }
            mode => {
                self.last_dither_mode = Some(mode);
                DitherMode::None
            }
        };
    }

    /// Cycles through available dithering modes.
    pub fn cycle_dither_mode(&mut self) {
        self.force_checkpoint();
        self.dither_mode = match self.dither_mode {
            DitherMode::None => DitherMode::Ordered {
                intensity: dither_consts::DEFAULT_INTENSITY,
                matrix: DitherMatrix::Bayer4x4,
            },
            DitherMode::Ordered {
                intensity: _,
                matrix: _,
            } => DitherMode::ErrorDiffusion { serpentine: true },
            DitherMode::ErrorDiffusion { .. } => DitherMode::Hybrid {
                edge_threshold: dither_consts::DEFAULT_HYBRID_EDGE_THRESHOLD,
                intensity: dither_consts::DEFAULT_INTENSITY,
                matrix: DitherMatrix::Bayer4x4,
            },
            DitherMode::Hybrid { .. } => DitherMode::None,
        };
        if self.dither_mode != DitherMode::None {
            self.last_dither_mode = Some(self.dither_mode);
        }
    }

    /// Adjusts dithering intensity.
    pub fn adjust_dither_intensity(&mut self, delta: f32) {
        self.checkpoint();
        self.dither_mode = match self.dither_mode {
            DitherMode::Ordered { intensity, matrix } => {
                let new_intensity = (intensity + delta).clamp(0.0, 1.0);
                DitherMode::Ordered {
                    intensity: new_intensity,
                    matrix,
                }
            }
            DitherMode::Hybrid {
                edge_threshold,
                intensity,
                matrix,
            } => {
                let new_intensity = (intensity + delta).clamp(0.0, 1.0);
                DitherMode::Hybrid {
                    edge_threshold,
                    intensity: new_intensity,
                    matrix,
                }
            }
            _ => self.dither_mode,
        };
    }

    /// Adjusts sensor angle.
    pub fn adjust_sensor_angle(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value = (self.sensor_angle + delta).clamp(
            agent_consts::MIN_SENSOR_ANGLE,
            agent_consts::MAX_SENSOR_ANGLE,
        );
        let at_bound = (new_value - self.sensor_angle).abs() < 0.01;
        self.sensor_angle = new_value;
        at_bound
    }

    /// Adjusts sensor distance.
    pub fn adjust_sensor_distance(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value = (self.sensor_distance + delta).clamp(
            agent_consts::MIN_SENSOR_DISTANCE,
            agent_consts::MAX_SENSOR_DISTANCE,
        );
        let at_bound = (new_value - self.sensor_distance).abs() < 0.01;
        self.sensor_distance = new_value;
        at_bound
    }

    /// Adjusts rotation angle.
    pub fn adjust_rotation_angle(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value = (self.rotation_angle + delta).clamp(
            agent_consts::MIN_ROTATION_ANGLE,
            agent_consts::MAX_ROTATION_ANGLE,
        );
        let at_bound = (new_value - self.rotation_angle).abs() < 0.01;
        self.rotation_angle = new_value;
        at_bound
    }

    /// Adjusts step size.
    pub fn adjust_step_size(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value = (self.step_size + delta)
            .clamp(agent_consts::MIN_STEP_SIZE, agent_consts::MAX_STEP_SIZE);
        let at_bound = (new_value - self.step_size).abs() < 0.01;
        self.step_size = new_value;
        at_bound
    }

    /// Adjusts decay factor.
    pub fn adjust_decay(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value =
            (self.decay_factor + delta).clamp(trail::MIN_DECAY_FACTOR, trail::MAX_DECAY_FACTOR);
        let at_bound = (new_value - self.decay_factor).abs() < 0.001;
        self.decay_factor = new_value;
        at_bound
    }

    /// Adjusts deposit amount.
    pub fn adjust_deposit(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value = (self.deposit_amount + delta).clamp(
            agent_consts::MIN_DEPOSIT_AMOUNT,
            agent_consts::MAX_DEPOSIT_AMOUNT,
        );
        let at_bound = (new_value - self.deposit_amount).abs() < 0.01;
        self.deposit_amount = new_value;
        at_bound
    }

    /// Cycles through diffusion kernels.
    pub fn cycle_diffusion_kernel(&mut self) {
        self.force_checkpoint();
        self.diffusion_kernel = match self.diffusion_kernel {
            DiffusionKernel::Mean3x3 => DiffusionKernel::Gaussian,
            DiffusionKernel::Gaussian => DiffusionKernel::Mean3x3,
        };
    }

    /// Adjusts diffusion sigma (for Gaussian kernel).
    pub fn adjust_diffusion_sigma(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value = (self.diffusion_sigma + delta)
            .clamp(trail::MIN_DIFFUSION_SIGMA, trail::MAX_DIFFUSION_SIGMA);
        let at_bound = (new_value - self.diffusion_sigma).abs() < 0.01;
        self.diffusion_sigma = new_value;
        at_bound
    }

    /// Adjusts global attractor strength.
    pub fn adjust_attractor_strength(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value = (self.attractor_strength + delta).clamp(
            env_consts::MIN_ATTRACTOR_STRENGTH,
            env_consts::MAX_ATTRACTOR_STRENGTH,
        );
        let at_bound = (new_value - self.attractor_strength).abs() < 0.01;
        self.attractor_strength = new_value;
        at_bound
    }

    /// Cycles mouse interaction mode.
    pub fn cycle_mouse_mode(&mut self) {
        self.force_checkpoint();
        self.mouse_mode = match self.mouse_mode {
            MouseInteractionMode::Disabled => MouseInteractionMode::Attract,
            MouseInteractionMode::Attract => MouseInteractionMode::Repel,
            MouseInteractionMode::Repel => MouseInteractionMode::Disabled,
        };
    }

    /// Cycles wind direction.
    pub fn cycle_wind_direction(&mut self) {
        self.force_checkpoint();
        self.wind_direction = match self.wind_direction {
            WindDirection::None => WindDirection::North,
            WindDirection::North => WindDirection::Northeast,
            WindDirection::Northeast => WindDirection::East,
            WindDirection::East => WindDirection::Southeast,
            WindDirection::Southeast => WindDirection::South,
            WindDirection::South => WindDirection::Southwest,
            WindDirection::Southwest => WindDirection::West,
            WindDirection::West => WindDirection::Northwest,
            WindDirection::Northwest => WindDirection::None,
        };
    }

    /// Cycles wind direction in reverse.
    pub fn cycle_wind_direction_reverse(&mut self) {
        self.force_checkpoint();
        self.wind_direction = match self.wind_direction {
            WindDirection::None => WindDirection::Northwest,
            WindDirection::North => WindDirection::None,
            WindDirection::Northeast => WindDirection::North,
            WindDirection::East => WindDirection::Northeast,
            WindDirection::Southeast => WindDirection::East,
            WindDirection::South => WindDirection::Southeast,
            WindDirection::Southwest => WindDirection::South,
            WindDirection::West => WindDirection::Southwest,
            WindDirection::Northwest => WindDirection::West,
        };
    }

    /// Adjusts terrain strength.
    pub fn adjust_terrain_strength(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value = (self.terrain_strength + delta).clamp(
            env_consts::MIN_TERRAIN_STRENGTH,
            env_consts::MAX_TERRAIN_STRENGTH,
        );
        let at_bound = (new_value - self.terrain_strength).abs() < 0.01;
        self.terrain_strength = new_value;
        at_bound
    }

    /// Cycles terrain type.
    pub fn cycle_terrain_type(&mut self) {
        self.force_checkpoint();
        self.terrain_type = match self.terrain_type {
            TerrainType::None => TerrainType::Smooth,
            TerrainType::Smooth => TerrainType::Turbulent,
            TerrainType::Turbulent => TerrainType::Mixed,
            TerrainType::Mixed => TerrainType::None,
        };
    }

    /// Toggles auto-normalization of brightness.
    pub fn toggle_auto_normalize(&mut self) {
        self.force_checkpoint();
        self.auto_normalize = !self.auto_normalize;
    }

    /// Cycles motion blur amount.
    pub fn cycle_motion_blur(&mut self) {
        self.force_checkpoint();
        self.motion_blur_frames = match self.motion_blur_frames {
            0 => 3,
            3 => 5,
            5 => 7,
            7 => 0,
            _ => 0,
        };
    }

    /// Adjusts the brightness white-point divisor (positive delta = dimmer).
    pub fn adjust_max_brightness(&mut self, delta: f32) -> bool {
        self.checkpoint();
        let new_value = (self.max_brightness + delta)
            .clamp(trail::MIN_MAX_BRIGHTNESS, trail::MAX_MAX_BRIGHTNESS);
        let at_bound = (new_value - self.max_brightness).abs() < 0.01;
        self.max_brightness = new_value;
        at_bound
    }

    /// Toggles fast rendering mode.
    pub fn toggle_fast_mode(&mut self) {
        self.force_checkpoint();
        self.fast_mode_enabled = !self.fast_mode_enabled;
    }

    /// Cycles palette shift speed.
    pub fn cycle_palette_shift_speed(&mut self) {
        self.force_checkpoint();
        self.palette_shift_speed = match self.palette_shift_speed {
            PaletteShiftSpeed::Off => PaletteShiftSpeed::Slow,
            PaletteShiftSpeed::Slow => PaletteShiftSpeed::Medium,
            PaletteShiftSpeed::Medium => PaletteShiftSpeed::Fast,
            PaletteShiftSpeed::Fast => PaletteShiftSpeed::Off,
        };
    }

    /// Toggles inverted palette.
    pub fn toggle_invert_palette(&mut self) {
        self.force_checkpoint();
        self.invert_palette = !self.invert_palette;
    }

    /// Toggles reversed palette.
    pub fn toggle_reverse_palette(&mut self) {
        self.force_checkpoint();
        self.reverse_palette = !self.reverse_palette;
    }

    /// Toggles dashboard overlay (merged stats + info).
    pub fn toggle_dashboard(&mut self) {
        self.overlay_state.toggle(OverlayType::Dashboard);
    }

    /// Called when simulation is paused.
    ///
    /// If the base chrome state is Minimal, expands chrome to show title + footer
    /// — but only when no overlay is open. An open overlay owns the screen, and
    /// the status bar would just clutter behind it, so the chrome stays collapsed
    /// (the status bar appears on pause **and** no-overlay only).
    /// If base is Expanded, stays Expanded (no change needed).
    /// Also cancels any in-progress fade-out, snapping back to Expanded.
    pub fn on_pause(&mut self) {
        if self.base_chrome_state == ChromeState::Minimal && !self.any_overlay_open() {
            self.chrome_state = ChromeState::Expanded; // cancels any fade
        }
    }

    /// Called when simulation is unpaused.
    ///
    /// If base is Minimal and no modal is open, collapses chrome back to Minimal.
    /// If base is Expanded, stays Expanded.
    pub fn on_unpause(&mut self) {
        if self.base_chrome_state == ChromeState::Minimal && !self.any_overlay_open() {
            self.chrome_state = ChromeState::Minimal;
        }
    }

    /// Called when simulation is unpaused with a fade-out animation.
    ///
    /// If base is Minimal and no modal is open, enters FadingOut(1.0) state
    /// so the chrome fades over 500ms before disappearing.
    pub fn on_unpause_with_fade(&mut self) {
        if self.base_chrome_state == ChromeState::Minimal && !self.any_overlay_open() {
            self.chrome_state = ChromeState::FadingOut(1.0);
        }
    }

    /// Duration of the chrome fade-out animation in seconds.
    const FADE_DURATION_SECS: f32 = 0.5;

    /// Advances the fade-out animation by `dt_secs` wall-clock seconds.
    ///
    /// Decrements the progress counter (1.0 → 0.0 over `FADE_DURATION_SECS`).
    /// When progress reaches 0.0, transitions to Minimal.
    ///
    /// `dt_secs` must be raw elapsed wall-clock time, **not** time-scale-multiplied,
    /// so the animation completes in a consistent 500 ms regardless of simulation speed.
    pub fn advance_fade(&mut self, dt_secs: f32) {
        if let ChromeState::FadingOut(ref mut p) = self.chrome_state {
            *p -= dt_secs / Self::FADE_DURATION_SECS;
            if *p <= 0.0 {
                self.chrome_state = ChromeState::Minimal;
            }
        }
    }

    /// Advances the monotonic phase clock by `dt` seconds (clamped frame time).
    pub fn advance_phase(&mut self, dt: f32) {
        // Wrap at a large multiple of 2π so f32 precision stays good for sin().
        const WRAP: f32 = 100_000.0;
        self.phase_clock = (self.phase_clock + dt) % WRAP;
    }

    /// Called when a modal pane is opened.
    ///
    /// The overlay owns the screen. With persistent (Expanded) base chrome the
    /// title + footer stay visible as ModalPane (with modal-navigation keybinds);
    /// with the default Minimal base, chrome collapses to Minimal so the status
    /// bar / title block don't clutter behind the overlay.
    pub fn on_modal_open(&mut self) {
        if self.base_chrome_state == ChromeState::Expanded {
            self.chrome_state = ChromeState::ModalPane;
        } else {
            self.chrome_state = ChromeState::Minimal;
        }
    }

    /// Called when the last modal pane is closed.
    ///
    /// If base is Expanded, or base is Minimal but sim is paused: stays/returns to Expanded.
    /// If base is Minimal and not paused: collapses to Minimal.
    pub fn on_modal_close(&mut self) {
        if self.any_overlay_open() {
            return; // not the last overlay — don't change chrome state
        }
        if self.base_chrome_state == ChromeState::Expanded || self.is_paused {
            self.chrome_state = ChromeState::Expanded;
        } else {
            self.chrome_state = ChromeState::Minimal;
        }
    }

    /// Resets the non-lever transient flags that the `apply_overrides` seam does not touch.
    /// The caller must call `apply_overrides(active_overrides, …, restart: true)` after this
    /// to restore all sim/render/app levers and trigger a fresh trail restart.
    pub fn reset_transient(&mut self) {
        self.force_checkpoint();
        self.auto_normalize = false;
        self.motion_blur_frames = 0;
        self.fast_mode_enabled = false;
    }

    /// Randomizes simulation parameters.
    pub fn randomize_params(&mut self) {
        self.force_checkpoint();
        let mut rng = rand::thread_rng();

        self.sensor_angle = rng.gen_range(15.0..60.0);
        self.rotation_angle = rng.gen_range(15.0..60.0);
        self.step_size = rng.gen_range(0.5..2.5);
        self.decay_factor = rng.gen_range(0.80..0.98);
        self.deposit_amount = rng.gen_range(2.0..10.0);

        self.diffusion_kernel = if rng.gen_bool(0.3) {
            DiffusionKernel::Gaussian
        } else {
            DiffusionKernel::Mean3x3
        };

        self.terrain_type = match rng.gen_range(0..4) {
            0 => TerrainType::None,
            1 => TerrainType::Smooth,
            2 => TerrainType::Turbulent,
            _ => TerrainType::Mixed,
        };
        self.terrain_strength = rng.gen_range(0.5..3.0);

        self.palette_index = rng.gen_range(0..ALL_PALETTES.len());
        self.live_palette = ALL_PALETTES[self.palette_index].clone();
        self.max_brightness = rng.gen_range(10.0..40.0);
    }

    /// Pushes a [`crate::render::ambient::AmbientState::Msg`] onto the ambient
    /// states vec, replacing any existing MSG so messages don't stack unbounded.
    ///
    /// At most one active MSG is kept. The duration is determined by `level`
    /// via [`crate::render::ambient::msg`].
    pub fn push_msg(&mut self, level: NotificationLevel, text: String) {
        if !self.notifications_enabled {
            return;
        }
        let now = self.phase_clock;
        self.ambient_states
            .retain(|s| !matches!(s, crate::render::ambient::AmbientState::Msg { .. }));
        self.ambient_states
            .push(crate::render::ambient::msg(level, text, now));
    }

    /// Shows a temporary notification message at Info level.
    pub fn show_notification(&mut self, message: String) {
        self.push_msg(NotificationLevel::Info, message);
    }

    /// Announce a preset switch using the configured transition style.
    ///
    /// `Toast` falls back to a normal notification; `Figlet`/`Type` arm a timed
    /// animation drawn over the sim. `tagline` is ignored unless
    /// `transition_tagline` is enabled.
    pub fn announce_preset_switch(&mut self, name: &str, tagline: &str) {
        match self.transition_style {
            // Default: switch silently, no on-screen announcement.
            TransitionStyle::Off => {}
            TransitionStyle::Toast => {
                self.show_notification(format!("Applied preset: {name}"));
            }
            style => {
                let tagline = if self.transition_tagline {
                    Some(tagline.to_string())
                } else {
                    None
                };
                self.active_transition = Some(TransitionAnim {
                    name: name.to_string(),
                    tagline,
                    style,
                    start: self.phase_clock,
                });
            }
        }
    }

    /// Shows a temporary notification with an explicit severity level.
    pub fn show_notification_with_level(&mut self, message: String, level: NotificationLevel) {
        self.push_msg(level, message);
    }

    /// Cycles to the next UI theme.
    pub fn cycle_theme(&mut self) {
        self.theme_index = (self.theme_index + 1) % ALL_THEMES.len();
        self.panel_style = ALL_THEMES[self.theme_index].style();
    }

    /// Cycles to the previous UI theme.
    pub fn cycle_theme_reverse(&mut self) {
        self.theme_index = self
            .theme_index
            .checked_sub(1)
            .unwrap_or(ALL_THEMES.len() - 1);
        self.panel_style = ALL_THEMES[self.theme_index].style();
    }

    /// Returns the display name of the current UI theme.
    pub fn current_theme_name(&self) -> &'static str {
        ALL_THEMES[self.theme_index].name()
    }

    /// Checks if the simulation is in the warmup phase.
    pub fn is_in_warmup(&self, warmup_frames: usize) -> bool {
        warmup_frames > 0 && self.warmup_counter < warmup_frames
    }

    /// Increments the warmup frame counter.
    pub fn increment_warmup(&mut self) {
        self.warmup_counter += 1;
    }

    /// Resets the warmup frame counter.
    pub fn reset_warmup(&mut self) {
        self.warmup_counter = 0;
    }

    /// Tracks entropy for collapse detection.
    ///
    /// Collapse = the trail's brightness-value entropy (see
    /// `DashboardOverlay::calculate_entropy`, range 0..8) drops *below* `threshold`
    /// and stays there for `duration_frames`. A healthy pattern sits high (~4-6); a
    /// dead/uniform field falls toward 0. Returns true once collapse is sustained.
    ///
    /// NOTE: the comparison is `<` (low entropy = collapse). An earlier `>` fired on
    /// every healthy frame (entropy ~4-6 always exceeds the 0.95 default), which made
    /// auto-reset presets (Constellation) restart roughly every `duration_frames`.
    pub fn track_entropy(&mut self, entropy: f32, threshold: f32, duration_frames: usize) -> bool {
        if entropy < threshold {
            self.collapse_frame_counter += 1;
            self.collapse_frame_counter >= duration_frames
        } else {
            self.collapse_frame_counter = 0;
            false
        }
    }

    /// Tracks pattern stagnation for collapse detection.
    ///
    /// A pattern can survive entropy-collapse detection while being visually
    /// dead — e.g. a near-static diagonal line that no longer evolves. This
    /// detector samples the trail map at a fixed stride and measures the mean
    /// frame-to-frame change, normalized by the signal magnitude so the
    /// threshold is scale-independent. When that relative change stays below
    /// `epsilon` for `duration_frames`, the pattern is considered collapsed.
    ///
    /// Returns true once stagnation is sustained for the full duration.
    pub fn track_stagnation(
        &mut self,
        blended: &[f32],
        epsilon: f32,
        duration_frames: usize,
    ) -> bool {
        const STRIDE: usize = 64;
        let sample: Vec<f32> = blended.iter().step_by(STRIDE).copied().collect();

        let stagnant = if !sample.is_empty() && self.prev_trail_sample.len() == sample.len() {
            let mut sum_abs = 0.0f32;
            let mut max_mag = 1e-6f32;
            for (cur, prev) in sample.iter().zip(self.prev_trail_sample.iter()) {
                sum_abs += (cur - prev).abs();
                max_mag = max_mag.max(cur.abs());
            }
            let mean_change = sum_abs / sample.len() as f32;
            (mean_change / max_mag) < epsilon
        } else {
            // First frame (or a resize changed the sample length): can't compare.
            false
        };

        self.prev_trail_sample = sample;

        if stagnant {
            self.stagnation_frame_counter += 1;
            self.stagnation_frame_counter >= duration_frames
        } else {
            self.stagnation_frame_counter = 0;
            false
        }
    }

    /// Resets the collapse frame counters (entropy + stagnation).
    pub fn reset_collapse_counter(&mut self) {
        self.collapse_frame_counter = 0;
        self.stagnation_frame_counter = 0;
        self.prev_trail_sample.clear();
    }

    /// Updates statistics history buffers.
    pub fn update_history(&mut self, fps: f32, entropy: f32, density: f32) {
        self.fps_history.push_back(fps);
        if self.fps_history.len() > 60 {
            self.fps_history.pop_front();
        }

        self.entropy_history.push_back(entropy);
        if self.entropy_history.len() > 60 {
            self.entropy_history.pop_front();
        }

        self.density_history.push_back(density);
        if self.density_history.len() > 60 {
            self.density_history.pop_front();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::PauseStyle;
    use crate::simulation::config::SimConfig;

    fn create_test_runtime_state() -> RuntimeState {
        RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Network,
            MouseInteractionMode::Disabled,
            0.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        )
    }

    #[test]
    fn notifications_enabled_by_default_and_push_msg_surfaces_a_toast() {
        let mut rs = create_test_runtime_state();
        assert!(rs.notifications_enabled, "default should be enabled");
        rs.push_msg(NotificationLevel::Info, "hello".to_string());
        assert!(
            rs.ambient_states
                .iter()
                .any(|s| matches!(s, crate::render::ambient::AmbientState::Msg { .. })),
            "a Msg should be surfaced when notifications are enabled"
        );
    }

    #[test]
    fn disabled_notifications_suppress_push_msg() {
        let mut rs = create_test_runtime_state();
        rs.notifications_enabled = false;
        rs.push_msg(NotificationLevel::Info, "hidden".to_string());
        assert!(
            !rs.ambient_states
                .iter()
                .any(|s| matches!(s, crate::render::ambient::AmbientState::Msg { .. })),
            "no Msg should be surfaced when notifications are disabled"
        );
    }

    #[test]
    fn reset_transient_clears_transient_flags() {
        let mut rs = create_test_runtime_state();
        rs.auto_normalize = true;
        rs.motion_blur_frames = 5;
        rs.fast_mode_enabled = true;
        rs.reset_transient();
        assert!(!rs.auto_normalize);
        assert_eq!(rs.motion_blur_frames, 0);
        assert!(!rs.fast_mode_enabled);
    }

    #[test]
    fn test_palette_shift_speed_cycling() {
        let mut state = create_test_runtime_state();

        assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Off);

        state.cycle_palette_shift_speed();
        assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Slow);

        state.cycle_palette_shift_speed();
        assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Medium);

        state.cycle_palette_shift_speed();
        assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Fast);

        state.cycle_palette_shift_speed();
        assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Off);
    }

    #[test]
    fn test_time_scale_adjustment() {
        let mut state = create_test_runtime_state();

        assert_eq!(state.time_scale, 1.0);

        state.adjust_time_scale(0.5);
        assert_eq!(state.time_scale, 1.5);

        state.adjust_time_scale(-0.5);
        assert_eq!(state.time_scale, 1.0);
    }

    #[test]
    fn test_controls_toggle() {
        let mut state = create_test_runtime_state();

        assert!(!state.overlay_state.is_open(OverlayType::Controls));

        state.toggle_controls();
        assert!(state.overlay_state.is_open(OverlayType::Controls));

        state.toggle_controls();
        assert!(!state.overlay_state.is_open(OverlayType::Controls));
    }

    #[test]
    fn test_any_overlay_open() {
        let mut state = create_test_runtime_state();

        assert!(!state.any_overlay_open());

        state.overlay_state.open(OverlayType::Controls);
        assert!(state.any_overlay_open());

        state.overlay_state.close();
        state.overlay_state.open(OverlayType::Dashboard);
        assert!(state.any_overlay_open());
    }

    #[test]
    fn test_close_all_overlays() {
        let mut state = create_test_runtime_state();

        state.overlay_state.open(OverlayType::Controls);
        state.overlay_state.open(OverlayType::Dashboard);

        state.close_all_overlays();

        assert!(!state.overlay_state.is_open(OverlayType::Controls));
        assert!(!state.overlay_state.is_open(OverlayType::Dashboard));
        assert!(!state.any_overlay_open());
    }

    #[test]
    fn test_close_all_overlays_clears_palette_editor_sub_state() {
        use crate::render::palette_editor::PaletteEditorState;

        let mut state = create_test_runtime_state();

        // Simulate opening the palette editor (sets both active overlay and sub-state).
        state
            .overlay_state
            .open_palette_editor(PaletteEditorState::new(&Palette::Forest));
        assert!(state.overlay_state.is_palette_editor_open());
        assert!(state.overlay_state.palette_editor.is_some());

        // close_all_overlays must clear both the active flag and the sub-state.
        state.close_all_overlays();

        assert!(!state.overlay_state.is_palette_editor_open());
        assert!(state.overlay_state.palette_editor.is_none());
        assert!(!state.any_overlay_open());
    }

    #[test]
    fn test_controls_category_cycling() {
        let mut state = create_test_runtime_state();

        assert_eq!(state.controls_category_idx, 0);

        state.cycle_controls_category(true);
        assert_eq!(state.controls_category_idx, 1);

        state.cycle_controls_category(true);
        assert_eq!(state.controls_category_idx, 2);

        state.cycle_controls_category(true);
        assert_eq!(state.controls_category_idx, 3);

        state.cycle_controls_category(true);
        assert_eq!(state.controls_category_idx, 4);

        state.cycle_controls_category(true);
        assert_eq!(state.controls_category_idx, 5);

        state.cycle_controls_category(true);
        assert_eq!(state.controls_category_idx, 0);

        state.cycle_controls_category(false);
        assert_eq!(state.controls_category_idx, 5);
    }

    #[test]
    fn runtime_state_starts_closed_focus_zero() {
        let s = create_test_runtime_state();
        assert_eq!(
            s.controls_depth,
            crate::render::controls::ControlsDepth::Closed
        );
        assert_eq!(s.controls_focus, 0);
    }

    #[test]
    fn test_wind_direction_cycling() {
        let mut state = create_test_runtime_state();

        assert_eq!(state.wind_direction, WindDirection::None);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::North);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::Northeast);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::East);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::Southeast);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::South);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::Southwest);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::West);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::Northwest);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::None);
    }

    #[test]
    fn test_wind_direction_names() {
        assert_eq!(WindDirection::None.name(), "None");
        assert_eq!(WindDirection::North.name(), "N");
        assert_eq!(WindDirection::Southwest.name(), "SW");
    }

    #[test]
    fn test_runtime_state_randomize() {
        let mut state = RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Organic,
            MouseInteractionMode::Disabled,
            3.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        );
        let orig_angle = state.sensor_angle;
        state.randomize_params();
        // Since it's random, it *could* be the same, but very unlikely
        assert!(state.sensor_angle != orig_angle || state.rotation_angle != 45.0);
    }

    #[test]
    fn test_parameter_state_roundtrip() {
        let mut state = RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Organic,
            MouseInteractionMode::Disabled,
            3.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        );
        state.sensor_angle = 12.3;
        let p = state.capture_parameter_state();
        state.sensor_angle = 45.6;
        state.apply_parameter_state(p);
        assert_eq!(state.sensor_angle, 12.3);
    }

    #[test]
    fn test_palette_shift_speed() {
        assert_eq!(PaletteShiftSpeed::Off.degrees_per_second(), 0.0);
        assert_eq!(PaletteShiftSpeed::Fast.degrees_per_second(), 45.0);
    }

    #[test]
    fn test_runtime_state_notifications() {
        let mut state = RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Organic,
            MouseInteractionMode::Disabled,
            3.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        );
        // show_notification delegates to push_msg — toasts live in ambient_states.
        state.show_notification("test".to_string());
        let has_msg = state
            .ambient_states
            .iter()
            .any(|s| matches!(s, crate::render::ambient::AmbientState::Msg { .. }));
        assert!(
            has_msg,
            "show_notification should push a Msg into ambient_states"
        );
    }

    #[test]
    fn test_runtime_state_warmup() {
        let mut state = RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Organic,
            MouseInteractionMode::Disabled,
            3.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        );
        assert!(!state.is_in_warmup(0));
        assert!(state.is_in_warmup(10));
        state.increment_warmup();
        assert_eq!(state.warmup_counter, 1);
        state.reset_warmup();
        assert_eq!(state.warmup_counter, 0);
    }

    #[test]
    fn test_runtime_state_entropy_tracking() {
        let mut state = RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Organic,
            MouseInteractionMode::Disabled,
            3.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        );
        // Collapse = entropy BELOW threshold. Above-threshold (healthy) never fires
        // and resets the counter; sustained below-threshold fires.
        assert!(!state.track_entropy(15.0, 10.0, 5)); // above threshold → healthy
        assert!(state.track_entropy(5.0, 10.0, 1)); // below threshold → collapse
        state.reset_collapse_counter();
        assert_eq!(state.collapse_frame_counter, 0);
    }

    #[test]
    fn test_runtime_state_stagnation_tracking() {
        let mut state = create_test_runtime_state();
        let static_frame = vec![0.5f32; 256];

        // First call seeds the previous sample (can't compare yet).
        assert!(!state.track_stagnation(&static_frame, 0.004, 3));
        // Identical frames → zero change → stagnant; fires after duration.
        assert!(!state.track_stagnation(&static_frame, 0.004, 3));
        assert!(!state.track_stagnation(&static_frame, 0.004, 3));
        assert!(state.track_stagnation(&static_frame, 0.004, 3));

        // A frame that changes substantially resets the stagnation counter.
        let moving_frame = vec![0.9f32; 256];
        assert!(!state.track_stagnation(&moving_frame, 0.004, 3));
        assert_eq!(state.stagnation_frame_counter, 0);
    }

    #[test]
    fn test_runtime_state_history() {
        let mut state = RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Organic,
            MouseInteractionMode::Disabled,
            3.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        );
        state.update_history(60.0, 5.0, 0.5);
        assert_eq!(state.fps_history.len(), 1);
        for _ in 0..65 {
            state.update_history(60.0, 5.0, 0.5);
        }
        assert_eq!(state.fps_history.len(), 60);
    }

    #[test]
    fn test_runtime_state_actions() {
        let mut state = RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Organic,
            MouseInteractionMode::Disabled,
            3.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        );

        state.toggle_pause();
        assert!(state.is_paused);
        state.toggle_pause();
        assert!(!state.is_paused);

        state.toggle_controls();
        assert!(state.overlay_state.is_open(OverlayType::Controls));
        state.toggle_controls();
        assert!(!state.overlay_state.is_open(OverlayType::Controls));

        state.toggle_keyboard_hints();
        assert!(state.overlay_state.is_open(OverlayType::KeyboardHints));
        state.toggle_keyboard_hints();
        assert!(!state.overlay_state.is_open(OverlayType::KeyboardHints));

        state.toggle_comparison(ComparisonTarget::Preset(Preset::Network));
        assert!(state.overlay_state.is_open(OverlayType::PresetComparison));
        state.toggle_comparison(ComparisonTarget::Preset(Preset::Network));
        assert!(!state.overlay_state.is_open(OverlayType::PresetComparison));

        assert!(!state.any_overlay_open());
        state.overlay_state.open(OverlayType::Dashboard);
        assert!(state.any_overlay_open());
        state.close_all_overlays();
        assert!(!state.any_overlay_open());

        state.cycle_controls_category(true);
        assert_eq!(state.controls_category_idx, 1);
        state.cycle_controls_category(false);
        assert_eq!(state.controls_category_idx, 0);

        state.set_preset(Preset::Fire);
        assert_eq!(state.current_preset, Preset::Fire);

        state.adjust_time_scale(0.5);
        assert_eq!(state.time_scale, 1.5);

        state.cycle_palette(10);
        assert_eq!(state.palette_index, 1);
        state.cycle_palette_reverse(10);
        assert_eq!(state.palette_index, 0);

        state.toggle_dither();
        assert!(matches!(state.dither_mode, DitherMode::Ordered { .. }));
        state.toggle_dither();
        assert_eq!(state.dither_mode, DitherMode::None);

        state.cycle_dither_mode();
        assert!(matches!(state.dither_mode, DitherMode::Ordered { .. }));

        state.adjust_sensor_angle(10.0);
        assert!(state.sensor_angle > 22.5);

        use crate::simulation::config::DiffusionKernel;
        state.cycle_diffusion_kernel();
        assert_eq!(state.diffusion_kernel, DiffusionKernel::Mean3x3);

        state.cycle_mouse_mode();
        assert_eq!(state.mouse_mode, MouseInteractionMode::Attract);

        state.cycle_wind_direction();
        assert_eq!(state.wind_direction, WindDirection::North);
    }

    #[test]
    fn test_runtime_state_undo_redo() {
        let mut state = RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Organic,
            MouseInteractionMode::Disabled,
            3.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        );
        let orig_angle = state.sensor_angle;
        state.force_checkpoint();
        state.adjust_sensor_angle(10.0);
        assert_ne!(state.sensor_angle, orig_angle);

        state.undo();
        assert_eq!(state.sensor_angle, orig_angle);

        state.redo();
        assert_ne!(state.sensor_angle, orig_angle);
    }

    #[test]
    fn test_palette_shift_speed_degrees() {
        assert_eq!(PaletteShiftSpeed::Off.degrees_per_second(), 0.0);
        assert_eq!(PaletteShiftSpeed::Slow.degrees_per_second(), 5.0);
        assert_eq!(PaletteShiftSpeed::Medium.degrees_per_second(), 15.0);
        assert_eq!(PaletteShiftSpeed::Fast.degrees_per_second(), 45.0);
    }

    #[test]
    fn test_motion_blur_cycling() {
        let mut state = create_test_runtime_state();

        assert_eq!(state.motion_blur_frames, 0);

        state.cycle_motion_blur();
        assert_eq!(state.motion_blur_frames, 3);

        state.cycle_motion_blur();
        assert_eq!(state.motion_blur_frames, 5);

        state.cycle_motion_blur();
        assert_eq!(state.motion_blur_frames, 7);

        state.cycle_motion_blur();
        assert_eq!(state.motion_blur_frames, 0);
    }

    #[test]
    fn test_randomize_params_updates() {
        let mut state = create_test_runtime_state();

        state.wind_direction = WindDirection::North;
        use crate::simulation::config::TerrainType;
        state.terrain_type = TerrainType::None;
        state.palette_index = 0;

        state.randomize_params();

        assert_eq!(state.wind_direction, WindDirection::North);
    }

    #[test]
    fn test_chrome_base_minimal_pause_expands() {
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::Minimal;
        state.on_pause();
        assert_eq!(state.chrome_state, ChromeState::Expanded);
    }

    #[test]
    fn test_chrome_base_minimal_unpause_collapses() {
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::Expanded;
        state.is_paused = false;
        state.on_unpause();
        assert_eq!(state.chrome_state, ChromeState::Minimal);
    }

    #[test]
    fn test_chrome_base_minimal_modal_open_stays_collapsed() {
        // Default (Minimal) base: opening an overlay keeps chrome collapsed so the
        // status bar does not clutter behind it.
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::Minimal;
        state.on_modal_open();
        assert_eq!(state.chrome_state, ChromeState::Minimal);
    }

    #[test]
    fn test_chrome_base_expanded_modal_open_is_modal_pane() {
        // Persistent (Expanded) base: an overlay shows ModalPane chrome.
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Expanded;
        state.on_modal_open();
        assert_eq!(state.chrome_state, ChromeState::ModalPane);
    }

    #[test]
    fn test_chrome_pause_with_overlay_open_stays_collapsed() {
        // Status bar appears on (pause AND no overlay): pausing while an overlay is
        // open must NOT expand chrome.
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::Minimal;
        state
            .overlay_state
            .open(crate::overlay::OverlayType::KeyboardHints);
        state.on_pause();
        assert_eq!(state.chrome_state, ChromeState::Minimal);
    }

    #[test]
    fn test_chrome_base_minimal_modal_close_no_pause() {
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::ModalPane;
        state.is_paused = false;
        state.on_modal_close();
        assert_eq!(state.chrome_state, ChromeState::Minimal);
    }

    #[test]
    fn test_chrome_base_minimal_modal_close_while_paused() {
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::ModalPane;
        state.is_paused = true;
        state.on_modal_close();
        assert_eq!(state.chrome_state, ChromeState::Expanded);
    }

    #[test]
    fn test_chrome_base_expanded_stays_expanded_across_pause() {
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Expanded;
        state.chrome_state = ChromeState::Expanded;
        state.on_pause();
        assert_eq!(state.chrome_state, ChromeState::Expanded);
        state.on_unpause();
        assert_eq!(state.chrome_state, ChromeState::Expanded);
    }

    #[test]
    fn test_chrome_unpause_enters_fading_state() {
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::Expanded;
        state.is_paused = true;
        state.on_unpause_with_fade();
        assert!(matches!(state.chrome_state, ChromeState::FadingOut(_)));
    }

    #[test]
    fn test_chrome_fade_snap_back_on_pause() {
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::FadingOut(0.5);
        state.on_pause(); // should snap back to Expanded
        assert_eq!(state.chrome_state, ChromeState::Expanded);
    }

    #[test]
    fn test_chrome_fade_advance_to_zero() {
        let mut state = create_test_runtime_state();
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::FadingOut(0.1);
        state.advance_fade(0.2); // dt > remaining, should transition to Minimal
        assert_eq!(state.chrome_state, ChromeState::Minimal);
    }

    #[test]
    fn test_cycle_chrome_style_reaches_expanded_then_fullscreen() {
        use crate::simulation::config::ChromeStyle;
        let mut state = create_test_runtime_state();
        state.chrome_style = ChromeStyle::Minimal;
        state.base_chrome_state = ChromeState::Minimal;
        state.chrome_state = ChromeState::Minimal;

        state.cycle_chrome_style();
        assert_eq!(state.chrome_style, ChromeStyle::Expanded);
        assert_eq!(state.base_chrome_state, ChromeState::Expanded);
        assert_eq!(state.chrome_state, ChromeState::Expanded);

        state.cycle_chrome_style();
        assert_eq!(state.chrome_style, ChromeStyle::Fullscreen);
        assert_eq!(state.base_chrome_state, ChromeState::Minimal);

        state.cycle_chrome_style();
        assert_eq!(state.chrome_style, ChromeStyle::Minimal);
        assert_eq!(state.base_chrome_state, ChromeState::Minimal);
        assert_eq!(state.chrome_state, ChromeState::Minimal);
    }

    #[test]
    fn color_aa_default_is_strong_for_braille_only() {
        use crate::render::antialiasing::AaStrength;
        use crate::render::charset::Charset;
        let rs = create_test_runtime_state();
        // ALL_CHARSETS index 3 == Braille.
        assert_eq!(rs.color_aa[3], AaStrength::Strong);
        for (i, c) in ALL_CHARSETS.iter().enumerate() {
            if *c != Charset::Braille {
                assert_eq!(rs.color_aa[i], AaStrength::Off);
            }
        }
    }

    #[test]
    fn cycle_color_aa_is_per_charset_and_skips_ineligible() {
        use crate::render::antialiasing::AaStrength;
        let mut rs = create_test_runtime_state();
        // Move to Quadrant (index 4).
        rs.charset_index = 4;
        assert_eq!(rs.current_color_aa(), AaStrength::Off);
        assert!(rs.cycle_color_aa());
        assert_eq!(rs.current_color_aa(), AaStrength::Subtle);
        // Braille's entry is untouched by cycling Quadrant.
        assert_eq!(rs.color_aa[3], AaStrength::Strong);
        // HalfBlockDual (index 1) is ineligible → no-op.
        rs.charset_index = 1;
        assert!(!rs.cycle_color_aa());
        assert_eq!(rs.current_color_aa(), AaStrength::Off);
    }

    #[test]
    fn color_aa_round_trips_undo() {
        use crate::render::antialiasing::AaStrength;
        let mut rs = create_test_runtime_state();
        rs.charset_index = 4; // Quadrant
        rs.cycle_color_aa(); // → Subtle
        let snap = rs.capture_parameter_state();
        rs.cycle_color_aa(); // → Strong
        rs.apply_parameter_state(snap);
        assert_eq!(rs.color_aa[4], AaStrength::Subtle);
    }

    #[test]
    fn apply_cli_color_aa_sets_current_charset_aa() {
        use crate::render::antialiasing::AaStrength;
        let mut rs = create_test_runtime_state();
        rs.charset_index = 4;
        rs.apply_cli_color_aa(AaStrength::Subtle);
        assert_eq!(rs.color_aa[4], AaStrength::Subtle);
    }

    #[test]
    fn test_wind_direction_values() {
        assert!(WindDirection::None.to_wind().is_none());
        assert!(WindDirection::North.to_wind().is_some());
        assert!(WindDirection::Northeast.to_wind().is_some());
        assert!(WindDirection::East.to_wind().is_some());
        assert!(WindDirection::Southeast.to_wind().is_some());
        assert!(WindDirection::South.to_wind().is_some());
        assert!(WindDirection::Southwest.to_wind().is_some());
        assert!(WindDirection::West.to_wind().is_some());
        assert!(WindDirection::Northwest.to_wind().is_some());

        let north = WindDirection::North.to_wind().unwrap();
        assert_eq!(north.dx, 0.0);
        assert_eq!(north.dy, -1.0);
    }

    #[test]
    fn reset_transient_does_not_touch_color_aa_slots() {
        use crate::render::antialiasing::AaStrength;
        // reset_transient is scoped to transient flags; color_aa is a lever restored
        // by the apply_overrides seam, not by reset_transient itself.
        let mut rs = create_test_runtime_state();
        rs.charset_index = 0;
        let braille_default = crate::config_defaults::DEFAULT_COLOR_AA[3];
        let non_default = if braille_default == AaStrength::Strong {
            AaStrength::Off
        } else {
            AaStrength::Strong
        };
        rs.color_aa[3] = non_default;
        assert_eq!(rs.color_aa[3], non_default);

        rs.reset_transient();

        // reset_transient must NOT change color_aa — the seam owns that.
        assert_eq!(
            rs.color_aa[3], non_default,
            "reset_transient must not restore color_aa; the seam does that"
        );
    }

    #[test]
    fn runtime_state_defaults_active_source_to_startup_cli() {
        // RuntimeState::new always initialises active_source to StartupCli;
        // runner.rs overrides it when args.preset is Some(_).
        let rs = create_test_runtime_state();
        assert_eq!(rs.active_source, crate::profile::ProfileSource::StartupCli);
    }

    /// `reset_transient` only touches the three transient flags; it does NOT clobber
    /// `active_overrides`.  After a successful preset switch, the caller commits
    /// the new `ProfileOverrides` to `rs.active_overrides` and then may call
    /// `reset_transient()`.  The contract is that `active_overrides` survives so
    /// the subsequent `apply_overrides` call can read it back.
    #[test]
    fn reset_reproduces_active_overrides() {
        use crate::profile_overrides::ProfileOverrides;
        use crate::simulation::config::Preset;

        let mut rs = create_test_runtime_state();

        // Commit a bare-preset override (as switch_preset does after apply).
        let bare_ov = ProfileOverrides {
            preset: Some(Preset::Organic),
            ..Default::default()
        };
        rs.active_overrides = bare_ov.clone();

        // Simulate transient state accumulated since last switch.
        rs.auto_normalize = true;
        rs.motion_blur_frames = 5;
        rs.fast_mode_enabled = true;

        // reset_transient clears transient flags but must not touch active_overrides.
        rs.reset_transient();

        assert!(
            !rs.auto_normalize,
            "reset_transient must clear auto_normalize"
        );
        assert_eq!(
            rs.motion_blur_frames, 0,
            "reset_transient must clear motion_blur_frames"
        );
        assert!(
            !rs.fast_mode_enabled,
            "reset_transient must clear fast_mode_enabled"
        );
        assert_eq!(
            rs.active_overrides, bare_ov,
            "reset_transient must not modify active_overrides"
        );
    }

    // ── Dirty-state guard ([P1] canonical projection) ──────────────────────────

    /// Builds a CLEAN session for `active_overrides`: resolves the overrides and
    /// writes every captured field into rs/sim exactly as startup would, then
    /// commits `active_overrides`. The returned `(rs, sim_config)` is what a fresh,
    /// UNEDITED session looks like — `is_dirty` MUST be false for it. Mirrors the
    /// resolve half of the apply seam (no renderer needed).
    fn clean_session(ov: crate::profile_overrides::ProfileOverrides) -> (RuntimeState, SimConfig) {
        let profile = ov.resolve().expect("active_overrides must resolve");
        let sim_config = profile.sim.clone();
        let mut rs = create_test_runtime_state();

        // Mirror the apply seam's RESOLVED writes: resolve() expands preset-derived
        // render/sim levers, and capture_overrides reads these rs fields, so a
        // faithful clean session must carry the resolved values.

        // Sim-side levers capture reads from rs (not from sim_config).
        rs.decay_gamma = sim_config.decay_gamma;
        rs.diffuse_weight = sim_config.diffuse_weight;
        rs.deposit_curve = sim_config.deposit_curve;
        rs.deposit_scale = sim_config.deposit_scale;
        rs.deposit_gamma = sim_config.deposit_gamma;
        rs.deposit_cap = sim_config.deposit_cap;

        // Render fields the seam writes through apply_render_config.
        let r = &profile.render;
        rs.live_palette = r.palette.clone();
        rs.live_charset = r.charset.clone();
        rs.charset_index = ALL_CHARSETS
            .iter()
            .position(|c| *c == r.charset)
            .unwrap_or(0);
        // color_aa: the active charset's slot carries the resolved scalar over defaults.
        rs.color_aa = crate::config_defaults::DEFAULT_COLOR_AA;
        rs.color_aa[rs.charset_index] = r.color_aa;
        rs.palette_shift_speed = palette_shift_speed_of(r.hue_shift);
        rs.intensity_mapping = r.intensity_mapping.clone();
        rs.palette_cycle = r.palette_cycle;
        rs.glyph = r.glyph;
        rs.temporal_color = r.temporal_color;
        rs.temporal_lag_frames = r.temporal_lag_frames;
        rs.temporal_mode = r.temporal_mode;
        rs.temporal_accent = r.temporal_accent;
        rs.afterglow = r.afterglow;
        rs.afterglow_rate = r.afterglow_rate;
        // auto_normalize: the seam writes the resolved flag into rs (apply_render_config);
        // capture_overrides reads rs.auto_normalize, so the clean mirror must carry it.
        rs.auto_normalize = r.auto_normalize;

        // App + wind (sourced from the resolved profile / sim).
        rs.app = profile.app.clone();
        rs.wind = sim_config.wind;

        rs.active_overrides = ov;
        (rs, sim_config)
    }

    /// FALSE-POSITIVE GATE (mandatory): a bare-preset session built by APPLYING
    /// `active_overrides = {preset: Some(Lumen)}` must NOT read dirty. A failure
    /// here means capture or projection is INCOMPLETE — fix capture/projection,
    /// never this test.
    #[test]
    fn clean_preset_swap_is_not_dirty() {
        use crate::profile_overrides::ProfileOverrides;
        let ov = ProfileOverrides {
            preset: Some(Preset::Mold),
            ..Default::default()
        };
        let (rs, sim) = clean_session(ov);
        assert!(
            !rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
            "a freshly-applied bare preset must not read dirty (projection incomplete?)"
        );
    }

    /// Repro: a freshly-applied Constellation preset must NOT read dirty.
    #[test]
    fn clean_constellation_swap_is_not_dirty() {
        use crate::profile_overrides::ProfileOverrides;
        let ov = ProfileOverrides {
            preset: Some(Preset::Constellation),
            ..Default::default()
        };
        let (rs, sim) = clean_session(ov);
        assert!(
            !rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
            "a freshly-applied Constellation preset must not read dirty"
        );
    }

    /// CLEAN `--color-aa subtle` session: the source carries a scalar `color_aa`,
    /// live capture re-captures it, and project() must reproduce apply_color_aa_all
    /// semantics (scalar on the RESOLVED charset slot over defaults) so the two
    /// sides match — NOT treat the source's absent color_aa_all as defaults.
    #[test]
    fn clean_color_aa_scalar_session_is_not_dirty() {
        use crate::profile_overrides::ProfileOverrides;
        use crate::render::antialiasing::AaStrength;
        let ov = ProfileOverrides {
            color_aa: Some(AaStrength::Subtle),
            ..Default::default()
        };
        let (rs, sim) = clean_session(ov);
        assert!(
            !rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
            "a clean --color-aa subtle session must not read dirty"
        );
    }

    /// Mutating a sim lever (sensor_angle) reads dirty.
    #[test]
    fn mutated_is_dirty() {
        use crate::profile_overrides::ProfileOverrides;
        let ov = ProfileOverrides {
            preset: Some(Preset::Mold),
            ..Default::default()
        };
        let (rs, mut sim) = clean_session(ov);
        sim.sensor_angle += 7.0;
        assert!(
            rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
            "a bumped sensor_angle must read dirty"
        );
    }

    /// Toggling an apply-only flag (reverse_palette) reads dirty — proves the
    /// projection sees flags that resolved-Profile equality would drop.
    #[test]
    fn flag_edit_is_dirty() {
        use crate::profile_overrides::ProfileOverrides;
        let ov = ProfileOverrides {
            preset: Some(Preset::Mold),
            ..Default::default()
        };
        let (mut rs, sim) = clean_session(ov);
        rs.reverse_palette = !rs.reverse_palette;
        assert!(
            rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
            "toggling reverse_palette must read dirty (apply-only flag)"
        );
    }

    /// Changing the palette-shift SPEED reads dirty (it crosses a bucket boundary),
    /// but a raw hue_shift difference WITHIN the same bucket does NOT, because both
    /// sides flow through the `palette_shift_speed_of` bucket.
    #[test]
    fn shift_speed_edit_is_dirty_but_intra_bucket_is_not() {
        use crate::profile_overrides::project;
        use crate::profile_overrides::ProfileOverrides;
        // Cross-bucket: Off (0) → Medium (20) reads dirty.
        let off = ProfileOverrides {
            hue_shift: Some(0.0),
            ..Default::default()
        };
        let medium = ProfileOverrides {
            hue_shift: Some(20.0),
            ..Default::default()
        };
        assert_ne!(
            project(&off).unwrap(),
            project(&medium).unwrap(),
            "Off vs Medium hue_shift must project unequal (cross-bucket)"
        );
        // Intra-bucket: 16 and 20 are both Medium (≤30, >10) → equal projection.
        let medium_a = ProfileOverrides {
            hue_shift: Some(16.0),
            ..Default::default()
        };
        let medium_b = ProfileOverrides {
            hue_shift: Some(20.0),
            ..Default::default()
        };
        assert_eq!(
            project(&medium_a).unwrap(),
            project(&medium_b).unwrap(),
            "two hue_shifts in the same bucket must project equal (raw value excluded)"
        );
    }

    /// StartupCli baseline: with `active_overrides == from_args(--sensor-angle 33)`,
    /// an UNEDITED session is NOT dirty; it becomes dirty only after an edit.
    #[test]
    fn startup_cli_with_overrides_unedited_is_not_dirty() {
        use crate::profile_overrides::ProfileOverrides;
        use clap::Parser;
        let args = crate::cli::Args::parse_from(["tslime", "--sensor-angle", "33"]);
        let ov = ProfileOverrides::from_args(&args).expect("from_args");
        let (rs, sim) = clean_session(ov);
        assert!(
            !rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
            "an unedited --sensor-angle 33 startup session must not read dirty"
        );
    }

    /// `pending_swap` parks a requested swap and `take()` hands it back exactly
    /// once (the confirm path consumes it), mirroring the runner's guard flow.
    #[test]
    fn pending_swap_parks_and_takes_once() {
        let mut rs = create_test_runtime_state();
        assert!(rs.pending_swap.is_none());
        rs.pending_swap = Some(PendingSwap::Preset(Preset::Mold));
        rs.overlay_state.open(OverlayType::DirtyGuard);
        assert!(rs.overlay_state.is_open(OverlayType::DirtyGuard));
        assert_eq!(
            rs.pending_swap.take(),
            Some(PendingSwap::Preset(Preset::Mold))
        );
        assert!(
            rs.pending_swap.is_none(),
            "take() must clear the parked swap"
        );
    }

    /// The cancel (Escape) path clears `pending_swap` without performing the swap.
    #[test]
    fn pending_swap_cancel_clears_without_swapping() {
        let mut rs = create_test_runtime_state();
        rs.pending_swap = Some(PendingSwap::Config(Box::new(
            crate::config_manager::NamedProfile {
                name: "demo".to_string(),
                description: None,
                overrides: Default::default(),
            },
        )));
        rs.overlay_state.open(OverlayType::DirtyGuard);
        // Mirror the runner's CloseOverlay arm for DirtyGuard.
        rs.overlay_state.close();
        rs.pending_swap = None;
        assert!(rs.pending_swap.is_none());
        assert!(!rs.overlay_state.is_open(OverlayType::DirtyGuard));
    }

    #[test]
    fn startup_cli_with_overrides_dirty_after_edit() {
        use crate::profile_overrides::ProfileOverrides;
        use clap::Parser;
        let args = crate::cli::Args::parse_from(["tslime", "--sensor-angle", "33"]);
        let ov = ProfileOverrides::from_args(&args).expect("from_args");
        let (rs, mut sim) = clean_session(ov);
        sim.sensor_angle = 40.0;
        assert!(
            rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
            "editing sensor_angle away from the startup value must read dirty"
        );
    }

    /// FALSE-POSITIVE GATE: a bare-preset session for PetriDish (which carries a
    /// Circle obstacle in its sim defaults) must NOT read dirty immediately after
    /// applying the preset.  Before the capture_overrides fix this returned true
    /// because obstacles: Vec::new() was emitted, causing the live projection to
    /// resolve to no obstacle while the active side resolved to the preset obstacle.
    #[test]
    fn clean_preset_swap_with_obstacles_is_not_dirty() {
        use crate::profile_overrides::ProfileOverrides;
        let ov = ProfileOverrides {
            preset: Some(Preset::PetriDish),
            ..Default::default()
        };
        let (rs, sim) = clean_session(ov);
        assert!(
            !rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
            "a freshly-applied PetriDish preset (with obstacles) must not read dirty"
        );
    }

    /// FALSE-POSITIVE GATE: bare-preset sessions for trail-modulated presets (Slime,
    /// DynamicTendrils) must NOT read dirty immediately after applying.
    ///
    /// Before the project() normalization fix these presets produced a false-positive
    /// because `SpeciesArg` cannot carry `trail_modulation`, so capture_overrides
    /// always emitted `None` while the active side resolved to `Some(_)` — causing
    /// every unedited Slime/Vines/Smoke/Vortex36/DynamicTendrils session to be
    /// incorrectly flagged dirty.
    #[test]
    fn clean_preset_swap_with_trail_modulation_is_not_dirty() {
        use crate::profile_overrides::ProfileOverrides;

        // Slime carries trail_modulation: Some(_) in PresetSimDefaults.
        let ov_slime = ProfileOverrides {
            preset: Some(Preset::Slime),
            ..Default::default()
        };
        let (rs, sim) = clean_session(ov_slime);
        assert!(
            !rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
            "a freshly-applied Slime preset (trail_modulation: Some) must not read dirty"
        );

        // DynamicTendrils also carries trail_modulation: Some(_).
        let ov_dt = ProfileOverrides {
            preset: Some(Preset::DynamicTendrils),
            ..Default::default()
        };
        let (rs2, sim2) = clean_session(ov_dt);
        assert!(
            !rs2.is_dirty(&sim2, rs2.live_palette.clone(), rs2.live_charset.clone()),
            "a freshly-applied DynamicTendrils preset (trail_modulation: Some) must not read dirty"
        );
    }

    #[test]
    fn controls_actions_exist() {
        let _ = [
            ControlAction::ToggleControlsDepth,
            ControlAction::ControlsFocusNext,
            ControlAction::ControlsFocusPrev,
            ControlAction::ControlsAdjustFocused(1.0),
            ControlAction::ControlsActivateFocused,
        ];
    }

    #[test]
    fn default_values_from_config_resolves_overrides() {
        use crate::config_manager::NamedProfile;
        use crate::profile_overrides::ProfileOverrides;
        let ov = ProfileOverrides {
            sensor_angle: Some(33.0),
            ..Default::default()
        };
        let profile = NamedProfile {
            name: "t".to_string(),
            description: None,
            overrides: ov,
        };
        let dv = DefaultValues::from_config(&profile);
        assert!((dv.sensor_angle - 33.0).abs() < 0.01);
    }

    #[test]
    fn toggle_comparison_sets_target_and_toggles_off() {
        let mut rs = create_test_runtime_state();
        rs.toggle_comparison(ComparisonTarget::Preset(Preset::Network));
        assert!(rs.overlay_state.is_open(OverlayType::PresetComparison));
        assert_eq!(
            rs.comparison_target,
            ComparisonTarget::Preset(Preset::Network)
        );
        rs.toggle_comparison(ComparisonTarget::Preset(Preset::Network));
        assert!(!rs.overlay_state.is_open(OverlayType::PresetComparison));
    }
}

#[cfg(test)]
mod phase_tests {
    use super::*;
    #[test]
    fn advance_phase_accumulates_dt() {
        let mut rs = RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Network,
            MouseInteractionMode::Disabled,
            0.0,
            &crate::simulation::config::SimConfig::default(),
            crate::cli::PauseStyle::Vignette,
            false,
            false,
        );
        let before = rs.phase_clock;
        rs.advance_phase(0.5);
        assert!((rs.phase_clock - (before + 0.5)).abs() < 1e-6);
    }
}
