// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.paintpreview.player.frame;

import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.Size;
import android.view.View;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.UnguessableToken;
import org.chromium.components.paintpreview.player.PlayerCompositorDelegate;
import org.chromium.components.paintpreview.player.PlayerGestureListener;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.url.GURL;

import java.util.ArrayList;
import java.util.List;

/**
 * Handles the business logic for the player frame component. Concretely, this class is responsible
 * for:
 * <ul>
 * <li>Maintaining a viewport {@link Rect} that represents the current user-visible section of this
 * frame. The dimension of the viewport is constant and is equal to the initial values received on
 * {@link #setLayoutDimensions}.</li>
 * <li>Constructing a matrix of {@link Bitmap} tiles that represents the content of this
 * frame for a given scale factor. Each tile is as big as the view port.</li>
 * <li>Requesting bitmaps from Paint Preview compositor.</li>
 * <li>Updating the viewport on touch gesture notifications (scrolling and scaling).<li/>
 * <li>Determining which sub-frames are visible given the current viewport and showing them.<li/>
 * </ul>
 */
class PlayerFrameMediator implements PlayerFrameViewDelegate, PlayerFrameMediatorDelegate {
    /** The GUID associated with the frame that this class is representing. */
    private final UnguessableToken mGuid;
    /** The size of the content inside this frame, at a scale factor of 1. */
    private final Size mContentSize;
    /**
     * Contains all {@link View}s corresponding to this frame's sub-frames.
     */
    private final List<View> mSubFrameViews = new ArrayList<>();
    /**
     * Contains all clip rects corresponding to this frame's sub-frames.
     */
    private final List<Rect> mSubFrameRects = new ArrayList<>();
    /**
     * Contains all mediators corresponding to this frame's sub-frames.
     */
    private final List<PlayerFrameMediator> mSubFrameMediators = new ArrayList<>();
    /**
     * Contains scaled clip rects corresponding to this frame's sub-frames.
     */
    private final List<Rect> mSubFrameScaledRects = new ArrayList<>();

    private final PropertyModel mModel;
    private final PlayerCompositorDelegate mCompositorDelegate;
    /** The viewport of this frame. */
    private final PlayerFrameViewport mViewport;

    private boolean mIsSubframe;
    /** Transient object to avoid allocation. */
    private Rect mScaledRectIntersection = new Rect();
    private float mInitialScaleFactor;
    private float mMinScaleFactor;
    /** Handles scaling of bitmaps. */
    private final Matrix mBitmapScaleMatrix;
    private final Point mOffsetForScaling;

    private final PlayerFrameBitmapStateController mBitmapStateController;

    private PlayerGestureListener mGestureListener;
    private Runnable mInitialViewportSizeAvailable;

    PlayerFrameMediator(PropertyModel model, PlayerCompositorDelegate compositorDelegate,
            PlayerGestureListener gestureListener, UnguessableToken frameGuid, Size contentSize,
            int initialScrollX, int initialScrollY, float initialScaleFactor,
            Runnable initialViewportSizeAvailable) {
        mBitmapScaleMatrix = new Matrix();
        mOffsetForScaling = new Point();
        mModel = model;
        mModel.set(PlayerFrameProperties.SCALE_MATRIX, mBitmapScaleMatrix);

        mCompositorDelegate = compositorDelegate;
        mGestureListener = gestureListener;
        mViewport = new PlayerFrameViewport();
        mIsSubframe = false;
        mInitialScaleFactor = initialScaleFactor;
        mGuid = frameGuid;
        mContentSize = contentSize;
        mBitmapStateController = new PlayerFrameBitmapStateController(
                mGuid, mViewport, mContentSize, mCompositorDelegate, this);
        mViewport.offset(initialScrollX, initialScrollY);
        mViewport.setScale(mInitialScaleFactor);
        mInitialViewportSizeAvailable = initialViewportSizeAvailable;
    }

    void destroy() {
        mBitmapStateController.destroy();
    }

    PlayerFrameBitmapStateController getBitmapStateControllerForTest() {
        return mBitmapStateController;
    }

    void updateViewportSize(int width, int height, float scaleFactor) {
        if (width <= 0 || height <= 0) return;

        // Ensure the viewport is within the bounds of the content.
        final int left = Math.max(0,
                Math.min(Math.round(mViewport.getTransX()),
                        Math.round(mContentSize.getWidth() * scaleFactor) - width));
        final int top = Math.max(0,
                Math.min(Math.round(mViewport.getTransY()),
                        Math.round(mContentSize.getHeight() * scaleFactor) - height));

        mViewport.setTrans(left, top);
        mViewport.setSize(width, height);
        final float oldScaleFactor = mViewport.getScale();
        mViewport.setScale(scaleFactor);
        updateVisuals(oldScaleFactor != scaleFactor);
    }

