// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.collide.client.filehistory;

import collide.client.common.CommonResources;
import collide.client.util.CssUtils;
import collide.client.util.Elements;

import com.google.collide.client.AppContext;
import com.google.collide.client.util.PathUtil;
import com.google.collide.client.util.dom.MouseMovePauseDetector;
import com.google.collide.client.util.dom.eventcapture.MouseCaptureListener;
import com.google.collide.dto.Revision;
import com.google.collide.dto.Revision.RevisionType;
import com.google.collide.json.client.JsoArray;
import com.google.collide.mvp.CompositeView;
import com.google.collide.mvp.UiComponent;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.resources.client.ImageResource;

import elemental.css.CSSStyleDeclaration;
import elemental.events.Event;
import elemental.events.MouseEvent;
import elemental.html.DivElement;
import elemental.html.StyleElement;

/**
 * Representation for the FileHistory timeline widget
 *
 */
public class Timeline extends UiComponent<Timeline.View> {

  /**
   * Static factory method for obtaining an instance of the Timeline.
   */
  public static Timeline create(FileHistory fileHistory, AppContext context) {
    return new Timeline(fileHistory, fileHistory.getView().timelineView, context);
  }

  /**
   * Style names used by the Timeline.
   */
  public interface Css extends CssResource {
    String base();

    String rangeLine();

    String rangeLineWrapper();

    String baseLine();

    String nodeContainer();

    String notice();
  }

  /**
   * CSS and images used by the Timeline.
   */
  public interface Resources extends CommonResources.BaseResources {
    @Source("Timeline.css")
    Css timelineCss();

    @Source("rangeLeft.png")
    ImageResource rangeLeft();

    @Source("rangeRight.png")
    ImageResource rangeRight();
  }

  /**
   * The View for the Timeline.
   */
  public static class View extends CompositeView<Void> {

    private final Resources res;
    private final Css css;

    private DivElement baseLine;
    private DivElement rangeLine;
    private DivElement rangeLineWrapper;
    private DivElement nodeContainer;

    // Keep range line width and left because in CSS they're stored as Strings
    // with unit PCT
    private double rangeLineWidth = 100.0;
    private double rangeLineLeft = 0.0;

    // Base line width for maxNode calculations
    private int baseLineWidth = 0;

    View(Timeline.Resources res) {
      super(Elements.createDivElement(res.timelineCss().base()));
      this.res = res;
      this.css = res.timelineCss();

      // Create DOM and initialize View.
      createDom();
    }

    /**
     * Get revision history and create the DOM for the timeline widget.
     */
    private void createDom() {
      // Instantiate DOM elems.
      baseLine = Elements.createDivElement(css.baseLine());
      rangeLine = Elements.createDivElement(css.rangeLine());
      rangeLineWrapper = Elements.createDivElement(css.rangeLineWrapper());
      nodeContainer = Elements.createDivElement(css.nodeContainer());

      rangeLineWrapper.appendChild(rangeLine);

      getElement().appendChild(baseLine);
      getElement().appendChild(rangeLineWrapper);
      getElement().appendChild(nodeContainer);
    }

    /**
     * Empty the node container of any previous nodes before we create
     * fresh nodes from the getRevisions call
     */
    public void emptyNodeContainer() {
      nodeContainer.setInnerHTML("");
    }

    public void setLoading() {
      emptyNodeContainer();

      toggleTimeline(true);
      // Capture length of baseLine for later use before hiding
      baseLineWidth = baseLine.getOffsetWidth();

      toggleTimeline(false);
    }

    public void setNotice(String text) {
      DivElement notice = Elements.createDivElement(css.notice());
      notice.setTextContent(text);
      nodeContainer.appendChild(notice);
    }

    public int getBaseLineWidth() {
      return baseLineWidth;
    }

    public void toggleTimeline(boolean visible) {
      CssUtils.setDisplayVisibility(rangeLineWrapper, visible);
      CssUtils.setDisplayVisibility(baseLine, visible);
    }

    /**
     * Adjust range line to be between the currentLeftRange and
     * currentRightRange. Used for snapping between a specific range of nodes.
     *
     * @param leftIndex index of the left edge node
     * @param rightIndex index of the right edge node
     * @param numNodes total number of nodes, used for percentage width calculations
     */
    public void adjustRangeLine(int leftIndex, int rightIndex, int numNodes) {
      rangeLineWidth = ((rightIndex - leftIndex) * 100.0) / (numNodes - 1);
      rangeLineLeft = (leftIndex * 100.0 / (numNodes - 1));

      rangeLineWrapper.getStyle().setWidth(rangeLineWidth, CSSStyleDeclaration.Unit.PCT);
      rangeLineWrapper.getStyle().setLeft(rangeLineLeft, CSSStyleDeclaration.Unit.PCT);
    }

