/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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/. */

"use strict";

const {
  ExtensionScriptingStore,
  makeInternalContentScript,
  makePublicContentScript,
} = ChromeUtils.importESModule(
  "resource://gre/modules/ExtensionScriptingStore.sys.mjs"
);

var { ExtensionError, parseMatchPatterns } = ExtensionUtils;

// Map<Extension, Map<string, number>> - For each extension, we keep a map
// where the key is a user-provided script ID, the value is an internal
// generated integer.
const gScriptIdsMap = new Map();

/**
 * Inserts a script or style in the given tab, and returns a promise which
 * resolves when the operation has completed.
 *
 * @param {BaseContext} context
 *        The extension context for which to perform the injection.
 * @param {object} details
 *        The details object, specifying what to inject, where, and when.
 *        Derived from the ScriptInjection or CSSInjection types.
 * @param {string} kind
 *        The kind of data being injected. Possible choices: "js" or "css".
 * @param {string} method
 *        The name of the method which was called to trigger the injection.
 *        Used to generate appropriate error messages on failure.
 *
 * @returns {Promise}
 *        Resolves to the result of the execution, once it has completed.
 */
const execute = (context, details, kind, method) => {
  const { tabManager } = context.extension;

  let options = {
    jsPaths: [],
    cssPaths: [],
    removeCSS: method == "removeCSS",
    extensionId: context.extension.id,
  };

  const { tabId, frameIds, allFrames } = details.target;
  const tab = tabManager.get(tabId);

  options.hasActiveTabPermission = tab.hasActiveTabPermission;
  options.matches = tab.extension.allowedOrigins.patterns.map(
    host => host.pattern
  );

  const codeKey = kind === "js" ? "func" : "css";
  if ((details.files === null) == (details[codeKey] === null)) {
    throw new ExtensionError(
      `Exactly one of files and ${codeKey} must be specified.`
    );
  }

  if (details[codeKey]) {
    options[`${kind}Code`] = details[codeKey];
  }

  if (details.files) {
    for (const file of details.files) {
      let url = context.uri.resolve(file);
      if (!tab.extension.isExtensionURL(url)) {
        throw new ExtensionError(
          "Files to be injected must be within the extension"
        );
      }
      options[`${kind}Paths`].push(url);
    }
  }

  if (allFrames && frameIds) {
    throw new ExtensionError("Cannot specify both 'allFrames' and 'frameIds'.");
  }

  if (allFrames) {
    options.allFrames = allFrames;
  } else if (frameIds) {
    options.frameIds = frameIds;
  } else {
    options.frameIds = [0];
  }

  options.runAt = details.injectImmediately
    ? "document_start"
    : "document_idle";
  options.world = details.world || "ISOLATED";
  options.matchOriginAsFallback = true; // Also implies matchAboutBlank:true.
  options.wantReturnValue = true;
  // With this option set to `true`, we'll receive executeScript() results with
  // `frameId/result` properties and an `error` property will also be returned
  // in case of an error.
  options.returnResultsWithFrameIds = kind === "js";

  if (details.origin) {
    options.cssOrigin = details.origin.toLowerCase();
  } else {
    options.cssOrigin = "author";
  }

  // There is no need to execute anything when we have an empty list of frame
  // IDs because (1) it isn't invalid and (2) nothing will get executed.
  if (options.frameIds && options.frameIds.length === 0) {
    return [];
  }

  // This function is derived from `_execute()` in `parent/ext-tabs-base.js`,
  // make sure to keep both in sync when relevant.
  return tab.queryContent("Execute", options);
};

const ensureValidScriptId = id => {
  if (!id.length || id.startsWith("_")) {
    throw new ExtensionError("Invalid content script id.");
  }
};

const ensureValidScriptParams = (extension, script) => {
  if (!script.js?.length && !script.css?.length) {
    throw new ExtensionError("At least one js or css must be specified.");
  }

  if (!script.matches?.length) {
    throw new ExtensionError("matches must be specified.");
  }

  // This will throw if a match pattern is invalid.
  parseMatchPatterns(script.matches, {
    // This only works with MV2, not MV3. See Bug 1780507 for more information.
    restrictSchemes: extension.restrictSchemes,
  });

  if (script.excludeMatches) {
    // This will throw if a match pattern is invalid.
    parseMatchPatterns(script.excludeMatches, {
      // This only works with MV2, not MV3. See Bug 1780507 for more information.
      restrictSchemes: extension.restrictSchemes,
    });
  }
};

