e that needs to be migrated to WebExtensions.
        lazy.logConsole.debug("Migrating existing engine");
        shouldSetAsDefault = shouldSetAsDefault || this.defaultEngine == engine;
        await this.removeEngine(
          engine,
          Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
        );
      }
    }

    let newEngine = new lazy.AddonSearchEngine({
      details: {
        extensionID: extension.id,
      },
    });
    await newEngine.init({
      settings,
      extension,
    });

    // If this extension is starting up, check to see if it previously overrode
    // an application provided engine that has now been removed from the user's
    // set-up. If the application provided engine has been removed and was
    // default, then we should set this engine back to default and copy
    // the settings across.
    if (extension.startupReason == "APP_STARTUP") {
      if (!settings) {
        settings = await this._settings.get();
      }
      // We check the saved settings for the overridden flag, because if the engine
      // has been removed, we won't have that in _engines.
      let previouslyOverridden = settings.engines?.find(
        e => !!e._metaData.overriddenBy
      );
      if (previouslyOverridden) {
        // Only allow override if we were previously overriding and the
        // engine is no longer installed, and the new engine still matches the
        // override allow list.
        if (
          previouslyOverridden._metaData.overriddenBy == extension.id &&
          !this._engines.get(previouslyOverridden.id) &&
          (await lazy.defaultOverrideAllowlist.canEngineOverride(
            newEngine,
            previouslyOverridden.id
          ))
        ) {
          shouldSetAsDefault = true;
          // We assume that the app provided engine was removed due to a
          // configuration change, and therefore we have re-added the add-on
          // search engine. It is possible that it was actually due to a
          // locale/region change, but that is harder to detect here.
          changeReason = Ci.nsISearchService.CHANGE_REASON_CONFIG;
          newEngine.copyUserSettingsFrom(previouslyOverridden);
        }
      }
    }

    this.#addEngineToStore(newEngine);
    if (shouldSetAsDefault) {
      this.#setEngineDefault(false, newEngine, changeReason);
    }
  }

  /**
   * Called when we see an upgrade to an existing search extension.
   *
   * @param {object} extension
   *   An Extension object containing data about the extension.
   */
  async #upgradeExtensionEngine(extension) {
    let extensionEngines = this.#getEnginesByExtensionID(extension.id);

    for (let engine of extensionEngines) {
      let isDefault = engine == this.defaultEngine;
      let isDefaultPrivate = engine == this.defaultPrivateEngine;

      let originalName = engine.name;

      await engine.update({
        extension,
      });

      if (engine.name != originalName) {
        if (isDefault) {
          this._settings.setVerifiedMetaDataAttribute(
            "defaultEngineId",
            engine.id
          );
        }
        if (isDefaultPrivate) {
          this._settings.setVerifiedMetaDataAttribute(
            "privateDefaultEngineId",
            engine.id
          );
        }
        this._cachedSortedEngines = null;
      }
    }
    return extensionEngines;
  }

  /**
   * Removes a search engine from _sortedEngines and _engines.
   *
   * @param {SearchEngine} engine
   *   The search engine to remove.
   */
  #internalRemoveEngine(engine) {
    // Remove the engine from _sortedEngines
    if (this._cachedSortedEngines) {
      var index = this._cachedSortedEngines.indexOf(engine);
      if (index == -1) {
        throw Components.Exception(
          "Can't find engine to remove in _sortedEngines!",
          Cr.NS_ERROR_FAILURE
        );
      }
      this._cachedSortedEngines.splice(index, 1);
    }

    // Remove the engine from the internal store
    this._engines.delete(engine.id);
  }

  /**
   * Helper function to find a new default engine and set it. This could
   * be used if there is not default set yet, or if the current default is
   * being removed.
   *
   * This function will not consider engines in #enginesPendingRemoval.
   *
   * The new default will be chosen from (in order):
   *
   * - Existing default from configuration, if it is not hidden.
   * - The first non-hidden engine that is a general search engine.
   * - If all other engines are hidden, unhide the default from the configuration.
   * - If the default from the configuration is the one being removed, unhide
   *   the first general search engine, or first visible engine.
   *
   * @param {object} options
   *   The options object.
   * @param {boolean} options.privateMode
   *   If true, returns the default engine for private browsing mode, otherwise
   *   the default engine for the normal mode. Note, this function does not
   *   check the "separatePrivateDefault" preference - that is up to the caller.
   * @param {nsISearchService.DefaultEngineChangeReason} changeReason
   *   The reason for the change of default engine.
   * @returns {?SearchEngine}
   *   The appropriate search engine, or null if one could not be determined.
   */
  #findAndSetNewDefaultEngine({ privateMode }, changeReason) {
    // First to the app default engine...
    let newDefault = privateMode
      ? this.appPrivateDefaultEngine
      : this.appDefaultEngine;

    if (
      !newDefault ||
      newDefault.hidden ||
      this.#enginesPendingRemoval.has(newDefault)
    ) {
      let sortedEngines = this.#sortedVisibleEngines;
      let generalSearchEngines = sortedEngines.filter(
        e => e.isGeneralPurposeEngine
      );

      // then to the first visible general search engine that isn't excluded...
      let firstVisible = generalSearchEngines.find(
        e => !this.#enginesPendingRemoval.has(e)
      );
      if (firstVisible) {
        newDefault = firstVisible;
      } else if (newDefault) {
        // then to the app default if it is not the one that is excluded...
        if (!this.#enginesPendingRemoval.has(newDefault)) {
          newDefault.hidden = false;
        } else {
          newDefault = null;
        }
      }

      // and finally as a last resort we unhide the first engine
      // even if the name is the same as the excluded one (should never happen).
      if (!newDefault) {
        if (!firstVisible) {
          sortedEngines = this.#sortedEngines;
          firstVisible = sortedEngines.find(e => e.isGeneralPurposeEngine);
          if (!firstVisible) {
            firstVisible = sortedEngines[0];
          }
        }
        if (firstVisible) {
          firstVisible.hidden = false;
          newDefault = firstVisible;
        }
      }
    }
    // We tried out best but something went very wrong.
    if (!newDefault) {
      lazy.logConsole.error("Could not find a replacement default engine.");
      return null;
    }

    // If the current engine wasn't set or was hidden, we used a fallback
    // to pick a new current engine. As soon as we return it, this new
    // current engine will become user-visible, so we should persist it.
    // by calling the setter.
    this.#setEngineDefault(privateMode, newDefault, changeReason);

    return privateMode ? this.#currentPrivateEngine : this.#currentEngine;
  }

  /**
   * Helper function to set the current default engine.
   *
   * @param {boolean} privateMode
   *   If true, sets the default engine for private browsing mode, otherwise
   *   sets the default engine for the normal mode. Note, this function does not
   *   check the "separatePrivateDefault" preference - that is up to the caller.
   * @param {SearchEngine} newEngine
   *   The search engine to select.
   * @param {nsISearchService.DefaultEngineChangeReason} changeReason
   *   The reason for the default search engine change, one of
   *   Ci.nsISearchService.CHANGE_REASON*.
   */
  #setEngineDefault(privateMode, newEngine, changeReason) {
    // Sometimes we get wrapped nsISearchEngine objects (external XPCOM callers),
    // and sometimes we get raw Engine JS objects (callers in this file), so
    // handle both.
    if (
      !(newEngine instanceof Ci.nsISearchEngine) &&
      !(newEngine instanceof lazy.SearchEngine)
    ) {
      throw Components.Exception(
        "Invalid argument passed to defaultEngine setter",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    const newCurrentEngine = this._engines.get(newEngine.id);
    if (!newCurrentEngine) {
      throw Components.Exception(
        "Can't find engine in store!",
        Cr.NS_ERROR_UNEXPECTED
      );
    }

    if (!newCurrentEngine.isConfigEngine) {
      // If a non config engine is being set as the current engine,
      // ensure its loadPath has a verification hash.
      if (!newCurrentEngine._loadPath) {
        newCurrentEngine._loadPath = "[other]unknown";
      }
      let loadPathHash = lazy.SearchUtils.getVerificationHash(
        newCurrentEngine._loadPath
      );
      let currentHash = newCurrentEngine.getAttr("loadPathHash");
      if (!currentHash || currentHash != loadPathHash) {
        newCurrentEngine.setAttr("loadPathHash", loadPathHash);
        lazy.SearchUtils.notifyAction(
          newCurrentEngine,
          lazy.SearchUtils.MODIFIED_TYPE.CHANGED
        );
      }
    }

    let currentEngine = privateMode
      ? this.#currentPrivateEngine
      : this.#currentEngine;

    if (newCurrentEngine == currentEngine) {
      return;
    }

    // Ensure that we reset an engine override if it was previously overridden.
    currentEngine?.removeExtensionOverride();

    if (privateMode) {
      this.#currentPrivateEngine = newCurrentEngine;
    } else {
      this.#currentEngine = newCurrentEngine;
    }

    // If we change the default engine in the future, that change should impact
    // users who have switched away from and then back to the build's
    // "app default" engine. So clear the user pref when the currentEngine is
    // set to the build's app default engine, so that the currentEngine getter
    // falls back to whatever the default is.
    // However, we do not do this whilst we are running an experiment - an
    // experiment must preseve the user's choice of default engine during it's
    // runtime and when it ends. Once the experiment ends, we will reset the
    // attribute elsewhere.
    let newId = newCurrentEngine.id;
    const appDefaultEngine = privateMode
      ? this.appPrivateDefaultEngine
      : this.appDefaultEngine;
    if (
      newCurrentEngine == appDefaultEngine &&
      !this.#lazyPrefs.experimentPrefValue
    ) {
      newId = "";
    }

    this._settings.setVerifiedMetaDataAttribute(
      privateMode ? "privateDefaultEngineId" : "defaultEngineId",
      newId
    );

    // Only do this if we're initialized though - this function can get called
    // during initalization.
    if (this.isInitialized) {
      this.#recordDefaultChangedEvent(
        privateMode,
        currentEngine,
        newCurrentEngine,
        changeReason
      );
      this.#recordDefaultEngineTelemetryData();
    }

    lazy.SearchUtils.notifyAction(
      newCurrentEngine,
      lazy.SearchUtils.MODIFIED_TYPE[
        privateMode ? "DEFAULT_PRIVATE" : "DEFAULT"
      ]
    );
    // If we've not got a separate private active, notify update of the
    // private so that the UI updates correctly.
    if (!privateMode && !this.#separatePrivateDefault) {
      lazy.SearchUtils.notifyAction(
        newCurrentEngine,
        lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
      );
    }
  }

  #onSeparateDefaultPrefChanged(prefName, previousValue, currentValue) {
    // Clear out the sorted engines settings, so that we re-sort it if necessary.
    this._cachedSortedEngines = null;
    // We should notify if the normal default, and the currently saved private
    // default are different. Otherwise, save the energy.
    if (this.defaultEngine != this._getEngineDefault(true)) {
      lazy.SearchUtils.notifyAction(
        // Always notify with the new private engine, the function checks
        // the preference value for us.
        this.defaultPrivateEngine,
        lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
      );
    }

    let eventReason = prefName.endsWith("separatePrivateDefault.ui.enabled")
      ? Ci.nsISearchService.CHANGE_REASON_USER_PRIVATE_PREF_ENABLED
      : Ci.nsISearchService.CHANGE_REASON_USER_PRIVATE_SPLIT;
    if (!previousValue && currentValue) {
      this.#recordDefaultChangedEvent(
        true,
        null,
        this._getEngineDefault(true),
        eventReason
      );
    } else {
      this.#recordDefaultChangedEvent(
        true,
        this._getEngineDefault(true),
        null,
        eventReason
      );
    }
    // Update the telemetry data.
    this.#recordDefaultEngineTelemetryData();
  }

  /**
   * Gets summary information for an engine to report to telemetry.
   *
   * @param {SearchEngine} engine
   */
  #getEngineInfo(engine) {
    if (!engine) {
      // The defaultEngine getter will throw if there's no engine at all,
      // which shouldn't happen unless an add-on or a test deleted all of them.
      // Our preferences UI doesn't let users do that.
      lazy.logConsole.error("getEngineInfo: No default engine");
      return {
        providerId: "NONE",
        partnerCode: "NONE",
        overriddenByThirdParty: false,
        telemetryId: "NONE",
        loadPath: "NONE",
        name: "NONE",
        submissionURL: "NONE",
      };
    }

    // When an engine is overridden by a third party, then we report the
    // override and skip reporting the partner code, since we don't have
    // a requirement to report the partner code in that case.
    let isOverridden = !!engine.overriddenById;

    let engineInfo = {
      providerId: engine.isConfigEngine ? engine.id : "other",
      partnerCode: isOverridden ? "" : engine.partnerCode,
      overriddenByThirdParty: isOverridden,
      telemetryId: engine.telemetryId,
      loadPath: engine.loadPath,
      name: engine.name ? engine.name : "",
      /** @type {string?} */
      submissionURL: undefined,
    };

    // For privacy, we only collect the submission URL for config engines...
    let sendSubmissionURL = engine.isConfigEngine;

    if (!sendSubmissionURL) {
      // ... or engines that are the same domain as a config engine.
      let engineHost = engine.searchUrlDomain;
      for (let innerEngine of this._engines.values()) {
        if (!innerEngine.isConfigEngine) {
          continue;
        }

        if (innerEngine.searchUrlDomain == engineHost) {
          sendSubmissionURL = true;
          break;
        }
      }

      if (!sendSubmissionURL) {
        // ... or well known search domains.
        //
        // Starts with: www.google., search.aol., yandex.
        // or
        // Ends with: search.yahoo.com, .ask.com, .bing.com, .startpage.com, baidu.com, duckduckgo.com
        const urlTest =
          /^(?:www\.google\.|search\.aol\.|yandex\.)|(?:search\.yahoo|\.ask|\.bing|\.startpage|\.baidu|duckduckgo)\.com$/;
        sendSubmissionURL = urlTest.test(engineHost);
      }
    }

    if (sendSubmissionURL) {
      let uri = engine.searchURLWithNoTerms;
      uri = uri
        .mutate()
        .setUserPass("") // Avoid reporting a username or password.
        .finalize();
      engineInfo.submissionURL = uri.spec;
    }

    return engineInfo;
  }

  /**
   * Records an event for where the default engine is changed. This is
   * recorded to both Glean and Telemetry.
   *
   * The Glean GIFFT functionality is not used here because we use longer
   * names in the extra arguments to the event.
   *
   * @param {boolean} isPrivate
   *   True if this is a event about a private engine.
   * @param {SearchEngine} [previousEngine]
   *   The previously default search engine.
   * @param {SearchEngine} [newEngine]
   *   The new default search engine.
   * @param {nsISearchService.DefaultEngineChangeReason} changeReason
   *   The reason for the default search engine change, one of
   *   Ci.nsISearchService.CHANGE_REASON*.
   */
  #recordDefaultChangedEvent(
    isPrivate,
    previousEngine,
    newEngine,
    changeReason = Ci.nsISearchService.CHANGE_REASON_UNKNOWN
  ) {
    let engineInfo;
    // If we are toggling the separate private browsing settings, we might not
    // have an engine to record.
    if (newEngine) {
      engineInfo = this.#getEngineInfo(newEngine);
    }

    let submissionURL = engineInfo?.submissionURL ?? "";
    let extraArgs = {
      // In docshell tests, the previous engine does not exist, so we allow
      // for the previousEngine to be undefined.
      previous_engine_id: previousEngine?.telemetryId ?? "",
      new_engine_id: engineInfo?.telemetryId ?? "",
      new_display_name: engineInfo?.name ?? "",
      new_load_path: engineInfo?.loadPath ?? "",
      // Glean has a limit of 100 characters.
      new_submission_url: submissionURL.slice(0, 100),
      change_reason: REASON_CHANGE_MAP.get(changeReason) ?? "unknown",
    };
    if (isPrivate) {
      Glean.searchEnginePrivate.changed.record(extraArgs);
    } else {
      Glean.searchEngineDefault.changed.record(extraArgs);
    }
  }

  /**
   * Records the user's current default engine (normal and private) data to
   * telemetry.
   */
  #recordDefaultEngineTelemetryData() {
    let engineInfo = this.#getEngineInfo(this.defaultEngine);

    Glean.searchEngineDefault.providerId.set(engineInfo.providerId);
    Glean.searchEngineDefault.partnerCode.set(engineInfo.partnerCode);
    Glean.searchEngineDefault.overriddenByThirdParty.set(
      engineInfo.overriddenByThirdParty
    );
    Glean.searchEngineDefault.engineId.set(engineInfo.telemetryId);
    Glean.searchEngineDefault.displayName.set(engineInfo.name);
    Glean.searchEngineDefault.loadPath.set(engineInfo.loadPath);
    Glean.searchEngineDefault.submissionUrl.set(
      engineInfo.submissionURL ?? "blank:"
    );

    if (this.#separatePrivateDefault) {
      let privateEngineInfo = this.#getEngineInfo(this.defaultPrivateEngine);

      Glean.searchEnginePrivate.providerId.set(privateEngineInfo.providerId);
      Glean.searchEnginePrivate.partnerCode.set(privateEngineInfo.partnerCode);
      Glean.searchEnginePrivate.overriddenByThirdParty.set(
        privateEngineInfo.overriddenByThirdParty
      );
      Glean.searchEnginePrivate.engineId.set(privateEngineInfo.telemetryId);
      Glean.searchEnginePrivate.displayName.set(privateEngineInfo.name);
      Glean.searchEnginePrivate.loadPath.set(privateEngineInfo.loadPath);
      Glean.searchEnginePrivate.submissionUrl.set(
        privateEngineInfo.submissionURL ?? "blank:"
      );
    } else {
      Glean.searchEnginePrivate.providerId.set("");
      Glean.searchEnginePrivate.partnerCode.set("");
      Glean.searchEnginePrivate.overriddenByThirdParty.set(false);
      Glean.searchEnginePrivate.engineId.set("");
      Glean.searchEnginePrivate.displayName.set("");
      Glean.searchEnginePrivate.loadPath.set("");
      Glean.searchEnginePrivate.submissionUrl.set("blank:");
    }
  }

  /**
   * This function is called at the beginning of search service init.
   * If the error type set in a test environment matches errorType
   * passed to this function, we throw an error.
   *
   * @param {string} errorType
   *   The error that can occur during search service init.
   */
  #maybeThrowErrorInTest(errorType) {
    if (
      Services.env.exists("XPCSHELL_TEST_PROFILE_DIR") &&
      this.errorToThrowInTest.type === errorType
    ) {
      throw new Error(
        this.errorToThrowInTest.message ??
          `Fake ${errorType} error during search service initialization.`
      );
    }
  }

  #buildParseSubmissionMap() {
    this.#parseSubmissionMap = new Map();

    // Used only while building the map, indicates which entries do not refer to
    // the main domain of the engine but to an alternate domain, for example
    // "www.google.fr" for the "www.google.com" search engine.
    let keysOfAlternates = new Set();

    for (let engine of this.#sortedEngines) {
      if (engine.hidden) {
        continue;
      }

      let urlParsingInfo = engine.getURLParsingInfo();
      if (!urlParsingInfo) {
        continue;
      }

      // Store the same object on each matching map key, as an optimization.
      let mapValueForEngine = {
        engine,
        termsParameterName: urlParsingInfo.termsParameterName,
      };

      let processDomain = (domain, isAlternate) => {
        let key = domain + urlParsingInfo.path;

        // Apply the logic for which main domains take priority over alternate
        // domains, even if they are found later in the ordered engine list.
        let existingEntry = this.#parseSubmissionMap.get(key);
        if (!existingEntry) {
          if (isAlternate) {
            keysOfAlternates.add(key);
          }
        } else if (!isAlternate && keysOfAlternates.has(key)) {
          keysOfAlternates.delete(key);
        } else {
          return;
        }

        this.#parseSubmissionMap.set(key, mapValueForEngine);
      };

      processDomain(urlParsingInfo.mainDomain, false);
      lazy.SearchStaticData.getAlternateDomains(
        urlParsingInfo.mainDomain
      ).forEach(d => processDomain(d, true));
    }
  }

  #addObservers() {
    if (this.#observersAdded) {
      // There might be a race between synchronous and asynchronous
      // initialization for which we try to register the observers twice.
      return;
    }
    this.#observersAdded = true;

    Services.obs.addObserver(this, lazy.SearchUtils.TOPIC_ENGINE_MODIFIED);
    Services.obs.addObserver(this, QUIT_APPLICATION_TOPIC);
    Services.obs.addObserver(this, TOPIC_LOCALES_CHANGE);

    this._settings.addObservers();

    // The current stage of shutdown. Used to help analyze crash
    // signatures in case of shutdown timeout.
    let shutdownState = {
      step: "Not started",
      latestError: {
        message: undefined,
        stack: undefined,
      },
    };
    IOUtils.profileBeforeChange.addBlocker(
      "Search service: shutting down",
      () =>
        (async () => {
          // If we are in initialization, then don't attempt to save the settings.
          // It is likely that shutdown will have caused the add-on manager to
          // stop, which can cause initialization to fail.
          // Hence at that stage, we could have broken settings which we don't
          // want to write.
          // The good news is, that if we don't write the settings here, we'll
          // detect the out-of-date settings on next state, and automatically
          // rebuild it.
          if (!this.isInitialized) {
            lazy.logConsole.warn(
              "not saving settings on shutdown due to initializing."
            );
            return;
          }

          try {
            await this._settings.shutdown(shutdownState);
          } catch (ex) {
            // Ensure that error is reported and that it causes tests
            // to fail, otherwise ignore it.
            Promise.reject(ex);
          }
        })(),

      () => shutdownState
    );
  }

  // This is prefixed with _ rather than # because it is
  // called in a test.
  _removeObservers() {
    if (this.ignoreListListener) {
      lazy.IgnoreLists.unsubscribe(this.ignoreListListener);
      delete this.ignoreListListener;
    }
    if (this.#queuedIdle) {
      lazy.idleService.removeIdleObserver(this, RECONFIG_IDLE_TIME_SEC);
      this.#queuedIdle = false;
    }

    this._settings.removeObservers();

    Services.obs.removeObserver(this, lazy.Region.REGION_TOPIC);
    Services.obs.removeObserver(this, lazy.SearchUtils.TOPIC_ENGINE_MODIFIED);
    Services.obs.removeObserver(this, QUIT_APPLICATION_TOPIC);
    Services.obs.removeObserver(this, TOPIC_LOCALES_CHANGE);
    this.#observersAdded = false;
    this.#earlyObserversAdded = false;
  }

  QueryInterface = ChromeUtils.generateQI([
    "nsISearchService",
    "nsIObserver",
    "nsITimerCallback",
  ]);

  // nsIObserver
  observe(engine, topic, verb) {
    switch (topic) {
      case lazy.SearchUtils.TOPIC_ENGINE_MODIFIED:
        switch (verb) {
          case lazy.SearchUtils.MODIFIED_TYPE.ADDED:
            this.#parseSubmissionMap = null;
            break;
          case lazy.SearchUtils.MODIFIED_TYPE.CHANGED:
            engine = engine.wrappedJSObject;
            if (
              engine == this.defaultEngine ||
              engine == this.defaultPrivateEngine
            ) {
              this.#recordDefaultChangedEvent(
                engine != this.defaultEngine,
                engine,
                engine,
                Ci.nsISearchService.CHANGE_REASON_ENGINE_UPDATE
              );
            }
            this.#parseSubmissionMap = null;
            break;
          case lazy.SearchUtils.MODIFIED_TYPE.REMOVED:
            // Invalidate the map used to parse URLs to search engines.
            this.#parseSubmissionMap = null;
            break;
        }
        break;

      case "idle": {
        lazy.idleService.removeIdleObserver(this, RECONFIG_IDLE_TIME_SEC);
        this.#queuedIdle = false;
        lazy.logConsole.debug(
          "Reloading engines after idle due to configuration change"
        );
        this._maybeReloadEngines(
          Ci.nsISearchService.CHANGE_REASON_CONFIG
        ).catch(console.error);
        break;
      }

      case QUIT_APPLICATION_TOPIC:
        this._removeObservers();
        break;

      case TOPIC_LOCALES_CHANGE:
        // Locale changed. Re-init. We rely on observers, because we can't
        // return this promise to anyone.

        // At the time of writing, when the user does a "Apply and Restart" for
        // a new language the preferences code triggers the locales change and
        // restart straight after, so we delay the check, which means we should
        // be able to avoid the reload on shutdown, and we'll sort it out
        // on next startup.
        // This also helps to avoid issues with the add-on manager shutting
        // down at the same time (see _reInit for more info).
        Services.tm.dispatchToMainThread(() => {
          if (!Services.startup.shuttingDown) {
            this._maybeReloadEngines(
              Ci.nsISearchService.CHANGE_REASON_LOCALE
            ).catch(console.error);
          }
        });
        break;
      case lazy.Region.REGION_TOPIC:
        lazy.logConsole.debug("Region updated:", lazy.Region.home);
        this._maybeReloadEngines(
          Ci.nsISearchService.CHANGE_REASON_REGION
        ).catch(console.error);
        break;
    }
  }

  /**
   * @param {object} metaData
   *    The metadata object that defines the details of the engine.
   * @returns {boolean}
   *    Returns true if metaData has different property values than
   *    the cached _metaData.
   */
  #didSettingsMetaDataUpdate(metaData) {
    let metaDataProperties = [
      "locale",
      "region",
      "channel",
      "experiment",
      "distroID",
    ];

    return metaDataProperties.some(p => {
      return metaData?.[p] !== this._settings.getMetaDataAttribute(p);
    });
  }

  /**
   * Shows an infobar to notify the user their default search engine has been
   * removed and replaced by a new default search engine.
   *
   * This indirection exists to simplify tests.
   *
   * @param {string} prevCurrentEngineName
   *   The name of the previous default engine that will be replaced.
   * @param {string} newCurrentEngineName
   *   The name of the engine that will be the new default engine.
   */
  _showRemovalOfSearchEngineNotificationBox(
    prevCurrentEngineName,
    newCurrentEngineName
  ) {
    lazy.BrowserUtils.callModulesFromCategory(
      { categoryName: "search-service-notification" },
      "search-engine-removal",
      prevCurrentEngineName,
      newCurrentEngineName
    );
  }

  /**
   * Infobar informing the user that the search settings had to be reset
   * and what their new default engine is.
   *
   * @param {string} newEngine
   *   The name of the new default search engine.
   */
  _showSearchSettingsResetNotificationBox(newEngine) {
    lazy.BrowserUtils.callModulesFromCategory(
      { categoryName: "search-service-notification" },
      "search-settings-reset",
      newEngine
    );
  }

  /**
   * Maybe starts the timer for OpenSearch engine updates. This will be set
   * only if updates are enabled and there are OpenSearch engines installed
   * which have updates.
   */
  #maybeStartOpenSearchUpdateTimer() {
    if (
      this.#openSearchUpdateTimerStarted ||
      !Services.prefs.getBoolPref(
        lazy.SearchUtils.BROWSER_SEARCH_PREF + "update",
        true
      )
    ) {
      return;
    }

    let engineWithUpdates = [...this._engines.values()].some(
      engine => engine instanceof lazy.OpenSearchEngine && engine.hasUpdates
    );

    if (engineWithUpdates) {
      lazy.logConsole.debug("Engine with updates found, setting update timer");
      lazy.timerManager.registerTimer(
        OPENSEARCH_UPDATE_TIMER_TOPIC,
        this,
        OPENSEARCH_UPDATE_TIMER_INTERVAL,
        true
      );
      this.#openSearchUpdateTimerStarted = true;
    }
  }
} // end SearchService class

