/*
 * Copyright (C) 2019 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.systemui.statusbar.phone;

import android.annotation.NonNull;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Rect;
import android.graphics.Region;
import android.util.Log;
import android.view.DisplayCutout;
import android.view.Gravity;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.ViewTreeObserver.OnComputeInternalInsetsListener;
import android.view.WindowInsets;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import com.android.compose.animation.scene.ObservableTransitionState;
import com.android.internal.policy.SystemBarUtils;
import com.android.systemui.Dumpable;
import com.android.systemui.ScreenDecorations;
import com.android.systemui.bouncer.domain.interactor.AlternateBouncerInteractor;
import com.android.systemui.bouncer.domain.interactor.PrimaryBouncerInteractor;
import com.android.systemui.communal.domain.interactor.CommunalSceneInteractor;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.desktop.domain.interactor.DesktopInteractor;
import com.android.systemui.res.R;
import com.android.systemui.scene.domain.interactor.SceneInteractor;
import com.android.systemui.scene.shared.flag.SceneContainerFlag;
import com.android.systemui.scene.shared.model.Scenes;
import com.android.systemui.shade.ShadeOverlayBoundsListener;
import com.android.systemui.shade.domain.interactor.ShadeInteractor;
import com.android.systemui.shade.domain.interactor.ShadeModeInteractor;
import com.android.systemui.shade.shared.model.ShadeMode;
import com.android.systemui.statusbar.NotificationShadeWindowController;
import com.android.systemui.statusbar.core.StatusBarForDesktop;
import com.android.systemui.statusbar.notification.headsup.HeadsUpManager;
import com.android.systemui.statusbar.notification.headsup.OnHeadsUpChangedListener;
import com.android.systemui.statusbar.policy.ConfigurationController;
import com.android.systemui.statusbar.policy.ConfigurationController.ConfigurationListener;
import com.android.systemui.util.kotlin.JavaAdapter;

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

import javax.inject.Inject;
import javax.inject.Provider;

/**
 * Manages what parts of the status bar are touchable. Clients are primarily UI that display in the
 * status bar even though the UI doesn't look like part of the status bar. Currently this consists
 * of HeadsUpNotifications.
 */
@SysUISingleton
public final class ShadeTouchableRegionManager implements Dumpable {
    private static final String TAG = "TouchableRegionManager";

    private final Context mContext;
    private final HeadsUpManager mHeadsUpManager;
    private final NotificationShadeWindowController mNotificationShadeWindowController;
    private final UnlockedScreenOffAnimationController mUnlockedScreenOffAnimationController;
    private final ShadeInteractor mShadeInteractor;
    private final PrimaryBouncerInteractor mPrimaryBouncerInteractor;
    private final AlternateBouncerInteractor mAlternateBouncerInteractor;
    private final Provider<ShadeModeInteractor> mShadeModeInteractorProvider;
    private final Provider<DesktopInteractor> mDesktopInteractorProvider;


    private boolean mIsAnyShadeExpanded = false;
    // Whether the scene container has no UI to render, i.e. is in idle state on the Gone scene and
    // without any overlays to display.
    private boolean mIsSceneContainerUiEmpty = true;
    private boolean mIsSceneGone = false;
    private boolean mIsDesktopStatusBarEnabled = false;
    private boolean mIsRemoteUserInteractionOngoing = false;
    private boolean mShouldAdjustInsets = false;
    private boolean mForceCollapsedUntilLayout = false;
    private Boolean mCommunalVisible = false;
    private final Region mTouchableRegion = new Region();
    private @Nullable Rect mShadeBounds = null;
    private int mDisplayCutoutTouchableRegionSize;
    private int mStatusBarHeight;

    private View mNotificationShadeWindowView;
    private View mNotificationPanelView;

    private final OnComputeInternalInsetsListener mOnComputeInternalInsetsListener;

