// 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.search.SearchModel.MatchCountListener;
import com.google.collide.client.editor.search.SearchTask.SearchTaskExecutor;
import com.google.collide.client.editor.selection.SelectionModel;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.DocumentMutator;
import com.google.collide.shared.document.Line;
import com.google.collide.shared.document.LineInfo;
import com.google.collide.shared.document.Position;
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.RegExpUtils;
import com.google.gwt.regexp.shared.MatchResult;
import com.google.gwt.regexp.shared.RegExp;

/**
 * Manages search matches and can be queried to determine the current match and
 * select a new match.
 */
/*
 * TODO: Consider making searching for matches asynchrounous if it
 * proves to be a bottleneck. Particularly revisit code that touches
 * totalMatches since it will no longer be valid and could lead to races.
 */
public class SearchMatchManager {
  
  private final Document document;
  private RegExp searchPattern;

  int totalMatches;
  private final SelectionModel selection;
  private final DocumentMutator editorDocumentMutator;
  private final SearchTask searchTask;
  private final ListenerManager<MatchCountListener> totalMatchesListenerManager =
      ListenerManager.create();

  public SearchMatchManager(Document document, SelectionModel selection,
      DocumentMutator editorDocumentMutator, SearchTask searchTask) {
    this.document = document;
    this.selection = selection;
    this.editorDocumentMutator = editorDocumentMutator;
    this.searchTask = searchTask;
  }

  /**
   * Moves to the next match starting from the current cursor position.
   *
   * @returns Position of match or null if no matches are found.
   */
  public Position selectNextMatch() {
    Position[] position = selection.getSelectionRange(false);
    return selectNextMatchFromPosition(position[1].getLineInfo(), position[1].getColumn());
  }

  /**
   * Moves to the next match after the given position (inclusive).
   *
   * @returns Position of match or null if no matches are found.
   */
  public Position selectNextMatchFromPosition(LineInfo lineInfo, int startColumn) {
    if (totalMatches == 0 || searchPattern == null || lineInfo == null) {
      return null;
    }

    /*
     * Basic Strategy: loop through lines until we find another match, if we hit
     * the end start at the top. Until we hit our own line then just select the
     * first match from index 0 (shouldn't be us).
     */
    Line beginLine = lineInfo.line();
    int column = startColumn;
    do {
      if (selectNextMatchOnLine(lineInfo, column, lineInfo.line().length())) {
        return new Position(lineInfo, selection.getCursorColumn());
      }
      if (!lineInfo.moveToNext()) {
        lineInfo = document.getFirstLineInfo();
      }
      // after first attempt, we always look at start of line
      column = 0;
    } while (lineInfo.line() != beginLine);

    // We check to ensure there wasn't another match to wrap to on our own line
    if (selectNextMatchOnLine(lineInfo, 0, startColumn)) {
      return new Position(lineInfo, selection.getCursorColumn());
    }
    return null;
  }

  /**
   * Moves to the previous match starting at the current cursor position.
   *
   * @returns Position of match or null if no matches are found.
   */
  public Position selectPreviousMatch() {
    Position[] position = selection.getSelectionRange(false);
    return selectPreviousMatchFromPosition(position[0].getLineInfo(), position[0].getColumn());
  }

  /**
   * Moves to the previous match from the given position (inclusive).
   *
   * @returns Position of match or null if no matches are found.
   */
  public Position selectPreviousMatchFromPosition(LineInfo lineInfo, int startColumn) {
    if (totalMatches == 0 || searchPattern == null || lineInfo == null) {
      return null;
    }

    /*
     * Basic Strategy: loop through lines going up, we have to go right to left
     * though so we use the line keys to determine how many matches should be in
     * a line and back out from that.
     */
    Line beginLine = lineInfo.line();
    int column = startColumn;
    do {
      if (selectPreviousMatchOnLine(lineInfo, 0, column)) {
        return new Position(lineInfo, selection.getCursorColumn());
      }

      if (!lineInfo.moveToPrevious()) {
        lineInfo = document.getLastLineInfo();
      }
      // after first attempt we want the last match in a line always
      column = lineInfo.line().getText().length();
    } while (lineInfo.line() != beginLine);

    // We check to ensure there wasn't another match to wrap to on our own line
    if (selectPreviousMatchOnLine(lineInfo, startColumn, beginLine.length())) {
      return new Position(lineInfo, selection.getCursorColumn());
    }
    return null;
  }

  /**
   * Increments the current total. If no match is currently selected this will
   * select the first match that is added automatically.
   */
  public void addMatches(LineInfo lineInfo, int matches) {
    assert searchPattern != null;

    if (totalMatches == 0 && matches > 0) {
      selectNextMatchOnLine(lineInfo, 0, lineInfo.line().length());
    }

    totalMatches += matches;
    dispatchTotalMatchesChanged();
  }

