// Copyright 2012 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.content.browser;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Path.Direction;
import android.graphics.PointF;
import android.graphics.PorterDuff.Mode;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region.Op;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.SystemClock;
import android.util.Log;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Interpolator;
import android.view.animation.OvershootInterpolator;

import org.chromium.content.R;

/**
 * PopupZoomer is used to show the on-demand link zooming popup. It handles manipulation of the
 * canvas and touch events to display the on-demand zoom magnifier.
 */
class PopupZoomer extends View {
    private static final String LOGTAG = "PopupZoomer";

    // The padding between the edges of the view and the popup. Note that there is a mirror
    // constant in content/renderer/render_view_impl.cc which should be kept in sync if
    // this is changed.
    private static final int ZOOM_BOUNDS_MARGIN = 25;
    // Time it takes for the animation to finish in ms.
    private static final long ANIMATION_DURATION = 300;

    /**
     * Interface to be implemented to listen for touch events inside the zoomed area.
     * The MotionEvent coordinates correspond to original unzoomed view.
     */
    public static interface OnTapListener {
        public boolean onSingleTap(View v, MotionEvent event);
        public boolean onLongPress(View v, MotionEvent event);
    }

    private OnTapListener mOnTapListener = null;

    /**
     * Interface to be implemented to add and remove PopupZoomer to/from the view hierarchy.
     */
    public static interface OnVisibilityChangedListener {
        public void onPopupZoomerShown(PopupZoomer zoomer);
        public void onPopupZoomerHidden(PopupZoomer zoomer);
    }

    private OnVisibilityChangedListener mOnVisibilityChangedListener = null;

    // Cached drawable used to frame the zooming popup.
    // TODO(tonyg): This should be marked purgeable so that if the system wants to recover this
    // memory, we can just reload it from the resource ID next time it is needed.
    // See android.graphics.BitmapFactory.Options#inPurgeable
    private static Drawable sOverlayDrawable;
    // The padding used for drawing the overlay around the content, instead of directly above it.
    private static Rect sOverlayPadding;
    // The radius of the overlay bubble, used for rounding the bitmap to draw underneath it.
    private static float sOverlayCornerRadius;

    private final Interpolator mShowInterpolator = new OvershootInterpolator();
    private final Interpolator mHideInterpolator = new ReverseInterpolator(mShowInterpolator);

    private boolean mAnimating = false;
    private boolean mShowing = false;
    private long mAnimationStartTime = 0;

    // The time that was left for the outwards animation to finish.
    // This is used in the case that the zoomer is cancelled while it is still animating outwards,
    // to avoid having it jump to full size then animate closed.
    private long mTimeLeft = 0;

    // initDimensions() needs to be called in onDraw().
    private boolean mNeedsToInitDimensions;

    // Available view area after accounting for ZOOM_BOUNDS_MARGIN.
    private RectF mViewClipRect;

    // The target rect to be zoomed.
    private Rect mTargetBounds;

    // The bitmap to hold the zoomed view.
    private Bitmap mZoomedBitmap;

    // How far to shift the canvas after all zooming is done, to keep it inside the bounds of the
    // view (including margin).
    private float mShiftX = 0, mShiftY = 0;
    // The magnification factor of the popup. It is recomputed once we have mTargetBounds and
    // mZoomedBitmap.
    private float mScale = 1.0f;
    // The bounds representing the actual zoomed popup.
    private RectF mClipRect;
    // The extrusion values are how far the zoomed area (mClipRect) extends from the touch point.
    // These values to used to animate the popup.
    private float mLeftExtrusion, mTopExtrusion, mRightExtrusion, mBottomExtrusion;
    // The last touch point, where the animation will start from.
    private final PointF mTouch = new PointF();

    // Since we sometimes overflow the bounds of the mViewClipRect, we need to allow scrolling.
    // Current scroll position.
    private float mPopupScrollX, mPopupScrollY;
    // Scroll bounds.
    private float mMinScrollX, mMaxScrollX;
    private float mMinScrollY, mMaxScrollY;

