/*
 * Copyright (C) 2017 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.server.accessibility;

import static android.view.MotionEvent.ACTION_DOWN;
import static android.view.MotionEvent.ACTION_MOVE;
import static android.view.MotionEvent.ACTION_POINTER_DOWN;
import static android.view.MotionEvent.ACTION_POINTER_UP;

import static com.android.server.testutils.TestUtils.strictMock;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyInt;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.animation.ValueAnimator;
import android.annotation.NonNull;
import android.content.Context;
import android.os.Handler;
import android.os.Message;
import android.util.DebugUtils;
import android.view.InputDevice;
import android.view.MotionEvent;

import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;

import com.android.server.testutils.OffsettableClock;
import com.android.server.testutils.TestHandler;
import com.android.server.wm.WindowManagerInternal;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.util.function.IntConsumer;

/**
 * Tests the state transitions of {@link FullScreenMagnificationGestureHandler}
 *
 * Here's a dot graph describing the transitions being tested:
 * {@code
 *      digraph {
 *          IDLE -> SHORTCUT_TRIGGERED [label="a11y\nbtn"]
 *          SHORTCUT_TRIGGERED -> IDLE [label="a11y\nbtn"]
 *          IDLE -> DOUBLE_TAP [label="2tap"]
 *          DOUBLE_TAP -> IDLE [label="timeout"]
 *          DOUBLE_TAP -> TRIPLE_TAP_AND_HOLD [label="down"]
 *          SHORTCUT_TRIGGERED -> TRIPLE_TAP_AND_HOLD [label="down"]
 *          TRIPLE_TAP_AND_HOLD -> ZOOMED [label="up"]
 *          TRIPLE_TAP_AND_HOLD -> DRAGGING_TMP [label="hold/\nswipe"]
 *          DRAGGING_TMP -> IDLE [label="release"]
 *          ZOOMED -> ZOOMED_DOUBLE_TAP [label="2tap"]
 *          ZOOMED_DOUBLE_TAP -> ZOOMED [label="timeout"]
 *          ZOOMED_DOUBLE_TAP -> DRAGGING [label="hold"]
 *          ZOOMED_DOUBLE_TAP -> IDLE [label="tap"]
 *          DRAGGING -> ZOOMED [label="release"]
 *          ZOOMED -> IDLE [label="a11y\nbtn"]
 *          ZOOMED -> PANNING [label="2hold"]
 *          PANNING -> PANNING_SCALING [label="pinch"]
 *          PANNING_SCALING -> ZOOMED [label="release"]
 *          PANNING -> ZOOMED [label="release"]
 *      }
 * }
 */
@RunWith(AndroidJUnit4.class)
public class FullScreenMagnificationGestureHandlerTest {

    public static final int STATE_IDLE = 1;
    public static final int STATE_ZOOMED = 2;
    public static final int STATE_2TAPS = 3;
    public static final int STATE_ZOOMED_2TAPS = 4;
    public static final int STATE_SHORTCUT_TRIGGERED = 5;
    public static final int STATE_DRAGGING_TMP = 6;
    public static final int STATE_DRAGGING = 7;
    public static final int STATE_PANNING = 8;
    public static final int STATE_SCALING_AND_PANNING = 9;


    public static final int FIRST_STATE = STATE_IDLE;
    public static final int LAST_STATE = STATE_SCALING_AND_PANNING;

    // Co-prime x and y, to potentially catch x-y-swapped errors
    public static final float DEFAULT_X = 301;
    public static final float DEFAULT_Y = 299;

    private static final int DISPLAY_0 = 0;

    private Context mContext;
    MagnificationController mMagnificationController;

    private OffsettableClock mClock;
    private FullScreenMagnificationGestureHandler mMgh;
    private TestHandler mHandler;

    private long mLastDownTime = Integer.MIN_VALUE;

    @Before
    public void setUp() {
        mContext = InstrumentationRegistry.getContext();
        final MagnificationController.ControllerContext mockController =
                mock(MagnificationController.ControllerContext.class);
        final WindowManagerInternal mockWindowManager = mock(WindowManagerInternal.class);
        when(mockController.getContext()).thenReturn(mContext);
        when(mockController.getAms()).thenReturn(mock(AccessibilityManagerService.class));
        when(mockController.getWindowManager()).thenReturn(mockWindowManager);
        when(mockController.getHandler()).thenReturn(new Handler(mContext.getMainLooper()));
        when(mockController.newValueAnimator()).thenReturn(new ValueAnimator());
        when(mockController.getAnimationDuration()).thenReturn(1000L);
        when(mockWindowManager.setMagnificationCallbacks(eq(DISPLAY_0), any())).thenReturn(true);
        mMagnificationController = new MagnificationController(mockController, new Object()) {
            @Override
            public boolean magnificationRegionContains(int displayId, float x, float y) {
                return true;
            }

            @Override
            void setForceShowMagnifiableBounds(int displayId, boolean show) {}
        };
        mMagnificationController.register(DISPLAY_0);
        mClock = new OffsettableClock.Stopped();

        boolean detectTripleTap = true;
        boolean detectShortcutTrigger = true;
        mMgh = newInstance(detectTripleTap, detectShortcutTrigger);
    }

