/*
 * 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.wm.shell.pip.phone;

import static android.view.WindowManager.SHELL_ROOT_LAYER_PIP;

import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.RemoteAction;
import android.content.Context;
import android.content.pm.ParceledListSlice;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Debug;
import android.os.Handler;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import android.util.Size;
import android.view.MotionEvent;
import android.view.SurfaceControl;
import android.view.SyncRtSurfaceTransactionApplier;
import android.view.SyncRtSurfaceTransactionApplier.SurfaceParams;
import android.view.WindowManagerGlobal;

import com.android.wm.shell.common.ShellExecutor;
import com.android.wm.shell.common.SystemWindows;
import com.android.wm.shell.pip.PipBoundsState;
import com.android.wm.shell.pip.PipMediaController;
import com.android.wm.shell.pip.PipMediaController.ActionListener;
import com.android.wm.shell.pip.PipMenuController;
import com.android.wm.shell.splitscreen.SplitScreenController;

import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/**
 * Manages the PiP menu view which can show menu options or a scrim.
 *
 * The current media session provides actions whenever there are no valid actions provided by the
 * current PiP activity. Otherwise, those actions always take precedence.
 */
public class PhonePipMenuController implements PipMenuController {

    private static final String TAG = "PhonePipMenuController";
    private static final boolean DEBUG = false;

    public static final int MENU_STATE_NONE = 0;
    public static final int MENU_STATE_FULL = 1;

    /**
     * A listener interface to receive notification on changes in PIP.
     */
    public interface Listener {
        /**
         * Called when the PIP menu visibility change has started.
         *
         * @param menuState the new, about-to-change state of the menu
         * @param resize whether or not to resize the PiP with the state change
         */
        void onPipMenuStateChangeStart(int menuState, boolean resize, Runnable callback);

        /**
         * Called when the PIP menu state has finished changing/animating.
         *
         * @param menuState the new state of the menu.
         */
        void onPipMenuStateChangeFinish(int menuState);

        /**
         * Called when the PIP requested to be expanded.
         */
        void onPipExpand();

        /**
         * Called when the PIP requested to be dismissed.
         */
        void onPipDismiss();

        /**
         * Called when the PIP requested to show the menu.
         */
        void onPipShowMenu();

        /**
         * Called when the PIP requested to enter Split.
         */
        void onEnterSplit();
    }

    private final Matrix mMoveTransform = new Matrix();
    private final Rect mTmpSourceBounds = new Rect();
    private final RectF mTmpSourceRectF = new RectF();
    private final RectF mTmpDestinationRectF = new RectF();
    private final Context mContext;
    private final PipBoundsState mPipBoundsState;
    private final PipMediaController mMediaController;
    private final ShellExecutor mMainExecutor;
    private final Handler mMainHandler;

    private final ArrayList<Listener> mListeners = new ArrayList<>();
    private final SystemWindows mSystemWindows;
    private final Optional<SplitScreenController> mSplitScreenController;
    private ParceledListSlice<RemoteAction> mAppActions;
    private ParceledListSlice<RemoteAction> mMediaActions;
    private SyncRtSurfaceTransactionApplier mApplier;
    private int mMenuState;

    private PipMenuView mPipMenuView;
    private IBinder mPipMenuInputToken;

    private ActionListener mMediaActionListener = new ActionListener() {
        @Override
        public void onMediaActionsChanged(List<RemoteAction> mediaActions) {
            mMediaActions = new ParceledListSlice<>(mediaActions);
            updateMenuActions();
        }
    };

    private final float[] mTmpValues = new float[9];
    private final Runnable mUpdateEmbeddedMatrix = () -> {
        if (mPipMenuView == null || mPipMenuView.getViewRootImpl() == null) {
            return;
        }
        mMoveTransform.getValues(mTmpValues);
        try {
            mPipMenuView.getViewRootImpl().getAccessibilityEmbeddedConnection()
                    .setScreenMatrix(mTmpValues);
        } catch (RemoteException e) {
        }
    };