    private GestureDetector mGestureDetector;

    private static float getOverlayCornerRadius(Context context) {
        if (sOverlayCornerRadius == 0) {
            try {
                sOverlayCornerRadius = context.getResources().getDimension(
                        R.dimen.link_preview_overlay_radius);
            } catch (Resources.NotFoundException e) {
                Log.w(LOGTAG, "No corner radius resource for PopupZoomer overlay found.");
                sOverlayCornerRadius = 1.0f;
            }
        }
        return sOverlayCornerRadius;
    }

    /**
     * Gets the drawable that should be used to frame the zooming popup, loading
     * it from the resource bundle if not already cached.
     */
    private static Drawable getOverlayDrawable(Context context) {
        if (sOverlayDrawable == null) {
            try {
                sOverlayDrawable = context.getResources().getDrawable(
                        R.drawable.ondemand_overlay);
            } catch (Resources.NotFoundException e) {
                Log.w(LOGTAG, "No drawable resource for PopupZoomer overlay found.");
                sOverlayDrawable = new ColorDrawable();
            }
            sOverlayPadding = new Rect();
            sOverlayDrawable.getPadding(sOverlayPadding);
        }
        return sOverlayDrawable;
    }

    private static float constrain(float amount, float low, float high) {
        return amount < low ? low : (amount > high ? high : amount);
    }

    private static int constrain(int amount, int low, int high) {
        return amount < low ? low : (amount > high ? high : amount);
    }

    /**
     * Creates Popupzoomer.
     * @param context Context to be used.
     * @param overlayRadiusDimensoinResId Resource to be used to get overlay corner radius.
     */
    public PopupZoomer(Context context) {
        super(context);

        setVisibility(INVISIBLE);
        setFocusable(true);
        setFocusableInTouchMode(true);

        GestureDetector.SimpleOnGestureListener listener =
                new GestureDetector.SimpleOnGestureListener() {
                    @Override
                    public boolean onScroll(MotionEvent e1, MotionEvent e2,
                            float distanceX, float distanceY) {
                        if (mAnimating) return true;

                        if (isTouchOutsideArea(e1.getX(), e1.getY())) {
                            hide(true);
                        } else {
                            scroll(distanceX, distanceY);
                        }
                        return true;
                    }

                    @Override
                    public boolean onSingleTapUp(MotionEvent e) {
                        return handleTapOrPress(e, false);
                    }

                    @Override
                    public void onLongPress(MotionEvent e) {
                        handleTapOrPress(e, true);
                    }

                    private boolean handleTapOrPress(MotionEvent e, boolean isLongPress) {
                        if (mAnimating) return true;

                        float x = e.getX();
                        float y = e.getY();
                        if (isTouchOutsideArea(x, y)) {
                            // User clicked on area outside the popup.
                            hide(true);
                        } else if (mOnTapListener != null) {
                            PointF converted = convertTouchPoint(x, y);
                            MotionEvent event = MotionEvent.obtainNoHistory(e);
                            event.setLocation(converted.x, converted.y);
                            if (isLongPress) {
                                mOnTapListener.onLongPress(PopupZoomer.this, event);
                            } else {
                                mOnTapListener.onSingleTap(PopupZoomer.this, event);
                            }
                            hide(true);
                        }
                        return true;
                    }
                };
        mGestureDetector = new GestureDetector(context, listener);
    }

    /**
     * Sets the OnTapListener.
     */
    public void setOnTapListener(OnTapListener listener) {
        mOnTapListener = listener;
    }

    /**
     * Sets the OnVisibilityChangedListener.
     */
    public void setOnVisibilityChangedListener(OnVisibilityChangedListener listener) {
        mOnVisibilityChangedListener = listener;
    }

