/* 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";

/* global EVENTS, gTelemetry */

// React & Redux
const {
  createFactory,
  Component,
} = require("resource://devtools/client/shared/vendor/react.js");
const {
  div,
  span,
} = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
const {
  findDOMNode,
} = require("resource://devtools/client/shared/vendor/react-dom.js");
const {
  connect,
} = require("resource://devtools/client/shared/vendor/react-redux.js");

const {
  TREE_ROW_HEIGHT,
  ORDERED_PROPS,
  ACCESSIBLE_EVENTS,
  VALUE_FLASHING_DURATION,
} = require("resource://devtools/client/accessibility/constants.js");
const {
  L10N,
} = require("resource://devtools/client/accessibility/utils/l10n.js");
const {
  flashElementOn,
  flashElementOff,
} = require("resource://devtools/client/inspector/markup/utils.js");
const {
  updateDetails,
} = require("resource://devtools/client/accessibility/actions/details.js");
const {
  select,
  unhighlight,
} = require("resource://devtools/client/accessibility/actions/accessibles.js");

const Tree = createFactory(
  require("resource://devtools/client/shared/components/VirtualizedTree.js")
);
// Reps
const {
  REPS,
  MODE,
} = require("resource://devtools/client/shared/components/reps/index.js");
const { Rep, ElementNode, Accessible: AccessibleRep, Obj } = REPS;

const {
  translateNodeFrontToGrip,
} = require("resource://devtools/client/inspector/shared/utils.js");

loader.lazyRequireGetter(
  this,
  "openContentLink",
  "resource://devtools/client/shared/link.js",
  true
);

const TELEMETRY_NODE_INSPECTED_COUNT =
  "devtools.accessibility.node_inspected_count";

const TREE_DEPTH_PADDING_INCREMENT = 20;

class AccessiblePropertyClass extends Component {
  static get propTypes() {
    return {
      accessibleFrontActorID: PropTypes.string,
      object: PropTypes.any,
      focused: PropTypes.bool,
      children: PropTypes.func,
    };
  }

  componentDidUpdate({
    object: prevObject,
    accessibleFrontActorID: prevAccessibleFrontActorID,
  }) {
    const { accessibleFrontActorID, object, focused } = this.props;
    // Fast check if row is focused or if the value did not update.
    if (
      focused ||
      accessibleFrontActorID !== prevAccessibleFrontActorID ||
      prevObject === object ||
      (object && prevObject && typeof object === "object")
    ) {
      return;
    }

    this.flashRow();
  }

  flashRow() {
    const row = findDOMNode(this);
    flashElementOn(row);
    if (this._flashMutationTimer) {
      clearTimeout(this._flashMutationTimer);
      this._flashMutationTimer = null;
    }
    this._flashMutationTimer = setTimeout(() => {
      flashElementOff(row);
    }, VALUE_FLASHING_DURATION);
  }

  render() {
    return this.props.children();
  }
}

const AccessibleProperty = createFactory(AccessiblePropertyClass);

class Accessible extends Component {
  static get propTypes() {
    return {
      accessibleFront: PropTypes.object,
      dispatch: PropTypes.func.isRequired,
      nodeFront: PropTypes.object,
      items: PropTypes.array,
      labelledby: PropTypes.string.isRequired,
      parents: PropTypes.object,
      relations: PropTypes.object,
      toolbox: PropTypes.object.isRequired,
      toolboxHighlighter: PropTypes.object.isRequired,
      highlightAccessible: PropTypes.func.isRequired,
      unhighlightAccessible: PropTypes.func.isRequired,
    };
  }

