/* 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-globals-from extensionControlled.js */
/* import-globals-from preferences.js */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  SearchUIUtils: "resource:///modules/SearchUIUtils.sys.mjs",
  SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
  CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
});

const PREF_URLBAR_QUICKSUGGEST_BLOCKLIST =
  "browser.urlbar.quicksuggest.blockedDigests";
const PREF_URLBAR_WEATHER_USER_ENABLED = "browser.urlbar.suggest.weather";

Preferences.addAll([
  { id: "browser.search.suggest.enabled", type: "bool" },
  { id: "browser.urlbar.suggest.searches", type: "bool" },
  { id: "browser.search.suggest.enabled.private", type: "bool" },
  { id: "browser.urlbar.showSearchSuggestionsFirst", type: "bool" },
  { id: "browser.urlbar.showSearchTerms.enabled", type: "bool" },
  { id: "browser.search.separatePrivateDefault", type: "bool" },
  { id: "browser.search.separatePrivateDefault.ui.enabled", type: "bool" },
  { id: "browser.urlbar.suggest.trending", type: "bool" },
  { id: "browser.urlbar.trending.featureGate", type: "bool" },
  { id: "browser.urlbar.recentsearches.featureGate", type: "bool" },
  { id: "browser.urlbar.suggest.recentsearches", type: "bool" },
  { id: "browser.urlbar.scotchBonnet.enableOverride", type: "bool" },
]);

const ENGINE_FLAVOR = "text/x-moz-search-engine";
const SEARCH_TYPE = "default_search";
const SEARCH_KEY = "defaultSearch";

var gEngineView = null;

