/* 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";

this.EXPORTED_SYMBOLS = ["ExtensionContent"];

/* globals ExtensionContent */

/*
 * This file handles the content process side of extensions. It mainly
 * takes care of content script injection, content script APIs, and
 * messaging.
 *
 * This file is also the initial entry point for addon processes.
 * ExtensionChild.jsm is responsible for functionality specific to addon
 * processes.
 */

const Ci = Components.interfaces;
const Cc = Components.classes;
const Cu = Components.utils;
const Cr = Components.results;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/AppConstants.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
                                  "resource://gre/modules/ExtensionManagement.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector",
                                  "resource:///modules/translation/LanguageDetector.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
                                  "resource://gre/modules/MatchPattern.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "MatchGlobs",
                                  "resource://gre/modules/MatchPattern.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
                                  "resource://gre/modules/MessageChannel.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
                                  "resource://gre/modules/PromiseUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                  "resource://gre/modules/Schemas.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames",
                                  "resource://gre/modules/WebNavigationFrames.jsm");

Cu.import("resource://gre/modules/ExtensionChild.jsm");
Cu.import("resource://gre/modules/ExtensionCommon.jsm");
Cu.import("resource://gre/modules/ExtensionUtils.jsm");

const {
  EventEmitter,
  LocaleData,
  defineLazyGetter,
  flushJarCache,
  getInnerWindowID,
  promiseDocumentReady,
  runSafeSyncWithoutClone,
} = ExtensionUtils;

const {
  BaseContext,
  SchemaAPIManager,
} = ExtensionCommon;

const {
  ChildAPIManager,
  Messenger,
} = ExtensionChild;

XPCOMUtils.defineLazyGetter(this, "console", ExtensionUtils.getConsole);

const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content";

function isWhenBeforeOrSame(when1, when2) {
  let table = {"document_start": 0,
               "document_end": 1,
               "document_idle": 2};
  return table[when1] <= table[when2];
}

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

  generateAPIs(...args) {
    if (!this.initialized) {
      this.initialized = true;
      for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS_CONTENT)) {
        this.loadScript(value);
      }
    }
    return super.generateAPIs(...args);
  }

  registerSchemaAPI(namespace, envType, getAPI) {
    if (envType == "content_child") {
      super.registerSchemaAPI(namespace, envType, getAPI);
    }
  }
}();

// Represents a content script.
function Script(extension, options, deferred = PromiseUtils.defer()) {
  this.extension = extension;
  this.options = options;
  this.run_at = this.options.run_at;
  this.js = this.options.js || [];
  this.css = this.options.css || [];
  this.remove_css = this.options.remove_css;
  this.match_about_blank = this.options.match_about_blank;

  this.deferred = deferred;

  this.matches_ = new MatchPattern(this.options.matches);
  this.exclude_matches_ = new MatchPattern(this.options.exclude_matches || null);
  // TODO: MatchPattern should pre-mangle host-only patterns so that we
  // don't need to call a separate match function.
  this.matches_host_ = new MatchPattern(this.options.matchesHost || null);
  this.include_globs_ = new MatchGlobs(this.options.include_globs);
  this.exclude_globs_ = new MatchGlobs(this.options.exclude_globs);

  this.requiresCleanup = !this.remove_css && (this.css.length > 0 || options.cssCode);
}

