/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");

/**
 * A tree widget with keyboard navigation and collapsable structure.
 */
class TreeWidget extends EventEmitter {
  /**
   * @param {Node} node
   *        The container element for the tree widget.
   * @param {object} options
   * @param {string} [options.emptyText]  text to display when no entries in the table.
   * @param {string} options.defaultType  The default type of the tree items. For ex.
   *  'js'
   * @param {boolean} [options.sorted]  Defaults to true. If true, tree items are kept in
   *  lexical order. If false, items will be kept in insertion order.
   * @param {string} [options.contextMenuId] ID of context menu to be displayed on
   *  tree items.
   */
  constructor(node, options = {}) {
    super();

    this.document = node.ownerDocument;
    this.window = this.document.defaultView;
    this._parent = node;

    this.emptyText = options.emptyText || "";
    this.defaultType = options.defaultType;
    this.sorted = options.sorted !== false;
    this.contextMenuId = options.contextMenuId;

    this.setupRoot();

    this.placeholder = this.document.createElementNS(HTML_NS, "label");
    this.placeholder.className = "tree-widget-empty-text";
    this._parent.appendChild(this.placeholder);

    if (this.emptyText) {
      this.setPlaceholderText(this.emptyText);
    }
    // A map to hold all the passed attachment to each leaf in the tree.
    this.attachments = new Map();
  }

  _selectedLabel = null;
  _selectedItem = null;
  /**
   * Select any node in the tree.
   *
   * @param {Array} ids
   *        An array of ids leading upto the selected item
   */
  set selectedItem(ids) {
    if (this._selectedLabel) {
      this._selectedLabel.classList.remove("theme-selected");
    }
    const currentSelected = this._selectedLabel;
    if (ids == -1) {
      this._selectedLabel = this._selectedItem = null;
      return;
    }
    if (!Array.isArray(ids)) {
      return;
    }
    this._selectedLabel = this.root.setSelectedItem(ids);
    if (!this._selectedLabel) {
      this._selectedItem = null;
    } else {
      if (currentSelected != this._selectedLabel) {
        this.ensureSelectedVisible();
      }
      this._selectedItem = ids;
      this.emit(
        "select",
        this._selectedItem,
        this.attachments.get(JSON.stringify(ids))
      );
    }
  }

  /**
   * Gets the selected item in the tree.
   *
   * @return {Array}
   *        An array of ids leading upto the selected item
   */
  get selectedItem() {
    return this._selectedItem;
  }

  /**
   * Returns if the passed array corresponds to the selected item in the tree.
   *
   * @return {Array}
   *        An array of ids leading upto the requested item
   */
  isSelected(item) {
    if (!this._selectedItem || this._selectedItem.length != item.length) {
      return false;
    }

    for (let i = 0; i < this._selectedItem.length; i++) {
      if (this._selectedItem[i] != item[i]) {
        return false;
      }
    }

    return true;
  }

  destroy() {
    this.root.remove();
    this.root = null;
  }

  /**
   * Sets up the root container of the TreeWidget.
   */
  setupRoot() {
    this.root = new TreeItem(this.document);
    if (this.contextMenuId) {
      this.root.children.addEventListener("contextmenu", event => {
        // Call stopPropagation() and preventDefault() here so that avoid to show default
        // context menu in about:devtools-toolbox. See Bug 1515265.
        event.stopPropagation();
        event.preventDefault();
        const menu = this.document.getElementById(this.contextMenuId);
        menu.openPopupAtScreen(event.screenX, event.screenY, true);
      });
    }

    this._parent.appendChild(this.root.children);

    this.root.children.addEventListener("mousedown", e => this.onClick(e));
    this.root.children.addEventListener("keydown", e => this.onKeydown(e));
  }

  /**
   * Sets the text to be shown when no node is present in the tree.
   * The placeholder will be hidden if text is empty.
   */
  setPlaceholderText(text) {
    this.placeholder.textContent = text;
    if (text) {
      this.placeholder.removeAttribute("hidden");
    } else {
      this.placeholder.setAttribute("hidden", "true");
    }
  }

  /**
   * Select any node in the tree.
   *
   * @param {Array} id
   *        An array of ids leading upto the selected item
   */
  selectItem(id) {
    this.selectedItem = id;
  }