var gSearchPane = {
  _engineStore: null,
  _engineDropDown: null,
  _engineDropDownPrivate: null,

  init() {
    this._engineStore = new EngineStore();
    gEngineView = new EngineView(this._engineStore);

    this._engineDropDown = new DefaultEngineDropDown(
      "normal",
      this._engineStore
    );
    this._engineDropDownPrivate = new DefaultEngineDropDown(
      "private",
      this._engineStore
    );

    this._engineStore.init().catch(console.error);

    if (
      Services.policies &&
      !Services.policies.isAllowed("installSearchEngine")
    ) {
      document.getElementById("addEnginesBox").hidden = true;
    } else {
      let addEnginesLink = document.getElementById("addEngines");
      addEnginesLink.setAttribute("href", lazy.SearchUIUtils.searchEnginesURL);
    }

    window.addEventListener("command", this);

    Services.obs.addObserver(this, "browser-search-engine-modified");
    Services.obs.addObserver(this, "intl:app-locales-changed");
    window.addEventListener("unload", () => {
      Services.obs.removeObserver(this, "browser-search-engine-modified");
      Services.obs.removeObserver(this, "intl:app-locales-changed");
    });

    let suggestsPref = Preferences.get("browser.search.suggest.enabled");
    let urlbarSuggestsPref = Preferences.get("browser.urlbar.suggest.searches");
    let privateSuggestsPref = Preferences.get(
      "browser.search.suggest.enabled.private"
    );

    let updateSuggestionCheckboxes =
      this._updateSuggestionCheckboxes.bind(this);
    suggestsPref.on("change", updateSuggestionCheckboxes);
    urlbarSuggestsPref.on("change", updateSuggestionCheckboxes);
    let customizableUIListener = {
      onWidgetAfterDOMChange: node => {
        if (node.id == "search-container") {
          updateSuggestionCheckboxes();
        }
      },
    };
    lazy.CustomizableUI.addListener(customizableUIListener);
    window.addEventListener("unload", () => {
      lazy.CustomizableUI.removeListener(customizableUIListener);
    });

    let urlbarSuggests = document.getElementById("urlBarSuggestion");
    urlbarSuggests.addEventListener("command", () => {
      urlbarSuggestsPref.value = urlbarSuggests.checked;
    });
    let suggestionsInSearchFieldsCheckbox = document.getElementById(
      "suggestionsInSearchFieldsCheckbox"
    );
    // We only want to call _updateSuggestionCheckboxes once after updating
    // all prefs.
    suggestionsInSearchFieldsCheckbox.addEventListener("command", () => {
      this._skipUpdateSuggestionCheckboxesFromPrefChanges = true;
      if (!lazy.CustomizableUI.getPlacementOfWidget("search-container")) {
        urlbarSuggestsPref.value = suggestionsInSearchFieldsCheckbox.checked;
      }
      suggestsPref.value = suggestionsInSearchFieldsCheckbox.checked;
      this._skipUpdateSuggestionCheckboxesFromPrefChanges = false;
      this._updateSuggestionCheckboxes();
    });
    let privateWindowCheckbox = document.getElementById(
      "showSearchSuggestionsPrivateWindows"
    );
    privateWindowCheckbox.addEventListener("command", () => {
      privateSuggestsPref.value = privateWindowCheckbox.checked;
    });

    setEventListener(
      "browserSeparateDefaultEngine",
      "command",
      this._onBrowserSeparateDefaultEngineChange.bind(this)
    );

    this._initDefaultEngines();
    this._initShowSearchTermsCheckbox();
    this._updateSuggestionCheckboxes();
    this._initRecentSeachesCheckbox();
    this._initAddressBar();
  },

  /**
   * Initialize the default engine handling. This will hide the private default
   * options if they are not enabled yet.
   */
  _initDefaultEngines() {
    this._separatePrivateDefaultEnabledPref = Preferences.get(
      "browser.search.separatePrivateDefault.ui.enabled"
    );

    this._separatePrivateDefaultPref = Preferences.get(
      "browser.search.separatePrivateDefault"
    );

    const checkbox = document.getElementById("browserSeparateDefaultEngine");
    checkbox.checked = !this._separatePrivateDefaultPref.value;

    this._updatePrivateEngineDisplayBoxes();

    const listener = () => {
      this._updatePrivateEngineDisplayBoxes();
      this._engineStore.notifyRebuildViews();
    };

    this._separatePrivateDefaultEnabledPref.on("change", listener);
    this._separatePrivateDefaultPref.on("change", listener);
  },

  _initShowSearchTermsCheckbox() {
    let checkbox = document.getElementById("searchShowSearchTermCheckbox");
    let updateCheckboxHidden = () => {
      checkbox.hidden =
        !UrlbarPrefs.get("showSearchTermsFeatureGate") ||
        !!lazy.CustomizableUI.getPlacementOfWidget("search-container");
    };

    // Add observer of CustomizableUI as showSearchTerms checkbox
    // should be hidden while Search Bar is enabled.
    let customizableUIListener = {
      onWidgetAfterDOMChange: node => {
        if (node.id == "search-container") {
          updateCheckboxHidden();
        }
      },
    };
    lazy.CustomizableUI.addListener(customizableUIListener);
    NimbusFeatures.urlbar.onUpdate(updateCheckboxHidden);

    // Fire once to initialize.
    updateCheckboxHidden();

    window.addEventListener("unload", () => {
      NimbusFeatures.urlbar.offUpdate(updateCheckboxHidden);
      lazy.CustomizableUI.removeListener(customizableUIListener);
    });
  },

  _updatePrivateEngineDisplayBoxes() {
    const separateEnabled = this._separatePrivateDefaultEnabledPref.value;
    document.getElementById("browserSeparateDefaultEngine").hidden =
      !separateEnabled;

    const separateDefault = this._separatePrivateDefaultPref.value;

    const vbox = document.getElementById("browserPrivateEngineSelection");
    vbox.hidden = !separateEnabled || !separateDefault;
  },

  _onBrowserSeparateDefaultEngineChange(event) {
    this._separatePrivateDefaultPref.value = !event.target.checked;
  },

  _updateSuggestionCheckboxes() {
    if (this._skipUpdateSuggestionCheckboxesFromPrefChanges) {
      return;
    }
    let suggestsPref = Preferences.get("browser.search.suggest.enabled");
    let permanentPB = Services.prefs.getBoolPref(
      "browser.privatebrowsing.autostart"
    );
    let urlbarSuggests = document.getElementById("urlBarSuggestion");
    let suggestionsInSearchFieldsCheckbox = document.getElementById(
      "suggestionsInSearchFieldsCheckbox"
    );
    let positionCheckbox = document.getElementById(
      "showSearchSuggestionsFirstCheckbox"
    );
    let privateWindowCheckbox = document.getElementById(
      "showSearchSuggestionsPrivateWindows"
    );
    let urlbarSuggestsPref = Preferences.get("browser.urlbar.suggest.searches");
    let searchBarVisible =
      !!lazy.CustomizableUI.getPlacementOfWidget("search-container");

    suggestionsInSearchFieldsCheckbox.checked =
      suggestsPref.value && (searchBarVisible || urlbarSuggestsPref.value);

    urlbarSuggests.disabled = !suggestsPref.value || permanentPB;
    urlbarSuggests.hidden = !searchBarVisible;

    privateWindowCheckbox.disabled = !suggestsPref.value;
    privateWindowCheckbox.checked = Preferences.get(
      "browser.search.suggest.enabled.private"
    ).value;
    if (privateWindowCheckbox.disabled) {
      privateWindowCheckbox.checked = false;
    }

    urlbarSuggests.checked = urlbarSuggestsPref.value;
    if (urlbarSuggests.disabled) {
      urlbarSuggests.checked = false;
    }
    if (urlbarSuggests.checked) {
      positionCheckbox.disabled = false;
      // Update the checked state of the show-suggestions-first checkbox.  Note
      // that this does *not* also update its pref, it only checks the box.
      positionCheckbox.checked = Preferences.get(
        positionCheckbox.getAttribute("preference")
      ).value;
    } else {
      positionCheckbox.disabled = true;
      positionCheckbox.checked = false;
    }
    if (
      suggestionsInSearchFieldsCheckbox.checked &&
      !searchBarVisible &&
      !urlbarSuggests.checked
    ) {
      urlbarSuggestsPref.value = true;
    }

    let permanentPBLabel = document.getElementById(
      "urlBarSuggestionPermanentPBLabel"
    );
    permanentPBLabel.hidden = urlbarSuggests.hidden || !permanentPB;

    this._updateTrendingCheckbox(!suggestsPref.value || permanentPB);
  },

  _initRecentSeachesCheckbox() {
    this._recentSearchesEnabledPref = Preferences.get(
      "browser.urlbar.recentsearches.featureGate"
    );
    let recentSearchesCheckBox = document.getElementById(
      "enableRecentSearches"
    );
    const listener = () => {
      recentSearchesCheckBox.hidden = !this._recentSearchesEnabledPref.value;
    };

    this._recentSearchesEnabledPref.on("change", listener);
    listener();
  },

  async _updateTrendingCheckbox(suggestDisabled) {
    let trendingBox = document.getElementById("showTrendingSuggestionsBox");
    let trendingCheckBox = document.getElementById("showTrendingSuggestions");
    let trendingSupported = (
      await Services.search.getDefault()
    ).supportsResponseType(lazy.SearchUtils.URL_TYPE.TRENDING_JSON);
    trendingBox.hidden = !Preferences.get("browser.urlbar.trending.featureGate")
      .value;
    trendingCheckBox.disabled = suggestDisabled || !trendingSupported;
  },

  // ADDRESS BAR

  /**
   * Initializes the address bar section.
   */
  _initAddressBar() {
    // Update the Firefox Suggest section when its Nimbus config changes.
    let onNimbus = () => this._updateFirefoxSuggestSection();
    NimbusFeatures.urlbar.onUpdate(onNimbus);
    window.addEventListener("unload", () => {
      NimbusFeatures.urlbar.offUpdate(onNimbus);
    });

    // The Firefox Suggest info box potentially needs updating when any of the
    // toggles change.
    let infoBoxPrefs = [
      "browser.urlbar.suggest.quicksuggest.nonsponsored",
      "browser.urlbar.suggest.quicksuggest.sponsored",
      "browser.urlbar.quicksuggest.dataCollection.enabled",
    ];
    for (let pref of infoBoxPrefs) {
      Preferences.get(pref).on("change", () =>
        this._updateFirefoxSuggestInfoBox()
      );
    }

    document.getElementById("clipboardSuggestion").hidden = !UrlbarPrefs.get(
      "clipboard.featureGate"
    );

    this._updateFirefoxSuggestSection(true);
    this._initQuickActionsSection();
  },

  /**
   * Updates the Firefox Suggest section (in the address bar section) depending
   * on whether the user is enrolled in a Firefox Suggest rollout.
   *
   * @param {boolean} [onInit]
   *   Pass true when calling this when initializing the pane.
   */
  _updateFirefoxSuggestSection(onInit = false) {
    let container = document.getElementById("firefoxSuggestContainer");

    if (
      UrlbarPrefs.get("quickSuggestEnabled") &&
      !UrlbarPrefs.get("quickSuggestHideSettingsUI")
    ) {
      // Update the l10n IDs of text elements.
      let l10nIdByElementId = {
        locationBarGroupHeader: "addressbar-header-firefox-suggest",
        locationBarSuggestionLabel: "addressbar-suggest-firefox-suggest",
      };
      for (let [elementId, l10nId] of Object.entries(l10nIdByElementId)) {
        let element = document.getElementById(elementId);
        element.dataset.l10nIdOriginal ??= element.dataset.l10nId;
        element.dataset.l10nId = l10nId;
      }

      // Show the container.
      this._updateFirefoxSuggestInfoBox();

      this._updateDismissedSuggestionsStatus();
      Preferences.get(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST).on("change", () =>
        this._updateDismissedSuggestionsStatus()
      );
      Preferences.get(PREF_URLBAR_WEATHER_USER_ENABLED).on("change", () =>
        this._updateDismissedSuggestionsStatus()
      );
      setEventListener("restoreDismissedSuggestions", "command", () =>
        this.restoreDismissedSuggestions()
      );

      container.removeAttribute("hidden");
    } else if (!onInit) {
      // Firefox Suggest is not enabled. This is the default, so to avoid
      // accidentally messing anything up, only modify the doc if we're being
      // called due to a change in the rollout-enabled status (!onInit).
      container.setAttribute("hidden", "true");
      let elementIds = ["locationBarGroupHeader", "locationBarSuggestionLabel"];
      for (let id of elementIds) {
        let element = document.getElementById(id);
        if (element.dataset.l10nIdOriginal) {
          document.l10n.setAttributes(element, element.dataset.l10nIdOriginal);
          delete element.dataset.l10nIdOriginal;
        }
      }
    }
  },

  /**
   * Updates the Firefox Suggest info box (in the address bar section) depending
   * on the states of the Firefox Suggest toggles.
   */
  _updateFirefoxSuggestInfoBox() {
    let nonsponsored = Preferences.get(
      "browser.urlbar.suggest.quicksuggest.nonsponsored"
    ).value;
    let sponsored = Preferences.get(
      "browser.urlbar.suggest.quicksuggest.sponsored"
    ).value;
    let dataCollection = Preferences.get(
      "browser.urlbar.quicksuggest.dataCollection.enabled"
    ).value;

    // Get the l10n ID of the appropriate text based on the values of the three
    // prefs.
    let l10nId;
    if (nonsponsored && sponsored && dataCollection) {
      l10nId = "addressbar-firefox-suggest-info-all";
    } else if (nonsponsored && sponsored && !dataCollection) {
      l10nId = "addressbar-firefox-suggest-info-nonsponsored-sponsored";
    } else if (nonsponsored && !sponsored && dataCollection) {
      l10nId = "addressbar-firefox-suggest-info-nonsponsored-data";
    } else if (nonsponsored && !sponsored && !dataCollection) {
      l10nId = "addressbar-firefox-suggest-info-nonsponsored";
    } else if (!nonsponsored && sponsored && dataCollection) {
      l10nId = "addressbar-firefox-suggest-info-sponsored-data";
    } else if (!nonsponsored && sponsored && !dataCollection) {
      l10nId = "addressbar-firefox-suggest-info-sponsored";
    } else if (!nonsponsored && !sponsored && dataCollection) {
      l10nId = "addressbar-firefox-suggest-info-data";
    }

    let instance = (this._firefoxSuggestInfoBoxInstance = {});
    let infoBox = document.getElementById("firefoxSuggestInfoBox");
    if (!l10nId) {
      infoBox.hidden = true;
    } else {
      let infoText = document.getElementById("firefoxSuggestInfoText");
      infoText.dataset.l10nId = l10nId;

      // If the info box is currently hidden and we unhide it immediately, it
      // will show its old text until the new text is asyncly fetched and shown.
      // That's ugly, so wait for the fetch to finish before unhiding it.
      document.l10n.translateElements([infoText]).then(() => {
        if (instance == this._firefoxSuggestInfoBoxInstance) {
          infoBox.hidden = false;
        }
      });
    }
  },

  _initQuickActionsSection() {
    let showPref = Preferences.get("browser.urlbar.quickactions.showPrefs");
    let scotchBonnet = Preferences.get(
      "browser.urlbar.scotchBonnet.enableOverride"
    );
    let showQuickActionsGroup = () => {
      document.getElementById("quickActionsBox").hidden = !(
        showPref.value || scotchBonnet.value
      );
    };
    showPref.on("change", showQuickActionsGroup);
    showQuickActionsGroup();
  },

  /**
   * Enables/disables the "Restore" button for dismissed Firefox Suggest
   * suggestions.
   */
  _updateDismissedSuggestionsStatus() {
    document.getElementById("restoreDismissedSuggestions").disabled =
      !Services.prefs.prefHasUserValue(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST) &&
      !(
        Services.prefs.prefHasUserValue(PREF_URLBAR_WEATHER_USER_ENABLED) &&
        !Services.prefs.getBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED)
      );
  },

  /**
   * Restores Firefox Suggest suggestions dismissed by the user.
   */
  restoreDismissedSuggestions() {
    Services.prefs.clearUserPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST);
    Services.prefs.clearUserPref(PREF_URLBAR_WEATHER_USER_ENABLED);
  },

  handleEvent(aEvent) {
    if (aEvent.type != "command") {
      return;
    }
    switch (aEvent.target.id) {
      case "":
        if (aEvent.target.parentNode && aEvent.target.parentNode.parentNode) {
          if (aEvent.target.parentNode.parentNode.id == "defaultEngine") {
            gSearchPane.setDefaultEngine();
          } else if (
            aEvent.target.parentNode.parentNode.id == "defaultPrivateEngine"
          ) {
            gSearchPane.setDefaultPrivateEngine();
          }
        }
        break;
      default:
        gEngineView.handleEvent(aEvent);
    }
  },

  /**
   * Handle when the app locale is changed.
   */
  async appLocalesChanged() {
    await document.l10n.ready;
    await gEngineView.loadL10nNames();
  },

  /**
   * nsIObserver implementation.
   */
  observe(subject, topic, data) {
    switch (topic) {
      case "intl:app-locales-changed": {
        this.appLocalesChanged();
        break;
      }
      case "browser-search-engine-modified": {
        let engine = subject.QueryInterface(Ci.nsISearchEngine);
        switch (data) {
          case "engine-default": {
            // Pass through to the engine store to handle updates.
            this._engineStore.browserSearchEngineModified(engine, data);
            gSearchPane._updateSuggestionCheckboxes();
            break;
          }
          default:
            this._engineStore.browserSearchEngineModified(engine, data);
        }
      }
    }
  },

  showRestoreDefaults(aEnable) {
    document.getElementById("restoreDefaultSearchEngines").disabled = !aEnable;
  },

  async setDefaultEngine() {
    await Services.search.setDefault(
      document.getElementById("defaultEngine").selectedItem.engine,
      Ci.nsISearchService.CHANGE_REASON_USER
    );
    if (ExtensionSettingsStore.getSetting(SEARCH_TYPE, SEARCH_KEY) !== null) {
      ExtensionSettingsStore.select(
        ExtensionSettingsStore.SETTING_USER_SET,
        SEARCH_TYPE,
        SEARCH_KEY
      );
    }
  },

  async setDefaultPrivateEngine() {
    await Services.search.setDefaultPrivate(
      document.getElementById("defaultPrivateEngine").selectedItem.engine,
      Ci.nsISearchService.CHANGE_REASON_USER
    );
  },
};

