/* 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/. */

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

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  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 _shortcutKeyMap = null;
function getShortcutKeyMap() {
  if (!_shortcutKeyMap) {
    _shortcutKeyMap = new lazy.ExtensionShortcutKeyMap();
  }
  return _shortcutKeyMap;
}

// Module-level template cache (DOMParser-based lazy initialization).
const domParser = new DOMParser();
const templates = {};

function getTemplate(id, markup) {
  if (!templates[id]) {
    let doc = domParser.parseFromString(markup, "text/html");
    templates[id] = doc.querySelector("template");
  }
  return document.importNode(templates[id].content, true);
}

function getViewTemplate() {
  return getTemplate(
    "shortcut-view",
    `<template>
      <div class="error-message">
        <img
          class="error-message-icon"
          src="chrome://global/skin/arrow/panelarrow-vertical.svg"
        />
        <div class="error-message-label"></div>
      </div>
      <message-bar-stack
        id="duplicate-warning-messages"
        reverse
        max-message-bar-count="5"
      >
      </message-bar-stack>
    </template>`
  );
}

function getCardTemplate() {
  return getTemplate(
    "shortcut-card-template",
    `<template>
      <div class="card card-no-hover shortcut">
        <div class="card-heading">
          <img class="card-heading-icon addon-icon" />
          <h2 class="addon-name"></h2>
        </div>
      </div>
    </template>`
  );
}

function getRowTemplate() {
  return getTemplate(
    "shortcut-row-template",
    `<template>
      <div class="shortcut-row">
        <label class="shortcut-label"></label>
        <input
          class="shortcut-input"
          data-l10n-id="shortcuts-input"
          type="text"
          readonly
        />
        <button
          class="shortcut-remove-button ghost-button"
          data-l10n-id="shortcuts-remove-button"
          data-l10n-attrs="aria-label"
        ></button>
      </div>
    </template>`
  );
}

function getNoAddonsTemplate() {
  return getTemplate(
    "shortcuts-no-addons",
    `<template>
      <div data-l10n-id="shortcuts-no-addons"></div>
    </template>`
  );
}

function getExpandRowTemplate() {
  return getTemplate(
    "expand-row-template",
    `<template>
      <div class="expand-row">
        <button class="expand-button"></button>
      </div>
    </template>`
  );
}

function getNoShortcutAddonsTemplate() {
  return getTemplate(
    "shortcuts-no-commands-template",
    `<template>
      <div data-l10n-id="shortcuts-no-commands"></div>
      <ul class="shortcuts-no-commands-list"></ul>
    </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 =
      lazy.ShortcutUtils.getModifiersAttribute(modifiers);
    let displayString =
      lazy.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 getShortcutKeyMap().buildForAddonIds(addons.map(addon => addon.id));
}

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

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

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

function setDuplicateWarnings() {
  let warningHolder = document.getElementById("duplicate-warning-messages");
  clearWarnings(warningHolder);
  for (let [shortcut, addons] of getShortcutKeyMap()) {
    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 (lazy.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 = lazy.ShortcutUtils.validate(shortcutString);
  switch (validation) {
    case lazy.ShortcutUtils.IS_VALID: {
      // Show an error if this is already a system shortcut.
      let chromeWindow = window.windowRoot.window;
      if (lazy.ShortcutUtils.isSystem(chromeWindow, shortcutString)) {
        setError(input, "shortcuts-system");
        break;
      }

      // Check if shortcut is already assigned.
      if (getShortcutKeyMap().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 lazy.ShortcutUtils.MODIFIER_REQUIRED:
      if (AppConstants.platform == "macosx") {
        setError(input, "shortcuts-modifier-mac");
      } else {
        setError(input, "shortcuts-modifier-other");
      }
      break;
    case lazy.ShortcutUtils.INVALID_COMBINATION:
      setError(input, "shortcuts-invalid");
      break;
    case lazy.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 = getNoShortcutAddonsTemplate();
  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, focusedExtensionId) {
  let frag = document.createDocumentFragment();
  let noShortcutAddons = [];

  await buildDuplicateShortcutsMap(addons);

  let isDuplicate = command => {
    if (command.shortcut) {
      let dupes = getShortcutKeyMap().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 = getCardTemplate().firstElementChild;
      let icon = AddonManager.getPreferredIconURL(addon, 24, window);
      card.setAttribute("addon-id", addon.id);
      card.setAttribute("addon-name", addon.name);
      if (focusedExtensionId && addon.id === focusedExtensionId) {
        card.classList.add("focused-extension");
      }
      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 = getRowTemplate().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 = getExpandRowTemplate();
        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;
}

// focusExtension() is called from the view-loaded listener so that it doesn't
// interfere with gViewController.scrollOffsets.restore() in view-controller.mjs.
function focusExtension() {
  document
    .querySelector(".shortcut.card.focused-extension")
    ?.scrollIntoView({ block: "center" });
}

class AddonShortcuts extends HTMLElement {
  connectedCallback() {
    document.addEventListener("view-loaded", focusExtension);
    setDuplicateWarnings();
  }

  disconnectedCallback() {
    document.removeEventListener("view-loaded", focusExtension);
    error = null;
  }

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

    if (addons.length) {
      frag = await renderAddons(addons, extensionId);
    } else {
      frag = getNoAddonsTemplate();
    }

    this.textContent = "";
    this.appendChild(getViewTemplate());
    error = this.querySelector(".error-message");
    this.appendChild(frag);
  }
}
customElements.define("addon-shortcuts", AddonShortcuts);
