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

/* eslint no-shadow: ["error", { "allow": ["open", "parent"] }] */

import React from "resource://devtools/client/shared/vendor/react.mjs";
import ReactDOM from "resource://devtools/client/shared/vendor/react-dom.mjs";
import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types.mjs";
import * as dom from "resource://devtools/client/shared/vendor/react-dom-factories.mjs";

import { ObjectProvider } from "resource://devtools/client/shared/components/tree/ObjectProvider.mjs";
import TreeRowClass from "resource://devtools/client/shared/components/tree/TreeRow.mjs";
import TreeHeaderClass from "resource://devtools/client/shared/components/tree/TreeHeader.mjs";

import { scrollIntoView } from "resource://devtools/client/shared/scroll.mjs";

const { cloneElement, Component, createFactory, createRef } = React;
const { findDOMNode } = ReactDOM;

const TreeRow = createFactory(TreeRowClass);
const TreeHeader = createFactory(TreeHeaderClass);

const SUPPORTED_KEYS = [
  "ArrowUp",
  "ArrowDown",
  "ArrowLeft",
  "ArrowRight",
  "End",
  "Home",
  "Enter",
  " ",
  "Escape",
];

// Use a function in order to avoid shared Set/Array between each instance!
function getDefaultProps() {
  return {
    object: null,
    renderRow: null,
    provider: ObjectProvider,
    expandedNodes: new Set(),
    selected: null,
    defaultSelectFirstNode: true,
    active: null,
    expandableStrings: true,
    bucketLargeArrays: false,
    maxStringLength: 50,
    columns: [],
  };
}

/**
 * This component represents a tree view with expandable/collapsible nodes.
 * The tree is rendered using <table> element where every node is represented
 * by <tr> element. The tree is one big table where nodes (rows) are properly
 * indented from the left to mimic hierarchical structure of the data.
 *
 * The tree can have arbitrary number of columns and so, might be use
 * as an expandable tree-table UI widget as well. By default, there is
 * one column for node label and one for node value.
 *
 * The tree is maintaining its (presentation) state, which consists
 * from list of expanded nodes and list of columns.
 *
 * Complete data provider interface:
 * var TreeProvider = {
 *   getChildren: function(object);
 *   hasChildren: function(object);
 *   getLabel: function(object, colId);
 *   getLevel: function(object); // optional
 *   getValue: function(object, colId);
 *   getKey: function(object);
 *   getType: function(object);
 * }
 *
 * Complete tree decorator interface:
 * var TreeDecorator = {
 *   getRowClass: function(object);
 *   getCellClass: function(object, colId);
 *   getHeaderClass: function(colId);
 *   renderValue: function(object, colId);
 *   renderRow: function(object);
 *   renderCell: function(object, colId);
 *   renderLabelCell: function(object);
 * }
 */
class TreeView extends Component {
  // The only required property (not set by default) is the input data
  // object that is used to populate the tree.
  static get propTypes() {
    return {
      // The input data object.
      object: PropTypes.any,
      className: PropTypes.string,
      label: PropTypes.string,
      // Data provider (see also the interface above)
      provider: PropTypes.shape({
        getChildren: PropTypes.func,
        hasChildren: PropTypes.func,
        getLabel: PropTypes.func,
        getValue: PropTypes.func,
        getKey: PropTypes.func,
        getLevel: PropTypes.func,
        getType: PropTypes.func,
      }).isRequired,
      // Tree decorator (see also the interface above)
      decorator: PropTypes.shape({
        getRowClass: PropTypes.func,
        getCellClass: PropTypes.func,
        getHeaderClass: PropTypes.func,
        renderValue: PropTypes.func,
        renderRow: PropTypes.func,
        renderCell: PropTypes.func,
        renderLabelCell: PropTypes.func,
      }),
      // Custom tree row (node) renderer
      renderRow: PropTypes.func,
      // Custom cell renderer
      renderCell: PropTypes.func,
      // Custom value renderer
      renderValue: PropTypes.func,
      // Custom tree label (including a toggle button) renderer
      renderLabelCell: PropTypes.func,
      // Set of expanded nodes
      expandedNodes: PropTypes.object,
      // Selected node
      selected: PropTypes.string,
      // Select first node by default
      defaultSelectFirstNode: PropTypes.bool,
      // The currently active (keyboard) item, if any such item exists.
      active: PropTypes.string,
      // Custom filtering callback
      onFilter: PropTypes.func,
      // Custom sorting callback
      onSort: PropTypes.func,
      // Enable bucketing for large arrays
      bucketLargeArrays: PropTypes.bool,
      // Custom row click callback
      onClickRow: PropTypes.func,
      // Row context menu event handler
      onContextMenuRow: PropTypes.func,
      // Tree context menu event handler
      onContextMenuTree: PropTypes.func,
      // A header is displayed if set to true
      header: PropTypes.bool,
      // Long string is expandable by a toggle button
      expandableStrings: PropTypes.bool,
      // Length at which a string is considered long
      maxStringLength: PropTypes.number,
      // Array of columns
      columns: PropTypes.arrayOf(
        PropTypes.shape({
          id: PropTypes.string.isRequired,
          title: PropTypes.string,
          width: PropTypes.string,
        })
      ),
    };
  }