    @Inject
    public ShadeTouchableRegionManager(
            Context context,
            NotificationShadeWindowController notificationShadeWindowController,
            ConfigurationController configurationController,
            HeadsUpManager headsUpManager,
            ShadeInteractor shadeInteractor,
            Provider<SceneInteractor> sceneInteractor,
            JavaAdapter javaAdapter,
            UnlockedScreenOffAnimationController unlockedScreenOffAnimationController,
            PrimaryBouncerInteractor primaryBouncerInteractor,
            AlternateBouncerInteractor alternateBouncerInteractor,
            CommunalSceneInteractor communalSceneInteractor,
            Provider<ShadeModeInteractor> shadeModeInteractor,
            Provider<DesktopInteractor> desktopInteractor
    ) {
        mShadeModeInteractorProvider = shadeModeInteractor;
        mDesktopInteractorProvider = desktopInteractor;
        mContext = context;
        initResources();
        configurationController.addCallback(new ConfigurationListener() {
            @Override
            public void onDensityOrFontScaleChanged() {
                initResources();
            }

            @Override
            public void onThemeChanged() {
                initResources();
            }
        });

        mHeadsUpManager = headsUpManager;
        mHeadsUpManager.addListener(
                new OnHeadsUpChangedListener() {
                    @Override
                    public void onHeadsUpPinnedModeChanged(boolean hasPinnedNotification) {
                        if (Log.isLoggable(TAG, Log.WARN)) {
                            Log.w(TAG, "onHeadsUpPinnedModeChanged");
                        }
                        updateTouchableRegion();
                    }
                });
        mHeadsUpManager.addHeadsUpPhoneListener(this::onHeadsUpAnimatingAwayStateChanged);

        mShadeInteractor = shadeInteractor;
        if (StatusBarForDesktop.isEnabled()) {
            ShadeOverlayBoundsListener shadeOverlayBoundsListener = bounds -> {
                if (!mIsDesktopStatusBarEnabled) {
                    return;
                }
                mShadeBounds = bounds;
                updateTouchableRegion();
            };

            mShadeInteractor.addShadeOverlayBoundsListener(shadeOverlayBoundsListener);
        }

        mNotificationShadeWindowController = notificationShadeWindowController;
        mNotificationShadeWindowController.setForcePluginOpenListener((forceOpen) -> {
            updateTouchableRegion();
        });

        mUnlockedScreenOffAnimationController = unlockedScreenOffAnimationController;

        if (SceneContainerFlag.isEnabled()) {
            javaAdapter.alwaysCollectFlow(
                    sceneInteractor.get().getTransitionState(),
                    this::onSceneContainerTransition);
            javaAdapter.alwaysCollectFlow(
                    sceneInteractor.get().isRemoteUserInteractionOngoing(),
                    this::onRemoteUserInteractionOngoingChanged);
            if (StatusBarForDesktop.isEnabled()) {
                javaAdapter.alwaysCollectFlow(shadeModeInteractor.get().getShadeMode(),
                        this::onShadeModeChanged);
                javaAdapter.alwaysCollectFlow(desktopInteractor.get().getUseDesktopStatusBar(),
                        this::onDesktopStatusBarStateChanged);
            }
        } else {
            javaAdapter.alwaysCollectFlow(
                    shadeInteractor.isAnyExpanded(),
                    this::onShadeOrQsExpanded);
            javaAdapter.alwaysCollectFlow(
                    communalSceneInteractor.isCommunalVisible(),
                    this::onCommunalVisible);
        }

        mPrimaryBouncerInteractor = primaryBouncerInteractor;
        mAlternateBouncerInteractor = alternateBouncerInteractor;
        mOnComputeInternalInsetsListener = this::onComputeInternalInsets;
    }

    protected void setup(@NonNull View notificationShadeWindowView) {
        mNotificationShadeWindowView = notificationShadeWindowView;
        mNotificationPanelView = mNotificationShadeWindowView.findViewById(R.id.notification_panel);
    }

    @Override
    public void dump(PrintWriter pw, String[] args) {
        pw.println("ShadeTouchableRegionManager state:");
        pw.print("  mTouchableRegion=");
        pw.println(mTouchableRegion);
        pw.print("  mIsDesktopStatusBarEnabled=");
        pw.println(mIsDesktopStatusBarEnabled);
        pw.print("  mShadeBounds=");
        pw.println(mShadeBounds);
    }

    private void onShadeOrQsExpanded(Boolean isExpanded) {
        if (isExpanded != mIsAnyShadeExpanded) {
            mIsAnyShadeExpanded = isExpanded;
            if (isExpanded) {
                // make sure our state is sensible
                mForceCollapsedUntilLayout = false;
            }
            updateTouchableRegion();
        }
    }

    private void onShadeModeChanged(ShadeMode mode) {
        final boolean isDualShadeEnabled = mode.equals(ShadeMode.dual());
        final boolean desktopStatusBarEnabled = StatusBarForDesktop.isEnabled()
                && isDualShadeEnabled
                && mDesktopInteractorProvider.get().getUseDesktopStatusBar().getValue();

        if (mIsDesktopStatusBarEnabled == desktopStatusBarEnabled) {
            return;
        }

        mIsDesktopStatusBarEnabled = desktopStatusBarEnabled;
        if (!desktopStatusBarEnabled) {
            mShadeBounds = null;
        }
        updateTouchableRegion();
    }

