/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";

const lazy = XPCOMUtils.declareLazy({
  ExtensionDocumentId: "resource://gre/modules/ExtensionDocumentId.sys.mjs",
  ExtensionProcessScript:
    "resource://gre/modules/ExtensionProcessScript.sys.mjs",
  ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
  ExtensionUserScriptsContent:
    "resource://gre/modules/ExtensionUserScriptsContent.sys.mjs",
  LanguageDetector:
    "resource://gre/modules/translations/LanguageDetector.sys.mjs",
  Schemas: "resource://gre/modules/Schemas.sys.mjs",
  WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",

  styleSheetService: {
    service: "@mozilla.org/content/style-sheet-service;1",
    iid: Ci.nsIStyleSheetService,
  },
  isContentScriptProcess: () =>
    Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT ||
    !WebExtensionPolicy.useRemoteWebExtensions ||
    // Thunderbird still loads some content in the parent process.
    AppConstants.MOZ_APP_NAME == "thunderbird",
  orderedContentScripts: {
    pref: "extensions.webextensions.content_scripts.ordered",
    default: true,
  },
});

const Timer = Components.Constructor(
  "@mozilla.org/timer;1",
  "nsITimer",
  "initWithCallback"
);

const ScriptError = Components.Constructor(
  "@mozilla.org/scripterror;1",
  "nsIScriptError",
  "initWithWindowID"
);

import {
  ChildAPIManager,
  ExtensionChild,
  ExtensionActivityLogChild,
  Messenger,
} from "resource://gre/modules/ExtensionChild.sys.mjs";
import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";
import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";

const {
  DefaultMap,
  DefaultWeakMap,
  getInnerWindowID,
  promiseDocumentIdle,
  promiseDocumentLoaded,
  promiseDocumentReady,
} = ExtensionUtils;

const {
  BaseContext,
  CanOfAPIs,
  SchemaAPIManager,
  defineLazyGetter,
  redefineGetter,
  runSafeSyncWithoutClone,
} = ExtensionCommon;

var DocumentManager;

const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content";

var apiManager = new (class extends SchemaAPIManager {
  constructor() {
    super("content", lazy.Schemas);
    this.initialized = false;
  }

  lazyInit() {
    if (!this.initialized) {
      this.initialized = true;
      this.initGlobal();
      for (let { value } of Services.catMan.enumerateCategory(
        CATEGORY_EXTENSION_SCRIPTS_CONTENT
      )) {
        this.loadScript(value);
      }
    }
  }
})();

const SCRIPT_EXPIRY_TIMEOUT_MS = 5 * 60 * 1000;
const SCRIPT_CLEAR_TIMEOUT_MS = 5 * 1000;

const CSS_EXPIRY_TIMEOUT_MS = 30 * 60 * 1000;
const CSSCODE_EXPIRY_TIMEOUT_MS = 10 * 60 * 1000;

const scriptCaches = new WeakSet();
const sheetCacheDocuments = new DefaultWeakMap(() => new WeakSet());

class CacheMap extends DefaultMap {
  constructor(timeout, getter, extension) {
    super(getter);

    this.expiryTimeout = timeout;

    // DocumentManager clears scriptCaches early under memory pressure. For
    // this to work, DocumentManager.lazyInit() should be called. In practice,
    // ScriptCache/CSSCache/CSSCodeCache are only instantiated and populated
    // when a content script/style is to be injected. This always depends on a
    // ContentScriptContextChild instance, which is always paired with a call
    // to DocumentManager.lazyInit().
    scriptCaches.add(this);

    // This ensures that all the cached scripts and stylesheets are deleted
    // from the cache and the xpi is no longer actively used.
    // See Bug 1435100 for rationale.
    extension.once("shutdown", () => {
      this.clear(-1);
    });
  }

  get(url) {
    let promise = super.get(url);

    promise.lastUsed = Date.now();
    if (promise.timer) {
      promise.timer.cancel();
    }
    promise.timer = Timer(
      this.delete.bind(this, url),
      this.expiryTimeout,
      Ci.nsITimer.TYPE_ONE_SHOT
    );

    return promise;
  }

  delete(url) {
    if (this.has(url)) {
      super.get(url).timer.cancel();
    }

    return super.delete(url);
  }

  clear(timeout = SCRIPT_CLEAR_TIMEOUT_MS) {
    let now = Date.now();
    for (let [url, promise] of this.entries()) {
      // Delete the entry if expired or if clear has been called with timeout -1
      // (which is used to force the cache to clear all the entries, e.g. when the
      // extension is shutting down).
      if (timeout === -1 || now - promise.lastUsed >= timeout) {
        this.delete(url);
      }
    }
  }
}

class ScriptCache extends CacheMap {
  constructor(options, extension) {
    super(
      SCRIPT_EXPIRY_TIMEOUT_MS,
      url => {
        /** @type {Promise<PrecompiledScript> & { script?: PrecompiledScript }} */
        let promise = ChromeUtils.compileScript(url, options);
        promise.then(script => {
          promise.script = script;
        });
        return promise;
      },
      extension
    );
  }
}

/**
 * Shared base class for the two specialized CSS caches:
 * CSSCache (for the "url"-based stylesheets) and CSSCodeCache
 * (for the stylesheet defined by plain CSS content as a string).
 */
class BaseCSSCache extends CacheMap {
  constructor(expiryTimeout, defaultConstructor, extension) {
    super(expiryTimeout, defaultConstructor, extension);
  }

  delete(key) {
    if (this.has(key)) {
      let sheetPromise = this.get(key);

      // Never remove a sheet from the cache if it's still being used by a
      // document. Rule processors can be shared between documents with the
      // same preloaded sheet, so we only lose by removing them while they're
      // still in use.
      let docs = ChromeUtils.nondeterministicGetWeakSetKeys(
        sheetCacheDocuments.get(sheetPromise)
      );
      if (docs.length) {
        return;
      }
    }

    return super.delete(key);
  }
}

/**
 * Cache of the preloaded stylesheet defined by url.
 */
