/*
 * Copyright (C) 2015 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.allapps;

import static com.android.launcher3.LauncherAnimUtils.SCALE_PROPERTY;
import static com.android.launcher3.LauncherAnimUtils.VIEW_TRANSLATE_Y;
import static com.android.launcher3.LauncherState.ALL_APPS;
import static com.android.launcher3.LauncherState.ALL_APPS_CONTENT;
import static com.android.launcher3.LauncherState.NORMAL;
import static com.android.launcher3.anim.Interpolators.DEACCEL_1_7;
import static com.android.launcher3.anim.Interpolators.LINEAR;
import static com.android.launcher3.anim.PropertySetter.NO_ANIM_PROPERTY_SETTER;
import static com.android.launcher3.states.StateAnimationConfig.ANIM_ALL_APPS_FADE;
import static com.android.launcher3.states.StateAnimationConfig.ANIM_VERTICAL_PROGRESS;
import static com.android.launcher3.util.SystemUiController.FLAG_DARK_NAV;
import static com.android.launcher3.util.SystemUiController.FLAG_LIGHT_NAV;
import static com.android.launcher3.util.SystemUiController.UI_STATE_ALL_APPS;

import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.util.FloatProperty;
import android.view.HapticFeedbackConstants;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.animation.Interpolator;

import androidx.annotation.FloatRange;
import androidx.annotation.Nullable;

import com.android.launcher3.DeviceProfile;
import com.android.launcher3.DeviceProfile.OnDeviceProfileChangeListener;
import com.android.launcher3.Launcher;
import com.android.launcher3.LauncherState;
import com.android.launcher3.R;
import com.android.launcher3.Utilities;
import com.android.launcher3.anim.AnimatedFloat;
import com.android.launcher3.anim.AnimatorListeners;
import com.android.launcher3.anim.Interpolators;
import com.android.launcher3.anim.PendingAnimation;
import com.android.launcher3.anim.PropertySetter;
import com.android.launcher3.config.FeatureFlags;
import com.android.launcher3.statemanager.StateManager.StateHandler;
import com.android.launcher3.states.StateAnimationConfig;
import com.android.launcher3.touch.AllAppsSwipeController;
import com.android.launcher3.util.MultiPropertyFactory;
import com.android.launcher3.util.MultiPropertyFactory.MultiProperty;
import com.android.launcher3.util.MultiValueAlpha;
import com.android.launcher3.util.ScrollableLayoutManager;
import com.android.launcher3.util.Themes;
import com.android.launcher3.util.VibratorWrapper;
import com.android.launcher3.views.ScrimView;

/**
 * Handles AllApps view transition.
 * 1) Slides all apps view using direct manipulation
 * 2) When finger is released, animate to either top or bottom accordingly.
 * <p/>
 * Algorithm:
 * If release velocity > THRES1, snap according to the direction of movement.
 * If release velocity < THRES1, snap according to either top or bottom depending on whether it's
 * closer to top or closer to the page indicator.
 */
