// 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 org.waveprotocol.wave.client.common.util.SignalEvent;

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

import com.google.collide.client.code.parenmatch.ParenMatchHighlighter;
import com.google.collide.client.document.linedimensions.LineDimensionsCalculator;
import com.google.collide.client.editor.gutter.Gutter;
import com.google.collide.client.editor.gutter.LeftGutterManager;
import com.google.collide.client.editor.input.InputController;
import com.google.collide.client.editor.renderer.LineRenderer;
import com.google.collide.client.editor.renderer.RenderTimeExecutor;
import com.google.collide.client.editor.renderer.Renderer;
import com.google.collide.client.editor.search.SearchMatchRenderer;
import com.google.collide.client.editor.search.SearchModel;
import com.google.collide.client.editor.selection.CursorView;
import com.google.collide.client.editor.selection.LocalCursorController;
import com.google.collide.client.editor.selection.SelectionLineRenderer;
import com.google.collide.client.editor.selection.SelectionManager;
import com.google.collide.client.editor.selection.SelectionModel;
import com.google.collide.client.util.dom.FontDimensionsCalculator;
import com.google.collide.client.util.dom.FontDimensionsCalculator.FontDimensions;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.mvp.CompositeView;
import com.google.collide.mvp.UiComponent;
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.TextChange;
import com.google.collide.shared.util.JsonCollections;
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.common.annotations.VisibleForTesting;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.resources.client.ImageResource;

import elemental.dom.Element;
import elemental.events.Event;

/**
 * The presenter for the Collide editor.
 *
 *  This class composes many of the other classes that together form the editor.
 * For example, the area where the text is displayed, the {@link Buffer}, is a
 * nested presenter. Other components are not presenters, such as the input
 * mechanism which is handled by the {@link InputController}.
 *
 *  If an added element wants native browser selection, you must not inherit the
 * "user-select" CSS property. See
 * {@link CssUtils#setUserSelect(Element, boolean)}.
 */
public class Editor extends UiComponent<Editor.View> {

  /**
   * Static factory method for obtaining an instance of the Editor.
   */
  public static Editor create(EditorContext<?> editorContext) {

    FontDimensionsCalculator fontDimensionsCalculator =
        FontDimensionsCalculator.get(editorContext.getResources().workspaceEditorCss().editorFont());
    RenderTimeExecutor renderTimeExecutor = new RenderTimeExecutor();
    LineDimensionsCalculator lineDimensions =
        LineDimensionsCalculator.create(fontDimensionsCalculator);

    Buffer buffer =
        Buffer.create(editorContext.getResources(), fontDimensionsCalculator.getFontDimensions(), lineDimensions,
            renderTimeExecutor);
    InputController input = new InputController();
    View view =
        new View(editorContext.getResources(), buffer.getView().getElement(), input.getInputElement());
    FocusManager focusManager = new FocusManager(buffer, input.getInputElement());
    return new Editor(editorContext, view, buffer, input, focusManager, fontDimensionsCalculator,
        renderTimeExecutor);
  }

  /**
   * Animation CSS.
   */
  @CssResource.Shared
  public interface EditorSharedCss extends CssResource {
    String animationEnabled();

    String scrollable();
  }

  /**
   * CssResource for the editor.
   */
  public interface Css extends EditorSharedCss {
    String leftGutter();

    String editorFont();

    String root();

    String scrolled();

    String gutter();

    String lineRendererError();
  }

  /**
   * A listener that is called when the user presses a key.
   */
  public interface KeyListener {
    /*
     * The reason for preventDefault() not preventing default behavior is that
     * Firefox does not have support the defaultPrevented attribute, so we have
     * know way of knowing if it was prevented from the native event. We could
     * create a proxy for SignalEvent to note calls to preventDefault(), but
     * this would not catch the case that the implementor interacts directly to
     * the native event.
     */
    /**
     * @param event the event for the key press. Note: Calling preventDefault()
     *        may not prevent the default behavior in some cases. The return
     *        value of this method is a better channel for indicating the
     *        default behavior should be prevented.
     * @return true if the event was handled (the default behavior will not run
     *         in this case), false to proceed with the default behavior. Even
     *         if true is returned, other listeners will still get the callback
     */
    boolean onKeyPress(SignalEvent event);
  }

  /**
   * A listener that is called on "keyup" native event.
   */
  public interface NativeKeyUpListener {

    /**
     * @param event the event for the key up
     * @return true if the event was handled, false to proceed with default
     *         behavior
     */
    boolean onNativeKeyUp(Event event);
  }