    @After
    public void tearDown() {
        mMagnificationController.unregister(DISPLAY_0);
    }

    @NonNull
    private FullScreenMagnificationGestureHandler newInstance(boolean detectTripleTap,
            boolean detectShortcutTrigger) {
        FullScreenMagnificationGestureHandler h = new FullScreenMagnificationGestureHandler(
                mContext, mMagnificationController,
                detectTripleTap, detectShortcutTrigger, DISPLAY_0);
        mHandler = new TestHandler(h.mDetectingState, mClock) {
            @Override
            protected String messageToString(Message m) {
                return DebugUtils.valueToString(
                        FullScreenMagnificationGestureHandler.DetectingState.class, "MESSAGE_",
                        m.what);
            }
        };
        h.mDetectingState.mHandler = mHandler;
        h.setNext(strictMock(EventStreamTransformation.class));
        return h;
    }

    @Test
    public void testInitialState_isIdle() {
        assertIn(STATE_IDLE);
    }

    /**
     * Covers paths to get to and back between each state and {@link #STATE_IDLE}
     * This navigates between states using "canonical" paths, specified in
     * {@link #goFromStateIdleTo} (for traversing away from {@link #STATE_IDLE}) and
     * {@link #returnToNormalFrom} (for navigating back to {@link #STATE_IDLE})
     */
    @Test
    public void testEachState_isReachableAndRecoverable() {
        forEachState(state -> {
            goFromStateIdleTo(state);
            assertIn(state);

            returnToNormalFrom(state);
            try {
                assertIn(STATE_IDLE);
            } catch (AssertionError e) {
                throw new AssertionError("Failed while testing state " + stateToString(state), e);
            }
        });
    }

    @Test
    public void testStates_areMutuallyExclusive() {
        forEachState(state1 -> {
            forEachState(state2 -> {
                if (state1 < state2) {
                    goFromStateIdleTo(state1);
                    try {
                        assertIn(state2);
                        fail("State " + stateToString(state1) + " also implies state "
                                + stateToString(state2) + stateDump());
                    } catch (AssertionError e) {
                        // expected
                        returnToNormalFrom(state1);
                    }
                }
            });
        });
    }

    @Test
    public void testTransitionToDelegatingStateAndClear_preservesShortcutTriggeredState() {
        mMgh.mDetectingState.transitionToDelegatingStateAndClear();
        assertFalse(mMgh.mDetectingState.mShortcutTriggered);

        goFromStateIdleTo(STATE_SHORTCUT_TRIGGERED);
        mMgh.mDetectingState.transitionToDelegatingStateAndClear();
        assertTrue(mMgh.mDetectingState.mShortcutTriggered);
    }

    /**
     * Covers edges of the graph not covered by "canonical" transitions specified in
     * {@link #goFromStateIdleTo} and {@link #returnToNormalFrom}
     */
    @SuppressWarnings("Convert2MethodRef")
    @Test
    public void testAlternativeTransitions_areWorking() {
        // A11y button followed by a tap&hold turns temporary "viewport dragging" zoom on
        assertTransition(STATE_SHORTCUT_TRIGGERED, () -> {
            send(downEvent());
            fastForward1sec();
        }, STATE_DRAGGING_TMP);

        // A11y button followed by a tap turns zoom on
        assertTransition(STATE_SHORTCUT_TRIGGERED, () -> tap(), STATE_ZOOMED);

        // A11y button pressed second time negates the 1st press
        assertTransition(STATE_SHORTCUT_TRIGGERED, () -> triggerShortcut(), STATE_IDLE);

        // A11y button turns zoom off
        assertTransition(STATE_ZOOMED, () -> triggerShortcut(), STATE_IDLE);


        // Double tap times out while zoomed
        assertTransition(STATE_ZOOMED_2TAPS, () -> {
            allowEventDelegation();
            fastForward1sec();
        }, STATE_ZOOMED);

        // tap+tap+swipe doesn't get delegated
        assertTransition(STATE_2TAPS, () -> swipe(), STATE_IDLE);

        // tap+tap+swipe initiates viewport dragging immediately
        assertTransition(STATE_2TAPS, () -> swipeAndHold(), STATE_DRAGGING_TMP);
    }

