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

/**
 * @file
 * This module is used for managing preferences from WebExtension APIs.
 * It takes care of the precedence chain and decides whether a preference
 * needs to be updated when a change is requested by an API.
 *
 * It deals with preferences via settings objects, which are objects with
 * the following properties:
 *
 * prefNames:   An array of strings, each of which is a preference on
 *              which the setting depends.
 * setCallback: A function that returns an object containing properties and
 *              values that correspond to the prefs to be set.
 */

export let ExtensionPreferencesManager;

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { Management } from "resource://gre/modules/Extension.sys.mjs";

const lazy = XPCOMUtils.declareLazy({
  ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
  ExtensionSettingsStore:
    "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
  Preferences: "resource://gre/modules/Preferences.sys.mjs",
  defaultPreferences: () => new lazy.Preferences({ defaultBranch: true }),
});

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

const { ExtensionError } = ExtensionUtils;

/* eslint-disable mozilla/balanced-listeners */
Management.on("uninstall", async (type, { id }) => {
  // Ensure managed preferences are cleared if they were
  // not cleared at the module level.
  await Management.asyncLoadSettingsModules();
  return ExtensionPreferencesManager.removeAll(id);
});

Management.on("disable", async (type, id) => {
  await Management.asyncLoadSettingsModules();
  return ExtensionPreferencesManager.disableAll(id);
});

Management.on("enabling", async (type, id) => {
  await Management.asyncLoadSettingsModules();
  return ExtensionPreferencesManager.enableAll(id);
});

Management.on("change-permissions", (type, change) => {
  // Called for added or removed, but we only care about removed here.
  if (!change.removed) {
    return;
  }
  ExtensionPreferencesManager.removeSettingsForPermissions(
    change.extensionId,
    change.removed.permissions
  );
});

/* eslint-enable mozilla/balanced-listeners */

const STORE_TYPE = "prefs";

// Definitions of settings, each of which correspond to a different API.
let settingsMap = new Map();

/**
 * This function is passed into the ExtensionSettingsStore to determine the
 * initial value of the setting. It reads an array of preference names from
 * the this scope, which gets bound to a settings object.
 *
 * @returns {object}
 *          An object with one property per preference, which holds the current
 *          value of that preference.
 */
function initialValueCallback() {
  let initialValue = {};
  for (let pref of this.prefNames) {
    // If there is a prior user-set value, get it.
    if (lazy.Preferences.isSet(pref)) {
      initialValue[pref] = lazy.Preferences.get(pref);
    }
  }
  return initialValue;
}

/**
 * Updates the initialValue stored to exclude any values that match
 * default preference values.
 *
 * @param {object} initialValue Initial Value data from settings store.
 * @returns {object}
 *          The initialValue object after updating the values.
 */
function settingsUpdate(initialValue) {
  for (let pref of this.prefNames) {
    try {
      if (
        initialValue[pref] !== undefined &&
        initialValue[pref] === lazy.defaultPreferences.get(pref)
      ) {
        initialValue[pref] = undefined;
      }
    } catch (e) {
      // Exception thrown if a default value doesn't exist.  We
      // presume that this pref had a user-set value initially.
    }
  }
  return initialValue;
}

/**
 * Loops through a set of prefs, either setting or resetting them.
 *
 * @param {string} name
 *        The api name of the setting.
 * @param {object} setting
 *        An object that represents a setting, which will have a setCallback
 *        property. If a onPrefsChanged function is provided it will be called
 *        with item when the preferences change.
 * @param {object} item
 *        An object that represents an item handed back from the setting store
 *        from which the new pref values can be calculated.
 */
function setPrefs(name, setting, item) {
  let prefs = item.initialValue || setting.setCallback(item.value);
  let changed = false;
  for (let pref of setting.prefNames) {
    if (prefs[pref] === undefined) {
      if (lazy.Preferences.isSet(pref)) {
        changed = true;
        lazy.Preferences.reset(pref);
      }
    } else if (lazy.Preferences.get(pref) != prefs[pref]) {
      lazy.Preferences.set(pref, prefs[pref]);
      changed = true;
    }
  }
  if (changed && typeof setting.onPrefsChanged == "function") {
    setting.onPrefsChanged(item);
  }
  Management.emit(`extension-setting-changed:${name}`);
}

