use core::num::NonZeroU32;
use std::collections::BTreeMap;

use bone_types::StandardView;
use bone_ui::hotkey::{
    ActionId, HotkeyBinding, HotkeyScope, HotkeyTable, HotkeyTableError, KeyChord,
};
use bone_ui::input::{KeyChar, KeyCode, ModifierMask, NamedKey};
use bone_ui::strings::StringKey;
use serde::{Deserialize, Serialize};

use crate::sketch_mode::{ESCAPE_ACTION, REDO_ACTION, UNDO_ACTION};
use crate::strings as s;

const fn action_id(value: u32) -> ActionId {
    let Some(nz) = NonZeroU32::new(value) else {
        panic!("ActionId must be non-zero");
    };
    ActionId::new(nz)
}

pub const ENTER_SKETCH_ACTION: ActionId = action_id(4);
pub const SMART_DIMENSION_ACTION: ActionId = action_id(5);
pub const TRIM_ACTION: ActionId = action_id(6);
pub const EXTEND_ACTION: ActionId = action_id(7);
pub const MIRROR_ACTION: ActionId = action_id(8);
pub const TOGGLE_CONSTRUCTION_ACTION: ActionId = action_id(9);
pub const NEW_DOCUMENT_ACTION: ActionId = action_id(10);
pub const OPEN_DOCUMENT_ACTION: ActionId = action_id(11);
pub const SAVE_DOCUMENT_ACTION: ActionId = action_id(12);
pub const SELECT_ALL_ACTION: ActionId = action_id(13);
pub const DELETE_SELECTION_ACTION: ActionId = action_id(14);
pub const ZOOM_FIT_ACTION: ActionId = action_id(15);
pub const OPEN_SHORTCUT_BAR_ACTION: ActionId = action_id(16);
pub const QUIT_ACTION: ActionId = action_id(17);
pub const IMPORT_STEP_ACTION: ActionId = action_id(18);
pub const EXPORT_STEP_ACTION: ActionId = action_id(19);
pub const VIEW_FRONT_ACTION: ActionId = action_id(20);
pub const VIEW_BACK_ACTION: ActionId = action_id(21);
pub const VIEW_LEFT_ACTION: ActionId = action_id(22);
pub const VIEW_RIGHT_ACTION: ActionId = action_id(23);
pub const VIEW_TOP_ACTION: ActionId = action_id(24);
pub const VIEW_BOTTOM_ACTION: ActionId = action_id(25);
pub const VIEW_ISOMETRIC_ACTION: ActionId = action_id(26);
pub const VIEW_NORMAL_TO_ACTION: ActionId = action_id(27);
pub const VIEW_SELECTOR_ACTION: ActionId = action_id(28);
pub const VIEW_CUBE_ACTION: ActionId = action_id(29);
pub const REBUILD_ACTION: ActionId = action_id(30);
pub const FORCE_REBUILD_ACTION: ActionId = action_id(31);

const fn ch(c: char) -> KeyCode {
    KeyCode::Char(KeyChar::from_ascii(c))
}

const fn named(k: NamedKey) -> KeyCode {
    KeyCode::Named(k)
}

const ESC: KeyChord = KeyChord::new(named(NamedKey::Escape), ModifierMask::NONE);
const CTRL_Z: KeyChord = KeyChord::new(ch('z'), ModifierMask::CTRL);
const CTRL_SHIFT_Z: KeyChord =
    KeyChord::new(ch('z'), ModifierMask::CTRL.union(ModifierMask::SHIFT));
