/* 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 {
  AutoRefreshHighlighter,
} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
const {
  CanvasFrameAnonymousContentHelper,
  getComputedStyle,
} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
const {
  setIgnoreLayoutChanges,
  getAdjustedQuads,
  getCurrentZoom,
} = require("resource://devtools/shared/layout/utils.js");
const {
  getMatchingCSSRules,
} = require("resource://devtools/shared/inspector/css-logic.js");

const GEOMETRY_LABEL_SIZE = 6;

// List of all DOM Events subscribed directly to the document from the
// Geometry Editor highlighter
const DOM_EVENTS = ["mousemove", "mouseup", "pagehide"];

const _dragging = Symbol("geometry/dragging");

/**
 * Element geometry properties helper that gives names of position and size
 * properties.
 */
var GeoProp = {
  SIDES: ["top", "right", "bottom", "left"],
  SIZES: ["width", "height"],

  allProps() {
    return [...this.SIDES, ...this.SIZES];
  },

  isSide(name) {
    return this.SIDES.includes(name);
  },

  isSize(name) {
    return this.SIZES.includes(name);
  },

  containsSide(names) {
    return names.some(name => this.SIDES.includes(name));
  },

  containsSize(names) {
    return names.some(name => this.SIZES.includes(name));
  },

  isHorizontal(name) {
    return name === "left" || name === "right" || name === "width";
  },

  isInverted(name) {
    return name === "right" || name === "bottom";
  },

  mainAxisStart(name) {
    return this.isHorizontal(name) ? "left" : "top";
  },

  crossAxisStart(name) {
    return this.isHorizontal(name) ? "top" : "left";
  },

  mainAxisSize(name) {
    return this.isHorizontal(name) ? "width" : "height";
  },

  crossAxisSize(name) {
    return this.isHorizontal(name) ? "height" : "width";
  },

  axis(name) {
    return this.isHorizontal(name) ? "x" : "y";
  },

  crossAxis(name) {
    return this.isHorizontal(name) ? "y" : "x";
  },
};

/**
 * Get the provided node's offsetParent dimensions.
 * Returns an object with the {parent, dimension} properties.
 * Note that the returned parent will be null if the offsetParent is the
 * default, non-positioned, body or html node.
 *
 * node.offsetParent returns the nearest positioned ancestor but if it is
 * non-positioned itself, we just return null to let consumers know the node is
 * actually positioned relative to the viewport.
 *
 * @return {object}
 */
function getOffsetParent(node) {
  const win = node.documentGlobal;

  let offsetParent = node.offsetParent;
  if (offsetParent && getComputedStyle(offsetParent).position === "static") {
    offsetParent = null;
  }

  let width, height;
  if (!offsetParent) {
    height = win.innerHeight;
    width = win.innerWidth;
  } else {
    height = offsetParent.offsetHeight;
    width = offsetParent.offsetWidth;
  }

  return {
    element: offsetParent,
    dimension: { width, height },
  };
}

/**
 * Get the list of geometry properties that are actually set on the provided
 * node.
 *
 * @param {Node} node The node to analyze.
 * @return {Map} A map indexed by property name and where the value is an
 * object having the cssRule property.
 */
function getDefinedGeometryProperties(node) {
  const props = new Map();
  if (!node) {
    return props;
  }

  // Get the list of css rules applying to the current node.
  const cssRules = getMatchingCSSRules(node);
  for (let i = 0; i < cssRules.length; i++) {
    const rule = cssRules[i];
    for (const name of GeoProp.allProps()) {
      const value = rule.style.getPropertyValue(name);
      if (value && value !== "auto") {
        // getMatchingCSSRules returns rules ordered from least to most specific
        // so just override any previous properties we have set.
        props.set(name, {
          cssRule: rule,
        });
      }
    }
  }

  // Go through the inline styles last, only if the node supports inline style
  // (e.g. pseudo elements don't have a style property)
  if (node.style) {
    for (const name of GeoProp.allProps()) {
      const value = node.style.getPropertyValue(name);
      if (value && value !== "auto") {
        props.set(name, {
          // There's no cssRule to store here, so store the node instead since
          // node.style exists.
          cssRule: node,
        });
      }
    }
  }

  // Post-process the list for invalid properties. This is done after the fact
  // because of cases like relative positioning with both top and bottom where
  // only top will actually be used, but both exists in css rules and computed
  // styles.
  const { position } = getComputedStyle(node);
  for (const [name] of props) {
    // Top/left/bottom/right on static positioned elements have no effect.
    if (position === "static" && GeoProp.SIDES.includes(name)) {
      props.delete(name);
    }

    // Bottom/right on relative positioned elements are only used if top/left
    // are not defined.
    const hasRightAndLeft = name === "right" && props.has("left");
    const hasBottomAndTop = name === "bottom" && props.has("top");
    if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) {
      props.delete(name);
    }
  }

  return props;
}
exports.getDefinedGeometryProperties = getDefinedGeometryProperties;

