// 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 java.util.List;

import org.waveprotocol.wave.model.operation.OperationPair;
import org.waveprotocol.wave.model.operation.TransformException;
import org.waveprotocol.wave.model.undo.UndoManagerImpl;
import org.waveprotocol.wave.model.undo.UndoManagerImpl.Algorithms;
import org.waveprotocol.wave.model.undo.UndoManagerPlus;

import com.google.collide.client.editor.selection.SelectionModel;
import com.google.collide.dto.DocOp;
import com.google.collide.dto.client.ClientDocOpFactory;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.LineInfo;
import com.google.collide.shared.document.Position;
import com.google.collide.shared.document.TextChange;
import com.google.collide.shared.document.util.PositionUtils;
import com.google.collide.shared.ot.Composer;
import com.google.collide.shared.ot.Composer.ComposeException;
import com.google.collide.shared.ot.DocOpApplier;
import com.google.collide.shared.ot.DocOpUtils;
import com.google.collide.shared.ot.Inverter;
import com.google.collide.shared.ot.Transformer;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.ListenerRegistrar;

// TODO: restore selection/cursor
/**
 * A class to manage the editor's undo/redo functionality.
 *
 */
public class EditorUndoManager {

  /**
   * Delegates to the document operation algorithms when requested by the Wave
   * undo library.
   */
  private static final UndoManagerImpl.Algorithms<DocOp> ALGORITHMS = new Algorithms<DocOp>() {
    @Override
    public DocOp invert(DocOp operation) {
      return Inverter.invert(ClientDocOpFactory.INSTANCE, operation);
    }

    @Override
    public DocOp compose(List<DocOp> operationsReverse) {
      try {
        return Composer.compose(ClientDocOpFactory.INSTANCE, operationsReverse);
      } catch (ComposeException e) {
        throw new RuntimeException(e);
      }
    }

    @Override
    public OperationPair<DocOp> transform(DocOp op1, DocOp op2) throws TransformException {
      try {
        com.google.collide.shared.ot.OperationPair ourPair =
            Transformer.transform(ClientDocOpFactory.INSTANCE, op1, op2);
        return new OperationPair<DocOp>(ourPair.clientOp(), ourPair.serverOp());
      } catch (com.google.collide.shared.ot.Transformer.TransformException e) {
        throw new TransformException(e);
      }
    }
  };

  public static EditorUndoManager create(Editor editor, Document document,
                                         SelectionModel selection) {
    return new EditorUndoManager(editor, document, selection,
        new UndoManagerImpl<DocOp>(ALGORITHMS));
  }

  /*
   * TODO: think about which other events should cause a
   * checkpoint. Push out to a toplevel class if it gets complicated.
   */
  /**
   * Produces undo checkpoints at opportune times, such as when the user
   * explicitly moves the cursor (arrow keys or mouse click) or when the user's
   * text mutations change from delete to insert.
   */
  private class CheckpointProducer
      implements
        SelectionModel.CursorListener,
        Editor.BeforeTextListener,
        Editor.TextListener {

    private TextChange.Type previousTextChangeType;

    @Override
    public void onCursorChange(LineInfo lineInfo, int column, boolean isExplicitChange) {
      if (isExplicitChange) {
        undoManager.checkpoint();
      }
    }

    @Override
    public void onBeforeTextChange(TextChange textChange) {
      if (previousTextChangeType != textChange.getType()) {
        undoManager.checkpoint();
      }

      previousTextChangeType = textChange.getType();
    }

    @Override
    public void onTextChange(TextChange textChange) {
      if (textChange.getText().contains("\n")) {
        /*
         * Checkpoint after newlines which tends to be a good granularity for
         * typical editor use
         */
        undoManager.checkpoint();
      }
    }
  }

  private final CheckpointProducer checkpointProducer = new CheckpointProducer();
  private final Document document;
  private final Document.TextListener documentTextListener = new Document.TextListener() {
    @Override
    public void onTextChange(Document document, JsonArray<TextChange> textChanges) {
      if (isMutatingDocument || editor.getEditorDocumentMutator().isMutatingDocument()) {
        // We will handle this text change in the editor text change callback
        return;
      }

      for (int i = 0, n = textChanges.size(); i < n; i++) {
        TextChange textChange = textChanges.get(i);
        // This is a collaborator doc op, which is not undoable by us
        undoManager.nonUndoableOp(DocOpUtils.createFromTextChange(ClientDocOpFactory.INSTANCE,
            textChange));
      }
    }
  };

  private final Editor editor;
  private final Editor.TextListener editorTextListener = new Editor.TextListener() {
    @Override
    public void onTextChange(TextChange textChange) {

      if (isMutatingDocument) {
        // We caused this text change, so don't handle it
        return;
      }

      // This is a user document mutation that is undoable
      undoManager.undoableOp(DocOpUtils.createFromTextChange(ClientDocOpFactory.INSTANCE,
          textChange));
    }
  };

  private boolean isMutatingDocument;
  private final JsonArray<ListenerRegistrar.Remover> listenerRemovers =
      JsonCollections.createArray();
  private final SelectionModel selection;
  private final UndoManagerPlus<DocOp> undoManager;

  private EditorUndoManager(Editor editor, Document document, SelectionModel selection,
      UndoManagerPlus<DocOp> undoManager) {
    this.document = document;
    this.editor = editor;
    this.selection = selection;
    this.undoManager = undoManager;

    listenerRemovers.add(document.getTextListenerRegistrar().add(documentTextListener));
    listenerRemovers.add(editor.getTextListenerRegistrar().add(editorTextListener));
    listenerRemovers.add(selection.getCursorListenerRegistrar().add(checkpointProducer));
    listenerRemovers.add(editor.getBeforeTextListenerRegistrar().add(checkpointProducer));
    listenerRemovers.add(editor.getTextListenerRegistrar().add(checkpointProducer));
  }

  boolean isMutatingDocument() {
    return isMutatingDocument;
  }

  void undo() {
    DocOp undoDocOp = undoManager.undo();
    if (undoDocOp == null) {
      return;
    }

    applyToDocument(undoDocOp);
  }

  void redo() {
    DocOp redoDocOp = undoManager.redo();
    if (redoDocOp == null) {
      return;
    }

    applyToDocument(redoDocOp);
  }

  void teardown() {
    for (int i = 0, n = listenerRemovers.size(); i < n; i++) {
      listenerRemovers.get(i).remove();
    }
  }

  private void applyToDocument(DocOp docOp) {
    JsonArray<TextChange> textChanges;

    isMutatingDocument = true;
    try {
      /*
       * Mutate via the document (instead of editor) since we don't want this to
       * be affected by autoindentation, etc. We also don't want to replace the
       * text in the selection, and the document's mutator will never do this.
       */
      textChanges = DocOpApplier.apply(docOp, document, document);
    } finally {
      isMutatingDocument = false;
    }

    // a retain only doc-op will not produce a text-change
    if (textChanges.size() == 0) {
      return;
    }

    /*
     * There can theoretically be multiple text changes, but we just set
     * selection to the first
     */
    TextChange textChange = textChanges.get(0);
    Position endPosition =
        new Position(new LineInfo(textChange.getEndLine(), textChange.getEndLineNumber()),
            textChange.getEndColumn());
    if (textChange.getType() == TextChange.Type.INSERT) {
      endPosition = PositionUtils.getPosition(endPosition, 1);
    }

    selection.setSelection(new LineInfo(textChange.getLine(), textChange.getLineNumber()),
        textChange.getColumn(), endPosition.getLineInfo(),
        endPosition.getColumn());
  }
}
