use crate::cli::Palette;
use crate::render::dither::DitherMode;
use crate::render::palette::RgbColor;
use crate::render::theme::PanelStyle;
use crate::render::widgets::{gauge, legend, spacing, truncate_ellipsis};
use crate::simulation::config::Preset;
use crate::terminal::control::{palette_name, preset_name};

pub use crate::render::panel::{
    footer_hints, BorderConfig, ColumnLayout, Padding, PanelBuilder, PanelRow, PanelSize,
    RenderedOverlay, RenderedTitleBox, RichCell, TextAlignment, TitleAlignment,
};

// --- OverlayConfig ---

/// Configuration for different overlay types.
///
/// Used primarily for color/style configuration by the renderer; panel
/// construction now uses `PanelBuilder` directly.
#[derive(Clone, Debug)]
pub struct OverlayConfig {
    /// Width of the overlay in characters (total, including border and padding)
    pub width: usize,
    /// Vertical padding (empty lines at top/bottom)
    pub height_padding: usize,
    /// Horizontal padding (spaces on left/right inside border)
    pub width_padding: usize,
    /// Text color (ANSI 256 index)
    pub text_color_256: u8,
    /// Background color (ANSI 256 index)
    pub bg_color_256: u8,
    /// Whether this overlay has a border
    pub has_border: bool,
}

impl OverlayConfig {
    /// Help overlay configuration
    pub const HELP: OverlayConfig = OverlayConfig {
        width: 62,
        height_padding: 1,
        width_padding: 2,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };

    /// Controls overlay configuration
    pub const CONTROLS: OverlayConfig = OverlayConfig {
        width: 50,
        height_padding: 1,
        width_padding: 2,
        text_color_256: 245,
        bg_color_256: 236,
        has_border: true,
    };

    /// Dashboard overlay configuration (merged stats + info, landscape layout).
    pub const DASHBOARD: OverlayConfig = OverlayConfig {
        width: 84,
        height_padding: 1,
        width_padding: 2,
        text_color_256: 245,
        bg_color_256: 236,
        has_border: true,
    };

    /// Preset comparison overlay configuration
    pub const PRESET_COMPARISON: OverlayConfig = OverlayConfig {
        width: 62,
        height_padding: 1,
        width_padding: 2,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };

    /// Config browser overlay configuration
    pub const CONFIG_BROWSER: OverlayConfig = OverlayConfig {
        width: 56,
        height_padding: 1,
        width_padding: 2,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };

    /// Config save overlay configuration
    pub const CONFIG_SAVE: OverlayConfig = OverlayConfig {
        width: 38,
        height_padding: 1,
        width_padding: 1,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };

    /// Dirty-state guard overlay configuration
    pub const DIRTY_GUARD: OverlayConfig = OverlayConfig {
        width: 48,
        height_padding: 1,
        width_padding: 1,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };

    /// Keyboard hints overlay configuration
    pub const KEYBOARD_HINTS: OverlayConfig = OverlayConfig {
        width: 88,
        height_padding: 1,
        width_padding: 2,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };

    /// Attractor help overlay configuration
    pub const ATTRACTOR: OverlayConfig = OverlayConfig {
        width: 42,
        height_padding: 1,
        width_padding: 1,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };

    /// Obstacle help overlay configuration
    pub const OBSTACLE: OverlayConfig = OverlayConfig {
        width: 42,
        height_padding: 1,
        width_padding: 1,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };

    /// Mouse attractor help overlay configuration
    pub const MOUSE_ATTRACTOR: OverlayConfig = OverlayConfig {
        width: 46,
        height_padding: 1,
        width_padding: 1,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };

    /// Status bar overlay configuration
    pub const STATUS: OverlayConfig = OverlayConfig {
        width: 0,
        height_padding: 1,
        width_padding: 0,
        text_color_256: 250,
        bg_color_256: 234,
        has_border: false,
    };

    /// Notification overlay configuration
    pub const NOTIFICATION: OverlayConfig = OverlayConfig {
        width: 0,
        height_padding: 1,
        width_padding: 0,
        text_color_256: 15,
        bg_color_256: 235,
        has_border: false,
    };

    /// Palette editor overlay configuration
    pub const PALETTE_EDITOR: OverlayConfig = OverlayConfig {
        width: 56,
        height_padding: 0,
        width_padding: 1,
        text_color_256: 15,
        bg_color_256: 236,
        has_border: true,
    };
}

// --- END OverlayConfig ---

/// Overlay showing keyboard shortcuts.
pub struct KeyboardHintsOverlay;

impl KeyboardHintsOverlay {
    /// Total rendered width of the keyboard hints window.
    pub const WIDTH: usize = 88;
    /// Content width (inner drawable area).
    const CONTENT_WIDTH: usize = 74; // 88 - 2(border) - 6(pad-L) - 6(pad-R)

    /// Builds the keyboard hints overlay content.
    pub fn build_overlay(
        accent: RgbColor,
        st: &PanelStyle,
        user_binds: &std::collections::HashMap<char, crate::keybind_manager::BindTarget>,
    ) -> RenderedOverlay {
        use crate::keybind_manager::BindTarget;
        use TextAlignment::Left;

        let label_for = |c: char| -> String {
            match user_binds.get(&c) {
                Some(BindTarget::Preset(p)) => p.name().to_string(),
                Some(BindTarget::Config(name)) => {
                    let truncated: String = name.chars().take(10).collect();
                    format!("cfg:{truncated}")
                }
                None => match crate::simulation::config::preset_for_set_key(c) {
                    Some(p) => p.name().to_string(),
                    None => "unbound".to_string(),
                },
            }
        };
        let keys_line1 = format!(
            "1:{} 2:{} 3:{}",
            label_for('1'),
            label_for('2'),
            label_for('3')
        );
        let keys_line2 = format!(
            "4:{} 5:{} 6:{} 7:{}",
            label_for('4'),
            label_for('5'),
            label_for('6'),
            label_for('7')
        );

        let thin_sep = "─".repeat(Self::CONTENT_WIDTH);

        let mut overlay = PanelBuilder::new(Self::CONTENT_WIDTH, None)
            .with_columns(ColumnLayout::TwoEqual)
            .with_padding(Padding::new(2, 2, 6, 6))
            .with_title("KEYBOARD REFERENCE")
            .with_title_box()
            .add_two_col("SIMULATION", "SYSTEM", Left, Left)
            .add_two_col(
                "Space      Pause / Resume",
                "Ctrl+S     Save config",
                Left,
                Left,
            )
            .add_two_col("r          Restart", "Ctrl+L     Load config", Left, Left)
            .add_two_col("Ctrl+Z     Undo", "Ctrl+Y     Redo", Left, Left)
            .add_single(keys_line1, Left)
            .add_single(keys_line2, Left)
            .add_single("Shift+1-7  Compare bound key", Left)
            .add_two_col(
                "p          Palette editor",
                "q / Esc    Quit / close",
                Left,
                Left,
            )
            .add_empty()
            .add_single(&thin_sep, Left)
            .add_empty()
            .add_two_col("OVERLAYS", "POST-PROCESSING", Left, Left)
            .add_two_col(
                "h          Controls panel",
                "m / M      Intensity map",
                Left,
                Left,
            )
            .add_two_col(
                "?          Keyboard hints",
                "9 / *      Cycle theme",
                Left,
                Left,
            )
            .add_two_col(
                "\\ / |      Dashboard",
                "{ }        Dither (dev)",
                Left,
                Left,
            )
            .add_two_col(
                "Ctrl+N     Notifications",
                "g          Save frame PNG",
                Left,
                Left,
            )
            .add_two_col(
                "( / )      Window frame",
                "F10        Cycle chrome",
                Left,
                Left,
            )
            .add_empty()
            .add_single(&thin_sep, Left)
            .add_empty()
            .add_two_col("CONTROLS (h opens)", "CONTROLS (h opens)", Left, Left)
            .add_two_col(
                "Tab        Tuner ⇄ Console",
                "[ / ]      Prev / Next category",
                Left,
                Left,
            )
            .add_two_col(
                "↑ / ↓      Move focus",
                "← / →      Adjust param",
                Left,
                Left,
            )
            .add_two_col(
                "↵          Activate action",
                {
                    #[cfg(feature = "audio")]
                    let choir_hint = "F2         Choir on / off";
                    #[cfg(not(feature = "audio"))]
                    let choir_hint = "";
                    choir_hint
                },
                Left,
                Left,
            )
            .build_overlay();
        overlay.rich_lines = Some(Self::generate_rich_lines(&overlay.lines, accent, st));
        overlay
    }

    /// Generates per-cell colour data: keybind tokens coloured with the accent colour.
    fn generate_rich_lines(
        lines: &[String],
        accent: RgbColor,
        st: &PanelStyle,
    ) -> Vec<Vec<RichCell>> {
        // Layout constants: 1 border + 6 padding = content starts at char index 7.
        const CONTENT_START: usize = 7;
        let col_width = Self::CONTENT_WIDTH / 2; // 37

        lines
            .iter()
            .map(|line| {
                let chars: Vec<char> = line.chars().collect();
                let n = chars.len();

                // Top border (█▀…), block separator (█▀…), and bottom border (█▄…):
                // all start with █ and have ▀ or ▄ as the second character.
                if chars.first() == Some(&'█')
                    && chars.get(1).map(|&c| c == '▀' || c == '▄').unwrap_or(false)
                {
                    return chars.iter().map(|&c| (c, None, None)).collect();
                }

                // Empty content rows: everything between the border █ chars is spaces.
                let inner_all_spaces = chars
                    .get(1..n.saturating_sub(1))
                    .map(|s| s.iter().all(|&c| c == ' '))
                    .unwrap_or(true);
                if inner_all_spaces {
                    return chars.iter().map(|&c| (c, None, None)).collect();
                }

                // Thin separator row: first content char (after border + padding) is ─.
                if chars.get(CONTENT_START) == Some(&'─') {
                    return chars.iter().map(|&c| (c, None, None)).collect();
                }

                // Regular content row: colour the key token in each column.
                let mut rich: Vec<RichCell> = chars.iter().map(|&c| (c, None, None)).collect();

                for col_idx in 0..2 {
                    let col_start = CONTENT_START + col_idx * col_width;
                    let col_end = (col_start + col_width).min(n);
                    if col_start >= n {
                        break;
                    }

                    let col_chars = &chars[col_start..col_end];
                    let col_text: String = col_chars.iter().collect();
                    let trimmed = col_text.trim_start();

                    if trimmed.is_empty() {
                        continue;
                    }

                    // Dev-only entries: muted for the whole column (token-based).
                    if col_text.contains("(dev)") {
                        for (i, &c) in col_chars.iter().enumerate() {
                            if c != ' ' {
                                rich[col_start + i] = (c, Some(st.muted), None);
                            }
                        }
                        continue;
                    }

                    // Section headers are all-uppercase (e.g. "SIMULATION", "POST-PROCESSING").
                    let first_word = trimmed.split_whitespace().next().unwrap_or("");
                    let is_header = !first_word.is_empty()
                        && first_word.chars().all(|c| c.is_uppercase() || c == '-');
                    if is_header {
                        continue;
                    }

                    // The key is the text up to the first run of two or more spaces.
                    let indent = col_text.chars().count() - trimmed.chars().count();
                    let key_len = Self::key_char_len(trimmed);
                    let abs_start = col_start + indent;
                    let abs_end = (abs_start + key_len).min(n);
                    for cell in rich.iter_mut().take(abs_end).skip(abs_start) {
                        cell.1 = Some(accent);
                    }
                }

                rich
            })
            .collect()
    }

    /// Returns the char length of the key token: everything before the first double-space run.
    fn key_char_len(text: &str) -> usize {
        let chars: Vec<char> = text.chars().collect();
        for (i, window) in chars.windows(2).enumerate() {
            if window[0] == ' ' && window[1] == ' ' {
                return i;
            }
        }
        chars.len()
    }

    /// Calculates center position for the overlay.
    ///
    /// `content_height` is the rendered body height (`overlay.lines.len()`). The
    /// floating title box sits one row above the body, so the visual block spans
    /// `content_height + 1` rows starting at `y - 1`; we center that whole block
    /// and keep `y >= 1` so the title stays on-screen. When the overlay is taller
    /// than the terminal it top-anchors (`y == 1`) so the top — the most useful
    /// rows — is always visible instead of being pushed off the bottom.
    pub fn calculate_position(
        term_width: usize,
        term_height: usize,
        content_height: usize,
    ) -> (usize, usize) {
        let x = (term_width.saturating_sub(Self::WIDTH)) / 2;
        let visual_height = content_height + 1; // +1 for the floating title row
        let top = term_height.saturating_sub(visual_height) / 2;
        (x, top + 1)
    }
}

