// Copyright 2016 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.ntp.cards;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Region;
import android.support.v4.view.animation.FastOutLinearInInterpolator;
import android.support.v4.view.animation.LinearOutSlowInInterpolator;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Interpolator;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;

import org.chromium.base.Log;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ntp.ContextMenuManager.TouchDisableableView;
import org.chromium.chrome.browser.ntp.NewTabPageLayout;
import org.chromium.chrome.browser.ntp.snippets.SectionHeaderViewHolder;
import org.chromium.chrome.browser.ntp.snippets.SnippetsConfig;
import org.chromium.chrome.browser.preferences.ChromePreferenceManager;
import org.chromium.chrome.browser.util.ViewUtils;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Simple wrapper on top of a RecyclerView that will acquire focus when tapped.  Ensures the
 * New Tab page receives focus when clicked.
 */
public class NewTabPageRecyclerView extends RecyclerView implements TouchDisableableView {
    private static final String TAG = "NtpCards";
    private static final Interpolator DISMISS_INTERPOLATOR = new FastOutLinearInInterpolator();
    private static final int DISMISS_ANIMATION_TIME_MS = 300;
    private static final Interpolator PEEKING_CARD_INTERPOLATOR = new LinearOutSlowInInterpolator();
    private static final int PEEKING_CARD_ANIMATION_TIME_MS = 1000;
    private static final int PEEKING_CARD_ANIMATION_START_DELAY_MS = 300;

    private static final String PREF_ANIMATION_RUN_COUNT = "ntp_recycler_view_animation_run_count";

    private final GestureDetector mGestureDetector;
    private final LinearLayoutManager mLayoutManager;
    private final int mToolbarHeight;
    private final int mSearchBoxTransitionLength;
    private final int mPeekingHeight;
    private final int mMaxHeaderHeight;
    /** How much of the first card is visible above the fold with the increased visibility UI. */
    private final int mPeekingCardBounceDistance;
    /** The peeking card animates in the first time it is made visible. */
    private boolean mFirstCardAnimationRun;
    /** We have tracked that the user has caused an impression after viewing the animation. */
    private boolean mCardImpressionAfterAnimationTracked;

    /**
     * Total height of the items being dismissed.  Tracked to allow the bottom space to compensate
     * for their removal animation and avoid moving the scroll position.
     */
    private int mCompensationHeight;

    /**
     * Height compensation value for each item being dismissed. Since dismissals sometimes include
     * sibling elements, and these don't get the standard treatment, we track the total height
     * associated with the element the user interacted with.
     */
    private final Map<ViewHolder, Integer> mCompensationHeightMap = new HashMap<>();

    /** View used to calculate the position of the cards' snap point. */
    private View mAboveTheFoldView;

    /** Whether the RecyclerView and its children should react to touch events. */
    private boolean mTouchEnabled = true;

    /** Whether the above-the-fold left space for a peeking card to be displayed. */
    private boolean mHasSpaceForPeekingCard;

    /** Whether the above-the-fold view has ever been rendered. */
    private boolean mHasRenderedAboveTheFoldView;

