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

import com.google.collide.client.editor.ViewportModel;
import com.google.collide.client.editor.search.SearchModel.SearchProgressListener;
import com.google.collide.client.util.IncrementalScheduler;
import com.google.collide.client.util.IncrementalScheduler.Task;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.Line;
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.common.base.Preconditions;

/**
 * A class which can be used to iterate through the lines of a document. It will
 * synchronously callback for each line in the viewport then asynchronously
 * callback for the remaining lines in the document. The direction and start
 * line within the viewport are configurable.
 */
public class SearchTask {

  public interface SearchTaskExecutor {
    /**
     * Called for each line in the document as it is searched.
     *
     * @param line the current line
     * @param number the current line number
     * @param shouldRenderLine if this line is in the viewport and a render must
     *        be performed if any visible changes are made.
     * @return false to stop the search.
     */
    public boolean onSearchLine(Line line, int number, boolean shouldRenderLine);
  }

  /**
   * The direction of the document search.
   */
  public enum SearchDirection {
    UP, DOWN
  }

  /**
   * An object which simplifies searches by hiding the logic which depends on
   * the search direction.
   */
  private static class SearchDirectionHelper {
    private final ViewportModel viewport;
    private final Document document;

    private SearchDirection direction;

    public SearchDirectionHelper(ViewportModel viewport, Document document) {
      this.viewport = viewport;
      this.document = document;
    }

    /**
     * Sets the direction of the search so the helper can return valid line
     * information.
     */
    public void setDirection(SearchDirection direction) {
      this.direction = direction;
    }

    /**
     * Gets the starting line of the viewport, bottom if
     * {@link SearchDirection#DOWN}, top if {@link SearchDirection#UP}.
     */
    public Line getViewportEndLine() {
      return isGoingDown() ? viewport.getBottomLine() : viewport.getTopLine();
    }

    /**
     * Gets the starting line of the viewport, bottom if
     * {@link SearchDirection#DOWN}, top if {@link SearchDirection#UP}.
     */
    public LineInfo getViewportEndLineInfo() {
      return isGoingDown() ? viewport.getBottomLineInfo() : viewport.getTopLineInfo();
    }

    /**
     * Gets the starting line of the viewport, top if
     * {@link SearchDirection#DOWN}, bottom if {@link SearchDirection#UP}.
     */
    public LineInfo getViewportStartLineInfo() {
      return isGoingDown() ? viewport.getTopLineInfo() : viewport.getBottomLineInfo();
    }

    /**
     * Returns the line necessary to wrap around the document. i.e. if you are
     * searching down it will return the top, if you are searching up it will
     * return the bottom.
     */
    public LineInfo getWrapDocumentLine() {
      return isGoingDown() ? document.getFirstLineInfo() : document.getLastLineInfo();
    }

    /**
     * Returns if the search is going down.
     */
    public boolean isGoingDown() {
      return direction == SearchDirection.DOWN;
    }

    /**
     * Returns true if the line to be wrapped to is not at the corresponding
     * edge of the document. i.e. Don't wrap to the top of the document if the
     * viewport is at the top already (which we've already scanned).
     */
    public boolean canWrapDocument() {
      boolean atEdge = isGoingDown() ? viewport.getTopLine() == document.getFirstLine() :
          viewport.getBottomLine() == document.getLastLine();
      return !atEdge;
    }
  }

  /**
   * Indicates that the search task should start a search starting at either the
   * top or bottom of the viewport depending on the selected direction.
   */
  public static final LineInfo DEFAULT_START_LINE = new LineInfo(null, -1);

  private static final AnchorType SEARCH_TASK_ANCHOR =
      AnchorType.create(SearchTask.class, "SearchAnchor");

  private final ViewportModel viewport;
  private final IncrementalScheduler scheduler;
  private final Document document;
  private final Task asyncSearchTask;
  private final SearchDirectionHelper searchDirectionHelper;

