// Copyright 2013 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.android_webview;

import android.graphics.Rect;
import android.widget.OverScroller;

import com.google.common.annotations.VisibleForTesting;

/**
 * Takes care of syncing the scroll offset between the Android View system and the
 * InProcessViewRenderer.
 *
 * Unless otherwise values (sizes, scroll offsets) are in physical pixels.
 */
@VisibleForTesting
public class AwScrollOffsetManager {
    // Values taken from WebViewClassic.

    // The amount of content to overlap between two screens when using pageUp/pageDown methiods.
    private static final int PAGE_SCROLL_OVERLAP = 24;
    // Standard animated scroll speed.
    private static final int STD_SCROLL_ANIMATION_SPEED_PIX_PER_SEC = 480;
    // Time for the longest scroll animation.
    private static final int MAX_SCROLL_ANIMATION_DURATION_MILLISEC = 750;

    /**
     * The interface that all users of AwScrollOffsetManager should implement.
     *
     * The unit of all the values in this delegate are physical pixels.
     */
    public interface Delegate {
        // Call View#overScrollBy on the containerView.
        void overScrollContainerViewBy(int deltaX, int deltaY, int scrollX, int scrollY,
                int scrollRangeX, int scrollRangeY, boolean isTouchEvent);
        // Call View#scrollTo on the containerView.
        void scrollContainerViewTo(int x, int y);
        // Store the scroll offset in the native side. This should really be a simple store
        // operation, the native side shouldn't synchronously alter the scroll offset from within
        // this call.
        void scrollNativeTo(int x, int y);

        int getContainerViewScrollX();
        int getContainerViewScrollY();

        void invalidate();
    }

    private final Delegate mDelegate;

    // Scroll offset as seen by the native side.
    private int mNativeScrollX;
    private int mNativeScrollY;

    // How many pixels can we scroll in a given direction.
    private int mMaxHorizontalScrollOffset;
    private int mMaxVerticalScrollOffset;

    // Size of the container view.
    private int mContainerViewWidth;
    private int mContainerViewHeight;

    // Whether we're in the middle of processing a touch event.
    private boolean mProcessingTouchEvent;

    private boolean mFlinging;

    // Whether (and to what value) to update the native side scroll offset after we've finished
    // processing a touch event.
    private boolean mApplyDeferredNativeScroll;
    private int mDeferredNativeScrollX;
    private int mDeferredNativeScrollY;

    private OverScroller mScroller;

    public AwScrollOffsetManager(Delegate delegate, OverScroller overScroller) {
        mDelegate = delegate;
        mScroller = overScroller;
    }

    //----- Scroll range and extent calculation methods -------------------------------------------

    public int computeHorizontalScrollRange() {
        return mContainerViewWidth + mMaxHorizontalScrollOffset;
    }

    public int computeMaximumHorizontalScrollOffset() {
        return mMaxHorizontalScrollOffset;
    }

    public int computeHorizontalScrollOffset() {
        return mDelegate.getContainerViewScrollX();
    }

    public int computeVerticalScrollRange() {
        return mContainerViewHeight + mMaxVerticalScrollOffset;
    }

    public int computeMaximumVerticalScrollOffset() {
        return mMaxVerticalScrollOffset;
    }

    public int computeVerticalScrollOffset() {
        return mDelegate.getContainerViewScrollY();
    }

    public int computeVerticalScrollExtent() {
        return mContainerViewHeight;
    }

    //---------------------------------------------------------------------------------------------
    /**
     * Called when the scroll range changes. This needs to be the size of the on-screen content.
     */
    public void setMaxScrollOffset(int width, int height) {
        mMaxHorizontalScrollOffset = width;
        mMaxVerticalScrollOffset = height;
    }

    /**
     * Called when the physical size of the view changes.
     */
    public void setContainerViewSize(int width, int height) {
        mContainerViewWidth = width;
        mContainerViewHeight = height;
    }