  /**
   * ClientBundle for the editor.
   */
  public interface Resources
      extends
      Buffer.Resources,
      CursorView.Resources,
      SelectionLineRenderer.Resources,
      SearchMatchRenderer.Resources,
      ParenMatchHighlighter.Resources {
    @Source({"Editor.css", "constants.css"})
    Css workspaceEditorCss();

    @Source("squiggle.gif")
    ImageResource squiggle();
  }

  /**
   * A listener that is called after the user enters or deletes text and before
   * it is applied to the document.
   */
  public interface BeforeTextListener {
    /**
     * Note: You should not mutate the document within this callback, as this is
     * not supported yet and can lead to other clients having stale position
     * information inside the {@code textChange}.
     *
     * Note: The {@link TextChange} contains a reference to the live
     * {@link Line} from the document model. If you hold on to a reference after
     * {@link #onBeforeTextChange} returns, beware that the contents of the
     * {@link Line} could change, invalidating some of the state in the
     * {@link TextChange}.
     *
     * @param textChange the text change whose last line will be the same as the
     *        insertion point (since the text hasn't been inserted yet)
     */
    void onBeforeTextChange(TextChange textChange);
  }

  /**
   * A listener that is called when the user enters or deletes text.
   *
   * Similar to {@link Document.TextListener} except is only called when the
   * text is entered/deleted by the local user.
   */
  public interface TextListener {
    /**
     * Note: You should not mutate the document within this callback, as this is
     * not supported yet and can lead to other clients having stale position
     * information inside the {@code textChange}.
     *
     * Note: The {@link TextChange} contains a reference to the live
     * {@link Line} from the document model. If you hold on to a reference after
     * {@link #onTextChange} returns, beware that the contents of the
     * {@link Line} could change, invalidating some of the state in the
     * {@link TextChange}.
     */
    void onTextChange(TextChange textChange);
  }

  /**
   * A listener that is called when the document changes.
   *
   *  This can be used by external clients of the editor; if the client is a
   * component of the editor, use {@link Editor#setDocument(Document)} instead.
   */
  public interface DocumentListener {
    void onDocumentChanged(Document oldDocument, Document newDocument);
  }

  /**
   * A listener that is called when the editor becomes or is no longer
   * read-only.
   */
  public interface ReadOnlyListener {
    void onReadOnlyChanged(boolean isReadOnly);
  }

  /**
   * The view for the editor, containing gutters and the buffer. This exposes
   * only the ability to enable or disable animations.
   */
  public static class View extends CompositeView<Void> {
    private final Element bufferElement;
    final Css css;
    final Resources res;

    private View(Resources res, Element bufferElement, Element inputElement) {

      this.res = res;
      this.bufferElement = bufferElement;
      this.css = res.workspaceEditorCss();

      Element rootElement = Elements.createDivElement(css.root());
      rootElement.appendChild(bufferElement);
      rootElement.appendChild(inputElement);
      setElement(rootElement);
    }

    private void addGutter(Element gutterElement) {
      getElement().insertBefore(gutterElement, bufferElement);
    }

    private void removeGutter(Element gutterElement) {
      getElement().removeChild(gutterElement);
    }

    public void setAnimationEnabled(boolean enabled) {
      // TODO: Re-enable animations when they are stable.
      if (enabled) {
        // getElement().addClassName(css.animationEnabled());
      } else {
        // getElement().removeClassName(css.animationEnabled());
      }
    }

    public Resources getResources() {
      return res;
    }
  }

  public static final int ANIMATION_DURATION = 100;
  private static int idCounter = 0;

  private final EditorContext<?> editorContext;
  private final Buffer buffer;
  private Document document;
  private final ListenerManager<DocumentListener> documentListenerManager =
      ListenerManager.create();
  private final EditorDocumentMutator editorDocumentMutator;
  private final FontDimensionsCalculator editorFontDimensionsCalculator;
  private EditorUndoManager editorUndoManager;
  private final FocusManager focusManager;
  private final MouseHoverManager mouseHoverManager;
  private final int id = idCounter++;

  private final FontDimensionsCalculator.Callback fontDimensionsChangedCallback =
      new FontDimensionsCalculator.Callback() {
        @Override
        public void onFontDimensionsChanged(FontDimensions fontDimensions) {
          handleFontDimensionsChanged();
        }
      };

