/* 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 {
  getCurrentZoom,
  getWindowDimensions,
  setIgnoreLayoutChanges,
} = require("resource://devtools/shared/layout/utils.js");
const {
  CanvasFrameAnonymousContentHelper,
} = require("resource://devtools/server/actors/highlighters/utils/markup.js");

// Hard coded value about the size of measuring tool label, in order to
// position and flip it when is needed.
const LABEL_SIZE_MARGIN = 8;
const LABEL_SIZE_WIDTH = 80;
const LABEL_SIZE_HEIGHT = 52;
const LABEL_POS_MARGIN = 4;
const LABEL_POS_WIDTH = 40;
const LABEL_POS_HEIGHT = 34;
const LABEL_TYPE_SIZE = "size";
const LABEL_TYPE_POSITION = "position";

// List of all DOM Events subscribed directly to the document from the
// Measuring Tool highlighter
const DOM_EVENTS = [
  "mousedown",
  "mousemove",
  "mouseup",
  "mouseleave",
  "scroll",
  "pagehide",
  "keydown",
  "keyup",
];

const SIDES = ["top", "right", "bottom", "left"];
const HANDLERS = [...SIDES, "topleft", "topright", "bottomleft", "bottomright"];
const HANDLER_SIZE = 6;
const HIGHLIGHTED_HANDLER_CLASSNAME = "highlight";

const IS_OSX = Services.appinfo.OS === "Darwin";

/**
 * The MeasuringToolHighlighter is used to measure distances in a content page.
 * It allows users to click and drag with their mouse to draw an area whose
 * dimensions will be displayed in a tooltip next to it.
 * This allows users to measure distances between elements on a page.
 */
class MeasuringToolHighlighter {
  constructor(highlighterEnv) {
    this.env = highlighterEnv;
    this.markup = new CanvasFrameAnonymousContentHelper(
      highlighterEnv,
      this._buildMarkup.bind(this),
      {
        contentRootHostClassName: "devtools-highlighter-measuring-tool",
      }
    );
    this.isReady = this.markup.initialize();

    this.rect = { x: 0, y: 0, w: 0, h: 0 };
    this.mouseCoords = { x: 0, y: 0 };

    const { pageListenerTarget } = highlighterEnv;

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

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

    const root = this.markup.createNode({
      parent: container,
      attributes: {
        id: "measuring-tool-root",
        class: "measuring-tool-root",
        hidden: "true",
      },
    });

    const svg = this.markup.createSVGNode({
      nodeType: "svg",
      parent: root,
      attributes: {
        id: "measuring-tool-elements",
        class: "measuring-tool-elements",
        width: "100%",
        height: "100%",
      },
    });

    for (const side of SIDES) {
      this.markup.createSVGNode({
        nodeType: "line",
        parent: svg,
        attributes: {
          class: `measuring-tool-guide-${side}`,
          id: `measuring-tool-guide-${side}`,
          hidden: "true",
        },
      });
    }

    this.markup.createNode({
      nodeType: "label",
      attributes: {
        id: "measuring-tool-label-size",
        class: "measuring-tool-label-size",
        hidden: "true",
      },
      parent: root,
    });

    this.markup.createNode({
      nodeType: "label",
      attributes: {
        id: "measuring-tool-label-position",
        class: "measuring-tool-label-position",
        hidden: "true",
      },
      parent: root,
    });

    // Creating a <g> element in order to group all the paths below, that
    // together represent the measuring tool; so that would be easier move them
    // around
    const g = this.markup.createSVGNode({
      nodeType: "g",
      attributes: {
        id: "measuring-tool-tool",
      },
      parent: svg,
    });

    this.markup.createSVGNode({
      nodeType: "path",
      attributes: {
        id: "measuring-tool-box-path",
        class: "measuring-tool-box-path",
      },
      parent: g,
    });

    this.markup.createSVGNode({
      nodeType: "path",
      attributes: {
        id: "measuring-tool-diagonal-path",
        class: "measuring-tool-diagonal-path",
      },
      parent: g,
    });

    for (const handler of HANDLERS) {
      this.markup.createSVGNode({
        nodeType: "circle",
        parent: g,
        attributes: {
          class: `measuring-tool-handler-${handler}`,
          id: `measuring-tool-handler-${handler}`,
          r: HANDLER_SIZE,
          hidden: "true",
        },
      });
    }

    return container;
  }

