/* 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/. */

import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";

const PRIVATE_BROWSING_PERMISSION = {
  permissions: ["internal:privateBrowsingAllowed"],
  origins: [],
};

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
  AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
  EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
  Extension: "resource://gre/modules/Extension.sys.mjs",
  ExtensionData: "resource://gre/modules/Extension.sys.mjs",
  ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
  ExtensionProcessCrashObserver: "resource://gre/modules/Extension.sys.mjs",
  GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs",
  Management: "resource://gre/modules/Extension.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

const { debug, warn } = GeckoViewUtils.initLogging("Console");

export var DownloadTracker = new (class extends EventEmitter {
  constructor() {
    super();

    // maps numeric IDs to DownloadItem objects
    this._downloads = new Map();
  }

  onEvent(event, data, callback) {
    switch (event) {
      case "GeckoView:WebExtension:DownloadChanged": {
        const downloadItem = this.getDownloadItemById(data.downloadItemId);

        if (!downloadItem) {
          callback.onError("Error: Trying to update unknown download");
          return;
        }

        const delta = downloadItem.update(data);
        if (delta) {
          this.emit("download-changed", {
            delta,
            downloadItem,
          });
        }
      }
    }
  }

  addDownloadItem(item) {
    this._downloads.set(item.id, item);
  }

  /**
   * Finds and returns a DownloadItem with a certain numeric ID
   *
   * @param {number} id
   * @returns {DownloadItem} download item
   */
  getDownloadItemById(id) {
    return this._downloads.get(id);
  }
})();

/** Provides common logic between page and browser actions */
export class ExtensionActionHelper {
  constructor({
    tabTracker,
    windowTracker,
    tabContext,
    properties,
    extension,
  }) {
    this.tabTracker = tabTracker;
    this.windowTracker = windowTracker;
    this.tabContext = tabContext;
    this.properties = properties;
    this.extension = extension;
  }

  getTab(aTabId) {
    if (aTabId !== null) {
      return this.tabTracker.getTab(aTabId);
    }
    return null;
  }

  getWindow(aWindowId) {
    if (aWindowId !== null) {
      return this.windowTracker.getWindow(aWindowId);
    }
    return null;
  }

  extractProperties(aAction) {
    const merged = {};
    for (const p of this.properties) {
      merged[p] = aAction[p];
    }
    return merged;
  }

  eventDispatcherFor(aTabId) {
    if (!aTabId) {
      return lazy.EventDispatcher.instance;
    }

    const windowId = lazy.GeckoViewTabBridge.tabIdToWindowId(aTabId);
    const window = this.windowTracker.getWindow(windowId);
    return window.WindowEventDispatcher;
  }

  sendRequest(aTabId, aData) {
    return this.eventDispatcherFor(aTabId).sendRequest({
      ...aData,
      aTabId,
      extensionId: this.extension.id,
    });
  }
}

class EmbedderPort {
  constructor(portId, messenger) {
    this.id = portId;
    this.messenger = messenger;
    this.dispatcher = lazy.EventDispatcher.byName(`port:${portId}`);
    this.dispatcher.registerListener(this, [
      "GeckoView:WebExtension:PortMessageFromApp",
      "GeckoView:WebExtension:PortDisconnect",
    ]);
  }
  close() {
    this.dispatcher.unregisterListener(this, [
      "GeckoView:WebExtension:PortMessageFromApp",
      "GeckoView:WebExtension:PortDisconnect",
    ]);
  }
  onPortDisconnect() {
    this.dispatcher.sendRequest({
      type: "GeckoView:WebExtension:Disconnect",
      sender: this.sender,
    });
    this.close();
  }
  onPortMessage(holder) {
    this.dispatcher.sendRequest({
      type: "GeckoView:WebExtension:PortMessage",
      data: holder.deserialize({}),
    });
  }
  onEvent(aEvent, aData, aCallback) {
    debug`onEvent ${aEvent} ${aData}`;

    switch (aEvent) {
      case "GeckoView:WebExtension:PortMessageFromApp": {
        const holder = new StructuredCloneHolder(
          "GeckoView:WebExtension:PortMessageFromApp",
          null,
          aData.message
        );
        this.messenger.sendPortMessage(this.id, holder);
        break;
      }

      case "GeckoView:WebExtension:PortDisconnect": {
        this.messenger.sendPortDisconnect(this.id);
        this.close();
        break;
      }
    }
  }
}

