/* 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/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  OpenSearchManager:
    "moz-src:///browser/components/search/OpenSearchManager.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  SearchUIUtils: "moz-src:///browser/components/search/SearchUIUtils.sys.mjs",
});

/**
 * @import {UrlbarUtils} from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"
 */

/**
 * @typedef {object} LegacySearchButton
 * @property {boolean} open
 *   Whether the button is in an open state.
 * @property {Values<typeof UrlbarUtils.RESULT_SOURCE>} [source]
 *   The result source of the button. Only appropriate for one-off buttons
 *   on the urlbar.
 * @property {nsISearchEngine} engine
 *   The search engine associated with the button.
 */

/**
 *  A XULElement augmented at runtime with additional properties.
 *
 *  @typedef {XULElement & LegacySearchButton} LegacySearchOneOffButton
 */

/**
 * Defines the search one-off button elements. These are displayed at the bottom
 * of the address bar and search bar. The address bar buttons are a subclass in
 * browser/components/urlbar/UrlbarSearchOneOffs.sys.mjs. If you are adding a new
 * subclass, see "Methods for subclasses to override" below.
 */
export class SearchOneOffs {
  constructor(container) {
    this.container = container;
    this.window = container.ownerGlobal;
    this.document = container.ownerDocument;

    this.container.appendChild(
      this.window.MozXULElement.parseXULToFragment(
        `
      <hbox class="search-panel-one-offs-header search-panel-header">
        <label class="search-panel-one-offs-header-label" data-l10n-id="search-one-offs-with-title"/>
      </hbox>
      <box class="search-panel-one-offs-container">
        <hbox class="search-panel-one-offs" role="group"/>
        <button class="searchbar-engine-one-off-item search-setting-button" tabindex="-1" data-l10n-id="search-one-offs-change-settings-compact-button"/>
      </box>
      <box>
        <menupopup class="search-one-offs-context-menu">
          <menuitem class="search-one-offs-context-open-in-new-tab" data-l10n-id="search-one-offs-context-open-new-tab"/>
          <menuitem class="search-one-offs-context-set-default" data-l10n-id="search-one-offs-context-set-as-default"/>
          <menuitem class="search-one-offs-context-set-default-private" data-l10n-id="search-one-offs-context-set-as-default-private"/>
        </menupopup>
      </box>
      `
      )
    );

    this._popup = null;
    this._textbox = null;

    this._textboxWidth = 0;

    /**
     * Set this to a string that identifies your one-offs consumer.  It'll
     * be appended to telemetry recorded with maybeRecordTelemetry().
     */
    this.telemetryOrigin = "";

    this._query = "";

    this._selectedButton = null;

    this.buttons = this.querySelector(".search-panel-one-offs");

    this.header = this.querySelector(".search-panel-one-offs-header");

    this.settingsButton = this.querySelector(".search-setting-button");

    this.contextMenuPopup = this.querySelector(".search-one-offs-context-menu");

    this._engineInfo = null;

    /**
     * `_rebuild()` is async, because it queries the Search Service, which means
     * there is a potential for a race when it's called multiple times in succession.
     */
    this._rebuilding = false;

    this.addEventListener("mousedown", this);
    this.addEventListener("click", this);
    this.addEventListener("command", this);
    this.addEventListener("contextmenu", this);

    // Prevent popup events from the context menu from reaching the autocomplete
    // binding (or other listeners).
    let listener = aEvent => aEvent.stopPropagation();
    this.contextMenuPopup.addEventListener("popupshowing", listener);
    this.contextMenuPopup.addEventListener("popuphiding", listener);
    this.contextMenuPopup.addEventListener("popupshown", aEvent => {
      aEvent.stopPropagation();
    });
    this.contextMenuPopup.addEventListener("popuphidden", aEvent => {
      aEvent.stopPropagation();
    });

    // Add weak referenced observers to invalidate our cached list of engines.
    this.QueryInterface = ChromeUtils.generateQI([
      "nsIObserver",
      "nsISupportsWeakReference",
    ]);
    Services.obs.addObserver(this, "browser-search-engine-modified", true);
    Services.obs.addObserver(this, "browser-search-service", true);

    // Rebuild the buttons when the theme changes.  See bug 1357800 for
    // details.  Summary: On Linux, switching between themes can cause a row
    // of buttons to disappear.
    Services.obs.addObserver(this, "lightweight-theme-changed", true);

    // This defaults to false in the Search Bar, subclasses can change their
    // default in the constructor.
    this.disableOneOffsHorizontalKeyNavigation = false;
  }