/// Overlay for comparing current settings against a preset.
pub struct PresetComparisonOverlay;

impl PresetComparisonOverlay {
    /// Total rendered width of the comparison window.
    pub const WIDTH: usize = 62;
    /// Content width (inner drawable area).
    const CONTENT_WIDTH: usize = 56; // 62 - 2(border) - 2*2(padding)

    /// Builds the comparison overlay showing modified parameters.
    pub fn build_overlay(
        current: &crate::terminal::control::RuntimeState,
        target: &crate::terminal::state::ComparisonTarget,
    ) -> RenderedOverlay {
        use crate::terminal::state::ComparisonTarget;
        use TextAlignment::Left;

        let (label, defaults, col_header) = match target {
            ComparisonTarget::Preset(p) => (
                preset_name(*p).to_string(),
                crate::terminal::control::DefaultValues::from_preset(*p),
                "Preset Default",
            ),
            ComparisonTarget::Config(np) => {
                let (lbl, dv) = match np.overrides.resolve() {
                    Ok(p) => (
                        np.name.clone(),
                        crate::terminal::control::DefaultValues::from_sim_config(
                            &p.sim,
                            p.render.auto_normalize,
                        ),
                    ),
                    Err(_) => (
                        format!("{} (unresolved)", np.name),
                        crate::terminal::control::DefaultValues::from_preset(
                            crate::simulation::config::Preset::Organic,
                        ),
                    ),
                };
                (lbl, dv, "Config Default")
            }
        };

        let mut builder = PanelBuilder::new(Self::CONTENT_WIDTH, None)
            .with_padding(Padding::new(1, 1, 2, 2))
            .with_title(format!("COMPARISON: {}", label))
            .with_title_box()
            .add_empty_n(spacing::ROW)
            .add_single(
                format!("Parameter        │ Current      │ {}", col_header),
                Left,
            )
            .add_single(
                "──────────────────┼──────────────┼──────────────────────",
                Left,
            );

        let add_row =
            |b: PanelBuilder, name: &str, cur: String, def: String, modif: bool| -> PanelBuilder {
                let marker = if modif { "⚙" } else { " " };
                b.add_single(
                    format!("{} {:<16} │ {:<12} │ {:<18}", marker, name, cur, def),
                    Left,
                )
            };

        builder = add_row(
            builder,
            "Sensor Angle",
            format!("{:.1}°", current.sensor_angle),
            format!("{:.1}°", defaults.sensor_angle),
            (current.sensor_angle - defaults.sensor_angle).abs() > 0.01,
        );
        builder = add_row(
            builder,
            "Sensor Dist",
            format!("{:.1}px", current.sensor_distance),
            format!("{:.1}px", defaults.sensor_distance),
            (current.sensor_distance - defaults.sensor_distance).abs() > 0.01,
        );
        builder = add_row(
            builder,
            "Turn Angle",
            format!("{:.1}°", current.rotation_angle),
            format!("{:.1}°", defaults.rotation_angle),
            (current.rotation_angle - defaults.rotation_angle).abs() > 0.01,
        );
        builder = add_row(
            builder,
            "Step Size",
            format!("{:.1}px", current.step_size),
            format!("{:.1}px", defaults.step_size),
            (current.step_size - defaults.step_size).abs() > 0.01,
        );
        builder = add_row(
            builder,
            "Decay Factor",
            format!("{:.3}x", current.decay_factor),
            format!("{:.3}x", defaults.decay_factor),
            (current.decay_factor - defaults.decay_factor).abs() > 0.001,
        );
        builder = add_row(
            builder,
            "Deposit Amt",
            format!("{:.1}x", current.deposit_amount),
            format!("{:.1}x", defaults.deposit_amount),
            (current.deposit_amount - defaults.deposit_amount).abs() > 0.01,
        );
        builder = add_row(
            builder,
            "Diff Sigma",
            format!("{:.2}x", current.diffusion_sigma),
            format!("{:.2}x", defaults.diffusion_sigma),
            (current.diffusion_sigma - defaults.diffusion_sigma).abs() > 0.01,
        );
        builder = add_row(
            builder,
            "Brightness",
            format!(
                "{:.1}x",
                crate::config_defaults::trail::brightness_gain(current.max_brightness)
            ),
            format!(
                "{:.1}x",
                crate::config_defaults::trail::brightness_gain(defaults.max_brightness)
            ),
            (current.max_brightness - defaults.max_brightness).abs() > 0.01,
        );

        builder
            .add_empty_n(spacing::ROW)
            .add_single(
                footer_hints(&[("↵", "apply preset"), ("esc", "close")]),
                Left,
            )
            .build_overlay()
    }

    /// Calculates center position for the comparison overlay.
    pub fn calculate_position(term_width: usize, term_height: usize) -> (usize, usize) {
        let x = (term_width.saturating_sub(Self::WIDTH)) / 2;
        let y = (term_height.saturating_sub(15)) / 2;
        (x, y)
    }
}

/// Overlay for browsing saved configurations.
pub struct ConfigBrowserOverlay;

impl ConfigBrowserOverlay {
    /// Total rendered width of the browser window.
    pub const WIDTH: usize = 56;
    /// Content width (inner drawable area).
    const CONTENT_WIDTH: usize = 50; // 56 - 2(border) - 2*2(padding)
    /// Maximum number of config rows shown at once (preserves panel height).
    const MAX_VISIBLE_CONFIGS: usize = 9;

    /// Computes the index of the first visible config so the selection stays in view.
    ///
    /// Returns the window start that keeps `selected` (clamped to `total - 1`)
    /// visible, anchored at the bottom of the `[start, start + max_visible)`
    /// window. Because the selection is pinned to the bottom of the window,
    /// navigating upward jumps the window so `selected` sits on the last visible
    /// row. The result is clamped so the window never runs past the end of the list.
    fn config_browser_window(selected: usize, total: usize, max_visible: usize) -> usize {
        if total <= max_visible || max_visible == 0 {
            return 0;
        }
        selected
            .min(total - 1)
            .saturating_sub(max_visible - 1)
            .min(total - max_visible)
    }

    /// Builds the configuration list overlay.
    pub fn build_overlay(
        configs: &[crate::config_manager::NamedProfile],
        selected_index: usize,
    ) -> RenderedOverlay {
        use TextAlignment::Left;

        let mut builder = PanelBuilder::new(Self::CONTENT_WIDTH, None)
            .with_padding(Padding::new(1, 1, 2, 2))
            .with_title("SAVED CONFIGURATIONS")
            .with_title_box();

        if configs.is_empty() {
            builder = builder
                .add_empty_n(spacing::ROW)
                .add_single("No saved configurations", Left)
                .add_empty_n(spacing::ROW)
                .add_single("Press Ctrl+S to save current settings", Left)
                .add_empty_n(spacing::ROW);
        } else {
            let total = configs.len();
            let start =
                Self::config_browser_window(selected_index, total, Self::MAX_VISIBLE_CONFIGS);
            let end = (start + Self::MAX_VISIBLE_CONFIGS).min(total);

            // "Above" scroll indicator replaces the leading empty line when scrolled.
            if start > 0 {
                builder = builder.add_single(format!("▲ {} above", start), Left);
            } else {
                builder = builder.add_empty();
            }

            // Enumerate before skip so `i` stays the absolute index for marker logic.
            for (i, config) in configs
                .iter()
                .enumerate()
                .skip(start)
                .take(Self::MAX_VISIBLE_CONFIGS)
            {
                let num = i + 1;
                let selected_marker = if i == selected_index { "›" } else { " " };
                let name = &config.name;
                let palette = config
                    .overrides
                    .palette
                    .as_ref()
                    .map(|p| p.name())
                    .unwrap_or("?");
                let pop = config.overrides.population.unwrap_or(0) / 1000;
                let suffix = format!(" - {} - {}k agents", palette, pop);
                // Derive name budget from the column width, subtracting the
                // fixed overhead: marker(1) + num digits + space(1) + suffix.
                let num_digits = num.to_string().len();
                let overhead = 1 + num_digits + 1 + suffix.chars().count();
                let name_budget = Self::CONTENT_WIDTH.saturating_sub(overhead).max(4);
                let truncated_name = truncate_ellipsis(name, name_budget);
                let line = format!("{}{} {}{}", selected_marker, num, truncated_name, suffix,);
                builder = builder.add_single(line, Left);
            }

            // "Below" scroll indicator when more entries remain past the window.
            // Always emit this row (blank when at the bottom) so the panel body
            // keeps a constant row count and never changes height while scrolling.
            if end < total {
                builder = builder.add_single(format!("▼ {} below", total - end), Left);
            } else {
                builder = builder.add_empty();
            }

            builder = builder.add_empty_n(spacing::ROW).add_single(
                footer_hints(&[
                    ("↑↓", "navigate"),
                    ("↵", "load"),
                    ("del", "delete"),
                    ("esc", "cancel"),
                ]),
                Left,
            );
            return builder.build_overlay();
        }

        builder
            .add_single(footer_hints(&[("esc", "cancel")]), Left)
            .build_overlay()
    }

    /// Calculates center position for the browser overlay.
    pub fn calculate_position(term_width: usize, term_height: usize) -> (usize, usize) {
        let x = (term_width.saturating_sub(Self::WIDTH)) / 2;
        let y = (term_height.saturating_sub(15)) / 2;
        (x, y)
    }
}

/// Overlay for saving a new configuration.
pub struct ConfigSaveOverlay;

impl ConfigSaveOverlay {
    /// Total rendered width.
    const TOTAL_WIDTH: usize = 38;
    /// Content width (inner drawable area).
    const CONTENT_WIDTH: usize = 34; // 38 - 2(border) - 2*1(padding)

    /// Builds the save dialog overlay.
    pub fn build_overlay(name_input: &str) -> RenderedOverlay {
        use TextAlignment::Left;

        PanelBuilder::new(Self::CONTENT_WIDTH, None)
            .with_padding(Padding::new(0, 0, 1, 1))
            .with_title("SAVE CONFIGURATION")
            .with_title_box()
            .add_empty_n(spacing::ROW)
            .add_single(format!("Name: {:<25}", name_input), Left)
            .add_empty_n(spacing::ROW)
            .add_single(footer_hints(&[("↵", "save"), ("esc", "cancel")]), Left)
            .build_overlay()
    }

    /// Calculates center position for the save dialog.
    pub fn calculate_position(term_width: usize, term_height: usize) -> (usize, usize) {
        let x = (term_width.saturating_sub(Self::TOTAL_WIDTH)) / 2;
        let y = (term_height.saturating_sub(5)) / 2;
        (x, y)
    }
}

/// Overlay for the dirty-state guard: confirms discarding live edits before a swap.
pub struct DirtyGuardOverlay;

impl DirtyGuardOverlay {
    /// Total rendered width.
    const TOTAL_WIDTH: usize = 48;
    /// Content width (inner drawable area). 48 - 2(border) - 2*1(padding).
    const CONTENT_WIDTH: usize = 44;

    /// Builds the dirty-state guard dialog overlay.
    pub fn build_overlay() -> RenderedOverlay {
        use TextAlignment::Left;

        PanelBuilder::new(Self::CONTENT_WIDTH, None)
            .with_padding(Padding::new(0, 0, 1, 1))
            .with_title("UNSAVED CHANGES")
            .with_title_box()
            .add_empty_n(spacing::ROW)
            .add_single("Discard live edits and switch?", Left)
            .add_empty_n(spacing::ROW)
            .add_single(
                footer_hints(&[("↵", "discard & switch"), ("esc", "cancel")]),
                Left,
            )
            .build_overlay()
    }

    /// Calculates center position for the guard dialog.
    ///
    /// The overlay is 7 visible rows tall: 6 main-panel lines (top border + 4 content
    /// rows + bottom border) plus 1 title-box line drawn one row above. Subtracting 7
    /// keeps the dialog vertically centred.
    pub fn calculate_position(term_width: usize, term_height: usize) -> (usize, usize) {
        let x = (term_width.saturating_sub(Self::TOTAL_WIDTH)) / 2;
        let y = (term_height.saturating_sub(7)) / 2;
        (x, y)
    }
}

pub use crate::terminal::control::NotificationLevel;

/// Formats a notification string with an icon prefix for the given level.
///
/// Example output: `"✓  Config saved"` for `NotificationLevel::Success`.
pub fn format_notification(text: &str, level: NotificationLevel) -> String {
    format!("{}  {}", level.icon(), text)
}

/// Utilities for rendering overlay elements (status line, help lists).
pub struct OverlayRenderer;

