/*
 * Copyright (C) 2022 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 android.tools.helpers;

import android.annotation.NonNull;
import android.app.Instrumentation;
import android.app.UiAutomation;
import android.os.SystemClock;
import android.view.InputDevice;
import android.view.InputEvent;
import android.view.MotionEvent;
import android.view.MotionEvent.PointerCoords;
import android.view.MotionEvent.PointerProperties;

import androidx.annotation.Nullable;

/** Injects gestures given an {@link Instrumentation} object. */
public class GestureHelper {
    // Inserted after each motion event injection.
    private static final int MOTION_EVENT_INJECTION_DELAY_MILLIS = 5;

    private final UiAutomation mUiAutomation;

    /** Primary pointer should be cached here for separate release */
    @Nullable private PointerProperties mPrimaryPtrProp;

    @Nullable private PointerCoords mPrimaryPtrCoord;
    private long mPrimaryPtrDownTime;

    /** A pair of floating point values. */
    public static class Tuple {
        public float x;
        public float y;

        public Tuple(float x, float y) {
            this.x = x;
            this.y = y;
        }
    }

    public GestureHelper(Instrumentation instrumentation) {
        mUiAutomation = instrumentation.getUiAutomation();
    }

    /**
     * Injects a series of {@link MotionEvent}s to simulate tapping.
     *
     * @param point coordinates of pointer to tap
     * @param times the number of times to tap
     */
    public boolean tap(@NonNull Tuple point, int times) throws InterruptedException {
        PointerProperties ptrProp = getPointerProp(0, MotionEvent.TOOL_TYPE_FINGER);
        PointerCoords ptrCoord = getPointerCoord(point.x, point.y, 1, 1);

        for (int i = 0; i <= times; i++) {
            // If already tapped, inject delay in between movements
            if (times > 0) {
                SystemClock.sleep(50L);
            }
            if (!primaryPointerDown(ptrProp, ptrCoord, SystemClock.uptimeMillis())) {
                return false;
            }
            // Delay before releasing tap
            SystemClock.sleep(100L);
            if (!primaryPointerUp(ptrProp, ptrCoord, SystemClock.uptimeMillis())) {
                return false;
            }
        }
        return true;
    }

    /**
     * Injects a series of {@link MotionEvent}s to simulate a drag gesture without pointer release.
     *
     * <p>Simulates a drag gesture without releasing the primary pointer. The primary pointer info
     * will be cached for potential release later on by {@code releasePrimaryPointer()}
     *
     * @param startPoint initial coordinates of the primary pointer
     * @param endPoint final coordinates of the primary pointer
     * @param steps number of steps to take to animate dragging
     * @return true if gesture is injected successfully
     */
    public boolean dragWithoutRelease(
            @NonNull Tuple startPoint, @NonNull Tuple endPoint, int steps) {
        PointerProperties ptrProp = getPointerProp(0, MotionEvent.TOOL_TYPE_FINGER);
        PointerCoords ptrCoord = getPointerCoord(startPoint.x, startPoint.y, 1, 1);

        PointerProperties[] ptrProps = new PointerProperties[] {ptrProp};
        PointerCoords[] ptrCoords = new PointerCoords[] {ptrCoord};

        long downTime = SystemClock.uptimeMillis();

        if (!primaryPointerDown(ptrProp, ptrCoord, downTime)) {
            return false;
        }

        // cache the primary pointer info for later potential release
        mPrimaryPtrProp = ptrProp;
        mPrimaryPtrCoord = ptrCoord;
        mPrimaryPtrDownTime = downTime;

        return movePointers(ptrProps, ptrCoords, new Tuple[] {endPoint}, downTime, steps);
    }

    /**
     * Release primary pointer if previous gesture has cached the primary pointer info.
     *
     * @return true if the release was injected successfully
     */
    public boolean releasePrimaryPointer() {
        if (mPrimaryPtrProp != null && mPrimaryPtrCoord != null) {
            return primaryPointerUp(mPrimaryPtrProp, mPrimaryPtrCoord, mPrimaryPtrDownTime);
        }

        return false;
    }

    /**
     * Injects a series of {@link MotionEvent} objects to simulate a pinch gesture.
     *
     * @param startPoint1 initial coordinates of the first pointer
     * @param startPoint2 initial coordinates of the second pointer
     * @param endPoint1 final coordinates of the first pointer
     * @param endPoint2 final coordinates of the second pointer
     * @param steps number of steps to take to animate pinching
     * @return true if gesture is injected successfully
     */
    public boolean pinch(
            @NonNull Tuple startPoint1,
            @NonNull Tuple startPoint2,
            @NonNull Tuple endPoint1,
            @NonNull Tuple endPoint2,
            int steps) {
        PointerProperties ptrProp1 = getPointerProp(0, MotionEvent.TOOL_TYPE_FINGER);
        PointerProperties ptrProp2 = getPointerProp(1, MotionEvent.TOOL_TYPE_FINGER);

        PointerCoords ptrCoord1 = getPointerCoord(startPoint1.x, startPoint1.y, 1, 1);
        PointerCoords ptrCoord2 = getPointerCoord(startPoint2.x, startPoint2.y, 1, 1);

        PointerProperties[] ptrProps = new PointerProperties[] {ptrProp1, ptrProp2};

        PointerCoords[] ptrCoords = new PointerCoords[] {ptrCoord1, ptrCoord2};

        long downTime = SystemClock.uptimeMillis();

        if (!primaryPointerDown(ptrProp1, ptrCoord1, downTime)) {
            return false;
        }

        if (!nonPrimaryPointerDown(ptrProps, ptrCoords, downTime, 1)) {
            return false;
        }

        if (!movePointers(
                ptrProps, ptrCoords, new Tuple[] {endPoint1, endPoint2}, downTime, steps)) {
            return false;
        }

        if (!nonPrimaryPointerUp(ptrProps, ptrCoords, downTime, 1)) {
            return false;
        }

        return primaryPointerUp(ptrProp1, ptrCoord1, downTime);
    }