/**
 * Keeps track of the search engine objects and notifies the views for updates.
 */
class EngineStore {
  /**
   * A list of engines that are currently visible in the UI.
   *
   * @type {Object[]}
   */
  engines = [];

  /**
   * A list of application provided engines used when restoring the list of
   * engines to the default set and order.
   *
   * @type {nsISearchEngine[]}
   */
  #appProvidedEngines = [];

  /**
   * A list of listeners to be notified when the engine list changes.
   *
   * @type {Object[]}
   */
  #listeners = [];

  async init() {
    let visibleEngines = await Services.search.getVisibleEngines();
    for (let engine of visibleEngines) {
      this.addEngine(engine);
    }

    let appProvidedEngines = await Services.search.getAppProvidedEngines();
    this.#appProvidedEngines = appProvidedEngines.map(this._cloneEngine, this);

    this.notifyRowCountChanged(0, visibleEngines.length);

    // check if we need to disable the restore defaults button
    var someHidden = this.#appProvidedEngines.some(e => e.hidden);
    gSearchPane.showRestoreDefaults(someHidden);
  }

  /**
   * Adds a listener to be notified when the engine list changes.
   *
   * @param {object} aListener
   */
  addListener(aListener) {
    this.#listeners.push(aListener);
  }

  /**
   * Notifies all listeners that the engine list has changed and they should
   * rebuild.
   */
  notifyRebuildViews() {
    for (let listener of this.#listeners) {
      try {
        listener.rebuild(this.engines);
      } catch (ex) {
        console.error("Error notifying EngineStore listener", ex);
      }
    }
  }

