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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
  ExtensionTestUtils:
    "resource://testing-common/ExtensionXPCShellUtils.sys.mjs",
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
  SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
  sinon: "resource://testing-common/Sinon.sys.mjs",
});

var gTestScope;

export var SearchTestUtils = {
  init(testScope) {
    gTestScope = testScope;
    this._isMochitest = !Services.env.exists("XPCSHELL_TEST_PROFILE_DIR");
    if (this._isMochitest) {
      this._isMochitest = true;
      lazy.AddonTestUtils.initMochitest(testScope);
    } else {
      this._isMochitest = false;
      // This handles xpcshell-tests.
      gTestScope.ExtensionTestUtils = lazy.ExtensionTestUtils;
      this.initXPCShellAddonManager(testScope);
    }
  },

  /**
   * 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 {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 promiseNewSearchEngine({
    url,
    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 Services.search.addOpenSearchEngine(url, "");
    let previousEngine = Services.search.defaultEngine;
    let previousPrivateEngine = Services.search.defaultPrivateEngine;
    if (setAsDefault) {
      await Services.search.setDefault(
        engine,
        Ci.nsISearchService.CHANGE_REASON_UNKNOWN
      );
    }
    if (setAsDefaultPrivate) {
      await Services.search.setDefaultPrivate(
        engine,
        Ci.nsISearchService.CHANGE_REASON_UNKNOWN
      );
    }
    gTestScope.registerCleanupFunction(async () => {
      if (setAsDefault && !skipReset) {
        await Services.search.setDefault(
          previousEngine,
          Ci.nsISearchService.CHANGE_REASON_UNKNOWN
        );
      }
      if (setAsDefaultPrivate && !skipReset) {
        await Services.search.setDefaultPrivate(
          previousPrivateEngine,
          Ci.nsISearchService.CHANGE_REASON_UNKNOWN
        );
      }
      try {
        await Services.search.removeEngine(engine);
      } catch (ex) {
        // Don't throw if the test has already removed it.
      }
    });
    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'.
   * @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") {
    return new Promise(resolve => {
      Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
        if (aData != expectedData) {
          return;
        }

        Services.obs.removeObserver(observer, topic);
        // Let the stack unwind.
        Services.tm.dispatchToMainThread(() => resolve(aSubject));
      }, topic);
    });
  },

  /**
   * For xpcshell tests, configures loading engines from test data located in
   * particular folders.
   *
   * @param {string} [folder]
   *   The folder name to use.
   * @param {string} [subFolder]
   *   The subfolder to use, if any.
   * @param {Array} [configData]
   *   An array which contains the configuration to set.
   * @returns {object}
   *   An object that is a sinon stub for the configuration getter.
   */
  async useTestEngines(folder = "data", subFolder = null, configData = null) {
    if (!lazy.SearchUtils.newSearchConfigEnabled) {
      let url = `resource://test/${folder}/`;
      if (subFolder) {
        url += `${subFolder}/`;
      }
      let resProt = Services.io
        .getProtocolHandler("resource")
        .QueryInterface(Ci.nsIResProtocolHandler);
      resProt.setSubstitution("search-extensions", Services.io.newURI(url));
    }

    const settings = await lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY);
    if (configData) {
      return lazy.sinon.stub(settings, "get").returns(configData);
    }

    let workDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
    let configFileName =
      "file://" +
      PathUtils.join(
        workDir.path,
        folder,
        subFolder ?? "",
        lazy.SearchUtils.newSearchConfigEnabled
          ? "search-config-v2.json"
          : "engines.json"
      );

    let response = await fetch(configFileName);
    let json = await response.json();
    return lazy.sinon.stub(settings, "get").returns(json.data);
  },

  /**
   * For mochitests, configures loading engines from test data located in
   * particular folders. This will cleanup at the end of the test.
   *
   * This will be removed when the old configuration is removed
   * (newSearchConfigEnabled = false).
   *
   * @param {nsIFile} testDir
   *   The test directory to use.
   */
  async useMochitestEngines(testDir) {
    // Replace the path we load search engines from with
    // the path to our test data.
    let resProt = Services.io
      .getProtocolHandler("resource")
      .QueryInterface(Ci.nsIResProtocolHandler);
    let originalSubstitution = resProt.getSubstitution("search-extensions");
    resProt.setSubstitution(
      "search-extensions",
      Services.io.newURI("file://" + testDir.path)
    );
    gTestScope.registerCleanupFunction(() => {
      resProt.setSubstitution("search-extensions", originalSubstitution);
    });
  },

  /**
   * Utility function for mochitests to configure a custom search engine
   * directory and search configuration.
   *
   * @param {string} testDir
   *   The test directory to use.
   * @param {Array} searchConfig
   *   The test search configuration to use.
   */
  async setupTestEngines(testDir, searchConfig) {
    let searchExtensions = gTestScope.getChromeDir(
      gTestScope.getResolvedURI(gTestScope.gTestPath)
    );
    searchExtensions.append(testDir);
    await this.useMochitestEngines(searchExtensions);

    this.useMockIdleService();

    await this.updateRemoteSettingsConfig(searchConfig);

    gTestScope.registerCleanupFunction(async () => {
      let settingsWritten = SearchTestUtils.promiseSearchNotification(
        "write-settings-to-disk-complete"
      );
      await SearchTestUtils.updateRemoteSettingsConfig();
      await settingsWritten;
    });
  },

  /**
   * Convert a list of engine configurations into engine objects.
   *
   * @param {Array} engineConfigurations
   *   An array of engine configurations.
   */
  async searchConfigToEngines(engineConfigurations) {
    let engines = [];

    for (let e of engineConfigurations) {
      if (!e.webExtension) {
        e.webExtension = {};
      }
      e.webExtension.locale =
        e.webExtension.locale ?? lazy.SearchUtils.DEFAULT_TAG;

      // TODO Bug 1875912 - Remove the webextension.id and webextension.locale when
      // we're ready to remove old search-config and use search-config-v2 for all
      // clients. The id in appProvidedSearchEngine should be changed to
      // engine.identifier.
      if (lazy.SearchUtils.newSearchConfigEnabled) {
        let identifierComponents = NON_SPLIT_ENGINE_IDS.includes(e.identifier)
          ? [e.identifier]
          : e.identifier.split("-");

        e.webExtension.locale =
          identifierComponents.slice(1).join("-") || "default";
        e.webExtension.id = identifierComponents[0] + "@search.mozilla.org";
      }
    }

    for (let config of engineConfigurations) {
      let engine = await Services.search.wrappedJSObject._makeEngineFromConfig(
        config
      );
      engines.push(engine);
    }
    return engines;
  },

  /**
   * Provides various setup for xpcshell-tests installing WebExtensions. Should
   * be called from the global scope of the test.
   *
   * @param {object} scope
   *  The global scope of the test being run.
   * @param {*} usePrivilegedSignatures
   *  How to sign created addons.
   */
  initXPCShellAddonManager(scope, usePrivilegedSignatures = false) {
    let scopes =
      lazy.AddonManager.SCOPE_PROFILE | lazy.AddonManager.SCOPE_APPLICATION;
    Services.prefs.setIntPref("extensions.enabledScopes", scopes);
    // Only do this once.
    try {
      gTestScope.ExtensionTestUtils.init(scope);
    } catch (ex) {
      // This can happen if init is called twice.
      if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
        throw ex;
      }
    }
    lazy.AddonTestUtils.usePrivilegedSignatures = usePrivilegedSignatures;
    lazy.AddonTestUtils.overrideCertDB();
  },

  /**
   * 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 = {}
  ) {
    await Services.search.init();

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

    let extension;

    let previousEngine = Services.search.defaultEngine;
    let previousPrivateEngine = Services.search.defaultPrivateEngine;

    async function cleanup() {
      if (setAsDefault) {
        await Services.search.setDefault(
          previousEngine,
          Ci.nsISearchService.CHANGE_REASON_UNKNOWN
        );
      }
      if (setAsDefaultPrivate) {
        await Services.search.setDefaultPrivate(
          previousPrivateEngine,
          Ci.nsISearchService.CHANGE_REASON_UNKNOWN
        );
      }
      await extension.unload();
    }

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

    extension = gTestScope.ExtensionTestUtils.loadExtension(extensionInfo);
    await extension.startup();
    await lazy.AddonTestUtils.waitForSearchProviderStartup(extension);
    let engine = Services.search.getEngineByName(manifest.name);

    if (setAsDefault) {
      await Services.search.setDefault(
        engine,
        Ci.nsISearchService.CHANGE_REASON_UNKNOWN
      );
    }
    if (setAsDefaultPrivate) {
      await Services.search.setDefaultPrivate(
        engine,
        Ci.nsISearchService.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) {
      gTestScope.registerCleanupFunction(cleanup);
    }

    return extension;
  },

  /**
   * Install a search engine as a system extension to simulate
   * Normandy updates. For xpcshell-tests only.
   *
   * @param {object} [options]
   *   See {@link createEngineManifest}
   */
  async installSystemSearchExtension(options = {}) {
    options.id = (options.id ?? "example") + "@search.mozilla.org";
    let xpi = await lazy.AddonTestUtils.createTempWebExtensionFile({
      manifest: this.createEngineManifest(options),
      background() {
        // eslint-disable-next-line no-undef
        browser.test.sendMessage("started");
      },
    });
    let wrapper = gTestScope.ExtensionTestUtils.expectExtension(options.id);

    const install = await lazy.AddonManager.getInstallForURL(
      `file://${xpi.path}`,
      {
        useSystemLocation: true,
      }
    );

    install.install();

    await wrapper.awaitStartup();
    await wrapper.awaitMessage("started");

    return wrapper;
  },

  /**
   * 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 {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.
   * @param {string} [options.search_form]
   *   The search form 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,
          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.search_form) {
      manifest.chrome_settings_overrides.search_provider.search_form =
        options.search_form;
    }
    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",
      SearchTestUtils.idleService
    );
    gTestScope.registerCleanupFunction(() => {
      MockRegistrar.unregister(fakeIdleService);
    });
  },

  /**
   * Simulates an update to the RemoteSettings configuration.
   *
   * @param {object} [config]
   *  The new configuration.
   */
  async updateRemoteSettingsConfig(config) {
    if (!config) {
      let settings = lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY);
      config = await settings.get();
    }
    const reloadObserved =
      SearchTestUtils.promiseSearchNotification("engines-reloaded");
    await lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY).emit("sync", {
      data: { current: config },
    });

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