/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.geckoview;

import android.content.Context;
import android.graphics.RectF;
import android.os.Handler;
import android.text.Editable;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.CursorAnchorInfo;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
import androidx.annotation.AnyThread;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import org.mozilla.gecko.IGeckoEditableParent;
import org.mozilla.gecko.InputMethods;
import org.mozilla.gecko.NativeQueue;
import org.mozilla.gecko.annotation.WrapForJNI;
import org.mozilla.gecko.util.ThreadUtils;

/**
 * {@code SessionTextInput} handles text input for {@code GeckoSession} through key events or input
 * methods. It is typically used to implement certain methods in {@link android.view.View} such as
 * {@link android.view.View#onCreateInputConnection}, by forwarding such calls to corresponding
 * methods in {@code SessionTextInput}.
 *
 * <p>For full functionality, {@code SessionTextInput} requires a {@link android.view.View} to be
 * set first through {@link #setView}. When a {@link android.view.View} is not set or set to null,
 * {@code SessionTextInput} will operate in a reduced functionality mode. See {@link
 * #onCreateInputConnection} and methods in {@link GeckoSession.TextInputDelegate} for changes in
 * behavior in this viewless mode.
 */
public final class SessionTextInput {
  /* package */ static final String LOGTAG = "GeckoSessionTextInput";
  private static final boolean DEBUG = false;

  // Interface to access GeckoInputConnection from SessionTextInput.
  /* package */ interface InputConnectionClient {
    View getView();

    Handler getHandler(Handler defHandler);

    InputConnection onCreateInputConnection(EditorInfo attrs);
  }

  // Interface to access GeckoEditable from GeckoInputConnection.
  /* package */ interface EditableClient {
    // The following value is used by requestCursorUpdates
    // ONE_SHOT calls updateCompositionRects() after getting current composing
    // character rects.
    @Retention(RetentionPolicy.SOURCE)
    @IntDef({ONE_SHOT, START_MONITOR, END_MONITOR})
    /* package */ @interface CursorMonitorMode {}

    @WrapForJNI int ONE_SHOT = 1;
    // START_MONITOR start the monitor for composing character rects.  If is is
    // updaed,  call updateCompositionRects()
    @WrapForJNI int START_MONITOR = 2;
    // ENDT_MONITOR stops the monitor for composing character rects.
    @WrapForJNI int END_MONITOR = 3;

    void sendKeyEvent(@Nullable View view, int action, @NonNull KeyEvent event);

    Editable getEditable();

    void setBatchMode(boolean isBatchMode);

    Handler setInputConnectionHandler(@NonNull Handler handler);

    void postToInputConnection(@NonNull Runnable runnable);

    void requestCursorUpdates(@CursorMonitorMode int requestMode);

    void insertImage(@NonNull byte[] data, @NonNull String mimeType);
  }

  // Interface to access GeckoInputConnection from GeckoEditable.
  /* package */ interface EditableListener {
    // IME notification type for notifyIME(), corresponding to NotificationToIME enum.
    @Retention(RetentionPolicy.SOURCE)
    @IntDef({
      NOTIFY_IME_OF_TOKEN,
      NOTIFY_IME_OPEN_VKB,
      NOTIFY_IME_REPLY_EVENT,
      NOTIFY_IME_OF_FOCUS,
      NOTIFY_IME_OF_BLUR,
      NOTIFY_IME_TO_COMMIT_COMPOSITION,
      NOTIFY_IME_TO_CANCEL_COMPOSITION
    })
    /* package */ @interface IMENotificationType {}

    @WrapForJNI int NOTIFY_IME_OF_TOKEN = -3;
    @WrapForJNI int NOTIFY_IME_OPEN_VKB = -2;
    @WrapForJNI int NOTIFY_IME_REPLY_EVENT = -1;
    @WrapForJNI int NOTIFY_IME_OF_FOCUS = 1;
    @WrapForJNI int NOTIFY_IME_OF_BLUR = 2;
    @WrapForJNI int NOTIFY_IME_TO_COMMIT_COMPOSITION = 8;
    @WrapForJNI int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9;

    // IME enabled state for notifyIMEContext().
    @Retention(RetentionPolicy.SOURCE)
    @IntDef({IME_STATE_UNKNOWN, IME_STATE_DISABLED, IME_STATE_ENABLED, IME_STATE_PASSWORD})
    /* package */ @interface IMEState {}

    int IME_STATE_UNKNOWN = -1;
    int IME_STATE_DISABLED = 0;
    int IME_STATE_ENABLED = 1;
    int IME_STATE_PASSWORD = 2;

    // Flags for notifyIMEContext().
    @Retention(RetentionPolicy.SOURCE)
    @IntDef(
        flag = true,
        value = {IME_FLAG_PRIVATE_BROWSING, IME_FLAG_USER_ACTION, IME_FOCUS_NOT_CHANGED})
    /* package */ @interface IMEContextFlags {}

    @WrapForJNI int IME_FLAG_PRIVATE_BROWSING = 1 << 0;
    @WrapForJNI int IME_FLAG_USER_ACTION = 1 << 1;
    @WrapForJNI int IME_FOCUS_NOT_CHANGED = 1 << 2;

    void notifyIME(@IMENotificationType int type);

    void notifyIMEContext(
        @IMEState int state,
        String typeHint,
        String modeHint,
        String actionHint,
        @IMEContextFlags int flag);

    void onSelectionChange();

    void onTextChange();

    void onDiscardComposition();

    void onDefaultKeyEvent(KeyEvent event);

    void updateCompositionRects(final RectF[] aRects, final RectF aCaretRect);
  }

