/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
const { debounce } = require("resource://devtools/shared/debounce.js");

/**
 * The ShapesInContextEditor:
 * - communicates with the ShapesHighlighter actor from the server;
 * - listens to events for shape change and hover point coming from the shape-highlighter;
 * - writes shape value changes to the CSS declaration it was triggered from;
 * - synchronises highlighting coordinate points on mouse over between the shapes
 *   highlighter and the shape value shown in the Rule view.
 *
 * It is instantiated once in HighlightersOverlay by calls to .getInContextEditor().
 */
class ShapesInContextEditor {
  constructor(highlighter, inspector, state) {
    EventEmitter.decorate(this);

    this.inspector = inspector;
    this.highlighter = highlighter;
    // Refence to the NodeFront currently being highlighted.
    this.highlighterTargetNode = null;
    this.highligherEventHandlers = {};
    this.highligherEventHandlers["shape-change"] = this.onShapeChange;
    this.highligherEventHandlers["shape-hover-on"] = this.onShapeHover;
    this.highligherEventHandlers["shape-hover-off"] = this.onShapeHover;
    // Mode for shapes highlighter: shape-outside or clip-path. Used to discern
    // when toggling the highlighter on the same node for different CSS properties.
    this.mode = null;
    // Reference to Rule view used to listen for changes
    this.ruleView = this.inspector.getPanel("ruleview").view;
    // Reference of |state| from HighlightersOverlay.
    this.state = state;
    // Reference to DOM node of the toggle icon for shapes highlighter.
    this.swatch = null;

    // Commit triggers expensive DOM changes in TextPropertyEditor.update()
    // so we debounce it.
    this.commit = debounce(this.commit, 200, this);
    this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
    this.onNodeFrontChanged = this.onNodeFrontChanged.bind(this);
    this.onShapeValueUpdated = this.onShapeValueUpdated.bind(this);
    this.onRuleViewChanged = this.onRuleViewChanged.bind(this);

    this.highlighter.on("highlighter-event", this.onHighlighterEvent);
    this.ruleView.on("ruleview-changed", this.onRuleViewChanged);
  }

  /**
   * Get the reference to the TextProperty where shape changes should be written.
   *
   * We can't rely on the TextProperty to be consistent while changing the value of an
   * inline style because the fix for Bug 1467076 forces a full rebuild of TextProperties
   * for the inline style's mock-CSS Rule in the Rule view.
   *
   * On |toggle()|, we store the target TextProperty index, property name and parent rule.
   * Here, we use that index and property name to attempt to re-identify the correct
   * TextProperty in the rule.
   *
   * @return {TextProperty|null}
   */
  get textProperty() {
    if (!this.rule || !this.rule.textProps) {
      return null;
    }

    const textProp = this.rule.textProps[this.textPropIndex];
    return textProp && textProp.name === this.textPropName ? textProp : null;
  }

  /**
   * Called when the element style changes from the Rule view.
   * If the TextProperty we're acting on isn't enabled anymore or overridden,
   * turn off the shapes highlighter.
   */
  async onRuleViewChanged() {
    if (
      this.textProperty &&
      (!this.textProperty.enabled || this.textProperty.overridden)
    ) {
      await this.hide();
    }
  }

  /**
   * Toggle the shapes highlighter for the given element.
   *
   * @param {NodeFront} node
   *        The NodeFront of the element with a shape to highlight.
   * @param {object} options
   *        Object used for passing options to the shapes highlighter.
   */
  async toggle(node, options, prop) {
    // Same target node, same mode -> hide and exit OR switch to toggle transform mode.
    if (node == this.highlighterTargetNode && this.mode === options.mode) {
      if (!options.transformMode) {
        await this.hide();
        return;
      }

      options.transformMode = !this.state.shapes.options.transformMode;
    }

    // Same target node, dfferent modes -> toggle between shape-outside, clip-path and offset-path.
    // Hide highlighter for previous property, but continue and show for other property.
    if (node == this.highlighterTargetNode && this.mode !== options.mode) {
      await this.hide();
    }

    // Save the target TextProperty's parent rule, index and property name for later
    // re-identification of the TextProperty. @see |get textProperty()|.
    this.rule = prop.rule;
    this.textPropIndex = this.rule.textProps.indexOf(prop);
    this.textPropName = prop.name;

    this.findSwatch();
    await this.show(node, options);
  }

  /**
   * Show the shapes highlighter for the given element.
   *
   * @param {NodeFront} node
   *        The NodeFront of the element with a shape to highlight.
   * @param {object} options
   *        Object used for passing options to the shapes highlighter.
   */
  async show(node, options) {
    const isShown = await this.highlighter.show(node, options);
    if (!isShown) {
      return;
    }

    this.inspector.selection.on("detached-front", this.onNodeFrontChanged);
    this.inspector.selection.on("new-node-front", this.onNodeFrontChanged);
    this.ruleView.on("property-value-updated", this.onShapeValueUpdated);
    this.highlighterTargetNode = node;
    this.mode = options.mode;
    this.emit("show", { node, options });
  }

  /**
   * Hide the shapes highlighter.
   */
  async hide() {
    try {
      await this.highlighter.hide();
    } catch (err) {
      // silent error
    }

    // Stop if the panel has been destroyed during the call to hide.
    if (this.destroyed) {
      return;
    }

    if (this.swatch) {
      this.swatch.setAttribute("aria-pressed", false);
    }
    this.swatch = null;
    this.rule = null;
    this.textPropIndex = -1;
    this.textPropName = null;

    this.emit("hide", { node: this.highlighterTargetNode });
    this.inspector.selection.off("detached-front", this.onNodeFrontChanged);
    this.inspector.selection.off("new-node-front", this.onNodeFrontChanged);
    this.ruleView.off("property-value-updated", this.onShapeValueUpdated);
    this.highlighterTargetNode = null;
  }

