// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.banners;

import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Region;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.FrameLayout;

import org.chromium.chrome.browser.util.MathUtils;
import org.chromium.content.browser.ContentViewCore;
import org.chromium.content_public.browser.GestureStateListener;

/**
 * View that slides up from the bottom of the page and slides away as the user scrolls the page.
 * Meant to be tacked onto the {@link org.chromium.content.browser.ContentViewCore}'s view and
 * alerted when either the page scroll position or viewport size changes.
 *
 * GENERAL BEHAVIOR
 * This View is brought onto the screen by sliding upwards from the bottom of the screen.  Afterward
 * the View slides onto and off of the screen vertically as the user scrolls upwards or
 * downwards on the page.
 *
 * As the scroll offset or the viewport height are updated via a scroll or fling, the difference
 * from the initial value is used to determine the View's Y-translation.  If a gesture is stopped,
 * the View will be snapped back into the center of the screen or entirely off of the screen, based
 * on how much of the View is visible, or where the user is currently located on the page.
 */
public abstract class SwipableOverlayView extends FrameLayout {
    private static final float FULL_THRESHOLD = 0.5f;
    private static final float VERTICAL_FLING_SHOW_THRESHOLD = 0.2f;
    private static final float VERTICAL_FLING_HIDE_THRESHOLD = 0.9f;

    private static final int GESTURE_NONE = 0;
    private static final int GESTURE_SCROLLING = 1;
    private static final int GESTURE_FLINGING = 2;

    private static final long ANIMATION_DURATION_MS = 250;

    /** Detects when the user is dragging the ContentViewCore. */
    private final GestureStateListener mGestureStateListener;

    /** Listens for changes in the layout. */
    private final View.OnLayoutChangeListener mLayoutChangeListener;

    /** Monitors for animation completions and resets the state. */
    private final AnimatorListener mAnimatorListener;

    /** Interpolator used for the animation. */
    private final Interpolator mInterpolator;

    /** Tracks whether the user is scrolling or flinging. */
    private int mGestureState;

    /** Animation currently being used to translate the View. */
    private Animator mCurrentAnimation;

    /** Used to determine when the layout has changed and the Viewport must be updated. */
    private int mParentHeight;

    /** Offset from the top of the page when the current gesture was first started. */
    private int mInitialOffsetY;

    /** How tall the View is, including its margins. */
    private int mTotalHeight;

    /** Whether or not the View ever been fully displayed. */
    private boolean mIsBeingDisplayedForFirstTime;

    /** The ContentViewCore to which the overlay is added. */
    private ContentViewCore mContentViewCore;

    /**
     * Creates a SwipableOverlayView.
     * @param context Context for acquiring resources.
     * @param attrs Attributes from the XML layout inflation.
     */
    public SwipableOverlayView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mGestureStateListener = createGestureStateListener();
        mGestureState = GESTURE_NONE;
        mLayoutChangeListener = createLayoutChangeListener();
        mAnimatorListener = createAnimatorListener();
        mInterpolator = new DecelerateInterpolator(1.0f);

