/* 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 { RemoteSettings } from "resource://services-settings/remote-settings.sys.mjs";

const COLLECTION_NAME = "remote-permissions";

/**
 * Allowlist of permission types and values allowed to be set through remote
 * settings. In this map, the key is the permission type, while the value is an
 * array of allowed permission values/capabilities allowed to be set. Possible
 * values for most permissions are:
 *
 * - Ci.nsIPermissionManager.ALLOW_ACTION
 * - Ci.nsIPermissionManager.DENY_ACTION
 * - Ci.nsIPermissionManager.PROMPT_ACTION
 * - "*" (Allows all values)
 *
 * Permission types with custom permission values (like
 * https-only-load-insecure) may include different values. Only change this
 * value with a review from #permissions-reviewers.
 */
const ALLOWED_PERMISSION_VALUES = {
  "https-only-load-insecure": [
    Ci.nsIHttpsOnlyModePermission.HTTPSFIRST_LOAD_INSECURE_ALLOW,
  ],
};

/**
 * See nsIRemotePermissionService.idl
 */
export class RemotePermissionService {
  classId = Components.ID("{a4b1b3b1-b68a-4129-aa2f-eb086162a8c7}");
  QueryInterface = ChromeUtils.generateQI(["nsIRemotePermissionService"]);

  #rs = RemoteSettings(COLLECTION_NAME);
  #onSyncCallback = null;
  #initialized = Promise.withResolvers();
  #allowedPermissionValues = ALLOWED_PERMISSION_VALUES;

  /**
   * Asynchonously import all default permissions from remote settings into the
   * permission manager. Also, if not already done, set up remote settings event
   * listener to keep remote permissions in sync.
   */
  async init() {
    try {
      if (Services.startup.shuttingDown) {
        return;
      }

      if (
        !Services.prefs.getBoolPref("permissions.manager.remote.enabled", false)
      ) {
        throw Error(
          "Tried to initialize remote permission service despite being disabled by pref"
        );
      }

      let remotePermissions = await this.#rs.get();
      for (const permission of remotePermissions) {
        this.#addDefaultPermission(permission);
      }

      // Init could be called multiple times if the permission manager is
      // reinitializing itself due to "testonly-reload-permissions-from-disk"
      // being emitted. In that case, we don't shouldn't set up the RS listener
      // again. We may also land in that situtation when "profile-do-change" is
      // emitted.
      if (!this.#onSyncCallback) {
        this.#onSyncCallback = this.#onSync.bind(this);
        this.#rs.on("sync", this.#onSyncCallback);
      }

      this.#initialized.resolve();
    } catch (e) {
      this.#initialized.reject(e);
      throw e;
    }
  }

  get isInitialized() {
    return this.#initialized.promise;
  }

  get testAllowedPermissionValues() {
    return this.#allowedPermissionValues;
  }

  set testAllowedPermissionValues(allowedPermissionValues) {
    Cu.crashIfNotInAutomation();
    this.#allowedPermissionValues = allowedPermissionValues;
  }

  // eslint-disable-next-line jsdoc/require-param
  /**
   * Callback for the "sync" event from remote settings. This function will
   * receive the created, updated and deleted permissions from remote settings,
   * and will update the permission manager accordingly.
   */
  #onSync({ data: { created = [], updated = [], deleted = [] } }) {
    const toBeDeletedPermissions = [
      // Delete permissions that got deleted in remote settings.
      ...deleted,
      // If an existing entry got updated in remote settings, but the origin or
      // type changed, we can not just update it, as permissions are identified
      // by origin and type in the permission manager. Instead, we need to
      // remove the old permission and add a new one.
      ...updated
        .filter(
          ({
            old: { origin: oldOrigin, type: oldType },
            new: { origin: newOrigin, type: newType },
          }) => oldOrigin != newOrigin || oldType != newType
        )
        .map(({ old }) => old),
    ];

    const toBeAddedPermissions = [
      // Add newly created permissions.
      ...created,
      // "Add" permissions updated in remote settings (the permission manager
      // will automatically update the existing default permission instead of
      // creating a new one if the permission origin and type match).
      ...updated.map(({ new: newPermission }) => newPermission),
      // Delete permissions by "adding" them with value UNKNOWN_ACTION.
      ...toBeDeletedPermissions.map(({ origin, type }) => ({
        origin,
        type,
        capability: Ci.nsIPermissionManager.UNKNOWN_ACTION,
      })),
    ];

    for (const permission of toBeAddedPermissions) {
      this.#addDefaultPermission(permission);
    }
  }

  /**
   * Check if a permission type and value is allowed to be set through remote
   * settings, based on the ALLOWED_PERMISSION_VALUES allowlist.
   *
   * @param {string} type       Permission type to check
   * @param {string} capability Permission capability to check
   * @returns {boolean}
   */
  #isAllowed(type, capability) {
    if (!this.#allowedPermissionValues[type]) {
      if (this.#allowedPermissionValues["*"]) {
        this.#allowedPermissionValues[type] =
          this.#allowedPermissionValues["*"];
      } else {
        return false;
      }
    }

    return (
      this.#allowedPermissionValues[type].includes("*") ||
      this.#allowedPermissionValues[type].includes(capability) ||
      capability === Ci.nsIPermissionManager.UNKNOWN_ACTION
    );
  }

  /**
   * Add a default permission to the permission manager.
   *
   * @param {object} permission            The permission to add
   * @param {string} permission.origin     Origin string of the permission
   * @param {string} permission.type       Type of the permission
   * @param {number} permission.capability Capability of the permission
   */
  #addDefaultPermission({ origin, type, capability }) {
    if (!this.#isAllowed(type, capability)) {
      console.error(
        `Remote Settings contain default permission of disallowed type '${type}' with value '${capability}' for origin '${origin}', skipping import`
      );
      return;
    }

    try {
      let principal = Services.scriptSecurityManager.createContentPrincipal(
        Services.io.newURI(origin),
        {}
      );
      Services.perms.addDefaultFromPrincipal(principal, type, capability);
    } catch (e) {
      console.error(e);
    }
  }
}
