/*
 * 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.tv;

import static android.app.WindowConfiguration.ACTIVITY_TYPE_UNDEFINED;
import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;

import android.annotation.IntDef;
import android.app.ActivityManager;
import android.app.ActivityTaskManager;
import android.app.RemoteAction;
import android.app.TaskInfo;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.ParceledListSlice;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
import android.os.RemoteException;
import android.util.Log;
import android.view.DisplayInfo;

import com.android.wm.shell.R;
import com.android.wm.shell.WindowManagerShellWrapper;
import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.common.ShellExecutor;
import com.android.wm.shell.common.TaskStackListenerCallback;
import com.android.wm.shell.common.TaskStackListenerImpl;
import com.android.wm.shell.pip.PinnedStackListenerForwarder;
import com.android.wm.shell.pip.Pip;
import com.android.wm.shell.pip.PipBoundsAlgorithm;
import com.android.wm.shell.pip.PipBoundsState;
import com.android.wm.shell.pip.PipMediaController;
import com.android.wm.shell.pip.PipTaskOrganizer;
import com.android.wm.shell.pip.PipTransitionController;

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

/**
 * Manages the picture-in-picture (PIP) UI and states.
 */
public class TvPipController implements PipTransitionController.PipTransitionCallback,
        TvPipMenuController.Delegate, TvPipNotificationController.Delegate {
    private static final String TAG = "TvPipController";
    static final boolean DEBUG = true;

    private static final int NONEXISTENT_TASK_ID = -1;

    @Retention(RetentionPolicy.SOURCE)
    @IntDef(prefix = { "STATE_" }, value = {
            STATE_NO_PIP,
            STATE_PIP,
            STATE_PIP_MENU
    })
    public @interface State {}

    /**
     * State when there is no applications in Pip.
     */
    private static final int STATE_NO_PIP = 0;
    /**
     * State when there is an applications in Pip and the Pip window located at its "normal" place
     * (usually the bottom right corner).
     */
    private static final int STATE_PIP = 1;
    /**
     * State when there is an applications in Pip and the Pip menu is open. In this state Pip window
     * is usually moved from its "normal" position on the screen to the "menu" position - which is
     * often at the middle of the screen, and gets slightly scaled up.
     */
    private static final int STATE_PIP_MENU = 2;

    private final Context mContext;

    private final PipBoundsState mPipBoundsState;
    private final PipBoundsAlgorithm mPipBoundsAlgorithm;
    private final PipTaskOrganizer mPipTaskOrganizer;
    private final PipMediaController mPipMediaController;
    private final TvPipNotificationController mPipNotificationController;
    private final TvPipMenuController mTvPipMenuController;
    private final ShellExecutor mMainExecutor;
    private final TvPipImpl mImpl = new TvPipImpl();

    private @State int mState = STATE_NO_PIP;
    private int mPinnedTaskId = NONEXISTENT_TASK_ID;

    private int mResizeAnimationDuration;

    public static Pip create(
            Context context,
            PipBoundsState pipBoundsState,
            PipBoundsAlgorithm pipBoundsAlgorithm,
            PipTaskOrganizer pipTaskOrganizer,
            PipTransitionController pipTransitionController,
            TvPipMenuController tvPipMenuController,
            PipMediaController pipMediaController,
            TvPipNotificationController pipNotificationController,
            TaskStackListenerImpl taskStackListener,
            WindowManagerShellWrapper wmShell,
            ShellExecutor mainExecutor) {
        return new TvPipController(
                context,
                pipBoundsState,
                pipBoundsAlgorithm,
                pipTaskOrganizer,
                pipTransitionController,
                tvPipMenuController,
                pipMediaController,
                pipNotificationController,
                taskStackListener,
                wmShell,
                mainExecutor).mImpl;
    }

    private TvPipController(
            Context context,
            PipBoundsState pipBoundsState,
            PipBoundsAlgorithm pipBoundsAlgorithm,
            PipTaskOrganizer pipTaskOrganizer,
            PipTransitionController pipTransitionController,
            TvPipMenuController tvPipMenuController,
            PipMediaController pipMediaController,
            TvPipNotificationController pipNotificationController,
            TaskStackListenerImpl taskStackListener,
            WindowManagerShellWrapper wmShell,
            ShellExecutor mainExecutor) {
        mContext = context;
        mMainExecutor = mainExecutor;

        mPipBoundsState = pipBoundsState;
        mPipBoundsState.setDisplayId(context.getDisplayId());
        mPipBoundsState.setDisplayLayout(new DisplayLayout(context, context.getDisplay()));
        mPipBoundsAlgorithm = pipBoundsAlgorithm;

        mPipMediaController = pipMediaController;

        mPipNotificationController = pipNotificationController;
        mPipNotificationController.setDelegate(this);

        mTvPipMenuController = tvPipMenuController;
        mTvPipMenuController.setDelegate(this);

        mPipTaskOrganizer = pipTaskOrganizer;
        pipTransitionController.registerPipTransitionCallback(this);

        loadConfigurations();

        registerTaskStackListenerCallback(taskStackListener);
        registerWmShellPinnedStackListener(wmShell);
    }

    private void onConfigurationChanged(Configuration newConfig) {
        if (DEBUG) Log.d(TAG, "onConfigurationChanged(), state=" + stateToName(mState));

        if (isPipShown()) {
            if (DEBUG) Log.d(TAG, "  > closing Pip.");
            closePip();
        }

        loadConfigurations();
        mPipNotificationController.onConfigurationChanged(mContext);
    }

    /**
     * Returns {@code true} if Pip is shown.
     */
    private boolean isPipShown() {
        return mState != STATE_NO_PIP;
    }

    /**
     * Starts the process if bringing up the Pip menu if by issuing a command to move Pip
     * task/window to the "Menu" position. We'll show the actual Menu UI (eg. actions) once the Pip
     * task/window is properly positioned in {@link #onPipTransitionFinished(ComponentName, int)}.
     */
    @Override
    public void showPictureInPictureMenu() {
        if (DEBUG) Log.d(TAG, "showPictureInPictureMenu(), state=" + stateToName(mState));

        if (mState != STATE_PIP) {
            if (DEBUG) Log.d(TAG, "  > cannot open Menu from the current state.");
            return;
        }

        setState(STATE_PIP_MENU);
        resizePinnedStack(STATE_PIP_MENU);
    }

    /**
     * Moves Pip window to its "normal" position.
     */
    @Override
    public void movePipToNormalPosition() {
        if (DEBUG) Log.d(TAG, "movePipToNormalPosition(), state=" + stateToName(mState));

        setState(STATE_PIP);
        resizePinnedStack(STATE_PIP);
    }

    /**
     * Opens the "Pip-ed" Activity fullscreen.
     */
    @Override
    public void movePipToFullscreen() {
        if (DEBUG) Log.d(TAG, "movePipToFullscreen(), state=" + stateToName(mState));

        mPipTaskOrganizer.exitPip(mResizeAnimationDuration, false /* requestEnterSplit */);
        onPipDisappeared();
    }

    /**
     * Closes Pip window.
     */
    @Override
    public void closePip() {
        if (DEBUG) Log.d(TAG, "closePip(), state=" + stateToName(mState));

        removeTask(mPinnedTaskId);
        onPipDisappeared();
    }

    /**
     * Resizes the Pip task/window to the appropriate size for the given state.
     * This is a legacy API. Now we expect that the state argument passed to it should always match
     * the current state of the Controller. If it does not match an {@link IllegalArgumentException}
     * will be thrown. However, if the passed state does match - we'll determine the right bounds
     * to the state and will move Pip task/window there.
     *
     * @param state the to determine the Pip bounds. IMPORTANT: should always match the current
     *              state of the Controller.
     */
    private void resizePinnedStack(@State int state) {
        if (state != mState) {
            throw new IllegalArgumentException("The passed state should match the current state!");
        }
        if (DEBUG) Log.d(TAG, "resizePinnedStack() state=" + stateToName(mState));

        final Rect newBounds;
        switch (mState) {
            case STATE_PIP_MENU:
                newBounds = mPipBoundsState.getExpandedBounds();
                break;

            case STATE_PIP:
                // Let PipBoundsAlgorithm figure out what the correct bounds are at the moment.
                // Internally, it will get the "default" bounds from PipBoundsState and adjust them
                // as needed to account for things like IME state (will query PipBoundsState for
                // this information as well, so it's important to keep PipBoundsState up to date).
                newBounds = mPipBoundsAlgorithm.getNormalBounds();
                break;

            case STATE_NO_PIP:
            default:
                return;
        }

        mPipTaskOrganizer.scheduleAnimateResizePip(newBounds, mResizeAnimationDuration, null);
    }

    private void registerSessionListenerForCurrentUser() {
        mPipMediaController.registerSessionListenerForCurrentUser();
    }

    private void checkIfPinnedTaskAppeared() {
        final TaskInfo pinnedTask = getPinnedTaskInfo();
        if (DEBUG) Log.d(TAG, "checkIfPinnedTaskAppeared(), task=" + pinnedTask);
        if (pinnedTask == null || pinnedTask.topActivity == null) return;
        mPinnedTaskId = pinnedTask.taskId;
        setState(STATE_PIP);

        mPipMediaController.onActivityPinned();
        mPipNotificationController.show(pinnedTask.topActivity.getPackageName());
    }

    private void checkIfPinnedTaskIsGone() {
        if (DEBUG) Log.d(TAG, "onTaskStackChanged()");

        if (isPipShown() && getPinnedTaskInfo() == null) {
            Log.w(TAG, "Pinned task is gone.");
            onPipDisappeared();
        }
    }

    private void onPipDisappeared() {
        if (DEBUG) Log.d(TAG, "onPipDisappeared() state=" + stateToName(mState));

        mPipNotificationController.dismiss();
        mTvPipMenuController.hideMenu();
        setState(STATE_NO_PIP);
        mPinnedTaskId = NONEXISTENT_TASK_ID;
    }

    @Override
    public void onPipTransitionStarted(int direction, Rect pipBounds) {
        if (DEBUG) Log.d(TAG, "onPipTransition_Started(), state=" + stateToName(mState));
    }

    @Override
    public void onPipTransitionCanceled(int direction) {
        if (DEBUG) Log.d(TAG, "onPipTransition_Canceled(), state=" + stateToName(mState));
    }

    @Override
    public void onPipTransitionFinished(int direction) {
        if (DEBUG) Log.d(TAG, "onPipTransition_Finished(), state=" + stateToName(mState));

        if (mState == STATE_PIP_MENU) {
            if (DEBUG) Log.d(TAG, "  > show menu");
            mTvPipMenuController.showMenu();
        }
    }

    private void setState(@State int state) {
        if (DEBUG) {
            Log.d(TAG, "setState(), state=" + stateToName(state) + ", prev="
                    + stateToName(mState));
        }
        mState = state;
    }

    private void loadConfigurations() {
        final Resources res = mContext.getResources();
        mResizeAnimationDuration = res.getInteger(R.integer.config_pipResizeAnimationDuration);
        // "Cache" bounds for the Pip menu as "expanded" bounds in PipBoundsState. We'll refer back
        // to this value in resizePinnedStack(), when we are adjusting Pip task/window position for
        // the menu.
        mPipBoundsState.setExpandedBounds(
                Rect.unflattenFromString(res.getString(R.string.pip_menu_bounds)));
    }

    private DisplayInfo getDisplayInfo() {
        final DisplayInfo displayInfo = new DisplayInfo();
        mContext.getDisplay().getDisplayInfo(displayInfo);
        return displayInfo;
    }

    private void registerTaskStackListenerCallback(TaskStackListenerImpl taskStackListener) {
        taskStackListener.addListener(new TaskStackListenerCallback() {
            @Override
            public void onActivityPinned(String packageName, int userId, int taskId, int stackId) {
                checkIfPinnedTaskAppeared();
            }

            @Override
            public void onTaskStackChanged() {
                checkIfPinnedTaskIsGone();
            }

            @Override
            public void onActivityRestartAttempt(ActivityManager.RunningTaskInfo task,
                    boolean homeTaskVisible, boolean clearedTask, boolean wasVisible) {
                if (task.getWindowingMode() == WINDOWING_MODE_PINNED) {
                    if (DEBUG) Log.d(TAG, "onPinnedActivityRestartAttempt()");

                    // If the "Pip-ed" Activity is launched again by Launcher or intent, make it
                    // fullscreen.
                    movePipToFullscreen();
                }
            }
        });
    }

    private void registerWmShellPinnedStackListener(WindowManagerShellWrapper wmShell) {
        try {
            wmShell.addPinnedStackListener(new PinnedStackListenerForwarder.PinnedTaskListener() {
                @Override
                public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
                    if (DEBUG) {
                        Log.d(TAG, "onImeVisibilityChanged(), visible=" + imeVisible
                                + ", height=" + imeHeight);
                    }

                    if (imeVisible == mPipBoundsState.isImeShowing()
                            && (!imeVisible || imeHeight == mPipBoundsState.getImeHeight())) {
                        // Nothing changed: either IME has been and remains invisible, or remains
                        // visible with the same height.
                        return;
                    }
                    mPipBoundsState.setImeVisibility(imeVisible, imeHeight);
                    // "Normal" Pip bounds may have changed, so if we are in the "normal" state,
                    // let's update the bounds.
                    if (mState == STATE_PIP) {
                        resizePinnedStack(STATE_PIP);
                    }
                }

                @Override
                public void onMovementBoundsChanged(boolean fromImeAdjustment) {}

                @Override
                public void onActionsChanged(ParceledListSlice<RemoteAction> actions) {
                    if (DEBUG) Log.d(TAG, "onActionsChanged()");

                    mTvPipMenuController.setAppActions(actions);
                }
            });
        } catch (RemoteException e) {
            Log.e(TAG, "Failed to register pinned stack listener", e);
        }
    }

    private static TaskInfo getPinnedTaskInfo() {
        if (DEBUG) Log.d(TAG, "getPinnedTaskInfo()");
        try {
            final TaskInfo taskInfo = ActivityTaskManager.getService().getRootTaskInfo(
                    WINDOWING_MODE_PINNED, ACTIVITY_TYPE_UNDEFINED);
            if (DEBUG) Log.d(TAG, "  > taskInfo=" + taskInfo);
            return taskInfo;
        } catch (RemoteException e) {
            Log.e(TAG, "getRootTaskInfo() failed", e);
            return null;
        }
    }

    private static void removeTask(int taskId) {
        if (DEBUG) Log.d(TAG, "removeTask(), taskId=" + taskId);
        try {
            ActivityTaskManager.getService().removeTask(taskId);
        } catch (Exception e) {
            Log.e(TAG, "Atm.removeTask() failed", e);
        }
    }

    private static String stateToName(@State int state) {
        switch (state) {
            case STATE_NO_PIP:
                return "NO_PIP";
            case STATE_PIP:
                return "PIP";
            case STATE_PIP_MENU:
                return "PIP_MENU";
            default:
                // This can't happen.
                throw new IllegalArgumentException("Unknown state " + state);
        }
    }

    private class TvPipImpl implements Pip {
        @Override
        public void onConfigurationChanged(Configuration newConfig) {
            mMainExecutor.execute(() -> {
                TvPipController.this.onConfigurationChanged(newConfig);
            });
        }

        @Override
        public void registerSessionListenerForCurrentUser() {
            mMainExecutor.execute(() -> {
                TvPipController.this.registerSessionListenerForCurrentUser();
            });
        }
    }
}
