/**
 * Copyright 2008 Google Inc.
 *
 * 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 org.waveprotocol.wave.client.common.util;

import org.waveprotocol.wave.client.common.util.SignalKeyLogic.OperatingSystem;
import org.waveprotocol.wave.client.common.util.SignalKeyLogic.UserAgentType;
import org.waveprotocol.wave.model.util.CollectionUtils;
import org.waveprotocol.wave.model.util.StringSet;

import com.google.gwt.dom.client.Element;
import com.google.gwt.user.client.Event;

/**
 * Attempts to bring sanity to the incredibly complex and inconsistent world of
 * browser events, especially with regards to key events.
 *
 * A new concept of the "signal" is introduced. A signal is basically an event,
 * but an event that we actually care about, with the information we care about.
 * Redundant events are merged into a single signal. For key events, a signal
 * corresponds to the key-repeat signal we get from the keyboard. For normal
 * typing input, this will always be the keypress event. For other types of key
 * events, it depends on the browser. For clipboard events, the "beforeXYZ" and
 * "XYZ" events are merged into a single one, the one that actually happens
 * right before the action (browser dependent). Key events are also classified
 * into subtypes identified by KeySignalType. This reflects the intended usage
 * of the event, not something to do with the event data itself.
 *
 * Currently the "filtering" needs to be done manually - simply construct a
 * signal from an event using {@link #create(Event, boolean)}, and if it returns null,
 * drop the event and do nothing with it (cancelling bubbling might be a good
 * idea though).
 *
 * NOTE(danilatos): getting the physical key pressed, even on a key down, is
 * inherently not possible without a big lookup table, because of international
 * input methods. e.g. press 'b' but in greek mode on safari on osx. nothing in
 * any of the events you receive will tell you it was a 'b', instead, you'll get
 * a beta for the keypress and 0 (zero) for the keydown. mmm, useful!
 *
 * TODO(danilatos): Hook this into the application's event plumbing in a more
 * invasive manner.
 *
 * @author danilatos@google.com (Daniel Danilatos)
 */
public class SignalEventImpl implements SignalEvent {

  public interface SignalEventFactory<T extends SignalEventImpl> {
    T create();
  }

  public static SignalEventFactory<SignalEventImpl> DEFAULT_FACTORY =
    new SignalEventFactory<SignalEventImpl>() {
      @Override public SignalEventImpl create() {
        return new SignalEventImpl();
      }
    };

  interface NativeEvent {
    String getType();
    int getButton();
    boolean getCtrlKey();
    boolean getMetaKey();
    boolean getAltKey();
    boolean getShiftKey();
    void preventDefault();
    void stopPropagation();
  }

  /**
   * @param event
   * @return True if the given event is a key event
   */
  public static boolean isKeyEvent(Event event) {
    return KEY_EVENTS.contains(event.getType());
  }

  private static final UserAgentType currentUserAgent =
      (UserAgent.isWebkit() ? UserAgentType.WEBKIT : (
          UserAgent.isFirefox() ? UserAgentType.GECKO : UserAgentType.IE));
  private static final OperatingSystem currentOs =
      (UserAgent.isWin() ? OperatingSystem.WINDOWS : (
          UserAgent.isMac() ? OperatingSystem.MAC : OperatingSystem.LINUX));
  private static final SignalKeyLogic logic = new SignalKeyLogic(
      currentUserAgent, currentOs, QuirksConstants.COMMAND_COMBO_DOESNT_GIVE_KEYPRESS);

  /**
   * This variable will be filled with mappings of unshifted keys to their shifted versions.
   */
  private static final int[] shiftMappings = new int[128];
  static {
    for (int a = 'A'; a <= 'Z'; a++) {
      shiftMappings[a] = a + 'a' - 'A';
    }
    // TODO(danilatos): Who knows what these mappings should be on other
    // keyboard layouts... e.g. pound signs? euros? etc? argh!
    shiftMappings['1'] = '!';
    shiftMappings['2'] = '@';
    shiftMappings['3'] = '#';
    shiftMappings['4'] = '$';
    shiftMappings['5'] = '%';
    shiftMappings['6'] = '^';
    shiftMappings['7'] = '&';
    shiftMappings['8'] = '*';
    shiftMappings['9'] = '(';
    shiftMappings['0'] = ')';
    shiftMappings['`'] = '~';
    shiftMappings['-'] = '_';
    shiftMappings['='] = '+';
    shiftMappings['['] = '{';
    shiftMappings[']'] = '}';
    shiftMappings['\\'] = '|';
    shiftMappings[';'] = ':';
    shiftMappings['\''] = '"';
    shiftMappings[','] = '<';
    shiftMappings['.'] = '>';
    shiftMappings['/'] = '?';
    // invalidate the inverse mappings
    for (int i = 1; i < shiftMappings.length; i++) {
      int m = shiftMappings[i];
      if (m > 0) {
        shiftMappings[m] = i;
      }
    }
  }