    /**
     * Adjust the range line during the middle of a drag (NOT strictly between
     * two different nodes)
     *
     * @param widthDelta increase in range line width, in percentage
     * @param leftDelta increase in range line left offset, in percentage
     */
    public void adjustRangeLineBetween(double widthDelta, double leftDelta) {
      rangeLineWidth += widthDelta;
      rangeLineLeft += leftDelta;

      rangeLineWrapper.getStyle().setWidth(rangeLineWidth, CSSStyleDeclaration.Unit.PCT);
      rangeLineWrapper.getStyle().setLeft(rangeLineLeft, CSSStyleDeclaration.Unit.PCT);
    }

    public void attachDragHandler(MouseCaptureListener mouseCaptureListener) {
      rangeLineWrapper.addEventListener(Event.MOUSEDOWN, mouseCaptureListener, false);
    }

  }

  /* Mouse dragging event listener for range Line */

  private final MouseCaptureListener mouseCaptureListener = new MouseCaptureListener() {
    @Override
    protected void onMouseMove(MouseEvent evt) {
      mouseMovePauseDetector.handleMouseMove(evt);
      if (!dragging) {
        onNodeDragStart();
        dragging = true;
      }
      onNodeDragMove(getDeltaX());
    }

    @Override
    protected void onMouseUp(MouseEvent evt) {
      onNodeDragEnd();
      dragging = false;
    }
  };

  private final MouseMovePauseDetector mouseMovePauseDetector =
      new MouseMovePauseDetector(new MouseMovePauseDetector.Callback() {
        @Override
        public void onMouseMovePaused() {
          if (closeEnoughToDot()) {
            adjustRangeLine();
            setDiffForRevisions();
          }
        }
      });
  
  private boolean dragging = false;
  

  private void onNodeDragStart() {
    // Record original x-coordinate
    setCurrentDragX(0);
    forceCursor("-webkit-grabbing");
    setDrag(true);
    mouseMovePauseDetector.start();
  }

  private void onNodeDragMove(int delta) {
    if (getDrag()) {
      moveRange(delta);
    }
  }

  public void onNodeDragEnd() {
    mouseMovePauseDetector.stop();
    setDrag(false);
    removeCursor();
    resetCatchUp();

    // Update current range = temp range
    resetLeftRange();
    resetRightRange();

    adjustRangeLine();

    setDiffForRevisions();
  }

  final AppContext context;
  FileHistoryApi api;
  final FileHistory fileHistory;
  PathUtil path;
  JsoArray<TimelineNode> nodes;
  int numNodes;

  // Minimum interval between (used to calculate max number of nodes) in pixels
  private final int MIN_NODE_INTERNAL = 70;

  // Nodes representing the current left and right sides of the rangeLine
  TimelineNode currentLeftRange;
  TimelineNode currentRightRange;

  // Temporary nodes representing the current left and right sides of the
  // range line during the current action (ex. during a drag). These values
  // are converted into the currentLeftRange and currentRightRange at the
  // end of the drag (see resetLeftRange() and resetRightRange()). We need
  // these because the drag offset is calculated from the original rangeLine
  // position before a drag.
  TimelineNode tempLeftRange;
  TimelineNode tempRightRange;

  // Current dx dragged so far, needed for snapping calculations
  private int currentDragX;

  // Amount the mouse needs to catch up to the range line due to snapping
  private int catchUp;
  private boolean catchUpLeft;

  // Whether the current node is draggable (must be an edge node)
  private boolean drag;

  // Snap-to constants
  private static final double SNAP_THRESHOLD = 2.0/3.0;

  // Cursor style to force when doing a drag action
  private final StyleElement forceDragCursor;

  protected Timeline(FileHistory fileHistory, View view, AppContext context) {
    super(view);
    this.context = context;
    this.fileHistory = fileHistory;
    this.forceDragCursor = Elements.getDocument().createStyleElement();

    this.nodes = JsoArray.create();

    view.attachDragHandler(mouseCaptureListener);
  }

  public void setApi(FileHistoryApi api) {
    this.api = api;
  }

  public void setPath(PathUtil path) {
    this.path = path;
  }

  public void setLoading() {
    // Set loading state for timeline
    getView().setLoading();
  }