    /**
     * Sets the bitmap to be used for the zoomed view.
     */
    public void setBitmap(Bitmap bitmap) {
        if (mZoomedBitmap != null) {
            mZoomedBitmap.recycle();
            mZoomedBitmap = null;
        }
        mZoomedBitmap = bitmap;

        // Round the corners of the bitmap so it doesn't stick out around the overlay.
        Canvas canvas = new Canvas(mZoomedBitmap);
        Path path = new Path();
        RectF canvasRect = new RectF(0, 0, canvas.getWidth(), canvas.getHeight());
        float overlayCornerRadius = getOverlayCornerRadius(getContext());
        path.addRoundRect(canvasRect, overlayCornerRadius, overlayCornerRadius, Direction.CCW);
        canvas.clipPath(path, Op.XOR);
        Paint clearPaint = new Paint();
        clearPaint.setXfermode(new PorterDuffXfermode(Mode.SRC));
        clearPaint.setColor(Color.TRANSPARENT);
        canvas.drawPaint(clearPaint);
    }

    private void scroll(float x, float y) {
        mPopupScrollX = constrain(mPopupScrollX - x, mMinScrollX, mMaxScrollX);
        mPopupScrollY = constrain(mPopupScrollY - y, mMinScrollY, mMaxScrollY);
        invalidate();
    }

    private void startAnimation(boolean show) {
        mAnimating = true;
        mShowing = show;
        mTimeLeft = 0;
        if (show) {
            setVisibility(VISIBLE);
            mNeedsToInitDimensions = true;
            if (mOnVisibilityChangedListener != null) {
                mOnVisibilityChangedListener.onPopupZoomerShown(this);
            }
        } else {
            long endTime = mAnimationStartTime + ANIMATION_DURATION;
            mTimeLeft = endTime - SystemClock.uptimeMillis();
            if (mTimeLeft < 0) mTimeLeft = 0;
        }
        mAnimationStartTime = SystemClock.uptimeMillis();
        invalidate();
    }

    private void hideImmediately() {
        mAnimating = false;
        mShowing = false;
        mTimeLeft = 0;
        if (mOnVisibilityChangedListener != null) {
            mOnVisibilityChangedListener.onPopupZoomerHidden(this);
        }
        setVisibility(INVISIBLE);
        mZoomedBitmap.recycle();
        mZoomedBitmap = null;
    }

    /**
     * Returns true if the view is currently being shown (or is animating).
     */
    public boolean isShowing() {
        return mShowing || mAnimating;
    }

    /**
     * Sets the last touch point (on the unzoomed view).
     */
    public void setLastTouch(float x, float y) {
        mTouch.x = x;
        mTouch.y = y;
    }

    private void setTargetBounds(Rect rect) {
        mTargetBounds = rect;
    }

