/* 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 { computeSha256HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs";
import {
  GATED_PERMISSIONS,
  SITEPERMS_ADDON_PROVIDER_PREF,
  SITEPERMS_ADDON_TYPE,
  isGatedPermissionType,
  isKnownPublicSuffix,
  isPrincipalInSitePermissionsBlocklist,
} from "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
});
ChromeUtils.defineLazyGetter(
  lazy,
  "addonsBundle",
  () => new Localization(["toolkit/about/aboutAddons.ftl"], true)
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "SITEPERMS_ADDON_PROVIDER_ENABLED",
  SITEPERMS_ADDON_PROVIDER_PREF,
  false
);

const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created";
const SITEPERMS_ADDON_ID_SUFFIX = "@siteperms.mozilla.org";

// Generate a per-session random salt, which is then used to generate
// per-siteOrigin hashed strings used as the addon id in SitePermsAddonWrapper constructor
// (expected to be matching new addon id generated for the same siteOrigin during
// the same browsing session and different ones in new browsing sessions).
//
// NOTE: `generateSalt` is exported for testing purpose, should not be
// used outside of tests.
let SALT;
export function generateSalt() {
  // Throw if we're not in test and SALT is already defined
  if (
    typeof SALT !== "undefined" &&
    !Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")
  ) {
    throw new Error("This should only be called from XPCShell tests");
  }
  SALT = crypto.getRandomValues(new Uint8Array(12)).join("");
}

function getSalt() {
  if (!SALT) {
    generateSalt();
  }
  return SALT;
}

class SitePermsAddonWrapper {
  // An array of nsIPermission granted for the siteOrigin.
  // We can't use a Set as handlePermissionChange might be called with different
  // nsIPermission instance for the same permission (in the generic sense)
  #permissions = [];

  // This will be set to true in the `uninstall` method to recognize when a perm-changed notification
  // is actually triggered by the SitePermsAddonWrapper uninstall method itself.
  isUninstalling = false;

  /**
   * @param {string} siteOriginNoSuffix: The origin this addon is installed for
   *                                     WITHOUT the suffix generated from the
   *                                     origin attributes (see:
   *                                     nsIPrincipal.siteOriginNoSuffix).
   * @param {Array<nsIPermission>} permissions: An array of the initial
   *                                            permissions the user granted
   *                                            for the addon origin.
   */
  constructor(siteOriginNoSuffix, permissions = []) {
    this.siteOrigin = siteOriginNoSuffix;
    this.principal =
      Services.scriptSecurityManager.createContentPrincipalFromOrigin(
        this.siteOrigin
      );
    // Use a template string for the concat in case `siteOrigin` isn't a string.
    const saltedValue = `${this.siteOrigin}${getSalt()}`;
    this.id = `${computeSha256HashAsString(
      saltedValue
    )}${SITEPERMS_ADDON_ID_SUFFIX}`;

    for (const perm of permissions) {
      this.#permissions.push(perm);
    }
  }

  get isUninstalled() {
    return this.#permissions.length === 0;
  }

  /**
   * Returns the list of gated permissions types granted for the instance's origin
   *
   * @return {Array<String>}
   */
  get sitePermissions() {
    return Array.from(new Set(this.#permissions.map(perm => perm.type)));
  }

  /**
   * Update #permissions, and calls `uninstall` if there are no remaining gated permissions
   * granted. This is called by SitePermsAddonProvider when it gets a "perm-changed" notification for a gated
   * permission.
   *
   * @param {nsIPermission} permission: The permission being added/removed
   * @param {String} action: The action perm-changed notifies us about
   */
  handlePermissionChange(permission, action) {
    if (action == "added") {
      this.#permissions.push(permission);
    } else if (action == "deleted") {
      // We want to remove the registered permission for the right principal (looking into originSuffix so we
      // can unregister revoked permission on a specific context, private window, ...).
      this.#permissions = this.#permissions.filter(
        perm =>
          !(
            perm.type == permission.type &&
            perm.principal.originSuffix === permission.principal.originSuffix
          )
      );

      if (this.#permissions.length === 0) {
        this.uninstall();
      }
    }
  }

  get type() {
    return SITEPERMS_ADDON_TYPE;
  }

  get name() {
    return lazy.addonsBundle.formatValueSync("addon-sitepermission-host", {
      host: this.principal.host,
    });
  }

  get creator() {
    return undefined;
  }

  get homepageURL() {
    return undefined;
  }

  get description() {
    return undefined;
  }

  get fullDescription() {
    return undefined;
  }

  get version() {
    // We consider the previous implementation attempt (signed addons) to be the initial version,
    // hence the 2.0 for this approach.
    return "2.0";
  }
  get updateDate() {
    return undefined;
  }

  get isActive() {
    return true;
  }

  get appDisabled() {
    return false;
  }

  get userDisabled() {
    return false;
  }
  set userDisabled(aVal) {}

  get size() {
    return 0;
  }

  async updateBlocklistState() {}

  get blocklistState() {
    return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
  }

  get scope() {
    return lazy.AddonManager.SCOPE_APPLICATION;
  }

  get pendingOperations() {
    return lazy.AddonManager.PENDING_NONE;
  }

  get operationsRequiringRestart() {
    return lazy.AddonManager.OP_NEEDS_RESTART_NONE;
  }

  get permissions() {
    // The addon only supports PERM_CAN_UNINSTALL and no other AOM permission.
    return lazy.AddonManager.PERM_CAN_UNINSTALL;
  }

  get signedState() {
    // Will make the permission prompt use the webextSitePerms.headerUnsignedWithPerms string
    return lazy.AddonManager.SIGNEDSTATE_MISSING;
  }

  async enable() {}

  async disable() {}

  /**
   * Uninstall the addon, calling AddonManager hooks and removing all granted permissions.
   *
   * @throws Services.perms.removeFromPrincipal could throw, see PermissionManager::AddInternal.
   */
  async uninstall() {
    if (this.isUninstalling) {
      return;
    }
    try {
      this.isUninstalling = true;
      lazy.AddonManagerPrivate.callAddonListeners(
        "onUninstalling",
        this,
        false
      );
      const permissions = [...this.#permissions];
      for (const permission of permissions) {
        try {
          Services.perms.removeFromPrincipal(
            permission.principal,
            permission.type
          );
          // Only remove the permission from the array if it was successfully removed from the principal
          this.#permissions.splice(this.#permissions.indexOf(permission), 1);
        } catch (err) {
          Cu.reportError(err);
        }
      }
      lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
    } finally {
      this.isUninstalling = false;
    }
  }

  get isCompatible() {
    return true;
  }

  get isPlatformCompatible() {
    return true;
  }

  get providesUpdatesSecurely() {
    return true;
  }

  get foreignInstall() {
    return false;
  }

  get installTelemetryInfo() {
    return { source: "siteperm-addon-provider", method: "synthetic-install" };
  }

  isCompatibleWith() {
    return true;
  }
}

class SitePermsAddonInstalling extends SitePermsAddonWrapper {
  /**
   * @param {string} siteOriginNoSuffix: The origin this addon is installed
   *                                     for, WITHOUT the suffix generated from
   *                                     the origin attributes (see:
   *                                     nsIPrincipal.siteOriginNoSuffix).
   * @param {SitePermsAddonInstall} install: The SitePermsAddonInstall instance
   *                                         calling this constructor.
   */
  constructor(siteOriginNoSuffix, install) {
    // SitePermsAddonWrapper expect an array of nsIPermission as its second parameter.
    // Since we don't have a proper permission here, we pass an object with the properties
    // being used in the class.
    const permission = {
      principal: install.principal,
      type: install.newSitePerm,
    };

    super(siteOriginNoSuffix, [permission]);
  }

  get existingAddon() {
    return SitePermsAddonProvider.wrappersMapByOrigin.get(this.siteOrigin);
  }

  uninstall() {
    // While about:addons tab is already open, new addon cards for newly installed
    // addons are created from the `onInstalled` AOM events, for the `SitePermsAddonWrapper`
    // the `onInstalling` and `onInstalled` events are emitted by `SitePermsAddonInstall`
    // and the addon instance is going to be a `SitePermsAddonInstalling` instance if
    // there wasn't an AddonCard for the same addon id yet.
    //
    // To make sure that all permissions will be uninstalled if a user uninstall the
    // addon from an AddonCard created from a `SitePermsAddonInstalling` instance,
    // we forward calls to the uninstall method of the existing `SitePermsAddonWrapper`
    // instance being tracked by the `SitePermsAddonProvider`.
    // If there isn't any then removing only the single permission added along with the
    // `SitePremsAddonInstalling` is going to be enough.
    if (this.existingAddon) {
      return this.existingAddon.uninstall();
    }

    return super.uninstall();
  }

  validInstallOrigins() {
    // Always return true from here,
    // actual checks are done from AddonManagerInternal.getSitePermsAddonInstallForWebpage
    return true;
  }
}

// Numeric id included in the install telemetry events to correlate multiple events related
// to the same install or update flow.
let nextInstallId = 0;

class SitePermsAddonInstall {
  #listeners = new Set();
  #installEvents = {
    INSTALL_CANCELLED: "onInstallCancelled",
    INSTALL_ENDED: "onInstallEnded",
    INSTALL_FAILED: "onInstallFailed",
  };

  /**
   * @param {nsIPrincipal} installingPrincipal
   * @param {String} sitePerm
   */
  constructor(installingPrincipal, sitePerm) {
    this.principal = installingPrincipal;
    this.newSitePerm = sitePerm;
    this.state = lazy.AddonManager.STATE_DOWNLOADED;
    this.addon = new SitePermsAddonInstalling(
      this.principal.siteOriginNoSuffix,
      this
    );
    this.installId = ++nextInstallId;
  }

  get installTelemetryInfo() {
    return this.addon.installTelemetryInfo;
  }

  async checkPrompt() {
    // `promptHandler` can be set from `AddonManagerInternal.setupPromptHandler`
    if (this.promptHandler) {
      let info = {
        // TODO: Investigate if we need to handle addon "update", i.e. granting new
        // gated permission on an origin other permissions were already granted for (Bug 1790778).
        existingAddon: null,
        addon: this.addon,
        icon: "chrome://mozapps/skin/extensions/category-sitepermission.svg",
        // Used in AMTelemetry to detect the install flow related to this prompt.
        install: this,
      };

      try {
        await this.promptHandler(info);
      } catch (err) {
        if (this.error < 0) {
          this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
          // In some cases onOperationCancelled is called during failures
          // to install/uninstall/enable/disable addons.  We may need to
          // do that here in the future.
          this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
        } else if (this.state !== lazy.AddonManager.STATE_CANCELLED) {
          this.cancel();
        }
        return;
      }
    }

    this.state = lazy.AddonManager.STATE_PROMPTS_DONE;
    this.install();
  }

  install() {
    if (this.state === lazy.AddonManager.STATE_PROMPTS_DONE) {
      lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this.addon);
      Services.perms.addFromPrincipal(
        this.principal,
        this.newSitePerm,
        Services.perms.ALLOW_ACTION
      );
      this.state = lazy.AddonManager.STATE_INSTALLED;
      this.#callInstallListeners(this.#installEvents.INSTALL_ENDED);
      lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this.addon);
      this.addon.install = null;
      return;
    }

    if (this.state !== lazy.AddonManager.STATE_DOWNLOADED) {
      this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
      this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
      return;
    }

    this.checkPrompt();
  }

  cancel() {
    // This method can be called if the install is already cancelled.
    // We don't want to go further in such case as it would lead to duplicated Telemetry events.
    if (this.state == lazy.AddonManager.STATE_CANCELLED) {
      console.error("SitePermsAddonInstall#cancel called twice on ", this);
      return;
    }

    this.state = lazy.AddonManager.STATE_CANCELLED;
    this.#callInstallListeners(this.#installEvents.INSTALL_CANCELLED);
  }

  /**
   * Add a listener for the install events
   *
   * @param {Object} listener
   * @param {Function} [listener.onDownloadEnded]
   * @param {Function} [listener.onInstallCancelled]
   * @param {Function} [listener.onInstallEnded]
   * @param {Function} [listener.onInstallFailed]
   */
  addListener(listener) {
    this.#listeners.add(listener);
  }

  /**
   * Remove a listener
   *
   * @param {Object} listener: The same object reference that was used for `addListener`
   */
  removeListener(listener) {
    this.#listeners.delete(listener);
  }

  /**
   * Call the listeners callbacks for a given event.
   *
   * @param {String} eventName: The event to fire. Should be one of `this.#installEvents`
   */
  #callInstallListeners(eventName) {
    if (!Object.values(this.#installEvents).includes(eventName)) {
      console.warn(`Unknown "${eventName}" "event`);
      return;
    }

    lazy.AddonManagerPrivate.callInstallListeners(
      eventName,
      Array.from(this.#listeners),
      this
    );
  }
}

