/* 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 = ["ExtensionChild"];

/*
 * This file handles addon logic that is independent of the chrome process.
 * When addons run out-of-process, this is the main entry point.
 * Its primary function is managing addon globals.
 *
 * Don't put contentscript logic here, use ExtensionContent.jsm instead.
 */

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

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

XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
                                  "resource://gre/modules/MessageChannel.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NativeApp",
                                  "resource://gre/modules/NativeMessaging.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
                                  "resource://gre/modules/PromiseUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                  "resource://gre/modules/Schemas.jsm");

const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";

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

const {
  DefaultMap,
  EventManager,
  SingletonEventManager,
  SpreadArgs,
  defineLazyGetter,
  getInnerWindowID,
  getMessageManager,
  getUniqueId,
  injectAPI,
} = ExtensionUtils;

const {
  BaseContext,
  LocalAPIImplementation,
  SchemaAPIInterface,
  SchemaAPIManager,
} = ExtensionCommon;

var ExtensionChild;

/**
 * Abstraction for a Port object in the extension API.
 *
 * @param {BaseContext} context The context that owns this port.
 * @param {nsIMessageSender} senderMM The message manager to send messages to.
 * @param {Array<nsIMessageListenerManager>} receiverMMs Message managers to
 *     listen on.
 * @param {string} name Arbitrary port name as defined by the addon.
 * @param {string} id An ID that uniquely identifies this port's channel.
 * @param {object} sender The `port.sender` property.
 * @param {object} recipient The recipient of messages sent from this port.
 */
class Port {
  constructor(context, senderMM, receiverMMs, name, id, sender, recipient) {
    this.context = context;
    this.senderMM = senderMM;
    this.receiverMMs = receiverMMs;
    this.name = name;
    this.id = id;
    this.sender = sender;
    this.recipient = recipient;
    this.disconnected = false;
    this.disconnectListeners = new Set();
    this.unregisterMessageFuncs = new Set();

    // Common options for onMessage and onDisconnect.
    this.handlerBase = {
      messageFilterStrict: {portId: id},

      filterMessage: (sender, recipient) => {
        return sender.contextId !== this.context.contextId;
      },
    };

    this.disconnectHandler = Object.assign({
      receiveMessage: ({data}) => this.disconnectByOtherEnd(data),
    }, this.handlerBase);

    MessageChannel.addListener(this.receiverMMs, "Extension:Port:Disconnect", this.disconnectHandler);

    this.context.callOnClose(this);
  }

  api() {
    let portObj = Cu.createObjectIn(this.context.cloneScope);

    let portError = null;
    let publicAPI = {
      name: this.name,

      disconnect: () => {
        this.disconnect();
      },

      postMessage: json => {
        this.postMessage(json);
      },

      onDisconnect: new EventManager(this.context, "Port.onDisconnect", fire => {
        return this.registerOnDisconnect(error => {
          portError = error && this.context.normalizeError(error);
          fire.withoutClone(portObj);
        });
      }).api(),

      onMessage: new EventManager(this.context, "Port.onMessage", fire => {
        return this.registerOnMessage(msg => {
          msg = Cu.cloneInto(msg, this.context.cloneScope);
          fire.withoutClone(msg, portObj);
        });
      }).api(),

      get error() {
        return portError;
      },
    };

    if (this.sender) {
      publicAPI.sender = this.sender;
    }

    injectAPI(publicAPI, portObj);
    return portObj;
  }

  postMessage(json) {
    if (this.disconnected) {
      throw new this.context.cloneScope.Error("Attempt to postMessage on disconnected port");
    }

    this._sendMessage("Extension:Port:PostMessage", json);
  }

  /**
   * Register a callback that is called when the port is disconnected by the
   * *other* end. The callback is automatically unregistered when the port or
   * context is closed.
   *
   * @param {function} callback Called when the other end disconnects the port.
   *     If the disconnect is caused by an error, the first parameter is an
   *     object with a "message" string property that describes the cause.
   * @returns {function} Function to unregister the listener.
   */
  registerOnDisconnect(callback) {
    let listener = error => {
      if (this.context.active && !this.disconnected) {
        callback(error);
      }
    };
    this.disconnectListeners.add(listener);
    return () => {
      this.disconnectListeners.delete(listener);
    };
  }