Script.prototype = {
  get cssURLs() {
    // We can handle CSS urls (css) and CSS code (cssCode).
    let urls = [];
    for (let url of this.css) {
      urls.push(this.extension.baseURI.resolve(url));
    }

    if (this.options.cssCode) {
      let url = "data:text/css;charset=utf-8," + encodeURIComponent(this.options.cssCode);
      urls.push(url);
    }

    return urls;
  },

  matches(window) {
    let uri = window.document.documentURIObject;
    let principal = window.document.nodePrincipal;

    // If mozAddonManager is present on this page, don't allow
    // content scripts.
    if (window.navigator.mozAddonManager !== undefined) {
      return false;
    }

    if (this.match_about_blank && ["about:blank", "about:srcdoc"].includes(uri.spec)) {
      // When matching about:blank/srcdoc documents, the checks below
      // need to be performed against the "owner" document's URI.
      uri = principal.URI;
    }

    // Documents from data: URIs also inherit the principal.
    if (Services.netUtils.URIChainHasFlags(uri, Ci.nsIProtocolHandler.URI_INHERITS_SECURITY_CONTEXT)) {
      if (!this.match_about_blank) {
        return false;
      }
      uri = principal.URI;
    }

    if (!(this.matches_.matches(uri) || this.matches_host_.matchesIgnoringPath(uri))) {
      return false;
    }

    if (this.exclude_matches_.matches(uri)) {
      return false;
    }

    if (this.options.include_globs != null) {
      if (!this.include_globs_.matches(uri.spec)) {
        return false;
      }
    }

    if (this.exclude_globs_.matches(uri.spec)) {
      return false;
    }

    if (this.options.frame_id != null) {
      if (WebNavigationFrames.getFrameId(window) != this.options.frame_id) {
        return false;
      }
    } else if (!this.options.all_frames && window.top != window) {
      return false;
    }

    return true;
  },

  cleanup(window) {
    if (!this.remove_css) {
      let winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindowUtils);

      for (let url of this.cssURLs) {
        runSafeSyncWithoutClone(winUtils.removeSheetUsingURIString, url, winUtils.AUTHOR_SHEET);
      }
    }
  },

  /**
   * Tries to inject this script into the given window and sandbox, if
   * there are pending operations for the window's current load state.
   *
   * @param {Window} window
   *        The DOM Window to inject the scripts and CSS into.
   * @param {Sandbox} sandbox
   *        A Sandbox inheriting from `window` in which to evaluate the
   *        injected scripts.
   * @param {function} shouldRun
   *        A function which, when passed the document load state that a
   *        script is expected to run at, returns `true` if we should
   *        currently be injecting scripts for that load state.
   *
   *        For initial injection of a script, this function should
   *        return true if the document is currently in or has already
   *        passed through the given state. For injections triggered by
   *        document state changes, it should only return true if the
   *        given state exactly matches the state that triggered the
   *        change.
   * @param {string} when
   *        The document's current load state, or if triggered by a
   *        document state change, the new document state that triggered
   *        the injection.
   */
  tryInject(window, sandbox, shouldRun, when) {
    if (shouldRun("document_start")) {
      let {cssURLs} = this;
      if (cssURLs.length > 0) {
        let winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor)
                             .getInterface(Ci.nsIDOMWindowUtils);

        let method = this.remove_css ? winUtils.removeSheetUsingURIString : winUtils.loadSheetUsingURIString;
        for (let url of cssURLs) {
          runSafeSyncWithoutClone(method, url, winUtils.AUTHOR_SHEET);
        }

        this.deferred.resolve();
      }
    }

    let result;
    let scheduled = this.run_at || "document_idle";
    if (shouldRun(scheduled)) {
      for (let [i, url] of this.js.entries()) {
        let options = {
          target: sandbox,
          charset: "UTF-8",
          // Inject the last script asynchronously unless we're expected to
          // inject before any page scripts have run, and we haven't already
          // missed that boat.
          async: (i === this.js.length - 1) &&
                 (this.run_at !== "document_start" || when !== "document_start"),
        };
        try {
          result = Services.scriptloader.loadSubScriptWithOptions(url, options);
        } catch (e) {
          Cu.reportError(e);
          this.deferred.reject(e);
        }
      }

      if (this.options.jsCode) {
        try {
          result = Cu.evalInSandbox(this.options.jsCode, sandbox, "latest");
        } catch (e) {
          Cu.reportError(e);
          this.deferred.reject(e);
        }
      }

      this.deferred.resolve(result);
    }
  },
};

function getWindowMessageManager(contentWindow) {
  let ir = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                        .getInterface(Ci.nsIDocShell)
                        .QueryInterface(Ci.nsIInterfaceRequestor);
  try {
    return ir.getInterface(Ci.nsIContentFrameMessageManager);
  } catch (e) {
    // Some windows don't support this interface (hidden window).
    return null;
  }
}