  private Anchor stopLineAnchor;
  private Anchor searchTaskAnchor;
  private SearchProgressListener progressListener;
  private SearchTaskExecutor executor;
  private boolean shouldWrapDocument = true;

  public SearchTask(Document document, ViewportModel viewport, IncrementalScheduler scheduler) {
    this.document = document;
    this.viewport = viewport;
    this.scheduler = scheduler;
    this.searchDirectionHelper = new SearchDirectionHelper(viewport, document);

    asyncSearchTask = new AsyncSearchTask();
  }

  public void teardown() {
    scheduler.teardown();
    removeSearchTaskAnchors();
  }

  /**
   * Starts searching the document in the down direction starting at the
   * default line.
   */
  public void searchDocument(
      SearchTaskExecutor executor, SearchProgressListener progressListener) {
    searchDocumentStartingAtLine(
        executor, progressListener, SearchDirection.DOWN, DEFAULT_START_LINE);
  }

  /**
   * Starts searching the document in the down direction starting at the given
   * line.
   */
  public void searchDocument(
      SearchTaskExecutor executor, SearchProgressListener progressListener, LineInfo startLine) {
    searchDocumentStartingAtLine(
        executor, progressListener, SearchDirection.DOWN, startLine);
  }

  /**
   * Starts searching the document in the given direction starting at the
   * default start line.
   */
  public void searchDocument(SearchTaskExecutor executor, SearchProgressListener progressListener,
      SearchDirection direction) {
    searchDocumentStartingAtLine(executor, progressListener, direction, DEFAULT_START_LINE);
  }

  /**
   * Starts searching the document at the given line in the viewport and in the
   * specified direction. If the startLine is not within the viewport then
   * behavior is undefined and terrible things will likely happen.
   */
  public void searchDocumentStartingAtLine(SearchTaskExecutor executor,
      SearchProgressListener progressListener, SearchDirection direction, LineInfo startLine) {
    scheduler.cancel();
    this.progressListener = progressListener;
    this.executor = executor;

    searchDirectionHelper.setDirection(direction);
    if (startLine == DEFAULT_START_LINE) {
      startLine = searchDirectionHelper.getViewportStartLineInfo();
    }

    dispatchSearchBegin();
    boolean doAsyncSearch = true;
    if (startLine.number() >= viewport.getTopLineNumber() &&
        startLine.number() <= viewport.getBottomLineNumber()) {
      doAsyncSearch = scanViewportStartingAtLine(startLine);
    }

    if (doAsyncSearch) {
      setupSearchTaskAnchors(startLine);
      scheduler.schedule(asyncSearchTask);
    } else {
      dispatchSearchDone();
    }
  }

  /**
   * Returns if the search will wrap around the document when it gets to the
   * bottom or top.
   */
  public boolean isShouldWrapDocument() {
    return shouldWrapDocument;
  }

  /**
   * Determines if the search should wrap around the document either from the
   * top to the bottom or vice versa.
   */
  public void setShouldWrapDocument(boolean shouldWrapDocument) {
    this.shouldWrapDocument = shouldWrapDocument;
  }

  /**
   * Cancels any currently running search task.
   */
  public void cancelTask() {
    scheduler.cancel();
  }

  /**
   * Starts a scan of the viewport at the given line. If the given lineInfo is
   * not a line within the viewport then behavior is undefined (and likely not
   * going to end well).
   */
  private boolean scanViewportStartingAtLine(LineInfo startLineInfo) {
    Preconditions.checkArgument(
        startLineInfo.number() >= viewport.getTopLineNumber() &&
        startLineInfo.number() <= viewport.getBottomLineNumber(),
        "Editor: Search start line number not within viewport.");

    LineInfo lineInfo = startLineInfo.copy();
    do {
      if (!executor.onSearchLine(lineInfo.line(), lineInfo.number(), true)) {
        return false;
      }
    } while (lineInfo.line() != searchDirectionHelper.getViewportEndLine()
        && lineInfo.moveTo(searchDirectionHelper.isGoingDown()));

    /*
     * If we stopped because lineInfo == endline then we need to continue async
     * scanning, otherwise this moveTo call will fail and we won't bother. We
     * also have to check for the case where the viewport was already scrolled
     * to the very bottom or top of the document.
     */
    return lineInfo.moveTo(searchDirectionHelper.isGoingDown())
        || (searchDirectionHelper.canWrapDocument() && shouldWrapDocument);
  }

