// Justifications for crate-level allows:
// - `struct_excessive_bools` / `fn_params_excessive_bools`: the public
//   API reflects `KeyboardEvent`'s four modifier flags (ctrl, meta,
//   shift, alt). Packing them into a bitfield would obscure the 1:1
//   correspondence with browser event properties at every call site.
// - `doc_markdown`: prose references (Chromium, IME, Korean) hit false
//   positives for missing code fences.
// - `trivially_copy_pass_by_ref`: we take `&KeyEvent` instead of
//   `KeyEvent` so future additions (non-Copy fields like optional
//   modifier locale data) don't break the API.
#![allow(
    clippy::struct_excessive_bools,
    clippy::fn_params_excessive_bools,
    clippy::doc_markdown,
    clippy::trivially_copy_pass_by_ref
)]

//! Hotkey / keybinding matcher for the devup editor.
//!
//! Ported from `packages/react/src/components/Editor.tsx` —
//! `matchKey()`, `matchKeyByCode()`, `codeToBindingKey()`. All logic
//! here is pure: the caller extracts the relevant
//! `KeyboardEvent` fields on the JS side and passes them in, and we
//! parse the binding spec + compare.
//!
//! The binding grammar mirrors Mousetrap / ProseMirror / VSCode:
//! - Plus-separated, case-insensitive tokens: `"mod+shift+l"`, `"tab"`,
//!   `"mod+enter"`.
//! - `mod` is an alias for "Ctrl on Linux/Windows OR ⌘ on macOS" — the
//!   matcher treats `metaKey || ctrlKey` as the "mod" signal.
//! - Recognised modifiers: `mod`, `shift`, `alt`. Anything else is
//!   treated as the primary key.
//! - The primary key is the LAST token (after the final `+`).
//!
//! Two matchers are exposed:
//!
//! - [`match_key`] compares the binding's primary token against
//!   `event.key` (case-insensitive). This is the everyday matcher used
//!   during normal typing.
//!
//! - [`match_key_by_code`] compares against the result of
//!   [`code_to_binding_key`] on `event.code`. Used as a FALLBACK when
//!   `event.key === "Process"` (Chromium reports this for any keydown
//!   Windows routes through an IME, so `Ctrl+B` during a Korean
//!   composition shows up as `Process` even though the underlying
//!   physical key is `KeyB`).
//!
//! See the Storybook `Editor/Keyboard Shortcuts` story for the canonical
//! shortcut table; the TS plugin layer still owns that.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyEvent<'a> {
    /// `event.key` — the logical character produced by the keystroke
    /// (`"b"`, `"Enter"`, `"Process"` during IME, …). Case-insensitive
    /// on the matcher side.
    pub key: &'a str,
    /// `event.code` — the physical key identifier that ignores
    /// keyboard layout and IME state (`"KeyB"`, `"Digit1"`, `"Tab"`, …).
    pub code: &'a str,
    pub ctrl: bool,
    pub meta: bool,
    pub shift: bool,
    pub alt: bool,
}

/// Match `binding` (e.g. `"mod+shift+l"`) against the logical key on
/// `event`. Returns `true` iff every modifier requirement is met and
/// the primary token equals the lowercased `event.key`.
#[must_use]
pub fn match_key(binding: &str, event: &KeyEvent<'_>) -> bool {
    let Some((primary, flags)) = parse_binding(binding) else {
        return false;
    };
    if !modifiers_match(&flags, event) {
        return false;
    }
    primary == event.key.to_ascii_lowercase()
}

/// Match `binding` against the physical `event.code`, falling back to
/// [`code_to_binding_key`] to translate codes to binding tokens.
///
/// Used when `event.key === "Process"` so Ctrl-modified keystrokes
/// survive IME interception on Windows.
#[must_use]
pub fn match_key_by_code(binding: &str, event: &KeyEvent<'_>) -> bool {
    let Some((primary, flags)) = parse_binding(binding) else {
        return false;
    };
    if !modifiers_match(&flags, event) {
        return false;
    }
    let Some(mapped) = code_to_binding_key(event.code) else {
        return false;
    };
    mapped == primary
}