export class GeckoViewConnection {
  constructor(sender, target, nativeApp, allowContentMessaging) {
    this.sender = sender;
    this.target = target;
    this.nativeApp = nativeApp;
    this.allowContentMessaging = allowContentMessaging;

    if (!allowContentMessaging && sender.envType !== "addon_child") {
      throw new Error(`Unexpected messaging sender: ${JSON.stringify(sender)}`);
    }
  }

  get dispatcher() {
    if (this.sender.envType === "addon_child") {
      // If this is a WebExtension Page we will have a GeckoSession associated
      // to it and thus a dispatcher.
      const dispatcher = GeckoViewUtils.getDispatcherForWindow(
        this.target.ownerGlobal
      );
      if (dispatcher) {
        return dispatcher;
      }

      // No dispatcher means this message is coming from a background script,
      // use the global event handler
      return lazy.EventDispatcher.instance;
    } else if (
      this.sender.envType === "content_child" &&
      this.allowContentMessaging
    ) {
      // If this message came from a content script, send the message to
      // the corresponding tab messenger so that GeckoSession can pick it
      // up.
      return GeckoViewUtils.getDispatcherForWindow(this.target.ownerGlobal);
    }

    throw new Error(`Uknown sender envType: ${this.sender.envType}`);
  }

  _sendMessage({ type, portId, data }) {
    const message = {
      type,
      sender: this.sender,
      data,
      portId,
      extensionId: this.sender.id,
      nativeApp: this.nativeApp,
    };

    return this.dispatcher.sendRequestForResult(message);
  }

  sendMessage(data) {
    return this._sendMessage({
      type: "GeckoView:WebExtension:Message",
      data: data.deserialize({}),
    });
  }

  onConnect(portId, messenger) {
    const port = new EmbedderPort(portId, messenger);

    this._sendMessage({
      type: "GeckoView:WebExtension:Connect",
      data: {},
      portId: port.id,
    });

    return port;
  }
}

async function filterPromptPermissions(aPermissions) {
  if (!aPermissions) {
    return [];
  }
  const promptPermissions = [];
  for (const permission of aPermissions) {
    if (!(await lazy.Extension.shouldPromptFor(permission))) {
      continue;
    }
    promptPermissions.push(permission);
  }
  return promptPermissions;
}

// Keep in sync with WebExtension.java
const FLAG_NONE = 0;
const FLAG_ALLOW_CONTENT_MESSAGING = 1 << 0;

function exportFlags(aPolicy) {
  let flags = FLAG_NONE;
  if (!aPolicy) {
    return flags;
  }
  const { extension } = aPolicy;
  if (extension.hasPermission("nativeMessagingFromContent")) {
    flags |= FLAG_ALLOW_CONTENT_MESSAGING;
  }
  return flags;
}

async function exportExtension(aAddon, aPermissions, aSourceURI) {
  // First, let's make sure the policy is ready if present
  let policy = WebExtensionPolicy.getByID(aAddon.id);
  if (policy?.readyPromise) {
    policy = await policy.readyPromise;
  }
  const {
    amoListingURL,
    averageRating,
    blocklistState,
    creator,
    description,
    embedderDisabled,
    fullDescription,
    homepageURL,
    icons,
    id,
    incognito,
    isActive,
    isBuiltin,
    isCorrectlySigned,
    isRecommended,
    name,
    optionsType,
    optionsURL,
    reviewCount,
    reviewURL,
    signedState,
    sourceURI,
    temporarilyInstalled,
    userDisabled,
    version,
  } = aAddon;
  let creatorName = null;
  let creatorURL = null;
  if (creator) {
    const { name, url } = creator;
    creatorName = name;
    creatorURL = url;
  }
  const openOptionsPageInTab =
    optionsType === lazy.AddonManager.OPTIONS_TYPE_TAB;
  const disabledFlags = [];
  if (userDisabled) {
    disabledFlags.push("userDisabled");
  }
  if (blocklistState !== Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
    disabledFlags.push("blocklistDisabled");
  }
  if (embedderDisabled) {
    disabledFlags.push("appDisabled");
  }
  // Add-ons without an `isCorrectlySigned` property are correctly signed as
  // they aren't the correct type for signing.
  if (lazy.AddonSettings.REQUIRE_SIGNING && isCorrectlySigned === false) {
    disabledFlags.push("signatureDisabled");
  }
  if (lazy.AddonManager.checkCompatibility && !aAddon.isCompatible) {
    disabledFlags.push("appVersionDisabled");
  }
  const baseURL = policy ? policy.getURL() : "";
  const privateBrowsingAllowed = policy ? policy.privateBrowsingAllowed : false;
  const promptPermissions = aPermissions
    ? await filterPromptPermissions(aPermissions.permissions)
    : [];

  let updateDate;
  try {
    updateDate = aAddon.updateDate?.toISOString();
  } catch {
    // `installDate` is used as a fallback for `updateDate` but only when the
    // add-on is installed. Before that, `installDate` might be undefined,
    // which would cause `updateDate` (and `installDate`) to be an "invalid
    // date".
    updateDate = null;
  }

  return {
    webExtensionId: id,
    locationURI: aSourceURI != null ? aSourceURI.spec : "",
    isBuiltIn: isBuiltin,
    webExtensionFlags: exportFlags(policy),
    metaData: {
      amoListingURL,
      averageRating,
      baseURL,
      blocklistState,
      creatorName,
      creatorURL,
      description,
      disabledFlags,
      downloadUrl: sourceURI?.displaySpec,
      enabled: isActive,
      fullDescription,
      homepageURL,
      icons,
      incognito,
      isRecommended,
      name,
      openOptionsPageInTab,
      optionsPageURL: optionsURL,
      origins: aPermissions ? aPermissions.origins : [],
      privateBrowsingAllowed,
      promptPermissions,
      reviewCount,
      reviewURL,
      signedState,
      temporary: temporarilyInstalled,
      updateDate,
      version,
    },
  };
}

