/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * 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.android.incallui.answer.impl.answermethod;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.annotation.SuppressLint;
import android.content.Context;
import android.support.annotation.FloatRange;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.View.OnTouchListener;
import android.view.ViewConfiguration;
import com.android.dialer.common.DpUtil;
import com.android.dialer.common.LogUtil;
import com.android.dialer.common.MathUtil;
import com.android.incallui.answer.impl.classifier.FalsingManager;
import com.android.incallui.answer.impl.utils.FlingAnimationUtils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/** Touch handler that keeps track of flings for {@link FlingUpDownMethod}. */
@SuppressLint("ClickableViewAccessibility")
class FlingUpDownTouchHandler implements OnTouchListener {

  /** Callback interface for significant events with this touch handler */
  interface OnProgressChangedListener {

    /**
     * Called when the visible answer progress has changed. Implementations should use this for
     * animation, but should not perform accepts or rejects until {@link #onMoveFinish(boolean)} is
     * called.
     *
     * @param progress float representation of the progress with +1f fully accepted, -1f fully
     *     rejected, and 0 neutral.
     */
    void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress);

    /** Called when a touch event has started being tracked. */
    void onTrackingStart();

    /** Called when touch events stop being tracked. */
    void onTrackingStopped();

    /**
     * Called when the progress has fully animated back to neutral. Normal resting animation should
     * resume, possibly with a hint animation first.
     *
     * @param showHint {@code true} iff the hint animation should be run before resuming normal
     *     animation.
     */
    void onMoveReset(boolean showHint);

    /**
     * Called when the progress has animated fully to accept or reject.
     *
     * @param accept {@code true} if the call has been accepted, {@code false} if it has been
     *     rejected.
     */
    void onMoveFinish(boolean accept);