  /**
   * Removes the search task anchors after the task has completed.
   */
  private void removeSearchTaskAnchors() {
    if (stopLineAnchor != null) {
      document.getAnchorManager().removeAnchor(stopLineAnchor);
      stopLineAnchor = null;
    }
    if (searchTaskAnchor != null) {
      document.getAnchorManager().removeAnchor(searchTaskAnchor);
      searchTaskAnchor = null;
    }
  }

  /**
   * Sets an anchor at the top of the current viewport and one line below the
   * end of the viewport so we can scan the rest of the document.
   */
  private void setupSearchTaskAnchors(LineInfo stopLine) {
    if (stopLineAnchor == null) {
      stopLineAnchor = document.getAnchorManager().createAnchor(
          SEARCH_TASK_ANCHOR, stopLine.line(), stopLine.number(), AnchorManager.IGNORE_COLUMN);
      stopLineAnchor.setRemovalStrategy(RemovalStrategy.SHIFT);
    } else {
      document.getAnchorManager().moveAnchor(
          stopLineAnchor, stopLine.line(), stopLine.number(), AnchorManager.IGNORE_COLUMN);
    }

    // Try to set the line at the top or bottom of viewport (depending on
    // direction), if we fail then wrap around the document. We don't have to
    // check shoudl wrap here since the viewport scan would have returned false.
    LineInfo startAnchorLine = searchDirectionHelper.getViewportEndLineInfo();
    if (!startAnchorLine.moveTo(searchDirectionHelper.isGoingDown())) {
      startAnchorLine = searchDirectionHelper.getWrapDocumentLine();
    }

    if (searchTaskAnchor == null) {
      searchTaskAnchor =
          document.getAnchorManager().createAnchor(SEARCH_TASK_ANCHOR, startAnchorLine.line(),
              startAnchorLine.number(), AnchorManager.IGNORE_COLUMN);
      searchTaskAnchor.setRemovalStrategy(RemovalStrategy.SHIFT);
    } else {
      document.getAnchorManager().moveAnchor(searchTaskAnchor, startAnchorLine.line(),
          startAnchorLine.number(), AnchorManager.IGNORE_COLUMN);
    }
  }

  private void dispatchSearchBegin() {
    if (progressListener != null) {
      progressListener.onSearchBegin();
    }
  }

  private void dispatchSearchProgress() {
    if (progressListener != null) {
      progressListener.onSearchProgress();
    }
  }

  private void dispatchSearchDone() {
    removeSearchTaskAnchors();
    if (progressListener != null) {
      progressListener.onSearchDone();
    }
  }

  private class AsyncSearchTask implements IncrementalScheduler.Task {
    @Override
    public boolean run(int workAmount) {
      LineInfo lineInfo = searchTaskAnchor.getLineInfo();

      for (; lineInfo.line() != stopLineAnchor.getLine() && workAmount > 0; workAmount--) {
        if (!executor.onSearchLine(lineInfo.line(), lineInfo.number(), false)) {
          dispatchSearchDone();
          return false;
        }

        if (!lineInfo.moveTo(searchDirectionHelper.isGoingDown())) {
          if (shouldWrapDocument) {
            lineInfo = searchDirectionHelper.getWrapDocumentLine();
          } else {
            dispatchSearchDone();
            return false;
          }
        }
      }

      if (lineInfo.line() == stopLineAnchor.getLine()) {
        dispatchSearchDone();
        return false;
      }

      document.getAnchorManager().moveAnchor(
          searchTaskAnchor, lineInfo.line(), lineInfo.number(), AnchorManager.IGNORE_COLUMN);
      dispatchSearchProgress();
      return true;
    }
  }
}