impl OverlayRenderer {
    #[allow(clippy::too_many_arguments)]
    /// Builds the status bar string displayed at the bottom of the screen.
    ///
    /// Uses `◦` as a segment separator. Segments are added in priority order;
    /// lower-priority segments are omitted when the terminal is too narrow.
    ///
    /// Layout (left to right, right side right-aligned):
    /// ```text
    ///   PRESET  ◦  1.0×  ◦  ■ PALETTE  ◦  50k  ◦  KERNEL      ↺ ↻  ⏸ PAUSED  ? help
    /// ```
    pub fn build_status_line(
        is_paused: bool,
        preset: Preset,
        time_scale: f32,
        palette: Palette,
        dither_mode: DitherMode,
        width: usize,
        population: Option<usize>,
        diffusion_kernel: Option<&str>,
        can_undo: bool,
        can_redo: bool,
        accent: Option<RgbColor>,
        st: &PanelStyle,
    ) -> (String, Vec<(usize, RgbColor)>) {
        const SEP: &str = "  ◦  ";
        let mut color_overrides: Vec<(usize, RgbColor)> = Vec::new();

        // Theme colors drawn from L2 tokens — no hardcoded RGB.
        let muted = st.muted;
        let accent_success = st.accent_success; // green for undo ↺
        let accent_info = st.accent_info; // teal for redo ↻ and ?

        let preset_text = preset_name(preset);
        let palette_text = palette_name(palette);
        let time_text = format!("{:.1}×", time_scale);

        // Core left-side segments (always visible)
        let mut left = format!("  {}{}{}  ", preset_text, SEP, time_text);

        // Add palette swatch + name if space permits
        if width >= 52 {
            left.push_str("◦  ");
            // Color swatch: two block chars tinted with the palette accent
            if let Some(accent_color) = accent {
                let swatch_start = left.chars().count();
                left.push_str("■  ");
                color_overrides.push((swatch_start, accent_color));
                color_overrides.push((swatch_start + 1, accent_color));
            }
            left.push_str(&format!("{}  ", palette_text));
        }

        // Add population if space permits
        if let Some(pop) = population {
            if width >= 68 {
                left.push_str(&format!("◦  {}k  ", pop / 1000));
            }
        }

        // Add diffusion kernel if space permits
        if let Some(kernel) = diffusion_kernel {
            if width >= 88 {
                left.push_str(&format!("◦  {}  ", kernel));
            }
        }

        // Add dither mode if active and space permits
        let dither_segment = match dither_mode {
            DitherMode::None => None,
            DitherMode::Ordered { intensity, .. } => Some(format!("D {:.1}×", intensity)),
            DitherMode::ErrorDiffusion { .. } => Some("ED".to_string()),
            DitherMode::Hybrid { intensity, .. } => Some(format!("H {:.1}×", intensity)),
        };
        if let Some(ref d) = dither_segment {
            if width >= 60 {
                left.push_str(&format!("◦  {}  ", d));
            }
        }

        // Color all ◦ separator characters in the left segment with muted color
        let separator_positions: Vec<usize> = left
            .chars()
            .enumerate()
            .filter_map(|(i, c)| if c == '◦' { Some(i) } else { None })
            .collect();
        for pos in &separator_positions {
            color_overrides.push((*pos, muted));
        }

        // Right-side status indicators
        let mut right = String::new();
        let mut paused_offset_in_right: Option<usize> = None;
        let mut undo_offset_in_right: Option<usize> = None;
        let mut redo_offset_in_right: Option<usize> = None;
        let mut help_q_offset_in_right: Option<usize> = None;

        if can_undo || can_redo {
            if can_undo {
                undo_offset_in_right = Some(right.chars().count());
            }
            right.push_str(if can_undo { "↺" } else { "·" });
            right.push(' ');
            if can_redo {
                redo_offset_in_right = Some(right.chars().count());
            }
            right.push_str(if can_redo { "↻" } else { "·" });
            right.push_str("  ");
        }

        if is_paused {
            paused_offset_in_right = Some(right.chars().count());
            right.push_str("⏸ PAUSED  ");
        }

        if width >= 100 {
            help_q_offset_in_right = Some(right.chars().count());
            right.push_str("? help  ");
        }

        // Combine: left segments + right-aligned indicators
        let combined_len = left.chars().count() + right.chars().count();
        let result = if combined_len <= width {
            // Pad between left and right
            let gap = width.saturating_sub(combined_len);
            let right_start = left.chars().count() + gap;

            // Color ↺ (undo) in accent_success
            if let Some(off) = undo_offset_in_right {
                color_overrides.push((right_start + off, accent_success));
            }
            // Color ↻ (redo) in accent_info
            if let Some(off) = redo_offset_in_right {
                color_overrides.push((right_start + off, accent_info));
            }
            // Color ? in accent_info
            if let Some(off) = help_q_offset_in_right {
                color_overrides.push((right_start + off, accent_info));
            }
            // Color ⏸ PAUSED with accent_warning token
            if let Some(paused_off) = paused_offset_in_right {
                let global_start = right_start + paused_off;
                for i in 0.."⏸ PAUSED".chars().count() {
                    color_overrides.push((global_start + i, st.accent_warning));
                }
            }
            format!("{}{}{}", left, " ".repeat(gap), right)
        } else {
            // No room to right-align; just return the left part
            left
        };

        (result, color_overrides)
    }

    /// Calculates the X position for the status line (left-aligned or centered).
    pub fn status_line_x(status_line: &str, width: usize) -> usize {
        if status_line.len() < width {
            2
        } else {
            width.saturating_sub(status_line.len() + 2)
        }
    }
}

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

    #[test]
    fn test_panel_builder_separator() {
        let panel = PanelBuilder::new(8, None).with_padding(Padding::new(0, 0, 1, 1));
        let sep = panel.render_separator_line();
        assert!(sep.starts_with('█'));
        assert!(sep.ends_with('█'));
        // total_width = 1 + 1 + 8 + 1 + 1 = 12
        assert_eq!(sep.chars().count(), 12);
    }

    #[test]
    fn test_keyboard_hints_position() {
        // Centers the visual block (content + 1 title row), keeping y >= 1.
        let (x, y) = KeyboardHintsOverlay::calculate_position(100, 100, 28);
        assert_eq!(x, (100 - KeyboardHintsOverlay::WIDTH) / 2);
        assert_eq!(y, 100usize.saturating_sub(29) / 2 + 1);
    }

    #[test]
    fn test_keyboard_hints_position_top_anchors_when_too_tall() {
        // Overlay taller than the terminal: top-anchor at y == 1 so the title and
        // top rows stay on-screen instead of the body being pushed off the bottom.
        let (_x, y) = KeyboardHintsOverlay::calculate_position(120, 24, 30);
        assert_eq!(y, 1);
    }

    #[test]
    fn test_config_browser_overlay_empty() {
        let lines = ConfigBrowserOverlay::build_overlay(&[], 0);
        assert!(lines
            .lines
            .iter()
            .any(|l| l.contains("No saved configurations")));
        let (x, _y) = ConfigBrowserOverlay::calculate_position(100, 100);
        assert_eq!(x, 22);
    }

    #[test]
    fn test_config_save_overlay() {
        let lines = ConfigSaveOverlay::build_overlay("test");
        assert!(lines.lines.iter().any(|l| l.contains("test")));
        let (x, _y) = ConfigSaveOverlay::calculate_position(100, 100);
        assert_eq!(x, 31);
    }

    #[test]
    fn test_overlay_renderer_helper_positions() {
        assert_eq!(OverlayRenderer::status_line_x("abc", 10), 2);
        assert_eq!(OverlayRenderer::status_line_x("abcdefghij", 10), 0);
    }
}

/// Unified dashboard overlay combining stats and environment info in a landscape three-zone layout.
pub struct DashboardOverlay;

/// Rich colored cells for a gauge bar: `(char, RgbColor)` per cell.
type GaugeCells = Vec<(char, RgbColor)>;

/// Gauge override entry: `(body_line_index, content_col_offset, cells)`.
///
/// Stored during `build_overlay` and consumed by `generate_rich_lines` to apply
/// threshold-band colors to gauge rows.
type GaugeOverride = (usize, usize, GaugeCells);

/// Resolved gauge lookup entry used inside `generate_rich_lines`:
/// `(rendered_line_index, content_col_offset, cells_slice)`.
type GaugeLookupEntry<'a> = (usize, usize, &'a [(char, RgbColor)]);

impl DashboardOverlay {
    /// Total rendered width of the dashboard window.
    pub const WIDTH: usize = 84;
    /// Content width (inner drawable area): 84 - 2(border) - 2*2(padding) = 78
    const CONTENT_WIDTH: usize = 78;
    /// Left column width within the two-column middle zone.
    const LEFT_COL: usize = 37;
    /// Right column width within the two-column middle zone.
    const RIGHT_COL: usize = 38;

    /// Fixed body row count for constant-height layout (paused / running must match).
    ///
    /// Body rows (between the top/bottom borders, excluding PanelBuilder-added borders):
    ///   PERFORMANCE header + blank + FPS + blank  = 4
    ///   ENV|SIM header + blank                    = 2
    ///   Preset|Trail + Palette|Entropy + Grid|Agents + Term|MaxTrail + Seed|Frames + Init|Time = 6
    ///   blank + SYSTEM header + blank             = 3
    ///   CPU|SIMD + Mem|AutoReset + Color|Charset  = 3
    ///   food (conditional, padded)                = 1
    ///   extras (conditional, padded)              = 1
    ///   legend row                                = 1
    ///   blank + PALETTE header + strip            = 3
    ///   Total                                     = 24
    const BODY_ROWS: usize = 24;

    // ── Threshold bands for gauge recoloring ─────────────────────────────────
    //
    // FPS (range 0..60, target ≈ 60):
    //   healthy  ≥ 55  (target-5)  → accent_success
    //   warn     ≥ 45  (target-15) → accent_warning
    //   critical < 45              → accent_error
    //
    // Trail utilisation (range 0..100 %):
    //   healthy  < 80 %  → accent_success
    //   warn     < 95 %  → accent_warning
    //   critical ≥ 95 %  → accent_error
    //
    // Entropy (range 0..8, from calculate_entropy; max_entropy scaled to 8.0):
    //   healthy  ≥ 3.0  → accent_success   (well-mixed, diverse pattern)
    //   warn     ≥ 1.5  → accent_warning   (starting to stagnate)
    //   critical < 1.5  → accent_error     (low entropy = collapsed/uniform)
    //
    // CPU % (range 0..100 %):
    //   healthy  < 50 % → accent_success
    //   warn     < 80 % → accent_warning
    //   critical ≥ 80 % → accent_error
    //
    // Memory MB (range 0..100 MB):
    //   healthy  < 50 MB → accent_success
    //   warn     < 80 MB → accent_warning
    //   critical ≥ 80 MB → accent_error

    /// Calculates entropy of the trail map for complexity analysis.
    pub fn calculate_entropy(trail_map: &[f32], sample_rate: usize) -> f32 {
        if trail_map.is_empty() {
            return 0.0;
        }

        const NUM_BINS: usize = 16;
        let mut bins = [0usize; NUM_BINS];
        let mut total_samples = 0usize;

        for (i, &value) in trail_map.iter().enumerate() {
            if i % sample_rate == 0 && value > 0.01 {
                let normalized = (value / 10.0).clamp(0.0, 0.9999);
                let bin_idx = (normalized * NUM_BINS as f32) as usize;
                bins[bin_idx] += 1;
                total_samples += 1;
            }
        }

        if total_samples < 2 {
            return 0.0;
        }

        let mut entropy = 0.0f32;
        for &count in bins.iter() {
            if count > 0 {
                let p = count as f32 / total_samples as f32;
                entropy -= p * p.log2();
            }
        }

        let max_entropy = (NUM_BINS as f32).log2();
        if max_entropy > 0.0 {
            (entropy / max_entropy * 8.0).clamp(0.0, 8.0)
        } else {
            0.0
        }
    }

    /// Builds a section header spanning the full content width.
    fn build_section_header(label: &str, width: usize) -> String {
        let prefix = format!(" ── {} ", label);
        let dashes = width.saturating_sub(prefix.chars().count());
        format!("{}{}", prefix, "─".repeat(dashes))
    }

    /// Builds a two-column row with a │ divider.
    fn build_two_col_row(left: &str, right: &str) -> String {
        let left_chars: usize = left.chars().count();
        let right_chars: usize = right.chars().count();
        let left_padded = if left_chars < Self::LEFT_COL {
            format!("{}{}", left, " ".repeat(Self::LEFT_COL - left_chars))
        } else {
            left.chars().take(Self::LEFT_COL).collect()
        };
        let right_padded = if right_chars < Self::RIGHT_COL {
            format!("{}{}", right, " ".repeat(Self::RIGHT_COL - right_chars))
        } else {
            right.chars().take(Self::RIGHT_COL).collect()
        };
        format!("{} │ {}", left_padded, right_padded)
    }