const CTRL_Y: KeyChord = KeyChord::new(ch('y'), ModifierMask::CTRL);
const CTRL_N: KeyChord = KeyChord::new(ch('n'), ModifierMask::CTRL);
const CTRL_O: KeyChord = KeyChord::new(ch('o'), ModifierMask::CTRL);
const CTRL_S: KeyChord = KeyChord::new(ch('s'), ModifierMask::CTRL);
const CTRL_A: KeyChord = KeyChord::new(ch('a'), ModifierMask::CTRL);
const CTRL_Q: KeyChord = KeyChord::new(ch('q'), ModifierMask::CTRL);
const CTRL_B: KeyChord = KeyChord::new(ch('b'), ModifierMask::CTRL);
const CTRL_I: KeyChord = KeyChord::new(ch('i'), ModifierMask::CTRL);
const CTRL_E: KeyChord = KeyChord::new(ch('e'), ModifierMask::CTRL);
const CTRL_1: KeyChord = KeyChord::new(ch('1'), ModifierMask::CTRL);
const CTRL_2: KeyChord = KeyChord::new(ch('2'), ModifierMask::CTRL);
const CTRL_3: KeyChord = KeyChord::new(ch('3'), ModifierMask::CTRL);
const CTRL_4: KeyChord = KeyChord::new(ch('4'), ModifierMask::CTRL);
const CTRL_5: KeyChord = KeyChord::new(ch('5'), ModifierMask::CTRL);
const CTRL_6: KeyChord = KeyChord::new(ch('6'), ModifierMask::CTRL);
const CTRL_7: KeyChord = KeyChord::new(ch('7'), ModifierMask::CTRL);
const CTRL_8: KeyChord = KeyChord::new(ch('8'), ModifierMask::CTRL);
const SPACE: KeyChord = KeyChord::new(named(NamedKey::Space), ModifierMask::NONE);
const CTRL_SPACE: KeyChord = KeyChord::new(named(NamedKey::Space), ModifierMask::CTRL);
const DELETE: KeyChord = KeyChord::new(named(NamedKey::Delete), ModifierMask::NONE);
const F_KEY: KeyChord = KeyChord::new(ch('f'), ModifierMask::NONE);
const S_KEY: KeyChord = KeyChord::new(ch('s'), ModifierMask::NONE);

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum HotkeyCommand {
    Undo,
    Redo,
    NewDocument,
    OpenDocument,
    SaveDocument,
    ImportStep,
    ExportStep,
    SelectAll,
    DeleteSelection,
    ZoomFit,
    OpenShortcutBar,
    Quit,
    RebuildChanged,
    ForceRebuild,
    EnterSketch,
    SmartDimension,
    Trim,
    Extend,
    Mirror,
    ToggleConstruction,
    StandardView(StandardView),
    ToggleViewSelector,
    ToggleViewCube,
}

#[derive(Copy, Clone, Debug)]
pub struct Command {
    pub action: ActionId,
    pub kind: Option<HotkeyCommand>,
    pub scope: HotkeyScope,
    pub label: StringKey,
    pub defaults: &'static [KeyChord],
}

const fn view_command(
    action: ActionId,
    view: StandardView,
    label: StringKey,
    defaults: &'static [KeyChord],
) -> Command {
    Command {
        action,
        kind: Some(HotkeyCommand::StandardView(view)),
        scope: HotkeyScope::Global,
        label,
        defaults,
    }
}

