/*
 * Copyright (C) 2023 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.taskbar.bubbles;

import android.annotation.SuppressLint;
import android.graphics.PointF;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.dynamicanimation.animation.FloatPropertyCompat;

import com.android.launcher3.taskbar.TaskbarActivityContext;
import com.android.wm.shell.common.bubbles.BaseBubblePinController.LocationChangeListener;
import com.android.wm.shell.common.bubbles.BubbleBarLocation;

/**
 * Controls bubble bar drag interactions.
 * Interacts with {@link BubbleDismissController}, used by {@link BubbleBarViewController}.
 * Supported interactions:
 * - Drag a single bubble view into dismiss target to remove it.
 * - Drag the bubble stack into dismiss target to remove all.
 * Restores initial position of dragged view if released outside of the dismiss target.
 */
public class BubbleDragController {

    /**
     * Property to update dragged bubble x-translation value.
     * <p>
     * When applied to {@link BubbleView}, will use set the translation through
     * {@link BubbleView#getDragTranslationX()} and {@link BubbleView#setDragTranslationX(float)}
     * methods.
     * <p>
     * When applied to {@link BubbleBarView}, will use {@link View#getTranslationX()} and
     * {@link View#setTranslationX(float)}.
     */
    public static final FloatPropertyCompat<View> DRAG_TRANSLATION_X = new FloatPropertyCompat<>(
            "dragTranslationX") {
        @Override
        public float getValue(View view) {
            if (view instanceof BubbleView bubbleView) {
                return bubbleView.getDragTranslationX();
            }
            return view.getTranslationX();
        }

        @Override
        public void setValue(View view, float value) {
            if (view instanceof BubbleView bubbleView) {
                bubbleView.setDragTranslationX(value);
            } else {
                view.setTranslationX(value);
            }
        }
    };

    private final TaskbarActivityContext mActivity;
    private BubbleBarController mBubbleBarController;
    private BubbleBarViewController mBubbleBarViewController;
    private BubbleDismissController mBubbleDismissController;
    private BubbleBarPinController mBubbleBarPinController;
    private BubblePinController mBubblePinController;

    public BubbleDragController(TaskbarActivityContext activity) {
        mActivity = activity;
    }

    /**
     * Initializes dependencies when bubble controllers are created.
     * Should be careful to only access things that were created in constructors for now, as some
     * controllers may still be waiting for init().
     */
    public void init(@NonNull BubbleControllers bubbleControllers) {
        mBubbleBarController = bubbleControllers.bubbleBarController;
        mBubbleBarViewController = bubbleControllers.bubbleBarViewController;
        mBubbleDismissController = bubbleControllers.bubbleDismissController;
        mBubbleBarPinController = bubbleControllers.bubbleBarPinController;
        mBubblePinController = bubbleControllers.bubblePinController;
        mBubbleDismissController.setListener(
                stuck -> {
                    if (stuck) {
                        mBubbleBarPinController.onStuckToDismissTarget();
                        mBubblePinController.onStuckToDismissTarget();
                    }
                });
    }