    private void onDesktopStatusBarStateChanged(boolean enabled) {
        final boolean isDualShadeEnabled =
                mShadeModeInteractorProvider.get()
                        .getShadeMode().getValue().equals(ShadeMode.dual());
        final boolean desktopStatusBarEnabled =
                StatusBarForDesktop.isEnabled() && enabled && isDualShadeEnabled;
        if (mIsDesktopStatusBarEnabled == desktopStatusBarEnabled) {
            return;
        }

        mIsDesktopStatusBarEnabled = desktopStatusBarEnabled;
        if (!desktopStatusBarEnabled) {
            mShadeBounds = null;
        }
        updateTouchableRegion();
    }

    private void onSceneContainerTransition(ObservableTransitionState transitionState) {
        mIsSceneGone = transitionState.isIdle(Scenes.Gone);
        boolean isSceneContainerUiEmpty = mIsSceneGone
                && ((ObservableTransitionState.Idle) transitionState).getCurrentOverlays()
                .isEmpty();
        if (isSceneContainerUiEmpty != mIsSceneContainerUiEmpty) {
            mIsSceneContainerUiEmpty = isSceneContainerUiEmpty;
            if (!isSceneContainerUiEmpty) {
                // make sure our state is sensible
                mForceCollapsedUntilLayout = false;
            }
            updateTouchableRegion();
        }
    }

    private void onRemoteUserInteractionOngoingChanged(Boolean ongoing) {
        if (ongoing != mIsRemoteUserInteractionOngoing) {
            mIsRemoteUserInteractionOngoing = ongoing;
            updateTouchableRegion();
        }
    }

    private void onCommunalVisible(Boolean visible) {
        mCommunalVisible = visible;
    }

    /**
     * Calculates the touch region needed for heads up notifications, taking into consideration
     * any existing display cutouts (notch)
     * @return the heads up notification touch area
     */
    public Region calculateTouchableRegion() {
        // Update touchable region for HeadsUp notifications
        final Region headsUpTouchableRegion = mHeadsUpManager.getTouchableRegion();
        if (headsUpTouchableRegion != null) {
            mTouchableRegion.set(headsUpTouchableRegion);
        } else {
            // If there aren't any HUNs, update the touch region to the status bar
            // width/height, potentially adjusting for a display cutout (notch)
            mTouchableRegion.set(0, 0, mNotificationShadeWindowView.getWidth(),
                    mStatusBarHeight);
            updateRegionForNotch(mTouchableRegion);
        }
        return mTouchableRegion;
    }

    /**
     * Returns the touchable region for the Shade Window when the {@code DesktopStatusBar}
     * is enabled.
     *
     * <p> When an overlay is visible over the {@link Scenes.Gone} scene, only the
     * overlay bounds should be touchable. When not on {@code Gone}, the entire screen except the
     * status bar area should be touchable, as the {@code DesktopStatusBar} is not part of the
     * shade window.
     *
     * <p>The touchable region for heads-up notifications is handled separately and merged.
     */
    @VisibleForTesting
    List<Rect> calculateTouchableRegionForDesktop() {
        final List<Rect> touchableRects = new ArrayList<>();
        if (mShadeBounds != null && mIsSceneGone) {
            touchableRects.add(mShadeBounds);
        } else {
            touchableRects.add(
                    new Rect(
                            /* left= */ 0,
                            /* top= */ mStatusBarHeight,
                            /* right= */ mNotificationShadeWindowView.getWidth(),
                            /* bottom= */ mNotificationShadeWindowView.getHeight()));
        }

        final Region headsUpTouchableRegion = mHeadsUpManager.getTouchableRegion();
        if (headsUpTouchableRegion != null) {
            touchableRects.add(headsUpTouchableRegion.getBounds());
        }

        return touchableRects;
    }

    private void initResources() {
        Resources resources = mContext.getResources();
        mDisplayCutoutTouchableRegionSize = resources.getDimensionPixelSize(
                com.android.internal.R.dimen.display_cutout_touchable_region_size);
        mStatusBarHeight = SystemBarUtils.getStatusBarHeight(mContext);
    }

    /**
     * Set the touchable portion of the status bar based on what elements are visible.
     */
    public void updateTouchableRegion() {
        final boolean shouldObserve = mIsDesktopStatusBarEnabled
                ? shouldObserveForDesktop()
                : shouldObserveForStandardMode();

        if (shouldObserve == mShouldAdjustInsets) {
            return;
        }

        final ViewTreeObserver observer = mNotificationShadeWindowView.getViewTreeObserver();
        if (shouldObserve) {
            observer.addOnComputeInternalInsetsListener(mOnComputeInternalInsetsListener);
            mNotificationShadeWindowView.requestLayout();
        } else {
            observer.removeOnComputeInternalInsetsListener(mOnComputeInternalInsetsListener);
        }
        mShouldAdjustInsets = shouldObserve;
    }