  /**
   * Selects the next visible item in the tree.
   */
  selectNextItem() {
    const next = this.getNextVisibleItem();
    if (next) {
      this.selectedItem = next;
    }
  }

  /**
   * Selects the previos visible item in the tree
   */
  selectPreviousItem() {
    const prev = this.getPreviousVisibleItem();
    if (prev) {
      this.selectedItem = prev;
    }
  }

  /**
   * Returns the next visible item in the tree
   */
  getNextVisibleItem() {
    let node = this._selectedLabel;
    if (node.hasAttribute("expanded") && node.nextSibling.firstChild) {
      return JSON.parse(node.nextSibling.firstChild.getAttribute("data-id"));
    }
    node = node.parentNode;
    if (node.nextSibling) {
      return JSON.parse(node.nextSibling.getAttribute("data-id"));
    }
    node = node.parentNode;
    while (node.parentNode && node != this.root.children) {
      if (node.parentNode?.nextSibling) {
        return JSON.parse(node.parentNode.nextSibling.getAttribute("data-id"));
      }
      node = node.parentNode;
    }
    return null;
  }

  /**
   * Returns the previous visible item in the tree
   */
  getPreviousVisibleItem() {
    let node = this._selectedLabel.parentNode;
    if (node.previousSibling) {
      node = node.previousSibling.firstChild;
      while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) {
        if (!node.nextSibling.lastChild) {
          break;
        }
        node = node.nextSibling.lastChild.firstChild;
      }
      return JSON.parse(node.parentNode.getAttribute("data-id"));
    }
    node = node.parentNode;
    if (node.parentNode && node != this.root.children) {
      node = node.parentNode;
      while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) {
        if (!node.nextSibling.firstChild) {
          break;
        }
        node = node.nextSibling.firstChild.firstChild;
      }
      return JSON.parse(node.getAttribute("data-id"));
    }
    return null;
  }

  clearSelection() {
    this.selectedItem = -1;
  }

  /**
   * Adds an item in the tree. The item can be added as a child to any node in
   * the tree. The method will also create any subnode not present in the
   * process.
   *
   * @param {[string|object]} items
   *        An array of either string or objects where each increasing index
   *        represents an item corresponding to an equivalent depth in the tree.
   *        Each array element can be either just a string with the value as the
   *        id of of that item as well as the display value, or it can be an
   *        object with the following propeties:
   *          - id {string} The id of the item
   *          - label {string} The display value of the item
   *          - node {DOMNode} The dom node if you want to insert some custom
   *                 element as the item. The label property is not used in this
   *                 case
   *          - attachment {object} Any object to be associated with this item.
   *          - type {string} The type of this particular item. If this is null,
   *                 then defaultType will be used.
   *        For example, if items = ["foo", "bar", { id: "id1", label: "baz" }]
   *        and the tree is empty, then the following hierarchy will be created
   *        in the tree:
   *        foo
   *         └ bar
   *            └ baz
   *        Passing the string id instead of the complete object helps when you
   *        are simply adding children to an already existing node and you know
   *        its id.
   */
  add(items) {
    this.root.add(items, this.defaultType, this.sorted);
    for (let i = 0; i < items.length; i++) {
      if (items[i].attachment) {
        this.attachments.set(
          JSON.stringify(items.slice(0, i + 1).map(item => item.id || item)),
          items[i].attachment
        );
      }
    }
    // Empty the empty-tree-text
    this.setPlaceholderText("");
  }

  /**
   * Check if an item exists.
   *
   * @param {Array} item
   *        The array of ids leading up to the item.
   */
  exists(item) {
    let bookmark = this.root;

    for (const id of item) {
      if (bookmark.items.has(id)) {
        bookmark = bookmark.items.get(id);
      } else {
        return false;
      }
    }
    return true;
  }

  /**
   * Removes the specified item and all of its child items from the tree.
   *
   * @param {Array} item
   *        The array of ids leading up to the item.
   */
  remove(item) {
    this.root.remove(item);
    this.attachments.delete(JSON.stringify(item));
    // Display the empty tree text
    if (this.root.items.size == 0 && this.emptyText) {
      this.setPlaceholderText(this.emptyText);
    }
  }

  /**
   * Removes all of the child nodes from this tree.
   */
  clear() {
    this.root.remove();
    this.setupRoot();
    this.attachments.clear();
    if (this.emptyText) {
      this.setPlaceholderText(this.emptyText);
    }
  }

  /**
   * Expands the tree completely
   */
  expandAll() {
    this.root.expandAll();
  }

  /**
   * Collapses the tree completely
   */
  collapseAll() {
    this.root.collapseAll();
  }

  /**
   * Click handler for the tree. Used to select, open and close the tree nodes.
   */
  onClick(event) {
    let target = event.originalTarget;
    while (target && !target.classList.contains("tree-widget-item")) {
      if (target == this.root.children) {
        return;
      }
      target = target.parentNode;
    }
    if (!target) {
      return;
    }

    if (target.hasAttribute("expanded")) {
      target.removeAttribute("expanded");
    } else {
      target.setAttribute("expanded", "true");
    }

    if (this._selectedLabel != target) {
      const ids = target.parentNode.getAttribute("data-id");
      this.selectedItem = JSON.parse(ids);
    }
  }

  /**
   * Keydown handler for this tree. Used to select next and previous visible
   * items, as well as collapsing and expanding any item.
   */
  onKeydown(event) {
    switch (event.keyCode) {
      case KeyCodes.DOM_VK_UP:
        this.selectPreviousItem();
        break;

      case KeyCodes.DOM_VK_DOWN:
        this.selectNextItem();
        break;

      case KeyCodes.DOM_VK_RIGHT:
        if (this._selectedLabel.hasAttribute("expanded")) {
          this.selectNextItem();
        } else {
          this._selectedLabel.setAttribute("expanded", "true");
        }
        break;

      case KeyCodes.DOM_VK_LEFT:
        if (
          this._selectedLabel.hasAttribute("expanded") &&
          !this._selectedLabel.hasAttribute("empty")
        ) {
          this._selectedLabel.removeAttribute("expanded");
        } else {
          this.selectPreviousItem();
        }
        break;

      default:
        return;
    }
    event.preventDefault();
  }

  /**
   * Scrolls the viewport of the tree so that the selected item is always
   * visible.
   */
  ensureSelectedVisible() {
    const { top, bottom } = this._selectedLabel.getBoundingClientRect();
    const height = this.root.children.parentNode.clientHeight;
    if (top < 0) {
      this._selectedLabel.scrollIntoView();
    } else if (bottom > height) {
      this._selectedLabel.scrollIntoView(false);
    }
  }
}

