/* 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 { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");

const PANE_APPEARANCE_DELAY = 50;

var namedTimeoutsStore = new Map();

/**
 * Helper for draining a rapid succession of events and invoking a callback
 * once everything settles down.
 *
 * @param string id
 *        A string identifier for the named timeout.
 * @param number wait
 *        The amount of milliseconds to wait after no more events are fired.
 * @param function callback
 *        Invoked when no more events are fired after the specified time.
 */
const setNamedTimeout = function setNamedTimeout(id, wait, callback) {
  clearNamedTimeout(id);

  namedTimeoutsStore.set(
    id,
    setTimeout(() => namedTimeoutsStore.delete(id) && callback(), wait)
  );
};
exports.setNamedTimeout = setNamedTimeout;

/**
 * Clears a named timeout.
 * @see setNamedTimeout
 *
 * @param string id
 *        A string identifier for the named timeout.
 */
const clearNamedTimeout = function clearNamedTimeout(id) {
  if (!namedTimeoutsStore) {
    return;
  }
  clearTimeout(namedTimeoutsStore.get(id));
  namedTimeoutsStore.delete(id);
};
exports.clearNamedTimeout = clearNamedTimeout;

/**
 * Helpers for creating and messaging between UI components.
 */
exports.ViewHelpers = {
  /**
   * Convenience method, dispatching a custom event.
   *
   * @param Node target
   *        A custom target element to dispatch the event from.
   * @param string type
   *        The name of the event.
   * @param any detail
   *        The data passed when initializing the event.
   * @return boolean
   *         True if the event was cancelled or a registered handler
   *         called preventDefault.
   */
  dispatchEvent(target, type, detail) {
    if (!(target instanceof Node)) {
      // Event cancelled.
      return true;
    }
    const document = target.ownerDocument || target;
    const dispatcher = target.ownerDocument ? target : document.documentElement;

    const event = document.createEvent("CustomEvent");
    event.initCustomEvent(type, true, true, detail);
    return dispatcher.dispatchEvent(event);
  },

  /**
   * Helper delegating some of the DOM attribute methods of a node to a widget.
   *
   * @param object widget
   *        The widget to assign the methods to.
   * @param Node node
   *        A node to delegate the methods to.
   */
  delegateWidgetAttributeMethods(widget, node) {
    widget.getAttribute = widget.getAttribute || node.getAttribute.bind(node);
    widget.setAttribute = widget.setAttribute || node.setAttribute.bind(node);
    widget.removeAttribute =
      widget.removeAttribute || node.removeAttribute.bind(node);
  },

  /**
   * Helper delegating some of the DOM event methods of a node to a widget.
   *
   * @param object widget
   *        The widget to assign the methods to.
   * @param Node node
   *        A node to delegate the methods to.
   */
  delegateWidgetEventMethods(widget, node) {
    widget.addEventListener =
      widget.addEventListener || node.addEventListener.bind(node);
    widget.removeEventListener =
      widget.removeEventListener || node.removeEventListener.bind(node);
  },

  /**
   * Checks if the specified object looks like it's been decorated by an
   * event emitter.
   *
   * @return boolean
   *         True if it looks, walks and quacks like an event emitter.
   */
  isEventEmitter(object) {
    return object?.on && object?.off && object?.once && object?.emit;
  },

  /**
   * Checks if the specified object is an instance of a DOM node.
   *
   * @return boolean
   *         True if it's a node, false otherwise.
   */
  isNode(object) {
    return (
      object instanceof Node ||
      object instanceof Element ||
      Cu.getClassName(object) == "DocumentFragment"
    );
  },

  /**
   * Prevents event propagation when navigation keys are pressed.
   *
   * @param Event e
   *        The event to be prevented.
   */
  preventScrolling(e) {
    switch (e.keyCode) {
      case KeyCodes.DOM_VK_UP:
      case KeyCodes.DOM_VK_DOWN:
      case KeyCodes.DOM_VK_LEFT:
      case KeyCodes.DOM_VK_RIGHT:
      case KeyCodes.DOM_VK_PAGE_UP:
      case KeyCodes.DOM_VK_PAGE_DOWN:
      case KeyCodes.DOM_VK_HOME:
      case KeyCodes.DOM_VK_END:
        e.preventDefault();
        e.stopPropagation();
    }
  },

  /**
   * Check if the enter key or space was pressed
   *
   * @param event event
   *        The event triggered by a keydown or keypress on an element
   */
  isSpaceOrReturn(event) {
    return (
      event.keyCode === KeyCodes.DOM_VK_SPACE ||
      event.keyCode === KeyCodes.DOM_VK_RETURN
    );
  },

  /**
   * Sets a toggled pane hidden or visible. The pane can either be displayed on
   * the side (right or left depending on the locale) or at the bottom.
   *
   * @param object flags
   *        An object containing some of the following properties:
   *        - visible: true if the pane should be shown, false to hide
   *        - animated: true to display an animation on toggle
   *        - delayed: true to wait a few cycles before toggle
   *        - callback: a function to invoke when the toggle finishes
   * @param Node pane
   *        The element representing the pane to toggle.
   */
  togglePane(flags, pane) {
    // Make sure a pane is actually available first.
    if (!pane) {
      return;
    }

    // Hiding is always handled via margins, not the hidden attribute.
    pane.removeAttribute("hidden");

    // Add a class to the pane to handle min-widths, margins and animations.
    pane.classList.add("generic-toggled-pane");

    // Avoid toggles in the middle of animation.
    if (pane.hasAttribute("animated")) {
      return;
    }

    // Avoid useless toggles.
    if (flags.visible == !pane.classList.contains("pane-collapsed")) {
      if (flags.callback) {
        flags.callback();
      }
      return;
    }

    // The "animated" attributes enables animated toggles (slide in-out).
    if (flags.animated) {
      pane.setAttribute("animated", "");
    } else {
      pane.removeAttribute("animated");
    }

    // Computes and sets the pane margins in order to hide or show it.
    const doToggle = () => {
      // Negative margins are applied to "right" and "left" to support RTL and
      // LTR directions, as well as to "bottom" to support vertical layouts.
      // Unnecessary negative margins are forced to 0 via CSS in widgets.css.
      if (flags.visible) {
        pane.style.marginLeft = "0";
        pane.style.marginRight = "0";
        pane.style.marginBottom = "0";
        pane.classList.remove("pane-collapsed");
      } else {
        const width = Math.floor(pane.getAttribute("width")) + 1;
        const height = Math.floor(pane.getAttribute("height")) + 1;
        pane.style.marginLeft = -width + "px";
        pane.style.marginRight = -width + "px";
        pane.style.marginBottom = -height + "px";
      }

      // Wait for the animation to end before calling afterToggle()
      if (flags.animated) {
        const options = {
          useCapture: false,
          once: true,
        };

        pane.addEventListener(
          "transitionend",
          () => {
            // Prevent unwanted transitions: if the panel is hidden and the layout
            // changes margins will be updated and the panel will pop out.
            pane.removeAttribute("animated");

            if (!flags.visible) {
              pane.classList.add("pane-collapsed");
            }
            if (flags.callback) {
              flags.callback();
            }
          },
          options
        );
      } else {
        if (!flags.visible) {
          pane.classList.add("pane-collapsed");
        }

        // Invoke the callback immediately since there's no transition.
        if (flags.callback) {
          flags.callback();
        }
      }
    };

    // Sometimes it's useful delaying the toggle a few ticks to ensure
    // a smoother slide in-out animation.
    if (flags.delayed) {
      pane.ownerDocument.defaultView.setTimeout(
        doToggle,
        PANE_APPEARANCE_DELAY
      );
    } else {
      doToggle();
    }
  },
};