    /**
     * Setup the bubble view for dragging and attach touch listener to it
     */
    @SuppressLint("ClickableViewAccessibility")
    public void setupBubbleView(@NonNull BubbleView bubbleView) {
        if (!(bubbleView.getBubble() instanceof BubbleBarBubble)) {
            // Don't setup dragging for overflow bubble view
            return;
        }

        bubbleView.setOnTouchListener(new BubbleTouchListener() {

            private BubbleBarLocation mReleasedLocation = BubbleBarLocation.DEFAULT;

            private final LocationChangeListener mLocationChangeListener =
                    new LocationChangeListener() {
                        @Override
                        public void onChange(@NonNull BubbleBarLocation location) {
                            mBubbleBarController.animateBubbleBarLocation(location);
                        }

                        @Override
                        public void onRelease(@NonNull BubbleBarLocation location) {
                            mReleasedLocation = location;
                        }
                    };

            @Override
            void onDragStart() {
                mBubblePinController.setListener(mLocationChangeListener);
                mBubbleBarViewController.onBubbleDragStart(bubbleView);
                mBubblePinController.onDragStart(
                        mBubbleBarViewController.getBubbleBarLocation().isOnLeft(
                                bubbleView.isLayoutRtl()));
            }

            @Override
            protected void onDragUpdate(float x, float y, float newTx, float newTy) {
                bubbleView.setDragTranslationX(newTx);
                bubbleView.setTranslationY(newTy);
                mBubblePinController.onDragUpdate(x, y);
            }

            @Override
            protected void onDragRelease() {
                mBubblePinController.onDragEnd();
                mBubbleBarViewController.onBubbleDragRelease(mReleasedLocation);
            }

            @Override
            protected void onDragDismiss() {
                mBubblePinController.onDragEnd();
                mBubbleBarViewController.onBubbleDragEnd();
            }

            @Override
            void onDragEnd() {
                mBubbleBarController.updateBubbleBarLocation(mReleasedLocation);
                mBubbleBarViewController.onBubbleDragEnd();
                mBubblePinController.setListener(null);
            }

            @Override
            protected PointF getRestingPosition() {
                return mBubbleBarViewController.getDraggedBubbleReleaseTranslation(
                        getInitialPosition(), mReleasedLocation);
            }
        });
    }

    /**
     * Setup the bubble bar view for dragging and attach touch listener to it
     */
    @SuppressLint("ClickableViewAccessibility")
    public void setupBubbleBarView(@NonNull BubbleBarView bubbleBarView) {
        PointF initialRelativePivot = new PointF();
        bubbleBarView.setOnTouchListener(new BubbleTouchListener() {

            private BubbleBarLocation mReleasedLocation = BubbleBarLocation.DEFAULT;

            private final LocationChangeListener mLocationChangeListener =
                    location -> mReleasedLocation = location;

            @Override
            protected boolean onTouchDown(@NonNull View view, @NonNull MotionEvent event) {
                if (bubbleBarView.isExpanded()) return false;
                return super.onTouchDown(view, event);
            }

            @Override
            void onDragStart() {
                mBubbleBarPinController.setListener(mLocationChangeListener);
                initialRelativePivot.set(bubbleBarView.getRelativePivotX(),
                        bubbleBarView.getRelativePivotY());
                // By default the bubble bar view pivot is in bottom right corner, while dragging
                // it should be centered in order to align it with the dismiss target view
                bubbleBarView.setRelativePivot(/* x = */ 0.5f, /* y = */ 0.5f);
                bubbleBarView.setIsDragging(true);
                mBubbleBarPinController.onDragStart(
                        bubbleBarView.getBubbleBarLocation().isOnLeft(bubbleBarView.isLayoutRtl()));
            }

            @Override
            protected void onDragUpdate(float x, float y, float newTx, float newTy) {
                bubbleBarView.setTranslationX(newTx);
                bubbleBarView.setTranslationY(newTy);
                mBubbleBarPinController.onDragUpdate(x, y);
            }

            @Override
            protected void onDragRelease() {
                mBubbleBarPinController.onDragEnd();
            }

            @Override
            protected void onDragDismiss() {
                mBubbleBarPinController.onDragEnd();
            }

            @Override
            void onDragEnd() {
                // Make sure to update location as the first thing. Pivot update causes a relayout
                mBubbleBarController.updateBubbleBarLocation(mReleasedLocation);
                bubbleBarView.setIsDragging(false);
                // Restoring the initial pivot for the bubble bar view
                bubbleBarView.setRelativePivot(initialRelativePivot.x, initialRelativePivot.y);
                mBubbleBarViewController.onBubbleBarDragEnd();
                mBubbleBarPinController.setListener(null);
            }

            @Override
            protected PointF getRestingPosition() {
                return mBubbleBarViewController.getBubbleBarDragReleaseTranslation(
                        getInitialPosition(), mReleasedLocation);
            }
        });
    }