  constructor(props) {
    super(props);

    this.state = {
      expanded: new Set(),
      active: null,
      focused: null,
    };

    this.onAccessibleInspected = this.onAccessibleInspected.bind(this);
    this.renderItem = this.renderItem.bind(this);
    this.update = this.update.bind(this);
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillMount() {
    window.on(
      EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED,
      this.onAccessibleInspected
    );
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillReceiveProps({ accessibleFront }) {
    const oldAccessibleFront = this.props.accessibleFront;

    if (oldAccessibleFront) {
      if (
        accessibleFront &&
        accessibleFront.actorID === oldAccessibleFront.actorID
      ) {
        return;
      }
      ACCESSIBLE_EVENTS.forEach(event =>
        oldAccessibleFront.off(event, this.update)
      );
    }

    if (accessibleFront) {
      ACCESSIBLE_EVENTS.forEach(event =>
        accessibleFront.on(event, this.update)
      );
    }
  }

  componentDidUpdate(prevProps) {
    if (
      this.props.accessibleFront &&
      !this.props.accessibleFront.isDestroyed() &&
      this.props.accessibleFront !== prevProps.accessibleFront
    ) {
      window.emit(EVENTS.PROPERTIES_UPDATED);
    }
  }

  componentWillUnmount() {
    window.off(
      EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED,
      this.onAccessibleInspected
    );

    const { accessibleFront } = this.props;
    if (accessibleFront) {
      ACCESSIBLE_EVENTS.forEach(event =>
        accessibleFront.off(event, this.update)
      );
    }
  }

  onAccessibleInspected() {
    const { props } = this.refs;
    if (props) {
      props.refs.tree.focus();
    }
  }

  update() {
    const { dispatch, accessibleFront } = this.props;
    if (accessibleFront.isDestroyed()) {
      return;
    }

    dispatch(updateDetails(accessibleFront));
  }

  setExpanded(item, isExpanded) {
    const { expanded } = this.state;

    if (isExpanded) {
      expanded.add(item.path);
    } else {
      expanded.delete(item.path);
    }

    this.setState({ expanded });
  }

  async showHighlighter(nodeFront) {
    if (!this.props.toolboxHighlighter) {
      return;
    }

    await this.props.toolboxHighlighter.highlight(nodeFront);
  }

  async hideHighlighter() {
    if (!this.props.toolboxHighlighter) {
      return;
    }

    await this.props.toolboxHighlighter.unhighlight();
  }

  showAccessibleHighlighter(accessibleFront) {
    this.props.dispatch(unhighlight());
    this.props.highlightAccessible(accessibleFront);
  }

  hideAccessibleHighlighter(accessibleFront) {
    this.props.dispatch(unhighlight());
    this.props.unhighlightAccessible(accessibleFront);
  }

  async selectNode(nodeFront, reason = "accessibility") {
    if (gTelemetry) {
      gTelemetry.scalarAdd(TELEMETRY_NODE_INSPECTED_COUNT, 1);
    }

    if (!this.props.toolbox) {
      return;
    }

    const inspector = await this.props.toolbox.selectTool("inspector");
    inspector.selection.setNodeFront(nodeFront, reason);
  }

  async selectAccessible(accessibleFront) {
    if (!accessibleFront) {
      return;
    }

    await this.props.dispatch(select(accessibleFront));

    const { props } = this.refs;
    if (props) {
      props.refs.tree.blur();
    }
    await this.setState({ active: null, focused: null });

    window.emit(EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED);
  }

  openLink(link, e) {
    openContentLink(link);
  }

  renderItem(item, depth, focused, arrow, expanded) {
    const object = item.contents;
    const valueProps = {
      object,
      mode: MODE.TINY,
      title: "Object",
      openLink: this.openLink,
    };

    if (isNodeFront(object)) {
      valueProps.defaultRep = ElementNode;
      valueProps.onDOMNodeMouseOut = () => this.hideHighlighter();
      valueProps.onDOMNodeMouseOver = () =>
        this.showHighlighter(this.props.nodeFront);

      valueProps.inspectIconTitle = L10N.getStr(
        "accessibility.accessible.selectNodeInInspector.title"
      );
      valueProps.onInspectIconClick = () =>
        this.selectNode(this.props.nodeFront);
    } else if (isAccessibleFront(object)) {
      const target = findAccessibleTarget(this.props.relations, object.actor);
      valueProps.defaultRep = AccessibleRep;
      valueProps.onAccessibleMouseOut = () =>
        this.hideAccessibleHighlighter(target);
      valueProps.onAccessibleMouseOver = () =>
        this.showAccessibleHighlighter(target);
      valueProps.inspectIconTitle = L10N.getStr(
        "accessibility.accessible.selectElement.title"
      );
      valueProps.onInspectIconClick = (obj, e) => {
        e.stopPropagation();
        this.selectAccessible(target);
      };
      valueProps.separatorText = "";
    } else if (item.name === "relations") {
      valueProps.defaultRep = Obj;
    } else {
      valueProps.noGrip = true;
    }

    const classList = ["node", "object-node"];
    if (focused) {
      classList.push("focused");
    }

    const depthPadding = depth * TREE_DEPTH_PADDING_INCREMENT;

    return AccessibleProperty(
      {
        object,
        focused,
        accessibleFrontActorID: this.props.accessibleFront.actorID,
      },
      () =>
        div(
          {
            className: classList.join(" "),
            style: {
              paddingInlineStart: depthPadding,
              inlineSize: `calc(var(--accessibility-properties-item-width) - ${depthPadding}px)`,
            },
            onClick: e => {
              if (e.target.classList.contains("theme-twisty")) {
                this.setExpanded(item, !expanded);
              }
            },
          },
          arrow,
          span({ className: "object-label" }, item.name),
          span({ className: "object-delimiter" }, ":"),
          span({ className: "object-value" }, Rep(valueProps) || "")
        )
    );
  }

  render() {
    const { expanded, active, focused } = this.state;
    const { items, parents, accessibleFront, labelledby } = this.props;

    if (accessibleFront) {
      return Tree({
        ref: "props",
        key: "accessible-properties",
        itemHeight: TREE_ROW_HEIGHT,
        getRoots: () => items,
        getKey: item => item.path,
        getParent: item => parents.get(item),
        getChildren: item => item.children,
        isExpanded: item => expanded.has(item.path),
        onExpand: item => this.setExpanded(item, true),
        onCollapse: item => this.setExpanded(item, false),
        onFocus: item => {
          if (this.state.focused !== item.path) {
            this.setState({ focused: item.path });
          }
        },
        onActivate: item => {
          if (item == null) {
            this.setState({ active: null });
          } else if (this.state.active !== item.path) {
            this.setState({ active: item.path });
          }
        },
        focused: findByPath(focused, items),
        active: findByPath(active, items),
        renderItem: this.renderItem,
        labelledby,
      });
    }

    return div(
      { className: "info" },
      L10N.getStr("accessibility.accessible.notAvailable")
    );
  }
}

/**
 * Match accessibility object from relations targets to the grip that's being activated.
 * @param  {Object} relations  Object containing relations grouped by type and targets.
 * @param  {String} actorID    Actor ID to match to the relation target.
 * @return {Object}            Accessible front that matches the relation target.
 */
const findAccessibleTarget = (relations, actorID) => {
  for (const relationType in relations) {
    let targets = relations[relationType];
    targets = Array.isArray(targets) ? targets : [targets];
    for (const target of targets) {
      if (target.actorID === actorID) {
        return target;
      }
    }
  }

  return null;
};

/**
 * Find an item based on a given path.
 * @param  {String} path
 *         Key of the item to be looked up.
 * @param  {Array}  items
 *         Accessibility properties array.
 * @return {Object?}
 *         Possibly found item.
 */
const findByPath = (path, items) => {
  for (const item of items) {
    if (item.path === path) {
      return item;
    }

    const found = findByPath(path, item.children);
    if (found) {
      return found;
    }
  }
  return null;
};

/**
 * Check if a given property is a DOMNode front.
 * @param  {Object?} value A property to check for being a DOMNode.
 * @return {Boolean}       A flag that indicates whether a property is a DOMNode.
 */
const isNodeFront = value => value && value.typeName === "domnode";

/**
 * Check if a given property is an Accessible front.
 * @param  {Object?} value A property to check for being an Accessible.
 * @return {Boolean}       A flag that indicates whether a property is an Accessible.
 */
const isAccessibleFront = value => value && value.typeName === "accessible";

/**
 * While waiting for a reps fix in https://github.com/firefox-devtools/reps/issues/92,
 * translate accessibleFront to a grip-like object that can be used with an Accessible
 * rep.
 *
 * @params  {accessibleFront} accessibleFront
 *          The AccessibleFront for which we want to create a grip-like object.
 * @returns {Object} a grip-like object that can be used with Reps.
 */
const translateAccessibleFrontToGrip = accessibleFront => ({
  actor: accessibleFront.actorID,
  typeName: accessibleFront.typeName,
  preview: {
    name: accessibleFront.name,
    role: accessibleFront.role,
    // All the grid containers are assumed to be in the Accessibility tree.
    isConnected: true,
  },
});

const translateNodeFrontToGripWrapper = nodeFront => ({
  ...translateNodeFrontToGrip(nodeFront),
  typeName: nodeFront.typeName,
});

/**
 * Build props ingestible by Tree component.
 * @param  {Object} props      Component properties to be processed.
 * @param  {String} parentPath Unique path that is used to identify a Tree Node.
 * @return {Object}            Processed properties.
 */
const makeItemsForDetails = (props, parentPath) =>
  Object.getOwnPropertyNames(props).map(name => {
    let children = [];
    const path = `${parentPath}/${name}`;
    let contents = props[name];

    if (contents) {
      if (isNodeFront(contents)) {
        contents = translateNodeFrontToGripWrapper(contents);
        name = "DOMNode";
      } else if (isAccessibleFront(contents)) {
        contents = translateAccessibleFrontToGrip(contents);
      } else if (Array.isArray(contents) || typeof contents === "object") {
        children = makeItemsForDetails(contents, path);
      }
    }

    return { name, path, contents, children };
  });

const makeParentMap = items => {
  const map = new WeakMap();

  function _traverse(item) {
    if (item.children.length) {
      for (const child of item.children) {
        map.set(child, item);
        _traverse(child);
      }
    }
  }

  items.forEach(_traverse);
  return map;
};

const mapStateToProps = ({ details }) => {
  const {
    accessible: accessibleFront,
    DOMNode: nodeFront,
    relations,
  } = details;
  if (!accessibleFront || !nodeFront) {
    return {};
  }

  const items = makeItemsForDetails(
    ORDERED_PROPS.reduce((props, key) => {
      if (key === "DOMNode") {
        props.nodeFront = nodeFront;
      } else if (key === "relations") {
        props.relations = relations;
      } else {
        props[key] = accessibleFront[key];
      }

      return props;
    }, {}),
    ""
  );
  const parents = makeParentMap(items);

  return { accessibleFront, nodeFront, items, parents, relations };
};

module.exports = connect(mapStateToProps)(Accessible);
