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

"use strict";

this.ContentSearchUIController = (function () {
  const MAX_DISPLAYED_SUGGESTIONS = 6;
  const SUGGESTION_ID_PREFIX = "searchSuggestion";
  const ONE_OFF_ID_PREFIX = "oneOff";
  const HTML_NS = "http://www.w3.org/1999/xhtml";

  /**
   * Creates a new object that manages search suggestions and their UI for a text
   * box.
   *
   * The UI consists of an html:table that's inserted into the DOM after the given
   * text box and styled so that it appears as a dropdown below the text box.
   *
   * @param {DOMElement} inputElement
   *        Search suggestions will be based on the text in this text box.
   *        Assumed to be an html:input.
   * @param {DOMElement} tableParent
   *        The suggestion table is appended as a child to this element.  Since
   *        the table is absolutely positioned and its top and left values are set
   *        to be relative to the top and left of the page, either the parent and
   *        all its ancestors should not be positioned elements (i.e., their
   *        positions should be "static"), or the parent's position should be the
   *        top left of the page.
   * @param {string} healthReportKey
   *        This will be sent with the search data for BrowserUsageTelemetry to
   *        record the search.
   * @param {string} searchPurpose
   *        Sent with search data, see nsISearchEngine.getSubmission.
   * @param {sring} idPrefix
   *        The IDs of elements created by the object will be prefixed with this
   *        string.
   */
  function ContentSearchUIController(
    inputElement,
    tableParent,
    healthReportKey,
    searchPurpose,
    idPrefix = ""
  ) {
    this.input = inputElement;
    this._idPrefix = idPrefix;
    this._healthReportKey = healthReportKey;
    this._searchPurpose = searchPurpose;
    this._isPrivateEngine = false;

    let tableID = idPrefix + "searchSuggestionTable";
    this.input.autocomplete = "off";
    this.input.setAttribute("aria-autocomplete", "true");
    this.input.setAttribute("aria-controls", tableID);
    tableParent.appendChild(this._makeTable(tableID));

    this.input.addEventListener("keydown", this);
    this.input.addEventListener("input", this);
    this.input.addEventListener("focus", this);
    this.input.addEventListener("blur", this);
    window.addEventListener("ContentSearchService", this);

    this._stickyInputValue = "";
    this._hideSuggestions();

    this._getSearchEngines();
    this._getStrings();
  }

  ContentSearchUIController.prototype = {
    _oneOffButtons: [],
    // Setting up the one off buttons causes an uninterruptible reflow. If we
    // receive the list of engines while the newtab page is loading, this reflow
    // may regress performance - so we set this flag and only set up the buttons
    // if it's set when the suggestions table is actually opened.
    _pendingOneOffRefresh: undefined,

    get defaultEngine() {
      return this._defaultEngine;
    },

    set defaultEngine(engine) {
      if (this._defaultEngine && this._defaultEngine.icon) {
        URL.revokeObjectURL(this._defaultEngine.icon);
      }
      let icon;
      if (engine.iconData) {
        icon = this._getFaviconURIFromIconData(engine.iconData);
      } else {
        icon = "chrome://global/skin/icons/defaultFavicon.svg";
      }
      this._defaultEngine = {
        name: engine.name,
        icon,
        isAppProvided: engine.isAppProvided,
      };
      this._updateDefaultEngineHeader();
      this._updateDefaultEngineIcon();

      if (engine && document.activeElement == this.input) {
        this._speculativeConnect();
      }
    },

    get engines() {
      return this._engines;
    },

    set engines(val) {
      this._engines = val;
      this._pendingOneOffRefresh = true;
    },

    // The selectedIndex is the index of the element with the "selected" class in
    // the list obtained by concatenating the suggestion rows, one-off buttons, and
    // search settings button.
    get selectedIndex() {
      let allElts = [
        ...this._suggestionsList.children,
        ...this._oneOffButtons,
        document.getElementById("contentSearchSettingsButton"),
      ];
      for (let i = 0; i < allElts.length; ++i) {
        let elt = allElts[i];
        if (elt.classList.contains("selected")) {
          return i;
        }
      }
      return -1;
    },

    set selectedIndex(idx) {
      // Update the table's rows, and the input when there is a selection.
      this._table.removeAttribute("aria-activedescendant");
      this.input.removeAttribute("aria-activedescendant");

      let allElts = [
        ...this._suggestionsList.children,
        ...this._oneOffButtons,
        document.getElementById("contentSearchSettingsButton"),
      ];
      // If we are selecting a suggestion and a one-off is selected, don't deselect it.
      let excludeIndex =
        idx < this.numSuggestions && this.selectedButtonIndex > -1
          ? this.numSuggestions + this.selectedButtonIndex
          : -1;
      for (let i = 0; i < allElts.length; ++i) {
        let elt = allElts[i];
        let ariaSelectedElt = i < this.numSuggestions ? elt.firstChild : elt;
        if (i == idx) {
          elt.classList.add("selected");
          ariaSelectedElt.setAttribute("aria-selected", "true");
          this.input.setAttribute("aria-activedescendant", ariaSelectedElt.id);
        } else if (i != excludeIndex) {
          elt.classList.remove("selected");
          ariaSelectedElt.setAttribute("aria-selected", "false");
        }
      }
    },

    get selectedButtonIndex() {
      let elts = [
        ...this._oneOffButtons,
        document.getElementById("contentSearchSettingsButton"),
      ];
      for (let i = 0; i < elts.length; ++i) {
        if (elts[i].classList.contains("selected")) {
          return i;
        }
      }
      return -1;
    },

    set selectedButtonIndex(idx) {
      let elts = [
        ...this._oneOffButtons,
        document.getElementById("contentSearchSettingsButton"),
      ];
      for (let i = 0; i < elts.length; ++i) {
        let elt = elts[i];
        if (i == idx) {
          elt.classList.add("selected");
          elt.setAttribute("aria-selected", "true");
        } else {
          elt.classList.remove("selected");
          elt.setAttribute("aria-selected", "false");
        }
      }
    },

    get selectedEngineName() {
      let selectedElt = this._oneOffsTable.querySelector(".selected");
      if (selectedElt) {
        return selectedElt.engineName;
      }
      return this.defaultEngine.name;
    },

    get numSuggestions() {
      return this._suggestionsList.children.length;
    },

    selectAndUpdateInput(idx) {
      this.selectedIndex = idx;
      let newValue = this.suggestionAtIndex(idx) || this._stickyInputValue;
      // Setting the input value when the value has not changed commits the current
      // IME composition, which we don't want to do.
      if (this.input.value != newValue) {
        this.input.value = newValue;
      }
      this._updateSearchWithHeader();
    },

    suggestionAtIndex(idx) {
      let row = this._suggestionsList.children[idx];
      return row ? row.textContent : null;
    },

    deleteSuggestionAtIndex(idx) {
      // Only form history suggestions can be deleted.
      if (this.isFormHistorySuggestionAtIndex(idx)) {
        let suggestionStr = this.suggestionAtIndex(idx);
        this._sendMsg("RemoveFormHistoryEntry", suggestionStr);
        this._suggestionsList.children[idx].remove();
        this.selectAndUpdateInput(-1);
      }
    },

    isFormHistorySuggestionAtIndex(idx) {
      let row = this._suggestionsList.children[idx];
      return row && row.classList.contains("formHistory");
    },

    addInputValueToFormHistory() {
      let entry = {
        value: this.input.value,
        engineName: this.selectedEngineName,
      };
      this._sendMsg("AddFormHistoryEntry", entry);
      return entry;
    },

    handleEvent(event) {
      // The event handler is triggered by external events while the search
      // element may no longer be present
      if (!document.contains(this.input)) {
        return;
      }
      this["_on" + event.type[0].toUpperCase() + event.type.substr(1)](event);
    },

    _onCommand(aEvent) {
      if (this.selectedButtonIndex == this._oneOffButtons.length) {
        // Settings button was selected.
        this._sendMsg("ManageEngines");
        return;
      }

      this.search(aEvent);

      if (aEvent) {
        aEvent.preventDefault();
      }
    },

    search(aEvent) {
      if (!this.defaultEngine) {
        return; // Not initialized yet.
      }

      let searchText = this.input;
      let searchTerms;
      if (
        this._table.hidden ||
        (aEvent.originalTarget &&
          aEvent.originalTarget.id == "contentSearchDefaultEngineHeader") ||
        aEvent instanceof KeyboardEvent
      ) {
        searchTerms = searchText.value;
      } else {
        searchTerms =
          this.suggestionAtIndex(this.selectedIndex) || searchText.value;
      }
      // Send an event that will perform a search and Firefox Health Report will
      // record that a search from the healthReportKey passed to the constructor.
      let eventData = {
        engineName: this.selectedEngineName,
        searchString: searchTerms,
        healthReportKey: this._healthReportKey,
        searchPurpose: this._searchPurpose,
        originalEvent: {
          shiftKey: aEvent.shiftKey,
          ctrlKey: aEvent.ctrlKey,
          metaKey: aEvent.metaKey,
          altKey: aEvent.altKey,
        },
      };
      if ("button" in aEvent) {
        eventData.originalEvent.button = aEvent.button;
      }

      if (this.suggestionAtIndex(this.selectedIndex)) {
        eventData.selection = {
          index: this.selectedIndex,
          kind: undefined,
        };
        if (aEvent instanceof MouseEvent) {
          eventData.selection.kind = "mouse";
        } else if (aEvent instanceof KeyboardEvent) {
          eventData.selection.kind = "key";
        }
      }

      this._sendMsg("Search", eventData);
      this.addInputValueToFormHistory();
    },

    _onInput() {
      if (!this.input.value) {
        this._stickyInputValue = "";
        this._hideSuggestions();
      } else if (this.input.value != this._stickyInputValue) {
        // Only fetch new suggestions if the input value has changed.
        this._getSuggestions();
        this.selectAndUpdateInput(-1);
      }
      this._updateSearchWithHeader();
    },

    _onKeydown(event) {
      let selectedIndexDelta = 0;
      let selectedSuggestionDelta = 0;
      let selectedOneOffDelta = 0;

      switch (event.keyCode) {
        case event.DOM_VK_UP:
          if (this._table.hidden) {
            return;
          }
          if (event.getModifierState("Accel")) {
            if (event.shiftKey) {
              selectedSuggestionDelta = -1;
              break;
            }
            this._cycleCurrentEngine(true);
            break;
          }
          if (event.altKey) {
            selectedOneOffDelta = -1;
            break;
          }
          selectedIndexDelta = -1;
          break;
        case event.DOM_VK_DOWN:
          if (this._table.hidden) {
            this._getSuggestions();
            return;
          }
          if (event.getModifierState("Accel")) {
            if (event.shiftKey) {
              selectedSuggestionDelta = 1;
              break;
            }
            this._cycleCurrentEngine(false);
            break;
          }
          if (event.altKey) {
            selectedOneOffDelta = 1;
            break;
          }
          selectedIndexDelta = 1;
          break;
        case event.DOM_VK_TAB:
          if (this._table.hidden) {
            return;
          }
          // Shift+tab when either the first or no one-off is selected, as well as
          // tab when the settings button is selected, should change focus as normal.
          if (
            (this.selectedButtonIndex <= 0 && event.shiftKey) ||
            (this.selectedButtonIndex == this._oneOffButtons.length &&
              !event.shiftKey)
          ) {
            return;
          }
          selectedOneOffDelta = event.shiftKey ? -1 : 1;
          break;
        case event.DOM_VK_RIGHT:
          // Allow normal caret movement until the caret is at the end of the input.
          if (
            this.input.selectionStart != this.input.selectionEnd ||
            this.input.selectionEnd != this.input.value.length
          ) {
            return;
          }
          if (
            this.numSuggestions &&
            this.selectedIndex >= 0 &&
            this.selectedIndex < this.numSuggestions
          ) {
            this.input.value = this.suggestionAtIndex(this.selectedIndex);
            this.input.setAttribute("selection-index", this.selectedIndex);
            this.input.setAttribute("selection-kind", "key");
          } else {
            // If we didn't select anything, make sure to remove the attributes
            // in case they were populated last time.
            this.input.removeAttribute("selection-index");
            this.input.removeAttribute("selection-kind");
          }
          this._stickyInputValue = this.input.value;
          this._hideSuggestions();
          return;
        case event.DOM_VK_RETURN:
          this._onCommand(event);
          return;
        case event.DOM_VK_DELETE:
          if (this.selectedIndex >= 0) {
            this.deleteSuggestionAtIndex(this.selectedIndex);
          }
          return;
        case event.DOM_VK_ESCAPE:
          if (!this._table.hidden) {
            this._hideSuggestions();
          }
          return;
        default:
          return;
      }

      let currentIndex = this.selectedIndex;
      if (selectedIndexDelta) {
        let newSelectedIndex = currentIndex + selectedIndexDelta;
        if (newSelectedIndex < -1) {
          newSelectedIndex = this.numSuggestions + this._oneOffButtons.length;
        }
        // If are moving up from the first one off, we have to deselect the one off
        // manually because the selectedIndex setter tries to exclude the selected
        // one-off (which is desirable for accel+shift+up/down).
        if (currentIndex == this.numSuggestions && selectedIndexDelta == -1) {
          this.selectedButtonIndex = -1;
        }
        this.selectAndUpdateInput(newSelectedIndex);
      } else if (selectedSuggestionDelta) {
        let newSelectedIndex;
        if (currentIndex >= this.numSuggestions || currentIndex == -1) {
          // No suggestion already selected, select the first/last one appropriately.
          newSelectedIndex =
            selectedSuggestionDelta == 1 ? 0 : this.numSuggestions - 1;
        } else {
          newSelectedIndex = currentIndex + selectedSuggestionDelta;
        }
        if (newSelectedIndex >= this.numSuggestions) {
          newSelectedIndex = -1;
        }
        this.selectAndUpdateInput(newSelectedIndex);
      } else if (selectedOneOffDelta) {
        let newSelectedIndex;
        let currentButton = this.selectedButtonIndex;
        if (
          currentButton == -1 ||
          currentButton == this._oneOffButtons.length
        ) {
          // No one-off already selected, select the first/last one appropriately.
          newSelectedIndex =
            selectedOneOffDelta == 1 ? 0 : this._oneOffButtons.length - 1;
        } else {
          newSelectedIndex = currentButton + selectedOneOffDelta;
        }
        // Allow selection of the settings button via the tab key.
        if (
          newSelectedIndex == this._oneOffButtons.length &&
          event.keyCode != event.DOM_VK_TAB
        ) {
          newSelectedIndex = -1;
        }
        this.selectedButtonIndex = newSelectedIndex;
      }

      // Prevent the input's caret from moving.
      event.preventDefault();
    },

    _currentEngineIndex: -1,
    _cycleCurrentEngine(aReverse) {
      if (
        (this._currentEngineIndex == this._engines.length - 1 && !aReverse) ||
        (this._currentEngineIndex == 0 && aReverse)
      ) {
        return;
      }
      this._currentEngineIndex += aReverse ? -1 : 1;
      let engineName = this._engines[this._currentEngineIndex].name;
      this._sendMsg("SetCurrentEngine", engineName);
    },

    _onFocus() {
      if (this._mousedown) {
        return;
      }
      // When the input box loses focus to something in our table, we refocus it
      // immediately. This causes the focus highlight to flicker, so we set a
      // custom attribute which consumers should use for focus highlighting. This
      // attribute is removed only when we do not immediately refocus the input
      // box, thus eliminating flicker.
      this.input.setAttribute("keepfocus", "true");
      this._speculativeConnect();
    },

    _onBlur() {
      if (this._mousedown) {
        // At this point, this.input has lost focus, but a new element has not yet
        // received it. If we re-focus this.input directly, the new element will
        // steal focus immediately, so we queue it instead.
        setTimeout(() => this.input.focus(), 0);
        return;
      }
      this.input.removeAttribute("keepfocus");
      this._hideSuggestions();
    },

    _onMousemove(event) {
      let idx = this._indexOfTableItem(event.target);
      if (idx >= this.numSuggestions) {
        // Deselect any search suggestion that has been selected.
        this.selectedIndex = -1;
        this.selectedButtonIndex = idx - this.numSuggestions;
        return;
      }
      this.selectedIndex = idx;
    },

    _onMouseup(event) {
      if (event.button == 2) {
        return;
      }
      this._onCommand(event);
    },

    _onMouseout(event) {
      // We only deselect one-off buttons and the settings button when they are
      // moused out.
      let idx = this._indexOfTableItem(event.originalTarget);
      if (idx >= this.numSuggestions) {
        this.selectedButtonIndex = -1;
      }
    },

    _onClick(event) {
      this._onMouseup(event);
    },

    _onContentSearchService(event) {
      let methodName = "_onMsg" + event.detail.type;
      if (methodName in this) {
        this[methodName](event.detail.data);
      }
    },

    _onMsgFocusInput(event) {
      this.input.focus();
    },

    _onMsgBlur(event) {
      this.input.blur();
      this._hideSuggestions();
    },

    _onMsgSuggestions(suggestions) {
      // Ignore the suggestions if their search string or engine doesn't match
      // ours.  Due to the async nature of message passing, this can easily happen
      // when the user types quickly.
      if (
        this._stickyInputValue != suggestions.searchString ||
        this.defaultEngine.name != suggestions.engineName
      ) {
        return;
      }

      this._clearSuggestionRows();

      // Position and size the table.
      let { left } = this.input.getBoundingClientRect();
      this._table.style.top = this.input.offsetHeight + "px";
      this._table.style.minWidth = this.input.offsetWidth + "px";
      this._table.style.maxWidth = window.innerWidth - left - 40 + "px";

      // Add the suggestions to the table.
      let searchWords = new Set(
        suggestions.searchString.trim().toLowerCase().split(/\s+/)
      );
      for (let i = 0; i < MAX_DISPLAYED_SUGGESTIONS; i++) {
        let type, idx;
        if (i < suggestions.formHistory.length) {
          [type, idx] = ["formHistory", i];
        } else {
          let j = i - suggestions.formHistory.length;
          if (j < suggestions.remote.length) {
            [type, idx] = ["remote", j];
          } else {
            break;
          }
        }
        this._suggestionsList.appendChild(
          this._makeTableRow(type, suggestions[type][idx], i, searchWords)
        );
      }

      if (this._table.hidden) {
        this.selectedIndex = -1;
        if (this._pendingOneOffRefresh) {
          this._setUpOneOffButtons();
          delete this._pendingOneOffRefresh;
        }
        this._currentEngineIndex = this._engines.findIndex(
          aEngine => aEngine.name == this.defaultEngine.name
        );
        this._table.hidden = false;
        this.input.setAttribute("aria-expanded", "true");
      }
    },

    _onMsgSuggestionsCancelled() {
      if (!this._table.hidden) {
        this._hideSuggestions();
      }
    },

    _onMsgState(state) {
      // Not all state messages broadcast the windows' privateness info.
      if ("isPrivateWindow" in state) {
        this._isPrivateEngine = state.isPrivateEngine;
      }

      this.engines = state.engines;

      let currentEngine = state.currentEngine;
      if (this._isPrivateEngine) {
        currentEngine = state.currentPrivateEngine;
      }

      // No point updating the default engine (and the header) if there's no change.
      if (
        this.defaultEngine &&
        this.defaultEngine.name == currentEngine.name &&
        this.defaultEngine.icon == currentEngine.icon
      ) {
        return;
      }
      this.defaultEngine = currentEngine;
    },

    _onMsgCurrentState(state) {
      this._onMsgState(state);
    },

    _onMsgCurrentEngine(engine) {
      if (this._isPrivateEngine) {
        return;
      }
      this.defaultEngine = engine;
      this._pendingOneOffRefresh = true;
    },

    _onMsgCurrentPrivateEngine(engine) {
      if (!this._isPrivateEngine) {
        return;
      }
      this.defaultEngine = engine;
      this._pendingOneOffRefresh = true;
    },

    _onMsgStrings(strings) {
      this._strings = strings;
      this._updateDefaultEngineHeader();
      this._updateSearchWithHeader();
      document.getElementById("contentSearchSettingsButton").textContent =
        this._strings.searchSettings;
    },

    _updateDefaultEngineIcon() {
      // We only show the engine's own icon for app provided engines, otherwise show
      // a default. xref https://bugzilla.mozilla.org/show_bug.cgi?id=1449338#c19
      let icon = this.defaultEngine.isAppProvided
        ? this.defaultEngine.icon
        : "chrome://global/skin/icons/search-glass.svg";

      document.body.style.setProperty(
        "--newtab-search-icon",
        "url(" + icon + ")"
      );
    },

    _updateDefaultEngineHeader() {
      let header = document.getElementById("contentSearchDefaultEngineHeader");
      header.firstChild.setAttribute("src", this.defaultEngine.icon);
      if (!this._strings) {
        return;
      }
      while (header.firstChild.nextSibling) {
        header.firstChild.nextSibling.remove();
      }
      header.appendChild(
        document.createTextNode(
          this._strings.searchHeader.replace("%S", this.defaultEngine.name)
        )
      );
    },

    _updateSearchWithHeader() {
      if (!this._strings) {
        return;
      }
      let searchWithHeader = document.getElementById(
        "contentSearchSearchWithHeader"
      );
      let labels = searchWithHeader.querySelectorAll("label");
      if (this.input.value) {
        let header = this._strings.searchForSomethingWith2;
        // Translators can use both %S and %1$S.
        header = header.replace("%1$S", "%S").split("%S");
        labels[0].textContent = header[0];
        labels[1].textContent = this.input.value;
        labels[2].textContent = header[1];
      } else {
        labels[0].textContent = this._strings.searchWithHeader;
        labels[1].textContent = "";
        labels[2].textContent = "";
      }
    },

    _speculativeConnect() {
      if (this.defaultEngine) {
        this._sendMsg("SpeculativeConnect", this.defaultEngine.name);
      }
    },

    _makeTableRow(type, suggestionStr, currentRow, searchWords) {
      let row = document.createElementNS(HTML_NS, "tr");
      row.dir = "auto";
      row.classList.add("contentSearchSuggestionRow");
      row.classList.add(type);
      row.setAttribute("role", "presentation");
      row.addEventListener("mousemove", this);
      row.addEventListener("mouseup", this);

      let entry = document.createElementNS(HTML_NS, "td");
      let img = document.createElementNS(HTML_NS, "div");
      img.setAttribute("class", "historyIcon");
      entry.appendChild(img);
      entry.classList.add("contentSearchSuggestionEntry");
      entry.setAttribute("role", "option");
      entry.id = this._idPrefix + SUGGESTION_ID_PREFIX + currentRow;
      entry.setAttribute("aria-selected", "false");

      let suggestionWords = suggestionStr.trim().toLowerCase().split(/\s+/);
      for (let i = 0; i < suggestionWords.length; i++) {
        let word = suggestionWords[i];
        let wordSpan = document.createElementNS(HTML_NS, "span");
        if (searchWords.has(word)) {
          wordSpan.classList.add("typed");
        }
        wordSpan.textContent = word;
        entry.appendChild(wordSpan);
        if (i < suggestionWords.length - 1) {
          entry.appendChild(document.createTextNode(" "));
        }
      }

      row.appendChild(entry);
      return row;
    },

    // If the favicon is an array buffer, convert it into a Blob URI.
    // Otherwise just return the plain URI.
    _getFaviconURIFromIconData(data) {
      if (typeof data == "string") {
        return data;
      }

      // If typeof(data) != "string", we assume it's an ArrayBuffer
      let blob = new Blob([data]);
      return URL.createObjectURL(blob);
    },

    // Adds "@2x" to the name of the given PNG url for "retina" screens.
    _getImageURIForCurrentResolution(uri) {
      if (window.devicePixelRatio > 1) {
        return uri.replace(/\.png$/, "@2x.png");
      }
      return uri;
    },

    _getSearchEngines() {
      this._sendMsg("GetState");
    },

    _getStrings() {
      this._sendMsg("GetStrings");
    },

    _getSuggestions() {
      this._stickyInputValue = this.input.value;
      if (this.defaultEngine) {
        this._sendMsg("GetSuggestions", {
          engineName: this.defaultEngine.name,
          searchString: this.input.value,
        });
      }
    },

    _clearSuggestionRows() {
      while (this._suggestionsList.firstElementChild) {
        this._suggestionsList.firstElementChild.remove();
      }
    },

    _hideSuggestions() {
      this.input.setAttribute("aria-expanded", "false");
      this.selectedIndex = -1;
      this.selectedButtonIndex = -1;
      this._currentEngineIndex = -1;
      this._table.hidden = true;
    },

    _indexOfTableItem(elt) {
      if (elt.classList.contains("contentSearchOneOffItem")) {
        return this.numSuggestions + this._oneOffButtons.indexOf(elt);
      }
      if (elt.classList.contains("contentSearchSettingsButton")) {
        return this.numSuggestions + this._oneOffButtons.length;
      }
      while (elt && elt.localName != "tr") {
        elt = elt.parentNode;
      }
      if (!elt) {
        throw new Error("Element is not a row");
      }
      return elt.rowIndex;
    },

    _makeTable(id) {
      this._table = document.createElementNS(HTML_NS, "table");
      this._table.id = id;
      this._table.hidden = true;
      this._table.classList.add("contentSearchSuggestionTable");
      this._table.setAttribute("role", "presentation");

      // When the search input box loses focus, we want to immediately give focus
      // back to it if the blur was because the user clicked somewhere in the table.
      // onBlur uses the _mousedown flag to detect this.
      this._table.addEventListener("mousedown", () => {
        this._mousedown = true;
      });
      document.addEventListener("mouseup", () => {
        delete this._mousedown;
      });

      // Deselect the selected element on mouseout if it wasn't a suggestion.
      this._table.addEventListener("mouseout", this);

      let headerRow = document.createElementNS(HTML_NS, "tr");
      let header = document.createElementNS(HTML_NS, "td");
      headerRow.setAttribute("class", "contentSearchHeaderRow");
      header.setAttribute("class", "contentSearchHeader");
      let iconImg = document.createElementNS(HTML_NS, "img");
      header.appendChild(iconImg);
      header.id = "contentSearchDefaultEngineHeader";
      headerRow.appendChild(header);
      headerRow.addEventListener("click", this);
      this._table.appendChild(headerRow);

      let row = document.createElementNS(HTML_NS, "tr");
      row.setAttribute("class", "contentSearchSuggestionsContainer");
      let cell = document.createElementNS(HTML_NS, "td");
      cell.setAttribute("class", "contentSearchSuggestionsContainer");
      this._suggestionsList = document.createElementNS(HTML_NS, "table");
      this._suggestionsList.setAttribute(
        "class",
        "contentSearchSuggestionsList"
      );
      cell.appendChild(this._suggestionsList);
      row.appendChild(cell);
      this._table.appendChild(row);
      this._suggestionsList.setAttribute("role", "listbox");

      this._oneOffsTable = document.createElementNS(HTML_NS, "table");
      this._oneOffsTable.setAttribute("class", "contentSearchOneOffsTable");
      this._oneOffsTable.classList.add("contentSearchSuggestionsContainer");
      this._oneOffsTable.setAttribute("role", "group");
      this._table.appendChild(this._oneOffsTable);

      headerRow = document.createElementNS(HTML_NS, "tr");
      header = document.createElementNS(HTML_NS, "td");
      headerRow.setAttribute("class", "contentSearchHeaderRow");
      header.setAttribute("class", "contentSearchHeader");
      headerRow.appendChild(header);
      header.id = "contentSearchSearchWithHeader";
      let start = document.createElement("label");
      let inputLabel = document.createElement("label");
      inputLabel.setAttribute(
        "class",
        "contentSearchSearchWithHeaderSearchText"
      );
      let end = document.createElement("label");
      header.appendChild(start);
      header.appendChild(inputLabel);
      header.appendChild(end);
      this._oneOffsTable.appendChild(headerRow);

      let button = document.createElementNS(HTML_NS, "button");
      button.setAttribute("class", "contentSearchSettingsButton");
      button.classList.add("contentSearchHeaderRow");
      button.classList.add("contentSearchHeader");
      button.id = "contentSearchSettingsButton";
      button.addEventListener("click", this);
      button.addEventListener("mousemove", this);
      this._table.appendChild(button);

      return this._table;
    },

    _setUpOneOffButtons() {
      // Sometimes we receive a CurrentEngine message from the ContentSearch service
      // before we've received a State message - i.e. before we have our engines.
      if (!this._engines) {
        return;
      }

      while (this._oneOffsTable.firstChild.nextSibling) {
        this._oneOffsTable.firstChild.nextSibling.remove();
      }

      this._oneOffButtons = [];

      let engines = this._engines
        .filter(aEngine => aEngine.name != this.defaultEngine.name)
        .filter(aEngine => !aEngine.hidden);
      if (!engines.length) {
        this._oneOffsTable.hidden = true;
        return;
      }

      const kDefaultButtonWidth = 49; // 48px + 1px border.
      let rowWidth = this.input.offsetWidth - 2; // 2px border.
      let enginesPerRow = Math.floor(rowWidth / kDefaultButtonWidth);
      let buttonWidth = Math.floor(rowWidth / enginesPerRow);

      let row = document.createElementNS(HTML_NS, "tr");
      let cell = document.createElementNS(HTML_NS, "td");
      row.setAttribute("class", "contentSearchSuggestionsContainer");
      cell.setAttribute("class", "contentSearchSuggestionsContainer");

      for (let i = 0; i < engines.length; ++i) {
        let engine = engines[i];
        if (i > 0 && i % enginesPerRow == 0) {
          row.appendChild(cell);
          this._oneOffsTable.appendChild(row);
          row = document.createElementNS(HTML_NS, "tr");
          cell = document.createElementNS(HTML_NS, "td");
          row.setAttribute("class", "contentSearchSuggestionsContainer");
          cell.setAttribute("class", "contentSearchSuggestionsContainer");
        }
        let button = document.createElementNS(HTML_NS, "button");
        button.setAttribute("class", "contentSearchOneOffItem");
        let img = document.createElementNS(HTML_NS, "img");
        let uri;
        if (engine.iconData) {
          uri = this._getFaviconURIFromIconData(engine.iconData);
        } else {
          uri = this._getImageURIForCurrentResolution(
            "chrome://browser/skin/search-engine-placeholder.png"
          );
        }
        img.setAttribute("src", uri);
        img.addEventListener(
          "load",
          function () {
            URL.revokeObjectURL(uri);
          },
          { once: true }
        );
        button.appendChild(img);
        button.style.width = buttonWidth + "px";
        button.setAttribute("engine-name", engine.name);

        button.engineName = engine.name;
        button.addEventListener("click", this);
        button.addEventListener("mousemove", this);

        if (engines.length - i <= enginesPerRow - (i % enginesPerRow)) {
          button.classList.add("last-row");
        }

        if ((i + 1) % enginesPerRow == 0) {
          button.classList.add("end-of-row");
        }

        button.id = ONE_OFF_ID_PREFIX + i;
        cell.appendChild(button);
        this._oneOffButtons.push(button);
      }
      row.appendChild(cell);
      this._oneOffsTable.appendChild(row);
      this._oneOffsTable.hidden = false;
    },

    _sendMsg(type, data = null) {
      dispatchEvent(
        new CustomEvent("ContentSearchClient", {
          detail: {
            type,
            data,
          },
        })
      );
    },
  };

  return ContentSearchUIController;
})();