    /// Returns a status pill string for a boolean.
    fn build_status(on: bool) -> &'static str {
        if on {
            "◦ On"
        } else {
            "○ Off"
        }
    }

    #[allow(clippy::too_many_arguments)]
    /// Builds the dashboard overlay content.
    pub fn build_overlay(
        agent_count: usize,
        trail_sum: f32,
        trail_capacity: f32,
        trail_max: f32,
        entropy: f32,
        fps: f32,
        avg_fps: f32,
        frame_count: u64,
        elapsed_seconds: f32,
        grid_width: usize,
        grid_height: usize,
        attractor_count: usize,
        obstacle_count: usize,
        species_count: usize,
        memory_mb: f32,
        cpu_percent: f32,
        is_paused: bool,
        preset_name: &str,
        palette_name: &str,
        palette_colors: &[RgbColor],
        term_width: usize,
        term_height: usize,
        init_mode: &str,
        color_mode: &str,
        charset: &str,
        simd_enabled: bool,
        _decay_factor: f32,
        _sensor_angle: f32,
        seed: u64,
        food_source: &Option<String>,
        _warmup_frames: usize,
        auto_reset: bool,
        accent: RgbColor,
        panel_style: &PanelStyle,
    ) -> RenderedOverlay {
        use TextAlignment::Left;

        let cw = Self::CONTENT_WIDTH;
        let st = panel_style;

        let trail_percent = if trail_capacity > 0.0 {
            (trail_sum / trail_capacity * 100.0).min(99.9)
        } else {
            0.0
        };
        let elapsed_str = format_elapsed_time(elapsed_seconds);

        // ── Threshold-aware fill color selection ──────────────────────────────
        // Returns the fill color for a gauge based on the metric's threshold bands.
        // See BODY_ROWS comment block above for full band definitions.
        let fps_fill_color = if fps >= 55.0 {
            st.accent_success
        } else if fps >= 45.0 {
            st.accent_warning
        } else {
            st.accent_error
        };
        let trail_fill_color = if trail_percent < 80.0 {
            st.accent_success
        } else if trail_percent < 95.0 {
            st.accent_warning
        } else {
            st.accent_error
        };
        let entropy_fill_color = if entropy >= 3.0 {
            st.accent_success
        } else if entropy >= 1.5 {
            st.accent_warning
        } else {
            st.accent_error
        };
        let cpu_fill_color = if cpu_percent < 50.0 {
            st.accent_success
        } else if cpu_percent < 80.0 {
            st.accent_warning
        } else {
            st.accent_error
        };
        let mem_fill_color = if memory_mb < 50.0 {
            st.accent_success
        } else if memory_mb < 80.0 {
            st.accent_warning
        } else {
            st.accent_error
        };

        // ── Build gauges (FPS is width 32, the rest width 15) ─────────────────
        // Each gauge returns Vec<(char, RgbColor)>; we extract chars for the
        // string lines and store cells for rich_lines coloring.
        //
        // gauge() uses accent_active for fill + value tick; the `recolor` closure
        // below repaints the fill cells ('█' + '│') with the threshold color.
        let fps_gauge_cells = gauge(fps, (0.0, 60.0), 60.0, 32, st);
        let trail_gauge_cells = gauge(trail_percent, (0.0, 100.0), 100.0, 15, st);
        let entropy_gauge_cells = gauge(entropy, (0.0, 8.0), 8.0, 15, st);
        let cpu_gauge_cells = gauge(cpu_percent, (0.0, 100.0), 100.0, 15, st);
        let mem_gauge_cells = gauge(memory_mb, (0.0, 100.0), 100.0, 15, st);

        // Extract plain chars from gauge cells for string rendering
        let fps_bar: String = fps_gauge_cells.iter().map(|(c, _)| c).collect();
        let trail_bar: String = trail_gauge_cells.iter().map(|(c, _)| c).collect();
        let entropy_bar: String = entropy_gauge_cells.iter().map(|(c, _)| c).collect();
        let cpu_bar: String = cpu_gauge_cells.iter().map(|(c, _)| c).collect();
        let mem_bar: String = mem_gauge_cells.iter().map(|(c, _)| c).collect();

        // Recolor fill cells (█ and │) with threshold-band color.
        // The ▲ default tick keeps st.state_default, ░ keeps st.muted.
        let recolor =
            |cells: Vec<(char, RgbColor)>, fill_color: RgbColor| -> Vec<(char, RgbColor)> {
                cells
                    .into_iter()
                    .map(|(c, col)| {
                        if c == '█' || c == '│' {
                            (c, fill_color)
                        } else {
                            (c, col)
                        }
                    })
                    .collect()
            };
        let fps_gauge_rich = recolor(fps_gauge_cells, fps_fill_color);
        let trail_gauge_rich = recolor(trail_gauge_cells, trail_fill_color);
        let entropy_gauge_rich = recolor(entropy_gauge_cells, entropy_fill_color);
        let cpu_gauge_rich = recolor(cpu_gauge_cells, cpu_fill_color);
        let mem_gauge_rich = recolor(mem_gauge_cells, mem_fill_color);

        // ── Legend row cells ──────────────────────────────────────────────────
        let legend_cells = legend(&[
            ("█ good", st.accent_success),
            ("█ warn", st.accent_warning),
            ("█ crit", st.accent_error),
            ("▲ default", st.state_default),
        ]);
        let legend_str: String = legend_cells.iter().map(|(c, _)| c).collect();

        let grid_str = format!("{}×{}", grid_width, grid_height);
        let term_str = format!("{}×{}", term_width, term_height);
        let seed_str = seed.to_string();
        let food_str = food_source.as_ref().map(|f| {
            std::path::Path::new(f)
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or(f)
                .to_string()
        });

        // ── Build line list ───────────────────────────────────────────────────
        // Lines are built in a fixed order. Optional rows (food, extras) are
        // always emitted (blank when not present) to keep BODY_ROWS constant.
        let mut lines: Vec<String> = Vec::with_capacity(Self::BODY_ROWS);
        // Map from body line index → (col_offset, gauge_rich_cells)
        // col_offset is the offset within the content (not including border/padding).
        let mut gauge_overrides: Vec<GaugeOverride> = Vec::new();
        // Map from body line index → (col_offset, legend_cells)
        let mut legend_overrides: Vec<GaugeOverride> = Vec::new();

        // ── PERFORMANCE ──                                                 row 0
        lines.push(format!(
            "{:<cw$}",
            Self::build_section_header("PERFORMANCE", cw)
        ));
        // blank                                                             row 1
        lines.push(format!("{:<cw$}", ""));
        // FPS row                                                           row 2
        {
            let row_idx = lines.len();
            let fps_row = if is_paused {
                // Show muted bar when paused (gauge chars but all muted — just use ░)
                format!("  FPS   {:<32}  PAUSED", "░".repeat(32))
            } else {
                format!("  FPS   {}  {:.0}  avg {:.0}", fps_bar, fps, avg_fps)
            };
            lines.push(format!("{:<cw$}", fps_row));
            if !is_paused {
                // "  FPS   " = 9 chars; gauge starts at col 9 within content
                gauge_overrides.push((row_idx, 9, fps_gauge_rich));
            }
        }
        // blank                                                             row 3
        lines.push(format!("{:<cw$}", ""));

        // ── ENVIRONMENT | SIMULATION ──                                    row 4
        let env_header = Self::build_section_header("ENVIRONMENT", Self::LEFT_COL);
        let sim_header = Self::build_section_header("SIMULATION", Self::RIGHT_COL);
        lines.push(format!(
            "{:<cw$}",
            Self::build_two_col_row(&env_header, &sim_header)
        ));
        // blank                                                             row 5
        lines.push(format!("{:<cw$}", ""));

        // Preset | Trail                                                    row 6
        {
            let row_idx = lines.len();
            let trail_row = format!("  Trail   {}  {:.1}%", trail_bar, trail_percent);
            lines.push(format!(
                "{:<cw$}",
                Self::build_two_col_row(
                    &format!("  Preset  {:>18}", truncate_ellipsis(preset_name, 18)),
                    &trail_row
                )
            ));
            // Two-col right side starts at LEFT_COL + 3 (" │ ").
            // Within the right side: "  Trail   " = 10 chars; gauge at right_start + 10
            let trail_gauge_col = Self::LEFT_COL + 3 + 10;
            gauge_overrides.push((row_idx, trail_gauge_col, trail_gauge_rich));
        }

        // Palette | Entropy                                                 row 7
        {
            let row_idx = lines.len();
            let entropy_row = format!("  Entropy {}  {:.1}", entropy_bar, entropy);
            lines.push(format!(
                "{:<cw$}",
                Self::build_two_col_row(
                    &format!("  Palette {:>18}", truncate_ellipsis(palette_name, 18)),
                    &entropy_row
                )
            ));
            // "  Entropy " = 10 chars
            let entropy_gauge_col = Self::LEFT_COL + 3 + 10;
            gauge_overrides.push((row_idx, entropy_gauge_col, entropy_gauge_rich));
        }

        // Grid | Agents                                                     row 8
        lines.push(format!(
            "{:<cw$}",
            Self::build_two_col_row(
                &format!("  Grid    {:>18}", grid_str),
                &format!("  Agents        {:>10}", Self::format_count(agent_count))
            )
        ));

        // Term | Max Trail                                                  row 9
        lines.push(format!(
            "{:<cw$}",
            Self::build_two_col_row(
                &format!("  Term    {:>18}", term_str),
                &format!("  Max Trail     {:>7.2}×", trail_max)
            )
        ));

        // Seed | Frames                                                     row 10
        lines.push(format!(
            "{:<cw$}",
            Self::build_two_col_row(
                &format!("  Seed    {:>18}", truncate_ellipsis(&seed_str, 18)),
                &format!(
                    "  Frames        {:>10}",
                    Self::format_count(frame_count as usize)
                )
            )
        ));

        // Init | Time                                                       row 11
        lines.push(format!(
            "{:<cw$}",
            Self::build_two_col_row(
                &format!("  Init    {:>18}", init_mode),
                &format!("  Time          {:>10}", elapsed_str)
            )
        ));

        // ── SYSTEM ──                                                      rows 12-14
        lines.push(format!("{:<cw$}", ""));
        lines.push(format!("{:<cw$}", Self::build_section_header("SYSTEM", cw)));
        lines.push(format!("{:<cw$}", ""));

        // CPU | SIMD                                                        row 15
        {
            let row_idx = lines.len();
            let cpu_row = format!("  CPU   {}  {:.0}%", cpu_bar, cpu_percent);
            lines.push(format!(
                "{:<cw$}",
                Self::build_two_col_row(
                    &cpu_row,
                    &format!("  SIMD          {:>10}", Self::build_status(simd_enabled))
                )
            ));
            // "  CPU   " = 8 chars
            gauge_overrides.push((row_idx, 8, cpu_gauge_rich));
        }

        // Mem | Auto Reset                                                  row 16
        {
            let row_idx = lines.len();
            let mem_label = format!("{:.1} MB", memory_mb);
            let mem_row = format!("  Mem   {}  {}", mem_bar, mem_label);
            let auto_reset_right =
                format!("  Auto Reset    {:>10}", Self::build_status(auto_reset));
            lines.push(format!(
                "{:<cw$}",
                Self::build_two_col_row(&mem_row, &auto_reset_right)
            ));
            // "  Mem   " = 8 chars
            gauge_overrides.push((row_idx, 8, mem_gauge_rich));
        }

        // Color | Charset                                                   row 17
        lines.push(format!(
            "{:<cw$}",
            Self::build_two_col_row(
                &format!("  Color   {:>18}", truncate_ellipsis(color_mode, 18)),
                &format!("  Charset       {:>10}", truncate_ellipsis(charset, 10))
            )
        ));

        // Food row (always emitted; blank when no food source)             row 18
        {
            let row_str = if let Some(food_display) = food_str.as_deref() {
                Self::build_two_col_row(
                    &format!("  Food    {:>18}", truncate_ellipsis(food_display, 18)),
                    "",
                )
            } else {
                String::new()
            };
            lines.push(format!("{:<cw$}", row_str));
        }

        // Extras row (always emitted; blank when no extras)                row 19
        {
            let row_str = if attractor_count > 0 || obstacle_count > 0 || species_count > 1 {
                #[cfg(feature = "multi-species")]
                let spc_segment = format!("Spc:{} ", species_count);
                #[cfg(not(feature = "multi-species"))]
                let spc_segment = String::new();
                Self::build_two_col_row(
                    &format!(
                        "  {}Att:{} Obs:{}",
                        spc_segment, attractor_count, obstacle_count
                    ),
                    "",
                )
            } else {
                String::new()
            };
            lines.push(format!("{:<cw$}", row_str));
        }

        // Legend row                                                        row 20
        {
            let row_idx = lines.len();
            // Indent 2 to align with other content rows
            let legend_padded = format!("  {}", legend_str);
            lines.push(format!("{:<cw$}", legend_padded));
            // Offset 2 for the "  " prefix
            legend_overrides.push((row_idx, 2, legend_cells));
        }

        // ── PALETTE ──                                                     rows 21-23
        lines.push(format!("{:<cw$}", ""));
        lines.push(format!(
            "{:<cw$}",
            Self::build_section_header("PALETTE", cw)
        ));
        // Palette gradient strip: 78 colored ▄ chars
        let palette_strip: String = (0..cw).map(|_| '▄').collect();
        lines.push(format!("{:<cw$}", palette_strip));

        debug_assert_eq!(
            lines.len(),
            Self::BODY_ROWS,
            "build_overlay: line count {} != BODY_ROWS {}",
            lines.len(),
            Self::BODY_ROWS
        );

        // ── Assemble overlay ──────────────────────────────────────────────────
        let mut overlay = PanelBuilder::new(cw, None)
            .with_padding(Padding::new(2, 0, 2, 2))
            .with_title("DASHBOARD")
            .with_title_box();

        for line in &lines {
            overlay = overlay.add_single(line.clone(), Left);
        }

        let mut rendered = overlay.build_overlay();
        rendered.rich_lines = Some(Self::generate_rich_lines(
            &rendered.lines,
            fps,
            accent,
            panel_style,
            palette_colors,
            &gauge_overrides,
            &legend_overrides,
        ));
        rendered
    }

    fn format_count(n: usize) -> String {
        if n >= 1_000_000 {
            format!("{:.1}M", n as f32 / 1_000_000.0)
        } else if n >= 1_000 {
            format!("{:.0}k", n as f32 / 1000.0)
        } else {
            format!("{}", n)
        }
    }

    /// Calculates centered position for the dashboard overlay.
    pub fn calculate_position(term_width: usize, term_height: usize) -> (usize, usize) {
        let x = (term_width.saturating_sub(Self::WIDTH)) / 2;
        let y = (term_height.saturating_sub(27)) / 2;
        (x, y)
    }

    /// Generates per-cell color overrides for the dashboard rich rendering.
    ///
    /// `gauge_overrides` and `legend_overrides` map body line indices
    /// (0-based in the raw `lines` vec) to `(content_col_offset, cells)` pairs.
    /// PanelBuilder prepends 1 top-border row + 2 top-padding rows, so body line
    /// `b` maps to rendered line `b + 3`.
    fn generate_rich_lines(
        lines: &[String],
        _fps: f32,
        accent: RgbColor,
        panel_style: &PanelStyle,
        palette_colors: &[RgbColor],
        gauge_overrides: &[GaugeOverride],
        legend_overrides: &[GaugeOverride],
    ) -> Vec<Vec<RichCell>> {
        let muted = panel_style.muted;
        let text_secondary = panel_style.text_secondary;

        // Build lookups: (rendered_line_idx, content_col_offset, cells_ref)
        // PanelBuilder build() applies top border (1) + top padding (2) = 3 offset, so body line b → rendered line b+3.
        // (title_box is a separate RenderedOverlay field, not in lines)
        let gauge_lookup: Vec<GaugeLookupEntry<'_>> = gauge_overrides
            .iter()
            .map(|(body_idx, col_offset, cells)| (body_idx + 3, *col_offset, cells.as_slice()))
            .collect();
        let legend_lookup: Vec<GaugeLookupEntry<'_>> = legend_overrides
            .iter()
            .map(|(body_idx, col_offset, cells)| (body_idx + 3, *col_offset, cells.as_slice()))
            .collect();

        lines
            .iter()
            .enumerate()
            .map(|(rendered_line_idx, line)| {
                let chars: Vec<char> = line.chars().collect();
                let n = chars.len();
                // Content starts at col 3 (border=1, padding.left=2)
                let content_start = 3.min(n);
                let content_end = n.saturating_sub(3);

                // Border rows (top/bottom) have no space at col 1 — skip all coloring.
                if n >= 2 && chars[1] != ' ' {
                    return chars.iter().map(|&c| (c, None, None)).collect();
                }

                // Detect section header rows: contain " ── "
                let is_section_header = {
                    let s: String = chars[content_start.min(n)..content_end.min(n)]
                        .iter()
                        .collect();
                    s.contains(" ── ")
                };

                // Detect palette strip: all content chars are '▄'
                let is_palette_strip = content_start < content_end
                    && chars[content_start..content_end].iter().all(|&c| c == '▄');

                // Detect FPS row (still needed for digit coloring after the gauge)
                let is_fps_row = content_start + 5 < n
                    && chars[content_start + 2..].starts_with(&['F', 'P', 'S']);

                // Detect divider row (contains '│' at approx col 40 — the column separator)
                // Only the two-col divider; gauge '│' ticks are in gauge cells, not plain '│'.
                // Find the divider-col '│' that is NOT inside any gauge region on this row.
                let gauge_ranges_this_row: Vec<(usize, usize)> = gauge_lookup
                    .iter()
                    .filter(|(ri, _, _)| *ri == rendered_line_idx)
                    .map(|(_, col_offset, cells)| {
                        let abs_start = content_start + col_offset;
                        (abs_start, abs_start + cells.len())
                    })
                    .collect();
                let divider_col = chars.iter().enumerate().position(|(i, &c)| {
                    c == '│' && !gauge_ranges_this_row.iter().any(|(s, e)| i >= *s && i < *e)
                });

                // Check if this row has gauge overrides
                let gauge_this_row: Option<(usize, &[(char, RgbColor)])> = gauge_lookup
                    .iter()
                    .find(|(ri, _, _)| *ri == rendered_line_idx)
                    .map(|(_, col_offset, cells)| (*col_offset, *cells));

                // Check if this row has legend overrides
                let legend_this_row: Option<(usize, &[(char, RgbColor)])> = legend_lookup
                    .iter()
                    .find(|(ri, _, _)| *ri == rendered_line_idx)
                    .map(|(_, col_offset, cells)| (*col_offset, *cells));

                if is_palette_strip {
                    // Per-char palette color from sampled gradient
                    chars
                        .iter()
                        .enumerate()
                        .map(|(i, &c)| {
                            let fg = if i >= content_start && i < content_end {
                                let palette_idx = i - content_start;
                                palette_colors.get(palette_idx).copied()
                            } else {
                                None
                            };
                            (c, fg, None)
                        })
                        .collect()
                } else if is_section_header {
                    chars
                        .iter()
                        .enumerate()
                        .map(|(i, &c)| {
                            let fg = if i >= content_start && i < content_end {
                                if matches!(c, '─' | ' ') {
                                    Some(muted)
                                } else {
                                    Some(text_secondary)
                                }
                            } else {
                                None
                            };
                            (c, fg, None)
                        })
                        .collect()
                } else {
                    // Generic / gauge / legend / FPS row.
                    // Priority: gauge cells > legend cells > FPS digit > divider > status dots.
                    chars
                        .iter()
                        .enumerate()
                        .map(|(i, &c)| {
                            let content_col = i.saturating_sub(content_start);

                            // Gauge override: apply pre-computed threshold-colored cells.
                            if let Some((gauge_col, gauge_cells)) = gauge_this_row {
                                if i >= content_start {
                                    let gauge_start = content_start + gauge_col;
                                    let gauge_end = gauge_start + gauge_cells.len();
                                    if i >= gauge_start && i < gauge_end {
                                        let g_idx = i - gauge_start;
                                        return (c, Some(gauge_cells[g_idx].1), None);
                                    }
                                }
                            }

                            // Legend override: apply pre-computed colored cells.
                            if let Some((leg_col, leg_cells)) = legend_this_row {
                                if i >= content_start {
                                    let leg_start = content_start + leg_col;
                                    let leg_end = leg_start + leg_cells.len();
                                    if i >= leg_start && i < leg_end {
                                        let l_idx = i - leg_start;
                                        return (c, Some(leg_cells[l_idx].1), None);
                                    }
                                }
                            }

                            // FPS value digits (after the gauge bar)
                            let fg = if is_fps_row
                                && i >= content_start
                                && i < content_end
                                && (41..60).contains(&content_col)
                                && (c.is_ascii_digit() || c == '.')
                            {
                                // FPS value digits appear after the 32-wide gauge bar
                                // "  FPS   [32 gauge chars]  N  avg N"
                                // col 0..1 = "  ", col 2..4 = "FPS", col 5..8 = "   ",
                                // col 9..40 = gauge (32), col 41+ = "  N  avg N"
                                Some(accent)
                            } else if Some(i) == divider_col {
                                Some(muted)
                            } else if c == '◦' {
                                Some(accent)
                            } else if c == '○' {
                                Some(muted)
                            } else {
                                None
                            };
                            (c, fg, None)
                        })
                        .collect()
                }
            })
            .collect()
    }
}