  /**
   * Register a callback that is called when a message is received. The callback
   * is automatically unregistered when the port or context is closed.
   *
   * @param {function} callback Called when a message is received.
   * @returns {function} Function to unregister the listener.
   */
  registerOnMessage(callback) {
    let handler = Object.assign({
      receiveMessage: ({data}) => {
        if (this.context.active && !this.disconnected) {
          callback(data);
        }
      },
    }, this.handlerBase);

    let unregister = () => {
      this.unregisterMessageFuncs.delete(unregister);
      MessageChannel.removeListener(this.receiverMMs, "Extension:Port:PostMessage", handler);
    };
    MessageChannel.addListener(this.receiverMMs, "Extension:Port:PostMessage", handler);
    this.unregisterMessageFuncs.add(unregister);
    return unregister;
  }

  _sendMessage(message, data) {
    let options = {
      recipient: Object.assign({}, this.recipient, {portId: this.id}),
      responseType: MessageChannel.RESPONSE_NONE,
    };

    return this.context.sendMessage(this.senderMM, message, data, options);
  }

  handleDisconnection() {
    MessageChannel.removeListener(this.receiverMMs, "Extension:Port:Disconnect", this.disconnectHandler);
    for (let unregister of this.unregisterMessageFuncs) {
      unregister();
    }
    this.context.forgetOnClose(this);
    this.disconnected = true;
  }

  /**
   * Disconnect the port from the other end (which may not even exist).
   *
   * @param {Error|{message: string}} [error] The reason for disconnecting,
   *     if it is an abnormal disconnect.
   */
  disconnectByOtherEnd(error = null) {
    if (this.disconnected) {
      return;
    }

    for (let listener of this.disconnectListeners) {
      listener(error);
    }

    this.handleDisconnection();
  }

  /**
   * Disconnect the port from this end.
   *
   * @param {Error|{message: string}} [error] The reason for disconnecting,
   *     if it is an abnormal disconnect.
   */
  disconnect(error = null) {
    if (this.disconnected) {
      // disconnect() may be called without side effects even after the port is
      // closed - https://developer.chrome.com/extensions/runtime#type-Port
      return;
    }
    this.handleDisconnection();
    if (error) {
      error = {message: this.context.normalizeError(error).message};
    }
    this._sendMessage("Extension:Port:Disconnect", error);
  }

  close() {
    this.disconnect();
  }
}

class NativePort extends Port {
  postMessage(data) {
    data = NativeApp.encodeMessage(this.context, data);

    return super.postMessage(data);
  }
}

/**
 * Each extension context gets its own Messenger object. It handles the
 * basics of sendMessage, onMessage, connect and onConnect.
 *
 * @param {BaseContext} context The context to which this Messenger is tied.
 * @param {Array<nsIMessageListenerManager>} messageManagers
 *     The message managers used to receive messages (e.g. onMessage/onConnect
 *     requests).
 * @param {object} sender Describes this sender to the recipient. This object
 *     is extended further by BaseContext's sendMessage method and appears as
 *     the `sender` object to `onConnect` and `onMessage`.
 *     Do not set the `extensionId`, `contextId` or `tab` properties. The former
 *     two are added by BaseContext's sendMessage, while `sender.tab` is set by
 *     the ProxyMessenger in the main process.
 * @param {object} filter A recipient filter to apply to incoming messages from
 *     the broker. Messages are only handled by this Messenger if all key-value
 *     pairs match the `recipient` as specified by the sender of the message.
 *     In other words, this filter defines the required fields of `recipient`.
 * @param {object} [optionalFilter] An additional filter to apply to incoming
 *     messages. Unlike `filter`, the keys from `optionalFilter` are allowed to
 *     be omitted from `recipient`. Only keys that are present in both
 *     `optionalFilter` and `recipient` are applied to filter incoming messages.
 */
class Messenger {
  constructor(context, messageManagers, sender, filter, optionalFilter) {
    this.context = context;
    this.messageManagers = messageManagers;
    this.sender = sender;
    this.filter = filter;
    this.optionalFilter = optionalFilter;
  }

