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

import React, { Component } from "devtools/client/shared/vendor/react";
import { div } from "devtools/client/shared/vendor/react-dom-factories";
import PropTypes from "devtools/client/shared/vendor/react-prop-types";
import { connect } from "devtools/client/shared/vendor/react-redux";

const Reps = ChromeUtils.importESModule(
  "resource://devtools/client/shared/components/reps/index.mjs"
);
const {
  REPS: { Grip },
  MODE,
} = Reps;
import * as objectInspector from "resource://devtools/client/shared/components/object-inspector/index.js";

const { ObjectInspector, utils } = objectInspector;

const {
  node: { nodeIsPrimitive },
} = utils;

import ExceptionPopup from "./ExceptionPopup";

import actions from "../../../actions/index";
import Popover from "../../shared/Popover";

export class Popup extends Component {
  constructor(props) {
    super(props);
  }

  static get propTypes() {
    return {
      clearPreview: PropTypes.func.isRequired,
      editorRef: PropTypes.object.isRequired,
      highlightDomElement: PropTypes.func.isRequired,
      openElementInInspector: PropTypes.func.isRequired,
      openLink: PropTypes.func.isRequired,
      preview: PropTypes.object.isRequired,
      selectSourceURL: PropTypes.func.isRequired,
      unHighlightDomElement: PropTypes.func.isRequired,
    };
  }

  componentDidMount() {
    this.addHighlightToToken(this.props.preview.target);
  }

  componentWillUnmount() {
    this.removeHighlightFromToken(this.props.preview.target);
  }

  componentDidUpdate(prevProps) {
    const { target } = this.props.preview;
    if (prevProps.target == target) {
      return;
    }

    this.removeHighlightFromToken(prevProps.preview.target);
    this.addHighlightToToken(target);
  }

  addHighlightToToken(target) {
    if (!target) {
      return;
    }

    target.classList.add("preview-token");
    addHighlightToTargetSiblings(target, this.props);
  }

  removeHighlightFromToken(target) {
    if (!target) {
      return;
    }

    target.classList.remove("preview-token");
    removeHighlightForTargetSiblings(target);
  }

  calculateMaxHeight = () => {
    const { editorRef } = this.props;
    if (!editorRef) {
      return "auto";
    }

    const { height, top } = editorRef.getBoundingClientRect();
    const maxHeight = height + top;
    if (maxHeight < 250) {
      return maxHeight;
    }

    return 250;
  };

  createElement(element) {
    return document.createElement(element);
  }

  renderExceptionPreview(exception) {
    return React.createElement(ExceptionPopup, {
      exception,
      mouseout: this.props.clearPreview,
    });
  }

  renderPreview() {
    const {
      preview: { root, exception, previewType },
    } = this.props;

    const usesCustomFormatter =
      root?.contents?.value?.useCustomFormatter ?? false;

    if (exception) {
      return this.renderExceptionPreview(exception);
    }

    return div(
      {
        className: `preview-popup preview-type-${previewType}`,
        style: {
          maxHeight: this.calculateMaxHeight(),
        },
      },
      // Bug 1915610 - JS Tracer isn't localized yet
      previewType == "tracer"
        ? div({ className: "preview-tracer-header" }, "Tracer preview")
        : null,
      previewType == "tracer" && !nodeIsPrimitive(root)
        ? div(
            { className: "preview-tracer-warning" },
            "Attribute previews on traced objects are showing the current values and not the value at execution of the selected frame."
          )
        : null,
      React.createElement(ObjectInspector, {
        roots: [root],
        autoExpandDepth: 1,
        autoReleaseObjectActors: false,
        mode: usesCustomFormatter ? MODE.LONG : MODE.SHORT,
        disableWrap: true,
        displayRootNodeAsHeader: true,
        focusable: false,
        openLink: this.props.openLink,
        defaultRep: Grip,
        createElement: this.createElement,
        onDOMNodeClick: grip => this.props.openElementInInspector(grip),
        onInspectIconClick: grip => this.props.openElementInInspector(grip),
        onDOMNodeMouseOver: grip => this.props.highlightDomElement(grip),
        onDOMNodeMouseOut: grip => this.props.unHighlightDomElement(grip),
        mayUseCustomFormatter: true,
        onViewSourceInDebugger: ({ url, line, column }) =>
          this.props.selectSourceURL(url, { line, column }),
      })
    );
  }