class CSSCache extends BaseCSSCache {
  constructor(sheetType, extension) {
    super(
      CSS_EXPIRY_TIMEOUT_MS,
      url => {
        let uri = Services.io.newURI(url);
        const sheetPromise = lazy.styleSheetService.preloadSheetAsync(
          uri,
          sheetType
        );
        sheetPromise.then(sheet => {
          sheetPromise.sheet = sheet;
        });
        return sheetPromise;
      },
      extension
    );
  }
}

/**
 * Cache of the preloaded stylesheet defined by plain CSS content as a string,
 * the key of the cached stylesheet is the hash of its "CSSCode" string.
 */
class CSSCodeCache extends BaseCSSCache {
  constructor(sheetType, extension) {
    super(
      CSSCODE_EXPIRY_TIMEOUT_MS,
      hash => {
        if (!this.has(hash)) {
          // Do not allow the getter to be used to lazily create the cached stylesheet,
          // the cached CSSCode stylesheet has to be explicitly set.
          throw new Error(
            "Unexistent cached cssCode stylesheet: " + Error().stack
          );
        }

        return super.get(hash);
      },
      extension
    );

    // Store the preferred sheetType (used to preload the expected stylesheet type in
    // the addCSSCode method).
    this.sheetType = sheetType;
  }

  addCSSCode(hash, cssCode) {
    if (this.has(hash)) {
      // This cssCode have been already cached, no need to create it again.
      return;
    }
    // The `webext=style` portion is added metadata to help us distinguish
    // different kinds of data URL loads that are triggered with the
    // SystemPrincipal. It shall be removed with bug 1699425.
    const uri = Services.io.newURI(
      "data:text/css;extension=style;charset=utf-8," +
        encodeURIComponent(cssCode)
    );
    const sheetPromise = lazy.styleSheetService.preloadSheetAsync(
      uri,
      this.sheetType
    );
    sheetPromise.then(sheet => {
      sheetPromise.sheet = sheet;
    });
    // styleURI: windowUtils.removeSheet requires a URI to identify the sheet.
    sheetPromise.styleURI = uri;

    super.set(hash, sheetPromise);
  }
}

defineLazyGetter(ExtensionChild.prototype, "staticScripts", function () {
  return new ScriptCache({ hasReturnValue: false }, this);
});

defineLazyGetter(ExtensionChild.prototype, "dynamicScripts", function () {
  return new ScriptCache({ hasReturnValue: true }, this);
});

defineLazyGetter(ExtensionChild.prototype, "anonStaticScripts", function () {
  // TODO bug 1651557: Use dynamic name to improve debugger experience.
  const filename = "<anonymous code>";
  return new ScriptCache({ filename, hasReturnValue: false }, this);
});

defineLazyGetter(ExtensionChild.prototype, "anonDynamicScripts", function () {
  // TODO bug 1651557: Use dynamic name to improve debugger experience.
  const filename = "<anonymous code>";
  return new ScriptCache({ filename, hasReturnValue: true }, this);
});

defineLazyGetter(ExtensionChild.prototype, "userCSS", function () {
  return new CSSCache(Ci.nsIStyleSheetService.USER_SHEET, this);
});

defineLazyGetter(ExtensionChild.prototype, "authorCSS", function () {
  return new CSSCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this);
});

// These two caches are similar to the above but specialized to cache the cssCode
// using an hash computed from the cssCode string as the key (instead of the generated data
// URI which can be pretty long for bigger injected cssCode).
defineLazyGetter(ExtensionChild.prototype, "userCSSCode", function () {
  return new CSSCodeCache(Ci.nsIStyleSheetService.USER_SHEET, this);
});

defineLazyGetter(ExtensionChild.prototype, "authorCSSCode", function () {
  return new CSSCodeCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this);
});

/**
 * This is still an ExtensionChild, but with the properties added above.
 * Unfortunately we can't express that using just JSDocs types locally,
 * so this needs to be used with `& ExtensionChild` explicitly below.
 *
 * @typedef {object} ExtensionChildContent
 * @property {ScriptCache} staticScripts
 * @property {ScriptCache} dynamicScripts
 * @property {ScriptCache} anonStaticScripts
 * @property {ScriptCache} anonDynamicScripts
 * @property {CSSCache} userCSS
 * @property {CSSCache} authorCSS
 * @property {CSSCodeCache} userCSSCode
 * @property {CSSCodeCache} authorCSSCode
 */

/**
 * Script/style injections depend on compiled scripts/styles. If the previously
 * compiled script or style is not found, we block that and later script/style
 * executions until compilation finishes. This is achieved by storing a Promise
 * for that compilation in this gPendingScriptBlockers, for a given context.
 *
 * @type {WeakMap<ContentScriptContextChild, Promise>}
 */
const gPendingScriptBlockers = new WeakMap();

// Represents a content script.
class Script {
  /**
   * @param {ExtensionChild & ExtensionChildContent} extension
   * @param {WebExtensionContentScript|object} matcher
   *        An object with a "matchesWindowGlobal" method and content script
   *        execution details. This is usually a plain WebExtensionContentScript
   *        except when the script is run via `tabs.executeScript`,
   *        `scripting.executeScript` or `userScripts.execute`. In this case,
   *        the object may have some extra properties: wantReturnValue, removeCSS
   *        and jsExecuteScriptSources
   */
  constructor(extension, matcher) {
    this.scriptType = "content_script";
    this.extension = extension;
    this.matcher = matcher;

    this.runAt = this.matcher.runAt;
    this.world = this.matcher.world;
    this.js = this.matcher.jsPaths;

    this.css = this.matcher.cssPaths.slice();
    this.cssCodeHash = null;

    this.removeCSS = this.matcher.removeCSS;
    this.cssOrigin = this.matcher.cssOrigin;

    this.cssCache =
      extension[this.cssOrigin === "user" ? "userCSS" : "authorCSS"];
    this.cssCodeCache =
      extension[this.cssOrigin === "user" ? "userCSSCode" : "authorCSSCode"];
    if (this.world === "MAIN") {
      this.scriptCache = matcher.wantReturnValue
        ? extension.anonDynamicScripts
        : extension.anonStaticScripts;
    } else {
      this.scriptCache = matcher.wantReturnValue
        ? extension.dynamicScripts
        : extension.staticScripts;
    }

    // If set jsDynamicScripts takes precedence over js.
    // Requires scriptCache and world to be set.
    /** @type {Array<Promise<PrecompiledScript> & {script?: PrecompiledScript}>} */
    this.jsDynamicScripts = this.matcher.jsExecuteScriptSources?.map(s =>
      this.#compileScriptSource(s)
    );

    /** @type {WeakSet<Document>} A set of documents injected into. */
    this.injectedInto = new WeakSet();
  }