    public void syncScrollOffsetFromOnDraw() {
        // Unfortunately apps override onScrollChanged without calling super which is why we need
        // to sync the scroll offset on every onDraw.
        onContainerViewScrollChanged(mDelegate.getContainerViewScrollX(),
                mDelegate.getContainerViewScrollY());
    }

    public void setProcessingTouchEvent(boolean processingTouchEvent) {
        assert mProcessingTouchEvent != processingTouchEvent;
        mProcessingTouchEvent = processingTouchEvent;

        if (!mProcessingTouchEvent && mApplyDeferredNativeScroll) {
            mApplyDeferredNativeScroll = false;
            scrollNativeTo(mDeferredNativeScrollX, mDeferredNativeScrollY);
        }
    }

    // Called by the native side to scroll the container view.
    public void scrollContainerViewTo(int x, int y) {
        mNativeScrollX = x;
        mNativeScrollY = y;

        final int scrollX = mDelegate.getContainerViewScrollX();
        final int scrollY = mDelegate.getContainerViewScrollY();
        final int deltaX = x - scrollX;
        final int deltaY = y - scrollY;
        final int scrollRangeX = computeMaximumHorizontalScrollOffset();
        final int scrollRangeY = computeMaximumVerticalScrollOffset();

        // We use overScrollContainerViewBy to be compatible with WebViewClassic which used this
        // method for handling both over-scroll as well as in-bounds scroll.
        mDelegate.overScrollContainerViewBy(deltaX, deltaY, scrollX, scrollY,
                scrollRangeX, scrollRangeY, mProcessingTouchEvent);
    }

    public boolean isFlingActive() {
        return mFlinging;
    }

    // Called by the native side to over-scroll the container view.
    public void overScrollBy(int deltaX, int deltaY) {
        // TODO(mkosiba): Once http://crbug.com/260663 and http://crbug.com/261239 are fixed it
        // should be possible to uncomment the following asserts:
        // if (deltaX < 0) assert mDelegate.getContainerViewScrollX() == 0;
        // if (deltaX > 0) assert mDelegate.getContainerViewScrollX() ==
        //          computeMaximumHorizontalScrollOffset();
        scrollBy(deltaX, deltaY);
    }

    private void scrollBy(int deltaX, int deltaY) {
        if (deltaX == 0 && deltaY == 0) return;

        final int scrollX = mDelegate.getContainerViewScrollX();
        final int scrollY = mDelegate.getContainerViewScrollY();
        final int scrollRangeX = computeMaximumHorizontalScrollOffset();
        final int scrollRangeY = computeMaximumVerticalScrollOffset();

        // The android.view.View.overScrollBy method is used for both scrolling and over-scrolling
        // which is why we use it here.
        mDelegate.overScrollContainerViewBy(deltaX, deltaY, scrollX, scrollY,
                scrollRangeX, scrollRangeY, mProcessingTouchEvent);
    }

    private int clampHorizontalScroll(int scrollX) {
        scrollX = Math.max(0, scrollX);
        scrollX = Math.min(computeMaximumHorizontalScrollOffset(), scrollX);
        return scrollX;
    }

    private int clampVerticalScroll(int scrollY) {
        scrollY = Math.max(0, scrollY);
        scrollY = Math.min(computeMaximumVerticalScrollOffset(), scrollY);
        return scrollY;
    }

    // Called by the View system as a response to the mDelegate.overScrollContainerViewBy call.
    public void onContainerViewOverScrolled(int scrollX, int scrollY, boolean clampedX,
            boolean clampedY) {
        // Clamp the scroll offset at (0, max).
        scrollX = clampHorizontalScroll(scrollX);
        scrollY = clampVerticalScroll(scrollY);

        mDelegate.scrollContainerViewTo(scrollX, scrollY);

        // This is only necessary if the containerView scroll offset ends up being different
        // than the one set from native in which case we want the value stored on the native side
        // to reflect the value stored in the containerView (and not the other way around).
        scrollNativeTo(mDelegate.getContainerViewScrollX(), mDelegate.getContainerViewScrollY());
    }

