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

import org.waveprotocol.wave.client.common.util.SignalEvent;

import collide.client.util.BrowserUtils;
import collide.client.util.Elements;

import com.google.collide.client.document.linedimensions.LineDimensionsUtils;
import com.google.collide.client.editor.Editor;
import com.google.collide.client.editor.Editor.KeyListener;
import com.google.collide.client.editor.Editor.NativeKeyUpListener;
import com.google.collide.client.editor.Editor.ReadOnlyListener;
import com.google.collide.client.editor.ViewportModel;
import com.google.collide.client.editor.selection.SelectionModel;
import com.google.collide.client.util.SignalEventUtils;
import com.google.collide.client.util.logging.Log;
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.LineFinder;
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.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.TextUtils;
import com.google.common.annotations.VisibleForTesting;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;

import elemental.css.CSSStyleDeclaration;
import elemental.dom.Element;
import elemental.events.Event;
import elemental.events.EventListener;
import elemental.events.TextEvent;
import elemental.html.TextAreaElement;

/**
 * Controller for taking input from the user. This manages an offscreen textarea
 * that receives the user's entered text.
 *
 * The lifecycle of this class is tied to the editor that owns it.
 *
 */
public class InputController {

  // TODO: move to elemental
  private static final String EVENT_TEXTINPUT = "textInput";

  final InputScheme nativeScheme;
  final InputScheme vimScheme;

  private Document document;
  private Editor editor;
  private DocumentMutator editorDocumentMutator;
  private final TextAreaElement inputElement;
  private InputScheme activeInputScheme = null;
  private final ListenerManager<KeyListener> keyListenerManager = ListenerManager.create();
  private final ListenerManager<NativeKeyUpListener> nativeKeyUpListenerManager = ListenerManager
      .create();
  private SelectionModel selection;
  private ViewportModel viewport;
  private final RootActionExecutor actionExecutor;

  public InputController() {
    inputElement = createInputElement();
    actionExecutor = new RootActionExecutor();
    nativeScheme = new DefaultScheme(this);
    vimScheme = new VimScheme(this);
  }

  public Document getDocument() {
    return document;
  }

  public Editor getEditor() {
    return editor;
  }

  public DocumentMutator getEditorDocumentMutator() {
    return editorDocumentMutator;
  }

  public Element getInputElement() {
    return inputElement;
  }

  public String getInputText() {
    return inputElement.getValue();
  }

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

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

  public SelectionModel getSelection() {
    return selection;
  }

  public void handleDocumentChanged(Document document, SelectionModel selection,
      ViewportModel viewport) {
    this.document = document;
    this.selection = selection;
    this.viewport = viewport;
  }

  public void initializeFromEditor(Editor editor, DocumentMutator editorDocumentMutator) {
    this.editor = editor;
    this.editorDocumentMutator = editorDocumentMutator;

    editor.getReadOnlyListenerRegistrar().add(new ReadOnlyListener() {
      @Override
      public void onReadOnlyChanged(boolean isReadOnly) {
        handleReadOnlyChanged(isReadOnly);
      }
    });

    handleReadOnlyChanged(editor.isReadOnly());
  }

  private void handleReadOnlyChanged(boolean isReadOnly) {
    if (isReadOnly) {
      setActiveInputScheme(new ReadOnlyScheme(this));
    } else {
      setActiveInputScheme(nativeScheme);
    }
  }

  public void setActiveInputScheme(InputScheme inputScheme) {
    if (this.activeInputScheme != null) {
      this.activeInputScheme.teardown();
    }
    this.activeInputScheme = inputScheme;
    this.activeInputScheme.setup();
  }

  public void setInputText(String text) {
    inputElement.setValue(text);
  }

  public void setSelection(SelectionModel selection) {
    this.selection = selection;
  }

  boolean dispatchKeyPress(final SignalEvent signalEvent) {
    class KeyDispatcher implements Dispatcher<KeyListener> {
      boolean handled;

      @Override
      public void dispatch(KeyListener listener) {
        handled |= listener.onKeyPress(signalEvent);
      }
    }

    KeyDispatcher keyDispatcher = new KeyDispatcher();
    keyListenerManager.dispatch(keyDispatcher);

    return keyDispatcher.handled;
  }

