// menu.rs --- Menu registry: the items that populate the right-click context menu.

//! Context-menu items.
//!
//! The right-click context menu (Q#CM2) is populated from a Lua
//! registry that mirrors [`crate::command::CommandRegistry`]. Each
//! [`MenuItem`] names a [`crate::command::Command`] to invoke and
//! carries optional visibility controls: a coarse `context` tag
//! (sugar) and/or a full `predicate` (the escape hatch), evaluated
//! against a context table when the menu opens (Q#CM3). Items are
//! grouped and ordered for layout; separators fall between groups.
//!
//! # Storage and lookup
//!
//! [`MenuRegistry`] owns the live items in insertion order. Unlike
//! commands, items are not uniquely *named* --- two contexts may each
//! contribute a "Copy". An optional `id` allows targeted removal or
//! in-place replacement, so a user config can hide or override a
//! builtin item (`pmacs.menu.remove("edit.copy")`, or re-`item` with
//! the same `id`) without disturbing the rest of the menu.
//!
//! # Threading
//!
//! Single-threaded, behind `Rc<RefCell<...>>` next to the command and
//! keymap registries.

use mlua::Function;
use thiserror::Error;

use crate::command::SourceLocation;

// ---------------------------------------------------------------------------
// Contexts
// ---------------------------------------------------------------------------

/// Coarse context tags that are sugar for a visibility predicate
/// (Q#CM3). Validated at registration so a typo (`"selecton"`) is a
/// hard error rather than a silently invisible item --- the same
/// typo-paranoia as the command spec's unknown-field check (R50).
///
/// The tag → predicate mapping is owned by the menu builder in the
/// core (it has the live context table); the registry only validates
/// the vocabulary.
pub const KNOWN_CONTEXTS: &[&str] = &["always", "selection", "symbol", "diagnostic"];

// ---------------------------------------------------------------------------
// MenuItem + errors
// ---------------------------------------------------------------------------

/// A single context-menu entry.
///
/// Cloning is cheap: `String`s clone trivially and `mlua::Function` is
/// reference-counted internally.
#[derive(Clone)]
pub struct MenuItem {
    /// Optional stable identifier. Enables targeted [`MenuRegistry::remove`]
    /// and in-place override (re-adding with the same id replaces).
    pub id: Option<String>,
    /// Human-readable label shown in the menu row.
    pub label: String,
    /// Name of the [`crate::command::Command`] this item invokes.
    /// Resolved at invoke time (like a keymap binding), not at
    /// registration --- the command may be defined later.
    pub command: String,
    /// Coarse visibility tag (one of [`KNOWN_CONTEXTS`]). Sugar for a
    /// predicate; ignored when `predicate` is set.
    pub context: Option<String>,
    /// Full visibility predicate: `fn(context_table) -> bool`. The
    /// escape hatch for items whose availability the coarse tags can't
    /// express. Takes precedence over `context`.
    pub predicate: Option<Function>,
    /// Layout group. Items sharing a group render together; a separator
    /// falls between distinct groups (group first-appearance order).
    pub group: String,
    /// Sort key within a group (ascending). Ties break on insertion order.
    pub order: i64,
    /// Where the item was defined (Lua `file:line`).
    pub source: SourceLocation,
}

/// Errors raised by the menu registry.
#[derive(Debug, Error)]
pub enum MenuError {
    /// `item` was called with no label or an all-whitespace one.
    #[error("menu item label must be non-empty")]
    EmptyLabel,

    /// `item` was called without a `command` to invoke.
    #[error("menu item \"{label}\" requires a non-empty `command`")]
    EmptyCommand {
        /// The offending item's label.
        label: String,
    },

    /// The `context` tag is not one of [`KNOWN_CONTEXTS`].
    #[error(
        "menu item \"{label}\" has unknown context `{context}`; supported: always, selection, symbol, diagnostic"
    )]
    UnknownContext {
        /// The offending item's label.
        label: String,
        /// The offending context tag.
        context: String,
    },

    /// The spec table contained a key the registry doesn't know about.
    #[error(
        "unknown field `{field}` in menu item spec; supported: id, label, command, context, predicate, group, order"
    )]
    UnknownField {
        /// The offending key.
        field: String,
    },
}