    private boolean primaryPointerDown(
            @NonNull PointerProperties prop, @NonNull PointerCoords coord, long downTime) {
        MotionEvent event =
                getMotionEvent(
                        downTime,
                        downTime,
                        MotionEvent.ACTION_DOWN,
                        1,
                        new PointerProperties[] {prop},
                        new PointerCoords[] {coord});

        return injectEventSync(event);
    }

    private boolean nonPrimaryPointerDown(
            @NonNull PointerProperties[] props,
            @NonNull PointerCoords[] coords,
            long downTime,
            int index) {
        // at least 2 pointers are needed
        if (props.length != coords.length || coords.length < 2) {
            return false;
        }

        long eventTime = SystemClock.uptimeMillis();

        MotionEvent event =
                getMotionEvent(
                        downTime,
                        eventTime,
                        MotionEvent.ACTION_POINTER_DOWN
                                + (index << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
                        coords.length,
                        props,
                        coords);

        return injectEventSync(event);
    }

    private boolean movePointers(
            @NonNull PointerProperties[] props,
            @NonNull PointerCoords[] coords,
            @NonNull Tuple[] endPoints,
            long downTime,
            int steps) {
        // the number of endpoints should be the same as the number of pointers
        if (props.length != coords.length || coords.length != endPoints.length) {
            return false;
        }

        // prevent division by 0 and negative number of steps
        if (steps < 1) {
            steps = 1;
        }

        // save the starting points before updating any pointers
        Tuple[] startPoints = new Tuple[coords.length];

        for (int i = 0; i < coords.length; i++) {
            startPoints[i] = new Tuple(coords[i].x, coords[i].y);
        }

        MotionEvent event;
        long eventTime;

        for (int i = 0; i < steps; i++) {
            // inject a delay between movements
            SystemClock.sleep(MOTION_EVENT_INJECTION_DELAY_MILLIS);

            // update the coordinates
            for (int j = 0; j < coords.length; j++) {
                coords[j].x += (endPoints[j].x - startPoints[j].x) / steps;
                coords[j].y += (endPoints[j].y - startPoints[j].y) / steps;
            }

            eventTime = SystemClock.uptimeMillis();

            event =
                    getMotionEvent(
                            downTime,
                            eventTime,
                            MotionEvent.ACTION_MOVE,
                            coords.length,
                            props,
                            coords);

            boolean didInject = injectEventSync(event);

            if (!didInject) {
                return false;
            }
        }

        return true;
    }

    private boolean primaryPointerUp(
            @NonNull PointerProperties prop, @NonNull PointerCoords coord, long downTime) {
        long eventTime = SystemClock.uptimeMillis();

        MotionEvent event =
                getMotionEvent(
                        downTime,
                        eventTime,
                        MotionEvent.ACTION_UP,
                        1,
                        new PointerProperties[] {prop},
                        new PointerCoords[] {coord});

        return injectEventSync(event);
    }

    private boolean nonPrimaryPointerUp(
            @NonNull PointerProperties[] props,
            @NonNull PointerCoords[] coords,
            long downTime,
            int index) {
        // at least 2 pointers are needed
        if (props.length != coords.length || coords.length < 2) {
            return false;
        }

        long eventTime = SystemClock.uptimeMillis();

        MotionEvent event =
                getMotionEvent(
                        downTime,
                        eventTime,
                        MotionEvent.ACTION_POINTER_UP
                                + (index << MotionEvent.ACTION_POINTER_INDEX_SHIFT),
                        coords.length,
                        props,
                        coords);

        return injectEventSync(event);
    }

    private PointerCoords getPointerCoord(float x, float y, float pressure, float size) {
        PointerCoords ptrCoord = new PointerCoords();
        ptrCoord.x = x;
        ptrCoord.y = y;
        ptrCoord.pressure = pressure;
        ptrCoord.size = size;
        return ptrCoord;
    }

    private PointerProperties getPointerProp(int id, int toolType) {
        PointerProperties ptrProp = new PointerProperties();
        ptrProp.id = id;
        ptrProp.toolType = toolType;
        return ptrProp;
    }

    private static MotionEvent getMotionEvent(
            long downTime,
            long eventTime,
            int action,
            int pointerCount,
            PointerProperties[] ptrProps,
            PointerCoords[] ptrCoords) {
        return MotionEvent.obtain(
                downTime,
                eventTime,
                action,
                pointerCount,
                ptrProps,
                ptrCoords,
                0,
                0,
                1.0f,
                1.0f,
                0,
                0,
                InputDevice.SOURCE_TOUCHSCREEN,
                0);
    }

    private boolean injectEventSync(InputEvent event) {
        return mUiAutomation.injectInputEvent(event, true);
    }
}