fn format_elapsed_time(seconds: f32) -> String {
    let total_secs = seconds as u64;
    let hours = total_secs / 3600;
    let minutes = (total_secs % 3600) / 60;
    let secs = total_secs % 60;

    if hours > 0 {
        format!("{}:{:02}:{:02}", hours, minutes, secs)
    } else {
        format!("{}:{:02}", minutes, secs)
    }
}

#[cfg(test)]
mod dashboard_tests {
    use super::*;
    use crate::render::theme::GRUVBOX_DARK;

    fn make_palette_colors() -> Vec<RgbColor> {
        (0..78)
            .map(|i| RgbColor {
                r: i as u8 * 3,
                g: 100,
                b: 200,
            })
            .collect()
    }

    #[test]
    fn test_dashboard_overlay_format() {
        let palette_colors = make_palette_colors();
        let accent = RgbColor {
            r: 57,
            g: 211,
            b: 83,
        };
        let rendered = DashboardOverlay::build_overlay(
            50000,
            1234567.0,
            8000000.0,
            8.5,
            5.5,
            30.0,
            28.5,
            1234,
            125.5,
            400,
            400,
            3,
            1,
            2,
            12.5,
            85.0,
            false,
            "Organic",
            "Heat",
            &palette_colors,
            120,
            40,
            "Random",
            "TrueColor",
            "HalfBlock",
            true,
            0.90,
            22.5,
            123456789,
            &None,
            0,
            false,
            accent,
            &GRUVBOX_DARK,
        );

        assert!(!rendered.lines.is_empty());
        // Solid-block borders
        assert!(
            rendered.lines[0].starts_with('█'),
            "Top border should start with solid block █, got: {}",
            rendered.lines[0]
        );
        assert!(
            rendered.lines.last().unwrap().starts_with('█'),
            "Bottom border should start with solid block █"
        );

        // All lines should be exactly WIDTH chars
        for (i, line) in rendered.lines.iter().enumerate() {
            assert_eq!(
                line.chars().count(),
                DashboardOverlay::WIDTH,
                "Line {} has wrong width: '{}' (expected {}, got {})",
                i,
                line,
                DashboardOverlay::WIDTH,
                line.chars().count()
            );
        }
    }

    #[test]
    fn test_dashboard_overlay_contains_key_data() {
        let palette_colors = make_palette_colors();
        let accent = RgbColor {
            r: 57,
            g: 211,
            b: 83,
        };
        let rendered = DashboardOverlay::build_overlay(
            50000,
            1234567.0,
            8000000.0,
            8.5,
            5.5,
            30.0,
            28.5,
            1234,
            125.5,
            400,
            400,
            0,
            0,
            1,
            12.5,
            85.0,
            false,
            "Organic",
            "Heat",
            &palette_colors,
            120,
            40,
            "Random",
            "TrueColor",
            "HalfBlock",
            true,
            0.90,
            22.5,
            42,
            &None,
            0,
            false,
            accent,
            &GRUVBOX_DARK,
        );

        let all_text: String = rendered.lines.join("\n");
        assert!(
            all_text.contains("PERFORMANCE"),
            "Missing PERFORMANCE section"
        );
        assert!(
            all_text.contains("ENVIRONMENT"),
            "Missing ENVIRONMENT section"
        );
        assert!(
            all_text.contains("SIMULATION"),
            "Missing SIMULATION section"
        );
        assert!(all_text.contains("PALETTE"), "Missing PALETTE section");
        assert!(all_text.contains("Organic"), "Missing preset name");
        assert!(all_text.contains("Heat"), "Missing palette name");
        assert!(all_text.contains("FPS"), "Missing FPS row");
    }

