//! TUI keymap config schema and canonical key-spec normalization.
//!
//! This module defines the on-disk `[tui.keymap]` contract used by
//! `~/.codex/config.toml` and normalizes user-entered key specs into canonical
//! forms consumed by runtime keymap resolution in `codex-rs/tui/src/keymap.rs`.
//!
//! Responsibilities:
//!
//! 1. Define strongly typed config contexts/actions with unknown-field
//!    rejection.
//! 2. Normalize accepted key aliases into canonical names.
//! 3. Reject malformed bindings early with user-facing diagnostics.
//!
//! Non-responsibilities:
//!
//! 1. Dispatch precedence and conflict validation.
//! 2. Input event matching at runtime.

use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as SerdeError;
use std::collections::BTreeMap;

/// Highest function key supported by portable TUI keymap configuration.
pub const MAX_FUNCTION_KEY: u8 = 24;

/// Normalized string representation of a single key event (for example `ctrl-a`).
///
/// The parser accepts a small alias set (for example `escape` -> `esc`,
/// `pageup` -> `page-up`) and stores the canonical form.
///
/// This deliberately represents one terminal key event, not a sequence of
/// events. A value like `ctrl-x ctrl-s` is not a chord in this schema; adding
/// multi-step chords would require a separate runtime state machine.
#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
pub struct KeybindingSpec(#[schemars(with = "String")] pub String);

impl KeybindingSpec {
    /// Returns the canonical key-spec string (for example `ctrl-a`).
    pub fn as_str(&self) -> &str {
        self.0.as_str()
    }
}

impl<'de> Deserialize<'de> for KeybindingSpec {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let raw = String::deserialize(deserializer)?;
        let normalized = normalize_keybinding_spec(&raw).map_err(SerdeError::custom)?;
        Ok(Self(normalized))
    }
}

/// One action binding value in config.
///
/// This accepts either:
///
/// 1. A single key spec string (`"ctrl-a"`).
/// 2. A list of key spec strings (`["ctrl-a", "alt-a"]`).
///
/// An empty list explicitly unbinds the action in that scope. Because an
/// explicit empty list is still a configured value, runtime resolution must not
/// fall through to global or built-in defaults for that action.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum KeybindingsSpec {
    One(KeybindingSpec),
    Many(Vec<KeybindingSpec>),
}

impl KeybindingsSpec {
    /// Returns all configured key specs for one action in declaration order.
    ///
    /// Callers should preserve this ordering when deriving UI hints so the
    /// first binding remains the primary affordance shown to users.
    pub fn specs(&self) -> Vec<&KeybindingSpec> {
        match self {
            Self::One(spec) => vec![spec],
            Self::Many(specs) => specs.iter().collect(),
        }
    }
}

/// Global keybindings. These are used when a context does not define an override.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiGlobalKeymap {
    /// Open the transcript overlay.
    pub open_transcript: Option<KeybindingsSpec>,
    /// Open the external editor for the current draft.
    pub open_external_editor: Option<KeybindingsSpec>,
    /// Copy the last agent response to the clipboard.
    pub copy: Option<KeybindingsSpec>,
    /// Clear the terminal UI.
    pub clear_terminal: Option<KeybindingsSpec>,
    /// Submit the current composer draft.
    pub submit: Option<KeybindingsSpec>,
    /// Queue the current composer draft while a task is running.
    pub queue: Option<KeybindingsSpec>,
    /// Toggle the composer shortcut overlay.
    pub toggle_shortcuts: Option<KeybindingsSpec>,
    /// Toggle Vim mode for the composer input.
    pub toggle_vim_mode: Option<KeybindingsSpec>,
    /// Toggle Fast mode.
    pub toggle_fast_mode: Option<KeybindingsSpec>,
    /// Toggle raw scrollback mode for copy-friendly transcript selection.
    pub toggle_raw_output: Option<KeybindingsSpec>,
}