  private static final StringSet KEY_EVENTS = CollectionUtils.createStringSet();
  private static final StringSet COMPOSITION_EVENTS = CollectionUtils.createStringSet();
  private static final StringSet MOUSE_EVENTS = CollectionUtils.createStringSet();
  private static final StringSet MOUSE_BUTTON_EVENTS = CollectionUtils.createStringSet();
  private static final StringSet MOUSE_BUTTONLESS_EVENTS = CollectionUtils.createStringSet();
  private static final StringSet FOCUS_EVENTS = CollectionUtils.createStringSet();
  private static final StringSet CLIPBOARD_EVENTS = CollectionUtils.createStringSet();

  /**
   * Events affected by
   * {@link QuirksConstants#CANCEL_BUBBLING_CANCELS_IME_COMPOSITION_AND_CONTEXTMENU}.
   */
  private static final StringSet CANCEL_BUBBLE_QUIRKS = CollectionUtils.createStringSet();

  static {
    for (String e : new String[]{"keydown", "keypress", "keyup"}) {
      KEY_EVENTS.add(e);
    }
    for (String e : new String[]{
        "compositionstart", "compositionend", "compositionupdate", "text"}) {
      COMPOSITION_EVENTS.add(e);
      CANCEL_BUBBLE_QUIRKS.add(e);
    }
    COMPOSITION_EVENTS.add("textInput");
    CANCEL_BUBBLE_QUIRKS.add("contextmenu");
    for (String e : new String[]{
        "mousewheel", "DOMMouseScroll", "mousemove", "mouseover", "mouseout",
        /* not strictly a mouse event*/ "contextmenu"}) {
      MOUSE_BUTTONLESS_EVENTS.add(e);
      MOUSE_EVENTS.add(e);
    }
    for (String e : new String[]{
        "mousedown", "mouseup", "click", "dblclick"}) {
      MOUSE_BUTTON_EVENTS.add(e);
      MOUSE_EVENTS.add(e);
    }
    for (String e : new String[]{"focus", "blur", "beforeeditfocus"}) {
      FOCUS_EVENTS.add(e);
    }
    for (String e : new String[]{"cut", "copy", "paste"}) {
      CLIPBOARD_EVENTS.add(e);
      CLIPBOARD_EVENTS.add("before" + e);
    }
  }

  protected NativeEvent nativeEvent;
  private KeySignalType keySignalType = null;
  private int cachedKeyCode = -1;
  private boolean hasBeenConsumed = false;

  protected SignalEventImpl() {
  }

  static class JsoNativeEvent extends Event implements NativeEvent {
    protected JsoNativeEvent() {}
  }

  /**
   * Create a signal from an event, possibly filtering the event
   * if it is deemed redundant.
   *
   * If the event is to be filtered, null is returned, and bubbling
   * is cancelled if cancelBubbleIfNullified is true.
   * (but the default is not prevented).
   *
   * NOTE(danilatos): So far, for key events, the following have been tested:
   * - Safari 3.1 OS/X (incl. num pad, with USB keyboard)
   * - Safari 3.0 OS/X, hosted mode only (so no ctrl+c, etc)
   * - Firefox 3, OS/X, WinXP
   * - IE7, WinXP
   * Needs testing:
   * - FF3 linux, Safari 3.0/3.1 Windows
   * - All kinds of weirdo keyboards (mac, international)
   * - Linux IME
   *
   * Currently, only key events have serious logic applied to them.
   * Maybe some logic for copy/paste, and mouse events?
   *
   * @param event Raw Event JSO
   * @param cancelBubbleIfNullified stops propagation if the event is nullified
   * @return SignalEvent mapping, or null, if the event is to be discarded
   */
  public static SignalEventImpl create(Event event, boolean cancelBubbleIfNullified) {
    return create(DEFAULT_FACTORY, event, cancelBubbleIfNullified);
  }

