/*
 * Copyright (C) 2017 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.launcher3.touch;

import static android.view.MotionEvent.INVALID_POINTER_ID;

import static com.android.launcher3.MotionEventsUtils.isTrackpadMotionEvent;

import android.content.Context;
import android.graphics.PointF;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.ViewConfiguration;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import com.android.launcher3.R;

import java.util.LinkedList;
import java.util.Queue;

/**
 * Scroll/drag/swipe gesture detector.
 *
 * Definition of swipe is different from android system in that this detector handles
 * 'swipe to dismiss', 'swiping up/down a container' but also keeps scrolling state before
 * swipe action happens.
 *
 * @see SingleAxisSwipeDetector
 * @see BothAxesSwipeDetector
 */
public abstract class BaseSwipeDetector {

    private static final boolean DBG = false;
    private static final String TAG = "BaseSwipeDetector";
    private static final float ANIMATION_DURATION = 1200;
    private static final PointF sTempPoint = new PointF();

    private final float mReleaseVelocity;
    private final PointF mDownPos = new PointF();
    private final PointF mLastPos = new PointF();
    protected final boolean mIsRtl;
    protected final float mTouchSlop;
    protected final float mMaxVelocity;
    private final Queue<Runnable> mSetStateQueue = new LinkedList<>();

    private int mActivePointerId = INVALID_POINTER_ID;
    private VelocityTracker mVelocityTracker;
    private PointF mLastDisplacement = new PointF();
    private PointF mDisplacement = new PointF();
    protected PointF mSubtractDisplacement = new PointF();
    @VisibleForTesting ScrollState mState = ScrollState.IDLE;
    private boolean mIsSettingState;
    protected boolean mIsTrackpadGesture;

    protected boolean mIgnoreSlopWhenSettling;
    protected Context mContext;

    private enum ScrollState {
        IDLE,
        DRAGGING,      // onDragStart, onDrag
        SETTLING       // onDragEnd
    }

    protected BaseSwipeDetector(@NonNull Context context, @NonNull ViewConfiguration config,
            boolean isRtl) {
        mTouchSlop = config.getScaledTouchSlop();
        mMaxVelocity = config.getScaledMaximumFlingVelocity();
        mIsRtl = isRtl;
        mContext = context;
        mReleaseVelocity = mContext.getResources()
                .getDimensionPixelSize(R.dimen.base_swift_detector_fling_release_velocity);
    }

    public static long calculateDuration(float velocity, float progressNeeded) {
        // TODO: make these values constants after tuning.
        float velocityDivisor = Math.max(2f, Math.abs(0.5f * velocity));
        float travelDistance = Math.max(0.2f, progressNeeded);
        long duration = (long) Math.max(100, ANIMATION_DURATION / velocityDivisor * travelDistance);
        if (DBG) {
            Log.d(TAG, String.format(
                    "calculateDuration=%d, v=%f, d=%f", duration, velocity, progressNeeded));
        }
        return duration;
    }

    public int getDownX() {
        return (int) mDownPos.x;
    }

    public int getDownY() {
        return (int) mDownPos.y;
    }
    /**
     * There's no touch and there's no animation.
     */
    public boolean isIdleState() {
        return mState == ScrollState.IDLE;
    }

    public boolean isSettlingState() {
        return mState == ScrollState.SETTLING;
    }

    public boolean isDraggingState() {
        return mState == ScrollState.DRAGGING;
    }

    public boolean isDraggingOrSettling() {
        return mState == ScrollState.DRAGGING || mState == ScrollState.SETTLING;
    }

    public boolean isTrackpadGesture() {
        return mIsTrackpadGesture;
    }

    public void finishedScrolling() {
        setState(ScrollState.IDLE);
    }

    public boolean isFling(float velocity) {
        return Math.abs(velocity) > mReleaseVelocity;
    }