  void updateNodeTooltips() {
    for (int i = 0; i < nodes.size(); i++) {
      nodes.get(i).updateTooltipTitle();
    }
  }

  /**
   * Return the number of nodes allowed with a minimum node spacing
   */
  public int maxNumberOfNodes() {
    return (getView().getBaseLineWidth() / MIN_NODE_INTERNAL) + 1;
  }

  private JsoArray<Revision> removeSyncSource(JsoArray<Revision> revisions) {
    JsoArray<Revision> result = JsoArray.create();
    for (int i = 0; i < revisions.size(); i++) {
      if (revisions.get(i).getRevisionType() != RevisionType.SYNC_SOURCE) {
        // For now, we hide SYNC_SOURCE.
        result.add(revisions.get(i));
      }
    }
    return result;
  }

  public void drawNodes(JsoArray<Revision> revisions) {
    // Remove any existing nodes
    getView().emptyNodeContainer();
    nodes.clear();

    if (revisions.size() > 1) {
      getView().toggleTimeline(true);
      // Make file history view default the left side of the diff to the last
      // sync point if any.
      int leftNodeIndex = -1;
      // Draw nodes based on data
      numNodes = revisions.size();
      for (int i = 0; i < revisions.size(); i++) {
        TimelineNode currNode = new TimelineNode(
          new TimelineNode.View(context.getResources()), i, revisions.get(i), this);
        nodes.add(currNode);
        getView().nodeContainer.appendChild(currNode.getView().getElement());

        if (revisions.get(i).getRevisionType() == RevisionType.SYNC_SOURCE) {
          leftNodeIndex = i;
        }
      }

      // By default, the range goes from the first to last node
      TimelineNode leftNode;
      if (leftNodeIndex < 0) {
        leftNode = nodes.get(0);
      } else if (leftNodeIndex == nodes.size() - 1) {
        // When leftNode is the same as the right node, move leftNode left.
        // This can happen when users sync and this file has NO conflict.
        // We have a SYNC_SOURCE and SYNC_MERGED; leftNodeIndex is at
        // SYNC_MERGED, which is the last node.
        // Since revisions.size() > 1, nodes.size() - 2 is valid.
        // To avoid to have left and right diff points to the same revision.
        leftNode = nodes.get(nodes.size() - 2);
      } else {
        leftNode = nodes.get(leftNodeIndex);
      }
      setActiveRange(leftNode, nodes.get(nodes.size() - 1));
      adjustRangeLine();
    } else {
      api.setUnchangedFile(path);
      getView().setNotice("File unchanged.");
    }
  }

  /* Set cursor style */

  public void forceCursor(String type) {
    forceDragCursor.setTextContent("* { cursor: " + type + " !important; }");
    Elements.getBody().appendChild(forceDragCursor);
  }

  public void removeCursor() {
    forceDragCursor.removeFromParent();
  }

  /* Getter and setter methods for private Timeline fields */

  public void setCurrentDragX(int previousDragX) {
    this.currentDragX = previousDragX;
  }

  public int getCurrentDragX() {
    return currentDragX;
  }

  public void setDrag(boolean drag) {
    this.drag = drag;
  }

  public boolean getDrag() {
    return drag;
  }

  public void resetCatchUp() {
    catchUp = 0;
  }

  /**
   * If not valid move for left or right, add distance mouse moved to
   * catchup to close the gap
   * @param dx
   */
  public void incrementCatchUp(int dx) {
    catchUp += dx;
    catchUpLeft = dx < 0;
  }

  /**
   * Update the current drag and catchUp variables to reflect the post autosnap
   * state
   * @param snap snap threshold in pixels
   * @param offset distance we auto-snapped over, need compensate in mouse movements
   */
  public void updateSnapVariables(int snap, int offset) {
    // Subtract the distance we just "snapped" to
    currentDragX += ((currentDragX > 0) ? -snap : snap);
    catchUp += ((currentDragX > 0) ? -offset : offset);

    // Save which direction you're currently going in. Let changing directions
    // be OK.
    catchUpLeft = currentDragX > 0;
  }

  /* Utility methods for snap-to/dragging calculations */

  /**
   * Return the distance (in pixels) of the distance between two nodes on
   * the timeline.
   */
  public int intervalInPx() {
    return getView().baseLine.getOffsetWidth() / (numNodes - 1);
  }

  /**
   * Return the horizontal delta dragged, as a percent of the length
   * of the timeline (because the width of the timeline is recorded
   * as a percentage).
   *
   * @param dx
   * @return
   */
  public double percentageMoved(int dx) {
    return (dx * 100.0) / getView().baseLine.getOffsetWidth();
  }