    @Test
    public void testNonTransitions_dontChangeState() {
        // ACTION_POINTER_DOWN triggers event delegation if not magnifying
        assertStaysIn(STATE_IDLE, () -> {
            allowEventDelegation();
            send(downEvent());
            send(pointerEvent(ACTION_POINTER_DOWN, DEFAULT_X * 2, DEFAULT_Y));
        });

        // Long tap breaks the triple-tap detection sequence
        Runnable tapAndLongTap = () -> {
            allowEventDelegation();
            tap();
            longTap();
        };
        assertStaysIn(STATE_IDLE, tapAndLongTap);
        assertStaysIn(STATE_ZOOMED, tapAndLongTap);

        // Triple tap with delays in between doesn't count
        Runnable slow3tap = () -> {
            tap();
            fastForward1sec();
            tap();
            fastForward1sec();
            tap();
        };
        assertStaysIn(STATE_IDLE, slow3tap);
        assertStaysIn(STATE_ZOOMED, slow3tap);
    }

    @Test
    public void testDisablingTripleTap_removesInputLag() {
        mMgh = newInstance(/* detect3tap */ false, /* detectShortcut */ true);
        goFromStateIdleTo(STATE_IDLE);
        allowEventDelegation();
        tap();
        // no fast forward
        verify(mMgh.getNext(), times(2)).onMotionEvent(any(), any(), anyInt());
    }

    @Test
    public void testTripleTapAndHold_zoomsImmediately() {
        assertZoomsImmediatelyOnSwipeFrom(STATE_2TAPS);
        assertZoomsImmediatelyOnSwipeFrom(STATE_SHORTCUT_TRIGGERED);
    }

    @Test
    public void testMultiTap_outOfDistanceSlop_shouldInIdle() {
        // All delay motion events should be sent, if multi-tap with out of distance slop.
        // STATE_IDLE will check if tapCount() < 2.
        allowEventDelegation();
        assertStaysIn(STATE_IDLE, () -> {
            tap();
            tap(DEFAULT_X * 2, DEFAULT_Y * 2);
        });
        assertStaysIn(STATE_IDLE, () -> {
            tap();
            tap(DEFAULT_X * 2, DEFAULT_Y * 2);
            tap();
            tap(DEFAULT_X * 2, DEFAULT_Y * 2);
            tap();
        });
    }

    private void assertZoomsImmediatelyOnSwipeFrom(int state) {
        goFromStateIdleTo(state);
        swipeAndHold();
        assertIn(STATE_DRAGGING_TMP);
        returnToNormalFrom(STATE_DRAGGING_TMP);
    }

    private void assertTransition(int fromState, Runnable transitionAction, int toState) {
        goFromStateIdleTo(fromState);
        transitionAction.run();
        assertIn(toState);
        returnToNormalFrom(toState);
    }

    private void assertStaysIn(int state, Runnable action) {
        assertTransition(state, action, state);
    }

    private void forEachState(IntConsumer action) {
        for (int state = FIRST_STATE; state <= LAST_STATE; state++) {
            action.accept(state);
        }
    }

    private void allowEventDelegation() {
        doNothing().when(mMgh.getNext()).onMotionEvent(any(), any(), anyInt());
    }

    private void fastForward1sec() {
        fastForward(1000);
    }

    private void fastForward(int ms) {
        mClock.fastForward(ms);
        mHandler.timeAdvance();
    }