/**
 * Commits a change to a setting and conditionally sets preferences.
 *
 * If the change to the setting causes a different extension to gain
 * control of the pref (or removes all extensions with control over the pref)
 * then the prefs should be updated, otherwise they should not be.
 * In addition, if the current value of any of the prefs does not
 * match what we expect the value to be (which could be the result of a
 * user manually changing the pref value), then we do not change any
 * of the prefs.
 *
 * @param {string} id
 *        The id of the extension for which a setting is being modified.  Also
 *        see selectSetting.
 * @param {string} name
 *        The name of the setting being processed.
 * @param {string} action
 *        The action that is being performed. Will be one of disable, enable
 *        or removeSetting.

 * @returns {Promise}
 *          Resolves to true if preferences were set as a result and to false
 *          if preferences were not set.
*/
async function processSetting(id, name, action) {
  await lazy.ExtensionSettingsStore.initialize();
  let expectedItem = lazy.ExtensionSettingsStore.getSetting(STORE_TYPE, name);
  let item = lazy.ExtensionSettingsStore[action](id, STORE_TYPE, name);
  if (item) {
    let setting = settingsMap.get(name);
    let expectedPrefs =
      expectedItem.initialValue || setting.setCallback(expectedItem.value);
    if (
      Object.keys(expectedPrefs).some(
        pref =>
          expectedPrefs[pref] &&
          lazy.Preferences.get(pref) != expectedPrefs[pref]
      )
    ) {
      return false;
    }
    setPrefs(name, setting, item);
    return true;
  }
  return false;
}

