import { MockRegistrar } from "resource://testing-common/MockRegistrar.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
  AppProvidedConfigEngine:
    "moz-src:///toolkit/components/search/ConfigSearchEngine.sys.mjs",
  ExtensionTestUtils:
    "resource://testing-common/ExtensionXPCShellUtils.sys.mjs",
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
  SearchService: "moz-src:///toolkit/components/search/SearchService.sys.mjs",
  SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs",
  sinon: "resource://testing-common/Sinon.sys.mjs",
});

/**
 * @import {AppProvidedConfigEngine} from "ConfigSearchEngine.sys.mjs"
 * @import {SearchEngine} from "moz-src:///toolkit/components/search/SearchEngine.sys.mjs"
 */

/**
 * A class containing useful testing functions for Search based tests.
 */
class _SearchTestUtils {
  /**
   * The test scope that the test is running in.
   *
   * @type {object}
   */
  #testScope = null;

  /**
   * True if we are in a mochitest scope, false for xpcshell-tests.
   *
   * @type {boolean?}
   */
  #isMochitest = null;

  /**
   * Whether the fake idle service is registered and needs to be cleaned up.
   *
   * @type {boolean}
   */
  #idleServiceCID = null;

  /**
   * All stubs of remote settings that will be cleaned up by their key.
   *
   * @type {object}
   */
  #stubs = new Map();