  private static final class DefaultDelegate implements GeckoSession.TextInputDelegate {
    public static final DefaultDelegate INSTANCE = new DefaultDelegate();

    private InputMethodManager getInputMethodManager(@Nullable final View view) {
      if (view == null) {
        return null;
      }
      return (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
    }

    @Override
    public void restartInput(@NonNull final GeckoSession session, final int reason) {
      ThreadUtils.assertOnUiThread();
      final View view = session.getTextInput().getView();

      final InputMethodManager imm = getInputMethodManager(view);
      if (imm == null) {
        return;
      }

      // InputMethodManager has internal logic to detect if we are restarting input
      // in an already focused View, which is the case here because all content text
      // fields are inside one LayerView. When this happens, InputMethodManager will
      // tell the input method to soft reset instead of hard reset. Stock latin IME
      // on Android 4.2+ has a quirk that when it soft resets, it does not clear the
      // composition. The following workaround tricks the IME into clearing the
      // composition when soft resetting.
      if (InputMethods.needsSoftResetWorkaround(
          InputMethods.getCurrentInputMethod(view.getContext()))) {
        // Fake a selection change, because the IME clears the composition when
        // the selection changes, even if soft-resetting. Offsets here must be
        // different from the previous selection offsets, and -1 seems to be a
        // reasonable, deterministic value
        imm.updateSelection(view, -1, -1, -1, -1);
      }

      try {
        imm.restartInput(view);
      } catch (final RuntimeException e) {
        Log.e(LOGTAG, "Error restarting input", e);
      }
    }

    @Override
    public void showSoftInput(@NonNull final GeckoSession session) {
      ThreadUtils.assertOnUiThread();
      final View view = session.getTextInput().getView();
      final InputMethodManager imm = getInputMethodManager(view);
      if (imm != null) {
        if (view.hasFocus() && !imm.isActive(view)) {
          // Marshmallow workaround: The view has focus but it is not the active
          // view for the input method. (Bug 1211848)
          view.clearFocus();
          view.requestFocus();
        }
        imm.showSoftInput(view, 0);
      }
    }

    @Override
    public void hideSoftInput(@NonNull final GeckoSession session) {
      ThreadUtils.assertOnUiThread();
      final View view = session.getTextInput().getView();
      final InputMethodManager imm = getInputMethodManager(view);
      if (imm != null) {
        imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
      }
    }

    @Override
    public void updateSelection(
        @NonNull final GeckoSession session,
        final int selStart,
        final int selEnd,
        final int compositionStart,
        final int compositionEnd) {
      ThreadUtils.assertOnUiThread();
      final View view = session.getTextInput().getView();
      final InputMethodManager imm = getInputMethodManager(view);
      if (imm != null) {
        // When composition start and end is -1,
        // InputMethodManager.updateSelection will remove composition
        // on most IMEs. If not working, we have to add a workaround
        // to EditableListener.onDiscardComposition.
        imm.updateSelection(view, selStart, selEnd, compositionStart, compositionEnd);
      }
    }

    @Override
    public void updateExtractedText(
        @NonNull final GeckoSession session,
        @NonNull final ExtractedTextRequest request,
        @NonNull final ExtractedText text) {
      ThreadUtils.assertOnUiThread();
      final View view = session.getTextInput().getView();
      final InputMethodManager imm = getInputMethodManager(view);
      if (imm != null) {
        imm.updateExtractedText(view, request.token, text);
      }
    }

    @Override
    public void updateCursorAnchorInfo(
        @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) {
      ThreadUtils.assertOnUiThread();
      final View view = session.getTextInput().getView();
      final InputMethodManager imm = getInputMethodManager(view);
      if (imm != null) {
        imm.updateCursorAnchorInfo(view, info);
      }
    }
  }

  private final GeckoSession mSession;
  private final NativeQueue mQueue;
  private final GeckoEditable mEditable;
  private InputConnectionClient mInputConnection;
  private GeckoSession.TextInputDelegate mDelegate;

  /* package */ SessionTextInput(
      final @NonNull GeckoSession session, final @NonNull NativeQueue queue) {
    mSession = session;
    mQueue = queue;
    mEditable = new GeckoEditable(session);
  }

  /* package */ void onWindowChanged(final GeckoSession.Window window) {
    if (mQueue.isReady()) {
      window.attachEditable(mEditable);
    } else {
      mQueue.queueUntilReady(window, "attachEditable", IGeckoEditableParent.class, mEditable);
    }
  }