    #[test]
    fn test_dashboard_overlay_position() {
        let (x, y) = DashboardOverlay::calculate_position(120, 40);
        assert_eq!(x, (120 - DashboardOverlay::WIDTH) / 2);
        assert_eq!(y, (40usize.saturating_sub(27)) / 2);
    }

    #[test]
    fn test_entropy_calculation() {
        let uniform = vec![1.0; 40000];
        let entropy_uniform = DashboardOverlay::calculate_entropy(&uniform, 100);
        assert!(
            entropy_uniform < 2.0,
            "uniform should have low entropy, got {}",
            entropy_uniform
        );

        let varied: Vec<f32> = (0..40000).map(|i| i as f32 / 400.0).collect();
        let entropy_varied = DashboardOverlay::calculate_entropy(&varied, 100);
        assert!(
            entropy_varied > entropy_uniform,
            "varied ({}) should have higher entropy than uniform ({})",
            entropy_varied,
            entropy_uniform
        );
    }

    #[test]
    fn test_entropy_empty_trail() {
        let empty: Vec<f32> = vec![];
        let entropy = DashboardOverlay::calculate_entropy(&empty, 10);
        assert_eq!(entropy, 0.0);
    }

    #[test]
    fn test_format_elapsed_time() {
        assert_eq!(format_elapsed_time(30.0), "0:30");
        assert_eq!(format_elapsed_time(90.0), "1:30");
        assert_eq!(format_elapsed_time(3661.0), "1:01:01");
        assert_eq!(format_elapsed_time(0.0), "0:00");
    }

    /// Helper: builds a dashboard overlay with a set of representative values.
    fn build_test_overlay(is_paused: bool) -> super::RenderedOverlay {
        let palette_colors = make_palette_colors();
        let accent = RgbColor {
            r: 57,
            g: 211,
            b: 83,
        };
        DashboardOverlay::build_overlay(
            50000,     // agent_count
            1234567.0, // trail_sum
            8000000.0, // trail_capacity
            8.5,       // trail_max
            5.5,       // entropy
            50.0,      // fps (warn band: 45 ≤ fps < 55)
            48.0,      // avg_fps
            1234,      // frame_count
            125.5,     // elapsed_seconds
            400,       // grid_width
            400,       // grid_height
            0,         // attractor_count
            0,         // obstacle_count
            1,         // species_count
            12.5,      // memory_mb
            45.0,      // cpu_percent
            is_paused,
            "Organic",
            "Heat",
            &palette_colors,
            120,         // term_width
            40,          // term_height
            "Random",    // init_mode
            "TrueColor", // color_mode
            "HalfBlock", // charset
            true,        // simd_enabled
            0.90,        // _decay_factor
            22.5,        // _sensor_angle
            42,          // seed
            &None,       // food_source
            0,           // _warmup_frames
            false,       // auto_reset
            accent,
            &GRUVBOX_DARK,
        )
    }

    /// UX-win D: gauge glyphs (█ ░ │ ▲) must appear in gauge rows.
    #[test]
    fn dashboard_gauges_use_canonical_glyphs() {
        let rendered = build_test_overlay(false);
        let all_text: String = rendered.lines.join(
            "
",
        );
        // The gauge widget uses │ (value tick) and ▲ (default tick).
        assert!(
            all_text.contains('│'),
            "Gauge value-tick │ must appear in dashboard"
        );
        assert!(
            all_text.contains('▲'),
            "Gauge default-tick ▲ must appear in dashboard"
        );
        // Fill and empty chars.
        assert!(all_text.contains('█'), "Gauge fill █ must appear");
        assert!(all_text.contains('░'), "Gauge empty ░ must appear");
    }

    /// UX-win D: body line count must be constant whether paused or running.
    #[test]
    fn dashboard_body_is_constant_height_paused_vs_running() {
        let running = build_test_overlay(false);
        let paused = build_test_overlay(true);
        assert_eq!(
            running.lines.len(),
            paused.lines.len(),
            "Dashboard must be constant height paused ({}) vs running ({})",
            paused.lines.len(),
            running.lines.len()
        );
    }

    /// UX-win D: legend row must be present and contain all four band labels.
    #[test]
    fn dashboard_legend_row_present() {
        let rendered = build_test_overlay(false);
        let all_text: String = rendered.lines.join(
            "
",
        );
        assert!(all_text.contains("good"), "Legend must contain 'good'");
        assert!(all_text.contains("warn"), "Legend must contain 'warn'");
        assert!(all_text.contains("crit"), "Legend must contain 'crit'");
        assert!(
            all_text.contains("default"),
            "Legend must contain 'default'"
        );
    }

    /// UX-win D: gauge rich_lines must apply threshold colors (not uniform accent_active).
    /// For fps=50 (warn band: 45 ≤ fps < 55), the fill cells should use accent_warning.
    #[test]
    fn dashboard_gauge_rich_lines_apply_threshold_colors() {
        let rendered = build_test_overlay(false);
        let rich = rendered
            .rich_lines
            .as_ref()
            .expect("rich_lines must be Some");
        // PanelBuilder build() applies top border (1) + top padding (2) = 3 offset.
        // Body line 2 (FPS) → rendered line 5. (title_box is a separate RenderedOverlay field)
        // Content starts at rendered col 3; gauge starts at content_col 9, so rendered col 12.
        let fps_rich_row = &rich[5];
        // Find a '█' fill cell in the FPS gauge region (rendered cols 12..44)
        let fill_cell = fps_rich_row
            .iter()
            .enumerate()
            .find(|(i, (c, fg, _))| *c == '█' && *i >= 12 && fg.is_some());
        assert!(
            fill_cell.is_some(),
            "FPS gauge fill cell must be present in rich row 5; row = {:?}",
            fps_rich_row.iter().map(|(c, _, _)| c).collect::<String>()
        );
        let (_, (_, fill_color, _)) = fill_cell.unwrap();
        // fps=50 → warn band (45 ≤ fps < 55) → accent_warning
        assert_eq!(
            *fill_color,
            Some(GRUVBOX_DARK.accent_warning),
            "fps=50 is in warn band; fill should be accent_warning"
        );
    }
    /// UX-win D: gauge fill cells in good band (fps ≥ 55) should use accent_success.
    #[test]
    fn dashboard_gauge_rich_lines_good_band_threshold_color() {
        let palette_colors = make_palette_colors();
        let accent = RgbColor {
            r: 57,
            g: 211,
            b: 83,
        };
        let rendered = DashboardOverlay::build_overlay(
            50000,
            1234567.0,
            8000000.0,
            8.5,
            5.5,
            60.0, // fps in good band (≥ 55)
            58.0,
            1234,
            125.5,
            400,
            400,
            0,
            0,
            1,
            12.5,
            45.0,
            false,
            "Organic",
            "Heat",
            &palette_colors,
            120,
            40,
            "Random",
            "TrueColor",
            "HalfBlock",
            true,
            0.90,
            22.5,
            42,
            &None,
            0,
            false,
            accent,
            &GRUVBOX_DARK,
        );
        let rich = rendered
            .rich_lines
            .as_ref()
            .expect("rich_lines must be Some");
        let fps_rich_row = &rich[5];
        // Find a '█' fill cell in the FPS gauge region (rendered cols 12..44), avoiding the tick
        let fill_cell = fps_rich_row.iter().enumerate().find(|(i, (c, fg, _))| {
            *c == '█' && *i >= 12 && *i < 40 && fg.is_some() // exclude rightmost cols near tick
        });
        assert!(
            fill_cell.is_some(),
            "FPS gauge fill cell must be present in good-band row; row = {:?}",
            fps_rich_row.iter().map(|(c, _, _)| c).collect::<String>()
        );
        let (_, (_, fill_color, _)) = fill_cell.unwrap();
        // fps=60 → good band (≥ 55) → accent_success
        assert_eq!(
            *fill_color,
            Some(GRUVBOX_DARK.accent_success),
            "fps=60 is in good band; fill should be accent_success"
        );
    }

    /// UX-win D: gauge fill cells in crit band (fps < 45) should use accent_error.
    #[test]
    fn dashboard_gauge_rich_lines_crit_band_threshold_color() {
        let palette_colors = make_palette_colors();
        let accent = RgbColor {
            r: 57,
            g: 211,
            b: 83,
        };
        let rendered = DashboardOverlay::build_overlay(
            50000,
            1234567.0,
            8000000.0,
            8.5,
            5.5,
            30.0, // fps in crit band (< 45)
            28.0,
            1234,
            125.5,
            400,
            400,
            0,
            0,
            1,
            12.5,
            45.0,
            false,
            "Organic",
            "Heat",
            &palette_colors,
            120,
            40,
            "Random",
            "TrueColor",
            "HalfBlock",
            true,
            0.90,
            22.5,
            42,
            &None,
            0,
            false,
            accent,
            &GRUVBOX_DARK,
        );
        let rich = rendered
            .rich_lines
            .as_ref()
            .expect("rich_lines must be Some");
        let fps_rich_row = &rich[5];
        // Find a '█' fill cell in the FPS gauge region (rendered cols 12..44)
        let fill_cell = fps_rich_row
            .iter()
            .enumerate()
            .find(|(i, (c, fg, _))| *c == '█' && *i >= 12 && *i < 30 && fg.is_some());
        assert!(
            fill_cell.is_some(),
            "FPS gauge fill cell must be present in crit-band row; row = {:?}",
            fps_rich_row.iter().map(|(c, _, _)| c).collect::<String>()
        );
        let (_, (_, fill_color, _)) = fill_cell.unwrap();
        // fps=30 → crit band (< 45) → accent_error
        assert_eq!(
            *fill_color,
            Some(GRUVBOX_DARK.accent_error),
            "fps=30 is in crit band; fill should be accent_error"
        );
    }
}

#[cfg(test)]
mod status_line_tests {
    use super::*;
    use crate::cli::Palette;
    use crate::render::dither::DitherMode;
    use crate::simulation::config::Preset;

    #[test]
    fn test_status_line_narrow_terminal_40_cols() {
        let (status, _) = OverlayRenderer::build_status_line(
            false,
            Preset::Organic,
            1.0,
            Palette::Organic,
            DitherMode::None,
            40,
            Some(50000),
            Some("Mean3x3"),
            false,
            false,
            None,
            &crate::render::theme::GRUVBOX_DARK,
        );
        // At 40 cols: should only have preset and time
        assert!(status.contains("Organic"));
        assert!(status.contains("1.0×"));
        // Should not have palette or population (too narrow)
        assert!(!status.contains("50k"));
    }

    #[test]
    fn test_status_line_medium_terminal_80_cols() {
        let (status, _) = OverlayRenderer::build_status_line(
            false,
            Preset::Network,
            2.5,
            Palette::Heat,
            DitherMode::None,
            80,
            Some(50000),
            Some("Mean3x3"),
            false,
            false,
            None,
            &crate::render::theme::GRUVBOX_DARK,
        );
        // At 80 cols: should have preset, time, palette, and population
        assert!(status.contains("Network"));
        assert!(status.contains("2.5×"));
        assert!(status.contains("Heat"));
        assert!(status.contains("50k"));
        // Should not have diffusion kernel (needs 90+)
        assert!(!status.contains("Mean3x3"));
        // Should not have help text (needs 100+)
        assert!(!status.contains("?"));
    }

    #[test]
    fn test_status_line_wide_terminal_120_cols() {
        let (status, _) = OverlayRenderer::build_status_line(
            false,
            Preset::Exploratory,
            1.5,
            Palette::Ocean,
            DitherMode::None,
            120,
            Some(30000),
            Some("Gaussian"),
            false,
            false,
            None,
            &crate::render::theme::GRUVBOX_DARK,
        );
        // At 120 cols: should have everything including help
        assert!(status.contains("Exploratory"));
        assert!(status.contains("1.5×"));
        assert!(status.contains("Ocean"));
        assert!(status.contains("30k"));
        assert!(status.contains("Gaussian"));
        assert!(status.contains("?"));
    }

    #[test]
    fn test_status_line_paused() {
        let st = &crate::render::theme::GRUVBOX_DARK;
        let (status, colors) = OverlayRenderer::build_status_line(
            true,
            Preset::Organic,
            1.0,
            Palette::Organic,
            DitherMode::None,
            120,
            Some(50000),
            Some("Mean3x3"),
            false,
            false,
            None,
            st,
        );
        assert!(status.contains("⏸ PAUSED"));
        // PAUSED should have accent_warning color overrides
        assert!(colors.iter().any(|(_, c)| *c == st.accent_warning));
    }