  get requiresCleanup() {
    return !this.removeCSS && (!!this.css.length || this.cssCodeHash);
  }

  async addCSSCode(cssCode) {
    if (!cssCode) {
      return;
    }

    // Store the hash of the cssCode.
    const buffer = await crypto.subtle.digest(
      "SHA-1",
      new TextEncoder().encode(cssCode)
    );
    this.cssCodeHash = String.fromCharCode(...new Uint16Array(buffer));

    // Cache and preload the cssCode stylesheet.
    this.cssCodeCache.addCSSCode(this.cssCodeHash, cssCode);
  }

  #compileScriptSource(source) {
    const { file, code } = source;
    if (file) {
      return this.scriptCache.get(file);
    }
    // else code property must be defined
    if (this.world === "MAIN") {
      // To support the scripting.executeScript API, we would like to execute a
      // string in the context of the web page in #injectIntoMainWorld().
      // To do so without being blocked by the web page's CSP, we convert
      // code to a PrecompiledScript, which is then executed by the logic
      // that is usually used for file-based execution.
      const dataUrl = `data:text/javascript,${encodeURIComponent(code)}`;
      const options = {
        hasReturnValue: this.matcher.wantReturnValue,
        // Redact the file name to hide actual script content from web pages.
        // TODO bug 1651557: Use dynamic name to improve debugger experience.
        filename: "<anonymous code>",
      };
      // Note: this logic is similar to this.scriptCaches.get(...), but we are
      // not using scriptCaches because we don't want the URL to be cached.
      /** @type {Promise<PrecompiledScript> & {script?: PrecompiledScript}} */
      const promisedScript = ChromeUtils.compileScript(dataUrl, options);
      promisedScript.then(script => {
        promisedScript.script = script;
      });
      return promisedScript;
    }
    // else this.world === "ISOLATED" || "USER_SCRIPT".
    /** @type {PrecompiledScript} */
    const precompiledScriptImitation = {
      hasReturnValue: this.matcher.wantReturnValue,
      // TODO bug 1651557: Use dynamic name to improve debugger experience.
      url: "<anonymous code>",
      // This implements reportExceptions=false. The true value is not
      // implemented because we only take this code path for executeScript,
      // which sets reportExceptions=false.
      executeInGlobal(global) {
        return Cu.evalInSandbox(
          code,
          global,
          "latest",
          // TODO bug 1651557: Use dynamic name to improve debugger experience.
          "sandbox eval code",
          1
        );
      },
    };
    const promisedScript = Promise.resolve(precompiledScriptImitation);
    promisedScript.script = precompiledScriptImitation;
    return promisedScript;
  }

  compileScripts() {
    return this.js.map(url => this.scriptCache.get(url));
  }

  loadCSS() {
    return this.css.map(url => this.cssCache.get(url));
  }

  preload() {
    this.loadCSS();
    this.compileScripts();
  }

  cleanup(window) {
    if (this.requiresCleanup) {
      if (window) {
        this.removeStyleSheets(window);
      }

      // Clear any sheets that were kept alive past their timeout as
      // a result of living in this document.
      this.cssCodeCache.clear(CSSCODE_EXPIRY_TIMEOUT_MS);
      this.cssCache.clear(CSS_EXPIRY_TIMEOUT_MS);
    }
  }

  matchesWindowGlobal(windowGlobal, ignorePermissions) {
    return this.matcher.matchesWindowGlobal(windowGlobal, ignorePermissions);
  }

  async injectInto(window, reportExceptions = true) {
    if (
      !lazy.isContentScriptProcess ||
      this.injectedInto.has(window.document)
    ) {
      return;
    }
    this.injectedInto.add(window.document);

    let context = this.extension.getContext(window);
    // executeScript are not logged here, but by callAndLog
    for (let script of this.matcher.jsPaths) {
      context.logActivity(this.scriptType, script, {
        url: window.location.href,
      });
    }

    try {
      // In case of uncommitted initial about:blank documents, inject
      // immediately without awaiting the runAt logic in the blocks below, to
      // avoid getting stuck due to
      // https://bugzilla.mozilla.org/show_bug.cgi?id=1900222#c7
      // This is only relevant for dynamic code execution because declarative
      // content scripts do not run on this about:blank - bug 1415539.
      if (!window.document.isUncommittedInitialDocument) {
        if (this.runAt === "document_end") {
          await promiseDocumentReady(window.document);
        } else if (this.runAt === "document_idle") {
          await Promise.race([
            promiseDocumentIdle(window),
            promiseDocumentLoaded(window.document),
          ]);
        }
      }

      return this.inject(context, reportExceptions);
    } catch (e) {
      return Promise.reject(context.normalizeError(e));
    }
  }

  /**
   * Tries to inject this script into the given window and sandbox, if
   * there are pending operations for the window's current load state.
   *
   * @param {ContentScriptContextChild} context
   *        The content script context into which to inject the scripts.
   * @param {boolean} reportExceptions
   *        Defaults to true and reports any exception directly to the console
   *        and no exception will be thrown out of this function.
   * @returns {Promise<any>}
   *        Resolves to the last value in the evaluated script, when
   *        execution is complete.
   */
  async inject(context, reportExceptions = true) {
    // NOTE: Avoid unnecessary use of "await" in this function, because doing
    // so can delay script execution beyond the scheduled point. In particular,
    // document_start scripts should run "immediately" in most cases.

    if (this.requiresCleanup) {
      context.addScript(this);
    }

    // To avoid another await (which affects timing) or .then() chaining
    // (which would create a new Promise that could duplicate a rejection),
    // we store the index where we expect the result of a Promise.all() call.
    let scriptsIndex, sheetsIndex;
    let sheets = this.getCompiledStyleSheets(context.contentWindow);
    let scripts = this.getCompiledScripts(context);

    let executionBlockingPromises = [];
    if (gPendingScriptBlockers.has(context) && lazy.orderedContentScripts) {
      executionBlockingPromises.push(gPendingScriptBlockers.get(context));
    }
    if (scripts instanceof Promise) {
      scriptsIndex = executionBlockingPromises.length;
      executionBlockingPromises.push(scripts);
    }
    if (sheets instanceof Promise) {
      sheetsIndex = executionBlockingPromises.length;
      executionBlockingPromises.push(sheets);
    }

    if (executionBlockingPromises.length) {
      let promise = Promise.all(executionBlockingPromises);

      // If we're supposed to inject at the start of the document load,
      // and we haven't already missed that point, block further parsing
      // until the scripts/styles have been loaded.
      // This maximizes the chance of content scripts executing before other
      // scripts in the web page.
      //
      // Blocking the full parser is overkill if we are only awaiting style
      // compilation, since we only need to block the parts that are dependent
      // on CSS (layout, onload event, CSSOM, etc). But we have an API to do
      // the former and not atter, so we do it that way. This hopefully isn't a
      // performance problem since there are no network loads involved, and
      // since we cache the stylesheets on first load. We should fix this up if
      // it does becomes a problem.
      const { document } = context.contentWindow;
      if (
        this.runAt === "document_start" &&
        document.readyState !== "complete"
      ) {
        document.blockParsing(promise, { blockScriptCreated: false });
      }

      // Store a promise that never rejects, so that failure to compile scripts
      // or styles here does not prevent the scheduling of others.
      let promiseSettled = promise.then(
        () => {},
        () => {}
      );
      gPendingScriptBlockers.set(context, promiseSettled);

      // Note: in theory, the following async await could result in script
      // execution being scheduled too late. That would be an issue for
      // document_start scripts. In practice, this is not a problem because the
      // compiled script is cached in the process, and preloading to compile
      // starts as soon as the network request for the document has been
      // received (see ExtensionPolicyService::CheckRequest).
      //
      // We use blockParsing() for document_start scripts (and styles) to
      // ensure that the DOM remains blocked when scripts are still compiling.
      try {
        // NOTE: This is the ONLY await in this injectInto function!
        const compiledResults = await promise;
        if (sheetsIndex !== undefined) {
          sheets = compiledResults[sheetsIndex];
        }
        if (scriptsIndex !== undefined) {
          scripts = compiledResults[scriptsIndex];
        }
      } finally {
        // gPendingScriptBlockers may be overwritten by another inject() call,
        // so check that this is the latest inject() attempt before clearing.
        if (gPendingScriptBlockers.get(context) === promiseSettled) {
          gPendingScriptBlockers.delete(context);
        }
      }
    }

    let window = context.contentWindow;
    if (!window) {
      // context unloaded or went into bfcache before compilation completed.
      return;
    }

    if (this.css.length || this.cssCodeHash) {
      if (this.removeCSS) {
        this.removeStyleSheets(window);
        // The tabs.removeCSS and scripting.removeCSS are never combined with
        // script execution, so we can now return early.
        return;
      }
      // Make sure we've injected any related CSS before we run content scripts.
      let { windowUtils } = window;
      let type =
        this.cssOrigin === "user"
          ? windowUtils.USER_SHEET
          : windowUtils.AUTHOR_SHEET;
      for (const sheet of sheets) {
        runSafeSyncWithoutClone(windowUtils.addSheet, sheet, type);
      }
    }

    const { extension } = context;

    // The evaluations below may throw, in which case the promise will be
    // automatically rejected.
    lazy.ExtensionTelemetry.contentScriptInjection.stopwatchStart(
      extension,
      context
    );
    try {
      if (this.world === "MAIN") {
        return this.#injectIntoMainWorld(context, scripts, reportExceptions);
      }
      if (this.world === "USER_SCRIPT") {
        return this.#injectIntoUserScriptWorld(
          context,
          scripts,
          reportExceptions
        );
      }
      return this.#injectIntoIsolatedWorld(context, scripts, reportExceptions);
    } finally {
      lazy.ExtensionTelemetry.contentScriptInjection.stopwatchFinish(
        extension,
        context
      );
    }
  }

  #injectIntoIsolatedWorld(context, scripts, reportExceptions) {
    let result;

    // Note: every script execution can potentially destroy the context, in
    // which case context.cloneScope becomes null (bug 1403505).
    for (let script of scripts) {
      result = script.executeInGlobal(context.cloneScope, { reportExceptions });
    }

    return result;
  }

  #injectIntoUserScriptWorld(context, scripts, reportExceptions) {
    let worldId = this.matcher.worldId;
    let sandbox = lazy.ExtensionUserScriptsContent.sandboxFor(context, worldId);

    let result;
    // Note: every script execution can potentially destroy the context or
    // navigate the window, in which case context.active will be false.
    for (let script of scripts) {
      if (!context.active) {
        // Return instead of throw, to avoid logspam like bug 1403505.
        return;
      }
      result = script.executeInGlobal(sandbox, { reportExceptions });
    }

    return result;
  }

  #injectIntoMainWorld(context, scripts, reportExceptions) {
    let result;

    // Note: every script execution can potentially destroy the context or
    // navigate the window, in which case context.contentWindow will be null,
    // which would cause an error to be thrown (bug 1403505).
    for (let script of scripts) {
      result = script.executeInGlobal(context.contentWindow, {
        reportExceptions,
      });
    }

    return result;
  }

  /**
   * Get the compiled scripts (if they are already precompiled and cached) or a
   * promise which resolves to the precompiled scripts (once they have been
   * compiled and cached).
   *
   * @param {ContentScriptContextChild} context
   *        The context where the caller intends to run the compiled script.
   *
   * @returns {PrecompiledScript[] | Promise<PrecompiledScript[]>}
   */
  getCompiledScripts(context) {
    let scriptPromises = this.jsDynamicScripts ?? this.compileScripts();
    let scripts = scriptPromises.map(promise => promise.script);

    // If not all scripts are already available in the cache, block
    // parsing and wait all promises to resolve.
    if (!scripts.every(script => script)) {
      let promise = Promise.all(scriptPromises);

      // If there is any syntax error, the script promises will be rejected.
      //
      // Notify the exception directly to the console so that it can
      // be displayed in the web console by flagging the error with the right
      // innerWindowID.
      for (const p of scriptPromises) {
        p.catch(error => {
          Services.console.logMessage(
            new ScriptError(
              error.toString(),
              error.fileName,
              error.lineNumber,
              error.columnNumber,
              Ci.nsIScriptError.errorFlag,
              "content javascript",
              context.innerWindowID
            )
          );
        });
      }

      return promise;
    }

    return scripts;
  }

  getCompiledStyleSheets(window) {
    const sheetPromises = this.loadCSS();
    if (this.cssCodeHash) {
      sheetPromises.push(this.cssCodeCache.get(this.cssCodeHash));
    }
    if (window) {
      for (const sheetPromise of sheetPromises) {
        sheetCacheDocuments.get(sheetPromise).add(window.document);
      }
    }

    let sheets = sheetPromises.map(sheetPromise => sheetPromise.sheet);
    if (!sheets.every(sheet => sheet)) {
      return Promise.all(sheetPromises);
    }
    return sheets;
  }

  removeStyleSheets(window) {
    let { windowUtils } = window;

    let type =
      this.cssOrigin === "user"
        ? windowUtils.USER_SHEET
        : windowUtils.AUTHOR_SHEET;

    for (let url of this.css) {
      if (this.cssCache.has(url)) {
        const sheetPromise = this.cssCache.get(url);
        sheetCacheDocuments.get(sheetPromise).delete(window.document);
      }

      if (!window.closed) {
        runSafeSyncWithoutClone(
          windowUtils.removeSheetUsingURIString,
          url,
          type
        );
      }
    }

    const { cssCodeHash } = this;

    if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) {
      const sheetPromise = this.cssCodeCache.get(cssCodeHash);
      sheetCacheDocuments.get(sheetPromise).delete(window.document);
      if (sheetPromise.sheet && !window.closed) {
        runSafeSyncWithoutClone(
          windowUtils.removeSheet,
          sheetPromise.styleURI,
          type
        );
      }
    }
  }
}

