//! The horizontal menu bar.
//!
//! [`MenuBar`] is a one-row [`MenuView`] that lays its top-level items out
//! left-to-right. The bar-specific work is its layout — [`draw`](MenuBar::draw)
//! and [`get_item_rect`](MenuView::get_item_rect). Its
//! [`handle_event`](MenuBar::handle_event) delegates to the shared passive
//! [`menu_view::handle_event`], which handles the command-graying broadcast,
//! posts an accelerator's command, and opens the menu session on a bar hotkey.
//!
//! Because [`MenuViewState`] embeds a [`ViewState`] (not a `View`), the differing
//! [`View`] methods (`state`/`state_mut`/`draw`/`handle_event`) are hand-written,
//! like [`ListBox`](crate::widgets::ListBox), rather than generated by the
//! `#[delegate]` macro.
//!
//! A menu bar holding a startup-disabled command would draw it enabled until the
//! first command-set-changed broadcast, which does not fire at startup; so
//! [`Program::new`](crate::app::Program::new) seeds each inserted bar by calling
//! [`update_menu_commands`](View::update_menu_commands) directly with the initial
//! disabled-command set.
//!
//! # Turbo Vision heritage
//! Ports `TMenuBar` (`tmenubar.cpp`/`menus.h`). The `TMenuView` base becomes the
//! [`MenuView`] trait (deviation D2); the disabled-command set is held as a
//! denylist (deviation D1) and `TStreamable` persistence is dropped (deviation
//! D12).

use crate::event::Event;
use crate::menu::menu_view::{self, MenuColors, MenuView, MenuViewState};
use crate::view::{Context, DrawCtx, Point, Rect, View, ViewState};

/// Display width of a `~`-marked label, **ignoring** the `~` markers (which are
/// not printed columns). A per-module copy mirroring `button.rs` / `cluster.rs`
/// (the established precedent), using the same `UnicodeWidthChar` primitive so
/// widths match the rest of the renderer.
fn cstrlen(s: &str) -> i32 {
    s.chars()
        .filter(|&c| c != '~')
        .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(1) as i32)
        .sum()
}

/// Display width of the kebab string (no `~` markers — plain display columns).
fn kebab_width(s: &str) -> i32 {
    unicode_width::UnicodeWidthStr::width(s) as i32
}

/// The horizontal menu bar. Holds the shared [`MenuViewState`]; the bar-specific
/// layout lives in [`draw`](MenuBar::draw) /
/// [`get_item_rect`](MenuView::get_item_rect).
///
/// # Turbo Vision heritage
/// Ports `TMenuBar` (`tmenubar.cpp`/`menus.h`).
pub struct MenuBar {
    mv: MenuViewState,
    /// When true the bar is rendered as the themed `menu_kebab` glyph
    /// (top-right, width derived from the glyph) and activation opens a corner
    /// popup instead of the in-bar session. Driven by the pump when a
    /// `Fullscreen::Screen` window covers the menu row.
    collapsed: bool,
}

impl MenuBar {
    /// Construct a menu bar over `bounds` presenting `menu`.
    ///
    /// `bounds` is the initial bounding rectangle (typically `Rect::new(0, 0,
    /// screen_width, 1)` to occupy the top row); the bar's `grow_mode.hi_x` flag
    /// causes it to stretch automatically when the terminal is resized. The bar
    /// sets `options.pre_process = true` so it intercepts accelerator keys and
    /// Alt-shortcuts in the preprocess pass, before the focused view consumes them.
    ///
    /// Use `MenuBar` when you want a persistent horizontal strip of top-level menu
    /// titles in the application frame. For a transient context popup (a right-click
    /// menu), use [`popup_menu`](crate::menu::popup_menu) instead.
    pub fn new(bounds: Rect, menu: crate::menu::Menu) -> Self {
        let mut state = ViewState::new(bounds);
        state.grow_mode.hi_x = true; // stretch with screen width
        state.options.pre_process = true; // see accelerators before focused view
        MenuBar {
            mv: MenuViewState::new(state, menu),
            collapsed: false,
        }
    }
}

impl MenuView for MenuBar {
    fn mv(&self) -> &MenuViewState {
        &self.mv
    }

    fn mv_mut(&mut self) -> &mut MenuViewState {
        &mut self.mv
    }