    /**
     * Determines if we need to compute custom insets when in desktop mode.
     */
    private boolean shouldObserveForDesktop() {
        return mNotificationShadeWindowView != null
                && (mHeadsUpManager.hasPinnedHeadsUp()
                || mHeadsUpManager.isHeadsUpAnimatingAwayValue()
                || mShadeBounds != null
                || !mIsSceneGone);
    }

    /**
     * Determines if we need to compute custom insets when in standard (non-desktop) mode.
     */
    private boolean shouldObserveForStandardMode() {
        boolean hasCutoutInset = (mNotificationShadeWindowView != null)
                && (mNotificationShadeWindowView.getRootWindowInsets() != null)
                && (mNotificationShadeWindowView.getRootWindowInsets().getDisplayCutout() != null);
        return mHeadsUpManager.hasPinnedHeadsUp()
                || mHeadsUpManager.isHeadsUpAnimatingAwayValue()
                || mForceCollapsedUntilLayout
                || hasCutoutInset
                || mNotificationShadeWindowController.getForcePluginOpen();
    }

    /**
     * Calls {@code updateTouchableRegion()} after a layout pass completes.
     */
    private void updateTouchableRegionAfterLayout() {
        if (mNotificationPanelView != null) {
            mForceCollapsedUntilLayout = true;
            mNotificationPanelView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
                @Override
                public void onLayoutChange(View v, int left, int top, int right, int bottom,
                        int oldLeft, int oldTop, int oldRight, int oldBottom) {
                    if (!mNotificationPanelView.isVisibleToUser()) {
                        mNotificationPanelView.removeOnLayoutChangeListener(this);
                        mForceCollapsedUntilLayout = false;
                        updateTouchableRegion();
                    }
                }
            });
        }
    }

    public void updateRegionForNotch(Region touchableRegion) {
        WindowInsets windowInsets = mNotificationShadeWindowView.getRootWindowInsets();
        if (windowInsets == null) {
            Log.w(TAG, "StatusBarWindowView is not attached.");
            return;
        }
        DisplayCutout cutout = windowInsets.getDisplayCutout();
        if (cutout == null) {
            return;
        }

        // Expand touchable region such that we also catch touches that just start below the notch
        // area.
        Rect bounds = new Rect();
        ScreenDecorations.DisplayCutoutView.boundsFromDirection(cutout, Gravity.TOP, bounds);
        bounds.offset(0, mDisplayCutoutTouchableRegionSize);
        touchableRegion.union(bounds);
    }

    /**
     * Helper to let us know when calculating the region is not needed because we know the entire
     * screen needs to be touchable.
     */
    @VisibleForTesting
    boolean shouldMakeEntireScreenTouchable() {
        if (mIsDesktopStatusBarEnabled) {
            // In desktop mode, the shade window never covers the entire screen's touchable area,
            // as the status bar is in a separate window.
            return false;
        }

        // The touchable region is always the full area when expanded, whether we're showing the
        // shade or the bouncer. It's also fully touchable when the screen off animation is playing
        // since we don't want stray touches to go through the light reveal scrim to whatever is
        // underneath.
        return mIsAnyShadeExpanded
                || (SceneContainerFlag.isEnabled()
                && (!mIsSceneContainerUiEmpty || mIsRemoteUserInteractionOngoing))
                || mPrimaryBouncerInteractor.isShowing().getValue()
                || mAlternateBouncerInteractor.isVisibleState()
                // The glanceable hub is a full-screen UI within the notification shade window. When
                // it's visible, the touchable region should be the full screen.
                || mCommunalVisible
                || mUnlockedScreenOffAnimationController.isAnimationPlaying();
    }

    private void onHeadsUpAnimatingAwayStateChanged(boolean headsUpAnimatingAway) {
        if (!headsUpAnimatingAway) {
            updateTouchableRegionAfterLayout();
        } else {
            updateTouchableRegion();
        }
    }

    private void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo info) {
        if (shouldMakeEntireScreenTouchable()) {
            info.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_FRAME);
            return;
        }
        // Update touch insets to include any area needed for touching features that live in
        // the status bar (ie: heads up notifications)
        info.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION);
        if (mIsDesktopStatusBarEnabled) {
            // The touchableRegion is a shared object, so we must clear it before rebuilding.
            info.touchableRegion.setEmpty();
            for (Rect rect : calculateTouchableRegionForDesktop()) {
                info.touchableRegion.union(rect);
            }
        } else {
            info.touchableRegion.set(calculateTouchableRegion());
        }
    }
}