  /**
   * Notifies all listeners that the number of engines in the list has changed.
   *
   * @param {number} index
   * @param {number} count
   */
  notifyRowCountChanged(index, count) {
    for (let listener of this.#listeners) {
      listener.rowCountChanged(index, count, this.engines);
    }
  }

  /**
   * Notifies all listeners that the default engine has changed.
   *
   * @param {string} type
   * @param {object} engine
   */
  notifyDefaultEngineChanged(type, engine) {
    for (let listener of this.#listeners) {
      if ("defaultEngineChanged" in listener) {
        listener.defaultEngineChanged(type, engine, this.engines);
      }
    }
  }

  notifyEngineIconUpdated(engine) {
    // Check the engine is still in the list.
    let index = this._getIndexForEngine(engine);
    if (index != -1) {
      for (let listener of this.#listeners) {
        listener.engineIconUpdated(index, this.engines);
      }
    }
  }

  _getIndexForEngine(aEngine) {
    return this.engines.indexOf(aEngine);
  }

  _getEngineByName(aName) {
    return this.engines.find(engine => engine.name == aName);
  }

  _cloneEngine(aEngine) {
    var clonedObj = {
      iconURL: null,
    };
    for (let i of ["id", "name", "alias", "hidden"]) {
      clonedObj[i] = aEngine[i];
    }
    clonedObj.originalEngine = aEngine;

    // Trigger getting the iconURL for this engine.
    aEngine.getIconURL().then(iconURL => {
      if (iconURL) {
        clonedObj.iconURL = iconURL;
      } else if (window.devicePixelRatio > 1) {
        clonedObj.iconURL =
          "chrome://browser/skin/search-engine-placeholder@2x.png";
      } else {
        clonedObj.iconURL =
          "chrome://browser/skin/search-engine-placeholder.png";
      }

      this.notifyEngineIconUpdated(clonedObj);
    });

    return clonedObj;
  }