pub const COMMANDS: &[Command] = &[
    Command {
        action: ESCAPE_ACTION,
        kind: None,
        scope: HotkeyScope::Sketch,
        label: s::HOTKEY_LABEL_ESCAPE,
        defaults: &[ESC],
    },
    Command {
        action: ESCAPE_ACTION,
        kind: None,
        scope: HotkeyScope::Extrude,
        label: s::HOTKEY_LABEL_ESCAPE,
        defaults: &[ESC],
    },
    Command {
        action: UNDO_ACTION,
        kind: Some(HotkeyCommand::Undo),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_UNDO,
        defaults: &[CTRL_Z],
    },
    Command {
        action: REDO_ACTION,
        kind: Some(HotkeyCommand::Redo),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_REDO,
        defaults: &[CTRL_Y, CTRL_SHIFT_Z],
    },
    Command {
        action: NEW_DOCUMENT_ACTION,
        kind: Some(HotkeyCommand::NewDocument),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_NEW,
        defaults: &[CTRL_N],
    },
    Command {
        action: OPEN_DOCUMENT_ACTION,
        kind: Some(HotkeyCommand::OpenDocument),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_OPEN,
        defaults: &[CTRL_O],
    },
    Command {
        action: SAVE_DOCUMENT_ACTION,
        kind: Some(HotkeyCommand::SaveDocument),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_SAVE,
        defaults: &[CTRL_S],
    },
    Command {
        action: IMPORT_STEP_ACTION,
        kind: Some(HotkeyCommand::ImportStep),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_IMPORT,
        defaults: &[CTRL_I],
    },
    Command {
        action: EXPORT_STEP_ACTION,
        kind: Some(HotkeyCommand::ExportStep),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_EXPORT,
        defaults: &[CTRL_E],
    },
    Command {
        action: SELECT_ALL_ACTION,
        kind: Some(HotkeyCommand::SelectAll),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_SELECT_ALL,
        defaults: &[CTRL_A],
    },
    Command {
        action: DELETE_SELECTION_ACTION,
        kind: Some(HotkeyCommand::DeleteSelection),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_DELETE_SELECTION,
        defaults: &[DELETE],
    },
    Command {
        action: ZOOM_FIT_ACTION,
        kind: Some(HotkeyCommand::ZoomFit),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_ZOOM_FIT,
        defaults: &[F_KEY],
    },
    Command {
        action: OPEN_SHORTCUT_BAR_ACTION,
        kind: Some(HotkeyCommand::OpenShortcutBar),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_SHORTCUT_BAR,
        defaults: &[S_KEY],
    },
    Command {
        action: QUIT_ACTION,
        kind: Some(HotkeyCommand::Quit),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_QUIT,
        defaults: &[],
    },
    Command {
        action: REBUILD_ACTION,
        kind: Some(HotkeyCommand::RebuildChanged),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_REBUILD,
        defaults: &[CTRL_B],
    },
    Command {
        action: FORCE_REBUILD_ACTION,
        kind: Some(HotkeyCommand::ForceRebuild),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_FORCE_REBUILD,
        defaults: &[CTRL_Q],
    },
    Command {
        action: ENTER_SKETCH_ACTION,
        kind: Some(HotkeyCommand::EnterSketch),
        scope: HotkeyScope::Global,
        label: s::HOTKEY_LABEL_SKETCH,
        defaults: &[],
    },
    Command {
        action: SMART_DIMENSION_ACTION,
        kind: Some(HotkeyCommand::SmartDimension),
        scope: HotkeyScope::Sketch,
        label: s::HOTKEY_LABEL_SMART_DIMENSION,
        defaults: &[],
    },
    Command {
        action: TRIM_ACTION,
        kind: Some(HotkeyCommand::Trim),
        scope: HotkeyScope::Sketch,
        label: s::HOTKEY_LABEL_TRIM,
        defaults: &[],
    },
    Command {
        action: EXTEND_ACTION,
        kind: Some(HotkeyCommand::Extend),
        scope: HotkeyScope::Sketch,
        label: s::HOTKEY_LABEL_EXTEND,
        defaults: &[],
    },
    Command {
        action: MIRROR_ACTION,
        kind: Some(HotkeyCommand::Mirror),
        scope: HotkeyScope::Sketch,
        label: s::HOTKEY_LABEL_MIRROR,
        defaults: &[],
    },
    Command {
        action: TOGGLE_CONSTRUCTION_ACTION,
        kind: Some(HotkeyCommand::ToggleConstruction),
        scope: HotkeyScope::Sketch,
        label: s::HOTKEY_LABEL_CONSTRUCTION_TOGGLE,
        defaults: &[],
    },
    view_command(
        VIEW_FRONT_ACTION,
        StandardView::Front,
        s::VIEW_FRONT,
        &[CTRL_1],
    ),
    view_command(
        VIEW_BACK_ACTION,
        StandardView::Back,
        s::VIEW_BACK,
        &[CTRL_2],
    ),
    view_command(
        VIEW_LEFT_ACTION,
        StandardView::Left,
        s::VIEW_LEFT,
        &[CTRL_3],
    ),
    view_command(
        VIEW_RIGHT_ACTION,
        StandardView::Right,
        s::VIEW_RIGHT,
        &[CTRL_4],
    ),
    view_command(VIEW_TOP_ACTION, StandardView::Top, s::VIEW_TOP, &[CTRL_5]),
    view_command(
        VIEW_BOTTOM_ACTION,
        StandardView::Bottom,
        s::VIEW_BOTTOM,
        &[CTRL_6],
    ),
    view_command(
        VIEW_ISOMETRIC_ACTION,
        StandardView::Isometric,
        s::VIEW_ISOMETRIC,
        &[CTRL_7],
    ),
    view_command(
        VIEW_NORMAL_TO_ACTION,
        StandardView::NormalTo,
        s::VIEW_NORMAL_TO,
        &[CTRL_8],
    ),
    Command {
        action: VIEW_SELECTOR_ACTION,
        kind: Some(HotkeyCommand::ToggleViewSelector),
        scope: HotkeyScope::Global,
        label: s::VIEW_SELECTOR,
        defaults: &[SPACE],
    },
    Command {
        action: VIEW_CUBE_ACTION,
        kind: Some(HotkeyCommand::ToggleViewCube),
        scope: HotkeyScope::Global,
        label: s::VIEW_CUBE,
        defaults: &[CTRL_SPACE],
    },
];

