class Shortcut {


  /** @summary <h1>JavaScript keyboard shortcut singleton to handle key strokes</h1>
   * @author Arthur Beaulieu
   * @since September 2020
   * @description <blockquote>The Shortcut class provides a singleton object to set custom keyboard shortcuts for
   * web application. It allows to register key shortcuts using a string notation (ie. <code>Ctrl+A</code>). It also exposes
   * several methods to pause/resume the callback for a given event or for all of them. With the key string, you must provide
   * a callback function to react to this shortcut fire event. That's all folks! For source code, please go to
   * <a href="https://github.com/ArthurBeaulieu/Shortcut.js" alt="shortcut-js">Shortcut.js</a></blockquote>
   * @param {object} [options={}] - The Shortcut singleton options, not mandatory
   * @param {string} [options.keyEvent=keydown] - The key event to react from
   * @param {boolean} [options.autoRepeat=true] - For <code>keydown</code> and <code>keypress</code>, auto repeat event if key is held on pushed
   * @returns {object} - The Shortcut singleton instance */
  constructor(options = {}) {
    // If an instance of Shortcut already exists, we just return it
    if (Shortcut.instance) {
      return Shortcut.instance;
    }
    // Set object instance
    Shortcut.instance = this;
    // Prevent wrong type for arguments, fallback according to attribute utility
    if (typeof options.keyEvent !== 'string' || (['keydown', 'keyup', 'keypress'].indexOf(options.keyEvent) === -1)) {
      options.keyEvent = 'keydown';
    }
    if (typeof options.autoRepeat !== 'boolean') {
      options.autoRepeat = true;
    }
    if (typeof options.noPrevention !== 'boolean') {
      options.noPrevention = false;
    }
    /** @private
     * @member {string} - Key event to use on keyboard event listener */
    this._keyEvent = options.keyEvent;
    /** @private
     * @member {boolean} - The auto repeat of an event when key is held on push */
    this._autoRepeat = options.autoRepeat;
    /** @private
     * @member {boolean} - Do not call prevent default on key event flag */
    this._noPrevention = options.noPrevention;
    /** @private
     * @member {object[]} - Single key saved shortcuts */
    this._singleKey = [];
    /** @private
     * @member {object[]} - Multi keys saved shortcuts */
    this._multiKey = [];
    /** @public
     * @member {string} - Component version */
    this.version = '1.0.3';
    // Save singleton scope for testShortcuts method to be able to properly remove event on demand
    this._testShortcuts = this._testShortcuts.bind(this);
    // Retun singleton to the caller
    return this;
  }


  /** @method
   * @name destroy
   * @public
   * @memberof Shortcut
   * @description <blockquote>Shortcut destructor. Will delete singleton instance, its events and its properties.</blockquote> */
  destroy() {
    // Remove all existing eventListener
    this._removeEvents();
    // Delete object attributes
    Object.keys(this).forEach(key => {
      delete this[key];
    });
    // Clear singleton instance
    Shortcut.instance = null;
  }


  /*  --------------------------------------------------------------------------------------------------------------- */
  /*  --------------------------------------  SHORTCUT JS INTERN METHODS  ------------------------------------------  */
  /*                                                                                                                  */
  /*  The following methods are made to abstract the event listeners from the JavaScript layer, so you can easily     */
  /*  --------------------------------------------------------------------------------------------------------------- */


  /** @method
   * @name _addEvents
   * @private
   * @memberof Shortcut
   * @description <blockquote>Internal private method to subscribe to DOM key stroke event, depending on which
   * key event was set in constructor, or using the <code>updateKeyEvent()</code> method. The key event is then
   * calling back the <code>_testShortcuts()</code> method to check if any event has to be fired.</blockquote> */
  _addEvents() {
    // Listen to keyboard's event
    document.addEventListener(this._keyEvent, this._testShortcuts);
  }


  /** @method
   * @name _removeEvents
   * @private
   * @memberof Shortcut
   * @description <blockquote>Internal private method to revoke DOM subscribtion to a key stroke event, depending on which
   * key event was set in constructor, or using the <code>updateKeyEvent()</code> method. The key event won't then
   * call back the <code>_testShortcuts()</code> method.</blockquote> */
  _removeEvents() {
    // Revoke listener on keyboard's event
    document.removeEventListener(this._keyEvent, this._testShortcuts);
  }