  _sendMessage(messageManager, message, data, recipient) {
    let options = {
      recipient,
      sender: this.sender,
      responseType: MessageChannel.RESPONSE_FIRST,
    };

    return this.context.sendMessage(messageManager, message, data, options);
  }

  sendMessage(messageManager, msg, recipient, responseCallback) {
    let promise = this._sendMessage(messageManager, "Extension:Message", msg, recipient)
      .catch(error => {
        if (error.result == MessageChannel.RESULT_NO_HANDLER) {
          return Promise.reject({message: "Could not establish connection. Receiving end does not exist."});
        } else if (error.result != MessageChannel.RESULT_NO_RESPONSE) {
          return Promise.reject({message: error.message});
        }
      });

    return this.context.wrapPromise(promise, responseCallback);
  }

  sendNativeMessage(messageManager, msg, recipient, responseCallback) {
    msg = NativeApp.encodeMessage(this.context, msg);
    return this.sendMessage(messageManager, msg, recipient, responseCallback);
  }

  onMessage(name) {
    return new SingletonEventManager(this.context, name, callback => {
      let listener = {
        messageFilterPermissive: this.optionalFilter,
        messageFilterStrict: this.filter,

        filterMessage: (sender, recipient) => {
          // Ignore the message if it was sent by this Messenger.
          return sender.contextId !== this.context.contextId;
        },

        receiveMessage: ({target, data: message, sender, recipient}) => {
          if (!this.context.active) {
            return;
          }

          let sendResponse;
          let response = undefined;
          let promise = new Promise(resolve => {
            sendResponse = value => {
              resolve(value);
              response = promise;
            };
          });

          message = Cu.cloneInto(message, this.context.cloneScope);
          sender = Cu.cloneInto(sender, this.context.cloneScope);
          sendResponse = Cu.exportFunction(sendResponse, this.context.cloneScope);

          // Note: We intentionally do not use runSafe here so that any
          // errors are propagated to the message sender.
          let result = callback(message, sender, sendResponse);
          if (result instanceof this.context.cloneScope.Promise) {
            return result;
          } else if (result === true) {
            return promise;
          }
          return response;
        },
      };

      MessageChannel.addListener(this.messageManagers, "Extension:Message", listener);
      return () => {
        MessageChannel.removeListener(this.messageManagers, "Extension:Message", listener);
      };
    }).api();
  }

  _connect(messageManager, port, recipient) {
    let msg = {
      name: port.name,
      portId: port.id,
    };

    this._sendMessage(messageManager, "Extension:Connect", msg, recipient).catch(error => {
      if (error.result === MessageChannel.RESULT_NO_HANDLER) {
        error = {message: "Could not establish connection. Receiving end does not exist."};
      } else if (error.result === MessageChannel.RESULT_DISCONNECTED) {
        error = null;
      }
      port.disconnectByOtherEnd(error);
    });

    return port.api();
  }

  connect(messageManager, name, recipient) {
    let portId = getUniqueId();

    let port = new Port(this.context, messageManager, this.messageManagers, name, portId, null, recipient);

    return this._connect(messageManager, port, recipient);
  }

  connectNative(messageManager, name, recipient) {
    let portId = getUniqueId();

    let port = new NativePort(this.context, messageManager, this.messageManagers, name, portId, null, recipient);

    return this._connect(messageManager, port, recipient);
  }

  onConnect(name) {
    return new SingletonEventManager(this.context, name, callback => {
      let listener = {
        messageFilterPermissive: this.optionalFilter,
        messageFilterStrict: this.filter,

        filterMessage: (sender, recipient) => {
          // Ignore the port if it was created by this Messenger.
          return sender.contextId !== this.context.contextId;
        },

        receiveMessage: ({target, data: message, sender}) => {
          let {name, portId} = message;
          let mm = getMessageManager(target);
          let recipient = Object.assign({}, sender);
          if (recipient.tab) {
            recipient.tabId = recipient.tab.id;
            delete recipient.tab;
          }
          let port = new Port(this.context, mm, this.messageManagers, name, portId, sender, recipient);
          this.context.runSafeWithoutClone(callback, port.api());
          return true;
        },
      };

      MessageChannel.addListener(this.messageManagers, "Extension:Connect", listener);
      return () => {
        MessageChannel.removeListener(this.messageManagers, "Extension:Connect", listener);
      };
    }).api();
  }
}

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

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

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

