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

/* exported ExtensionPageChild */

this.EXPORTED_SYMBOLS = ["ExtensionPageChild"];

/**
 * This file handles privileged extension page logic that runs in the
 * child process.
 */

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, "ExtensionManagement",
                                  "resource://gre/modules/ExtensionManagement.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                  "resource://gre/modules/Schemas.jsm");

const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";
const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools";

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

const {
  defineLazyGetter,
  getInnerWindowID,
  promiseEvent,
} = ExtensionUtils;

const {
  BaseContext,
  CanOfAPIs,
  SchemaAPIManager,
} = ExtensionCommon;

const {
  ChildAPIManager,
  Messenger,
} = ExtensionChild;

var ExtensionPageChild;

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

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

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

  lazyInit() {
    if (!this.initialized) {
      this.initialized = true;
      for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS)) {
        this.loadScript(value);
      }
    }
  }
}();

class ExtensionBaseContextChild extends BaseContext {
  /**
   * This ExtensionBaseContextChild represents an addon execution environment
   * that is running in an addon or devtools child process.
   *
   * @param {BrowserExtensionContent} extension This context's owner.
   * @param {object} params
   * @param {string} params.envType One of "addon_child" or "devtools_child".
   * @param {nsIDOMWindow} params.contentWindow The window where the addon runs.
   * @param {string} params.viewType One of "background", "popup", "tab",
   *   "sidebar", "devtools_page" or "devtools_panel".
   * @param {number} [params.tabId] This tab's ID, used if viewType is "tab".
   */
  constructor(extension, params) {
    if (!params.envType) {
      throw new Error("Missing envType");
    }

    super(params.envType, extension);
    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.id};
    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;
    });
  }

  get cloneScope() {
    return this.contentWindow;
  }

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

  get windowId() {
    if (["tab", "popup", "sidebar"].includes(this.viewType)) {
      let globalView = ExtensionPageChild.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();
  }
}

defineLazyGetter(ExtensionBaseContextChild.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);
});

class ExtensionPageContextChild extends ExtensionBaseContextChild {
  /**
   * 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", "sidebar" or "tab".
   *     "background", "sidebar" 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(extension, Object.assign(params, {envType: "addon_child"}));

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

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

defineLazyGetter(ExtensionPageContextChild.prototype, "childManager", function() {
  apiManager.lazyInit();

  let localApis = {};
  let can = new CanOfAPIs(this, apiManager, localApis);

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

  this.callOnClose(childManager);

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

  return childManager;
});

class DevToolsContextChild extends ExtensionBaseContextChild {
  /**
   * This DevToolsContextChild represents a devtools-related addon execution
   * environment that has access to the devtools API namespace and to the same subset
   * of APIs available in a content script execution environment.
   *
   * @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 "devtools_page" or "devtools_panel".
   * @param {object} [params.devtoolsToolboxInfo] This devtools toolbox's information,
   *   used if viewType is "devtools_page" or "devtools_panel".
   */
  constructor(extension, params) {
    super(extension, Object.assign(params, {envType: "devtools_child"}));

    this.devtoolsToolboxInfo = params.devtoolsToolboxInfo;

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

  unload() {
    super.unload();
    this.extension.devtoolsViews.delete(this);
  }
}

defineLazyGetter(DevToolsContextChild.prototype, "childManager", function() {
  devtoolsAPIManager.lazyInit();

  let localApis = {};
  let can = new CanOfAPIs(this, devtoolsAPIManager, localApis);

  let childManager = new ChildAPIManager(this, this.messageManager, can, {
    envType: "devtools_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);
  }

  uninit() {
    this.global.removeMessageListener("Extension:InitExtensionView", this);
    this.global.removeMessageListener("Extension:SetTabAndWindowId", 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);
        this.viewType = data.viewType;

        // Force external links to open in tabs.
        if (["popup", "sidebar"].includes(this.viewType)) {
          this.global.docShell.isAppTab = true;
        }

        if (data.devtoolsToolboxInfo) {
          this.devtoolsToolboxInfo = data.devtoolsToolboxInfo;
        }

        promiseEvent(this.global, "DOMContentLoaded", true).then(() => {
          let windowId = getInnerWindowID(this.global.content);
          let context = ExtensionPageChild.extensionContexts.get(windowId);

          this.global.sendAsyncMessage("Extension:ExtensionViewLoaded",
                                       {childId: context && context.childManager.id});
        });

        /* FALLTHROUGH */
      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;
  }
}

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

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

  initialized: false,

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

    Services.obs.addObserver(this, "inner-window-destroyed"); // eslint-ignore-line mozilla/balanced-listeners
  },

  init(global) {
    if (!ExtensionManagement.isExtensionProcess) {
      throw new Error("Cannot init extension page global in current process");
    }

    if (!this.contentGlobals.has(global)) {
      this.contentGlobals.set(global, new ContentGlobal(global));
    }
  },

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

  observe(subject, topic, data) {
    if (topic === "inner-window-destroyed") {
      let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;

      this.destroyExtensionContext(windowId);
    }
  },

  /**
   * 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.
   */
  initExtensionContext(extension, contentWindow) {
    this._init();

    if (!ExtensionManagement.isExtensionProcess) {
      throw new Error("Cannot create an extension page context in current process");
    }

    let windowId = getInnerWindowID(contentWindow);
    let context = this.extensionContexts.get(windowId);
    if (context) {
      if (context.extension !== extension) {
        throw new Error("A different extension context already exists for this frame");
      }
      throw new Error("An extension context was already initialized for this frame");
    }

    let mm = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIDocShell)
                          .QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIContentFrameMessageManager);

    let {viewType, tabId, devtoolsToolboxInfo} = this.contentGlobals.get(mm).ensureInitialized();

    let uri = contentWindow.document.documentURIObject;

    if (devtoolsToolboxInfo) {
      context = new DevToolsContextChild(extension, {
        viewType, contentWindow, uri, tabId, devtoolsToolboxInfo,
      });
    } else {
      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);
      }
    }
  },
};
