/**
 * A set of helpers and management functions for dealing with user input from keyboard events.
 * {@link https://keycode.info/}
 */
class KeyboardManager {
  constructor() {
    this._reset();
  }

  /* -------------------------------------------- */

  /**
   * Begin listening to keyboard events.
   * @internal
   */
  _activateListeners() {
    window.addEventListener("keydown", event => this._handleKeyboardEvent(event, false));
    window.addEventListener("keyup", event => this._handleKeyboardEvent(event, true));
    window.addEventListener("visibilitychange", this._reset.bind(this));
    window.addEventListener("compositionend", this._onCompositionEnd.bind(this));
    window.addEventListener("focusin", this._onFocusIn.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * The set of key codes which are currently depressed (down)
   * @type {Set<string>}
   */
  downKeys = new Set();

  /* -------------------------------------------- */

  /**
   * The set of movement keys which were recently pressed
   * @type {Set<string>}
   */
  moveKeys = new Set();

  /* -------------------------------------------- */

  /**
   * Allowed modifier keys
   * @enum {string}
   */
  static MODIFIER_KEYS = {
    CONTROL: "Control",
    SHIFT: "Shift",
    ALT: "Alt"
  };

  /* -------------------------------------------- */

  /**
   * Track which KeyboardEvent#code presses associate with each modifier
   * @enum {string[]}
   */
  static MODIFIER_CODES = {
    [this.MODIFIER_KEYS.ALT]: ["AltLeft", "AltRight"],
    [this.MODIFIER_KEYS.CONTROL]: ["ControlLeft", "ControlRight", "MetaLeft", "MetaRight", "Meta", "OsLeft", "OsRight"],
    [this.MODIFIER_KEYS.SHIFT]: ["ShiftLeft", "ShiftRight"]
  };

  /* -------------------------------------------- */

  /**
   * Key codes which are "protected" and should not be used because they are reserved for browser-level actions.
   * @type {string[]}
   */
  static PROTECTED_KEYS = ["F5", "F11", "F12", "PrintScreen", "ScrollLock", "NumLock", "CapsLock"];

  /* -------------------------------------------- */

  /**
   * The OS-specific string display for what their Command key is
   * @type {string}
   */
  static CONTROL_KEY_STRING = navigator.appVersion.includes("Mac") ? "⌘" : "Control";

  /* -------------------------------------------- */

  /**
   * A special mapping of how special KeyboardEvent#code values should map to displayed strings or symbols.
   * Values in this configuration object override any other display formatting rules which may be applied.
   * @type {Record<string, string>}
   */
  static KEYCODE_DISPLAY_MAPPING = (() => {
    const isMac = navigator.appVersion.includes("Mac");
    return {
      ArrowLeft: isMac ? "←" : "🡸",
      ArrowRight: isMac ? "→" : "🡺",
      ArrowUp: isMac ? "↑" : "🡹",
      ArrowDown: isMac ? "↓" : "🡻",
      Backquote: "`",
      Backslash: "\\",
      BracketLeft: "[",
      BracketRight: "]",
      Comma: ",",
      Control: this.CONTROL_KEY_STRING,
      Equal: "=",
      Meta: isMac ? "⌘" : "⊞",
      MetaLeft: isMac ? "⌘" : "⊞",
      MetaRight: isMac ? "⌘" : "⊞",
      OsLeft: isMac ? "⌘" : "⊞",
      OsRight: isMac ? "⌘" : "⊞",
      Minus: "-",
      NumpadAdd: "Numpad+",
      NumpadSubtract: "Numpad-",
      Period: ".",
      Quote: "'",
      Semicolon: ";",
      Slash: "/"
    };
  })();

  /* -------------------------------------------- */

  /**
   * Test whether an HTMLElement currently has focus.
   * If so we normally don't want to process keybinding actions.
   * @type {boolean}
   */
  get hasFocus() {
    return document.querySelector(":focus") instanceof HTMLElement;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Emulates a key being pressed, triggering the Keyboard event workflow.
   * @param {boolean} up                        If True, emulates the `keyup` Event. Else, the `keydown` event
   * @param {string} code                       The KeyboardEvent#code which is being pressed
   * @param {object} [options]                  Additional options to configure behavior.
   * @param {boolean} [options.altKey=false]    Emulate the ALT modifier as pressed
   * @param {boolean} [options.ctrlKey=false]   Emulate the CONTROL modifier as pressed
   * @param {boolean} [options.shiftKey=false]  Emulate the SHIFT modifier as pressed
   * @param {boolean} [options.repeat=false]    Emulate this as a repeat event
   * @param {boolean} [options.force=false]     Force the event to be handled.
   * @returns {KeyboardEventContext}
   */
  static emulateKeypress(up, code, {altKey=false, ctrlKey=false, shiftKey=false, repeat=false, force=false}={}) {
    const event = new KeyboardEvent(`key${up ? "up" : "down"}`, {code, altKey, ctrlKey, shiftKey, repeat});
    const context = this.getKeyboardEventContext(event, up);
    game.keyboard._processKeyboardContext(context, {force});
    game.keyboard.downKeys.delete(context.key);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Format a KeyboardEvent#code into a displayed string.
   * @param {string} code       The input code
   * @returns {string}          The displayed string for this code
   */
  static getKeycodeDisplayString(code) {
    if ( code in this.KEYCODE_DISPLAY_MAPPING ) return this.KEYCODE_DISPLAY_MAPPING[code];
    if ( code.startsWith("Digit") ) return code.replace("Digit", "");
    if ( code.startsWith("Key") ) return code.replace("Key", "");
    return code;
  }

  /* -------------------------------------------- */

  /**
   * Get a standardized keyboard context for a given event.
   * Every individual keypress is uniquely identified using the KeyboardEvent#code property.
   * A list of possible key codes is documented here: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values
   *
   * @param {KeyboardEvent} event   The originating keypress event
   * @param {boolean} up            A flag for whether the key is down or up
   * @return {KeyboardEventContext} The standardized context of the event
   */
  static getKeyboardEventContext(event, up=false) {
    let context = {
      event: event,
      key: event.code,
      isShift: event.shiftKey,
      isControl: event.ctrlKey || event.metaKey,
      isAlt: event.altKey,
      hasModifier: event.shiftKey || event.ctrlKey || event.metaKey || event.altKey,
      modifiers: [],
      up: up,
      repeat: event.repeat
    };
    if ( context.isShift ) context.modifiers.push(this.MODIFIER_KEYS.SHIFT);
    if ( context.isControl ) context.modifiers.push(this.MODIFIER_KEYS.CONTROL);
    if ( context.isAlt ) context.modifiers.push(this.MODIFIER_KEYS.ALT);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Report whether a modifier in KeyboardManager.MODIFIER_KEYS is currently actively depressed.
   * @param {string} modifier     A modifier in MODIFIER_KEYS
   * @returns {boolean}           Is this modifier key currently down (active)?
   */
  isModifierActive(modifier) {
    return this.constructor.MODIFIER_CODES[modifier].some(k => this.downKeys.has(k));
  }

  /* -------------------------------------------- */

  /**
   * Report whether a core action key is currently actively depressed.
   * @param {string} action       The core action to verify (ex: "target")
   * @returns {boolean}           Is this core action key currently down (active)?
   */
  isCoreActionKeyActive(action) {
    const binds = game.keybindings.get("core", action);
    return !!binds?.some(k => this.downKeys.has(k.key));
  }

  /* -------------------------------------------- */

  /**
   * Converts a Keyboard Context event into a string representation, such as "C" or "Control+C"
   * @param {KeyboardEventContext} context  The standardized context of the event
   * @param {boolean} includeModifiers      If True, includes modifiers in the string representation
   * @return {string}
   * @private
   */
  static _getContextDisplayString(context, includeModifiers = true) {
    const parts = [this.getKeycodeDisplayString(context.key)];
    if ( includeModifiers && context.hasModifier ) {
      if ( context.isShift && context.event.key !== "Shift" ) parts.unshift(this.MODIFIER_KEYS.SHIFT);
      if ( context.isControl && context.event.key !== "Control" ) parts.unshift(this.MODIFIER_KEYS.CONTROL);
      if ( context.isAlt && context.event.key !== "Alt" ) parts.unshift(this.MODIFIER_KEYS.ALT);
    }
    return parts.join("+");
  }

  /* ----------------------------------------- */

  /**
   * Given a standardized pressed key, find all matching registered Keybind Actions.
   * @param {KeyboardEventContext} context  A standardized keyboard event context
   * @return {KeybindingAction[]}           The matched Keybind Actions. May be empty.
   * @internal
   */
  static _getMatchingActions(context) {
    let possibleMatches = game.keybindings.activeKeys.get(context.key) ?? [];
    if ( CONFIG.debug.keybindings ) console.dir(possibleMatches);
    return possibleMatches.filter(action => KeyboardManager._testContext(action, context));
  }

  /* -------------------------------------------- */

  /**
   * Test whether a keypress context matches the registration for a keybinding action
   * @param {KeybindingAction} action             The keybinding action
   * @param {KeyboardEventContext} context        The keyboard event context
   * @returns {boolean}                           Does the context match the action requirements?
   * @private
   */
  static _testContext(action, context) {
    if ( context.repeat && !action.repeat ) return false;
    if ( action.restricted && !game.user.isGM ) return false;

    // If the context includes no modifiers, we match if the binding has none
    if ( !context.hasModifier ) return action.requiredModifiers.length === 0;

    // Test that modifiers match expectation
    const modifiers = this.MODIFIER_KEYS;
    const activeModifiers = {
      [modifiers.CONTROL]: context.isControl,
      [modifiers.SHIFT]: context.isShift,
      [modifiers.ALT]: context.isAlt
    };
    for (let [k, v] of Object.entries(activeModifiers)) {

      // Ignore exact matches to a modifier key
      if ( this.MODIFIER_CODES[k].includes(context.key) ) continue;

      // Verify that required modifiers are present
      if ( action.requiredModifiers.includes(k) ) {
        if ( !v ) return false;
      }

      // No unsupported modifiers can be present for a "down" event
      else if ( !context.up && !action.optionalModifiers.includes(k) && v ) return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Given a registered Keybinding Action, executes the action with a given event and context
   *
   * @param {KeybindingAction} keybind         The registered Keybinding action to execute
   * @param {KeyboardEventContext} context     The gathered context of the event
   * @return {boolean}                         Returns true if the keybind was consumed
   * @private
   */
  static _executeKeybind(keybind, context) {
    if ( CONFIG.debug.keybindings ) console.log("Executing " + game.i18n.localize(keybind.name));
    context.action = keybind.action;
    let consumed = false;
    if ( context.up && keybind.onUp ) consumed = keybind.onUp(context);
    else if ( !context.up && keybind.onDown ) consumed = keybind.onDown(context);
    return consumed;
  }

  /* -------------------------------------------- */

  /**
   * Processes a keyboard event context, checking it against registered keybinding actions
   * @param {KeyboardEventContext} context   The keyboard event context
   * @param {object} [options]               Additional options to configure behavior.
   * @param {boolean} [options.force=false]  Force the event to be handled.
   * @protected
   */
  _processKeyboardContext(context, {force=false}={}) {

    // Track the current set of pressed keys
    if ( context.up ) this.downKeys.delete(context.key);
    else this.downKeys.add(context.key);

    // If an input field has focus, don't process Keybinding Actions
    if ( this.hasFocus && !force ) return;

    // Open debugging group
    if ( CONFIG.debug.keybindings ) {
      console.group(`[${context.up ? 'UP' : 'DOWN'}] Checking for keybinds that respond to ${context.modifiers}+${context.key}`);
      console.dir(context);
    }

    // Check against registered Keybindings
    const actions = KeyboardManager._getMatchingActions(context);
    if (actions.length === 0) {
      if ( CONFIG.debug.keybindings ) {
        console.log("No matching keybinds");
        console.groupEnd();
      }
      return;
    }

    // Execute matching Keybinding Actions to see if any consume the event
    let handled;
    for ( const action of actions ) {
      handled = KeyboardManager._executeKeybind(action, context);
      if ( handled ) break;
    }

    // Cancel event since we handled it
    if ( handled && context.event ) {
      if ( CONFIG.debug.keybindings ) console.log("Event was consumed");
      context.event?.preventDefault();
      context.event?.stopPropagation();
    }
    if ( CONFIG.debug.keybindings ) console.groupEnd();
  }

  /* -------------------------------------------- */

  /**
   * Reset tracking for which keys are in the down and released states
   * @private
   */
  _reset() {
    this.downKeys = new Set();
    this.moveKeys = new Set();
  }

  /* -------------------------------------------- */

  /**
   * Emulate a key-up event for any currently down keys. When emulating, we go backwards such that combinations such as
   * "CONTROL + S" emulate the "S" first in order to capture modifiers.
   * @param {object} [options]              Options to configure behavior.
   * @param {boolean} [options.force=true]  Force the keyup events to be handled.
   */
  releaseKeys({force=true}={}) {
    const reverseKeys = Array.from(this.downKeys).reverse();
    for ( const key of reverseKeys ) {
      this.constructor.emulateKeypress(true, key, {
        force,
        ctrlKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.CONTROL),
        shiftKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.SHIFT),
        altKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.ALT)
      });
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle a key press into the down position
   * @param {KeyboardEvent} event   The originating keyboard event
   * @param {boolean} up            A flag for whether the key is down or up
   * @private
   */
  _handleKeyboardEvent(event, up) {
    if ( event.isComposing ) return; // Ignore IME composition
    if ( !event.key && !event.code ) return; // Some browsers fire keyup and keydown events when autocompleting values.
    let context = KeyboardManager.getKeyboardEventContext(event, up);
    this._processKeyboardContext(context);
  }

  /* -------------------------------------------- */

  /**
   * Input events do not fire with isComposing = false at the end of a composition event in Chrome
   * See: https://github.com/w3c/uievents/issues/202
   * @param {CompositionEvent} event
   */
  _onCompositionEnd(event) {
    return this._handleKeyboardEvent(event, false);
  }

  /* -------------------------------------------- */

  /**
   * Release any down keys when focusing a form element.
   * @param {FocusEvent} event  The focus event.
   * @protected
   */
  _onFocusIn(event) {
    const formElements = [
      HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLOptionElement, HTMLButtonElement
    ];
    if ( event.target.isContentEditable || formElements.some(cls => event.target instanceof cls) ) this.releaseKeys();
  }
}
