/*
 * This file provided by Facebook is for non-commercial testing and evaluation
 * purposes only.  Facebook reserves all rights not expressly granted.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
 * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
 * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

package com.facebook.samples.zoomable;

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

import android.graphics.Matrix;
import android.graphics.PointF;
import android.graphics.RectF;
import android.support.annotation.IntDef;
import android.view.MotionEvent;

import com.facebook.common.logging.FLog;
import com.facebook.samples.gestures.TransformGestureDetector;

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

  @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 Listener mListener = null;

  private boolean mIsEnabled = false;
  private boolean mIsRotationEnabled = false;
  private boolean mIsScaleEnabled = true;
  private boolean mIsTranslationEnabled = 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;
  }

  /** 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();
    }
  }

  /** 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;
  }

  /**
   * 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.
   *
   * 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.
   *
   * 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) {
      return mGestureDetector.onTouchEvent(event);
    }
    return false;
  }

  /* TransformGestureDetector.Listener methods  */

  @Override
  public void onGestureBegin(TransformGestureDetector detector) {
    FLog.v(TAG, "onGestureBegin");
    mPreviousTransform.set(mActiveTransform);
    // 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");
  }

  /**
   * 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 onTransformChanged() {
    mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds);
    if (mListener != null && isEnabled()) {
      mListener.onTransformChanged(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;
  }
}