/// Chat context keybindings.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiChatKeymap {
    /// Interrupt the active turn.
    pub interrupt_turn: Option<KeybindingsSpec>,
    /// Decrease the active reasoning effort.
    pub decrease_reasoning_effort: Option<KeybindingsSpec>,
    /// Increase the active reasoning effort.
    pub increase_reasoning_effort: Option<KeybindingsSpec>,
    /// Edit the most recently queued message.
    pub edit_queued_message: Option<KeybindingsSpec>,
}

/// Composer context keybindings. These override corresponding `global` actions.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiComposerKeymap {
    /// Submit the current composer draft.
    pub submit: Option<KeybindingsSpec>,
    /// Queue the current composer draft while a task is running.
    pub queue: Option<KeybindingsSpec>,
    /// Toggle the composer shortcut overlay.
    pub toggle_shortcuts: Option<KeybindingsSpec>,
    /// Open reverse history search or move to the previous match.
    pub history_search_previous: Option<KeybindingsSpec>,
    /// Move to the next match in reverse history search.
    pub history_search_next: Option<KeybindingsSpec>,
}

/// Editor context keybindings for text editing inside text areas.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiEditorKeymap {
    /// Insert a newline in the editor.
    pub insert_newline: Option<KeybindingsSpec>,
    /// Move cursor left by one grapheme.
    pub move_left: Option<KeybindingsSpec>,
    /// Move cursor right by one grapheme.
    pub move_right: Option<KeybindingsSpec>,
    /// Move cursor up one visual line.
    pub move_up: Option<KeybindingsSpec>,
    /// Move cursor down one visual line.
    pub move_down: Option<KeybindingsSpec>,
    /// Move cursor to beginning of previous word.
    pub move_word_left: Option<KeybindingsSpec>,
    /// Move cursor to end of next word.
    pub move_word_right: Option<KeybindingsSpec>,
    /// Move cursor to beginning of line.
    pub move_line_start: Option<KeybindingsSpec>,
    /// Move cursor to end of line.
    pub move_line_end: Option<KeybindingsSpec>,
    /// Delete one grapheme to the left.
    pub delete_backward: Option<KeybindingsSpec>,
    /// Delete one grapheme to the right.
    pub delete_forward: Option<KeybindingsSpec>,
    /// Delete the previous word.
    pub delete_backward_word: Option<KeybindingsSpec>,
    /// Delete the next word.
    pub delete_forward_word: Option<KeybindingsSpec>,
    /// Kill text from cursor to line start.
    pub kill_line_start: Option<KeybindingsSpec>,
    /// Kill the current line.
    pub kill_whole_line: Option<KeybindingsSpec>,
    /// Kill text from cursor to line end.
    pub kill_line_end: Option<KeybindingsSpec>,
    /// Yank the kill buffer.
    pub yank: Option<KeybindingsSpec>,
}

