/*
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

package awais.instagrabber.customviews.drawee;

import android.graphics.Matrix;
import android.graphics.PointF;
import android.graphics.RectF;
import android.view.MotionEvent;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;

import com.facebook.common.logging.FLog;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * Zoomable controller that calculates transformation based on touch events.
 */
public class DefaultZoomableController
        implements ZoomableController, TransformGestureDetector.Listener {

    /**
     * Interface for handling call backs when the image bounds are set.
     */
    public interface ImageBoundsListener {
        void onImageBoundsSet(RectF imageBounds);
    }

    @IntDef(
            flag = true,
            value = {LIMIT_NONE, LIMIT_TRANSLATION_X, LIMIT_TRANSLATION_Y, LIMIT_SCALE, LIMIT_ALL})
    @Retention(RetentionPolicy.SOURCE)
    public @interface LimitFlag {}

    public static final int LIMIT_NONE = 0;
    public static final int LIMIT_TRANSLATION_X = 1;
    public static final int LIMIT_TRANSLATION_Y = 2;
    public static final int LIMIT_SCALE = 4;
    public static final int LIMIT_ALL = LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y | LIMIT_SCALE;

    private static final float EPS = 1e-3f;

    private static final Class<?> TAG = DefaultZoomableController.class;

    private static final RectF IDENTITY_RECT = new RectF(0, 0, 1, 1);

    private TransformGestureDetector mGestureDetector;

    private @Nullable
    ImageBoundsListener mImageBoundsListener;

    private @Nullable
    Listener mListener = null;

    private boolean mIsEnabled = false;
    private boolean mIsRotationEnabled = false;
    private boolean mIsScaleEnabled = true;
    private boolean mIsTranslationEnabled = true;
    private boolean mIsGestureZoomEnabled = true;

    private float mMinScaleFactor = 1.0f;
    private float mMaxScaleFactor = 2.0f;

    // View bounds, in view-absolute coordinates
    private final RectF mViewBounds = new RectF();
    // Non-transformed image bounds, in view-absolute coordinates
    private final RectF mImageBounds = new RectF();
    // Transformed image bounds, in view-absolute coordinates
    private final RectF mTransformedImageBounds = new RectF();

    private final Matrix mPreviousTransform = new Matrix();
    private final Matrix mActiveTransform = new Matrix();
    private final Matrix mActiveTransformInverse = new Matrix();
    private final float[] mTempValues = new float[9];
    private final RectF mTempRect = new RectF();
    private boolean mWasTransformCorrected;

    public static DefaultZoomableController newInstance() {
        return new DefaultZoomableController(TransformGestureDetector.newInstance());
    }

    public DefaultZoomableController(TransformGestureDetector gestureDetector) {
        mGestureDetector = gestureDetector;
        mGestureDetector.setListener(this);
    }

    /**
     * Rests the controller.
     */
    public void reset() {
        FLog.v(TAG, "reset");
        mGestureDetector.reset();
        mPreviousTransform.reset();
        mActiveTransform.reset();
        onTransformChanged();
    }

    /**
     * Sets the zoomable listener.
     */
    @Override
    public void setListener(Listener listener) {
        mListener = listener;
    }

    /**
     * Sets whether the controller is enabled or not.
     */
    @Override
    public void setEnabled(boolean enabled) {
        mIsEnabled = enabled;
        if (!enabled) {
            reset();
        }
    }

    /**
     * Gets whether the controller is enabled or not.
     */
    @Override
    public boolean isEnabled() {
        return mIsEnabled;
    }

    /**
     * Sets whether the rotation gesture is enabled or not.
     */
    public void setRotationEnabled(boolean enabled) {
        mIsRotationEnabled = enabled;
    }

    /**
     * Gets whether the rotation gesture is enabled or not.
     */
    public boolean isRotationEnabled() {
        return mIsRotationEnabled;
    }

    /**
     * Sets whether the scale gesture is enabled or not.
     */
    public void setScaleEnabled(boolean enabled) {
        mIsScaleEnabled = enabled;
    }

    /**
     * Gets whether the scale gesture is enabled or not.
     */
    public boolean isScaleEnabled() {
        return mIsScaleEnabled;
    }

    /**
     * Sets whether the translation gesture is enabled or not.
     */
    public void setTranslationEnabled(boolean enabled) {
        mIsTranslationEnabled = enabled;
    }

    /**
     * Gets whether the translations gesture is enabled or not.
     */
    public boolean isTranslationEnabled() {
        return mIsTranslationEnabled;
    }

    /**
     * Sets the minimum scale factor allowed.
     *
     * <p>Hierarchy's scaling (if any) is not taken into account.
     */
    public void setMinScaleFactor(float minScaleFactor) {
        mMinScaleFactor = minScaleFactor;
    }

    /**
     * Gets the minimum scale factor allowed.
     */
    public float getMinScaleFactor() {
        return mMinScaleFactor;
    }

    /**
     * Sets the maximum scale factor allowed.
     *
     * <p>Hierarchy's scaling (if any) is not taken into account.
     */
    public void setMaxScaleFactor(float maxScaleFactor) {
        mMaxScaleFactor = maxScaleFactor;
    }

    /**
     * Gets the maximum scale factor allowed.
     */
    public float getMaxScaleFactor() {
        return mMaxScaleFactor;
    }

    /**
     * Sets whether gesture zooms are enabled or not.
     */
    public void setGestureZoomEnabled(boolean isGestureZoomEnabled) {
        mIsGestureZoomEnabled = isGestureZoomEnabled;
    }

    /**
     * Gets whether gesture zooms are enabled or not.
     */
    public boolean isGestureZoomEnabled() {
        return mIsGestureZoomEnabled;
    }

    /**
     * Gets the current scale factor.
     */
    @Override
    public float getScaleFactor() {
        return getMatrixScaleFactor(mActiveTransform);
    }

    /**
     * Sets the image bounds, in view-absolute coordinates.
     */
    @Override
    public void setImageBounds(RectF imageBounds) {
        if (!imageBounds.equals(mImageBounds)) {
            mImageBounds.set(imageBounds);
            onTransformChanged();
            if (mImageBoundsListener != null) {
                mImageBoundsListener.onImageBoundsSet(mImageBounds);
            }
        }
    }

    /**
     * Gets the non-transformed image bounds, in view-absolute coordinates.
     */
    public RectF getImageBounds() {
        return mImageBounds;
    }

    /**
     * Gets the transformed image bounds, in view-absolute coordinates
     */
    private RectF getTransformedImageBounds() {
        return mTransformedImageBounds;
    }

    /**
     * Sets the view bounds.
     */
    @Override
    public void setViewBounds(RectF viewBounds) {
        mViewBounds.set(viewBounds);
    }

    /**
     * Gets the view bounds.
     */
    public RectF getViewBounds() {
        return mViewBounds;
    }

    /**
     * Sets the image bounds listener.
     */
    public void setImageBoundsListener(@Nullable ImageBoundsListener imageBoundsListener) {
        mImageBoundsListener = imageBoundsListener;
    }

    /**
     * Gets the image bounds listener.
     */
    public @Nullable
    ImageBoundsListener getImageBoundsListener() {
        return mImageBoundsListener;
    }

    /**
     * Returns true if the zoomable transform is identity matrix.
     */
    @Override
    public boolean isIdentity() {
        return isMatrixIdentity(mActiveTransform, 1e-3f);
    }

    /**
     * Returns true if the transform was corrected during the last update.
     *
     * <p>We should rename this method to `wasTransformedWithoutCorrection` and just return the
     * internal flag directly. However, this requires interface change and negation of meaning.
     */
    @Override
    public boolean wasTransformCorrected() {
        return mWasTransformCorrected;
    }

    /**
     * Gets the matrix that transforms image-absolute coordinates to view-absolute coordinates. The
     * zoomable transformation is taken into account.
     *
     * <p>Internal matrix is exposed for performance reasons and is not to be modified by the callers.
     */
    @Override
    public Matrix getTransform() {
        return mActiveTransform;
    }

    /**
     * Gets the matrix that transforms image-relative coordinates to view-absolute coordinates. The
     * zoomable transformation is taken into account.
     */
    public void getImageRelativeToViewAbsoluteTransform(Matrix outMatrix) {
        outMatrix.setRectToRect(IDENTITY_RECT, mTransformedImageBounds, Matrix.ScaleToFit.FILL);
    }

    /**
     * Maps point from view-absolute to image-relative coordinates. This takes into account the
     * zoomable transformation.
     */
    public PointF mapViewToImage(PointF viewPoint) {
        float[] points = mTempValues;
        points[0] = viewPoint.x;
        points[1] = viewPoint.y;
        mActiveTransform.invert(mActiveTransformInverse);
        mActiveTransformInverse.mapPoints(points, 0, points, 0, 1);
        mapAbsoluteToRelative(points, points, 1);
        return new PointF(points[0], points[1]);
    }

    /**
     * Maps point from image-relative to view-absolute coordinates. This takes into account the
     * zoomable transformation.
     */
    public PointF mapImageToView(PointF imagePoint) {
        float[] points = mTempValues;
        points[0] = imagePoint.x;
        points[1] = imagePoint.y;
        mapRelativeToAbsolute(points, points, 1);
        mActiveTransform.mapPoints(points, 0, points, 0, 1);
        return new PointF(points[0], points[1]);
    }

    /**
     * Maps array of 2D points from view-absolute to image-relative coordinates. This does NOT take
     * into account the zoomable transformation. Points are represented by a float array of [x0, y0,
     * x1, y1, ...].
     *
     * @param destPoints destination array (may be the same as source array)
     * @param srcPoints  source array
     * @param numPoints  number of points to map
     */
    private void mapAbsoluteToRelative(float[] destPoints, float[] srcPoints, int numPoints) {
        for (int i = 0; i < numPoints; i++) {
            destPoints[i * 2 + 0] = (srcPoints[i * 2 + 0] - mImageBounds.left) / mImageBounds.width();
            destPoints[i * 2 + 1] = (srcPoints[i * 2 + 1] - mImageBounds.top) / mImageBounds.height();
        }
    }

    /**
     * Maps array of 2D points from image-relative to view-absolute coordinates. This does NOT take
     * into account the zoomable transformation. Points are represented by float array of [x0, y0, x1,
     * y1, ...].
     *
     * @param destPoints destination array (may be the same as source array)
     * @param srcPoints  source array
     * @param numPoints  number of points to map
     */
    private void mapRelativeToAbsolute(float[] destPoints, float[] srcPoints, int numPoints) {
        for (int i = 0; i < numPoints; i++) {
            destPoints[i * 2 + 0] = srcPoints[i * 2 + 0] * mImageBounds.width() + mImageBounds.left;
            destPoints[i * 2 + 1] = srcPoints[i * 2 + 1] * mImageBounds.height() + mImageBounds.top;
        }
    }

    /**
     * Zooms to the desired scale and positions the image so that the given image point corresponds to
     * the given view point.
     *
     * @param scale      desired scale, will be limited to {min, max} scale factor
     * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1)
     * @param viewPoint  2D point in view's absolute coordinate system
     */
    public void zoomToPoint(float scale, PointF imagePoint, PointF viewPoint) {
        FLog.v(TAG, "zoomToPoint");
        calculateZoomToPointTransform(mActiveTransform, scale, imagePoint, viewPoint, LIMIT_ALL);
        onTransformChanged();
    }

    /**
     * Calculates the zoom transformation that would zoom to the desired scale and position the image
     * so that the given image point corresponds to the given view point.
     *
     * @param outTransform the matrix to store the result to
     * @param scale        desired scale, will be limited to {min, max} scale factor
     * @param imagePoint   2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1)
     * @param viewPoint    2D point in view's absolute coordinate system
     * @param limitFlags   whether to limit translation and/or scale.
     * @return whether or not the transform has been corrected due to limitation
     */
    protected boolean calculateZoomToPointTransform(
            Matrix outTransform,
            float scale,
            PointF imagePoint,
            PointF viewPoint,
            @LimitFlag int limitFlags) {
        float[] viewAbsolute = mTempValues;
        viewAbsolute[0] = imagePoint.x;
        viewAbsolute[1] = imagePoint.y;
        mapRelativeToAbsolute(viewAbsolute, viewAbsolute, 1);
        float distanceX = viewPoint.x - viewAbsolute[0];
        float distanceY = viewPoint.y - viewAbsolute[1];
        boolean transformCorrected = false;
        outTransform.setScale(scale, scale, viewAbsolute[0], viewAbsolute[1]);
        transformCorrected |= limitScale(outTransform, viewAbsolute[0], viewAbsolute[1], limitFlags);
        outTransform.postTranslate(distanceX, distanceY);
        transformCorrected |= limitTranslation(outTransform, limitFlags);
        return transformCorrected;
    }

    /**
     * Sets a new zoom transformation.
     */
    public void setTransform(Matrix newTransform) {
        FLog.v(TAG, "setTransform");
        mActiveTransform.set(newTransform);
        onTransformChanged();
    }

    /**
     * Gets the gesture detector.
     */
    protected TransformGestureDetector getDetector() {
        return mGestureDetector;
    }

    /**
     * Notifies controller of the received touch event.
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        FLog.v(TAG, "onTouchEvent: action: ", event.getAction());
        if (mIsEnabled && mIsGestureZoomEnabled) {
            return mGestureDetector.onTouchEvent(event);
        }
        return false;
    }

    /* TransformGestureDetector.Listener methods  */

    @Override
    public void onGestureBegin(TransformGestureDetector detector) {
        FLog.v(TAG, "onGestureBegin");
        mPreviousTransform.set(mActiveTransform);
        onTransformBegin();
        // We only received a touch down event so far, and so we don't know yet in which direction a
        // future move event will follow. Therefore, if we can't scroll in all directions, we have to
        // assume the worst case where the user tries to scroll out of edge, which would cause
        // transformation to be corrected.
        mWasTransformCorrected = !canScrollInAllDirection();
    }

    @Override
    public void onGestureUpdate(TransformGestureDetector detector) {
        FLog.v(TAG, "onGestureUpdate");
        boolean transformCorrected = calculateGestureTransform(mActiveTransform, LIMIT_ALL);
        onTransformChanged();
        if (transformCorrected) {
            mGestureDetector.restartGesture();
        }
        // A transformation happened, but was it without correction?
        mWasTransformCorrected = transformCorrected;
    }

    @Override
    public void onGestureEnd(TransformGestureDetector detector) {
        FLog.v(TAG, "onGestureEnd");
        onTransformEnd();
    }

    /**
     * Calculates the zoom transformation based on the current gesture.
     *
     * @param outTransform the matrix to store the result to
     * @param limitTypes   whether to limit translation and/or scale.
     * @return whether or not the transform has been corrected due to limitation
     */
    protected boolean calculateGestureTransform(Matrix outTransform, @LimitFlag int limitTypes) {
        TransformGestureDetector detector = mGestureDetector;
        boolean transformCorrected = false;
        outTransform.set(mPreviousTransform);
        if (mIsRotationEnabled) {
            float angle = detector.getRotation() * (float) (180 / Math.PI);
            outTransform.postRotate(angle, detector.getPivotX(), detector.getPivotY());
        }
        if (mIsScaleEnabled) {
            float scale = detector.getScale();
            outTransform.postScale(scale, scale, detector.getPivotX(), detector.getPivotY());
        }
        transformCorrected |=
                limitScale(outTransform, detector.getPivotX(), detector.getPivotY(), limitTypes);
        if (mIsTranslationEnabled) {
            outTransform.postTranslate(detector.getTranslationX(), detector.getTranslationY());
        }
        transformCorrected |= limitTranslation(outTransform, limitTypes);
        return transformCorrected;
    }

    private void onTransformBegin() {
        if (mListener != null && isEnabled()) {
            mListener.onTransformBegin(mActiveTransform);
        }
    }

    private void onTransformChanged() {
        mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds);
        if (mListener != null && isEnabled()) {
            mListener.onTransformChanged(mActiveTransform);
        }
    }

    private void onTransformEnd() {
        if (mListener != null && isEnabled()) {
            mListener.onTransformEnd(mActiveTransform);
        }
    }

    /**
     * Keeps the scaling factor within the specified limits.
     *
     * @param pivotX     x coordinate of the pivot point
     * @param pivotY     y coordinate of the pivot point
     * @param limitTypes whether to limit scale.
     * @return whether limiting has been applied or not
     */
    private boolean limitScale(
            Matrix transform, float pivotX, float pivotY, @LimitFlag int limitTypes) {
        if (!shouldLimit(limitTypes, LIMIT_SCALE)) {
            return false;
        }
        float currentScale = getMatrixScaleFactor(transform);
        float targetScale = limit(currentScale, mMinScaleFactor, mMaxScaleFactor);
        if (targetScale != currentScale) {
            float scale = targetScale / currentScale;
            transform.postScale(scale, scale, pivotX, pivotY);
            return true;
        }
        return false;
    }

    /**
     * Limits the translation so that there are no empty spaces on the sides if possible.
     *
     * <p>The image is attempted to be centered within the view bounds if the transformed image is
     * smaller. There will be no empty spaces within the view bounds if the transformed image is
     * bigger. This applies to each dimension (horizontal and vertical) independently.
     *
     * @param limitTypes whether to limit translation along the specific axis.
     * @return whether limiting has been applied or not
     */
    private boolean limitTranslation(Matrix transform, @LimitFlag int limitTypes) {
        if (!shouldLimit(limitTypes, LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y)) {
            return false;
        }
        RectF b = mTempRect;
        b.set(mImageBounds);
        transform.mapRect(b);
        float offsetLeft =
                shouldLimit(limitTypes, LIMIT_TRANSLATION_X)
                        ? getOffset(
                        b.left, b.right, mViewBounds.left, mViewBounds.right, mImageBounds.centerX())
                        : 0;
        float offsetTop =
                shouldLimit(limitTypes, LIMIT_TRANSLATION_Y)
                        ? getOffset(
                        b.top, b.bottom, mViewBounds.top, mViewBounds.bottom, mImageBounds.centerY())
                        : 0;
        if (offsetLeft != 0 || offsetTop != 0) {
            transform.postTranslate(offsetLeft, offsetTop);
            return true;
        }
        return false;
    }

    /**
     * Checks whether the specified limit flag is present in the limits provided.
     *
     * <p>If the flag contains multiple flags together using a bitwise OR, this only checks that at
     * least one of the flags is included.
     *
     * @param limits the limits to apply
     * @param flag   the limit flag(s) to check for
     * @return true if the flag (or one of the flags) is included in the limits
     */
    private static boolean shouldLimit(@LimitFlag int limits, @LimitFlag int flag) {
        return (limits & flag) != LIMIT_NONE;
    }

    /**
     * Returns the offset necessary to make sure that: - the image is centered within the limit if the
     * image is smaller than the limit - there is no empty space on left/right if the image is bigger
     * than the limit
     */
    private float getOffset(
            float imageStart, float imageEnd, float limitStart, float limitEnd, float limitCenter) {
        float imageWidth = imageEnd - imageStart, limitWidth = limitEnd - limitStart;
        float limitInnerWidth = Math.min(limitCenter - limitStart, limitEnd - limitCenter) * 2;
        // center if smaller than limitInnerWidth
        if (imageWidth < limitInnerWidth) {
            return limitCenter - (imageEnd + imageStart) / 2;
        }
        // to the edge if in between and limitCenter is not (limitLeft + limitRight) / 2
        if (imageWidth < limitWidth) {
            if (limitCenter < (limitStart + limitEnd) / 2) {
                return limitStart - imageStart;
            } else {
                return limitEnd - imageEnd;
            }
        }
        // to the edge if larger than limitWidth and empty space visible
        if (imageStart > limitStart) {
            return limitStart - imageStart;
        }
        if (imageEnd < limitEnd) {
            return limitEnd - imageEnd;
        }
        return 0;
    }

    /**
     * Limits the value to the given min and max range.
     */
    private float limit(float value, float min, float max) {
        return Math.min(Math.max(min, value), max);
    }

    /**
     * Gets the scale factor for the given matrix. This method assumes the equal scaling factor for X
     * and Y axis.
     */
    private float getMatrixScaleFactor(Matrix transform) {
        transform.getValues(mTempValues);
        return mTempValues[Matrix.MSCALE_X];
    }

    /**
     * Same as {@code Matrix.isIdentity()}, but with tolerance {@code eps}.
     */
    private boolean isMatrixIdentity(Matrix transform, float eps) {
        // Checks whether the given matrix is close enough to the identity matrix:
        //   1 0 0
        //   0 1 0
        //   0 0 1
        // Or equivalently to the zero matrix, after subtracting 1.0f from the diagonal elements:
        //   0 0 0
        //   0 0 0
        //   0 0 0
        transform.getValues(mTempValues);
        mTempValues[0] -= 1.0f; // m00
        mTempValues[4] -= 1.0f; // m11
        mTempValues[8] -= 1.0f; // m22
        for (int i = 0; i < 9; i++) {
            if (Math.abs(mTempValues[i]) > eps) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns whether the scroll can happen in all directions. I.e. the image is not on any edge.
     */
    private boolean canScrollInAllDirection() {
        return mTransformedImageBounds.left < mViewBounds.left - EPS
                && mTransformedImageBounds.top < mViewBounds.top - EPS
                && mTransformedImageBounds.right > mViewBounds.right + EPS
                && mTransformedImageBounds.bottom > mViewBounds.bottom + EPS;
    }

    @Override
    public int computeHorizontalScrollRange() {
        return (int) mTransformedImageBounds.width();
    }

    @Override
    public int computeHorizontalScrollOffset() {
        return (int) (mViewBounds.left - mTransformedImageBounds.left);
    }

    @Override
    public int computeHorizontalScrollExtent() {
        return (int) mViewBounds.width();
    }

    @Override
    public int computeVerticalScrollRange() {
        return (int) mTransformedImageBounds.height();
    }

    @Override
    public int computeVerticalScrollOffset() {
        return (int) (mViewBounds.top - mTransformedImageBounds.top);
    }

    @Override
    public int computeVerticalScrollExtent() {
        return (int) mViewBounds.height();
    }

    public Listener getListener() {
        return mListener;
    }
}