  boolean dispatchKeyUp(final Event event) {
    class NativeKeyUpDispatcher implements Dispatcher<Editor.NativeKeyUpListener> {
      boolean handled;

      @Override
      public void dispatch(NativeKeyUpListener listener) {
        handled |= listener.onNativeKeyUp(event);
      }
    }

    NativeKeyUpDispatcher nativeKeyUpDispatcher = new NativeKeyUpDispatcher();
    nativeKeyUpListenerManager.dispatch(nativeKeyUpDispatcher);

    return nativeKeyUpDispatcher.handled;
  }

  private TextAreaElement createInputElement() {
    final TextAreaElement inputElement = Elements.createTextAreaElement();

    // Ensure it is offscreen
    inputElement.getStyle().setPosition(CSSStyleDeclaration.Position.ABSOLUTE);
    inputElement.getStyle().setLeft("-100000px");
    inputElement.getStyle().setTop("0");
    inputElement.getStyle().setHeight("1px");
    inputElement.getStyle().setWidth("1px");
    /*
     * Firefox doesn't seem to respect just the NOWRAP value, so we need to set
     * the legacy wrap attribute.
     */
    inputElement.setAttribute("wrap", "off");

    // Attach listeners
    /*
     * For text events, call inputHandler.handleInput(event, text) if the text
     * entered was > 1 character -> from a paste event. This gets fed directly
     * into the document. Single keypresses all get captured by signalEventListener
     * and passed through the shortcut system.
     *
     * TODO: This isn't actually true, there could be paste events
     * of only one character. Change this to check if the event was a clipboard
     * event.
     */
    inputElement.addEventListener(EVENT_TEXTINPUT, new EventListener() {
      @Override
      public void handleEvent(Event event) {
        /*
         * TODO: figure out best event to listen to. Tried "input",
         * but see http://code.google.com/p/chromium/issues/detail?id=76516
         */
        String text = ((TextEvent) event).getData();
        if (text.length() <= 1) {
          return;
        }
        setInputText("");
        activeInputScheme.handleEvent(SignalEventUtils.create(event), text);
      }
    }, false);

    if (BrowserUtils.isFirefox()) {
      inputElement.addEventListener(Event.INPUT, new EventListener() {
        @Override
        public void handleEvent(Event event) {
          /*
           * TODO: FF doesn't support textInput, and Chrome's input
           * is buggy.
           */
          String text = getInputText();
          if (text.length() <= 1) {
            return;
          }
          setInputText("");

          activeInputScheme.handleEvent(SignalEventUtils.create(event), text);

          event.preventDefault();
          event.stopPropagation();
        }
      }, false);
    }

    EventListener signalEventListener = new EventListener() {
      @Override
      public void handleEvent(Event event) {
        SignalEvent signalEvent = SignalEventUtils.create(event);
        if (signalEvent != null) {
          processSignalEvent(signalEvent);
        } else if ("keyup".equals(event.getType())) {
          boolean handled = dispatchKeyUp(event);
          if (handled) {
            // Prevent any browser handling.
            event.preventDefault();
            event.stopPropagation();
          }
        }
      }
    };

    /*
     * Attach to all of key events, and the SignalEvent logic will filter
     * appropriately
     */
    inputElement.addEventListener(Event.KEYDOWN, signalEventListener, false);
    inputElement.addEventListener(Event.KEYPRESS, signalEventListener, false);
    inputElement.addEventListener(Event.KEYUP, signalEventListener, false);
    inputElement.addEventListener(Event.COPY, signalEventListener, false);
    inputElement.addEventListener(Event.PASTE, signalEventListener, false);
    inputElement.addEventListener(Event.CUT, signalEventListener, false);

    return inputElement;
  }

  @VisibleForTesting
  public void processSignalEvent(SignalEvent signalEvent) {
    boolean handled = dispatchKeyPress(signalEvent);

    if (!handled) {
      if (signalEvent.isCopyEvent() || signalEvent.isCutEvent()) {
        prepareForCopy();
        if (signalEvent.isCutEvent() && selection.hasSelection()) {
          selection.deleteSelection(editorDocumentMutator);
        }

        // These events are special cased, nothing else should happen.
        return;
      }

      /*
       * Send all keypresses through here.
       */
      try {
        handled = activeInputScheme.handleEvent(signalEvent, "");
      } catch (Throwable t) {
        Log.error(getClass(), t);
      }
    }

    if (handled) {
      // Prevent any browser handling.
      signalEvent.preventDefault();
      signalEvent.stopPropagation();
      setInputText("");
    }
  }

