import _ from 'lodash';
import '../../../assert/js/assert.js';
import '../../../globals.js';
// Copyright 2024, University of Colorado Boulder
/**
 * Manages hotkeys based on two sources:
 *
 * 1. Global hotkeys (from globalHotkeyRegistry)
 * 2. Hotkeys from the current focus trail (FocusManager.pdomFocusProperty, all hotkeys on all input listeners of
 *    nodes in the trail)
 *
 * Manages key press state using EnglishKey from globalKeyStateTracker.
 *
 * The "available" hotkeys are the union of the above two sources.
 *
 * The "enabled" hotkeys are the subset of available hotkeys whose enabledProperties are true.
 *
 * The "active" hotkeys are the subset of enabled hotkeys that are considered pressed. They will have fire-on-hold
 * behavior active.
 *
 * The set of enabled hotkeys determines the set of modifier keys that are considered "active" (in addition to
 * ctrl/alt/meta/shift, which are always included).
 *
 * @author Jesse Greenberg (PhET Interactive Simulations)
 * @author Jonathan Olson <jonathan.olson@colorado.edu>
 */
import { eventCodeToEnglishString, FocusManager, globalHotkeyRegistry, globalKeyStateTracker, KeyboardUtils, metaEnglishKeys, scenery } from '../imports.js';
import DerivedProperty from '../../../axon/js/DerivedProperty.js';
import TinyProperty from '../../../axon/js/TinyProperty.js';
const arrayComparator = (a, b) => {
    return a.length === b.length && a.every((element, index) => element === b[index]);
};
const setComparator = (a, b) => {
    return a.size === b.size && [...a].every(element => b.has(element));
};
class HotkeyManager {
    constructor() {
        // Enabled hotkeys that are either global, or under the current focus trail
        this.enabledHotkeysProperty = new TinyProperty([]);
        // The set of EnglishKeys that are currently pressed.
        this.englishKeysDown = new Set();
        // The current set of modifier keys (pressed or not) based on current enabled hotkeys
        // NOTE: Pressed modifier keys will prevent any other Hotkeys from becoming active. For example if you have a hotkey
        // with 'b+x', pressing 'b' will prevent any other hotkeys from becoming active.
        this.modifierKeys = [];
        // Hotkeys that are actively pressed
        this.activeHotkeys = new Set();
        this.availableHotkeysProperty = new DerivedProperty([
            globalHotkeyRegistry.hotkeysProperty,
            FocusManager.pdomFocusProperty
        ], (globalHotkeys, focus) => {
            const hotkeys = [];
            // If we have focus, include the hotkeys from the focus trail
            if (focus) {
                for (const node of focus.trail.nodes.slice().reverse()) {
                    if (!node.isInputEnabled()) {
                        break;
                    }
                    node.inputListeners.forEach(listener => {
                        listener.hotkeys?.forEach(hotkey => {
                            hotkeys.push(hotkey);
                        });
                    });
                }
            }
            // Always include global hotkeys. Use a set since we might have duplicates.
            hotkeys.push(...globalHotkeys);
            return _.uniq(hotkeys);
        }, {
            // We want to not over-notify, so we compare the sets directly
            valueComparisonStrategy: arrayComparator
        });
        // If any of the nodes in the focus trail change inputEnabled, we need to recompute availableHotkeysProperty
        const onInputEnabledChanged = () => {
            this.availableHotkeysProperty.recomputeDerivation();
        };
        FocusManager.pdomFocusProperty.link((focus, oldFocus) => {
            if (oldFocus) {
                oldFocus.trail.nodes.forEach(node => {
                    node.inputEnabledProperty.unlink(onInputEnabledChanged);
                });
            }
            if (focus) {
                focus.trail.nodes.forEach(node => {
                    node.inputEnabledProperty.lazyLink(onInputEnabledChanged);
                });
            }
        });
        // Update enabledHotkeysProperty when availableHotkeysProperty (or any enabledProperty or keyDescriptorProperty)
        // changes.
        const rebuildHotkeys = () => {
            const overriddenHotkeyStrings = new Set();
            const enabledHotkeys = [];
            for (const hotkey of this.availableHotkeysProperty.value) {
                if (hotkey.enabledProperty.value) {
                    // Each hotkey will have a canonical way to represent it, so we can check for duplicates when overridden.
                    // Catch shift+ctrl+c and ctrl+shift+c as the same hotkey.
                    const modifierKeys = hotkey.keyDescriptorProperty.value.modifierKeys;
                    const key = hotkey.keyDescriptorProperty.value.key;
                    const hotkeyCanonicalString = [
                        ...modifierKeys.slice().sort(),
                        key
                    ].join('+');
                    if (!overriddenHotkeyStrings.has(hotkeyCanonicalString)) {
                        enabledHotkeys.push(hotkey);
                        if (hotkey.override) {
                            overriddenHotkeyStrings.add(hotkeyCanonicalString);
                        }
                    }
                }
            }
            this.enabledHotkeysProperty.value = enabledHotkeys;
        };
        // Because we can't add duplicate listeners, we create extra closures to have a unique handle for each hotkey
        // eslint-disable-next-line comma-spacing, @stylistic/comma-spacing
        const hotkeyRebuildListenerMap = new Map();
        this.availableHotkeysProperty.link((newHotkeys, oldHotkeys) => {
            // Track whether any hotkeys changed. If none did, we don't need to rebuild.
            let hotkeysChanged = false;
            // Any old hotkeys and aren't in new hotkeys should be unlinked
            if (oldHotkeys) {
                for (const hotkey of oldHotkeys) {
                    if (!newHotkeys.includes(hotkey)) {
                        const listener = hotkeyRebuildListenerMap.get(hotkey);
                        hotkeyRebuildListenerMap.delete(hotkey);
                        assert && assert(listener);
                        hotkey.enabledProperty.unlink(listener);
                        hotkey.keyDescriptorProperty.unlink(listener);
                        hotkeysChanged = true;
                    }
                }
            }
            // Any new hotkeys that aren't in old hotkeys should be linked
            for (const hotkey of newHotkeys) {
                if (!oldHotkeys || !oldHotkeys.includes(hotkey)) {
                    // Unfortunate. Perhaps in the future we could have an abstraction that makes a "count" of how many times we
                    // are "listening" to a Property.
                    const listener = () => rebuildHotkeys();
                    hotkeyRebuildListenerMap.set(hotkey, listener);
                    hotkey.enabledProperty.lazyLink(listener);
                    hotkey.keyDescriptorProperty.lazyLink(listener);
                    hotkeysChanged = true;
                }
            }
            if (hotkeysChanged) {
                rebuildHotkeys();
            }
        });
        // Update modifierKeys and whether each hotkey is currently pressed. This is how hotkeys can have their state change
        // from either themselves (or other hotkeys with modifier keys) being added/removed from enabledHotkeys.
        this.enabledHotkeysProperty.link((newHotkeys, oldHotkeys) => {
            this.modifierKeys = _.uniq([
                ...metaEnglishKeys,
                // enabledHotkeysProperty is recomputed when the keyDescriptorProperty changes, so we can rely on the current
                // value of keyDescriptorProperty.
                ...[...newHotkeys].flatMap(hotkey => hotkey.keyDescriptorProperty.value.modifierKeys)
            ]);
            // Remove any hotkeys that are no longer available or enabled
            if (oldHotkeys) {
                for (const hotkey of oldHotkeys) {
                    if (!newHotkeys.includes(hotkey) && this.activeHotkeys.has(hotkey)) {
                        this.removeActiveHotkey(hotkey, null, false);
                    }
                }
            }
            // Re-check all hotkeys (since modifier keys might have changed, OR we need to validate that there are no conflicts).
            this.updateHotkeyStatus(null);
        });
        // Track key state changes
        globalKeyStateTracker.keyDownStateChangedEmitter.addListener((keyboardEvent) => {
            const englishKeysDown = globalKeyStateTracker.getEnglishKeysDown();
            const englishKeysChanged = !setComparator(this.englishKeysDown, englishKeysDown);
            if (englishKeysChanged) {
                this.englishKeysDown = englishKeysDown;
                this.updateHotkeyStatus(keyboardEvent);
            }
            else {
                // No keys changed, got the browser/OS "fire on hold". See what hotkeys have the browser fire-on-hold behavior.
                // Handle re-entrancy (if something changes the state of activeHotkeys)
                for (const hotkey of [...this.activeHotkeys]) {
                    if (hotkey.fireOnHold && hotkey.fireOnHoldTiming === 'browser') {
                        hotkey.fire(keyboardEvent);
                    }
                }
            }
        });
    }
    /**
     * Scenery-internal. If a Node along the focused trail changes its input listeners we need to manually recompute
     * the available hotkeys. There is no other way at this time to observe the input listeners of a Node. Otherwise,
     * the available hotkeys will be recomputed when focus changes, inputEnabledProperty changes for Nodes along the
     * trail, or when global hotkeys change.
     *
     * Called by Node.addInputListener/Node.removeInputListener.
     *
     * (scenery-internal)
     */
    updateHotkeysFromInputListenerChange(node) {
        if (FocusManager.pdomFocusProperty.value && FocusManager.pdomFocusProperty.value.trail.nodes.includes(node)) {
            this.availableHotkeysProperty.recomputeDerivation();
        }
    }
    /**
     * Given a main `key`, see if there is a hotkey that should be considered "active/pressed" for it.
     *
     * For a hotkey to be compatible, it needs to have:
     *
     * 1. Main key pressed
     * 2. All modifier keys in the hotkey's modifierKeys pressed
     * 3. All modifier keys not in the hotkey's modifierKeys (but in the other hotkeys above) not pressed
     */
    getHotkeysForMainKey(mainKey) {
        // If the main key isn't down, there's no way it could be active
        if (!this.englishKeysDown.has(mainKey)) {
            return [];
        }
        const compatibleKeys = [...this.enabledHotkeysProperty.value].filter(hotkey => {
            // Filter out hotkeys that don't have the main key
            const key = hotkey.keyDescriptorProperty.value.key;
            if (key !== mainKey) {
                return false;
            }
            // See whether the modifier keys match
            return this.modifierKeys.every(modifierKey => {
                const ignoredModifierKeys = hotkey.keyDescriptorProperty.value.ignoredModifierKeys;
                return this.englishKeysDown.has(modifierKey) === hotkey.keysProperty.value.includes(modifierKey) ||
                    ignoredModifierKeys.includes(modifierKey);
            });
        });
        if (assert) {
            const conflictingKeys = compatibleKeys.filter(hotkey => !hotkey.allowOverlap);
            assert && assert(conflictingKeys.length < 2, `Key conflict detected: ${conflictingKeys.map(hotkey => hotkey.keyDescriptorProperty.value.getHotkeyString())}`);
        }
        return compatibleKeys;
    }
    /**
     * Re-check all hotkey active/pressed states (since modifier keys might have changed, OR we need to validate that
     * there are no conflicts).
     */
    updateHotkeyStatus(keyboardEvent) {
        // For fireOnDown on/off cases, we only want to fire the hotkeys when we have a keyboard event specifying hotkey's
        // main `key`.
        const pressedOrReleasedKeyCode = KeyboardUtils.getEventCode(keyboardEvent);
        const pressedOrReleasedEnglishKey = pressedOrReleasedKeyCode ? eventCodeToEnglishString(pressedOrReleasedKeyCode) : null;
        for (const hotkey of this.enabledHotkeysProperty.value) {
            // A hotkey should be  active if its main key is pressed. If it was interrupted, it can only become
            // active again if there was an actual key press event from the user. If a Hotkey is interrupted during
            // a press, it should remain inactive and interrupted until the NEXT press.
            const key = hotkey.keyDescriptorProperty.value.key;
            const keyPressed = this.getHotkeysForMainKey(key).includes(hotkey);
            const notInterrupted = !hotkey.interrupted || (keyboardEvent && keyboardEvent.type === 'keydown');
            const shouldBeActive = keyPressed && notInterrupted;
            const isActive = this.activeHotkeys.has(hotkey);
            const descriptorKey = hotkey.keyDescriptorProperty.value.key;
            if (shouldBeActive && !isActive) {
                this.addActiveHotkey(hotkey, keyboardEvent, descriptorKey === pressedOrReleasedEnglishKey);
            }
            else if (!shouldBeActive && isActive) {
                this.removeActiveHotkey(hotkey, keyboardEvent, descriptorKey === pressedOrReleasedEnglishKey);
            }
        }
    }
    /**
     * Hotkey made active/pressed
     */
    addActiveHotkey(hotkey, keyboardEvent, triggeredFromPress) {
        this.activeHotkeys.add(hotkey);
        const shouldFire = triggeredFromPress && hotkey.fireOnDown;
        hotkey.onPress(keyboardEvent, shouldFire);
    }
    /**
     * Hotkey made inactive/released.
     */
    removeActiveHotkey(hotkey, keyboardEvent, triggeredFromRelease) {
        // Remove from activeHotkeys before Hotkey.onRelease so that we do not try to remove it again if there is
        // re-entrancy. This is possible if the release listener moves focus or interrupts a Hotkey.
        this.activeHotkeys.delete(hotkey);
        const shouldFire = triggeredFromRelease && !hotkey.fireOnDown;
        const interrupted = !triggeredFromRelease;
        hotkey.onRelease(keyboardEvent, interrupted, shouldFire);
    }
    /**
     * Called by Hotkey, removes the Hotkey from the active set when it is interrupted. The Hotkey cannot be active
     * again in this manager until there is an actual key press event from the user.
     */
    interruptHotkey(hotkey) {
        assert && assert(hotkey.isPressedProperty.value, 'hotkey must be pressed to be interrupted');
        this.removeActiveHotkey(hotkey, null, false);
    }
}
scenery.register('HotkeyManager', HotkeyManager);
const hotkeyManager = new HotkeyManager();
export default hotkeyManager;
//# sourceMappingURL=hotkeyManager.js.map