class ExtensionInstallListener {
  constructor(aResolve, aInstall, aInstallId) {
    this.install = aInstall;
    this.installId = aInstallId;
    this.resolve = result => {
      aResolve(result);
      lazy.EventDispatcher.instance.unregisterListener(this, [
        "GeckoView:WebExtension:CancelInstall",
      ]);
    };
    lazy.EventDispatcher.instance.registerListener(this, [
      "GeckoView:WebExtension:CancelInstall",
    ]);
  }

  async onEvent(aEvent, aData, aCallback) {
    debug`onEvent ${aEvent} ${aData}`;

    switch (aEvent) {
      case "GeckoView:WebExtension:CancelInstall": {
        const { installId } = aData;
        if (this.installId !== installId) {
          return;
        }
        this.cancelling = true;
        let cancelled = false;
        try {
          this.install.cancel();
          cancelled = true;
        } catch (ex) {
          // install may have already failed or been cancelled
          debug`Unable to cancel the install installId ${installId}, Error: ${ex}`;
          // When we attempt to cancel an install but the cancellation fails for
          // some reasons (e.g., because it is too late), we need to revert this
          // boolean property to allow another cancellation to be possible.
          // Otherwise, events like `onDownloadCancelled` won't resolve and that
          // will cause problems in the embedder.
          this.cancelling = false;
        }
        aCallback.onSuccess({ cancelled });
        break;
      }
    }
  }

  onDownloadCancelled(aInstall) {
    debug`onDownloadCancelled state=${aInstall.state}`;
    // Do not resolve we were told to CancelInstall,
    // to prevent racing with that handler.
    if (!this.cancelling) {
      const { error: installError, state } = aInstall;
      this.resolve({ installError, state });
    }
  }

  onDownloadFailed(aInstall) {
    debug`onDownloadFailed state=${aInstall.state}`;
    const { error: installError, state } = aInstall;
    this.resolve({ installError, state });
  }

  onDownloadEnded() {
    // Nothing to do
  }

  onInstallCancelled(aInstall, aCancelledByUser) {
    debug`onInstallCancelled state=${aInstall.state} cancelledByUser=${aCancelledByUser}`;
    // Do not resolve we were told to CancelInstall,
    // to prevent racing with that handler.
    if (!this.cancelling) {
      const { error: installError, state } = aInstall;
      // An install can be cancelled by the user OR something else, e.g. when
      // the blocklist prevents the install of a blocked add-on.
      this.resolve({ installError, state, cancelledByUser: aCancelledByUser });
    }
  }

  onInstallFailed(aInstall) {
    debug`onInstallFailed state=${aInstall.state}`;
    const { error: installError, state } = aInstall;
    this.resolve({ installError, state });
  }

  onInstallPostponed(aInstall) {
    debug`onInstallPostponed state=${aInstall.state}`;
    const { error: installError, state } = aInstall;
    this.resolve({ installError, state });
  }

  async onInstallEnded(aInstall, aAddon) {
    debug`onInstallEnded addonId=${aAddon.id}`;
    const extension = await exportExtension(
      aAddon,
      aAddon.userPermissions,
      aInstall.sourceURI
    );
    this.resolve({ extension });
  }
}

class ExtensionPromptObserver {
  constructor() {
    Services.obs.addObserver(this, "webextension-permission-prompt");
    Services.obs.addObserver(this, "webextension-optional-permission-prompt");
  }

