package com.lazy.emulate.input

import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.input.InputManager
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.os.Build
import android.util.Log
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

private const val TAG = "ControllerManager"

// Nintendo USB/BT vendor id — Joy-Cons, Pro Controller, NES/SNES Online controllers
private const val VENDOR_NINTENDO = 0x057E

// 8BitDo USB vendor id — adapter exposes a variety of products under this VID
// depending on current adapter mode (XInput/DInput/macOS/PS Classic).
private const val VENDOR_8BITDO = 0x2DC8

// Max lines kept in the in-app raw event log (visible on ControllerTestScreen).
private const val MAX_EVENT_LOG_LINES = 300

data class AnalogSnapshot(
    val lx: Float = 0f,
    val ly: Float = 0f,
    val rx: Float = 0f,
    val ry: Float = 0f,
    val hatX: Float = 0f,
    val hatY: Float = 0f
)

data class UsbDeviceInfo(
    val deviceName: String,
    val vendorId: Int,
    val productId: Int,
    val deviceClass: Int,
    val deviceSubclass: Int,
    val productName: String?,
    val manufacturerName: String?,
    val interfaces: List<UsbInterfaceInfo>
)

data class UsbInterfaceInfo(
    val number: Int,
    val interfaceClass: Int,
    val interfaceSubclass: Int,
    val interfaceProtocol: Int,
    val endpointCount: Int
)