// ---------------------------------------------------------------------------
// MenuRegistry
// ---------------------------------------------------------------------------

/// Ordered registry of context-menu items.
///
/// Insert via [`Self::add`] (validates, replacing in place on a
/// matching `id`). Read the live set via [`Self::items`]; the menu
/// builder sorts and groups at open time. Remove by id via
/// [`Self::remove`]; reset via [`Self::clear`].
#[derive(Default)]
pub struct MenuRegistry {
    items: Vec<MenuItem>,
}

impl MenuRegistry {
    /// An empty registry.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Insert `item`, validating its metadata. If the item carries an
    /// `id` already present, the existing entry is replaced in place
    /// (preserving its slot) so re-running config and user overrides are
    /// idempotent; otherwise the item is appended.
    pub fn add(&mut self, item: MenuItem) -> Result<(), MenuError> {
        if item.label.trim().is_empty() {
            return Err(MenuError::EmptyLabel);
        }
        if item.command.trim().is_empty() {
            return Err(MenuError::EmptyCommand { label: item.label });
        }
        if let Some(context) = &item.context
            && !KNOWN_CONTEXTS.contains(&context.as_str())
        {
            return Err(MenuError::UnknownContext {
                label: item.label,
                context: context.clone(),
            });
        }
        if let Some(id) = item.id.clone()
            && let Some(slot) = self
                .items
                .iter_mut()
                .find(|it| it.id.as_deref() == Some(&id))
        {
            *slot = item;
            return Ok(());
        }
        self.items.push(item);
        Ok(())
    }

    /// The live items, in insertion order.
    #[must_use]
    pub fn items(&self) -> &[MenuItem] {
        &self.items
    }

    /// Remove every item whose `id` equals `id`. Returns `true` if any
    /// item was removed.
    pub fn remove(&mut self, id: &str) -> bool {
        let before = self.items.len();
        self.items.retain(|it| it.id.as_deref() != Some(id));
        self.items.len() != before
    }

    /// Drop every item.
    pub fn clear(&mut self) {
        self.items.clear();
    }

    /// Number of registered items.
    #[must_use]
    pub fn len(&self) -> usize {
        self.items.len()
    }

    /// True iff no items are registered.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }
}

// ---------------------------------------------------------------------------
// Open-menu runtime state (Q#CM1) + TUI surface
// ---------------------------------------------------------------------------

use std::sync::{Arc, Mutex};

use crate::buffer::Buffer;
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style};
use crate::view::{View, Viewport};

/// One rendered row of an open menu: either a selectable command or a
/// non-selectable group divider. The resolved list is built in Lua
/// (`pmacs.menu.build`, which evaluates predicates / context tags and
/// groups items) and stored here; navigation skips `Separator` rows.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MenuRow {
    /// A divider between groups.
    Separator,
    /// A selectable entry that invokes `command` when chosen.
    Item {
        /// Display label.
        label: String,
        /// Name of the command invoked on selection.
        command: String,
    },
}

impl MenuRow {
    const fn is_item(&self) -> bool {
        matches!(self, MenuRow::Item { .. })
    }
}

/// The live state of an open context menu (Q#CM1). Frontend-agnostic:
/// the TUI [`MenuView`] overlay and (later) the GPU producer both render
/// from this. `active` is an index into `rows` that always points at an
/// [`MenuRow::Item`]. `anchor` is the absolute cell the menu opens at
/// (the click point) — the TUI render origin; the GPU positions in
/// pixels locally and ignores it.
pub struct MenuState {
    /// Rows top-to-bottom (items + separators).
    pub rows: Vec<MenuRow>,
    /// Highlighted row index (always an `Item`).
    pub active: usize,
    /// Absolute `(row, col)` cell the popup renders from.
    pub anchor: (u32, u32),
    /// Popup width in cells (max label + padding, clamped).
    pub width: u32,
}

