/* 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 {
 *   RefinedSearchConfig,
 *   SearchEngineDefinition
 * } from "../uniffi-bindgen-gecko-js/components/generated/RustSearch.sys.mjs";
 */

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

const lazy = XPCOMUtils.declareLazy({
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
  SearchDeviceType:
    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSearch.sys.mjs",
  SearchEngineSelector:
    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSearch.sys.mjs",
  SearchUserEnvironment:
    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSearch.sys.mjs",
  SearchApplicationName:
    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSearch.sys.mjs",
  SearchUpdateChannel:
    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSearch.sys.mjs",
  SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs",
  logConsole: () =>
    console.createInstance({
      prefix: "SearchEngineSelector",
      maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
    }),
});

/**
 * SearchEngineSelector parses the JSON configuration for
 * search engines and returns the applicable engines depending
 * on their region + locale.
 */
export class SearchEngineSelector {
  /**
   * @param {() => void} 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.#boundOnConfigurationUpdated = this._onConfigurationUpdated.bind(this);
    this.#boundOnConfigurationOverridesUpdated =
      this._onConfigurationOverridesUpdated.bind(this);
    this.#changeListener = listener;
  }

  /**
   * Resets the remote settings listeners, intended for test use only.
   */
  reset() {
    /**
     * Records whether the listeners have been added or not.
     */
    if (this.#listenerAdded) {
      this.#remoteConfig.off("sync", this.#boundOnConfigurationUpdated);
      this.#remoteConfigOverrides.off(
        "sync",
        this.#boundOnConfigurationOverridesUpdated
      );
      /**
       * Records whether the listeners have been added or not.
       */
      this.#listenerAdded = false;
    }
  }

  /**
   * Resets the cached configuration, thus clearing the way to fetch a new
   * configuration the next time `fetchEngineConfiguration` is called.
   */
  clearCachedConfigurationForTests() {
    this.#configuration = null;
  }

  /**
   * Handles getting the configuration from remote settings.
   *
   * @returns {Promise<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];
    // For Base Browser, we don't expect the configuration to ever change in a
    // session, so we keep the #getConfigurationPromise as-is for later calls.

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

    /**
     * Records whether the listeners have been added or not.
     */
    // For Base Browser, we don't use remoteConfig. tor-browser#43525.
    if (!AppConstants.BASE_BROWSER_VERSION && !this.#listenerAdded) {
      this.#remoteConfig.on("sync", this.#boundOnConfigurationUpdated);
      this.#remoteConfigOverrides.on(
        "sync",
        this.#boundOnConfigurationOverridesUpdated
      );
      /**
       * Records whether the listeners have been added or not.
       */
      this.#listenerAdded = true;
    }

    this.#selector.setSearchConfig(
      JSON.stringify({ data: this.#configuration })
    );
    this.#selector.setConfigOverrides(
      JSON.stringify({ data: remoteSettingsData[1] })
    );

    return this.#configuration;
  }

  /**
   * Finds an engine configuration that has a matching host.
   *
   * @param {string} host
   *   The host to match.
   *
   * @returns {Promise<?SearchEngineDefinition>}
   *   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 {Promise<?SearchEngineDefinition>}
   *   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;
  }

  /**
   * @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.
   * @param {boolean} [options.javascriptEnabled]
   *   Tell whether JS is enabled. If not, we will prefer plain HTML version of
   *   search engines, when available.
   * @returns {Promise<RefinedSearchConfig>}
   *   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 ?? "",
    javascriptEnabled = true,
  }) {
    if (!this.#configuration) {
      await this.getEngineConfiguration();
    }

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

    let refinedSearchConfig = this.#selector.filterEngineConfiguration(
      new lazy.SearchUserEnvironment({
        locale,
        region,
        updateChannel: this.#convertUpdateChannel(channel),
        distributionId: distroID ?? "",
        experiment: experiment ?? "",
        appName: this.#convertApplicationName(appName),
        version,
        deviceType: lazy.SearchDeviceType.NONE,
      })
    );

    refinedSearchConfig.engines = refinedSearchConfig.engines.filter(
      e => !e.optional
    );

    if (!javascriptEnabled) {
      refinedSearchConfig.engines = refinedSearchConfig.engines.map(e => {
        if (e.identifier === "ddg") {
          e.urls.search.base = "https://html.duckduckgo.com/html";
        } else if (e.identifier === "ddg-onion") {
          e.urls.search.base += "html";
        }
        return e;
      });
    }

    if (
      !refinedSearchConfig.appDefaultEngineId ||
      !refinedSearchConfig.engines.find(
        e => e.identifier == refinedSearchConfig.appDefaultEngineId
      )
    ) {
      if (refinedSearchConfig.engines.length) {
        lazy.logConsole.error(
          "Could not find a matching default engine, using the first one in the list"
        );
        refinedSearchConfig.appDefaultEngineId =
          refinedSearchConfig.engines[0].identifier;
      } else {
        throw new Error(
          "Could not find any engines in the filtered configuration"
        );
      }
    }
    if (lazy.SearchUtils.loggingEnabled) {
      lazy.logConsole.debug(
        "fetchEngineConfiguration: " +
          refinedSearchConfig.engines.map(e => e.identifier)
      );
    }

    return refinedSearchConfig;
  }

  /**
   * The remote settings client for handling the search configuration collection.
   */
  #remoteConfig;

  /**
   * The remote settings client for handling the search configuration overrides
   * collection.
   */
  #remoteConfigOverrides;

  /**
   * The currently cached configuration. This is cached so that
   * `findContextualSearchEngineByHost` and `findContextualSearchEngineById`
   * do not need to get the configuration from remote settings every time
   * they are called.
   *
   * @type {object[]}
   */
  #configuration;

  /**
   * The bound version of the configuration updated listener.
   */
  #boundOnConfigurationUpdated;

  /**
   * The bound version of the configuration overrides updated listener.
   */
  #boundOnConfigurationOverridesUpdated;

  /**
   * Records whether the listeners have been added or not.
   */
  #listenerAdded = false;

  /**
   * A listener that is notified when changes to the search collections are
   * detected.
   */
  #changeListener;

  /**
   * A promise that is used to de-bounce getting the configuration, it is
   * resolved once the has been completed.
   *
   * @type {Promise<[object[], object[]]>}
   */
  #getConfigurationPromise;

  /**
   * 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 {Promise<object[]>}
   *   An array of objects in the database, or an empty array if none
   *   could be obtained.
   */
  async #getConfiguration(firstTime = true) {
    if (AppConstants.BASE_BROWSER_VERSION) {
      // For Base Browser, load the config from a local file, rather than
      // #remoteConfig. tor-browser#43525.
      return (
        await fetch(
          "chrome://global/content/search/base-browser-search-engines.json"
        )
      ).json();
    }
    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.
   *
   * This is exposed so that searchengine-devtools can easily change the
   * configuration within its instance of the class object.
   *
   * @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 } }) {
    // tor-browser#43525: Even though RemoteSettings are a no-op for us, we do
    // not want them to interfere in any way.
    if (AppConstants.BASE_BROWSER_VERSION) {
      return;
    }

    this.#configuration = current;

    this.#selector.setSearchConfig(
      JSON.stringify({ data: this.#configuration })
    );

    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.
   *
   * This is exposed so that searchengine-devtools can easily change the
   * configuration within its instance of the class object.
   *
   * @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 } }) {
    // tor-browser#43525: Even though RemoteSettings are a no-op for us, we do
    // not want them to interfere in any way.
    if (AppConstants.BASE_BROWSER_VERSION) {
      return;
    }

    this.#selector.setConfigOverrides(JSON.stringify({ data: current }));

    lazy.logConsole.debug("Search configuration overrides updated remotely");
    if (this.#changeListener) {
      this.#changeListener();
    }
  }

  /**
   * Obtains the configuration overrides from remote settings.
   *
   * @returns {Promise<object[]>}
   *   An array of objects in the database, or an empty array if none
   *   could be obtained.
   */
  async #getConfigurationOverrides() {
    if (AppConstants.BASE_BROWSER_VERSION) {
      // For Base Browser, we don't want overrides. tor-browser#43525.
      return [];
    }
    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;
  }

  /**
   * @type {InstanceType<typeof lazy.SearchEngineSelector>?}
   */
  #cachedSelector = null;

  /**
   * Returns the Rust based selector.
   *
   * @returns {InstanceType<typeof lazy.SearchEngineSelector>}
   */
  get #selector() {
    if (!this.#cachedSelector) {
      this.#cachedSelector = lazy.SearchEngineSelector.init();
    }
    return this.#cachedSelector;
  }

  /**
   * Converts the update channel from a string into a type the search engine
   * selector can understand.
   *
   * @param {string} channel
   *   The channel name to convert.
   * @returns {Values<typeof lazy.SearchUpdateChannel>}
   */
  #convertUpdateChannel(channel) {
    let uppercaseChannel = channel.toUpperCase();

    if (uppercaseChannel in lazy.SearchUpdateChannel) {
      return lazy.SearchUpdateChannel[uppercaseChannel];
    }

    return lazy.SearchUpdateChannel.DEFAULT;
  }

  /**
   * Converts the application name from a string into a type the search engine
   * selector can understand.
   *
   * @param {string} appName
   *   The application name to convert.
   * @returns {Values<typeof lazy.SearchApplicationName>}
   */
  #convertApplicationName(appName) {
    let uppercaseAppName = appName.toUpperCase().replace("-", "_");

    if (uppercaseAppName in lazy.SearchApplicationName) {
      return lazy.SearchApplicationName[uppercaseAppName];
    }
    return lazy.SearchApplicationName.FIREFOX;
  }
}