  public void prepareForCopy() {
    if (!selection.hasSelection()) {
      // TODO: Discuss Ctrl-X feature.
      return;
    }

    Position[] selectionRange = selection.getSelectionRange(true);
    String selectionText = LineUtils.getText(
        selectionRange[0].getLine(), selectionRange[0].getColumn(),
        selectionRange[1].getLine(), selectionRange[1].getColumn());
    setInputText(selectionText);
    inputElement.select();

    Scheduler.get().scheduleDeferred(new ScheduledCommand() {
      @Override
      public void execute() {
        /*
         * The text has been copied by now, so clear it (if the text was large,
         * it would cause slow layout)
         */
        setInputText("");
      }
    });
  }

  /**
   * Moves all selected lines down by deleting then inserting Lines
   */
  public void moveLinesDown() {
    //expand selection to fill all affected lines
    Document doc = editor.getDocument();
    LineFinder finder = doc.getLineFinder();
    Line cursorLine = selection.getCursorLine();
    
    //on no selection, select the line the cursor is on
    if (!selection.hasSelection()){
      int selectionEnd = LineUtils.getLastCursorColumn(cursorLine);
      LineInfo selectStart = finder.findLine(cursorLine);
      if (selectionEnd==0){
        //special case, the cursor is on a line without text.
        //insert a new line below cursor line,
        editor.getEditorDocumentMutator().insertText(selectStart.line().getNextLine().getNextLine(), 0, "\n");
        //delete cursor line
        TextChange change = editor.getEditorDocumentMutator().deleteText(
            cursorLine, 0, 1);
        //readjust cursor
        selectStart = finder.findLine(change.getLastLineNumber()+1);
        selection.setCursorPosition(selectStart, 0);
        return;
      }
      //make sure selection fills cursor line
      selection.setSelection(selectStart, 0, selectStart, selectionEnd);
    }
    assert selection.hasSelection() : "No text selected, cannot complete move operation";
    
    //grab the lines we need
    Position[] selectionRange = selection.getSelectionRange(true);
    LineInfo start = selectionRange[0].getLineInfo();
    LineInfo end = selectionRange[1].getLineInfo();
    
    //if the cursor ends on the first column, we need to advance our range one line
    if (selection.isCursorAtEndOfSelection()&&selection.getCursorColumn()==0){
      end.moveToNext();
    }
    int lastCol = LineUtils.getLastCursorColumn(end.line());
    
    //copy the text we want to move
    String move = LineUtils.getText(start.line(), 0,
        end.line(), lastCol);
    
    //determine size of delete required
    int deleteCount =
        LineUtils.getTextCount(start.line(), 0,
            end.line(), LineUtils.getLastCursorColumn(end.line()));

    //delete previous
    editor.getEditorDocumentMutator().deleteText(
        start.line(), start.number(),0, deleteCount);
    
    //insert new copy
    TextChange change = editor.getEditorDocumentMutator().insertText(
        start.line().getNextLine(), start.number()+1,0, move,true
    );
    
    //reselect the moved text
    end = finder.findLine(change.getLastLineNumber()+(end.number()-start.number()));
    selection.setSelection(
        new LineInfo(change.getLine(),change.getLineNumber())
        , 0
        ,end,lastCol
        );
  }

