/* 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": ["MutationObserver"] }] */

import React from "resource://devtools/client/shared/vendor/react.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 ReactDOM from "resource://devtools/client/shared/vendor/react-dom.mjs";

import TreeCellClass from "resource://devtools/client/shared/components/tree/TreeCell.mjs";
import LabelCellClass from "resource://devtools/client/shared/components/tree/LabelCell.mjs";

import {
  wrapMoveFocus,
  getFocusableElements,
} from "resource://devtools/client/shared/focus.mjs";

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

// Tree
const TreeCell = createFactory(TreeCellClass);
const LabelCell = createFactory(LabelCellClass);

const UPDATE_ON_PROPS = [
  "name",
  "open",
  "value",
  "loading",
  "level",
  "selected",
  "active",
  "hasChildren",
];

/**
 * This template represents a node in TreeView component. It's rendered
 * using <tr> element (the entire tree is one big <table>).
 */
class TreeRow extends Component {
  // See TreeView component for more details about the props and
  // the 'member' object.
  static get propTypes() {
    return {
      member: PropTypes.shape({
        object: PropTypes.object,
        name: PropTypes.string,
        type: PropTypes.string.isRequired,
        rowClass: PropTypes.string.isRequired,
        level: PropTypes.number.isRequired,
        hasChildren: PropTypes.bool,
        value: PropTypes.any,
        open: PropTypes.bool.isRequired,
        path: PropTypes.string.isRequired,
        hidden: PropTypes.bool,
        selected: PropTypes.bool,
        active: PropTypes.bool,
        loading: PropTypes.bool,
      }),
      decorator: PropTypes.object,
      renderCell: PropTypes.func,
      renderLabelCell: PropTypes.func,
      columns: PropTypes.array.isRequired,
      id: PropTypes.string.isRequired,
      provider: PropTypes.object.isRequired,
      onClick: PropTypes.func.isRequired,
      onContextMenu: PropTypes.func,
      onMouseOver: PropTypes.func,
      onMouseOut: PropTypes.func,
    };
  }

  constructor(props) {
    super(props);

    this.treeRowRef = createRef();

    this.getRowClass = this.getRowClass.bind(this);
    this._onKeyDown = this._onKeyDown.bind(this);
  }

  componentDidMount() {
    this._setTabbableState();

    // Child components might add/remove new focusable elements, watch for the
    // additions/removals of descendant nodes and update focusable state.
    const win = this.treeRowRef.current.ownerDocument.defaultView;
    const { MutationObserver } = win;
    this.observer = new MutationObserver(() => {
      this._setTabbableState();
    });
    this.observer.observe(this.treeRowRef.current, {
      childList: true,
      subtree: true,
    });
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillReceiveProps(nextProps) {
    // I don't like accessing the underlying DOM elements directly,
    // but this optimization makes the filtering so damn fast!
    // The row doesn't have to be re-rendered, all we really need
    // to do is toggling a class name.
    // The important part is that DOM elements don't need to be
    // re-created when they should appear again.
    if (nextProps.member.hidden != this.props.member.hidden) {
      const row = findDOMNode(this);
      row.classList.toggle("hidden");
    }
  }

  /**
   * Optimize row rendering. If props are the same do not render.
   * This makes the rendering a lot faster!
   */
  shouldComponentUpdate(nextProps) {
    for (const prop of UPDATE_ON_PROPS) {
      if (nextProps.member[prop] !== this.props.member[prop]) {
        return true;
      }
    }

    return false;
  }

  componentWillUnmount() {
    this.observer.disconnect();
    this.observer = null;
  }

  /**
   * Makes sure that none of the focusable elements inside the row container
   * are tabbable if the row is not active. If the row is active and focus
   * is outside its container, focus on the first focusable element inside.
   */
  _setTabbableState() {
    const elms = getFocusableElements(this.treeRowRef.current);
    if (elms.length === 0) {
      return;
    }

    const { active } = this.props.member;
    if (!active) {
      elms.forEach(elm => elm.setAttribute("tabindex", "-1"));
      return;
    }

    if (!elms.includes(document.activeElement)) {
      elms[0].focus();
    }
  }

  _onKeyDown(e) {
    const { target, key, shiftKey } = e;

    if (key !== "Tab") {
      return;
    }

    const focusMoved = !!wrapMoveFocus(
      getFocusableElements(this.treeRowRef.current),
      target,
      shiftKey
    );
    if (focusMoved) {
      // Focus was moved to the begining/end of the list, so we need to
      // prevent the default focus change that would happen here.
      e.preventDefault();
    }

    e.stopPropagation();
  }

  getRowClass(object) {
    const decorator = this.props.decorator;
    if (!decorator || !decorator.getRowClass) {
      return [];
    }

    // Decorator can return a simple string or array of strings.
    let classNames = decorator.getRowClass(object);
    if (!classNames) {
      return [];
    }

    if (typeof classNames == "string") {
      classNames = [classNames];
    }

    return classNames;
  }

  render() {
    const member = this.props.member;
    const decorator = this.props.decorator;

    const props = {
      id: this.props.id,
      ref: this.treeRowRef,
      role: "treeitem",
      "aria-level": member.level + 1,
      "aria-selected": !!member.selected,
      onClick: this.props.onClick,
      onContextMenu: this.props.onContextMenu,
      onKeyDownCapture: member.active ? this._onKeyDown : undefined,
      onMouseOver: this.props.onMouseOver,
      onMouseOut: this.props.onMouseOut,
    };

    // Compute class name list for the <tr> element.
    const classNames = this.getRowClass(member.object) || [];
    classNames.push("treeRow");
    classNames.push(member.type + "Row");

    if (member.hasChildren) {
      classNames.push("hasChildren");

      // There are 2 situations where hasChildren is true:
      // 1. it is an object with children. Only set aria-expanded in this situation
      // 2. It is a long string (> 50 chars) that can be expanded to fully display it
      if (member.type !== "string") {
        props["aria-expanded"] = member.open;
      }
    }

    if (member.open) {
      classNames.push("opened");
    }

    if (member.loading) {
      classNames.push("loading");
    }

    if (member.selected) {
      classNames.push("selected");
    }

    if (member.hidden) {
      classNames.push("hidden");
    }

    props.className = classNames.join(" ");

    // The label column (with toggle buttons) is usually
    // the first one, but there might be cases (like in
    // the Memory panel) where the toggling is done
    // in the last column.
    const cells = [];

    // Get components for rendering cells.
    let renderCell = this.props.renderCell || RenderCell;
    let renderLabelCell = this.props.renderLabelCell || RenderLabelCell;
    if (decorator?.renderLabelCell) {
      renderLabelCell =
        decorator.renderLabelCell(member.object) || renderLabelCell;
    }

    // Render a cell for every column.
    this.props.columns.forEach(col => {
      const cellProps = Object.assign({}, this.props, {
        key: col.id,
        id: col.id,
        value: this.props.provider.getValue(member.object, col.id),
      });

      if (decorator?.renderCell) {
        renderCell = decorator.renderCell(member.object, col.id);
      }

      const render = col.id == "default" ? renderLabelCell : renderCell;

      // Some cells don't have to be rendered. This happens when some
      // other cells span more columns. Note that the label cells contains
      // toggle buttons and should be usually there unless we are rendering
      // a simple non-expandable table.
      if (render) {
        cells.push(render(cellProps));
      }
    });

    // Render tree row
    return tr(props, cells);
  }
}

// Helpers

const RenderCell = props => {
  return TreeCell(props);
};

const RenderLabelCell = props => {
  return LabelCell(props);
};

// Exports from this module
export default TreeRow;