    private void initDimensions() {
        if (mTargetBounds == null || mTouch == null) return;

        // Compute the final zoom scale.
        mScale = (float) mZoomedBitmap.getWidth() / mTargetBounds.width();

        float l = mTouch.x - mScale * (mTouch.x - mTargetBounds.left);
        float t = mTouch.y - mScale * (mTouch.y - mTargetBounds.top);
        float r = l + mZoomedBitmap.getWidth();
        float b = t + mZoomedBitmap.getHeight();
        mClipRect = new RectF(l, t, r, b);
        int width = getWidth();
        int height = getHeight();

        mViewClipRect = new RectF(ZOOM_BOUNDS_MARGIN,
                ZOOM_BOUNDS_MARGIN,
                width - ZOOM_BOUNDS_MARGIN,
                height - ZOOM_BOUNDS_MARGIN);

        // Ensure it stays inside the bounds of the view.  First shift it around to see if it
        // can fully fit in the view, then clip it to the padding section of the view to
        // ensure no overflow.
        mShiftX = 0;
        mShiftY = 0;

        // Right now this has the happy coincidence of showing the leftmost portion
        // of a scaled up bitmap, which usually has the text in it.  When we want to support
        // RTL languages, we can conditionally switch the order of this check to push it
        // to the left instead of right.
        if (mClipRect.left < ZOOM_BOUNDS_MARGIN) {
            mShiftX = ZOOM_BOUNDS_MARGIN - mClipRect.left;
            mClipRect.left += mShiftX;
            mClipRect.right += mShiftX;
        } else if (mClipRect.right > width - ZOOM_BOUNDS_MARGIN) {
            mShiftX = (width - ZOOM_BOUNDS_MARGIN - mClipRect.right);
            mClipRect.right += mShiftX;
            mClipRect.left += mShiftX;
        }
        if (mClipRect.top < ZOOM_BOUNDS_MARGIN) {
            mShiftY = ZOOM_BOUNDS_MARGIN - mClipRect.top;
            mClipRect.top += mShiftY;
            mClipRect.bottom += mShiftY;
        } else if (mClipRect.bottom > height - ZOOM_BOUNDS_MARGIN) {
            mShiftY = height - ZOOM_BOUNDS_MARGIN - mClipRect.bottom;
            mClipRect.bottom += mShiftY;
            mClipRect.top += mShiftY;
        }

        // Allow enough scrolling to get to the entire bitmap that may be clipped inside the
        // bounds of the view.
        mMinScrollX = mMaxScrollX = mMinScrollY = mMaxScrollY = 0;
        if (mViewClipRect.right + mShiftX < mClipRect.right) {
            mMinScrollX = mViewClipRect.right - mClipRect.right;
        }
        if (mViewClipRect.left + mShiftX > mClipRect.left) {
            mMaxScrollX = mViewClipRect.left - mClipRect.left;
        }
        if (mViewClipRect.top + mShiftY > mClipRect.top) {
            mMaxScrollY = mViewClipRect.top - mClipRect.top;
        }
        if (mViewClipRect.bottom + mShiftY < mClipRect.bottom) {
            mMinScrollY = mViewClipRect.bottom - mClipRect.bottom;
        }
        // Now that we know how much we need to scroll, we can intersect with mViewClipRect.
        mClipRect.intersect(mViewClipRect);

        mLeftExtrusion = mTouch.x - mClipRect.left;
        mRightExtrusion = mClipRect.right - mTouch.x;
        mTopExtrusion = mTouch.y - mClipRect.top;
        mBottomExtrusion = mClipRect.bottom - mTouch.y;

        // Set an initial scroll position to take touch point into account.
        float percentX =
                (mTouch.x - mTargetBounds.centerX()) / (mTargetBounds.width() / 2.f) + .5f;
        float percentY =
                (mTouch.y - mTargetBounds.centerY()) / (mTargetBounds.height() / 2.f) + .5f;

        float scrollWidth = mMaxScrollX - mMinScrollX;
        float scrollHeight = mMaxScrollY - mMinScrollY;
        mPopupScrollX = scrollWidth * percentX * -1f;
        mPopupScrollY = scrollHeight * percentY * -1f;
        // Constrain initial scroll position within allowed bounds.
        mPopupScrollX = constrain(mPopupScrollX, mMinScrollX, mMaxScrollX);
        mPopupScrollY = constrain(mPopupScrollY, mMinScrollY, mMaxScrollY);
    }

