/* 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 ClassList = require("resource://devtools/client/inspector/rules/models/class-list.js");

const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
const L10N = new LocalizationHelper(
  "devtools/client/locales/inspector.properties"
);
const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js");
const { debounce } = require("resource://devtools/shared/debounce.js");

/**
 * This UI widget shows a textfield and a series of checkboxes in the rule-view. It is
 * used to toggle classes on the current node selection, and add new classes.
 */
class ClassListPreviewer {
  /**
   * @param {Inspector} inspector
   *        The current inspector instance.
   * @param {DomNode} containerEl
   *        The element in the rule-view where the widget should go.
   */
  constructor(inspector, containerEl) {
    this.inspector = inspector;
    this.containerEl = containerEl;
    this.model = new ClassList(inspector);

    this.onNewSelection = this.onNewSelection.bind(this);
    this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
    this.onKeyDown = this.onKeyDown.bind(this);
    this.onAddElementInputModified = debounce(
      this.onAddElementInputModified,
      75,
      this
    );
    this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
    this.onNodeFrontWillUnset = this.onNodeFrontWillUnset.bind(this);
    this.onAutocompleteClassHovered = debounce(
      this.onAutocompleteClassHovered,
      75,
      this
    );
    this.onAutocompleteClosed = this.onAutocompleteClosed.bind(this);

    // Create the add class text field.
    this.addEl = this.doc.createElement("input");
    this.addEl.classList.add("devtools-textinput");
    this.addEl.classList.add("add-class");
    this.addEl.setAttribute(
      "placeholder",
      L10N.getStr("inspector.classPanel.newClass.placeholder")
    );
    this.addEl.addEventListener("keydown", this.onKeyDown);
    this.addEl.addEventListener("input", this.onAddElementInputModified);
    this.containerEl.appendChild(this.addEl);

    // Create the class checkboxes container.
    this.classesEl = this.doc.createElement("div");
    this.classesEl.classList.add("classes");
    this.containerEl.appendChild(this.classesEl);

    // Create the autocomplete popup
    this.autocompletePopup = new AutocompletePopup(this.inspector.toolbox.doc, {
      listId: "inspector_classListPreviewer_autocompletePopupListBox",
      position: "bottom",
      autoSelect: true,
      useXulWrapper: true,
      input: this.addEl,
      onClick: (e, item) => {
        if (item) {
          this.addEl.value = item.label;
          this.autocompletePopup.hidePopup();
          this.autocompletePopup.clearItems();
          this.model.previewClass(item.label);
        }
      },
      onSelect: item => {
        if (item) {
          this.onAutocompleteClassHovered(item?.label);
        }
      },
    });

    // Start listening for interesting events.
    this.inspector.selection.on("new-node-front", this.onNewSelection);
    this.inspector.selection.on(
      "node-front-will-unset",
      this.onNodeFrontWillUnset
    );
    this.containerEl.addEventListener("input", this.onCheckBoxChanged);
    this.model.on("current-node-class-changed", this.onCurrentNodeClassChanged);
    this.autocompletePopup.on("popup-closed", this.onAutocompleteClosed);

    this.onNewSelection();
  }

  destroy() {
    this.inspector.selection.off("new-node-front", this.onNewSelection);
    this.inspector.selection.off(
      "node-front-will-unset",
      this.onNodeFrontWillUnset
    );
    this.autocompletePopup.off("popup-closed", this.onAutocompleteClosed);
    this.addEl.removeEventListener("keydown", this.onKeyDown);
    this.addEl.removeEventListener("input", this.onAddElementInputModified);
    this.containerEl.removeEventListener("input", this.onCheckBoxChanged);

    this.autocompletePopup.destroy();

    this.containerEl.innerHTML = "";

    this.model.destroy();
    this.containerEl = null;
    this.inspector = null;
    this.addEl = null;
    this.classesEl = null;
  }

  get doc() {
    return this.containerEl.ownerDocument;
  }

  /**
   * Render the content of the panel. You typically don't need to call this as the panel
   * renders itself on inspector selection changes.
   */
  render() {
    this.classesEl.innerHTML = "";

    for (const { name, isApplied } of this.model.currentClasses) {
      const checkBox = this.renderCheckBox(name, isApplied);
      this.classesEl.appendChild(checkBox);
    }

    if (!this.model.currentClasses.length) {
      this.classesEl.appendChild(this.renderNoClassesMessage());
    }
  }