  // Callback for Array's some(). A thisObj must be passed to some()
  _isSameEngine(aEngineClone) {
    return aEngineClone.originalEngine.id == this.originalEngine.id;
  }

  addEngine(aEngine) {
    this.engines.push(this._cloneEngine(aEngine));
  }

  updateEngine(newEngine) {
    let engineToUpdate = this.engines.findIndex(
      e => e.originalEngine.id == newEngine.id
    );
    if (engineToUpdate != -1) {
      this.engines[engineToUpdate] = this._cloneEngine(newEngine);
    }
  }

  moveEngine(aEngine, aNewIndex) {
    if (aNewIndex < 0 || aNewIndex > this.engines.length - 1) {
      throw new Error("ES_moveEngine: invalid aNewIndex!");
    }
    var index = this._getIndexForEngine(aEngine);
    if (index == -1) {
      throw new Error("ES_moveEngine: invalid engine?");
    }

    if (index == aNewIndex) {
      return Promise.resolve();
    } // nothing to do

    // Move the engine in our internal store
    var removedEngine = this.engines.splice(index, 1)[0];
    this.engines.splice(aNewIndex, 0, removedEngine);

    return Services.search.moveEngine(aEngine.originalEngine, aNewIndex);
  }

  removeEngine(aEngine) {
    if (this.engines.length == 1) {
      throw new Error("Cannot remove last engine!");
    }

    let engineName = aEngine.name;
    let index = this.engines.findIndex(element => element.name == engineName);

    if (index == -1) {
      throw new Error("invalid engine?");
    }

    this.engines.splice(index, 1)[0];

    if (aEngine.isAppProvided) {
      gSearchPane.showRestoreDefaults(true);
    }

    this.notifyRowCountChanged(index, -1);

    document.getElementById("engineList").focus();
  }

  /**
   * Update the default engine UI and engine tree view as appropriate when engine changes
   * or locale changes occur.
   *
   * @param {nsISearchEngine} engine
   * @param {string} data
   */
  browserSearchEngineModified(engine, data) {
    engine.QueryInterface(Ci.nsISearchEngine);
    switch (data) {
      case "engine-added":
        this.addEngine(engine);
        this.notifyRowCountChanged(gEngineView.lastEngineIndex, 1);
        break;
      case "engine-changed":
      case "engine-icon-changed":
        this.updateEngine(engine);
        this.notifyRebuildViews();
        break;
      case "engine-removed":
        this.removeEngine(engine);
        break;
      case "engine-default":
        this.notifyDefaultEngineChanged("normal", engine);
        break;
      case "engine-default-private":
        this.notifyDefaultEngineChanged("private", engine);
        break;
    }
  }

  async restoreDefaultEngines() {
    var added = 0;

    for (var i = 0; i < this.#appProvidedEngines.length; ++i) {
      var e = this.#appProvidedEngines[i];

      // If the engine is already in the list, just move it.
      if (this.engines.some(this._isSameEngine, e)) {
        await this.moveEngine(this._getEngineByName(e.name), i);
      } else {
        // Otherwise, add it back to our internal store

        // The search service removes the alias when an engine is hidden,
        // so clear any alias we may have cached before unhiding the engine.
        e.alias = "";

        this.engines.splice(i, 0, e);
        let engine = e.originalEngine;
        engine.hidden = false;
        await Services.search.moveEngine(engine, i);
        added++;
      }
    }

    // We can't do this as part of the loop above because the indices are
    // used for moving engines.
    let policyRemovedEngineNames =
      Services.policies.getActivePolicies()?.SearchEngines?.Remove || [];
    for (let engineName of policyRemovedEngineNames) {
      let engine = Services.search.getEngineByName(engineName);
      if (engine) {
        try {
          await Services.search.removeEngine(engine);
        } catch (ex) {
          // Engine might not exist
        }
      }
    }

    Services.search.resetToAppDefaultEngine();
    gSearchPane.showRestoreDefaults(false);
    this.notifyRebuildViews();
    return added;
  }

  changeEngine(aEngine, aProp, aNewValue) {
    var index = this._getIndexForEngine(aEngine);
    if (index == -1) {
      throw new Error("invalid engine?");
    }

    this.engines[index][aProp] = aNewValue;
    aEngine.originalEngine[aProp] = aNewValue;
  }
}

/**
 * Manages the view of the Search Shortcuts tree on the search pane of preferences.
 */
class EngineView {
  _engineStore = null;
  _engineList = null;
  tree = null;

  constructor(aEngineStore) {
    this._engineStore = aEngineStore;
    this._engineList = document.getElementById("engineList");
    this._engineList.view = this;

    UrlbarPrefs.addObserver(this);
    aEngineStore.addListener(this);

    this.loadL10nNames();
    this.#addListeners();
    this.#showAddEngineButton();
  }

  async loadL10nNames() {
    // This maps local shortcut sources to their l10n names.  The names are needed
    // by getCellText.  Getting the names is async but getCellText is not, so we
    // cache them here to retrieve them syncronously in getCellText.
    this._localShortcutL10nNames = new Map();

    let getIDs = (suffix = "") =>
      UrlbarUtils.LOCAL_SEARCH_MODES.map(mode => {
        let name = UrlbarUtils.getResultSourceName(mode.source);
        return { id: `urlbar-search-mode-${name}${suffix}` };
      });

    try {
      let localizedIDs = getIDs();
      let englishIDs = getIDs("-en");

      let englishSearchStrings = new Localization([
        "preview/enUS-searchFeatures.ftl",
      ]);
      let localizedNames = await document.l10n.formatValues(localizedIDs);
      let englishNames = await englishSearchStrings.formatValues(englishIDs);

      UrlbarUtils.LOCAL_SEARCH_MODES.forEach(({ source }, index) => {
        let localizedName = localizedNames[index];
        let englishName = englishNames[index];

        // Add only the English name if localized and English are the same
        let names =
          localizedName === englishName
            ? [englishName]
            : [localizedName, englishName];

        this._localShortcutL10nNames.set(source, names);

        // Invalidate the tree now that we have the names in case getCellText was
        // called before name retrieval finished.
        this.invalidate();
      });
    } catch (ex) {
      console.error("Error loading l10n names", ex);
    }
  }

