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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
  SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logConsole", () => {
  return console.createInstance({
    prefix: "SearchEngineSelector",
    maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
  });
});

/**
 * @typedef {object} RefinedConfig
 * @property {object[]} engines
 *   An array of objects defining the engines that should be presented to the user.
 * @property {string} appDefaultEngineId
 *   The identifier of the engine that should be used for the application
 *   default engine.
 * @property {string} [appPrivateDefaultEngineId]
 *   If specified, the identifier of the engine that should be used for the
 *   application default engine in private browsing mode.
 */

/**
 * SearchEngineSelector parses the JSON configuration for
 * search engines and returns the applicable engines depending
 * on their region + locale.
 */
export class SearchEngineSelector {
  /**
   * @param {Function} listener
   *   A listener for configuration update changes.
   */
  constructor(listener) {
    this._remoteConfig = lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY);
    this._remoteConfigOverrides = lazy.RemoteSettings(
      lazy.SearchUtils.SETTINGS_OVERRIDES_KEY
    );
    this._listenerAdded = false;
    this._onConfigurationUpdated = this._onConfigurationUpdated.bind(this);
    this._onConfigurationOverridesUpdated =
      this._onConfigurationOverridesUpdated.bind(this);
    this._changeListener = listener;
  }

  /**
   * Resets the remote settings listeners.
   */
  reset() {
    if (this._listenerAdded) {
      this._remoteConfig.off("sync", this._onConfigurationUpdated);
      this._remoteConfigOverrides.off(
        "sync",
        this._onConfigurationOverridesUpdated
      );
      this._listenerAdded = false;
    }
  }

  /**
   * Handles getting the configuration from remote settings.
   *
   * @returns {object}
   *   The configuration data.
   */
  async getEngineConfiguration() {
    if (this._getConfigurationPromise) {
      return this._getConfigurationPromise;
    }

    this._getConfigurationPromise = Promise.all([
      this._getConfiguration(),
      this._getConfigurationOverrides(),
    ]);
    let remoteSettingsData = await this._getConfigurationPromise;
    this._configuration = remoteSettingsData[0];
    this._configurationOverrides = remoteSettingsData[1];
    delete this._getConfigurationPromise;

    if (!this._configuration?.length) {
      throw Components.Exception(
        "Failed to get engine data from Remote Settings",
        Cr.NS_ERROR_UNEXPECTED
      );
    }

    if (!this._listenerAdded) {
      this._remoteConfig.on("sync", this._onConfigurationUpdated);
      this._remoteConfigOverrides.on(
        "sync",
        this._onConfigurationOverridesUpdated
      );
      this._listenerAdded = true;
    }

    return this._configuration;
  }

  /**
   * Finds an engine configuration that has a matching host.
   *
   * @param {string} host
   *   The host to match.
   *
   * @returns {object}
   *   The configuration data for an engine.
   */
  async findContextualSearchEngineByHost(host) {
    for (let config of this._configuration) {
      if (config.recordType !== "engine") {
        continue;
      }
      let searchHost = new URL(config.base.urls.search.base).hostname;
      if (searchHost.startsWith("www.")) {
        searchHost = searchHost.slice(4);
      }
      if (searchHost.startsWith(host)) {
        let engine = structuredClone(config.base);
        engine.identifier = config.identifier;
        return engine;
      }
    }
    return null;
  }

  /**
   * Finds an engine configuration that has a matching identifier.
   *
   * @param {string} id
   *   The identifier to match.
   *
   * @returns {object}
   *   The configuration data for an engine.
   */
  async findContextualSearchEngineById(id) {
    for (let config of this._configuration) {
      if (config.recordType !== "engine") {
        continue;
      }
      if (config.identifier == id) {
        let engine = structuredClone(config.base);
        engine.identifier = config.identifier;
        return engine;
      }
    }
    return null;
  }

  /**
   * Used by tests to get the configuration overrides.
   *
   * @returns {object}
   *   The engine overrides data.
   */
  async getEngineConfigurationOverrides() {
    await this.getEngineConfiguration();
    return this._configurationOverrides;
  }

  /**
   * Obtains the configuration from remote settings. This includes
   * verifying the signature of the record within the database.
   *
   * If the signature in the database is invalid, the database will be wiped
   * and the stored dump will be used, until the settings next update.
   *
   * Note that this may cause a network check of the certificate, but that
   * should generally be quick.
   *
   * @param {boolean} [firstTime]
   *   Internal boolean to indicate if this is the first time check or not.
   * @returns {Array}
   *   An array of objects in the database, or an empty array if none
   *   could be obtained.
   */
  async _getConfiguration(firstTime = true) {
    let result = [];
    let failed = false;
    try {
      result = await this._remoteConfig.get({
        order: "id",
      });
    } catch (ex) {
      lazy.logConsole.error(ex);
      failed = true;
    }
    if (!result.length) {
      lazy.logConsole.error("Received empty search configuration!");
      failed = true;
    }
    // If we failed, or the result is empty, try loading from the local dump.
    if (firstTime && failed) {
      await this._remoteConfig.db.clear();
      // Now call this again.
      return this._getConfiguration(false);
    }
    return result;
  }

  /**
   * Handles updating of the configuration. Note that the search service is
   * only updated after a period where the user is observed to be idle.
   *
   * @param {object} options
   *   The options object
   * @param {object} options.data
   *   The data to update
   * @param {Array} options.data.current
   *   The new configuration object
   */
  _onConfigurationUpdated({ data: { current } }) {
    this._configuration = current;
    lazy.logConsole.debug("Search configuration updated remotely");
    if (this._changeListener) {
      this._changeListener();
    }
  }

  /**
   * Handles updating of the configuration. Note that the search service is
   * only updated after a period where the user is observed to be idle.
   *
   * @param {object} options
   *   The options object
   * @param {object} options.data
   *   The data to update
   * @param {Array} options.data.current
   *   The new configuration object
   */
  _onConfigurationOverridesUpdated({ data: { current } }) {
    this._configurationOverrides = current;
    lazy.logConsole.debug("Search configuration overrides updated remotely");
    if (this._changeListener) {
      this._changeListener();
    }
  }

  /**
   * Obtains the configuration overrides from remote settings.
   *
   * @returns {Array}
   *   An array of objects in the database, or an empty array if none
   *   could be obtained.
   */
  async _getConfigurationOverrides() {
    let result = [];
    try {
      result = await this._remoteConfigOverrides.get();
    } catch (ex) {
      // This data is remote only, so we just return an empty array if it fails.
    }
    return result;
  }

  /**
   * @param {object} options
   *   The options object
   * @param {string} options.locale
   *   Users locale.
   * @param {string} options.region
   *   Users region.
   * @param {string} [options.channel]
   *   The update channel the application is running on.
   * @param {string} [options.distroID]
   *   The distribution ID of the application.
   * @param {string} [options.experiment]
   *   Any associated experiment id.
   * @param {string} [options.appName]
   *   The name of the application.
   * @param {string} [options.version]
   *   The version of the application.
   * @returns {RefinedConfig}
   *   An object which contains the refined configuration with a filtered list
   *   of search engines, and the identifiers for the application default engines.
   */
  async fetchEngineConfiguration({
    locale,
    region,
    channel = "default",
    distroID,
    experiment,
    appName = Services.appinfo.name ?? "",
    version = Services.appinfo.version ?? "",
  }) {
    if (!this._configuration) {
      await this.getEngineConfiguration();
    }

    lazy.logConsole.debug(
      `fetchEngineConfiguration ${locale}:${region}:${channel}:${distroID}:${experiment}:${appName}:${version}`
    );

    appName = appName.toLowerCase();
    version = version.toLowerCase();
    locale = locale.toLowerCase();
    region = region.toLowerCase();

    let engines = [];
    let defaultsConfig;
    let engineOrders;
    let userEnv = {
      appName,
      version,
      locale,
      region,
      channel,
      distroID,
      experiment,
    };

    for (let config of this._configuration) {
      if (config.recordType == "defaultEngines") {
        defaultsConfig = config;
      }

      if (config.recordType == "engineOrders") {
        engineOrders = config;
      }

      if (config.recordType !== "engine") {
        continue;
      }

      let variant = config.variants?.findLast(v =>
        this.#matchesUserEnvironment(v, userEnv)
      );

      if (!variant) {
        continue;
      }

      let subVariant = variant.subVariants?.findLast(sv =>
        this.#matchesUserEnvironment(sv, userEnv)
      );

      let engine = structuredClone(config.base);
      engine.identifier = config.identifier;
      engine = this.#deepCopyObject(engine, variant);

      if (subVariant) {
        engine = this.#deepCopyObject(engine, subVariant);
      }

      for (let override of this._configurationOverrides) {
        if (override.identifier == engine.identifier) {
          engine = this.#deepCopyObject(engine, override);
        }
      }

      engines.push(engine);
    }

    let { defaultEngine, privateDefault } = this.#defaultEngines(
      engines,
      defaultsConfig,
      userEnv
    );

    for (const orderData of engineOrders.orders) {
      let environment = orderData.environment;

      if (this.#matchesUserEnvironment({ environment }, userEnv)) {
        this.#setEngineOrders(engines, orderData.order);
      }
    }

    if (!defaultEngine) {
      if (engines.length) {
        lazy.logConsole.error(
          "Could not find a matching default engine, using the first one in the list"
        );
        defaultEngine = engines[0];
      } else {
        throw new Error(
          "Could not find any engines in the filtered configuration"
        );
      }
    }

    engines.sort(this._sort.bind(this, defaultEngine, privateDefault));

    let result = { engines, appDefaultEngineId: defaultEngine.identifier };

    if (privateDefault) {
      result.appPrivateDefaultEngineId = privateDefault.identifier;
    }

    if (lazy.SearchUtils.loggingEnabled) {
      lazy.logConsole.debug(
        "fetchEngineConfiguration: " + result.engines.map(e => e.identifier)
      );
    }
    return result;
  }

  _sort(defaultEngine, defaultPrivateEngine, a, b) {
    return (
      this._sortIndex(b, defaultEngine, defaultPrivateEngine) -
      this._sortIndex(a, defaultEngine, defaultPrivateEngine)
    );
  }

  /**
   * Create an index order to ensure default (and backup default)
   * engines are ordered correctly.
   *
   * @param {object} obj
   *   Object representing the engine configuration.
   * @param {object} defaultEngine
   *   The default engine, for comparison to obj.
   * @param {object} defaultPrivateEngine
   *   The default private engine, for comparison to obj.
   * @returns {integer}
   *  Number indicating how this engine should be sorted.
   */
  _sortIndex(obj, defaultEngine, defaultPrivateEngine) {
    if (obj == defaultEngine) {
      return Number.MAX_SAFE_INTEGER;
    }
    if (obj == defaultPrivateEngine) {
      return Number.MAX_SAFE_INTEGER - 1;
    }
    return obj.orderHint || 0;
  }

  /**
   * Deep copies an object to the target object and ignores some keys.
   *
   * @param {object} target - Object to copy to.
   * @param {object} source - Object to copy from.
   * @returns {object} - The source object.
   */
  #deepCopyObject(target, source) {
    for (let key in source) {
      if (["environment"].includes(key)) {
        continue;
      }

      if (["subVariants"].includes(key)) {
        continue;
      }

      if (typeof source[key] == "object" && !Array.isArray(source[key])) {
        if (key in target) {
          this.#deepCopyObject(target[key], source[key]);
        } else {
          target[key] = structuredClone(source[key]);
        }
      } else {
        target[key] = structuredClone(source[key]);
      }
    }

    return target;
  }

  /**
   * Matches the user's environment against the engine config's environment.
   *
   * @param {object} config
   *   The config for the given base or variant engine.
   * @param {object} user
   *   The user's environment we use to match with the engine's environment.
   * @param {string} user.appName
   *   The name of the application.
   * @param {string} user.version
   *   The version of the application.
   * @param {string} user.locale
   *   The locale of the user.
   * @param {string} user.region
   *   The region of the user.
   * @param {string} user.channel
   *   The channel the application is running on.
   * @param {string} user.distroID
   *   The distribution ID of the application.
   * @param {string} user.experiment
   *   Any associated experiment id.
   * @returns {boolean}
   *   True if the engine config's environment matches the user's environment.
   */
  #matchesUserEnvironment(config, user = {}) {
    if ("experiment" in config.environment) {
      if (user.experiment != config.environment.experiment) {
        return false;
      }
    }

    if ("excludedDistributions" in config.environment) {
      if (config.environment.excludedDistributions.includes(user.distroID)) {
        return false;
      }
    }

    // Skip the optional flag for Desktop, it's a feature only on Android.
    if (config.optional) {
      return false;
    }

    return (
      this.#matchesRegionAndLocale(
        user.region,
        user.locale,
        config.environment
      ) &&
      this.#matchesDistribution(
        user.distroID,
        config.environment.distributions
      ) &&
      this.#matchesVersions(
        config.environment.minVersion,
        config.environment.maxVersion,
        user.version
      ) &&
      this.#matchesChannel(config.environment.channels, user.channel) &&
      this.#matchesApplication(config.environment.applications, user.appName) &&
      !this.#hasDeviceType(config.environment)
    );
  }

  /**
   * @param {string} userDistro
   *  The distribution from the user's environment.
   * @param {string[]} configDistro
   *  An array of distributions for the particular environment in the config.
   * @returns {boolean}
   *  True if the user's distribution is included in the config distribution
   *  list.
   */
  #matchesDistribution(userDistro, configDistro) {
    // If there's no distribution for this engineConfig, ignore the check.
    if (!configDistro) {
      return true;
    }

    return configDistro?.includes(userDistro);
  }

  /**
   * @param {string} min
   *  The minimum version supported.
   * @param {string} max
   *  The maximum version supported.
   * @param {string} userVersion
   *  The user's version.
   * @returns {boolean}
   *  True if the user's version is within the range of the min and max versions
   *  supported.
   */
  #matchesVersions(min, max, userVersion) {
    // If there's no versions for this engineConfig, ignore the check.
    if (!min && !max) {
      return true;
    }

    if (!userVersion) {
      return false;
    }

    if (min && !max) {
      return this.#isAboveOrEqualMin(userVersion, min);
    }

    if (!min && max) {
      return this.#isBelowOrEqualMax(userVersion, max);
    }

    return (
      this.#isAboveOrEqualMin(userVersion, min) &&
      this.#isBelowOrEqualMax(userVersion, max)
    );
  }

  #isAboveOrEqualMin(userVersion, min) {
    return Services.vc.compare(userVersion, min) >= 0;
  }

  #isBelowOrEqualMax(userVersion, max) {
    return Services.vc.compare(userVersion, max) <= 0;
  }

  /**
   * @param {string[]} configChannels
   *  Release channels such as nightly, beta, release, esr.
   * @param {string} userChannel
   *  The user's channel.
   * @returns {boolean}
   *  True if the user's channel is included in the config channels.
   */
  #matchesChannel(configChannels, userChannel) {
    // If there's no channels for this engineConfig, ignore the check.
    if (!configChannels) {
      return true;
    }

    return configChannels.includes(userChannel);
  }

  /**
   * @param {string[]} configApps
   *  The applications such as firefox, firefox-android, firefox-ios,
   *  focus-android, and focus-ios.
   * @param {string} userApp
   *  The user's application.
   * @returns {boolean}
   *  True if the user's application is included in the config applications.
   */
  #matchesApplication(configApps, userApp) {
    // If there's no config Applications for this engineConfig, ignore the check.
    if (!configApps) {
      return true;
    }

    return configApps.includes(userApp);
  }

  /**
   * Generally the device type option should only be used when the application
   * is selected to be on an android or iOS based product. However, we support
   * rejecting if this is non-empty in case of future requirements that we haven't
   * predicted.
   *
   * @param {object} environment
   *   An environment section from the engine configuration.
   * @returns {boolean}
   *   Returns true if there is a device type section and it is not empty.
   */
  #hasDeviceType(environment) {
    return !!environment.deviceType?.length;
  }

  /**
   * Determines whether the region and locale constraints in the config
   * environment  applies to a user given what region and locale they are using.
   *
   * @param {string} region
   *   The region the user is in.
   * @param {string} locale
   *   The language the user has configured.
   * @param {object} configEnv
   *   The environment of the engine configuration.
   * @returns {boolean}
   *   True if the user's region and locale matches the config's region and
   *   locale contraints. Otherwise false.
   */
  #matchesRegionAndLocale(region, locale, configEnv) {
    if (
      this.#doesConfigInclude(configEnv.excludedLocales, locale) ||
      this.#doesConfigInclude(configEnv.excludedRegions, region)
    ) {
      return false;
    }

    if (configEnv.allRegionsAndLocales) {
      return true;
    }

    // When none of the regions and locales are set. This implies its available
    // everywhere.
    if (
      !Object.hasOwn(configEnv, "allRegionsAndLocales") &&
      !Object.hasOwn(configEnv, "regions") &&
      !Object.hasOwn(configEnv, "locales")
    ) {
      return true;
    }

    if (
      this.#doesConfigInclude(configEnv?.locales, locale) &&
      this.#doesConfigInclude(configEnv?.regions, region)
    ) {
      return true;
    }

    if (
      this.#doesConfigInclude(configEnv?.locales, locale) &&
      !Object.hasOwn(configEnv, "regions")
    ) {
      return true;
    }

    if (
      this.#doesConfigInclude(configEnv?.regions, region) &&
      !Object.hasOwn(configEnv, "locales")
    ) {
      return true;
    }

    return false;
  }

  /**
   * This function converts the characters in the config to lowercase and
   * checks if the user's locale or region is included in config the
   * environment.
   *
   * @param {Array} configArray
   *   An Array of locales or regions from the config environment.
   * @param {string} compareItem
   *   The user's locale or region.
   * @returns {boolean}
   *   True if user's region or locale is found in the config environment.
   *   Otherwise false.
   */
  #doesConfigInclude(configArray, compareItem) {
    if (!configArray) {
      return false;
    }

    return configArray.find(
      configItem => configItem.toLowerCase() === compareItem
    );
  }

  /**
   * Gets the default engine and default private engine based on the user's
   * environment.
   *
   * @param {Array} engines
   *   An array that contains the engines for the user environment.
   * @param {object} defaultsConfig
   *   The defaultEngines record type from the search config.
   * @param {object} userEnv
   *   The user's environment.
   * @returns {object}
   *   An object with default engine and default private engine.
   */
  #defaultEngines(engines, defaultsConfig, userEnv) {
    let defaultEngine, privateDefault;

    for (let data of defaultsConfig.specificDefaults) {
      let environment = data.environment;

      if (this.#matchesUserEnvironment({ environment }, userEnv)) {
        defaultEngine = this.#findDefault(engines, data) ?? defaultEngine;
        privateDefault =
          this.#findDefault(engines, data, "private") ?? privateDefault;
      }
    }

    defaultEngine ??= this.#findGlobalDefault(engines, defaultsConfig);
    privateDefault ??= this.#findGlobalDefault(
      engines,
      defaultsConfig,
      "private"
    );

    return { defaultEngine, privateDefault };
  }

  /**
   * Finds the global default engine or global default private engine.
   *
   * @param {Array} engines
   *   The engines for the user environment.
   * @param {string} config
   *   The defaultEngines record from the config.
   * @param {string} [engineType]
   *   A string to identify default or default private.
   * @returns {object}
   *   The global default engine or global default private engine.
   */
  #findGlobalDefault(engines, config, engineType = "default") {
    let engine;
    if (config.globalDefault && engineType == "default") {
      engine = engines.find(e => e.identifier == config.globalDefault);
    }

    if (config.globalDefaultPrivate && engineType == "private") {
      engine = engines.find(e => e.identifier == config.globalDefaultPrivate);
    }

    return engine;
  }

  /**
   * Finds the default engine or default private engine from the list of
   * engines that match the user's environment.
   *
   * @param {Array} engines
   *   The engines for the user environment.
   * @param {string} config
   *   The specific defaults record that contains the default engine or default
   *   private engine identifer for the environment.
   * @param {string} [engineType]
   *   A string to identify default engine or default private engine.
   * @returns {object|undefined}
   *   The default engine or default private engine. Undefined if none can be
   *   found.
   */
  #findDefault(engines, config, engineType = "default") {
    let defaultMatch =
      engineType == "default" ? config.default : config.defaultPrivate;

    if (!defaultMatch) {
      return undefined;
    }

    return this.#findEngineWithMatch(engines, defaultMatch);
  }

  /**
   * Sets the orderHint number for the engines.
   *
   * @param {Array} engines
   *  The engines for the user environment.
   * @param {Array} orderedEngines
   *  The ordering of engines. Engines in the beginning of the list get a higher
   *  orderHint number.
   */
  #setEngineOrders(engines, orderedEngines) {
    let orderNumber = orderedEngines.length;

    for (const engine of orderedEngines) {
      let foundEngine = this.#findEngineWithMatch(engines, engine);
      if (foundEngine) {
        foundEngine.orderHint = orderNumber;
        orderNumber -= 1;
      }
    }
  }

  /**
   * Finds an engine with the given match.
   *
   * @param {object[]} engines
   *   An array of search engine configurations.
   * @param {string} match
   *   A string to match against the engine identifier. This will be an exact
   *   match, unless the string ends with `*`, in which case it will use a
   *   startsWith match.
   * @returns {object|undefined}
   */
  #findEngineWithMatch(engines, match) {
    if (match.endsWith("*")) {
      let matchNoStar = match.slice(0, -1);
      return engines.find(e => e.identifier.startsWith(matchNoStar));
    }
    return engines.find(e => e.identifier == match);
  }
}
