/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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("devtools/client/shared/keycodes");

const PANE_APPEARANCE_DELAY = 50;
const PAGE_SIZE_ITEM_COUNT_RATIO = 5;
const WIDGET_FOCUSABLE_NODES = new Set(["vbox", "hbox"]);

var namedTimeoutsStore = new Map();

/**
 * Inheritance helpers from the addon SDK's core/heritage.
 * Remove these when all devtools are loadered.
 */
exports.Heritage = {
  /**
   * @see extend in sdk/core/heritage.
   */
  extend: function (prototype, properties = {}) {
    return Object.create(prototype, this.getOwnPropertyDescriptors(properties));
  },

  /**
   * @see getOwnPropertyDescriptors in sdk/core/heritage.
   */
  getOwnPropertyDescriptors: function (object) {
    return Object.getOwnPropertyNames(object).reduce((descriptor, name) => {
      descriptor[name] = Object.getOwnPropertyDescriptor(object, name);
      return descriptor;
    }, {});
  }
};

/**
 * 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;

/**
 * Same as `setNamedTimeout`, but invokes the callback only if the provided
 * predicate function returns true. Otherwise, the timeout is re-triggered.
 *
 * @param string id
 *        A string identifier for the conditional timeout.
 * @param number wait
 *        The amount of milliseconds to wait after no more events are fired.
 * @param function predicate
 *        The predicate function used to determine whether the timeout restarts.
 * @param function callback
 *        Invoked when no more events are fired after the specified time, and
 *        the provided predicate function returns true.
 */
const setConditionalTimeout = function setConditionalTimeout(id, wait,
                                                             predicate,
                                                             callback) {
  setNamedTimeout(id, wait, function maybeCallback() {
    if (predicate()) {
      callback();
      return;
    }
    setConditionalTimeout(id, wait, predicate, callback);
  });
};
exports.setConditionalTimeout = setConditionalTimeout;

/**
 * Clears a conditional timeout.
 * @see setConditionalTimeout
 *
 * @param string id
 *        A string identifier for the conditional timeout.
 */
const clearConditionalTimeout = function clearConditionalTimeout(id) {
  clearNamedTimeout(id);
};
exports.clearConditionalTimeout = clearConditionalTimeout;

/**
 * Helpers for creating and messaging between UI components.
 */
