/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * vim: ts=4 sw=4 expandtab:
 * 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.annotation.TargetApi;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Matrix;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.os.TransactionTooLargeException;
import android.text.TextUtils;
import android.util.Log;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import java.util.ArrayList;
import java.util.List;
import org.mozilla.gecko.util.ThreadUtils;

/**
 * Class that implements a basic SelectionActionDelegate. This class is used by GeckoView by default
 * if the consumer does not explicitly set a SelectionActionDelegate.
 *
 * <p>To provide custom actions, extend this class and override the following methods,
 *
 * <p>1) Override {@link #getAllActions} to include custom action IDs in the returned array. This
 * array must include all actions, available or not, and must not change over the class lifetime.
 *
 * <p>2) Override {@link #isActionAvailable} to return whether a custom action is currently
 * available.
 *
 * <p>3) Override {@link #prepareAction} to set custom title and/or icon for a custom action.
 *
 * <p>4) Override {@link #performAction} to perform a custom action when used.
 */
@UiThread
public class BasicSelectionActionDelegate
    implements ActionMode.Callback, GeckoSession.SelectionActionDelegate {
  private static final String LOGTAG = "BasicSelectionAction";

  protected static final String ACTION_PROCESS_TEXT = Intent.ACTION_PROCESS_TEXT;

  private static final String[] FLOATING_TOOLBAR_ACTIONS =
      new String[] {
        ACTION_CUT,
        ACTION_COPY,
        ACTION_PASTE,
        ACTION_SELECT_ALL,
        ACTION_PASTE_AS_PLAIN_TEXT,
        ACTION_PROCESS_TEXT
      };
  private static final String[] FIXED_TOOLBAR_ACTIONS =
      new String[] {ACTION_SELECT_ALL, ACTION_CUT, ACTION_COPY, ACTION_PASTE};

  // This is limitation of intent text.
  private static final int MAX_INTENT_TEXT_LENGTH = 100000;

  protected final @NonNull Activity mActivity;
  protected final boolean mUseFloatingToolbar;

  private boolean mExternalActionsEnabled;

  protected @Nullable ActionMode mActionMode;
  protected @Nullable GeckoSession mSession;
  protected @Nullable Selection mSelection;
  protected boolean mRepopulatedMenu;

  private @Nullable ActionMode mActionModeForClipboardPermission;

  @TargetApi(Build.VERSION_CODES.M)
  private class Callback2Wrapper extends ActionMode.Callback2 {
    @Override
    public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
      return BasicSelectionActionDelegate.this.onCreateActionMode(actionMode, menu);
    }

    @Override
    public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
      return BasicSelectionActionDelegate.this.onPrepareActionMode(actionMode, menu);
    }

    @Override
    public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
      return BasicSelectionActionDelegate.this.onActionItemClicked(actionMode, menuItem);
    }

    @Override
    public void onDestroyActionMode(final ActionMode actionMode) {
      BasicSelectionActionDelegate.this.onDestroyActionMode(actionMode);
    }

    @Override
    public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) {
      super.onGetContentRect(mode, view, outRect);
      BasicSelectionActionDelegate.this.onGetContentRect(mode, view, outRect);
    }
  }

  @SuppressWarnings("checkstyle:javadocmethod")
  public BasicSelectionActionDelegate(final @NonNull Activity activity) {
    this(activity, Build.VERSION.SDK_INT >= 23);
  }

  @SuppressWarnings("checkstyle:javadocmethod")
  public BasicSelectionActionDelegate(
      final @NonNull Activity activity, final boolean useFloatingToolbar) {
    mActivity = activity;
    mUseFloatingToolbar = useFloatingToolbar;
    mExternalActionsEnabled = true;
  }

  /**
   * Set whether to include text actions from other apps in the floating toolbar.
   *
   * @param enable True if external actions should be enabled.
   */
  public void enableExternalActions(final boolean enable) {
    ThreadUtils.assertOnUiThread();
    mExternalActionsEnabled = enable;

    if (mActionMode != null) {
      mActionMode.invalidate();
    }
  }

  /**
   * Get whether text actions from other apps are enabled.
   *
   * @return True if external actions are enabled.
   */
  public boolean areExternalActionsEnabled() {
    return mExternalActionsEnabled;
  }

  /**
   * Return list of all actions in proper order, regardless of their availability at present.
   * Override to add to or remove from the default set.
   *
   * @return Array of action IDs in proper order.
   */
  protected @NonNull String[] getAllActions() {
    return mUseFloatingToolbar ? FLOATING_TOOLBAR_ACTIONS : FIXED_TOOLBAR_ACTIONS;
  }

  /**
   * Return whether an action is presently available. Override to indicate availability for custom
   * actions.
   *
   * @param id Action ID.
   * @return True if the action is presently available.
   */
  protected boolean isActionAvailable(final @NonNull String id) {
    if (mSelection == null) {
      return false;
    }

    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && ACTION_PASTE_AS_PLAIN_TEXT.equals(id)) {
      return false;
    }

    if (mExternalActionsEnabled && !mSelection.text.isEmpty() && ACTION_PROCESS_TEXT.equals(id)) {
      return !getProcessTextExportedActivities().isEmpty();
    }

    return mSelection.isActionAvailable(id);
  }

  /**
   * Get exported activities for {@link BasicSelectionActionDelegate#ACTION_PROCESS_TEXT} when text
   * is selected.
   *
   * @return list of exported activities
   */
  private @NonNull List<ResolveInfo> getProcessTextExportedActivities() {
    final PackageManager pm = mActivity.getPackageManager();
    final List<ResolveInfo> resolvedList =
        pm.queryIntentActivityOptions(
            null, null, getProcessTextIntent(null), PackageManager.MATCH_DEFAULT_ONLY);
    final ArrayList<ResolveInfo> exportedList = new ArrayList<>();
    for (final ResolveInfo info : resolvedList) {
      if (info.activityInfo.exported) {
        exportedList.add(info);
      }
    }

    return exportedList;
  }

  /**
   * Provides access to whether there are text selection actions available. Override to indicate
   * availability for custom actions.
   *
   * @return True if there are text selection actions available.
   */
  public boolean isActionAvailable() {
    if (mSelection == null) {
      return false;
    }

    return isActionAvailable(ACTION_PROCESS_TEXT) || !mSelection.availableActions.isEmpty();
  }

  /**
   * Prepare a menu item corresponding to a certain action. Override to prepare menu item for custom
   * action.
   *
   * @param id Action ID.
   * @param item New menu item to prepare.
   */
  protected void prepareAction(final @NonNull String id, final @NonNull MenuItem item) {
    switch (id) {
      case ACTION_CUT:
        item.setTitle(android.R.string.cut);
        break;
      case ACTION_COPY:
        item.setTitle(android.R.string.copy);
        break;
      case ACTION_PASTE:
        item.setTitle(android.R.string.paste);
        break;
      case ACTION_PASTE_AS_PLAIN_TEXT:
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
          throw new IllegalStateException("Unexpected version for action");
        }
        item.setTitle(android.R.string.paste_as_plain_text);
        break;
      case ACTION_SELECT_ALL:
        item.setTitle(android.R.string.selectAll);
        break;
      case ACTION_PROCESS_TEXT:
        throw new IllegalStateException("Unexpected action");
    }
  }

  /**
   * Perform the specified action. Override to perform custom actions.
   *
   * @param id Action ID.
   * @param item Nenu item for the action.
   * @return True if the action was performed.
   */
  protected boolean performAction(final @NonNull String id, final @NonNull MenuItem item) {
    if (ACTION_PROCESS_TEXT.equals(id)) {
      try {
        mActivity.startActivity(item.getIntent());
      } catch (final ActivityNotFoundException e) {
        Log.e(LOGTAG, "Cannot perform action", e);
        return false;
      }
      return true;
    }

    if (mSelection == null) {
      return false;
    }
    mSelection.execute(id);

    // Android behavior is to clear selection on copy.
    if (ACTION_COPY.equals(id)) {
      if (mUseFloatingToolbar) {
        clearSelection();
      } else {
        mActionMode.finish();
      }
    }
    return true;
  }

  /**
   * Get the current selection object. This object should not be stored as it does not update when
   * the selection becomes invalid. Stale actions are ignored.
   *
   * @return The {@link GeckoSession.SelectionActionDelegate.Selection} attached to the current
   *     action menu. <code>null</code> if no action menu is active.
   */
  public @Nullable Selection getSelection() {
    return mSelection;
  }

  /** Clear the current selection, if possible. */
  public void clearSelection() {
    if (mSelection == null) {
      return;
    }

    if (isActionAvailable(ACTION_COLLAPSE_TO_END)) {
      mSelection.collapseToEnd();
    } else if (isActionAvailable(ACTION_UNSELECT)) {
      mSelection.unselect();
    } else {
      mSelection.hide();
    }
  }

  private String getSelectedText(final int maxLength) {
    if (mSelection == null) {
      return "";
    }

    if (TextUtils.isEmpty(mSelection.text) || mSelection.text.length() < maxLength) {
      return mSelection.text;
    }

    return mSelection.text.substring(0, maxLength);
  }

  private Intent getProcessTextIntent(@Nullable final ResolveInfo resolveInfo) {
    final Intent intent = new Intent(Intent.ACTION_PROCESS_TEXT);
    if (resolveInfo != null) {
      intent.setComponent(
          new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name));
    }
    intent.addCategory(Intent.CATEGORY_DEFAULT);
    intent.setType("text/plain");
    // If using large text, anything intent may throw RemoteException.
    intent.putExtra(Intent.EXTRA_PROCESS_TEXT, getSelectedText(MAX_INTENT_TEXT_LENGTH));
    // TODO: implement ability to replace text in Gecko for editable selection (bug 1453137).
    intent.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true);
    return intent;
  }

  @Override
  public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
    ThreadUtils.assertOnUiThread();
    final String[] allActions = getAllActions();
    for (final String actionId : allActions) {
      if (isActionAvailable(actionId)) {
        if (!mUseFloatingToolbar && (Build.VERSION.SDK_INT == 22 || Build.VERSION.SDK_INT == 23)) {
          // Android bug where onPrepareActionMode is not called initially.
          onPrepareActionMode(actionMode, menu);
        }
        return true;
      }
    }
    return false;
  }

  @Override
  public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
    ThreadUtils.assertOnUiThread();
    final String[] allActions = getAllActions();
    boolean changed = false;

    // Whether we are repopulating an existing menu.
    mRepopulatedMenu = menu.size() != 0;

    // For each action, see if it's available at present, and if necessary,
    // add to or remove from menu.
    for (int i = 0; i < allActions.length; i++) {
      final String actionId = allActions[i];
      final int menuId = i + Menu.FIRST;

      if (ACTION_PROCESS_TEXT.equals(actionId)) {
        if (mExternalActionsEnabled && mSelection != null && !mSelection.text.isEmpty()) {
          final List<ResolveInfo> exportedPackageInfo = getProcessTextExportedActivities();
          if (!exportedPackageInfo.isEmpty()) {
            for (final ResolveInfo info : exportedPackageInfo) {
              final boolean isMenuItemAdded = addProcessTextMenuItem(menu, menuId, info);
              if (isMenuItemAdded) {
                changed = true;
              }
            }
          }
        } else if (menu.findItem(menuId) != null) {
          menu.removeGroup(menuId);
          changed = true;
        }
        continue;
      }

      if (isActionAvailable(actionId)) {
        if (menu.findItem(menuId) == null) {
          prepareAction(actionId, menu.add(/* group */ Menu.NONE, menuId, menuId, /* title */ ""));
          changed = true;
        }
      } else if (menu.findItem(menuId) != null) {
        menu.removeItem(menuId);
        changed = true;
      }
    }
    return changed;
  }

  private boolean addProcessTextMenuItem(
      final Menu menu, final int menuId, final ResolveInfo info) {
    boolean isMenuItemAdded = false;
    try {
      menu.addIntentOptions(
          menuId,
          menuId,
          menuId,
          mActivity.getComponentName(),
          /* specifiec */ null,
          getProcessTextIntent(info),
          /* flags */ Menu.FLAG_APPEND_TO_GROUP, /* items */
          null);
      isMenuItemAdded = true;
    } catch (final RuntimeException e) {
      if (e.getCause() instanceof TransactionTooLargeException) {
        // Binder size error. MAX_INTENT_TEXT_LENGTH is still large?
        Log.e(LOGTAG, "Cannot add intent option", e);
      } else {
        throw e;
      }
    }
    return isMenuItemAdded;
  }

  @Override
  public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
    ThreadUtils.assertOnUiThread();
    MenuItem realMenuItem = null;
    if (mRepopulatedMenu) {
      // When we repopulate an existing menu, Android can sometimes give us an old,
      // deleted MenuItem. Find the current MenuItem that corresponds to the old one.
      final Menu menu = actionMode.getMenu();
      final int size = menu.size();
      for (int i = 0; i < size; i++) {
        final MenuItem item = menu.getItem(i);
        if (item == menuItem
            || (item.getItemId() == menuItem.getItemId()
                && item.getTitle().equals(menuItem.getTitle()))) {
          realMenuItem = item;
          break;
        }
      }
    } else {
      realMenuItem = menuItem;
    }

    if (realMenuItem == null) {
      return false;
    }
    final String[] allActions = getAllActions();
    return performAction(allActions[realMenuItem.getItemId() - Menu.FIRST], realMenuItem);
  }

  @Override
  public void onDestroyActionMode(final ActionMode actionMode) {
    ThreadUtils.assertOnUiThread();
    if (!mUseFloatingToolbar) {
      clearSelection();
    }
    mSession = null;
    mSelection = null;
    mActionMode = null;
  }

  @SuppressWarnings("checkstyle:javadocmethod")
  public void onGetContentRect(
      final @Nullable ActionMode mode, final @Nullable View view, final @NonNull Rect outRect) {
    ThreadUtils.assertOnUiThread();
    if (mSelection == null || mSelection.screenRect == null) {
      return;
    }

    // outRect has to convert to current window coordinate.
    final Matrix matrix = new Matrix();
    mSession.getScreenToWindowManagerOffsetMatrix(matrix);
    final RectF transformedRect = new RectF();
    matrix.mapRect(transformedRect, mSelection.screenRect);
    transformedRect.roundOut(outRect);
  }

  @TargetApi(Build.VERSION_CODES.M)
  @Override
  public void onShowActionRequest(final GeckoSession session, final Selection selection) {
    ThreadUtils.assertOnUiThread();
    mSession = session;
    mSelection = selection;

    if (mActionMode != null) {
      if (isActionAvailable()) {
        mActionMode.invalidate();
      } else {
        mActionMode.finish();
      }
      return;
    }

    if (mActionModeForClipboardPermission != null) {
      mActionModeForClipboardPermission.finish();
      return;
    }

    if (mUseFloatingToolbar) {
      mActionMode = mActivity.startActionMode(new Callback2Wrapper(), ActionMode.TYPE_FLOATING);
    } else {
      mActionMode = mActivity.startActionMode(this);
    }
  }

  @Override
  public void onHideAction(final GeckoSession session, final int reason) {
    ThreadUtils.assertOnUiThread();
    if (mActionMode == null) {
      return;
    }

    switch (reason) {
      case HIDE_REASON_ACTIVE_SCROLL:
      case HIDE_REASON_ACTIVE_SELECTION:
      case HIDE_REASON_INVISIBLE_SELECTION:
        if (mUseFloatingToolbar) {
          // Hide the floating toolbar when scrolling/selecting.
          mActionMode.finish();
        }
        break;

      case HIDE_REASON_NO_SELECTION:
        mActionMode.finish();
        break;
    }
  }

  /** Callback class of clipboard permission. This is used on pre-M only */
  private class ClipboardPermissionCallback implements ActionMode.Callback {
    private GeckoResult<AllowOrDeny> mResult;

    public ClipboardPermissionCallback(final GeckoResult<AllowOrDeny> result) {
      mResult = result;
    }

    @Override
    public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
      return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission(
          actionMode, menu);
    }

    @Override
    public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
      return false;
    }

    @Override
    public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
      mResult.complete(AllowOrDeny.ALLOW);
      mResult = null;
      actionMode.finish();
      return true;
    }

    @Override
    public void onDestroyActionMode(final ActionMode actionMode) {
      if (mResult != null) {
        mResult.complete(AllowOrDeny.DENY);
      }
      BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode);
    }
  }

  /** Callback class of clipboard permission for Android M+ */
  @TargetApi(Build.VERSION_CODES.M)
  private class ClipboardPermissionCallbackM extends ActionMode.Callback2 {
    private @Nullable GeckoResult<AllowOrDeny> mResult;
    private final @NonNull GeckoSession mSession;
    private final @Nullable Point mPoint;

    public ClipboardPermissionCallbackM(
        final @NonNull GeckoSession session,
        final @Nullable Point screenPoint,
        final @NonNull GeckoResult<AllowOrDeny> result) {
      mSession = session;
      mPoint = screenPoint;
      mResult = result;
    }

    @Override
    public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
      return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission(
          actionMode, menu);
    }

    @Override
    public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
      return false;
    }

    @Override
    public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
      mResult.complete(AllowOrDeny.ALLOW);
      mResult = null;
      actionMode.finish();
      return true;
    }

    @Override
    public void onDestroyActionMode(final ActionMode actionMode) {
      if (mResult != null) {
        mResult.complete(AllowOrDeny.DENY);
      }
      BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode);
    }

    @Override
    public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) {
      super.onGetContentRect(mode, view, outRect);

      if (mPoint == null) {
        return;
      }

      outRect.set(mPoint.x, mPoint.y, mPoint.x + 1, mPoint.y + 1);
    }
  }

  /**
   * Show action mode bar to request clipboard permission
   *
   * @param session The GeckoSession that initiated the callback.
   * @param permission An {@link ClipboardPermission} describing the permission being requested.
   * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the
   *     permission request for this site.
   */
  @TargetApi(Build.VERSION_CODES.M)
  @Override
  public GeckoResult<AllowOrDeny> onShowClipboardPermissionRequest(
      final GeckoSession session, final ClipboardPermission permission) {
    ThreadUtils.assertOnUiThread();

    final GeckoResult<AllowOrDeny> result = new GeckoResult<>();

    if (mActionMode != null) {
      mActionMode.finish();
      mActionMode = null;
    }
    if (mActionModeForClipboardPermission != null) {
      mActionModeForClipboardPermission.finish();
      mActionModeForClipboardPermission = null;
    }

    if (mUseFloatingToolbar) {
      mActionModeForClipboardPermission =
          mActivity.startActionMode(
              new ClipboardPermissionCallbackM(session, permission.screenPoint, result),
              ActionMode.TYPE_FLOATING);
    } else {
      mActionModeForClipboardPermission =
          mActivity.startActionMode(new ClipboardPermissionCallback(result));
    }

    return result;
  }

  /**
   * Dismiss action mode for requesting clipboard permission popup or model.
   *
   * @param session The GeckoSession that initiated the callback.
   */
  @Override
  public void onDismissClipboardPermissionRequest(final GeckoSession session) {
    ThreadUtils.assertOnUiThread();

    if (mActionModeForClipboardPermission != null) {
      mActionModeForClipboardPermission.finish();
      mActionModeForClipboardPermission = null;
    }
  }

  /* package */ boolean onCreateActionModeForClipboardPermission(
      final ActionMode actionMode, final Menu menu) {
    final MenuItem item = menu.add(/* group */ Menu.NONE, Menu.FIRST, Menu.FIRST, /* title */ "");
    item.setTitle(android.R.string.paste);
    return true;
  }

  /* package */ void onDestroyActionModeForClipboardPermission(final ActionMode actionMode) {
    mActionModeForClipboardPermission = null;
  }
}