/**
 * Handles getting and checking extensions against the allow list.
 */
class SearchDefaultOverrideAllowlistHandler {
  constructor() {
    this._remoteConfig = lazy.RemoteSettings(
      lazy.SearchUtils.SETTINGS_ALLOWLIST_KEY
    );
  }

  /**
   * Determines if a search engine extension can override a default one
   * according to the allow list.
   *
   * @param {object} extension
   *   The extension object (from add-on manager) that will override the
   *   app provided search engine.
   * @param {string} appProvidedEngineId
   *   The id of the search engine that will be overriden.
   * @returns {Promise<boolean>}
   *   Returns true if the search engine extension may override the app provided
   *   instance.
   */
  async canOverride(extension, appProvidedEngineId) {
    const overrideTable = await this._getAllowlist();

    let entry = overrideTable.find(e => e.thirdPartyId == extension.id);
    if (!entry) {
      return false;
    }

    if (appProvidedEngineId != entry.overridesAppIdv2) {
      return false;
    }

    let searchProvider =
      extension.manifest.chrome_settings_overrides.search_provider;

    return entry.urls.some(
      e =>
        searchProvider.search_url == e.search_url &&
        searchProvider.search_url_get_params == e.search_url_get_params &&
        searchProvider.search_url_post_params == e.search_url_post_params
    );
  }

