// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.midi;

import android.hardware.usb.UsbConstants;
import android.hardware.usb.UsbDevice;
import android.hardware.usb.UsbDeviceConnection;
import android.hardware.usb.UsbEndpoint;
import android.hardware.usb.UsbInterface;
import android.hardware.usb.UsbManager;
import android.hardware.usb.UsbRequest;
import android.os.Handler;
import android.util.SparseArray;

import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.annotations.NativeMethods;

import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

/**
 * Owned by its native counterpart declared in usb_midi_device_android.h.
 * Refer to that class for general comments.
 */
@JNINamespace("midi")
class UsbMidiDeviceAndroid {
    /**
     * A connection handle for this device.
     */
    private final UsbDeviceConnection mConnection;

    /**
     * A map from endpoint number to UsbEndpoint.
     */
    private final SparseArray<UsbEndpoint> mEndpointMap;

    /**
     * A map from UsbEndpoint to UsbRequest associated to it.
     */
    private final Map<UsbEndpoint, UsbRequest> mRequestMap;

    /**
     * The handler used for posting events on the main thread.
     */
    private final Handler mHandler;

    /**
     * True if this device is closed.
     */
    private boolean mIsClosed;

    /**
     * True if there is a thread processing input data.
     */
    private boolean mHasInputThread;

    /**
     * The identifier of this device.
     */
    private long mNativePointer;

    /**
     * The underlying USB device.
     */
    private UsbDevice mUsbDevice;

    /**
     * Audio interface subclass code for MIDI.
     */
    static final int MIDI_SUBCLASS = 3;

    /**
     * The request type to request a USB descriptor.
     */
    static final int REQUEST_GET_DESCRIPTOR = 0x06;

    /**
     * The STRING descriptor type.
     */
    static final int STRING_DESCRIPTOR_TYPE = 0x03;

    /**
     * Constructs a UsbMidiDeviceAndroid.
     * @param manager
     * @param device The USB device which this object is associated with.
     */
    UsbMidiDeviceAndroid(UsbManager manager, UsbDevice device) {
        mConnection = manager.openDevice(device);
        mEndpointMap = new SparseArray<UsbEndpoint>();
        mRequestMap = new HashMap<UsbEndpoint, UsbRequest>();
        mHandler = new Handler();
        mUsbDevice = device;
        mIsClosed = false;
        mHasInputThread = false;
        mNativePointer = 0;

        for (int i = 0; i < device.getInterfaceCount(); ++i) {
            UsbInterface iface = device.getInterface(i);
            if (iface.getInterfaceClass() != UsbConstants.USB_CLASS_AUDIO
                    || iface.getInterfaceSubclass() != MIDI_SUBCLASS) {
                continue;
            }
            mConnection.claimInterface(iface, true);
            for (int j = 0; j < iface.getEndpointCount(); ++j) {
                UsbEndpoint endpoint = iface.getEndpoint(j);
                if (endpoint.getDirection() == UsbConstants.USB_DIR_OUT) {
                    mEndpointMap.put(endpoint.getEndpointNumber(), endpoint);
                }
            }
        }
        // Start listening for input endpoints.
        // This function will create and run a thread if there is USB-MIDI endpoints in the
        // device. Note that because UsbMidiDevice is shared among all tabs and the thread
        // will be terminated when the device is disconnected, at most one thread can be created
        // for each connected USB-MIDI device.
        startListen(device);
    }

    /**
     * Starts listening for input endpoints.
     */
    private void startListen(final UsbDevice device) {
        final Map<UsbEndpoint, ByteBuffer> bufferForEndpoints =
                new HashMap<UsbEndpoint, ByteBuffer>();

        for (int i = 0; i < device.getInterfaceCount(); ++i) {
            UsbInterface iface = device.getInterface(i);
            if (iface.getInterfaceClass() != UsbConstants.USB_CLASS_AUDIO
                    || iface.getInterfaceSubclass() != MIDI_SUBCLASS) {
                continue;
            }
            for (int j = 0; j < iface.getEndpointCount(); ++j) {
                UsbEndpoint endpoint = iface.getEndpoint(j);
                if (endpoint.getDirection() == UsbConstants.USB_DIR_IN) {
                    ByteBuffer buffer = ByteBuffer.allocate(endpoint.getMaxPacketSize());
                    UsbRequest request = new UsbRequest();
                    request.initialize(mConnection, endpoint);
                    request.queue(buffer, buffer.remaining());
                    bufferForEndpoints.put(endpoint, buffer);
                }
            }
        }
        if (bufferForEndpoints.isEmpty()) {
            return;
        }
        mHasInputThread = true;
        // bufferForEndpoints must not be accessed hereafter on this thread.
        new Thread() {
            @Override
            public void run() {
                while (true) {
                    UsbRequest request = mConnection.requestWait();
                    if (request == null) {
                        // When the device is closed requestWait will fail.
                        break;
                    }
                    UsbEndpoint endpoint = request.getEndpoint();
                    if (endpoint.getDirection() != UsbConstants.USB_DIR_IN) {
                        continue;
                    }
                    ByteBuffer buffer = bufferForEndpoints.get(endpoint);
                    int length = getInputDataLength(buffer);
                    if (length > 0) {
                        buffer.rewind();
                        final byte[] bs = new byte[length];
                        buffer.get(bs, 0, length);
                        postOnDataEvent(endpoint.getEndpointNumber(), bs);
                    }
                    buffer.rewind();
                    request.queue(buffer, buffer.capacity());
                }
            }
        }.start();
    }