/// Min / max popup width in cells.
const MENU_MIN_WIDTH: u32 = 8;
const MENU_MAX_WIDTH: u32 = 48;

impl MenuState {
    /// Build from a resolved row list. Returns `None` when no row is an
    /// `Item` (an empty menu never opens). Width is the widest label
    /// plus padding, clamped to [`MENU_MIN_WIDTH`]..[`MENU_MAX_WIDTH`].
    #[must_use]
    pub fn new(rows: Vec<MenuRow>, anchor: (u32, u32)) -> Option<Self> {
        let active = rows.iter().position(MenuRow::is_item)?;
        let widest = rows
            .iter()
            .filter_map(|r| match r {
                MenuRow::Item { label, .. } => Some(label.chars().count() as u32),
                MenuRow::Separator => None,
            })
            .max()
            .unwrap_or(0);
        let width = (widest + 2).clamp(MENU_MIN_WIDTH, MENU_MAX_WIDTH);
        Some(Self {
            rows,
            active,
            anchor,
            width,
        })
    }

    /// Move the highlight one `Item` row forward (`delta >= 0`) or back
    /// (`delta < 0`), wrapping and skipping separators. Only the sign of
    /// `delta` matters — callers step one item at a time.
    pub fn step(&mut self, delta: isize) {
        let n = self.rows.len();
        if n == 0 {
            return;
        }
        let forward = delta >= 0;
        for _ in 0..n {
            self.active = if forward {
                (self.active + 1) % n
            } else {
                (self.active + n - 1) % n
            };
            if self.rows[self.active].is_item() {
                return;
            }
        }
    }

    /// The active item's command name.
    #[must_use]
    pub fn active_command(&self) -> Option<&str> {
        match self.rows.get(self.active)? {
            MenuRow::Item { command, .. } => Some(command),
            MenuRow::Separator => None,
        }
    }

    /// Map an absolute cell to the row index it covers, but only when
    /// that row is a selectable `Item` (separators and cells outside the
    /// popup rectangle return `None`).
    #[must_use]
    pub fn hit(&self, row: u32, col: u32) -> Option<usize> {
        let (arow, acol) = self.anchor;
        if row < arow || col < acol {
            return None;
        }
        let ri = (row - arow) as usize;
        let ci = col - acol;
        if ri >= self.rows.len() || ci >= self.width {
            return None;
        }
        self.rows[ri].is_item().then_some(ri)
    }
}

/// Shared handle to the open menu (`None` when closed). Held by
/// [`crate::editor_core::EditorCore`] and read by [`MenuView`], mirroring
/// the search store's `Arc<Mutex>` bridge between core state and the
/// overlay that renders it.
pub type SharedMenu = Arc<Mutex<Option<MenuState>>>;

/// A fresh, closed shared menu.
#[must_use]
pub fn make_shared_menu() -> SharedMenu {
    Arc::new(Mutex::new(None))
}

/// Popup background (non-selected rows) — a dim fill so the menu reads
/// as a floating surface over the buffer text it occludes.
fn menu_style() -> Style {
    Style {
        fg: Color::Indexed(252),
        bg: Color::Indexed(236),
        ..Style::default()
    }
}

/// Highlighted-row style (the active item).
fn menu_selected_style() -> Style {
    Style {
        fg: Color::Indexed(231),
        bg: Color::Indexed(24),
        ..Style::default()
    }
}

/// TUI overlay that paints the open menu (Q#CM1). Persistent on the
/// active window once attached (deduped by [`View::kind`]); renders
/// nothing while the menu is closed, mirroring `SearchView`'s
/// self-suppressing model. Owns every cell inside the popup rectangle,
/// occluding the buffer text beneath.
pub struct MenuView {
    menu: SharedMenu,
}

impl MenuView {
    /// Build a view reading `menu`.
    #[must_use]
    pub fn new(menu: SharedMenu) -> Self {
        Self { menu }
    }
}