/**
 * A generic Item is used to describe children present in a Widget.
 *
 * This is basically a very thin wrapper around a Node, with a few
 * characteristics, like a `value` and an `attachment`.
 *
 * The characteristics are optional, and their meaning is entirely up to you.
 * - The `value` should be a string, passed as an argument.
 * - The `attachment` is any kind of primitive or object, passed as an argument.
 *
 * Iterable via "for (let childItem of parentItem) { }".
 *
 * @param object ownerView
 *        The owner view creating this item.
 * @param Node element
 *        A prebuilt node to be wrapped.
 * @param string value
 *        A string identifying the node.
 * @param any attachment
 *        Some attached primitive/object.
 */
function Item(ownerView, element, value, attachment) {
  this.ownerView = ownerView;
  this.attachment = attachment;
  this._value = value + "";
  this._prebuiltNode = element;
  this._itemsByElement = new Map();
}

Item.prototype = {
  get value() {
    return this._value;
  },
  get target() {
    return this._target;
  },
  get prebuiltNode() {
    return this._prebuiltNode;
  },

  /**
   * Immediately appends a child item to this item.
   *
   * @param Node element
   *        A Node representing the child element to append.
   * @param object options [optional]
   *        Additional options or flags supported by this operation:
   *          - attachment: some attached primitive/object for the item
   *          - attributes: a batch of attributes set to the displayed element
   *          - finalize: function invoked when the child item is removed
   * @return Item
   *         The item associated with the displayed element.
   */
  append(element, options = {}) {
    const item = new Item(this, element, "", options.attachment);

    // Entangle the item with the newly inserted child node.
    // Make sure this is done with the value returned by appendChild(),
    // to avoid storing a potential DocumentFragment.
    this._entangleItem(item, this._target.appendChild(element));

    // Handle any additional options after entangling the item.
    if (options.attributes) {
      options.attributes.forEach(e => item._target.setAttribute(e[0], e[1]));
    }
    if (options.finalize) {
      item.finalize = options.finalize;
    }

    // Return the item associated with the displayed element.
    return item;
  },

  /**
   * Immediately removes the specified child item from this item.
   *
   * @param Item item
   *        The item associated with the element to remove.
   */
  remove(item) {
    if (!item) {
      return;
    }
    this._target.removeChild(item._target);
    this._untangleItem(item);
  },

  /**
   * Entangles an item (model) with a displayed node element (view).
   *
   * @param Item item
   *        The item describing a target element.
   * @param Node element
   *        The element displaying the item.
   */
  _entangleItem(item, element) {
    this._itemsByElement.set(element, item);
    item._target = element;
  },

  /**
   * Untangles an item (model) from a displayed node element (view).
   *
   * @param Item item
   *        The item describing a target element.
   */
  _untangleItem(item) {
    if (item.finalize) {
      item.finalize(item);
    }
    for (const childItem of item) {
      item.remove(childItem);
    }

    this._unlinkItem(item);
    item._target = null;
  },

  /**
   * Deletes an item from the its parent's storage maps.
   *
   * @param Item item
   *        The item describing a target element.
   */
  _unlinkItem(item) {
    this._itemsByElement.delete(item._target);
  },

  /**
   * Returns a string representing the object.
   * Avoid using `toString` to avoid accidental JSONification.
   * @return string
   */
  stringify() {
    return JSON.stringify(
      {
        value: this._value,
        target: this._target + "",
        prebuiltNode: this._prebuiltNode + "",
        attachment: this.attachment,
      },
      null,
      2
    );
  },

  _value: "",
  _target: null,
  _prebuiltNode: null,
  finalize: null,
  attachment: null,
};
