/* 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 KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
const {
  HTMLTooltip,
} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js");

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

/**
 * Base class for all (color, gradient, ...)-swatch based value editors inside
 * tooltips
 *
 * @param {Document} document
 *        The document to attach the SwatchBasedEditorTooltip. This should be the
 *        toolbox document
 */

class SwatchBasedEditorTooltip extends EventEmitter {
  constructor(document) {
    super();

    // This one will consume outside clicks as it makes more sense to let the user
    // close the tooltip by clicking out
    // It will also close on <escape> and <enter>
    this.tooltip = new HTMLTooltip(document, {
      type: "arrow",
      consumeOutsideClicks: true,
      useXulWrapper: true,
    });

    // By default, swatch-based editor tooltips revert value change on <esc> and
    // commit value change on <enter>
    this.shortcuts = new KeyShortcuts({
      window: this.tooltip.doc.defaultView,
    });
    this.shortcuts.on("Escape", event => {
      if (!this.tooltip.isVisible()) {
        return;
      }
      this.revert();
      this.hide();
      event.stopPropagation();
      event.preventDefault();
    });
    this.shortcuts.on("Return", event => {
      if (!this.tooltip.isVisible()) {
        return;
      }
      this.commit();
      this.hide();
      event.stopPropagation();
      event.preventDefault();
    });

    // All target swatches are kept in a WeakMap, indexed by swatch DOM elements
    this.swatches = new WeakMap();

    // When a swatch is clicked, and for as long as the tooltip is shown, the
    // activeSwatch property will hold the reference to the swatch DOM element
    // that was clicked
    this.activeSwatch = null;

    this._onSwatchClick = this._onSwatchClick.bind(this);
    this._onSwatchKeyDown = this._onSwatchKeyDown.bind(this);
  }

  /**
   * Reports if the tooltip is currently shown
   *
   * @return {boolean} True if the tooltip is displayed.
   */
  isVisible() {
    return this.tooltip.isVisible();
  }

  /**
   * Reports if the tooltip is currently editing the targeted value
   *
   * @return {boolean} True if the tooltip is editing.
   */
  isEditing() {
    return this.isVisible();
  }

  /**
   * Show the editor tooltip for the currently active swatch.
   *
   * @return {Promise} a promise that resolves once the editor tooltip is displayed, or
   *         immediately if there is no currently active swatch.
   */
  show() {
    if (this.tooltipAnchor) {
      const onShown = this.tooltip.once("shown");

      this.tooltip.show(this.tooltipAnchor);
      this.tooltip.once("hidden", () => this.onTooltipHidden());

      return onShown;
    }

    return Promise.resolve();
  }

  /**
   * Can be overridden by subclasses if implementation specific behavior is needed on
   * tooltip hidden.
   */
  onTooltipHidden() {
    // When the tooltip is closed by clicking outside the panel we want to commit any
    // changes.
    if (!this._reverted) {
      this.commit();
    }
    this._reverted = false;

    // Once the tooltip is hidden we need to clean up any remaining objects.
    this.activeSwatch = null;
  }

  hide() {
    if (this.swatchActivatedWithKeyboard) {
      this.activeSwatch.focus();
      this.swatchActivatedWithKeyboard = null;
    }

    this.tooltip.hide();
  }

  /**
   * Add a new swatch DOM element to the list of swatch elements this editor
   * tooltip knows about. That means from now on, clicking on that swatch will
   * toggle the editor.
   *
   * @param {node} swatchEl
   *        The element to add
   * @param {object} callbacks
   *        Callbacks that will be executed when the editor wants to preview a
   *        value change, or revert a change, or commit a change.
   *        - onShow: will be called when one of the swatch tooltip is shown
   *        - onPreview: will be called when one of the sub-classes calls
   *        preview
   *        - onRevert: will be called when the user ESCapes out of the tooltip
   *        - onCommit: will be called when the user presses ENTER or clicks
   *        outside the tooltip.
   * @param {node|undefined} previousSwatchEl
   *        If we are updating an already rendered property value,
   *        the previously registered swatch element (previous call to addSwatch's `swatchEl`
   *        argument).
   */
  addSwatch(swatchEl, callbacks = {}, previousSwatchEl) {
    if (!callbacks.onShow) {
      callbacks.onShow = function () {};
    }
    if (!callbacks.onPreview) {
      callbacks.onPreview = function () {};
    }
    if (!callbacks.onRevert) {
      callbacks.onRevert = function () {};
    }
    if (!callbacks.onCommit) {
      callbacks.onCommit = function () {};
    }

    // If we are updating an already rendered swatch:
    // * unregister the old element in favor of the new one
    // * remove event listener from the old element
    // * if the swatch was active, automatically flag the new
    //   element as active.
    if (previousSwatchEl) {
      this.swatches.delete(previousSwatchEl);

      previousSwatchEl.removeEventListener("click", this._onSwatchClick);
      previousSwatchEl.removeEventListener("keydown", this._onSwatchKeyDown);

      if (this.activeSwatch == previousSwatchEl) {
        this.activeSwatch = swatchEl;
      }
    }
    this.swatches.set(swatchEl, {
      callbacks,
    });
    swatchEl.addEventListener("click", this._onSwatchClick);
    swatchEl.addEventListener("keydown", this._onSwatchKeyDown);
  }

  removeSwatch(swatchEl) {
    if (this.swatches.has(swatchEl)) {
      if (this.activeSwatch === swatchEl) {
        this.hide();
        this.activeSwatch = null;
      }
      swatchEl.removeEventListener("click", this._onSwatchClick);
      swatchEl.removeEventListener("keydown", this._onSwatchKeyDown);
      this.swatches.delete(swatchEl);
    }
  }

  _onSwatchKeyDown(event) {
    if (
      event.keyCode === KeyCodes.DOM_VK_RETURN ||
      event.keyCode === KeyCodes.DOM_VK_SPACE
    ) {
      event.preventDefault();
      event.stopPropagation();
      this._onSwatchClick(event);
    }
  }

  _onSwatchClick(event) {
    const { shiftKey, clientX, clientY, target } = event;

    // If mouse coordinates are 0, the event listener could have been triggered
    // by a keybaord
    this.swatchActivatedWithKeyboard =
      event.key && clientX === 0 && clientY === 0;

    if (shiftKey) {
      event.stopPropagation();
      return;
    }

    const swatch = this.swatches.get(target);

    if (swatch) {
      this.activeSwatch = target;
      this.show();
      swatch.callbacks.onShow();
      event.stopPropagation();
    }
  }

  /**
   * Not called by this parent class, needs to be taken care of by sub-classes
   */
  preview(value) {
    if (this.activeSwatch) {
      const swatch = this.swatches.get(this.activeSwatch);
      swatch.callbacks.onPreview(value);
    }
  }

  /**
   * This parent class only calls this on <esc> keydown
   */
  revert() {
    if (this.activeSwatch) {
      this._reverted = true;
      const swatch = this.swatches.get(this.activeSwatch);
      this.tooltip.once("hidden", () => {
        swatch.callbacks.onRevert();
      });
    }
  }

  /**
   * This parent class only calls this on <enter> keydown
   */
  commit() {
    if (this.activeSwatch) {
      const swatch = this.swatches.get(this.activeSwatch);
      swatch.callbacks.onCommit();
    }
  }

  get tooltipAnchor() {
    return this.activeSwatch;
  }

  destroy() {
    this.activeSwatch = null;
    this.tooltip.off("keydown", this._onTooltipKeydown);
    this.tooltip.destroy();
    this.shortcuts.destroy();
  }
}

module.exports = SwatchBasedEditorTooltip;