var DocumentManager;
var ExtensionManager;

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

    let {isExtensionPage} = contextOptions;

    this.isExtensionPage = isExtensionPage;

    this.setContentWindow(contentWindow);

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

    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. overwrite the addonId.
    let attrs = contentPrincipal.originAttributes;
    attrs.addonId = this.extension.id;
    let extensionPrincipal = ssm.createCodebasePrincipal(this.extension.baseURI, attrs);

    let principal;
    if (ssm.isSystemPrincipal(contentPrincipal)) {
      // 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];
    }

    if (isExtensionPage) {
      if (ExtensionManagement.getAddonIdForWindow(this.contentWindow) != this.extension.id) {
        throw new Error("Invalid target window for this extension context");
      }
      // 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 for rationale)
      this.sandbox = Cu.Sandbox(contentWindow, {
        sandboxName: `Web-Accessible Extension Page ${this.extension.id}`,
        sandboxPrototype: contentWindow,
        sameZoneAs: contentWindow,
        wantXrays: false,
        isWebExtensionContentScript: true,
      });
    } else {
      // 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 = {
        "inner-window-id": this.innerWindowID,
        addonId: attrs.addonId,
      };

      this.sandbox = Cu.Sandbox(principal, {
        metadata,
        sandboxName: `Content Script ${this.extension.id}`,
        sandboxPrototype: contentWindow,
        sameZoneAs: contentWindow,
        wantXrays: true,
        isWebExtensionContentScript: true,
        wantExportHelpers: true,
        wantGlobalProperties: ["XMLHttpRequest", "fetch"],
        originAttributes: attrs,
      });

      Cu.evalInSandbox(`
        window.JSON = JSON;
        window.XMLHttpRequest = XMLHttpRequest;
        window.fetch = fetch;
      `, this.sandbox);
    }

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

    this.url = contentWindow.location.href;

    defineLazyGetter(this, "chromeObj", () => {
      let chromeObj = Cu.createObjectIn(this.sandbox);

      Schemas.inject(chromeObj, this.childManager);
      return chromeObj;
    });

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

    // This is an iframe with content script API enabled (bug 1214658)
    if (isExtensionPage) {
      Schemas.exportLazyGetter(this.contentWindow,
                               "browser", () => this.chromeObj);
      Schemas.exportLazyGetter(this.contentWindow,
                               "chrome", () => this.chromeObj);
    }
  }

  get cloneScope() {
    return this.sandbox;
  }

  execute(script, shouldRun, when) {
    script.tryInject(this.contentWindow, this.sandbox, shouldRun, when);
  }

  addScript(script, when) {
    let state = DocumentManager.getWindowState(this.contentWindow);
    this.execute(script, scheduled => isWhenBeforeOrSame(scheduled, state), when);

    // Save the script in case it has pending operations in later load
    // states, but only if we're before document_idle, or require cleanup.
    if (state != "document_idle" || script.requiresCleanup) {
      this.scripts.push(script);
    }
  }

  triggerScripts(documentState) {
    for (let script of this.scripts) {
      this.execute(script, scheduled => scheduled == documentState, documentState);
    }
    if (documentState == "document_idle") {
      // Don't bother saving scripts after document_idle.
      this.scripts = this.scripts.filter(script => script.requiresCleanup);
    }
  }

  close() {
    super.unload();

    if (this.contentWindow) {
      for (let script of this.scripts) {
        if (script.requiresCleanup) {
          script.cleanup(this.contentWindow);
        }
      }

      // Overwrite the content script APIs with an empty object if the APIs objects are still
      // defined in the content window (bug 1214658).
      if (this.isExtensionPage) {
        Cu.createObjectIn(this.contentWindow, {defineAs: "browser"});
        Cu.createObjectIn(this.contentWindow, {defineAs: "chrome"});
      }
    }
    Cu.nukeSandbox(this.sandbox);
    this.sandbox = null;
  }
}

defineLazyGetter(ContentScriptContextChild.prototype, "messenger", function() {
  // The |sender| parameter is passed directly to the extension.
  let sender = {id: this.extension.id, frameId: this.frameId, url: this.url};
  let filter = {extensionId: this.extension.id};
  let optionalFilter = {frameId: this.frameId};

  return new Messenger(this, [this.messageManager], sender, filter, optionalFilter);
});

defineLazyGetter(ContentScriptContextChild.prototype, "childManager", function() {
  let localApis = {};
  apiManager.generateAPIs(this, localApis);

  let childManager = new ChildAPIManager(this, this.messageManager, localApis, {
    envType: "content_parent",
    url: this.url,
  });

  this.callOnClose(childManager);

  return childManager;
});

