/*
 * 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.eclipse.che.ide.util.input;

import com.google.gwt.dom.client.Element;
import com.google.gwt.user.client.Event;
import java.util.HashSet;
import java.util.Set;
import org.eclipse.che.ide.collections.Jso;
import org.eclipse.che.ide.util.browser.QuirksConstants;
import org.eclipse.che.ide.util.browser.UserAgent;

/**
 * Attempts to bring sanity to the incredibly complex and inconsistent world of browser events,
 * especially with regards to key events.
 *
 * <p>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.
 *
 * <p>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).
 *
 * <p>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!
 *
 * <p>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 SignalKeyLogic.UserAgentType currentUserAgent =
      (UserAgent.isWebkit()
          ? SignalKeyLogic.UserAgentType.WEBKIT
          : (UserAgent.isFirefox()
              ? SignalKeyLogic.UserAgentType.GECKO
              : SignalKeyLogic.UserAgentType.IE));

  private static final SignalKeyLogic.OperatingSystem currentOs =
      (UserAgent.isWin()
          ? SignalKeyLogic.OperatingSystem.WINDOWS
          : (UserAgent.isMac()
              ? SignalKeyLogic.OperatingSystem.MAC
              : SignalKeyLogic.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 Set<String> KEY_EVENTS = new HashSet<>();

  private static final Set<String> COMPOSITION_EVENTS = new HashSet<>();

  private static final Set<String> MOUSE_EVENTS = new HashSet<>();

  private static final Set<String> MOUSE_BUTTON_EVENTS = new HashSet<>();

  private static final Set<String> MOUSE_BUTTONLESS_EVENTS = new HashSet<>();

  private static final Set<String> FOCUS_EVENTS = new HashSet<>();

  private static final Set<String> CLIPBOARD_EVENTS = new HashSet<>();

  /**
   * Events affected by {@link
   * QuirksConstants#CANCEL_BUBBLING_CANCELS_IME_COMPOSITION_AND_CONTEXTMENU}.
   */
  private static final Set<String> CANCEL_BUBBLE_QUIRKS = new HashSet<>();

  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.
   *
   * <p>If the event is to be filtered, null is returned, and bubbling is cancelled if
   * cancelBubbleIfNullified is true. (but the default is not prevented).
   *
   * <p>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
   *
   * <p>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.<Jso>cast().getJavaObjectField(EVENT_PROP);
  }

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

    T signal = factory.create();
    event.<Jso>cast().addField(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
    }-*/;

  /** @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 SignalEvent.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();
    }
  }
}