    // Called by the View system when the scroll offset had changed. This might not get called if
    // the embedder overrides WebView#onScrollChanged without calling super.onScrollChanged. If
    // this method does get called it is called both as a response to the embedder scrolling the
    // view as well as a response to mDelegate.scrollContainerViewTo.
    public void onContainerViewScrollChanged(int x, int y) {
        scrollNativeTo(x, y);
    }

    private void scrollNativeTo(int x, int y) {
        x = clampHorizontalScroll(x);
        y = clampVerticalScroll(y);

        // We shouldn't do the store to native while processing a touch event since that confuses
        // the gesture processing logic.
        if (mProcessingTouchEvent) {
            mDeferredNativeScrollX = x;
            mDeferredNativeScrollY = y;
            mApplyDeferredNativeScroll = true;
            return;
        }

        if (x == mNativeScrollX && y == mNativeScrollY)
            return;

        // The scrollNativeTo call should be a simple store, so it's OK to assume it always
        // succeeds.
        mNativeScrollX = x;
        mNativeScrollY = y;

        mDelegate.scrollNativeTo(x, y);
    }

    // Called whenever some other touch interaction requires the fling gesture to be canceled.
    public void onFlingCancelGesture() {
        // TODO(mkosiba): Support speeding up a fling by flinging again.
        // http://crbug.com/265841
        mScroller.forceFinished(true);
    }

    // Called when a fling gesture is not handled by the renderer.
    // We explicitly ask the renderer not to handle fling gestures targeted at the root
    // scroll layer.
    public void onUnhandledFlingStartEvent(int velocityX, int velocityY) {
        flingScroll(-velocityX, -velocityY);
    }

    // Starts the fling animation. Called both as a response to a fling gesture and as via the
    // public WebView#flingScroll(int, int) API.
    public void flingScroll(int velocityX, int velocityY) {
        final int scrollX = mDelegate.getContainerViewScrollX();
        final int scrollY = mDelegate.getContainerViewScrollY();
        final int scrollRangeX = computeMaximumHorizontalScrollOffset();
        final int scrollRangeY = computeMaximumVerticalScrollOffset();

        mScroller.fling(scrollX, scrollY, velocityX, velocityY,
                0, scrollRangeX, 0, scrollRangeY);
        mDelegate.invalidate();
    }

    // Called immediately before the draw to update the scroll offset.
    public void computeScrollAndAbsorbGlow(OverScrollGlow overScrollGlow) {
        mFlinging = mScroller.computeScrollOffset();
        if (!mFlinging) {
            return;
        }

        final int oldX = mDelegate.getContainerViewScrollX();
        final int oldY = mDelegate.getContainerViewScrollY();
        int x = mScroller.getCurrX();
        int y = mScroller.getCurrY();

        final int scrollRangeX = computeMaximumHorizontalScrollOffset();
        final int scrollRangeY = computeMaximumVerticalScrollOffset();

        if (overScrollGlow != null) {
            overScrollGlow.absorbGlow(x, y, oldX, oldY, scrollRangeX, scrollRangeY,
                    mScroller.getCurrVelocity());
        }

        // The mScroller is configured not to go outside of the scrollable range, so this call
        // should never result in attempting to scroll outside of the scrollable region.
        scrollBy(x - oldX, y - oldY);

        mDelegate.invalidate();
    }

    private static int computeDurationInMilliSec(int dx, int dy) {
        int distance = Math.max(Math.abs(dx), Math.abs(dy));
        int duration = distance * 1000 / STD_SCROLL_ANIMATION_SPEED_PIX_PER_SEC;
        return Math.min(duration, MAX_SCROLL_ANIMATION_DURATION_MILLISEC);
    }

    private boolean animateScrollTo(int x, int y) {
        final int scrollX = mDelegate.getContainerViewScrollX();
        final int scrollY = mDelegate.getContainerViewScrollY();

        x = clampHorizontalScroll(x);
        y = clampVerticalScroll(y);

        int dx = x - scrollX;
        int dy = y - scrollY;

        if (dx == 0 && dy == 0)
            return false;

        mScroller.startScroll(scrollX, scrollY, dx, dy, computeDurationInMilliSec(dx, dy));
        mDelegate.invalidate();

        return true;
    }