  /**
   * Initialises the test utils, setting up the scope and working out if these
   * are mochitest or xpcshell-test.
   *
   * @param {object} testScope
   *   The global scope for the test.
   */
  init(testScope) {
    this.#testScope = testScope;
    this.#isMochitest = !Services.env.exists("XPCSHELL_TEST_PROFILE_DIR");
    if (this.#isMochitest) {
      lazy.AddonTestUtils.initMochitest(testScope);

      testScope.registerCleanupFunction(async () => {
        this.#stubs.forEach(stub => stub.restore());

        if (this.#stubs.size) {
          this.#stubs = new Map();

          lazy.SearchService._settings._testResetSettings();
          let settingsWritten = SearchTestUtils.promiseSearchNotification(
            "write-settings-to-disk-complete"
          );
          await this.updateRemoteSettingsConfig();
          await settingsWritten;
        }

        if (this.#idleServiceCID) {
          MockRegistrar.unregister(this.#idleServiceCID);
          this.#idleServiceCID = null;
        }
      });
    }
  }

  /**
   * Adds an OpenSearch based engine to the search service. It will remove
   * the engine at the end of the test.
   *
   * @param {object} options
   *   The options for the new search engine.
   * @param {string} options.url
   *   The URL of the engine to add.
   * @param {string} [options.faviconURL]
   *   The icon to be used for the open search engine, if not specified in the
   *   OpenSearch engine data.
   * @param {boolean} [options.setAsDefault]
   *   Whether or not to set the engine as default automatically. If this is
   *   true, the engine will be set as default, and the previous default engine
   *   will be restored when the test exits.
   * @param {boolean} [options.setAsDefaultPrivate]
   *   Whether or not to set the engine as default automatically for private mode.
   *   If this is true, the engine will be set as default, and the previous default
   *   engine will be restored when the test exits.
   * @param {boolean} [options.skipReset]
   *   Skips resetting the default engine at the end of the test.
   * @returns {Promise} Returns a promise that is resolved with the new engine
   *                    or rejected if it fails.
   */
  async installOpenSearchEngine({
    url,
    faviconURL,
    setAsDefault = false,
    setAsDefaultPrivate = false,
    skipReset = false,
  }) {
    // OpenSearch engines can only be added via http protocols.
    url = url.replace("chrome://mochitests/content", "https://example.com");
    let engine = await lazy.SearchService.addOpenSearchEngine(url, faviconURL);
    let previousEngine = lazy.SearchService.defaultEngine;
    let previousPrivateEngine = lazy.SearchService.defaultPrivateEngine;
    if (setAsDefault) {
      await lazy.SearchService.setDefault(
        engine,
        lazy.SearchService.CHANGE_REASON.UNKNOWN
      );
    }
    if (setAsDefaultPrivate) {
      await lazy.SearchService.setDefaultPrivate(
        engine,
        lazy.SearchService.CHANGE_REASON.UNKNOWN
      );
    }
    this.#testScope.registerCleanupFunction(async () => {
      if (setAsDefault && !skipReset) {
        await lazy.SearchService.setDefault(
          previousEngine,
          lazy.SearchService.CHANGE_REASON.UNKNOWN
        );
      }
      if (setAsDefaultPrivate && !skipReset) {
        await lazy.SearchService.setDefaultPrivate(
          previousPrivateEngine,
          lazy.SearchService.CHANGE_REASON.UNKNOWN
        );
      }
      try {
        await lazy.SearchService.removeEngine(engine);
      } catch (ex) {
        // Don't throw if the test has already removed it.
      }
      if (setAsDefault) {
        this.clearDefaultSearchEngineCachedPrefs();
      }
    });
    return engine;
  }

  /**
   * Returns a promise that is resolved when an observer notification from the
   * search service fires with the specified data.
   *
   * @param {*} expectedData
   *        The value the observer notification sends that causes us to resolve
   *        the promise.
   * @param {string} topic
   *        The notification topic to observe. Defaults to 'browser-search-service'.
   * @param {number} times
   *        The number of notifications required to resolve the promise. Defaults to 1.
   * @returns {Promise}
   *        Returns a promise that is resolved with the subject of the
   *        topic once the topic with the data has been observed.
   */
  promiseSearchNotification(
    expectedData,
    topic = "browser-search-service",
    times = 1
  ) {
    return new Promise(resolve => {
      let i = 0;
      Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
        if (aData == expectedData) {
          i += 1;
          if (i == times) {
            Services.obs.removeObserver(observer, topic);
            // Let the stack unwind.
            Services.tm.dispatchToMainThread(() =>
              resolve(aSubject?.wrappedJSObject ?? aSubject)
            );
          }
        }
      }, topic);
    });
  }

  /**
   * Stubs the get property of the remote settings client with a
   * given Key. Configuration does not get expanded.
   *
   * @param {string} [key]
   *   The remote settings key of the configuration to be stubbed.
   * @param {object[]} [config]
   *   The configuration that will be returned by the stub.
   */
  #stubConfig(key, config) {
    if (!config) {
      if (this.#stubs.has(key)) {
        this.#stubs.get(key).restore();
        this.#stubs.delete(key);
      }
      return;
    }

    if (!this.#stubs.has(key)) {
      let settings = lazy.RemoteSettings(key);
      this.#stubs.set(key, lazy.sinon.stub(settings, "get").returns(config));
    } else {
      this.#stubs.get(key).returns(config);
    }
  }

  /**
   * Expands a partial search config by the minumum number of properties
   * to be a valid config and loads it to make test cases less verbose.
   * Does not modify the input.
   *
   * defaultEngines and engineOrders are not required. The first
   * engine will be set to default if defaultEngines is not specified.
   *
   * Engine objects only require an identifier and there needs to be at least
   * one engine. The name defaults to the identifier, the classification
   * to general, the search url to https://www.example.com/search?q=query
   * and the environment to allRegionsAndLocales.
   *
   * The recordType is detected automatically and thus optional.
   *
   * @param {object[]} [partialConfig]
   *  The partial search config that will be expanded.
   * @returns {object[]}
   *  The expanded search config.
   */
  expandPartialConfig(partialConfig) {
    if (!partialConfig) {
      return partialConfig;
    }
    let fullConfig = structuredClone(partialConfig);
    let numEngines = 0;
    let defaultEngines;
    let engineOrders;
    let availableLocales;

    for (let obj of fullConfig) {
      obj.recordType = this.#detectRecordType(obj);

      switch (obj.recordType) {
        case "engine":
          if (!obj.base) {
            obj.base = {};
          }
          if (!obj.base.name) {
            obj.base.name = obj.identifier;
          }
          if (!obj.base.classification) {
            obj.base.classification = "general";
          }
          if (!obj.base.urls) {
            obj.base.urls = {};
          }
          if (!obj.base.urls.search) {
            obj.base.urls.search = {
              base: "https://www.example.com/search",
              searchTermParamName: "q",
            };
          }

          if (!obj.variants) {
            obj.variants = [{ environment: { allRegionsAndLocales: true } }];
          }
          numEngines++;
          break;
        case "defaultEngines":
          defaultEngines = obj;
          break;
        case "engineOrders":
          engineOrders = obj;
          break;
        case "availableLocales":
          availableLocales = obj;
      }
    }

    if (!numEngines) {
      throw new Error("One engine is required.");
    }

    if (!engineOrders) {
      engineOrders = {
        recordType: "engineOrders",
        orders: [],
      };
      fullConfig.push(engineOrders);
    }

    if (!defaultEngines) {
      defaultEngines = { recordType: "defaultEngines" };
      fullConfig.push(defaultEngines);
    }

    if (!defaultEngines.globalDefault) {
      let firstEngine = fullConfig.find(r => r.recordType == "engine");
      defaultEngines.globalDefault = firstEngine.identifier;
    }
    if (!defaultEngines.specificDefaults) {
      defaultEngines.specificDefaults = [];
    }

    if (!availableLocales) {
      availableLocales = {
        recordType: "availableLocales",
        locales: Array.from(this.extractAvailableLocales(fullConfig)),
      };
      fullConfig.push(availableLocales);
    }

    return fullConfig;
  }

  /**
   * Extracts the list of available locales from a search configuration.
   *
   * @param {object[]} config
   */
  extractAvailableLocales(config) {
    let result = new Set();

    function addLocalesFromEnvironment(environment) {
      environment.locales?.forEach(locale => result.add(locale));
      environment.excludedLocales?.forEach(locale => result.add(locale));
    }

    for (let entry of config) {
      switch (entry.recordType) {
        case "engine": {
          for (let variant of entry.variants) {
            addLocalesFromEnvironment(variant.environment);
            for (let subVariant of variant.subVariants ?? []) {
              addLocalesFromEnvironment(subVariant.environment);
            }
          }
          break;
        }
        case "defaultEngines": {
          for (let specificDefault of entry.specificDefaults) {
            addLocalesFromEnvironment(specificDefault.environment);
          }
          break;
        }
        case "engineOrders": {
          for (let order of entry.orders) {
            addLocalesFromEnvironment(order.environment);
          }
          break;
        }
      }
    }
    return result;
  }

  /**
   * Detects the recordType of a partial search config object based
   * on its properties.
   *
   * @param {object} [partialObject]
   *  The partial search config object whose recordType will be detected.
   * @returns {string}
   *   The detected recordType.
   */
  #detectRecordType(partialObject) {
    const identifyingProperties = {
      engine: ["identifier"],
      defaultEngines: ["specificDefaults", "globalDefault"],
      engineOrders: ["orders"],
    };

    let detectedType = partialObject.recordType;
    for (let recordType in identifyingProperties) {
      if (identifyingProperties[recordType].some(p => p in partialObject)) {
        if (detectedType && detectedType != recordType) {
          throw new Error("Ambiguous recordType");
        }
        detectedType = recordType;
      }
    }

    if (!detectedType) {
      throw new Error(
        "Could not detect recordType. Identifier is mandatory for engine recordTypes."
      );
    }

    return detectedType;
  }

  /**
   * Convert a list of engine configurations into engine objects.
   *
   * @param {Array} engineConfigurations
   *   An array of engine configurations.
   * @returns {Promise<AppProvidedConfigEngine[]>}
   *   An array of app provided config engine objects.
   */
  async searchConfigToEngines(engineConfigurations) {
    return engineConfigurations.map(
      config => new lazy.AppProvidedConfigEngine({ config })
    );
  }

  /**
   * Sets up the add-on manager so that it is ready for loading WebExtension
   * in xpcshell-tests.
   */
  async initXPCShellAddonManager() {
    this.#testScope.ExtensionTestUtils = lazy.ExtensionTestUtils;

    if (
      lazy.ExtensionTestUtils.addonManagerStarted ||
      lazy.AddonTestUtils.addonIntegrationService
    ) {
      // We have already started the add-on manager, and the following functions
      // may throw if they are called twice.
      return;
    }

    lazy.ExtensionTestUtils.init(this.#testScope);
    lazy.AddonTestUtils.overrideCertDB();
    lazy.AddonTestUtils.init(this.#testScope, false);

    await lazy.ExtensionTestUtils.startAddonManager();
  }

  /**
   * Add a search engine as a WebExtension.
   *
   * Note: for tests, the extension must generally be unloaded before
   * `registerCleanupFunction`s are triggered. See bug 1694409.
   *
   * This function automatically registers an unload for the extension, this
   * may be skipped with the skipUnload argument.
   *
   * @param {object} [manifest]
   *   See {@link createEngineManifest}
   * @param {object} [options]
   *   Options for how the engine is installed and uninstalled.
   * @param {boolean} [options.setAsDefault]
   *   Whether or not to set the engine as default automatically. If this is
   *   true, the engine will be set as default, and the previous default engine
   *   will be restored when the test exits.
   * @param {boolean} [options.setAsDefaultPrivate]
   *   Whether or not to set the engine as default automatically for private mode.
   *   If this is true, the engine will be set as default, and the previous default
   *   engine will be restored when the test exits.
   * @param {boolean} [options.skipUnload]
   *   If true, this will skip the automatic unloading of the extension.
   * @param {object} [files]
   *   A key value object where the keys are the filenames and their contents are
   *   the values. Used for simulating locales and other files in the WebExtension.
   * @returns {object}
   *   The loaded extension.
   */
  async installSearchExtension(
    manifest = {},
    {
      setAsDefault = false,
      setAsDefaultPrivate = false,
      skipUnload = false,
    } = {},
    files = {}
  ) {
    if (!this.#isMochitest) {
      await this.initXPCShellAddonManager();
    }

    await lazy.SearchService.init();

    let extensionInfo = {
      useAddonManager: "permanent",
      files,
      manifest: this.createEngineManifest(manifest),
    };

    let extension;

    let previousEngine = lazy.SearchService.defaultEngine;
    let previousPrivateEngine = lazy.SearchService.defaultPrivateEngine;

    let cleanup = async () => {
      if (setAsDefault) {
        await lazy.SearchService.setDefault(
          previousEngine,
          lazy.SearchService.CHANGE_REASON.UNKNOWN
        );
        this.clearDefaultSearchEngineCachedPrefs();
      }
      if (setAsDefaultPrivate) {
        await lazy.SearchService.setDefaultPrivate(
          previousPrivateEngine,
          lazy.SearchService.CHANGE_REASON.UNKNOWN
        );
      }
      await extension.unload();
    };

    // Cleanup must be registered before loading the extension to avoid
    // failures for mochitests.
    if (!skipUnload && this.#isMochitest) {
      this.#testScope.registerCleanupFunction(cleanup);
    }

    extension = this.#testScope.ExtensionTestUtils.loadExtension(extensionInfo);
    await extension.startup();
    await lazy.AddonTestUtils.waitForSearchProviderStartup(extension);
    let engine = lazy.SearchService.getEngineByName(manifest.name);

    if (setAsDefault) {
      await lazy.SearchService.setDefault(
        engine,
        lazy.SearchService.CHANGE_REASON.UNKNOWN
      );
    }
    if (setAsDefaultPrivate) {
      await lazy.SearchService.setDefaultPrivate(
        engine,
        lazy.SearchService.CHANGE_REASON.UNKNOWN
      );
    }

    // For xpcshell-tests we must register the unload after adding the extension.
    // See bug 1694409 for why this is.
    if (!skipUnload && !this.#isMochitest) {
      this.#testScope.registerCleanupFunction(cleanup);
    }

    return extension;
  }

  /**
   * Create a search engine extension manifest.
   *
   * @param {object} [options]
   *   The options for the manifest.
   * @param {object} [options.icons]
   *   The icons to use for the WebExtension.
   * @param {string} [options.id]
   *   The id to use for the WebExtension.
   * @param {string} [options.name]
   *   The display name to use for the WebExtension.
   * @param {string} [options.version]
   *   The version to use for the WebExtension.
   * @param {boolean} [options.is_default]
   *   Whether or not to ask for the search engine in the WebExtension to be
   *   attempted to set as default.
   * @param {string} [options.favicon_url]
   *   The favicon to use for the search engine in the WebExtension.
   * @param {string} [options.keyword]
   *   The keyword to use for the search engine.
   * @param {string} [options.encoding]
   *   The encoding to use for the search engine.
   * @param {string} [options.search_url]
   *   The search URL to use for the search engine.
   * @param {string} [options.search_url_get_params]
   *   The GET search URL parameters to use for the search engine
   * @param {string} [options.search_url_post_params]
   *   The POST search URL parameters to use for the search engine
   * @param {string} [options.suggest_url]
   *   The suggestion URL to use for the search engine.
   * @param {string} [options.suggest_url_get_params]
   *   The suggestion URL parameters to use for the search engine.
   * @returns {object}
   *   The generated manifest.
   */
  createEngineManifest(options = {}) {
    options.name = options.name ?? "Example";
    options.id = options.id ?? options.name.toLowerCase().replaceAll(" ", "");
    if (!options.id.includes("@")) {
      options.id += "@tests.mozilla.org";
    }
    options.version = options.version ?? "1.0";
    let manifest = {
      version: options.version,
      browser_specific_settings: {
        gecko: {
          id: options.id,
        },
      },
      chrome_settings_overrides: {
        search_provider: {
          name: options.name,
          is_default: !!options.is_default,
          search_url: options.search_url ?? "https://example.com/",
        },
      },
    };

    if (options.icons) {
      manifest.icons = options.icons;
    }

    if (options.default_locale) {
      manifest.default_locale = options.default_locale;
    }

    if (options.search_url_post_params) {
      manifest.chrome_settings_overrides.search_provider.search_url_post_params =
        options.search_url_post_params;
    } else {
      manifest.chrome_settings_overrides.search_provider.search_url_get_params =
        options.search_url_get_params ?? "?q={searchTerms}";
    }

    if (options.favicon_url) {
      manifest.chrome_settings_overrides.search_provider.favicon_url =
        options.favicon_url;
    }
    if (options.encoding) {
      manifest.chrome_settings_overrides.search_provider.encoding =
        options.encoding;
    }
    if (options.keyword) {
      manifest.chrome_settings_overrides.search_provider.keyword =
        options.keyword;
    }
    if (options.suggest_url) {
      manifest.chrome_settings_overrides.search_provider.suggest_url =
        options.suggest_url;
    }
    if (options.suggest_url) {
      manifest.chrome_settings_overrides.search_provider.suggest_url_get_params =
        options.suggest_url_get_params;
    }
    if (options.favicon_url) {
      manifest.chrome_settings_overrides.search_provider.favicon_url =
        options.favicon_url;
    }
    return manifest;
  }

  /**
   * A mock idleService that allows us to simulate RemoteSettings
   * configuration updates.
   */
  idleService = {
    _observers: new Set(),

    _reset() {
      this._observers.clear();
    },

    _fireObservers(state) {
      for (let observer of this._observers.values()) {
        observer.observe(observer, state, null);
      }
    },

    QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]),
    idleTime: 19999,

    addIdleObserver(observer) {
      this._observers.add(observer);
    },

    removeIdleObserver(observer) {
      this._observers.delete(observer);
    },
  };

  /**
   * Register the mock idleSerice.
   */
  useMockIdleService() {
    let fakeIdleService = MockRegistrar.register(
      "@mozilla.org/widget/useridleservice;1",
      this.idleService
    );
    this.#idleServiceCID = fakeIdleService;
  }

  /**
   * Sets the search configuration and search overrides configuration without
   * reloading the engines.
   * If parameters are not specified, the appropriate configuration is
   * reset to the data stored in remote settings.
   *
   * This is useful for example in xpcshell-tests before the search service
   * is initialized.
   *
   * @param {object[]} [partialConfig]
   *   The replacement configuration. Will be expanded via `expandPartialConfig`.
   * @param {object[]} [overridesConfig]
   *   The replacement overrides configuration.
   */
  setRemoteSettingsConfig(partialConfig, overridesConfig) {
    let config = this.expandPartialConfig(partialConfig);
    this.#stubConfig(lazy.SearchUtils.SETTINGS_KEY, config);
    this.#stubConfig(lazy.SearchUtils.SETTINGS_OVERRIDES_KEY, overridesConfig);
  }

  /**
   * Simulates an update to the RemoteSettings configuration.
   * If parameters are not specified, the appropriate configuration is
   * reset to the data stored in remote settings.
   *
   * This is useful if the search service has already been initialized.
   * Note that the search service is always initialized in mochitests.
   *
   * @param {object[]} [partialConfig]
   *   The replacement configuration. Will be expanded via `expandPartialConfig`.
   * @param {object[]} [overridesConfig]
   *   The replacement overrides configuration.
   */
  async updateRemoteSettingsConfig(partialConfig, overridesConfig) {
    if (!this.#idleServiceCID) {
      this.useMockIdleService();
    }
    let config = this.expandPartialConfig(partialConfig);
    this.#stubConfig(lazy.SearchUtils.SETTINGS_KEY, config);
    this.#stubConfig(lazy.SearchUtils.SETTINGS_OVERRIDES_KEY, overridesConfig);

    if (!config) {
      let settings = lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY);
      config = await settings.get();
    }
    if (!overridesConfig) {
      let settings = lazy.RemoteSettings(
        lazy.SearchUtils.SETTINGS_OVERRIDES_KEY
      );
      overridesConfig = await settings.get();
    }
    const reloadObserved = this.promiseSearchNotification("engines-reloaded");
    await lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY).emit("sync", {
      data: { current: config },
    });
    await lazy
      .RemoteSettings(lazy.SearchUtils.SETTINGS_OVERRIDES_KEY)
      .emit("sync", {
        data: { current: overridesConfig },
      });

    this.idleService._fireObservers("idle");
    await reloadObserved;
  }

  /**
   * Fetches a URL and converts the content to a data URL.
   *
   * @param {string} url
   *   The URL of the file that should be fetched.
   * @returns {Promise<string>}
   *   The content of the file as a data URL.
   */
  async fetchAsDataUrl(url) {
    let res = await fetch(url);
    let blob = await res.blob();
    let reader = new FileReader();
    return new Promise((resolve, reject) => {
      reader.onload = () => {
        resolve(reader.result);
      };
      reader.onerror = () => {
        reject(new Error("Failed to read blob."));
      };
      reader.readAsDataURL(blob);
    });
  }

  /**
   * Extracts post data string from the data returned by getSubmission.
   * If there is no post data, returns null.
   *
   * @param {?{postData: nsIMIMEInputStream}} submission
   * @returns {?string}
   */
  getPostDataString(submission) {
    if (!submission.postData) {
      return null;
    }

    let binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
      Ci.nsIBinaryInputStream
    );
    binaryStream.setInputStream(submission.postData.data);

    return binaryStream.readBytes(binaryStream.available());
  }

  /**
   * Wait until a specific engine event on a specific engine.
   *
   * @param {string} expectedEngineName
   *   Name of the engine to wait for.
   * @param {string} expectedData
   *   Data to wait for.
   * @returns {Promise<SearchEngine>}
   *   Resolves to the search engine with the expected name.
   */
  promiseEngine(expectedEngineName, expectedData = "engine-added") {
    let { promise, resolve } = Promise.withResolvers();
    Services.obs.addObserver(function obs(subject, _topic, data) {
      let engine = subject.wrappedJSObject;

      if (data == expectedData && engine.name == expectedEngineName) {
        Services.obs.removeObserver(obs, "browser-search-engine-modified");
        resolve(engine);
      }
    }, "browser-search-engine-modified");
    return promise;
  }

  /**
   * Clears preferences which store settings relating to caching of the default
   * search engines. This is used to avoid compare-preferences reporting that the
   * preferences have changed.
   */
  clearDefaultSearchEngineCachedPrefs() {
    const prefs = [
      "browser.urlbar.recentsearches.lastDefaultChanged",
      "browser.newtabpage.activity-stream.trendingSearch.defaultSearchEngine",
    ];
    for (let pref of prefs) {
      Services.prefs.clearUserPref(pref);
    }
  }
}

export const SearchTestUtils = new _SearchTestUtils();