/**
 * An object that runs an remote implementation of an API.
 */
class ProxyAPIImplementation extends SchemaAPIInterface {
  /**
   * @param {string} namespace The full path to the namespace that contains the
   *     `name` member. This may contain dots, e.g. "storage.local".
   * @param {string} name The name of the method or property.
   * @param {ChildAPIManager} childApiManager The owner of this implementation.
   */
  constructor(namespace, name, childApiManager) {
    super();
    this.path = `${namespace}.${name}`;
    this.childApiManager = childApiManager;
  }

  callFunctionNoReturn(args) {
    this.childApiManager.callParentFunctionNoReturn(this.path, args);
  }

  callAsyncFunction(args, callback) {
    return this.childApiManager.callParentAsyncFunction(this.path, args, callback);
  }

  addListener(listener, args) {
    let map = this.childApiManager.listeners.get(this.path);

    if (map.listeners.has(listener)) {
      // TODO: Called with different args?
      return;
    }

    let id = getUniqueId();

    map.ids.set(id, listener);
    map.listeners.set(listener, id);

    this.childApiManager.messageManager.sendAsyncMessage("API:AddListener", {
      childId: this.childApiManager.id,
      listenerId: id,
      path: this.path,
      args,
    });
  }

  removeListener(listener) {
    let map = this.childApiManager.listeners.get(this.path);

    if (!map.listeners.has(listener)) {
      return;
    }

    let id = map.listeners.get(listener);
    map.listeners.delete(listener);
    map.ids.delete(id);

    this.childApiManager.messageManager.sendAsyncMessage("API:RemoveListener", {
      childId: this.childApiManager.id,
      listenerId: id,
      path: this.path,
    });
  }

  hasListener(listener) {
    let map = this.childApiManager.listeners.get(this.path);
    return map.listeners.has(listener);
  }
}

// We create one instance of this class for every extension context that
// needs to use remote APIs. It uses the message manager to communicate
// with the ParentAPIManager singleton in ExtensionParent.jsm. It
// handles asynchronous function calls as well as event listeners.
class ChildAPIManager {
  constructor(context, messageManager, localApis, contextData) {
    this.context = context;
    this.messageManager = messageManager;
    this.url = contextData.url;

    // The root namespace of all locally implemented APIs. If an extension calls
    // an API that does not exist in this object, then the implementation is
    // delegated to the ParentAPIManager.
    this.localApis = localApis;

    this.id = `${context.extension.id}.${context.contextId}`;

    MessageChannel.addListener(messageManager, "API:RunListener", this);
    messageManager.addMessageListener("API:CallResult", this);

    this.messageFilterStrict = {childId: this.id};

    this.listeners = new DefaultMap(() => ({
      ids: new Map(),
      listeners: new Map(),
    }));

    // Map[callId -> Deferred]
    this.callPromises = new Map();

    let params = {
      childId: this.id,
      extensionId: context.extension.id,
      principal: context.principal,
    };
    Object.assign(params, contextData);

    this.messageManager.sendAsyncMessage("API:CreateProxyContext", params);
  }

  receiveMessage({name, messageName, data}) {
    if (data.childId != this.id) {
      return;
    }

    switch (name || messageName) {
      case "API:RunListener":
        let map = this.listeners.get(data.path);
        let listener = map.ids.get(data.listenerId);

        if (listener) {
          return this.context.runSafe(listener, ...data.args);
        }

        Cu.reportError(`Unknown listener at childId=${data.childId} path=${data.path} listenerId=${data.listenerId}\n`);
        break;

      case "API:CallResult":
        let deferred = this.callPromises.get(data.callId);
        if ("error" in data) {
          deferred.reject(data.error);
        } else {
          deferred.resolve(new SpreadArgs(data.result));
        }
        this.callPromises.delete(data.callId);
        break;
    }
  }

  /**
   * Call a function in the parent process and ignores its return value.
   *
   * @param {string} path The full name of the method, e.g. "tabs.create".
   * @param {Array} args The parameters for the function.
   */
  callParentFunctionNoReturn(path, args) {
    this.messageManager.sendAsyncMessage("API:Call", {
      childId: this.id,
      path,
      args,
    });
  }