// Represents a user script.
class UserScript extends Script {
  /**
   * @param {ExtensionChild & ExtensionChildContent} extension
   * @param {WebExtensionContentScript|object} matcher
   *        An object with a "matchesWindowGlobal" method and content script
   *        execution details.
   */
  constructor(extension, matcher) {
    super(extension, matcher);
    this.scriptType = "user_script";

    // This is an opaque object that the extension provides, it is associated to
    // the particular userScript and it is passed as a parameter to the custom
    // userScripts APIs defined by the extension.
    this.scriptMetadata = matcher.userScriptOptions.scriptMetadata;
    this.apiScriptURL =
      extension.manifest.user_scripts &&
      extension.manifest.user_scripts.api_script;

    // Add the apiScript to the js scripts to compile.
    if (this.apiScriptURL) {
      this.js = [this.apiScriptURL].concat(this.js);
    }

    // WeakMap<ContentScriptContextChild, Sandbox>
    this.sandboxes = new DefaultWeakMap(context => {
      return this.createSandbox(context);
    });
  }

  async inject(context) {
    let scripts = this.getCompiledScripts(context);
    if (scripts instanceof Promise) {
      // If we're supposed to inject at the start of the document load,
      // and we haven't already missed that point, block further parsing
      // until the scripts have been loaded.
      const { document } = context.contentWindow;
      if (
        this.runAt === "document_start" &&
        document.readyState !== "complete"
      ) {
        document.blockParsing(scripts, { blockScriptCreated: false });
      }
      scripts = await scripts;
    }
    // NOTE: Other than "await scripts" above, there is no other "await" before
    // execution. This ensures that document_start scripts execute immediately.

    let apiScript, sandboxScripts;

    if (this.apiScriptURL) {
      [apiScript, ...sandboxScripts] = scripts;
    } else {
      sandboxScripts = scripts;
    }

    // Load and execute the API script once per context.
    if (apiScript) {
      context.executeAPIScript(apiScript);
    }

    let userScriptSandbox = this.sandboxes.get(context);

    context.callOnClose({
      close: () => {
        // Destroy the userScript sandbox when the related ContentScriptContextChild instance
        // is being closed.
        this.sandboxes.delete(context);
        Cu.nukeSandbox(userScriptSandbox);
      },
    });

    // Notify listeners subscribed to the userScripts.onBeforeScript API event,
    // to allow extension API script to provide its custom APIs to the userScript.
    if (apiScript) {
      context.userScriptsEvents.emit(
        "on-before-script",
        this.scriptMetadata,
        userScriptSandbox
      );
    }

    for (let script of sandboxScripts) {
      script.executeInGlobal(userScriptSandbox);
    }
  }

