// 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.editor;

import com.google.collide.client.editor.selection.SelectionModel;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.Line;
import com.google.collide.shared.document.LineFinder;
import com.google.collide.shared.document.LineInfo;
import com.google.collide.shared.document.anchor.Anchor;
import com.google.collide.shared.document.anchor.Anchor.RemovalStrategy;
import com.google.collide.shared.document.anchor.AnchorManager;
import com.google.collide.shared.document.anchor.AnchorType;
import com.google.collide.shared.document.util.LineUtils;
import com.google.collide.shared.util.ListenerManager;
import com.google.collide.shared.util.ListenerManager.Dispatcher;
import com.google.collide.shared.util.ListenerRegistrar;
import com.google.collide.shared.util.MathUtils;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;

/**
 * The model for the editor's viewport. This model also listens for events that
 * affect the viewport, and adjusts the bounds accordingly. For example, it
 * listens as a scroll listener and shifts the viewport when the user scrolls.
 *
 * The lifecycle of this class is tied to the current document in the editor. If
 * the document is replaced, a new instance of this class is used for the new
 * document.
 */
public class ViewportModel
    implements
      Buffer.ScrollListener,
      Buffer.ResizeListener,
      Document.LineListener,
      SelectionModel.CursorListener,
      Buffer.SpacerListener {

  private static final AnchorType VIEWPORT_MODEL_ANCHOR_TYPE = AnchorType.create(
      ViewportModel.class, "viewport model");

  /**
   * Listener that is notified of viewport changes.
   */
  public interface Listener {
    /**
     * Called when the content of the viewport changes, for example a line gets
     * added or removed. This will not be called if the text changes within a
     * line.
     *
     * @param added true if the given {@code lineInfo} was added, false if
     *        removed (note that when removed, the lines will no longer be in
     *        the document)
     * @param lines the lines added or removed (see the parameters on
     *        {@link Document.LineListener})
     */
    void onViewportContentChanged(ViewportModel viewport, int lineNumber, boolean added,
        JsonArray<Line> lines);

    /**
     * Called when the viewport is shifted, meaning at least one of its edges
     * points to new lines.
     *
     * @param oldTop may be null if this is the first range set
     * @param oldBottom may be null if this is the first range set
     */
    void onViewportShifted(ViewportModel viewport, LineInfo top, LineInfo bottom, LineInfo oldTop,
        LineInfo oldBottom);

    /**
     * Called when the line number of the viewport top or bottom changes.
     *
     * One example of where this differs from {@link #onViewportShifted} is if a
     * collaborator is typing above our viewport. When she presses ENTER, it our
     * viewport's line numbers will change, but will still point to the same
     * line instances. Therefore, this method would be called but not
     * {@link #onViewportShifted}.
     */
    void onViewportLineNumberChanged(ViewportModel viewport, Edge edge);
  }
  
  public enum Edge {
    TOP, BOTTOM
  }

  private class ChangeDispatcher implements ListenerManager.Dispatcher<Listener> {
    private LineInfo oldTop;
    private LineInfo oldBottom;

    @Override
    public void dispatch(Listener listener) {
      listener.onViewportShifted(ViewportModel.this, topAnchor.getLineInfo(),
          bottomAnchor.getLineInfo(), oldTop, oldBottom);
    }

    private void dispatch(LineInfo oldTop, LineInfo oldBottom) {
      this.oldTop = oldTop;
      this.oldBottom = oldBottom;
      listenerManager.dispatch(this);
    }
  }

  static ViewportModel create(Document document, SelectionModel selection, Buffer buffer) {
    return new ViewportModel(document, selection, buffer);
  }

  private final Anchor.ShiftListener anchorShiftedListener =
      new Anchor.ShiftListener() {
        private final Dispatcher<Listener> lineNumberChangedDispatcher =
            new ListenerManager.Dispatcher<ViewportModel.Listener>() {
              @Override
              public void dispatch(Listener listener) {
                listener.onViewportLineNumberChanged(ViewportModel.this, curChangedEdge);
              }
            };

        private Edge curChangedEdge;
        
        @Override
        public void onAnchorShifted(Anchor anchor) {
          curChangedEdge = anchor == topAnchor ? Edge.TOP : Edge.BOTTOM;
          listenerManager.dispatch(lineNumberChangedDispatcher);
        }
      };

  private final AnchorManager anchorManager;
  private Anchor bottomAnchor;
  private final Buffer buffer;
  private final Document document;
  private final ChangeDispatcher changeDispatcher;
  private final ListenerManager<Listener> listenerManager;
  private final SelectionModel selection;
  private Anchor topAnchor;
  private final ListenerRegistrar.RemoverManager removerManager =
      new ListenerRegistrar.RemoverManager();

  private ViewportModel(Document document, SelectionModel selection, Buffer buffer) {
    this.document = document;
    this.anchorManager = document.getAnchorManager();
    this.buffer = buffer;
    this.changeDispatcher = new ChangeDispatcher();
    this.listenerManager = ListenerManager.create();
    this.selection = selection;

    attachHandlers();
  }

  public LineInfo getBottom() {
    return bottomAnchor.getLineInfo();
  }

  public Line getBottomLine() {
    return bottomAnchor.getLine();
  }

  public int getBottomLineNumber() {
    return bottomAnchor.getLineNumber();
  }

  public LineInfo getBottomLineInfo() {
    return bottomAnchor.getLineInfo();
  }

  public Document getDocument() {
    return document;
  }

  public ListenerRegistrar<Listener> getListenerRegistrar() {
    return listenerManager;
  }

  public LineInfo getTop() {
    return topAnchor.getLineInfo();
  }

  public Line getTopLine() {
    return topAnchor.getLine();
  }

  public LineInfo getTopLineInfo() {
    return topAnchor.getLineInfo();
  }

  public int getTopLineNumber() {
    return topAnchor.getLineNumber();
  }

  public void initialize() {
    resetPosition();
  }

  /**
   * Returns whether the text of the given {@code lineNumber} is fully visible
   * in the viewport, determined by the current scrollTop and height of the
   * buffer.
   *
   * Note: This does not check whether any spacers above the given line are
   * fully visible.
   */
  public boolean isLineNumberFullyVisibleInViewport(int lineNumber) {
    int lineTop = buffer.calculateLineTop(lineNumber);
    int scrollTop = buffer.getScrollTop();
    int lineHeight = buffer.getEditorLineHeight();
    return lineTop >= scrollTop && (lineTop + lineHeight) <= scrollTop + buffer.getHeight();
  }

  public boolean isLineInViewport(Line line) {
    // Lines in the viewport will always return a line number
    int lineNumber = LineUtils.getCachedLineNumber(line);
    return (lineNumber != -1) && isLineNumberInViewport(lineNumber);
  }

  public boolean isLineNumberInViewport(int lineNumber) {
    /*
     * TODO: fix this to only do the expensive
     * calculation when a spacer is in the viewport.
     */
    /*
     * When the viewport is covered entirely by a spacer, the lines set as top
     * and bottom aren't actually visible, so check using their absolute buffer
     * positions.
     */
    int bufferTop = buffer.getScrollTop();
    int bufferBottom = bufferTop + buffer.getHeight();
    int lineTop = buffer.calculateLineTop(lineNumber);
    int lineBottom = lineTop + buffer.getEditorLineHeight();
    return !(bufferTop > lineBottom || bufferBottom < lineTop);
  }

  @Override
  public void onCursorChange(LineInfo lineInfo, int column, boolean isExplicitChange) {
    int cursorLineNumber = lineInfo.number();
    int cursorLeft = buffer.calculateColumnLeft(lineInfo.line(), column);
    int cursorTop = buffer.calculateLineTop(cursorLineNumber);
    int scrollLeft = buffer.getScrollLeft();
    int scrollTop = buffer.getScrollTop();
    int newScrollLeft = scrollLeft;
    int newScrollTop = scrollTop;

    int lineHeight = buffer.getEditorLineHeight();
    int bufferHeight = buffer.getHeight();

    int preCursorLineTop = Math.max(cursorTop - lineHeight, 0);
    int postCursorLineBottom = cursorTop + 2 * lineHeight;

    if (preCursorLineTop < scrollTop) {
      newScrollTop = preCursorLineTop;
    } else {
      if (postCursorLineBottom > scrollTop + bufferHeight) {
        newScrollTop = postCursorLineBottom - bufferHeight;
      }
    }

    if (cursorLeft < scrollLeft) {
      newScrollLeft = cursorLeft;
    } else {
      int bufferWidth = buffer.getWidth();
      int columnWidth = (int) buffer.getEditorCharacterWidth();
      if (cursorLeft + columnWidth > scrollLeft + bufferWidth) {
        newScrollLeft = cursorLeft + columnWidth - bufferWidth;
      }
    }

    if (newScrollTop != scrollTop || newScrollLeft != scrollLeft) {
      setBufferScrollAsync(newScrollLeft, newScrollTop);
    }
  }

  @Override
  public void onLineAdded(Document document, final int lineNumber,
      final JsonArray<Line> addedLines) {
    if (adjustViewportBoundsForLineAdditionOrRemoval(document, lineNumber)) {
      listenerManager.dispatch(new Dispatcher<ViewportModel.Listener>() {
        @Override
        public void dispatch(Listener listener) {
          listener.onViewportContentChanged(ViewportModel.this, lineNumber, true, addedLines);
        }
      });
    }
  }

  @Override
  public void onLineRemoved(Document document, final int lineNumber,
      final JsonArray<Line> removedLines) {
    if (adjustViewportBoundsForLineAdditionOrRemoval(document, lineNumber)) {
      listenerManager.dispatch(new Dispatcher<ViewportModel.Listener>() {
        @Override
        public void dispatch(Listener listener) {
          listener.onViewportContentChanged(ViewportModel.this, lineNumber, false,
              removedLines);
        }
      });
    }
  }

  @Override
  public void onScroll(Buffer buffer, int scrollTop) {
    moveViewportToScrollTop(scrollTop);
  }

  @Override
  public void onResize(Buffer buffer, int documentHeight, int viewportHeight, int scrollTop) {
    moveViewportToScrollTop(scrollTop);
  }

  private void moveViewportToScrollTop(int scrollTop) {
    int newTopLineNumber = buffer.convertYToLineNumber(scrollTop, true);
    int numLinesToShow = buffer.getFlooredHeightInLines() + 1;
    moveViewportToLineNumber(newTopLineNumber, numLinesToShow);
  }

  public void teardown() {
    removeAnchors();
    detachHandlers();
  }

  /**
   * Adjusts the viewport bounds after a line is added or removed, returning
   * whether there an adjustment was made.
   */
  private boolean adjustViewportBoundsForLineAdditionOrRemoval(Document document, int lineNumber) {
    int bottomLineNumber = bottomAnchor.getLineNumber();
    int topLineNumber = topAnchor.getLineNumber();
    int lastVisibleLineNumber = topLineNumber + buffer.getFlooredHeightInLines();

    /*
     * The "lastVisibleLineNumber != bottomLineNumber" catches the case where
     * the viewport's last line is deleted, causing the bottom anchor to shift
     * up a line before this method is called. So, the lineNumber will not be in
     * the range of the top and bottom anchors.
     */
    if (MathUtils.isInRangeInclusive(lineNumber, topLineNumber, bottomLineNumber)
        || lastVisibleLineNumber != bottomLineNumber) {

      // Update the viewport to cope with the line addition or removal
      int shiftAmount = lastVisibleLineNumber - bottomLineNumber;
      if (shiftAmount != 0) {
        shiftBottomAnchor(shiftAmount);
      }

      return true;
    } else {
      return false;
    }
  }

  private void attachHandlers() {
    removerManager.track(buffer.getScrollListenerRegistrar().add(this));
    removerManager.track(buffer.getResizeListenerRegistrar().add(this));
    removerManager.track(document.getLineListenerRegistrar().add(this));
    removerManager.track(selection.getCursorListenerRegistrar().add(this));
    removerManager.track(buffer.getSpacerListenerRegistrar().add(this));
  }

  private void detachHandlers() {
    removerManager.remove();
  }

  private void moveViewportToLineNumber(int topLineNumber, int numLinesToShow) {
    LineFinder lineFinder = buffer.getDocument().getLineFinder();

    LineInfo newTop = lineFinder.findLine(getTop(), topLineNumber);

    int targetBottomLineNumber = newTop.number() + numLinesToShow - 1;
    LineInfo newBottom =
        lineFinder.findLine(getBottom(),
            Math.min(document.getLastLineNumber(), targetBottomLineNumber));

    setRange(newTop, newBottom);
  }

  public void shiftHorizontally(boolean right) {
    int deltaScrollLeft = right ? 20 : -20;
    setBufferScrollAsync(buffer.getScrollLeft() + deltaScrollLeft, buffer.getScrollTop());
  }

  public void shiftVertically(boolean down, boolean byPage) {
    int deltaAbsoluteScrollTop =
        byPage ? buffer.getHeight() - buffer.getEditorLineHeight() : buffer.getEditorLineHeight();
    int deltaScrollTop = down ? deltaAbsoluteScrollTop : -deltaAbsoluteScrollTop;
    setBufferScrollAsync(buffer.getScrollLeft(), buffer.getScrollTop() + deltaScrollTop);
  }

  public void jumpTo(boolean end) {
    int newScrollTop = end ? buffer.getScrollHeight() - buffer.getHeight() : 0;
    setBufferScrollAsync(buffer.getScrollLeft(), newScrollTop);
  }

  private void removeAnchors() {
    anchorManager.removeAnchor(topAnchor);
    anchorManager.removeAnchor(bottomAnchor);

    topAnchor = null;
    bottomAnchor = null;
  }

  private void resetPosition() {
    LineInfo firstLine = new LineInfo(document.getFirstLine(), 0);
    int lastLineNumber = Math.min(document.getLastLineNumber(), buffer.getFlooredHeightInLines());
    LineInfo lastLine = document.getLineFinder().findLine(lastLineNumber);
    setRange(firstLine, lastLine);
  }

  /**
   * Sets scroll top of the buffer asynchronously. This allows some events to be
   * processed in the browser event loop between renders. (For example, without
   * the asynchronous posting, holding down page down would only render one
   * frame a second.)
   */
  private void setBufferScrollAsync(final int scrollLeft, final int scrollTop) {
    Scheduler.get().scheduleDeferred(new ScheduledCommand() {
      @Override
      public void execute() {
        buffer.setScrollLeft(Math.max(0, scrollLeft));

        /* We'll synchronously get a callback and shift our viewport model to this new scroll top */
        buffer.setScrollTop(Math.max(0, scrollTop));
      }
    });
  }

  private void setRange(LineInfo newTop, LineInfo newBottom) {
    LineInfo oldTop;
    LineInfo oldBottom;

    if (topAnchor == null || bottomAnchor == null) {
      oldTop = oldBottom = null;
      topAnchor =
          anchorManager.createAnchor(VIEWPORT_MODEL_ANCHOR_TYPE, newTop.line(), newTop.number(),
              AnchorManager.IGNORE_COLUMN);
      topAnchor.setRemovalStrategy(RemovalStrategy.SHIFT);
      topAnchor.getShiftListenerRegistrar().add(anchorShiftedListener);

      bottomAnchor =
          anchorManager.createAnchor(VIEWPORT_MODEL_ANCHOR_TYPE, newBottom.line(),
              newBottom.number(), AnchorManager.IGNORE_COLUMN);
      bottomAnchor.setRemovalStrategy(RemovalStrategy.SHIFT);
      bottomAnchor.getShiftListenerRegistrar().add(anchorShiftedListener);

    } else {
      oldTop = topAnchor.getLineInfo();
      oldBottom = bottomAnchor.getLineInfo();

      if (oldTop.equals(newTop) && oldBottom.equals(newBottom)) {
        return;
      }

      anchorManager.moveAnchor(topAnchor, newTop.line(), newTop.number(),
          AnchorManager.IGNORE_COLUMN);
      anchorManager.moveAnchor(bottomAnchor, newBottom.line(), newBottom.number(),
          AnchorManager.IGNORE_COLUMN);
    }

    changeDispatcher.dispatch(oldTop, oldBottom);
  }

  /**
   * @param lineCount if negative, the bottom anchor will shift upward that many
   *        lines
   */
  private void shiftBottomAnchor(int lineCount) {
    LineInfo bottomLineInfo = bottomAnchor.getLineInfo();
    if (lineCount < 0) {
      for (; lineCount < 0; lineCount++) {
        bottomLineInfo.moveToPrevious();
      }
    } else {
      for (; lineCount > 0; lineCount--) {
        bottomLineInfo.moveToNext();
      }
    }

    setRange(topAnchor.getLineInfo(), bottomLineInfo);
  }

  @Override
  public void onSpacerAdded(Spacer spacer) {
    updateBoundsAfterSpacerChanged(spacer.getLineNumber());
  }

  @Override
  public void onSpacerHeightChanged(Spacer spacer, int oldHeight) {
    updateBoundsAfterSpacerChanged(spacer.getLineNumber());
  }

  @Override
  public void onSpacerRemoved(Spacer spacer, Line oldLine, int oldLineNumber) {
    updateBoundsAfterSpacerChanged(oldLineNumber);
  }

  private void updateBoundsAfterSpacerChanged(int spacerLineNumber) {
    if (spacerLineNumber < getTopLineNumber() || spacerLineNumber > getBottomLineNumber()) {
      return;
    }

    int newBottomLineNumber =
        buffer.convertYToLineNumber(buffer.getScrollTop() + buffer.getHeight(), true);
    setRange(getTop(), document
        .getLineFinder().findLine(getBottom(), newBottomLineNumber));
  }

}