    public boolean onTouchEvent(MotionEvent ev) {
        int actionMasked = ev.getActionMasked();
        if (actionMasked == MotionEvent.ACTION_DOWN && mVelocityTracker != null) {
            mVelocityTracker.clear();
        }
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(ev);

        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN:
                mActivePointerId = ev.getPointerId(0);
                mDownPos.set(ev.getX(), ev.getY());
                mLastPos.set(mDownPos);
                mLastDisplacement.set(0, 0);
                mDisplacement.set(0, 0);
                mIsTrackpadGesture = isTrackpadMotionEvent(ev);
                if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) {
                    setState(ScrollState.DRAGGING);
                }
                break;
            //case MotionEvent.ACTION_POINTER_DOWN:
            case MotionEvent.ACTION_POINTER_UP:
                int ptrIdx = ev.getActionIndex();
                int ptrId = ev.getPointerId(ptrIdx);
                if (ptrId == mActivePointerId) {
                    final int newPointerIdx = ptrIdx == 0 ? 1 : 0;
                    mDownPos.set(
                            ev.getX(newPointerIdx) - (mLastPos.x - mDownPos.x),
                            ev.getY(newPointerIdx) - (mLastPos.y - mDownPos.y));
                    mLastPos.set(ev.getX(newPointerIdx), ev.getY(newPointerIdx));
                    mActivePointerId = ev.getPointerId(newPointerIdx);
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int pointerIndex = ev.findPointerIndex(mActivePointerId);
                if (pointerIndex == INVALID_POINTER_ID) {
                    break;
                }
                mDisplacement.set(ev.getX(pointerIndex) - mDownPos.x,
                        ev.getY(pointerIndex) - mDownPos.y);
                if (mIsRtl) {
                    mDisplacement.x = -mDisplacement.x;
                }

                // handle state and listener calls.
                if (mState != ScrollState.DRAGGING && shouldScrollStart(mDisplacement)) {
                    setState(ScrollState.DRAGGING);
                }
                if (mState == ScrollState.DRAGGING) {
                    reportDragging(ev);
                }
                mLastPos.set(ev.getX(pointerIndex), ev.getY(pointerIndex));
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                // These are synthetic events and there is no need to update internal values.
                if (mState == ScrollState.DRAGGING) {
                    setState(ScrollState.SETTLING);
                }
                mVelocityTracker.recycle();
                mVelocityTracker = null;
                break;
            default:
                break;
        }
        return true;
    }

    //------------------- ScrollState transition diagram -----------------------------------
    //
    // IDLE -> (mDisplacement > mTouchSlop) -> DRAGGING
    // DRAGGING -> (MotionEvent#ACTION_UP, MotionEvent#ACTION_CANCEL) -> SETTLING
    // SETTLING -> (MotionEvent#ACTION_DOWN) -> DRAGGING
    // SETTLING -> (View settled) -> IDLE

    private void setState(ScrollState newState) {
        if (mIsSettingState) {
            mSetStateQueue.add(() -> setState(newState));
            return;
        }
        mIsSettingState = true;

        if (DBG) {
            Log.d(TAG, "setState:" + mState + "->" + newState);
        }
        // onDragStart and onDragEnd is reported ONLY on state transition
        if (newState == ScrollState.DRAGGING) {
            initializeDragging();
            if (mState == ScrollState.IDLE) {
                reportDragStart(false /* recatch */);
            } else if (mState == ScrollState.SETTLING) {
                reportDragStart(true /* recatch */);
            }
        }
        if (newState == ScrollState.SETTLING) {
            reportDragEnd();
        }

        mState = newState;
        mIsSettingState = false;
        if (!mSetStateQueue.isEmpty()) {
            mSetStateQueue.remove().run();
        }
    }

    private void initializeDragging() {
        if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) {
            mSubtractDisplacement.set(0, 0);
        } else {
            mSubtractDisplacement.x = mDisplacement.x > 0 ? mTouchSlop : -mTouchSlop;
            mSubtractDisplacement.y = mDisplacement.y > 0 ? mTouchSlop : -mTouchSlop;
        }
    }

    protected abstract boolean shouldScrollStart(PointF displacement);

    private void reportDragStart(boolean recatch) {
        reportDragStartInternal(recatch);
        if (DBG) {
            Log.d(TAG, "onDragStart recatch:" + recatch);
        }
    }

    protected abstract void reportDragStartInternal(boolean recatch);

    private void reportDragging(MotionEvent event) {
        if (mDisplacement != mLastDisplacement) {
            if (DBG) {
                Log.d(TAG, String.format("onDrag disp=%s", mDisplacement));
            }

            mLastDisplacement.set(mDisplacement);
            sTempPoint.set(mDisplacement.x - mSubtractDisplacement.x,
                    mDisplacement.y - mSubtractDisplacement.y);
            reportDraggingInternal(sTempPoint, event);
        }
    }

    protected abstract void reportDraggingInternal(PointF displacement, MotionEvent event);

    private void reportDragEnd() {
        mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
        PointF velocity = new PointF(mVelocityTracker.getXVelocity() / 1000,
                mVelocityTracker.getYVelocity() / 1000);
        if (mIsRtl) {
            velocity.x = -velocity.x;
        }
        if (DBG) {
            Log.d(TAG, String.format("onScrollEnd disp=%.1s, velocity=%.1s",
                    mDisplacement, velocity));
        }

        reportDragEndInternal(velocity);
    }

    protected abstract void reportDragEndInternal(PointF velocity);
}