  addEventListener(...args) {
    this.container.addEventListener(...args);
  }

  removeEventListener(...args) {
    this.container.removeEventListener(...args);
  }

  dispatchEvent(...args) {
    this.container.dispatchEvent(...args);
  }

  getAttribute(...args) {
    return this.container.getAttribute(...args);
  }

  hasAttribute(...args) {
    return this.container.hasAttribute(...args);
  }

  setAttribute(...args) {
    this.container.setAttribute(...args);
  }

  querySelector(...args) {
    return this.container.querySelector(...args);
  }

  handleEvent(event) {
    let methodName = "_on_" + event.type;
    if (methodName in this) {
      this[methodName](event);
    } else {
      throw new Error("Unrecognized search-one-offs event: " + event.type);
    }
  }

  /**
   * @returns {Promise<boolean>}
   *   True if we will hide the one-offs when they are requested.
   */
  async willHide() {
    if (this._engineInfo?.willHide !== undefined) {
      return this._engineInfo.willHide;
    }
    let engineInfo = await this.getEngineInfo();
    let oneOffCount = engineInfo.engines.length;
    this._engineInfo.willHide =
      !oneOffCount ||
      (oneOffCount == 1 &&
        engineInfo.engines[0].name == engineInfo.default.name);
    return this._engineInfo.willHide;
  }

  /**
   * Invalidates the engine cache. After invalidating the cache, the one-offs
   * will be rebuilt the next time they are shown.
   */
  invalidateCache() {
    if (!this._rebuilding) {
      this._engineInfo = null;
    }
  }

  /**
   * Width in pixels of the one-off buttons.
   * NOTE: Used in browser/components/search/content/searchbar.js only.
   *
   * @returns {number}
   */
  get buttonWidth() {
    return 48;
  }

  /**
   * The popup that contains the one-offs.
   *
   * @param {XULPopupElement} val
   *        The new value to set.
   */
  set popup(val) {
    if (this._popup) {
      this._popup.removeEventListener("popupshowing", this);
      this._popup.removeEventListener("popuphidden", this);
    }
    if (val) {
      val.addEventListener("popupshowing", this);
      val.addEventListener("popuphidden", this);
    }
    this._popup = val;

    // If the popup is already open, rebuild the one-offs now.  The
    // popup may be opening, so check that the state is not closed
    // instead of checking popupOpen.
    if (val && val.state != "closed") {
      this._rebuild();
    }
  }

  get popup() {
    return this._popup;
  }

  /**
   * The textbox associated with the one-offs.  Set this to a textbox to
   * automatically keep the related one-offs UI up to date.  Otherwise you
   * can leave it null/undefined, and in that case you should update the
   * query property manually.
   *
   * @param {HTMLInputElement} val
   *        The new value to set.
   */
  set textbox(val) {
    if (this._textbox) {
      this._textbox.removeEventListener("input", this);
    }
    if (val) {
      val.addEventListener("input", this);
    }
    this._textbox = val;
  }

  get style() {
    return this.container.style;
  }

  get textbox() {
    return this._textbox;
  }

  /**
   * The query string currently shown in the one-offs.  If the textbox
   * property is non-null, then this is automatically updated on
   * input.
   *
   * @param {string} val
   *        The new query string to set.
   */
  set query(val) {
    this._query = val;
    if (this.isViewOpen) {
      let isOneOffSelected =
        this.selectedButton &&
        this.selectedButton.classList.contains(
          "searchbar-engine-one-off-item"
        ) &&
        !(
          this.selectedButton == this.settingsButton &&
          this.hasAttribute("is_searchbar")
        );
      // Typing de-selects the settings or opensearch buttons at the bottom
      // of the search panel, as typing shows the user intends to search.
      if (this.selectedButton && !isOneOffSelected) {
        this.selectedButton = null;
      }
    }
  }

  get query() {
    return this._query;
  }