    /// The screen rect of item `index`, computed by a left-to-right accumulator.
    ///
    /// Starting at column 1, each item's start is carried from the previous item's
    /// end, then (only for a named item) the end advances by the label width plus a
    /// leading and trailing space. A separator carries the start but adds nothing,
    /// so it consumes no horizontal space. Only ever called with an existing index.
    fn get_item_rect(&self, index: usize) -> Rect {
        let mut r = Rect::new(1, 0, 1, 1);
        for (i, item) in self.mv.menu.items.iter().enumerate() {
            r.a.x = r.b.x;
            if !matches!(item, crate::menu::MenuItem::Separator) {
                r.b.x += cstrlen(item_name(item)) + 2;
            }
            if i == index {
                return r;
            }
        }
        // The C++ loop never exits without a match (it is only called with a valid
        // index); return the last accumulated rect as a defensive fallback.
        r
    }
}

impl View for MenuBar {
    fn state(&self) -> &ViewState {
        &self.mv.state
    }

    fn state_mut(&mut self) -> &mut ViewState {
        &mut self.mv.state
    }

    /// Fill the bar then lay the named items out left-to-right, each with a
    /// leading and trailing space, in the per-item colour resolved via
    /// [`MenuColors`]. An item that would overflow the bar width is skipped but
    /// still advances the cursor. Paints directly through the view-local
    /// [`DrawCtx`].
    fn draw(&mut self, ctx: &mut DrawCtx) {
        let colors = MenuColors::resolve(ctx);

        if self.collapsed {
            // Render the themed kebab at the top-right; width derived from the
            // glyph so the rest of the row stays transparent (fullscreen window
            // shows through).
            let size = self.mv.state.size;
            let kebab = ctx.glyphs().menu_kebab;
            let kw = kebab_width(kebab);
            ctx.put_str(size.x - kw, 0, kebab, colors.normal.0);
            return;
        }

        let size = self.mv.state.size;
        // b.moveChar(0, ' ', cNormal, size.x) — fill the whole bar.
        ctx.fill(Rect::new(0, 0, size.x, 1), ' ', colors.normal.0);

        let mut x = 1;
        for (i, item) in self.mv.menu.items.iter().enumerate() {
            let name = match item {
                crate::menu::MenuItem::Separator => continue, // C++ p->name == 0 skip
                other => item_name(other),
            };
            let l = cstrlen(name);
            if x + l < size.x {
                let disabled = item_disabled(item);
                let selected = self.mv.current == Some(i);
                let (lo, hi) = colors.item(disabled, selected);
                // b.moveChar(x, ' ', color, 1); moveCStr(x+1, name, color);
                // moveChar(x+l+1, ' ', color, 1).
                ctx.put_char(x, 0, ' ', lo);
                ctx.put_cstr(x + 1, 0, name, lo, hi);
                ctx.put_char(x + l + 1, 0, ' ', lo);
            }
            x += l + 2;
        }
    }

    /// Delegates to the shared passive [`menu_view::handle_event`]
    /// (command-graying broadcast, accelerator post, and bar hotkey activation).
    /// When collapsed, activation (mouse click, F10/`Command::MENU`, or any
    /// Alt-shortcut) opens a corner popup anchored at the top-right cell instead
    /// of the in-bar session — the session freezes the bar's bounds at activation,
    /// so reclaiming width for an in-bar drop is impossible.
    fn handle_event(&mut self, ev: &mut Event, ctx: &mut Context) {
        if self.collapsed {
            // Activation (click the kebab, F10/cmMenu, or any Alt-shortcut) opens a
            // vertical popup anchored at the top-right corner — the session freezes
            // the bar's bounds at activation, so reclaiming width is impossible
            // (see docs/design/fullscreen-window.md). Other events (graying
            // broadcast, accelerator posts) still delegate.
            let activate = match *ev {
                Event::MouseDown(_) => true,
                Event::Command(c) => c == crate::command::Command::MENU,
                Event::KeyDown(k) => k.modifiers.alt,
                _ => false,
            };
            if activate {
                let owner = ctx.owner_size();
                crate::menu::popup_menu(
                    Point::new(owner.x - 1, 0),
                    self.mv.menu.clone(),
                    owner,
                    ctx,
                );
                ev.clear();
                return;
            }
        }
        menu_view::handle_event(&self.mv, ev, ctx);
    }

    /// The command-graying broker hook. The pump calls this at apply time (via
    /// [`Deferred::UpdateMenu`]) with the live **disabled-command set** in hand,
    /// and [`Program::new`](crate::app::Program::new) calls it once at startup to
    /// seed the initial graying; it walks the menu tree setting each command
    /// item's `disabled = disabled_cmds.has(command)` (the shared
    /// [`menu_view::update_menu_commands`] free function). Without this override
    /// the broker would silently no-op on the trait default — and both the startup
    /// seed and the command-set-changed broadcast depend on it.
    fn update_menu_commands(&mut self, disabled_cmds: &crate::command::CommandSet) {
        menu_view::update_menu_commands(&mut self.mv.menu, disabled_cmds);
    }