    #[test]
    fn test_status_line_with_dither() {
        let (status, _) = OverlayRenderer::build_status_line(
            false,
            Preset::Organic,
            1.0,
            Palette::Organic,
            DitherMode::Ordered {
                intensity: 0.5,
                matrix: crate::render::dither::DitherMatrix::Bayer4x4,
            },
            80,
            Some(50000),
            Some("Mean3x3"),
            false,
            false,
            None,
            &crate::render::theme::GRUVBOX_DARK,
        );
        assert!(status.contains("D 0.5×"));
    }

    #[test]
    fn test_status_line_without_optional_params() {
        let (status, _) = OverlayRenderer::build_status_line(
            false,
            Preset::Organic,
            1.0,
            Palette::Organic,
            DitherMode::None,
            120,
            None,
            None,
            false,
            false,
            None,
            &crate::render::theme::GRUVBOX_DARK,
        );
        // Should still work without population or diffusion kernel
        assert!(status.contains("Organic"));
        assert!(status.contains("1.0×"));
    }

    fn overlay_to_string(overlay: &RenderedOverlay) -> String {
        overlay.lines.join("\n")
    }

    #[test]
    fn keyboard_hints_show_live_binds() {
        use crate::keybind_manager::BindTarget;
        use crate::simulation::config::Preset;
        use std::collections::HashMap;
        let mut binds = HashMap::new();
        binds.insert('4', BindTarget::Preset(Preset::Fire));
        let overlay = KeyboardHintsOverlay::build_overlay(
            RgbColor::new(255, 255, 255),
            &crate::render::theme::GRUVBOX_DARK,
            &binds,
        );
        let text = overlay_to_string(&overlay);
        assert!(text.contains("Fire"), "user bind name shown");
        assert!(
            text.contains("unbound"),
            "an empty 4-7 slot shows 'unbound'"
        );
    }

    #[test]
    fn keyboard_hints_utf8_config_truncation() {
        use crate::keybind_manager::BindTarget;
        use std::collections::HashMap;
        let mut binds = HashMap::new();
        // Multi-byte UTF-8 name longer than 10 bytes; byte index 10 falls
        // inside a 3-byte UTF-8 char, triggering panic if byte-sliced
        binds.insert('4', BindTarget::Config("abc日本語設定テスト".to_string()));
        // Should not panic on multi-byte UTF-8 char boundary
        let overlay = KeyboardHintsOverlay::build_overlay(
            RgbColor::new(255, 255, 255),
            &crate::render::theme::GRUVBOX_DARK,
            &binds,
        );
        let text = overlay_to_string(&overlay);
        assert!(text.contains("cfg:"), "config label present");
    }

    #[test]
    fn test_keyboard_hints_lists_frame_and_chrome() {
        let overlay = KeyboardHintsOverlay::build_overlay(
            RgbColor::new(255, 255, 255),
            &crate::render::theme::GRUVBOX_DARK,
            &std::collections::HashMap::new(),
        );
        let joined = overlay.lines.join("\n");
        assert!(joined.contains("Window frame"), "missing window-frame hint");
        assert!(joined.contains("( / )"), "missing window-frame keybind");
        assert!(joined.contains("Cycle chrome"), "missing chrome hint");
        assert!(joined.contains("F10"), "missing chrome keybind");
    }

    #[test]
    fn test_keyboard_hints_overlay_format() {
        let hints_lines = KeyboardHintsOverlay::build_overlay(
            RgbColor {
                r: 180,
                g: 220,
                b: 100,
            },
            &crate::render::theme::GRUVBOX_DARK,
            &std::collections::HashMap::new(),
        );

        // Solid-block borders
        for line in &hints_lines.lines {
            assert!(
                line.starts_with('█') || line.starts_with('▀') || line.starts_with('▄'),
                "Line should start with solid block char, got: {}",
                line
            );
            assert!(
                line.ends_with('█') || line.ends_with('▀') || line.ends_with('▄'),
                "Line should end with solid block char, got: {}",
                line
            );
        }

        // All lines should be KeyboardHintsOverlay::WIDTH chars wide
        for line in &hints_lines.lines {
            assert_eq!(
                line.chars().count(),
                KeyboardHintsOverlay::WIDTH,
                "Keyboard hints line has unexpected width ({}): {}",
                line.chars().count(),
                line
            );
        }
    }

    #[test]
    fn test_keyboard_hints_controls_depth_grammar() {
        let overlay = KeyboardHintsOverlay::build_overlay(
            RgbColor::new(255, 255, 255),
            &crate::render::theme::GRUVBOX_DARK,
            &std::collections::HashMap::new(),
        );
        let joined = overlay.lines.join("\n");

        // New grammar: Tab toggles depth (Tuner ⇄ Console)
        assert!(joined.contains("Tab"), "missing Tab keybind");
        assert!(
            joined.contains("Tuner") || joined.contains("Console") || joined.contains("depth"),
            "Tab hint should reference Tuner, Console, or depth"
        );

        // New grammar: [ ] for prev/next category
        assert!(joined.contains("["), "missing [ keybind for prev category");
        assert!(joined.contains("]"), "missing ] keybind for next category");
        assert!(joined.contains("category"), "missing category hint");

        // New grammar: ← → for adjust focused param
        assert!(
            joined.contains("←") || joined.contains("→") || joined.contains("adjust"),
            "should reference arrow keys for adjust"
        );

        // Old grammar: Tab should NOT say "Cycle category" anymore
        assert!(
            !joined.contains("Tab        Cycle category"),
            "Tab should no longer cycle category"
        );
    }

    #[test]
    fn test_keyboard_hints_dev_entry_uses_muted_token() {
        // Dev-only entries (containing "(dev)") must use st.muted for the
        // right column, not a hardcoded gray, so the color adapts across themes.
        //
        // The row in question has two columns:
        //   left:  "( / )      Window frame"   → keybind colored with accent
        //   right: "{ }        Dither (dev)"   → all non-space chars colored muted
        let accent = RgbColor::new(200, 100, 50);
        let gruvbox = crate::render::theme::GRUVBOX_DARK;
        let overlay = KeyboardHintsOverlay::build_overlay(
            accent,
            &gruvbox,
            &std::collections::HashMap::new(),
        );
        let rich_lines = overlay
            .rich_lines
            .expect("KeyboardHints must have rich_lines");

        let dev_row_idx = overlay
            .lines
            .iter()
            .position(|l| l.contains("(dev)"))
            .expect("hints must contain a (dev) row");

        let rich = &rich_lines[dev_row_idx];
        // Content starts at index 7 (border + padding). The right column
        // starts at CONTENT_START + col_width = 7 + 37 = 44.
        const RIGHT_COL_START: usize = 7 + 37;
        let right_colored: Vec<RgbColor> = rich
            .iter()
            .skip(RIGHT_COL_START)
            .filter(|(c, fg, _)| *c != ' ' && *c != '█' && fg.is_some())
            .filter_map(|(_, fg, _)| *fg)
            .collect();

        assert!(
            !right_colored.is_empty(),
            "dev right column must have colored non-space cells"
        );
        for color in &right_colored {
            assert_eq!(
                *color, gruvbox.muted,
                "dev entry right column must equal st.muted, got {:?}",
                color
            );
            // Verify token-based: not the old hardcoded gray.
            assert_ne!(
                *color,
                RgbColor::new(128, 128, 128),
                "dev entry must not use hardcoded gray (128,128,128)"
            );
        }
        // Ensure accent ≠ muted so the test is discriminating.
        assert_ne!(
            gruvbox.muted, accent,
            "test accent must differ from gruvbox.muted"
        );
    }

    #[test]
    fn test_keyboard_hints_key_tokens_use_accent() {
        // Key tokens (the keybind portion of each row) must be colored with the accent.
        let accent = RgbColor::new(200, 100, 50);
        let st = crate::render::theme::GRUVBOX_DARK;
        let overlay =
            KeyboardHintsOverlay::build_overlay(accent, &st, &std::collections::HashMap::new());
        let rich_lines = overlay
            .rich_lines
            .expect("KeyboardHints must have rich_lines");

        // Find a keybind row, e.g. one containing "Space" for Pause/Resume.
        let space_row = overlay
            .lines
            .iter()
            .position(|l| l.contains("Space") && l.contains("Pause"))
            .expect("hints must contain Space/Pause row");
        let rich = &rich_lines[space_row];
        // The "Space" key text starts after the border+padding (7 chars in).
        // Verify at least one cell in that row has the accent color.
        let has_accent = rich.iter().any(|(_, fg, _)| *fg == Some(accent));
        assert!(has_accent, "key token 'Space' must be colored with accent");
    }

    #[test]
    fn test_preset_comparison_overlay() {
        use crate::cli::PauseStyle;
        use crate::simulation::config::{InitMode, SimConfig};

        let mut state = crate::terminal::control::RuntimeState::new(
            42,
            InitMode::Random,
            Preset::Organic,
            crate::terminal::control::MouseInteractionMode::Disabled,
            0.0,
            &SimConfig::default(),
            PauseStyle::Vignette,
            false,
            false,
        );
        state.sensor_angle = 90.0; // Changed from default

        let lines = PresetComparisonOverlay::build_overlay(
            &state,
            &crate::terminal::state::ComparisonTarget::Preset(Preset::Organic),
        );
        assert!(!lines.lines.is_empty());
        let content_lines = lines
            .lines
            .iter()
            .filter(|l| l.contains("Sensor Angle"))
            .collect::<Vec<_>>();
        assert!(!content_lines.is_empty());
        // Should show modified marker ⚙
        assert!(content_lines[0].contains('⚙'));
    }

    fn make_named_profile(name: &str) -> crate::config_manager::NamedProfile {
        use crate::profile_overrides::ProfileOverrides;
        use crate::render::palette::Palette;
        crate::config_manager::NamedProfile {
            name: name.to_string(),
            description: None,
            overrides: ProfileOverrides {
                population: Some(10000),
                palette: Some(Palette::Forest),
                ..Default::default()
            },
        }
    }

    #[test]
    fn test_config_browser_overlay_items() {
        let configs = vec![make_named_profile("Test Config")];

        let lines = ConfigBrowserOverlay::build_overlay(&configs, 0);
        assert!(lines.lines.iter().any(|l| l.contains("Test Config")));
        assert!(lines.lines.iter().any(|l| l.contains("10k agents")));
    }

    fn make_saved_config(name: &str) -> crate::config_manager::NamedProfile {
        make_named_profile(name)
    }

    #[test]
    fn test_config_browser_window_helper() {
        // total <= max_visible: always anchored at top.
        assert_eq!(ConfigBrowserOverlay::config_browser_window(0, 5, 9), 0);
        assert_eq!(ConfigBrowserOverlay::config_browser_window(4, 5, 9), 0);
        assert_eq!(ConfigBrowserOverlay::config_browser_window(8, 9, 9), 0);

        // Selection within the first window: anchored at top.
        assert_eq!(ConfigBrowserOverlay::config_browser_window(0, 15, 9), 0);
        assert_eq!(ConfigBrowserOverlay::config_browser_window(8, 15, 9), 0);

        // Selection past the first window: scroll just enough to reveal it.
        // selected 11/15 with max 9 -> start 3 (showing 3..12 includes 11).
        assert_eq!(ConfigBrowserOverlay::config_browser_window(11, 15, 9), 3);
        assert_eq!(ConfigBrowserOverlay::config_browser_window(9, 15, 9), 1);

        // Selection at the end: clamp so window never runs past total.
        // selected 14/15 with max 9 -> start 6 (showing 6..15).
        assert_eq!(ConfigBrowserOverlay::config_browser_window(14, 15, 9), 6);

        // Out-of-range selection is clamped to the last item.
        assert_eq!(ConfigBrowserOverlay::config_browser_window(99, 15, 9), 6);
    }

    #[test]
    fn test_config_browser_scrolls_to_selection() {
        let configs: Vec<_> = (0..15)
            .map(|i| make_saved_config(&format!("Config{:02}", i)))
            .collect();

        // Select an entry well beyond the first window (index 11 of 15).
        let overlay = ConfigBrowserOverlay::build_overlay(&configs, 11);

        // The selected entry must have scrolled into view, marked with the caret.
        assert!(
            overlay
                .lines
                .iter()
                .any(|l| l.contains("›") && l.contains("Config11")),
            "selected entry Config11 should be visible with the selection marker; got:\n{}",
            overlay.lines.join("\n")
        );

        // Entries that scrolled off the top should no longer be rendered.
        assert!(
            !overlay.lines.iter().any(|l| l.contains("Config00")),
            "Config00 should have scrolled off the top"
        );

        // An "above" indicator should be present since we scrolled down.
        assert!(
            overlay.lines.iter().any(|l| l.contains("above")),
            "expected an 'above' scroll indicator; got:\n{}",
            overlay.lines.join("\n")
        );
    }