module.exports.TreeWidget = TreeWidget;

/**
 * Any item in the tree. This can be an empty leaf node also.
 */
class TreeItem {
  /**
   * @param {HTMLDocument} document
   *        The document element used for creating new nodes.
   * @param {TreeItem} parent
   *        The parent item for this item.
   * @param {string|DOMElement} label
   *        Either the dom node to be used as the item, or the string to be
   *        displayed for this node in the tree
   * @param {string} type
   *        The type of the current node. For ex. "js"
   */
  constructor(document, parent, label, type) {
    this.document = document;
    this.node = this.document.createElementNS(HTML_NS, "li");
    this.node.setAttribute("tabindex", "0");
    this.isRoot = !parent;
    this.parent = parent;
    if (this.parent) {
      this.level = this.parent.level + 1;
    }
    if (label) {
      this.label = this.document.createElementNS(HTML_NS, "div");
      this.label.setAttribute("empty", "true");
      this.label.setAttribute("level", this.level);
      this.label.className = "tree-widget-item";
      if (type) {
        this.label.setAttribute("type", type);
      }
      if (typeof label == "string") {
        this.label.textContent = label;
      } else {
        this.label.appendChild(label);
      }
      this.node.appendChild(this.label);
    }
    this.children = this.document.createElementNS(HTML_NS, "ul");
    if (this.isRoot) {
      this.children.className = "tree-widget-container";
    } else {
      this.children.className = "tree-widget-children";
    }
    this.node.appendChild(this.children);
    this.items = new Map();
  }

  items = null;

  isSelected = false;