  /**
   * The selected one-off including the add-engine button
   * and the search-settings button.
   *
   * @param {LegacySearchOneOffButton|null} val
   *        The selected one-off button. Null if no one-off is selected.
   */
  set selectedButton(val) {
    let previousButton = this._selectedButton;
    if (previousButton) {
      previousButton.removeAttribute("selected");
    }
    if (val) {
      val.toggleAttribute("selected", true);
    }
    this._selectedButton = val;

    if (this.textbox) {
      if (val) {
        this.textbox.setAttribute("aria-activedescendant", val.id);
      } else {
        let active = this.textbox.getAttribute("aria-activedescendant");
        if (active && active.includes("-engine-one-off-item-")) {
          this.textbox.removeAttribute("aria-activedescendant");
        }
      }
    }

    this.dispatchEvent(new CustomEvent("SelectedOneOffButtonChanged"));
  }

  get selectedButton() {
    return this._selectedButton;
  }

  /**
   * The index of the selected one-off, including the add-engine button
   * and the search-settings button.
   *
   * @param {number} val
   *        The new index to set, -1 for nothing selected.
   */
  set selectedButtonIndex(val) {
    let buttons = this.getSelectableButtons(true);
    this.selectedButton = buttons[val];
  }

  get selectedButtonIndex() {
    let buttons = this.getSelectableButtons(true);
    for (let i = 0; i < buttons.length; i++) {
      if (buttons[i] == this._selectedButton) {
        return i;
      }
    }
    return -1;
  }

  async getEngineInfo() {
    if (this._engineInfo) {
      return this._engineInfo;
    }

    this._engineInfo = {};
    if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) {
      this._engineInfo.default = await Services.search.getDefaultPrivate();
    } else {
      this._engineInfo.default = await Services.search.getDefault();
    }

    let currentEngineNameToIgnore;
    if (!this.getAttribute("includecurrentengine")) {
      currentEngineNameToIgnore = this._engineInfo.default.name;
    }

    this._engineInfo.engines = (
      await Services.search.getVisibleEngines()
    ).filter(e => {
      let name = e.name;
      return (
        (!currentEngineNameToIgnore || name != currentEngineNameToIgnore) &&
        !e.hideOneOffButton
      );
    });