  async permissionPrompt(aInstall, aAddon, aInfo) {
    const { sourceURI } = aInstall;
    const { permissions } = aInfo;
    const extension = await exportExtension(aAddon, permissions, sourceURI);
    const response = await lazy.EventDispatcher.instance.sendRequestForResult({
      type: "GeckoView:WebExtension:InstallPrompt",
      extension,
    });

    if (response.allow) {
      aInfo.resolve();
    } else {
      aInfo.reject();
    }
  }

  async optionalPermissionPrompt(aExtensionId, aPermissions, resolve) {
    const response = await lazy.EventDispatcher.instance.sendRequestForResult({
      type: "GeckoView:WebExtension:OptionalPrompt",
      extensionId: aExtensionId,
      permissions: aPermissions,
    });
    resolve(response.allow);
  }

  observe(aSubject, aTopic, aData) {
    debug`observe ${aTopic}`;

    switch (aTopic) {
      case "webextension-permission-prompt": {
        const { info } = aSubject.wrappedJSObject;
        const { addon, install } = info;
        this.permissionPrompt(install, addon, info);
        break;
      }
      case "webextension-optional-permission-prompt": {
        const { id, permissions, resolve } = aSubject.wrappedJSObject;
        this.optionalPermissionPrompt(id, permissions, resolve);
        break;
      }
    }
  }
}

class AddonInstallObserver {
  constructor() {
    Services.obs.addObserver(this, "addon-install-failed");
  }

  async onInstallationFailed(aAddon, aAddonName, aError) {
    // aAddon could be null if we have a network error where we can't download the xpi file.
    // aAddon could also be a valid object without an ID when the xpi file is corrupt.
    let extension = null;
    if (aAddon?.id) {
      extension = await exportExtension(
        aAddon,
        aAddon.userPermissions,
        /* aSourceURI */ null
      );
    }

    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnInstallationFailed",
      extension,
      addonName: aAddonName,
      error: aError,
    });
  }

  observe(aSubject, aTopic, aData) {
    debug`observe ${aTopic}`;
    switch (aTopic) {
      case "addon-install-failed": {
        aSubject.wrappedJSObject.installs.forEach(install => {
          const { addon, error, name } = install;
          // For some errors, we have a valid `addon` but not the `name` set on
          // the `install` object yet so we check both here.
          const addonName = name || addon?.name;

          this.onInstallationFailed(addon, addonName, error);
        });
        break;
      }
    }
  }
}

new ExtensionPromptObserver();
new AddonInstallObserver();

class AddonManagerListener {
  constructor() {
    lazy.AddonManager.addAddonListener(this);
    // Some extension properties are not going to be available right away after the extension
    // have been installed (e.g. in particular metaData.optionsPageURL), the GeckoView event
    // dispatched from onExtensionReady listener will be providing updated extension metadata to
    // the GeckoView side when it is actually going to be available.
    this.onExtensionReady = this.onExtensionReady.bind(this);
    lazy.Management.on("ready", this.onExtensionReady);
  }

  async onExtensionReady(name, extInstance) {
    // In xpcshell tests there wil be test extensions that trigger this event while the
    // AddonManager has not been started at all, on the contrary on a regular browser
    // instance the AddonManager is expected to be already fully started for an extension
    // for the extension to be able to reach the "ready" state, and so we just silently
    // early exit here if the AddonManager is not ready.
    if (!lazy.AddonManager.isReady) {
      return;
    }

    debug`onExtensionReady ${extInstance.id}`;

    const addonWrapper = await lazy.AddonManager.getAddonByID(extInstance.id);
    if (!addonWrapper) {
      return;
    }

    const extension = await exportExtension(
      addonWrapper,
      addonWrapper.userPermissions,
      /* aSourceURI */ null
    );
    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnReady",
      extension,
    });
  }

  async onDisabling(aAddon) {
    debug`onDisabling ${aAddon.id}`;

    const extension = await exportExtension(
      aAddon,
      aAddon.userPermissions,
      /* aSourceURI */ null
    );
    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnDisabling",
      extension,
    });
  }

  async onDisabled(aAddon) {
    debug`onDisabled ${aAddon.id}`;

    const extension = await exportExtension(
      aAddon,
      aAddon.userPermissions,
      /* aSourceURI */ null
    );
    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnDisabled",
      extension,
    });
  }

  async onEnabling(aAddon) {
    debug`onEnabling ${aAddon.id}`;

    const extension = await exportExtension(
      aAddon,
      aAddon.userPermissions,
      /* aSourceURI */ null
    );
    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnEnabling",
      extension,
    });
  }

  async onEnabled(aAddon) {
    debug`onEnabled ${aAddon.id}`;

    const extension = await exportExtension(
      aAddon,
      aAddon.userPermissions,
      /* aSourceURI */ null
    );
    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnEnabled",
      extension,
    });
  }

  async onUninstalling(aAddon) {
    debug`onUninstalling ${aAddon.id}`;

    const extension = await exportExtension(
      aAddon,
      aAddon.userPermissions,
      /* aSourceURI */ null
    );
    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnUninstalling",
      extension,
    });
  }

  async onUninstalled(aAddon) {
    debug`onUninstalled ${aAddon.id}`;

    const extension = await exportExtension(
      aAddon,
      aAddon.userPermissions,
      /* aSourceURI */ null
    );
    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnUninstalled",
      extension,
    });
  }

  async onInstalling(aAddon) {
    debug`onInstalling ${aAddon.id}`;

    const extension = await exportExtension(
      aAddon,
      aAddon.userPermissions,
      /* aSourceURI */ null
    );
    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnInstalling",
      extension,
    });
  }

  async onInstalled(aAddon) {
    debug`onInstalled ${aAddon.id}`;

    const extension = await exportExtension(
      aAddon,
      aAddon.userPermissions,
      /* aSourceURI */ null
    );
    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnInstalled",
      extension,
    });
  }
}