  /**
   * Get a Handler for the background input method thread.
   *
   * @param defHandler Handler returned by the system {@code getHandler} implementation.
   * @return Handler to return to the system through {@code getHandler}.
   */
  @AnyThread
  public synchronized @NonNull Handler getHandler(final @NonNull Handler defHandler) {
    // May be called on any thread.
    if (mInputConnection != null) {
      return mInputConnection.getHandler(defHandler);
    }
    return defHandler;
  }

  /**
   * Get the current {@link android.view.View} for text input.
   *
   * @return Current text input View or null if not set.
   * @see #setView(View)
   */
  @UiThread
  public @Nullable View getView() {
    ThreadUtils.assertOnUiThread();
    return mInputConnection != null ? mInputConnection.getView() : null;
  }

  /**
   * Set the current {@link android.view.View} for text input. The {@link android.view.View} is used
   * to interact with the system input method manager and to display certain text input UI elements.
   * See the {@code SessionTextInput} class documentation for information on viewless mode, when the
   * current {@link android.view.View} is not set or set to null.
   *
   * @param view Text input View or null to clear current View.
   * @see #getView()
   */
  @UiThread
  public synchronized void setView(final @Nullable View view) {
    ThreadUtils.assertOnUiThread();

    if (view == null) {
      mInputConnection = null;
    } else if (mInputConnection == null || mInputConnection.getView() != view) {
      mInputConnection = GeckoInputConnection.create(mSession, view, mEditable);
    }
    mEditable.setListener((EditableListener) mInputConnection);
  }

  /**
   * Get an {@link android.view.inputmethod.InputConnection} instance. In viewless mode, this method
   * still fills out the {@link android.view.inputmethod.EditorInfo} object, but the return value
   * will always be null.
   *
   * @param attrs EditorInfo instance to be filled on return.
   * @return InputConnection instance, or null if there is no active input (or if in viewless mode).
   */
  @AnyThread
  public synchronized @Nullable InputConnection onCreateInputConnection(
      final @NonNull EditorInfo attrs) {
    // May be called on any thread.
    mEditable.onCreateInputConnection(attrs);

    if (!mQueue.isReady() || mInputConnection == null) {
      return null;
    }
    return mInputConnection.onCreateInputConnection(attrs);
  }

  /**
   * Process a KeyEvent as a pre-IME event.
   *
   * @param keyCode Key code.
   * @param event KeyEvent instance.
   * @return True if the event was handled.
   */
  @UiThread
  public boolean onKeyPreIme(final int keyCode, final @NonNull KeyEvent event) {
    ThreadUtils.assertOnUiThread();
    return mEditable.onKeyPreIme(getView(), keyCode, event);
  }

  /**
   * Process a KeyEvent as a key-down event.
   *
   * @param keyCode Key code.
   * @param event KeyEvent instance.
   * @return True if the event was handled.
   */
  @UiThread
  public boolean onKeyDown(final int keyCode, final @NonNull KeyEvent event) {
    ThreadUtils.assertOnUiThread();
    return mEditable.onKeyDown(getView(), keyCode, event);
  }

  /**
   * Process a KeyEvent as a key-up event.
   *
   * @param keyCode Key code.
   * @param event KeyEvent instance.
   * @return True if the event was handled.
   */
  @UiThread
  public boolean onKeyUp(final int keyCode, final @NonNull KeyEvent event) {
    ThreadUtils.assertOnUiThread();
    return mEditable.onKeyUp(getView(), keyCode, event);
  }

  /**
   * Process a KeyEvent as a long-press event.
   *
   * @param keyCode Key code.
   * @param event KeyEvent instance.
   * @return True if the event was handled.
   */
  @UiThread
  public boolean onKeyLongPress(final int keyCode, final @NonNull KeyEvent event) {
    ThreadUtils.assertOnUiThread();
    return mEditable.onKeyLongPress(getView(), keyCode, event);
  }

  /**
   * Process a KeyEvent as a multiple-press event.
   *
   * @param keyCode Key code.
   * @param repeatCount Key repeat count.
   * @param event KeyEvent instance.
   * @return True if the event was handled.
   */
  @UiThread
  public boolean onKeyMultiple(
      final int keyCode, final int repeatCount, final @NonNull KeyEvent event) {
    ThreadUtils.assertOnUiThread();
    return mEditable.onKeyMultiple(getView(), keyCode, repeatCount, event);
  }

  /**
   * Set the current text input delegate.
   *
   * @param delegate TextInputDelegate instance or null to restore to default.
   */
  @UiThread
  public void setDelegate(@Nullable final GeckoSession.TextInputDelegate delegate) {
    ThreadUtils.assertOnUiThread();
    mDelegate = delegate;
  }

  /**
   * Get the current text input delegate.
   *
   * @return TextInputDelegate instance or a default instance if no delegate has been set.
   */
  @UiThread
  public @NonNull GeckoSession.TextInputDelegate getDelegate() {
    ThreadUtils.assertOnUiThread();
    if (mDelegate == null) {
      mDelegate = DefaultDelegate.INSTANCE;
    }
    return mDelegate;
  }
}