  static get defaultProps() {
    return getDefaultProps();
  }

  static subPath(path, subKey) {
    return path + "/" + String(subKey).replace(/[\\/]/g, "\\$&");
  }

  /**
   * Creates a set with the paths of the nodes that should be expanded by default
   * according to the passed options.
   *
   * @param {object} The root node of the tree.
   * @param {object} [optional] An object with the following optional parameters:
   *   - maxLevel: nodes nested deeper than this level won't be expanded.
   *   - maxNodes: maximum number of nodes that can be expanded. The traversal is
         breadth-first, so expanding nodes nearer to the root will be preferred.
         Sibling nodes will either be all expanded or none expanded.
   * }
   */
  static getExpandedNodes(
    rootObj,
    { maxLevel = Infinity, maxNodes = Infinity } = {}
  ) {
    const expandedNodes = new Set();
    const queue = [
      {
        object: rootObj,
        level: 1,
        path: "",
      },
    ];
    while (queue.length) {
      const { object, level, path } = queue.shift();
      if (Object(object) !== object) {
        continue;
      }
      const keys = Object.keys(object);
      if (expandedNodes.size + keys.length > maxNodes) {
        // Avoid having children half expanded.
        break;
      }
      for (const key of keys) {
        const nodePath = TreeView.subPath(path, key);
        expandedNodes.add(nodePath);
        if (level < maxLevel) {
          queue.push({
            object: object[key],
            level: level + 1,
            path: nodePath,
          });
        }
      }
    }
    return expandedNodes;
  }

