/**
 * @typedef {object} PaletteCommand~Command
 * @property {string|symbol} name - Name of command (print in palette after category name)
 * @property {string} [description] - Detail text for explain action of command
 * @property {function} action - Callback when user press `Enter` on command when selected
 */

/**
 * @callback PaletteCommand~ConstructorOptionsShortcut
 * @param {KeyboardEvent} event - keypress event from document
 * @return {boolean} - return true if keypress event is your shortcut
 */

/**
 * @callback PaletteCommand~ConstructorOptionsNavigation
 * @param {KeyboardEvent} event - keypress event from document
 * @this {PaletteCommand}
 */

/**
 * @typedef {object} PaletteCommand~ConstructorOptions
 * @property {string} [cssClass] - add if you want stylize the palette with your own css ;-)
 * @property {PaletteCommand~ConstructorOptionsShortcut} [isShortcut] - take a KeyboardEvent and return if right shortcut, by default the shortcut is ctrl-alt-p
 * @property {PaletteCommand~ConstructorOptionsNavigation} [navigationCallback] - take a KeyboardEvent and execute your navigation
 */

/**
 * @type {number}
 * @private
 */
let sequence = 0;

/**
 * @type {PaletteCommand~ConstructorOptions}
 * @private
 */
const defaultOptions = {
  isShortcut: event => event.ctrlKey && event.altKey && event.key === 'p',
  navigationCallback(event) {
    switch (event.key) {
      case 'ArrowDown':
        this.next();
        break;
      case 'ArrowUp':
        this.prev();
        break;
      case 'Enter':
        this.dispatch();
        break;
    }
  }
};

/**
 * @property {PaletteCommand~ConstructorOptions} options - Merged options (Object.assign) you give to constructor with default options
 */
class PaletteCommand {

  /**
   * @returns {number}
   * @private
   */
  static get _sequence() {
    return sequence++;
  }

  /**
   * Default options is litteraly
   *
   * ```javascript
   *
   * const defaultOptions = {
   *  isShortcut: event => event.ctrlKey && event.altKey && event.key === 'p',
   *  navigationCallback(event) {
   *    switch (event.key) {
   *      case 'ArrowDown':
   *        this.next();
   *        break;
   *      case 'ArrowUp':
   *        this.prev();
   *        break;
   *      case 'Enter':
   *        this.dispatch();
   *        break;
   *    }
   *  }
   * }
   * ```
   *
   * @param {PaletteCommand~ConstructorOptions} [options={}] - Options for palette
   *
   * @example {@lang javascript}
   * // add `cmd-palette--custom` class to classlist of root command-palette dom element
   * new PaletteCommand({
   *  cssClass: 'cmd-palette--custom'
   * });
   *
   * // use ctrl+alt+m ShortCut for display palette
   * new PaletteCommand({
   *  isShortcut: e => e.ctrlKey && e.altKey && e.key === 'm'
   * });
   *
   * // shift+ArrowDown or shift+ArrowUp for navigate 5 by 5
   * new PaletteCommand({
   *  navigationCallback(event) {
   *    switch(event.key) {
   *      case 'ArrowDown':
   *        event.shiftKey
   *          ? (this.next(), this.next(), this.next(), this.next(), this.next())
   *          : this.next();
   *        break;
   *      case 'ArrowUp':
   *        event.shiftKey
   *          ? (this.prev(), this.prev(), this.prev(), this.prev(), this.prev())
   *          : this.prev();
   *        break;
   *      case 'Enter':
   *        this.dispatch();
   *        break;
   *    }
   *  }
   * );
   */
  constructor(options = {}) {
    this.categories = {
      '': {}
    };
    this._id = PaletteCommand._sequence;
    this.options = Object.assign({}, defaultOptions, options);
    this._initEvent();
  }