    public PhonePipMenuController(Context context, PipBoundsState pipBoundsState,
            PipMediaController mediaController, SystemWindows systemWindows,
            Optional<SplitScreenController> splitScreenOptional,
            ShellExecutor mainExecutor, Handler mainHandler) {
        mContext = context;
        mPipBoundsState = pipBoundsState;
        mMediaController = mediaController;
        mSystemWindows = systemWindows;
        mMainExecutor = mainExecutor;
        mMainHandler = mainHandler;
        mSplitScreenController = splitScreenOptional;
    }

    public boolean isMenuVisible() {
        return mPipMenuView != null && mMenuState != MENU_STATE_NONE;
    }

    /**
     * Attach the menu when the PiP task first appears.
     */
    @Override
    public void attach(SurfaceControl leash) {
        attachPipMenuView();
    }

    /**
     * Detach the menu when the PiP task is gone.
     */
    @Override
    public void detach() {
        hideMenu();
        detachPipMenuView();
    }

    private void attachPipMenuView() {
        // In case detach was not called (e.g. PIP unexpectedly closed)
        if (mPipMenuView != null) {
            detachPipMenuView();
        }
        mPipMenuView = new PipMenuView(mContext, this, mMainExecutor, mMainHandler,
                mSplitScreenController);
        mSystemWindows.addView(mPipMenuView,
                getPipMenuLayoutParams(MENU_WINDOW_TITLE, 0 /* width */, 0 /* height */),
                0, SHELL_ROOT_LAYER_PIP);
        setShellRootAccessibilityWindow();
    }

    private void detachPipMenuView() {
        if (mPipMenuView == null) {
            return;
        }

        mApplier = null;
        mSystemWindows.removeView(mPipMenuView);
        mPipMenuView = null;
        mPipMenuInputToken = null;
    }

    /**
     * Updates the layout parameters of the menu.
     * @param destinationBounds New Menu bounds.
     */
    @Override
    public void updateMenuBounds(Rect destinationBounds) {
        mSystemWindows.updateViewLayout(mPipMenuView,
                getPipMenuLayoutParams(MENU_WINDOW_TITLE, destinationBounds.width(),
                        destinationBounds.height()));
        updateMenuLayout(destinationBounds);
    }

    @Override
    public void onFocusTaskChanged(ActivityManager.RunningTaskInfo taskInfo) {
        if (mPipMenuView != null) {
            mPipMenuView.onFocusTaskChanged(taskInfo);
        }
    }

    /**
     * Tries to grab a surface control from {@link PipMenuView}. If this isn't available for some
     * reason (ie. the window isn't ready yet, thus {@link android.view.ViewRootImpl} is
     * {@code null}), it will get the leash that the WindowlessWM has assigned to it.
     */
    public SurfaceControl getSurfaceControl() {
        return mSystemWindows.getViewSurface(mPipMenuView);
    }

    /**
     * Adds a new menu activity listener.
     */
    public void addListener(Listener listener) {
        if (!mListeners.contains(listener)) {
            mListeners.add(listener);
        }
    }

    @Nullable
    Size getEstimatedMinMenuSize() {
        return mPipMenuView == null ? null : mPipMenuView.getEstimatedMinMenuSize();
    }

    /**
     * When other components requests the menu controller directly to show the menu, we must
     * first fire off the request to the other listeners who will then propagate the call
     * back to the controller with the right parameters.
     */
    @Override
    public void showMenu() {
        mListeners.forEach(Listener::onPipShowMenu);
    }