  /** @method
   * @name _testShortcuts
   * @private
   * @memberof Shortcut
   * @description <blockquote>This method needs to be called when a key event has been detected. It takes as a parameters
   * the JavaScript event object, which contains several, required, information. It will then crawl the registered shortcuts to
   * check if one needs to be fired and call back the application. It handle both single key and multi key shortcuts. Finally,
   * it will not fire any event if the event <code>repeat</code> flag is at <code>true</code>, and the singleton is not in
   * auto repeat event.</blockquote>
   * @param {event} event - The keyboard event (<code>keydown</code>, <code>keypress</code> and <code>keyup</code>) */
  _testShortcuts(event) {
    // Avoid auto repeat event if singleton is configured this way
    if (this._autoRepeat === false && event.repeat === true) {
      event.preventDefault();
      return;
    }
    // Analyze event to check proper shortcut array
    if (event.ctrlKey || event.altKey || event.shiftKey) { // Multi key shortcut
      this._multiKeyEvent(event);
    } else { // Single key shortcut
      this._singleKeyEvent(event);
    }
  }


  /** @method
   * @name _singleKeyEvent
   * @private
   * @memberof Shortcut
   * @description <blockquote>This method will parse all single key events registered in its internal attributes, and will
   * fire the call back if its registered key matches the event key. It also prevent defaults on the event only if a match
   * is found to keep browser behavior in case there is no regstered shortcut.</blockquote>
   * @param {event} event - The keyboard event (<code>keydown</code>, <code>keypress</code> and <code>keyup</code>) */
  _singleKeyEvent(event) {
    // Iterate over registered single key shortcut to fire it if one matches
    for (let i = 0; i < this._singleKey.length; ++i) {
      // Check that event is active and flatten key string to compare
      if (!this._singleKey[i].pause && event.key.toLowerCase() === this._singleKey[i].key) {
        if (this._noPrevention === false) {
          event.preventDefault();
        }

        this._singleKey[i].fire(this);
      }
    }
  }


  /** @method
   * @name _multiKeyEvent
   * @private
   * @memberof Shortcut
   * @description <blockquote>This method will parse all multi keys events registered in its internal attributes, and will
   * fire the call back if its registered key matches the event key. Multi key events are made using ctrl, alt and shift modifiers.
   * It also prevent defaults on the event only if a match is found to keep browser behavior in case there is no regstered
   * shortcut.</blockquote>
   * @param {event} event - The keyboard event (<code>keydown</code>, <code>keypress</code> and <code>keyup</code>) */
  _multiKeyEvent(event) {
    for (let i = 0; i < this._multiKey.length; ++i) {
      // Handy shortcut variable to work with
      const sh = this._multiKey[i];
      // Check that event is active and flatten key string to compare
      if (!sh.pause && event.key.toLowerCase() === sh.key) {
        switch (sh.modifierCount) {
          case 1: // 2 key strokes
            if ((sh.modifiers.ctrlKey && event.ctrlKey)
            || (sh.modifiers.altKey && event.altKey)
            || (sh.modifiers.shiftKey && event.shiftKey)) {
              if (this._noPrevention === false) {
                event.preventDefault();
              }

              sh.fire();
            }
            break;
          case 2: // 3 key strokes
            if ((sh.modifiers.ctrlKey && event.ctrlKey && sh.modifiers.altKey && event.altKey)
            || (sh.modifiers.ctrlKey && event.ctrlKey && sh.modifiers.shiftKey && event.shiftKey)
            || (sh.modifiers.altKey && event.altKey && sh.modifiers.shiftKey && event.shiftKey)) {
              if (this._noPrevention === false) {
                event.preventDefault();
              }

              sh.fire();
            }
            break;
          case 3: // 4 key strokes
            if ((sh.modifiers.ctrlKey && event.ctrlKey
            && sh.modifiers.altKey && event.altKey
            && sh.modifiers.shiftKey && event.shiftKey)) {
              if (this._noPrevention === false) {
                event.preventDefault();
              }

              sh.fire();
            }
            break;
          default:
            break;
        }
      }
    }
  }


  /** @method
   * @name _getModifiersCount
   * @private
   * @memberof Shortcut
   * @description <blockquote>Shorthand method to count modifiers in a given shrotcut string. It uses regex that are
   * case insensitive to avoid multi testing (and because it's what this singleton do).</blockquote>
   * @param {string} keyString - The raw shortcut string that is defined when registering an event */
  _getModifiersCount(keyString) {
    // Build local modifiers count with regex
    const modifiers = {
      ctrlKey: /ctrl/i.test(keyString),
      altKey: /alt/i.test(keyString),
      shiftKey: /shift/i.test(keyString)
    };
    // Count modifiers that are set to true and update count with it
    let count = 0;
    Object.values(modifiers).reduce((a, item) => count = a + item, 0);
    // Return count value
    return count;
  }


