/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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";

const HTML_NS = "http://www.w3.org/1999/xhtml";
const Services = require("Services");
const {gDevTools} = require("devtools/client/framework/devtools");
const {HTMLTooltip} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
const EventEmitter = require("devtools/shared/event-emitter");

let itemIdCounter = 0;
/**
 * Autocomplete popup UI implementation.
 *
 * @constructor
 * @param {Document} toolboxDoc
 *        The toolbox document to attach the autocomplete popup panel.
 * @param {Object} options
 *        An object consiting any of the following options:
 *        - listId {String} The id for the list <LI> element.
 *        - position {String} The position for the tooltip ("top" or "bottom").
 *        - theme {String} String related to the theme of the popup
 *        - autoSelect {Boolean} Boolean to allow the first entry of the popup
 *          panel to be automatically selected when the popup shows.
 *        - onSelect {String} Callback called when the selected index is updated.
 *        - onClick {String} Callback called when the autocomplete popup receives a click
 *          event. The selectedIndex will already be updated if need be.
 */
function AutocompletePopup(toolboxDoc, options = {}) {
  EventEmitter.decorate(this);

  this._document = toolboxDoc;

  this.autoSelect = options.autoSelect || false;
  this.position = options.position || "bottom";
  let theme = options.theme || "dark";

  this.onSelectCallback = options.onSelect;
  this.onClickCallback = options.onClick;

  // If theme is auto, use the devtools.theme pref
  if (theme === "auto") {
    theme = Services.prefs.getCharPref("devtools.theme");
    this.autoThemeEnabled = true;
    // Setup theme change listener.
    this._handleThemeChange = this._handleThemeChange.bind(this);
    gDevTools.on("pref-changed", this._handleThemeChange);
  }

  // Create HTMLTooltip instance
  this._tooltip = new HTMLTooltip(this._document);
  this._tooltip.panel.classList.add(
    "devtools-autocomplete-popup",
    "devtools-monospace",
    theme + "-theme");
  // Stop this appearing as an alert to accessibility.
  this._tooltip.panel.setAttribute("role", "presentation");

  this._list = this._document.createElementNS(HTML_NS, "ul");
  this._list.setAttribute("flex", "1");

  // The list clone will be inserted in the same document as the anchor, and will receive
  // a copy of the main list innerHTML to allow screen readers to access the list.
  this._listClone = this._document.createElementNS(HTML_NS, "ul");
  this._listClone.className = "devtools-autocomplete-list-aria-clone";

  if (options.listId) {
    this._list.setAttribute("id", options.listId);
  }
  this._list.className = "devtools-autocomplete-listbox " + theme + "-theme";

  this._tooltip.setContent(this._list);

  this.onClick = this.onClick.bind(this);
  this._list.addEventListener("click", this.onClick, false);

  // Array of raw autocomplete items
  this.items = [];
  // Map of autocompleteItem to HTMLElement
  this.elements = new WeakMap();

  this.selectedIndex = -1;
}
exports.AutocompletePopup = AutocompletePopup;

