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

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::{EXIT_SKETCH_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);

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 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,
    SelectAll,
    DeleteSelection,
    ZoomFit,
    OpenShortcutBar,
    Quit,
    EnterSketch,
    SmartDimension,
    Trim,
    Extend,
    Mirror,
    ToggleConstruction,
}

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

pub const COMMANDS: &[Command] = &[
    Command {
        action: EXIT_SKETCH_ACTION,
        kind: None,
        scope: HotkeyScope::Sketch,
        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: 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: &[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: &[],
    },
];

#[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()
        .map(|cmd| RemapEntry {
            action: cmd.action,
            scope: cmd.scope,
            label: cmd.label,
            default_chord: cmd.defaults.first().copied(),
        })
        .collect()
}

#[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 inner.scope != HotkeyScope::Sketch {
            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, EXIT_SKETCH_ACTION, EXTEND_ACTION,
        HotkeyOverrides, 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, ZOOM_FIT_ACTION, compose_table, default_bindings, remap_entries,
    };
    use bone_ui::hotkey::{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(EXIT_SKETCH_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<_>>();
        [
            EXIT_SKETCH_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,
        ]
        .iter()
        .for_each(|a| assert!(actions.contains(a), "missing remappable: {a:?}"));
    }

    #[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; SolidWorks stock has no default for it"
            );
        });
    }

    #[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",
        );
    }
}