  /**
   * Calls a function in the parent process and returns its result
   * asynchronously.
   *
   * @param {string} path The full name of the method, e.g. "tabs.create".
   * @param {Array} args The parameters for the function.
   * @param {function(*)} [callback] The callback to be called when the function
   *     completes.
   * @returns {Promise|undefined} Must be void if `callback` is set, and a
   *     promise otherwise. The promise is resolved when the function completes.
   */
  callParentAsyncFunction(path, args, callback) {
    let callId = getUniqueId();
    let deferred = PromiseUtils.defer();
    this.callPromises.set(callId, deferred);

    this.messageManager.sendAsyncMessage("API:Call", {
      childId: this.id,
      callId,
      path,
      args,
    });

    return this.context.wrapPromise(deferred.promise, callback);
  }

  /**
   * Create a proxy for an event in the parent process. The returned event
   * object shares its internal state with other instances. For instance, if
   * `removeListener` is used on a listener that was added on another object
   * through `addListener`, then the event is unregistered.
   *
   * @param {string} path The full name of the event, e.g. "tabs.onCreated".
   * @returns {object} An object with the addListener, removeListener and
   *   hasListener methods. See SchemaAPIInterface for documentation.
   */
  getParentEvent(path) {
    path = path.split(".");

    let name = path.pop();
    let namespace = path.join(".");

    let impl = new ProxyAPIImplementation(namespace, name, this);
    return {
      addListener: (listener, ...args) => impl.addListener(listener, args),
      removeListener: (listener) => impl.removeListener(listener),
      hasListener: (listener) => impl.hasListener(listener),
    };
  }

  close() {
    this.messageManager.sendAsyncMessage("API:CloseProxyContext", {childId: this.id});
  }

  get cloneScope() {
    return this.context.cloneScope;
  }

  get principal() {
    return this.context.principal;
  }

  shouldInject(namespace, name, allowedContexts) {
    // Do not generate content script APIs, unless explicitly allowed.
    if (this.context.envType === "content_child" &&
        !allowedContexts.includes("content")) {
      return false;
    }
    if (allowedContexts.includes("addon_parent_only")) {
      return false;
    }
    return true;
  }

  getImplementation(namespace, name) {
    let obj = namespace.split(".").reduce(
      (object, prop) => object && object[prop],
      this.localApis);

    if (obj && name in obj) {
      return new LocalAPIImplementation(obj, name, this.context);
    }

    return this.getFallbackImplementation(namespace, name);
  }

  getFallbackImplementation(namespace, name) {
    // No local API found, defer implementation to the parent.
    return new ProxyAPIImplementation(namespace, name, this);
  }

  hasPermission(permission) {
    return this.context.extension.hasPermission(permission);
  }
}

class ExtensionPageContextChild extends BaseContext {
  /**
   * This ExtensionPageContextChild represents a privileged addon
   * execution environment that has full access to the WebExtensions
   * APIs (provided that the correct permissions have been requested).
   *
   * This is the child side of the ExtensionPageContextParent class
   * defined in ExtensionParent.jsm.
   *
   * @param {BrowserExtensionContent} extension This context's owner.
   * @param {object} params
   * @param {nsIDOMWindow} params.contentWindow The window where the addon runs.
   * @param {string} params.viewType One of "background", "popup" or "tab".
   *     "background" and "tab" are used by `browser.extension.getViews`.
   *     "popup" is only used internally to identify page action and browser
   *     action popups and options_ui pages.
   * @param {number} [params.tabId] This tab's ID, used if viewType is "tab".
   */
  constructor(extension, params) {
    super("addon_child", extension);
    if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) {
      // This check is temporary. It should be removed once the proxy creation
      // is asynchronous.
      throw new Error("ExtensionPageContextChild cannot be created in child processes");
    }

    let {viewType, uri, contentWindow, tabId} = params;
    this.viewType = viewType;
    this.uri = uri || extension.baseURI;

    this.setContentWindow(contentWindow);

    // This is the MessageSender property passed to extension.
    // It can be augmented by the "page-open" hook.
    let sender = {id: extension.uuid};
    if (viewType == "tab") {
      sender.tabId = tabId;
      this.tabId = tabId;
    }
    if (uri) {
      sender.url = uri.spec;
    }
    this.sender = sender;