  public int getTotalMatches() {
    return totalMatches;
  }
  
  ListenerRegistrar<MatchCountListener> getMatchCountChangedListenerRegistrar() {
    return totalMatchesListenerManager;
  }

  public void clearMatches() {
    totalMatches = 0;
    dispatchTotalMatchesChanged();
  }

  /**
   * @return true if current selection is a match to the searchPattern.
   */
  private boolean isSelectionRangeAMatch() {
    Position[] selectionRange = selection.getSelectionRange(false);
    if (searchPattern != null && totalMatches > 0
        && selectionRange[0].getLine() == selectionRange[1].getLine()) {
      String text =
          document.getText(selectionRange[0].getLine(), selectionRange[0].getColumn(),
              selectionRange[1].getColumn() - selectionRange[0].getColumn());

      return !text.isEmpty() && RegExpUtils.resetAndTest(searchPattern, text);
    }
    return false;
  }

  /**
   * Sets the search pattern used when finding matches. Also clears any existing
   * match count.
   */
  public void setSearchPattern(RegExp searchPattern) {
    clearMatches();
    this.searchPattern = searchPattern;
  }
  
  private void dispatchTotalMatchesChanged() {
    totalMatchesListenerManager.dispatch(new Dispatcher<MatchCountListener>() {
      @Override
      public void dispatch(MatchCountListener listener) {
        listener.onMatchCountChanged(totalMatches);
      }
    });
  }

  /**
   * Selects the next match using the search pattern given line and startIndex.
   *
   * @param startIndex The boundary to find the next match after.
   * @param endIndex The boundary to find the next match before.
   *
   * @returns true if match is found
   */
  private boolean selectNextMatchOnLine(LineInfo line, int startIndex, int endIndex) {
    searchPattern.setLastIndex(startIndex);
    MatchResult result = searchPattern.exec(line.line().getText());

    if (result == null || result.getIndex() >= endIndex) {
      return false;
    }

    moveAndSelectMatch(line, result.getIndex(), result.getGroup(0).length());
    return true;
  }

  /**
   * Selects the previous match using the search pattern given line and
   * startIndex.
   *
   * @param startIndex The boundary to find a previous match after.
   * @param endIndex The boundary to find a previous match before.
   *
   * @returns true if a match is found
   */
  private boolean selectPreviousMatchOnLine(LineInfo line, int startIndex, int endIndex) {
    searchPattern.setLastIndex(0);

    // Find the last match without going over our startIndex
    MatchResult lastMatch = null;
    for (MatchResult result = searchPattern.exec(line.line().getText());
        result != null && result.getIndex() < endIndex && result.getIndex() >= startIndex;
        result = searchPattern.exec(line.line().getText())) {
      lastMatch = result;
    }

    if (lastMatch == null) {
      return false;
    }

    moveAndSelectMatch(line, lastMatch.getIndex(), lastMatch.getGroup(0).length());
    return true;
  }

  /**
   * Moves the editor selection to the specified line and column and selects
   * length characters.
   */
  private void moveAndSelectMatch(LineInfo line, int column, int length) {
    selection.setSelection(line, column + length, line, column);
  }

  public void replaceAllMatches(final String replacement) {
    // TODO: There's an issue relying on the same SearchTask as
    // SearchModel, since they share the same scheduler the searchModel can
    // preempt a replaceAll before it is finish!
    searchTask.searchDocument(new SearchTaskExecutor() {
      @Override
      public boolean onSearchLine(Line line, int number, boolean shouldRenderLine) {
        searchPattern.setLastIndex(0);
        for (MatchResult result = searchPattern.exec(line.getText());
            result != null && result.getGroup(0).length() != 0;
            result = searchPattern.exec(line.getText())) {

          int start = searchPattern.getLastIndex() - result.getGroup(0).length();
          editorDocumentMutator.deleteText(line, number, start, result.getGroup(0).length());
          editorDocumentMutator.insertText(line, number, start, replacement);

          int newIndex = result.getIndex() + replacement.length();
          searchPattern.setLastIndex(newIndex);
        }
        return true;
      }
    }, null);
  }

  public boolean replaceMatch(String replacement) {
    if (!isSelectionRangeAMatch() && selectNextMatch() == null) {
      return false;
    }

    editorDocumentMutator.insertText(selection.getCursorLine(), selection.getCursorLineNumber(),
        selection.getCursorColumn(), replacement, true);
    selectNextMatch();
    return true;
  }
}