  createSandbox(context) {
    const { contentWindow } = context;
    const contentPrincipal = contentWindow.document.nodePrincipal;
    const ssm = Services.scriptSecurityManager;

    let principal;
    if (contentPrincipal.isSystemPrincipal) {
      principal = ssm.createNullPrincipal(contentPrincipal.originAttributes);
    } else {
      principal = [contentPrincipal];
    }

    const sandbox = Cu.Sandbox(principal, {
      sandboxName: `User Script registered by ${this.extension.policy.debugName}`,
      sandboxPrototype: contentWindow,
      sameZoneAs: contentWindow,
      wantXrays: true,
      wantGlobalProperties: [
        "XMLHttpRequest",
        "fetch",
        "WebSocket",
        // The 'structuredClone' method in user scripts should clone within the
        // user script global by default, rather than cloning into the target
        // contentWindow.
        //
        // TODO(Bug 2022941): add explicit test coverage.
        "structuredClone",
      ],
      originAttributes: contentPrincipal.originAttributes,
      metadata: {
        "browser-id": context.browserId,
        "inner-window-id": context.innerWindowID,
        addonId: this.extension.policy.id,
      },
    });

    return sandbox;
  }
}

var contentScripts = new DefaultWeakMap(matcher => {
  const extension = lazy.ExtensionProcessScript.extensions.get(
    matcher.extension
  );

  if ("userScriptOptions" in matcher) {
    return new UserScript(extension, matcher);
  }

  return new Script(extension, matcher);
});

/**
 * An execution context for semi-privileged extension content scripts.
 *
 * This is the child side of the ContentScriptContextParent class
 * defined in ExtensionParent.sys.mjs.
 */