  /**
   * Identify the swatch (aka toggle icon) DOM node from the TextPropertyEditor of the
   * TextProperty we're working with. Whenever the TextPropertyEditor is updated (i.e.
   * when committing the shape value to the Rule view), it rebuilds its DOM and the old
   * swatch reference becomes invalid. Call this method to identify the current swatch.
   */
  findSwatch() {
    if (!this.textProperty) {
      return;
    }

    const valueSpan = this.textProperty.editor.valueSpan;
    this.swatch = valueSpan.querySelector(".inspector-shapeswatch");
    if (this.swatch) {
      this.swatch.setAttribute("aria-pressed", true);
    }
  }

  /**
   * Handle events emitted by the highlighter.
   * Find any callback assigned to the event type and call it with the given data object.
   *
   * @param {object} data
   *        The data object sent in the event.
   */
  onHighlighterEvent(data) {
    const handler = this.highligherEventHandlers[data.type];
    if (!handler || typeof handler !== "function") {
      return;
    }
    handler.call(this, data);
    this.inspector.highlighters.emit("highlighter-event-handled");
  }

  /**
   * Clean up when node selection changes because Rule view and TextPropertyEditor
   * instances are not automatically destroyed when selection changes.
   */
  async onNodeFrontChanged() {
    try {
      await this.hide();
    } catch (err) {
      // Silent error.
    }
  }

  /**
   * Handler for "shape-change" event from the shapes highlighter.
   *
   * @param  {object} data
   *         Data associated with the "shape-change" event.
   *         Contains:
   *         - {String} value: the new shape value.
   *         - {String} type: the event type ("shape-change").
   */
  onShapeChange(data) {
    this.preview(data.value);
    this.commit(data.value);
  }

  /**
   * Handler for "shape-hover-on" and "shape-hover-off" events from the shapes highlighter.
   * Called when the mouse moves over or off of a coordinate point inside the shapes
   * highlighter. Marks/unmarks the corresponding coordinate node in the shape value
   * from the Rule view.
   *
   * @param  {object} data
   *         Data associated with the "shape-hover" event.
   *         Contains:
   *         - {String|null} point: coordinate to highlight or null if nothing to highlight
   *         - {String} type: the event type ("shape-hover-on" or "shape-hover-on").
   */
  onShapeHover(data) {
    const shapeValueEl = this.swatch && this.swatch.nextSibling;
    if (!shapeValueEl) {
      return;
    }

    const pointSelector = ".inspector-shape-point";
    // First, unmark all highlighted coordinate nodes from Rule view
    for (const node of shapeValueEl.querySelectorAll(
      `${pointSelector}.active`
    )) {
      node.classList.remove("active");
    }

    // Exit if there's no coordinate to highlight.
    if (typeof data.point !== "string") {
      return;
    }

    const point = data.point.includes(",")
      ? data.point.split(",")[0]
      : data.point;

    /**
     * Build selector for coordinate nodes in shape value that must be highlighted.
     * Coordinate values for inset() use class names instead of data attributes because
     * a single node may represent multiple coordinates in shorthand notation.
     * Example: inset(50px); The node wrapping 50px represents all four inset coordinates.
     */
    const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];
    const selector = INSET_POINT_TYPES.includes(point)
      ? `${pointSelector}.${point}`
      : `${pointSelector}[data-point='${point}']`;

    for (const node of shapeValueEl.querySelectorAll(selector)) {
      node.classList.add("active");
    }
  }

  /**
   * Handler for "property-value-updated" event triggered by the Rule view.
   * Called after the shape value has been written to the element's style and the Rule
   * view updated. Emits an event on HighlightersOverlay that is expected by
   * tests in order to check if the shape value has been correctly applied.
   */
  async onShapeValueUpdated() {
    if (this.textProperty) {
      // When TextPropertyEditor updates, it replaces the previous swatch DOM node.
      // Find and store the new one.
      this.findSwatch();
      this.inspector.highlighters.emit("shapes-highlighter-changes-applied");
    } else {
      await this.hide();
    }
  }

  /**
   * Preview a shape value on the element without committing the changes to the Rule view.
   *
   * @param {string} value
   *        The shape value to set the current property to
   */
  preview(value) {
    if (!this.textProperty) {
      return;
    }
    // Update the element's style to see live results.
    this.textProperty.rule.previewPropertyValue(this.textProperty, value);
    // Update the text of CSS value in the Rule view. This makes it inert.
    // When commit() is called, the value is reparsed and its DOM structure rebuilt.
    this.swatch.nextSibling.textContent = value;
  }

  /**
   * Commit a shape value change which triggers an expensive operation that rebuilds
   * part of the DOM of the TextPropertyEditor. Called in a debounced manner; see
   * constructor.
   *
   * @param {string} value
   *        The shape value for the current property
   */
  commit(value) {
    if (!this.textProperty) {
      return;
    }

    this.textProperty.setValue(value);
  }

  destroy() {
    this.highlighter.off("highlighter-event", this.onHighlighterEvent);
    this.ruleView.off("ruleview-changed", this.onRuleViewChanged);
    this.highligherEventHandlers = {};

    this.destroyed = true;
  }
}

module.exports = ShapesInContextEditor;
