/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  ExtensionChildDevToolsUtils:
    "resource://gre/modules/ExtensionChildDevToolsUtils.sys.mjs",
  Schemas: "resource://gre/modules/Schemas.sys.mjs",
});

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

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

const { getInnerWindowID, promiseEvent } = ExtensionUtils;

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

const initializeBackgroundPage = context => {
  // Override the `alert()` method inside background windows;
  // we alias it to console.log().
  // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1203394
  let alertDisplayedWarning = false;
  const innerWindowID = getInnerWindowID(context.contentWindow);

  /** @param {{ text, filename, lineNumber?, columnNumber? }} options */
  function logWarningMessage({ text, filename, lineNumber, columnNumber }) {
    let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance(
      Ci.nsIScriptError
    );
    consoleMsg.initWithWindowID(
      text,
      filename,
      lineNumber,
      columnNumber,
      Ci.nsIScriptError.warningFlag,
      "webextension",
      innerWindowID
    );
    Services.console.logMessage(consoleMsg);
  }

  function ignoredSuspendListener() {
    logWarningMessage({
      text: "Background event page was not terminated on idle because a DevTools toolbox is attached to the extension.",
      filename: context.contentWindow.location.href,
    });
  }

  if (!context.extension.manifest.background.persistent) {
    context.extension.on(
      "background-script-suspend-ignored",
      ignoredSuspendListener
    );
    context.callOnClose({
      close: () => {
        context.extension.off(
          "background-script-suspend-ignored",
          ignoredSuspendListener
        );
      },
    });
  }

  let alertOverwrite = text => {
    const { filename, columnNumber, lineNumber } = Components.stack.caller;

    if (!alertDisplayedWarning) {
      context.childManager.callParentAsyncFunction(
        "runtime.openBrowserConsole",
        []
      );

      logWarningMessage({
        text: "alert() is not supported in background windows; please use console.log instead.",
        filename,
        lineNumber,
        columnNumber,
      });

      alertDisplayedWarning = true;
    }

    logWarningMessage({ text, filename, lineNumber, columnNumber });
  };
  Cu.exportFunction(alertOverwrite, context.contentWindow, {
    defineAs: "alert",
  });
};

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

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

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

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

export function getContextChildManagerGetter(
  { envType },
  ChildAPIManagerClass = ChildAPIManager
) {
  return function () {
    let apiManager =
      envType === "devtools_parent"
        ? devtoolsAPIManager
        : this.extension.apiManager;

    apiManager.lazyInit();

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

    let childManager = new ChildAPIManagerClass(
      this,
      this.messageManager,
      can,
      {
        envType,
        viewType: this.viewType,
        url: this.uri.spec,
        incognito: this.incognito,
        // Additional data a BaseContext subclass may optionally send
        // as part of the CreateProxyContext request sent to the main process
        // (e.g. WorkerContexChild implements this method to send the service
        // worker descriptor id along with the details send by default here).
        ...this.getCreateProxyContextData?.(),
      }
    );

    this.callOnClose(childManager);

    return childManager;
  };
}

