// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Behavior for handling display layout, specifically
 *     edge snapping and collisions.
 */

/** @polymerBehavior */
const LayoutBehavior = {
  properties: {
    /**
     * Array of display layouts.
     * @type {!Array<!chrome.system.display.DisplayLayout>}
     */
    layouts: Array,

    /**
     * Whether or not mirroring is enabled.
     * @type {boolean}
     */
    mirroring: {
      type: Boolean,
      value: false,
    },
  },

  /** @private {!Map<string, chrome.system.display.Bounds>} */
  displayBoundsMap_: new Map(),

  /** @private {!Map<string, chrome.system.display.DisplayLayout>} */
  displayLayoutMap_: new Map(),

  /**
   * The calculated bounds used for generating the div bounds.
   * @private {!Map<string, chrome.system.display.Bounds>}
   */
  calculatedBoundsMap_: new Map(),

  /** @private {string} */
  dragLayoutId_: '',

  /** @private {string} */
  dragParentId_: '',

  /** @private {!chrome.system.display.Bounds|undefined} */
  dragBounds_: undefined,

  /** @private {!chrome.system.display.LayoutPosition|undefined} */
  dragLayoutPosition_: undefined,

  /**
   * @param {!Array<!chrome.system.display.DisplayUnitInfo>} displays
   * @param {!Array<!chrome.system.display.DisplayLayout>} layouts
   */
  initializeDisplayLayout: function(displays, layouts) {
    this.dragLayoutId_ = '';
    this.dragParentId_ = '';

    this.mirroring = displays.length > 0 && !!displays[0].mirroringSourceId;

    this.displayBoundsMap_.clear();
    for (const display of displays) {
      this.displayBoundsMap_.set(display.id, display.bounds);
    }
    this.displayLayoutMap_.clear();
    for (const layout of layouts) {
      this.displayLayoutMap_.set(layout.id, layout);
    }
    this.calculatedBoundsMap_.clear();
    for (const display of displays) {
      if (!this.calculatedBoundsMap_.has(display.id)) {
        const bounds = display.bounds;
        this.calculateBounds_(display.id, bounds.width, bounds.height);
      }
    }
  },

  /**
   * Called when a drag event occurs. Checks collisions and updates the layout.
   * @param {string} id
   * @param {!chrome.system.display.Bounds} newBounds The new calculated
   *     bounds for the display.
   * @return {!chrome.system.display.Bounds}
   */
  updateDisplayBounds: function(id, newBounds) {
    this.dragLayoutId_ = id;

    // Find the closest parent.
    const closestId = this.findClosest_(id, newBounds);
    assert(closestId);

    // Find the closest edge.
    const closestBounds = this.getCalculatedDisplayBounds(closestId);
    const layoutPosition =
        this.getLayoutPositionForBounds_(newBounds, closestBounds);

    // Snap to the closest edge.
    const snapPos = this.snapBounds_(newBounds, closestId, layoutPosition);
    newBounds.left = snapPos.x;
    newBounds.top = snapPos.y;

    // Calculate the new bounds and delta.
    const oldBounds = this.dragBounds_ || this.getCalculatedDisplayBounds(id);
    const deltaPos = {
      x: newBounds.left - oldBounds.left,
      y: newBounds.top - oldBounds.top
    };

    // Check for collisions after snapping. This should not collide with the
    // closest parent.
    this.collideAndModifyDelta_(id, oldBounds, deltaPos);

    // If the edge changed, update and highlight it.
    if (layoutPosition != this.dragLayoutPosition_ ||
        closestId != this.dragParentId_) {
      this.dragLayoutPosition_ = layoutPosition;
      this.dragParentId_ = closestId;
      this.highlightEdge_(closestId, layoutPosition);
    }

    newBounds.left = oldBounds.left + deltaPos.x;
    newBounds.top = oldBounds.top + deltaPos.y;

    this.dragBounds_ = newBounds;

    return newBounds;
  },

  /**
   * Called when dragging ends. Sends the updated layout to chrome.
   * @param {string} id
   */
  finishUpdateDisplayBounds: function(id) {
    this.highlightEdge_('', undefined);  // Remove any highlights.
    if (id != this.dragLayoutId_ || !this.dragBounds_ ||
        !this.dragLayoutPosition_) {
      return;
    }

    const layout = this.displayLayoutMap_.get(id);

    let orphanIds;
    if (!layout || layout.parentId == '') {
      // Primary display. Set the calculated position to |dragBounds_|.
      this.setCalculatedDisplayBounds_(id, this.dragBounds_);

      // We cannot re-parent the primary display, so instead make all other
      // displays orphans and clear their calculated bounds.
      orphanIds = this.findChildren_(id, true /* recurse */);

      // Re-parent |dragParentId_|. It will be forced to parent to the dragged
      // display since it is the only non-orphan.
      this.reparentOrphan_(this.dragParentId_, orphanIds);
      orphanIds.splice(orphanIds.indexOf(this.dragParentId_), 1);
    } else {
      // All immediate children of |layout| will need to be re-parented.
      orphanIds = this.findChildren_(id, false /* do not recurse */);

      // When re-parenting to a descendant, also parent any immediate child to
      // drag display's current parent.
      let topLayout = this.displayLayoutMap_.get(this.dragParentId_);
      while (topLayout && topLayout.parentId != '') {
        if (topLayout.parentId == id) {
          topLayout.parentId = layout.parentId;
          break;
        }
        topLayout = this.displayLayoutMap_.get(topLayout.parentId);
      }

      // Re-parent the dragged display.
      layout.parentId = this.dragParentId_;
      this.updateOffsetAndPosition_(
          this.dragBounds_, this.dragLayoutPosition_, layout);
    }

    // Update any orphaned children. This may cause the dragged display to
    // be re-attached if it was attached to a child.
    this.updateOrphans_(orphanIds);

    // Send the updated layouts.
    chrome.system.display.setDisplayLayout(this.layouts, function() {
      if (chrome.runtime.lastError) {
        console.error(
            'setDisplayLayout Error: ' + chrome.runtime.lastError.message);
      }
    });
  },

  /**
   * @param {string} displayId
   * @param {boolean=} opt_notest Set to true if bounds may not be set.
   * @return {!chrome.system.display.Bounds} bounds
   */
  getCalculatedDisplayBounds: function(displayId, opt_notest) {
    const bounds = this.calculatedBoundsMap_.get(displayId);
    assert(opt_notest || bounds);
    return bounds;
  },

  /**
   * @param {string} displayId
   * @param {!chrome.system.display.Bounds|undefined} bounds
   * @private
   */
  setCalculatedDisplayBounds_: function(displayId, bounds) {
    assert(bounds);
    this.calculatedBoundsMap_.set(
        displayId,
        /** @type {!chrome.system.display.Bounds} */
        (Object.assign({}, bounds)));
  },

  /**
   * Re-parents all entries in |orphanIds| and any children.
   * @param {!Array<string>} orphanIds The list of ids affected by the move.
   * @private
   */
  updateOrphans_: function(orphanIds) {
    const orphans = orphanIds.slice();
    for (let i = 0; i < orphanIds.length; ++i) {
      const orphan = orphanIds[i];
      const newOrphans = this.findChildren_(orphan, true /* recurse */);
      // If the dragged display was re-parented to one of its children,
      // there may be duplicates so merge the lists.
      for (let ii = 0; ii < newOrphans.length; ++ii) {
        const o = newOrphans[ii];
        if (!orphans.includes(o)) {
          orphans.push(o);
        }
      }
    }

    // Remove each orphan from the list as it is re-parented so that
    // subsequent orphans can be parented to it.
    while (orphans.length) {
      const orphanId = orphans.shift();
      this.reparentOrphan_(orphanId, orphans);
    }
  },

  /**
   * Re-parents the orphan to a layout that is not a member of
   * |otherOrphanIds|.
   * @param {string} orphanId The id of the orphan to re-parent.
   * @param {Array<string>} otherOrphanIds The list of ids of other orphans
   *     to ignore when re-parenting.
   * @private
   */
  reparentOrphan_: function(orphanId, otherOrphanIds) {
    const layout = this.displayLayoutMap_.get(orphanId);
    assert(layout);
    if (orphanId == this.dragId && layout.parentId != '') {
      this.setCalculatedDisplayBounds_(orphanId, this.dragBounds_);
      return;
    }
    const bounds = this.getCalculatedDisplayBounds(orphanId);

    // Find the closest parent.
    const newParentId = this.findClosest_(orphanId, bounds, otherOrphanIds);
    assert(newParentId != '');
    layout.parentId = newParentId;

    // Find the closest edge.
    const parentBounds = this.getCalculatedDisplayBounds(newParentId);
    const layoutPosition =
        this.getLayoutPositionForBounds_(bounds, parentBounds);

    // Move from the nearest corner to the desired location and get the delta.
    const cornerBounds = this.getCornerBounds_(bounds, parentBounds);
    const desiredPos = this.snapBounds_(bounds, newParentId, layoutPosition);
    const deltaPos = {
      x: desiredPos.x - cornerBounds.left,
      y: desiredPos.y - cornerBounds.top
    };

    // Check for collisions.
    this.collideAndModifyDelta_(orphanId, cornerBounds, deltaPos);
    const desiredBounds = {
      left: cornerBounds.left + deltaPos.x,
      top: cornerBounds.top + deltaPos.y,
      width: bounds.width,
      height: bounds.height
    };

    this.updateOffsetAndPosition_(desiredBounds, layoutPosition, layout);
  },

  /**
   * @param {string} parentId
   * @param {boolean} recurse Include descendants of children.
   * @return {!Array<string>}
   * @private
   */
  findChildren_: function(parentId, recurse) {
    let children = [];
    this.displayLayoutMap_.forEach((value, key) => {
      const childId = key;
      if (childId != parentId && value.parentId == parentId) {
        // Insert immediate children at the front of the array.
        children.unshift(childId);
        if (recurse) {
          // Descendants get added to the end of the list.
          children = children.concat(this.findChildren_(childId, true));
        }
      }
    });
    return children;
  },

  /**
   * Recursively calculates the absolute bounds of a display.
   * Caches the display bounds so that parent bounds are only calculated once.
   * @param {string} id
   * @param {number} width
   * @param {number} height
   * @private
   */
  calculateBounds_: function(id, width, height) {
    let left, top;
    const layout = this.displayLayoutMap_.get(id);
    if (this.mirroring || !layout || !layout.parentId) {
      left = -width / 2;
      top = -height / 2;
    } else {
      if (!this.calculatedBoundsMap_.has(layout.parentId)) {
        const pbounds = this.displayBoundsMap_.get(layout.parentId);
        this.calculateBounds_(layout.parentId, pbounds.width, pbounds.height);
      }
      const parentBounds = this.getCalculatedDisplayBounds(layout.parentId);
      left = parentBounds.left;
      top = parentBounds.top;
      switch (layout.position) {
        case chrome.system.display.LayoutPosition.TOP:
          left += layout.offset;
          top -= height;
          break;
        case chrome.system.display.LayoutPosition.RIGHT:
          left += parentBounds.width;
          top += layout.offset;
          break;
        case chrome.system.display.LayoutPosition.BOTTOM:
          left += layout.offset;
          top += parentBounds.height;
          break;
        case chrome.system.display.LayoutPosition.LEFT:
          left -= width;
          top += layout.offset;
          break;
      }
    }
    const result = {
      left: left,
      top: top,
      width: width,
      height: height,
    };
    this.setCalculatedDisplayBounds_(id, result);
  },

  /**
   * Finds the display closest to |bounds| ignoring |opt_ignoreIds|.
   * @param {string} displayId
   * @param {!chrome.system.display.Bounds} bounds
   * @param {Array<string>=} opt_ignoreIds Ids to ignore.
   * @return {string}
   * @private
   */
  findClosest_: function(displayId, bounds, opt_ignoreIds) {
    const x = bounds.left + bounds.width / 2;
    const y = bounds.top + bounds.height / 2;
    let closestId = '';
    let closestDelta2 = 0;
    const keys = this.calculatedBoundsMap_.keys();
    for (let iter = keys.next(); !iter.done; iter = keys.next()) {
      const otherId = iter.value;
      if (otherId == displayId) {
        continue;
      }
      if (opt_ignoreIds && opt_ignoreIds.includes(otherId)) {
        continue;
      }
      const otherBounds = this.getCalculatedDisplayBounds(otherId);
      const left = otherBounds.left;
      const top = otherBounds.top;
      const width = otherBounds.width;
      const height = otherBounds.height;
      if (x >= left && x < left + width && y >= top && y < top + height) {
        return otherId;
      }  // point is inside rect
      let dx, dy;
      if (x < left) {
        dx = left - x;
      } else if (x > left + width) {
        dx = x - (left + width);
      } else {
        dx = 0;
      }
      if (y < top) {
        dy = top - y;
      } else if (y > top + height) {
        dy = y - (top + height);
      } else {
        dy = 0;
      }
      const delta2 = dx * dx + dy * dy;
      if (closestId == '' || delta2 < closestDelta2) {
        closestId = otherId;
        closestDelta2 = delta2;
      }
    }
    return closestId;
  },

  /**
   * Calculates the LayoutPosition for |bounds| relative to |parentId|.
   * @param {!chrome.system.display.Bounds} bounds
   * @param {!chrome.system.display.Bounds} parentBounds
   * @return {!chrome.system.display.LayoutPosition}
   */
  getLayoutPositionForBounds_: function(bounds, parentBounds) {
    // Translate bounds from top-left to center.
    const x = bounds.left + bounds.width / 2;
    const y = bounds.top + bounds.height / 2;

    // Determine the distance from the new bounds to both of the near edges.
    const left = parentBounds.left;
    const top = parentBounds.top;
    const width = parentBounds.width;
    const height = parentBounds.height;

    // Signed deltas to the center.
    const dx = x - (left + width / 2);
    const dy = y - (top + height / 2);

    // Unsigned distance to each edge.
    const distx = Math.abs(dx) - width / 2;
    const disty = Math.abs(dy) - height / 2;

    if (distx > disty) {
      if (dx < 0) {
        return chrome.system.display.LayoutPosition.LEFT;
      } else {
        return chrome.system.display.LayoutPosition.RIGHT;
      }
    } else {
      if (dy < 0) {
        return chrome.system.display.LayoutPosition.TOP;
      } else {
        return chrome.system.display.LayoutPosition.BOTTOM;
      }
    }
  },

  /**
   * Modifies |bounds| to the position closest to it along the edge of
   * |parentId| specified by |layoutPosition|.
   * @param {!chrome.system.display.Bounds} bounds
   * @param {string} parentId
   * @param {!chrome.system.display.LayoutPosition} layoutPosition
   * @return {!{x: number, y: number}}
   */
  snapBounds_: function(bounds, parentId, layoutPosition) {
    const parentBounds = this.getCalculatedDisplayBounds(parentId);

    let x;
    if (layoutPosition == chrome.system.display.LayoutPosition.LEFT) {
      x = parentBounds.left - bounds.width;
    } else if (layoutPosition == chrome.system.display.LayoutPosition.RIGHT) {
      x = parentBounds.left + parentBounds.width;
    } else {
      x = this.snapToX_(bounds, parentBounds);
    }

    let y;
    if (layoutPosition == chrome.system.display.LayoutPosition.TOP) {
      y = parentBounds.top - bounds.height;
    } else if (layoutPosition == chrome.system.display.LayoutPosition.BOTTOM) {
      y = parentBounds.top + parentBounds.height;
    } else {
      y = this.snapToY_(bounds, parentBounds);
    }

    return {x: x, y: y};
  },

  /**
   * Snaps a horizontal value, see snapToEdge.
   * @param {!chrome.system.display.Bounds} newBounds
   * @param {!chrome.system.display.Bounds} parentBounds
   * @param {number=} opt_snapDistance Provide to override the snap distance.
   *     0 means snap from any distance.
   * @return {number}
   */
  snapToX_: function(newBounds, parentBounds, opt_snapDistance) {
    return this.snapToEdge_(
        newBounds.left, newBounds.width, parentBounds.left, parentBounds.width,
        opt_snapDistance);
  },

  /**
   * Snaps a vertical value, see snapToEdge.
   * @param {!chrome.system.display.Bounds} newBounds
   * @param {!chrome.system.display.Bounds} parentBounds
   * @param {number=} opt_snapDistance Provide to override the snap distance.
   *     0 means snap from any distance.
   * @return {number}
   */
  snapToY_: function(newBounds, parentBounds, opt_snapDistance) {
    return this.snapToEdge_(
        newBounds.top, newBounds.height, parentBounds.top, parentBounds.height,
        opt_snapDistance);
  },

  /**
   * Snaps the region [point, width] to [basePoint, baseWidth] if
   * the [point, width] is close enough to the base's edge.
   * @param {number} point The starting point of the region.
   * @param {number} width The width of the region.
   * @param {number} basePoint The starting point of the base region.
   * @param {number} baseWidth The width of the base region.
   * @param {number=} opt_snapDistance Provide to override the snap distance.
   *     0 means snap at any distance.
   * @return {number} The moved point. Returns the point itself if it doesn't
   *     need to snap to the edge.
   * @private
   */
  snapToEdge_: function(point, width, basePoint, baseWidth, opt_snapDistance) {
    // If the edge of the region is smaller than this, it will snap to the
    // base's edge.
    const SNAP_DISTANCE_PX = 16;
    const snapDist =
        (opt_snapDistance !== undefined) ? opt_snapDistance : SNAP_DISTANCE_PX;

    const startDiff = Math.abs(point - basePoint);
    const endDiff = Math.abs(point + width - (basePoint + baseWidth));
    // Prefer the closer one if both edges are close enough.
    if ((!snapDist || startDiff < snapDist) && startDiff < endDiff) {
      return basePoint;
    } else if (!snapDist || endDiff < snapDist) {
      return basePoint + baseWidth - width;
    }

    return point;
  },

  /**
   * Intersects |layout| with each other layout and reduces |deltaPos| to
   * avoid any collisions (or sets it to [0,0] if the display can not be moved
   * in the direction of |deltaPos|).
   * Note: this assumes that deltaPos is already 'snapped' to the parent edge,
   * and therefore will not collide with the parent, i.e. this is to prevent
   * overlapping with displays other than the parent.
   * @param {string} id
   * @param {!chrome.system.display.Bounds} bounds
   * @param {!{x: number, y: number}} deltaPos
   */
  collideAndModifyDelta_: function(id, bounds, deltaPos) {
    const keys = this.calculatedBoundsMap_.keys();
    const others = new Set(keys);
    others.delete(id);
    let checkCollisions = true;
    while (checkCollisions) {
      checkCollisions = false;
      const othersValues = others.values();
      for (let iter = othersValues.next(); !iter.done;
           iter = othersValues.next()) {
        const otherId = iter.value;
        const otherBounds = this.getCalculatedDisplayBounds(otherId);
        if (this.collideWithBoundsAndModifyDelta_(
                bounds, otherBounds, deltaPos)) {
          if (deltaPos.x == 0 && deltaPos.y == 0) {
            return;
          }
          others.delete(otherId);
          checkCollisions = true;
          break;
        }
      }
    }
  },

  /**
   * Intersects |bounds| with |otherBounds|. If there is a collision, modifies
   * |deltaPos| to limit movement to a single axis and avoid the collision
   * and returns true. See note for |collideAndModifyDelta_|.
   * @param {!chrome.system.display.Bounds} bounds
   * @param {!chrome.system.display.Bounds} otherBounds
   * @param {!{x: number, y: number}} deltaPos
   * @return {boolean} Whether there was a collision.
   */
  collideWithBoundsAndModifyDelta_: function(bounds, otherBounds, deltaPos) {
    const newX = bounds.left + deltaPos.x;
    const newY = bounds.top + deltaPos.y;

    if ((newX + bounds.width <= otherBounds.left) ||
        (newX >= otherBounds.left + otherBounds.width) ||
        (newY + bounds.height <= otherBounds.top) ||
        (newY >= otherBounds.top + otherBounds.height)) {
      return false;
    }

    // |deltaPos| should already be restricted to X or Y. This shortens the
    // delta to stay outside the bounds, however it does not change the sign of
    // the delta, i.e. it does not "push" the point outside the bounds if
    // the point is already inside.
    if (Math.abs(deltaPos.x) > Math.abs(deltaPos.y)) {
      deltaPos.y = 0;
      let snapDeltaX;
      if (deltaPos.x > 0) {
        snapDeltaX =
            Math.max(0, (otherBounds.left - bounds.width) - bounds.left);
      } else {
        snapDeltaX =
            Math.min(0, (otherBounds.left + otherBounds.width) - bounds.left);
      }
      deltaPos.x = snapDeltaX;
    } else {
      deltaPos.x = 0;
      let snapDeltaY;
      if (deltaPos.y > 0) {
        snapDeltaY =
            Math.min(0, (otherBounds.top - bounds.height) - bounds.top);
      } else if (deltaPos.y < 0) {
        snapDeltaY =
            Math.max(0, (otherBounds.top + otherBounds.height) - bounds.top);
      } else {
        snapDeltaY = 0;
      }
      deltaPos.y = snapDeltaY;
    }

    return true;
  },

  /**
   * Updates the offset for |layout| from |bounds|.
   * @param {!chrome.system.display.Bounds} bounds
   * @param {!chrome.system.display.LayoutPosition} position
   * @param {!chrome.system.display.DisplayLayout} layout
   */
  updateOffsetAndPosition_: function(bounds, position, layout) {
    layout.position = position;
    if (!layout.parentId) {
      layout.offset = 0;
      return;
    }

    // Offset is calculated from top or left edge.
    const parentBounds = this.getCalculatedDisplayBounds(layout.parentId);
    let offset, minOffset, maxOffset;
    if (position == chrome.system.display.LayoutPosition.LEFT ||
        position == chrome.system.display.LayoutPosition.RIGHT) {
      offset = bounds.top - parentBounds.top;
      minOffset = -bounds.height;
      maxOffset = parentBounds.height;
    } else {
      offset = bounds.left - parentBounds.left;
      minOffset = -bounds.width;
      maxOffset = parentBounds.width;
    }
    const MIN_OFFSET_OVERLAP = 50;
    minOffset += MIN_OFFSET_OVERLAP;
    maxOffset -= MIN_OFFSET_OVERLAP;
    layout.offset = Math.max(minOffset, Math.min(offset, maxOffset));

    // Update the calculated bounds to match the new offset.
    this.calculateBounds_(layout.id, bounds.width, bounds.height);
  },

  /**
   * Returns |bounds| translated to touch the closest corner of |parentBounds|.
   * @param {!chrome.system.display.Bounds} bounds
   * @param {!chrome.system.display.Bounds} parentBounds
   * @return {!chrome.system.display.Bounds}
   * @private
   */
  getCornerBounds_: function(bounds, parentBounds) {
    let x;
    if (bounds.left > parentBounds.left + parentBounds.width / 2) {
      x = parentBounds.left + parentBounds.width;
    } else {
      x = parentBounds.left - bounds.width;
    }
    let y;
    if (bounds.top > parentBounds.top + parentBounds.height / 2) {
      y = parentBounds.top + parentBounds.height;
    } else {
      y = parentBounds.top - bounds.height;
    }
    return {
      left: x,
      top: y,
      width: bounds.width,
      height: bounds.height,
    };
  },

  /**
   * Highlights the edge of the div associated with |id| based on
   * |layoutPosition| and removes any other highlights. If |layoutPosition| is
   * undefined, removes all highlights.
   * @param {string} id
   * @param {chrome.system.display.LayoutPosition|undefined} layoutPosition
   * @private
   */
  highlightEdge_: function(id, layoutPosition) {
    for (let i = 0; i < this.layouts.length; ++i) {
      const layout = this.layouts[i];
      const highlight = (layout.id == id) ? layoutPosition : undefined;
      const div = this.$$('#_' + layout.id);
      div.classList.toggle(
          'highlight-right',
          highlight == chrome.system.display.LayoutPosition.RIGHT);
      div.classList.toggle(
          'highlight-left',
          highlight == chrome.system.display.LayoutPosition.LEFT);
      div.classList.toggle(
          'highlight-top',
          highlight == chrome.system.display.LayoutPosition.TOP);
      div.classList.toggle(
          'highlight-bottom',
          highlight == chrome.system.display.LayoutPosition.BOTTOM);
    }
  },
};