ExtensionPreferencesManager = {
  /**
   * Adds a setting to the settingsMap. This is how an API tells the
   * preferences manager what its setting object is. The preferences
   * manager needs to know this when settings need to be removed
   * automatically.
   *
   * @param {string} name The unique id of the setting.
   * @param {object} setting
   *        A setting object that should have properties for
   *        prefNames, getCallback and setCallback.
   */
  addSetting(name, setting) {
    settingsMap.set(name, setting);
  },

  /**
   * Gets the default value for a preference.
   *
   * @param {string} prefName The name of the preference.
   *
   * @returns {string|number|boolean} The default value of the preference.
   */
  getDefaultValue(prefName) {
    return lazy.defaultPreferences.get(prefName);
  },

  /**
   * Returns a map of prefName to setting Name for use in about:config, about:preferences or
   * other areas of Firefox that need to know whether a specific pref is controlled by an
   * extension.
   *
   * Given a prefName, you can get the settingName.  Call EPM.getSetting(settingName) to
   * get the details of the setting, including which id if any is in control of the
   * setting.
   *
   * @returns {Promise}
   *          Resolves to a Map of prefName->settingName
   */
  async getManagedPrefDetails() {
    await Management.asyncLoadSettingsModules();
    let prefs = new Map();
    settingsMap.forEach((setting, name) => {
      for (let prefName of setting.prefNames) {
        prefs.set(prefName, name);
      }
    });
    return prefs;
  },

  /**
   * Indicates that an extension would like to change the value of a previously
   * defined setting.
   *
   * @param {string} id
   *        The id of the extension for which a setting is being set.
   * @param {string} name
   *        The unique id of the setting.
   * @param {any} value
   *        The value to be stored in the settings store for this
   *        group of preferences.
   *
   * @returns {Promise}
   *          Resolves to true if the preferences were changed and to false if
   *          the preferences were not changed.
   */
  async setSetting(id, name, value) {
    let setting = settingsMap.get(name);
    await lazy.ExtensionSettingsStore.initialize();
    let item = await lazy.ExtensionSettingsStore.addSetting(
      id,
      STORE_TYPE,
      name,
      value,
      initialValueCallback.bind(setting),
      name,
      settingsUpdate.bind(setting)
    );
    if (item) {
      setPrefs(name, setting, item);
      return true;
    }
    return false;
  },

  /**
   * Indicates that this extension wants to temporarily cede control over the
   * given setting.
   *
   * @param {string} id
   *        The id of the extension for which a preference setting is being disabled.
   * @param {string} name
   *        The unique id of the setting.
   *
   * @returns {Promise}
   *          Resolves to true if the preferences were changed and to false if
   *          the preferences were not changed.
   */
  disableSetting(id, name) {
    return processSetting(id, name, "disable");
  },

  /**
   * Enable a setting that has been disabled.
   *
   * @param {string} id
   *        The id of the extension for which a setting is being enabled.
   * @param {string} name
   *        The unique id of the setting.
   *
   * @returns {Promise}
   *          Resolves to true if the preferences were changed and to false if
   *          the preferences were not changed.
   */
  enableSetting(id, name) {
    return processSetting(id, name, "enable");
  },

  /**
   * Specifically select an extension, the user, or the precedence order that will
   * be in control of this setting.
   *
   * @param {string | null} id
   *        The id of the extension for which a setting is being selected, or
   *        ExtensionSettingStore.SETTING_USER_SET (null).
   * @param {string} name
   *        The unique id of the setting.
   *
   * @returns {Promise}
   *          Resolves to true if the preferences were changed and to false if
   *          the preferences were not changed.
   */
  selectSetting(id, name) {
    return processSetting(id, name, "select");
  },

  /**
   * Indicates that this extension no longer wants to set the given setting.
   *
   * @param {string} id
   *        The id of the extension for which a preference setting is being removed.
   * @param {string} name
   *        The unique id of the setting.
   *
   * @returns {Promise}
   *          Resolves to true if the preferences were changed and to false if
   *          the preferences were not changed.
   */
  removeSetting(id, name) {
    return processSetting(id, name, "removeSetting");
  },

  /**
   * Disables all previously set settings for an extension. This can be called when
   * an extension is being disabled, for example.
   *
   * @param {string} id
   *        The id of the extension for which all settings are being unset.
   */
  async disableAll(id) {
    await lazy.ExtensionSettingsStore.initialize();
    let settings = lazy.ExtensionSettingsStore.getAllForExtension(
      id,
      STORE_TYPE
    );
    let disablePromises = [];
    for (let name of settings) {
      disablePromises.push(this.disableSetting(id, name));
    }
    await Promise.all(disablePromises);
  },

  /**
   * Enables all disabled settings for an extension. This can be called when
   * an extension has finished updating or is being re-enabled, for example.
   *
   * @param {string} id
   *        The id of the extension for which all settings are being enabled.
   */
  async enableAll(id) {
    await lazy.ExtensionSettingsStore.initialize();
    let settings = lazy.ExtensionSettingsStore.getAllForExtension(
      id,
      STORE_TYPE
    );
    let enablePromises = [];
    for (let name of settings) {
      enablePromises.push(this.enableSetting(id, name));
    }
    await Promise.all(enablePromises);
  },

  /**
   * Removes all previously set settings for an extension. This can be called when
   * an extension is being uninstalled, for example.
   *
   * @param {string} id
   *        The id of the extension for which all settings are being unset.
   */
  async removeAll(id) {
    await lazy.ExtensionSettingsStore.initialize();
    let settings = lazy.ExtensionSettingsStore.getAllForExtension(
      id,
      STORE_TYPE
    );
    let removePromises = [];
    for (let name of settings) {
      removePromises.push(this.removeSetting(id, name));
    }
    await Promise.all(removePromises);
  },

  /**
   * Removes a set of settings that are available under certain addon permissions.
   *
   * @param {string} id
   *        The extension id.
   * @param {Array<string>} permissions
   *        The permission name from the extension manifest.
   * @returns {Promise}
   *        A promise that resolves when all related settings are removed.
   */
  async removeSettingsForPermissions(id, permissions) {
    if (!permissions || !permissions.length) {
      return;
    }
    await Management.asyncLoadSettingsModules();
    let removePromises = [];
    settingsMap.forEach((setting, name) => {
      if (permissions.includes(setting.permission)) {
        removePromises.push(this.removeSetting(id, name));
      }
    });
    return Promise.all(removePromises);
  },

  /**
   * Return the currently active value for a setting.
   *
   * @param {string} name
   *        The unique id of the setting.
   *
   * @returns {Promise<object>} The current setting object.
   */
  async getSetting(name) {
    await lazy.ExtensionSettingsStore.initialize();
    return lazy.ExtensionSettingsStore.getSetting(STORE_TYPE, name);
  },

  /**
   * Return the levelOfControl for a setting / extension combo.
   * This queries the levelOfControl from the ExtensionSettingsStore and also
   * takes into account whether any of the setting's preferences are locked.
   *
   * @param {string} id
   *        The id of the extension for which levelOfControl is being requested.
   * @param {string} name
   *        The unique id of the setting.
   * @param {string} storeType
   *        The name of the store in ExtensionSettingsStore.
   *        Defaults to STORE_TYPE.
   *
   * @returns {Promise}
   *          Resolves to the level of control of the extension over the setting.
   */
  async getLevelOfControl(id, name, storeType = STORE_TYPE) {
    // This could be called for a setting that isn't defined to the PreferencesManager,
    // in which case we simply defer to the SettingsStore.
    if (storeType === STORE_TYPE) {
      let setting = settingsMap.get(name);
      if (!setting) {
        return "not_controllable";
      }
      for (let prefName of setting.prefNames) {
        if (lazy.Preferences.locked(prefName)) {
          return "not_controllable";
        }
      }
    }
    await lazy.ExtensionSettingsStore.initialize();
    return lazy.ExtensionSettingsStore.getLevelOfControl(id, storeType, name);
  },

  /**
   * Returns an API object with get/set/clear used for a setting.
   *
   * @param {string|object} extensionId or params object
   * @param {string} name
   *          The unique id of the setting.
   * @param {Function} callback
   *          The function that retreives the current setting from prefs.
   * @param {string} storeType
   *          The name of the store in ExtensionSettingsStore.
   *          Defaults to STORE_TYPE.
   * @param {boolean} readOnly
   * @param {Function} validate
   *          Utility function for any specific validation, such as checking
   *          for supported platform.  Function should throw an error if necessary.
   *
   * @returns {object} API object with get/set/clear methods
   */
  getSettingsAPI(
    extensionId,
    name,
    callback,
    storeType,
    readOnly = false,
    validate
  ) {
    if (arguments.length > 1) {
      Services.console.logStringMessage(
        `ExtensionPreferencesManager.getSettingsAPI for ${name} should be updated to use a single paramater object.`
      );
    }
    return ExtensionPreferencesManager._getInternalSettingsAPI(
      arguments.length === 1
        ? extensionId
        : {
            extensionId,
            name,
            callback,
            storeType,
            readOnly,
            validate,
          }
    ).api;
  },

  /**
   * getPrimedSettingsListener returns a function used to create
   * a primed event listener.
   *
   * If a module overrides onChange then it must provide it's own
   * persistent listener logic.  See homepage_override in browserSettings
   * for an example.
   *
   * addSetting must be called prior to priming listeners.
   *
   * @param {object} config see getSettingsAPI
   *        {Extension} extension, passed through to validate and used for extensionId
   *        {string} name
   *          The unique id of the settings api in the module, e.g. "settings"
   * @returns {object} prime listener object
   */
  getPrimedSettingsListener(config) {
    let { name, extension } = config;
    if (!name || !extension) {
      throw new Error(
        `name and extension are required for getPrimedSettingListener`
      );
    }
    if (!settingsMap.get(name)) {
      throw new Error(
        `addSetting must be called prior to getPrimedSettingListener`
      );
    }
    return ExtensionPreferencesManager._getInternalSettingsAPI({
      name,
      extension,
    }).registerEvent;
  },

  /**
   * Returns an object with a public API containing get/set/clear used for a setting,
   * and a registerEvent function used for registering the event listener.
   *
   * @param {object} params The params object contains the following:
   *        {BaseContext} context
   *        {Extension} extension, optional, passed through to validate and used for extensionId
   *        {string} extensionId, optional to support old API
   *        {string} module
   *          The name of the api module, e.g. "proxy"
   *        {string} name
   *          The unique id of the settings api in the module, e.g. "settings"
   *          "name" should match the name given in the addSetting call.
   *        {Function} callback
   *          The function that retreives the current setting from prefs.
   *        {string} storeType
   *          The name of the store in ExtensionSettingsStore.
   *          Defaults to STORE_TYPE.
   *        {boolean} readOnly
   *        {Function} validate
   *          Utility function for any specific validation, such as checking
   *          for supported platform.  Function should throw an error if necessary.
   *
   * @returns {object} internal API object with
   *          {object} api
   *            the public api available to extensions
   *          {Function} registerEvent
   *            the registration function used for priming events
   */
  _getInternalSettingsAPI(params) {
    let {
      extensionId,
      context,
      extension,
      module,
      name,
      callback,
      storeType,
      readOnly = false,
      onChange,
      validate,
    } = params;
    if (context) {
      extension = context.extension;
    }
    if (!extensionId && extension) {
      extensionId = extension.id;
    }

    const checkScope = details => {
      let { scope } = details;
      if (scope && scope !== "regular") {
        throw new ExtensionError(
          `Firefox does not support the ${scope} settings scope.`
        );
      }
    };

    // Check the setting for anything we may need.
    let setting = settingsMap.get(name);
    readOnly = readOnly || !!setting?.readOnly;
    validate = validate || setting?.validate || (() => {});
    let getValue = callback || setting?.getCallback;
    if (!getValue || typeof getValue !== "function") {
      throw new Error(`Invalid get callback for setting ${name} in ${module}`);
    }

    let settingsAPI = {
      async get(details) {
        validate(extension);
        let levelOfControl = details.incognito
          ? "not_controllable"
          : await ExtensionPreferencesManager.getLevelOfControl(
              extensionId,
              name,
              storeType
            );
        levelOfControl =
          readOnly && levelOfControl === "controllable_by_this_extension"
            ? "not_controllable"
            : levelOfControl;
        return {
          levelOfControl,
          value: await getValue(),
        };
      },
      set(details) {
        validate(extension);
        checkScope(details);
        if (!readOnly) {
          return ExtensionPreferencesManager.setSetting(
            extensionId,
            name,
            details.value
          );
        }
        return false;
      },
      clear(details) {
        validate(extension);
        checkScope(details);
        if (!readOnly) {
          return ExtensionPreferencesManager.removeSetting(extensionId, name);
        }
        return false;
      },
      onChange,
    };
    let registerEvent = fire => {
      let listener = async () => {
        fire.async(await settingsAPI.get({}));
      };
      Management.on(`extension-setting-changed:${name}`, listener);
      return {
        unregister: () => {
          Management.off(`extension-setting-changed:${name}`, listener);
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    };

    // Any caller using the old call signature will not have passed
    // context to us.  This should only be experimental addons in the
    // wild.
    if (onChange === undefined && context) {
      // Some settings that are read-only may not have called addSetting, in
      // which case we have no way to listen on the pref changes.
      if (setting) {
        settingsAPI.onChange = new lazy.ExtensionCommon.EventManager({
          context,
          module,
          event: name,
          name: `${name}.onChange`,
          register: fire => {
            return registerEvent(fire).unregister;
          },
        }).api();
      } else {
        Services.console.logStringMessage(
          `ExtensionPreferencesManager API ${name} created but addSetting was not called.`
        );
      }
    }
    return { api: settingsAPI, registerEvent };
  },
};