    /**
     * Adds a new sub-frame to this frame.
     * @param subFrameView The {@link View} associated with the sub-frame.
     * @param clipRect     The bounds of the sub-frame, relative to this frame.
     * @param mediator     The mediator of the sub-frame.
     */
    void addSubFrame(View subFrameView, Rect clipRect, PlayerFrameMediator mediator) {
        mSubFrameViews.add(subFrameView);
        mSubFrameRects.add(clipRect);
        mSubFrameMediators.add(mediator);
        mSubFrameScaledRects.add(new Rect());
        mediator.markAsSubframe();
        mediator.setInitialScaleFactor(mInitialScaleFactor);
        mModel.set(PlayerFrameProperties.SUBFRAME_VIEWS, mSubFrameViews);
        mModel.set(PlayerFrameProperties.SUBFRAME_RECTS, mSubFrameScaledRects);
    }

    void setBitmapScaleMatrixOfSubframe(Matrix matrix, float scaleFactor) {
        // Don't update the subframes if the matrix is identity as it will be forcibly recalculated.
        if (!matrix.isIdentity()) {
            float relativeScale = scaleFactor / mViewport.getScale();
            mModel.set(PlayerFrameProperties.OFFSET,
                    new Point(Math.round(mOffsetForScaling.x / relativeScale),
                            Math.round(mOffsetForScaling.y / relativeScale)));
            updateSubframes(mViewport.getVisibleViewport(mIsSubframe), scaleFactor);
        }
        setBitmapScaleMatrix(matrix, scaleFactor);
    }

    private void updateSubframeBitmapTileSizeRecursive(Size size) {
        if (mIsSubframe) {
            mViewport.overrideTileSize(size.getWidth(), size.getHeight());
        }
        for (int i = 0; i < mSubFrameViews.size(); i++) {
            mSubFrameMediators.get(i).updateSubframeBitmapTileSizeRecursive(size);
        }
    }

    // PlayerFrameViewDelegate

    @Override
    public void setLayoutDimensions(int width, int height) {
        // Don't trigger a re-draw if we are actively scaling.
        if (!mBitmapScaleMatrix.isIdentity()) {
            mViewport.setSize(width, height);
            return;
        }

        // Set initial scale so that content width fits within the layout dimensions.
        if (!mIsSubframe) {
            adjustInitialScaleFactor(width);
            updateSubframeBitmapTileSizeRecursive(new Size(width, Math.round(height / 2f)));
        }

        final float scaleFactor = mViewport.getScale();
        // Ensure subframes use their assigned initial scale factor.
        updateViewportSize(width, height, (scaleFactor == 0f) ? mInitialScaleFactor : scaleFactor);

        if (mInitialViewportSizeAvailable != null) {
            mInitialViewportSizeAvailable.run();
            mInitialViewportSizeAvailable = null;
        }
    }

    @Override
    public void onTap(int x, int y, boolean isAbsolute) {
        // x and y are in the View's coordinate system (scaled). This needs to be adjusted to the
        // absolute coordinate system for hit testing.
        final float scaleFactor = mViewport.getScale();
        float translationX = isAbsolute ? 0f : mViewport.getTransX();
        float translationY = isAbsolute ? 0f : mViewport.getTransY();
        GURL url = mCompositorDelegate.onClick(mGuid, Math.round((translationX + x) / scaleFactor),
                Math.round((translationY + y) / scaleFactor));
        mGestureListener.onTap(url);
    }

    @Override
    public void onLongPress(int x, int y) {
        mGestureListener.onLongPress();
    }

    // PlayerFrameMediatorDelegate

    @Override
    public PlayerFrameViewport getViewport() {
        return mViewport;
    }

    @Override
    public Size getContentSize() {
        return mContentSize;
    }

    @Override
    public float getMinScaleFactor() {
        return mMinScaleFactor;
    }

    @Override
    public void onStartScaling() {
        mOffsetForScaling.set(mViewport.getOffset().x, mViewport.getOffset().y);
        mBitmapStateController.onStartScaling();
        for (int i = 0; i < mSubFrameViews.size(); i++) {
            mSubFrameMediators.get(i).onStartScaling();
        }
    }

    @Override
    public void onSwapState() {
        PlayerFrameBitmapState bitmapState = mBitmapStateController.getBitmapState(false);
        mBitmapScaleMatrix.reset();
        mOffsetForScaling.set(0, 0);
        setBitmapScaleMatrix(mBitmapScaleMatrix, 1f);
        mModel.set(PlayerFrameProperties.TILE_DIMENSIONS, bitmapState.getTileDimensions());
        mModel.set(PlayerFrameProperties.OFFSET, mViewport.getOffset());
        mModel.set(PlayerFrameProperties.VIEWPORT, mViewport.getVisibleViewport(mIsSubframe));
        mModel.set(PlayerFrameProperties.BITMAP_MATRIX, bitmapState.getMatrix());
    }