impl View for MenuView {
    fn kind(&self) -> &'static str {
        "context-menu"
    }

    fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
        let guard = self.menu.lock().expect("menu mutex poisoned");
        let Some(menu) = guard.as_ref() else {
            return;
        };
        let top = viewport.cell_origin.row;
        let left = viewport.cell_origin.col;
        let bottom = top + viewport.cell_size.rows;
        let right = left + viewport.cell_size.cols;
        let (arow, acol) = menu.anchor;

        for (i, row) in menu.rows.iter().enumerate() {
            let r = arow + i as u32;
            if r < top || r >= bottom {
                continue; // clip rows that fall outside the window
            }
            let selected = i == menu.active;
            let row_style = if selected {
                menu_selected_style()
            } else {
                menu_style()
            };
            // Paint the full-width row background first.
            for c in 0..menu.width {
                let col = acol + c;
                if col < left || col >= right {
                    continue;
                }
                let cell = cells.at(CellCoord::new(r, col));
                cell.glyph = Glyph::Char(' ');
                cell.style = row_style;
                cell.attachment = None;
            }
            match row {
                MenuRow::Separator => {
                    for c in 0..menu.width {
                        let col = acol + c;
                        if col < left || col >= right {
                            continue;
                        }
                        cells.at(CellCoord::new(r, col)).glyph = Glyph::Char('─');
                    }
                }
                MenuRow::Item { label, .. } => {
                    // One cell of left padding; stop at the popup's right edge.
                    let row_right = (acol + menu.width).min(right);
                    for (col, ch) in (acol + 1..).zip(label.chars()) {
                        if col >= row_right {
                            break;
                        }
                        let cell = cells.at(CellCoord::new(r, col));
                        cell.glyph = Glyph::Char(ch);
                        cell.style = row_style;
                        cell.attachment = None;
                    }
                }
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn item(id: Option<&str>, label: &str, command: &str) -> MenuItem {
        MenuItem {
            id: id.map(ToOwned::to_owned),
            label: label.to_owned(),
            command: command.to_owned(),
            context: None,
            predicate: None,
            group: String::new(),
            order: 0,
            source: SourceLocation::default(),
        }
    }

    #[test]
    fn add_then_items_round_trips() {
        let mut r = MenuRegistry::new();
        r.add(item(None, "Copy", "edit.copy")).unwrap();
        assert_eq!(r.len(), 1);
        assert_eq!(r.items()[0].label, "Copy");
        assert_eq!(r.items()[0].command, "edit.copy");
    }

    #[test]
    fn empty_label_is_rejected() {
        let mut r = MenuRegistry::new();
        assert!(matches!(
            r.add(item(None, "   ", "edit.copy")),
            Err(MenuError::EmptyLabel)
        ));
    }

    #[test]
    fn empty_command_is_rejected() {
        let mut r = MenuRegistry::new();
        match r.add(item(None, "Copy", "")) {
            Err(MenuError::EmptyCommand { label }) => assert_eq!(label, "Copy"),
            other => panic!("expected EmptyCommand, got {other:?}"),
        }
    }

    #[test]
    fn unknown_context_is_rejected() {
        let mut r = MenuRegistry::new();
        let mut it = item(None, "Copy", "edit.copy");
        it.context = Some("selecton".into());
        match r.add(it) {
            Err(MenuError::UnknownContext { label, context }) => {
                assert_eq!(label, "Copy");
                assert_eq!(context, "selecton");
            }
            other => panic!("expected UnknownContext, got {other:?}"),
        }
    }

    #[test]
    fn known_contexts_are_accepted() {
        let mut r = MenuRegistry::new();
        for cx in KNOWN_CONTEXTS {
            let mut it = item(None, "X", "x.cmd");
            it.context = Some((*cx).to_owned());
            r.add(it).unwrap();
        }
        assert_eq!(r.len(), KNOWN_CONTEXTS.len());
    }

    #[test]
    fn predicate_is_stored() {
        let lua = Lua::new();
        let mut r = MenuRegistry::new();
        let mut it = item(None, "Paste", "edit.paste");
        it.predicate = Some(lua.create_function(|_, ()| Ok(true)).unwrap());
        r.add(it).unwrap();
        assert!(r.items()[0].predicate.is_some());
    }

    #[test]
    fn matching_id_replaces_in_place() {
        let mut r = MenuRegistry::new();
        r.add(item(Some("a"), "First", "cmd.a")).unwrap();
        r.add(item(Some("b"), "Second", "cmd.b")).unwrap();
        // Override `a` in place: stays at slot 0 with the new label.
        r.add(item(Some("a"), "First!", "cmd.a")).unwrap();
        assert_eq!(r.len(), 2);
        assert_eq!(r.items()[0].label, "First!");
        assert_eq!(r.items()[1].label, "Second");
    }

    #[test]
    fn remove_by_id_drops_the_item() {
        let mut r = MenuRegistry::new();
        r.add(item(Some("a"), "A", "cmd.a")).unwrap();
        r.add(item(None, "B", "cmd.b")).unwrap();
        assert!(r.remove("a"));
        assert!(!r.remove("a")); // already gone
        assert_eq!(r.len(), 1);
        assert_eq!(r.items()[0].label, "B");
    }

    #[test]
    fn clear_empties_the_registry() {
        let mut r = MenuRegistry::new();
        r.add(item(None, "A", "cmd.a")).unwrap();
        r.add(item(None, "B", "cmd.b")).unwrap();
        r.clear();
        assert!(r.is_empty());
    }

    #[test]
    fn items_without_id_both_append() {
        let mut r = MenuRegistry::new();
        r.add(item(None, "A", "cmd.a")).unwrap();
        r.add(item(None, "A", "cmd.a")).unwrap();
        // No id → no dedup; both are kept.
        assert_eq!(r.len(), 2);
    }

    // ---- MenuState (open-menu runtime) -------------------------------------

    fn row_item(label: &str) -> MenuRow {
        MenuRow::Item {
            label: label.to_owned(),
            command: format!("cmd.{label}"),
        }
    }

    #[test]
    fn menu_state_new_requires_a_selectable_item() {
        assert!(MenuState::new(vec![], (0, 0)).is_none());
        assert!(MenuState::new(vec![MenuRow::Separator], (0, 0)).is_none());
        // active lands on the first item, skipping a leading separator.
        let m = MenuState::new(vec![MenuRow::Separator, row_item("A")], (2, 3)).unwrap();
        assert_eq!(m.active, 1);
        assert_eq!(m.anchor, (2, 3));
    }

    #[test]
    fn menu_state_step_skips_separators_and_wraps() {
        let rows = vec![row_item("A"), MenuRow::Separator, row_item("B")];
        let mut m = MenuState::new(rows, (0, 0)).unwrap();
        assert_eq!(m.active, 0);
        m.step(1);
        assert_eq!(m.active, 2); // jumps over the separator at row 1
        m.step(1);
        assert_eq!(m.active, 0); // wraps to the top
        m.step(-1);
        assert_eq!(m.active, 2); // wraps back, still skipping the separator
    }

    #[test]
    fn menu_state_hit_maps_cells_to_item_rows_only() {
        let rows = vec![row_item("A"), MenuRow::Separator, row_item("B")];
        let m = MenuState::new(rows, (5, 10)).unwrap();
        // Row 5 = item A (anywhere within the popup width).
        assert_eq!(m.hit(5, 10), Some(0));
        assert_eq!(m.hit(5, 10 + m.width - 1), Some(0));
        // Row 6 = separator → not selectable.
        assert_eq!(m.hit(6, 10), None);
        // Row 7 = item B.
        assert_eq!(m.hit(7, 12), Some(2));
        // Outside the popup rectangle in each direction.
        assert_eq!(m.hit(4, 10), None); // above
        assert_eq!(m.hit(8, 10), None); // below
        assert_eq!(m.hit(5, 9), None); // left
        assert_eq!(m.hit(5, 10 + m.width), None); // right
    }

    #[test]
    fn menu_state_active_command_reads_the_highlight() {
        let mut m = MenuState::new(vec![row_item("A"), row_item("B")], (0, 0)).unwrap();
        assert_eq!(m.active_command(), Some("cmd.A"));
        m.step(1);
        assert_eq!(m.active_command(), Some("cmd.B"));
    }
}