  /*
   * Reset range methods - encompasses resetting nodes, code, and labels
   */

  public void resetLeftRange() {
    currentLeftRange = tempLeftRange;
  }

  public void resetRightRange() {
    currentRightRange = tempRightRange;
  }


  /**
   * Adjust rangeline to be between the temp left and right edge nodes.
   */
  public void adjustRangeLine() {
    getView().adjustRangeLine(tempLeftRange.index, tempRightRange.index, numNodes);
  }

  // TODO: If moving back and forth really fast, mouse gets out of
  // sync with the range line (there's a gap). Maybe this is due to arithmetic
  // rounding errors? Investigate and fix.

  /**
   * Controls the edge of the range line currently being dragged. If it is a
   * valid drag, calculates how the rangeline should be moved in terms of width
   * changed (percentage) and left offset. Includes auto-snap while dragging if
   * dragged more than 2/3 the way to the next node. Also, "catchup" is allocated
   * to compensate for the gap between the mouse and the range line after auto-snapping
   *
   * @param currentNode the node currently being dragged
   * @param dx the current drag dx recorded (+ is to the right, - is to the left)
   */
  public void moveRangeEdge(TimelineNode currentNode, int dx) {

    // Continue recording and calculating snapping as usual if the mouse
    // doesn't need to catch up
    if (!catchUp(dx)) {
      double percentMoved = percentageMoved(dx);

      currentDragX += dx;
      // Check if dragging left or right edge node
      if (currentNode == currentLeftRange && validLeftEdgeMove(percentMoved < 0)) {
        // Left: need to set new left and extend width

        // Add to left and subtract from width
        getView().adjustRangeLineBetween(-percentMoved, percentMoved);

      } else if (currentNode == currentRightRange && validRightEdgeMove(percentMoved < 0)) {
        // Right: only need to extend width

        // Add to width
        getView().adjustRangeLineBetween(percentMoved, 0);
      } else {
        currentDragX -= dx;
        incrementCatchUp(dx);
      }

      int snap = (int) (SNAP_THRESHOLD * intervalInPx());
      int offset = (int) ((1 - SNAP_THRESHOLD) * intervalInPx());

      // If > snapThreshold away, snapTo the next one
      if (currentDragX > snap || -currentDragX > snap) {
        snapToDot(currentNode);
        adjustRangeLine();

        updateSnapVariables(snap, offset);
      }
    }
  }

  public void moveRange(int dx) {
    if(!catchUp(dx)) {
      double percentMoved = percentageMoved(dx);
      boolean left = percentMoved < 0;

      currentDragX += dx;
      if ((left && validLeftMove()) || (!left && validRightMove())) {
        // Add percent moved to the left offset, width stays the same
        getView().adjustRangeLineBetween(0, percentMoved);
      } else {
        currentDragX -= dx;
        incrementCatchUp(dx);
      }

      int snap = (int) (SNAP_THRESHOLD * intervalInPx());
      int offset = (int) ((1 - SNAP_THRESHOLD) * intervalInPx());

      // If > snapThreshold away, snapTo the next one
      if (currentDragX > snap || -currentDragX > snap) {
        snapToRange();
        adjustRangeLine();

        updateSnapVariables(snap, offset);
      }
    }
  }

  boolean closeEnoughToDot() {
    return Math.abs(currentDragX) < Math.min(30, (1 - SNAP_THRESHOLD) * intervalInPx());
  }

  /**
   * Because of auto-snapping, there's an awkward gap between the mouse and
   * the edge of the range line. Let the mouse catch up to the next node before
   * moving the range line with mouse movements again.
   *
   * @param dx number of pixels dragged
   * @return if the mouse needs to catch up
   */
  public boolean catchUp(int dx) {
    int error = 1;
    boolean goingLeft = dx > 0;

    if ((goingLeft && catchUp < -error) || (!goingLeft && catchUp > error)) {

      // Reset catchup if decide to move mouse in the opposite direction before
      // reaching the next node
      if (goingLeft == catchUpLeft) {
        catchUp += dx;
      } else {
        resetCatchUp();
      }

      // Skip moving the range line, let the mouse catch up to the snapping
      return true;
    }
    return false;
  }

  /*
   * Snap-to methods for dragging the rangeLine
   */

