/*
 * Copyright (C) 2018 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.helper.aoa;

import static com.android.helper.aoa.AoaDevice.ACCESSORY_REGISTER_HID;
import static com.android.helper.aoa.AoaDevice.ACCESSORY_SEND_HID_EVENT;
import static com.android.helper.aoa.AoaDevice.ACCESSORY_SET_HID_REPORT_DESC;
import static com.android.helper.aoa.AoaDevice.ACCESSORY_START;
import static com.android.helper.aoa.AoaDevice.ACCESSORY_START_MAX_RETRIES;
import static com.android.helper.aoa.AoaDevice.ACCESSORY_UNREGISTER_HID;
import static com.android.helper.aoa.AoaDevice.DEVICE_NOT_FOUND;
import static com.android.helper.aoa.AoaDevice.GOOGLE_VID;
import static com.android.helper.aoa.AoaDevice.LONG_CLICK;
import static com.android.helper.aoa.AoaDevice.SYSTEM_BACK;
import static com.android.helper.aoa.AoaDevice.SYSTEM_HOME;
import static com.android.helper.aoa.AoaDevice.SYSTEM_WAKE;
import static com.android.helper.aoa.AoaDevice.TOUCH_DOWN;
import static com.android.helper.aoa.AoaDevice.TOUCH_UP;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyByte;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.google.common.primitives.Shorts;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.verification.VerificationMode;

import java.awt.*;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import javax.annotation.Nonnull;

/** Unit tests for {@link AoaDevice} */
@RunWith(JUnit4.class)
public class AoaDeviceTest {

    private static final int HID_COUNT = AoaHID.values().length;
    private static final String SERIAL_NUMBER = "serial-number";
    private static final int INVALID_VID = 0x0000;
    private static final int ADB_DISABLED_PID = 0x2D00;
    private static final int ADB_ENABLED_PID = 0x2D01;
    private static final int AUDIO_ENABLED_PID = 0x2D05;

    private AoaDevice mDevice;

    private UsbHelper mHelper;
    private UsbDevice mDelegate;

    @Before
    public void setUp() {
        // valid accessory mode device by default
        mDelegate = mock(UsbDevice.class);
        when(mDelegate.isValid()).thenReturn(true);
        when(mDelegate.getSerialNumber()).thenReturn(SERIAL_NUMBER);
        when(mDelegate.getVendorId()).thenReturn(GOOGLE_VID);
        when(mDelegate.getProductId()).thenReturn(ADB_DISABLED_PID);

        mHelper = mock(UsbHelper.class);
        when(mHelper.getDevice(anyString(), any())).thenReturn(mDelegate);
    }

    // Initialization

    @Test
    public void testRegistersHIDsAutomatically() {
        mDevice = createDevice();

        // already in accessory mode
        verifyRequest(never(), ACCESSORY_START);

        // registers HIDs
        verifyRequest(never(), ACCESSORY_UNREGISTER_HID);
        verifyRequest(times(HID_COUNT), ACCESSORY_REGISTER_HID);
        verifyRequest(times(HID_COUNT), ACCESSORY_SET_HID_REPORT_DESC);
    }

    @Test
    public void testDetectsAccessoryMode() {
        // not in accessory mode initially
        when(mDelegate.getVendorId())
                .thenReturn(INVALID_VID)
                .thenReturn(GOOGLE_VID);

        mDevice = createDevice();

        // restarts in accessory mode
        verifyRequest(times(1), ACCESSORY_START);

        // registers HIDs afterwards
        verifyRequest(never(), ACCESSORY_UNREGISTER_HID);
        verifyRequest(times(HID_COUNT), ACCESSORY_REGISTER_HID);
        verifyRequest(times(HID_COUNT), ACCESSORY_SET_HID_REPORT_DESC);
    }

    @Test
    public void testRetriesAccessoryMode() {
        // never in accessory mode
        when(mDelegate.getVendorId()).thenReturn(INVALID_VID);

        mDevice = createDevice();

        // retried starting accessory mode before giving up
        verifyRequest(times(ACCESSORY_START_MAX_RETRIES), ACCESSORY_START);
    }