  public static <T extends SignalEventImpl> T create(SignalEventFactory<T> factory,
      Event event, boolean cancelBubbleIfNullified) {
    if (hasBeenConsumed(event)) {
      return null;
    } else {
      T signal = createInner(factory, event);
      if (cancelBubbleIfNullified && signal == null) {
        event.stopPropagation();
      }
      return signal;
    }
  }

  private static boolean hasBeenConsumed(Event event) {
    SignalEventImpl existing = getFor(null, event);
    return existing != null && existing.hasBeenConsumed();
  }

  private static final String EVENT_PROP = "$se";
  @SuppressWarnings("unchecked")
  private static <T extends SignalEventImpl> T getFor(SignalEventFactory<T> factory, Event event) {
    return (T) (SignalEventImpl) event.<JsoView>cast().getObject(EVENT_PROP);
  }

  private static <T extends SignalEventImpl> T createFor(
      SignalEventFactory<T> factory, Event event) {

    T signal = factory.create();
    event.<JsoView>cast().setObject(EVENT_PROP, signal);
    return signal;
  }

  /** This would be a static local variable if java allowed it. Grouping it here. */
  private static final SignalKeyLogic.Result computeKeySignalTypeResult =
      new SignalKeyLogic.Result();

  private static <T extends SignalEventImpl> T createInner(
      SignalEventFactory<T> factory, Event event) {

    SignalKeyLogic.Result keySignalResult;
    if (isKeyEvent(event)) {
      keySignalResult = computeKeySignalTypeResult;

      String keyIdentifier = getKeyIdentifier(event);

      logic.computeKeySignalType(keySignalResult,
          event.getType(), getNativeKeyCode(event), getWhich(event), keyIdentifier,
          event.getMetaKey(), event.getCtrlKey(), event.getAltKey(), event.getShiftKey());

    } else {
      keySignalResult = null;
    }

    return createInner(createFor(factory, event), event.<JsoNativeEvent>cast(), keySignalResult);
  }

  /**
   * Populate a SignalEventImpl with the necessary information
   *
   * @param ret
   * @param keySignalResult only required if it's a key event
   * @return the signal, or null if it is to be ignored.
   */
  protected static <T extends SignalEventImpl> T createInner(T ret,
      NativeEvent event, SignalKeyLogic.Result keySignalResult) {
    ret.nativeEvent = event;
    if (ret.isKeyEvent()) {
      KeySignalType type = keySignalResult.type;

      if (type != null) {
        ret.cacheKeyCode(keySignalResult.keyCode);
        ret.setup(type);
      } else {
        ret = null;
      }

    } else if ((UserAgent.isIE() ? "paste" : "beforepaste").equals(event.getType())) {
      // Only want 'beforepaste' for ie and 'paste' for everything else.
      // TODO(danilatos): Generalise clipboard events
      ret = null;
    }

    // TODO: return null if it's something we should ignore.
    return ret;
  }

  public static native int getNativeKeyCode(Event event) /*-{
    return event.keyCode || 0;
  }-*/;

  public static native int getWhich(Event event) /*-{
    return event.which || 0;
  }-*/;

  public static native String getKeyIdentifier(Event event) /*-{
    return event.key || event.keyIdentifier
  }-*/;

  /**
   * @return Event type as a string, e.g. "keypress"
   */
  public final String getType() {
    return nativeEvent.getType();
  }

  /**
   * @return The target element of the event
   */
  public Element getTarget() {
    return asEvent().getTarget();
  }

  /**
   * @return true if the event is a key event
   * TODO(danilatos): Have a top level EventSignalType enum
   */
  public final boolean isKeyEvent() {
    return KEY_EVENTS.contains(nativeEvent.getType());
  }

  /**
   * @return true if it is an IME composition event
   */
  public final boolean isCompositionEvent() {
    return COMPOSITION_EVENTS.contains(getType());
  }

  /**
   * Returns true if the key event is an IME input event.
   * Only makes sense to call this method if this is a key signal.
   * Does not work on FF. (TODO(danilatos): Can it be done? Tricks
   * with dom mutation events?)
   *
   * @return true if this is an IME input event
   */
  public final boolean isImeKeyEvent() {
    return getKeyCode() == SignalKeyLogic.IME_CODE;
  }