  #addListeners() {
    this._engineList.addEventListener("click", this);
    this._engineList.addEventListener("dragstart", this);
    this._engineList.addEventListener("keypress", this);
    this._engineList.addEventListener("select", this);
    this._engineList.addEventListener("dblclick", this);
  }

  /**
   * Shows the "Add Search Engine" button if the pref is enabled.
   */
  #showAddEngineButton() {
    let aliasRefresh = Services.prefs.getBoolPref(
      "browser.urlbar.update2.engineAliasRefresh",
      false
    );
    if (aliasRefresh) {
      let addButton = document.getElementById("addEngineButton");
      addButton.hidden = false;
    }
  }

  get lastEngineIndex() {
    return this._engineStore.engines.length - 1;
  }

  get selectedIndex() {
    var seln = this.selection;
    if (seln.getRangeCount() > 0) {
      var min = {};
      seln.getRangeAt(0, min, {});
      return min.value;
    }
    return -1;
  }

  get selectedEngine() {
    return this._engineStore.engines[this.selectedIndex];
  }

  // Helpers
  rebuild() {
    this.invalidate();
  }

  rowCountChanged(index, count) {
    if (!this.tree) {
      return;
    }
    this.tree.rowCountChanged(index, count);

    // If we're removing elements, ensure that we still have a selection.
    if (count < 0) {
      this.selection.select(Math.min(index, this.rowCount - 1));
      this.ensureRowIsVisible(this.currentIndex);
    }
  }

  engineIconUpdated(index) {
    this.tree?.invalidateCell(
      index,
      this.tree.columns.getNamedColumn("engineName")
    );
  }

  invalidate() {
    this.tree?.invalidate();
  }

  ensureRowIsVisible(index) {
    this.tree.ensureRowIsVisible(index);
  }

  getSourceIndexFromDrag(dataTransfer) {
    return parseInt(dataTransfer.getData(ENGINE_FLAVOR));
  }

  isCheckBox(index, column) {
    return column.id == "engineShown";
  }

  isEngineSelectedAndRemovable() {
    let defaultEngine = Services.search.defaultEngine;
    let defaultPrivateEngine = Services.search.defaultPrivateEngine;
    // We don't allow the last remaining engine to be removed, thus the
    // `this.lastEngineIndex != 0` check.
    // We don't allow the default engine to be removed.
    return (
      this.selectedIndex != -1 &&
      this.lastEngineIndex != 0 &&
      !this._getLocalShortcut(this.selectedIndex) &&
      this.selectedEngine.name != defaultEngine.name &&
      this.selectedEngine.name != defaultPrivateEngine.name
    );
  }

  /**
   * Returns the local shortcut corresponding to a tree row, or null if the row
   * is not a local shortcut.
   *
   * @param {number} index
   *   The tree row index.
   * @returns {object}
   *   The local shortcut object or null if the row is not a local shortcut.
   */
  _getLocalShortcut(index) {
    let engineCount = this._engineStore.engines.length;
    if (index < engineCount) {
      return null;
    }
    return UrlbarUtils.LOCAL_SEARCH_MODES[index - engineCount];
  }

  /**
   * Called by UrlbarPrefs when a urlbar pref changes.
   *
   * @param {string} pref
   *   The name of the pref relative to the browser.urlbar branch.
   */
  onPrefChanged(pref) {
    // If one of the local shortcut prefs was toggled, toggle its row's
    // checkbox.
    let parts = pref.split(".");
    if (parts[0] == "shortcuts" && parts[1] && parts.length == 2) {
      this.invalidate();
    }
  }

  handleEvent(aEvent) {
    switch (aEvent.type) {
      case "dblclick":
        if (aEvent.target.id == "engineChildren") {
          let cell = aEvent.target.parentNode.getCellAt(
            aEvent.clientX,
            aEvent.clientY
          );
          if (cell.col?.id == "engineKeyword") {
            this.#startEditingAlias(this.selectedIndex);
          }
        }
        break;
      case "click":
        if (
          aEvent.target.id != "engineChildren" &&
          !aEvent.target.classList.contains("searchEngineAction")
        ) {
          // We don't want to toggle off selection while editing keyword
          // so proceed only when the input field is hidden.
          // We need to check that engineList.view is defined here
          // because the "click" event listener is on <window> and the
          // view might have been destroyed if the pane has been navigated
          // away from.
          if (this._engineList.inputField.hidden && this._engineList.view) {
            let selection = this._engineList.view.selection;
            if (selection?.count > 0) {
              selection.toggleSelect(selection.currentIndex);
            }
            this._engineList.blur();
          }
        }
        break;
      case "command":
        switch (aEvent.target.id) {
          case "restoreDefaultSearchEngines":
            this.#onRestoreDefaults();
            break;
          case "removeEngineButton":
            Services.search.removeEngine(this.selectedEngine.originalEngine);
            break;
          case "addEngineButton":
            gSubDialog.open(
              "chrome://browser/content/preferences/dialogs/addEngine.xhtml",
              { features: "resizable=no, modal=yes" }
            );
            break;
        }
        break;
      case "dragstart":
        if (aEvent.target.id == "engineChildren") {
          this.#onDragEngineStart(aEvent);
        }
        break;
      case "keypress":
        if (aEvent.target.id == "engineList") {
          this.#onTreeKeyPress(aEvent);
        }
        break;
      case "select":
        if (aEvent.target.id == "engineList") {
          this.#onTreeSelect();
        }
        break;
    }
  }

  /**
   * Called when the restore default engines button is clicked to reset the
   * list of engines to their defaults.
   */
  async #onRestoreDefaults() {
    let num = await this._engineStore.restoreDefaultEngines();
    this.rowCountChanged(0, num);
  }

  #onDragEngineStart(event) {
    let selectedIndex = this.selectedIndex;

    // Local shortcut rows can't be dragged or re-ordered.
    if (this._getLocalShortcut(selectedIndex)) {
      event.preventDefault();
      return;
    }

    let tree = document.getElementById("engineList");
    let cell = tree.getCellAt(event.clientX, event.clientY);
    if (selectedIndex >= 0 && !this.isCheckBox(cell.row, cell.col)) {
      event.dataTransfer.setData(ENGINE_FLAVOR, selectedIndex.toString());
      event.dataTransfer.effectAllowed = "move";
    }
  }

  #onTreeSelect() {
    document.getElementById("removeEngineButton").disabled =
      !this.isEngineSelectedAndRemovable();
  }

  #onTreeKeyPress(aEvent) {
    let index = this.selectedIndex;
    let tree = document.getElementById("engineList");
    if (tree.hasAttribute("editing")) {
      return;
    }

    if (aEvent.charCode == KeyEvent.DOM_VK_SPACE) {
      // Space toggles the checkbox.
      let newValue = !this.getCellValue(
        index,
        tree.columns.getNamedColumn("engineShown")
      );
      this.setCellValue(
        index,
        tree.columns.getFirstColumn(),
        newValue.toString()
      );
      // Prevent page from scrolling on the space key.
      aEvent.preventDefault();
    } else {
      let isMac = Services.appinfo.OS == "Darwin";
      if (
        (isMac && aEvent.keyCode == KeyEvent.DOM_VK_RETURN) ||
        (!isMac && aEvent.keyCode == KeyEvent.DOM_VK_F2)
      ) {
        this.#startEditingAlias(index);
      } else if (
        aEvent.keyCode == KeyEvent.DOM_VK_DELETE ||
        (isMac &&
          aEvent.shiftKey &&
          aEvent.keyCode == KeyEvent.DOM_VK_BACK_SPACE &&
          this.isEngineSelectedAndRemovable())
      ) {
        // Delete and Shift+Backspace (Mac) removes selected engine.
        Services.search.removeEngine(this.selectedEngine.originalEngine);
      }
    }
  }

  /**
   * Triggers editing of an alias in the tree.
   *
   * @param {number} index
   */
  #startEditingAlias(index) {
    // Local shortcut aliases can't be edited.
    if (this._getLocalShortcut(index)) {
      return;
    }

    let tree = document.getElementById("engineList");
    let engine = this._engineStore.engines[index];
    tree.startEditing(index, tree.columns.getLastColumn());
    tree.inputField.value = engine.alias || "";
    tree.inputField.select();
  }

  // nsITreeView
  get rowCount() {
    let localModes = UrlbarUtils.LOCAL_SEARCH_MODES;
    if (!lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
      localModes = localModes.filter(
        mode => mode.source != UrlbarUtils.RESULT_SOURCE.ACTIONS
      );
    }
    return this._engineStore.engines.length + localModes.length;
  }

  getImageSrc(index, column) {
    if (column.id == "engineName") {
      let shortcut = this._getLocalShortcut(index);
      if (shortcut) {
        return shortcut.icon;
      }

      return this._engineStore.engines[index].iconURL;
    }

    return "";
  }

  getCellText(index, column) {
    if (column.id == "engineName") {
      let shortcut = this._getLocalShortcut(index);
      if (shortcut) {
        return this._localShortcutL10nNames.get(shortcut.source)[0] || "";
      }
      return this._engineStore.engines[index].name;
    } else if (column.id == "engineKeyword") {
      let shortcut = this._getLocalShortcut(index);
      if (shortcut) {
        if (
          lazy.UrlbarPrefs.getScotchBonnetPref(
            "searchRestrictKeywords.featureGate"
          )
        ) {
          let keywords = this._localShortcutL10nNames
            .get(shortcut.source)
            .map(keyword => `@${keyword.toLowerCase()}`)
            .join(", ");

          return `${keywords}, ${shortcut.restrict}`;
        }

        return shortcut.restrict;
      }
      return this._engineStore.engines[index].originalEngine.aliases.join(", ");
    }
    return "";
  }

  setTree(tree) {
    this.tree = tree;
  }

  canDrop(targetIndex, orientation, dataTransfer) {
    var sourceIndex = this.getSourceIndexFromDrag(dataTransfer);
    return (
      sourceIndex != -1 &&
      sourceIndex != targetIndex &&
      sourceIndex != targetIndex + orientation &&
      // Local shortcut rows can't be dragged or dropped on.
      targetIndex < this._engineStore.engines.length
    );
  }

  async drop(dropIndex, orientation, dataTransfer) {
    // Local shortcut rows can't be dragged or dropped on.  This can sometimes
    // be reached even though canDrop returns false for these rows.
    if (this._engineStore.engines.length <= dropIndex) {
      return;
    }

    var sourceIndex = this.getSourceIndexFromDrag(dataTransfer);
    var sourceEngine = this._engineStore.engines[sourceIndex];

    const nsITreeView = Ci.nsITreeView;
    if (dropIndex > sourceIndex) {
      if (orientation == nsITreeView.DROP_BEFORE) {
        dropIndex--;
      }
    } else if (orientation == nsITreeView.DROP_AFTER) {
      dropIndex++;
    }

    await this._engineStore.moveEngine(sourceEngine, dropIndex);
    gSearchPane.showRestoreDefaults(true);

    // Redraw, and adjust selection
    this.invalidate();
    this.selection.select(dropIndex);
  }

  selection = null;
  getRowProperties() {
    return "";
  }
  getCellProperties(index, column) {
    if (column.id == "engineName") {
      // For local shortcut rows, return the result source name so we can style
      // the icons in CSS.
      let shortcut = this._getLocalShortcut(index);
      if (shortcut) {
        return UrlbarUtils.getResultSourceName(shortcut.source);
      }
    }
    return "";
  }
  getColumnProperties() {
    return "";
  }
  isContainer() {
    return false;
  }
  isContainerOpen() {
    return false;
  }
  isContainerEmpty() {
    return false;
  }
  isSeparator() {
    return false;
  }
  isSorted() {
    return false;
  }
  getParentIndex() {
    return -1;
  }
  hasNextSibling() {
    return false;
  }
  getLevel() {
    return 0;
  }
  getCellValue(index, column) {
    if (column.id == "engineShown") {
      let shortcut = this._getLocalShortcut(index);
      if (shortcut) {
        return UrlbarPrefs.get(shortcut.pref);
      }
      return !this._engineStore.engines[index].originalEngine.hideOneOffButton;
    }
    return undefined;
  }
  toggleOpenState() {}
  cycleHeader() {}
  selectionChanged() {}
  cycleCell() {}
  isEditable(index, column) {
    return (
      column.id != "engineName" &&
      (column.id == "engineShown" || !this._getLocalShortcut(index))
    );
  }
  setCellValue(index, column, value) {
    if (column.id == "engineShown") {
      let shortcut = this._getLocalShortcut(index);
      if (shortcut) {
        UrlbarPrefs.set(shortcut.pref, value == "true");
        this.invalidate();
        return;
      }
      this._engineStore.engines[index].originalEngine.hideOneOffButton =
        value != "true";
      this.invalidate();
    }
  }
  setCellText(index, column, value) {
    if (column.id == "engineKeyword") {
      this.#changeKeyword(this._engineStore.engines[index], value).then(
        valid => {
          if (!valid) {
            this.#startEditingAlias(index);
          }
        }
      );
    }
  }

  /**
   * Handles changing the keyword for an engine. This will check for potentially
   * duplicate keywords and prompt the user if necessary.
   *
   * @param {object} aEngine
   *   The engine to change.
   * @param {string} aNewKeyword
   *   The new keyword.
   * @returns {Promise<boolean>}
   *   Resolves to true if the keyword was changed.
   */
  async #changeKeyword(aEngine, aNewKeyword) {
    let keyword = aNewKeyword.trim();
    if (keyword) {
      let eduplicate = false;
      let dupName = "";

      // Check for duplicates in Places keywords.
      let bduplicate = !!(await PlacesUtils.keywords.fetch(keyword));

      // Check for duplicates in changes we haven't committed yet
      let engines = this._engineStore.engines;
      let lc_keyword = keyword.toLocaleLowerCase();
      for (let engine of engines) {
        if (
          engine.alias &&
          engine.alias.toLocaleLowerCase() == lc_keyword &&
          engine.name != aEngine.name
        ) {
          eduplicate = true;
          dupName = engine.name;
          break;
        }
      }

      // Notify the user if they have chosen an existing engine/bookmark keyword
      if (eduplicate || bduplicate) {
        let msgids = [{ id: "search-keyword-warning-title" }];
        if (eduplicate) {
          msgids.push({
            id: "search-keyword-warning-engine",
            args: { name: dupName },
          });
        } else {
          msgids.push({ id: "search-keyword-warning-bookmark" });
        }

        let [dtitle, msg] = await document.l10n.formatValues(msgids);

        Services.prompt.alert(window, dtitle, msg);
        return false;
      }
    }

    this._engineStore.changeEngine(aEngine, "alias", keyword);
    this.invalidate();
    return true;
  }
}