        // We make this view 'draw' to provide a placeholder for its animations.
        setWillNotDraw(false);
    }

    /**
     * Watches the given ContentViewCore for scrolling changes.
     */
    public void setContentViewCore(ContentViewCore contentViewCore) {
        if (mContentViewCore != null) {
            mContentViewCore.removeGestureStateListener(mGestureStateListener);
        }

        mContentViewCore = contentViewCore;
        if (mContentViewCore != null) {
            mContentViewCore.addGestureStateListener(mGestureStateListener);
        }
    }

    /**
     * @return the ContentViewCore that this View is monitoring.
     */
    protected ContentViewCore getContentViewCore() {
        return mContentViewCore;
    }

    protected void addToParentView(ViewGroup parentView) {
        if (parentView == null) return;
        if (getParent() == null) {
            parentView.addView(this, createLayoutParams());

            // Listen for the layout to know when to animate the View coming onto the screen.
            addOnLayoutChangeListener(mLayoutChangeListener);
        }
    }

    /**
     * Removes the SwipableOverlayView from its parent and stops monitoring the ContentViewCore.
     * @return Whether the View was removed from its parent.
     */
    public boolean removeFromParentView() {
        if (getParent() == null) return false;

        ((ViewGroup) getParent()).removeView(this);
        removeOnLayoutChangeListener(mLayoutChangeListener);
        return true;
    }

    /**
     * Creates a set of LayoutParams that makes the View hug the bottom of the screen.  Override it
     * for other types of behavior.
     * @return LayoutParams for use when adding the View to its parent.
     */
    public ViewGroup.MarginLayoutParams createLayoutParams() {
        return new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT,
                Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL);
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        if (!isAllowedToAutoHide()) setTranslationY(0.0f);
    }

    @Override
    public void onWindowFocusChanged(boolean hasWindowFocus) {
        super.onWindowFocusChanged(hasWindowFocus);
        if (!isAllowedToAutoHide()) setTranslationY(0.0f);
    }

    /**
     * See {@link #android.view.ViewGroup.onLayout(boolean, int, int, int, int)}.
     */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        // Update the viewport height when the parent View's height changes (e.g. after rotation).
        int currentParentHeight = getParent() == null ? 0 : ((View) getParent()).getHeight();
        if (mParentHeight != currentParentHeight) {
            mParentHeight = currentParentHeight;
            mGestureState = GESTURE_NONE;
            if (mCurrentAnimation != null) mCurrentAnimation.end();
        }

        // Update the known effective height of the View.
        MarginLayoutParams params = (MarginLayoutParams) getLayoutParams();
        mTotalHeight = getMeasuredHeight() + params.topMargin + params.bottomMargin;

        super.onLayout(changed, l, t, r, b);
    }

    /**
     * Creates a listener than monitors the ContentViewCore for scrolls and flings.
     * The listener updates the location of this View to account for the user's gestures.
     * @return GestureStateListener to send to the ContentViewCore.
     */
    private GestureStateListener createGestureStateListener() {
        return new GestureStateListener() {
            /** Tracks the previous event's scroll offset to determine if a scroll is up or down. */
            private int mLastScrollOffsetY;

            /** Location of the View when the current gesture was first started. */
            private float mInitialTranslationY;

            /** The initial extent of the scroll when triggered. */
            private float mInitialExtentY;

            @Override
            public void onFlingStartGesture(int scrollOffsetY, int scrollExtentY) {
                if (!isAllowedToAutoHide() || !cancelCurrentAnimation()) return;
                resetInitialOffsets(scrollOffsetY, scrollExtentY);
                mGestureState = GESTURE_FLINGING;
            }

            @Override
            public void onFlingEndGesture(int scrollOffsetY, int scrollExtentY) {
                if (mGestureState != GESTURE_FLINGING) return;
                mGestureState = GESTURE_NONE;

                updateTranslation(scrollOffsetY, scrollExtentY);

                boolean isScrollingDownward = scrollOffsetY > mLastScrollOffsetY;

                boolean isVisibleInitially = mInitialTranslationY < mTotalHeight;
                float percentageVisible = 1.0f - (getTranslationY() / mTotalHeight);
                float visibilityThreshold = isVisibleInitially
                        ? VERTICAL_FLING_HIDE_THRESHOLD : VERTICAL_FLING_SHOW_THRESHOLD;
                boolean isVisibleEnough = percentageVisible > visibilityThreshold;
                boolean isNearTopOfPage = scrollOffsetY < (mTotalHeight * FULL_THRESHOLD);

                boolean show = (!isScrollingDownward && isVisibleEnough) || isNearTopOfPage;

                createVerticalSnapAnimation(show);
            }

            @Override
            public void onScrollStarted(int scrollOffsetY, int scrollExtentY) {
                if (!isAllowedToAutoHide() || !cancelCurrentAnimation()) return;
                resetInitialOffsets(scrollOffsetY, scrollExtentY);
                mLastScrollOffsetY = scrollOffsetY;
                mGestureState = GESTURE_SCROLLING;
            }

            @Override
            public void onScrollEnded(int scrollOffsetY, int scrollExtentY) {
                if (mGestureState != GESTURE_SCROLLING) return;
                mGestureState = GESTURE_NONE;

                updateTranslation(scrollOffsetY, scrollExtentY);

                boolean isNearTopOfPage = scrollOffsetY < (mTotalHeight * FULL_THRESHOLD);
                boolean isVisibleEnough = getTranslationY() < mTotalHeight * FULL_THRESHOLD;
                createVerticalSnapAnimation(isNearTopOfPage || isVisibleEnough);
            }

            @Override
            public void onScrollOffsetOrExtentChanged(int scrollOffsetY, int scrollExtentY) {
                // This function is called for both fling and scrolls.
                if (mGestureState == GESTURE_NONE || !cancelCurrentAnimation()) return;
                mLastScrollOffsetY = scrollOffsetY;
                updateTranslation(scrollOffsetY, scrollExtentY);
            }

            private void updateTranslation(int scrollOffsetY, int scrollExtentY) {
                float scrollDiff =
                        (scrollOffsetY - mInitialOffsetY) + (scrollExtentY - mInitialExtentY);
                float translation =
                        MathUtils.clamp(mInitialTranslationY + scrollDiff, mTotalHeight, 0);

                // If the container has reached the completely shown position, reset the initial
                // scroll so any movement will start hiding it again.
                if (translation <= 0f) resetInitialOffsets(scrollOffsetY, scrollExtentY);

                setTranslationY(translation);
            }

            /**
             * Records the conditions of the page to handle scrolls appropriately.
             */
            private void resetInitialOffsets(int scrollOffsetY, int scrollExtentY) {
                mInitialOffsetY = scrollOffsetY;
                mInitialExtentY = scrollExtentY;
                mInitialTranslationY = getTranslationY();
            }
        };
    }

    /**
     * Creates a listener that is used only to animate the View coming onto the screen.
     * @return The SimpleOnGestureListener that will monitor the View.
     */
    private View.OnLayoutChangeListener createLayoutChangeListener() {
        return new View.OnLayoutChangeListener() {
            @Override
            public void onLayoutChange(View v, int left, int top, int right, int bottom,
                    int oldLeft, int oldTop, int oldRight, int oldBottom) {
                removeOnLayoutChangeListener(mLayoutChangeListener);

                // Animate the View coming in from the bottom of the screen.
                setTranslationY(mTotalHeight);
                mIsBeingDisplayedForFirstTime = true;
                createVerticalSnapAnimation(true);
                mCurrentAnimation.start();
            }
        };
    }

    /**
     * Create an animation that snaps the View into position vertically.
     * @param visible If true, snaps the View to the bottom-center of the screen.  If false,
     *                translates the View below the bottom-center of the screen so that it is
     *                effectively invisible.
     */
    private void createVerticalSnapAnimation(boolean visible) {
        float translationY = visible ? 0.0f : mTotalHeight;
        float yDifference = Math.abs(translationY - getTranslationY()) / mTotalHeight;
        long duration = Math.max(0, (long) (ANIMATION_DURATION_MS * yDifference));

        mCurrentAnimation = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, translationY);
        mCurrentAnimation.setDuration(duration);
        mCurrentAnimation.addListener(mAnimatorListener);
        mCurrentAnimation.setInterpolator(mInterpolator);
        mCurrentAnimation.start();
    }

    /**
     * Creates an AnimatorListenerAdapter that cleans up after an animation is completed.
     * @return {@link AnimatorListenerAdapter} to use for animations.
     */
    private AnimatorListener createAnimatorListener() {
        return new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                mGestureState = GESTURE_NONE;
                mCurrentAnimation = null;
                mIsBeingDisplayedForFirstTime = false;
            }
        };
    }

    /**
     * Cancels the current animation, unless the View is coming onto the screen for the first time.
     * @return True if the animation was canceled or wasn't running, false otherwise.
     */
    private boolean cancelCurrentAnimation() {
        if (mIsBeingDisplayedForFirstTime) return false;
        if (mCurrentAnimation != null) mCurrentAnimation.cancel();
        return true;
    }

    /**
     * @return Whether the SwipableOverlayView is allowed to hide itself on scroll.
     */
    protected boolean isAllowedToAutoHide() {
        return true;
    }

    /**
     * Override gatherTransparentRegion to make this view's layout a placeholder for its
     * animations. This is only called during layout, so it doesn't really make sense to apply
     * post-layout properties like it does by default. Together with setWillNotDraw(false),
     * this ensures no child animation within this view's layout will be clipped by a SurfaceView.
     */
    @Override
    public boolean gatherTransparentRegion(Region region) {
        float translationY = getTranslationY();
        setTranslationY(0);
        boolean result = super.gatherTransparentRegion(region);
        // Restoring TranslationY invalidates this view unnecessarily. However, this function
        // is called as part of layout, which implies a full redraw is about to occur anyway.
        setTranslationY(translationY);
        return result;
    }
}