new AddonManagerListener();

class ExtensionProcessListener {
  constructor() {
    this.onExtensionProcessCrash = this.onExtensionProcessCrash.bind(this);
    lazy.Management.on("extension-process-crash", this.onExtensionProcessCrash);

    lazy.EventDispatcher.instance.registerListener(this, [
      "GeckoView:WebExtension:EnableProcessSpawning",
      "GeckoView:WebExtension:DisableProcessSpawning",
    ]);
  }

  async onEvent(aEvent, aData, aCallback) {
    debug`onEvent ${aEvent} ${aData}`;

    switch (aEvent) {
      case "GeckoView:WebExtension:EnableProcessSpawning": {
        debug`Extension process crash -> re-enable process spawning`;
        lazy.ExtensionProcessCrashObserver.enableProcessSpawning();
        break;
      }
    }
  }

  async onExtensionProcessCrash(name, { childID, processSpawningDisabled }) {
    debug`Extension process crash -> childID=${childID} processSpawningDisabled=${processSpawningDisabled}`;

    // When an extension process has crashed too many times, Gecko will set
    // `processSpawningDisabled` and no longer allow the extension process
    // spawning. We only want to send a request to the embedder when we are
    // disabling the process spawning.  If process spawning is still enabled
    // then we short circuit and don't notify the embedder.
    if (!processSpawningDisabled) {
      return;
    }

    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnDisabledProcessSpawning",
    });
  }
}

new ExtensionProcessListener();

class MobileWindowTracker extends EventEmitter {
  constructor() {
    super();
    this._topWindow = null;
    this._topNonPBWindow = null;
  }

  get topWindow() {
    if (this._topWindow) {
      return this._topWindow.get();
    }
    return null;
  }

  get topNonPBWindow() {
    if (this._topNonPBWindow) {
      return this._topNonPBWindow.get();
    }
    return null;
  }

  setTabActive(aWindow, aActive) {
    const { browser, tab: nativeTab, docShell } = aWindow;
    nativeTab.active = aActive;

    if (aActive) {
      this._topWindow = Cu.getWeakReference(aWindow);
      const isPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(browser);
      if (!isPrivate) {
        this._topNonPBWindow = this._topWindow;
      }
      this.emit("tab-activated", {
        windowId: docShell.outerWindowID,
        tabId: nativeTab.id,
        isPrivate,
        nativeTab,
      });
    }
  }
}

export var mobileWindowTracker = new MobileWindowTracker();

async function updatePromptHandler(aInfo) {
  const oldPerms = aInfo.existingAddon.userPermissions;
  if (!oldPerms) {
    // Updating from a legacy add-on, let it proceed
    return;
  }

  const newPerms = aInfo.addon.userPermissions;

  const difference = lazy.Extension.comparePermissions(oldPerms, newPerms);

  // We only care about permissions that we can prompt the user for
  const newPermissions = await filterPromptPermissions(difference.permissions);
  const { origins: newOrigins } = difference;

  // If there are no new permissions, just proceed
  if (!newOrigins.length && !newPermissions.length) {
    return;
  }

  const currentlyInstalled = await exportExtension(
    aInfo.existingAddon,
    oldPerms
  );
  const updatedExtension = await exportExtension(aInfo.addon, newPerms);
  const response = await lazy.EventDispatcher.instance.sendRequestForResult({
    type: "GeckoView:WebExtension:UpdatePrompt",
    currentlyInstalled,
    updatedExtension,
    newPermissions,
    newOrigins,
  });

  if (!response.allow) {
    throw new Error("Extension update rejected.");
  }
}