/// Vim normal-mode keybindings for modal editing inside text areas.
///
/// Actions that use uppercase letters (like `A` for append-line-end) should
/// be specified as `shift-a` in config; the runtime matcher handles
/// cross-terminal shift-reporting differences automatically.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimNormalKeymap {
    /// Enter insert mode at cursor (`i`).
    pub enter_insert: Option<KeybindingsSpec>,
    /// Enter insert mode after cursor (`a`).
    pub append_after_cursor: Option<KeybindingsSpec>,
    /// Enter insert mode at end of line (`A`).
    pub append_line_end: Option<KeybindingsSpec>,
    /// Enter insert mode at first non-blank of line (`I`).
    pub insert_line_start: Option<KeybindingsSpec>,
    /// Open a new line below and enter insert mode (`o`).
    pub open_line_below: Option<KeybindingsSpec>,
    /// Open a new line above and enter insert mode (`O`).
    pub open_line_above: Option<KeybindingsSpec>,
    /// Move cursor left (`h`).
    pub move_left: Option<KeybindingsSpec>,
    /// Move cursor right (`l`).
    pub move_right: Option<KeybindingsSpec>,
    /// Move cursor up (`k`), or recall older composer history at history boundaries.
    pub move_up: Option<KeybindingsSpec>,
    /// Move cursor down (`j`), or recall newer composer history at history boundaries.
    pub move_down: Option<KeybindingsSpec>,
    /// Move cursor to start of next word (`w`).
    pub move_word_forward: Option<KeybindingsSpec>,
    /// Move cursor to start of previous word (`b`).
    pub move_word_backward: Option<KeybindingsSpec>,
    /// Move cursor to end of current/next word (`e`).
    pub move_word_end: Option<KeybindingsSpec>,
    /// Move cursor to start of line (`0`).
    pub move_line_start: Option<KeybindingsSpec>,
    /// Move cursor to end of line (`$`).
    pub move_line_end: Option<KeybindingsSpec>,
    /// Delete character under cursor (`x`).
    pub delete_char: Option<KeybindingsSpec>,
    /// Delete character under cursor and enter insert mode (`s`).
    pub substitute_char: Option<KeybindingsSpec>,
    /// Delete from cursor to end of line (`D`).
    pub delete_to_line_end: Option<KeybindingsSpec>,
    /// Change from cursor to end of line and enter insert mode (`C`).
    pub change_to_line_end: Option<KeybindingsSpec>,
    /// Yank the entire line (`Y`).
    pub yank_line: Option<KeybindingsSpec>,
    /// Paste after cursor (`p`).
    pub paste_after: Option<KeybindingsSpec>,
    /// Begin delete operator; next key selects motion (`d`).
    pub start_delete_operator: Option<KeybindingsSpec>,
    /// Begin yank operator; next key selects motion (`y`).
    pub start_yank_operator: Option<KeybindingsSpec>,
    /// Begin change operator; next keys select a text object.
    pub start_change_operator: Option<KeybindingsSpec>,
    /// Cancel a pending operator and return to normal mode.
    pub cancel_operator: Option<KeybindingsSpec>,
}

/// Vim operator-pending keybindings for modal editing inside text areas.
///
/// This context is active only while waiting for a motion after `d` or `y`.
/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing
/// `Esc` cancels the pending operator and returns to normal mode without
/// modifying text.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimOperatorKeymap {
    /// Repeat delete operator to delete the whole line (`dd`).
    pub delete_line: Option<KeybindingsSpec>,
    /// Repeat yank operator to yank the whole line (`yy`).
    pub yank_line: Option<KeybindingsSpec>,
    /// Motion: left (`h`).
    pub motion_left: Option<KeybindingsSpec>,
    /// Motion: right (`l`).
    pub motion_right: Option<KeybindingsSpec>,
    /// Motion: up one line (`k`).
    pub motion_up: Option<KeybindingsSpec>,
    /// Motion: down one line (`j`).
    pub motion_down: Option<KeybindingsSpec>,
    /// Motion: to start of next word (`w`).
    pub motion_word_forward: Option<KeybindingsSpec>,
    /// Motion: to start of previous word (`b`).
    pub motion_word_backward: Option<KeybindingsSpec>,
    /// Motion: to end of current/next word (`e`).
    pub motion_word_end: Option<KeybindingsSpec>,
    /// Motion: to start of line (`0`).
    pub motion_line_start: Option<KeybindingsSpec>,
    /// Motion: to end of line (`$`).
    pub motion_line_end: Option<KeybindingsSpec>,
    /// Select an inner text object after an operator.
    pub select_inner_text_object: Option<KeybindingsSpec>,
    /// Select an around text object after an operator.
    pub select_around_text_object: Option<KeybindingsSpec>,
    /// Cancel the pending operator and return to normal mode.
    pub cancel: Option<KeybindingsSpec>,
}

/// Vim text-object keybindings for modal editing inside text areas.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimTextObjectKeymap {
    /// Text object: word.
    pub word: Option<KeybindingsSpec>,
    /// Text object: whitespace-delimited WORD.
    pub big_word: Option<KeybindingsSpec>,
    /// Text object: parentheses.
    pub parentheses: Option<KeybindingsSpec>,
    /// Text object: brackets.
    pub brackets: Option<KeybindingsSpec>,
    /// Text object: braces.
    pub braces: Option<KeybindingsSpec>,
    /// Text object: double quotes.
    pub double_quote: Option<KeybindingsSpec>,
    /// Text object: single quotes.
    pub single_quote: Option<KeybindingsSpec>,
    /// Text object: backticks.
    pub backtick: Option<KeybindingsSpec>,
    /// Cancel the pending text-object command.
    pub cancel: Option<KeybindingsSpec>,
}