/**
 * The GeometryEditor highlights an elements's top, left, bottom, right, width
 * and height dimensions, when they are set.
 *
 * To determine if an element has a set size and position, the highlighter lists
 * the CSS rules that apply to the element and checks for the top, left, bottom,
 * right, width and height properties.
 * The highlighter won't be shown if the element doesn't have any of these
 * properties set, but will be shown when at least 1 property is defined.
 *
 * The highlighter displays lines and labels for each of the defined properties
 * in and around the element (relative to the offset parent when one exists).
 * The highlighter also highlights the element itself and its offset parent if
 * there is one.
 *
 * Note that the class name contains the word Editor because the aim is for the
 * handles to be draggable in content to make the geometry editable.
 */
class GeometryEditorHighlighter extends AutoRefreshHighlighter {
  constructor(highlighterEnv) {
    super(highlighterEnv);

    // The list of element geometry properties that can be set.
    this.definedProperties = new Map();

    this.markup = new CanvasFrameAnonymousContentHelper(
      highlighterEnv,
      this._buildMarkup.bind(this),
      {
        contentRootHostClassName: "devtools-highlighter-geometry-editor",
      }
    );
    this.isReady = this.initialize();

    const { pageListenerTarget } = this.highlighterEnv;

    // Register the geometry editor instance to all events we're interested in.
    DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this));

    this.onWillNavigate = this.onWillNavigate.bind(this);

    this.highlighterEnv.on("will-navigate", this.onWillNavigate);
  }

  async initialize() {
    await this.markup.initialize();
    // Register the mousedown event for each Geometry Editor's handler.
    // Those events are automatically removed when the markup is destroyed.
    const onMouseDown = this.handleEvent.bind(this);

    for (const side of GeoProp.SIDES) {
      this.getElement("geometry-editor-handler-" + side).addEventListener(
        "mousedown",
        onMouseDown
      );
    }
  }

  _buildMarkup() {
    const container = this.markup.createNode({
      attributes: { class: "highlighter-container" },
    });

    this.rootEl = this.markup.createNode({
      parent: container,
      attributes: {
        id: "geometry-editor-root",
        class: "geometry-editor-root",
        hidden: "true",
      },
    });

    const svg = this.markup.createSVGNode({
      nodeType: "svg",
      parent: this.rootEl,
      attributes: {
        id: "geometry-editor-elements",
        width: "100%",
        height: "100%",
      },
    });

    // Offset parent node highlighter.
    this.markup.createSVGNode({
      nodeType: "polygon",
      parent: svg,
      attributes: {
        class: "geometry-editor-offset-parent",
        id: "geometry-editor-offset-parent",
        hidden: "true",
      },
    });

    // Current node highlighter (margin box).
    this.markup.createSVGNode({
      nodeType: "polygon",
      parent: svg,
      attributes: {
        class: "geometry-editor-current-node",
        id: "geometry-editor-current-node",
        hidden: "true",
      },
    });

    // Build the 4 side arrows, handlers and labels.
    for (const name of GeoProp.SIDES) {
      this.markup.createSVGNode({
        nodeType: "line",
        parent: svg,
        attributes: {
          class: "geometry-editor-arrow " + name,
          id: "geometry-editor-arrow-" + name,
          hidden: "true",
        },
      });

      this.markup.createSVGNode({
        nodeType: "circle",
        parent: svg,
        attributes: {
          class: "geometry-editor-handler-" + name,
          id: "geometry-editor-handler-" + name,
          r: "4",
          "data-side": name,
          hidden: "true",
        },
      });

      // Labels are positioned by using a translated <g>. This group contains
      // a path and text that are themselves positioned using another translated
      // <g>. This is so that the label arrow points at the 0,0 coordinates of
      // parent <g>.
      const labelG = this.markup.createSVGNode({
        nodeType: "g",
        parent: svg,
        attributes: {
          id: "geometry-editor-label-" + name,
          hidden: "true",
        },
      });

      const subG = this.markup.createSVGNode({
        nodeType: "g",
        parent: labelG,
        attributes: {
          transform: GeoProp.isHorizontal(name)
            ? "translate(-30 -30)"
            : "translate(5 -10)",
        },
      });

      this.markup.createSVGNode({
        nodeType: "path",
        parent: subG,
        attributes: {
          class: "geometry-editor-label-bubble",
          d: GeoProp.isHorizontal(name)
            ? "M0 0 L60 0 L60 20 L35 20 L30 25 L25 20 L0 20z"
            : "M5 0 L65 0 L65 20 L5 20 L5 15 L0 10 L5 5z",
        },
      });

      this.markup.createSVGNode({
        nodeType: "text",
        parent: subG,
        attributes: {
          class: "geometry-editor-label-text",
          id: "geometry-editor-label-text-" + name,
          x: GeoProp.isHorizontal(name) ? "30" : "35",
          y: "10",
        },
      });
    }

    return container;
  }

  destroy() {
    // Avoiding exceptions if `destroy` is called multiple times; and / or the
    // highlighter environment was already destroyed.
    if (!this.highlighterEnv) {
      return;
    }

    const { pageListenerTarget } = this.highlighterEnv;

    if (pageListenerTarget) {
      DOM_EVENTS.forEach(type =>
        pageListenerTarget.removeEventListener(type, this)
      );
    }

    AutoRefreshHighlighter.prototype.destroy.call(this);

    this.markup.destroy();
    this.rootEl = null;

    this.definedProperties.clear();
    this.definedProperties = null;
    this.offsetParent = null;
  }

  handleEvent(event, id) {
    // No event handling if the highlighter is hidden
    if (this.getElement("geometry-editor-root").hasAttribute("hidden")) {
      return;
    }

    const { target, type, pageX, pageY } = event;

    switch (type) {
      case "pagehide":
        // If a page hide event is triggered for current window's highlighter, hide the
        // highlighter.
        if (target.defaultView === this.win) {
          this.destroy();
        }

        break;
      case "mousedown": {
        // The mousedown event is intended only for the handler
        if (!id) {
          return;
        }

        const handlerSide = this.markup
          .getElement(id)
          .getAttribute("data-side");

        if (handlerSide) {
          const side = handlerSide;
          const sideProp = this.definedProperties.get(side);

          if (!sideProp) {
            return;
          }

          let value = sideProp.cssRule.style.getPropertyValue(side);
          const computedValue = this.computedStyle.getPropertyValue(side);

          const [unit] = value.match(/[^\d]+$/) || [""];

          value = parseFloat(value);

          const ratio = value / parseFloat(computedValue) || 1;
          const dir = GeoProp.isInverted(side) ? -1 : 1;

          // Store all the initial values needed for drag & drop
          this[_dragging] = {
            side,
            value,
            unit,
            x: pageX,
            y: pageY,
            inc: ratio * dir,
          };

          this.getElement("geometry-editor-handler-" + side).classList?.add(
            "dragging"
          );
        }

        this.getElement("geometry-editor-root").setAttribute(
          "dragging",
          "true"
        );
        break;
      }
      case "mouseup":
        // If we're dragging, drop it.
        if (this[_dragging]) {
          const { side } = this[_dragging];
          this.getElement("geometry-editor-root").removeAttribute("dragging");
          this.getElement("geometry-editor-handler-" + side).classList?.remove(
            "dragging"
          );
          this[_dragging] = null;
        }
        break;
      case "mousemove": {
        if (!this[_dragging]) {
          return;
        }

        const { side, x, y, value, unit, inc } = this[_dragging];
        const sideProps = this.definedProperties.get(side);

        if (!sideProps) {
          return;
        }

        const delta =
          (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc;

        // The inline style has usually the priority over any other CSS rule
        // set in stylesheets. However, if a rule has `!important` keyword,
        // it will override the inline style too. To ensure Geometry Editor
        // will always update the element, we have to add `!important` as
        // well.
        this.currentNode.style.setProperty(
          side,
          value + delta + unit,
          "important"
        );

        break;
      }
    }
  }

  getElement(id) {
    return this.markup.getElement(id);
  }

  _show() {
    this.computedStyle = getComputedStyle(this.currentNode);
    const pos = this.computedStyle.position;
    // XXX: sticky positioning is ignored for now. To be implemented next.
    if (pos === "sticky") {
      this.hide();
      return false;
    }

    const hasUpdated = this._update();
    if (!hasUpdated) {
      this.hide();
      return false;
    }

    this.getElement("geometry-editor-root").removeAttribute("hidden");

    return true;
  }

  _update() {
    // At each update, the position or/and size may have changed, so get the
    // list of defined properties, and re-position the arrows and highlighters.
    this.definedProperties = getDefinedGeometryProperties(this.currentNode);
    // We need the zoom factor to fix the original position of the node
    // as well as the arrows.
    this.zoomFactor = getCurrentZoom(this.currentNode);

    if (!this.definedProperties.size) {
      console.warn("The element does not have editable geometry properties");
      return false;
    }

    setIgnoreLayoutChanges(true);

    // Update the highlighters and arrows.
    this.updateOffsetParent();
    this.updateCurrentNode();
    this.updateArrows();

    // Avoid zooming the arrows when content is zoomed.
    const node = this.currentNode;
    this.markup.scaleRootElement(node, "geometry-editor-root");

    setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
    return true;
  }

  /**
   * Update the offset parent rectangle.
   * There are 3 different cases covered here:
   * - the node is absolutely/fixed positioned, and an offsetParent is defined
   *   (i.e. it's not just positioned in the viewport): the offsetParent node
   *   is highlighted (i.e. the rectangle is shown),
   * - the node is relatively positioned: the rectangle is shown where the node
   *   would originally have been (because that's where the relative positioning
   *   is calculated from),
   * - the node has no offset parent at all: the offsetParent rectangle is
   *   hidden.
   */
  updateOffsetParent() {
    // Get the offsetParent, if any.
    this.offsetParent = getOffsetParent(this.currentNode);
    // And the offsetParent quads.
    this.parentQuads = getAdjustedQuads(
      this.win,
      this.offsetParent.element,
      "padding"
    );

    const el = this.getElement("geometry-editor-offset-parent");

    const isPositioned =
      this.computedStyle.position === "absolute" ||
      this.computedStyle.position === "fixed";
    const isRelative = this.computedStyle.position === "relative";
    let isHighlighted = false;

    if (this.offsetParent.element && isPositioned) {
      const { p1, p2, p3, p4 } = this.parentQuads[0];
      const points =
        p1.x +
        "," +
        p1.y +
        " " +
        p2.x +
        "," +
        p2.y +
        " " +
        p3.x +
        "," +
        p3.y +
        " " +
        p4.x +
        "," +
        p4.y;
      el.setAttribute("points", points);
      isHighlighted = true;
    } else if (isRelative) {
      const xDelta = parseFloat(this.computedStyle.left) * this.zoomFactor;
      const yDelta = parseFloat(this.computedStyle.top) * this.zoomFactor;
      if (xDelta || yDelta) {
        const { p1, p2, p3, p4 } = this.currentQuads.margin[0];
        const points =
          p1.x -
          xDelta +
          "," +
          (p1.y - yDelta) +
          " " +
          (p2.x - xDelta) +
          "," +
          (p2.y - yDelta) +
          " " +
          (p3.x - xDelta) +
          "," +
          (p3.y - yDelta) +
          " " +
          (p4.x - xDelta) +
          "," +
          (p4.y - yDelta);
        el.setAttribute("points", points);
        isHighlighted = true;
      }
    }

    if (isHighlighted) {
      el.removeAttribute("hidden");
    } else {
      el.setAttribute("hidden", "true");
    }
  }

  updateCurrentNode() {
    const box = this.getElement("geometry-editor-current-node");
    const { p1, p2, p3, p4 } = this.currentQuads.margin[0];
    const attr =
      p1.x +
      "," +
      p1.y +
      " " +
      p2.x +
      "," +
      p2.y +
      " " +
      p3.x +
      "," +
      p3.y +
      " " +
      p4.x +
      "," +
      p4.y;
    box.setAttribute("points", attr);
    box.removeAttribute("hidden");
  }

  _hide() {
    setIgnoreLayoutChanges(true);

    this.getElement("geometry-editor-root").setAttribute("hidden", "true");
    this.getElement("geometry-editor-current-node").setAttribute(
      "hidden",
      "true"
    );
    this.getElement("geometry-editor-offset-parent").setAttribute(
      "hidden",
      "true"
    );
    this.hideArrows();

    this.definedProperties.clear();

    setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
  }

  hideArrows() {
    for (const side of GeoProp.SIDES) {
      this.getElement("geometry-editor-arrow-" + side).setAttribute(
        "hidden",
        "true"
      );
      this.getElement("geometry-editor-label-" + side).setAttribute(
        "hidden",
        "true"
      );
      this.getElement("geometry-editor-handler-" + side).setAttribute(
        "hidden",
        "true"
      );
    }
  }

  updateArrows() {
    this.hideArrows();

    // Position arrows always end at the node's margin box.
    const marginBox = this.currentQuads.margin[0].bounds;

    // Position the side arrows which need to be visible.
    // Arrows always start at the offsetParent edge, and end at the middle
    // position of the node's margin edge.
    // Note that for relative positioning, the offsetParent is considered to be
    // the node itself, where it would have been originally.
    // +------------------+----------------+
    // | offsetparent     | top            |
    // | or viewport      |                |
    // |         +--------+--------+       |
    // |         | node            |       |
    // +---------+                 +-------+
    // | left    |                 | right |
    // |         +--------+--------+       |
    // |                  | bottom         |
    // +------------------+----------------+
    const getSideArrowStartPos = side => {
      // In case of relative positioning.
      if (this.computedStyle.position === "relative") {
        if (GeoProp.isInverted(side)) {
          return (
            marginBox[side] +
            parseFloat(this.computedStyle[side]) * this.zoomFactor
          );
        }
        return (
          marginBox[side] -
          parseFloat(this.computedStyle[side]) * this.zoomFactor
        );
      }

      // In case an offsetParent exists and is highlighted.
      if (this.parentQuads && this.parentQuads.length) {
        return this.parentQuads[0].bounds[side];
      }

      // In case the element is positioned in the viewport.
      if (GeoProp.isInverted(side)) {
        return this.offsetParent.dimension[GeoProp.mainAxisSize(side)];
      }
      return (
        -1 *
        this.currentNode.documentGlobal[
          "scroll" + GeoProp.axis(side).toUpperCase()
        ]
      );
    };

    for (const side of GeoProp.SIDES) {
      const sideProp = this.definedProperties.get(side);
      if (!sideProp) {
        continue;
      }

      const mainAxisStartPos = getSideArrowStartPos(side);
      const mainAxisEndPos = marginBox[side];
      const crossAxisPos =
        marginBox[GeoProp.crossAxisStart(side)] +
        marginBox[GeoProp.crossAxisSize(side)] / 2;

      this.updateArrow(
        side,
        mainAxisStartPos,
        mainAxisEndPos,
        crossAxisPos,
        sideProp.cssRule.style.getPropertyValue(side)
      );
    }
  }

  updateArrow(side, mainStart, mainEnd, crossPos, labelValue) {
    const arrowEl = this.getElement("geometry-editor-arrow-" + side);
    const labelEl = this.getElement("geometry-editor-label-" + side);
    const labelTextEl = this.getElement("geometry-editor-label-text-" + side);
    const handlerEl = this.getElement("geometry-editor-handler-" + side);

    // Position the arrow <line>.
    arrowEl.setAttribute(GeoProp.axis(side) + "1", mainStart);
    arrowEl.setAttribute(GeoProp.crossAxis(side) + "1", crossPos);
    arrowEl.setAttribute(GeoProp.axis(side) + "2", mainEnd);
    arrowEl.setAttribute(GeoProp.crossAxis(side) + "2", crossPos);
    arrowEl.removeAttribute("hidden");

    handlerEl.setAttribute("c" + GeoProp.axis(side), mainEnd);
    handlerEl.setAttribute("c" + GeoProp.crossAxis(side), crossPos);
    handlerEl.removeAttribute("hidden");

    // Position the label <text> in the middle of the arrow (making sure it's
    // not hidden below the fold).
    const capitalize = str => str[0].toUpperCase() + str.substring(1);
    const winMain = this.win["inner" + capitalize(GeoProp.mainAxisSize(side))];
    let labelMain = mainStart + (mainEnd - mainStart) / 2;
    if (
      (mainStart > 0 && mainStart < winMain) ||
      (mainEnd > 0 && mainEnd < winMain)
    ) {
      if (labelMain < GEOMETRY_LABEL_SIZE) {
        labelMain = GEOMETRY_LABEL_SIZE;
      } else if (labelMain > winMain - GEOMETRY_LABEL_SIZE) {
        labelMain = winMain - GEOMETRY_LABEL_SIZE;
      }
    }
    const labelCross = crossPos;
    labelEl.setAttribute(
      "transform",
      GeoProp.isHorizontal(side)
        ? "translate(" + labelMain + " " + labelCross + ")"
        : "translate(" + labelCross + " " + labelMain + ")"
    );
    labelEl.removeAttribute("hidden");
    labelTextEl.setTextContent(labelValue);
  }

  onWillNavigate({ isTopLevel }) {
    if (isTopLevel) {
      this.hide();
    }
  }
}

exports.GeometryEditorHighlighter = GeometryEditorHighlighter;