  _update() {
    const { window } = this.env;

    setIgnoreLayoutChanges(true);

    const zoom = getCurrentZoom(window);

    const { width, height } = getWindowDimensions(window);

    const { rect } = this;

    const isZoomChanged = zoom !== rect.zoom;

    if (isZoomChanged) {
      rect.zoom = zoom;
      this.updateLabel();
    }

    const isDocumentSizeChanged =
      width !== rect.documentWidth || height !== rect.documentHeight;

    if (isDocumentSizeChanged) {
      rect.documentWidth = width;
      rect.documentHeight = height;
    }

    // If either the document's size or the zoom is changed since the last
    // repaint, we update the tool's size as well.
    if (isZoomChanged || isDocumentSizeChanged) {
      this.updateViewport();
    }

    setIgnoreLayoutChanges(false, window.document.documentElement);

    this._rafID = window.requestAnimationFrame(() => this._update());
  }

  _cancelUpdate() {
    if (this._rafID) {
      this.env.window.cancelAnimationFrame(this._rafID);
      this._rafID = 0;
    }
  }

  destroy() {
    this.hide();

    this._cancelUpdate();

    const { pageListenerTarget } = this.env;

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

    this.markup.destroy();
  }

  show() {
    setIgnoreLayoutChanges(true);

    this.getElement("measuring-tool-root").removeAttribute("hidden");

    this._update();

    setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
  }

  hide() {
    setIgnoreLayoutChanges(true);

    this.hideLabel(LABEL_TYPE_SIZE);
    this.hideLabel(LABEL_TYPE_POSITION);

    this.getElement("measuring-tool-root").setAttribute("hidden", "true");

    this._cancelUpdate();

    setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
  }

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

  setSize(w, h) {
    this.setRect(undefined, undefined, w, h);
  }

  setRect(x, y, w, h) {
    const { rect } = this;

    if (typeof x !== "undefined") {
      rect.x = x;
    }

    if (typeof y !== "undefined") {
      rect.y = y;
    }

    if (typeof w !== "undefined") {
      rect.w = w;
    }

    if (typeof h !== "undefined") {
      rect.h = h;
    }

    setIgnoreLayoutChanges(true);

    if (this._dragging) {
      this.updatePaths();
      this.updateHandlers();
    }

    this.updateLabel();

    setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
  }

  updatePaths() {
    const { x, y, w, h } = this.rect;
    const dir = `M0 0 L${w} 0 L${w} ${h} L0 ${h}z`;

    // Adding correction to the line path, otherwise some pixels are drawn
    // outside the main rectangle area.
    const x1 = w > 0 ? 0.5 : 0;
    const y1 = w < 0 && h < 0 ? -0.5 : 0;
    const w1 = w + (h < 0 && w < 0 ? 0.5 : 0);
    const h1 = h + (h > 0 && w > 0 ? -0.5 : 0);

    const linedir = `M${x1} ${y1} L${w1} ${h1}`;

    this.getElement("measuring-tool-box-path").setAttribute("d", dir);
    this.getElement("measuring-tool-diagonal-path").setAttribute("d", linedir);
    this.getElement("measuring-tool-tool").setAttribute(
      "transform",
      `translate(${x},${y})`
    );
  }