/// Pager context keybindings for transcript and static overlays.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiPagerKeymap {
    /// Scroll up by one row.
    pub scroll_up: Option<KeybindingsSpec>,
    /// Scroll down by one row.
    pub scroll_down: Option<KeybindingsSpec>,
    /// Scroll up by one page.
    pub page_up: Option<KeybindingsSpec>,
    /// Scroll down by one page.
    pub page_down: Option<KeybindingsSpec>,
    /// Scroll up by half a page.
    pub half_page_up: Option<KeybindingsSpec>,
    /// Scroll down by half a page.
    pub half_page_down: Option<KeybindingsSpec>,
    /// Jump to the beginning.
    pub jump_top: Option<KeybindingsSpec>,
    /// Jump to the end.
    pub jump_bottom: Option<KeybindingsSpec>,
    /// Close the pager overlay.
    pub close: Option<KeybindingsSpec>,
    /// Close the transcript overlay via its dedicated toggle key.
    pub close_transcript: Option<KeybindingsSpec>,
}

/// List selection context keybindings for popup-style selectable lists.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiListKeymap {
    /// Move list selection up.
    pub move_up: Option<KeybindingsSpec>,
    /// Move list selection down.
    pub move_down: Option<KeybindingsSpec>,
    /// Move horizontally left in list pickers that support horizontal actions.
    pub move_left: Option<KeybindingsSpec>,
    /// Move horizontally right in list pickers that support horizontal actions.
    pub move_right: Option<KeybindingsSpec>,
    /// Move list selection up by one page.
    pub page_up: Option<KeybindingsSpec>,
    /// Move list selection down by one page.
    pub page_down: Option<KeybindingsSpec>,
    /// Jump to the first list item.
    pub jump_top: Option<KeybindingsSpec>,
    /// Jump to the last list item.
    pub jump_bottom: Option<KeybindingsSpec>,
    /// Accept current selection.
    pub accept: Option<KeybindingsSpec>,
    /// Cancel and close selection view.
    pub cancel: Option<KeybindingsSpec>,
}

/// Approval overlay keybindings.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiApprovalKeymap {
    /// Open the full-screen approval details view.
    pub open_fullscreen: Option<KeybindingsSpec>,
    /// Open the thread that requested approval when shown from another thread.
    pub open_thread: Option<KeybindingsSpec>,
    /// Approve the primary option.
    pub approve: Option<KeybindingsSpec>,
    /// Approve for session when that option exists.
    pub approve_for_session: Option<KeybindingsSpec>,
    /// Approve with exec-policy prefix when that option exists.
    pub approve_for_prefix: Option<KeybindingsSpec>,
    /// Deny without providing follow-up guidance.
    pub deny: Option<KeybindingsSpec>,
    /// Decline and provide corrective guidance.
    pub decline: Option<KeybindingsSpec>,
    /// Cancel an elicitation request.
    pub cancel: Option<KeybindingsSpec>,
}

/// Raw keymap configuration from `[tui.keymap]`.
///
/// Each context contains action-level overrides. Missing actions inherit from
/// built-in defaults, and selected chat/composer actions can fall back
/// through `global` during runtime resolution.
///
/// This type is intentionally a persistence shape, not the structure used by
/// input handlers. Runtime consumers should resolve it into
/// `RuntimeKeymap` first so precedence, empty-list unbinding, and duplicate-key
/// validation are applied consistently.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiKeymap {
    #[serde(default)]
    pub global: TuiGlobalKeymap,
    #[serde(default)]
    pub chat: TuiChatKeymap,
    #[serde(default)]
    pub composer: TuiComposerKeymap,
    #[serde(default)]
    pub editor: TuiEditorKeymap,
    #[serde(default)]
    pub vim_normal: TuiVimNormalKeymap,
    #[serde(default)]
    pub vim_operator: TuiVimOperatorKeymap,
    #[serde(default)]
    pub vim_text_object: TuiVimTextObjectKeymap,
    #[serde(default)]
    pub pager: TuiPagerKeymap,
    #[serde(default)]
    pub list: TuiListKeymap,
    #[serde(default)]
    pub approval: TuiApprovalKeymap,
}