  /** @method
   * @name _setAllPauseFlag
   * @private
   * @memberof Shortcut
   * @description <blockquote>Parse all registered event and make them listen or not to any key event.</blockquote>
   * @param {boolean} value - The state to set, to pause/resume all registered shortcuts */
  _setAllPauseFlag(value) {
    // Iterate over both arays to update pause flag on each registered shortcut
    for (let i = 0; i < this._singleKey.length; ++i) {
      this._setOnePauseFlag(this._singleKey[i].keyString, value);
    }

    for (let i = 0; i < this._multiKey.length; ++i) {
      this._setOnePauseFlag(this._multiKey[i].keyString, value);
    }
  }


  /** @method
   * @name _setOnePauseFlag
   * @private
   * @memberof Shortcut
   * @description <blockquote>Parse all registered event and make the one that matches the key string listen or not
   * to any key event.</blockquote>
   * @param {string} keyString - The raw shortcut string that is defined when registering an event
   * @param {boolean} value - The state to set, to pause/resume all registered shortcuts */
  _setOnePauseFlag(keyString, value) {
    if (this._getModifiersCount(keyString) === 0) {
      for (let i = 0; i < this._singleKey.length; ++i) {
        if (this._singleKey[i].keyString === keyString) {
          this._singleKey[i].pause = value;
        }
      }
    } else {
      for (let i = 0; i < this._multiKey.length; ++i) {
        if (this._multiKey[i].keyString === keyString) {
          this._multiKey[i].pause = value;
        }
      }
    }
  }


  /** @method
   * @name _shortcutAlreadyExist
   * @private
   * @memberof Shortcut
   * @description <blockquote>Internal method to test if a given key string isn't already related to a registered
   * shortcut.</blockquote>
   * @param {string} keyString - The raw shortcut string that is defined when registering an event
   * @returns {boolean} - The existence state of given key string */
  _shortcutAlreadyExist(keyString) {
    // Parse single or multi shortcuts depending on modifiers count to find maching one
    if (this._getModifiersCount(keyString) === 0) {
      for (let i = 0; i < this._singleKey.length; ++i) {
        if (this._singleKey[i].keyString === keyString) {
          return true;
        }
      }
    } else {
      for (let i = 0; i < this._multiKey.length; ++i) {
        if (this._multiKey[i].keyString === keyString) {
          return true;
        }
      }
    }
    // False by default to allow the shortcut creation
    return false;
  }


  /*  --------------------------------------------------------------------------------------------------------------- */
  /*  --------------------------------------  SHORTCUT JS PUBLIC METHOD  -------------------------------------------  */
  /*                                                                                                                  */
  /*  The following methods are made to register shortcut, to remove them, or to pause/resume all shortcuts.          */
  /*  --------------------------------------------------------------------------------------------------------------- */


  /** @method
   * @name register
   * @public
   * @memberof Shortcut
   * @description <blockquote>This method is the entry point to register a shortcut. The caller must send the key
   * comibination as a string (ie. <code>F</code>, <code>Ctrl+shift+r</code>). The Shortcut singleton is case insensitive,
   * which means you can write it with the case you want. For modifiers, please use <code>ctrl</code>, <code>alt</code> and
   * <code>shift</code> strings. Then the given callback will be called each time the key stroke matches the shortcut key
   * string. For fine tuning on auto repeat, see constructor options.</blockquote>
   * @param {string} keyString - The raw shortcut string that is defined when registering an event
   * @param {function} fire - The callback to attach to the shortcut */
  register(keyString, fire) {
    if (typeof keyString !== 'string' || typeof fire !== 'function') {
      return;
    }

    if (!this._shortcutAlreadyExist(keyString)) {
      // First shortcut to be registered ; listen to keyboard key down event
      if (this._singleKey.length === 0 && this._multiKey.length === 0) {
        this._addEvents();
      }
      // New shortcut internals
      const shortcut = {
        keyString: keyString,
        modifiers: { // Regex insensitive to string case to search for modifiers
          ctrlKey: /ctrl/i.test(keyString),
          altKey: /alt/i.test(keyString),
          shiftKey: /shift/i.test(keyString)
        },
        modifierCount: this._getModifiersCount(keyString),
        key: keyString.toLowerCase().replace('ctrl', '').replace('alt', '').replace('shift', '').replaceAll(' ', '').replaceAll('+', ''),
        paused: false,
        fire: fire
      };
      // Save shortcut to its appropriated array
      if (this._getModifiersCount(keyString) === 0) {
        this._singleKey.push(shortcut);
      } else {
        this._multiKey.push(shortcut);
      }
    }
  }