    /// Write the session-owned highlight index — the pump's
    /// [`Deferred::SetMenuCurrent`](crate::view::Deferred::SetMenuCurrent) broker
    /// target. The bar is never focused while a menu session is active, so this is
    /// the only path that moves its highlight.
    fn set_menu_current(&mut self, current: Option<usize>) {
        self.mv.current = current;
    }

    /// Expose the concrete bar so the pump / tests can introspect its
    /// [`MenuViewState`] (the highlight cache the session drives).
    fn as_any_mut(&mut self) -> Option<&mut dyn core::any::Any> {
        Some(self)
    }
}

impl MenuBar {
    /// Collapse the bar to a `[⋮]` bracketed kebab (or restore the full bar).
    /// The pump drives this together with a bounds change to the 3-cell kebab rect.
    pub fn set_collapsed(&mut self, v: bool) {
        self.collapsed = v;
    }

    /// Whether the bar is currently collapsed to a `[⋮]` bracketed kebab.
    pub fn collapsed(&self) -> bool {
        self.collapsed
    }

    /// Read the bar's current highlight index (test/inspection hook for the
    /// session-driven highlight cache).
    pub fn current(&self) -> Option<usize> {
        self.mv.current
    }

    /// Borrow the bar's menu tree (test/inspection hook — lets the program-wiring
    /// regray tests read a top-level item's `disabled` flag after the broker hook
    /// runs).
    #[cfg(test)]
    pub fn menu(&self) -> &crate::menu::Menu {
        &self.mv.menu
    }
}

/// The display label of a named menu item. Never called on a [`Separator`], which
/// has no name (the caller skips separators); returns `""` for one defensively.
///
/// [`Separator`]: crate::menu::MenuItem::Separator
fn item_name(item: &crate::menu::MenuItem) -> &str {
    match item {
        crate::menu::MenuItem::Command { name, .. }
        | crate::menu::MenuItem::SubMenu { name, .. } => name,
        crate::menu::MenuItem::Separator => "",
    }
}