// Responsible for creating ExtensionContexts and injecting content
// scripts into them when new documents are created.
DocumentManager = {
  extensionCount: 0,

  // Map[windowId -> Map[extensionId -> ContentScriptContextChild]]
  contentScriptWindows: new Map(),

  // Map[windowId -> ContentScriptContextChild]
  extensionPageWindows: new Map(),

  init() {
    Services.obs.addObserver(this, "content-document-global-created", false);
    Services.obs.addObserver(this, "document-element-inserted", false);
    Services.obs.addObserver(this, "inner-window-destroyed", false);
  },

  uninit() {
    Services.obs.removeObserver(this, "content-document-global-created");
    Services.obs.removeObserver(this, "document-element-inserted");
    Services.obs.removeObserver(this, "inner-window-destroyed");
  },

  getWindowState(contentWindow) {
    let readyState = contentWindow.document.readyState;
    if (readyState == "complete") {
      return "document_idle";
    }
    if (readyState == "interactive") {
      return "document_end";
    }
    return "document_start";
  },

  loadInto(window) {
    // Enable the content script APIs should be available in subframes' window
    // if it is recognized as a valid addon id (see Bug 1214658 for rationale).
    const {
      NO_PRIVILEGES,
      CONTENTSCRIPT_PRIVILEGES,
      FULL_PRIVILEGES,
    } = ExtensionManagement.API_LEVELS;
    let extensionId = ExtensionManagement.getAddonIdForWindow(window);
    let apiLevel = ExtensionManagement.getAPILevelForWindow(window, extensionId);

    if (apiLevel != NO_PRIVILEGES) {
      let extension = ExtensionManager.get(extensionId);
      if (extension) {
        if (apiLevel == CONTENTSCRIPT_PRIVILEGES) {
          DocumentManager.getExtensionPageContext(extension, window);
        } else if (apiLevel == FULL_PRIVILEGES) {
          ExtensionChild.createExtensionContext(extension, window);
        }
      }
    }
  },

  observe: function(subject, topic, data) {
    // For some types of documents (about:blank), we only see the first
    // notification, for others (data: URIs) we only observe the second.
    if (topic == "content-document-global-created" || topic == "document-element-inserted") {
      let document = subject;
      let window = document && document.defaultView;

      if (topic == "content-document-global-created") {
        window = subject;
        document = window && window.document;
      }

      if (!document || !document.location || !window) {
        return;
      }

      // Make sure we only load into frames that ExtensionContent.init
      // was called on (i.e., not frames for social or sidebars).
      let mm = getWindowMessageManager(window);
      if (!mm || !ExtensionContent.globals.has(mm)) {
        return;
      }

      // Load on document-element-inserted, except for about:blank which doesn't
      // see it, and needs special late handling on DOMContentLoaded event.
      if (topic === "document-element-inserted") {
        this.loadInto(window);
        this.trigger("document_start", window);
      }

      /* eslint-disable mozilla/balanced-listeners */
      window.addEventListener("DOMContentLoaded", this, true);
      window.addEventListener("load", this, true);
      /* eslint-enable mozilla/balanced-listeners */
    } else if (topic == "inner-window-destroyed") {
      let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;

      MessageChannel.abortResponses({innerWindowID: windowId});

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

        this.contentScriptWindows.delete(windowId);
      }

      // Close any existent iframe extension page context for the destroyed window.
      if (this.extensionPageWindows.has(windowId)) {
        let context = this.extensionPageWindows.get(windowId);
        context.close();
        this.extensionPageWindows.delete(windowId);
      }

      ExtensionChild.destroyExtensionContext(windowId);
    }
  },

  handleEvent: function(event) {
    let window = event.currentTarget;
    if (event.target != window.document) {
      // We use capturing listeners so we have precedence over content script
      // listeners, but only care about events targeted to the element we're
      // listening on.
      return;
    }
    window.removeEventListener(event.type, this, true);

    // Need to check if we're still on the right page? Greasemonkey does this.

    if (event.type == "DOMContentLoaded") {
      // By this time, we can be sure if this is an explicit about:blank
      // document, and if it needs special late loading and fake trigger.
      if (window.location.href === "about:blank") {
        this.loadInto(window);
        this.trigger("document_start", window);
      }
      this.trigger("document_end", window);
    } else if (event.type == "load") {
      this.trigger("document_idle", window);
    }
  },

  // Used to executeScript, insertCSS and removeCSS.
  executeScript(global, extensionId, options) {
    let extension = ExtensionManager.get(extensionId);

    let executeInWin = (window) => {
      let deferred = PromiseUtils.defer();
      let script = new Script(extension, options, deferred);

      if (script.matches(window)) {
        let context = this.getContentScriptContext(extension, window);
        context.addScript(script);
        return deferred.promise;
      }
      return null;
    };

    let promises = Array.from(this.enumerateWindows(global.docShell), executeInWin)
                        .filter(promise => promise);

    if (!promises.length) {
      let details = {};
      for (let key of ["all_frames", "frame_id", "matches_about_blank", "matchesHost"]) {
        if (key in options) {
          details[key] = options[key];
        }
      }

      return Promise.reject({message: `No window matching ${JSON.stringify(details)}`});
    }
    if (!options.all_frames && promises.length > 1) {
      return Promise.reject({message: `Internal error: Script matched multiple windows`});
    }
    return Promise.all(promises);
  },

  enumerateWindows: function* (docShell) {
    let window = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
                         .getInterface(Ci.nsIDOMWindow);
    yield window;

    for (let i = 0; i < docShell.childCount; i++) {
      let child = docShell.getChildAt(i).QueryInterface(Ci.nsIDocShell);
      yield* this.enumerateWindows(child);
    }
  },

  getContentScriptGlobalsForWindow(window) {
    let winId = getInnerWindowID(window);
    let extensions = this.contentScriptWindows.get(winId);

    if (extensions) {
      return Array.from(extensions.values(), ctx => ctx.sandbox);
    }

    return [];
  },

  getContentScriptContext(extension, window) {
    let winId = getInnerWindowID(window);
    if (!this.contentScriptWindows.has(winId)) {
      this.contentScriptWindows.set(winId, new Map());
    }

    let extensions = this.contentScriptWindows.get(winId);
    if (!extensions.has(extension.id)) {
      let context = new ContentScriptContextChild(extension, window);
      extensions.set(extension.id, context);
    }

    return extensions.get(extension.id);
  },

  getExtensionPageContext(extension, window) {
    let winId = getInnerWindowID(window);

    let context = this.extensionPageWindows.get(winId);
    if (!context) {
      let context = new ContentScriptContextChild(extension, window, {isExtensionPage: true});
      this.extensionPageWindows.set(winId, context);
    }

    return context;
  },

  startupExtension(extensionId) {
    if (this.extensionCount == 0) {
      this.init();
    }
    this.extensionCount++;

    let extension = ExtensionManager.get(extensionId);
    for (let global of ExtensionContent.globals.keys()) {
      // Note that we miss windows in the bfcache here. In theory we
      // could execute content scripts on a pageshow event for that
      // window, but that seems extreme.
      for (let window of this.enumerateWindows(global.docShell)) {
        for (let script of extension.scripts) {
          if (script.matches(window)) {
            let context = this.getContentScriptContext(extension, window);
            context.addScript(script);
          }
        }
      }
    }
  },

  shutdownExtension(extensionId) {
    // Clean up content-script contexts on extension shutdown.
    for (let [, extensions] of this.contentScriptWindows) {
      let context = extensions.get(extensionId);
      if (context) {
        context.close();
        extensions.delete(extensionId);
      }
    }

    // Clean up iframe extension page contexts on extension shutdown.
    for (let [winId, context] of this.extensionPageWindows) {
      if (context.extension.id == extensionId) {
        context.close();
        this.extensionPageWindows.delete(winId);
      }
    }

    ExtensionChild.shutdownExtension(extensionId);

    MessageChannel.abortResponses({extensionId});

    this.extensionCount--;
    if (this.extensionCount == 0) {
      this.uninit();
    }
  },

  trigger(when, window) {
    if (when === "document_start") {
      for (let extension of ExtensionManager.extensions.values()) {
        for (let script of extension.scripts) {
          if (script.matches(window)) {
            let context = this.getContentScriptContext(extension, window);
            context.addScript(script, when);
          }
        }
      }
    } else {
      let contexts = this.contentScriptWindows.get(getInnerWindowID(window)) || new Map();
      for (let context of contexts.values()) {
        context.triggerScripts(when);
      }
    }
  },
};