#[must_use]
pub fn command_for_action(action: ActionId) -> Option<HotkeyCommand> {
    COMMANDS
        .iter()
        .find(|c| c.action == action)
        .and_then(|c| c.kind)
}

#[must_use]
pub fn label_for_command(kind: HotkeyCommand) -> StringKey {
    COMMANDS
        .iter()
        .find(|c| c.kind == Some(kind))
        .map_or(s::HOTKEY_UNBOUND_LABEL, |c| c.label)
}

#[must_use]
pub fn accelerator_label(action: ActionId, overrides: &HotkeyOverrides) -> Option<String> {
    let from_override = overrides.lookup(action);
    let from_default = COMMANDS
        .iter()
        .find(|c| c.action == action)
        .and_then(|c| c.defaults.first().copied());
    from_override
        .or(from_default)
        .map(|chord| chord.to_string())
}

#[cfg(test)]
#[must_use]
fn default_bindings() -> Vec<HotkeyBinding> {
    COMMANDS
        .iter()
        .flat_map(|cmd| {
            cmd.defaults
                .iter()
                .copied()
                .map(move |chord| HotkeyBinding::new(chord, cmd.scope, cmd.action))
        })
        .collect()
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct RemapEntry {
    pub action: ActionId,
    pub scope: HotkeyScope,
    pub label: StringKey,
    pub default_chord: Option<KeyChord>,
}

#[must_use]
pub fn remap_entries() -> Vec<RemapEntry> {
    COMMANDS.iter().fold(Vec::new(), |mut acc, cmd| {
        if !acc
            .iter()
            .any(|entry: &RemapEntry| entry.action == cmd.action)
        {
            acc.push(RemapEntry {
                action: cmd.action,
                scope: cmd.scope,
                label: cmd.label,
                default_chord: cmd.defaults.first().copied(),
            });
        }
        acc
    })
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HotkeyOverrides {
    entries: BTreeMap<ActionId, KeyChord>,
}

impl HotkeyOverrides {
    pub fn set(&mut self, action: ActionId, chord: KeyChord) {
        self.entries.insert(action, chord);
    }

    #[must_use]
    pub fn lookup(&self, action: ActionId) -> Option<KeyChord> {
        self.entries.get(&action).copied()
    }
}

pub fn compose_table(overrides: &HotkeyOverrides) -> Result<HotkeyTable, HotkeyTableError> {
    let bindings: Vec<HotkeyBinding> = COMMANDS
        .iter()
        .flat_map(|cmd| {
            let chords: Vec<KeyChord> = overrides
                .lookup(cmd.action)
                .map_or_else(|| cmd.defaults.to_vec(), |chord| vec![chord]);
            chords
                .into_iter()
                .map(move |chord| HotkeyBinding::new(chord, cmd.scope, cmd.action))
        })
        .collect();
    reject_cross_scope_shadow(&bindings)?;
    HotkeyTable::try_from_bindings(bindings)
}

fn reject_cross_scope_shadow(bindings: &[HotkeyBinding]) -> Result<(), HotkeyTableError> {
    bindings.iter().try_for_each(|inner| {
        if !matches!(inner.scope, HotkeyScope::Sketch | HotkeyScope::Extrude) {
            return Ok(());
        }
        let outer = bindings
            .iter()
            .find(|other| other.scope == HotkeyScope::Global && other.chord == inner.chord);
        match outer {
            None => Ok(()),
            Some(other) => Err(HotkeyTableError::Conflict {
                chord: inner.chord,
                scope: inner.scope,
                existing: other.action,
                attempted: inner.action,
            }),
        }
    })
}

#[cfg(test)]
mod tests {
    use super::{
        DELETE_SELECTION_ACTION, ENTER_SKETCH_ACTION, ESCAPE_ACTION, EXPORT_STEP_ACTION,
        EXTEND_ACTION, HotkeyOverrides, IMPORT_STEP_ACTION, MIRROR_ACTION, NEW_DOCUMENT_ACTION,
        OPEN_DOCUMENT_ACTION, OPEN_SHORTCUT_BAR_ACTION, QUIT_ACTION, REDO_ACTION,
        SAVE_DOCUMENT_ACTION, SELECT_ALL_ACTION, SMART_DIMENSION_ACTION,
        TOGGLE_CONSTRUCTION_ACTION, TRIM_ACTION, UNDO_ACTION, VIEW_BACK_ACTION, VIEW_BOTTOM_ACTION,
        VIEW_CUBE_ACTION, VIEW_FRONT_ACTION, VIEW_ISOMETRIC_ACTION, VIEW_LEFT_ACTION,
        VIEW_NORMAL_TO_ACTION, VIEW_RIGHT_ACTION, VIEW_SELECTOR_ACTION, VIEW_TOP_ACTION,
        ZOOM_FIT_ACTION, compose_table, default_bindings, remap_entries,
    };
    use bone_ui::hotkey::{ActionId, HotkeyScope, HotkeyScopes, KeyChord};
    use bone_ui::input::{KeyChar, KeyCode, ModifierMask, NamedKey};

    fn ch(c: char) -> KeyCode {
        KeyCode::Char(KeyChar::from_char(c))
    }

    fn scopes() -> HotkeyScopes {
        HotkeyScopes::from_outer_to_inner([HotkeyScope::Global, HotkeyScope::Sketch])
    }

    #[test]
    fn default_table_snapshot_pins_action_chord_scope() {
        let mut rendered = default_bindings()
            .into_iter()
            .map(|b| {
                format!(
                    "action={} chord={} scope={:?}",
                    b.action.get().get(),
                    KeyChord::new(b.chord.key, b.chord.modifiers),
                    b.scope,
                )
            })
            .collect::<Vec<_>>();
        rendered.sort();
        insta::assert_snapshot!("default_hotkey_table", rendered.join("\n"));
    }

    #[test]
    fn override_replaces_chord_for_action() {
        let mut overrides = HotkeyOverrides::default();
        let new_chord = KeyChord::new(ch('q'), ModifierMask::SHIFT);
        overrides.set(SMART_DIMENSION_ACTION, new_chord);
        let Ok(table) = compose_table(&overrides) else {
            panic!("non-conflicting override must compose");
        };
        assert_eq!(
            table.dispatch(new_chord, &scopes()),
            Some(SMART_DIMENSION_ACTION)
        );
    }

    #[test]
    fn empty_overrides_match_defaults() {
        let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
            panic!("defaults must compose");
        };
        let chord = KeyChord::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE);
        assert_eq!(table.dispatch(chord, &scopes()), Some(ESCAPE_ACTION));
    }

    #[test]
    fn redo_has_two_default_chords() {
        let redo = default_bindings()
            .into_iter()
            .filter(|b| b.action == REDO_ACTION)
            .count();
        assert_eq!(redo, 2, "Ctrl+Y and Ctrl+Shift+Z both bind redo");
    }

    #[test]
    fn remap_entries_cover_every_command() {
        let actions = remap_entries()
            .into_iter()
            .map(|e| e.action)
            .collect::<std::collections::BTreeSet<_>>();
        [
            ESCAPE_ACTION,
            UNDO_ACTION,
            REDO_ACTION,
            ENTER_SKETCH_ACTION,
            SMART_DIMENSION_ACTION,
            TRIM_ACTION,
            EXTEND_ACTION,
            MIRROR_ACTION,
            TOGGLE_CONSTRUCTION_ACTION,
            NEW_DOCUMENT_ACTION,
            OPEN_DOCUMENT_ACTION,
            SAVE_DOCUMENT_ACTION,
            SELECT_ALL_ACTION,
            DELETE_SELECTION_ACTION,
            ZOOM_FIT_ACTION,
            OPEN_SHORTCUT_BAR_ACTION,
            QUIT_ACTION,
            IMPORT_STEP_ACTION,
            EXPORT_STEP_ACTION,
            VIEW_FRONT_ACTION,
            VIEW_BACK_ACTION,
            VIEW_LEFT_ACTION,
            VIEW_RIGHT_ACTION,
            VIEW_TOP_ACTION,
            VIEW_BOTTOM_ACTION,
            VIEW_ISOMETRIC_ACTION,
            VIEW_NORMAL_TO_ACTION,
            VIEW_SELECTOR_ACTION,
            VIEW_CUBE_ACTION,
        ]
        .iter()
        .for_each(|a| assert!(actions.contains(a), "missing remappable: {a:?}"));
    }

    #[test]
    fn ctrl_digits_dispatch_standard_views() {
        let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
            panic!("defaults must compose");
        };
        let expected: [(char, ActionId); 8] = [
            ('1', VIEW_FRONT_ACTION),
            ('2', VIEW_BACK_ACTION),
            ('3', VIEW_LEFT_ACTION),
            ('4', VIEW_RIGHT_ACTION),
            ('5', VIEW_TOP_ACTION),
            ('6', VIEW_BOTTOM_ACTION),
            ('7', VIEW_ISOMETRIC_ACTION),
            ('8', VIEW_NORMAL_TO_ACTION),
        ];
        expected.iter().for_each(|(digit, action)| {
            let chord = KeyChord::new(ch(*digit), ModifierMask::CTRL);
            assert_eq!(table.dispatch(chord, &scopes()), Some(*action));
        });
    }

    #[test]
    fn space_chords_dispatch_view_surfaces() {
        let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
            panic!("defaults must compose");
        };
        let space = KeyChord::new(KeyCode::Named(NamedKey::Space), ModifierMask::NONE);
        let ctrl_space = KeyChord::new(KeyCode::Named(NamedKey::Space), ModifierMask::CTRL);
        assert_eq!(table.dispatch(space, &scopes()), Some(VIEW_SELECTOR_ACTION));
        assert_eq!(
            table.dispatch(ctrl_space, &scopes()),
            Some(VIEW_CUBE_ACTION)
        );
    }

    #[test]
    fn ctrl_e_dispatches_export_inside_sketch_scope() {
        let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
            panic!("defaults must compose");
        };
        let ctrl_e = KeyChord::new(ch('e'), ModifierMask::CTRL);
        assert_eq!(table.dispatch(ctrl_e, &scopes()), Some(EXPORT_STEP_ACTION));
    }

    #[test]
    fn sketch_tool_actions_unbound_by_default() {
        let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
            panic!("defaults compose");
        };
        let only_sketch = HotkeyScopes::from_outer_to_inner([HotkeyScope::Sketch]);
        let all = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global, HotkeyScope::Sketch]);
        [
            TRIM_ACTION,
            EXTEND_ACTION,
            MIRROR_ACTION,
            TOGGLE_CONSTRUCTION_ACTION,
            SMART_DIMENSION_ACTION,
            ENTER_SKETCH_ACTION,
        ]
        .iter()
        .for_each(|action| {
            let bound_in_sketch = ['d', 't', 'e', 'm', 'g', 's'].iter().any(|c| {
                let chord = KeyChord::new(ch(*c), ModifierMask::NONE);
                table.dispatch(chord, &only_sketch) == Some(*action)
                    || table.dispatch(chord, &all) == Some(*action)
            });
            assert!(
                !bound_in_sketch,
                "{action:?} must ship unbound with no stock default"
            );
        });
    }

    #[test]
    fn override_conflicting_with_default_is_rejected() {
        let mut overrides = HotkeyOverrides::default();
        let save_chord = KeyChord::new(ch('s'), ModifierMask::CTRL);
        overrides.set(NEW_DOCUMENT_ACTION, save_chord);
        assert!(compose_table(&overrides).is_err());
    }

    #[test]
    fn override_replaces_all_defaults() {
        let mut overrides = HotkeyOverrides::default();
        let new_redo = KeyChord::new(ch('u'), ModifierMask::CTRL);
        overrides.set(REDO_ACTION, new_redo);
        let Ok(table) = compose_table(&overrides) else {
            panic!("non-conflicting override must compose");
        };
        let ctrl_y = KeyChord::new(ch('y'), ModifierMask::CTRL);
        let ctrl_shift_z = KeyChord::new(ch('z'), ModifierMask::CTRL.union(ModifierMask::SHIFT));
        assert_eq!(table.dispatch(new_redo, &scopes()), Some(REDO_ACTION));
        assert_eq!(
            table.dispatch(ctrl_y, &scopes()),
            None,
            "default Ctrl+Y must be dropped after override",
        );
        assert_eq!(
            table.dispatch(ctrl_shift_z, &scopes()),
            None,
            "default Ctrl+Shift+Z must be dropped after override",
        );
    }

    #[test]
    fn override_equal_to_default_replaces_other_defaults() {
        let mut overrides = HotkeyOverrides::default();
        let ctrl_y = KeyChord::new(ch('y'), ModifierMask::CTRL);
        overrides.set(REDO_ACTION, ctrl_y);
        let Ok(table) = compose_table(&overrides) else {
            panic!("override matching a default must compose");
        };
        let ctrl_shift_z = KeyChord::new(ch('z'), ModifierMask::CTRL.union(ModifierMask::SHIFT));
        assert_eq!(table.dispatch(ctrl_y, &scopes()), Some(REDO_ACTION));
        assert_eq!(
            table.dispatch(ctrl_shift_z, &scopes()),
            None,
            "non-overridden default must also drop when any override is set",
        );
    }

    #[test]
    fn sketch_override_shadowing_global_is_rejected() {
        let mut overrides = HotkeyOverrides::default();
        let ctrl_s = KeyChord::new(ch('s'), ModifierMask::CTRL);
        overrides.set(SMART_DIMENSION_ACTION, ctrl_s);
        assert!(
            compose_table(&overrides).is_err(),
            "sketch-scope override must not shadow a Global-scope default",
        );
    }
}
