/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* import-globals-from aboutaddonsCommon.js */

"use strict";

const { AppConstants } = ChromeUtils.importESModule(
  "resource://gre/modules/AppConstants.sys.mjs"
);

ChromeUtils.defineESModuleGetters(this, {
  ExtensionShortcutKeyMap: "resource://gre/modules/ExtensionShortcuts.sys.mjs",
  ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
});

{
  const FALLBACK_ICON = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
  const COLLAPSE_OPTIONS = {
    limit: 5, // We only want to show 5 when collapsed.
    allowOver: 1, // Avoid collapsing to hide 1 row.
  };

  let templatesLoaded = false;
  let shortcutKeyMap = new ExtensionShortcutKeyMap();
  const templates = {};

  function loadTemplates() {
    if (templatesLoaded) {
      return;
    }
    templatesLoaded = true;

    templates.view = document.getElementById("shortcut-view");
    templates.card = document.getElementById("shortcut-card-template");
    templates.row = document.getElementById("shortcut-row-template");
    templates.noAddons = document.getElementById("shortcuts-no-addons");
    templates.expandRow = document.getElementById("expand-row-template");
    templates.noShortcutAddons = document.getElementById(
      "shortcuts-no-commands-template"
    );
  }

  function extensionForAddonId(id) {
    let policy = WebExtensionPolicy.getByID(id);
    return policy && policy.extension;
  }

  let builtInNames = new Map([
    ["_execute_action", "shortcuts-browserAction2"],
    ["_execute_browser_action", "shortcuts-browserAction2"],
    ["_execute_page_action", "shortcuts-pageAction"],
    ["_execute_sidebar_action", "shortcuts-sidebarAction"],
  ]);
  let getCommandDescriptionId = command => {
    if (!command.description && builtInNames.has(command.name)) {
      return builtInNames.get(command.name);
    }
    return null;
  };

  const _functionKeys = [
    "F1",
    "F2",
    "F3",
    "F4",
    "F5",
    "F6",
    "F7",
    "F8",
    "F9",
    "F10",
    "F11",
    "F12",
    "F13",
    "F14",
    "F15",
    "F16",
    "F17",
    "F18",
    "F19",
  ];
  const functionKeys = new Set(_functionKeys);
  const validKeys = new Set([
    "Home",
    "End",
    "PageUp",
    "PageDown",
    "Insert",
    "Delete",
    "0",
    "1",
    "2",
    "3",
    "4",
    "5",
    "6",
    "7",
    "8",
    "9",
    ..._functionKeys,
    "MediaNextTrack",
    "MediaPlayPause",
    "MediaPrevTrack",
    "MediaStop",
    "A",
    "B",
    "C",
    "D",
    "E",
    "F",
    "G",
    "H",
    "I",
    "J",
    "K",
    "L",
    "M",
    "N",
    "O",
    "P",
    "Q",
    "R",
    "S",
    "T",
    "U",
    "V",
    "W",
    "X",
    "Y",
    "Z",
    "Up",
    "Down",
    "Left",
    "Right",
    "Comma",
    "Period",
    "Space",
  ]);

  /**
   * Trim a valid prefix from an event string.
   *
   *     "Digit3" ~> "3"
   *     "ArrowUp" ~> "Up"
   *     "W" ~> "W"
   *
   * @param {string} string The input string.
   * @returns {string} The trimmed string, or unchanged.
   */
  function trimPrefix(string) {
    return string.replace(/^(?:Digit|Numpad|Arrow)/, "");
  }

  const remapKeys = {
    ",": "Comma",
    ".": "Period",
    " ": "Space",
  };
  /**
   * Map special keys to their shortcut name.
   *
   *     "," ~> "Comma"
   *     " " ~> "Space"
   *
   * @param {string} string The input string.
   * @returns {string} The remapped string, or unchanged.
   */
  function remapKey(string) {
    if (remapKeys.hasOwnProperty(string)) {
      return remapKeys[string];
    }
    return string;
  }

  const keyOptions = [
    e => String.fromCharCode(e.which), // A letter?
    e => e.code.toUpperCase(), // A letter.
    e => trimPrefix(e.code), // Digit3, ArrowUp, Numpad9.
    e => trimPrefix(e.key), // Digit3, ArrowUp, Numpad9.
    e => remapKey(e.key), // Comma, Period, Space.
  ];
  /**
   * Map a DOM event to a shortcut string character.
   *
   * For example:
   *
   *    "a" ~> "A"
   *    "Digit3" ~> "3"
   *    "," ~> "Comma"
   *
   * @param {object} event A KeyboardEvent.
   * @returns {string} A string corresponding to the pressed key.
   */
  function getStringForEvent(event) {
    for (let option of keyOptions) {
      let value = option(event);
      if (validKeys.has(value)) {
        return value;
      }
    }

    return "";
  }

  function getShortcutValue(shortcut) {
    if (!shortcut) {
      // Ensure the shortcut is a string, even if it is unset.
      return null;
    }

    let modifiers = shortcut.split("+");
    let key = modifiers.pop();

    if (modifiers.length) {
      let modifiersAttribute = ShortcutUtils.getModifiersAttribute(modifiers);
      let displayString =
        ShortcutUtils.getModifierString(modifiersAttribute) + key;
      return displayString;
    }

    if (functionKeys.has(key)) {
      return key;
    }

    return null;
  }

  let error;

  function setError(...args) {
    setInputMessage("error", ...args);
  }

  function setWarning(...args) {
    setInputMessage("warning", ...args);
  }

  function setInputMessage(type, input, messageId, args) {
    let { x, y, height, right } = input.getBoundingClientRect();
    error.style.top = `${y + window.scrollY + height - 5}px`;

    if (document.dir == "ltr") {
      error.style.left = `${x}px`;
      error.style.right = null;
    } else {
      error.style.right = `${document.documentElement.clientWidth - right}px`;
      error.style.left = null;
    }

    error.setAttribute("type", type);
    document.l10n.setAttributes(
      error.querySelector(".error-message-label"),
      messageId,
      args
    );
    error.style.visibility = "visible";
  }

  function inputBlurred(e) {
    error.style.visibility = "hidden";
    e.target.value = getShortcutValue(e.target.getAttribute("shortcut"));
  }

  function onFocus(e) {
    e.target.value = "";

    let warning = e.target.getAttribute("warning");
    if (warning) {
      setWarning(e.target, warning);
    }
  }

  function getShortcutForEvent(e) {
    let modifierMap;

    if (AppConstants.platform == "macosx") {
      modifierMap = {
        MacCtrl: e.ctrlKey,
        Alt: e.altKey,
        Command: e.metaKey,
        Shift: e.shiftKey,
      };
    } else {
      modifierMap = {
        Ctrl: e.ctrlKey,
        Alt: e.altKey,
        Shift: e.shiftKey,
      };
    }

    return Object.entries(modifierMap)
      .filter(([, isDown]) => isDown)
      .map(([key]) => key)
      .concat(getStringForEvent(e))
      .join("+");
  }

  async function buildDuplicateShortcutsMap(addons) {
    await shortcutKeyMap.buildForAddonIds(addons.map(addon => addon.id));
  }

  function recordShortcut(shortcut, addonName, commandName) {
    shortcutKeyMap.recordShortcut(shortcut, addonName, commandName);
  }

  function removeShortcut(shortcut, addonName, commandName) {
    shortcutKeyMap.removeShortcut(shortcut, addonName, commandName);
  }

  function getAddonName(shortcut) {
    return shortcutKeyMap.getFirstAddonName(shortcut);
  }

  function setDuplicateWarnings() {
    let warningHolder = document.getElementById("duplicate-warning-messages");
    clearWarnings(warningHolder);
    for (let [shortcut, addons] of shortcutKeyMap) {
      if (addons.size > 1) {
        warningHolder.appendChild(createDuplicateWarningBar(shortcut));
        markDuplicates(shortcut);
      }
    }
  }

  function clearWarnings(warningHolder) {
    warningHolder.textContent = "";
    let inputs = document.querySelectorAll(".shortcut-input[warning]");
    for (let input of inputs) {
      input.removeAttribute("warning");
      let row = input.closest(".shortcut-row");
      if (row.hasAttribute("hide-before-expand")) {
        row
          .closest(".card")
          .querySelector(".expand-button")
          .removeAttribute("warning");
      }
    }
  }

  function createDuplicateWarningBar(shortcut) {
    let messagebar = document.createElement("moz-message-bar");
    messagebar.setAttribute("type", "warning");

    document.l10n.setAttributes(
      messagebar,
      "shortcuts-duplicate-warning-message2",
      { shortcut }
    );
    messagebar.setAttribute("data-l10n-attrs", "message");

    return messagebar;
  }

  function markDuplicates(shortcut) {
    let inputs = document.querySelectorAll(
      `.shortcut-input[shortcut="${shortcut}"]`
    );
    for (let input of inputs) {
      input.setAttribute("warning", "shortcuts-duplicate");
      let row = input.closest(".shortcut-row");
      if (row.hasAttribute("hide-before-expand")) {
        row
          .closest(".card")
          .querySelector(".expand-button")
          .setAttribute("warning", "shortcuts-duplicate");
      }
    }
  }

  function onShortcutChange(e) {
    let input = e.target;

    if (e.key == "Escape") {
      input.blur();
      return;
    }

    if (e.key == "Tab") {
      return;
    }

    if (!e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
      if (e.key == "Delete" || e.key == "Backspace") {
        // Avoid triggering back-navigation.
        e.preventDefault();
        assignShortcutToInput(input, "");
        return;
      }
    }

    e.preventDefault();
    e.stopPropagation();

    // Some system actions aren't in the keyset, handle them independantly.
    if (ShortcutUtils.getSystemActionForEvent(e)) {
      e.defaultCancelled = true;
      setError(input, "shortcuts-system");
      return;
    }

    let shortcutString = getShortcutForEvent(e);
    input.value = getShortcutValue(shortcutString);

    if (e.type == "keyup" || !shortcutString.length) {
      return;
    }

    let validation = ShortcutUtils.validate(shortcutString);
    switch (validation) {
      case ShortcutUtils.IS_VALID:
        // Show an error if this is already a system shortcut.
        let chromeWindow = window.windowRoot.ownerGlobal;
        if (ShortcutUtils.isSystem(chromeWindow, shortcutString)) {
          setError(input, "shortcuts-system");
          break;
        }

        // Check if shortcut is already assigned.
        if (shortcutKeyMap.has(shortcutString)) {
          setError(input, "shortcuts-exists", {
            addon: getAddonName(shortcutString),
          });
        } else {
          // Update the shortcut if it isn't reserved or assigned.
          assignShortcutToInput(input, shortcutString);
        }
        break;
      case ShortcutUtils.MODIFIER_REQUIRED:
        if (AppConstants.platform == "macosx") {
          setError(input, "shortcuts-modifier-mac");
        } else {
          setError(input, "shortcuts-modifier-other");
        }
        break;
      case ShortcutUtils.INVALID_COMBINATION:
        setError(input, "shortcuts-invalid");
        break;
      case ShortcutUtils.INVALID_KEY:
        setError(input, "shortcuts-letter");
        break;
    }
  }

  function onShortcutRemove(e) {
    let removeButton = e.target;
    let input = removeButton.parentNode.querySelector(".shortcut-input");
    if (input.getAttribute("shortcut")) {
      input.value = "";
      assignShortcutToInput(input, "");
    }
  }

  function assignShortcutToInput(input, shortcutString) {
    let addonId = input.closest(".card").getAttribute("addon-id");
    let extension = extensionForAddonId(addonId);

    let oldShortcut = input.getAttribute("shortcut");
    let addonName = input.closest(".card").getAttribute("addon-name");
    let commandName = input.getAttribute("name");

    removeShortcut(oldShortcut, addonName, commandName);
    recordShortcut(shortcutString, addonName, commandName);

    // This is async, but we're not awaiting it to keep the handler sync.
    extension.shortcuts.updateCommand({
      name: commandName,
      shortcut: shortcutString,
    });
    input.setAttribute("shortcut", shortcutString);
    input.blur();
    setDuplicateWarnings();
  }

  function renderNoShortcutAddons(addons) {
    let fragment = document.importNode(
      templates.noShortcutAddons.content,
      true
    );
    let list = fragment.querySelector(".shortcuts-no-commands-list");
    for (let addon of addons) {
      let addonItem = document.createElement("li");
      addonItem.textContent = addon.name;
      addonItem.setAttribute("addon-id", addon.id);
      list.appendChild(addonItem);
    }

    return fragment;
  }

  async function renderAddons(addons) {
    let frag = document.createDocumentFragment();
    let noShortcutAddons = [];

    await buildDuplicateShortcutsMap(addons);

    let isDuplicate = command => {
      if (command.shortcut) {
        let dupes = shortcutKeyMap.get(command.shortcut);
        return dupes.size > 1;
      }
      return false;
    };

    for (let addon of addons) {
      let extension = extensionForAddonId(addon.id);

      // Skip this extension if it isn't a webextension.
      if (!extension) {
        continue;
      }

      if (extension.shortcuts) {
        let card = document.importNode(
          templates.card.content,
          true
        ).firstElementChild;
        let icon = AddonManager.getPreferredIconURL(addon, 24, window);
        card.setAttribute("addon-id", addon.id);
        card.setAttribute("addon-name", addon.name);
        card.querySelector(".addon-icon").src = icon || FALLBACK_ICON;
        card.querySelector(".addon-name").textContent = addon.name;

        let commands = await extension.shortcuts.allCommands();

        // Sort the commands so the ones with shortcuts are at the top.
        commands.sort((a, b) => {
          if (isDuplicate(a) && isDuplicate(b)) {
            return 0;
          }
          if (isDuplicate(a)) {
            return -1;
          }
          if (isDuplicate(b)) {
            return 1;
          }
          // Boolean compare the shortcuts to see if they're both set or unset.
          if (!a.shortcut == !b.shortcut) {
            return 0;
          }
          if (a.shortcut) {
            return -1;
          }
          return 1;
        });

        let { limit, allowOver } = COLLAPSE_OPTIONS;
        let willHideCommands = commands.length > limit + allowOver;
        let firstHiddenInput;

        for (let i = 0; i < commands.length; i++) {
          let command = commands[i];

          let row = document.importNode(
            templates.row.content,
            true
          ).firstElementChild;

          if (willHideCommands && i >= limit) {
            row.setAttribute("hide-before-expand", "true");
          }

          let label = row.querySelector(".shortcut-label");
          let descriptionId = getCommandDescriptionId(command);
          if (descriptionId) {
            document.l10n.setAttributes(label, descriptionId);
          } else {
            label.textContent = command.description || command.name;
          }
          let input = row.querySelector(".shortcut-input");
          input.value = getShortcutValue(command.shortcut);
          input.setAttribute("name", command.name);
          input.setAttribute("shortcut", command.shortcut);
          input.addEventListener("keydown", onShortcutChange);
          input.addEventListener("keyup", onShortcutChange);
          input.addEventListener("blur", inputBlurred);
          input.addEventListener("focus", onFocus);

          let removeButton = row.querySelector(".shortcut-remove-button");
          removeButton.addEventListener("click", onShortcutRemove);

          if (willHideCommands && i == limit) {
            firstHiddenInput = input;
          }

          card.appendChild(row);
        }

        // Add an expand button, if needed.
        if (willHideCommands) {
          let row = document.importNode(templates.expandRow.content, true);
          let button = row.querySelector(".expand-button");
          let numberToShow = commands.length - limit;
          let setLabel = type => {
            document.l10n.setAttributes(
              button,
              `shortcuts-card-${type}-button`,
              {
                numberToShow,
              }
            );
          };

          setLabel("expand");
          button.addEventListener("click", event => {
            let expanded = card.hasAttribute("expanded");
            if (expanded) {
              card.removeAttribute("expanded");
              setLabel("expand");
            } else {
              card.setAttribute("expanded", "true");
              setLabel("collapse");
              // If this as a keyboard event then focus the next input.
              if (event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
                firstHiddenInput.focus();
              }
            }
          });
          card.appendChild(row);
        }

        frag.appendChild(card);
      } else if (!addon.hidden) {
        noShortcutAddons.push({ id: addon.id, name: addon.name });
      }
    }

    if (noShortcutAddons.length) {
      frag.appendChild(renderNoShortcutAddons(noShortcutAddons));
    }

    return frag;
  }

  class AddonShortcuts extends HTMLElement {
    connectedCallback() {
      setDuplicateWarnings();
    }

    disconnectedCallback() {
      error = null;
    }

    async render() {
      loadTemplates();
      let allAddons = await AddonManager.getAddonsByTypes(["extension"]);
      let addons = allAddons
        .filter(addon => addon.isActive)
        .sort((a, b) => a.name.localeCompare(b.name));
      let frag;

      if (addons.length) {
        frag = await renderAddons(addons);
      } else {
        frag = document.importNode(templates.noAddons.content, true);
      }

      this.textContent = "";
      this.appendChild(document.importNode(templates.view.content, true));
      error = this.querySelector(".error-message");
      this.appendChild(frag);
    }
  }
  customElements.define("addon-shortcuts", AddonShortcuts);
}