    /**
     * Asserts that {@link #mMgh the handler} is in the given {@code state}
     */
    private void assertIn(int state) {
        switch (state) {

            // Asserts on separate lines for accurate stack traces

            case STATE_IDLE: {
                check(tapCount() < 2, state);
                check(!mMgh.mDetectingState.mShortcutTriggered, state);
                check(!isZoomed(), state);
            } break;
            case STATE_ZOOMED: {
                check(isZoomed(), state);
                check(tapCount() < 2, state);
            } break;
            case STATE_2TAPS: {
                check(!isZoomed(), state);
                check(tapCount() == 2, state);
            } break;
            case STATE_ZOOMED_2TAPS: {
                check(isZoomed(), state);
                check(tapCount() == 2, state);
            } break;
            case STATE_DRAGGING: {
                check(isZoomed(), state);
                check(mMgh.mCurrentState == mMgh.mViewportDraggingState,
                        state);
                check(mMgh.mViewportDraggingState.mZoomedInBeforeDrag, state);
            } break;
            case STATE_DRAGGING_TMP: {
                check(isZoomed(), state);
                check(mMgh.mCurrentState == mMgh.mViewportDraggingState,
                        state);
                check(!mMgh.mViewportDraggingState.mZoomedInBeforeDrag, state);
            } break;
            case STATE_SHORTCUT_TRIGGERED: {
                check(mMgh.mDetectingState.mShortcutTriggered, state);
                check(!isZoomed(), state);
            } break;
            case STATE_PANNING: {
                check(isZoomed(), state);
                check(mMgh.mCurrentState == mMgh.mPanningScalingState,
                        state);
                check(!mMgh.mPanningScalingState.mScaling, state);
            } break;
            case STATE_SCALING_AND_PANNING: {
                check(isZoomed(), state);
                check(mMgh.mCurrentState == mMgh.mPanningScalingState,
                        state);
                check(mMgh.mPanningScalingState.mScaling, state);
            } break;
            default: throw new IllegalArgumentException("Illegal state: " + state);
        }
    }

    /**
     * Defines a "canonical" path from {@link #STATE_IDLE} to {@code state}
     */
    private void goFromStateIdleTo(int state) {
        try {
            switch (state) {
                case STATE_IDLE: {
                    mMgh.clearAndTransitionToStateDetecting();
                } break;
                case STATE_2TAPS: {
                    goFromStateIdleTo(STATE_IDLE);
                    tap();
                    tap();
                } break;
                case STATE_ZOOMED: {
                    if (mMgh.mDetectTripleTap) {
                        goFromStateIdleTo(STATE_2TAPS);
                        tap();
                    } else {
                        goFromStateIdleTo(STATE_SHORTCUT_TRIGGERED);
                        tap();
                    }
                } break;
                case STATE_ZOOMED_2TAPS: {
                    goFromStateIdleTo(STATE_ZOOMED);
                    tap();
                    tap();
                } break;
                case STATE_DRAGGING: {
                    goFromStateIdleTo(STATE_ZOOMED_2TAPS);
                    send(downEvent());
                    fastForward1sec();
                } break;
                case STATE_DRAGGING_TMP: {
                    goFromStateIdleTo(STATE_2TAPS);
                    send(downEvent());
                    fastForward1sec();
                } break;
                case STATE_SHORTCUT_TRIGGERED: {
                    goFromStateIdleTo(STATE_IDLE);
                    triggerShortcut();
                } break;
                case STATE_PANNING: {
                    goFromStateIdleTo(STATE_ZOOMED);
                    send(downEvent());
                    send(pointerEvent(ACTION_POINTER_DOWN, DEFAULT_X * 2, DEFAULT_Y));
                } break;
                case STATE_SCALING_AND_PANNING: {
                    goFromStateIdleTo(STATE_PANNING);
                    send(pointerEvent(ACTION_MOVE, DEFAULT_X * 2, DEFAULT_Y * 3));
                    send(pointerEvent(ACTION_MOVE, DEFAULT_X * 2, DEFAULT_Y * 4));
                } break;
                default:
                    throw new IllegalArgumentException("Illegal state: " + state);
            }
        } catch (Throwable t) {
            throw new RuntimeException("Failed to go to state " + stateToString(state), t);
        }
    }

    /**
     * Defines a "canonical" path from {@code state} to {@link #STATE_IDLE}
     */
    private void returnToNormalFrom(int state) {
        switch (state) {
            case STATE_IDLE: {
                // no op
            } break;
            case STATE_2TAPS: {
                allowEventDelegation();
                fastForward1sec();
            } break;
            case STATE_ZOOMED: {
                if (mMgh.mDetectTripleTap) {
                    tap();
                    tap();
                    returnToNormalFrom(STATE_ZOOMED_2TAPS);
                } else {
                    triggerShortcut();
                }
            } break;
            case STATE_ZOOMED_2TAPS: {
                tap();
            } break;
            case STATE_DRAGGING: {
                send(upEvent());
                returnToNormalFrom(STATE_ZOOMED);
            } break;
            case STATE_DRAGGING_TMP: {
                send(upEvent());
            } break;
            case STATE_SHORTCUT_TRIGGERED: {
                triggerShortcut();
            } break;
            case STATE_PANNING: {
                send(pointerEvent(ACTION_POINTER_UP, DEFAULT_X * 2, DEFAULT_Y));
                send(upEvent());
                returnToNormalFrom(STATE_ZOOMED);
            } break;
            case STATE_SCALING_AND_PANNING: {
                returnToNormalFrom(STATE_PANNING);
            } break;
            default: throw new IllegalArgumentException("Illegal state: " + state);
        }
    }

