// 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 com.google.collide.client.util.SignalEventUtils;
import com.google.collide.client.util.input.CharCodeWithModifiers;
import com.google.collide.client.util.input.KeyCodeMap;

import elemental.events.Event;
import elemental.js.util.JsArrayOfInt;
import elemental.js.util.JsMapFromIntTo;

/**
 * Class that represents an editor mode for handling input, such as vi's insert
 * mode.
 *
 * <p>Each mode controls a collection of {@link Shortcut}s and fires them
 * based upon any input via {@link Shortcut#event}.
 */
public abstract class InputMode {

  /**
   * Effect of a SignalEvent.
   */
  public enum EventResult {
    /**
     * Event would directly trigger a StreamShortcut callback.
     */
    STREAM_CALLBACK,

    /**
     * Event is part of one or more StreamShortcuts, but has not been completely
     * typed.
     */
    STREAM_PART,

    /**
     * Event would directly trigger an EventShortcut callback.
     */
    EVENT_CALLBACK,

    /**
     * Event would not trigger an Event, or was part/all of StreamShortcut
     */
    NONE
  }

  /**
   * Manager of collection of shortcuts for this class and the buffer of any
   * ongoing shortcut stream of keys.
   */
  public class ShortcutController {
    /**
     * Generic Node class.
     *
     * Cannot be nested inside PartialTrie
     * (<a href="http://code.google.com/p/google-web-toolkit/issues/detail?id=5483">link</a>).
     */
    private class Node<T> {
      T value;
      JsMapFromIntTo<Node<T>> next;

      Node() {
        next = JsMapFromIntTo.create();
      }
    }

    //TODO: Move this functionality to AbstractTrie.
    /**
     * Basic trie class, supporting only put and get, plus a feedback function
     * to check if a string is along the path to a valid trie entry.
     *
     * @see #alongPath
     */
    private class PartialTrie<T> {
      private Node<T> root;

      PartialTrie() {
        root = new Node<T>();
      }

      /**
       * Inserts a new value T into the trie.
       *
       * @return T previous value at prefix, or null if there was no old entry
       */
      T put(JsArrayOfInt prefix, T value) {
        Node<T> current = root;
        for (int i = 0, n = prefix.length(); i < n; i++) {
          int index = prefix.get(i);
          Node<T> next = current.next.get(index);
          if (next == null) {
            // this branch doesn't exist yet
            next = new Node<T>();
            current.next.put(index, next);
            current = next;
          }
        }
        T old = current.value;
        current.value = value;
        return old;
      }

      /**
       * Returns 0 if seq is along the path to one or more entries.
       *
       * <p>For example, nearestValue("app") would return 0 for a trie with
       * values "apples", "apple", "orange". "apple" has 0 characters to get
       * to "apple".
       *
       * @return {@code 1} for direct match, {@code 0} for path match,
       *         {@code -1} for no match
       */
      int alongPath(JsArrayOfInt seq) {
        Node<T> current = root;

        for (int i = 0, n = seq.length(); i < n; i++) {
          int index = seq.get(i);
          current = current.next.get(index);

          if (current == null) {
            return -1; // off the end of the trie, no match
          }
        }

        // If we get here, current is along the path to one or more valid
        // entries
        if (current.value != null) {
          return 1;
        } else {
          return 0;
        }
      }

      /**
       * Returns the value T stored at exactly this location in the trie.
       *
       * @return T
       */
      T get(JsArrayOfInt seq) {
        Node<T> current = root;
        for (int i = 0, n = seq.length(); i < n; i++) {
          int index = seq.get(i);
          current = current.next.get(index);

          if (current == null) {
            return null;
          }
        }

        return current.value;
      }
    }

    /**
     * Buffer of the current input stream building to a shortcut.
     *
     * <p>String is represented by an array of UTF-16 integers.
     *
     * <p>This will be appended to when the input matches a prefix of one
     * or more {@link StreamShortcut}s. Used to match against a PrefixTrie.
     */
    JsArrayOfInt streamBuffer;

    PartialTrie<StreamShortcut> streamTrie;

    /**
     * A map of {@link EventShortcut}s from event hash to shortcut object.
     */
    JsMapFromIntTo<EventShortcut> eventShortcuts;


    public ShortcutController() {
      eventShortcuts = JsMapFromIntTo.create();
      streamTrie = new PartialTrie<StreamShortcut>();
      streamBuffer = JsArrayOfInt.create();
    }

    /**
     * Adds an event shortcut to the event shortcut map.
     */
    public void addShortcut(EventShortcut event) {
      eventShortcuts.put(event.getKeyDigest(), event);
    }

    /**
     * Adds a stream shortcut to the stream shortcut trie.
     */
    public void addShortcut(StreamShortcut stream) {
      streamTrie.put(stream.getActivationStream(), stream);
    }

    /**
     * Clears any internal state (streamBuffer value),
     * after shortcut was triggered.
     */
    public void reset() {
      streamBuffer.setLength(0);
    }

    /**
     * Returns the shortcut associated with this event.
     */
    private Shortcut findEventShortcut(SignalEvent event) {
      int keyDigest = CharCodeWithModifiers.computeKeyDigest(event);
      if (eventShortcuts.hasKey(keyDigest)) {
        return eventShortcuts.get(keyDigest);
      } else {
        return null;
      }
    }