    /**
     * Similar to {@link #showMenu(int, Rect, boolean, boolean, boolean)} but only show the menu
     * upon PiP window transition is finished.
     */
    public void showMenuWithPossibleDelay(int menuState, Rect stackBounds, boolean allowMenuTimeout,
            boolean willResizeMenu, boolean showResizeHandle) {
        if (willResizeMenu) {
            // hide all visible controls including close button and etc. first, this is to ensure
            // menu is totally invisible during the transition to eliminate unpleasant artifacts
            fadeOutMenu();
        }
        showMenuInternal(menuState, stackBounds, allowMenuTimeout, willResizeMenu,
                willResizeMenu /* withDelay=willResizeMenu here */, showResizeHandle);
    }

    /**
     * Shows the menu activity immediately.
     */
    public void showMenu(int menuState, Rect stackBounds, boolean allowMenuTimeout,
            boolean willResizeMenu, boolean showResizeHandle) {
        showMenuInternal(menuState, stackBounds, allowMenuTimeout, willResizeMenu,
                false /* withDelay */, showResizeHandle);
    }

    private void showMenuInternal(int menuState, Rect stackBounds, boolean allowMenuTimeout,
            boolean willResizeMenu, boolean withDelay, boolean showResizeHandle) {
        if (DEBUG) {
            Log.d(TAG, "showMenu() state=" + menuState
                    + " isMenuVisible=" + isMenuVisible()
                    + " allowMenuTimeout=" + allowMenuTimeout
                    + " willResizeMenu=" + willResizeMenu
                    + " withDelay=" + withDelay
                    + " showResizeHandle=" + showResizeHandle
                    + " callers=\n" + Debug.getCallers(5, "    "));
        }

        if (!maybeCreateSyncApplier()) {
            return;
        }

        // Sync the menu bounds before showing it in case it is out of sync.
        movePipMenu(null /* pipLeash */, null /* transaction */, stackBounds);
        updateMenuBounds(stackBounds);

        mPipMenuView.showMenu(menuState, stackBounds, allowMenuTimeout, willResizeMenu, withDelay,
                showResizeHandle);
    }

    /**
     * Move the PiP menu, which does a translation and possibly a scale transformation.
     */
    @Override
    public void movePipMenu(@Nullable SurfaceControl pipLeash,
            @Nullable SurfaceControl.Transaction t,
            Rect destinationBounds) {
        if (destinationBounds.isEmpty()) {
            return;
        }

        if (!maybeCreateSyncApplier()) {
            return;
        }

        // If there is no pip leash supplied, that means the PiP leash is already finalized
        // resizing and the PiP menu is also resized. We then want to do a scale from the current
        // new menu bounds.
        if (pipLeash != null && t != null) {
            mPipMenuView.getBoundsOnScreen(mTmpSourceBounds);
        } else {
            mTmpSourceBounds.set(0, 0, destinationBounds.width(), destinationBounds.height());
        }

        mTmpSourceRectF.set(mTmpSourceBounds);
        mTmpDestinationRectF.set(destinationBounds);
        mMoveTransform.setRectToRect(mTmpSourceRectF, mTmpDestinationRectF, Matrix.ScaleToFit.FILL);
        SurfaceControl surfaceControl = getSurfaceControl();
        SurfaceParams params = new SurfaceParams.Builder(surfaceControl)
                .withMatrix(mMoveTransform)
                .build();
        if (pipLeash != null && t != null) {
            SurfaceParams pipParams = new SurfaceParams.Builder(pipLeash)
                    .withMergeTransaction(t)
                    .build();
            mApplier.scheduleApply(params, pipParams);
        } else {
            mApplier.scheduleApply(params);
        }

        if (mPipMenuView.getViewRootImpl() != null) {
            mPipMenuView.getHandler().removeCallbacks(mUpdateEmbeddedMatrix);
            mPipMenuView.getHandler().post(mUpdateEmbeddedMatrix);
        }
    }