    @Override
    public void offsetBitmapScaleMatrix(float dx, float dy) {
        // If we are still waiting on new bitmaps after a scale operation, the scroll should scroll
        // the bitmaps we currently have. In order to do so we apply an opposite transform to the
        // bitmaps that are shown on the screen.
        if (!mBitmapScaleMatrix.isIdentity()) {
            float[] bitmapScaleMatrixValues = new float[9];
            mBitmapScaleMatrix.getValues(bitmapScaleMatrixValues);
            bitmapScaleMatrixValues[Matrix.MTRANS_X] -= dx;
            bitmapScaleMatrixValues[Matrix.MTRANS_Y] -= dy;
            mBitmapScaleMatrix.setValues(bitmapScaleMatrixValues);
            setBitmapScaleMatrix(mBitmapScaleMatrix, mViewport.getScale());
        }
    }

    /**
     * Called when the viewport is moved or the scale factor is changed. Updates the viewport
     * and requests bitmap tiles for portion of the view port that don't have bitmap tiles.
     * @param scaleUpdated Whether the scale was updated.
     */
    @Override
    public void updateVisuals(boolean scaleUpdated) {
        final float scaleFactor = mViewport.getScale();

        // Prevent updates before the viewport is ready.
        if (scaleFactor == 0f || mViewport.getWidth() == 0 || mViewport.getHeight() == 0) return;

        PlayerFrameBitmapState activeLoadingState =
                mBitmapStateController.getBitmapState(scaleUpdated);

        // Scaling locks the visible state from further updates. If the state is locked we
        // should not progress updating anything other than |mBitmapScaleMatrix| until
        // a new state is present.
        if (activeLoadingState.isLocked()) return;

        Rect viewportRect = mViewport.getVisibleViewport(mIsSubframe);
        updateSubframes(viewportRect, scaleFactor);
        // Let the view know |mViewport| changed. PropertyModelChangeProcessor is smart about
        // this and will only update the view if |mViewport|'s rect is actually changed.
        if (mBitmapStateController.isVisible(activeLoadingState)) {
            mModel.set(
                    PlayerFrameProperties.TILE_DIMENSIONS, activeLoadingState.getTileDimensions());
            mModel.set(PlayerFrameProperties.OFFSET, mViewport.getOffset());
            mModel.set(PlayerFrameProperties.VIEWPORT, viewportRect);
        }
        if (viewportRect.isEmpty()) return;

        // Request bitmaps for tiles inside the view port that don't already have a bitmap.
        activeLoadingState.requestBitmapForRect(viewportRect);
    }

    @Override
    public void updateScaleFactorOfAllSubframes(float scaleFactor) {
        for (int i = 0; i < mSubFrameViews.size(); i++) {
            mSubFrameMediators.get(i).updateScaleFactor(scaleFactor);
        }
    }

    @Override
    public void forceRedrawVisibleSubframes() {
        for (int i = 0; i < mSubFrameViews.size(); i++) {
            if (mSubFrameViews.get(i).getVisibility() != View.VISIBLE) continue;

            mSubFrameMediators.get(i).forceRedraw();
        }
    }

    @Override
    public void updateBitmapMatrix(Bitmap[][] bitmapMatrix) {
        mModel.set(PlayerFrameProperties.BITMAP_MATRIX, bitmapMatrix);
    }

    @Override
    public void updateSubframes(Rect viewport, float scaleFactor) {
        Point offset = mViewport.getOffset();
        for (int i = 0; i < mSubFrameRects.size(); i++) {
            Rect subFrameScaledRect = mSubFrameScaledRects.get(i);
            scaleRect(mSubFrameRects.get(i), subFrameScaledRect, scaleFactor);
            mScaledRectIntersection.set(subFrameScaledRect);
            if (!mScaledRectIntersection.intersect(viewport)) {
                mSubFrameViews.get(i).setVisibility(View.GONE);
                mSubFrameMediators.get(i).setVisibleRegion(0, 0, 0, 0);
                subFrameScaledRect.set(0, 0, 0, 0);
                continue;
            }
            int visibleLeft = mScaledRectIntersection.left - subFrameScaledRect.left;
            int visibleTop = mScaledRectIntersection.top - subFrameScaledRect.top;
            mSubFrameMediators.get(i).setVisibleRegion(visibleLeft, visibleTop,
                    visibleLeft + mScaledRectIntersection.width(),
                    visibleTop + mScaledRectIntersection.height());

            int transformedLeft = offset.x + subFrameScaledRect.left - viewport.left;
            int transformedTop = offset.y + subFrameScaledRect.top - viewport.top;
            subFrameScaledRect.set(transformedLeft, transformedTop,
                    transformedLeft + subFrameScaledRect.width(),
                    transformedTop + subFrameScaledRect.height());
            mSubFrameViews.get(i).setVisibility(View.VISIBLE);
        }
        mModel.set(PlayerFrameProperties.SUBFRAME_RECTS, mSubFrameScaledRects);
        mModel.set(PlayerFrameProperties.SUBFRAME_VIEWS, mSubFrameViews);
    }