this.scripting = class extends ExtensionAPI {
  constructor(extension) {
    super(extension);

    // We initialize the scriptIdsMap for the extension with the scriptIds of
    // the store because this store initializes the extension before we
    // construct the scripting API here (and we need those IDs for some of the
    // API methods below).
    gScriptIdsMap.set(
      extension,
      ExtensionScriptingStore.getInitialScriptIdsMap(extension)
    );
  }

  onShutdown() {
    // When the extension is unloaded, the following happens:
    //
    // 1. The shared memory is cleared in the parent, see [1]
    // 2. The policy is marked as invalid, see [2]
    //
    // The following are not explicitly cleaned up:
    //
    // - `extension.registeredContentScripts
    // - `ExtensionProcessScript.registeredContentScripts` +
    //   `policy.contentScripts` (via `policy.unregisterContentScripts`)
    //
    // This means the script won't run again, but there is still potential for
    // memory leaks if there is a reference to `extension` or `policy`
    // somewhere.
    //
    // [1]: https://searchfox.org/mozilla-central/rev/211649f071259c4c733b4cafa94c44481c5caacc/toolkit/components/extensions/Extension.jsm#2974-2976
    // [2]: https://searchfox.org/mozilla-central/rev/211649f071259c4c733b4cafa94c44481c5caacc/toolkit/components/extensions/ExtensionProcessScript.jsm#239

    gScriptIdsMap.delete(this.extension);
  }

  getAPI(context) {
    const { extension } = context;

    return {
      scripting: {
        executeScriptInternal: async details => {
          return execute(context, details, "js", "executeScript");
        },

        insertCSS: async details => {
          return execute(context, details, "css", "insertCSS").then(() => {});
        },

        removeCSS: async details => {
          return execute(context, details, "css", "removeCSS").then(() => {});
        },

        registerContentScripts: async scripts => {
          // Map<string, number>
          const scriptIdsMap = gScriptIdsMap.get(extension);
          // Map<string, { scriptId: number, options: Object }>
          const scriptsToRegister = new Map();

          for (const script of scripts) {
            ensureValidScriptId(script.id);

            if (scriptIdsMap.has(script.id)) {
              throw new ExtensionError(
                `Content script with id "${script.id}" is already registered.`
              );
            }

            if (scriptsToRegister.has(script.id)) {
              throw new ExtensionError(
                `Script ID "${script.id}" found more than once in 'scripts' array.`
              );
            }

            ensureValidScriptParams(extension, script);

            scriptsToRegister.set(
              script.id,
              makeInternalContentScript(extension, script)
            );
          }

          for (const [id, { scriptId, options }] of scriptsToRegister) {
            scriptIdsMap.set(id, scriptId);
            extension.registeredContentScripts.set(scriptId, options);
          }
          extension.updateContentScripts();

          ExtensionScriptingStore.persistAll(extension);

          await extension.broadcast("Extension:RegisterContentScripts", {
            id: extension.id,
            scripts: Array.from(scriptsToRegister.values()),
          });
        },

        getRegisteredContentScripts: async details => {
          // Map<string, number>
          const scriptIdsMap = gScriptIdsMap.get(extension);

          return Array.from(scriptIdsMap.entries())
            .filter(([id]) => !details?.ids || details.ids.includes(id))
            .map(([, scriptId]) => {
              const options = extension.registeredContentScripts.get(scriptId);

              return makePublicContentScript(extension, options);
            });
        },

        unregisterContentScripts: async details => {
          // Map<string, number>
          const scriptIdsMap = gScriptIdsMap.get(extension);

          let ids = [];

          if (details?.ids) {
            for (const id of details.ids) {
              ensureValidScriptId(id);

              if (!scriptIdsMap.has(id)) {
                throw new ExtensionError(
                  `Content script with id "${id}" does not exist.`
                );
              }
            }

            ids = details.ids;
          } else {
            ids = Array.from(scriptIdsMap.keys());
          }

          if (ids.length === 0) {
            return;
          }

          const scriptIds = [];
          for (const id of ids) {
            const scriptId = scriptIdsMap.get(id);

            extension.registeredContentScripts.delete(scriptId);
            scriptIdsMap.delete(id);
            scriptIds.push(scriptId);
          }
          extension.updateContentScripts();

          ExtensionScriptingStore.persistAll(extension);

          await extension.broadcast("Extension:UnregisterContentScripts", {
            id: extension.id,
            scriptIds,
          });
        },

        updateContentScripts: async scripts => {
          // Map<string, number>
          const scriptIdsMap = gScriptIdsMap.get(extension);
          // Map<string, { scriptId: number, options: Object }>
          const scriptsToUpdate = new Map();

          for (const script of scripts) {
            ensureValidScriptId(script.id);

            if (!scriptIdsMap.has(script.id)) {
              throw new ExtensionError(
                `Content script with id "${script.id}" does not exist.`
              );
            }

            if (scriptsToUpdate.has(script.id)) {
              throw new ExtensionError(
                `Script ID "${script.id}" found more than once in 'scripts' array.`
              );
            }

            // Retrieve the existing script options.
            const scriptId = scriptIdsMap.get(script.id);
            const options = extension.registeredContentScripts.get(scriptId);

            // Use existing values if not specified in the update.
            script.allFrames ??= options.allFrames;
            script.css ??= options.cssPaths;
            script.excludeMatches ??= options.excludeMatches;
            script.js ??= options.jsPaths;
            script.matches ??= options.matches;
            script.matchOriginAsFallback ??= options.matchOriginAsFallback;
            script.runAt ??= options.runAt;
            script.world ??= options.world;
            script.persistAcrossSessions ??= options.persistAcrossSessions;

            ensureValidScriptParams(extension, script);

            scriptsToUpdate.set(script.id, {
              ...makeInternalContentScript(extension, script),
              // Re-use internal script ID.
              scriptId,
            });
          }

          for (const { scriptId, options } of scriptsToUpdate.values()) {
            extension.registeredContentScripts.set(scriptId, options);
          }
          extension.updateContentScripts();

          ExtensionScriptingStore.persistAll(extension);

          await extension.broadcast("Extension:UpdateContentScripts", {
            id: extension.id,
            scripts: Array.from(scriptsToUpdate.values()),
          });
        },
      },
    };
  }
};