    /**
     * Does an immediate window crop of the PiP menu.
     */
    @Override
    public void resizePipMenu(@Nullable SurfaceControl pipLeash,
            @Nullable SurfaceControl.Transaction t,
            Rect destinationBounds) {
        if (destinationBounds.isEmpty()) {
            return;
        }

        if (!maybeCreateSyncApplier()) {
            return;
        }

        SurfaceControl surfaceControl = getSurfaceControl();
        SurfaceParams params = new SurfaceParams.Builder(surfaceControl)
                .withWindowCrop(destinationBounds)
                .build();
        if (pipLeash != null && t != null) {
            SurfaceParams pipParams = new SurfaceParams.Builder(pipLeash)
                    .withMergeTransaction(t)
                    .build();
            mApplier.scheduleApply(params, pipParams);
        } else {
            mApplier.scheduleApply(params);
        }
    }

    private boolean maybeCreateSyncApplier() {
        if (mPipMenuView == null || mPipMenuView.getViewRootImpl() == null) {
            Log.v(TAG, "Not going to move PiP, either menu or its parent is not created.");
            return false;
        }

        if (mApplier == null) {
            mApplier = new SyncRtSurfaceTransactionApplier(mPipMenuView);
            mPipMenuInputToken = mPipMenuView.getViewRootImpl().getInputToken();
        }

        return mApplier != null;
    }

    /**
     * Pokes the menu, indicating that the user is interacting with it.
     */
    public void pokeMenu() {
        final boolean isMenuVisible = isMenuVisible();
        if (DEBUG) {
            Log.d(TAG, "pokeMenu() isMenuVisible=" + isMenuVisible);
        }
        if (isMenuVisible) {
            mPipMenuView.pokeMenu();
        }
    }

    private void fadeOutMenu() {
        final boolean isMenuVisible = isMenuVisible();
        if (DEBUG) {
            Log.d(TAG, "fadeOutMenu() isMenuVisible=" + isMenuVisible);
        }
        if (isMenuVisible) {
            mPipMenuView.fadeOutMenu();
        }
    }

    /**
     * Hides the menu view.
     */
    public void hideMenu() {
        final boolean isMenuVisible = isMenuVisible();
        if (isMenuVisible) {
            mPipMenuView.hideMenu();
        }
    }

    /**
     * Hides the menu view.
     *
     * @param animationType the animation type to use upon hiding the menu
     * @param resize whether or not to resize the PiP with the state change
     */
    public void hideMenu(@PipMenuView.AnimationType int animationType, boolean resize) {
        final boolean isMenuVisible = isMenuVisible();
        if (DEBUG) {
            Log.d(TAG, "hideMenu() state=" + mMenuState
                    + " isMenuVisible=" + isMenuVisible
                    + " animationType=" + animationType
                    + " resize=" + resize
                    + " callers=\n" + Debug.getCallers(5, "    "));
        }
        if (isMenuVisible) {
            mPipMenuView.hideMenu(resize, animationType);
        }
    }

    /**
     * Hides the menu activity.
     */
    public void hideMenu(Runnable onStartCallback, Runnable onEndCallback) {
        if (isMenuVisible()) {
            // If the menu is visible in either the closed or full state, then hide the menu and
            // trigger the animation trigger afterwards
            if (onStartCallback != null) {
                onStartCallback.run();
            }
            mPipMenuView.hideMenu(onEndCallback);
        }
    }

    /**
     * Sets the menu actions to the actions provided by the current PiP menu.
     */
    @Override
    public void setAppActions(ParceledListSlice<RemoteAction> appActions) {
        mAppActions = appActions;
        updateMenuActions();
    }

    void onPipExpand() {
        mListeners.forEach(Listener::onPipExpand);
    }

    void onPipDismiss() {
        mListeners.forEach(Listener::onPipDismiss);
    }

    void onEnterSplit() {
        mListeners.forEach(Listener::onEnterSplit);
    }

    /**
     * @return the best set of actions to show in the PiP menu.
     */
    private ParceledListSlice<RemoteAction> resolveMenuActions() {
        if (isValidActions(mAppActions)) {
            return mAppActions;
        }
        return mMediaActions;
    }