  /**
   * @return true if this is a mouse event
   * TODO(danilatos): Have a top level EventSignalType enum
   */
  public final boolean isMouseEvent() {
    return MOUSE_EVENTS.contains(getType());
  }

  /**
   * TODO(danilatos): Click + drag? I.e. return true for mouse move, if the
   * button is pressed? (this might be useful for tracking changing selections
   * as the user holds & drags)
   * @return true if this is an event involving some use of mouse buttons
   */
  public final boolean isMouseButtonEvent() {
    return MOUSE_BUTTON_EVENTS.contains(getType());
  }

  /**
   * @return true if this is a mouse event but not {@link #isMouseButtonEvent()}
   */
  public final boolean isMouseButtonlessEvent() {
    return MOUSE_BUTTONLESS_EVENTS.contains(getType());
  }

  /**
   * @return true if this is a "click" event
   */
  public final boolean isClickEvent() {
    return "click".equals(getType());
  }

  /**
   * @return True if this is a dom mutation event
   */
  public final boolean isMutationEvent() {
    // What about DOMMouseScroll?
    return getType().startsWith("DOM");
  }

  /**
   * @return true if this is any sort of clipboard event
   */
  public final boolean isClipboardEvent() {
    return CLIPBOARD_EVENTS.contains(getType());
  }

  /**
   * @return If this is a focus event
   */
  public final boolean isFocusEvent() {
    return FOCUS_EVENTS.contains(getType());
  }

  /**
   * @return true if this is a paste event
   * TODO(danilatos): Make a ClipboardSignalType enum instead
   */
  public final boolean isPasteEvent() {
    return (UserAgent.isIE() ? "beforepaste" : "paste").equals(nativeEvent.getType());
  }

  /**
   * @return true if this is a cut event
   * TODO(danilatos): Make a ClipboardSignalType enum instead
   */
  public final boolean isCutEvent() {
    return (UserAgent.isIE() ? "beforecut" : "cut").equals(nativeEvent.getType());
  }

  /**
   * @return true if this is a copy event
   * TODO(danilatos): Make a ClipboardSignalType enum instead
   */
  public final boolean isCopyEvent() {
    return "copy".equals(nativeEvent.getType());
  }

  /**
   * @return true if the command key is depressed
   * @see SignalKeyLogic#commandIsCtrl()
   */
  public final boolean getCommandKey() {
    return logic.commandIsCtrl() ? getCtrlKey() : getMetaKey();
  }

  public static boolean getCommandKey(com.google.gwt.dom.client.NativeEvent event) {
    return logic.commandIsCtrl() ? event.getCtrlKey() : event.getMetaKey();
  }

  /**
   * @return true if the ctrl key is depressed
   */
  public final boolean getCtrlKey() {
    return nativeEvent.getCtrlKey();
  }

  /**
   * @return true if the meta key is depressed
   */
  public final boolean getMetaKey() {
    return nativeEvent.getMetaKey();
  }

  /**
   * @return true if the alt key is depressed
   */
  public final boolean getAltKey() {
    // TODO(danilatos): Handle Alt vs Option on OSX?
    return nativeEvent.getAltKey();
  }

  /**
   * @return true if the shift key is depressed
   */
  public final boolean getShiftKey() {
    return nativeEvent.getShiftKey();
  }

  /**
   * @return The underlying event view of this event
   */
  public final Event asEvent() {
    return (Event) nativeEvent;
  }

  /**
   * Only valid for key events.
   * Currently only implemented for deleting, not actual navigating.
   * @return The move unit of this event
   */
  public final MoveUnit getMoveUnit() {
    if (getKeySignalType() == KeySignalType.DELETE) {
      if (UserAgent.isMac()) {
        if (getAltKey()) {
          // Note: in practice, some combinations of bkspc/delete + modifier key
          // have no effect. This is inconsistent across browsers. It's probably
          // ok to normalise it here, as we will be manually implementing everything
          // except character-sized deletes on collapsed selections, and so users
          // would get a more consistent (and logical and symmetrical) experience.
          return MoveUnit.WORD;
        } else if (getCommandKey()) {
          return MoveUnit.LINE;
        } else {
          return MoveUnit.CHARACTER;
        }
      } else {
        if (getCommandKey()) {
          return MoveUnit.WORD;
        } else {
          return MoveUnit.CHARACTER;
        }
      }
    } else {
      // TODO(danilatos): Also implement for mere navigation events?
      // Currently just for deleting... so we'll at least for now just pretend
      // everything else is of character magnitude. This is because we
      // probably won't be using the information anyway, instead letting
      // the browser just do its default navigation behaviour.

      return MoveUnit.CHARACTER;
    }
  }