  constructor(props) {
    super(props);

    this.state = {
      expandedNodes: props.expandedNodes,
      columns: ensureDefaultColumn(props.columns),
      selected: props.selected,
      active: props.active,
      lastSelectedIndex: props.defaultSelectFirstNode ? 0 : null,
      mouseDown: false,
    };

    this.treeRef = createRef();

    this.toggle = this.toggle.bind(this);
    this.isExpanded = this.isExpanded.bind(this);
    this.onFocus = this.onFocus.bind(this);
    this.onKeyDown = this.onKeyDown.bind(this);
    this.onClickRow = this.onClickRow.bind(this);
    this.getSelectedRow = this.getSelectedRow.bind(this);
    this.selectRow = this.selectRow.bind(this);
    this.activateRow = this.activateRow.bind(this);
    this.isSelected = this.isSelected.bind(this);
    this.onFilter = this.onFilter.bind(this);
    this.onSort = this.onSort.bind(this);
    this.getMembers = this.getMembers.bind(this);
    this.renderRows = this.renderRows.bind(this);
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillReceiveProps(nextProps) {
    const { expandedNodes, selected } = nextProps;
    const state = {
      expandedNodes,
      lastSelectedIndex: this.getSelectedRowIndex(),
    };

    if (selected) {
      state.selected = selected;
    }

    this.setState(Object.assign({}, this.state, state));
  }

  shouldComponentUpdate(nextProps, nextState) {
    const {
      expandedNodes,
      columns,
      selected,
      active,
      lastSelectedIndex,
      mouseDown,
    } = this.state;

    return (
      expandedNodes !== nextState.expandedNodes ||
      columns !== nextState.columns ||
      selected !== nextState.selected ||
      active !== nextState.active ||
      lastSelectedIndex !== nextState.lastSelectedIndex ||
      mouseDown === nextState.mouseDown
    );
  }

  componentDidUpdate() {
    const selected = this.getSelectedRow();
    if (selected || this.state.active) {
      return;
    }

    const rows = this.visibleRows;
    if (rows.length === 0) {
      return;
    }

    // Only select a row if there is a previous lastSelected Index
    // This mostly happens when the treeview is loaded the first time
    if (this.state.lastSelectedIndex !== null) {
      this.selectRow(
        rows[Math.min(this.state.lastSelectedIndex, rows.length - 1)],
        { alignTo: "top" }
      );
    }
  }

  /**
   * Get rows that are currently visible. Some rows can be filtered and made
   * invisible, in which case, when navigating around the tree we need to
   * ignore the ones that are not reachable by the user.
   */
  get visibleRows() {
    return this.rows.filter(row => {
      const rowEl = findDOMNode(row);
      return rowEl?.offsetParent;
    });
  }

  // Node expand/collapse

  toggle(nodePath) {
    const nodes = this.state.expandedNodes;
    if (this.isExpanded(nodePath)) {
      nodes.delete(nodePath);
    } else {
      nodes.add(nodePath);
    }

    // Compute new state and update the tree.
    this.setState(
      Object.assign({}, this.state, {
        expandedNodes: nodes,
      })
    );
  }

  isExpanded(nodePath) {
    return this.state.expandedNodes.has(nodePath);
  }

  // Event Handlers

  onFocus(_event) {
    if (this.state.mouseDown) {
      return;
    }
    // Set focus to the first element, if none is selected or activated
    // This is needed because keyboard navigation won't work without an element being selected
    this.componentDidUpdate();
  }

  // eslint-disable-next-line complexity
  onKeyDown(event) {
    const keyEligibleForFirstLetterNavigation = event.key.length === 1;
    if (
      (!SUPPORTED_KEYS.includes(event.key) &&
        !keyEligibleForFirstLetterNavigation) ||
      event.shiftKey ||
      event.ctrlKey ||
      event.metaKey ||
      event.altKey
    ) {
      return;
    }

    const row = this.getSelectedRow();
    if (!row) {
      return;
    }

    const rows = this.visibleRows;
    const index = rows.indexOf(row);
    const { hasChildren, open } = row.props.member;

    switch (event.key) {
      case "ArrowRight":
        if (hasChildren) {
          if (open) {
            const firstChildRow = this.rows
              .slice(index + 1)
              .find(r => r.props.member.level > row.props.member.level);
            if (firstChildRow) {
              this.selectRow(firstChildRow, { alignTo: "bottom" });
            }
          } else {
            this.toggle(this.state.selected);
          }
        }
        break;
      case "ArrowLeft":
        if (hasChildren && open) {
          this.toggle(this.state.selected);
        } else {
          const parentRow = rows
            .slice(0, index)
            .reverse()
            .find(r => r.props.member.level < row.props.member.level);
          if (parentRow) {
            this.selectRow(parentRow, { alignTo: "top" });
          }
        }
        break;
      case "ArrowDown": {
        const nextRow = rows[index + 1];
        if (nextRow) {
          this.selectRow(nextRow, { alignTo: "bottom" });
        }
        break;
      }
      case "ArrowUp": {
        const previousRow = rows[index - 1];
        if (previousRow) {
          this.selectRow(previousRow, { alignTo: "top" });
        }
        break;
      }
      case "Home": {
        const firstRow = rows[0];

        if (firstRow) {
          this.selectRow(firstRow, { alignTo: "top" });
        }
        break;
      }
      case "End": {
        const lastRow = rows[rows.length - 1];
        if (lastRow) {
          this.selectRow(lastRow, { alignTo: "bottom" });
        }
        break;
      }
      case "Enter":
      case " ":
        // On space or enter make selected row active. This means keyboard
        // focus handling is passed on to the tree row itself.
        if (this.treeRef.current === document.activeElement) {
          event.stopPropagation();
          event.preventDefault();
          if (this.state.active !== this.state.selected) {
            this.activateRow(this.state.selected);
          }

          return;
        }
        break;
      case "Escape":
        event.stopPropagation();
        if (this.state.active != null) {
          this.activateRow(null);
        }
        break;
    }

    if (keyEligibleForFirstLetterNavigation) {
      const next = rows
        .slice(index + 1)
        .find(r => r.props.member.name.startsWith(event.key));
      if (next) {
        this.selectRow(next, { alignTo: "bottom" });
      }
    }

    // Focus should always remain on the tree container itself.
    this.treeRef.current.focus();
    event.preventDefault();
  }

  onClickRow(nodePath, event) {
    const onClickRow = this.props.onClickRow;
    const row = this.visibleRows.find(r => r.props.member.path === nodePath);

    // Call custom click handler and bail out if it returns true.
    if (
      onClickRow &&
      onClickRow.call(this, nodePath, event, row.props.member)
    ) {
      return;
    }

    event.stopPropagation();

    const cell = event.target.closest("td");
    if (cell && cell.classList.contains("treeLabelCell")) {
      this.toggle(nodePath);
    }

    this.selectRow(row, { preventAutoScroll: true });
  }

  onContextMenu(member, event) {
    const onContextMenuRow = this.props.onContextMenuRow;
    if (onContextMenuRow) {
      onContextMenuRow.call(this, member, event);
    }
  }

  getSelectedRow() {
    const rows = this.visibleRows;
    if (!this.state.selected || rows.length === 0) {
      return null;
    }
    return rows.find(row => this.isSelected(row.props.member.path));
  }

  getSelectedRowIndex() {
    const row = this.getSelectedRow();
    if (!row) {
      return this.props.defaultSelectFirstNode ? 0 : null;
    }

    return this.visibleRows.indexOf(row);
  }

  _scrollIntoView(row, options = {}) {
    const treeEl = this.treeRef.current;
    if (!treeEl || !row) {
      return;
    }

    const { props: { member: { path } = {} } = {} } = row;
    if (!path) {
      return;
    }

    const element = treeEl.ownerDocument.getElementById(path);
    if (!element) {
      return;
    }

    scrollIntoView(element, { ...options });
  }

  selectRow(row, options = {}) {
    const { props: { member: { path } = {} } = {} } = row;
    if (this.isSelected(path)) {
      return;
    }

    if (this.state.active != null) {
      const treeEl = this.treeRef.current;
      if (treeEl && treeEl !== treeEl.ownerDocument.activeElement) {
        treeEl.focus();
      }
    }

    if (!options.preventAutoScroll) {
      this._scrollIntoView(row, options);
    }

    this.setState({
      ...this.state,
      selected: path,
      active: null,
    });
  }

  activateRow(active) {
    this.setState({
      ...this.state,
      active,
    });
  }

  isSelected(nodePath) {
    return nodePath === this.state.selected;
  }

  isActive(nodePath) {
    return nodePath === this.state.active;
  }

  // Filtering & Sorting

  /**
   * Filter out nodes that don't correspond to the current filter.
   *
   * @return {boolean} true if the node should be visible otherwise false.
   */
  onFilter(object) {
    const onFilter = this.props.onFilter;
    return onFilter ? onFilter(object) : true;
  }

  onSort(parent, children) {
    const onSort = this.props.onSort;
    return onSort ? onSort(parent, children) : children;
  }

  // Members

  /**
   * Return children node objects (so called 'members') for given
   * parent object.
   */
  getMembers(parent, level, path) {
    // Strings don't have children. Note that 'long' strings are using
    // the expander icon (+/-) to display the entire original value,
    // but there are no child items.
    if (typeof parent == "string") {
      return [];
    }

    const { expandableStrings, provider, bucketLargeArrays, maxStringLength } =
      this.props;
    let children = provider.getChildren(parent, { bucketLargeArrays }) || [];

    // If the return value is non-array, the children
    // are being loaded asynchronously.
    if (!Array.isArray(children)) {
      return children;
    }

    children = this.onSort(parent, children) || children;

    return children.map(child => {
      const key = provider.getKey(child);
      const nodePath = TreeView.subPath(path, key);
      const type = provider.getType(child);
      let hasChildren = provider.hasChildren(child);

      // Value with no column specified is used for optimization.
      // The row is re-rendered only if this value changes.
      // Value for actual column is get when a cell is rendered.
      const value = provider.getValue(child);

      if (expandableStrings && isLongString(value, maxStringLength)) {
        hasChildren = true;
      }

      // Return value is a 'member' object containing meta-data about
      // tree node. It describes node label, value, type, etc.
      return {
        // An object associated with this node.
        object: child,
        // A label for the child node
        name: provider.getLabel(child),
        // Data type of the child node (used for CSS customization)
        type,
        // Class attribute computed from the type.
        rowClass: "treeRow-" + type,
        // Level of the child within the hierarchy (top == 0)
        level: provider.getLevel ? provider.getLevel(child, level) : level,
        // True if this node has children.
        hasChildren,
        // Value associated with this node (as provided by the data provider)
        value,
        // True if the node is expanded.
        open: this.isExpanded(nodePath),
        // Node path
        path: nodePath,
        // True if the node is hidden (used for filtering)
        hidden: !this.onFilter(child),
        // True if the node is selected with keyboard
        selected: this.isSelected(nodePath),
        // True if the node is activated with keyboard
        active: this.isActive(nodePath),
      };
    });
  }

  /**
   * Render tree rows/nodes.
   */
  renderRows(parent, level = 0, path = "") {
    let rows = [];
    const decorator = this.props.decorator;
    let renderRow = this.props.renderRow || TreeRow;

    // Get children for given parent node, iterate over them and render
    // a row for every one. Use row template (a component) from properties.
    // If the return value is non-array, the children are being loaded
    // asynchronously.
    const members = this.getMembers(parent, level, path);
    if (!Array.isArray(members)) {
      return members;
    }

    members.forEach(member => {
      if (decorator?.renderRow) {
        renderRow = decorator.renderRow(member.object) || renderRow;
      }

      const props = Object.assign({}, this.props, {
        key: `${member.path}-${member.active ? "active" : "inactive"}`,
        member,
        columns: this.state.columns,
        id: member.path,
        ref: row => row && this.rows.push(row),
        onClick: this.onClickRow.bind(this, member.path),
        onContextMenu: this.onContextMenu.bind(this, member),
      });

      // Render single row.
      rows.push(renderRow(props));

      // If a child node is expanded render its rows too.
      if (member.hasChildren && member.open) {
        const childRows = this.renderRows(
          member.object,
          level + 1,
          member.path
        );

        // If children needs to be asynchronously fetched first,
        // set 'loading' property to the parent row. Otherwise
        // just append children rows to the array of all rows.
        if (!Array.isArray(childRows)) {
          const lastIndex = rows.length - 1;
          props.member.loading = true;
          rows[lastIndex] = cloneElement(rows[lastIndex], props);
        } else {
          rows = rows.concat(childRows);
        }
      }
    });

    return rows;
  }

  render() {
    const root = this.props.object;
    const classNames = ["treeTable"];
    this.rows = [];

    const { className, onContextMenuTree } = this.props;
    // Use custom class name from props.
    if (className) {
      classNames.push(...className.split(" "));
    }

    // Alright, let's render all tree rows. The tree is one big <table>.
    let rows = this.renderRows(root, 0, "");

    // This happens when the view needs to do initial asynchronous
    // fetch for the root object. The tree might provide a hook API
    // for rendering animated spinner (just like for tree nodes).
    if (!Array.isArray(rows)) {
      rows = [];
    }

    const props = Object.assign({}, this.props, {
      columns: this.state.columns,
    });

    return dom.table(
      {
        className: classNames.join(" "),
        role: "tree",
        ref: this.treeRef,
        tabIndex: 0,
        onFocus: this.onFocus,
        onKeyDown: this.onKeyDown,
        onContextMenu: onContextMenuTree && onContextMenuTree.bind(this),
        onMouseDown: () => this.setState({ mouseDown: true }),
        onMouseUp: () => this.setState({ mouseDown: false }),
        onClick: () => {
          // Focus should always remain on the tree container itself.
          this.treeRef.current.focus();
        },
        onBlur: event => {
          if (this.state.active != null) {
            const { relatedTarget } = event;
            if (!this.treeRef.current.contains(relatedTarget)) {
              this.activateRow(null);
            }
          }
        },
        "aria-label": this.props.label || "",
        "aria-activedescendant": this.state.selected,
        cellPadding: 0,
        cellSpacing: 0,
      },
      TreeHeader(props),
      dom.tbody(
        {
          role: "presentation",
          tabIndex: -1,
        },
        rows
      )
    );
  }
}

// Helpers

/**
 * There should always be at least one column (the one with toggle buttons)
 * and this function ensures that it's true.
 */
function ensureDefaultColumn(columns) {
  if (!columns) {
    columns = [];
  }

  const defaultColumn = columns.filter(col => col.id == "default");
  if (defaultColumn.length) {
    return columns;
  }

  // The default column is usually the first one.
  return [{ id: "default" }, ...columns];
}

function isLongString(value, maxStringLength) {
  return typeof value == "string" && value.length > maxStringLength;
}

// Exports from this module
export default TreeView;