  /**
   * Snap dragging to the next left or right (depending on which direction you
   * are currently dragging) node, if it is a valid drag direction.
   *
   * @param currentNode node currently being dragged
   */
  public void snapToDot(TimelineNode currentNode) {
    boolean left = currentDragX < 0;
    int passed = !left ? 1 : -1;

    // Check if dragging the left or right edge node
    if (currentNode == currentLeftRange && validLeftEdgeMove(left)) {

      // Set the node we "snapped to" by dragging as the new left
      nodes.get(tempLeftRange.index + passed).setTempLeftRange(false);
    } else if (currentNode == currentRightRange && validRightEdgeMove(left)) {

      // Set the node we "snapped to" by dragging as the new right
      nodes.get(tempRightRange.index + passed).setTempRightRange(false);
    }
  }

  public void snapToRange() {
    boolean left = currentDragX < 0;
    int passed = !left ? 1 : -1;

    // Check that dragging is valid depending on the drag direction
    if ((left && validLeftMove()) || (!left && validRightMove())) {

      // Set new rangeline range
      setTempRange(nodes.get(tempLeftRange.index + passed),
        nodes.get(tempRightRange.index + passed), false);
    }
  }

  /**
   * Set the temporary range left and right edges at the same time. Used
   * in snapToRange().
   *
   * @param nextLeft the next left edge to be set
   * @param nextRight the next right edge to be set
   */
  private void setTempRange(TimelineNode nextLeft, TimelineNode nextRight, boolean updateDiff) {
    TimelineNode oldLeft = tempLeftRange;
    TimelineNode oldRight = tempRightRange;

    if (oldRight != null && oldLeft != null) {
      oldLeft.getView().clearRangeStyles(oldLeft.nodeType);
      oldRight.getView().clearRangeStyles(oldRight.nodeType);
    }

    tempLeftRange = nextLeft;
    tempRightRange = nextRight;

    nextLeft.getView().addRangeStyles(nextLeft.nodeType, true);
    nextRight.getView().addRangeStyles(nextRight.nodeType, false);

    if (updateDiff) {
      setDiffForRevisions();
    }
  }

  public void setActiveRange(TimelineNode nextLeft, TimelineNode nextRight) {
    setTempRange(nextLeft, nextRight, true);

    currentLeftRange = nextLeft;
    currentRightRange = nextRight;
  }

  public void setDiffForRevisions() {
    fileHistory.changeLeftRevisionTitle(tempLeftRange.getRevisionTitle());
    fileHistory.changeRightRevisionTitle(tempRightRange.getRevisionTitle());
    api.setFile(path, tempLeftRange.getRevision(), tempRightRange.getRevision());
  }

  void setDiffFilePaths(String leftFilePath, String rightFilePath) {
    if (tempLeftRange != null) {
      tempLeftRange.setFilePath(leftFilePath);
      fileHistory.changeLeftRevisionTitle(tempLeftRange.getRevisionTitle());
    } else {
      fileHistory.changeLeftRevisionTitle(leftFilePath);
    }

    if (tempRightRange != null) {
      tempRightRange.setFilePath(rightFilePath);
      fileHistory.changeRightRevisionTitle(tempRightRange.getRevisionTitle());
    } else {
      fileHistory.changeRightRevisionTitle(rightFilePath);
    }
  }
  /**
   * Don't allow dragging to the left past the first node
   *
   * @return if dragging to the left is valid
   */
  public boolean validLeftMove() {
    return !(currentDragX < 0 && tempLeftRange.index == 0);
  }

  /**
   * Don't allow dragging to the right past the last node
   *
   * @return if dragging to the right is valid
   */
  public boolean validRightMove() {
    return !(currentDragX > 0 && tempRightRange.index == numNodes - 1);
  }

  /**
   * Don't allow dragging the left edge right past the first node or
   * dragging the left edge to the right if length = 1
   *
   * @param dragLeft drag direction (true for left, false for right)
   * @return if the current direction is a valid direction for the left edge node
   */
  public boolean validLeftEdgeMove(boolean dragLeft) {
    return (!dragLeft || validLeftMove())
        && !(!dragLeft && currentDragX > 0 && tempRightRange.index - tempLeftRange.index == 1);
  }

  /**
   * Don't allow dragging the right edge right past the last node or
   * dragging the right edge to the left if length = 1
   *
   * @param dragLeft drag direction (true for left, false for right)
   * @return if the current direction is a valid direction for the right edge node
   */
  public boolean validRightEdgeMove(boolean dragLeft) {
    return (dragLeft || validRightMove())
        && !(dragLeft && currentDragX < 0 && tempRightRange.index - tempLeftRange.index == 1);
  }

  FileHistoryApi getFileHistoryApi() {
    return api;
  }
}