  /**
   * Determines if an existing search engine is allowed to override a default one
   * according to the allow list.
   *
   * @param {SearchEngine} engine
   *   The existing search engine.
   * @param {string} appProvidedEngineId
   *   The id of the search engine that will be overriden.
   * @returns {Promise<boolean>}
   *   Returns true if the existing search engine is allowed to override the
   *   app provided instance.
   */
  async canEngineOverride(engine, appProvidedEngineId) {
    const overrideEntries = await this._getAllowlist();

    let entry;

    if (engine instanceof lazy.AddonSearchEngine) {
      entry = overrideEntries.find(e => e.thirdPartyId == engine._extensionID);
    } else if (engine instanceof lazy.OpenSearchEngine) {
      entry = overrideEntries.find(
        e =>
          e.thirdPartyId == "opensearch@search.mozilla.org" &&
          e.engineName == engine.name
      );
    }
    if (!entry) {
      return false;
    }

    if (appProvidedEngineId != entry.overridesAppIdv2) {
      return false;
    }

    return entry.urls.some(urlSet =>
      engine.checkSearchUrlMatchesManifest(urlSet)
    );
  }

  /**
   * 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.
   *
   * @returns {Promise<object[]>}
   *   An array of objects in the database, or an empty array if none
   *   could be obtained.
   */
  async _getAllowlist() {
    let result = [];
    try {
      result = await this._remoteConfig.get();
    } catch (ex) {
      // Don't throw an error just log it, just continue with no data, and hopefully
      // a sync will fix things later on.
      console.error(ex);
    }
    lazy.logConsole.debug("Allow list is:", result);
    return result;
  }
}
PK