  updateLabel(type) {
    type = type || (this._dragging ? LABEL_TYPE_SIZE : LABEL_TYPE_POSITION);

    const isSizeLabel = type === LABEL_TYPE_SIZE;

    const label = this.getElement(`measuring-tool-label-${type}`);

    let origin = "top left";

    const { innerWidth, innerHeight, scrollX, scrollY } = this.env.window;
    const { x: mouseX, y: mouseY } = this.mouseCoords;
    let { x, y, w, h, zoom } = this.rect;
    const scale = 1 / zoom;

    w = w || 0;
    h = h || 0;
    x = x || 0;
    y = y || 0;
    if (type === LABEL_TYPE_SIZE) {
      x += w;
      y += h;
    } else {
      x = mouseX;
      y = mouseY;
    }

    let labelMargin, labelHeight, labelWidth;

    if (isSizeLabel) {
      labelMargin = LABEL_SIZE_MARGIN;
      labelWidth = LABEL_SIZE_WIDTH;
      labelHeight = LABEL_SIZE_HEIGHT;

      const d = Math.hypot(w, h).toFixed(2);

      label.setTextContent(`W: ${Math.abs(w)} px
                            H: ${Math.abs(h)} px
                            ↘: ${d}px`);
    } else {
      labelMargin = LABEL_POS_MARGIN;
      labelWidth = LABEL_POS_WIDTH;
      labelHeight = LABEL_POS_HEIGHT;

      label.setTextContent(`${mouseX}
                            ${mouseY}`);
    }

    // Size used to position properly the label
    const labelBoxWidth = (labelWidth + labelMargin) * scale;
    const labelBoxHeight = (labelHeight + labelMargin) * scale;

    const isGoingLeft = w < scrollX;
    const isSizeGoingLeft = isSizeLabel && isGoingLeft;
    const isExceedingLeftMargin = x - labelBoxWidth < scrollX;
    const isExceedingRightMargin = x + labelBoxWidth > innerWidth + scrollX;
    const isExceedingTopMargin = y - labelBoxHeight < scrollY;
    const isExceedingBottomMargin = y + labelBoxHeight > innerHeight + scrollY;

    if ((isSizeGoingLeft && !isExceedingLeftMargin) || isExceedingRightMargin) {
      x -= labelBoxWidth;
      origin = "top right";
    } else {
      x += labelMargin * scale;
    }

    if (isSizeLabel) {
      y += isExceedingTopMargin ? labelMargin * scale : -labelBoxHeight;
    } else {
      y += isExceedingBottomMargin ? -labelBoxHeight : labelMargin * scale;
    }

    label.setAttribute(
      "style",
      `
      width: ${labelWidth}px;
      height: ${labelHeight}px;
      transform-origin: ${origin};
      transform: translate(${x}px,${y}px) scale(${scale})
    `
    );

    if (!isSizeLabel) {
      const labelSize = this.getElement("measuring-tool-label-size");
      const style = labelSize.getAttribute("style");

      if (style) {
        labelSize.setAttribute(
          "style",
          style.replace(/scale[^)]+\)/, `scale(${scale})`)
        );
      }
    }
  }

  updateViewport() {
    const { devicePixelRatio } = this.env.window;
    const { documentWidth, documentHeight, zoom } = this.rect;

    // Because `devicePixelRatio` is affected by zoom (see bug 809788),
    // in order to get the "real" device pixel ratio, we need divide by `zoom`
    const pixelRatio = devicePixelRatio / zoom;

    // The "real" device pixel ratio is used to calculate the max stroke
    // width we can actually assign: on retina, for instance, it would be 0.5,
    // where on non high dpi monitor would be 1.
    const minWidth = 1 / pixelRatio;
    const strokeWidth = minWidth / zoom;

    this.getElement("measuring-tool-root").setAttribute(
      "style",
      `stroke-width:${strokeWidth};
       width:${documentWidth}px;
       height:${documentHeight}px;`
    );
  }

  updateGuides() {
    const { x, y, w, h } = this.rect;

    let guide = this.getElement("measuring-tool-guide-top");

    guide.setAttribute("x1", "0");
    guide.setAttribute("y1", y);
    guide.setAttribute("x2", "100%");
    guide.setAttribute("y2", y);

    guide = this.getElement("measuring-tool-guide-right");

    guide.setAttribute("x1", x + w);
    guide.setAttribute("y1", 0);
    guide.setAttribute("x2", x + w);
    guide.setAttribute("y2", "100%");

    guide = this.getElement("measuring-tool-guide-bottom");

    guide.setAttribute("x1", "0");
    guide.setAttribute("y1", y + h);
    guide.setAttribute("x2", "100%");
    guide.setAttribute("y2", y + h);

    guide = this.getElement("measuring-tool-guide-left");

    guide.setAttribute("x1", x);
    guide.setAttribute("y1", 0);
    guide.setAttribute("x2", x);
    guide.setAttribute("y2", "100%");
  }

  setHandlerPosition(handler, x, y) {
    const handlerElement = this.getElement(`measuring-tool-handler-${handler}`);
    handlerElement.setAttribute("cx", x);
    handlerElement.setAttribute("cy", y);
  }

  updateHandlers() {
    const { w, h } = this.rect;

    this.setHandlerPosition("top", w / 2, 0);
    this.setHandlerPosition("topright", w, 0);
    this.setHandlerPosition("right", w, h / 2);
    this.setHandlerPosition("bottomright", w, h);
    this.setHandlerPosition("bottom", w / 2, h);
    this.setHandlerPosition("bottomleft", 0, h);
    this.setHandlerPosition("left", 0, h / 2);
    this.setHandlerPosition("topleft", 0, 0);
  }

  showLabel(type) {
    setIgnoreLayoutChanges(true);

    this.getElement(`measuring-tool-label-${type}`).removeAttribute("hidden");

    setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
  }

  hideLabel(type) {
    setIgnoreLayoutChanges(true);

    this.getElement(`measuring-tool-label-${type}`).setAttribute(
      "hidden",
      "true"
    );

    setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
  }

  showGuides() {
    const prefix = "measuring-tool-guide-";

    for (const side of SIDES) {
      this.markup.removeAttributeForElement(`${prefix + side}`, "hidden");
    }
  }

  hideGuides() {
    const prefix = "measuring-tool-guide-";

    for (const side of SIDES) {
      this.markup.setAttributeForElement(`${prefix + side}`, "hidden", "true");
    }
  }

  showHandler(id) {
    const prefix = "measuring-tool-handler-";
    this.markup.removeAttributeForElement(prefix + id, "hidden");
  }

  showHandlers() {
    const prefix = "measuring-tool-handler-";

    for (const handler of HANDLERS) {
      this.markup.removeAttributeForElement(prefix + handler, "hidden");
    }
  }

  hideAll() {
    this.hideLabel(LABEL_TYPE_POSITION);
    this.hideLabel(LABEL_TYPE_SIZE);
    this.hideGuides();
    this.hideHandlers();
  }

  showGuidesAndHandlers() {
    // Shows the guides and handlers only if an actual area is selected
    if (this.rect.w !== 0 && this.rect.h !== 0) {
      this.updateGuides();
      this.showGuides();
      this.updateHandlers();
      this.showHandlers();
    }
  }

  hideHandlers() {
    const prefix = "measuring-tool-handler-";

    for (const handler of HANDLERS) {
      this.markup.setAttributeForElement(prefix + handler, "hidden", "true");
    }
  }

  handleEvent(event) {
    const { target, type } = event;

    switch (type) {
      case "mousedown": {
        if (event.button || this._dragging) {
          return;
        }

        const isHandler = event.originalTarget.id.includes("handler");
        if (isHandler) {
          this.handleResizingMouseDownEvent(event);
        } else {
          this.handleMouseDownEvent(event);
        }
        break;
      }
      case "mousemove":
        if (this._dragging && this._dragging.handler) {
          this.handleResizingMouseMoveEvent(event);
        } else {
          this.handleMouseMoveEvent(event);
        }
        break;
      case "mouseup":
        if (this._dragging) {
          if (this._dragging.handler) {
            this.handleResizingMouseUpEvent();
          } else {
            this.handleMouseUpEvent();
          }
        }
        break;
      case "mouseleave": {
        if (!this._dragging) {
          this.hideLabel(LABEL_TYPE_POSITION);
        }
        break;
      }
      case "scroll": {
        this.hideLabel(LABEL_TYPE_POSITION);
        break;
      }
      case "pagehide": {
        // If a page hide event is triggered for current window's highlighter, hide the
        // highlighter.
        if (target.defaultView === this.env.window) {
          this.destroy();
        }
        break;
      }
      case "keydown": {
        this.handleKeyDown(event);
        break;
      }
      case "keyup": {
        if (MeasuringToolHighlighter.#isResizeModifierPressed(event)) {
          this.getElement("measuring-tool-handler-topleft").classList?.remove(
            HIGHLIGHTED_HANDLER_CLASSNAME
          );
        }
        break;
      }
    }
  }

  handleMouseDownEvent(event) {
    const { pageX, pageY } = event;
    const { window } = this.env;
    const elementId = `measuring-tool-tool`;

    setIgnoreLayoutChanges(true);

    this.markup.getElement(elementId)?.classList.add("dragging");

    this.hideAll();

    setIgnoreLayoutChanges(false, window.document.documentElement);

    // Store all the initial values needed for drag & drop
    this._dragging = {
      handler: null,
      x: pageX,
      y: pageY,
    };

    this.setRect(pageX, pageY, 0, 0);
  }

  handleMouseMoveEvent(event) {
    const { pageX, pageY } = event;
    const { mouseCoords } = this;
    let { x, y, w, h } = this.rect;
    let labelType;

    if (this._dragging) {
      w = pageX - x;
      h = pageY - y;

      this.setRect(x, y, w, h);

      labelType = LABEL_TYPE_SIZE;
    } else {
      mouseCoords.x = pageX;
      mouseCoords.y = pageY;
      this.updateLabel(LABEL_TYPE_POSITION);

      labelType = LABEL_TYPE_POSITION;
    }

    this.showLabel(labelType);
  }

  handleMouseUpEvent() {
    setIgnoreLayoutChanges(true);

    this.getElement("measuring-tool-tool").classList?.remove("dragging");

    this.showGuidesAndHandlers();

    setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
    this._dragging = null;
  }

  handleResizingMouseDownEvent(event) {
    const { originalTarget, pageX, pageY } = event;
    const { window } = this.env;
    const prefix = "measuring-tool-handler-";
    const handler = originalTarget.id.replace(prefix, "");

    setIgnoreLayoutChanges(true);

    this.markup.getElement(originalTarget.id)?.classList.add("dragging");

    this.hideAll();
    this.showHandler(handler);

    // Set coordinates to the current measurement area's position
    const [, x, y] = this.getElement("measuring-tool-tool")
      .getAttribute("transform")
      .match(/(\d+),(\d+)/);
    this.setRect(Number(x), Number(y));

    setIgnoreLayoutChanges(false, window.document.documentElement);

    // Store all the initial values needed for drag & drop
    this._dragging = {
      handler,
      x: pageX,
      y: pageY,
    };
  }

  handleResizingMouseMoveEvent(event) {
    const { pageX, pageY } = event;
    const { rect } = this;
    let { x, y, w, h } = rect;

    const { handler } = this._dragging;

    switch (handler) {
      case "top":
        y = pageY;
        h = rect.y + rect.h - pageY;
        break;
      case "topright":
        y = pageY;
        w = pageX - rect.x;
        h = rect.y + rect.h - pageY;
        break;
      case "right":
        w = pageX - rect.x;
        break;
      case "bottomright":
        w = pageX - rect.x;
        h = pageY - rect.y;
        break;
      case "bottom":
        h = pageY - rect.y;
        break;
      case "bottomleft":
        x = pageX;
        w = rect.x + rect.w - pageX;
        h = pageY - rect.y;
        break;
      case "left":
        x = pageX;
        w = rect.x + rect.w - pageX;
        break;
      case "topleft":
        x = pageX;
        y = pageY;
        w = rect.x + rect.w - pageX;
        h = rect.y + rect.h - pageY;
        break;
    }

    this.setRect(x, y, w, h);

    // Changes the resizing cursors in case the measuring box is mirrored
    const isMirrored =
      (rect.w < 0 || rect.h < 0) && !(rect.w < 0 && rect.h < 0);
    this.getElement("measuring-tool-tool").classList.toggle(
      "mirrored",
      isMirrored
    );

    this.showLabel("size");
  }

  handleResizingMouseUpEvent() {
    const { handler } = this._dragging;

    setIgnoreLayoutChanges(true);

    this.getElement(`measuring-tool-handler-${handler}`).classList?.remove(
      "dragging"
    );
    this.showHandlers();

    this.showGuidesAndHandlers();

    setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
    this._dragging = null;
  }

  handleKeyDown(event) {
    if (MeasuringToolHighlighter.#isResizeModifierPressed(event)) {
      this.getElement("measuring-tool-handler-topleft").classList?.add(
        HIGHLIGHTED_HANDLER_CLASSNAME
      );
    }

    if (
      !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)
    ) {
      return;
    }

    const { x, y, w, h } = this.rect;
    const modifier = event.shiftKey ? 10 : 1;

    event.preventDefault();
    if (MeasuringToolHighlighter.#isResizeModifierHeld(event)) {
      // If Ctrl (or Command on OS X) is held, resize the tool
      switch (event.key) {
        case "ArrowUp":
          this.setSize(undefined, h - modifier);
          break;
        case "ArrowDown":
          this.setSize(undefined, h + modifier);
          break;
        case "ArrowLeft":
          this.setSize(w - modifier, undefined);
          break;
        case "ArrowRight":
          this.setSize(w + modifier, undefined);
          break;
      }
    } else {
      // Arrow keys with no modifier move the tool
      switch (event.key) {
        case "ArrowUp":
          this.setRect(undefined, y - modifier);
          break;
        case "ArrowDown":
          this.setRect(undefined, y + modifier);
          break;
        case "ArrowLeft":
          this.setRect(x - modifier, undefined);
          break;
        case "ArrowRight":
          this.setRect(x + modifier, undefined);
          break;
      }
    }

    this.updatePaths();
    this.updateGuides();
    this.updateHandlers();
    this.updateLabel(LABEL_TYPE_SIZE);
  }

  static #isResizeModifierPressed(event) {
    return (
      (!IS_OSX && event.key === "Control") || (IS_OSX && event.key === "Meta")
    );
  }

  static #isResizeModifierHeld(event) {
    return (!IS_OSX && event.ctrlKey) || (IS_OSX && event.metaKey);
  }
}
exports.MeasuringToolHighlighter = MeasuringToolHighlighter;