    private void check(boolean condition, int expectedState) {
        if (!condition) {
            fail("Expected to be in state " + stateToString(expectedState) + stateDump());
        }
    }

    private boolean isZoomed() {
        return mMgh.mMagnificationController.isMagnifying(DISPLAY_0);
    }

    private int tapCount() {
        return mMgh.mDetectingState.tapCount();
    }

    private static String stateToString(int state) {
        return DebugUtils.valueToString(FullScreenMagnificationGestureHandlerTest.class, "STATE_",
                state);
    }

    private void tap() {
        send(downEvent());
        send(upEvent());
    }

    private void tap(float x, float y) {
        send(downEvent(x, y));
        send(upEvent(x, y));
    }

    private void swipe() {
        swipeAndHold();
        send(upEvent());
    }

    private void swipeAndHold() {
        send(downEvent());
        send(moveEvent(DEFAULT_X * 2, DEFAULT_Y * 2));
    }

    private void longTap() {
        send(downEvent());
        fastForward(2000);
        send(upEvent());
    }

    private void triggerShortcut() {
        mMgh.notifyShortcutTriggered();
    }

    private void send(MotionEvent event) {
        event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
        try {
            mMgh.onMotionEvent(event, event, /* policyFlags */ 0);
        } catch (Throwable t) {
            throw new RuntimeException("Exception while handling " + event, t);
        }
        fastForward(1);
    }

    private static MotionEvent fromTouchscreen(MotionEvent ev) {
        ev.setSource(InputDevice.SOURCE_TOUCHSCREEN);
        return ev;
    }

    private MotionEvent moveEvent(float x, float y) {
        return fromTouchscreen(
                MotionEvent.obtain(mLastDownTime, mClock.now(), ACTION_MOVE, x, y, 0));
    }

    private MotionEvent downEvent() {
        return downEvent(DEFAULT_X, DEFAULT_Y);
    }

    private MotionEvent downEvent(float x, float y) {
        mLastDownTime = mClock.now();
        return fromTouchscreen(MotionEvent.obtain(mLastDownTime, mLastDownTime,
                ACTION_DOWN, x, y, 0));
    }

    private MotionEvent upEvent() {
        return upEvent(DEFAULT_X, DEFAULT_Y, mLastDownTime);
    }

    private MotionEvent upEvent(float x, float y) {
        return upEvent(x, y, mLastDownTime);
    }

    private MotionEvent upEvent(float x, float y, long downTime) {
        return fromTouchscreen(MotionEvent.obtain(downTime, mClock.now(),
                MotionEvent.ACTION_UP, x, y, 0));
    }

    private MotionEvent pointerEvent(int action, float x, float y) {
        MotionEvent.PointerProperties defPointerProperties = new MotionEvent.PointerProperties();
        defPointerProperties.id = 0;
        defPointerProperties.toolType = MotionEvent.TOOL_TYPE_FINGER;
        MotionEvent.PointerProperties pointerProperties = new MotionEvent.PointerProperties();
        pointerProperties.id = 1;
        pointerProperties.toolType = MotionEvent.TOOL_TYPE_FINGER;

        MotionEvent.PointerCoords defPointerCoords = new MotionEvent.PointerCoords();
        defPointerCoords.x = DEFAULT_X;
        defPointerCoords.y = DEFAULT_Y;
        MotionEvent.PointerCoords pointerCoords = new MotionEvent.PointerCoords();
        pointerCoords.x = x;
        pointerCoords.y = y;

        return MotionEvent.obtain(
            /* downTime */ mClock.now(),
            /* eventTime */ mClock.now(),
            /* action */ action,
            /* pointerCount */ 2,
            /* pointerProperties */ new MotionEvent.PointerProperties[] {
                    defPointerProperties, pointerProperties},
            /* pointerCoords */ new MotionEvent.PointerCoords[] { defPointerCoords, pointerCoords },
            /* metaState */ 0,
            /* buttonState */ 0,
            /* xPrecision */ 1.0f,
            /* yPrecision */ 1.0f,
            /* deviceId */ 0,
            /* edgeFlags */ 0,
            /* source */ InputDevice.SOURCE_TOUCHSCREEN,
            /* flags */ 0);
    }

    private String stateDump() {
        return "\nCurrent state dump:\n" + mMgh + "\n" + mHandler.getPendingMessages();
    }
}