    /**
     * Bubble touch listener for handling a single bubble view or bubble bar view while dragging.
     * The dragging starts after "shorter" long click (the long click duration might change):
     * - When the touch gesture moves out of the {@code ACTION_DOWN} location the dragging
     * interaction is cancelled.
     * - When {@code ACTION_UP} happens before long click is registered and there was no significant
     * movement the view will perform click.
     * - When the listener registers long click it starts dragging interaction, all the subsequent
     * {@code ACTION_MOVE} events will drag the view, and the interaction finishes when
     * {@code ACTION_UP} or {@code ACTION_CANCEL} are received.
     * Lifecycle methods can be overridden do add extra setup/clean up steps.
     */
    private abstract class BubbleTouchListener implements View.OnTouchListener {
        /**
         * The internal state of the touch listener
         */
        private enum State {
            // Idle and ready for the touch events.
            // Changes to:
            // - TOUCHED, when the {@code ACTION_DOWN} is handled
            IDLE,

            // Touch down was handled and the lister is recognising the gestures.
            // Changes to:
            // - IDLE, when performs the click
            // - DRAGGING, when registers the long click and starts dragging interaction
            // - CANCELLED, when the touch events move out of the initial location before the long
            // click is recognised

            TOUCHED,

            // The long click was registered and the view is being dragged.
            // Changes to:
            // - IDLE, when the gesture ends with the {@code ACTION_UP} or {@code ACTION_CANCEL}
            DRAGGING,

            // The dragging was cancelled.
            // Changes to:
            // - IDLE, when the current gesture completes
            CANCELLED
        }

        private final PointF mTouchDownLocation = new PointF();
        private final PointF mViewInitialPosition = new PointF();
        private final VelocityTracker mVelocityTracker = VelocityTracker.obtain();
        private final long mPressToDragTimeout = ViewConfiguration.getLongPressTimeout() / 2;
        private State mState = State.IDLE;
        private int mTouchSlop = -1;
        private BubbleDragAnimator mAnimator;
        @Nullable
        private Runnable mLongClickRunnable;

        /**
         * Called when the dragging interaction has started
         */
        abstract void onDragStart();

        /**
         * Called when bubble is dragged to new coordinates.
         * Not called while bubble is stuck to the dismiss target.
         */
        protected abstract void onDragUpdate(float x, float y, float newTx, float newTy);

        /**
         * Called when the dragging interaction has ended and all the animations have completed
         */
        abstract void onDragEnd();

        /**
         * Called when the dragged bubble is released outside of the dismiss target area and will
         * move back to its initial position
         */
        protected void onDragRelease() {
        }

        /**
         * Called when the dragged bubble is released inside of the dismiss target area and will get
         * dismissed with animation
         */
        protected void onDragDismiss() {
        }

        /**
         * Get the initial position of the view when drag started
         */
        protected PointF getInitialPosition() {
            return mViewInitialPosition;
        }

        /**
         * Get the resting position of the view when drag is released
         */
        protected PointF getRestingPosition() {
            return mViewInitialPosition;
        }

        @Override
        @SuppressLint("ClickableViewAccessibility")
        public boolean onTouch(@NonNull View view, @NonNull MotionEvent event) {
            updateVelocity(event);
            switch (event.getActionMasked()) {
                case MotionEvent.ACTION_DOWN:
                    return onTouchDown(view, event);
                case MotionEvent.ACTION_MOVE:
                    onTouchMove(view, event);
                    break;
                case MotionEvent.ACTION_UP:
                    onTouchUp(view, event);
                    break;
                case MotionEvent.ACTION_CANCEL:
                    onTouchCancel(view, event);
                    break;
            }
            return true;
        }

        /**
         * The touch down starts the interaction and schedules the long click handler.
         *
         * @param view  the view that received the event
         * @param event the motion event
         * @return true if the gesture should be intercepted and handled, false otherwise. Note if
         * the false is returned subsequent events in the gesture won't get reported.
         */
        protected boolean onTouchDown(@NonNull View view, @NonNull MotionEvent event) {
            mState = State.TOUCHED;
            mTouchSlop = ViewConfiguration.get(view.getContext()).getScaledTouchSlop();
            mTouchDownLocation.set(event.getRawX(), event.getRawY());
            mViewInitialPosition.set(view.getTranslationX(), view.getTranslationY());
            setupLongClickHandler(view);
            return true;
        }