  private final JsonArray<Gutter> gutters = JsonCollections.createArray();
  private final InputController input;
  private final LeftGutterManager leftGutterManager;
  private LocalCursorController localCursorController;
  private final ListenerManager<ReadOnlyListener> readOnlyListenerManager = ListenerManager
      .create();
  private Renderer renderer;
  private SearchModel searchModel;
  private SelectionManager selectionManager;
  private final EditorActivityManager editorActivityManager;
  private ViewportModel viewport;
  private boolean isReadOnly;
  private final RenderTimeExecutor renderTimeExecutor;

  private Editor(EditorContext<?> editorContext, View view, Buffer buffer, InputController input,
      FocusManager focusManager, FontDimensionsCalculator editorFontDimensionsCalculator,
      RenderTimeExecutor renderTimeExecutor) {
    super(view);
    this.editorContext = editorContext;
    this.buffer = buffer;
    this.input = input;
    this.focusManager = focusManager;
    this.editorFontDimensionsCalculator = editorFontDimensionsCalculator;
    this.renderTimeExecutor = renderTimeExecutor;
    Gutter leftGutter = createGutter(
        false, Gutter.Position.LEFT, editorContext.getResources().workspaceEditorCss().leftGutter());
    leftGutterManager = new LeftGutterManager(leftGutter, buffer);

    editorDocumentMutator = new EditorDocumentMutator(this);
    mouseHoverManager = new MouseHoverManager(this);

    editorActivityManager =
        new EditorActivityManager(editorContext.getUserActivityManager(),
            buffer.getScrollListenerRegistrar(), getKeyListenerRegistrar());

    // TODO: instantiate input from here
    input.initializeFromEditor(this, editorDocumentMutator);

    setAnimationEnabled(true);
    addBoxShadowOnScrollHandler();
    editorFontDimensionsCalculator.addCallback(fontDimensionsChangedCallback);
  }

  private void handleFontDimensionsChanged() {
    buffer.repositionAnchoredElementsWithColumn();
    if (renderer != null) {
      /*
       * TODO: think about a scheme where we don't have to rerender
       * the whole viewport (currently we do because of the right-side gap
       * fillers)
       */
      renderer.renderAll();
    }
  }

  /**
   * Adds a scroll handler to the buffer scrollableElement so that a drop shadow
   * can be added and removed when scrolled.
   */
  private void addBoxShadowOnScrollHandler() {
  }

  public void addLineRenderer(LineRenderer lineRenderer) {
    /*
     * TODO: Because the line renderer is document-scoped, line
     * renderers have to re-add themselves whenever the document changes. This
     * is unexpected.
     */
    renderer.addLineRenderer(lineRenderer);
  }

  public Gutter createGutter(boolean overviewMode, Gutter.Position position, String cssClassName) {
    Gutter gutter = Gutter.create(overviewMode, position, cssClassName, buffer);
    if (viewport != null && renderer != null) {
      gutter.handleDocumentChanged(viewport, renderer);
    }

    gutters.add(gutter);

    gutter.getGutterElement().addClassName(getView().css.gutter());
    getView().addGutter(gutter.getGutterElement());
    return gutter;
  }

  public void removeGutter(Gutter gutter) {
    getView().removeGutter(gutter.getGutterElement());
    gutters.remove(gutter);
  }

  public void setAnimationEnabled(boolean enabled) {
    getView().setAnimationEnabled(enabled);
  }

  public ListenerRegistrar<BeforeTextListener> getBeforeTextListenerRegistrar() {
    return editorDocumentMutator.getBeforeTextListenerRegistrar();
  }

  public Buffer getBuffer() {
    return buffer;
  }

  /*
   * TODO: if left gutter manager gets public API, expose that
   * instead of directly exposign the gutter. Or, if we don't want to expose
   * Gutter#setWidth publicly for the left gutter, make LeftGutterManager the
   * public API.
   */
  public Gutter getLeftGutter() {
    return leftGutterManager.getGutter();
  }

  public Document getDocument() {
    return document;
  }

  /**
   * Returns a document mutator that will also notify editor text listeners.
   */
  public EditorDocumentMutator getEditorDocumentMutator() {
    return editorDocumentMutator;
  }

  public Element getElement() {
    return getView().getElement();
  }

  public FocusManager getFocusManager() {
    return focusManager;
  }

  public MouseHoverManager getMouseHoverManager() {
    return mouseHoverManager;
  }

  public ListenerRegistrar<KeyListener> getKeyListenerRegistrar() {
    return input.getKeyListenerRegistrar();
  }

  public ListenerRegistrar<NativeKeyUpListener> getNativeKeyUpListenerRegistrar() {
    return input.getNativeKeyUpListenerRegistrar();
  }

  public Renderer getRenderer() {
    return renderer;
  }