/// Translate `KeyboardEvent.code` into the binding token an editor
/// shortcut table expects. Only covers the subset of codes we plausibly
/// see during IME + modifier combos; everything else returns `None` so
/// callers treat it as "no match".
#[must_use]
#[inline]
pub fn code_to_binding_key(code: &str) -> Option<String> {
    // `KeyB` → `b`, etc. `Key` prefix is 3 chars, so the total length
    // for a single letter is 4.
    if let Some(rest) = code.strip_prefix("Key")
        && rest.len() == 1
    {
        return Some(rest.to_ascii_lowercase());
    }
    // `Digit1` → `1`, etc.
    if let Some(rest) = code.strip_prefix("Digit")
        && rest.len() == 1
    {
        return Some(rest.to_string());
    }
    match code {
        "Backspace" => Some("backspace".into()),
        "Enter" => Some("enter".into()),
        "Tab" => Some("tab".into()),
        "Space" => Some(" ".into()),
        _ => None,
    }
}

// ── Internal helpers ─────────────────────────────────────────────

#[derive(Debug, Clone, Copy, Default)]
struct Flags {
    need_mod: bool,
    need_shift: bool,
    need_alt: bool,
}

/// Split a binding spec `"mod+shift+l"` into `("l", {mod, shift})`.
/// Returns `None` when the spec has no primary token (e.g. trailing `+`).
fn parse_binding(binding: &str) -> Option<(String, Flags)> {
    // Everything is case-insensitive.
    let lower = binding.to_ascii_lowercase();
    // Split on `+`. An empty spec (`""`) or trailing-`+` spec (`"mod+"`)
    // is rejected because the primary token must be non-empty.
    let mut tokens: Vec<&str> = lower.split('+').collect();
    let primary = tokens.pop()?;
    if primary.is_empty() {
        return None;
    }
    let mut flags = Flags::default();
    for tok in tokens {
        match tok {
            "mod" => flags.need_mod = true,
            "shift" => flags.need_shift = true,
            "alt" => flags.need_alt = true,
            _ => {
                // Unknown token before the primary — the binding is
                // malformed. We refuse to match rather than silently
                // ignore, which would let a typo like `"shft+b"` map to
                // the plain `b` binding.
                return None;
            }
        }
    }
    Some((primary.to_string(), flags))
}