    @Test
    public void testResetConnection() {
        mDevice = createDevice();
        clearInvocations(mDelegate);
        mDevice.resetConnection();

        verify(mHelper, times(1)).getDevice(eq(SERIAL_NUMBER), any());

        // re-registers HIDs
        verifyRequest(times(HID_COUNT), ACCESSORY_UNREGISTER_HID);
        verifyRequest(times(HID_COUNT), ACCESSORY_REGISTER_HID);
        verifyRequest(times(HID_COUNT), ACCESSORY_SET_HID_REPORT_DESC);
    }

    @Test(expected = UsbException.class)
    public void testThrowsIfConnectionInvalid() {
        when(mDelegate.isValid()).thenReturn(false);
        mDevice = createDevice();
    }

    @Test(expected = UsbException.class)
    public void testThrowsIfSerialNumberMissing() {
        when(mDelegate.getSerialNumber()).thenReturn(null);
        mDevice = createDevice();
    }

    @Test
    public void testGetSerialNumber() {
        mDevice = createDevice();
        assertEquals(SERIAL_NUMBER, mDevice.getSerialNumber());
    }

    @Test
    public void testIsAdbEnabled() {
        mDevice = createDevice();

        // invalid VID and valid PID
        when(mDelegate.getVendorId()).thenReturn(INVALID_VID);
        when(mDelegate.getProductId()).thenReturn(ADB_ENABLED_PID);
        assertFalse(mDevice.isAdbEnabled());

        // valid VID and invalid PID
        when(mDelegate.getVendorId()).thenReturn(GOOGLE_VID);
        when(mDelegate.getProductId()).thenReturn(ADB_DISABLED_PID);
        assertFalse(mDevice.isAdbEnabled());

        // both valid
        when(mDelegate.getVendorId()).thenReturn(GOOGLE_VID);
        when(mDelegate.getProductId()).thenReturn(ADB_ENABLED_PID);
        assertTrue(mDevice.isAdbEnabled());
    }

    @Test
    public void testIsAudioEnabled() {
        mDevice = createDevice();

        // invalid PID
        assertFalse(mDevice.isAudioEnabled());

        // both valid
        when(mDelegate.getProductId()).thenReturn(AUDIO_ENABLED_PID);
        assertTrue(mDevice.isAudioEnabled());
    }

    @Test
    public void testClose() {
        mDevice = createDevice();
        mDevice.close();

        // unregisters descriptors and closes connection
        verifyRequest(times(HID_COUNT), ACCESSORY_UNREGISTER_HID);
        verify(mDelegate, times(1)).close();
    }

    // Actions

    @Test
    public void testClick() {
        mDevice = createDevice();
        clearInvocations(mDevice);
        mDevice.click(new Point(12, 34));

        verifyTouches(new Touch(TOUCH_DOWN, 12, 34), new Touch(TOUCH_UP, 12, 34));
    }

    @Test
    public void testLongClick() {
        mDevice = createDevice();
        clearInvocations(mDevice);
        mDevice.longClick(new Point(23, 45));

        verifyTouches(new Touch(TOUCH_DOWN, 23, 45), new Touch(TOUCH_UP, 23, 45));
        verify(mDevice, atLeastOnce()).sleep(LONG_CLICK);
    }

    @Test
    public void testSwipe() {
        mDevice = createDevice();
        mDevice.swipe(new Point(20, 0), new Point(70, 100), Duration.ofMillis(50));

        List<Touch> events =
                List.of(
                        new Touch(TOUCH_DOWN, 20, 0),
                        new Touch(TOUCH_DOWN, 30, 20),
                        new Touch(TOUCH_DOWN, 40, 40),
                        new Touch(TOUCH_DOWN, 50, 60),
                        new Touch(TOUCH_DOWN, 60, 80),
                        new Touch(TOUCH_DOWN, 70, 100),
                        new Touch(TOUCH_UP, 70, 100));
        verifyTouches(events);
    }

    @Test
    public void testPressKeys() {
        mDevice = createDevice();
        mDevice.pressKeys(new AoaKey(1), null, new AoaKey(2, AoaKey.Modifier.SHIFT));

        InOrder order = inOrder(mDelegate);
        // press and release 1
        verifyHidRequest(order, times(1), AoaHID.KEYBOARD, (byte) 0, (byte) 1);
        verifyHidRequest(order, times(1), AoaHID.KEYBOARD, (byte) 0, (byte) 0);
        // skip null, and press and release 2
        verifyHidRequest(order, times(1), AoaHID.KEYBOARD, (byte) 2, (byte) 2);
        verifyHidRequest(order, times(1), AoaHID.KEYBOARD, (byte) 0, (byte) 0);
    }