AutocompletePopup.prototype = {
  _document: null,
  _tooltip: null,
  _list: null,

  onSelect: function (e) {
    if (this.onSelectCallback) {
      this.onSelectCallback(e);
    }
  },

  onClick: function (e) {
    let item = e.target.closest(".autocomplete-item");
    if (item && typeof item.dataset.index !== "undefined") {
      this.selectedIndex = parseInt(item.dataset.index, 10);
    }

    this.emit("popup-click");
    if (this.onClickCallback) {
      this.onClickCallback(e);
    }
  },

  /**
   * Open the autocomplete popup panel.
   *
   * @param {nsIDOMNode} anchor
   *        Optional node to anchor the panel to.
   * @param {Number} xOffset
   *        Horizontal offset in pixels from the left of the node to the left
   *        of the popup.
   * @param {Number} yOffset
   *        Vertical offset in pixels from the top of the node to the starting
   *        of the popup.
   * @param {Number} index
   *        The position of item to select.
   */
  openPopup: function (anchor, xOffset = 0, yOffset = 0, index) {
    this.__maxLabelLength = -1;
    this._updateSize();

    // Retrieve the anchor's document active element to add accessibility metadata.
    this._activeElement = anchor.ownerDocument.activeElement;

    this._tooltip.show(anchor, {
      x: xOffset,
      y: yOffset,
      position: this.position,
    });

    this._tooltip.once("shown", () => {
      if (this.autoSelect) {
        this.selectItemAtIndex(index);
      }

      this.emit("popup-opened");
    });
  },

  /**
   * Select item at the provided index.
   *
   * @param {Number} index
   *        The position of the item to select.
   */
  selectItemAtIndex: function (index) {
    if (typeof index !== "number") {
      // If no index was provided, select the item closest to the input.
      let isAboveInput = this.position === "top";
      index = isAboveInput ? this.itemCount - 1 : 0;
    }
    this.selectedIndex = index;
  },

  /**
   * Hide the autocomplete popup panel.
   */
  hidePopup: function () {
    this._tooltip.once("hidden", () => {
      this.emit("popup-closed");
    });

    this._clearActiveDescendant();
    this._activeElement = null;
    this._tooltip.hide();
  },

  /**
   * Check if the autocomplete popup is open.
   */
  get isOpen() {
    return this._tooltip && this._tooltip.isVisible();
  },

  /**
   * Destroy the object instance. Please note that the panel DOM elements remain
   * in the DOM, because they might still be in use by other instances of the
   * same code. It is the responsability of the client code to perform DOM
   * cleanup.
   */
  destroy: function () {
    if (this.isOpen) {
      this.hidePopup();
    }

    this._list.removeEventListener("click", this.onClick, false);

    if (this.autoThemeEnabled) {
      gDevTools.off("pref-changed", this._handleThemeChange);
    }

    this._list.remove();
    this._listClone.remove();
    this._tooltip.destroy();
    this._document = null;
    this._list = null;
    this._tooltip = null;
  },

  /**
   * Get the autocomplete items array.
   *
   * @param {Number} index
   *        The index of the item what is wanted.
   *
   * @return {Object} The autocomplete item at index index.
   */
  getItemAtIndex: function (index) {
    return this.items[index];
  },

  /**
   * Get the autocomplete items array.
   *
   * @return {Array} The array of autocomplete items.
   */
  getItems: function () {
    // Return a copy of the array to avoid side effects from the caller code.
    return this.items.slice(0);
  },

  /**
   * Set the autocomplete items list, in one go.
   *
   * @param {Array} items
   *        The list of items you want displayed in the popup list.
   * @param {Number} index
   *        The position of the item to select.
   */
  setItems: function (items, index) {
    this.clearItems();
    items.forEach(this.appendItem, this);

    if (this.isOpen && this.autoSelect) {
      this.selectItemAtIndex(index);
    }
  },

  __maxLabelLength: -1,

  get _maxLabelLength() {
    if (this.__maxLabelLength !== -1) {
      return this.__maxLabelLength;
    }

    let max = 0;
    for (let {label, count} of this.items) {
      if (count) {
        label += count + "";
      }
      max = Math.max(label.length, max);
    }

    this.__maxLabelLength = max;
    return this.__maxLabelLength;
  },

  /**
   * Update the panel size to fit the content.
   */
  _updateSize: function () {
    if (!this._tooltip) {
      return;
    }

    this._list.style.width = (this._maxLabelLength + 3) + "ch";
    let selectedItem = this.selectedItem;
    if (selectedItem) {
      this._scrollElementIntoViewIfNeeded(this.elements.get(selectedItem));
    }
  },

  _scrollElementIntoViewIfNeeded: function (element) {
    let quads = element.getBoxQuads({relativeTo: this._tooltip.panel});
    if (!quads || !quads[0]) {
      return;
    }

    let {top, height} = quads[0].bounds;
    let containerHeight = this._tooltip.panel.getBoundingClientRect().height;
    if (top < 0) {
      // Element is above container.
      element.scrollIntoView(true);
    } else if ((top + height) > containerHeight) {
      // Element is beloew container.
      element.scrollIntoView(false);
    }
  },

  /**
   * Clear all the items from the autocomplete list.
   */
  clearItems: function () {
    // Reset the selectedIndex to -1 before clearing the list
    this.selectedIndex = -1;
    this._list.innerHTML = "";
    this.__maxLabelLength = -1;
    this.items = [];
    this.elements = new WeakMap();
  },

  /**
   * Getter for the index of the selected item.
   *
   * @type {Number}
   */
  get selectedIndex() {
    return this._selectedIndex;
  },

  /**
   * Setter for the selected index.
   *
   * @param {Number} index
   *        The number (index) of the item you want to select in the list.
   */
  set selectedIndex(index) {
    let previousSelected = this._list.querySelector(".autocomplete-selected");
    if (previousSelected) {
      previousSelected.classList.remove("autocomplete-selected");
    }

    let item = this.items[index];
    if (this.isOpen && item) {
      let element = this.elements.get(item);

      element.classList.add("autocomplete-selected");
      this._scrollElementIntoViewIfNeeded(element);
      this._setActiveDescendant(element.id);
    } else {
      this._clearActiveDescendant();
    }
    this._selectedIndex = index;

    if (this.isOpen && item && this.onSelectCallback) {
      // Call the user-defined select callback if defined.
      this.onSelectCallback();
    }
  },

  /**
   * Getter for the selected item.
   * @type Object
   */
  get selectedItem() {
    return this.items[this._selectedIndex];
  },

  /**
   * Setter for the selected item.
   *
   * @param {Object} item
   *        The object you want selected in the list.
   */
  set selectedItem(item) {
    let index = this.items.indexOf(item);
    if (index !== -1 && this.isOpen) {
      this.selectedIndex = index;
    }
  },

  /**
   * Update the aria-activedescendant attribute on the current active element for
   * accessibility.
   *
   * @param {String} id
   *        The id (as in DOM id) of the currently selected autocomplete suggestion
   */
  _setActiveDescendant: function (id) {
    if (!this._activeElement) {
      return;
    }

    // Make sure the list clone is in the same document as the anchor.
    let anchorDoc = this._activeElement.ownerDocument;
    if (!this._listClone.parentNode || this._listClone.ownerDocument !== anchorDoc) {
      anchorDoc.documentElement.appendChild(this._listClone);
    }

    // Update the clone content to match the current list content.
    this._listClone.innerHTML = this._list.innerHTML;

    this._activeElement.setAttribute("aria-activedescendant", id);
  },

  /**
   * Clear the aria-activedescendant attribute on the current active element.
   */
  _clearActiveDescendant: function () {
    if (!this._activeElement) {
      return;
    }

    this._activeElement.removeAttribute("aria-activedescendant");
  },

  /**
   * Append an item into the autocomplete list.
   *
   * @param {Object} item
   *        The item you want appended to the list.
   *        The item object can have the following properties:
   *        - label {String} Property which is used as the displayed value.
   *        - preLabel {String} [Optional] The String that will be displayed
   *                   before the label indicating that this is the already
   *                   present text in the input box, and label is the text
   *                   that will be auto completed. When this property is
   *                   present, |preLabel.length| starting characters will be
   *                   removed from label.
   *        - count {Number} [Optional] The number to represent the count of
   *                autocompleted label.
   */
  appendItem: function (item) {
    let listItem = this._document.createElementNS(HTML_NS, "li");
    // Items must have an id for accessibility.
    listItem.setAttribute("id", "autocomplete-item-" + itemIdCounter++);
    listItem.className = "autocomplete-item";
    listItem.setAttribute("data-index", this.items.length);
    if (this.direction) {
      listItem.setAttribute("dir", this.direction);
    }
    let label = this._document.createElementNS(HTML_NS, "span");
    label.textContent = item.label;
    label.className = "autocomplete-value";
    if (item.preLabel) {
      let preDesc = this._document.createElementNS(HTML_NS, "span");
      preDesc.textContent = item.preLabel;
      preDesc.className = "initial-value";
      listItem.appendChild(preDesc);
      label.textContent = item.label.slice(item.preLabel.length);
    }
    listItem.appendChild(label);
    if (item.count && item.count > 1) {
      let countDesc = this._document.createElementNS(HTML_NS, "span");
      countDesc.textContent = item.count;
      countDesc.setAttribute("flex", "1");
      countDesc.className = "autocomplete-count";
      listItem.appendChild(countDesc);
    }

    this._list.appendChild(listItem);
    this.items.push(item);
    this.elements.set(item, listItem);
  },

  /**
   * Remove an item from the popup list.
   *
   * @param {Object} item
   *        The item you want removed.
   */
  removeItem: function (item) {
    if (!this.items.includes(item)) {
      return;
    }

    let itemIndex = this.items.indexOf(item);
    let selectedIndex = this.selectedIndex;

    // Remove autocomplete item.
    this.items.splice(itemIndex, 1);

    // Remove corresponding DOM element from the elements WeakMap and from the DOM.
    let elementToRemove = this.elements.get(item);
    this.elements.delete(elementToRemove);
    elementToRemove.remove();

    if (itemIndex <= selectedIndex) {
      // If the removed item index was before or equal to the selected index, shift the
      // selected index by 1.
      this.selectedIndex = Math.max(0, selectedIndex - 1);
    }
  },

  /**
   * Getter for the number of items in the popup.
   * @type {Number}
   */
  get itemCount() {
    return this.items.length;
  },

  /**
   * Getter for the height of each item in the list.
   *
   * @type {Number}
   */
  get _itemsPerPane() {
    if (this.items.length) {
      let listHeight = this._tooltip.panel.clientHeight;
      let element = this.elements.get(this.items[0]);
      let elementHeight = element.getBoundingClientRect().height;
      return Math.floor(listHeight / elementHeight);
    }
    return 0;
  },

  /**
   * Select the next item in the list.
   *
   * @return {Object}
   *         The newly selected item object.
   */
  selectNextItem: function () {
    if (this.selectedIndex < (this.items.length - 1)) {
      this.selectedIndex++;
    } else {
      this.selectedIndex = 0;
    }
    return this.selectedItem;
  },

  /**
   * Select the previous item in the list.
   *
   * @return {Object}
   *         The newly-selected item object.
   */
  selectPreviousItem: function () {
    if (this.selectedIndex > 0) {
      this.selectedIndex--;
    } else {
      this.selectedIndex = this.items.length - 1;
    }

    return this.selectedItem;
  },

  /**
   * Select the top-most item in the next page of items or
   * the last item in the list.
   *
   * @return {Object}
   *         The newly-selected item object.
   */
  selectNextPageItem: function () {
    let nextPageIndex = this.selectedIndex + this._itemsPerPane + 1;
    this.selectedIndex = Math.min(nextPageIndex, this.itemCount - 1);
    return this.selectedItem;
  },

  /**
   * Select the bottom-most item in the previous page of items,
   * or the first item in the list.
   *
   * @return {Object}
   *         The newly-selected item object.
   */
  selectPreviousPageItem: function () {
    let prevPageIndex = this.selectedIndex - this._itemsPerPane - 1;
    this.selectedIndex = Math.max(prevPageIndex, 0);
    return this.selectedItem;
  },

  /**
   * Manages theme switching for the popup based on the devtools.theme pref.
   *
   * @private
   *
   * @param {String} event
   *        The name of the event. In this case, "pref-changed".
   * @param {Object} data
   *        An object passed by the emitter of the event. In this case, the
   *        object consists of three properties:
   *        - pref {String} The name of the preference that was modified.
   *        - newValue {Object} The new value of the preference.
   *        - oldValue {Object} The old value of the preference.
   */
  _handleThemeChange: function (event, data) {
    if (data.pref === "devtools.theme") {
      this._tooltip.panel.classList.toggle(data.oldValue + "-theme", false);
      this._tooltip.panel.classList.toggle(data.newValue + "-theme", true);
      this._list.classList.toggle(data.oldValue + "-theme", false);
      this._list.classList.toggle(data.newValue + "-theme", true);
    }
  },

  /**
   * Used by tests.
   */
  get _panel() {
    return this._tooltip.panel;
  },

  /**
   * Used by tests.
   */
  get _window() {
    return this._document.defaultView;
  },
};