    /*
     * Tests override it as the PopupZoomer is never attached to the view hierarchy.
     */
    protected boolean acceptZeroSizeView() {
        return false;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (!isShowing() || mZoomedBitmap == null) return;
        if (!acceptZeroSizeView() && (getWidth() == 0 || getHeight() == 0)) return;

        if (mNeedsToInitDimensions) {
            mNeedsToInitDimensions = false;
            initDimensions();
        }

        canvas.save();
        // Calculate the elapsed fraction of animation.
        float time = (SystemClock.uptimeMillis() - mAnimationStartTime + mTimeLeft) /
                ((float) ANIMATION_DURATION);
        time = constrain(time, 0, 1);
        if (time >= 1) {
            mAnimating = false;
            if (!isShowing()) {
                hideImmediately();
                return;
            }
        } else {
            invalidate();
        }

        // Fraction of the animation to actally show.
        float fractionAnimation;
        if (mShowing) {
            fractionAnimation = mShowInterpolator.getInterpolation(time);
        } else {
            fractionAnimation = mHideInterpolator.getInterpolation(time);
        }

        // Draw a faded color over the entire view to fade out the original content, increasing
        // the alpha value as fractionAnimation increases.
        // TODO(nileshagrawal): We should use time here instead of fractionAnimation
        // as fractionAnimaton is interpolated and can go over 1.
        canvas.drawARGB((int) (80 * fractionAnimation), 0, 0, 0);
        canvas.save();

        // Since we want the content to appear directly above its counterpart we need to make
        // sure that it starts out at exactly the same size as it appears in the page,
        // i.e. scale grows from 1/mScale to 1. Note that extrusion values are already zoomed
        // with mScale.
        float scale = fractionAnimation * (mScale - 1.0f) / mScale + 1.0f / mScale;

        // Since we want the content to appear directly above its counterpart on the
        // page, we need to remove the mShiftX/Y effect at the beginning of the animation.
        // The unshifting decreases with the animation.
        float unshiftX = -mShiftX * (1.0f - fractionAnimation) / mScale;
        float unshiftY = -mShiftY * (1.0f - fractionAnimation) / mScale;

        // Compute the rect to show.
        RectF rect = new RectF();
        rect.left = mTouch.x - mLeftExtrusion * scale + unshiftX;
        rect.top = mTouch.y - mTopExtrusion * scale + unshiftY;
        rect.right = mTouch.x + mRightExtrusion * scale + unshiftX;
        rect.bottom = mTouch.y + mBottomExtrusion * scale + unshiftY;
        canvas.clipRect(rect);

        // Since the canvas transform APIs all pre-concat the transformations, this is done in
        // reverse order. The canvas is first scaled up, then shifted the appropriate amount of
        // pixels.
        canvas.scale(scale, scale, rect.left, rect.top);
        canvas.translate(mPopupScrollX, mPopupScrollY);
        canvas.drawBitmap(mZoomedBitmap, rect.left, rect.top, null);
        canvas.restore();
        Drawable overlayNineTile = getOverlayDrawable(getContext());
        overlayNineTile.setBounds((int) rect.left - sOverlayPadding.left,
                (int) rect.top - sOverlayPadding.top,
                (int) rect.right + sOverlayPadding.right,
                (int) rect.bottom + sOverlayPadding.bottom);
        // TODO(nileshagrawal): We should use time here instead of fractionAnimation
        // as fractionAnimaton is interpolated and can go over 1.
        int alpha = constrain((int) (fractionAnimation * 255), 0, 255);
        overlayNineTile.setAlpha(alpha);
        overlayNineTile.draw(canvas);
        canvas.restore();
    }

    /**
     * Show the PopupZoomer view with given target bounds.
     */
    public void show(Rect rect) {
        if (mShowing || mZoomedBitmap == null) return;

        setTargetBounds(rect);
        startAnimation(true);
    }

    /**
     * Hide the PopupZoomer view.
     * @param animation true if hide with animation.
     */
    public void hide(boolean animation) {
        if (!mShowing) return;

        if (animation) {
            startAnimation(false);
        } else {
            hideImmediately();
        }
    }

    /**
     * Converts the coordinates to a point on the original un-zoomed view.
     */
    private PointF convertTouchPoint(float x, float y) {
        x -= mShiftX;
        y -= mShiftY;
        x = mTouch.x + (x - mTouch.x - mPopupScrollX) / mScale;
        y = mTouch.y + (y - mTouch.y - mPopupScrollY) / mScale;
        return new PointF(x, y);
    }

    /**
     * Returns true if the point is inside the final drawable area for this popup zoomer.
     */
    private boolean isTouchOutsideArea(float x, float y) {
        return !mClipRect.contains(x, y);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mGestureDetector.onTouchEvent(event);
        return true;
    }

    private static class ReverseInterpolator implements Interpolator {
        private final Interpolator mInterpolator;

        public ReverseInterpolator(Interpolator i) {
            mInterpolator = i;
        }

        @Override
        public float getInterpolation(float input) {
            input = 1.0f - input;
            if (mInterpolator == null) return input;
            return mInterpolator.getInterpolation(input);
        }
    }
}