/// Normalize one user-entered key spec into canonical storage format.
///
/// The output always orders modifiers as `ctrl-alt-shift-<key>` when present
/// and applies accepted aliases (`escape` -> `esc`, `pageup` -> `page-up`).
/// Inputs that cannot be represented unambiguously are rejected.
///
/// Normalization happens at config-deserialization time so downstream runtime
/// code only has to parse one spelling for each key. Callers should not bypass
/// this function when accepting user-authored key specs, or otherwise equivalent
/// keys can fail to compare equal in tests, UI hints, and duplicate detection.
fn normalize_keybinding_spec(raw: &str) -> Result<String, String> {
    let lower = raw.trim().to_ascii_lowercase();
    if lower.is_empty() {
        return Err(
            "keybinding cannot be empty. Use values like `ctrl-a` or `shift-enter`.\n\
See the Codex keymap documentation for supported actions and examples."
                .to_string(),
        );
    }

    let segments: Vec<&str> = lower
        .split('-')
        .filter(|segment| !segment.is_empty())
        .collect();
    if segments.is_empty() {
        return Err(format!(
            "invalid keybinding `{raw}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`."
        ));
    }

    let mut modifiers =
        BTreeMap::<&str, bool>::from([("ctrl", false), ("alt", false), ("shift", false)]);
    let mut key_segments = Vec::new();
    let mut saw_key = false;

    for segment in segments {
        let canonical_mod = match segment {
            "ctrl" | "control" => Some("ctrl"),
            "alt" | "option" => Some("alt"),
            "shift" => Some("shift"),
            _ => None,
        };

        if !saw_key && let Some(modifier) = canonical_mod {
            if modifiers.get(modifier).copied().unwrap_or(false) {
                return Err(format!(
                    "duplicate modifier in keybinding `{raw}`. Use each modifier at most once."
                ));
            }
            modifiers.insert(modifier, true);
            continue;
        }

        saw_key = true;
        key_segments.push(segment);
    }

    if key_segments.is_empty() {
        return Err(format!(
            "missing key in keybinding `{raw}`. Add a key name like `a`, `enter`, or `page-down`."
        ));
    }

    if key_segments
        .iter()
        .any(|segment| matches!(*segment, "ctrl" | "control" | "alt" | "option" | "shift"))
    {
        return Err(format!(
            "invalid keybinding `{raw}`: modifiers must come before the key (for example `ctrl-a`)."
        ));
    }

    let key = normalize_key_name(&key_segments.join("-"), raw)?;
    let mut normalized = Vec::new();
    if modifiers.get("ctrl").copied().unwrap_or(false) {
        normalized.push("ctrl".to_string());
    }
    if modifiers.get("alt").copied().unwrap_or(false) {
        normalized.push("alt".to_string());
    }
    if modifiers.get("shift").copied().unwrap_or(false) {
        normalized.push("shift".to_string());
    }
    normalized.push(key);
    Ok(normalized.join("-"))
}