    @Override
    public void setBitmapScaleMatrix(Matrix matrix, float scaleFactor) {
        float[] matrixValues = new float[9];
        matrix.getValues(matrixValues);
        mBitmapScaleMatrix.setValues(matrixValues);
        Matrix childBitmapScaleMatrix = new Matrix();
        childBitmapScaleMatrix.setScale(
                matrixValues[Matrix.MSCALE_X], matrixValues[Matrix.MSCALE_Y]);
        for (int i = 0; i < mSubFrameViews.size(); i++) {
            if (mSubFrameViews.get(i).getVisibility() != View.VISIBLE) continue;

            mSubFrameMediators.get(i).setBitmapScaleMatrixOfSubframe(
                    childBitmapScaleMatrix, scaleFactor);
        }
        mModel.set(PlayerFrameProperties.SCALE_MATRIX, mBitmapScaleMatrix);
    }

    // Internal methods

    private void setInitialScaleFactor(float scaleFactor) {
        mInitialScaleFactor = scaleFactor;

        if (mSubFrameViews == null) return;

        for (int i = 0; i < mSubFrameViews.size(); i++) {
            mSubFrameMediators.get(i).setInitialScaleFactor(mInitialScaleFactor);
        }
    }

    void setVisibleRegion(int left, int top, int right, int bottom) {
        mViewport.setVisibleRegion(left, top, right, bottom);

        // The region is no longer visible delete all the bitmaps.
        if (!mViewport.isVisible(mIsSubframe)) {
            mBitmapStateController.deleteAll();
        }
    }

    private void markAsSubframe() {
        mIsSubframe = true;
    }

    @VisibleForTesting
    void updateScaleFactor(float scaleFactor) {
        float relativeScale = scaleFactor / mViewport.getScale();
        mViewport.setScale(scaleFactor);
        mViewport.setTrans(
                mViewport.getTransX() * relativeScale, mViewport.getTransY() * relativeScale);
        for (int i = 0; i < mSubFrameViews.size(); i++) {
            mSubFrameMediators.get(i).updateScaleFactor(scaleFactor);
        }
    }

    @VisibleForTesting
    void forceRedraw() {
        updateVisuals(true);
        for (int i = 0; i < mSubFrameViews.size(); i++) {
            if (mSubFrameViews.get(i).getVisibility() != View.VISIBLE) continue;

            mSubFrameMediators.get(i).forceRedraw();
        }
    }

    private void scaleRect(Rect inRect, Rect outRect, float scaleFactor) {
        outRect.set((int) (((float) inRect.left) * scaleFactor),
                (int) (((float) inRect.top) * scaleFactor),
                (int) (((float) inRect.right) * scaleFactor),
                (int) (((float) inRect.bottom) * scaleFactor));
    }

    /**
     * Calculates the initial scale factor for a given viewport width.
     * @param width The viewport width.
     */
    private void adjustInitialScaleFactor(float width) {
        mMinScaleFactor = width / ((float) mContentSize.getWidth());
        if (mInitialScaleFactor == 0f) {
            mInitialScaleFactor = mMinScaleFactor;
        }

        for (int i = 0; i < mSubFrameViews.size(); i++) {
            mSubFrameMediators.get(i).setInitialScaleFactor(mInitialScaleFactor);
        }
    }

    public boolean checkRequiredBitmapsLoadedForTest() {
        PlayerFrameBitmapState state = mBitmapStateController.getBitmapState(false);
        assert mBitmapStateController.isVisible(state);
        boolean hasBitmaps = state.checkRequiredBitmapsLoadedForTest();
        if (!hasBitmaps) return false;

        for (int i = 0; i < mSubFrameViews.size(); i++) {
            if (mSubFrameViews.get(i).getVisibility() != View.VISIBLE) continue;

            hasBitmaps &= mSubFrameMediators.get(i).checkRequiredBitmapsLoadedForTest();
        }
        return hasBitmaps;
    }
}
