package de.usbserver.bridge

import android.content.Context
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbManager
import android.net.LocalSocket
import android.net.LocalSocketAddress
import android.util.Log
import org.json.JSONObject
import java.io.File

/**
 * Hands USB devices to the usb-client process.
 *
 * An Android app cannot open /dev/bus/usb, so the client cannot find devices
 * by itself. This class obtains the file descriptor through UsbManager — which
 * is what the permission dialog is for — and passes it over a Unix socket
 * using ancillary data, along with the raw descriptors the client needs to
 * understand the device.
 *
 * Connections are kept open per handover: the descriptor stays valid only as
 * long as the UsbDeviceConnection is alive, so this class holds on to them.
 */
class UsbBridge(
    private val context: Context,
    private val socketPath: String,
) {
    private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager

    /** Open connections, keyed by bus ID. Closing one invalidates its descriptor. */
    private val connections = mutableMapOf<String, UsbDeviceConnection>()

    /**
     * Offers a device to the client.
     *
     * The caller must already hold permission for it — see
     * [UsbManager.requestPermission]. Without permission openDevice returns
     * null and this fails.
     */
    fun share(device: UsbDevice): Result<Unit> {
        if (!usbManager.hasPermission(device)) {
            return Result.failure(
                IllegalStateException("no permission for ${device.deviceName}; request it first")
            )
        }

        val connection = usbManager.openDevice(device)
            ?: return Result.failure(IllegalStateException("could not open ${device.deviceName}"))

        val busId = busIdOf(device)

        return try {
            val request = JSONObject().apply {
                put("action", "add")
                put("bus_id", busId)
                // getRawDescriptors returns exactly what a usbdevfs read
                // returns: device descriptor followed by all configurations.
                put("descriptors", android.util.Base64.encodeToString(
                    connection.rawDescriptors, android.util.Base64.NO_WRAP))
                put("bus_num", busNumberOf(device))
                put("dev_num", deviceNumberOf(device))
                put("speed", speedOf(device))
                put("config_value", configValueOf(device))
                put("manufacturer", device.manufacturerName ?: "")
                put("product", device.productName ?: "")
                put("serial", serialOf(device, connection))
            }

            sendRequest(request, connection.fileDescriptor)

            // Keep the connection open: closing it would close the descriptor
            // the client is now using.
            connections[busId]?.close()
            connections[busId] = connection

            Log.i(TAG, "shared $busId (${device.manufacturerName} ${device.productName})")
            Result.success(Unit)
        } catch (e: Exception) {
            connection.close()
            Result.failure(e)
        }
    }

    /** Withdraws a device and closes its connection. */
    fun unshare(device: UsbDevice): Result<Unit> {
        val busId = busIdOf(device)

        return try {
            val request = JSONObject().apply {
                put("action", "remove")
                put("bus_id", busId)
            }
            sendRequest(request, fd = -1)

            connections.remove(busId)?.close()
            Log.i(TAG, "withdrew $busId")
            Result.success(Unit)
        } catch (e: Exception) {
            connections.remove(busId)?.close()
            Result.failure(e)
        }
    }

    /** Closes every open connection. Call when the service stops. */
    fun closeAll() {
        connections.values.forEach { it.close() }
        connections.clear()
    }

    /**
     * Sends one request, attaching fd as ancillary data when it is valid.
     *
     * LocalSocket's setFileDescriptorsForSend is Android's SCM_RIGHTS: the
     * descriptor is duplicated into the receiving process, which is the only
     * way to give the client access to a device it cannot open itself.
     */
    private fun sendRequest(request: JSONObject, fd: Int) {
        LocalSocket().use { socket ->
            socket.connect(LocalSocketAddress(socketPath, LocalSocketAddress.Namespace.FILESYSTEM))

            if (fd >= 0) {
                socket.setFileDescriptorsForSend(arrayOf(fdToFileDescriptor(fd)))
            }

            socket.outputStream.write(request.toString().toByteArray())
            socket.outputStream.flush()

            val buffer = ByteArray(4096)
            val n = socket.inputStream.read(buffer)
            if (n <= 0) {
                throw IllegalStateException("client closed the connection without replying")
            }

            val response = JSONObject(String(buffer, 0, n))
            if (!response.optBoolean("ok", false)) {
                throw IllegalStateException(response.optString("error", "client rejected the device"))
            }
        }
    }

    /**
     * Wraps a raw descriptor number in a FileDescriptor.
     *
     * FileDescriptor's int constructor is not public API, so this goes through
     * reflection. It is the same approach every library that needs to pass
     * descriptors on Android takes; if a future release blocks it, the
     * alternative is a small JNI shim.
     */
    private fun fdToFileDescriptor(fd: Int): java.io.FileDescriptor {
        val descriptor = java.io.FileDescriptor()
        val field = java.io.FileDescriptor::class.java.getDeclaredField("descriptor")
        field.isAccessible = true
        field.setInt(descriptor, fd)
        return descriptor
    }

    /**
     * Derives a stable bus ID.
     *
     * Android device names look like "/dev/bus/usb/001/002". The client uses
     * this string to identify the device to peers, so it has to stay the same
     * for as long as the device is plugged in.
     */
    private fun busIdOf(device: UsbDevice): String {
        val parts = device.deviceName.trim('/').split("/")
        return if (parts.size >= 2) {
            val bus = parts[parts.size - 2].trimStart('0').ifEmpty { "0" }
            val dev = parts[parts.size - 1].trimStart('0').ifEmpty { "0" }
            "$bus-$dev"
        } else {
            device.deviceId.toString()
        }
    }

    private fun busNumberOf(device: UsbDevice): Int =
        device.deviceName.trim('/').split("/").let { parts ->
            parts.getOrNull(parts.size - 2)?.toIntOrNull() ?: 0
        }

    private fun deviceNumberOf(device: UsbDevice): Int =
        device.deviceName.trim('/').split("/").lastOrNull()?.toIntOrNull() ?: 0

    /** Maps Android's speed constants onto the USB/IP speed codes. */
    private fun speedOf(device: UsbDevice): Int {
        // UsbDevice exposes no speed before API 29, and even then only
        // indirectly. High speed is the safe assumption: it is what almost
        // everything an OTG port sees actually runs at, and the value is only
        // advisory on the receiving end.
        return 3
    }

    private fun configValueOf(device: UsbDevice): Int {
        // The framework activates configuration 1 on open; devices with more
        // than one configuration are vanishingly rare in practice.
        return 1
    }

    private fun serialOf(device: UsbDevice, connection: UsbDeviceConnection): String =
        try {
            connection.serial ?: ""
        } catch (e: SecurityException) {
            // Reading the serial needs permission the app may not hold; it is
            // cosmetic, so carry on without it.
            ""
        }

    companion object {
        private const val TAG = "UsbBridge"

        /** Default socket path inside the app's private directory. */
        fun defaultSocketPath(context: Context): String =
            File(context.filesDir, "bridge.sock").absolutePath
    }
}