  getPreviewType() {
    const {
      preview: { root, exception },
    } = this.props;
    if (exception || nodeIsPrimitive(root)) {
      return "tooltip";
    }

    return "popover";
  }

  render() {
    const {
      preview: { cursorPos, resultGrip, exception },
      editorRef,
    } = this.props;

    if (
      !exception &&
      (typeof resultGrip == "undefined" || resultGrip?.optimizedOut)
    ) {
      return null;
    }

    const type = this.getPreviewType();
    return React.createElement(
      Popover,
      {
        targetPosition: cursorPos,
        type,
        editorRef,
        target: this.props.preview.target,
        mouseout: this.props.clearPreview,
      },
      this.renderPreview()
    );
  }
}

export function addHighlightToTargetSiblings(target, props) {
  // This function searches for related tokens that should also be highlighted when previewed.
  // Here is the process:
  // It conducts a search on the target's next siblings and then another search for the previous siblings.
  // If a sibling is not an element node (nodeType === 1), the highlight is not added and the search is short-circuited.
  // If the element sibling is the same token type as the target, and is also found in the preview expression, the highlight class is added.

  const tokenType = target.classList.item(0);
  const previewExpression = props.preview.expression;

  if (
    tokenType &&
    previewExpression &&
    target.innerHTML !== previewExpression
  ) {
    let nextSibling = target.nextSibling;
    let nextElementSibling = target.nextElementSibling;

    // Note: Declaring previous/next ELEMENT siblings as well because
    // properties like innerHTML can't be checked on nextSibling
    // without creating a flow error even if the node is an element type.
    while (
      nextSibling &&
      nextElementSibling &&
      nextSibling.nodeType === 1 &&
      nextElementSibling.className.includes(tokenType) &&
      previewExpression.includes(nextElementSibling.innerHTML)
    ) {
      // All checks passed, add highlight and continue the search.
      nextElementSibling.classList.add("preview-token");

      nextSibling = nextSibling.nextSibling;
      nextElementSibling = nextElementSibling.nextElementSibling;
    }

    let previousSibling = target.previousSibling;
    let previousElementSibling = target.previousElementSibling;

    while (
      previousSibling &&
      previousElementSibling &&
      previousSibling.nodeType === 1 &&
      previousElementSibling.className.includes(tokenType) &&
      previewExpression.includes(previousElementSibling.innerHTML)
    ) {
      // All checks passed, add highlight and continue the search.
      previousElementSibling.classList.add("preview-token");

      previousSibling = previousSibling.previousSibling;
      previousElementSibling = previousElementSibling.previousElementSibling;
    }
  }
}

export function removeHighlightForTargetSiblings(target) {
  // Look at target's previous and next token siblings.
  // If they also have the highlight class 'preview-token',
  // remove that class.
  let nextSibling = target.nextElementSibling;
  while (nextSibling && nextSibling.className.includes("preview-token")) {
    nextSibling.classList.remove("preview-token");
    nextSibling = nextSibling.nextElementSibling;
  }
  let previousSibling = target.previousElementSibling;
  while (
    previousSibling &&
    previousSibling.className.includes("preview-token")
  ) {
    previousSibling.classList.remove("preview-token");
    previousSibling = previousSibling.previousElementSibling;
  }
}

const mapDispatchToProps = {
  addExpression: actions.addExpression,
  selectSourceURL: actions.selectSourceURL,
  openLink: actions.openLink,
  openElementInInspector: actions.openElementInInspectorCommand,
  highlightDomElement: actions.highlightDomElement,
  unHighlightDomElement: actions.unHighlightDomElement,
};

export default connect(null, mapDispatchToProps)(Popup);