    /**
     * Updates the PiP menu with the best set of actions provided.
     */
    private void updateMenuActions() {
        if (mPipMenuView != null) {
            final ParceledListSlice<RemoteAction> menuActions = resolveMenuActions();
            if (menuActions != null) {
                mPipMenuView.setActions(mPipBoundsState.getBounds(), menuActions.getList());
            }
        }
    }

    /**
     * Returns whether the set of actions are valid.
     */
    private static boolean isValidActions(ParceledListSlice<?> actions) {
        return actions != null && actions.getList().size() > 0;
    }

    /**
     * Handles changes in menu visibility.
     */
    void onMenuStateChangeStart(int menuState, boolean resize, Runnable callback) {
        if (DEBUG) {
            Log.d(TAG, "onMenuStateChangeStart() mMenuState=" + mMenuState
                    + " menuState=" + menuState + " resize=" + resize
                    + " callers=\n" + Debug.getCallers(5, "    "));
        }

        if (menuState != mMenuState) {
            mListeners.forEach(l -> l.onPipMenuStateChangeStart(menuState, resize, callback));
            if (menuState == MENU_STATE_FULL) {
                // Once visible, start listening for media action changes. This call will trigger
                // the menu actions to be updated again.
                mMediaController.addActionListener(mMediaActionListener);
            } else {
                // Once hidden, stop listening for media action changes. This call will trigger
                // the menu actions to be updated again.
                mMediaController.removeActionListener(mMediaActionListener);
            }

            try {
                WindowManagerGlobal.getWindowSession().grantEmbeddedWindowFocus(null /* window */,
                        mPipMenuInputToken, menuState != MENU_STATE_NONE /* grantFocus */);
            } catch (RemoteException e) {
                Log.e(TAG, "Unable to update focus as menu appears/disappears", e);
            }
        }
    }

    void onMenuStateChangeFinish(int menuState) {
        if (menuState != mMenuState) {
            mListeners.forEach(l -> l.onPipMenuStateChangeFinish(menuState));
        }
        mMenuState = menuState;
        setShellRootAccessibilityWindow();
    }

    private void setShellRootAccessibilityWindow() {
        switch (mMenuState) {
            case MENU_STATE_NONE:
                mSystemWindows.setShellRootAccessibilityWindow(0, SHELL_ROOT_LAYER_PIP, null);
                break;
            default:
                mSystemWindows.setShellRootAccessibilityWindow(0, SHELL_ROOT_LAYER_PIP,
                        mPipMenuView);
                break;
        }
    }

    /**
     * Handles a pointer event sent from pip input consumer.
     */
    void handlePointerEvent(MotionEvent ev) {
        if (mPipMenuView == null) {
            return;
        }

        if (ev.isTouchEvent()) {
            mPipMenuView.dispatchTouchEvent(ev);
        } else {
            mPipMenuView.dispatchGenericMotionEvent(ev);
        }
    }

    /**
     * Tell the PIP Menu to recalculate its layout given its current position on the display.
     */
    public void updateMenuLayout(Rect bounds) {
        final boolean isMenuVisible = isMenuVisible();
        if (DEBUG) {
            Log.d(TAG, "updateMenuLayout() state=" + mMenuState
                    + " isMenuVisible=" + isMenuVisible
                    + " callers=\n" + Debug.getCallers(5, "    "));
        }
        if (isMenuVisible) {
            mPipMenuView.updateMenuLayout(bounds);
        }
    }

    void dump(PrintWriter pw, String prefix) {
        final String innerPrefix = prefix + "  ";
        pw.println(prefix + TAG);
        pw.println(innerPrefix + "mMenuState=" + mMenuState);
        pw.println(innerPrefix + "mPipMenuView=" + mPipMenuView);
        pw.println(innerPrefix + "mListeners=" + mListeners.size());
    }
}