    Schemas.exportLazyGetter(contentWindow, "browser", () => {
      let browserObj = Cu.createObjectIn(contentWindow);
      Schemas.inject(browserObj, this.childManager);
      return browserObj;
    });

    Schemas.exportLazyGetter(contentWindow, "chrome", () => {
      let chromeApiWrapper = Object.create(this.childManager);
      chromeApiWrapper.isChromeCompat = true;

      let chromeObj = Cu.createObjectIn(contentWindow);
      Schemas.inject(chromeObj, chromeApiWrapper);
      return chromeObj;
    });

    this.extension.views.add(this);
  }

  get cloneScope() {
    return this.contentWindow;
  }

  get principal() {
    return this.contentWindow.document.nodePrincipal;
  }

  get windowId() {
    if (this.viewType == "tab" || this.viewType == "popup") {
      let globalView = ExtensionChild.contentGlobals.get(this.messageManager);
      return globalView ? globalView.windowId : -1;
    }
  }

  // Called when the extension shuts down.
  shutdown() {
    this.unload();
  }

  // This method is called when an extension page navigates away or
  // its tab is closed.
  unload() {
    // Note that without this guard, we end up running unload code
    // multiple times for tab pages closed by the "page-unload" handlers
    // triggered below.
    if (this.unloaded) {
      return;
    }

    if (this.contentWindow) {
      this.contentWindow.close();
    }

    super.unload();
    this.extension.views.delete(this);
  }
}

defineLazyGetter(ExtensionPageContextChild.prototype, "messenger", function() {
  let filter = {extensionId: this.extension.id};
  let optionalFilter = {};
  // Addon-generated messages (not necessarily from the same process as the
  // addon itself) are sent to the main process, which forwards them via the
  // parent process message manager. Specific replies can be sent to the frame
  // message manager.
  return new Messenger(this, [Services.cpmm, this.messageManager], this.sender,
                       filter, optionalFilter);
});

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

  if (this.viewType == "background") {
    apiManager.global.initializeBackgroundPage(this.contentWindow);
  }

  let childManager = new ChildAPIManager(this, this.messageManager, localApis, {
    envType: "addon_parent",
    viewType: this.viewType,
    url: this.uri.spec,
    incognito: this.incognito,
  });

  this.callOnClose(childManager);

  return childManager;
});

// All subframes in a tab, background page, popup, etc. have the same view type.
// This class keeps track of such global state.
// Note that this is created even for non-extension tabs because at present we
// do not have a way to distinguish regular tabs from extension tabs at the
// initialization of a frame script.
class ContentGlobal {
  /**
   * @param {nsIContentFrameMessageManager} global The frame script's global.
   */
  constructor(global) {
    this.global = global;
    // Unless specified otherwise assume that the extension page is in a tab,
    // because the majority of all class instances are going to be a tab. Any
    // special views (background page, extension popup) will immediately send an
    // Extension:InitExtensionView message to change the viewType.
    this.viewType = "tab";
    this.tabId = -1;
    this.windowId = -1;
    this.initialized = false;
    this.global.addMessageListener("Extension:InitExtensionView", this);
    this.global.addMessageListener("Extension:SetTabAndWindowId", this);

    this.initialDocuments = new WeakSet();
  }

  uninit() {
    this.global.removeMessageListener("Extension:InitExtensionView", this);
    this.global.removeMessageListener("Extension:SetTabAndWindowId", this);
    this.global.removeEventListener("DOMContentLoaded", this);
  }

  ensureInitialized() {
    if (!this.initialized) {
      // Request tab and window ID in case "Extension:InitExtensionView" is not
      // sent (e.g. when `viewType` is "tab").
      let reply = this.global.sendSyncMessage("Extension:GetTabAndWindowId");
      this.handleSetTabAndWindowId(reply[0] || {});
    }
    return this;
  }