export class ExtensionBaseContextChild extends BaseContext {
  /**
   * This ExtensionBaseContextChild represents an addon execution environment
   * that is running in an addon or devtools child process.
   *
   * @param {ExtensionChild} 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".
   * @param {nsIURI} [params.uri] The URI of the page.
   */
  constructor(extension, params) {
    if (!params.envType) {
      throw new Error("Missing envType");
    }

    super(params.envType, extension);
    let { viewType = "tab", uri, contentWindow, tabId } = params;
    this.viewType = viewType;
    this.uri = uri || extension.baseURI;

    this.setContentWindow(contentWindow);
    this.browsingContextId = contentWindow.docShell.browsingContext.id;

    if (viewType == "tab") {
      Object.defineProperty(this, "tabId", {
        value: tabId,
        enumerable: true,
        configurable: true,
      });
    }

    lazy.Schemas.exportLazyGetter(contentWindow, "browser", () => {
      return this.browserObj;
    });

    lazy.Schemas.exportLazyGetter(contentWindow, "chrome", () => {
      // For MV3 and later, this is just an alias for browser.
      if (extension.manifestVersion > 2) {
        return this.browserObj;
      }
      // Chrome compat is only used with MV2
      let chromeApiWrapper = Object.create(this.childManager);
      chromeApiWrapper.isChromeCompat = true;

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

  get browserObj() {
    const browserObj = Cu.createObjectIn(this.contentWindow);
    this.childManager.inject(browserObj);
    return redefineGetter(this, "browserObj", browserObj);
  }

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

  get cloneScope() {
    return this.contentWindow;
  }

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

  get tabId() {
    // Will be overwritten in the constructor if necessary.
    return -1;
  }

  // Called when the extension shuts down.
  shutdown() {
    if (this.contentWindow) {
      this.contentWindow.close();
    }

    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;
    }

    super.unload();
  }

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

  /** @type {ReturnType<ReturnType<getContextChildManagerGetter>>} */
  get childManager() {
    throw new Error("childManager getter must be overridden");
  }
}

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.sys.mjs.
   *
   * @param {ExtensionChild} 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".
   * @param {nsIURI} [params.uri] The URI of the page.
   */
  constructor(extension, params) {
    super(extension, Object.assign(params, { envType: "addon_child" }));

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

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

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

  get childManager() {
    const childManager = getContextChildManagerGetter({
      envType: "addon_parent",
    }).call(this);
    return redefineGetter(this, "childManager", childManager);
  }
}

export 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 {ExtensionChild} 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".
   * @param {number} [params.tabId] This tab's ID, used if viewType is "tab".
   * @param {nsIURI} [params.uri] The URI of the page.
   */
  constructor(extension, params) {
    super(extension, Object.assign(params, { envType: "devtools_child" }));

    this.devtoolsToolboxInfo = params.devtoolsToolboxInfo;
    lazy.ExtensionChildDevToolsUtils.initThemeChangeObserver(
      params.devtoolsToolboxInfo.themeName,
      this
    );

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

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

  get childManager() {
    const childManager = getContextChildManagerGetter({
      envType: "devtools_parent",
    }).call(this);
    return redefineGetter(this, "childManager", childManager);
  }
}

export var ExtensionPageChild = {
  initialized: false,

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

  apiManager,

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

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

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

      this.destroyExtensionContext(windowId);
    }
  },

  expectViewLoad(global, viewType) {
    promiseEvent(
      global,
      "DOMContentLoaded",
      true,
      /** @param {{target: Window|any}} event */
      event =>
        event.target.location != "about:blank" &&
        // Ignore DOMContentLoaded bubbled from child frames:
        event.target.defaultView === global.content
    ).then(() => {
      let windowId = getInnerWindowID(global.content);
      let context = this.extensionContexts.get(windowId);
      // This initializes ChildAPIManager (and creation of ProxyContextParent)
      // if they don't exist already at this point.
      let childId = context?.childManager.id;
      if (viewType === "background") {
        global.sendAsyncMessage("Extension:BackgroundViewLoaded", { childId });
      }
    });
  },

  /**
   * Create a privileged context at initial-document-element-inserted.
   *
   * @param {ExtensionChild} extension
   *     The extension for which the context should be created.
   * @param {nsIDOMWindow} contentWindow The global of the page.
   */
  initExtensionContext(extension, contentWindow) {
    this._init();

    if (!WebExtensionPolicy.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 uri = contentWindow.document.documentURIObject;

    let mm = contentWindow.docShell.messageManager;
    let data = mm.sendSyncMessage("Extension:GetFrameData")[0];
    if (!data) {
      let policy = WebExtensionPolicy.getByHostname(uri.host);
      // TODO bug 1749116: Handle this unexpected result, because data
      // (viewType in particular) should never be void for extension documents.
      Cu.reportError(`FrameData missing for ${policy?.id} page ${uri.spec}`);
    }
    let { viewType, tabId, devtoolsToolboxInfo } = data ?? {};

    if (viewType && contentWindow.top === contentWindow) {
      ExtensionPageChild.expectViewLoad(mm, viewType);
    }

    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);
      }
    }
  },
};