  /**
   * Render a single checkbox for a given classname.
   *
   * @param {string} name
   *        The name of this class.
   * @param {boolean} isApplied
   *        Is this class currently applied on the DOM node.
   * @return {DOMNode} The DOM element for this checkbox.
   */
  renderCheckBox(name, isApplied) {
    const box = this.doc.createElement("input");
    box.setAttribute("type", "checkbox");
    if (isApplied) {
      box.setAttribute("checked", "checked");
    }
    box.dataset.name = name;

    const labelWrapper = this.doc.createElement("label");
    labelWrapper.setAttribute("title", name);
    labelWrapper.appendChild(box);

    // A child element is required to do the ellipsis.
    const label = this.doc.createElement("span");
    label.textContent = name;
    labelWrapper.appendChild(label);

    return labelWrapper;
  }

  /**
   * Render the message displayed in the panel when the current element has no classes.
   *
   * @return {DOMNode} The DOM element for the message.
   */
  renderNoClassesMessage() {
    const msg = this.doc.createElement("p");
    msg.classList.add("no-classes");
    msg.textContent = L10N.getStr("inspector.classPanel.noClasses");
    return msg;
  }

  /**
   * Focus the add-class text field.
   */
  focusAddClassField() {
    if (this.addEl) {
      this.addEl.focus();
    }
  }

  onCheckBoxChanged({ target }) {
    if (!target.dataset.name) {
      return;
    }

    this.model.setClassState(target.dataset.name, target.checked).catch(e => {
      // Only log the error if the panel wasn't destroyed in the meantime.
      if (this.containerEl) {
        console.error(e);
      }
    });
  }

  onKeyDown(event) {
    // If the popup is already open, all the keyboard interaction are handled
    // directly by the popup component.
    if (this.autocompletePopup.isOpen) {
      return;
    }

    // Open the autocomplete popup on Ctrl+Space / ArrowDown (when the input isn't empty)
    if (
      (this.addEl.value && event.key === " " && event.ctrlKey) ||
      event.key === "ArrowDown"
    ) {
      this.onAddElementInputModified();
      return;
    }

    if (this.addEl.value !== "" && event.key === "Enter") {
      this.addClassName(this.addEl.value);
    }
  }

  async onAddElementInputModified() {
    const newValue = this.addEl.value;

    // if the input is empty, let's close the popup, if it was open.
    if (newValue === "") {
      if (this.autocompletePopup.isOpen) {
        this.autocompletePopup.hidePopup();
        this.autocompletePopup.clearItems();
      } else {
        this.model.previewClass("");
      }
      return;
    }

    // Otherwise, we need to update the popup items to match the new input.
    let items = [];
    try {
      const classNames = await this.model.getClassNames(newValue);
      if (!this.autocompletePopup.isOpen) {
        this._previewClassesBeforeAutocompletion =
          this.model.previewClasses.map(previewClass => previewClass.className);
      }
      items = classNames.map(className => {
        return {
          preLabel: className.substring(0, newValue.length),
          label: className,
        };
      });
    } catch (e) {
      // If there was an error while retrieving the classNames, we'll simply NOT show the
      // popup, which is okay.
      console.warn("Error when calling getClassNames", e);
    }

    if (!items.length || (items.length == 1 && items[0].label === newValue)) {
      this.autocompletePopup.clearItems();
      await this.autocompletePopup.hidePopup();
      this.model.previewClass(newValue);
    } else {
      this.autocompletePopup.setItems(items);
      this.autocompletePopup.openPopup();
    }
  }

  async addClassName(className) {
    try {
      await this.model.addClassName(className);
      this.render();
      this.addEl.value = "";
    } catch (e) {
      // Only log the error if the panel wasn't destroyed in the meantime.
      if (this.containerEl) {
        console.error(e);
      }
    }
  }

  onNewSelection() {
    this.render();
  }

  onCurrentNodeClassChanged() {
    this.render();
  }

  onNodeFrontWillUnset() {
    this.model.eraseClassPreview();
    this.addEl.value = "";
  }

  onAutocompleteClassHovered(autocompleteItemLabel = "") {
    if (this.autocompletePopup.isOpen) {
      this.model.previewClass(autocompleteItemLabel);
    }
  }

  onAutocompleteClosed() {
    const inputValue = this.addEl.value;
    this.model.previewClass(inputValue);
  }
}

module.exports = ClassListPreviewer;