        /**
         * The move event drags the view or cancels the interaction if hasn't long clicked yet.
         *
         * @param view  the view that received the event
         * @param event the motion event
         */
        protected void onTouchMove(@NonNull View view, @NonNull MotionEvent event) {
            float rawX = event.getRawX();
            float rawY = event.getRawY();
            final float dx = rawX - mTouchDownLocation.x;
            final float dy = rawY - mTouchDownLocation.y;
            switch (mState) {
                case TOUCHED:
                    final boolean movedOut = Math.hypot(dx, dy) > mTouchSlop;
                    if (movedOut) {
                        // Moved out of the initial location before the long click was registered
                        mState = State.CANCELLED;
                        cleanUpLongClickHandler(view);
                    }
                    break;
                case DRAGGING:
                    drag(view, event, dx, dy, rawX, rawY);
                    break;
            }
        }

        /**
         * On touch up performs click or finishes the dragging depending on the state.
         *
         * @param view  the view that received the event
         * @param event the motion event
         */
        protected void onTouchUp(@NonNull View view, @NonNull MotionEvent event) {
            switch (mState) {
                case TOUCHED:
                    view.performClick();
                    cleanUp(view);
                    break;
                case DRAGGING:
                    stopDragging(view, event);
                    break;
                default:
                    cleanUp(view);
                    break;
            }
        }

        /**
         * The gesture is cancelled and the interaction should clean up and complete.
         *
         * @param view  the view that received the event
         * @param event the motion event
         */
        protected void onTouchCancel(@NonNull View view, @NonNull MotionEvent event) {
            if (mState == State.DRAGGING) {
                stopDragging(view, event);
            } else {
                cleanUp(view);
            }
        }

        private void startDragging(@NonNull View view) {
            onDragStart();
            mActivity.setTaskbarWindowFullscreen(true);
            mAnimator = new BubbleDragAnimator(view);
            mAnimator.animateFocused();
            mBubbleDismissController.setupDismissView(view, mAnimator);
            mBubbleDismissController.showDismissView();
        }

        private void drag(@NonNull View view, @NonNull MotionEvent event, float dx, float dy,
                float x, float y) {
            if (mBubbleDismissController.handleTouchEvent(event)) return;
            final float newTx = mViewInitialPosition.x + dx;
            final float newTy = mViewInitialPosition.y + dy;
            onDragUpdate(x, y, newTx, newTy);
        }

        private void stopDragging(@NonNull View view, @NonNull MotionEvent event) {
            Runnable onComplete = () -> {
                mActivity.setTaskbarWindowFullscreen(false);
                cleanUp(view);
                onDragEnd();
            };

            if (mBubbleDismissController.handleTouchEvent(event)) {
                onDragDismiss();
                mAnimator.animateDismiss(mViewInitialPosition, onComplete);
            } else {
                onDragRelease();
                mAnimator.animateToRestingState(getRestingPosition(), getCurrentVelocity(),
                        onComplete);
            }
            mBubbleDismissController.hideDismissView();
        }

        private void setupLongClickHandler(@NonNull View view) {
            cleanUpLongClickHandler(view);
            mLongClickRunnable = () -> {
                // Register long click and start dragging interaction
                mState = State.DRAGGING;
                startDragging(view);
            };
            view.getHandler().postDelayed(mLongClickRunnable, mPressToDragTimeout);
        }

        private void cleanUpLongClickHandler(@NonNull View view) {
            if (mLongClickRunnable == null || view.getHandler() == null) return;
            view.getHandler().removeCallbacks(mLongClickRunnable);
            mLongClickRunnable = null;
        }

        private void cleanUp(@NonNull View view) {
            cleanUpLongClickHandler(view);
            mVelocityTracker.clear();
            mState = State.IDLE;
        }

        private void updateVelocity(MotionEvent event) {
            final float deltaX = event.getRawX() - event.getX();
            final float deltaY = event.getRawY() - event.getY();
            event.offsetLocation(deltaX, deltaY);
            mVelocityTracker.addMovement(event);
            event.offsetLocation(-deltaX, -deltaY);
        }

        private PointF getCurrentVelocity() {
            mVelocityTracker.computeCurrentVelocity(/* units = */ 1000);
            return new PointF(mVelocityTracker.getXVelocity(), mVelocityTracker.getYVelocity());
        }
    }
}