export class ContentScriptContextChild extends BaseContext {
  constructor(extension, contentWindow) {
    super("content_child", extension);

    this.setContentWindow(contentWindow);

    let frameId = lazy.WebNavigationFrames.getFrameId(contentWindow);
    this.frameId = frameId;

    this.browsingContextId = contentWindow.docShell.browsingContext.id;

    this.scripts = [];

    let contentPrincipal = contentWindow.document.nodePrincipal;
    let ssm = Services.scriptSecurityManager;

    // Copy origin attributes from the content window origin attributes to
    // preserve the user context id.
    let attrs = contentPrincipal.originAttributes;
    let extensionPrincipal = ssm.createContentPrincipal(
      this.extension.baseURI,
      attrs
    );

    this.isExtensionPage = contentPrincipal.equals(extensionPrincipal);

    if (this.isExtensionPage) {
      // This is an iframe with content script API enabled and its principal
      // should be the contentWindow itself. We create a sandbox with the
      // contentWindow as principal and with X-rays disabled because it
      // enables us to create the APIs object in this sandbox object and then
      // copying it into the iframe's window.  See bug 1214658.
      this.sandbox = Cu.Sandbox(contentWindow, {
        sandboxName: `Web-Accessible Extension Page ${extension.policy.debugName}`,
        sandboxPrototype: contentWindow,
        sameZoneAs: contentWindow,
        wantXrays: false,
        isWebExtensionContentScript: true,
      });
    } else {
      let principal;
      if (contentPrincipal.isSystemPrincipal) {
        // Make sure we don't hand out the system principal by accident.
        // Also make sure that the null principal has the right origin attributes.
        principal = ssm.createNullPrincipal(attrs);
      } else {
        principal = [contentPrincipal, extensionPrincipal];
      }
      // This metadata is required by the Developer Tools, in order for
      // the content script to be associated with both the extension and
      // the tab holding the content page.
      let metadata = {
        "browser-id": this.browserId,
        "inner-window-id": this.innerWindowID,
        addonId: extensionPrincipal.addonId,
      };

      // Within a content script, we want the 'structuredClone' method to clone
      // the object within the content script's global, rather than cloning it
      // into the the embedding scope. (see bug 2017797)
      let wantGlobalProperties = ["structuredClone"];

      let isMV2 = extension.manifestVersion == 2;
      let sandboxContentSecurityPolicy;
      if (isMV2) {
        // In MV2, fetch/XHR support cross-origin requests.
        // WebSocket was also included to avoid CSP effects (bug 1676024).
        wantGlobalProperties.push("XMLHttpRequest", "fetch", "WebSocket");
      } else {
        // In MV3, fetch/XHR have the same capabilities as the web page.

        // In MV3, the base CSP is enforced for content scripts. Overrides are
        // currently not supported, but this was considered at some point, see
        // https://bugzilla.mozilla.org/show_bug.cgi?id=1581611#c10
        sandboxContentSecurityPolicy = extension.policy.baseCSP;
      }
      this.sandbox = Cu.Sandbox(principal, {
        metadata,
        sandboxName: `Content Script ${extension.policy.debugName}`,
        sandboxPrototype: contentWindow,
        sandboxContentSecurityPolicy,
        sameZoneAs: contentWindow,
        wantXrays: true,
        isWebExtensionContentScript: true,
        wantExportHelpers: true,
        wantGlobalProperties,
        originAttributes: attrs,
      });

      // Preserve a copy of the original Error and Promise globals from the sandbox object,
      // which are used in the WebExtensions internals (before any content script code had
      // any chance to redefine them).
      this.cloneScopePromise = this.sandbox.Promise;
      this.cloneScopeError = this.sandbox.Error;

      if (isMV2) {
        // Preserve a copy of the original window's XMLHttpRequest and fetch
        // in a content object (fetch is manually binded to the window
        // to prevent it from raising a TypeError because content object is not
        // a real window).
        Cu.evalInSandbox(
          `
          this.content = {
            XMLHttpRequest: window.XMLHttpRequest,
            fetch: window.fetch.bind(window),
            WebSocket: window.WebSocket,
          };

          window.JSON = JSON;
          window.XMLHttpRequest = XMLHttpRequest;
          window.fetch = fetch;
          window.WebSocket = WebSocket;
        `,
          this.sandbox
        );
      } else {
        // The sandbox's JSON API can deal with values from the sandbox and the
        // contentWindow, but window.JSON cannot (and it could potentially be
        // spoofed by the web page). jQuery.parseJSON relies on window.JSON.
        Cu.evalInSandbox("window.JSON = JSON;", this.sandbox);
      }
    }

    Object.defineProperty(this, "principal", {
      value: Cu.getObjectPrincipal(this.sandbox),
      enumerable: true,
      configurable: true,
    });

    this.url = contentWindow.location.href;

    lazy.Schemas.exportLazyGetter(
      this.sandbox,
      "browser",
      () => this.chromeObj
    );
    lazy.Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj);

    // Keep track if the userScript API script has been already executed in this context
    // (e.g. because there are more then one UserScripts that match the related webpage
    // and so the UserScript apiScript has already been executed).
    this.hasUserScriptAPIs = false;