/// The `disabled` flag of a menu item (`false` for a [`Separator`], which is never
/// greyed).
///
/// [`Separator`]: crate::menu::MenuItem::Separator
fn item_disabled(item: &crate::menu::MenuItem) -> bool {
    match item {
        crate::menu::MenuItem::Command { disabled, .. }
        | crate::menu::MenuItem::SubMenu { disabled, .. } => *disabled,
        crate::menu::MenuItem::Separator => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backend::{HeadlessBackend, Renderer};
    use crate::command::Command;
    use crate::menu::{Menu, alt};
    use crate::screen::Buffer;
    use crate::theme::Theme;

    /// A `File`/`Edit`/`Window` bar — three top-level submenus.
    fn sample_bar(bounds: Rect) -> MenuBar {
        let menu = Menu::builder()
            .submenu("~F~ile", alt('f'), |m| m.command("~O~pen", Command::OPEN))
            .submenu("~E~dit", alt('e'), |m| m.command("~C~ut", Command::CUT))
            .submenu("~W~indow", alt('w'), |m| m.command("~N~ext", Command::NEXT))
            .build();
        MenuBar::new(bounds, menu)
    }

    fn render(bar: &mut MenuBar, w: u16, h: u16) -> String {
        let theme = Theme::classic_blue();
        let (backend, screen) = HeadlessBackend::new(w, h);
        let mut r = Renderer::new(Box::new(backend));
        r.render(|buf: &mut Buffer| {
            let bounds = bar.state().get_bounds();
            let mut dc = DrawCtx::new(buf, &theme, bounds, bounds.a);
            bar.draw(&mut dc);
        });
        screen.snapshot()
    }

    // -- ctor ---------------------------------------------------------------

    #[test]
    fn ctor_sets_grow_and_preprocess() {
        let bar = sample_bar(Rect::new(0, 0, 30, 1));
        assert!(bar.mv.state.grow_mode.hi_x, "gfGrowHiX set");
        assert!(bar.mv.state.options.pre_process, "ofPreProcess set");
        assert_eq!(bar.mv.current, None, "nothing highlighted at construction");
    }

    // -- get_item_rect ------------------------------------------------------

    #[test]
    fn get_item_rect_accumulates_horizontally() {
        // " File  Edit  Window " — each item is a leading space + name + trailing
        // space, so item i occupies [x, x + cstrlen + 2).
        //   File: cstrlen 4 -> [1, 7)
        //   Edit: cstrlen 4 -> [7, 13)
        //   Window: cstrlen 6 -> [13, 21)
        let bar = sample_bar(Rect::new(0, 0, 30, 1));

        let file = bar.get_item_rect(0);
        assert_eq!((file.a.x, file.b.x), (1, 7), "File occupies cols [1, 7)");

        let edit = bar.get_item_rect(1);
        // BITE: forgetting the `r.a.x = r.b.x` carry would yield (1, 13) — b.x
        // still accumulates (7 -> 13), only a.x stays 1. The correct (7, 13) proves
        // Edit starts where File ended (7), not at 1.
        assert_eq!(
            (edit.a.x, edit.b.x),
            (7, 13),
            "Edit starts where File ended"
        );

        let window = bar.get_item_rect(2);
        assert_eq!(
            (window.a.x, window.b.x),
            (13, 21),
            "Window starts where Edit ended"
        );
        // y span is always the single bar row [0, 1).
        assert_eq!((window.a.y, window.b.y), (0, 1));
    }

    #[test]
    fn get_item_rect_separator_consumes_no_x() {
        // A bar with a separator between two named items: the separator runs
        // r.a.x = r.b.x but adds nothing, so the item after it starts where the
        // item before it ended.
        let menu = Menu::builder()
            .submenu("~F~ile", alt('f'), |m| m.command("~O~pen", Command::OPEN))
            .separator()
            .submenu("~E~dit", alt('e'), |m| m.command("~C~ut", Command::CUT))
            .build();
        let bar = MenuBar::new(Rect::new(0, 0, 30, 1), menu);

        // File (idx 0): [1, 7).
        assert_eq!((bar.get_item_rect(0).a.x, bar.get_item_rect(0).b.x), (1, 7));
        // Separator (idx 1): r.a.x = r.b.x = 7, no advance -> [7, 7).
        let sep = bar.get_item_rect(1);
        assert_eq!(
            (sep.a.x, sep.b.x),
            (7, 7),
            "separator consumes no horizontal space"
        );
        // Edit (idx 2): starts at 7 (NOT pushed by the separator).
        // BITE: a "separator advances x" bug would put Edit at [9, 15) or similar.
        let edit = bar.get_item_rect(2);
        assert_eq!(
            (edit.a.x, edit.b.x),
            (7, 13),
            "item after a separator starts where the previous named item ended"
        );
    }

    // -- draw snapshots -----------------------------------------------------

    #[test]
    fn snapshot_bar_highlight_and_disabled() {
        // Edit is highlighted (current = 1); Window is disabled (greyed).
        let mut bar = sample_bar(Rect::new(0, 0, 24, 1));
        bar.mv.current = Some(1);
        if let crate::menu::MenuItem::SubMenu { disabled, .. } = &mut bar.mv.menu.items[2] {
            *disabled = true;
        }
        insta::assert_snapshot!(render(&mut bar, 24, 1));
    }

    #[test]
    fn snapshot_bar_narrow_drops_overflowing_item() {
        // Width 8: File occupies [1, 7) (x=1, l=4 -> 1+4 < 8 true, drawn). After
        // File, x = 7; Edit has l=4 -> 7+4 < 8 is FALSE, so Edit is NOT drawn but
        // x still advances (the `if x+l < size.x` false branch). This exercises the
        // clipped-item path the wide bar never hits.
        let menu = Menu::builder()
            .submenu("~F~ile", alt('f'), |m| m.command("~O~pen", Command::OPEN))
            .submenu("~E~dit", alt('e'), |m| m.command("~C~ut", Command::CUT))
            .build();
        let mut bar = MenuBar::new(Rect::new(0, 0, 8, 1), menu);
        insta::assert_snapshot!(render(&mut bar, 8, 1));
    }

    #[test]
    fn draw_empty_menu_does_not_panic() {
        // An empty menu: the bar fills its background and the item loop is a no-op.
        // Cheapest guard against an index/iter edge case.
        let mut bar = MenuBar::new(Rect::new(0, 0, 12, 1), Menu::builder().build());
        let _ = render(&mut bar, 12, 1); // completes without panic.
    }

    #[test]
    fn collapsed_bar_draws_only_kebab() {
        // A collapsed bar paints the [⋮] kebab at the top-right 3 cells and nothing
        // else; the rest of the row is left transparent.
        let mut bar = sample_bar(Rect::new(0, 0, 24, 1));
        bar.set_collapsed(true);
        assert!(bar.collapsed());
        let snap = render(&mut bar, 24, 1);
        assert!(snap.contains("[⋮]"), "bracketed kebab drawn");
        assert!(
            !snap.contains("File"),
            "top-level items NOT drawn when collapsed"
        );
    }
}