    /**
     * Searches the trie using streamBuffer for an exact
     * {@link StreamShortcut} match.
     *
     * @return {@link StreamShortcut} if found, else {@code null}
     */
    private Shortcut findStreamShortcut() {
      return streamTrie.get(streamBuffer);
    }

    /**
     * Adds the keycode of the event to the end of the stream buffer.
     */
    private void addToStreamBuffer(SignalEvent event) {
      streamBuffer.push(KeyCodeMap.getKeyFromEvent(event));
    }

    /**
     * Deletes the last character from the stream buffer (backspace).
     */
    public void deleteLastCharFromStreamBuffer() {
      streamBuffer.setLength(streamBuffer.length() - 1);
    }

    /**
     * Tests if the event should be "captured".
     *
     * <p>Event should be captured if it either:<ul>
     * <li>directly fires a callback or
     * <li>is part of a StreamCallback that hasn't been fully typed yet
     * </ul>
     *
     * @return {@link EventResult} that this event would cause
     */
    public EventResult testEventEffect(SignalEvent event) {
      // Letters above U+FFFF will wrap around, so they aren't supported in
      // shortcuts.
      if (event.getKeyCode() > 0xFFFF) {
        return EventResult.NONE;
      }

      // Try EventShortcut.
      int keyDigest = CharCodeWithModifiers.computeKeyDigest(event);
      if (eventShortcuts.hasKey(keyDigest)) {
        return EventResult.EVENT_CALLBACK;
      }

      // Then try StreamShortcut.
      addToStreamBuffer(event);
      int streamResult = streamTrie.alongPath(streamBuffer);
      // Take off event keycode - it was added only to search trie.
      deleteLastCharFromStreamBuffer();

      if (streamResult == 1) {
        // Exact match.
        return EventResult.STREAM_CALLBACK;
      } else if (streamResult == 0) {
        // Partial match.
        return EventResult.STREAM_PART;
      }

      // No effect.
      return EventResult.NONE;
    }
  }

  private ShortcutController shortcutController;

  private InputScheme scheme = null;

  public InputMode() {
    shortcutController = new ShortcutController();
  }

  /**
   * Preforms mode-specific setup (such as adding a new overlay to
   * display the current search term as the user types).
   */
  public abstract void setup();

  /**
   * Removes document changes made in {@link InputMode#setup()}.
   */
  public abstract void teardown();

  /**
   * Implements default behavior when no shortcut matches the input event.
   *
   * <p>Include the text captured from the hidden input field.
   *
   * @param character - 0 for no printable character
   * @return {@code true} to prevent default action in browser
   */
  public abstract boolean onDefaultInput(SignalEvent signal, char character);

  /**
   * Takes action after user has inserted more than one character of text.
   *
   * @param text - more than one character
   * @return boolean True to prevent default action in browser
   */
  public abstract boolean onDefaultPaste(SignalEvent signal, String text);

  void setScheme(InputScheme scheme) {
    this.scheme = scheme;
  }

  public InputScheme getScheme() {
    return this.scheme;
  }

  /**
   * Binds specified key to named action.
   */
  public void bindAction(String actionName, int modifiers, int charCode) {
    shortcutController.addShortcut(new ActionShortcut(modifiers, charCode, actionName));
  }

  /**
   * Adds this event shortcut to the shortcut controller.
   */
  public void addShortcut(EventShortcut shortcut) {
    shortcutController.addShortcut(shortcut);
  }

  public void addShortcut(StreamShortcut shortcut) {
    shortcutController.addShortcut(shortcut);
  }

  /**
   * Checks if this event should fire any shortcuts.
   *
   * <p>There is not matching events, fires the defaultInput function.
   *
   * @return {@code true} if default browser behavior should be prevented
   */
  public boolean handleEvent(SignalEvent event, String text) {
    if (event.isPasteEvent()) {
      String pasteContents = SignalEventUtils.getPasteContents((Event) event.asEvent());
      if (pasteContents != null) {
        return onDefaultPaste(event, pasteContents);
      }
    }

    // If one character was entered, send it through the shortcut system, else
    // think of it as a paste.
    if (text.length() > 1) {
      return onDefaultPaste(event, text);
    } else {
      Shortcut eventShortcut = null;
      EventResult result = shortcutController.testEventEffect(event);
      if (result == EventResult.EVENT_CALLBACK) {
        eventShortcut = shortcutController.findEventShortcut(event);
      }

      if (result == EventResult.NONE) {
        shortcutController.reset();
        char character = 0;
        if (text.length() == 1) {
          character = text.charAt(0);
        }
        return onDefaultInput(event, character);
      }

      if (result == EventResult.STREAM_CALLBACK || result == EventResult.STREAM_PART) {
        // Always add to the buffer for either of these.
        shortcutController.addToStreamBuffer(event);

        if (result == EventResult.STREAM_CALLBACK) {
          eventShortcut = shortcutController.findStreamShortcut();
        } else {
          // STREAM_PART
          return true; // Always prevent default when adding to stream buffer.
        }
      }

      // Tell the shortcut controller that a shortcut was fired so it can reset
      // state.
      shortcutController.reset();
      boolean returnValue = eventShortcut.event(this.scheme, event);

      // Only fire if the event is blocked.
      if (returnValue) {
        this.scheme.handleShortcutCalled();
      }
      return returnValue;
    }
  }
}