  /**
   * If no category specified, merge commands in generic category
   * else replace existent category (if exist) with this set of commands
   *
   * @param {PaletteCommand~Command[]} commands
   * @param {string|symbol} [category='']
   *
   * @example {@lang javascript}
   * // add commands to default context
   * palette.setCategory([
   *  {
   *    name: 'foo',
   *    description: 'log `foo` in console'
   *    action() {
   *      console.log('foo');
   *    }
   *  }
   * ]);
   *
   * // replace `Bar` context
   * palette.setCategory([
   *  {
   *    name: 'foo',
   *    description: 'log `foo` in console'
   *    action() {
   *      console.log('foo');
   *    }
   *  }
   * ], 'Bar');
   */
  setCategory(commands = [], category = '') {
    const context = category ? {} : this.categories[category];
    commands.forEach(cmd => context[cmd.name] = cmd);

    this.categories[category] = context;
  }

  /**
   * @param {string|symbol} category
   */
  removeCategory(category) {
    if (!category) return;
    if (!category in this.categories) return;

    delete this.categories[category];
  }

  /**
   * Add list (or single) Command to the category
   *
   * @param {PaletteCommand~Command | PaletteCommand~Command[]} commands
   * @param {string|symbol} [category=''] - Generic category if not specified
   */
  addToCategory(commands, category = '') {
    this.categories[category] || (this.categories[category] = {});

    if (Array.isArray(commands)) {
      commands.forEach(command => this.categories[category][command.name] = command);
    } else {
      this.categories[category][commands.name] = commands;
    }
  }

  /**
   * @returns {HTMLElement}
   * @private
   */
  _findDom() {
    return document.getElementById(`cmd-palette-${this._id}`);
  }

  /**
   * @returns {string[]}
   * @private
   */
  _generateCommandDOM() {
    const commands = [];

    for (let [category, context] of Object.entries(this.categories)) {
      for (let [name, command] of Object.entries(context)) {
        commands.push({
          ...command, category, name,
          title: `${category ? `${category}: ` : ''}${name}`,
        });
      }
    }

    return commands
      .sort((a, b) => a.category.localeCompare(b.category) || a.title.localeCompare(b.title))
      .map((cmd, idx) => `
        <li data-category="${cmd.category}" data-name="${cmd.name}"${idx === 0 ? ' selected' : ''}>
          <span class="title">${cmd.title}</span>
          <span class="description">${cmd.description}</span>
        </li>
      `);
  }

  /**
   * @returns {HTMLElement}
   * @private
   */
  _generateDom() {
    document.body.insertAdjacentHTML('afterbegin', `
      <div id="cmd-palette-${this._id}" class="cmd-palette${this.options.cssClass ? ' ' + this.options.cssClass : ''} cmd-palette--hide">
        <input type="text" placeholder="Search Command">
        <ul>
          ${this._generateCommandDOM().join('\n')}
        </ul>
      </div>
    `);

    const dom = this._findDom();
    dom.querySelector('input').addEventListener('keyup', event => this.search(event.target.value));

    return dom;
  }

  /**
   * @returns {HTMLElement}
   * @private
   */
  _findOrGenerateDom() {
    return this._findDom() || this._generateDom();
  }

  /**
   * lazy load the command palette and display it
   */
  show() {
    const dom = this._findOrGenerateDom();
    dom.classList.replace('cmd-palette--hide', 'cmd-palette--show');
    dom.querySelector('input').focus();
  }

  /**
   * lazy load the command palette and hide it
   */
  hide() {
    const dom = this._findOrGenerateDom();
    dom.classList.replace('cmd-palette--show', 'cmd-palette--hide');
  }

  /**
   * lazy load the command palette and return if it display
   *
   * @returns {boolean}
   */
  isShow() {
    return this._findOrGenerateDom().classList.contains('cmd-palette--show');
  }

  /**
   * lazy load the command palette and return if it not display
   *
   * @returns {boolean}
   */
  isHide() {
    return this._findOrGenerateDom().classList.contains('cmd-palette--hide');
  }

  /**
   * return selected element in list of commands
   *
   * @returns {Element | null}
   * @private
   */
  _getSelectedDomItem() {
    return this._findOrGenerateDom().querySelector('li[selected]');
  }