    return this._engineInfo;
  }

  observe(aEngine, aTopic, aData) {
    // For the "browser-search-service" topic, we only need to invalidate
    // the cache on initialization complete or when the engines are reloaded.
    if (aTopic != "browser-search-service" || aData == "engines-reloaded") {
      // Make sure the engine list was updated.
      this.invalidateCache();
    }

    if (aData === "engine-icon-changed") {
      aEngine.getIconURL().then(icon => {
        this.getSelectableButtons(false)
          .find(b => b.engine?.id == aEngine.id)
          ?.setAttribute(
            "image",
            icon || "chrome://browser/skin/search-engine-placeholder.png"
          );
      });
    }
  }

  get _maxInlineAddEngines() {
    return 3;
  }

  /**
   * Infallible, non-re-entrant version of `__rebuild()`.
   */
  async _rebuild() {
    if (this._rebuilding) {
      return;
    }

    this._rebuilding = true;
    try {
      await this.__rebuild();
    } catch (ex) {
      console.error("Search-one-offs::_rebuild() error:", ex);
    } finally {
      this._rebuilding = false;
      this.dispatchEvent(new Event("rebuild"));
    }
  }

  /**
   * Builds all the UI.
   */
  async __rebuild() {
    // Return early if the list of engines has not changed.
    if (!this.popup && this._engineInfo?.domWasUpdated) {
      return;
    }

    const addEngines = lazy.OpenSearchManager.getEngines(
      this.window.gBrowser.selectedBrowser
    );

    // Return early if the engines and panel width have not changed.
    if (this.popup && this._textbox) {
      let textboxWidth = await this.window.promiseDocumentFlushed(() => {
        return this._textbox.clientWidth;
      });

      if (
        this._engineInfo?.domWasUpdated &&
        this._textboxWidth == textboxWidth &&
        this._addEngines == addEngines
      ) {
        return;
      }
      this._textboxWidth = textboxWidth;
      this._addEngines = addEngines;
    }

    const isSearchBar = this.hasAttribute("is_searchbar");
    if (isSearchBar) {
      // Hide the container during updating to avoid flickering.
      this.container.hidden = true;
    }

    // Finally, build the list of one-off buttons.
    while (this.buttons.firstElementChild) {
      this.buttons.firstElementChild.remove();
    }

    let headerText = this.header.querySelector(
      ".search-panel-one-offs-header-label"
    );
    headerText.id = this.telemetryOrigin + "-one-offs-header-label";
    this.buttons.setAttribute("aria-labelledby", headerText.id);

    // For the search-bar, always show the one-off buttons where there is an
    // option to add an engine.
    let addEngineNeeded = isSearchBar && addEngines.length;
    let hideOneOffs = (await this.willHide()) && !addEngineNeeded;

    // The _engineInfo cache is used by more consumers, thus it is not a good
    // representation of whether this method already updated the one-off buttons
    // DOM. For this reason we introduce a separate flag tracking the DOM
    // updating, and use it to know when it's okay to not rebuild the one-offs.
    // We set this early, since we might either rebuild the DOM or hide it.
    this._engineInfo.domWasUpdated = true;

    this.container.hidden = hideOneOffs;

    if (hideOneOffs) {
      return;
    }

    // Ensure we can refer to the settings buttons by ID:
    let origin = this.telemetryOrigin;
    this.settingsButton.id = origin + "-anon-search-settings";

    let engines = (await this.getEngineInfo()).engines;
    await this._rebuildEngineList(engines, addEngines);
  }

  /**
   * Adds one-offs for the given engines to the DOM.
   *
   * @param {Array} engines
   *        The engines to add.
   * @param {Array} addEngines
   *        The engines that can be added.
   */
  async _rebuildEngineList(engines, addEngines) {
    for (let i = 0; i < engines.length; ++i) {
      let engine = engines[i];
      let button = this.document.createXULElement("button");
      button.engine = engine;
      button.id = this._buttonIDForEngine(engine);
      let iconURL =
        (await engine.getIconURL()) ||
        "chrome://browser/skin/search-engine-placeholder.png";
      button.setAttribute("image", iconURL);
      button.setAttribute("class", "searchbar-engine-one-off-item");
      button.setAttribute("tabindex", "-1");
      this.setTooltipForEngineButton(button);
      this.buttons.appendChild(button);
    }

    for (
      let i = 0, len = Math.min(addEngines.length, this._maxInlineAddEngines);
      i < len;
      i++
    ) {
      const engine = addEngines[i];
      const button = this.document.createXULElement("button");
      button.id = this._buttonIDForEngine(engine);
      button.classList.add("searchbar-engine-one-off-item");
      button.classList.add("searchbar-engine-one-off-add-engine");
      button.setAttribute("tabindex", "-1");
      if (engine.icon) {
        button.setAttribute("image", engine.icon);
      }
      this.document.l10n.setAttributes(button, "search-one-offs-add-engine", {
        engineName: engine.title,
      });
      button.setAttribute("engine-name", engine.title);
      button.setAttribute("uri", engine.uri);
      this.buttons.appendChild(button);
    }
  }

  _buttonIDForEngine(engine) {
    return (
      this.telemetryOrigin +
      "-engine-one-off-item-engine-" +
      this._engineInfo.engines.indexOf(engine)
    );
  }

  getSelectableButtons(aIncludeNonEngineButtons) {
    const buttons = [
      ...this.buttons.querySelectorAll(".searchbar-engine-one-off-item"),
    ];

    if (aIncludeNonEngineButtons) {
      buttons.push(this.settingsButton);
    }

    return buttons;
  }

  /**
   * Returns information on where a search results page should be loaded: in the
   * current tab or a new tab.
   *
   * @param {event} aEvent
   *        The event that triggered the page load.
   * @param {boolean} [aForceNewTab]
   *        True to force the load in a new tab.
   * @returns {object} An object { where, params }.  `where` is a string:
   *          "current" or "tab".  `params` is an object further describing how
   *          the page should be loaded.
   */
  _whereToOpen(aEvent, aForceNewTab = false) {
    let where = "current";
    let params;
    // Open ctrl/cmd clicks on one-off buttons in a new background tab.
    if (aForceNewTab) {
      where = "tab";
      if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
        params = {
          inBackground: true,
        };
      }
    } else {
      let newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
      if (
        (KeyboardEvent.isInstance(aEvent) && aEvent.altKey) != newTabPref &&
        !this.window.gBrowser.selectedTab.isEmpty
      ) {
        where = "tab";
      }
      if (
        MouseEvent.isInstance(aEvent) &&
        (aEvent.button == 1 || aEvent.getModifierState("Accel"))
      ) {
        where = "tab";
        params = {
          inBackground: true,
        };
      }
    }

    return { where, params };
  }

  /**
   * Increments or decrements the index of the currently selected one-off.
   *
   * @param {boolean} aForward
   *        If true, the index is incremented, and if false, the index is
   *        decremented.
   * @param {boolean} aIncludeNonEngineButtons
   *        If true, buttons that do not have engines are included.
   *        These buttons include the OpenSearch and settings buttons.  For
   *        example, if the currently selected button is an engine button,
   *        the next button is the settings button, and you pass true for
   *        aForward, then passing true for this value would cause the
   *        settings to be selected.  Passing false for this value would
   *        cause the selection to clear or wrap around, depending on what
   *        value you passed for the aWrapAround parameter.
   * @param {boolean} aWrapAround
   *        If true, the selection wraps around between the first and last
   *        buttons.
   */
  advanceSelection(aForward, aIncludeNonEngineButtons, aWrapAround) {
    let buttons = this.getSelectableButtons(aIncludeNonEngineButtons);
    let index;
    if (this.selectedButton) {
      let inc = aForward ? 1 : -1;
      let oldIndex = buttons.indexOf(this.selectedButton);
      index = (oldIndex + inc + buttons.length) % buttons.length;
      if (
        !aWrapAround &&
        ((aForward && index <= oldIndex) || (!aForward && oldIndex <= index))
      ) {
        // The index has wrapped around, but wrapping around isn't
        // allowed.
        index = -1;
      }
    } else {
      index = aForward ? 0 : buttons.length - 1;
    }
    this.selectedButton = index < 0 ? null : buttons[index];
  }

  /**
   * This handles key presses specific to the one-off buttons like Tab and
   * Alt+Up/Down, and Up/Down keys within the buttons.  Since one-off buttons
   * are always used in conjunction with a list of some sort (in this.popup),
   * it also handles Up/Down keys that cross the boundaries between list
   * items and the one-off buttons.
   *
   * If this method handles the key press, then it will call
   * event.preventDefault() and return true.
   *
   * @param {Event} event
   *        The key event.
   * @param {number} numListItems
   *        The number of items in the list.  The reason that this is a
   *        parameter at all is that the list may contain items at the end
   *        that should be ignored, depending on the consumer.  That's true
   *        for the urlbar for example.
   * @param {boolean} allowEmptySelection
   *        Pass true if it's OK that neither the list nor the one-off
   *        buttons contains a selection.  Pass false if either the list or
   *        the one-off buttons (or both) should always contain a selection.
   * @param {string} [textboxUserValue]
   *        When the last list item is selected and the user presses Down,
   *        the first one-off becomes selected and the textbox value is
   *        restored to the value that the user typed.  Pass that value here.
   *        However, if you pass true for allowEmptySelection, you don't need
   *        to pass anything for this parameter.  (Pass undefined or null.)
   * @returns {boolean} True if the one-offs handled the key press.
   */
  handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) {
    if (!this.hasView) {
      return false;
    }
    let handled = this._handleKeyDown(
      event,
      numListItems,
      allowEmptySelection,
      textboxUserValue
    );
    if (handled) {
      event.preventDefault();
      event.stopPropagation();
    }
    return handled;
  }

  _handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) {
    if (this.container.hidden) {
      return false;
    }
    if (
      event.keyCode == KeyEvent.DOM_VK_RIGHT &&
      this.selectedButton &&
      this.selectedButton.classList.contains("addengine-menu-button")
    ) {
      // If the add-engine overflow menu item is selected and the user
      // presses the right arrow key, open the submenu.  Unfortunately
      // handling the left arrow key -- to close the popup -- isn't
      // straightforward.  Once the popup is open, it consumes all key
      // events.  Setting ignorekeys=handled on it doesn't help, since the
      // popup handles all arrow keys.  Setting ignorekeys=true on it does
      // mean that the popup no longer consumes the left arrow key, but
      // then it no longer handles up/down keys to select items in the
      // popup.
      this.selectedButton.open = true;
      return true;
    }

    // Handle the Tab key, but only if non-Shift modifiers aren't also
    // pressed to avoid clobbering other shortcuts (like the Alt+Tab
    // browser tab switcher).  The reason this uses getModifierState() and
    // checks for "AltGraph" is that when you press Shift-Alt-Tab,
    // event.altKey is actually false for some reason, at least on macOS.
    // getModifierState("Alt") is also false, but "AltGraph" is true.
    if (
      event.keyCode == KeyEvent.DOM_VK_TAB &&
      !event.getModifierState("Alt") &&
      !event.getModifierState("AltGraph") &&
      !event.getModifierState("Control") &&
      !event.getModifierState("Meta")
    ) {
      if (
        this.getAttribute("disabletab") == "true" ||
        (event.shiftKey && this.selectedButtonIndex <= 0) ||
        (!event.shiftKey &&
          this.selectedButtonIndex ==
            this.getSelectableButtons(true).length - 1)
      ) {
        this.selectedButton = null;
        return false;
      }
      this.selectedViewIndex = -1;
      this.advanceSelection(!event.shiftKey, true, false);
      return !!this.selectedButton;
    }

    if (event.keyCode == KeyboardEvent.DOM_VK_UP) {
      if (event.altKey) {
        // Keep the currently selected result in the list (if any) as a
        // secondary "alt" selection and move the selection up within the
        // buttons.
        this.advanceSelection(false, false, false);
        return true;
      }
      if (numListItems == 0) {
        this.advanceSelection(false, true, false);
        return true;
      }
      if (this.selectedViewIndex > 0) {
        // Moving up within the list.  The autocomplete controller should
        // handle this case.  A button may be selected, so null it.
        this.selectedButton = null;
        return false;
      }
      if (this.selectedViewIndex == 0) {
        // Moving up from the top of the list.
        if (allowEmptySelection) {
          // Let the autocomplete controller remove selection in the list
          // and revert the typed text in the textbox.
          return false;
        }
        // Wrap selection around to the last button.
        if (this.textbox && typeof textboxUserValue == "string") {
          this.textbox.value = textboxUserValue;
        }
        this.selectedViewIndex = -1;
        this.advanceSelection(false, true, true);
        return true;
      }
      if (!this.selectedButton) {
        // Moving up from no selection in the list or the buttons, back
        // down to the last button.
        this.advanceSelection(false, true, true);
        return true;
      }
      if (this.selectedButtonIndex == 0) {
        // Moving up from the buttons to the bottom of the list.
        this.selectedButton = null;
        return false;
      }
      // Moving up/left within the buttons.
      this.advanceSelection(false, true, false);
      return true;
    }

    if (event.keyCode == KeyboardEvent.DOM_VK_DOWN) {
      if (event.altKey) {
        // Keep the currently selected result in the list (if any) as a
        // secondary "alt" selection and move the selection down within
        // the buttons.
        this.advanceSelection(true, false, false);
        return true;
      }
      if (numListItems == 0) {
        this.advanceSelection(true, true, false);
        return true;
      }
      if (
        this.selectedViewIndex >= 0 &&
        this.selectedViewIndex < numListItems - 1
      ) {
        // Moving down within the list.  The autocomplete controller
        // should handle this case.  A button may be selected, so null it.
        this.selectedButton = null;
        return false;
      }
      if (this.selectedViewIndex == numListItems - 1) {
        // Moving down from the last item in the list to the buttons.
        if (!allowEmptySelection) {
          this.selectedViewIndex = -1;
          if (this.textbox && typeof textboxUserValue == "string") {
            this.textbox.value = textboxUserValue;
          }
        }
        this.selectedButtonIndex = 0;
        if (allowEmptySelection) {
          // Let the autocomplete controller remove selection in the list
          // and revert the typed text in the textbox.
          return false;
        }
        return true;
      }
      if (this.selectedButton) {
        let buttons = this.getSelectableButtons(true);
        if (this.selectedButtonIndex == buttons.length - 1) {
          // Moving down from the buttons back up to the top of the list.
          this.selectedButton = null;
          if (allowEmptySelection) {
            // Prevent the selection from wrapping around to the top of
            // the list by returning true, since the list currently has no
            // selection.  Nothing should be selected after handling this
            // Down key.
            return true;
          }
          return false;
        }
        // Moving down/right within the buttons.
        this.advanceSelection(true, true, false);
        return true;
      }
      return false;
    }

    if (event.keyCode == KeyboardEvent.DOM_VK_LEFT) {
      if (
        this.selectedButton &&
        this.selectedButton.engine &&
        !this.disableOneOffsHorizontalKeyNavigation
      ) {
        // Moving left within the buttons.
        this.advanceSelection(false, true, true);
        return true;
      }
      return false;
    }

    if (event.keyCode == KeyboardEvent.DOM_VK_RIGHT) {
      if (
        this.selectedButton &&
        this.selectedButton.engine &&
        !this.disableOneOffsHorizontalKeyNavigation
      ) {
        // Moving right within the buttons.
        this.advanceSelection(true, true, true);
        return true;
      }
      return false;
    }

    return false;
  }

  /**
   * Determines if the target of the event is a one-off button or
   * context menu on a one-off button.
   *
   * @param {Event} event
   *        An event, like a click on a one-off button.
   * @returns {boolean} True if telemetry was recorded and false if not.
   */
  eventTargetIsAOneOff(event) {
    if (!event) {
      return false;
    }

    let target = event.originalTarget;

    if (KeyboardEvent.isInstance(event) && this.selectedButton) {
      return true;
    }
    if (
      MouseEvent.isInstance(event) &&
      Element.isInstance(target) &&
      target.classList.contains("searchbar-engine-one-off-item")
    ) {
      return true;
    }
    if (
      this.window.XULCommandEvent.isInstance(event) &&
      Element.isInstance(target) &&
      target.classList.contains("search-one-offs-context-open-in-new-tab")
    ) {
      return true;
    }

    return false;
  }

  // Methods for subclasses to override

  /**
   * @returns {boolean} True if the one-offs are connected to a view.
   */
  get hasView() {
    return !!this.popup;
  }

  /**
   * @returns {boolean} True if the view is open.
   */
  get isViewOpen() {
    // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
    return this.popup && this.popup.popupOpen;
  }

  /**
   * @returns {number} The selected index in the view or -1 if no selection.
   */
  get selectedViewIndex() {
    // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
    return this.popup.selectedIndex;
  }

  /**
   * Sets the selected index in the view.
   *
   * @param {number} val
   *        The selected index or -1 if no selection.
   */
  set selectedViewIndex(val) {
    // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
    this.popup.selectedIndex = val;
  }

  /**
   * Closes the view.
   */
  closeView() {
    this.popup.hidePopup();
  }

  /**
   * Called when a one-off is clicked or the "Search in New Tab" context menu
   * item is picked.  This is not called for the settings button.
   *
   * @param {event} event
   *        The event that triggered the pick.
   * @param {nsISearchEngine} engine
   *        The engine that was picked.
   * @param {boolean} forceNewTab
   *        True if the search results page should be loaded in a new tab.
   */
  handleSearchCommand(event, engine, forceNewTab = false) {
    let { where, params } = this._whereToOpen(event, forceNewTab);
    // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
    this.popup.handleOneOffSearch(event, engine, where, params);
  }

  /**
   * Sets the tooltip for a one-off button with an engine.  This should set
   * either the `tooltiptext` attribute or the relevant l10n ID.
   *
   * @param {LegacySearchOneOffButton} button
   *        The one-off button.
   */
  setTooltipForEngineButton(button) {
    button.setAttribute("tooltiptext", button.engine.name);
  }

  // Event handlers below.

  _on_mousedown(event) {
    // This is necessary to prevent the input from losing focus and closing the
    // popup. Unfortunately it also has the side effect of preventing the
    // buttons from receiving the `:active` pseudo-class.
    event.preventDefault();
  }

  _on_click(event) {
    if (event.button == 2) {
      return; // ignore right clicks.
    }

    let button = event.originalTarget;
    let engine = button.engine;

    if (!engine) {
      return;
    }

    if (!this.textbox.value) {
      if (event.shiftKey) {
        // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
        this.popup.openSearchForm(event, engine);
      }
      return;
    }
    // Select the clicked button so that consumers can easily tell which
    // button was acted on.
    this.selectedButton = button;
    this.handleSearchCommand(event, engine);
  }

  async _on_command(event) {
    let target = event.target;

    if (target == this.settingsButton) {
      this.window.openPreferences("paneSearch");

      // If the preference tab was already selected, the panel doesn't
      // close itself automatically.
      this.closeView();
      return;
    }

    if (target.classList.contains("searchbar-engine-one-off-add-engine")) {
      // On success, hide the panel and tell event listeners to reshow it to
      // show the new engine.
      lazy.SearchUIUtils.addOpenSearchEngine(
        target.getAttribute("uri"),
        target.getAttribute("image"),
        this.window.gBrowser.selectedBrowser.browsingContext
      )
        .then(result => {
          if (result) {
            this._rebuild();
          }
        })
        .catch(console.error);
      return;
    }

    if (target.classList.contains("search-one-offs-context-open-in-new-tab")) {
      // Select the context-clicked button so that consumers can easily
      // tell which button was acted on.
      this.selectedButton = target.closest("menupopup")._triggerButton;
      if (this.textbox.value) {
        this.handleSearchCommand(event, this.selectedButton.engine, true);
      } else {
        // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
        this.popup.openSearchForm(event, this.selectedButton.engine, true);
      }
    }

    const isPrivateButton = target.classList.contains(
      "search-one-offs-context-set-default-private"
    );
    if (
      target.classList.contains("search-one-offs-context-set-default") ||
      isPrivateButton
    ) {
      const engineType = isPrivateButton
        ? "defaultPrivateEngine"
        : "defaultEngine";
      let currentEngine = Services.search[engineType];

      const isPrivateWin = lazy.PrivateBrowsingUtils.isWindowPrivate(
        this.window
      );
      let button = target.closest("menupopup")._triggerButton;
      // We're about to replace this, so it must be stored now.
      let newDefaultEngine = button.engine;
      if (
        !this.getAttribute("includecurrentengine") &&
        isPrivateButton == isPrivateWin
      ) {
        // Make the target button of the context menu reflect the current
        // search engine first. Doing this as opposed to rebuilding all the
        // one-off buttons avoids flicker.
        let iconURL =
          (await currentEngine.getIconURL()) ||
          "chrome://browser/skin/search-engine-placeholder.png";
        button.setAttribute("image", iconURL);
        button.setAttribute("tooltiptext", currentEngine.name);
        button.engine = currentEngine;
      }

      if (isPrivateButton) {
        Services.search.setDefaultPrivate(
          newDefaultEngine,
          Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT
        );
      } else {
        Services.search.setDefault(
          newDefaultEngine,
          Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT
        );
      }
    }
  }

  _on_contextmenu(event) {
    let target = event.originalTarget;
    // Prevent the context menu from appearing except on the one off buttons.
    if (
      !target.classList.contains("searchbar-engine-one-off-item") ||
      target.classList.contains("search-setting-button")
    ) {
      event.preventDefault();
      return;
    }
    this.contextMenuPopup
      .querySelector(".search-one-offs-context-set-default")
      .setAttribute(
        "disabled",
        target.engine == Services.search.defaultEngine.wrappedJSObject
      );

    const privateDefaultItem = this.contextMenuPopup.querySelector(
      ".search-one-offs-context-set-default-private"
    );

    if (
      Services.prefs.getBoolPref(
        "browser.search.separatePrivateDefault.ui.enabled",
        false
      ) &&
      Services.prefs.getBoolPref("browser.search.separatePrivateDefault", false)
    ) {
      privateDefaultItem.hidden = false;
      privateDefaultItem.setAttribute(
        "disabled",
        target.engine == Services.search.defaultPrivateEngine.wrappedJSObject
      );
    } else {
      privateDefaultItem.hidden = true;
    }

    // When a context menu is opened on a one-off button, this is set to the
    // button to be used for the command.
    this.contextMenuPopup._triggerButton = target;
    this.contextMenuPopup.openPopupAtScreen(event.screenX, event.screenY, true);
    event.preventDefault();
  }

  _on_input(event) {
    // Allow the consumer's input to override its value property with
    // a oneOffSearchQuery property.  That way if the value is not
    // actually what the user typed (e.g., it's autofilled, or it's a
    // mozaction URI), the consumer has some way of providing it.
    this.query = event.target.oneOffSearchQuery || event.target.value;
  }

  _on_popupshowing() {
    this._rebuild();
  }

  _on_popuphidden() {
    this.selectedButton = null;
  }
}