/**
 * Manages the default engine dropdown buttons in the search pane of preferences.
 */
class DefaultEngineDropDown {
  #element = null;
  #type = null;

  constructor(type, engineStore) {
    this.#type = type;
    this.#element = document.getElementById(
      type == "private" ? "defaultPrivateEngine" : "defaultEngine"
    );

    engineStore.addListener(this);
  }

  rowCountChanged(index, count, enginesList) {
    // Simply rebuild the menulist, rather than trying to update the changed row.
    this.rebuild(enginesList);
  }

  defaultEngineChanged(type, engine, enginesList) {
    if (type != this.#type) {
      return;
    }
    // If the user is going through the drop down using up/down keys, the
    // dropdown may still be open (eg. on Windows) when engine-default is
    // fired, so rebuilding the list unconditionally would get in the way.
    let selectedEngineName = this.#element.selectedItem?.engine?.name;
    if (selectedEngineName != engine.name) {
      this.rebuild(enginesList);
    }
  }

  engineIconUpdated(index, enginesList) {
    let item = this.#element.getItemAtIndex(index);
    // Check this is the right item.
    if (item?.label == enginesList[index].name) {
      item.setAttribute("image", enginesList[index].iconURL);
    }
  }

  async rebuild(enginesList) {
    if (
      this.#type == "private" &&
      !gSearchPane._separatePrivateDefaultPref.value
    ) {
      return;
    }
    let defaultEngine =
      await Services.search[
        this.#type == "normal" ? "getDefault" : "getDefaultPrivate"
      ]();

    this.#element.removeAllItems();
    for (let engine of enginesList) {
      let item = this.#element.appendItem(engine.name);
      item.setAttribute(
        "class",
        "menuitem-iconic searchengine-menuitem menuitem-with-favicon"
      );
      if (engine.iconURL) {
        item.setAttribute("image", engine.iconURL);
      }
      item.engine = engine;
      if (engine.name == defaultEngine.name) {
        this.#element.selectedItem = item;
      }
    }
    // This should never happen, but try and make sure we have at least one
    // selected item.
    if (!this.#element.selectedItem) {
      this.#element.selectedIndex = 0;
    }
  }
}