const SitePermsAddonProvider = {
  get name() {
    return "SitePermsAddonProvider";
  },

  wrappersMapByOrigin: new Map(),

  /**
   * Update wrappersMapByOrigin on perm-changed
   *
   * @param {nsIPermission} permission: The permission being added/removed
   * @param {String} action: The action perm-changed notifies us about
   */
  handlePermissionChange(permission, action = "added") {
    // Bail out if it it's not a gated perm
    if (!isGatedPermissionType(permission.type)) {
      return;
    }

    // Gated APIs should probably not be available on non-secure origins,
    // but let's double check here.
    if (permission.principal.scheme !== "https") {
      return;
    }

    if (isPrincipalInSitePermissionsBlocklist(permission.principal)) {
      return;
    }

    const { siteOriginNoSuffix } = permission.principal;

    // Install origin cannot be on a known etld (e.g. github.io).
    // We shouldn't get a permission change for those here, but let's
    // be  extra safe
    if (isKnownPublicSuffix(siteOriginNoSuffix)) {
      return;
    }

    // Pipe the change to the existing addon if there is one.
    if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
      this.wrappersMapByOrigin
        .get(siteOriginNoSuffix)
        .handlePermissionChange(permission, action);
    }

    if (action == "added") {
      // We only have one SitePermsAddon per origin, handling multiple permissions.
      if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
        return;
      }

      const addonWrapper = new SitePermsAddonWrapper(siteOriginNoSuffix, [
        permission,
      ]);
      this.wrappersMapByOrigin.set(siteOriginNoSuffix, addonWrapper);
      return;
    }

    if (action == "deleted") {
      if (!this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
        return;
      }
      // Only remove the addon if it doesn't have any permissions left.
      if (!this.wrappersMapByOrigin.get(siteOriginNoSuffix).isUninstalled) {
        return;
      }
      this.wrappersMapByOrigin.delete(siteOriginNoSuffix);
    }
  },

  /**
   * Returns a Promise that resolves when handled the list of gated permissions
   * and setup ther observer for the "perm-changed" event.
   *
   * @returns Promise
   */
  lazyInit() {
    if (!this._initPromise) {
      this._initPromise = new Promise(resolve => {
        // Build the initial list of addons per origin
        const perms = Services.perms.getAllByTypes(GATED_PERMISSIONS);
        for (const perm of perms) {
          this.handlePermissionChange(perm);
        }
        Services.obs.addObserver(this, "perm-changed");
        resolve();
      });
    }
    return this._initPromise;
  },

  shutdown() {
    if (this._initPromise) {
      Services.obs.removeObserver(this, "perm-changed");
    }
    this.wrappersMapByOrigin.clear();
    this._initPromise = null;
  },

  /**
   * Get a SitePermsAddonWrapper from an extension id
   *
   * @param {String|null|undefined} id: The extension id,
   * @returns {SitePermsAddonWrapper|undefined}
   */
  async getAddonByID(id) {
    await this.lazyInit();
    if (!id?.endsWith?.(SITEPERMS_ADDON_ID_SUFFIX)) {
      return undefined;
    }

    for (const addon of this.wrappersMapByOrigin.values()) {
      if (addon.id === id) {
        return addon;
      }
    }
    return undefined;
  },

  /**
   * Get a list of SitePermsAddonWrapper for a given list of extension types.
   *
   * @param {Array<String>|null|undefined} types: If null or undefined is passed,
   *        the callsites expect to get all the addons from the provider, without
   *        any filtering.
   * @returns {Array<SitePermsAddonWrapper>}
   */
  async getAddonsByTypes(types) {
    if (
      !this.isEnabled ||
      // `types` can be null/undefined, and in such case we _do_ want to return the addons.
      (Array.isArray(types) && !types.includes(SITEPERMS_ADDON_TYPE))
    ) {
      return [];
    }

    await this.lazyInit();
    return Array.from(this.wrappersMapByOrigin.values());
  },

  /**
   * Create and return a SitePermsAddonInstall instance for a permission on a given principal
   *
   * @param {nsIPrincipal} installingPrincipal
   * @param {String} sitePerm
   * @returns {SitePermsAddonInstall}
   */
  getSitePermsAddonInstallForWebpage(installingPrincipal, sitePerm) {
    return new SitePermsAddonInstall(installingPrincipal, sitePerm);
  },

  get isEnabled() {
    return lazy.SITEPERMS_ADDON_PROVIDER_ENABLED;
  },

  observe(subject, topic, data) {
    if (!this.isEnabled) {
      return;
    }

    if (topic == FIRST_CONTENT_PROCESS_TOPIC) {
      Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC);

      lazy.AddonManagerPrivate.registerProvider(SitePermsAddonProvider, [
        SITEPERMS_ADDON_TYPE,
      ]);
      Services.obs.notifyObservers(null, "sitepermsaddon-provider-registered");
    } else if (topic === "perm-changed") {
      if (data === "cleared") {
        // In such case, `subject` is null, but we can simply uninstall all existing addons.
        for (const addon of this.wrappersMapByOrigin.values()) {
          addon.uninstall();
        }
        this.wrappersMapByOrigin.clear();
        return;
      }

      const perm = subject.QueryInterface(Ci.nsIPermission);
      this.handlePermissionChange(perm, data);
    }
  },

  addFirstContentProcessObserver() {
    Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
  },
};

// We want to register the SitePermsAddonProvider once the first content process gets created
// (and only if the feature is also enabled through the "dom.sitepermsaddon-provider.enabled"
// about:config pref).
SitePermsAddonProvider.addFirstContentProcessObserver();