  /**
   * Moves all selected lines up by deleting then inserting Lines
   */
  public void moveLinesUp() {
    //expand selection to fill all affected lines
    Document doc = editor.getDocument();
    LineFinder finder = doc.getLineFinder();
    Line cursorLine = selection.getCursorLine();
    
    //on no selection, select the line the cursor is on
    if (!selection.hasSelection()){
      int selectionEnd = LineUtils.getLastCursorColumn(cursorLine);
      LineInfo selectStart = finder.findLine(cursorLine);
      if (selectionEnd==0){
        //special case, the cursor is on a line without text.
        //insert a new line above cursor line,
        editor.getEditorDocumentMutator().insertText(selectStart.line().getPreviousLine(), 0, "\n");
        //delete cursor line
        TextChange change = editor.getEditorDocumentMutator().deleteText(
            cursorLine, 0, 1);
        //readjust cursor
        selectStart = finder.findLine(change.getLineNumber()-2);
        selection.setCursorPosition(selectStart, 0);
        return;
      }
      //make sure selection fills cursor line
      selection.setSelection(selectStart, 0, selectStart, selectionEnd);
    }
    assert selection.hasSelection() : "No text selected, cannot complete move operation";
    
    //grab the lines we need
    Position[] selectionRange = selection.getSelectionRange(true);
    LineInfo start = selectionRange[0].getLineInfo();
    LineInfo end = selectionRange[1].getLineInfo();
    //if the cursor ends on the first column, we need to advance our range one line
    if (selection.isCursorAtEndOfSelection()&&selection.getCursorColumn()==0){
      end.moveToNext();
    }
    
    //copy the text we want to move
    int lastCol = LineUtils.getLastCursorColumn(end.line());
    String move = LineUtils.getText(start.line(), 0,
        end.line(), lastCol);
    
    //determine size of delete required
    int deleteCount =
        LineUtils.getTextCount(start.line(), 0,
            end.line(), LineUtils.getLastCursorColumn(end.line()));

    //delete previous
    editor.getEditorDocumentMutator().deleteText(
        start.line(), start.number(),0, deleteCount);
    
    //insert new copy
    TextChange change = editor.getEditorDocumentMutator().insertText(
        start.line().getPreviousLine(), start.number()-1,0, move,true
    );
    
    //reselect the moved text
    end = finder.findLine(change.getLastLineNumber()+(end.number()-start.number()));
    selection.setSelection(
        new LineInfo(change.getLine(),change.getLineNumber())
        , 0
        ,end,lastCol
        );
  }
  /**
   * Add a tab character to the beginning of each line in the current selection,
   * or at the current cursor position if no text is selected.
   */
  // TODO: This should probably be a setting, tabs or spaces
  public void handleTab() {
    if (selection.hasMultilineSelection()) {
      indentSelection();
    } else {
      getEditorDocumentMutator().insertText(selection.getCursorLine(),
          selection.getCursorLineNumber(), selection.getCursorColumn(),
          LineDimensionsUtils.getTabAsSpaces());
    }
  }

  public void indentSelection() {
    selection.adjustSelectionIndentation(
        editorDocumentMutator, LineDimensionsUtils.getTabAsSpaces(), true);
  }

  /**
   * Removes the indentation from the beginning of each line of a multiline
   * selection.
   */
  public void dedentSelection() {
    selection.adjustSelectionIndentation(
        editorDocumentMutator, LineDimensionsUtils.getTabAsSpaces(), false);
  }

  /**
   * Delete a character around the current cursor, and take care of joining lines
   * together if the delete removes a newline. This is used to implement backspace
   * and delete, depending upon the afterCursor argument.
   *
   * @param afterCursor if true, delete the character to the right of the cursor
   */
  public void deleteCharacter(boolean afterCursor) {
    if (tryDeleteSelection()) {
      return;
    }

    Line cursorLine = selection.getCursorLine();
    int cursorLineNumber = selection.getCursorLineNumber();
    int deleteColumn = !afterCursor ? selection.getCursorColumn() - 1 : selection.getCursorColumn();
    if (cursorLine.hasColumn(deleteColumn)) {
      getEditorDocumentMutator().deleteText(cursorLine, cursorLineNumber, deleteColumn, 1);
    } else if (deleteColumn < 0 && cursorLine.getPreviousLine() != null) {
      // Join the lines
      Line previousLine = cursorLine.getPreviousLine();
      getEditorDocumentMutator().deleteText(previousLine, cursorLineNumber - 1,
          previousLine.getText().length() - 1, 1);
    }
  }

  public void deleteWord(boolean afterCursor) {
    if (tryDeleteSelection()) {
      return;
    }

    Line cursorLine = selection.getCursorLine();
    int cursorColumn = selection.getCursorColumn();

    boolean mergeWithPreviousLine = cursorColumn == 0 && !afterCursor;
    boolean mergeWithNextLine = cursorColumn == cursorLine.length() - 1 && afterCursor;
    if (mergeWithPreviousLine || mergeWithNextLine) {
      // Re-use delete character logic
      deleteCharacter(afterCursor);
      return;
    }

    int otherColumn =
        afterCursor ? TextUtils.findNextWord(cursorLine.getText(), cursorColumn, true) : TextUtils
            .findPreviousWord(cursorLine.getText(), cursorColumn, false);
    editorDocumentMutator.deleteText(cursorLine, Math.min(otherColumn, cursorColumn),
        Math.abs(otherColumn - cursorColumn));
  }

  private boolean tryDeleteSelection() {
    if (selection.hasSelection()) {
      selection.deleteSelection(editorDocumentMutator);
      return true;
    } else {
      return false;
    }
  }

  ViewportModel getViewportModel() {
    return viewport;
  }

  public RootActionExecutor getActionExecutor() {
    return actionExecutor;
  }
}