    /**
     * Posts a data input event to the main thread.
     */
    private void postOnDataEvent(final int endpointNumber, final byte[] bs) {
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                if (mIsClosed) {
                    return;
                }
                UsbMidiDeviceAndroidJni.get().onData(mNativePointer, endpointNumber, bs);
            }
        });
    }

    UsbDevice getUsbDevice() {
        return mUsbDevice;
    }

    boolean isClosed() {
        return mIsClosed;
    }

    /**
     * Register the own native pointer.
     */
    @CalledByNative
    void registerSelf(long nativePointer) {
        mNativePointer = nativePointer;
    }

    /**
     * Sends a USB-MIDI data to the device.
     * @param endpointNumber The endpoint number of the destination endpoint.
     * @param bs The data to be sent.
     */
    @CalledByNative
    void send(int endpointNumber, byte[] bs) {
        if (mIsClosed) {
            return;
        }
        UsbEndpoint endpoint = mEndpointMap.get(endpointNumber);
        if (endpoint == null) {
            return;
        }
        if (shouldUseBulkTransfer()) {
            // We use bulkTransfer instead of UsbRequest.queue because queueing
            // a UsbRequest is currently not thread safe.
            // Note that this is not exactly correct because we don't care
            // about the transfer attribute (bmAttribute) of the endpoint.
            // See also:
            //  http://stackoverflow.com/questions/9644415/
            //  https://code.google.com/p/android/issues/detail?id=59467
            //
            // TODO(yhirano): Delete this block once the problem is fixed.
            final int timeout = 100;
            mConnection.bulkTransfer(endpoint, bs, bs.length, timeout);
        } else {
            UsbRequest request = mRequestMap.get(endpoint);
            if (request == null) {
                request = new UsbRequest();
                request.initialize(mConnection, endpoint);
                mRequestMap.put(endpoint, request);
            }
            request.queue(ByteBuffer.wrap(bs), bs.length);
        }
    }

    /**
     * Returns true if |bulkTransfer| should be used in |send|.
     * See comments in |send|.
     */
    private boolean shouldUseBulkTransfer() {
        return mHasInputThread;
    }

    /**
     * Returns the descriptors bytes of this device.
     * @return The descriptors bytes of this device.
     */
    @CalledByNative
    byte[] getDescriptors() {
        if (mConnection == null) {
            return new byte[0];
        }
        return mConnection.getRawDescriptors();
    }

    /**
     * Returns the string descriptor bytes for the given index
     * @param index index of the descriptor
     * @return the string descriptor bytes for the given index.
     */
    @CalledByNative
    byte[] getStringDescriptor(int index) {
        if (mConnection == null) {
            return new byte[0];
        }
        byte[] buffer = new byte[255];
        int type = UsbConstants.USB_DIR_IN | UsbConstants.USB_TYPE_STANDARD;
        int request = REQUEST_GET_DESCRIPTOR;
        int value = (STRING_DESCRIPTOR_TYPE << 8) | index;
        int read = mConnection.controlTransfer(type, request, value, 0, buffer, buffer.length, 0);
        if (read < 0) {
            return new byte[0];
        }
        return Arrays.copyOf(buffer, read);
    }

    /**
     * Closes the device connection.
     */
    @CalledByNative
    void close() {
        mEndpointMap.clear();
        for (UsbRequest request : mRequestMap.values()) {
            request.close();
        }
        mRequestMap.clear();
        mConnection.close();
        mNativePointer = 0;
        mIsClosed = true;
    }

    /**
     * Returns the length of a USB-MIDI input.
     * Since the Android API doesn't provide us the length,
     * we calculate it manually.
     */
    private static int getInputDataLength(ByteBuffer buffer) {
        int position = buffer.position();
        // We assume that the data length is always divisable by 4.
        for (int i = 0; i < position; i += 4) {
            // Since Code Index Number 0 is reserved, it is not a valid USB-MIDI data.
            if (buffer.get(i) == 0) {
                return i;
            }
        }
        return position;
    }

    @NativeMethods
    interface Natives {
        void onData(long nativeUsbMidiDeviceAndroid, int endpointNumber, byte[] data);
    }
}