    /**
     * Constructor needed to inflate from XML.
     */
    public NewTabPageRecyclerView(Context context, AttributeSet attrs) {
        super(context, attrs);

        mGestureDetector =
                new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
                    @Override
                    public boolean onSingleTapUp(MotionEvent e) {
                        boolean retVal = super.onSingleTapUp(e);
                        requestFocus();
                        return retVal;
                    }
                });
        mLayoutManager = new LinearLayoutManager(getContext());
        setLayoutManager(mLayoutManager);

        Resources res = context.getResources();
        mToolbarHeight = res.getDimensionPixelSize(R.dimen.toolbar_height_no_shadow)
                + res.getDimensionPixelSize(R.dimen.toolbar_progress_bar_height);
        mMaxHeaderHeight = res.getDimensionPixelSize(R.dimen.snippets_article_header_height);
        mPeekingCardBounceDistance =
                res.getDimensionPixelSize(R.dimen.snippets_peeking_card_bounce_distance);
        mSearchBoxTransitionLength =
                res.getDimensionPixelSize(R.dimen.ntp_search_box_transition_length);
        mPeekingHeight = res.getDimensionPixelSize(R.dimen.snippets_padding);

        setHasFixedSize(true);

        addOnChildAttachStateChangeListener(new OnChildAttachStateChangeListener() {
            @Override
            public void onChildViewAttachedToWindow(View view) {
                if (view == mAboveTheFoldView) {
                    mHasRenderedAboveTheFoldView = true;
                    removeOnChildAttachStateChangeListener(this);
                }
            }

            @Override
            public void onChildViewDetachedFromWindow(View view) {}
        });
    }

    public boolean isFirstItemVisible() {
        return mLayoutManager.findFirstVisibleItemPosition() == 0;
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        mGestureDetector.onTouchEvent(ev);
        if (!mTouchEnabled) return true;
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public void setTouchEnabled(boolean enabled) {
        mTouchEnabled = enabled;
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (!mTouchEnabled) return false;

        // Action down would already have been handled in onInterceptTouchEvent
        if (ev.getActionMasked() != MotionEvent.ACTION_DOWN) {
            mGestureDetector.onTouchEvent(ev);
        }
        return super.onTouchEvent(ev);
    }

    @Override
    public void focusableViewAvailable(View v) {
        // To avoid odd jumps during NTP animation transitions, we do not attempt to give focus
        // to child views if this scroll view already has focus.
        if (hasFocus()) return;
        super.focusableViewAvailable(v);
    }

    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        // Fixes landscape transitions when unfocusing the URL bar: crbug.com/288546
        outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN;
        return super.onCreateInputConnection(outAttrs);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int numberViews = getChildCount();
        for (int i = 0; i < numberViews; ++i) {
            View view = getChildAt(i);
            NewTabPageViewHolder viewHolder = (NewTabPageViewHolder) getChildViewHolder(view);
            if (viewHolder == null) return;
            viewHolder.updateLayoutParams();
        }
        super.onLayout(changed, l, t, r, b);
    }

    public void setAboveTheFoldView(View aboveTheFoldView) {
        mAboveTheFoldView = aboveTheFoldView;
    }

    public void setHasSpaceForPeekingCard(boolean hasSpaceForPeekingCard) {
        mHasSpaceForPeekingCard = hasSpaceForPeekingCard;
    }

    /** Scroll up from the cards' current position and snap to present the first one. */
    public void scrollToFirstCard() {
        // Offset the target scroll by the height of the omnibox (the top padding).
        final int targetScroll = mAboveTheFoldView.getHeight() - mAboveTheFoldView.getPaddingTop();
        // If (somehow) the peeking card is tapped while midway through the transition,
        // we need to account for how much we have already scrolled.
        smoothScrollBy(0, targetScroll - computeVerticalScrollOffset());
    }

    /**
     * Updates the space added at the end of the list to make sure the above/below the fold
     * distinction can be preserved.
     */
    public void refreshBottomSpacing() {
        ViewHolder bottomSpacingViewHolder = findBottomSpacer();

        // It might not be in the layout yet if it's not visible or ready to be displayed.
        if (bottomSpacingViewHolder == null) return;

        assert bottomSpacingViewHolder.getItemViewType() == ItemViewType.SPACING;
        bottomSpacingViewHolder.itemView.requestLayout();
    }

    /**
     * Calculates the height of the bottom spacing item, such that there is always enough content
     * below the fold to push the header up to to the top of the screen.
     */
    int calculateBottomSpacing() {
        int aboveTheFoldPosition = getNewTabPageAdapter().getAboveTheFoldPosition();
        int firstVisiblePos = mLayoutManager.findFirstVisibleItemPosition();
        if (aboveTheFoldPosition == RecyclerView.NO_POSITION
                || firstVisiblePos == RecyclerView.NO_POSITION) {
            return 0;
        }

        if (firstVisiblePos > aboveTheFoldPosition && mHasRenderedAboveTheFoldView) {
            // We have enough items to fill the viewport, since we have scrolled past the
            // above-the-fold item. We must check whether the above-the-fold view has been rendered
            // at least once, because it's possible to skip right over it if the initial scroll
            // position is not 0, in which case we may need the spacer to be taller than 0.
            return 0;
        }

        ViewHolder lastContentItem = findLastContentItem();
        ViewHolder aboveTheFold = findViewHolderForAdapterPosition(aboveTheFoldPosition);

        int bottomSpacing = getHeight() - mToolbarHeight;
        if (lastContentItem == null || aboveTheFold == null) {
            // This can happen in several cases, where some elements are not visible and the
            // RecyclerView didn't already attach them. We handle it by just adding space to make
            // sure that we never run out and force the UI to jump around and get stuck in a
            // position that breaks the animations. The height will be properly adjusted at the
            // next pass. Known cases that make it necessary:
            //  - The card list is refreshed while the NTP is not shown, for example when changing
            //    the sync settings.
            //  - Dismissing a suggestion and having the status card coming to take its place.
            //  - Refresh while being below the fold, for example by tapping the status card.

            if (aboveTheFold != null) bottomSpacing -= aboveTheFold.itemView.getBottom();

            Log.w(TAG, "The RecyclerView items are not attached, can't determine the content "
                            + "height: snap=%s, spacer=%s. Using full height: %d ",
                    aboveTheFold, lastContentItem, bottomSpacing);
        } else {
            int contentHeight =
                    lastContentItem.itemView.getBottom() - aboveTheFold.itemView.getBottom();
            bottomSpacing -= contentHeight - mCompensationHeight;
        }

        return Math.max(0, bottomSpacing);
    }

    public void updatePeekingCardAndHeader() {
        NewTabPageLayout aboveTheFoldView = findAboveTheFoldView();
        if (aboveTheFoldView == null) return;

        SectionHeaderViewHolder header = findFirstHeader();
        if (header == null) return;

        header.updateDisplay(computeVerticalScrollOffset(), mHasSpaceForPeekingCard);

        CardViewHolder firstCard = findFirstCard();
        if (firstCard != null) updatePeekingCard(firstCard);

        // Update the space at the bottom, which needs to know about the height of the header.
        refreshBottomSpacing();
    }

    /**
     * Updates the peeking state of the provided card. Relies on the dimensions of the header to
     * be correct, prefer {@link #updatePeekingCardAndHeader} that updates both together.
     */
    public void updatePeekingCard(CardViewHolder peekingCard) {
        SectionHeaderViewHolder header = findFirstHeader();
        if (header == null) {
            // No header, we must have scrolled quite far. Fallback to a non animated (full bleed)
            // card.
            peekingCard.updatePeek(0, /* shouldAnimate */ false);
            return;
        }

        // Peeking is disabled in the card offset field trial and the increased visibility feature.
        if (CardsVariationParameters.getFirstCardOffsetDp() != 0
                || SnippetsConfig.isIncreasedCardVisibilityEnabled()) {
            peekingCard.updatePeek(0, /* shouldAnimate */ false);
            return;
        }

        // Here we consider that if the header is animating (is not completely expanded), the card
        // should as well. In that case, the space below the header is what we have available.
        boolean shouldAnimate = header.itemView.getHeight() < mMaxHeaderHeight;
        peekingCard.updatePeek(getHeight() - header.itemView.getBottom(), shouldAnimate);
    }

    public NewTabPageAdapter getNewTabPageAdapter() {
        return (NewTabPageAdapter) getAdapter();
    }

    public LinearLayoutManager getLinearLayoutManager() {
        return mLayoutManager;
    }

    /**
     * Returns the approximate adapter position that the user has scrolled to. The purpose of this
     * value is that it can be stored and later retrieved to restore a scroll position that is
     * familiar to the user, showing (part of) the same content the user was previously looking at.
     * This position is valid for that purpose regardless of device orientation changes. Note that
     * if the underlying data has changed in the meantime, different content would be shown for this
     * position.
     */
    public int getScrollPosition() {
        return mLayoutManager.findFirstVisibleItemPosition();
    }

    /**
     * Finds the view holder for the first header.
     * @return The {@code ViewHolder} of the header, or null if it is not present.
     */
    private SectionHeaderViewHolder findFirstHeader() {
        int position = getNewTabPageAdapter().getFirstHeaderPosition();
        if (position == RecyclerView.NO_POSITION) return null;

        ViewHolder viewHolder = findViewHolderForAdapterPosition(position);
        if (!(viewHolder instanceof SectionHeaderViewHolder)) return null;

        return (SectionHeaderViewHolder) viewHolder;
    }

    /**
     * Finds the view holder for the first card.
     * @return The {@code ViewHolder} for the first card, or null if it is not present.
     */
    private CardViewHolder findFirstCard() {
        int position = getNewTabPageAdapter().getFirstCardPosition();
        if (position == RecyclerView.NO_POSITION) return null;

        ViewHolder viewHolder = findViewHolderForAdapterPosition(position);
        if (!(viewHolder instanceof CardViewHolder)) return null;

        return (CardViewHolder) viewHolder;
    }

    /**
     * Finds the view holder for the bottom spacer.
     * @return The {@code ViewHolder} of the bottom spacer, or null if it is not present.
     */
    private ViewHolder findBottomSpacer() {
        int position = getNewTabPageAdapter().getBottomSpacerPosition();
        if (position == RecyclerView.NO_POSITION) return null;

        return findViewHolderForAdapterPosition(position);
    }

    private ViewHolder findLastContentItem() {
        int position = getNewTabPageAdapter().getLastContentItemPosition();
        if (position == RecyclerView.NO_POSITION) return null;

        return findViewHolderForAdapterPosition(position);
    }

    /**
     * Finds the above the fold view.
     * @return The view for above the fold or null, if it is not present.
     */
    public NewTabPageLayout findAboveTheFoldView() {
        int position = getNewTabPageAdapter().getAboveTheFoldPosition();
        if (position == RecyclerView.NO_POSITION) return null;

        ViewHolder viewHolder = findViewHolderForAdapterPosition(position);
        if (viewHolder == null) return null;

        View view = viewHolder.itemView;
        if (!(view instanceof NewTabPageLayout)) return null;

        return (NewTabPageLayout) view;
    }

    /** Called when an item is in the process of being removed from the view. */
    public void onItemDismissStarted(ViewHolder viewHolder) {
        assert !mCompensationHeightMap.containsKey(viewHolder);

        int dismissedHeight = viewHolder.itemView.getHeight();

        ViewHolder siblingViewHolder = getNewTabPageAdapter().getDismissSibling(viewHolder);
        if (siblingViewHolder != null) {
            dismissedHeight += siblingViewHolder.itemView.getHeight();
        }

        mCompensationHeightMap.put(viewHolder, dismissedHeight);
        mCompensationHeight += dismissedHeight;
        refreshBottomSpacing();
    }

    /** Called when an item has finished being removed from the view. */
    public void onItemDismissFinished(ViewHolder viewHolder) {
        if (!mCompensationHeightMap.containsKey(viewHolder)) return;

        mCompensationHeight -= mCompensationHeightMap.remove(viewHolder);

        assert mCompensationHeight >= 0;
        refreshBottomSpacing();
    }

    /**
     * If the RecyclerView is currently scrolled to between regionStart and regionEnd, smooth scroll
     * out of the region. flipPoint is the threshold used to decide which bound of the region to
     * scroll to. It returns whether the view was scrolled.
     */
    private boolean scrollOutOfRegion(int regionStart, int flipPoint, int regionEnd) {
        final int currentScroll = computeVerticalScrollOffset();

        if (currentScroll < regionStart || currentScroll > regionEnd) return false;

        if (currentScroll < flipPoint) {
            smoothScrollBy(0, regionStart - currentScroll);
        } else {
            smoothScrollBy(0, regionEnd - currentScroll);
        }
        return true;
    }

    /**
     * If the RecyclerView is currently scrolled to between regionStart and regionEnd, smooth scroll
     * out of the region to the nearest edge.
     */
    private boolean scrollOutOfRegion(int regionStart, int regionEnd) {
        return scrollOutOfRegion(regionStart, (regionStart + regionEnd) / 2, regionEnd);
    }

    /**
     * Snaps the scroll point of the RecyclerView to prevent the user from scrolling to midway
     * through a transition and to allow peeking card behaviour.
     */
    public void snapScroll(View fakeBox, int parentScrollY, int parentHeight) {
        // Snap scroll to prevent resting in the middle of the omnibox transition.
        int fakeBoxUpperBound = fakeBox.getTop() + fakeBox.getPaddingTop();
        if (scrollOutOfRegion(fakeBoxUpperBound - mSearchBoxTransitionLength, fakeBoxUpperBound)) {
            // The snap scrolling regions should never overlap.
            return;
        }

        // Snap scroll to prevent only part of the toolbar from showing.
        if (scrollOutOfRegion(0, mToolbarHeight)) return;

        // Snap scroll to prevent resting in the middle of the peeking card transition
        // and to allow the peeking card to peek a bit before snapping back.
        CardViewHolder peekingCardViewHolder = findFirstCard();
        if (peekingCardViewHolder != null && isFirstItemVisible()) {
            if (!mHasSpaceForPeekingCard) return;

            ViewHolder firstHeaderViewHolder = findFirstHeader();
            // It is possible to have a card but no header, for example the sign in promo.
            // That one does not peek.
            if (firstHeaderViewHolder == null) return;

            View peekingCardView = peekingCardViewHolder.itemView;
            View headerView = firstHeaderViewHolder.itemView;

            // |A + B - C| gives the offset of the peeking card relative to the RecyclerView,
            // so scrolling to this point would put the peeking card at the top of the
            // screen. Remove the |headerView| height which gets dynamically increased with
            // scrolling.
            // |A + B - C - D| will scroll us so that the peeking card is just off the bottom
            // of the screen.
            // Finally, we get |A + B - C - D + E| because the transition starts from the
            // peeking card's resting point, which is |E| from the bottom of the screen.
            int start = peekingCardView.getTop()  // A.
                    + parentScrollY // B.
                    - headerView.getHeight()  // C.
                    - parentHeight  // D.
                    + mPeekingHeight;  // E.

            // The height of the region in which the the peeking card will snap.
            int snapScrollHeight = mPeekingHeight + headerView.getHeight();

            scrollOutOfRegion(start,
                              start + snapScrollHeight,
                              start + snapScrollHeight);
        }
    }

    @Override
    public boolean gatherTransparentRegion(Region region) {
        ViewUtils.gatherTransparentRegionsForOpaqueView(this, region);
        return true;
    }

    /**
     * Animates the card being swiped to the right as if the user had dismissed it. Any changes to
     * the animation here should be reflected also in
     * {@link #updateViewStateForDismiss(float, ViewHolder)} and reset in
     * {@link CardViewHolder#onBindViewHolder()}.
     */
    public void dismissItemWithAnimation(final ViewHolder viewHolder) {
        // We need to check the position, as the view holder might have been removed.
        final int position = viewHolder.getAdapterPosition();
        if (position == RecyclerView.NO_POSITION) {
            // The item does not exist anymore, so ignore.
            return;
        }

        if (!((NewTabPageViewHolder) viewHolder).isDismissable()) {
            // The item is not dismissable (anymore), so ignore.
            return;
        }

        List<Animator> animations = new ArrayList<>();
        addDismissalAnimators(animations, viewHolder.itemView);

        final ViewHolder dismissSibling = getNewTabPageAdapter().getDismissSibling(viewHolder);
        if (dismissSibling != null) addDismissalAnimators(animations, dismissSibling.itemView);

        AnimatorSet animation = new AnimatorSet();
        animation.playTogether(animations);
        animation.setDuration(DISMISS_ANIMATION_TIME_MS);
        animation.setInterpolator(DISMISS_INTERPOLATOR);
        animation.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationStart(Animator animation) {
                NewTabPageRecyclerView.this.onItemDismissStarted(viewHolder);
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                getNewTabPageAdapter().dismissItem(position);
                NewTabPageRecyclerView.this.onItemDismissFinished(viewHolder);
            }
        });
        animation.start();
    }

    /**
     * @param animations in/out list holding the animators to play.
     * @param view  view to animate.
     */
    private void addDismissalAnimators(List<Animator> animations, View view) {
        animations.add(ObjectAnimator.ofFloat(view, View.ALPHA, 0f));
        animations.add(ObjectAnimator.ofFloat(view, View.TRANSLATION_X, (float) view.getWidth()));
    }

    /**
     * Update the view's state as it is being swiped away. Any changes to the animation here should
     * be reflected also in {@link #dismissItemWithAnimation(ViewHolder)} and reset in
     * {@link CardViewHolder#onBindViewHolder()}.
     * @param dX The amount of horizontal displacement caused by user's action.
     * @param viewHolder The view holder containing the view to be updated.
     */
    public void updateViewStateForDismiss(float dX, ViewHolder viewHolder) {
        if (!((NewTabPageViewHolder) viewHolder).isDismissable()) return;

        viewHolder.itemView.setTranslationX(dX);

        float input = Math.abs(dX) / viewHolder.itemView.getMeasuredWidth();
        float alpha = 1 - DISMISS_INTERPOLATOR.getInterpolation(input);
        viewHolder.itemView.setAlpha(alpha);
    }

    /**
     * To be triggered when a snippet is bound to a ViewHolder.
     */
    public void onSnippetBound(View cardView) {
        // We only run if the feature is enabled and once per NTP.
        if (!SnippetsConfig.isIncreasedCardVisibilityEnabled() || mFirstCardAnimationRun) return;
        mFirstCardAnimationRun = true;

        // We only want an animation to run if we are not scrolled.
        if (computeVerticalScrollOffset() != 0) return;

        // We only show the animation a certain number of times to a user.
        ChromePreferenceManager manager = ChromePreferenceManager.getInstance(getContext());
        int animCount = manager.getNewTabPageFirstCardAnimationRunCount();
        if (animCount > CardsVariationParameters.getFirstCardAnimationMaxRuns()) return;
        manager.setNewTabPageFirstCardAnimationRunCount(animCount + 1);

        // We do not show the animation if the user has previously seen it then scrolled.
        if (manager.getCardsImpressionAfterAnimation()) return;

        // The peeking card bounces up twice from its position.
        ObjectAnimator animator = ObjectAnimator.ofFloat(cardView, View.TRANSLATION_Y,
                0f, -mPeekingCardBounceDistance, 0f, -mPeekingCardBounceDistance, 0f);
        animator.setStartDelay(PEEKING_CARD_ANIMATION_START_DELAY_MS);
        animator.setDuration(PEEKING_CARD_ANIMATION_TIME_MS);
        animator.setInterpolator(PEEKING_CARD_INTERPOLATOR);
        animator.start();
    }

    /**
     * To be triggered when a snippet impression is triggered.
     */
    public void onSnippetImpression() {
        // If the user has seen the first card animation and causes a snippet impression, remember
        // for future runs.
        if (!mFirstCardAnimationRun && !mCardImpressionAfterAnimationTracked) return;

        ChromePreferenceManager.getInstance(getContext()).setCardsImpressionAfterAnimation(true);
        mCardImpressionAfterAnimationTracked = true;
    }
}