    @Test
    public void testWakeUp() {
        mDevice = createDevice();
        mDevice.wakeUp();

        verifyHidRequest(times(1), AoaHID.SYSTEM, SYSTEM_WAKE);
    }

    @Test
    public void testGoHome() {
        mDevice = createDevice();
        mDevice.goHome();

        verifyHidRequest(times(1), AoaHID.SYSTEM, SYSTEM_HOME);
    }

    @Test
    public void testGoBack() {
        mDevice = createDevice();
        mDevice.goBack();

        verifyHidRequest(times(1), AoaHID.SYSTEM, SYSTEM_BACK);
    }

    @Test
    public void testRetryAction() {
        mDevice = createDevice();

        // first attempt will fail to find device
        when(mDelegate.controlTransfer(anyByte(), anyByte(), anyInt(), anyInt(), any()))
                .thenReturn(DEVICE_NOT_FOUND)
                .thenReturn(0);

        mDevice.click(new Point(34, 56));

        // reset the connection
        verify(mHelper, times(1)).getDevice(eq(SERIAL_NUMBER), any());

        verifyTouches(
                new Touch(TOUCH_DOWN, 34, 56), // failed
                new Touch(TOUCH_DOWN, 34, 56), // retry after resetting connection
                new Touch(TOUCH_UP, 34, 56));
    }

    // Helpers

    /** Creates a mock device with predictable timestamps. */
    private AoaDevice createDevice() {
        AoaDevice device =
                new AoaDevice(mHelper, mDelegate) {
                    private Instant mInstant;

                    @Override
                    Instant now() {
                        if (mInstant == null) {
                            mInstant = Instant.MIN;
                        }
                        return mInstant;
                    }

                    @Override
                    public void sleep(@Nonnull Duration duration) {
                        mInstant = now().plus(duration);
                    }
                };
        return spy(device);
    }

    private void verifyRequest(VerificationMode mode, byte request) {
        verify(mDelegate, mode)
                .controlTransfer(anyByte(), eq(request), anyInt(), anyInt(), any());
    }

    private void verifyHidRequest(VerificationMode mode, AoaHID hid, byte... data) {
        verifyHidRequest(null, mode, hid, data);
    }

    private void verifyHidRequest(InOrder order, VerificationMode mode, AoaHID hid, byte... data) {
        UsbDevice verifier =
                order == null ? verify(mDelegate, mode) : order.verify(mDelegate, mode);
        verifier.controlTransfer(
                anyByte(), eq(ACCESSORY_SEND_HID_EVENT), eq(hid.getId()), anyInt(), eq(data));
    }

    private void verifyTouches(Touch... expected) {
        verifyTouches(Arrays.asList(expected));
    }

    private void verifyTouches(List<Touch> expected) {
        ArgumentCaptor<byte[]> captor = ArgumentCaptor.forClass(byte[].class);

        verify(mDelegate, times(expected.size()))
                .controlTransfer(
                        anyByte(),
                        eq(ACCESSORY_SEND_HID_EVENT),
                        eq(AoaHID.TOUCH_SCREEN.getId()),
                        anyInt(),
                        captor.capture());

        List<Touch> events =
                captor.getAllValues().stream().map(Touch::new).collect(Collectors.toList());
        assertEquals(expected, events);
    }

    /** Touch HID event. */
    private static class Touch {

        private final byte mType;
        private final int mX;
        private final int mY;

        private Touch(byte type, int x, int y) {
            mType = type;
            mX = x;
            mY = y;
        }

        private Touch(byte[] data) {
            mType = data[0];
            mX = Shorts.fromBytes(data[2], data[1]);
            mY = Shorts.fromBytes(data[4], data[3]);
        }

        @Override
        public boolean equals(Object object) {
            if (this == object) {
                return true;
            }
            if (!(object instanceof Touch)) {
                return false;
            }
            Touch event = (Touch) object;
            return mType == event.mType && mX == event.mX && mY == event.mY;
        }

        @Override
        public int hashCode() {
            return Objects.hash(mType, mX, mY);
        }

        @Override
        public String toString() {
            return String.format("Touch{%d, %d, %d}", mType, mX, mY);
        }
    }
}