  @Override
  public final boolean isUndoCombo() {
    return isCombo('Z', KeyModifier.COMMAND);
  }

  @Override
  public final boolean isRedoCombo() {
    if ((UserAgent.isMac() || UserAgent.isLinux()) &&
        isCombo('Z', KeyModifier.COMMAND_SHIFT)) {
      // Mac and Linux accept command-shift-z for undo
      return true;
    }
    // NOTE(user): COMMAND + Y for redo, except for Mac OS X (for chrome,
    // default behaviour is browser history)
    return !UserAgent.isMac() && isCombo('Y', KeyModifier.COMMAND);
  }

  /**
   * Because we must use keypress events for FF, in order to get repeats,
   * but prefer keydowns for combo type events for the other browsers,
   * we need to convert the case here.
   *
   * @param letter
   */
  private final int comboInputKeyCode(char letter) {
    // TODO(danilatos): Check the compiled javascript to make sure it does simple
    // numerical operations and not string manipulations and conversions... char is
    // used all over this file
    return UserAgent.isFirefox()
      ? letter + 'a' - 'A'
      : letter;
  }

  /**
   * @param letter Treated case-insensitive, including things like '1' vs '!'
   *    User may provide either, but upper case for letters and unshifted for
   *    other keys is recommended
   * @param modifier
   * @return True if the given letter is pressed, and only the given modifiers.
   */
  public final boolean isCombo(int letter, KeyModifier modifier) {
    assert letter > 0 && letter < shiftMappings.length;
    int keyCode = getKeyCode();
    if (keyCode >= shiftMappings.length) {
      return false;
    }

    return (letter == keyCode || letter == shiftMappings[keyCode]) && modifier.check(this);
  }

  /**
   * @param letter
   * @return true, if the given letter was pressed without modifiers. Takes into
   *         account the caps lock key being pressed (it will be as if it
   *         weren't pressed)
   */
  public final boolean isOnly(int letter) {
    return isCombo(letter, KeyModifier.NONE);
  }

  @Override
  public final int getMouseButton() {
    return nativeEvent.getButton();
  }

  /**
   * @return The key signal type of this even, or null if it is not a key event
   * @see KeySignalType
   */
  public KeySignalType getKeySignalType() {
    return this.keySignalType;
  }

  /**
   * @return The gwtKeyCode of this event, with some minor compatibility
   *         adjustments
   */
  public int getKeyCode() {
    return this.cachedKeyCode;
  }

  /**
   * Returns true if the event has effectively had its propagation stopped, since
   * we couldn't physically stop it due to browser quirkiness.  See {@link #stopPropagation()}.
   */
  private boolean hasBeenConsumed() {
    return hasBeenConsumed;
  }

  private void markAsConsumed() {
    hasBeenConsumed = true;
  }

  protected void cacheKeyCode(int keyCode) {
    this.cachedKeyCode = keyCode;
  }

  private boolean stopPropagationPreventsDefault() {
    if (QuirksConstants.CANCEL_BUBBLING_CANCELS_IME_COMPOSITION_AND_CONTEXTMENU) {
      return CANCEL_BUBBLE_QUIRKS.contains(getType());
    } else {
      return false;
    }
  }

  private boolean isPreventDefaultEffective() {
    if (QuirksConstants.PREVENT_DEFAULT_STOPS_CONTEXTMENT) {
      return true;
    } else {
      String type = nativeEvent.getType();
      return !type.equals("contextmenu");
    }
  }

  @Override
  public final void stopPropagation() {
    if (stopPropagationPreventsDefault()) {
      markAsConsumed();
    } else {
      nativeEvent.stopPropagation();
    }
  }

  protected final void setup(KeySignalType signalType) {
    this.keySignalType = signalType;
  }

  @Override
  public final void preventDefault() {
    nativeEvent.preventDefault();
    if (!isPreventDefaultEffective()) {
      // HACK(user): Really we would like the event to continue to propagate
      //   and stop it immediately before reaching the top, rather than at this
      //   point.
      nativeEvent.stopPropagation();
    }
  }
}