export var GeckoViewWebExtension = {
  observe(aSubject, aTopic, aData) {
    debug`observe ${aTopic}`;

    switch (aTopic) {
      case "testing-installed-addon":
      case "testing-uninstalled-addon": {
        // We pretend devtools installed/uninstalled this addon so we don't
        // have to add an API just for internal testing.
        // TODO: assert this is under a test
        lazy.EventDispatcher.instance.sendRequest({
          type: "GeckoView:WebExtension:DebuggerListUpdated",
        });
        break;
      }

      case "devtools-installed-addon": {
        lazy.EventDispatcher.instance.sendRequest({
          type: "GeckoView:WebExtension:DebuggerListUpdated",
        });
        break;
      }
    }
  },

  async extensionById(aId) {
    const addon = await lazy.AddonManager.getAddonByID(aId);
    if (!addon) {
      debug`Could not find extension with id=${aId}`;
      return null;
    }
    return addon;
  },

  async ensureBuiltIn(aUri, aId) {
    await lazy.AddonManager.readyPromise;
    // Although the add-on is privileged in practice due to it being installed
    // as a built-in extension, we pass isPrivileged=false since the exact flag
    // doesn't matter as we are only using ExtensionData to read the version.
    const extensionData = new lazy.ExtensionData(aUri, false);
    const [extensionVersion, extension] = await Promise.all([
      extensionData.getExtensionVersionWithoutValidation(),
      this.extensionById(aId),
    ]);

    if (!extension || extensionVersion != extension.version) {
      return this.installBuiltIn(aUri);
    }

    const exported = await exportExtension(
      extension,
      extension.userPermissions,
      aUri
    );
    return { extension: exported };
  },

  async installBuiltIn(aUri) {
    await lazy.AddonManager.readyPromise;
    const addon = await lazy.AddonManager.installBuiltinAddon(aUri.spec);
    const exported = await exportExtension(addon, addon.userPermissions, aUri);
    return { extension: exported };
  },

  async installWebExtension(aInstallId, aUri, installMethod) {
    const install = await lazy.AddonManager.getInstallForURL(aUri.spec, {
      telemetryInfo: {
        source: "geckoview-app",
        method: installMethod || undefined,
      },
    });
    const promise = new Promise(resolve => {
      install.addListener(
        new ExtensionInstallListener(resolve, install, aInstallId)
      );
    });

    lazy.AddonManager.installAddonFromAOM(null, aUri, install);

    return promise;
  },

  async setPrivateBrowsingAllowed(aId, aAllowed) {
    if (aAllowed) {
      await lazy.ExtensionPermissions.add(aId, PRIVATE_BROWSING_PERMISSION);
    } else {
      await lazy.ExtensionPermissions.remove(aId, PRIVATE_BROWSING_PERMISSION);
    }

    // Reload the extension if it is already enabled.  This ensures any change
    // on the private browsing permission is properly handled.
    const addon = await this.extensionById(aId);
    if (addon.isActive) {
      await addon.reload();
    }

    return exportExtension(addon, addon.userPermissions, /* aSourceURI */ null);
  },

  async uninstallWebExtension(aId) {
    const extension = await this.extensionById(aId);
    if (!extension) {
      throw new Error(`Could not find an extension with id='${aId}'.`);
    }

    return extension.uninstall();
  },

  async browserActionClick(aId) {
    const policy = WebExtensionPolicy.getByID(aId);
    if (!policy) {
      return undefined;
    }

    const browserAction = this.browserActions.get(policy.extension);
    if (!browserAction) {
      return undefined;
    }

    return browserAction.triggerClickOrPopup();
  },

  async pageActionClick(aId) {
    const policy = WebExtensionPolicy.getByID(aId);
    if (!policy) {
      return undefined;
    }

    const pageAction = this.pageActions.get(policy.extension);
    if (!pageAction) {
      return undefined;
    }

    return pageAction.triggerClickOrPopup();
  },

  async actionDelegateAttached(aId) {
    const policy = WebExtensionPolicy.getByID(aId);
    if (!policy) {
      debug`Could not find extension with id=${aId}`;
      return;
    }

    const { extension } = policy;

    const browserAction = this.browserActions.get(extension);
    if (browserAction) {
      // Send information about this action to the delegate
      browserAction.updateOnChange(null);
    }

    const pageAction = this.pageActions.get(extension);
    if (pageAction) {
      pageAction.updateOnChange(null);
    }
  },

  async enableWebExtension(aId, aSource) {
    const extension = await this.extensionById(aId);
    if (aSource === "user") {
      await extension.enable();
    } else if (aSource === "app") {
      await extension.setEmbedderDisabled(false);
    }
    return exportExtension(
      extension,
      extension.userPermissions,
      /* aSourceURI */ null
    );
  },

  async disableWebExtension(aId, aSource) {
    const extension = await this.extensionById(aId);
    if (aSource === "user") {
      await extension.disable();
    } else if (aSource === "app") {
      await extension.setEmbedderDisabled(true);
    }
    return exportExtension(
      extension,
      extension.userPermissions,
      /* aSourceURI */ null
    );
  },

  /**
   * @return A promise resolved with either an AddonInstall object if an update
   * is available or null if no update is found.
   */
  checkForUpdate(aAddon) {
    return new Promise(resolve => {
      const listener = {
        onUpdateAvailable(aAddon, install) {
          install.promptHandler = updatePromptHandler;
          resolve(install);
        },
        onNoUpdateAvailable() {
          resolve(null);
        },
      };
      aAddon.findUpdates(
        listener,
        lazy.AddonManager.UPDATE_WHEN_USER_REQUESTED
      );
    });
  },

  async updateWebExtension(aId) {
    // Refresh the cached metadata when necessary. This allows us to always
    // export relatively recent metadata to the embedder.
    if (lazy.AddonRepository.isMetadataStale()) {
      // We use a promise to avoid more than one call to `backgroundUpdateCheck()`
      // when `updateWebExtension()` is called for multiple add-ons in parallel.
      if (!this._promiseAddonRepositoryUpdate) {
        this._promiseAddonRepositoryUpdate =
          lazy.AddonRepository.backgroundUpdateCheck().finally(() => {
            this._promiseAddonRepositoryUpdate = null;
          });
      }
      await this._promiseAddonRepositoryUpdate;
    }

    // Early-return when extension updates are disabled.
    if (!lazy.AddonManager.updateEnabled) {
      return null;
    }

    const extension = await this.extensionById(aId);

    const install = await this.checkForUpdate(extension);
    if (!install) {
      return null;
    }
    const promise = new Promise(resolve => {
      install.addListener(new ExtensionInstallListener(resolve));
    });
    install.install();
    return promise;
  },

  validateBuiltInLocation(aLocationUri, aCallback) {
    let uri;
    try {
      uri = Services.io.newURI(aLocationUri);
    } catch (ex) {
      aCallback.onError(`Could not parse uri: ${aLocationUri}. Error: ${ex}`);
      return null;
    }

    if (uri.scheme !== "resource" || uri.host !== "android") {
      aCallback.onError(`Only resource://android/... URIs are allowed.`);
      return null;
    }

    if (uri.fileName !== "") {
      aCallback.onError(
        `This URI does not point to a folder. Note: folders URIs must end with a "/".`
      );
      return null;
    }

    return uri;
  },

  /* eslint-disable complexity */
  async onEvent(aEvent, aData, aCallback) {
    debug`onEvent ${aEvent} ${aData}`;

    switch (aEvent) {
      case "GeckoView:BrowserAction:Click": {
        const popupUrl = await this.browserActionClick(aData.extensionId);
        aCallback.onSuccess(popupUrl);
        break;
      }
      case "GeckoView:PageAction:Click": {
        const popupUrl = await this.pageActionClick(aData.extensionId);
        aCallback.onSuccess(popupUrl);
        break;
      }
      case "GeckoView:WebExtension:MenuClick": {
        aCallback.onError(`Not implemented`);
        break;
      }
      case "GeckoView:WebExtension:MenuShow": {
        aCallback.onError(`Not implemented`);
        break;
      }
      case "GeckoView:WebExtension:MenuHide": {
        aCallback.onError(`Not implemented`);
        break;
      }

      case "GeckoView:ActionDelegate:Attached": {
        this.actionDelegateAttached(aData.extensionId);
        break;
      }

      case "GeckoView:WebExtension:Get": {
        const extension = await this.extensionById(aData.extensionId);
        if (!extension) {
          aCallback.onError(
            `Could not find extension with id: ${aData.extensionId}`
          );
          return;
        }

        aCallback.onSuccess({
          extension: await exportExtension(
            extension,
            extension.userPermissions,
            /* aSourceURI */ null
          ),
        });
        break;
      }

      case "GeckoView:WebExtension:SetPBAllowed": {
        const { extensionId, allowed } = aData;
        try {
          const extension = await this.setPrivateBrowsingAllowed(
            extensionId,
            allowed
          );
          aCallback.onSuccess({ extension });
        } catch (ex) {
          aCallback.onError(`Unexpected error: ${ex}`);
        }
        break;
      }

      case "GeckoView:WebExtension:Install": {
        const { locationUri, installId, installMethod } = aData;
        let uri;
        try {
          uri = Services.io.newURI(locationUri);
        } catch (ex) {
          aCallback.onError(`Could not parse uri: ${locationUri}`);
          return;
        }

        try {
          const result = await this.installWebExtension(
            installId,
            uri,
            installMethod
          );
          if (result.extension) {
            aCallback.onSuccess(result);
          } else {
            aCallback.onError(result);
          }
        } catch (ex) {
          debug`Install exception error ${ex}`;
          aCallback.onError(`Unexpected error: ${ex}`);
        }

        break;
      }

      case "GeckoView:WebExtension:EnsureBuiltIn": {
        const { locationUri, webExtensionId } = aData;
        const uri = this.validateBuiltInLocation(locationUri, aCallback);
        if (!uri) {
          return;
        }

        try {
          const result = await this.ensureBuiltIn(uri, webExtensionId);
          if (result.extension) {
            aCallback.onSuccess(result);
          } else {
            aCallback.onError(result);
          }
        } catch (ex) {
          debug`Install exception error ${ex}`;
          aCallback.onError(`Unexpected error: ${ex}`);
        }

        break;
      }

      case "GeckoView:WebExtension:InstallBuiltIn": {
        const uri = this.validateBuiltInLocation(aData.locationUri, aCallback);
        if (!uri) {
          return;
        }

        try {
          const result = await this.installBuiltIn(uri);
          if (result.extension) {
            aCallback.onSuccess(result);
          } else {
            aCallback.onError(result);
          }
        } catch (ex) {
          debug`Install exception error ${ex}`;
          aCallback.onError(`Unexpected error: ${ex}`);
        }

        break;
      }

      case "GeckoView:WebExtension:Uninstall": {
        try {
          await this.uninstallWebExtension(aData.webExtensionId);
          aCallback.onSuccess();
        } catch (ex) {
          debug`Failed uninstall ${ex}`;
          aCallback.onError(
            `This extension cannot be uninstalled. Error: ${ex}.`
          );
        }
        break;
      }

      case "GeckoView:WebExtension:Enable": {
        try {
          const { source, webExtensionId } = aData;
          if (source !== "user" && source !== "app") {
            throw new Error("Illegal source parameter");
          }
          const extension = await this.enableWebExtension(
            webExtensionId,
            source
          );
          aCallback.onSuccess({ extension });
        } catch (ex) {
          debug`Failed enable ${ex}`;
          aCallback.onError(`Unexpected error: ${ex}`);
        }
        break;
      }

      case "GeckoView:WebExtension:Disable": {
        try {
          const { source, webExtensionId } = aData;
          if (source !== "user" && source !== "app") {
            throw new Error("Illegal source parameter");
          }
          const extension = await this.disableWebExtension(
            webExtensionId,
            source
          );
          aCallback.onSuccess({ extension });
        } catch (ex) {
          debug`Failed disable ${ex}`;
          aCallback.onError(`Unexpected error: ${ex}`);
        }
        break;
      }

      case "GeckoView:WebExtension:List": {
        try {
          await lazy.AddonManager.readyPromise;
          const addons = await lazy.AddonManager.getAddonsByTypes([
            "extension",
          ]);
          const extensions = await Promise.all(
            addons.map(addon =>
              exportExtension(addon, addon.userPermissions, null)
            )
          );

          aCallback.onSuccess({ extensions });
        } catch (ex) {
          debug`Failed list ${ex}`;
          aCallback.onError(`Unexpected error: ${ex}`);
        }
        break;
      }

      case "GeckoView:WebExtension:Update": {
        try {
          const { webExtensionId } = aData;
          const result = await this.updateWebExtension(webExtensionId);
          if (result === null || result.extension) {
            aCallback.onSuccess(result);
          } else {
            aCallback.onError(result);
          }
        } catch (ex) {
          debug`Failed update ${ex}`;
          aCallback.onError(`Unexpected error: ${ex}`);
        }
        break;
      }
    }
  },
};

// WeakMap[Extension -> BrowserAction]
GeckoViewWebExtension.browserActions = new WeakMap();
// WeakMap[Extension -> PageAction]
GeckoViewWebExtension.pageActions = new WeakMap();