class ControllerManager(
    context: Context,
    var buttonMappingManager: ButtonMappingManager? = null
) : InputManager.InputDeviceListener {

    private val appContext = context.applicationContext
    private val inputManager = appContext.getSystemService(Context.INPUT_SERVICE) as InputManager
    private val usbManager = appContext.getSystemService(Context.USB_SERVICE) as UsbManager

    private val usbPermissionAction = "${appContext.packageName}.USB_PERMISSION"

    private val usbPermissionReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            if (intent?.action != usbPermissionAction) return
            @Suppress("DEPRECATION")
            val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
            val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
            appendEventLog("USB permission result: granted=$granted device=${device?.deviceName}")
            if (granted && device != null) {
                onUsbDeviceAttached(device)
            }
        }
    }
    private var usbPermissionReceiverRegistered = false

    private val _usbDevices = MutableStateFlow<List<UsbDeviceInfo>>(emptyList())
    val usbDevices: StateFlow<List<UsbDeviceInfo>> = _usbDevices.asStateFlow()

    // USB claim experiment: when a target device is attached via the USB_DEVICE_ATTACHED
    // intent, we detach the kernel usbhid driver by calling claimInterface(force=true) and
    // hold the claim until release(). Theory: the adapter's mode-cycling is triggered by the
    // kernel driver's repeated probes. Holding the interface claim should stop the cycle.
    private data class ClaimedDevice(
        val device: UsbDevice,
        val connection: UsbDeviceConnection,
        val interfaces: List<UsbInterface>
    )
    private var claimedDevice: ClaimedDevice? = null

    // Once we successfully claim any target device, refuse all subsequent attach events.
    // The adapter cycles modes rapidly when Android can't complete an Xinput handshake;
    // without this flag, each cycle would spawn a new reader thread and eventually OOM.
    private var hasActiveClaim = false

    // Xinput reader state — runs on a background thread reading 20-byte reports from the
    // Xbox-360-style interrupt IN endpoint on interface #0, parsing them, and dispatching
    // GamepadButton / analog events into the normal event pipeline.
    private var xinputReaderThread: Thread? = null
    private val xinputReaderStop = AtomicBoolean(false)
    private var xinputPrevButtons = 0
    private var xinputPrevL2 = false
    private var xinputPrevR2 = false

    // Xinput button bit layout
    private val XINPUT_DPAD_UP = 0x0001
    private val XINPUT_DPAD_DOWN = 0x0002
    private val XINPUT_DPAD_LEFT = 0x0004
    private val XINPUT_DPAD_RIGHT = 0x0008
    private val XINPUT_START = 0x0010
    private val XINPUT_BACK = 0x0020
    private val XINPUT_LTHUMB = 0x0040
    private val XINPUT_RTHUMB = 0x0080
    private val XINPUT_LB = 0x0100
    private val XINPUT_RB = 0x0200
    private val XINPUT_GUIDE = 0x0400
    private val XINPUT_A = 0x1000
    private val XINPUT_B = 0x2000
    private val XINPUT_X = 0x4000
    private val XINPUT_Y = 0x8000

    private val xinputBitToButton: List<Pair<Int, GamepadButton>> = listOf(
        XINPUT_DPAD_UP to GamepadButton.DPAD_UP,
        XINPUT_DPAD_DOWN to GamepadButton.DPAD_DOWN,
        XINPUT_DPAD_LEFT to GamepadButton.DPAD_LEFT,
        XINPUT_DPAD_RIGHT to GamepadButton.DPAD_RIGHT,
        XINPUT_START to GamepadButton.START,
        XINPUT_BACK to GamepadButton.SELECT,
        XINPUT_LTHUMB to GamepadButton.L3,
        XINPUT_RTHUMB to GamepadButton.R3,
        XINPUT_LB to GamepadButton.L1,
        XINPUT_RB to GamepadButton.R1,
        XINPUT_A to GamepadButton.FACE_BOTTOM,
        XINPUT_B to GamepadButton.FACE_RIGHT,
        XINPUT_X to GamepadButton.FACE_LEFT,
        XINPUT_Y to GamepadButton.FACE_TOP
    )

    // Debounce: only request USB permission once per vid/pid per session. The cycling adapter
    // generates ATTACH broadcasts ~5/sec, which otherwise floods Android with permission
    // dialogs and OOMs the app.
    private val permissionRequestedFor = mutableSetOf<String>()

    // Also rate-limit onUsbDeviceAttached itself — the broadcast can fire dozens of times per
    // second. Drop calls that arrive within 500 ms of the previous one for the same vid/pid.
    private val lastAttachTimeByKey = mutableMapOf<String, Long>()
    private val attachThrottleMs = 500L

    private val targetVidPids: Set<Pair<Int, Int>> = setOf(
        0x2DC8 to 0x3106, // 8BitDo Pro 2 Wired — D-input
        0x2DC8 to 0x3107, // 8BitDo IDLE state
        0x2DC8 to 0x3105, // 8BitDo ...?
        0x045E to 0x028E, // Xbox 360 controller — X-input
        0x045E to 0x02e0, // Xbox 360 controller — also X-input
        0x054C to 0x05C4  // Sony DualShock 4 — what the adapter latched to on macOS
    )

    // ESP32 Joy-Con bridge serial VID/PIDs. When we see one of these, we open it as a
    // serial port and read the 11-byte wire protocol frames (see docs/joycon-esp32-bridge.md).
    private val espSerialVidPids: Set<Pair<Int, Int>> = setOf(
        0x10C4 to 0xEA60,  // Silicon Labs CP2102 (most ESP32-WROOM-32 boards)
        0x1A86 to 0x7523,  // WCH CH340 (some ESP32 boards)
        0x303A to 0x1001,  // Espressif native USB (ESP32-S3 etc)
    )

    // ESP32 serial reader state
    private var espSerialPort: UsbSerialPort? = null
    private var espSerialThread: Thread? = null
    private val espSerialStop = AtomicBoolean(false)
    private var espPrevBtnsLo = 0
    private var espPrevBtnsHi = 0
    private var espPrevDpad = 8 // 8 = neutral

    private fun isTargetDevice(device: UsbDevice): Boolean =
        (device.vendorId to device.productId) in targetVidPids ||
        (device.vendorId to device.productId) in espSerialVidPids

    /** Sweep already-connected USB devices — called on resume. */
    fun tryClaimAlreadyConnectedUsb() {
        for (device in usbManager.deviceList.values) {
            if (isTargetDevice(device) && claimedDevice?.device?.deviceName != device.deviceName) {
                appendEventLog("USB sweep found target device ${device.deviceName}, attaching")
                onUsbDeviceAttached(device)
            }
        }
    }

    fun onUsbDeviceAttached(device: UsbDevice) {
        // Ignore USB events that aren't our target adapter.
        if (!isTargetDevice(device)) {
            Log.d(TAG, "ignoring non-target USB device v=${device.vendorId} p=${device.productId}")
            return
        }

        // One claim per session. The cycling adapter otherwise thrashes threads and OOMs.
        if (hasActiveClaim) {
            Log.d(TAG, "hasActiveClaim=true, ignoring new attach for ${device.deviceName}")
            return
        }

        // If we already hold a claim on this exact device, nothing to do.
        if (claimedDevice?.device?.deviceName == device.deviceName) {
            Log.d(TAG, "already claimed ${device.deviceName}, ignoring re-attach")
            return
        }

        // Throttle: the cycling adapter fires ATTACH broadcasts every ~400 ms.
        // Drop duplicate calls within the throttle window to avoid flooding the system.
        val key = "${device.vendorId}:${device.productId}"
        val now = System.currentTimeMillis()
        val last = lastAttachTimeByKey[key] ?: 0L
        if (now - last < attachThrottleMs) {
            Log.d(TAG, "throttled re-attach for $key (${now - last}ms since last)")
            return
        }
        lastAttachTimeByKey[key] = now

        appendEventLog(
            "USB ATTACH '${device.productName ?: device.deviceName}' " +
                "v=0x${"%04x".format(device.vendorId)} p=0x${"%04x".format(device.productId)}"
        )
        Log.d(TAG, "onUsbDeviceAttached ${device.deviceName} v=${device.vendorId} p=${device.productId}")

        // Enumerate interfaces once so we can decide whether to claim.
        val allIfaces = (0 until device.interfaceCount).map { device.getInterface(it) }
        for (i in allIfaces) {
            appendEventLog(
                "  iface #${i.id} class=${i.interfaceClass}(${usbClassName(i.interfaceClass)}) " +
                    "sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} eps=${i.endpointCount}"
            )
        }

        // If any interface is standard HID (class=3), this is an 8BitDo in DInput / macOS /
        // PS-Classic mode. On Samsung Fold 6, the kernel's usbhid driver ONLY binds the
        // PS-Classic variant (vid=0x054c pid=0x0cda, Sony Interactive Entertainment Controller)
        // — DInput and macOS modes are silently refused. In PS-Classic mode Android creates
        // a proper GAMEPAD InputDevice and events flow through dispatchKeyEvent → handleKeyEvent.
        //
        // Switch the adapter to PS-Classic mode with Select + D-Pad Down (3 seconds).
        //
        // In any HID case we do NOT claim the interface: claiming detaches whatever kernel
        // driver may have bound and turns a working controller into a dead one.
        val hasHidInterface = allIfaces.any { it.interfaceClass == UsbConstants.USB_CLASS_HID }
        if (hasHidInterface) {
            appendEventLog(
                "USB device has HID interface — letting Android's usbhid driver handle it " +
                    "(no claim). Use PS-Classic mode (Select+Down) if events don't appear."
            )
            hasActiveClaim = true
            refreshUsbDevices()
            return
        }

        // ESP32 Joy-Con bridge: detect known USB-serial chips and open as a serial port.
        val vidPid = device.vendorId to device.productId
        if (vidPid in espSerialVidPids) {
            appendEventLog("ESP32 serial device detected — opening as Joy-Con bridge")
            releaseClaim()
            startEspSerialReader(device)
            return
        }

        // Release any prior claim (e.g. the adapter just re-enumerated in a new mode).
        releaseClaim()

        if (!usbManager.hasPermission(device)) {
            // Only request permission once per vid/pid — flooding Android with dialogs
            // causes OOM.
            if (key in permissionRequestedFor) {
                appendEventLog("USB permission still missing, already requested once — skipping")
                return
            }
            permissionRequestedFor.add(key)
            appendEventLog("USB permission missing — requesting (one-shot)")
            val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
            } else {
                PendingIntent.FLAG_UPDATE_CURRENT
            }
            val pi = PendingIntent.getBroadcast(
                appContext,
                0,
                Intent(usbPermissionAction).apply { setPackage(appContext.packageName) },
                flags
            )
            try {
                usbManager.requestPermission(device, pi)
            } catch (t: Throwable) {
                appendEventLog("requestPermission threw: ${t.message}")
            }
            return
        }

        val connection = try {
            usbManager.openDevice(device)
        } catch (t: Throwable) {
            appendEventLog("USB openDevice threw: ${t.message}")
            return
        }
        if (connection == null) {
            appendEventLog("USB openDevice returned null")
            return
        }

        // Only claim the main controller input interface (vendor-spec, subclass 0x5D,
        // protocol 0x01). Claiming the headset / security interfaces appears to trigger
        // re-enumeration on the adapter.
        val mainIface = allIfaces.firstOrNull {
            it.interfaceClass == 0xFF && it.interfaceSubclass == 0x5D && it.interfaceProtocol == 0x01
        } ?: allIfaces.firstOrNull()

        if (mainIface == null) {
            appendEventLog("USB no interfaces to claim, closing")
            connection.close()
            return
        }

        val ok = try {
            connection.claimInterface(mainIface, true)
        } catch (t: Throwable) {
            appendEventLog("claimInterface #${mainIface.id} threw: ${t.message}")
            false
        }
        appendEventLog(
            "USB claimInterface #${mainIface.id} " +
                "class=${mainIface.interfaceClass} sub=${mainIface.interfaceSubclass} " +
                "proto=${mainIface.interfaceProtocol} → ${if (ok) "OK (kernel detached)" else "FAIL"}"
        )
        if (!ok) {
            connection.close()
            return
        }

        claimedDevice = ClaimedDevice(device, connection, listOf(mainIface))
        hasActiveClaim = true
        appendEventLog("USB holding claim on 1 interface.")
        startXinputReader(connection, listOf(mainIface))
        // Refresh the in-app USB list so the UI reflects the new state.
        refreshUsbDevices()
    }

    fun releaseClaim() {
        // Stop ESP32 serial reader if active.
        stopEspSerialReader()

        val c = claimedDevice ?: return
        appendEventLog("USB releasing claim on '${c.device.productName ?: c.device.deviceName}'")

        // Close the connection BEFORE joining the reader thread — this unblocks any
        // in-flight bulkTransfer call so the thread can actually exit.
        xinputReaderStop.set(true)
        for (iface in c.interfaces) {
            try {
                c.connection.releaseInterface(iface)
            } catch (t: Throwable) {
                Log.w(TAG, "releaseInterface failed", t)
            }
        }
        try {
            c.connection.close()
        } catch (t: Throwable) {
            Log.w(TAG, "connection.close failed", t)
        }

        // Now that the connection is dead, the reader's bulkTransfer will fail fast.
        stopXinputReader()

        claimedDevice = null
        hasActiveClaim = false
        // Clear any buttons left pressed by the reader thread.
        _pressedButtons.value = emptySet()
        _analogSnapshot.value = AnalogSnapshot()
        xinputPrevButtons = 0
        xinputPrevL2 = false
        xinputPrevR2 = false
    }

    /**
     * Locate the interrupt IN endpoint on the Xbox-360-style main controller interface
     * (class 0xFF / subclass 0x5D / protocol 0x01) and spin up a reader thread.
     */
    private fun startXinputReader(connection: UsbDeviceConnection, interfaces: List<UsbInterface>) {
        // Main controller interface: vendor-specific, subclass 0x5D, protocol 0x01.
        val mainIface = interfaces.firstOrNull {
            it.interfaceClass == 0xFF && it.interfaceSubclass == 0x5D && it.interfaceProtocol == 0x01
        } ?: interfaces.firstOrNull()
        if (mainIface == null) {
            appendEventLog("Xinput reader: no suitable interface")
            return
        }
        var inEndpoint: UsbEndpoint? = null
        var outEndpoint: UsbEndpoint? = null
        for (i in 0 until mainIface.endpointCount) {
            val ep = mainIface.getEndpoint(i)
            if (ep.direction == UsbConstants.USB_DIR_IN &&
                (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT || ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK)
            ) {
                inEndpoint = ep
            }
            if (ep.direction == UsbConstants.USB_DIR_OUT &&
                (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT || ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK)
            ) {
                outEndpoint = ep
            }
        }
        if (inEndpoint == null) {
            appendEventLog("Xinput reader: no IN endpoint on iface #${mainIface.id}")
            return
        }
        appendEventLog(
            "Xinput reader starting on iface #${mainIface.id} " +
                "endpoint addr=0x${"%02x".format(inEndpoint.address)} " +
                "maxPkt=${inEndpoint.maxPacketSize}"
        )

        // Xbox 360 wired controllers need an LED init before they emit reports.
        // The real command is an interrupt OUT write (NOT a HID SET_REPORT), so we use
        // bulkTransfer against the OUT endpoint — UsbDeviceConnection.bulkTransfer handles
        // both bulk and interrupt endpoints.
        if (outEndpoint != null) {
            // LED pattern 0x06 = player 1 blinking → solid
            val ledCommand = byteArrayOf(0x01, 0x03, 0x06)
            val sent = connection.bulkTransfer(outEndpoint, ledCommand, ledCommand.size, 100)
            appendEventLog("Xinput LED init bulkTransfer(OUT) → $sent bytes")
        }

        xinputPrevButtons = 0
        xinputPrevL2 = false
        xinputPrevR2 = false
        xinputReaderStop.set(false)
        val ep = inEndpoint
        xinputReaderThread = thread(name = "XinputReader", isDaemon = true) {
            val buf = ByteArray(32)
            var consecutiveErrors = 0
            var totalPackets = 0
            try {
                while (!xinputReaderStop.get()) {
                    val n = try {
                        connection.bulkTransfer(ep, buf, buf.size, 200)
                    } catch (t: Throwable) {
                        Log.w(TAG, "bulkTransfer threw", t)
                        -1
                    }
                    when {
                        n >= 20 && buf[0].toInt() == 0x00 && buf[1].toInt() == 0x14 -> {
                            consecutiveErrors = 0
                            totalPackets++
                            if (totalPackets == 1) {
                                Log.d(TAG, "XinputReader: first valid packet")
                            }
                            parseXinputReport(buf)
                        }
                        n >= 0 -> {
                            // Short / non-input packet (heartbeat, LED status, etc).
                            consecutiveErrors = 0
                        }
                        else -> {
                            consecutiveErrors++
                            // After sustained failures the device is gone. Exit the thread
                            // instead of spinning forever.
                            if (consecutiveErrors > 100) {
                                Log.w(TAG, "XinputReader: $consecutiveErrors consecutive errors, giving up")
                                break
                            }
                        }
                    }
                }
            } finally {
                Log.d(TAG, "XinputReader thread exiting (packets=$totalPackets)")
            }
        }
    }

    private fun stopXinputReader() {
        if (xinputReaderThread == null) return
        xinputReaderStop.set(true)
        try {
            xinputReaderThread?.join(500)
        } catch (_: InterruptedException) {}
        xinputReaderThread = null
    }

    // ---- ESP32 Joy-Con Bridge (USB serial) ----

    /**
     * Open an ESP32 USB-serial device, configure it at 115200 8N1, and spin up a reader
     * thread that consumes the 11-byte wire protocol frames from the Joy-Con bridge firmware.
     */
    private fun startEspSerialReader(device: UsbDevice) {
        if (!usbManager.hasPermission(device)) {
            val key = "${device.vendorId}:${device.productId}"
            if (key in permissionRequestedFor) {
                appendEventLog("ESP32 serial: permission still missing, already requested — skipping")
                return
            }
            permissionRequestedFor.add(key)
            appendEventLog("ESP32 serial: requesting USB permission")
            val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
            } else {
                PendingIntent.FLAG_UPDATE_CURRENT
            }
            val pi = PendingIntent.getBroadcast(
                appContext, 0,
                Intent(usbPermissionAction).apply { setPackage(appContext.packageName) },
                flags
            )
            try { usbManager.requestPermission(device, pi) } catch (t: Throwable) {
                appendEventLog("ESP32 serial: requestPermission threw: ${t.message}")
            }
            return
        }

        // Use usb-serial-for-android's prober to auto-detect the serial chip (CP2102/CH340/CDC).
        val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
        if (driver == null) {
            appendEventLog("ESP32 serial: no serial driver matched for ${device.productName}")
            return
        }
        val port = driver.ports.firstOrNull()
        if (port == null) {
            appendEventLog("ESP32 serial: driver has no ports")
            return
        }
        val connection = usbManager.openDevice(device)
        if (connection == null) {
            appendEventLog("ESP32 serial: openDevice returned null")
            return
        }
        try {
            port.open(connection)
            port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
        } catch (t: Throwable) {
            appendEventLog("ESP32 serial: port.open/setParameters threw: ${t.message}")
            try { port.close() } catch (_: Throwable) {}
            return
        }

        espSerialPort = port
        hasActiveClaim = true
        espPrevBtnsLo = 0
        espPrevBtnsHi = 0
        espPrevDpad = 8
        espSerialStop.set(false)

        appendEventLog("ESP32 serial: opened ${driver.javaClass.simpleName} at 115200 baud")

        espSerialThread = thread(name = "EspSerialReader", isDaemon = true) {
            val buf = ByteArray(128)
            var totalFrames = 0
            var loggedSamples = 0
            try {
                while (!espSerialStop.get()) {
                    val n = try {
                        port.read(buf, 200) // 200 ms timeout
                    } catch (t: Throwable) {
                        if (!espSerialStop.get()) {
                            Log.w(TAG, "ESP serial read threw", t)
                        }
                        -1
                    }
                    if (n <= 0) continue

                    // Scan the read buffer for complete 11-byte frames starting with 0xA5.
                    // At 60 Hz / 11 bytes per frame / 115200 baud, each read() typically
                    // returns 1-3 complete frames. Partial frames at boundaries are rare
                    // and we just drop them (the next read picks up the next sync byte).
                    var offset = 0
                    while (offset + ESP_FRAME_SIZE <= n) {
                        if (buf[offset].toInt() and 0xFF == ESP_SYNC_BYTE) {
                            // Validate CRC (XOR of bytes 0..9)
                            var xor = 0
                            for (j in 0 until ESP_FRAME_SIZE - 1) {
                                xor = xor xor (buf[offset + j].toInt() and 0xFF)
                            }
                            val expectedCrc = buf[offset + ESP_FRAME_SIZE - 1].toInt() and 0xFF
                            if (xor == expectedCrc) {
                                totalFrames++
                                if (loggedSamples < 5) {
                                    val hex = (0 until ESP_FRAME_SIZE).joinToString(" ") {
                                        "%02x".format(buf[offset + it])
                                    }
                                    appendEventLog("ESP frame[$totalFrames]: $hex")
                                    loggedSamples++
                                }
                                parseEspFrame(buf, offset)
                                offset += ESP_FRAME_SIZE
                            } else {
                                // Bad CRC — skip this byte and resync
                                offset++
                            }
                        } else {
                            // Not a sync byte — advance until we find one
                            offset++
                        }
                    }
                }
            } finally {
                Log.d(TAG, "EspSerialReader thread exiting (frames=$totalFrames)")
            }
        }
        refreshUsbDevices()
    }

    private fun stopEspSerialReader() {
        espSerialStop.set(true)
        try {
            espSerialPort?.close()
        } catch (_: Throwable) {}
        try {
            espSerialThread?.join(500)
        } catch (_: InterruptedException) {}
        espSerialPort = null
        espSerialThread = null
        espPrevBtnsLo = 0
        espPrevBtnsHi = 0
        espPrevDpad = 8
    }

    /**
     * Parse one 11-byte ESP32 Joy-Con bridge frame and dispatch button/analog events.
     *
     * Wire format (see docs/joycon-esp32-bridge.md):
     *   [0]  0xA5  sync
     *   [1]  seq   rolling counter
     *   [2]  btns_lo: bit0=A(FACE_BOTTOM) bit1=B(FACE_RIGHT) bit2=X(FACE_LEFT)
     *                 bit3=Y(FACE_TOP) bit4=L1 bit5=R1 bit6=L2 bit7=R2
     *   [3]  btns_hi: bit0=START bit1=SELECT bit2=L3 bit3=R3 bit4=HOME(MODE) bit5=CAPTURE
     *   [4]  dpad: low nibble = hat direction 0-7 clockwise from N, 8=none
     *   [5]  lx  signed int8
     *   [6]  ly  signed int8
     *   [7]  rx  signed int8
     *   [8]  ry  signed int8
     *   [9]  flags
     *   [10] crc8 (XOR of bytes 0-9)
     */
    private fun parseEspFrame(buf: ByteArray, off: Int) {
        val btnsLo = buf[off + 2].toInt() and 0xFF
        val btnsHi = buf[off + 3].toInt() and 0xFF
        val dpad = buf[off + 4].toInt() and 0x0F
        val lx = buf[off + 5].toInt() // signed byte → int preserving sign
        val ly = buf[off + 6].toInt()
        val rx = buf[off + 7].toInt()
        val ry = buf[off + 8].toInt()

        // Button diff + dispatch — btns_lo
        if (btnsLo != espPrevBtnsLo) {
            val changed = btnsLo xor espPrevBtnsLo
            for ((bit, btn) in ESP_BTNS_LO_MAP) {
                if (changed and bit != 0) dispatchButton(btn, (btnsLo and bit) != 0)
            }
            espPrevBtnsLo = btnsLo
        }

        // btns_hi
        if (btnsHi != espPrevBtnsHi) {
            val changed = btnsHi xor espPrevBtnsHi
            for ((bit, btn) in ESP_BTNS_HI_MAP) {
                if (changed and bit != 0) dispatchButton(btn, (btnsHi and bit) != 0)
            }
            espPrevBtnsHi = btnsHi
        }

        // Dpad (hat switch)
        if (dpad != espPrevDpad) {
            val active = dpad <= 7
            val up    = active && (dpad == 0 || dpad == 1 || dpad == 7)
            val right = active && (dpad in 1..3)
            val down  = active && (dpad in 3..5)
            val left  = active && (dpad in 5..7)
            dispatchButton(GamepadButton.DPAD_UP, up)
            dispatchButton(GamepadButton.DPAD_RIGHT, right)
            dispatchButton(GamepadButton.DPAD_DOWN, down)
            dispatchButton(GamepadButton.DPAD_LEFT, left)
            espPrevDpad = dpad
        }

        // Analog sticks — signed int8 → float [-1, 1], Y negated for Android convention
        val nLx =  lx / 127f
        val nLy = -ly / 127f
        val nRx =  rx / 127f
        val nRy = -ry / 127f
        onAnalogEvent?.invoke(nLx, nLy, 0)
        onAnalogEvent?.invoke(nRx, nRy, 1)
        _analogSnapshot.value = AnalogSnapshot(nLx, nLy, nRx, nRy, 0f, 0f)
    }

    companion object {
        private const val ESP_SYNC_BYTE = 0xA5
        private const val ESP_FRAME_SIZE = 11

        private val ESP_BTNS_LO_MAP = listOf(
            0x01 to GamepadButton.FACE_BOTTOM,   // A / Cross
            0x02 to GamepadButton.FACE_RIGHT,    // B / Circle
            0x04 to GamepadButton.FACE_LEFT,     // X / Square
            0x08 to GamepadButton.FACE_TOP,      // Y / Triangle
            0x10 to GamepadButton.L1,
            0x20 to GamepadButton.R1,
            0x40 to GamepadButton.L2,
            0x80 to GamepadButton.R2,
        )

        private val ESP_BTNS_HI_MAP = listOf(
            0x01 to GamepadButton.START,
            0x02 to GamepadButton.SELECT,
            0x04 to GamepadButton.L3,
            0x08 to GamepadButton.R3,
            0x10 to GamepadButton.MODE,          // Home / PS
        )
    }

    private fun parseXinputReport(buf: ByteArray) {
        // buf[0] = 0x00 (type), buf[1] = 0x14 (len)
        val buttons = (buf[2].toInt() and 0xFF) or ((buf[3].toInt() and 0xFF) shl 8)
        val lTrigger = buf[4].toInt() and 0xFF
        val rTrigger = buf[5].toInt() and 0xFF
        val lx = readInt16LE(buf, 6)
        val ly = readInt16LE(buf, 8)
        val rx = readInt16LE(buf, 10)
        val ry = readInt16LE(buf, 12)

        // Button diff + dispatch
        val changed = buttons xor xinputPrevButtons
        if (changed != 0) {
            for ((bit, gamepadBtn) in xinputBitToButton) {
                if (changed and bit != 0) {
                    val nowDown = (buttons and bit) != 0
                    dispatchButton(gamepadBtn, nowDown)
                }
            }
            xinputPrevButtons = buttons
        }

        // Triggers as discrete L2/R2 with a deadzone threshold
        val l2Down = lTrigger > 30
        val r2Down = rTrigger > 30
        if (l2Down != xinputPrevL2) {
            dispatchButton(GamepadButton.L2, l2Down)
            xinputPrevL2 = l2Down
        }
        if (r2Down != xinputPrevR2) {
            dispatchButton(GamepadButton.R2, r2Down)
            xinputPrevR2 = r2Down
        }

        // Sticks — normalize int16 → float [-1, 1] and negate Y to match Android conventions
        // (Xinput: Y positive = up; Android input: Y positive = down).
        val nLx = lx.toFloat() / 32767f
        val nLy = -ly.toFloat() / 32767f
        val nRx = rx.toFloat() / 32767f
        val nRy = -ry.toFloat() / 32767f

        onAnalogEvent?.invoke(nLx, nLy, 0)
        onAnalogEvent?.invoke(nRx, nRy, 1)
        _analogSnapshot.value = AnalogSnapshot(nLx, nLy, nRx, nRy, 0f, 0f)
    }

    private fun readInt16LE(buf: ByteArray, offset: Int): Int {
        val lo = buf[offset].toInt() and 0xFF
        val hi = buf[offset + 1].toInt()  // preserve sign
        return (hi shl 8) or lo
    }

    private fun dispatchButton(button: GamepadButton, isDown: Boolean) {
        if (isDown) {
            _pressedButtons.value = _pressedButtons.value + button
        } else {
            _pressedButtons.value = _pressedButtons.value - button
        }
        onButtonEvent?.invoke(button, isDown)
    }

    fun refreshUsbDevices() {
        val snapshot = try {
            usbManager.deviceList.values.map { device -> device.toInfo() }
        } catch (t: Throwable) {
            Log.w(TAG, "usbManager.deviceList threw", t)
            emptyList()
        }
        _usbDevices.value = snapshot
        appendEventLog("--- USB enumeration (${snapshot.size} device${if (snapshot.size == 1) "" else "s"}) ---")
        for (d in snapshot) {
            val classStr = usbClassName(d.deviceClass)
            appendEventLog(
                "USB '${d.productName ?: d.deviceName}' " +
                    "v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " +
                    "class=${d.deviceClass}($classStr) sub=${d.deviceSubclass} " +
                    "ifaces=${d.interfaces.size}"
            )
            for (i in d.interfaces) {
                val iclassStr = usbClassName(i.interfaceClass)
                appendEventLog(
                    "  iface #${i.number} class=${i.interfaceClass}($iclassStr) " +
                        "sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} endpoints=${i.endpointCount}"
                )
            }
        }
    }

    private fun UsbDevice.toInfo(): UsbDeviceInfo {
        val interfaces = (0 until interfaceCount).map { idx ->
            val iface = getInterface(idx)
            UsbInterfaceInfo(
                number = iface.id,
                interfaceClass = iface.interfaceClass,
                interfaceSubclass = iface.interfaceSubclass,
                interfaceProtocol = iface.interfaceProtocol,
                endpointCount = iface.endpointCount
            )
        }
        return UsbDeviceInfo(
            deviceName = deviceName,
            vendorId = vendorId,
            productId = productId,
            deviceClass = deviceClass,
            deviceSubclass = deviceSubclass,
            productName = try { productName } catch (t: Throwable) { null },
            manufacturerName = try { manufacturerName } catch (t: Throwable) { null },
            interfaces = interfaces
        )
    }

    private fun usbClassName(c: Int): String = when (c) {
        UsbConstants.USB_CLASS_PER_INTERFACE -> "per-interface"
        UsbConstants.USB_CLASS_AUDIO -> "audio"
        UsbConstants.USB_CLASS_COMM -> "comm"
        UsbConstants.USB_CLASS_HID -> "HID"
        UsbConstants.USB_CLASS_PHYSICA -> "physical"
        UsbConstants.USB_CLASS_STILL_IMAGE -> "still-image"
        UsbConstants.USB_CLASS_PRINTER -> "printer"
        UsbConstants.USB_CLASS_MASS_STORAGE -> "mass-storage"
        UsbConstants.USB_CLASS_HUB -> "hub"
        UsbConstants.USB_CLASS_CDC_DATA -> "cdc-data"
        UsbConstants.USB_CLASS_CSCID -> "smartcard"
        UsbConstants.USB_CLASS_CONTENT_SEC -> "content-sec"
        UsbConstants.USB_CLASS_VIDEO -> "video"
        UsbConstants.USB_CLASS_WIRELESS_CONTROLLER -> "wireless-ctl"
        UsbConstants.USB_CLASS_MISC -> "misc"
        UsbConstants.USB_CLASS_APP_SPEC -> "app-specific"
        UsbConstants.USB_CLASS_VENDOR_SPEC -> "VENDOR-SPEC (xinput?)"
        0 -> "use-iface-class"
        else -> "class-$c"
    }

    private val _connectedControllers = MutableStateFlow<List<GameController>>(emptyList())
    val connectedControllers: StateFlow<List<GameController>> = _connectedControllers.asStateFlow()

    private val _activeController = MutableStateFlow<GameController?>(null)
    val activeController: StateFlow<GameController?> = _activeController.asStateFlow()

    // Raw-event ring buffer shown on ControllerTestScreen. Mirrors the logcat debug output.
    private val _rawEventLog = MutableStateFlow<List<String>>(emptyList())
    val rawEventLog: StateFlow<List<String>> = _rawEventLog.asStateFlow()

    // Currently pressed mapped buttons — drives the live indicator on the test screen.
    private val _pressedButtons = MutableStateFlow<Set<GamepadButton>>(emptySet())
    val pressedButtons: StateFlow<Set<GamepadButton>> = _pressedButtons.asStateFlow()

    // Most recent axis values, for analog readout on the test screen.
    private val _analogSnapshot = MutableStateFlow(AnalogSnapshot())
    val analogSnapshot: StateFlow<AnalogSnapshot> = _analogSnapshot.asStateFlow()

    private val timestampFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)

    var onButtonEvent: ((GamepadButton, Boolean) -> Unit)? = null
    var onAnalogEvent: ((Float, Float, Int) -> Unit)? = null

    fun clearEventLog() {
        _rawEventLog.value = emptyList()
    }

    private fun appendEventLog(line: String) {
        val stamped = "${timestampFormat.format(Date())} $line"
        // Mirror in-app log to logcat so we can see the full claim flow from adb.
        Log.d(TAG, "LOG: $line")
        val current = _rawEventLog.value
        val next = current + stamped
        _rawEventLog.value = if (next.size > MAX_EVENT_LOG_LINES) {
            next.takeLast(MAX_EVENT_LOG_LINES)
        } else {
            next
        }
    }

    fun start() {
        inputManager.registerInputDeviceListener(this, null)
        refreshControllers()
        if (!usbPermissionReceiverRegistered) {
            val filter = IntentFilter(usbPermissionAction)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                appContext.registerReceiver(usbPermissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
            } else {
                @Suppress("UnspecifiedRegisterReceiverFlag")
                appContext.registerReceiver(usbPermissionReceiver, filter)
            }
            usbPermissionReceiverRegistered = true
        }
    }

    fun stop() {
        inputManager.unregisterInputDeviceListener(this)
        if (usbPermissionReceiverRegistered) {
            try { appContext.unregisterReceiver(usbPermissionReceiver) } catch (_: Throwable) {}
            usbPermissionReceiverRegistered = false
        }
    }

    private fun refreshControllers() {
        val allDevices = InputDevice.getDeviceIds()
            .toList()
            .mapNotNull { InputDevice.getDevice(it) }

        // Log every device we see, so we can tell why the Joy-Con is or isn't being accepted.
        appendEventLog("--- device enumeration ---")
        for (d in allDevices) {
            val line = "DEV id=${d.id} '${d.name}' " +
                "v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " +
                "src=0x${"%08x".format(d.sources)} ext=${d.isExternal} ok=${isGameController(d)}"
            Log.d(TAG, line)
            appendEventLog(line)
        }

        val controllers = allDevices
            .filter { isGameController(it) }
            .map { device ->
                GameController(
                    deviceId = device.id,
                    name = device.name,
                    vendorId = device.vendorId,
                    productId = device.productId,
                    isExternal = device.isExternal
                )
            }
        _connectedControllers.value = controllers
        if (_activeController.value == null && controllers.isNotEmpty()) {
            _activeController.value = controllers.first()
        }
    }

    fun handleKeyEvent(event: KeyEvent): Boolean {
        val device = event.device ?: return false

        // Log every key event from every device (both to logcat and the in-app ring buffer)
        // so we can see exactly what a controller emits even when it isn't yet mapped.
        val actionStr = when (event.action) {
            KeyEvent.ACTION_DOWN -> "DOWN"
            KeyEvent.ACTION_UP -> "UP"
            else -> "A${event.action}"
        }
        val keyLine = "KEY $actionStr " +
            "dev=${device.id}('${device.name}') " +
            "v=0x${"%04x".format(device.vendorId)} p=0x${"%04x".format(device.productId)} " +
            "code=${event.keyCode}(${KeyEvent.keyCodeToString(event.keyCode)}) " +
            "scan=${event.scanCode}"
        Log.d(TAG, keyLine)
        appendEventLog(keyLine)

        if (!isGameControllerDevice(device)) return false

        val mapped = mapKeyToButton(event.keyCode) ?: return false
        // Per-device fixups for the 8BitDo USB Adapter 2 in PS-Classic mode (Sony Interactive
        // Entertainment Controller). The adapter routes the NES Joy-Con's buttons through a
        // 6-button-style layout that doesn't match what game cores expect:
        //  - Joy-Con `-` / `+` come through as BTN_TL2/TR2 → KEYCODE_BUTTON_L2/R2. There are
        //    no real triggers on this controller, so reinterpret those as Select/Start —
        //    otherwise the Joy-Con can't send Select/Start at all (Home/Capture get eaten by
        //    the OS via the Consumer Control sibling node).
        //  - Joy-Con B (the bottom-right face button) comes through as BTN_C → BUTTON_C →
        //    C_BUTTON. Most cores have no mapping for C_BUTTON, so remap it to FACE_BOTTOM
        //    so it acts as the "south" face button (NES B / SNES B / PS Cross).
        val rawButton = if (isDpadOnPrimaryAxisDevice(device)) {
            when (mapped) {
                GamepadButton.L2 -> GamepadButton.SELECT
                GamepadButton.R2 -> GamepadButton.START
                GamepadButton.C_BUTTON -> GamepadButton.FACE_BOTTOM
                else -> mapped
            }
        } else mapped
        val button = buttonMappingManager?.remapControllerButton(rawButton) ?: rawButton
        when (event.action) {
            KeyEvent.ACTION_DOWN -> dispatchButton(button, true)
            KeyEvent.ACTION_UP -> dispatchButton(button, false)
        }
        return true
    }

    fun handleMotionEvent(event: MotionEvent): Boolean {
        val device = event.device ?: return false

        val lx = event.getAxisValue(MotionEvent.AXIS_X)
        val ly = event.getAxisValue(MotionEvent.AXIS_Y)
        val rx = event.getAxisValue(MotionEvent.AXIS_Z)
        val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
        val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
        val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
        val anyNonZero = lx != 0f || ly != 0f || rx != 0f || ry != 0f || hatX != 0f || hatY != 0f

        if (anyNonZero) {
            val motionLine = "MOT dev=${device.id}('${device.name}') " +
                "L=(${"%.2f".format(lx)},${"%.2f".format(ly)}) " +
                "R=(${"%.2f".format(rx)},${"%.2f".format(ry)}) " +
                "HAT=(${"%.2f".format(hatX)},${"%.2f".format(hatY)})"
            Log.d(TAG, motionLine)
            appendEventLog(motionLine)
            _analogSnapshot.value = AnalogSnapshot(lx, ly, rx, ry, hatX, hatY)
        }

        if (!isGameControllerDevice(device)) return false

        if (isDpadOnPrimaryAxisDevice(device)) {
            // Sony PS Classic / 8BitDo in PS-Classic mode reports its dpad as raw ABS_X/ABS_Y
            // (range 0..2, normalized to -1..+1) and has no real analog sticks. Route the
            // primary axis to the dpad handler instead of the left stick.
            handleDpadAxis(lx, ly)
        } else {
            if (lx != 0f || ly != 0f) {
                onAnalogEvent?.invoke(lx, ly, 0)
            }
            if (rx != 0f || ry != 0f) {
                onAnalogEvent?.invoke(rx, ry, 1)
            }
            handleDpadAxis(hatX, hatY)
        }

        return true
    }

    /**
     * True for devices whose dpad is reported on AXIS_X/AXIS_Y rather than AXIS_HAT_X/Y,
     * and which have no real analog sticks. Currently: 8BitDo USB Adapter 2 in PS-Classic
     * mode (Select+Down 3s), which impersonates the Sony Interactive Entertainment
     * Controller (vid=0x054C, pid=0x0CDA).
     */
    private fun isDpadOnPrimaryAxisDevice(device: InputDevice): Boolean {
        return device.vendorId == 0x054C && device.productId == 0x0CDA
    }

    private fun handleDpadAxis(hatX: Float, hatY: Float) {
        // Route through dispatchButton so the test screen's _pressedButtons reflects dpad
        // state, the buttonMappingManager remap path applies, and the emulator core sees
        // the events via onButtonEvent — same path as physical key events.
        dispatchButton(GamepadButton.DPAD_LEFT, hatX < -0.5f)
        dispatchButton(GamepadButton.DPAD_RIGHT, hatX > 0.5f)
        dispatchButton(GamepadButton.DPAD_UP, hatY < -0.5f)
        dispatchButton(GamepadButton.DPAD_DOWN, hatY > 0.5f)
    }

    fun setActiveController(controller: GameController) {
        _activeController.value = controller
    }

    private fun mapKeyToButton(keyCode: Int): GamepadButton? {
        return when (keyCode) {
            KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER -> GamepadButton.FACE_BOTTOM
            KeyEvent.KEYCODE_BUTTON_B -> GamepadButton.FACE_RIGHT
            KeyEvent.KEYCODE_BUTTON_C -> GamepadButton.C_BUTTON
            KeyEvent.KEYCODE_BUTTON_X -> GamepadButton.FACE_LEFT
            KeyEvent.KEYCODE_BUTTON_Y -> GamepadButton.FACE_TOP
            KeyEvent.KEYCODE_BUTTON_Z -> GamepadButton.Z_BUTTON
            KeyEvent.KEYCODE_BUTTON_L1 -> GamepadButton.L1
            KeyEvent.KEYCODE_BUTTON_R1 -> GamepadButton.R1
            KeyEvent.KEYCODE_BUTTON_L2 -> GamepadButton.L2
            KeyEvent.KEYCODE_BUTTON_R2 -> GamepadButton.R2
            KeyEvent.KEYCODE_BUTTON_THUMBL -> GamepadButton.L3
            KeyEvent.KEYCODE_BUTTON_THUMBR -> GamepadButton.R3
            KeyEvent.KEYCODE_BUTTON_START -> GamepadButton.START
            KeyEvent.KEYCODE_BUTTON_SELECT -> GamepadButton.SELECT
            KeyEvent.KEYCODE_DPAD_UP -> GamepadButton.DPAD_UP
            KeyEvent.KEYCODE_DPAD_DOWN -> GamepadButton.DPAD_DOWN
            KeyEvent.KEYCODE_DPAD_LEFT -> GamepadButton.DPAD_LEFT
            KeyEvent.KEYCODE_DPAD_RIGHT -> GamepadButton.DPAD_RIGHT
            else -> null
        }
    }

    private fun isGameController(device: InputDevice): Boolean {
        if (device.isVirtual) return false
        val sources = device.sources
        val isGamepad = (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
        val isJoystick = (sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
        if (isGamepad || isJoystick) return true

        // Nintendo Joy-Cons and the NES/SNES Online controllers frequently register as
        // keyboard-only on Android — accept them by vendor id so their events reach us.
        if (device.vendorId == VENDOR_NINTENDO) return true

        // 8BitDo adapters in DInput mode (and bare 8BitDo controllers) sometimes don't
        // get GAMEPAD/JOYSTICK bits set on Samsung OneUI — accept by vendor id.
        if (device.vendorId == VENDOR_8BITDO) return true

        return false
    }

    private fun isGameControllerDevice(device: InputDevice): Boolean = isGameController(device)

    // InputManager.InputDeviceListener
    override fun onInputDeviceAdded(deviceId: Int) = refreshControllers()
    override fun onInputDeviceRemoved(deviceId: Int) {
        if (_activeController.value?.deviceId == deviceId) {
            _activeController.value = null
        }
        refreshControllers()
    }
    override fun onInputDeviceChanged(deviceId: Int) = refreshControllers()
}

data class GameController(
    val deviceId: Int,
    val name: String,
    val vendorId: Int,
    val productId: Int,
    val isExternal: Boolean
)