  /**
   * lazy load the palette
   * and select next item in command list
   */
  next() {
    if (this.isHide()) return;

    const oldSelected = this._getSelectedDomItem();
    if (!oldSelected) return;
    let selected = oldSelected;

    while (selected.nextElementSibling) {
      selected = selected.nextElementSibling;

      if (selected.matches('.cmd-palette-item--hide')) continue;
      break;
    }

    oldSelected.removeAttribute('selected');
    selected.setAttribute('selected', 'selected');

    PaletteCommand._scrollTo(selected);
  }

  /**
   * lazy load the palette
   * and select previous item in command list
   */
  prev() {
    if (this.isHide()) return;

    const oldSelected = this._getSelectedDomItem();
    if (!oldSelected) return;
    let selected = oldSelected;

    while (selected.previousElementSibling) {
      selected = selected.previousElementSibling;

      if (selected.classList.contains('cmd-palette-item--hide')) continue;
      break;
    }

    oldSelected.removeAttribute('selected');
    selected.setAttribute('selected', 'selected');

    PaletteCommand._scrollTo(selected);
  }

  /**
   * @param {HTMLElement|Element} selected
   * @private
   */
  static _scrollTo(selected) {
    if (!selected) return;
    if (!selected.parentElement) return;

    const itemHeight = selected.clientHeight;
    const scrollTopSelectedTopMax = selected.offsetTop - itemHeight;

    selected.parentElement.scrollTop = scrollTopSelectedTopMax - (2 * itemHeight);
  }

  /**
   * fuzzy searching
   *
   * @param {string} search
   * @param {string} term
   * @returns {boolean}
   * @private
   */
  static _fuzzySearch(search, term) {
    let hay = term.toLowerCase(), i = 0, n = -1, l;
    search = search.toLowerCase();
    for (; l = search[i++];) if (!~(n = hay.indexOf(l, n + 1))) return false;
    return true;
  }

  /**
   * lazy load palette
   * and hide items whose not respect the fuzzySearch terms
   *
   * @param {string} text
   */
  search(text) {
    if (this.isHide()) return;
    text = text.trim();

    const dom = this._findOrGenerateDom();
    const items = [...dom.querySelectorAll('li')];

    if (!text) {
      items.forEach(li => li.classList.remove('cmd-palette-item--hide'));
    } else {
      items.forEach(li => {
        if (PaletteCommand._fuzzySearch(text, li.querySelector('.title').textContent)) {
          li.classList.remove('cmd-palette-item--hide');
        } else {
          li.classList.add('cmd-palette-item--hide');
        }
      });
    }

    dom.querySelector('li[selected].cmd-palette-item--hide') && this.prev();
    dom.querySelector('li[selected].cmd-palette-item--hide') && this.next();
  }

  /**
   * lazy load palette
   * and run selected command
   */
  dispatch() {
    if (this.isHide()) return;

    const dom = this._findOrGenerateDom();
    const item = dom.querySelector('li[selected]:not(.cmd-palette-item--hide)');
    if (!item) return;

    const {category = '', name} = item.dataset;
    const context = this.categories[category];
    if (!context) return;

    const command = context[name];
    if (!command) return;

    typeof command.action === 'function' && command.action();
    this.hide();
  }

  /**
   * place some events handler in document
   *
   * @private
   */
  _initEvent() {
    this._docClickHide = event => {
      if (event.target.matches('.cmd-palette') || event.target.closest('.cmd-palette')) return;

      this.hide();
    };

    this._docKeyPressShow = evt => this.options.isShortcut(evt) && this.show();
    this._docKeyPressNav = evt => {
      if (this.isHide()) return;

      this.options.navigationCallback.call(this, evt);
    };

    document.addEventListener('click', this._docClickHide);
    document.addEventListener('keypress', this._docKeyPressShow);
    document.addEventListener('keypress', this._docKeyPressNav);
  }

  /**
   * Remove PaletteCommand from DOM
   */
  destroy() {
    document.removeEventListener('click', this._docClickHide);
    document.removeEventListener('keypress', this._docKeyPressShow);
    document.removeEventListener('keypress', this._docKeyPressNav);

    const dom = this._findDom();
    dom.parentNode.removeChild(dom);
  }
}

export default PaletteCommand;