  /** @method
   * @name remove
   * @public
   * @memberof Shortcut
   * @description <blockquote>This method will remove a registered shortcut using its key string.</blockquote>
   * @param {string} keyString - The raw shortcut string that is defined when registering an event */
  remove(keyString) {
    if (typeof keyString !== 'string') {
      return;
    }
    // Reverse parsing to ensure proper slicing of shortcut arrays
    if (this._getModifiersCount(keyString) === 0) {
      for (let i = this._singleKey.length - 1; i >= 0; i--) {
        if (this._singleKey[i].keyString === keyString) {
          this._singleKey.splice(i, 1);
        }
      }
    } else {
      for (let i = this._multiKey.length - 1; i >= 0; i--) {
        if (this._multiKey[i].keyString === keyString) {
          this._multiKey.splice(i, 1);
        }
      }
    }
    // In case there are no remaining shortcut, we remove listener on keyboard's event
    if (this._singleKey.length === 0 && this._multiKey.length === 0) {
      this._removeEvents();
    }
  }


  /** @method
   * @name removeAll
   * @public
   * @memberof Shortcut
   * @description <blockquote>This method will remove all registered shortcut events.</blockquote> */
  removeAll() {
    // Clear all saved shortcut
    this._singleKey = [];
    this._multiKey = [];
    // Remove listener on keyboard's key down
    this._removeEvents();
  }


  /** @method
   * @name pause
   * @public
   * @memberof Shortcut
   * @description <blockquote>This method will pause a registered shortcut using its key string. The
   * shortcut won't then fire the callback when the shortcut is used.</blockquote>
   * @param {string} keyString - The raw shortcut string that is defined when registering an event */
  pause(keyString) {
    if (typeof keyString !== 'string') {
      return;
    }

    this._setOnePauseFlag(keyString, true);
  }


  /** @method
   * @name resume
   * @public
   * @memberof Shortcut
   * @description <blockquote>This method will resume a registered shortcut using its key string. The
   * shortcut will then fire the callback when the shortcut is used.</blockquote>
   * @param {string} keyString - The raw shortcut string that is defined when registering an event */
  resume(keyString) {
    if (typeof keyString !== 'string') {
      return;
    }

    this._setOnePauseFlag(keyString, false);
  }


  /** @method
   * @name pauseAll
   * @public
   * @memberof Shortcut
   * @description <blockquote>This method will pause all registered shortcuts.</blockquote>
   * @param {string} keyString - The raw shortcut string that is defined when registering an event */
  pauseAll() {
    this._setAllPauseFlag(true);
  }


  /** @method
   * @name resumeAll
   * @public
   * @memberof Shortcut
   * @description <blockquote>This method will resume all registered shortcuts.</blockquote>
   * @param {string} keyString - The raw shortcut string that is defined when registering an event */
  resumeAll() {
    this._setAllPauseFlag(false);
  }


  /** @method
   * @name updateKeyEvent
   * @public
   * @memberof Shortcut
   * @description <blockquote>This method will update the singleton's listener for a given keyboard event.</blockquote>
   * @param {string} keyEvent - The key event to apply to the DOM listener in <code>keydown</code>, <code>keypress</code> and <code>keyup</code> */
  updateKeyEvent(keyEvent) {
    // Prevent wrong type or un-existing key event
    if (typeof keyEvent !== 'string' || (['keydown', 'keyup', 'keypress'].indexOf(keyEvent) === -1)) {
      keyEvent = 'keydown';
    }
    // Key event actual update
    this._removeEvents(); // Remove previous key event value and shortcut listener
    this._keyEvent = keyEvent; // Update private attribute
    this._addEvents(); // Restore shortcut listening with knew key event
  }


  /** @method
   * @name updateAutoRepeat
   * @public
   * @memberof Shortcut
   * @description <blockquote>This method will update the auto repeat flag that makes an event continuously callback
   * when the key is hed pushed.</blockquote>
   * @param {boolean} autoRepeat - The auto repeat state to set */
  updateAutoRepeat(autoRepeat) {
    // Prevent wrong type for input
    if (typeof autoRepeat !== 'boolean') {
      autoRepeat = true;
    }
    // Update private attribute
    this._autoRepeat = autoRepeat;
  }


}


export default Shortcut;