  public SearchModel getSearchModel() {
    return searchModel;
  }

  public SelectionModel getSelection() {
    return selectionManager.getSelectionModel();
  }

  public LocalCursorController getCursorController() {
    return localCursorController;
  }

  public ListenerRegistrar<TextListener> getTextListenerRegistrar() {
    return editorDocumentMutator.getTextListenerRegistrar();
  }

  public ListenerRegistrar<DocumentListener> getDocumentListenerRegistrar() {
    return documentListenerManager;
  }

  // TODO: need a public interface and impl
  public ViewportModel getViewport() {
    return viewport;
  }

  public boolean isMutatingDocumentFromUndoOrRedo() {
    return editorUndoManager.isMutatingDocument();
  }

  public void removeLineRenderer(LineRenderer lineRenderer) {
    renderer.removeLineRenderer(lineRenderer);
  }

  public void setDocument(final Document document) {
    final Document oldDocument = this.document;

    if (oldDocument != null) {
      // Teardown the objects depending on the old document
      renderer.teardown();
      viewport.teardown();
      selectionManager.teardown();
      localCursorController.teardown();
      editorUndoManager.teardown();
      searchModel.teardown();
    }

    this.document = document;

    /*
     * TODO: dig into each component, figure out dependencies,
     * break apart components so we can reduce circular dependencies which
     * require the multiple stages of initialization
     */
    // Core editor components
    buffer.handleDocumentChanged(document);
    leftGutterManager.handleDocumentChanged(document);
    selectionManager =
        SelectionManager.create(document, buffer, focusManager, editorContext.getResources());

    SelectionModel selection = selectionManager.getSelectionModel();
    viewport = ViewportModel.create(document, selection, buffer);
    input.handleDocumentChanged(document, selection, viewport);
    renderer = Renderer.create(document,
        viewport,
        buffer,
        getLeftGutter(),
        selection,
        focusManager,
        this,
        editorContext.getResources(),
        renderTimeExecutor);

    // Delayed core editor component initialization
    viewport.initialize();
    selection.initialize(viewport);
    selectionManager.initialize(renderer);
    buffer.handleComponentsInitialized(viewport, renderer);
    for (int i = 0, n = gutters.size(); i < n; i++) {
      gutters.get(i).handleDocumentChanged(viewport, renderer);
    }

    // Non-core editor components
    editorUndoManager = EditorUndoManager.create(this, document, selection);
    searchModel = SearchModel.create(editorContext,
        document,
        renderer,
        viewport,
        selection,
        editorDocumentMutator);
    localCursorController =
        LocalCursorController.create(editorContext.getResources(), focusManager, selection, buffer, this);

    documentListenerManager.dispatch(new Dispatcher<Editor.DocumentListener>() {
      @Override
      public void dispatch(DocumentListener listener) {
        listener.onDocumentChanged(oldDocument, document);
      }
    });
  }

  public void undo() {
    editorUndoManager.undo();
  }

  public void redo() {
    editorUndoManager.redo();
  }

  public void scrollTo(int lineNumber, int column) {
    if (document != null) {
      LineInfo lineInfo = document.getLineFinder().findLine(lineNumber);
      /*
       * TODO: the cursor will be the last line in the viewport,
       * fix this
       */
      SelectionModel selectionModel = getSelection();
      selectionModel.deselect();
      selectionModel.setCursorPosition(lineInfo, column);
    }
  }

  public void cleanup() {
    editorFontDimensionsCalculator.removeCallback(fontDimensionsChangedCallback);
    editorActivityManager.teardown();
  }

  public void setReadOnly(final boolean isReadOnly) {

    if (this.isReadOnly == isReadOnly) {
      return;
    }

    this.isReadOnly = isReadOnly;

    readOnlyListenerManager.dispatch(new Dispatcher<Editor.ReadOnlyListener>() {
      @Override
      public void dispatch(ReadOnlyListener listener) {
        listener.onReadOnlyChanged(isReadOnly);
      }
    });
  }

  public boolean isReadOnly() {
    return isReadOnly;
  }

  public ListenerRegistrar<ReadOnlyListener> getReadOnlyListenerRegistrar() {
    return readOnlyListenerManager;
  }

  public int getId() {
    return id;
  }

  @VisibleForTesting
  public InputController getInput() {
    return input;
  }

  public void setLeftGutterVisible(boolean visible) {
    Element gutterElement = leftGutterManager.getGutter().getGutterElement();
    if (visible) {
      getView().addGutter(gutterElement);
    } else {
      getView().removeGutter(gutterElement);
    }
  }
}