    /**
     * Determine whether this gesture should use the {@link FalsingManager} to reject accidental
     * touches
     *
     * @param downEvent the MotionEvent corresponding to the start of the gesture
     * @return {@code true} if the {@link FalsingManager} should be used to reject accidental
     *     touches for this gesture
     */
    boolean shouldUseFalsing(@NonNull MotionEvent downEvent);
  }

  // Progress that must be moved through to not show the hint animation after gesture completes
  private static final float HINT_MOVE_THRESHOLD_RATIO = .1f;
  // Dp touch needs to move upward to be considered fully accepted
  private static final int ACCEPT_THRESHOLD_DP = 150;
  // Dp touch needs to move downward to be considered fully rejected
  private static final int REJECT_THRESHOLD_DP = 150;
  // Dp touch needs to move for it to not be considered a false touch (if FalsingManager is not
  // enabled)
  private static final int FALSING_THRESHOLD_DP = 40;

  // Progress at which a fling in the opposite direction will recenter instead of
  // accepting/rejecting
  private static final float PROGRESS_FLING_RECENTER = .1f;

  // Progress at which a slow swipe would continue toward accept/reject after the
  // touch has been let go, otherwise will recenter
  private static final float PROGRESS_SWIPE_RECENTER = .8f;

  private static final float REJECT_FLING_THRESHOLD_MODIFIER = 2f;

  @Retention(RetentionPolicy.SOURCE)
  @IntDef({FlingTarget.CENTER, FlingTarget.ACCEPT, FlingTarget.REJECT})
  private @interface FlingTarget {
    int CENTER = 0;
    int ACCEPT = 1;
    int REJECT = -1;
  }

  /**
   * Create a new FlingUpDownTouchHandler and attach it to the target. Will call {@link
   * View#setOnTouchListener(OnTouchListener)} before returning.
   *
   * @param target View whose touches are to be listened to
   * @param listener Callback to listen to major events
   * @param falsingManager FalsingManager to identify false touches
   * @return the instance of FlingUpDownTouchHandler that has been added as a touch listener
   */
  public static FlingUpDownTouchHandler attach(
      @NonNull View target,
      @NonNull OnProgressChangedListener listener,
      @Nullable FalsingManager falsingManager) {
    FlingUpDownTouchHandler handler = new FlingUpDownTouchHandler(target, listener, falsingManager);
    target.setOnTouchListener(handler);
    return handler;
  }

  @NonNull private final View target;
  @NonNull private final OnProgressChangedListener listener;

  private VelocityTracker velocityTracker;
  private FlingAnimationUtils flingAnimationUtils;

  private boolean touchEnabled = true;
  private boolean flingEnabled = true;
  private float currentProgress;
  private boolean tracking;

  private boolean motionAborted;
  private boolean touchSlopExceeded;
  private boolean hintDistanceExceeded;
  private int trackingPointer;
  private Animator progressAnimator;

  private float touchSlop;
  private float initialTouchY;
  private float acceptThresholdY;
  private float rejectThresholdY;
  private float zeroY;

  private boolean touchAboveFalsingThreshold;
  private float falsingThresholdPx;
  private boolean touchUsesFalsing;

  private final float acceptThresholdPx;
  private final float rejectThresholdPx;
  private final float deadZoneTopPx;

  @Nullable private final FalsingManager falsingManager;

  private FlingUpDownTouchHandler(
      @NonNull View target,
      @NonNull OnProgressChangedListener listener,
      @Nullable FalsingManager falsingManager) {
    this.target = target;
    this.listener = listener;
    Context context = target.getContext();
    touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
    flingAnimationUtils = new FlingAnimationUtils(context, .6f);
    falsingThresholdPx = DpUtil.dpToPx(context, FALSING_THRESHOLD_DP);
    acceptThresholdPx = DpUtil.dpToPx(context, ACCEPT_THRESHOLD_DP);
    rejectThresholdPx = DpUtil.dpToPx(context, REJECT_THRESHOLD_DP);

    deadZoneTopPx =
        Math.max(
            context.getResources().getDimension(R.dimen.answer_swipe_dead_zone_top),
            acceptThresholdPx);
    this.falsingManager = falsingManager;
  }

  /** Returns {@code true} iff a touch is being tracked */
  public boolean isTracking() {
    return tracking;
  }

  /**
   * Sets whether touch events will continue to be listened to
   *
   * @param touchEnabled whether future touch events will be listened to
   */
  public void setTouchEnabled(boolean touchEnabled) {
    this.touchEnabled = touchEnabled;
  }

  /**
   * Sets whether fling velocity is used to affect accept/reject behavior
   *
   * @param flingEnabled whether fling velocity will be used when determining whether to
   *     accept/reject or recenter
   */
  public void setFlingEnabled(boolean flingEnabled) {
    this.flingEnabled = flingEnabled;
  }

  public void detach() {
    cancelProgressAnimator();
    setTouchEnabled(false);
  }

  @Override
  public boolean onTouch(View v, MotionEvent event) {
    if (falsingManager != null) {
      falsingManager.onTouchEvent(event);
    }
    if (!touchEnabled) {
      return false;
    }
    if (motionAborted && (event.getActionMasked() != MotionEvent.ACTION_DOWN)) {
      return false;
    }

    int pointerIndex = event.findPointerIndex(trackingPointer);
    if (pointerIndex < 0) {
      pointerIndex = 0;
      trackingPointer = event.getPointerId(pointerIndex);
    }
    final float pointerY = event.getY(pointerIndex);

    switch (event.getActionMasked()) {
      case MotionEvent.ACTION_DOWN:
        if (pointerY < deadZoneTopPx) {
          return false;
        }
        motionAborted = false;
        startMotion(pointerY, false, currentProgress);
        touchAboveFalsingThreshold = false;
        touchUsesFalsing = listener.shouldUseFalsing(event);
        if (velocityTracker == null) {
          initVelocityTracker();
        }
        trackMovement(event);
        cancelProgressAnimator();
        touchSlopExceeded = progressAnimator != null;
        onTrackingStarted();
        break;
      case MotionEvent.ACTION_POINTER_UP:
        final int upPointer = event.getPointerId(event.getActionIndex());
        if (trackingPointer == upPointer) {
          // gesture is ongoing, find a new pointer to track
          int newIndex = event.getPointerId(0) != upPointer ? 0 : 1;
          float newY = event.getY(newIndex);
          trackingPointer = event.getPointerId(newIndex);
          startMotion(newY, true, currentProgress);
        }
        break;
      case MotionEvent.ACTION_POINTER_DOWN:
        motionAborted = true;
        endMotionEvent(event, pointerY, true);
        return false;
      case MotionEvent.ACTION_MOVE:
        float deltaY = pointerY - initialTouchY;

        if (Math.abs(deltaY) > touchSlop) {
          touchSlopExceeded = true;
        }
        if (Math.abs(deltaY) >= falsingThresholdPx) {
          touchAboveFalsingThreshold = true;
        }
        setCurrentProgress(pointerYToProgress(pointerY));
        trackMovement(event);
        break;

      case MotionEvent.ACTION_UP:
      case MotionEvent.ACTION_CANCEL:
        trackMovement(event);
        endMotionEvent(event, pointerY, false);
    }
    return true;
  }

  private void endMotionEvent(MotionEvent event, float pointerY, boolean forceCancel) {
    trackingPointer = -1;
    if ((tracking && touchSlopExceeded)
        || Math.abs(pointerY - initialTouchY) > touchSlop
        || event.getActionMasked() == MotionEvent.ACTION_CANCEL
        || forceCancel) {
      float vel = 0f;
      float vectorVel = 0f;
      if (velocityTracker != null) {
        velocityTracker.computeCurrentVelocity(1000);
        vel = velocityTracker.getYVelocity();
        vectorVel =
            Math.copySign(
                (float) Math.hypot(velocityTracker.getXVelocity(), velocityTracker.getYVelocity()),
                vel);
      }

      boolean falseTouch = isFalseTouch();
      boolean forceRecenter =
          falseTouch
              || !touchSlopExceeded
              || forceCancel
              || event.getActionMasked() == MotionEvent.ACTION_CANCEL;

      @FlingTarget
      int target = forceRecenter ? FlingTarget.CENTER : getFlingTarget(pointerY, vectorVel);

      fling(vel, target, falseTouch);
      onTrackingStopped();
    } else {
      onTrackingStopped();
      setCurrentProgress(0);
      onMoveEnded();
    }

    if (velocityTracker != null) {
      velocityTracker.recycle();
      velocityTracker = null;
    }
  }

  @FlingTarget
  private int getFlingTarget(float pointerY, float vectorVel) {
    float progress = pointerYToProgress(pointerY);

    float minVelocityPxPerSecond = flingAnimationUtils.getMinVelocityPxPerSecond();
    if (vectorVel > 0) {
      minVelocityPxPerSecond *= REJECT_FLING_THRESHOLD_MODIFIER;
    }
    if (!flingEnabled || Math.abs(vectorVel) < minVelocityPxPerSecond) {
      // Not a fling
      if (Math.abs(progress) > PROGRESS_SWIPE_RECENTER) {
        // Progress near one of the edges
        return progress > 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT;
      } else {
        return FlingTarget.CENTER;
      }
    }

    boolean sameDirection = vectorVel < 0 == progress > 0;
    if (!sameDirection && Math.abs(progress) >= PROGRESS_FLING_RECENTER) {
      // Being flung back toward center
      return FlingTarget.CENTER;
    }
    // Flung toward an edge
    return vectorVel < 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT;
  }

  @FloatRange(from = -1f, to = 1f)
  private float pointerYToProgress(float pointerY) {
    boolean pointerAboveZero = pointerY > zeroY;
    float nearestThreshold = pointerAboveZero ? rejectThresholdY : acceptThresholdY;

    float absoluteProgress = (pointerY - zeroY) / (nearestThreshold - zeroY);
    return MathUtil.clamp(absoluteProgress * (pointerAboveZero ? -1 : 1), -1f, 1f);
  }

  private boolean isFalseTouch() {
    if (falsingManager != null && falsingManager.isEnabled()) {
      if (falsingManager.isFalseTouch()) {
        if (touchUsesFalsing) {
          LogUtil.i("FlingUpDownTouchHandler.isFalseTouch", "rejecting false touch");
          return true;
        } else {
          LogUtil.i(
              "FlingUpDownTouchHandler.isFalseTouch",
              "Suspected false touch, but not using false touch rejection for this gesture");
          return false;
        }
      } else {
        return false;
      }
    }
    return !touchAboveFalsingThreshold;
  }

  private void trackMovement(MotionEvent event) {
    if (velocityTracker != null) {
      velocityTracker.addMovement(event);
    }
  }

  private void fling(float velocity, @FlingTarget int target, boolean centerBecauseOfFalsing) {
    ValueAnimator animator = createProgressAnimator(target);
    if (target == FlingTarget.CENTER) {
      flingAnimationUtils.apply(animator, currentProgress, target, velocity);
    } else {
      flingAnimationUtils.applyDismissing(animator, currentProgress, target, velocity, 1);
    }
    if (target == FlingTarget.CENTER && centerBecauseOfFalsing) {
      velocity = 0;
    }
    if (velocity == 0) {
      animator.setDuration(350);
    }

    animator.addListener(
        new AnimatorListenerAdapter() {
          boolean canceled;

          @Override
          public void onAnimationCancel(Animator animation) {
            canceled = true;
          }

          @Override
          public void onAnimationEnd(Animator animation) {
            progressAnimator = null;
            if (!canceled) {
              onMoveEnded();
            }
          }
        });
    progressAnimator = animator;
    animator.start();
  }

  private void onMoveEnded() {
    if (currentProgress == 0) {
      listener.onMoveReset(!hintDistanceExceeded);
    } else {
      listener.onMoveFinish(currentProgress > 0);
    }
  }

  private ValueAnimator createProgressAnimator(float targetProgress) {
    ValueAnimator animator = ValueAnimator.ofFloat(currentProgress, targetProgress);
    animator.addUpdateListener(
        new AnimatorUpdateListener() {
          @Override
          public void onAnimationUpdate(ValueAnimator animation) {
            setCurrentProgress((Float) animation.getAnimatedValue());
          }
        });
    return animator;
  }

  private void initVelocityTracker() {
    if (velocityTracker != null) {
      velocityTracker.recycle();
    }
    velocityTracker = VelocityTracker.obtain();
  }

  private void startMotion(float newY, boolean startTracking, float startProgress) {
    initialTouchY = newY;
    hintDistanceExceeded = false;

    if (startProgress <= .25) {
      acceptThresholdY = Math.max(0, initialTouchY - acceptThresholdPx);
      rejectThresholdY = Math.min(target.getHeight(), initialTouchY + rejectThresholdPx);
      zeroY = initialTouchY;
    }

    if (startTracking) {
      touchSlopExceeded = true;
      onTrackingStarted();
      setCurrentProgress(startProgress);
    }
  }

  private void onTrackingStarted() {
    tracking = true;
    listener.onTrackingStart();
  }

  private void onTrackingStopped() {
    tracking = false;
    listener.onTrackingStopped();
  }

  private void cancelProgressAnimator() {
    if (progressAnimator != null) {
      progressAnimator.cancel();
    }
  }

  private void setCurrentProgress(float progress) {
    if (Math.abs(progress) > HINT_MOVE_THRESHOLD_RATIO) {
      hintDistanceExceeded = true;
    }
    currentProgress = progress;
    listener.onProgressChanged(progress);
  }
}