    #[test]
    fn test_config_browser_top_anchored_with_below_indicator() {
        let configs: Vec<_> = (0..15)
            .map(|i| make_saved_config(&format!("Config{:02}", i)))
            .collect();

        let overlay = ConfigBrowserOverlay::build_overlay(&configs, 0);

        // First entry visible and marked.
        assert!(overlay
            .lines
            .iter()
            .any(|l| l.contains("›") && l.contains("Config00")));
        // A "below" indicator should be present (more configs off the bottom).
        assert!(
            overlay.lines.iter().any(|l| l.contains("below")),
            "expected a 'below' scroll indicator; got:\n{}",
            overlay.lines.join("\n")
        );
        // No "above" indicator when anchored at top.
        assert!(!overlay.lines.iter().any(|l| l.contains("above")));
    }

    #[test]
    fn test_config_browser_bottom_anchored_constant_height() {
        let configs: Vec<_> = (0..15)
            .map(|i| make_saved_config(&format!("Config{:02}", i)))
            .collect();
        let total = configs.len();

        // Scrolled fully to the bottom: select the last entry (index 14 of 15).
        let bottom = ConfigBrowserOverlay::build_overlay(&configs, total - 1);

        // (a) The selected (last) entry is visible and marked.
        assert!(
            bottom
                .lines
                .iter()
                .any(|l| l.contains("›") && l.contains("Config14")),
            "selected last entry Config14 should be visible with the selection marker; got:\n{}",
            bottom.lines.join("\n")
        );

        // (b) An "above" indicator is present (earlier entries scrolled off the top).
        assert!(
            bottom.lines.iter().any(|l| l.contains("above")),
            "expected an 'above' scroll indicator at the bottom; got:\n{}",
            bottom.lines.join("\n")
        );

        // (c) No "below" indicator text since there is nothing past the window.
        assert!(
            !bottom.lines.iter().any(|l| l.contains("below")),
            "expected no 'below' scroll indicator at the bottom; got:\n{}",
            bottom.lines.join("\n")
        );

        // Panel height must stay constant across scroll positions. A mid-scroll
        // render (which DOES emit a "below" indicator) must have the same number
        // of rows as the bottom render (which emits a blank line in its place).
        let mid = ConfigBrowserOverlay::build_overlay(&configs, 11);
        assert!(
            mid.lines.iter().any(|l| l.contains("below")),
            "mid-scroll render should still show a 'below' indicator; got:\n{}",
            mid.lines.join("\n")
        );
        assert_eq!(
            bottom.lines.len(),
            mid.lines.len(),
            "panel body row count must be identical at the bottom and mid-scroll \
             (Fix 1: constant ▼ slot); bottom={}, mid={}",
            bottom.lines.len(),
            mid.lines.len()
        );
    }
}

// --- PauseOverlay ---

/// VCR-style pause screen: logo rendered in palette colors + blinking badge.
pub struct PauseOverlay;

impl PauseOverlay {
    /// Build a logo overlay using sculpted quadrant characters with dual-color.
    ///
    /// Uses `map_sculpted_outline` (16 block elements + 4 triangle fills) for
    /// shape fidelity, with independent fg/bg per cell. The `brightness_map`
    /// must be `(logo_w * 2) x (logo_h * 2)` pixels (2×2 quadrants per cell).
    #[allow(clippy::too_many_arguments)]
    pub fn build_logo(
        brightness_map: &[f32],
        logo_w: usize,
        logo_h: usize,
        palette: Palette,
        reverse: bool,
        invert: bool,
        hue_shift: f32,
        mapping: Option<&crate::render::palette::IntensityMapping>,
    ) -> RenderedOverlay {
        use crate::render::charset::map_quadrant;
        use crate::render::palette::map_brightness_rgb;

        const THRESHOLD: f32 = 0.12;

        let pixel_w = logo_w * 2;
        let mut rich_lines: Vec<Vec<RichCell>> = Vec::with_capacity(logo_h);
        let mut lines: Vec<String> = Vec::with_capacity(logo_h);

        for row in 0..logo_h {
            let mut rich_row: Vec<RichCell> = Vec::with_capacity(logo_w);
            let mut line = String::with_capacity(logo_w * 3);
            for col in 0..logo_w {
                // Sample 2×2 quadrants
                let tl_idx = (row * 2) * pixel_w + col * 2;
                let tr_idx = tl_idx + 1;
                let bl_idx = (row * 2 + 1) * pixel_w + col * 2;
                let br_idx = bl_idx + 1;

                let tl_raw = brightness_map.get(tl_idx).copied().unwrap_or(0.0);
                let tr_raw = brightness_map.get(tr_idx).copied().unwrap_or(0.0);
                let bl_raw = brightness_map.get(bl_idx).copied().unwrap_or(0.0);
                let br_raw = brightness_map.get(br_idx).copied().unwrap_or(0.0);

                // Apply intensity mapping to pixel values before thresholding
                // so log/exp/perlin etc. affect shape and visibility
                let tl = if let Some(m) = mapping {
                    m.apply(tl_raw.clamp(0.0, 1.0))
                } else {
                    tl_raw
                };
                let tr = if let Some(m) = mapping {
                    m.apply(tr_raw.clamp(0.0, 1.0))
                } else {
                    tr_raw
                };
                let bl = if let Some(m) = mapping {
                    m.apply(bl_raw.clamp(0.0, 1.0))
                } else {
                    bl_raw
                };
                let br = if let Some(m) = mapping {
                    m.apply(br_raw.clamp(0.0, 1.0))
                } else {
                    br_raw
                };

                // Count "on" quadrants and accumulate brightness
                let vals = [tl, tr, bl, br];
                let mut total_brightness: f32 = 0.0;
                let mut on_count: u32 = 0;
                for &v in &vals {
                    if v > THRESHOLD {
                        total_brightness += v;
                        on_count += 1;
                    }
                }

                if on_count == 0 {
                    // Fully transparent — dimmed sim shows through
                    rich_row.push((' ', None, None));
                    line.push(' ');
                } else {
                    let ch = map_quadrant(tl, tr, bl, br, THRESHOLD);
                    let avg = total_brightness / on_count as f32;
                    // Pass None for mapping here — already applied above
                    let fg_color =
                        map_brightness_rgb(avg, palette.clone(), reverse, invert, hue_shift, None);

                    if on_count == 4 {
                        // Full block — both fg and bg colored
                        rich_row.push((ch, Some(fg_color), Some(fg_color)));
                    } else {
                        // Partial — fg colored, bg transparent
                        rich_row.push((ch, Some(fg_color), None));
                    }
                    line.push(ch);
                }
            }
            rich_lines.push(rich_row);
            lines.push(line);
        }

        // Trim empty rows from top and bottom so centering works on visible content
        let is_empty_row = |row: &Vec<RichCell>| row.iter().all(|&(ch, _, _)| ch == ' ');
        let first_nonempty = rich_lines
            .iter()
            .position(|r| !is_empty_row(r))
            .unwrap_or(0);
        let last_nonempty = rich_lines
            .iter()
            .rposition(|r| !is_empty_row(r))
            .unwrap_or(rich_lines.len().saturating_sub(1));
        let rich_lines = rich_lines[first_nonempty..=last_nonempty].to_vec();
        let lines = lines[first_nonempty..=last_nonempty].to_vec();

        RenderedOverlay {
            lines,
            title_box: None,
            rich_lines: Some(rich_lines),
        }
    }

    /// Build a blinking "⏸ PAUSED" badge.
    ///
    /// `visible` controls whether the badge text is shown (for blink effect).
    pub fn build_badge(visible: bool) -> RenderedOverlay {
        let content = if visible {
            "  ⏸  PAUSED  "
        } else {
            "              "
        };
        let accent = RgbColor {
            r: 220,
            g: 180,
            b: 60,
        };
        let rich_row: Vec<RichCell> = content.chars().map(|c| (c, Some(accent), None)).collect();
        RenderedOverlay {
            lines: vec![content.to_string()],
            title_box: None,
            rich_lines: Some(vec![rich_row]),
        }
    }
}

// --- ExpandedChromeOverlay ---

/// Builds the 2-row title block and 2-row footer for expanded window chrome.
///
/// This is a pure data-builder — it produces plain strings with no ANSI escape
/// codes. The caller is responsible for positioning and rendering them into the
/// frame buffer at the appropriate rows.
pub struct ExpandedChromeOverlay;

impl ExpandedChromeOverlay {
    /// Builds the 2-row title block shown at the top of the window chrome.
    ///
    /// Returns `[row1, row2]` as plain strings (no ANSI). Row 1 contains the
    /// app name and preset; row 2 the palette, charset, and agent count.
    /// `_width` is reserved for future truncation logic.
    pub fn build_title_block(
        preset: Preset,
        palette: Palette,
        charset_str: &str,
        population: usize,
        _width: usize,
    ) -> [String; 2] {
        let preset_str = preset_name(preset);
        let palette_str = palette_name(palette);
        let pop_k = population / 1000;
        [
            format!("  \u{25C9} tslime \u{00B7} {}", preset_str),
            format!(
                "  {} palette \u{00B7} {} \u{00B7} {}k ag.",
                palette_str, charset_str, pop_k
            ),
        ]
    }

    /// Builds footer row 1 (status) by delegating to `OverlayRenderer::build_status_line`.
    ///
    /// Returns the status string and per-character color overrides, identical to
    /// what the status bar would show in windowed mode.
    #[allow(clippy::too_many_arguments)]
    pub fn build_footer_status(
        is_paused: bool,
        preset: Preset,
        time_scale: f32,
        palette: Palette,
        dither_mode: DitherMode,
        width: usize,
        population: Option<usize>,
        diffusion_kernel: Option<&str>,
        can_undo: bool,
        can_redo: bool,
        accent: Option<RgbColor>,
        st: &PanelStyle,
    ) -> (String, Vec<(usize, RgbColor)>) {
        OverlayRenderer::build_status_line(
            is_paused,
            preset,
            time_scale,
            palette,
            dither_mode,
            width,
            population,
            diffusion_kernel,
            can_undo,
            can_redo,
            accent,
            st,
        )
    }

    /// Builds footer row 2: context-sensitive keybind hints.
    ///
    /// When `is_modal_open` is true (e.g. config browser showing), the hints
    /// switch to modal navigation keys; otherwise the standard running-mode
    /// shortcuts are shown. `_width` is reserved for future truncation logic.
    pub fn build_footer_keybinds(is_modal_open: bool, _width: usize) -> String {
        if is_modal_open {
            format!(
                "  {}",
                footer_hints(&[("↑↓", "navigate"), ("↵", "select"), ("esc", "close")])
            )
        } else {
            format!(
                "  {}",
                footer_hints(&[
                    ("q", "quit"),
                    ("h", "help"),
                    ("space", "pause"),
                    ("c", "cycle palette"),
                    ("\\", "dashboard"),
                ])
            )
        }
    }
}

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

    #[test]
    fn test_title_block_row1_contains_app_and_preset() {
        let rows = ExpandedChromeOverlay::build_title_block(
            Preset::Organic,
            Palette::Forest,
            "HalfBlock",
            50_000,
            80,
        );
        assert!(rows[0].contains("tslime"), "row0: {}", rows[0]);
        assert!(
            rows[0].contains("organic") || rows[0].contains("Organic"),
            "row0: {}",
            rows[0]
        );
    }

    #[test]
    fn test_title_block_row2_contains_palette_charset_population() {
        let rows = ExpandedChromeOverlay::build_title_block(
            Preset::Organic,
            Palette::Forest,
            "HalfBlock",
            50_000,
            80,
        );
        assert!(
            rows[1].contains("Forest") || rows[1].contains("forest"),
            "row1: {}",
            rows[1]
        );
        assert!(rows[1].contains("HalfBlock"), "row1: {}", rows[1]);
        assert!(rows[1].contains("50k"), "row1: {}", rows[1]);
    }

    #[test]
    fn test_footer_keybinds_running() {
        let hint = ExpandedChromeOverlay::build_footer_keybinds(false, 80);
        assert!(hint.contains("q quit"), "hint: {}", hint);
        assert!(hint.contains("space pause"), "hint: {}", hint);
    }

    #[test]
    fn test_footer_keybinds_modal() {
        let hint = ExpandedChromeOverlay::build_footer_keybinds(true, 80);
        assert!(hint.contains("esc close"), "hint: {}", hint);
    }
}