fn modifiers_match(flags: &Flags, event: &KeyEvent<'_>) -> bool {
    let has_mod = event.meta || event.ctrl;
    flags.need_mod == has_mod && flags.need_shift == event.shift && flags.need_alt == event.alt
}

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

    fn ev(
        key: &'static str,
        code: &'static str,
        ctrl: bool,
        meta: bool,
        shift: bool,
        alt: bool,
    ) -> KeyEvent<'static> {
        KeyEvent {
            key,
            code,
            ctrl,
            meta,
            shift,
            alt,
        }
    }

    // ── code_to_binding_key ──

    #[test]
    fn code_letters_map_to_lowercase() {
        assert_eq!(code_to_binding_key("KeyB").as_deref(), Some("b"));
        assert_eq!(code_to_binding_key("KeyA").as_deref(), Some("a"));
        assert_eq!(code_to_binding_key("KeyZ").as_deref(), Some("z"));
    }

    #[test]
    fn code_digits_map_to_single_char() {
        assert_eq!(code_to_binding_key("Digit1").as_deref(), Some("1"));
        assert_eq!(code_to_binding_key("Digit0").as_deref(), Some("0"));
        assert_eq!(code_to_binding_key("Digit9").as_deref(), Some("9"));
    }

    #[test]
    fn code_named_keys() {
        assert_eq!(
            code_to_binding_key("Backspace").as_deref(),
            Some("backspace")
        );
        assert_eq!(code_to_binding_key("Enter").as_deref(), Some("enter"));
        assert_eq!(code_to_binding_key("Tab").as_deref(), Some("tab"));
        assert_eq!(code_to_binding_key("Space").as_deref(), Some(" "));
    }

    #[test]
    fn code_unknown_returns_none() {
        assert!(code_to_binding_key("ArrowLeft").is_none());
        assert!(code_to_binding_key("F1").is_none());
        assert!(code_to_binding_key("Escape").is_none());
        assert!(code_to_binding_key("KeyAB").is_none()); // too long
        assert!(code_to_binding_key("Key").is_none()); // empty suffix
    }

    // ── match_key ──

    #[test]
    fn match_key_plain_letter() {
        assert!(match_key("b", &ev("b", "KeyB", false, false, false, false)));
    }

    #[test]
    fn match_key_case_insensitive() {
        assert!(match_key("B", &ev("b", "KeyB", false, false, false, false)));
        assert!(match_key("B", &ev("B", "KeyB", false, false, false, false)));
    }

    #[test]
    fn match_key_mod_on_ctrl_linux_windows() {
        assert!(match_key(
            "mod+b",
            &ev("b", "KeyB", true, false, false, false)
        ));
    }

    #[test]
    fn match_key_mod_on_meta_macos() {
        assert!(match_key(
            "mod+b",
            &ev("b", "KeyB", false, true, false, false)
        ));
    }

    #[test]
    fn match_key_rejects_missing_modifier() {
        assert!(!match_key(
            "mod+b",
            &ev("b", "KeyB", false, false, false, false)
        ));
    }

    #[test]
    fn match_key_rejects_extra_modifier() {
        assert!(!match_key("b", &ev("b", "KeyB", true, false, false, false)));
    }

    #[test]
    fn match_key_shift_tab_pair() {
        assert!(match_key(
            "shift+tab",
            &ev("Tab", "Tab", false, false, true, false)
        ));
        assert!(!match_key(
            "shift+tab",
            &ev("Tab", "Tab", false, false, false, false)
        ));
    }

    #[test]
    fn match_key_multi_modifier() {
        assert!(match_key(
            "mod+shift+l",
            &ev("l", "KeyL", true, false, true, false)
        ));
        assert!(!match_key(
            "mod+shift+l",
            &ev("l", "KeyL", true, false, false, false)
        ));
    }

    #[test]
    fn match_key_empty_spec_fails() {
        assert!(!match_key("", &ev("b", "KeyB", false, false, false, false)));
        assert!(!match_key(
            "mod+",
            &ev("b", "KeyB", true, false, false, false)
        ));
    }

    #[test]
    fn match_key_unknown_modifier_fails() {
        // Typo protection: "shft" shouldn't fall through as a primary.
        assert!(!match_key(
            "shft+b",
            &ev("b", "KeyB", false, false, false, false)
        ));
    }

    // ── match_key_by_code ──

    #[test]
    fn match_key_by_code_process_during_ime() {
        // `event.key === "Process"` but physical code is `KeyB` + Ctrl.
        // The IME-aware matcher must still fire.
        assert!(match_key_by_code(
            "mod+b",
            &ev("Process", "KeyB", true, false, false, false)
        ));
    }

    #[test]
    fn match_key_by_code_respects_modifiers() {
        assert!(!match_key_by_code(
            "mod+b",
            &ev("Process", "KeyB", false, false, false, false)
        ));
    }

    #[test]
    fn match_key_by_code_rejects_mapped_mismatch() {
        assert!(!match_key_by_code(
            "mod+b",
            &ev("Process", "KeyA", true, false, false, false)
        ));
    }

    #[test]
    fn match_key_by_code_rejects_unmapped_code() {
        // Arrow keys aren't in the binding-key map → never match.
        assert!(!match_key_by_code(
            "mod+b",
            &ev("Process", "ArrowLeft", true, false, false, false)
        ));
    }
}
