/* 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 file expects tabTracker to be defined in the global scope (e.g.
// by ext-browser.js or ext-android.js).
/* global tabTracker */

var { ExtensionParent } = ChromeUtils.importESModule(
  "resource://gre/modules/ExtensionParent.sys.mjs"
);

var { DefaultWeakMap } = ExtensionUtils;

ChromeUtils.defineESModuleGetters(this, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
});

XPCOMUtils.defineLazyPreferenceGetter(
  this,
  "gRuntimeTimeout",
  "extensions.webextensions.runtime.timeout",
  5000
);

this.runtime = class extends ExtensionAPIPersistent {
  PERSISTENT_EVENTS = {
    // Despite not being part of PERSISTENT_EVENTS, the following events are
    // still triggered (after waking up the background context if needed):
    // - runtime.onConnect
    // - runtime.onConnectExternal
    // - runtime.onMessage
    // - runtime.onMessageExternal
    // - runtime.onUserScriptMessage
    // - runtime.onUserScriptConnect
    // For details, see bug 1852317 and test_ext_eventpage_messaging_wakeup.js.

    onInstalled({ fire }) {
      let { extension } = this;
      let temporary = !!extension.addonData.temporarilyInstalled;

      let listener = () => {
        switch (extension.startupReason) {
          case "APP_STARTUP":
            if (AddonManagerPrivate.browserUpdated) {
              fire.sync({ reason: "browser_update", temporary });
            }
            break;
          case "ADDON_INSTALL":
            fire.sync({ reason: "install", temporary });
            break;
          case "ADDON_UPGRADE":
            fire.sync({
              reason: "update",
              previousVersion: extension.addonData.oldVersion,
              temporary,
            });
            break;
        }
      };
      extension.on("background-first-run", listener);
      return {
        unregister() {
          extension.off("background-first-run", listener);
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    },
    onUpdateAvailable({ fire }) {
      let { extension } = this;
      let instanceID = extension.addonData.instanceID;
      AddonManager.addUpgradeListener(instanceID, upgrade => {
        extension.upgrade = upgrade;
        let details = {
          version: upgrade.version,
        };
        fire.sync(details);
      });
      return {
        unregister() {
          AddonManager.removeUpgradeListener(instanceID);
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    },
    onPerformanceWarning({ fire }) {
      let { extension } = this;

      let observer = subject => {
        let report = subject.QueryInterface(Ci.nsIHangReport);

        if (report?.addonId !== extension.id) {
          return;
        }

        const performanceWarningEventDetails = {
          category: "content_script",
          severity: "high",
          description:
            "Slow extension content script caused a page hang, user was warned.",
        };

        let scriptBrowser = report.scriptBrowser;
        let nativeTab =
          scriptBrowser?.documentGlobal.gBrowser?.getTabForBrowser(
            scriptBrowser
          );
        if (nativeTab) {
          performanceWarningEventDetails.tabId = tabTracker.getId(nativeTab);
        }

        fire.async(performanceWarningEventDetails);
      };

      Services.obs.addObserver(observer, "process-hang-report");
      return {
        unregister: () => {
          Services.obs.removeObserver(observer, "process-hang-report");
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    },
  };

  // Although we have an internal context.contextId field, we generate a new one here because
  // the internal type is an integer and the public field a (UUID) string.
  // TODO: Move the implementation elsewhere when contextId is used anywhere other than
  // runtime.getContexts. See https://bugzilla.mozilla.org/show_bug.cgi?id=1628178#c5
  //
  // Map<ProxyContextParent, string>
  #contextUUIDMap = new DefaultWeakMap(_context =>
    String(Services.uuid.generateUUID()).slice(1, -1)
  );

  getContexts(filter) {
    const { extension } = this;
    const { proxyContexts } = ExtensionParent.ParentAPIManager;
    const results = [];
    for (const proxyContext of proxyContexts.values()) {
      if (proxyContext.extension !== extension) {
        continue;
      }
      let ctx;
      try {
        ctx = proxyContext.toExtensionContext();
      } catch (err) {
        // toExtensionContext may throw if the contextType getter
        // raised an exception due to an internal viewType has
        // not be mapped with a contextType value.
        //
        // When running in DEBUG builds we reject the getContexts
        // call, while in non DEBUG build we just omit the result
        // and log a warning in the Browser Console.
        if (AppConstants.DEBUG) {
          throw err;
        } else {
          Cu.reportError(err);
        }
      }

      if (this.matchContextFilter(filter, ctx)) {
        results.push({
          ...ctx,
          contextId: this.#contextUUIDMap.get(proxyContext),
        });
      }
    }
    return results;
  }

  matchContextFilter(filter, ctx) {
    if (!ctx) {
      // Filter out subclasses that do not return any ExtensionContext details
      // from their toExtensionContext method.
      return false;
    }
    if (filter.contextIds && !filter.contextIds.includes(ctx.contextId)) {
      return false;
    }
    if (filter.contextTypes && !filter.contextTypes.includes(ctx.contextType)) {
      return false;
    }
    if (filter.documentIds && !filter.documentIds.includes(ctx.documentId)) {
      return false;
    }
    if (
      filter.documentOrigins &&
      !filter.documentOrigins.includes(ctx.documentOrigin)
    ) {
      return false;
    }
    if (filter.documentUrls && !filter.documentUrls.includes(ctx.documentUrl)) {
      return false;
    }
    if (filter.frameIds && !filter.frameIds.includes(ctx.frameId)) {
      return false;
    }
    if (filter.tabIds && !filter.tabIds.includes(ctx.tabId)) {
      return false;
    }
    if (filter.windowIds && !filter.windowIds.includes(ctx.windowId)) {
      return false;
    }
    if (
      typeof filter.incognito === "boolean" &&
      ctx.incognito !== filter.incognito
    ) {
      return false;
    }
    return true;
  }

  getAPI(context) {
    let { extension } = context;
    return {
      runtime: {
        getContexts: filter => this.getContexts(filter),

        // onStartup is special-cased in ext-backgroundPages to cause
        // an immediate startup.  We do not prime onStartup.
        onStartup: new EventManager({
          context,
          module: "runtime",
          event: "onStartup",
          register: fire => {
            if (context.incognito || extension.startupReason != "APP_STARTUP") {
              // This event should not fire if we are operating in a private profile.
              return () => {};
            }
            let listener = () => {
              return fire.sync();
            };

            extension.on("background-first-run", listener);

            return () => {
              extension.off("background-first-run", listener);
            };
          },
        }).api(),

        onInstalled: new EventManager({
          context,
          module: "runtime",
          event: "onInstalled",
          extensionApi: this,
        }).api(),

        onUpdateAvailable: new EventManager({
          context,
          module: "runtime",
          event: "onUpdateAvailable",
          extensionApi: this,
        }).api(),

        onSuspend: new EventManager({
          context,
          name: "runtime.onSuspend",
          resetIdleOnEvent: false,
          register: fire => {
            let listener = async () => {
              let timedOut = false;
              async function promiseFire() {
                try {
                  await fire.async();
                } catch (e) {}
              }
              await Promise.race([
                promiseFire(),
                ExtensionUtils.promiseTimeout(gRuntimeTimeout).then(() => {
                  timedOut = true;
                }),
              ]);
              if (timedOut) {
                Cu.reportError(
                  `runtime.onSuspend in ${extension.id} took too long`
                );
              }
            };
            extension.on("background-script-suspend", listener);
            return () => {
              extension.off("background-script-suspend", listener);
            };
          },
        }).api(),

        onSuspendCanceled: new EventManager({
          context,
          name: "runtime.onSuspendCanceled",
          register: fire => {
            let listener = () => {
              fire.async();
            };
            extension.on("background-script-suspend-canceled", listener);
            return () => {
              extension.off("background-script-suspend-canceled", listener);
            };
          },
        }).api(),

        onPerformanceWarning: new EventManager({
          context,
          module: "runtime",
          event: "onPerformanceWarning",
          extensionApi: this,
        }).api(),

        reload: async () => {
          if (extension.upgrade) {
            // If there is a pending update, install it now.
            extension.upgrade.install();
          } else {
            // Otherwise, reload the current extension.
            let addon = await AddonManager.getAddonByID(extension.id);
            addon.reload();
          }
        },

        get lastError() {
          // TODO(robwu): Figure out how to make sure that errors in the parent
          // process are propagated to the child process.
          // lastError should not be accessed from the parent.
          return context.lastError;
        },

        getBrowserInfo: function () {
          const { name, vendor, version, appBuildID } = Services.appinfo;
          const info = { name, vendor, version, buildID: appBuildID };
          return Promise.resolve(info);
        },

        getPlatformInfo: function () {
          return Promise.resolve(ExtensionParent.PlatformInfo);
        },

        openOptionsPage: function () {
          if (!extension.optionsPageProperties) {
            return Promise.reject({ message: "No `options_ui` declared" });
          }

          // This expects openOptionsPage to be defined in the file using this,
          // e.g. the browser/ version of ext-runtime.js
          /* global openOptionsPage:false */
          return openOptionsPage(extension).then(() => {});
        },

        setUninstallURL: function (url) {
          if (url === null || url.length === 0) {
            extension.uninstallURL = null;
            return Promise.resolve();
          }

          let uri = URL.parse(url);
          if (!uri) {
            return Promise.reject({
              message: `Invalid URL: ${JSON.stringify(url)}`,
            });
          }

          if (uri.protocol != "http:" && uri.protocol != "https:") {
            return Promise.reject({
              message: "url must have the scheme http or https",
            });
          }

          extension.uninstallURL = url;
          return Promise.resolve();
        },

        async internalWakeupBackground() {
          const { background } = extension.manifest;
          if (
            background &&
            (background.page || background.scripts) &&
            // Note: if background.service_worker is specified, it takes
            // precedence over page/scripts, and persistentBackground is false.
            !extension.persistentBackground
          ) {
            await extension.wakeupBackground();
          }
        },
      },
    };
  }
};