public class AllAppsTransitionController
        implements StateHandler<LauncherState>, OnDeviceProfileChangeListener {
    // This constant should match the second derivative of the animator interpolator.
    public static final float INTERP_COEFF = 1.7f;
    public static final int REVERT_SWIPE_ALL_APPS_TO_HOME_ANIMATION_DURATION_MS = 200;

    private static final float NAV_BAR_COLOR_FORCE_UPDATE_THRESHOLD = 0.1f;
    private static final float SWIPE_DRAG_COMMIT_THRESHOLD =
            1 - AllAppsSwipeController.ALL_APPS_STATE_TRANSITION_MANUAL;

    public static final FloatProperty<AllAppsTransitionController> ALL_APPS_PROGRESS =
            new FloatProperty<AllAppsTransitionController>("allAppsProgress") {

                @Override
                public Float get(AllAppsTransitionController controller) {
                    return controller.mProgress;
                }

                @Override
                public void setValue(AllAppsTransitionController controller, float progress) {
                    controller.setProgress(progress);
                }
            };

    private static final float ALL_APPS_PULL_BACK_TRANSLATION_DEFAULT = 0f;

    public static final FloatProperty<AllAppsTransitionController> ALL_APPS_PULL_BACK_TRANSLATION =
            new FloatProperty<AllAppsTransitionController>("allAppsPullBackTranslation") {

                @Override
                public Float get(AllAppsTransitionController controller) {
                    if (controller.mIsTablet) {
                        return controller.mAppsView.getActiveRecyclerView().getTranslationY();
                    } else {
                        return controller.getAppsViewPullbackTranslationY().getValue();
                    }
                }

                @Override
                public void setValue(AllAppsTransitionController controller, float translation) {
                    if (controller.mIsTablet) {
                        controller.mAppsView.getActiveRecyclerView().setTranslationY(translation);
                        controller.getAppsViewPullbackTranslationY().setValue(
                                ALL_APPS_PULL_BACK_TRANSLATION_DEFAULT);
                    } else {
                        controller.getAppsViewPullbackTranslationY().setValue(translation);
                        controller.mAppsView.getActiveRecyclerView().setTranslationY(
                                ALL_APPS_PULL_BACK_TRANSLATION_DEFAULT);
                    }
                }
            };

    private static final float ALL_APPS_PULL_BACK_ALPHA_DEFAULT = 1f;

    public static final FloatProperty<AllAppsTransitionController> ALL_APPS_PULL_BACK_ALPHA =
            new FloatProperty<AllAppsTransitionController>("allAppsPullBackAlpha") {

                @Override
                public Float get(AllAppsTransitionController controller) {
                    if (controller.mIsTablet) {
                        return controller.mAppsView.getActiveRecyclerView().getAlpha();
                    } else {
                        return controller.getAppsViewPullbackAlpha().getValue();
                    }
                }

                @Override
                public void setValue(AllAppsTransitionController controller, float alpha) {
                    if (controller.mIsTablet) {
                        controller.mAppsView.getActiveRecyclerView().setAlpha(alpha);
                        controller.getAppsViewPullbackAlpha().setValue(
                                ALL_APPS_PULL_BACK_ALPHA_DEFAULT);
                    } else {
                        controller.getAppsViewPullbackAlpha().setValue(alpha);
                        controller.mAppsView.getActiveRecyclerView().setAlpha(
                                ALL_APPS_PULL_BACK_ALPHA_DEFAULT);
                    }
                }
            };

    private static final int INDEX_APPS_VIEW_PROGRESS = 0;
    private static final int INDEX_APPS_VIEW_PULLBACK = 1;
    private static final int APPS_VIEW_INDEX_COUNT = 2;

    private ActivityAllAppsContainerView<Launcher> mAppsView;

    private final Launcher mLauncher;
    private final AnimatedFloat mAllAppScale = new AnimatedFloat(this::onScaleProgressChanged);
    private final int mNavScrimFlag;

    private boolean mIsVerticalLayout;

    // Whether this class should take care of closing the keyboard.
    private boolean mShouldControlKeyboard;

    // Animation in this class is controlled by a single variable {@link mProgress}.
    // Visually, it represents top y coordinate of the all apps container if multiplied with
    // {@link mShiftRange}.

    // When {@link mProgress} is 0, all apps container is pulled up.
    // When {@link mProgress} is 1, all apps container is pulled down.
    private float mShiftRange;      // changes depending on the orientation
    private float mProgress;        // [0, 1], mShiftRange * mProgress = shiftCurrent

    private ScrimView mScrimView;

    private MultiValueAlpha mAppsViewAlpha;
    private MultiPropertyFactory<View> mAppsViewTranslationY;

    private boolean mIsTablet;

    private boolean mHasScaleEffect;
    private final VibratorWrapper mVibratorWrapper;

    public AllAppsTransitionController(Launcher l) {
        mLauncher = l;
        DeviceProfile dp = mLauncher.getDeviceProfile();
        mProgress = 1f;
        mIsVerticalLayout = dp.isVerticalBarLayout();
        mIsTablet = dp.isTablet;
        mNavScrimFlag = Themes.getAttrBoolean(l, R.attr.isMainColorDark)
                ? FLAG_DARK_NAV : FLAG_LIGHT_NAV;

        setShiftRange(dp.allAppsShiftRange);
        mAllAppScale.value = 1;
        mLauncher.addOnDeviceProfileChangeListener(this);
        mVibratorWrapper = VibratorWrapper.INSTANCE.get(mLauncher.getApplicationContext());
    }

    public float getShiftRange() {
        return mShiftRange;
    }

    @Override
    public void onDeviceProfileChanged(DeviceProfile dp) {
        mIsVerticalLayout = dp.isVerticalBarLayout();
        setShiftRange(dp.allAppsShiftRange);

        if (mIsVerticalLayout) {
            mLauncher.getHotseat().setTranslationY(0);
            mLauncher.getWorkspace().getPageIndicator().setTranslationY(0);
        }

        mIsTablet = dp.isTablet;
    }

    /**
     * Note this method should not be called outside this class. This is public because it is used
     * in xml-based animations which also handle updating the appropriate UI.
     *
     * @param progress value between 0 and 1, 0 shows all apps and 1 shows workspace
     * @see #setState(LauncherState)
     * @see #setStateWithAnimation(LauncherState, StateAnimationConfig, PendingAnimation)
     */
    public void setProgress(float progress) {
        mProgress = progress;
        getAppsViewProgressTranslationY().setValue(mProgress * mShiftRange);
        mLauncher.onAllAppsTransition(1 - progress);

        boolean hasScrim = progress < NAV_BAR_COLOR_FORCE_UPDATE_THRESHOLD
                && mLauncher.getAppsView().getNavBarScrimHeight() > 0;
        mLauncher.getSystemUiController().updateUiState(
                UI_STATE_ALL_APPS, hasScrim ? mNavScrimFlag : 0);
    }

    public float getProgress() {
        return mProgress;
    }

    private MultiProperty getAppsViewProgressTranslationY() {
        return mAppsViewTranslationY.get(INDEX_APPS_VIEW_PROGRESS);
    }

    private MultiProperty getAppsViewPullbackTranslationY() {
        return mAppsViewTranslationY.get(INDEX_APPS_VIEW_PULLBACK);
    }

    private MultiProperty getAppsViewProgressAlpha() {
        return mAppsViewAlpha.get(INDEX_APPS_VIEW_PROGRESS);
    }

    private MultiProperty getAppsViewPullbackAlpha() {
        return mAppsViewAlpha.get(INDEX_APPS_VIEW_PULLBACK);
    }

    /**
     * Sets the vertical transition progress to {@param state} and updates all the dependent UI
     * accordingly.
     */
    @Override
    public void setState(LauncherState state) {
        setProgress(state.getVerticalProgress(mLauncher));
        setAlphas(state, new StateAnimationConfig(), NO_ANIM_PROPERTY_SETTER);
        onProgressAnimationEnd();
    }

    @Override
    public void onBackProgressed(
            LauncherState toState, @FloatRange(from = 0.0, to = 1.0) float backProgress) {
        if (!mLauncher.isInState(ALL_APPS) || !NORMAL.equals(toState)) {
            return;
        }

        float deceleratedProgress =
                Interpolators.PREDICTIVE_BACK_DECELERATED_EASE.getInterpolation(backProgress);
        float scaleProgress = ScrollableLayoutManager.PREDICTIVE_BACK_MIN_SCALE
                + (1 - ScrollableLayoutManager.PREDICTIVE_BACK_MIN_SCALE)
                * (1 - deceleratedProgress);

        mAllAppScale.updateValue(scaleProgress);
    }

    private void onScaleProgressChanged() {
        final float scaleProgress = mAllAppScale.value;
        SCALE_PROPERTY.set(mLauncher.getAppsView(), scaleProgress);
        mLauncher.getScrimView().setScrimHeaderScale(scaleProgress);

        AllAppsRecyclerView rv = mLauncher.getAppsView().getActiveRecyclerView();
        if (rv != null && rv.getScrollbar() != null) {
            rv.getScrollbar().setVisibility(scaleProgress < 1f ? View.INVISIBLE : View.VISIBLE);
        }

        // Disable view clipping from all apps' RecyclerView up to all apps view during scale
        // animation, and vice versa. The goal is to display extra roll(s) app icons (rendered in
        // {@link AppsGridLayoutManager#calculateExtraLayoutSpace}) during scale animation.
        boolean hasScaleEffect = scaleProgress < 1f;
        if (hasScaleEffect != mHasScaleEffect) {
            mHasScaleEffect = hasScaleEffect;
            if (mHasScaleEffect) {
                setClipChildrenOnViewTree(rv, mLauncher.getAppsView(), false);
            } else {
                restoreClipChildrenOnViewTree(rv, mLauncher.getAppsView());
            }
        }
    }

    /** Animate all apps view to 1f scale. */
    public void animateAllAppsToNoScale() {
        mAllAppScale.animateToValue(1f)
                .setDuration(REVERT_SWIPE_ALL_APPS_TO_HOME_ANIMATION_DURATION_MS)
                .start();
    }

    /**
     * Creates an animation which updates the vertical transition progress and updates all the
     * dependent UI using various animation events
     *
     * This method also dictates where along the progress the haptics should be played. As the user
     * scrolls up from workspace or down from AllApps, a drag haptic is being played until the
     * commit point where it plays a commit haptic. Where we play the haptics differs when going
     * from workspace -> allApps and vice versa.
     */
    @Override
    public void setStateWithAnimation(LauncherState toState,
            StateAnimationConfig config, PendingAnimation builder) {
        if (mLauncher.isInState(ALL_APPS) && !ALL_APPS.equals(toState)) {
            // For atomic animations, we close the keyboard immediately.
            if (!config.userControlled && mShouldControlKeyboard) {
                mLauncher.getAppsView().getSearchUiManager().getEditText().hideKeyboard();
            }

            builder.addEndListener(success -> {
                // Reset pull back progress and alpha after switching states.
                ALL_APPS_PULL_BACK_TRANSLATION.set(this, ALL_APPS_PULL_BACK_TRANSLATION_DEFAULT);
                ALL_APPS_PULL_BACK_ALPHA.set(this, ALL_APPS_PULL_BACK_ALPHA_DEFAULT);

                // We only want to close the keyboard if the animation has completed successfully.
                // The reason is that with keyboard sync, if the user swipes down from All Apps with
                // the keyboard open and then changes their mind and swipes back up, we want the
                // keyboard to remain open. However an onCancel signal is sent to the listeners
                // (success = false), so we need to check for that.
                if (config.userControlled && success && mShouldControlKeyboard) {
                    mLauncher.getAppsView().getSearchUiManager().getEditText().hideKeyboard();
                }

                mAllAppScale.updateValue(1f);
            });
        }

        if(FeatureFlags.ENABLE_HAPTICS_ALL_APPS.get() && config.userControlled
                && Utilities.ATLEAST_S) {
            if (toState == ALL_APPS) {
                builder.addOnFrameListener(
                        new VibrationAnimatorUpdateListener(this, mVibratorWrapper,
                                SWIPE_DRAG_COMMIT_THRESHOLD, 1));
            } else {
                builder.addOnFrameListener(
                        new VibrationAnimatorUpdateListener(this, mVibratorWrapper,
                                0, SWIPE_DRAG_COMMIT_THRESHOLD));
            }
            builder.addEndListener(mVibratorWrapper::cancelVibrate);
        }

        float targetProgress = toState.getVerticalProgress(mLauncher);
        if (Float.compare(mProgress, targetProgress) == 0) {
            setAlphas(toState, config, builder);
            // Fail fast
            return;
        }

        // need to decide depending on the release velocity
        Interpolator verticalProgressInterpolator = config.getInterpolator(ANIM_VERTICAL_PROGRESS,
                config.userControlled ? LINEAR : DEACCEL_1_7);
        Animator anim = createSpringAnimation(mProgress, targetProgress);
        anim.setInterpolator(verticalProgressInterpolator);
        anim.addListener(getProgressAnimatorListener());
        builder.add(anim);

        setAlphas(toState, config, builder);

        if (ALL_APPS.equals(toState) && mLauncher.isInState(NORMAL) && !(Utilities.ATLEAST_S)) {
            mLauncher.getAppsView().performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY,
                    HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING);
        }
    }

    public Animator createSpringAnimation(float... progressValues) {
        return ObjectAnimator.ofFloat(this, ALL_APPS_PROGRESS, progressValues);
    }

    /**
     * Updates the property for the provided state
     */
    public void setAlphas(LauncherState state, StateAnimationConfig config, PropertySetter setter) {
        int visibleElements = state.getVisibleElements(mLauncher);
        boolean hasAllAppsContent = (visibleElements & ALL_APPS_CONTENT) != 0;

        Interpolator allAppsFade = config.getInterpolator(ANIM_ALL_APPS_FADE, LINEAR);
        setter.setFloat(getAppsViewProgressAlpha(), MultiPropertyFactory.MULTI_PROPERTY_VALUE,
                hasAllAppsContent ? 1 : 0, allAppsFade);

        boolean shouldProtectHeader =
                ALL_APPS == state || mLauncher.getStateManager().getState() == ALL_APPS;
        mScrimView.setDrawingController(shouldProtectHeader ? mAppsView : null);
    }

    public AnimatorListener getProgressAnimatorListener() {
        return AnimatorListeners.forSuccessCallback(this::onProgressAnimationEnd);
    }

    /**
     * see Launcher#setupViews
     */
    public void setupViews(ScrimView scrimView, ActivityAllAppsContainerView<Launcher> appsView) {
        mScrimView = scrimView;
        mAppsView = appsView;
        mAppsView.setScrimView(scrimView);

        mAppsViewAlpha = new MultiValueAlpha(mAppsView, APPS_VIEW_INDEX_COUNT);
        mAppsViewAlpha.setUpdateVisibility(true);
        mAppsViewTranslationY = new MultiPropertyFactory<>(
                mAppsView, VIEW_TRANSLATE_Y, APPS_VIEW_INDEX_COUNT, Float::sum);

        mShouldControlKeyboard = !mLauncher.getSearchConfig().isKeyboardSyncEnabled();
    }

    /**
     * Recursively call {@link ViewGroup#setClipChildren(boolean)} from {@link View} to ts parent
     * (direct or indirect) inclusive. This method will also save the old clipChildren value on each
     * view with {@link View#setTag(int, Object)}, which can be restored in
     * {@link #restoreClipChildrenOnViewTree(View, ViewParent)}.
     *
     * Note that if parent is null or not a parent of the view, this method will be applied all the
     * way to root view.
     *
     * @param v child view
     * @param parent direct or indirect parent of child view
     * @param clipChildren whether we should clip children
     */
    private static void setClipChildrenOnViewTree(
            @Nullable View v,
            @Nullable ViewParent parent,
            boolean clipChildren) {
        if (v == null) {
            return;
        }

        if (v instanceof ViewGroup) {
            ViewGroup viewGroup = (ViewGroup) v;
            boolean oldClipChildren = viewGroup.getClipChildren();
            if (oldClipChildren != clipChildren) {
                v.setTag(R.id.saved_clip_children_tag_id, oldClipChildren);
                viewGroup.setClipChildren(clipChildren);
            }
        }

        if (v == parent) {
            return;
        }

        if (v.getParent() instanceof View) {
            setClipChildrenOnViewTree((View) v.getParent(), parent, clipChildren);
        }
    }

    /**
     * Recursively call {@link ViewGroup#setClipChildren(boolean)} to restore clip children value
     * set in {@link #setClipChildrenOnViewTree(View, ViewParent, boolean)} on view to its parent
     * (direct or indirect) inclusive.
     *
     * Note that if parent is null or not a parent of the view, this method will be applied all the
     * way to root view.
     *
     * @param v child view
     * @param parent direct or indirect parent of child view
     */
    private static void restoreClipChildrenOnViewTree(
            @Nullable View v, @Nullable ViewParent parent) {
        if (v == null) {
            return;
        }
        if (v instanceof ViewGroup) {
            ViewGroup viewGroup = (ViewGroup) v;
            Object viewTag = viewGroup.getTag(R.id.saved_clip_children_tag_id);
            if (viewTag instanceof Boolean) {
                viewGroup.setClipChildren((boolean) viewTag);
                viewGroup.setTag(R.id.saved_clip_children_tag_id, null);
            }
        }

        if (v == parent) {
            return;
        }

        if (v.getParent() instanceof View) {
            restoreClipChildrenOnViewTree((View) v.getParent(), parent);
        }
    }

    /**
     * Updates the total scroll range but does not update the UI.
     */
    public void setShiftRange(float shiftRange) {
        mShiftRange = shiftRange;
    }

    /**
     * Set the final view states based on the progress.
     * TODO: This logic should go in {@link LauncherState}
     */
    private void onProgressAnimationEnd() {
        if (Float.compare(mProgress, 1f) == 0) {
            mAppsView.reset(false /* animate */);
            if (mShouldControlKeyboard) {
                mLauncher.getAppsView().getSearchUiManager().getEditText().hideKeyboard();
            }
        }
    }

    /**
     * This VibrationAnimatorUpdateListener class takes in four parameters, a controller, start
     * threshold, end threshold, and a Vibrator wrapper. We use the progress given by the controller
     * as it gives an accurate progress that dictates where the vibrator should vibrate.
     * Note: once the user begins a gesture and does the commit haptic, there should not be anymore
     * haptics played for that gesture.
     */
    private static class VibrationAnimatorUpdateListener implements
            ValueAnimator.AnimatorUpdateListener {
        private final VibratorWrapper mVibratorWrapper;
        private final AllAppsTransitionController mController;
        private final float mStartThreshold;
        private final float mEndThreshold;
        private boolean mHasCommitted;

        VibrationAnimatorUpdateListener(AllAppsTransitionController controller,
                                        VibratorWrapper vibratorWrapper, float startThreshold,
                                        float endThreshold) {
            mController = controller;
            mVibratorWrapper = vibratorWrapper;
            mStartThreshold = startThreshold;
            mEndThreshold = endThreshold;
        }

        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            if (mHasCommitted) {
                return;
            }
            float currentProgress =
                    AllAppsTransitionController.ALL_APPS_PROGRESS.get(mController);
            if (currentProgress > mStartThreshold && currentProgress < mEndThreshold) {
                mVibratorWrapper.vibrateForDragTexture();
            } else if (!(currentProgress == 0 || currentProgress == 1)) {
                // This check guards against committing at the location of the start of the gesture
                mVibratorWrapper.vibrateForDragCommit();
                mHasCommitted = true;
            }
        }
    }
}