/// Normalize and validate one key name segment.
///
/// This accepts a constrained key vocabulary to keep runtime parser behavior
/// deterministic across platforms.
fn normalize_key_name(key: &str, original: &str) -> Result<String, String> {
    let alias = match key {
        "escape" => "esc",
        "return" => "enter",
        "spacebar" => "space",
        "pgup" | "pageup" => "page-up",
        "pgdn" | "pagedown" => "page-down",
        "del" => "delete",
        other => other,
    };

    if alias.len() == 1 {
        let ch = alias.chars().next().unwrap_or_default();
        if ch.is_ascii() && !ch.is_ascii_control() && ch != '-' {
            return Ok(alias.to_string());
        }
    }

    if matches!(
        alias,
        "enter"
            | "tab"
            | "backspace"
            | "esc"
            | "delete"
            | "up"
            | "down"
            | "left"
            | "right"
            | "home"
            | "end"
            | "page-up"
            | "page-down"
            | "space"
            | "minus"
    ) {
        return Ok(alias.to_string());
    }

    if let Some(number) = alias.strip_prefix('f')
        && let Ok(number) = number.parse::<u8>()
        && (1..=MAX_FUNCTION_KEY).contains(&number)
    {
        return Ok(alias.to_string());
    }

    Err(format!(
        "unknown key `{key}` in keybinding `{original}`. \
Use a printable character (for example `a`), function keys (`f1`-`f{MAX_FUNCTION_KEY}`), \
or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space, minus.\n\
See the Codex keymap documentation for supported actions and examples."
    ))
}

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

    #[test]
    fn misplaced_action_at_keymap_root_is_rejected() {
        // Actions placed directly under [tui.keymap] instead of a context
        // sub-table (e.g. [tui.keymap.global]) must produce a parse error,
        // not be silently ignored.
        let toml_input = r#"
            open_transcript = "ctrl-s"
        "#;
        let result = toml::from_str::<TuiKeymap>(toml_input);
        assert!(
            result.is_err(),
            "expected error for action at keymap root, got: {result:?}"
        );
    }

    #[test]
    fn misspelled_action_under_context_is_rejected() {
        let toml_input = r#"
            [global]
            open_transcrip = "ctrl-x"
        "#;
        let err = toml::from_str::<TuiKeymap>(toml_input)
            .expect_err("expected unknown action under context");
        assert!(
            err.to_string().contains("open_transcrip"),
            "expected error to mention misspelled field, got: {err}"
        );
    }

    #[test]
    fn misspelled_vim_text_object_action_is_rejected() {
        let toml_input = r#"
            [vim_text_object]
            double_quotes = "shift-quote"
        "#;
        let err = toml::from_str::<TuiKeymap>(toml_input)
            .expect_err("expected unknown vim text object action");
        assert!(
            err.to_string().contains("double_quotes"),
            "expected error to mention misspelled field, got: {err}"
        );
    }

    #[test]
    fn removed_backtrack_actions_are_rejected() {
        for (context, action) in [
            ("global", "edit_previous_message"),
            ("global", "confirm_edit_previous_message"),
            ("chat", "edit_previous_message"),
            ("chat", "confirm_edit_previous_message"),
            ("pager", "edit_previous_message"),
            ("pager", "edit_next_message"),
            ("pager", "confirm_edit_message"),
        ] {
            let toml_input = format!(
                r#"
                [{context}]
                {action} = "ctrl-x"
                "#
            );
            let err = toml::from_str::<TuiKeymap>(&toml_input)
                .expect_err("expected removed backtrack action to be rejected");
            assert!(
                err.to_string().contains(action),
                "expected error to mention removed field {action}, got: {err}"
            );
        }
    }

    #[test]
    fn action_under_global_context_is_accepted() {
        let toml_input = r#"
            [global]
            open_transcript = "ctrl-s"
        "#;
        let keymap: TuiKeymap = toml::from_str(toml_input).expect("valid config");
        assert!(keymap.global.open_transcript.is_some());
    }

    #[test]
    fn minus_bindings_under_global_context_are_accepted() {
        for (spec, expected) in [
            (
                "minus",
                KeybindingsSpec::One(KeybindingSpec("minus".to_string())),
            ),
            (
                "alt-minus",
                KeybindingsSpec::One(KeybindingSpec("alt-minus".to_string())),
            ),
        ] {
            let toml_input = format!(
                r#"
                [global]
                open_transcript = "{spec}"
                "#
            );
            let keymap: TuiKeymap = toml::from_str(&toml_input).expect("valid config");
            let mut expected_keymap = TuiKeymap::default();
            expected_keymap.global.open_transcript = Some(expected);

            assert_eq!(keymap, expected_keymap);
        }
    }

    #[test]
    fn function_keys_through_f24_are_accepted() {
        assert_eq!(normalize_keybinding_spec("F13"), Ok("f13".to_string()));
        assert_eq!(normalize_keybinding_spec("f24"), Ok("f24".to_string()));
        assert!(normalize_keybinding_spec("f25").is_err());
    }
}