    /**
     * See {@link android.webkit.WebView#pageUp(boolean)}
     */
    public boolean pageUp(boolean top) {
        final int scrollX = mDelegate.getContainerViewScrollX();
        final int scrollY = mDelegate.getContainerViewScrollY();

        if (top) {
            // go to the top of the document
            return animateScrollTo(scrollX, 0);
        }
        int dy = -mContainerViewHeight / 2;
        if (mContainerViewHeight > 2 * PAGE_SCROLL_OVERLAP) {
            dy = -mContainerViewHeight + PAGE_SCROLL_OVERLAP;
        }
        // animateScrollTo clamps the argument to the scrollable range so using (scrollY + dy) is
        // fine.
        return animateScrollTo(scrollX, scrollY + dy);
    }

    /**
     * See {@link android.webkit.WebView#pageDown(boolean)}
     */
    public boolean pageDown(boolean bottom) {
        final int scrollX = mDelegate.getContainerViewScrollX();
        final int scrollY = mDelegate.getContainerViewScrollY();

        if (bottom) {
            return animateScrollTo(scrollX, computeVerticalScrollRange());
        }
        int dy = mContainerViewHeight / 2;
        if (mContainerViewHeight > 2 * PAGE_SCROLL_OVERLAP) {
            dy = mContainerViewHeight - PAGE_SCROLL_OVERLAP;
        }
        // animateScrollTo clamps the argument to the scrollable range so using (scrollY + dy) is
        // fine.
        return animateScrollTo(scrollX, scrollY + dy);
    }

    /**
     * See {@link android.webkit.WebView#requestChildRectangleOnScreen(View, Rect, boolean)}
     */
    public boolean requestChildRectangleOnScreen(int childOffsetX, int childOffsetY, Rect rect,
            boolean immediate) {
        // TODO(mkosiba): WebViewClassic immediately returns false if a zoom animation is
        // in progress. We currently can't tell if one is happening.. should we instead cancel any
        // scroll animation when the size/pageScaleFactor changes?

        // TODO(mkosiba): Take scrollbar width into account in the screenRight/screenBotton
        // calculations. http://crbug.com/269032

        final int scrollX = mDelegate.getContainerViewScrollX();
        final int scrollY = mDelegate.getContainerViewScrollY();

        rect.offset(childOffsetX, childOffsetY);

        int screenTop = scrollY;
        int screenBottom = scrollY + mContainerViewHeight;
        int scrollYDelta = 0;

        if (rect.bottom > screenBottom) {
            int oneThirdOfScreenHeight = mContainerViewHeight / 3;
            if (rect.width() > 2 * oneThirdOfScreenHeight) {
                // If the rectangle is too tall to fit in the bottom two thirds
                // of the screen, place it at the top.
                scrollYDelta = rect.top - screenTop;
            } else {
                // If the rectangle will still fit on screen, we want its
                // top to be in the top third of the screen.
                scrollYDelta = rect.top - (screenTop + oneThirdOfScreenHeight);
            }
        } else if (rect.top < screenTop) {
            scrollYDelta = rect.top - screenTop;
        }

        int screenLeft = scrollX;
        int screenRight = scrollX + mContainerViewWidth;
        int scrollXDelta = 0;

        if (rect.right > screenRight && rect.left > screenLeft) {
            if (rect.width() > mContainerViewWidth) {
                scrollXDelta += (rect.left - screenLeft);
            } else {
                scrollXDelta += (rect.right - screenRight);
            }
        } else if (rect.left < screenLeft) {
            scrollXDelta -= (screenLeft - rect.left);
        }

        if (scrollYDelta == 0 && scrollXDelta == 0) {
            return false;
        }

        if (immediate) {
            scrollBy(scrollXDelta, scrollYDelta);
            return true;
        } else {
            return animateScrollTo(scrollX + scrollXDelta, scrollY + scrollYDelta);
        }
    }
}
