/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.internal.view;

import android.annotation.UiThread;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.graphics.HardwareRenderer;
import android.graphics.HardwareRenderer.SyncAndDrawResult;
import android.graphics.RecordingCanvas;
import android.graphics.Rect;
import android.graphics.RenderNode;
import android.os.CancellationSignal;
import android.os.SystemClock;
import android.provider.Settings;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Display.ColorMode;
import android.view.ScrollCaptureCallback;
import android.view.ScrollCaptureSession;
import android.view.Surface;
import android.view.View;
import android.view.ViewGroup;

import com.android.internal.view.ScrollCaptureViewHelper.ScrollResult;

import java.lang.ref.WeakReference;
import java.util.function.Consumer;

/**
 * Provides a base ScrollCaptureCallback implementation to handle arbitrary View-based scrolling
 * containers. This class handles the bookkeeping aspects of {@link ScrollCaptureCallback}
 * including rendering output using HWUI. Adaptable to any {@link View} using
 * {@link ScrollCaptureViewHelper}.
 *
 * @param <V> the specific View subclass handled
 * @see ScrollCaptureViewHelper
 */
@UiThread
public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCallback {

    private static final String TAG = "SCViewSupport";

    private static final String SETTING_CAPTURE_DELAY = "screenshot.scroll_capture_delay";
    private static final long SETTING_CAPTURE_DELAY_DEFAULT = 60L; // millis

    private final WeakReference<V> mWeakView;
    private final ScrollCaptureViewHelper<V> mViewHelper;
    private final ViewRenderer mRenderer;
    private final long mPostScrollDelayMillis;

    private boolean mStarted;
    private boolean mEnded;

    ScrollCaptureViewSupport(V containingView, ScrollCaptureViewHelper<V> viewHelper) {
        mWeakView = new WeakReference<>(containingView);
        mRenderer = new ViewRenderer();
        // TODO(b/177649144): provide access to color space from android.media.Image
        mViewHelper = viewHelper;
        Context context = containingView.getContext();
        ContentResolver contentResolver = context.getContentResolver();
        mPostScrollDelayMillis = Settings.Global.getLong(contentResolver,
                SETTING_CAPTURE_DELAY, SETTING_CAPTURE_DELAY_DEFAULT);
        Log.d(TAG, "screenshot.scroll_capture_delay = " + mPostScrollDelayMillis);
    }

    /** Based on ViewRootImpl#updateColorModeIfNeeded */
    @ColorMode
    private static int getColorMode(View containingView) {
        Context context = containingView.getContext();
        int colorMode = containingView.getViewRootImpl().mWindowAttributes.getColorMode();
        if (!context.getResources().getConfiguration().isScreenWideColorGamut()) {
            colorMode = ActivityInfo.COLOR_MODE_DEFAULT;
        }
        return colorMode;
    }

    /**
     * Maps a rect in request bounds relative space  (relative to requestBounds) to container-local
     * space, accounting for the provided value of scrollY.
     *
     * @param scrollY the current scroll offset to apply to rect
     * @param requestBounds defines the local coordinate space of rect, within the container
     * @param requestRect the rectangle to transform to container-local coordinates
     * @return the same rectangle mapped to container bounds
     */
    public static Rect transformFromRequestToContainer(int scrollY, Rect requestBounds,
            Rect requestRect) {
        Rect requestedContainerBounds = new Rect(requestRect);
        requestedContainerBounds.offset(0, -scrollY);
        requestedContainerBounds.offset(requestBounds.left, requestBounds.top);
        return requestedContainerBounds;
    }

    /**
     * Maps a rect in container-local coordinate space to request space (relative to
     * requestBounds), accounting for the provided value of scrollY.
     *
     * @param scrollY the current scroll offset of the container
     * @param requestBounds defines the local coordinate space of rect, within the container
     * @param containerRect the rectangle within the container local coordinate space
     * @return the same rectangle mapped to within request bounds
     */
    public static Rect transformFromContainerToRequest(int scrollY, Rect requestBounds,
            Rect containerRect) {
        Rect requestRect = new Rect(containerRect);
        requestRect.offset(-requestBounds.left, -requestBounds.top);
        requestRect.offset(0, scrollY);
        return requestRect;
    }

    /**
     * Implements the core contract of requestRectangleOnScreen. Given a bounding rect and
     * another rectangle, return the minimum scroll distance that will maximize the visible area
     * of the requested rectangle.
     *
     * @param parentVisibleBounds the visible area
     * @param requested the requested area
     */
    public static int computeScrollAmount(Rect parentVisibleBounds, Rect requested) {
        final int height = parentVisibleBounds.height();
        final int top = parentVisibleBounds.top;
        final int bottom = parentVisibleBounds.bottom;
        int scrollYDelta = 0;

        if (requested.bottom > bottom && requested.top > top) {
            // need to scroll DOWN (move views up) to get it in view:
            // move just enough so that the entire rectangle is in view
            // (or at least the first screen size chunk).

            if (requested.height() > height) {
                // just enough to get screen size chunk on
                scrollYDelta += (requested.top - top);
            } else {
                // entire rect at bottom
                scrollYDelta += (requested.bottom - bottom);
            }
        } else if (requested.top < top && requested.bottom < bottom) {
            // need to scroll UP (move views down) to get it in view:
            // move just enough so that entire rectangle is in view
            // (or at least the first screen size chunk of it).

            if (requested.height() > height) {
                // screen size chunk
                scrollYDelta -= (bottom - requested.bottom);
            } else {
                // entire rect at top
                scrollYDelta -= (top - requested.top);
            }
        }
        return scrollYDelta;
    }

    /**
     * Locate a view to use as a reference, given an anticipated scrolling movement.
     * <p>
     * This view will be used to measure the actual movement of child views after scrolling.
     * When scrolling down, the last (max(y)) view is used, otherwise the first (min(y)
     * view. This helps to avoid recycling the reference view as a side effect of scrolling.
     *
     * @param parent the scrolling container
     * @param expectedScrollDistance the amount of scrolling to perform
     */
    public static View findScrollingReferenceView(ViewGroup parent, int expectedScrollDistance) {
        View selected = null;
        Rect parentLocalVisible = new Rect();
        parent.getLocalVisibleRect(parentLocalVisible);

        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View child = parent.getChildAt(i);
            if (selected == null) {
                selected = child;
            } else if (expectedScrollDistance < 0) {
                if (child.getTop() < selected.getTop()) {
                    selected = child;
                }
            } else if (child.getBottom() > selected.getBottom()) {
                selected = child;
            }
        }
        return selected;
    }

    @Override
    public final void onScrollCaptureSearch(CancellationSignal signal, Consumer<Rect> onReady) {
        if (signal.isCanceled()) {
            return;
        }
        V view = mWeakView.get();
        mStarted = false;
        mEnded = false;

        if (view != null && view.isVisibleToUser() && mViewHelper.onAcceptSession(view)) {
            onReady.accept(mViewHelper.onComputeScrollBounds(view));
            return;
        }
        onReady.accept(null);
    }

    @Override
    public final void onScrollCaptureStart(ScrollCaptureSession session, CancellationSignal signal,
            Runnable onReady) {
        if (signal.isCanceled()) {
            return;
        }
        V view = mWeakView.get();

        mEnded = false;
        mStarted = true;

        // Note: If somehow the view is already gone or detached, the first call to
        // {@code onScrollCaptureImageRequest} will return an error and request the session to
        // end.
        if (view != null && view.isVisibleToUser()) {
            mRenderer.setSurface(session.getSurface());
            mViewHelper.onPrepareForStart(view, session.getScrollBounds());
        }
        onReady.run();
    }

    @Override
    public final void onScrollCaptureImageRequest(ScrollCaptureSession session,
            CancellationSignal signal, Rect requestRect, Consumer<Rect> onComplete) {
        if (signal.isCanceled()) {
            Log.w(TAG, "onScrollCaptureImageRequest: cancelled!");
            return;
        }

        V view = mWeakView.get();
        if (view == null || !view.isVisibleToUser()) {
            // Signal to the controller that we have a problem and can't continue.
            onComplete.accept(new Rect());
            return;
        }

        // Ask the view to scroll as needed to bring this area into view.
        mViewHelper.onScrollRequested(view, session.getScrollBounds(), requestRect, signal,
                (result) -> onScrollResult(result, view, signal, onComplete));
    }

    private void onScrollResult(ScrollResult scrollResult, V view, CancellationSignal signal,
            Consumer<Rect> onComplete) {

        if (signal.isCanceled()) {
            Log.w(TAG, "onScrollCaptureImageRequest: cancelled! skipping render.");
            return;
        }

        if (scrollResult.availableArea.isEmpty()) {
            onComplete.accept(scrollResult.availableArea);
            return;
        }

        // For image capture, shift back by scrollDelta to arrive at the location
        // within the view where the requested content will be drawn
        Rect viewCaptureArea = new Rect(scrollResult.availableArea);
        viewCaptureArea.offset(0, -scrollResult.scrollDelta);

        view.postOnAnimationDelayed(
                () -> doCapture(scrollResult, view, viewCaptureArea, onComplete),
                mPostScrollDelayMillis);
    }

    private void doCapture(ScrollResult scrollResult, V view, Rect viewCaptureArea,
            Consumer<Rect> onComplete) {
        int result = mRenderer.renderView(view, viewCaptureArea);
        if (result == HardwareRenderer.SYNC_OK
                || result == HardwareRenderer.SYNC_REDRAW_REQUESTED) {
            /* Frame synced, buffer will be produced... notify client. */
            onComplete.accept(new Rect(scrollResult.availableArea));
        } else {
            // No buffer will be produced.
            Log.e(TAG, "syncAndDraw(): SyncAndDrawResult = " + result);
            onComplete.accept(new Rect(/* empty */));
        }
    }

    @Override
    public final void onScrollCaptureEnd(Runnable onReady) {
        V view = mWeakView.get();
        if (mStarted && !mEnded) {
            if (view != null) {
                mViewHelper.onPrepareForEnd(view);
                view.invalidate();
            }
            mEnded = true;
            mRenderer.destroy();
        }
        onReady.run();
    }

    /**
     * Internal helper class which assists in rendering sections of the view hierarchy relative to a
     * given view.
     */
    static final class ViewRenderer {
        // alpha, "reasonable default" from Javadoc
        private static final float AMBIENT_SHADOW_ALPHA = 0.039f;
        private static final float SPOT_SHADOW_ALPHA = 0.039f;

        // Default values:
        //    lightX = (screen.width() / 2) - windowLeft
        //    lightY = 0 - windowTop
        //    lightZ = 600dp
        //    lightRadius = 800dp
        private static final float LIGHT_Z_DP = 400;
        private static final float LIGHT_RADIUS_DP = 800;
        private static final String TAG = "ViewRenderer";

        private final HardwareRenderer mRenderer;
        private final RenderNode mCaptureRenderNode;
        private final Rect mTempRect = new Rect();
        private final int[] mTempLocation = new int[2];
        private long mLastRenderedSourceDrawingId = -1;
        private Surface mSurface;

        ViewRenderer() {
            mRenderer = new HardwareRenderer();
            mRenderer.setName("ScrollCapture");
            mCaptureRenderNode = new RenderNode("ScrollCaptureRoot");
            mRenderer.setContentRoot(mCaptureRenderNode);

            // TODO: Figure out a way to flip this on when we are sure the source window is opaque
            mRenderer.setOpaque(false);
        }

        public void setSurface(Surface surface) {
            mSurface = surface;
            mRenderer.setSurface(surface);
        }

        /**
         * Cache invalidation check. If the source view is the same as the previous call (which is
         * mostly always the case, then we can skip setting up lighting on each call (for now)
         *
         * @return true if the view changed, false if the view was previously rendered by this class
         */
        private boolean updateForView(View source) {
            if (mLastRenderedSourceDrawingId == source.getUniqueDrawingId()) {
                return false;
            }
            mLastRenderedSourceDrawingId = source.getUniqueDrawingId();
            return true;
        }

        // TODO: may need to adjust lightY based on the virtual canvas position to get
        //       consistent shadow positions across the whole capture. Or possibly just
        //       pull lightZ way back to make shadows more uniform.
        private void setupLighting(View mSource) {
            mLastRenderedSourceDrawingId = mSource.getUniqueDrawingId();
            DisplayMetrics metrics = mSource.getResources().getDisplayMetrics();
            mSource.getLocationOnScreen(mTempLocation);
            final float lightX = metrics.widthPixels / 2f - mTempLocation[0];
            final float lightY = metrics.heightPixels - mTempLocation[1];
            final int lightZ = (int) (LIGHT_Z_DP * metrics.density);
            final int lightRadius = (int) (LIGHT_RADIUS_DP * metrics.density);

            // Enable shadows for elevation/Z
            mRenderer.setLightSourceGeometry(lightX, lightY, lightZ, lightRadius);
            mRenderer.setLightSourceAlpha(AMBIENT_SHADOW_ALPHA, SPOT_SHADOW_ALPHA);
        }

        private void updateRootNode(View source, Rect localSourceRect) {
            final View rootView = source.getRootView();
            transformToRoot(source, localSourceRect, mTempRect);

            mCaptureRenderNode.setPosition(0, 0, mTempRect.width(), mTempRect.height());
            RecordingCanvas canvas = mCaptureRenderNode.beginRecording();
            canvas.enableZ();
            canvas.translate(-mTempRect.left, -mTempRect.top);

            RenderNode rootViewRenderNode = rootView.updateDisplayListIfDirty();
            if (rootViewRenderNode.hasDisplayList()) {
                canvas.drawRenderNode(rootViewRenderNode);
            }
            mCaptureRenderNode.endRecording();
        }

        @SyncAndDrawResult
        public int renderView(View view, Rect sourceRect) {
            HardwareRenderer.FrameRenderRequest request = mRenderer.createRenderRequest();
            request.setVsyncTime(System.nanoTime());
            if (updateForView(view)) {
                setupLighting(view);
            }
            view.invalidate();
            updateRootNode(view, sourceRect);
            return request.syncAndDraw();
        }

        public void trimMemory() {
            mRenderer.clearContent();
        }

        public void destroy() {
            mSurface = null;
            mRenderer.destroy();
        }

        private void transformToRoot(View local, Rect localRect, Rect outRect) {
            local.getLocationInWindow(mTempLocation);
            outRect.set(localRect);
            outRect.offset(mTempLocation[0], mTempLocation[1]);
        }

        public void setColorMode(@ColorMode int colorMode) {
            mRenderer.setColorMode(colorMode);
        }
    }

    @Override
    public String toString() {
        return "ScrollCaptureViewSupport{"
                + "view=" + mWeakView.get()
                + ", helper=" + mViewHelper
                + '}';
    }
}
