//! Central action metadata shared by dispatch, contextual help, and command mode.

use crossterm::event::{KeyCode, KeyModifiers};

use crate::app::Mode;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Action {
    Quit,
    Help,
    Command,
    Cancel,
    Lock,
    Unlock,
    Refresh,
    Next,
    Previous,
    PageDown,
    PageUp,
    First,
    Last,
    Parent,
    Child,
    Activate,
    Filter,
    NextMatch,
    PreviousMatch,
    FocusNext,
    FocusPrevious,
    Copy,
    ScrollDown,
    ScrollUp,
    CloseEntry,
    EditEntry,
    BeginInput,
    SaveEditor,
    AddField,
    RemoveField,
    MoveFieldUp,
    MoveFieldDown,
    Generate,
    ConfirmDiscard,
    KeepEditing,
    Initialize,
    InsertEntry,
    GenerateEntry,
    Grep,
    ImportKdbx,
    RemoveEntry,
    MoveEntry,
    CopyEntry,
    GitPull,
    GitPush,
    OtpCode,
    OtpCopyCode,
    OtpUri,
    OtpCopyUri,
    OtpQr,
    OtpInsert,
    OtpAppend,
    OtpValidate,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkflowAction {
    Initialize,
    InsertEntry,
    GenerateEntry,
    Grep,
    ImportKdbx,
    RemoveEntry,
    MoveEntry,
    CopyEntry,
    GitPull,
    GitPush,
}

impl Action {
    pub fn workflow(self) -> Option<WorkflowAction> {
        match self {
            Self::Initialize => Some(WorkflowAction::Initialize),
            Self::InsertEntry => Some(WorkflowAction::InsertEntry),
            Self::GenerateEntry => Some(WorkflowAction::GenerateEntry),
            Self::Grep => Some(WorkflowAction::Grep),
            Self::ImportKdbx => Some(WorkflowAction::ImportKdbx),
            Self::RemoveEntry => Some(WorkflowAction::RemoveEntry),
            Self::MoveEntry => Some(WorkflowAction::MoveEntry),
            Self::CopyEntry => Some(WorkflowAction::CopyEntry),
            Self::GitPull => Some(WorkflowAction::GitPull),
            Self::GitPush => Some(WorkflowAction::GitPush),
            _ => None,
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct KeyBinding {
    pub code: KeyCode,
    pub modifiers: KeyModifiers,
    pub then: Option<(KeyCode, KeyModifiers)>,
    pub display: &'static str,
}

impl KeyBinding {
    fn is_single(self) -> bool {
        self.then.is_none()
    }

    fn starts_with(self, code: KeyCode, modifiers: KeyModifiers) -> bool {
        self.code == code && self.modifiers == modifiers
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActionSpec {
    pub action: Action,
    pub label: &'static str,
    pub command: &'static str,
    pub bindings: &'static [KeyBinding],
    modes: &'static [Mode],
}

impl ActionSpec {
    pub fn is_available(self, mode: Mode) -> bool {
        self.modes.contains(&mode)
    }

    pub fn help(self) -> &'static str {
        match self.action {
            Action::RemoveEntry => "destructive; opens confirmation (y accepts, n/Esc cancels)",
            Action::ConfirmDiscard => "confirm the destructive dialog choice",
            Action::KeepEditing => "decline the destructive dialog choice",
            Action::GitPull | Action::GitPush => "two-key Git sequence",
            Action::Lock => "manual lock; unsaved secrets are discarded",
            Action::Command => "other Git operations are available through command mode",
            _ => "",
        }
    }

    pub fn is_compact(self) -> bool {
        matches!(
            self.action,
            Action::Quit
                | Action::Help
                | Action::Cancel
                | Action::Lock
                | Action::Next
                | Action::Previous
                | Action::Activate
                | Action::Filter
                | Action::FocusNext
                | Action::Copy
                | Action::EditEntry
                | Action::BeginInput
                | Action::SaveEditor
                | Action::ConfirmDiscard
                | Action::KeepEditing
                | Action::Unlock
        )
    }
}

const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer];
const ENTRY_CONTEXT: &[Mode] = &[Mode::Browser, Mode::Viewer];
const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor];
const COMMANDABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor, Mode::Locked];
const QUITTABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Locked];
const LOCKABLE: &[Mode] = &[
    Mode::Browser,
    Mode::Viewer,
    Mode::Editor,
    Mode::Dialog,
    Mode::Help,
    Mode::Command,
];
#[cfg(test)]
const ALL: &[Mode] = &[
    Mode::Browser,
    Mode::Viewer,
    Mode::Editor,
    Mode::Dialog,
    Mode::Help,
    Mode::Command,
    Mode::Locked,
];

macro_rules! keys {
    ($(($code:expr, $modifiers:expr, $display:expr)),+ $(,)?) => {
        &[$(KeyBinding {
            code: $code,
            modifiers: $modifiers,
            then: None,
            display: $display,
        }),+]
    };
}

macro_rules! sequences {
    ($(($code:expr, $modifiers:expr, $then_code:expr, $then_modifiers:expr, $display:expr)),+ $(,)?) => {
        &[$(KeyBinding {
            code: $code,
            modifiers: $modifiers,
            then: Some(($then_code, $then_modifiers)),
            display: $display,
        }),+]
    };
}

pub static ACTIONS: &[ActionSpec] = &[
    ActionSpec {
        action: Action::Quit,
        label: "quit",
        command: "quit",
        bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")),
        modes: QUITTABLE,
    },
    ActionSpec {
        action: Action::Help,
        label: "help",
        command: "help",
        bindings: keys!((KeyCode::Char('?'), KeyModifiers::NONE, "?")),
        modes: UNLOCKED,
    },
    ActionSpec {
        action: Action::Unlock,
        label: "unlock",
        command: "unlock",
        bindings: keys!((KeyCode::Enter, KeyModifiers::NONE, "Enter")),
        modes: &[Mode::Locked],
    },
    ActionSpec {
        action: Action::Command,
        label: "command",
        command: "command",
        bindings: keys!((KeyCode::Char(':'), KeyModifiers::NONE, ":")),
        modes: COMMANDABLE,
    },
    ActionSpec {
        action: Action::Cancel,
        label: "back",
        command: "cancel",
        bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")),
        modes: &[
            Mode::Browser,
            Mode::Editor,
            Mode::Help,
            Mode::Command,
            Mode::Dialog,
        ],
    },
    ActionSpec {
        action: Action::Lock,
        label: "lock",
        command: "lock",
        bindings: keys!(
            (KeyCode::Char('l'), KeyModifiers::CONTROL, "C-l"),
            (KeyCode::Char('z'), KeyModifiers::CONTROL, "C-z"),
        ),
        modes: LOCKABLE,
    },
    ActionSpec {
        action: Action::Refresh,
        label: "refresh",
        command: "refresh",
        bindings: keys!((KeyCode::Char('r'), KeyModifiers::NONE, "r")),
        modes: BROWSER_LIKE,
    },
    ActionSpec {
        action: Action::Next,
        label: "next",
        command: "next",
        bindings: keys!(
            (KeyCode::Char('j'), KeyModifiers::NONE, "j"),
            (KeyCode::Down, KeyModifiers::NONE, "↓"),
        ),
        modes: BROWSER_LIKE,
    },
    ActionSpec {
        action: Action::Previous,
        label: "previous",
        command: "previous",
        bindings: keys!(
            (KeyCode::Char('k'), KeyModifiers::NONE, "k"),
            (KeyCode::Up, KeyModifiers::NONE, "↑"),
        ),
        modes: BROWSER_LIKE,
    },
    ActionSpec {
        action: Action::PageDown,
        label: "page down",
        command: "page-down",
        bindings: keys!((KeyCode::PageDown, KeyModifiers::NONE, "PgDn")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::PageUp,
        label: "page up",
        command: "page-up",
        bindings: keys!((KeyCode::PageUp, KeyModifiers::NONE, "PgUp")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::First,
        label: "first",
        command: "first",
        bindings: keys!((KeyCode::Home, KeyModifiers::NONE, "Home")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::Last,
        label: "last",
        command: "last",
        bindings: keys!((KeyCode::End, KeyModifiers::NONE, "End")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::Parent,
        label: "parent",
        command: "parent",
        bindings: keys!(
            (KeyCode::Char('h'), KeyModifiers::NONE, "h"),
            (KeyCode::Left, KeyModifiers::NONE, "←"),
        ),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::Child,
        label: "child",
        command: "child",
        bindings: keys!(
            (KeyCode::Char('l'), KeyModifiers::NONE, "l"),
            (KeyCode::Right, KeyModifiers::NONE, "→"),
        ),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::Activate,
        label: "open/toggle",
        command: "open",
        bindings: keys!((KeyCode::Enter, KeyModifiers::NONE, "Enter")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::Filter,
        label: "filter",
        command: "filter",
        bindings: keys!((KeyCode::Char('/'), KeyModifiers::NONE, "/")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::NextMatch,
        label: "next match",
        command: "next-match",
        bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::PreviousMatch,
        label: "previous match",
        command: "previous-match",
        bindings: keys!((KeyCode::Char('N'), KeyModifiers::SHIFT, "N")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::FocusNext,
        label: "next field/pane",
        command: "focus-next",
        bindings: keys!((KeyCode::Tab, KeyModifiers::NONE, "Tab")),
        modes: UNLOCKED,
    },
    ActionSpec {
        action: Action::FocusPrevious,
        label: "previous field/pane",
        command: "focus-previous",
        bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")),
        modes: UNLOCKED,
    },
    ActionSpec {
        action: Action::Copy,
        label: "copy field",
        command: "copy",
        bindings: keys!((KeyCode::Char('y'), KeyModifiers::NONE, "y")),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::ScrollDown,
        label: "scroll down",
        command: "scroll-down",
        bindings: keys!((KeyCode::PageDown, KeyModifiers::NONE, "PgDn")),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::ScrollUp,
        label: "scroll up",
        command: "scroll-up",
        bindings: keys!((KeyCode::PageUp, KeyModifiers::NONE, "PgUp")),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::CloseEntry,
        label: "close entry",
        command: "close-entry",
        bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::EditEntry,
        label: "edit entry",
        command: "edit-entry",
        bindings: keys!((KeyCode::Char('e'), KeyModifiers::NONE, "e")),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::BeginInput,
        label: "edit field",
        command: "edit-field",
        bindings: keys!((KeyCode::Char('i'), KeyModifiers::NONE, "i")),
        modes: &[Mode::Editor],
    },
    ActionSpec {
        action: Action::SaveEditor,
        label: "save",
        command: "save",
        bindings: keys!((KeyCode::Char('s'), KeyModifiers::CONTROL, "C-s")),
        modes: &[Mode::Editor],
    },
    ActionSpec {
        action: Action::AddField,
        label: "add field",
        command: "add-field",
        bindings: keys!((KeyCode::Char('a'), KeyModifiers::NONE, "a")),
        modes: &[Mode::Editor],
    },
    ActionSpec {
        action: Action::RemoveField,
        label: "remove field",
        command: "remove-field",
        bindings: keys!((KeyCode::Char('d'), KeyModifiers::NONE, "d")),
        modes: &[Mode::Editor],
    },
    ActionSpec {
        action: Action::MoveFieldUp,
        label: "move field up",
        command: "move-field-up",
        bindings: keys!((KeyCode::Char('K'), KeyModifiers::SHIFT, "K")),
        modes: &[Mode::Editor],
    },
    ActionSpec {
        action: Action::MoveFieldDown,
        label: "move field down",
        command: "move-field-down",
        bindings: keys!((KeyCode::Char('J'), KeyModifiers::SHIFT, "J")),
        modes: &[Mode::Editor],
    },
    ActionSpec {
        action: Action::Generate,
        label: "generate field",
        command: "generate-field",
        bindings: keys!((KeyCode::Char('g'), KeyModifiers::NONE, "g")),
        modes: &[Mode::Editor],
    },
    ActionSpec {
        action: Action::ConfirmDiscard,
        label: "discard edits",
        command: "discard-edits",
        bindings: keys!((KeyCode::Char('y'), KeyModifiers::NONE, "y")),
        modes: &[Mode::Dialog],
    },
    ActionSpec {
        action: Action::KeepEditing,
        label: "keep editing",
        command: "keep-editing",
        bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")),
        modes: &[Mode::Dialog],
    },
    ActionSpec {
        action: Action::Initialize,
        label: "initialize recipients",
        command: "init",
        bindings: keys!((KeyCode::Char('I'), KeyModifiers::SHIFT, "I")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::InsertEntry,
        label: "insert entry",
        command: "insert",
        bindings: keys!((KeyCode::Char('i'), KeyModifiers::NONE, "i")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::GenerateEntry,
        label: "generate entry",
        command: "generate",
        bindings: keys!((KeyCode::Char('p'), KeyModifiers::NONE, "p")),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::Grep,
        label: "grep decrypted entries",
        command: "grep",
        bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")),
        modes: BROWSER_LIKE,
    },
    ActionSpec {
        action: Action::ImportKdbx,
        label: "import KeePass KDBX",
        command: "import-kdbx",
        bindings: keys!((KeyCode::Char('K'), KeyModifiers::SHIFT, "K")),
        modes: BROWSER_LIKE,
    },
    ActionSpec {
        action: Action::RemoveEntry,
        label: "remove entry",
        command: "remove-entry",
        bindings: sequences!((
            KeyCode::Char('d'),
            KeyModifiers::NONE,
            KeyCode::Char('d'),
            KeyModifiers::NONE,
            "d d"
        )),
        modes: ENTRY_CONTEXT,
    },
    ActionSpec {
        action: Action::MoveEntry,
        label: "move entry",
        command: "move-entry",
        bindings: keys!((KeyCode::Char('m'), KeyModifiers::NONE, "m")),
        modes: ENTRY_CONTEXT,
    },
    ActionSpec {
        action: Action::CopyEntry,
        label: "copy entry",
        command: "copy-entry",
        bindings: keys!((KeyCode::Char('c'), KeyModifiers::NONE, "c")),
        modes: ENTRY_CONTEXT,
    },
    ActionSpec {
        action: Action::GitPull,
        label: "Git pull",
        command: "git-pull",
        bindings: sequences!((
            KeyCode::Char('g'),
            KeyModifiers::NONE,
            KeyCode::Char('p'),
            KeyModifiers::NONE,
            "g p"
        )),
        modes: BROWSER_LIKE,
    },
    ActionSpec {
        action: Action::GitPush,
        label: "Git push",
        command: "git-push",
        bindings: sequences!((
            KeyCode::Char('g'),
            KeyModifiers::NONE,
            KeyCode::Char('P'),
            KeyModifiers::SHIFT,
            "g P"
        )),
        modes: BROWSER_LIKE,
    },
    ActionSpec {
        action: Action::OtpCode,
        label: "generate OTP code",
        command: "otp-code",
        bindings: sequences!((
            KeyCode::Char('o'),
            KeyModifiers::NONE,
            KeyCode::Char('c'),
            KeyModifiers::NONE,
            "o c"
        )),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::OtpCopyCode,
        label: "copy OTP code",
        command: "otp-copy-code",
        bindings: sequences!((
            KeyCode::Char('o'),
            KeyModifiers::NONE,
            KeyCode::Char('y'),
            KeyModifiers::NONE,
            "o y"
        )),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::OtpUri,
        label: "show OTP URI",
        command: "otp-uri",
        bindings: sequences!((
            KeyCode::Char('o'),
            KeyModifiers::NONE,
            KeyCode::Char('u'),
            KeyModifiers::NONE,
            "o u"
        )),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::OtpCopyUri,
        label: "copy OTP URI",
        command: "otp-copy-uri",
        bindings: sequences!((
            KeyCode::Char('o'),
            KeyModifiers::NONE,
            KeyCode::Char('x'),
            KeyModifiers::NONE,
            "o x"
        )),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::OtpQr,
        label: "show OTP QR",
        command: "otp-qr",
        bindings: sequences!((
            KeyCode::Char('o'),
            KeyModifiers::NONE,
            KeyCode::Char('q'),
            KeyModifiers::NONE,
            "o q"
        )),
        modes: &[Mode::Viewer],
    },
    ActionSpec {
        action: Action::OtpInsert,
        label: "insert OTP URI",
        command: "otp-insert",
        bindings: sequences!((
            KeyCode::Char('o'),
            KeyModifiers::NONE,
            KeyCode::Char('i'),
            KeyModifiers::NONE,
            "o i"
        )),
        modes: &[Mode::Browser],
    },
    ActionSpec {
        action: Action::OtpAppend,
        label: "append OTP URI",
        command: "otp-append",
        bindings: sequences!((
            KeyCode::Char('o'),
            KeyModifiers::NONE,
            KeyCode::Char('a'),
            KeyModifiers::NONE,
            "o a"
        )),
        modes: ENTRY_CONTEXT,
    },
    ActionSpec {
        action: Action::OtpValidate,
        label: "validate OTP URI",
        command: "otp-validate",
        bindings: sequences!((
            KeyCode::Char('o'),
            KeyModifiers::NONE,
            KeyCode::Char('v'),
            KeyModifiers::NONE,
            "o v"
        )),
        modes: BROWSER_LIKE,
    },
];

pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
    let modifiers = normalize_modifiers(code, modifiers);
    ACTIONS
        .iter()
        .find(|spec| {
            spec.is_available(mode)
                && spec
                    .bindings
                    .iter()
                    .any(|binding| binding.is_single() && binding.starts_with(code, modifiers))
        })
        .map(|spec| spec.action)
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum KeyResolution {
    Action(Action),
    Pending,
    Unavailable,
}

#[derive(Debug, Default)]
pub struct KeyResolver {
    mode: Option<Mode>,
    pending: Option<(KeyCode, KeyModifiers)>,
}

impl KeyResolver {
    pub fn feed(&mut self, mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> KeyResolution {
        if self.mode != Some(mode) {
            self.pending = None;
            self.mode = Some(mode);
        }
        let modifiers = normalize_modifiers(code, modifiers);
        if let Some((first_code, first_modifiers)) = self.pending.take()
            && code != KeyCode::Esc
        {
            return ACTIONS
                .iter()
                .filter(|spec| spec.is_available(mode))
                .find_map(|spec| {
                    spec.bindings
                        .iter()
                        .any(|binding| {
                            binding.starts_with(first_code, first_modifiers)
                                && binding.then == Some((code, modifiers))
                        })
                        .then_some(KeyResolution::Action(spec.action))
                })
                .unwrap_or(KeyResolution::Unavailable);
        }

        if let Some(action) = resolve_key(mode, code, modifiers) {
            return KeyResolution::Action(action);
        }
        if ACTIONS
            .iter()
            .filter(|spec| spec.is_available(mode))
            .flat_map(|spec| spec.bindings)
            .any(|binding| !binding.is_single() && binding.starts_with(code, modifiers))
        {
            self.pending = Some((code, modifiers));
            KeyResolution::Pending
        } else {
            KeyResolution::Unavailable
        }
    }

    pub fn reset(&mut self) {
        self.pending = None;
        self.mode = None;
    }
}

fn normalize_modifiers(code: KeyCode, modifiers: KeyModifiers) -> KeyModifiers {
    let mut normalized = modifiers
        & (KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
    if code == KeyCode::BackTab {
        normalized.insert(KeyModifiers::SHIFT);
    }
    normalized
}

pub fn available_actions(mode: Mode) -> impl Iterator<Item = &'static ActionSpec> {
    ACTIONS.iter().filter(move |spec| spec.is_available(mode))
}

pub fn context_actions(mode: Mode) -> impl Iterator<Item = &'static ActionSpec> {
    available_actions(mode).filter(|spec| spec.is_compact())
}

pub fn help_actions(context: Mode) -> impl Iterator<Item = &'static ActionSpec> {
    ACTIONS
        .iter()
        .filter(move |spec| spec.is_available(context) || spec.is_available(Mode::Help))
}

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

    #[test]
    fn registry_has_unique_commands_and_bindings_per_mode() {
        for (index, action) in ACTIONS.iter().enumerate() {
            assert!(!action.command.is_empty());
            assert!(!action.label.is_empty());
            assert!(!action.modes.is_empty());
            for other in &ACTIONS[index + 1..] {
                assert_ne!(action.command, other.command);
                for mode in ALL {
                    if action.is_available(*mode) && other.is_available(*mode) {
                        for binding in action.bindings {
                            for other_binding in other.bindings {
                                assert_ne!(binding, other_binding);
                                assert!(
                                    !(binding
                                        .starts_with(other_binding.code, other_binding.modifiers)
                                        && (binding.is_single() || other_binding.is_single())),
                                    "a single binding may not prefix another binding in {mode:?}"
                                );
                            }
                        }
                    }
                }
            }
        }
    }

    #[test]
    fn resolution_and_help_consume_the_same_registry() {
        for mode in ALL {
            for spec in available_actions(*mode) {
                for binding in spec.bindings {
                    let mut resolver = KeyResolver::default();
                    let first = resolver.feed(*mode, binding.code, binding.modifiers);
                    if let Some((code, modifiers)) = binding.then {
                        assert_eq!(first, KeyResolution::Pending);
                        assert_eq!(
                            resolver.feed(*mode, code, modifiers),
                            KeyResolution::Action(spec.action)
                        );
                    } else {
                        assert_eq!(first, KeyResolution::Action(spec.action));
                    }
                }
            }
        }
        assert_eq!(
            resolve_key(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE),
            None
        );
    }

    #[test]
    fn mode_precedence_and_terminal_key_normalization_are_explicit() {
        let mut resolver = KeyResolver::default();
        assert_eq!(
            resolver.feed(Mode::Browser, KeyCode::Char('i'), KeyModifiers::NONE),
            KeyResolution::Action(Action::InsertEntry)
        );
        assert_eq!(
            resolver.feed(Mode::Editor, KeyCode::Char('i'), KeyModifiers::NONE),
            KeyResolution::Action(Action::BeginInput)
        );
        assert_eq!(
            resolver.feed(Mode::Editor, KeyCode::BackTab, KeyModifiers::NONE),
            KeyResolution::Action(Action::FocusPrevious)
        );
        assert_eq!(
            resolver.feed(Mode::Viewer, KeyCode::Down, KeyModifiers::NONE),
            KeyResolution::Action(Action::Next)
        );
        assert_eq!(
            resolver.feed(Mode::Viewer, KeyCode::Char('k'), KeyModifiers::NONE),
            KeyResolution::Action(Action::Previous)
        );
    }

    #[test]
    fn repeated_sequences_unavailable_keys_and_escape_are_deterministic() {
        let mut resolver = KeyResolver::default();
        assert_eq!(
            resolver.feed(Mode::Browser, KeyCode::Char('d'), KeyModifiers::NONE),
            KeyResolution::Pending
        );
        assert_eq!(
            resolver.feed(Mode::Browser, KeyCode::Char('d'), KeyModifiers::NONE),
            KeyResolution::Action(Action::RemoveEntry)
        );
        assert_eq!(
            resolver.feed(Mode::Viewer, KeyCode::Char('g'), KeyModifiers::NONE),
            KeyResolution::Pending
        );
        assert_eq!(
            resolver.feed(Mode::Viewer, KeyCode::Esc, KeyModifiers::NONE),
            KeyResolution::Action(Action::CloseEntry)
        );
        assert_eq!(
            resolver.feed(Mode::Browser, KeyCode::Char('g'), KeyModifiers::NONE),
            KeyResolution::Pending
        );
        assert_eq!(
            resolver.feed(Mode::Browser, KeyCode::Char('x'), KeyModifiers::NONE),
            KeyResolution::Unavailable
        );
        assert_eq!(
            resolver.feed(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE),
            KeyResolution::Unavailable
        );
    }

    #[test]
    fn confirmation_shortcuts_are_mode_scoped_and_discoverable() {
        let mut resolver = KeyResolver::default();
        assert_eq!(
            resolver.feed(Mode::Dialog, KeyCode::Char('y'), KeyModifiers::NONE),
            KeyResolution::Action(Action::ConfirmDiscard)
        );
        assert_eq!(
            resolver.feed(Mode::Dialog, KeyCode::Char('n'), KeyModifiers::NONE),
            KeyResolution::Action(Action::KeepEditing)
        );
        assert!(help_actions(Mode::Dialog).any(|spec| spec.action == Action::ConfirmDiscard));
        assert!(help_actions(Mode::Dialog).any(|spec| spec.action == Action::KeepEditing));
    }
}
