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

Components.utils.import("resource:///modules/CustomizableUI.jsm");

var gManagers = new WeakMap();

const kPaletteId = "customization-palette";
const kPlaceholderClass = "panel-customization-placeholder";

this.EXPORTED_SYMBOLS = ["DragPositionManager"];

function AreaPositionManager(aContainer) {
  // Caching the direction and bounds of the container for quick access later:
  let window = aContainer.ownerGlobal;
  this._dir = window.getComputedStyle(aContainer).direction;
  let containerRect = aContainer.getBoundingClientRect();
  this._containerInfo = {
    left: containerRect.left,
    right: containerRect.right,
    top: containerRect.top,
    width: containerRect.width
  };
  this._inPanel = aContainer.id == CustomizableUI.AREA_PANEL;
  this._horizontalDistance = null;
  this.update(aContainer);
}

AreaPositionManager.prototype = {
  _nodePositionStore: null,
  _wideCache: null,

  update: function(aContainer) {
    this._nodePositionStore = new WeakMap();
    this._wideCache = new Set();
    let last = null;
    let singleItemHeight;
    for (let child of aContainer.children) {
      if (child.hidden) {
        continue;
      }
      let isNodeWide = this._checkIfWide(child);
      if (isNodeWide) {
        this._wideCache.add(child.id);
      }
      let coordinates = this._lazyStoreGet(child);
      // We keep a baseline horizontal distance between non-wide nodes around
      // for use when we can't compare with previous/next nodes
      if (!this._horizontalDistance && last && !isNodeWide) {
        this._horizontalDistance = coordinates.left - last.left;
      }
      // We also keep the basic height of non-wide items for use below:
      if (!isNodeWide && !singleItemHeight) {
        singleItemHeight = coordinates.height;
      }
      last = !isNodeWide ? coordinates : null;
    }
    if (this._inPanel) {
      this._heightToWidthFactor = CustomizableUI.PANEL_COLUMN_COUNT;
    } else {
      this._heightToWidthFactor = this._containerInfo.width / singleItemHeight;
    }
  },

  /**
   * Find the closest node in the container given the coordinates.
   * "Closest" is defined in a somewhat strange manner: we prefer nodes
   * which are in the same row over nodes that are in a different row.
   * In order to implement this, we use a weighted cartesian distance
   * where dy is more heavily weighted by a factor corresponding to the
   * ratio between the container's width and the height of its elements.
   */
  find: function(aContainer, aX, aY, aDraggedItemId) {
    let closest = null;
    let minCartesian = Number.MAX_VALUE;
    let containerX = this._containerInfo.left;
    let containerY = this._containerInfo.top;
    for (let node of aContainer.children) {
      let coordinates = this._lazyStoreGet(node);
      let offsetX = coordinates.x - containerX;
      let offsetY = coordinates.y - containerY;
      let hDiff = offsetX - aX;
      let vDiff = offsetY - aY;
      // For wide widgets, we're always going to be further from the center
      // horizontally. Compensate:
      if (this.isWide(node)) {
        hDiff /= CustomizableUI.PANEL_COLUMN_COUNT;
      }
      // Then compensate for the height/width ratio so that we prefer items
      // which are in the same row:
      hDiff /= this._heightToWidthFactor;

      let cartesianDiff = hDiff * hDiff + vDiff * vDiff;
      if (cartesianDiff < minCartesian) {
        minCartesian = cartesianDiff;
        closest = node;
      }
    }

    // Now correct this node based on what we're dragging
    if (closest) {
      let doc = aContainer.ownerDocument;
      let draggedItem = doc.getElementById(aDraggedItemId);
      // If dragging a wide item, always pick the first item in a row:
      if (this._inPanel && draggedItem &&
          draggedItem.classList.contains(CustomizableUI.WIDE_PANEL_CLASS)) {
        return this._firstInRow(closest);
      }
      let targetBounds = this._lazyStoreGet(closest);
      let farSide = this._dir == "ltr" ? "right" : "left";
      let outsideX = targetBounds[farSide];
      // Check if we're closer to the next target than to this one:
      // Only move if we're not targeting a node in a different row:
      if (aY > targetBounds.top && aY < targetBounds.bottom) {
        if ((this._dir == "ltr" && aX > outsideX) ||
            (this._dir == "rtl" && aX < outsideX)) {
          return closest.nextSibling || aContainer;
        }
      }
    }
    return closest;
  },

  /**
   * "Insert" a "placeholder" by shifting the subsequent children out of the
   * way. We go through all the children, and shift them based on the position
   * they would have if we had inserted something before aBefore. We use CSS
   * transforms for this, which are CSS transitioned.
   */
  insertPlaceholder: function(aContainer, aBefore, aWide, aSize, aIsFromThisArea) {
    let isShifted = false;
    let shiftDown = aWide;
    for (let child of aContainer.children) {
      // Don't need to shift hidden nodes:
      if (child.getAttribute("hidden") == "true") {
        continue;
      }
      // If this is the node before which we're inserting, start shifting
      // everything that comes after. One exception is inserting at the end
      // of the menupanel, in which case we do not shift the placeholders:
      if (child == aBefore && !child.classList.contains(kPlaceholderClass)) {
        isShifted = true;
        // If the node before which we're inserting is wide, we should
        // shift everything one row down:
        if (!shiftDown && this.isWide(child)) {
          shiftDown = true;
        }
      }
      // If we're moving items before a wide node that were already there,
      // it's possible it's not necessary to shift nodes
      // including & after the wide node.
      if (this.__undoShift) {
        isShifted = false;
      }
      if (isShifted) {
        // Conversely, if we're adding something before a wide node, for
        // simplicity's sake we move everything including the wide node down:
        if (this.__moveDown) {
          shiftDown = true;
        }
        if (aIsFromThisArea && !this._lastPlaceholderInsertion) {
          child.setAttribute("notransition", "true");
        }
        // Determine the CSS transform based on the next node:
        child.style.transform = this._getNextPos(child, shiftDown, aSize);
      } else {
        // If we're not shifting this node, reset the transform
        child.style.transform = "";
      }
    }
    if (aContainer.lastChild && aIsFromThisArea &&
        !this._lastPlaceholderInsertion) {
      // Flush layout:
      aContainer.lastChild.getBoundingClientRect();
      // then remove all the [notransition]
      for (let child of aContainer.children) {
        child.removeAttribute("notransition");
      }
    }
    delete this.__moveDown;
    delete this.__undoShift;
    this._lastPlaceholderInsertion = aBefore;
  },

  isWide: function(aNode) {
    return this._wideCache.has(aNode.id);
  },

  _checkIfWide: function(aNode) {
    return this._inPanel && aNode && aNode.firstChild &&
           aNode.firstChild.classList.contains(CustomizableUI.WIDE_PANEL_CLASS);
  },

  /**
   * Reset all the transforms in this container, optionally without
   * transitioning them.
   * @param aContainer    the container in which to reset transforms
   * @param aNoTransition if truthy, adds a notransition attribute to the node
   *                      while resetting the transform.
   */
  clearPlaceholders: function(aContainer, aNoTransition) {
    for (let child of aContainer.children) {
      if (aNoTransition) {
        child.setAttribute("notransition", true);
      }
      child.style.transform = "";
      if (aNoTransition) {
        // Need to force a reflow otherwise this won't work.
        child.getBoundingClientRect();
        child.removeAttribute("notransition");
      }
    }
    // We snapped back, so we can assume there's no more
    // "last" placeholder insertion point to keep track of.
    if (aNoTransition) {
      this._lastPlaceholderInsertion = null;
    }
  },

  _getNextPos: function(aNode, aShiftDown, aSize) {
    // Shifting down is easy:
    if (this._inPanel && aShiftDown) {
      return "translate(0, " + aSize.height + "px)";
    }
    return this._diffWithNext(aNode, aSize);
  },

  _diffWithNext: function(aNode, aSize) {
    let xDiff;
    let yDiff = null;
    let nodeBounds = this._lazyStoreGet(aNode);
    let side = this._dir == "ltr" ? "left" : "right";
    let next = this._getVisibleSiblingForDirection(aNode, "next");
    // First we determine the transform along the x axis.
    // Usually, there will be a next node to base this on:
    if (next) {
      let otherBounds = this._lazyStoreGet(next);
      xDiff = otherBounds[side] - nodeBounds[side];
      // If the next node is a wide item in the panel, check if we could maybe
      // just move further out in the same row, without snapping to the next
      // one. This happens, for example, if moving an item that's before a wide
      // node within its own row of items. There will be space to drop this
      // item within the row, and the rest of the items do not need to shift.
      if (this.isWide(next)) {
        let otherXDiff = this._moveNextBasedOnPrevious(aNode, nodeBounds,
                                                       this._firstInRow(aNode));
        // If this has the same sign as our original shift, we're still
        // snapping to the start of the row. In this case, we should move
        // everything after us a row down, so as not to display two nodes on
        // top of each other:
        // (we would be able to get away with checking for equality instead of
        //  equal signs here, but one of these is based on the x coordinate of
        //  the first item in row N and one on that for row N - 1, so this is
        //  safer, as their margins might differ)
        if ((otherXDiff < 0) == (xDiff < 0)) {
          this.__moveDown = true;
        } else {
          // Otherwise, we succeeded and can move further out. This also means
          // we can stop shifting the rest of the content:
          xDiff = otherXDiff;
          this.__undoShift = true;
        }
      } else {
        // We set this explicitly because otherwise some strange difference
        // between the height and the actual difference between line creeps in
        // and messes with alignments
        yDiff = otherBounds.top - nodeBounds.top;
      }
    } else {
      // We don't have a sibling whose position we can use. First, let's see
      // if we're also the first item (which complicates things):
      let firstNode = this._firstInRow(aNode);
      if (aNode == firstNode) {
        // Maybe we stored the horizontal distance between non-wide nodes,
        // if not, we'll use the width of the incoming node as a proxy:
        xDiff = this._horizontalDistance || aSize.width;
      } else {
        // If not, we should be able to get the distance to the previous node
        // and use the inverse, unless there's no room for another node (ie we
        // are the last node and there's no room for another one)
        xDiff = this._moveNextBasedOnPrevious(aNode, nodeBounds, firstNode);
      }
    }

    // If we've not determined the vertical difference yet, check it here
    if (yDiff === null) {
      // If the next node is behind rather than in front, we must have moved
      // vertically:
      if ((xDiff > 0 && this._dir == "rtl") || (xDiff < 0 && this._dir == "ltr")) {
        yDiff = aSize.height;
      } else {
        // Otherwise, we haven't
        yDiff = 0;
      }
    }
    return "translate(" + xDiff + "px, " + yDiff + "px)";
  },

  /**
   * Helper function to find the transform a node if there isn't a next node
   * to base that on.
   * @param aNode           the node to transform
   * @param aNodeBounds     the bounding rect info of this node
   * @param aFirstNodeInRow the first node in aNode's row
   */
  _moveNextBasedOnPrevious: function(aNode, aNodeBounds, aFirstNodeInRow) {
    let next = this._getVisibleSiblingForDirection(aNode, "previous");
    let otherBounds = this._lazyStoreGet(next);
    let side = this._dir == "ltr" ? "left" : "right";
    let xDiff = aNodeBounds[side] - otherBounds[side];
    // If, however, this means we move outside the container's box
    // (i.e. the row in which this item is placed is full)
    // we should move it to align with the first item in the next row instead
    let bound = this._containerInfo[this._dir == "ltr" ? "right" : "left"];
    if ((this._dir == "ltr" && xDiff + aNodeBounds.right > bound) ||
        (this._dir == "rtl" && xDiff + aNodeBounds.left < bound)) {
      xDiff = this._lazyStoreGet(aFirstNodeInRow)[side] - aNodeBounds[side];
    }
    return xDiff;
  },

  /**
   * Get position details from our cache. If the node is not yet cached, get its position
   * information and cache it now.
   * @param aNode  the node whose position info we want
   * @return the position info
   */
  _lazyStoreGet: function(aNode) {
    let rect = this._nodePositionStore.get(aNode);
    if (!rect) {
      // getBoundingClientRect() returns a DOMRect that is live, meaning that
      // as the element moves around, the rects values change. We don't want
      // that - we want a snapshot of what the rect values are right at this
      // moment, and nothing else. So we have to clone the values.
      let clientRect = aNode.getBoundingClientRect();
      rect = {
        left: clientRect.left,
        right: clientRect.right,
        width: clientRect.width,
        height: clientRect.height,
        top: clientRect.top,
        bottom: clientRect.bottom,
      };
      rect.x = rect.left + rect.width / 2;
      rect.y = rect.top + rect.height / 2;
      Object.freeze(rect);
      this._nodePositionStore.set(aNode, rect);
    }
    return rect;
  },

  _firstInRow: function(aNode) {
    // XXXmconley: I'm not entirely sure why we need to take the floor of these
    // values - it looks like, periodically, we're getting fractional pixels back
    // from lazyStoreGet. I've filed bug 994247 to investigate.
    let bound = Math.floor(this._lazyStoreGet(aNode).top);
    let rv = aNode;
    let prev;
    while (rv && (prev = this._getVisibleSiblingForDirection(rv, "previous"))) {
      if (Math.floor(this._lazyStoreGet(prev).bottom) <= bound) {
        return rv;
      }
      rv = prev;
    }
    return rv;
  },

  _getVisibleSiblingForDirection: function(aNode, aDirection) {
    let rv = aNode;
    do {
      rv = rv[aDirection + "Sibling"];
    } while (rv && rv.getAttribute("hidden") == "true")
    return rv;
  }
}

var DragPositionManager = {
  start: function(aWindow) {
    let areas = CustomizableUI.areas.filter((area) => CustomizableUI.getAreaType(area) != "toolbar");
    areas = areas.map((area) => CustomizableUI.getCustomizeTargetForArea(area, aWindow));
    areas.push(aWindow.document.getElementById(kPaletteId));
    for (let areaNode of areas) {
      let positionManager = gManagers.get(areaNode);
      if (positionManager) {
        positionManager.update(areaNode);
      } else {
        gManagers.set(areaNode, new AreaPositionManager(areaNode));
      }
    }
  },

  add: function(aWindow, aArea, aContainer) {
    if (CustomizableUI.getAreaType(aArea) != "toolbar") {
      return;
    }

    gManagers.set(aContainer, new AreaPositionManager(aContainer));
  },

  remove: function(aWindow, aArea, aContainer) {
    if (CustomizableUI.getAreaType(aArea) != "toolbar") {
      return;
    }

    gManagers.delete(aContainer);
  },

  stop: function() {
    gManagers = new WeakMap();
  },

  getManagerForArea: function(aArea) {
    return gManagers.get(aArea);
  }
};

Object.freeze(DragPositionManager);