// Represents a browser extension in the content process.
class BrowserExtensionContent extends EventEmitter {
  constructor(data) {
    super();

    this.id = data.id;
    this.uuid = data.uuid;
    this.data = data;
    this.instanceId = data.instanceId;

    this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`;
    Services.cpmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this);

    this.scripts = data.content_scripts.map(scriptData => new Script(this, scriptData));
    this.webAccessibleResources = new MatchGlobs(data.webAccessibleResources);
    this.whiteListedHosts = new MatchPattern(data.whiteListedHosts);
    this.permissions = data.permissions;
    this.principal = data.principal;

    this.localeData = new LocaleData(data.localeData);

    this.manifest = data.manifest;
    this.baseURI = Services.io.newURI(data.baseURL, null, null);

    // Only used in addon processes.
    this.views = new Set();

    let uri = Services.io.newURI(data.resourceURL, null, null);

    if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
      // Extension.jsm takes care of this in the parent.
      ExtensionManagement.startupExtension(this.uuid, uri, this);
    }
  }

  shutdown() {
    Services.cpmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this);

    if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
      ExtensionManagement.shutdownExtension(this.uuid);
    }
  }

  emit(event, ...args) {
    Services.cpmm.sendAsyncMessage(this.MESSAGE_EMIT_EVENT, {event, args});

    super.emit(event, ...args);
  }

  receiveMessage({name, data}) {
    if (name === this.MESSAGE_EMIT_EVENT) {
      super.emit(data.event, ...data.args);
    }
  }

  localizeMessage(...args) {
    return this.localeData.localizeMessage(...args);
  }

  localize(...args) {
    return this.localeData.localize(...args);
  }

  hasPermission(perm) {
    let match = /^manifest:(.*)/.exec(perm);
    if (match) {
      return this.manifest[match[1]] != null;
    }
    return this.permissions.has(perm);
  }
}

ExtensionManager = {
  // Map[extensionId, BrowserExtensionContent]
  extensions: new Map(),

  init() {
    Schemas.init();
    ExtensionChild.initOnce();

    Services.cpmm.addMessageListener("Extension:Startup", this);
    Services.cpmm.addMessageListener("Extension:Shutdown", this);
    Services.cpmm.addMessageListener("Extension:FlushJarCache", this);

    if (Services.cpmm.initialProcessData && "Extension:Extensions" in Services.cpmm.initialProcessData) {
      let extensions = Services.cpmm.initialProcessData["Extension:Extensions"];
      for (let data of extensions) {
        this.extensions.set(data.id, new BrowserExtensionContent(data));
        DocumentManager.startupExtension(data.id);
      }
    }
  },

  get(extensionId) {
    return this.extensions.get(extensionId);
  },

  receiveMessage({name, data}) {
    let extension;
    switch (name) {
      case "Extension:Startup": {
        extension = new BrowserExtensionContent(data);

        this.extensions.set(data.id, extension);

        DocumentManager.startupExtension(data.id);

        Services.cpmm.sendAsyncMessage("Extension:StartupComplete");
        break;
      }

      case "Extension:Shutdown": {
        extension = this.extensions.get(data.id);
        extension.shutdown();

        DocumentManager.shutdownExtension(data.id);

        this.extensions.delete(data.id);
        break;
      }

      case "Extension:FlushJarCache": {
        let nsIFile = Components.Constructor("@mozilla.org/file/local;1", "nsIFile",
                                             "initWithPath");
        let file = new nsIFile(data.path);
        flushJarCache(file);
        Services.cpmm.sendAsyncMessage("Extension:FlushJarCacheComplete");
        break;
      }
    }
  },
};

class ExtensionGlobal {
  constructor(global) {
    this.global = global;

    MessageChannel.addListener(global, "Extension:Capture", this);
    MessageChannel.addListener(global, "Extension:DetectLanguage", this);
    MessageChannel.addListener(global, "Extension:Execute", this);
    MessageChannel.addListener(global, "WebNavigation:GetFrame", this);
    MessageChannel.addListener(global, "WebNavigation:GetAllFrames", this);

    this.windowId = global.content
                          .QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIDOMWindowUtils)
                          .outerWindowID;

    global.sendAsyncMessage("Extension:TopWindowID", {windowId: this.windowId});
  }

  uninit() {
    this.global.sendAsyncMessage("Extension:RemoveTopWindowID", {windowId: this.windowId});
  }

  get messageFilterStrict() {
    return {
      innerWindowID: getInnerWindowID(this.global.content),
    };
  }

  receiveMessage({target, messageName, recipient, data}) {
    switch (messageName) {
      case "Extension:Capture":
        return this.handleExtensionCapture(data.width, data.height, data.options);
      case "Extension:DetectLanguage":
        return this.handleDetectLanguage(target);
      case "Extension:Execute":
        return this.handleExtensionExecute(target, recipient.extensionId, data.options);
      case "WebNavigation:GetFrame":
        return this.handleWebNavigationGetFrame(data.options);
      case "WebNavigation:GetAllFrames":
        return this.handleWebNavigationGetAllFrames();
    }
  }

  handleExtensionCapture(width, height, options) {
    let win = this.global.content;

    const XHTML_NS = "http://www.w3.org/1999/xhtml";
    let canvas = win.document.createElementNS(XHTML_NS, "canvas");
    canvas.width = width;
    canvas.height = height;
    canvas.mozOpaque = true;

    let ctx = canvas.getContext("2d");

    // We need to scale the image to the visible size of the browser,
    // in order for the result to appear as the user sees it when
    // settings like full zoom come into play.
    ctx.scale(canvas.width / win.innerWidth, canvas.height / win.innerHeight);

    ctx.drawWindow(win, win.scrollX, win.scrollY, win.innerWidth, win.innerHeight, "#fff");

    return canvas.toDataURL(`image/${options.format}`, options.quality / 100);
  }

  handleDetectLanguage(target) {
    let doc = target.content.document;

    return promiseDocumentReady(doc).then(() => {
      let elem = doc.documentElement;

      let language = (elem.getAttribute("xml:lang") || elem.getAttribute("lang") ||
                      doc.contentLanguage || null);

      // We only want the last element of the TLD here.
      // Only country codes have any effect on the results, but other
      // values cause no harm.
      let tld = doc.location.hostname.match(/[a-z]*$/)[0];

      // The CLD2 library used by the language detector is capable of
      // analyzing raw HTML. Unfortunately, that takes much more memory,
      // and since it's hosted by emscripten, and therefore can't shrink
      // its heap after it's grown, it has a performance cost.
      // So we send plain text instead.
      let encoder = Cc["@mozilla.org/layout/documentEncoder;1?type=text/plain"].createInstance(Ci.nsIDocumentEncoder);
      encoder.init(doc, "text/plain", encoder.SkipInvisibleContent);
      let text = encoder.encodeToStringWithMaxLength(60 * 1024);

      let encoding = doc.characterSet;

      return LanguageDetector.detectLanguage({language, tld, text, encoding})
        .then(result => result.language === "un" ? "und" : result.language);
    });
  }

  // Used to executeScript, insertCSS and removeCSS.
  handleExtensionExecute(target, extensionId, options) {
    return DocumentManager.executeScript(target, extensionId, options).then(result => {
      try {
        // Make sure we can structured-clone the result value before
        // we try to send it back over the message manager.
        Cu.cloneInto(result, target);
      } catch (e) {
        return Promise.reject({message: "Script returned non-structured-clonable data"});
      }
      return result;
    });
  }

  handleWebNavigationGetFrame({frameId}) {
    return WebNavigationFrames.getFrame(this.global.docShell, frameId);
  }

  handleWebNavigationGetAllFrames() {
    return WebNavigationFrames.getAllFrames(this.global.docShell);
  }
}

this.ExtensionContent = {
  globals: new Map(),

  init(global) {
    this.globals.set(global, new ExtensionGlobal(global));
    ExtensionChild.init(global);
  },

  uninit(global) {
    ExtensionChild.uninit(global);
    this.globals.get(global).uninit();
    this.globals.delete(global);
  },

  // This helper is exported to be integrated in the devtools RDP actors,
  // that can use it to retrieve the existent WebExtensions ContentScripts
  // of a target window and be able to show the ContentScripts source in the
  // DevTools Debugger panel.
  getContentScriptGlobalsForWindow(window) {
    return DocumentManager.getContentScriptGlobalsForWindow(window);
  },
};

ExtensionManager.init();