  expanded = false;

  isRoot = false;

  parent = null;

  children = null;

  level = 0;

  /**
   * Adds the item to the sub tree contained by this node. The item to be
   * inserted can be a direct child of this node, or further down the tree.
   *
   * @param {Array} items
   *        Same as TreeWidget.add method's argument
   * @param {string} defaultType
   *        The default type of the item to be used when items[i].type is null
   * @param {boolean} sorted
   *        true if the tree items are inserted in a lexically sorted manner.
   *        Otherwise, false if the item are to be appended to their parent.
   */
  add(items, defaultType, sorted) {
    if (items.length == this.level) {
      // This is the exit condition of recursive TreeItem.add calls
      return;
    }
    // Get the id and label corresponding to this level inside the tree.
    const id = items[this.level].id || items[this.level];
    if (this.items.has(id)) {
      // An item with same id already exists, thus calling the add method of
      // that child to add the passed node at correct position.
      this.items.get(id).add(items, defaultType, sorted);
      return;
    }
    // No item with the id `id` exists, so we create one and call the add
    // method of that item.
    // The display string of the item can be the label, the id, or the item
    // itself if its a plain string.
    let label =
      items[this.level].label || items[this.level].id || items[this.level];
    const node = items[this.level].node;
    if (node) {
      // The item is supposed to be a DOMNode, so we fetch the textContent in
      // order to find the correct sorted location of this new item.
      label = node.textContent;
    }
    const treeItem = new TreeItem(
      this.document,
      this,
      node || label,
      items[this.level].type || defaultType
    );

    treeItem.add(items, defaultType, sorted);
    treeItem.node.setAttribute(
      "data-id",
      JSON.stringify(
        items.slice(0, this.level + 1).map(item => item.id || item)
      )
    );

    if (sorted) {
      // Inserting this newly created item at correct position
      const nextSibling = [...this.items.values()].find(child => {
        return child.label.textContent >= label;
      });

      if (nextSibling) {
        this.children.insertBefore(treeItem.node, nextSibling.node);
      } else {
        this.children.appendChild(treeItem.node);
      }
    } else {
      this.children.appendChild(treeItem.node);
    }

    if (this.label) {
      this.label.removeAttribute("empty");
    }
    this.items.set(id, treeItem);
  }

  /**
   * If this item is to be removed, then removes this item and thus all of its
   * subtree. Otherwise, call the remove method of appropriate child. This
   * recursive method goes on till we have reached the end of the branch or the
   * current item is to be removed.
   *
   * @param {Array} items
   *        Ids of items leading up to the item to be removed.
   */
  remove(items = []) {
    const id = items.shift();
    if (id && this.items.has(id)) {
      const deleted = this.items.get(id);
      if (!items.length) {
        this.items.delete(id);
      }
      if (this.items.size == 0) {
        this.label.setAttribute("empty", "true");
      }
      deleted.remove(items);
    } else if (!id) {
      this.destroy();
    }
  }

  /**
   * If this item is to be selected, then selected and expands the item.
   * Otherwise, if a child item is to be selected, just expands this item.
   *
   * @param {Array} items
   *        Ids of items leading up to the item to be selected.
   */
  setSelectedItem(items) {
    if (!items[this.level]) {
      this.label.classList.add("theme-selected");
      this.label.setAttribute("expanded", "true");
      return this.label;
    }
    if (this.items.has(items[this.level])) {
      const label = this.items.get(items[this.level]).setSelectedItem(items);
      if (label && this.label) {
        this.label.setAttribute("expanded", true);
      }
      return label;
    }
    return null;
  }

  /**
   * Collapses this item and all of its sub tree items
   */
  collapseAll() {
    if (this.label) {
      this.label.removeAttribute("expanded");
    }
    for (const child of this.items.values()) {
      child.collapseAll();
    }
  }

  /**
   * Expands this item and all of its sub tree items
   */
  expandAll() {
    if (this.label) {
      this.label.setAttribute("expanded", "true");
    }
    for (const child of this.items.values()) {
      child.expandAll();
    }
  }

  destroy() {
    this.children.remove();
    this.node.remove();
    this.label = null;
    this.items = null;
    this.children = null;
  }
}