    // A lazy created EventEmitter related to userScripts-specific events.
    defineLazyGetter(this, "userScriptsEvents", () => {
      return new ExtensionCommon.EventEmitter();
    });
  }

  injectAPI() {
    if (!this.isExtensionPage) {
      throw new Error("Cannot inject extension API into non-extension window");
    }

    // This is an iframe with content script API enabled (See Bug 1214658)
    lazy.Schemas.exportLazyGetter(
      this.contentWindow,
      "browser",
      () => this.chromeObj
    );
    lazy.Schemas.exportLazyGetter(
      this.contentWindow,
      "chrome",
      () => this.chromeObj
    );
  }

  async logActivity(type, name, data) {
    ExtensionActivityLogChild.log(this, type, name, data);
  }

  get cloneScope() {
    return this.sandbox;
  }

  async executeAPIScript(apiScript) {
    // Execute the UserScript apiScript only once per context (e.g. more then one UserScripts
    // match the same webpage and the apiScript has already been executed).
    if (apiScript && !this.hasUserScriptAPIs) {
      this.hasUserScriptAPIs = true;
      apiScript.executeInGlobal(this.cloneScope);
    }
  }

  addScript(script) {
    if (script.requiresCleanup) {
      this.scripts.push(script);
    }
  }

  close() {
    super.unload();

    // Cleanup the scripts even if the contentWindow have been destroyed.
    for (let script of this.scripts) {
      script.cleanup(this.contentWindow);
    }

    if (this.contentWindow) {
      // Overwrite the content script APIs with an empty object if the APIs objects are still
      // defined in the content window (See Bug 1214658).
      if (this.isExtensionPage) {
        Cu.createObjectIn(this.contentWindow, { defineAs: "browser" });
        Cu.createObjectIn(this.contentWindow, { defineAs: "chrome" });
      }
    }
    Services.obs.notifyObservers(this.sandbox, "content-script-destroyed");
    Cu.nukeSandbox(this.sandbox);

    this.sandbox = null;
  }

  get childManager() {
    apiManager.lazyInit();
    let can = new CanOfAPIs(this, apiManager, {});
    let childManager = new ChildAPIManager(this, this.messageManager, can, {
      envType: "content_parent",
      url: this.url,
    });
    this.callOnClose(childManager);
    return redefineGetter(this, "childManager", childManager);
  }

  get chromeObj() {
    let chromeObj = Cu.createObjectIn(this.sandbox);
    this.childManager.inject(chromeObj);
    return redefineGetter(this, "chromeObj", chromeObj);
  }

  get messenger() {
    return redefineGetter(this, "messenger", new Messenger(this));
  }
}

// Responsible for tracking the lifetime of a document, to manage the lifetime
// of ContentScriptContextChild instances for that document. When a caller
// wants to run extension code in a document (often in a sandbox) and need to
// have that code's lifetime be bound to the document, they call
// ExtensionContent.getContext() (indirectly via ExtensionChild's getContext()).
//
// As part of the initialization of a ContentScriptContextChild, the document's
// lifetime is tracked here, by DocumentManager. This DocumentManager ensures
// that the ContentScriptContextChild and any supporting caches are cleared
// when the document is destroyed.
DocumentManager = {
  /** @type {Map<number, Map<ExtensionChild, ContentScriptContextChild>>} */
  contexts: new Map(),

  initialized: false,

  lazyInit() {
    if (this.initialized) {
      return;
    }
    this.initialized = true;

    Services.obs.addObserver(this, "inner-window-destroyed");
    Services.obs.addObserver(this, "memory-pressure");
  },

  uninit() {
    Services.obs.removeObserver(this, "inner-window-destroyed");
    Services.obs.removeObserver(this, "memory-pressure");
  },

  observers: {
    "inner-window-destroyed"(subject) {
      let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;

      // Close any existent content-script context for the destroyed window.
      if (this.contexts.has(windowId)) {
        let extensions = this.contexts.get(windowId);
        for (let context of extensions.values()) {
          context.close();
        }

        this.contexts.delete(windowId);
      }
    },
    "memory-pressure"(subject, topic, data) {
      let timeout = data === "heap-minimize" ? 0 : undefined;

      for (let cache of ChromeUtils.nondeterministicGetWeakSetKeys(
        scriptCaches
      )) {
        cache.clear(timeout);
      }
    },
  },

  /**
   * @param {object} subject
   * @param {keyof typeof DocumentManager.observers} topic
   * @param {any} data
   */
  observe(subject, topic, data) {
    this.observers[topic].call(this, subject, topic, data);
  },

  shutdownExtension(extension) {
    for (let extensions of this.contexts.values()) {
      let context = extensions.get(extension);
      if (context) {
        context.close();
        extensions.delete(extension);
      }
    }
  },

  getContexts(window) {
    let winId = getInnerWindowID(window);

    let extensions = this.contexts.get(winId);
    if (!extensions) {
      extensions = new Map();
      this.contexts.set(winId, extensions);
      // When ExtensionContent.getContext() calls DocumentManager.getContexts,
      // it is about to create ContentScriptContextChild instances that wraps
      // the document. Call DocumentManager.lazyInit() to ensure that we have
      // the relevant observers to close contexts as needed.
      this.lazyInit();
    }

    return extensions;
  },

  // For test use only.
  getContext(extensionId, window) {
    for (let [extension, context] of this.getContexts(window)) {
      if (extension.id === extensionId) {
        return context;
      }
    }
  },

  getAllContentScriptGlobals() {
    const sandboxes = [];
    for (let extensions of this.contexts.values()) {
      for (let ctx of extensions.values()) {
        sandboxes.push(ctx.sandbox);
      }
    }
    return sandboxes;
  },

  initExtensionContext(extension, window) {
    // Note: getContext() always returns an ContentScriptContextChild instance.
    // This can be a content script, or a sandbox holding the extension APIs
    // for an extension document embedded in a non-extension document.
    extension.getContext(window).injectAPI();
  },
};