  receiveMessage({name, data}) {
    switch (name) {
      case "Extension:InitExtensionView":
        // The view type is initialized once and then fixed.
        this.global.removeMessageListener("Extension:InitExtensionView", this);
        let {viewType, url} = data;
        this.viewType = viewType;
        this.global.addEventListener("DOMContentLoaded", this);
        if (url) {
          // TODO(robwu): Remove this check. It is only here because the popup
          // implementation does not always load a URL at the initialization,
          // and the logic is too complex to fix at once.
          let {document} = this.global.content;
          this.initialDocuments.add(document);
          document.location.replace(url);
        }
        /* Falls through to allow these properties to be initialized at once */
      case "Extension:SetTabAndWindowId":
        this.handleSetTabAndWindowId(data);
        break;
    }
  }

  handleSetTabAndWindowId(data) {
    let {tabId, windowId} = data;
    if (tabId) {
      // Tab IDs are not expected to change.
      if (this.tabId !== -1 && tabId !== this.tabId) {
        throw new Error("Attempted to change a tabId after it was set");
      }
      this.tabId = tabId;
    }
    if (windowId !== undefined) {
      // Window IDs may change if a tab is moved to a different location.
      // Note: This is the ID of the browser window for the extension API.
      // Do not confuse it with the innerWindowID of DOMWindows!
      this.windowId = windowId;
    }
    this.initialized = true;
  }

  // "DOMContentLoaded" event.
  handleEvent(event) {
    let {document} = this.global.content;
    if (event.target === document) {
      // If the document was still being loaded at the time of navigation, then
      // the DOMContentLoaded event is fired for the old document. Ignore it.
      if (this.initialDocuments.has(document)) {
        this.initialDocuments.delete(document);
        return;
      }
      this.global.removeEventListener("DOMContentLoaded", this);
      this.global.sendAsyncMessage("Extension:ExtensionViewLoaded");
    }
  }
}

ExtensionChild = {
  // Map<nsIContentFrameMessageManager, ContentGlobal>
  contentGlobals: new Map(),

  // Map<innerWindowId, ExtensionPageContextChild>
  extensionContexts: new Map(),

  initOnce() {
    // This initializes the default message handler for messages targeted at
    // an addon process, in case the addon process receives a message before
    // its Messenger has been instantiated. For example, if a content script
    // sends a message while there is no background page.
    MessageChannel.setupMessageManagers([Services.cpmm]);
  },

  init(global) {
    this.contentGlobals.set(global, new ContentGlobal(global));
  },

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

  /**
   * Create a privileged context at document-element-inserted.
   *
   * @param {BrowserExtensionContent} extension
   *     The extension for which the context should be created.
   * @param {nsIDOMWindow} contentWindow The global of the page.
   */
  createExtensionContext(extension, contentWindow) {
    let windowId = getInnerWindowID(contentWindow);
    let context = this.extensionContexts.get(windowId);
    if (context) {
      if (context.extension !== extension) {
        // Oops. This should never happen.
        Cu.reportError("A different extension context already exists in this frame!");
      } else {
        // This should not happen either.
        Cu.reportError("The extension context was already initialized in this frame.");
      }
      return;
    }

    let mm = contentWindow
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsIDocShell)
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsIContentFrameMessageManager);
    let {viewType, tabId} = this.contentGlobals.get(mm).ensureInitialized();

    let uri = contentWindow.document.documentURIObject;

    context = new ExtensionPageContextChild(extension, {viewType, contentWindow, uri, tabId});
    this.extensionContexts.set(windowId, context);
  },

  /**
   * Close the ExtensionPageContextChild belonging to the given window, if any.
   *
   * @param {number} windowId The inner window ID of the destroyed context.
   */
  destroyExtensionContext(windowId) {
    let context = this.extensionContexts.get(windowId);
    if (context) {
      context.unload();
      this.extensionContexts.delete(windowId);
    }
  },

  shutdownExtension(extensionId) {
    for (let [windowId, context] of this.extensionContexts) {
      if (context.extension.id == extensionId) {
        context.shutdown();
        this.extensionContexts.delete(windowId);
      }
    }
  },
};

// TODO(robwu): Change this condition when addons move to a separate process.
if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) {
  Object.keys(ExtensionChild).forEach(function(key) {
    if (typeof ExtensionChild[key] == "function") {
      // :/
      ExtensionChild[key] = () => {};
    }
  });
}

Object.assign(ExtensionChild, {
  ChildAPIManager,
  Messenger,
  Port,
});