const ViewHelpers = exports.ViewHelpers = {
  /**
   * Convenience method, dispatching a custom event.
   *
   * @param nsIDOMNode 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: function (target, type, detail) {
    if (!(target instanceof Node)) {
      // Event cancelled.
      return true;
    }
    let document = target.ownerDocument || target;
    let dispatcher = target.ownerDocument ? target : document.documentElement;

    let 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 nsIDOMNode node
   *        A node to delegate the methods to.
   */
  delegateWidgetAttributeMethods: function (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 nsIDOMNode node
   *        A node to delegate the methods to.
   */
  delegateWidgetEventMethods: function (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: function (object) {
    return object && 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: function (object) {
    return object instanceof Node ||
           object instanceof Element ||
           object instanceof DocumentFragment;
  },

  /**
   * Prevents event propagation when navigation keys are pressed.
   *
   * @param Event e
   *        The event to be prevented.
   */
  preventScrolling: function (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 keypress on an element
   */
  isSpaceOrReturn: function (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 nsIDOMNode pane
   *        The element representing the pane to toggle.
   */
  togglePane: function (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.
    let 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 {
        let width = Math.floor(pane.getAttribute("width")) + 1;
        let 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) {
        let 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 an nsIDOMNode, 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 nsIDOMNode 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 nsIDOMNode element
   *        An nsIDOMNode 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: function (element, options = {}) {
    let 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: function (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 nsIDOMNode element
   *        The element displaying the item.
   */
  _entangleItem: function (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: function (item) {
    if (item.finalize) {
      item.finalize(item);
    }
    for (let 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: function (item) {
    this._itemsByElement.delete(item._target);
  },

  /**
   * Returns a string representing the object.
   * Avoid using `toString` to avoid accidental JSONification.
   * @return string
   */
  stringify: function () {
    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
};

/**
 * Some generic Widget methods handling Item instances.
 * Iterable via "for (let childItem of wrappedView) { }".
 *
 * Usage:
 *   function MyView() {
 *     this.widget = new MyWidget(document.querySelector(".my-node"));
 *   }
 *
 *   MyView.prototype = Heritage.extend(WidgetMethods, {
 *     myMethod: function() {},
 *     ...
 *   });
 *
 * See https://gist.github.com/victorporof/5749386 for more details.
 * The devtools/shared/widgets/SimpleListWidget.jsm is an implementation
 * example.
 *
 * Language:
 *   - An "item" is an instance of an Item.
 *   - An "element" or "node" is a nsIDOMNode.
 *
 * The supplied widget can be any object implementing the following
 * methods:
 *   - function:nsIDOMNode insertItemAt(aIndex:number, aNode:nsIDOMNode,
 *                                      aValue:string)
 *   - function:nsIDOMNode getItemAtIndex(aIndex:number)
 *   - function removeChild(aChild:nsIDOMNode)
 *   - function removeAllItems()
 *   - get:nsIDOMNode selectedItem()
 *   - set selectedItem(aChild:nsIDOMNode)
 *   - function getAttribute(aName:string)
 *   - function setAttribute(aName:string, aValue:string)
 *   - function removeAttribute(aName:string)
 *   - function addEventListener(aName:string, aCallback:function,
 *                               aBubbleFlag:boolean)
 *   - function removeEventListener(aName:string, aCallback:function,
 *                                  aBubbleFlag:boolean)
 *
 * Optional methods that can be implemented by the widget:
 *   - function ensureElementIsVisible(aChild:nsIDOMNode)
 *
 * Optional attributes that may be handled (when calling
 * get/set/removeAttribute):
 *   - "emptyText": label temporarily added when there are no items present
 *   - "headerText": label permanently added as a header
 *
 * For automagical keyboard and mouse accessibility, the widget should be an
 * event emitter with the following events:
 *   - "keyPress" -> (aName:string, aEvent:KeyboardEvent)
 *   - "mousePress" -> (aName:string, aEvent:MouseEvent)
 */
const WidgetMethods = exports.WidgetMethods = {
  /**
   * Sets the element node or widget associated with this container.
   * @param nsIDOMNode | object widget
   */
  set widget(widget) {
    this._widget = widget;

    // Can't use a WeakMap for _itemsByValue because keys are strings, and
    // can't use one for _itemsByElement either, since it needs to be iterable.
    this._itemsByValue = new Map();
    this._itemsByElement = new Map();
    this._stagedItems = [];

    // Handle internal events emitted by the widget if necessary.
    if (ViewHelpers.isEventEmitter(widget)) {
      widget.on("keyPress", this._onWidgetKeyPress.bind(this));
      widget.on("mousePress", this._onWidgetMousePress.bind(this));
    }
  },

  /**
   * Gets the element node or widget associated with this container.
   * @return nsIDOMNode | object
   */
  get widget() {
    return this._widget;
  },

  /**
   * Prepares an item to be added to this container. This allows, for example,
   * for a large number of items to be batched up before being sorted & added.
   *
   * If the "staged" flag is *not* set to true, the item will be immediately
   * inserted at the correct position in this container, so that all the items
   * still remain sorted. This can (possibly) be much slower than batching up
   * multiple items.
   *
   * By default, this container assumes that all the items should be displayed
   * sorted by their value. This can be overridden with the "index" flag,
   * specifying on which position should an item be appended. The "staged" and
   * "index" flags are mutually exclusive, meaning that all staged items
   * will always be appended.
   *
   * @param nsIDOMNode element
   *        A prebuilt node to be wrapped.
   * @param string value
   *        A string identifying the node.
   * @param object options [optional]
   *        Additional options or flags supported by this operation:
   *          - attachment: some attached primitive/object for the item
   *          - staged: true to stage the item to be appended later
   *          - index: specifies on which position should the item be appended
   *          - attributes: a batch of attributes set to the displayed element
   *          - finalize: function invoked when the item is removed
   * @return Item
   *         The item associated with the displayed element if an unstaged push,
   *         undefined if the item was staged for a later commit.
   */
  push: function ([element, value], options = {}) {
    let item = new Item(this, element, value, options.attachment);

    // Batch the item to be added later.
    if (options.staged) {
      // An ulterior commit operation will ignore any specified index, so
      // no reason to keep it around.
      options.index = undefined;
      return void this._stagedItems.push({ item: item, options: options });
    }
    // Find the target position in this container and insert the item there.
    if (!("index" in options)) {
      return this._insertItemAt(this._findExpectedIndexFor(item), item,
                                options);
    }
    // Insert the item at the specified index. If negative or out of bounds,
    // the item will be simply appended.
    return this._insertItemAt(options.index, item, options);
  },

  /**
   * Flushes all the prepared items into this container.
   * Any specified index on the items will be ignored. Everything is appended.
   *
   * @param object options [optional]
   *        Additional options or flags supported by this operation:
   *          - sorted: true to sort all the items before adding them
   */
  commit: function (options = {}) {
    let stagedItems = this._stagedItems;

    // Sort the items before adding them to this container, if preferred.
    if (options.sorted) {
      stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item));
    }
    // Append the prepared items to this container.
    for (let { item, opt } of stagedItems) {
      this._insertItemAt(-1, item, opt);
    }
    // Recreate the temporary items list for ulterior pushes.
    this._stagedItems.length = 0;
  },

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

    if (!this._itemsByElement.size) {
      this._preferredValue = this.selectedValue;
      this._widget.selectedItem = null;
      this._widget.setAttribute("emptyText", this._emptyText);
    }
  },

  /**
   * Removes the item at the specified index from this container.
   *
   * @param number index
   *        The index of the item to remove.
   */
  removeAt: function (index) {
    this.remove(this.getItemAtIndex(index));
  },

  /**
   * Removes the items in this container based on a predicate.
   */
  removeForPredicate: function (predicate) {
    let item;
    while ((item = this.getItemForPredicate(predicate))) {
      this.remove(item);
    }
  },

  /**
   * Removes all items from this container.
   */
  empty: function () {
    this._preferredValue = this.selectedValue;
    this._widget.selectedItem = null;
    this._widget.removeAllItems();
    this._widget.setAttribute("emptyText", this._emptyText);

    for (let [, item] of this._itemsByElement) {
      this._untangleItem(item);
    }

    this._itemsByValue.clear();
    this._itemsByElement.clear();
    this._stagedItems.length = 0;
  },

  /**
   * Ensures the specified item is visible in this container.
   *
   * @param Item item
   *        The item to bring into view.
   */
  ensureItemIsVisible: function (item) {
    this._widget.ensureElementIsVisible(item._target);
  },

  /**
   * Ensures the item at the specified index is visible in this container.
   *
   * @param number index
   *        The index of the item to bring into view.
   */
  ensureIndexIsVisible: function (index) {
    this.ensureItemIsVisible(this.getItemAtIndex(index));
  },

  /**
   * Sugar for ensuring the selected item is visible in this container.
   */
  ensureSelectedItemIsVisible: function () {
    this.ensureItemIsVisible(this.selectedItem);
  },

  /**
   * If supported by the widget, the label string temporarily added to this
   * container when there are no child items present.
   */
  set emptyText(value) {
    this._emptyText = value;

    // Apply the emptyText attribute right now if there are no child items.
    if (!this._itemsByElement.size) {
      this._widget.setAttribute("emptyText", value);
    }
  },

  /**
   * If supported by the widget, the label string permanently added to this
   * container as a header.
   * @param string value
   */
  set headerText(value) {
    this._headerText = value;
    this._widget.setAttribute("headerText", value);
  },

  /**
   * Toggles all the items in this container hidden or visible.
   *
   * This does not change the default filtering predicate, so newly inserted
   * items will always be visible. Use WidgetMethods.filterContents if you care.
   *
   * @param boolean visibleFlag
   *        Specifies the intended visibility.
   */
  toggleContents: function (visibleFlag) {
    for (let [element] of this._itemsByElement) {
      element.hidden = !visibleFlag;
    }
  },

  /**
   * Toggles all items in this container hidden or visible based on a predicate.
   *
   * @param function predicate [optional]
   *        Items are toggled according to the return value of this function,
   *        which will become the new default filtering predicate in this
   *        container.
   *        If unspecified, all items will be toggled visible.
   */
  filterContents: function (predicate = this._currentFilterPredicate) {
    this._currentFilterPredicate = predicate;

    for (let [element, item] of this._itemsByElement) {
      element.hidden = !predicate(item);
    }
  },

  /**
   * Sorts all the items in this container based on a predicate.
   *
   * @param function predicate [optional]
   *        Items are sorted according to the return value of the function,
   *        which will become the new default sorting predicate in this
   *        container. If unspecified, all items will be sorted by their value.
   */
  sortContents: function (predicate = this._currentSortPredicate) {
    let sortedItems = this.items.sort(this._currentSortPredicate = predicate);

    for (let i = 0, len = sortedItems.length; i < len; i++) {
      this.swapItems(this.getItemAtIndex(i), sortedItems[i]);
    }
  },

  /**
   * Visually swaps two items in this container.
   *
   * @param Item first
   *        The first item to be swapped.
   * @param Item second
   *        The second item to be swapped.
   */
  swapItems: function (first, second) {
    if (first == second) {
      // We're just dandy, thank you.
      return;
    }
    let { _prebuiltNode: firstPrebuiltTarget, _target: firstTarget } = first;
    let { _prebuiltNode: secondPrebuiltTarget, _target: secondTarget } = second;

    // If the two items were constructed with prebuilt nodes as
    // DocumentFragments, then those DocumentFragments are now
    // empty and need to be reassembled.
    if (firstPrebuiltTarget instanceof DocumentFragment) {
      for (let node of firstTarget.childNodes) {
        firstPrebuiltTarget.appendChild(node.cloneNode(true));
      }
    }
    if (secondPrebuiltTarget instanceof DocumentFragment) {
      for (let node of secondTarget.childNodes) {
        secondPrebuiltTarget.appendChild(node.cloneNode(true));
      }
    }

    // 1. Get the indices of the two items to swap.
    let i = this._indexOfElement(firstTarget);
    let j = this._indexOfElement(secondTarget);

    // 2. Remeber the selection index, to reselect an item, if necessary.
    let selectedTarget = this._widget.selectedItem;
    let selectedIndex = -1;
    if (selectedTarget == firstTarget) {
      selectedIndex = i;
    } else if (selectedTarget == secondTarget) {
      selectedIndex = j;
    }

    // 3. Silently nuke both items, nobody needs to know about this.
    this._widget.removeChild(firstTarget);
    this._widget.removeChild(secondTarget);
    this._unlinkItem(first);
    this._unlinkItem(second);

    // 4. Add the items again, but reversing their indices.
    this._insertItemAt.apply(this, i < j ? [i, second] : [j, first]);
    this._insertItemAt.apply(this, i < j ? [j, first] : [i, second]);

    // 5. Restore the previous selection, if necessary.
    if (selectedIndex == i) {
      this._widget.selectedItem = first._target;
    } else if (selectedIndex == j) {
      this._widget.selectedItem = second._target;
    }

    // 6. Let the outside world know that these two items were swapped.
    ViewHelpers.dispatchEvent(first.target, "swap", [second, first]);
  },

  /**
   * Visually swaps two items in this container at specific indices.
   *
   * @param number first
   *        The index of the first item to be swapped.
   * @param number second
   *        The index of the second item to be swapped.
   */
  swapItemsAtIndices: function (first, second) {
    this.swapItems(this.getItemAtIndex(first), this.getItemAtIndex(second));
  },

  /**
   * Checks whether an item with the specified value is among the elements
   * shown in this container.
   *
   * @param string value
   *        The item's value.
   * @return boolean
   *         True if the value is known, false otherwise.
   */
  containsValue: function (value) {
    return this._itemsByValue.has(value) ||
           this._stagedItems.some(({ item }) => item._value == value);
  },

  /**
   * Gets the "preferred value". This is the latest selected item's value,
   * remembered just before emptying this container.
   * @return string
   */
  get preferredValue() {
    return this._preferredValue;
  },

  /**
   * Retrieves the item associated with the selected element.
   * @return Item | null
   */
  get selectedItem() {
    let selectedElement = this._widget.selectedItem;
    if (selectedElement) {
      return this._itemsByElement.get(selectedElement);
    }
    return null;
  },

  /**
   * Retrieves the selected element's index in this container.
   * @return number
   */
  get selectedIndex() {
    let selectedElement = this._widget.selectedItem;
    if (selectedElement) {
      return this._indexOfElement(selectedElement);
    }
    return -1;
  },

  /**
   * Retrieves the value of the selected element.
   * @return string
   */
  get selectedValue() {
    let selectedElement = this._widget.selectedItem;
    if (selectedElement) {
      return this._itemsByElement.get(selectedElement)._value;
    }
    return "";
  },

  /**
   * Retrieves the attachment of the selected element.
   * @return object | null
   */
  get selectedAttachment() {
    let selectedElement = this._widget.selectedItem;
    if (selectedElement) {
      return this._itemsByElement.get(selectedElement).attachment;
    }
    return null;
  },

  _selectItem: function (item) {
    // A falsy item is allowed to invalidate the current selection.
    let targetElement = item ? item._target : null;
    let prevElement = this._widget.selectedItem;

    // Make sure the selected item's target element is focused and visible.
    if (this.autoFocusOnSelection && targetElement) {
      targetElement.focus();
    }

    if (targetElement != prevElement) {
      this._widget.selectedItem = targetElement;
    }
  },

  /**
   * Selects the element with the entangled item in this container.
   * @param Item | function item
   */
  set selectedItem(item) {
    // A predicate is allowed to select a specific item.
    // If no item is matched, then the current selection is removed.
    if (typeof item == "function") {
      item = this.getItemForPredicate(item);
    }

    let targetElement = item ? item._target : null;
    let prevElement = this._widget.selectedItem;

    if (this.maintainSelectionVisible && targetElement) {
      // Some methods are optional. See the WidgetMethods object documentation
      // for a comprehensive list.
      if ("ensureElementIsVisible" in this._widget) {
        this._widget.ensureElementIsVisible(targetElement);
      }
    }

    this._selectItem(item);

    // Prevent selecting the same item again and avoid dispatching
    // a redundant selection event, so return early.
    if (targetElement != prevElement) {
      let dispTarget = targetElement || prevElement;
      let dispName = this.suppressSelectionEvents ? "suppressed-select"
                                                  : "select";
      ViewHelpers.dispatchEvent(dispTarget, dispName, item);
    }
  },

  /**
   * Selects the element at the specified index in this container.
   * @param number index
   */
  set selectedIndex(index) {
    let targetElement = this._widget.getItemAtIndex(index);
    if (targetElement) {
      this.selectedItem = this._itemsByElement.get(targetElement);
      return;
    }
    this.selectedItem = null;
  },

  /**
   * Selects the element with the specified value in this container.
   * @param string value
   */
  set selectedValue(value) {
    this.selectedItem = this._itemsByValue.get(value);
  },

  /**
   * Deselects and re-selects an item in this container.
   *
   * Useful when you want a "select" event to be emitted, even though
   * the specified item was already selected.
   *
   * @param Item | function item
   * @see `set selectedItem`
   */
  forceSelect: function (item) {
    this.selectedItem = null;
    this.selectedItem = item;
  },

  /**
   * Specifies if this container should try to keep the selected item visible.
   * (For example, when new items are added the selection is brought into view).
   */
  maintainSelectionVisible: true,

  /**
   * Specifies if "select" events dispatched from the elements in this container
   * when their respective items are selected should be suppressed or not.
   *
   * If this flag is set to true, then consumers of this container won't
   * be normally notified when items are selected.
   */
  suppressSelectionEvents: false,

  /**
   * Focus this container the first time an element is inserted?
   *
   * If this flag is set to true, then when the first item is inserted in
   * this container (and thus it's the only item available), its corresponding
   * target element is focused as well.
   */
  autoFocusOnFirstItem: true,

  /**
   * Focus on selection?
   *
   * If this flag is set to true, then whenever an item is selected in
   * this container (e.g. via the selectedIndex or selectedItem setters),
   * its corresponding target element is focused as well.
   *
   * You can disable this flag, for example, to maintain a certain node
   * focused but visually indicate a different selection in this container.
   */
  autoFocusOnSelection: true,

  /**
   * Focus on input (e.g. mouse click)?
   *
   * If this flag is set to true, then whenever an item receives user input in
   * this container, its corresponding target element is focused as well.
   */
  autoFocusOnInput: true,

  /**
   * When focusing on input, allow right clicks?
   * @see WidgetMethods.autoFocusOnInput
   */
  allowFocusOnRightClick: false,

  /**
   * The number of elements in this container to jump when Page Up or Page Down
   * keys are pressed. If falsy, then the page size will be based on the
   * number of visible items in the container.
   */
  pageSize: 0,

  /**
   * Focuses the first visible item in this container.
   */
  focusFirstVisibleItem: function () {
    this.focusItemAtDelta(-this.itemCount);
  },

  /**
   * Focuses the last visible item in this container.
   */
  focusLastVisibleItem: function () {
    this.focusItemAtDelta(+this.itemCount);
  },

  /**
   * Focuses the next item in this container.
   */
  focusNextItem: function () {
    this.focusItemAtDelta(+1);
  },

  /**
   * Focuses the previous item in this container.
   */
  focusPrevItem: function () {
    this.focusItemAtDelta(-1);
  },

  /**
   * Focuses another item in this container based on the index distance
   * from the currently focused item.
   *
   * @param number delta
   *        A scalar specifying by how many items should the selection change.
   */
  focusItemAtDelta: function (delta) {
    // Make sure the currently selected item is also focused, so that the
    // command dispatcher mechanism has a relative node to work with.
    // If there's no selection, just select an item at a corresponding index
    // (e.g. the first item in this container if delta <= 1).
    let selectedElement = this._widget.selectedItem;
    if (selectedElement) {
      selectedElement.focus();
    } else {
      this.selectedIndex = Math.max(0, delta - 1);
      return;
    }

    let direction = delta > 0 ? "advanceFocus" : "rewindFocus";
    let distance = Math.abs(Math[delta > 0 ? "ceil" : "floor"](delta));
    while (distance--) {
      if (!this._focusChange(direction)) {
        // Out of bounds.
        break;
      }
    }

    // Synchronize the selected item as being the currently focused element.
    this.selectedItem = this.getItemForElement(this._focusedElement);
  },

  /**
   * Focuses the next or previous item in this container.
   *
   * @param string direction
   *        Either "advanceFocus" or "rewindFocus".
   * @return boolean
   *         False if the focus went out of bounds and the first or last item
   *         in this container was focused instead.
   */
  _focusChange: function (direction) {
    let commandDispatcher = this._commandDispatcher;
    let prevFocusedElement = commandDispatcher.focusedElement;
    let currFocusedElement;

    do {
      commandDispatcher.suppressFocusScroll = true;
      commandDispatcher[direction]();
      currFocusedElement = commandDispatcher.focusedElement;

      // Make sure the newly focused item is a part of this container. If the
      // focus goes out of bounds, revert the previously focused item.
      if (!this.getItemForElement(currFocusedElement)) {
        prevFocusedElement.focus();
        return false;
      }
    } while (!WIDGET_FOCUSABLE_NODES.has(currFocusedElement.tagName));

    // Focus remained within bounds.
    return true;
  },

  /**
   * Gets the command dispatcher instance associated with this container's DOM.
   * If there are no items displayed in this container, null is returned.
   * @return nsIDOMXULCommandDispatcher | null
   */
  get _commandDispatcher() {
    if (this._cachedCommandDispatcher) {
      return this._cachedCommandDispatcher;
    }
    let someElement = this._widget.getItemAtIndex(0);
    if (someElement) {
      let commandDispatcher = someElement.ownerDocument.commandDispatcher;
      this._cachedCommandDispatcher = commandDispatcher;
      return commandDispatcher;
    }
    return null;
  },

  /**
   * Gets the currently focused element in this container.
   *
   * @return nsIDOMNode
   *         The focused element, or null if nothing is found.
   */
  get _focusedElement() {
    let commandDispatcher = this._commandDispatcher;
    if (commandDispatcher) {
      return commandDispatcher.focusedElement;
    }
    return null;
  },

  /**
   * Gets the item in the container having the specified index.
   *
   * @param number index
   *        The index used to identify the element.
   * @return Item
   *         The matched item, or null if nothing is found.
   */
  getItemAtIndex: function (index) {
    return this.getItemForElement(this._widget.getItemAtIndex(index));
  },

  /**
   * Gets the item in the container having the specified value.
   *
   * @param string value
   *        The value used to identify the element.
   * @return Item
   *         The matched item, or null if nothing is found.
   */
  getItemByValue: function (value) {
    return this._itemsByValue.get(value);
  },

  /**
   * Gets the item in the container associated with the specified element.
   *
   * @param nsIDOMNode element
   *        The element used to identify the item.
   * @param object flags [optional]
   *        Additional options for showing the source. Supported options:
   *          - noSiblings: if siblings shouldn't be taken into consideration
   *                        when searching for the associated item.
   * @return Item
   *         The matched item, or null if nothing is found.
   */
  getItemForElement: function (element, flags = {}) {
    while (element) {
      let item = this._itemsByElement.get(element);

      // Also search the siblings if allowed.
      if (!flags.noSiblings) {
        item = item ||
          this._itemsByElement.get(element.nextElementSibling) ||
          this._itemsByElement.get(element.previousElementSibling);
      }
      if (item) {
        return item;
      }
      element = element.parentNode;
    }
    return null;
  },

  /**
   * Gets a visible item in this container validating a specified predicate.
   *
   * @param function predicate
   *        The first item which validates this predicate is returned
   * @return Item
   *         The matched item, or null if nothing is found.
   */
  getItemForPredicate: function (predicate, owner = this) {
    // Recursively check the items in this widget for a predicate match.
    for (let [element, item] of owner._itemsByElement) {
      let match;
      if (predicate(item) && !element.hidden) {
        match = item;
      } else {
        match = this.getItemForPredicate(predicate, item);
      }
      if (match) {
        return match;
      }
    }
    // Also check the staged items. No need to do this recursively since
    // they're not even appended to the view yet.
    for (let { item } of this._stagedItems) {
      if (predicate(item)) {
        return item;
      }
    }
    return null;
  },

  /**
   * Shortcut function for getItemForPredicate which works on item attachments.
   * @see getItemForPredicate
   */
  getItemForAttachment: function (predicate, owner = this) {
    return this.getItemForPredicate(e => predicate(e.attachment));
  },

  /**
   * Finds the index of an item in the container.
   *
   * @param Item item
   *        The item get the index for.
   * @return number
   *         The index of the matched item, or -1 if nothing is found.
   */
  indexOfItem: function (item) {
    return this._indexOfElement(item._target);
  },

  /**
   * Finds the index of an element in the container.
   *
   * @param nsIDOMNode element
   *        The element get the index for.
   * @return number
   *         The index of the matched element, or -1 if nothing is found.
   */
  _indexOfElement: function (element) {
    for (let i = 0; i < this._itemsByElement.size; i++) {
      if (this._widget.getItemAtIndex(i) == element) {
        return i;
      }
    }
    return -1;
  },

  /**
   * Gets the total number of items in this container.
   * @return number
   */
  get itemCount() {
    return this._itemsByElement.size;
  },

  /**
   * Returns a list of items in this container, in the displayed order.
   * @return array
   */
  get items() {
    let store = [];
    let itemCount = this.itemCount;
    for (let i = 0; i < itemCount; i++) {
      store.push(this.getItemAtIndex(i));
    }
    return store;
  },

  /**
   * Returns a list of values in this container, in the displayed order.
   * @return array
   */
  get values() {
    return this.items.map(e => e._value);
  },

  /**
   * Returns a list of attachments in this container, in the displayed order.
   * @return array
   */
  get attachments() {
    return this.items.map(e => e.attachment);
  },

  /**
   * Returns a list of all the visible (non-hidden) items in this container,
   * in the displayed order
   * @return array
   */
  get visibleItems() {
    return this.items.filter(e => !e._target.hidden);
  },

  /**
   * Checks if an item is unique in this container. If an item's value is an
   * empty string, "undefined" or "null", it is considered unique.
   *
   * @param Item item
   *        The item for which to verify uniqueness.
   * @return boolean
   *         True if the item is unique, false otherwise.
   */
  isUnique: function (item) {
    let value = item._value;
    if (value == "" || value == "undefined" || value == "null") {
      return true;
    }
    return !this._itemsByValue.has(value);
  },

  /**
   * Checks if an item is eligible for this container. By default, this checks
   * whether an item is unique and has a prebuilt target node.
   *
   * @param Item item
   *        The item for which to verify eligibility.
   * @return boolean
   *         True if the item is eligible, false otherwise.
   */
  isEligible: function (item) {
    return this.isUnique(item) && item._prebuiltNode;
  },

  /**
   * Finds the expected item index in this container based on the default
   * sort predicate.
   *
   * @param Item item
   *        The item for which to get the expected index.
   * @return number
   *         The expected item index.
   */
  _findExpectedIndexFor: function (item) {
    let itemCount = this.itemCount;
    for (let i = 0; i < itemCount; i++) {
      if (this._currentSortPredicate(this.getItemAtIndex(i), item) > 0) {
        return i;
      }
    }
    return itemCount;
  },

  /**
   * Immediately inserts an item in this container at the specified index.
   *
   * @param number index
   *        The position in the container intended for this item.
   * @param Item item
   *        The item describing a target element.
   * @param object options [optional]
   *        Additional options or flags supported by this operation:
   *          - attributes: a batch of attributes set to the displayed element
   *          - finalize: function when the item is untangled (removed)
   * @return Item
   *         The item associated with the displayed element, null if rejected.
   */
  _insertItemAt: function (index, item, options = {}) {
    if (!this.isEligible(item)) {
      return null;
    }

    // Entangle the item with the newly inserted node.
    // Make sure this is done with the value returned by insertItemAt(),
    // to avoid storing a potential DocumentFragment.
    let node = item._prebuiltNode;
    let attachment = item.attachment;
    this._entangleItem(item,
                       this._widget.insertItemAt(index, node, attachment));

    // Handle any additional options after entangling the item.
    if (!this._currentFilterPredicate(item)) {
      item._target.hidden = true;
    }
    if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) {
      item._target.focus();
    }
    if (options.attributes) {
      options.attributes.forEach(e => item._target.setAttribute(e[0], e[1]));
    }
    if (options.finalize) {
      item.finalize = options.finalize;
    }

    // Hide the empty text if the selection wasn't lost.
    this._widget.removeAttribute("emptyText");

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

  /**
   * Entangles an item (model) with a displayed node element (view).
   *
   * @param Item item
   *        The item describing a target element.
   * @param nsIDOMNode element
   *        The element displaying the item.
   */
  _entangleItem: function (item, element) {
    this._itemsByValue.set(item._value, item);
    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: function (item) {
    if (item.finalize) {
      item.finalize(item);
    }
    for (let 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: function (item) {
    this._itemsByValue.delete(item._value);
    this._itemsByElement.delete(item._target);
  },

  /**
   * The keyPress event listener for this container.
   * @param string name
   * @param KeyboardEvent event
   */
  _onWidgetKeyPress: function (name, event) {
    // Prevent scrolling when pressing navigation keys.
    ViewHelpers.preventScrolling(event);

    switch (event.keyCode) {
      case KeyCodes.DOM_VK_UP:
      case KeyCodes.DOM_VK_LEFT:
        this.focusPrevItem();
        return;
      case KeyCodes.DOM_VK_DOWN:
      case KeyCodes.DOM_VK_RIGHT:
        this.focusNextItem();
        return;
      case KeyCodes.DOM_VK_PAGE_UP:
        this.focusItemAtDelta(-(this.pageSize ||
                               (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
        return;
      case KeyCodes.DOM_VK_PAGE_DOWN:
        this.focusItemAtDelta(+(this.pageSize ||
                               (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
        return;
      case KeyCodes.DOM_VK_HOME:
        this.focusFirstVisibleItem();
        return;
      case KeyCodes.DOM_VK_END:
        this.focusLastVisibleItem();
        return;
    }
  },

  /**
   * The mousePress event listener for this container.
   * @param string name
   * @param MouseEvent event
   */
  _onWidgetMousePress: function (name, event) {
    if (event.button != 0 && !this.allowFocusOnRightClick) {
      // Only allow left-click to trigger this event.
      return;
    }

    let item = this.getItemForElement(event.target);
    if (item) {
      // The container is not empty and we clicked on an actual item.
      this.selectedItem = item;
      // Make sure the current event's target element is also focused.
      this.autoFocusOnInput && item._target.focus();
    }
  },

  /**
   * The predicate used when filtering items. By default, all items in this
   * view are visible.
   *
   * @param Item item
   *        The item passing through the filter.
   * @return boolean
   *         True if the item should be visible, false otherwise.
   */
  _currentFilterPredicate: function (item) {
    return true;
  },

  /**
   * The predicate used when sorting items. By default, items in this view
   * are sorted by their label.
   *
   * @param Item first
   *        The first item used in the comparison.
   * @param Item second
   *        The second item used in the comparison.
   * @return number
   *         -1 to sort first to a lower index than second
   *          0 to leave first and second unchanged with respect to each other
   *          1 to sort second to a lower index than first
   */
  _currentSortPredicate: function (first, second) {
    return +(first._value.toLowerCase() > second._value.toLowerCase());
  },

  /**
   * Call a method on this widget named `methodName`. Any further arguments are
   * passed on to the method. Returns the result of the method call.
   *
   * @param String methodName
   *        The name of the method you want to call.
   * @param args
   *        Optional. Any arguments you want to pass through to the method.
   */
  callMethod: function (methodName, ...args) {
    return this._widget[methodName].apply(this._widget, args);
  },

  _widget: null,
  _emptyText: "",
  _headerText: "",
  _preferredValue: "",
  _cachedCommandDispatcher: null
};

/**
 * A generator-iterator over all the items in this container.
 */
Item.prototype[Symbol.iterator] =
WidgetMethods[Symbol.iterator] = function* () {
  yield* this._itemsByElement.values();
};