export var ExtensionContent = {
  contentScripts,

  shutdownExtension(extension) {
    DocumentManager.shutdownExtension(extension);
  },

  // This helper is exported to be integrated in the devtools RDP actors,
  // that can use it to retrieve all the existent WebExtensions ContentScripts
  // running in the current content process and be able to show the
  // ContentScripts source in the DevTools Debugger panel.
  getAllContentScriptGlobals() {
    return DocumentManager.getAllContentScriptGlobals();
  },

  initExtensionContext(extension, window) {
    DocumentManager.initExtensionContext(extension, window);
  },

  /**
   * Implementation of extension.getContext(window), which returns the "context"
   * that wraps the current document in the window. The returned context is
   * aware of the document's lifetime, including bfcache transitions.
   *
   * @param {ExtensionChild} extension
   * @param {DOMWindow} window
   * @returns {ContentScriptContextChild}
   */
  getContext(extension, window) {
    let extensions = DocumentManager.getContexts(window);

    let context = extensions.get(extension);
    if (!context) {
      context = new ContentScriptContextChild(extension, window);
      extensions.set(extension, context);
    }
    return context;
  },

  // For test use only.
  getContextByExtensionId(extensionId, window) {
    return DocumentManager.getContext(extensionId, window);
  },

  async handleDetectLanguage({ windows }) {
    let wgc = WindowGlobalChild.getByInnerWindowId(windows[0]);
    let doc = wgc.browsingContext.window.document;
    await promiseDocumentReady(doc);

    // The CLD2 library can analyze HTML, but that uses more memory, and
    // emscripten can't shrink its heap, so we use plain text instead.
    let encoder = Cu.createDocumentEncoder("text/plain");
    encoder.init(doc, "text/plain", Ci.nsIDocumentEncoder.SkipInvisibleContent);

    let result = await lazy.LanguageDetector.detectLanguage({
      language:
        doc.documentElement.getAttribute("xml:lang") ||
        doc.documentElement.getAttribute("lang") ||
        doc.contentLanguage ||
        null,
      tld: doc.location.hostname.match(/[a-z]*$/)[0],
      text: encoder.encodeToStringWithMaxLength(60 * 1024),
      encoding: doc.characterSet,
    });
    return result.language === "un" ? "und" : result.language;
  },

  // Activate MV3 content scripts in all same-origin frames for this tab.
  handleActivateScripts({ options, windows }) {
    let policy = WebExtensionPolicy.getByID(options.id);

    // Order content scripts by run_at timing.
    let runAt = { document_start: [], document_end: [], document_idle: [] };
    for (let matcher of policy.contentScripts) {
      runAt[matcher.runAt].push(this.contentScripts.get(matcher));
    }

    // If we got here, checks in TabManagerBase.activateScripts assert:
    // 1) this is a MV3 extension, with Origin Controls,
    // 2) with a host permission (or content script) for the tab's top origin,
    // 3) and that host permission hasn't been granted yet.

    // We treat the action click as implicit user's choice to activate the
    // extension on the current site, so we can safely run (matching) content
    // scripts in all sameOriginWithTop frames while ignoring host permission.

    let { browsingContext } = WindowGlobalChild.getByInnerWindowId(windows[0]);
    for (let bc of browsingContext.getAllBrowsingContextsInSubtree()) {
      let wgc = bc.currentWindowContext.windowGlobalChild;
      if (wgc?.sameOriginWithTop) {
        // This is TOCTOU safe: if a frame navigated after same-origin check,
        // wgc.isClosed would be true and .matchesWindowGlobal() would fail.
        const runScript = cs => {
          if (cs.matchesWindowGlobal(wgc, /* ignorePermissions */ true)) {
            return cs.injectInto(bc.window);
          }
        };

        // Inject all matching content scripts in proper run_at order.
        Promise.all(runAt.document_start.map(runScript))
          .then(() => Promise.all(runAt.document_end.map(runScript)))
          .then(() => Promise.all(runAt.document_idle.map(runScript)));
      }
    }
  },

  // Used to executeScript, insertCSS and removeCSS.
  async handleActorExecute({ options, windows }) {
    let policy = WebExtensionPolicy.getByID(options.extensionId);
    // `WebExtensionContentScript` uses `MozDocumentMatcher::Matches` to ensure
    // that a script can be run in a document. That requires either `frameId`
    // or `allFrames` to be set. queryContent in the parent sets the `windows`
    // parameter to specify the frames to inject into, potentially multiple,
    // which we check below before calling script.injectInto. Therefore we can
    // safely set `allFrames=true` (which is needed to run in subframes).
    options.allFrames = true;
    let matcher = new WebExtensionContentScript(policy, options);

    Object.assign(matcher, {
      wantReturnValue: options.wantReturnValue,
      removeCSS: options.removeCSS,
      // Scripts (file or code) that should be executed must be passed via
      // options.jsExecuteScriptSources.
      // Any file paths passed via options.js are ignored.
      jsExecuteScriptSources: options.jsExecuteScriptSources,
    });
    let script = contentScripts.get(matcher);
    // preload any css files
    script.loadCSS();
    // Add the cssCode to the script, so that it can be converted into a cached URL.
    await script.addCSSCode(options.cssCode);
    delete options.cssCode;

    const executeInWin = innerId => {
      let wg = WindowGlobalChild.getByInnerWindowId(innerId);
      if (wg?.isCurrentGlobal && script.matchesWindowGlobal(wg)) {
        let bc = wg.browsingContext;

        return {
          frameId: bc.parent ? bc.id : 0,
          innerId,
          // Disable exception reporting directly to the console
          // in order to pass the exceptions back to the callsite.
          promise: script.injectInto(bc.window, false),
        };
      }
    };

    let promisesWithFrameIds = windows.map(executeInWin).filter(obj => obj);

    let result = await Promise.all(
      promisesWithFrameIds.map(async ({ frameId, innerId, promise }) => {
        if (!options.returnResultsWithFrameIds) {
          return promise;
        }

        const documentId = lazy.ExtensionDocumentId.getDocumentId(innerId);
        try {
          const result = await promise;

          return { frameId, documentId, result };
        } catch (error) {
          return { frameId, documentId, error };
        }
      })
    ).catch(
      // This is useful when we do not return results/errors with frame IDs in
      // the promises above.
      e => Promise.reject({ message: e.message })
    );

    try {
      // Check if the result can be structured-cloned before sending back.
      return Cu.cloneInto(result, this);
    } catch (e) {
      // Cloning errors are usually attributed to the last script.
      // TODO bug 2041680: path is sometimes incorrect.
      let path =
        options.jsExecuteScriptSources.at(-1)?.file ?? "<anonymous code>";
      let message = `Script '${path}' result is non-structured-clonable data`;
      // TODO bug 2041680: consider cloning individually for
      // scripting.executeScript and userScripts.execute
      return Promise.reject({ message, fileName: path });
    }
  },
};

/**
 * Child side of the ExtensionContent process actor, handles some tabs.* APIs.
 */
export class ExtensionContentChild extends JSProcessActorChild {
  receiveMessage({ name, data }) {
    if (!lazy.isContentScriptProcess) {
      return;
    }
    switch (name) {
      case "DetectLanguage":
        return ExtensionContent.handleDetectLanguage(data);
      case "Execute":
        return ExtensionContent.handleActorExecute(data);
      case "ActivateScripts":
        return ExtensionContent.handleActivateScripts(data);
    }
  }
}
