BLEManager.kt

package io.github.tritbool.euc.ble.core

import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback as AndroidScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.Companion.PRIVATE
import io.github.tritbool.euc.ble.exceptions.BLEException
import io.github.tritbool.euc.ble.integration.BleBackendEvent
import io.github.tritbool.euc.ble.integration.BleBackendListener
import io.github.tritbool.euc.ble.models.EUCData
import io.github.tritbool.euc.ble.models.EUCDevice
import io.github.tritbool.euc.ble.protocols.CommandSupport
import io.github.tritbool.euc.ble.protocols.CommandType
import io.github.tritbool.euc.ble.protocols.EUCProtocol
import io.github.tritbool.euc.ble.protocols.ProtocolQuerySpec
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds

/**
 * Main BLE manager for Electric Unicycles.
 *
 * This class is responsible for BLE scanning, connection lifecycle management,
 * protocol selection, command transmission, and decoded data delivery.
 *
 * Threading model:
 * - Internal asynchronous work is executed on a background coroutine scope based on
 *   `Dispatchers.IO + SupervisorJob()`.
 * - Public callbacks exposed by this manager (`ConnectionCallback`, `DataCallback`,
 *   and `ErrorCallback`) are not guaranteed to be invoked on the Android main thread.
 * - Callers must explicitly switch to `Dispatchers.Main`, `runOnUiThread`, or an
 *   equivalent UI mechanism before touching Android views.
 *
 * Rationale:
 * - BLE operations, retries, polling, and response timeouts are background work and
 *   should not run on the main thread.
 * - Keeping callback dispatching explicit avoids hiding threading behavior from the
 *   library consumer and keeps the manager usable from non-UI contexts as well.
 */
class BLEManager internal constructor(
    private val context: Context, private val logger: Logger = AndroidLogger(),
    private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
) : BluetoothGattCallback() {
    companion object {
        private const val MIN_QUERY_ATTEMPTS = 1
        private const val MIN_QUERY_TIMEOUT_MS = 200L
    }

    // Configuration
    private var scanTimeout: Long = BLEConstants.DEFAULT_SCAN_TIMEOUT_MS
    private var connectionTimeout: Long = BLEConstants.DEFAULT_CONNECTION_TIMEOUT_MS
    private var autoReconnect: Boolean = true
    private var maxRetries: Int = 3

    // State management
    private var connectionState: BLEConstants.ConnectionState =
        BLEConstants.ConnectionState.DISCONNECTED
    private var currentDevice: EUCDevice? = null
    private var bluetoothGatt: BluetoothGatt? = null
    private var bluetoothAdapter: BluetoothAdapter? = null

    // Reconnection state fields
    private var reconnectRetryCount: Int = 0
    private var reconnectJob: Job? = null
    private var manualDisconnect: Boolean = false
    private val reconnectBaseDelayMs: Long = BLEConstants.RECONNECT_BASE_DELAY_MS
    private val maxReconnectDelayMs: Long = BLEConstants.MAX_RECONNECT_DELAY_MS

    // Protocol management
    @VisibleForTesting(otherwise = PRIVATE)
    internal val protocols: MutableList<EUCProtocol> = mutableListOf()

    @VisibleForTesting(otherwise = PRIVATE)
    internal var currentProtocol: EUCProtocol? = null
    private var metadataMatchedProtocols: List<EUCProtocol> = emptyList()
    private var frameCandidateProtocols: List<EUCProtocol> = emptyList()
    private var queryOrchestrationJob: Job? = null
    private var dataFlowCollectorJob: Job? = null
    private var writeFlowCollectorJob: Job? = null
    private val pendingQueries: MutableMap<String, PendingQueryState> = ConcurrentHashMap()

    // Callbacks
    private var platformScanCallback: AndroidScanCallback? = null
    private var connectionCallback: ConnectionCallback? = null
    private var dataCallback: DataCallback? = null
    private var errorCallback: ErrorCallback? = null

    // Raw frame capture: every raw BLE characteristic notification is emitted here
    private val _rawFrameFlow = MutableSharedFlow<ByteArray>(
        extraBufferCapacity = BLEConstants.DEFAULT_FLOW_BUFFER_CAPACITY, onBufferOverflow = BufferOverflow.DROP_OLDEST
    )

    /**
     * Flow emitting every raw BLE characteristic notification received from the
     * connected device as a defensive copy of the original byte array.
     *
     * This flow can be collected to implement raw frame logging, packet inspection,
     * or custom decoding pipelines independent from the built-in protocol decoder.
     *
     * Threading notes:
     * - Emissions originate from BLE callback handling and therefore must be treated
     *   as background events.
     * - Collectors are responsible for choosing the appropriate collection context.
     *
     * Example:
     * ```kotlin
     * bleManager.rawFrameFlow.collect { bytes ->
     *     outputStream.write(bytes)
     * }
     * ```
     */
    val rawFrameFlow: SharedFlow<ByteArray> = _rawFrameFlow.asSharedFlow()

    private val _queryTraceFlow = MutableSharedFlow<QueryTraceEvent>(
        extraBufferCapacity = BLEConstants.DEFAULT_FLOW_BUFFER_CAPACITY, onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val queryTraceFlow: SharedFlow<QueryTraceEvent> = _queryTraceFlow.asSharedFlow()

    // Coroutine management
    private var scanJob: Job? = null
    private var connectionJob: Job? = null
    private var scanTimeoutJob: Job? = null
    private var connectionTimeoutJob: Job? = null

    private data class PendingQueryState(
        val protocolName: String, val query: ProtocolQuerySpec, val attempt: Int, val sentAtMs: Long
    )

    // Device cache
    private val discoveredDevices = ConcurrentHashMap<String, EUCDevice>()

    /**
     * Initialize the BLE Manager
     */
    fun initialize() {
        logger.info("BLEManager", "Initializing BLE Manager")
        bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
        if (bluetoothAdapter == null) {
            logger.error("BLEManager", "Bluetooth not supported on this device")
            errorCallback?.onError(BLEException("Bluetooth not supported on this device"))
        } else {
            logger.info("BLEManager", "Bluetooth adapter initialized successfully")
        }
    }

    /**
     * Register a protocol for device detection and data processing
     */
    fun registerProtocol(protocol: EUCProtocol) {
        protocols.add(protocol)
    }

    /**
     * Start scanning for EUC devices
     */
    @RequiresApi(Build.VERSION_CODES.M)
    @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
    fun startScan() {
        logger.info("BLEManager", "Starting BLE scan")

        if (connectionState != BLEConstants.ConnectionState.DISCONNECTED) {
            logger.warn("BLEManager", "Cannot scan while connected")
            errorCallback?.onError(BLEException("Cannot scan while connected"))
            return
        }

        if (!isBluetoothEnabled()) {
            logger.error("BLEManager", "Bluetooth is disabled")
            errorCallback?.onError(BLEException("Bluetooth is disabled"))
            return
        }

        discoveredDevices.clear()
        connectionState = BLEConstants.ConnectionState.DISCONNECTED

        scanJob = coroutineScope.launch {
            startBleScan()
        }

        scanTimeoutJob = coroutineScope.launch {
            delay(scanTimeout.milliseconds)
            if (connectionState == BLEConstants.ConnectionState.DISCONNECTED
                && platformScanCallback != null
            ) {
                logger.info("BLEManager", "Scan timeout reached")
                stopScan()
            }
        }
    }

    /**
     * Stop scanning for devices
     */
    @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
    fun stopScan() {
        scanTimeoutJob?.cancel()
        scanTimeoutJob = null
        scanJob?.cancel()
        bluetoothAdapter?.bluetoothLeScanner?.stopScan(platformScanCallback)
        platformScanCallback = null
        connectionCallback?.onScanCompleted(discoveredDevices.values.toList())
    }

    /**
     * Connect to a specific device
     */
    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    fun connect(device: EUCDevice) {
        if (connectionState != BLEConstants.ConnectionState.DISCONNECTED) {
            errorCallback?.onError(BLEException("Already connecting or connected"))
            return
        }

        manualDisconnect = false
        reconnectRetryCount = 0

        currentDevice = device
        connectionState = BLEConstants.ConnectionState.CONNECTING

        // Main connection job
        connectionJob = coroutineScope.launch {
            connectToDevice(device.bluetoothDevice!!)
        }

        connectionTimeoutJob = coroutineScope.launch {
            delay(connectionTimeout.milliseconds)
            if (connectionState == BLEConstants.ConnectionState.CONNECTING) {
                disconnect()
                errorCallback?.onError(BLEException("Connection timeout"))
            }
        }
    }

    /**
     * Disconnect from current device
     */
    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    fun disconnect() {
        manualDisconnect = true
        reconnectJob?.cancel()
        reconnectJob = null
        connectionTimeoutJob?.cancel()
        connectionTimeoutJob = null
        reconnectRetryCount = 0
        cancelPollingOrchestration()
        cancelDataFlowCollection()

        connectionJob?.cancel()
        bluetoothGatt?.disconnect()
        bluetoothGatt?.close()
        bluetoothGatt = null
        currentDevice = null
        currentProtocol = null
        metadataMatchedProtocols = emptyList()
        frameCandidateProtocols = emptyList()
        connectionState = BLEConstants.ConnectionState.DISCONNECTED
        connectionCallback?.onDisconnected()
    }

    /**
     * Send a command to the connected device
     */
    fun getCommandSupport(commandType: CommandType): CommandSupport {
        val protocol = currentProtocol ?: return CommandSupport.UNSUPPORTED
        return protocol.getCommandSupport(commandType)
    }

    fun createCommand(commandType: CommandType, value: Any = Unit): ByteArray {
        val protocol = currentProtocol ?: run {
            errorCallback?.onError(BLEException("No protocol selected; cannot create command"))
            return byteArrayOf()
        }
        if (protocol.getCommandSupport(commandType) == CommandSupport.UNSUPPORTED) {
            errorCallback?.onError(
                BLEException("Unsupported command '$commandType' for protocol ${protocol.manufacturer}")
            )
            return byteArrayOf()
        }
        val payload = protocol.createCommand(commandType, value)
        if (payload.isEmpty()) {
            errorCallback?.onError(
                BLEException("Protocol ${protocol.manufacturer} returned empty payload for '$commandType'")
            )
        }
        return payload
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    fun sendCommand(commandType: CommandType, value: Any = Unit) {
        val protocol = currentProtocol ?: run {
            errorCallback?.onError(BLEException("No protocol selected; cannot send command"))
            return
        }
        val payload = createCommand(commandType, value)
        if (payload.isEmpty()) return
        sendCommand(payload, protocol.getWriteCharacteristicUUID())
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    fun sendCommand(command: ByteArray, characteristicUuid: UUID) {
        if (connectionState != BLEConstants.ConnectionState.CONNECTED) {
            errorCallback?.onError(BLEException("Not connected to a device"))
            return
        }
        if (command.isEmpty()) {
            errorCallback?.onError(BLEException("Cannot send empty command payload"))
            return
        }

        val characteristic = getCharacteristic(characteristicUuid)
        val payload = command.clone() // defensive copy

        characteristic?.let { char ->
            // ensure consistent write type (default)
            char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                // new API 33+ overload
                bluetoothGatt?.writeCharacteristic(char, payload, char.writeType)
            } else {
                // fallback for older APIs
                @Suppress("DEPRECATION") run {
                    char.setValue(payload)
                    bluetoothGatt?.writeCharacteristic(char)
                }
            }
        } ?: run {
            errorCallback?.onError(BLEException("Characteristic not found: $characteristicUuid"))
        }
    }

    /**
     * Get current connection state
     */
    fun getConnectionState(): BLEConstants.ConnectionState = connectionState

    /**
     * Get currently connected device
     */
    fun getConnectedDevice(): EUCDevice? = currentDevice

    /**
     * Check if Bluetooth is enabled
     */
    fun isBluetoothEnabled(): Boolean {
        return bluetoothAdapter?.isEnabled == true
    }

    // Setters for configuration
    fun setScanTimeout(timeout: Long) {
        this.scanTimeout = timeout
    }

    fun setConnectionTimeout(timeout: Long) {
        this.connectionTimeout = timeout
    }

    fun setAutoReconnect(enabled: Boolean) {
        this.autoReconnect = enabled
    }

    fun setMaxRetries(retries: Int) {
        this.maxRetries = retries
    }

    /**
     * Callback registration methods.
     *
     * Threading contract:
     * - Registered callbacks are invoked from background execution contexts.
     * - No callback registered through these setters is guaranteed to run on the
     *   Android main thread.
     * - UI updates must be marshalled explicitly by the caller.
     */
    // Callback setters
    fun setConnectionCallback(callback: ConnectionCallback) {
        this.connectionCallback = callback
    }

    fun setDataCallback(callback: DataCallback) {
        this.dataCallback = callback
    }

    fun setErrorCallback(callback: ErrorCallback) {
        this.errorCallback = callback
    }

    // Private implementation methods
    @RequiresApi(Build.VERSION_CODES.M)
    @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
    //@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)

    private fun startBleScan() {
        val scanner = bluetoothAdapter?.bluetoothLeScanner ?: return

        val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
            .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
            .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
            .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT).build()

        platformScanCallback = object : AndroidScanCallback() {
            @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
            override fun onScanResult(callbackType: Int, result: ScanResult) {
                processScanResult(result)
            }

            override fun onScanFailed(errorCode: Int) {
                errorCallback?.onError(BLEException("Scan failed with error: $errorCode"))
            }
        }

        scanner.startScan(null, settings, platformScanCallback)
        connectionCallback?.onScanStarted()
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun processScanResult(result: ScanResult) {
        data class WheelManufacturerData(
            val manufacturerId: Int, val data: ByteArray?
        )

        val device = result.device
        val scanRecord = result.scanRecord
        val foundManufacturerData: WheelManufacturerData? = sequenceOf(
            BLEConstants.MANUFACTURER_KINGSONG,
            BLEConstants.MANUFACTURER_GOTWAY,
            BLEConstants.MANUFACTURER_INMOTION,
            BLEConstants.MANUFACTURER_NINEBOT,
            BLEConstants.MANUFACTURER_VETERAN,
            BLEConstants.MANUFACTURER_LEAPERKIM,
        ).firstNotNullOfOrNull { id ->
            scanRecord?.getManufacturerSpecificData(id)
                ?.let { bytes -> WheelManufacturerData(id, bytes) }
        }

        val eucDevice = EUCDevice(
            bluetoothDevice = device,
            name = device.name ?: "Unknown EUC",
            address = device.address,
            manufacturerId = foundManufacturerData?.manufacturerId ?: 0,
            manufacturerData = foundManufacturerData?.data,
            rssi = result.rssi
        )

        // Check if this device is supported by any protocol
        val supportingProtocol = protocols.find { it.canHandle(eucDevice) }
        if (supportingProtocol != null) {
            discoveredDevices[eucDevice.address] = eucDevice
            connectionCallback?.onDeviceDiscovered(eucDevice)
        }
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun connectToDevice(device: BluetoothDevice) {
        try {
            bluetoothGatt = device.connectGatt(context, false, this)
        } catch (e: Exception) {
            errorCallback?.onError(BLEException("Failed to connect to device: ${e.message}"))
            disconnect()
        }
    }

    private fun getCharacteristic(uuid: UUID): BluetoothGattCharacteristic? {
        return bluetoothGatt?.services?.flatMap { service ->
            service.characteristics
        }?.find { characteristic ->
            characteristic.uuid == uuid
        }
    }

    // BluetoothGattCallback implementations
    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        super.onConnectionStateChange(gatt, status, newState)

        when (newState) {
            BluetoothProfile.STATE_CONNECTED -> {
                // reset des compteurs de reconnexion
                reconnectRetryCount = 0
                reconnectJob?.cancel()
                reconnectJob = null
                connectionState = BLEConstants.ConnectionState.CONNECTED
                connectionTimeoutJob?.cancel()
                connectionTimeoutJob = null
                connectionCallback?.onConnected()
                // Discover services
                gatt.discoverServices()
            }

            BluetoothProfile.STATE_DISCONNECTED -> {
                cancelPollingOrchestration()
                connectionState = BLEConstants.ConnectionState.DISCONNECTED
                connectionCallback?.onDisconnected()

                if (!manualDisconnect && autoReconnect && currentDevice != null) {
                    scheduleReconnect()
                } else {
                    // if we don't want to reconnect, reset the counter
                    reconnectRetryCount = 0
                }
            }

            BluetoothProfile.STATE_CONNECTING -> {
                connectionState = BLEConstants.ConnectionState.CONNECTING
            }

            BluetoothProfile.STATE_DISCONNECTING -> {
                connectionState = BLEConstants.ConnectionState.DISCONNECTING
            }
        }
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun scheduleReconnect() {
        // If already in progress or max retries reached, do not reschedule
        if (reconnectJob != null) return
        if (reconnectRetryCount >= maxRetries) {
            connectionCallback?.onConnectionFailed(BLEException("Reconnection failed after $reconnectRetryCount attempts"))
            reconnectRetryCount = 0
            return
        }

        // calculate delay with backoff and jitter
        val multiplier = 1L shl reconnectRetryCount.coerceAtMost(30) // prevent overflow
        val baseDelay = (reconnectBaseDelayMs * multiplier).coerceAtMost(maxReconnectDelayMs)
        val jitter = kotlin.random.Random.nextLong(0, BLEConstants.RECONNECT_JITTER_MAX_MS)
        val delayMs = (baseDelay + jitter).coerceAtMost(maxReconnectDelayMs)

        reconnectJob = coroutineScope.launch {

            kotlinx.coroutines.delay(delayMs)
            // double-check conditions avant tentative
            if (manualDisconnect) {
                reconnectJob = null
                return@launch
            }
            if (connectionState == BLEConstants.ConnectionState.CONNECTED) {
                reconnectJob = null
                reconnectRetryCount = 0
                return@launch
            }
            reconnectRetryCount++
            connectionState = BLEConstants.ConnectionState.CONNECTING
            try {
                currentDevice?.bluetoothDevice?.let { device ->
                    connectToDevice(device)
                } ?: run {
                    connectionCallback?.onConnectionFailed(BLEException("No device to reconnect"))
                    reconnectJob = null
                }
            } catch (e: Exception) {
                // in case of immediate failure, let the next onConnectionStateChange trigger a new attempt
                reconnectJob = null
            }

        }
    }

    // Optional: helper to explicitly cancel reconnection (used by cleanup if needed)
    private fun cancelReconnectAttempts() {
        reconnectJob?.cancel()
        reconnectJob = null
        reconnectRetryCount = 0
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
        super.onServicesDiscovered(gatt, status)

        if (status == BluetoothGatt.GATT_SUCCESS) {
            val device = currentDevice
            if (device == null) {
                errorCallback?.onError(BLEException("No connected device available for protocol selection"))
                disconnect()
                return
            }

            prepareProtocolCandidates(device)

            if (frameCandidateProtocols.isEmpty()) {
                errorCallback?.onError(BLEException("No protocol found for this device"))
                disconnect()
                return
            }

            frameCandidateProtocols.map { it.getDataCharacteristicUUID() }.distinct()
                .forEach { characteristicUuid ->
                    enableNotifications(characteristicUuid)
                }
            connectionCallback?.onServicesDiscovered(gatt.services)

            when {
                metadataMatchedProtocols.size == 1 -> setActiveProtocol(
                    metadataMatchedProtocols.single(), "metadata"
                )

                frameCandidateProtocols.size == 1 -> setActiveProtocol(
                    frameCandidateProtocols.single(), "single candidate"
                )
            }
        } else {
            errorCallback?.onError(BLEException("Service discovery failed: $status"))
            disconnect()
        }
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    override fun onCharacteristicChanged(
        gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray
    ) {
        super.onCharacteristicChanged(gatt, characteristic, value)
        val data = value.clone()
        handleIncomingBytes(data)
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    @Suppress("DEPRECATION")
    override fun onCharacteristicChanged(
        gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic
    ) {
        super.onCharacteristicChanged(gatt, characteristic)
        val raw = characteristic.value ?: return
        val data = raw.clone()
        handleIncomingBytes(data)
    }

    @VisibleForTesting(otherwise = PRIVATE)
    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    internal fun handleIncomingBytes(data: ByteArray) {
        _rawFrameFlow.tryEmit(data.clone())
        if (currentProtocol == null) {
            maybeSelectProtocolFromFrame(data)
        }
        currentProtocol?.let { protocol ->
            try {
                matchPendingQueries(protocol, data)
                protocol.decode(data)
            } catch (e: Exception) {
                errorCallback?.onError(BLEException("Data decoding failed: ${e.message}"))
            }
        }
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun maybeSelectProtocolFromFrame(data: ByteArray) {
        if (currentProtocol != null) return
        val candidates =
            if (frameCandidateProtocols.isNotEmpty()) frameCandidateProtocols else protocols
        if (candidates.isEmpty()) return

        val frameMatches = candidates.filter { candidate ->
            runCatching { candidate.looksLikeMyFrames(data) }.getOrDefault(false)
        }

        when {
            frameMatches.size == 1 -> setActiveProtocol(frameMatches.single(), "frame signature")

            metadataMatchedProtocols.size == 1 -> setActiveProtocol(
                metadataMatchedProtocols.single(), "metadata fallback"
            )

            candidates.size == 1 -> setActiveProtocol(
                candidates.single(), "single registered protocol"
            )

            frameMatches.size == 2 -> {
                val manuf1 = frameMatches[0].manufacturer
                val manuf2 = frameMatches[1].manufacturer

                // CANNOT DECIDE BETWEEN LK AND NOSFET
                if (manuf1.equals("Leaperkim", ignoreCase = true) && manuf2.equals(
                        "Nosfet", ignoreCase = true
                    ) || manuf2.equals("Leaperkim", ignoreCase = true) && manuf1.equals(
                        "Nosfet", ignoreCase = true
                    )
                ) {
                    setActiveProtocol(protocols.find {
                        it.manufacturer.equals(
                            "Leaperkim", ignoreCase = true
                        )
                    }!!, "frame signature tie-break LK/Nosfet")
                }

                // CANNOT DECIDE BETWEEN GW AND EB
                if (manuf1.equals("ExtremeBull", ignoreCase = true) && manuf2.equals(
                        "GOTWAY", ignoreCase = true
                    ) || manuf2.equals("Gotway", ignoreCase = true) && manuf1.equals(
                        "ExtremeBull", ignoreCase = true
                    )
                ) {
                    setActiveProtocol(protocols.find {
                        it.manufacturer.equals(
                            "Gotway", ignoreCase = true
                        )
                    }!!, "frame signature tie-break EB/GW")
                }

                // CANNOT DECIDE BETWEEN NINEBOT AND NINEBOTZ
                if (manuf1.equals("ninebot", ignoreCase = true) && manuf2.equals(
                        "ninebot", ignoreCase = true
                    ) || manuf2.equals("ninebot", ignoreCase = true) && manuf1.equals(
                        "ninebot", ignoreCase = true
                    )
                ) {
                    setActiveProtocol(protocols.find {
                        it.supportedModels.contains(
                            "Z10"
                        )
                    }!!, "frame signature tie-break Ninebot/NinebotZ")
                }

            }
        }
    }

    override fun onCharacteristicWrite(
        gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int
    ) {
        super.onCharacteristicWrite(gatt, characteristic, status)

        if (status != BluetoothGatt.GATT_SUCCESS) {
            errorCallback?.onError(BLEException("Characteristic write failed: $status"))
        }
    }

    override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
        super.onMtuChanged(gatt, mtu, status)

        if (status == BluetoothGatt.GATT_SUCCESS) {
            connectionCallback?.onMtuChanged(mtu)
        } else {
            errorCallback?.onError(BLEException("MTU change failed: $status"))
        }
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun enableNotifications(characteristicUuid: UUID) {
        val characteristic = getCharacteristic(characteristicUuid)
        characteristic?.let { char ->
            val cccdUuid = UUID.fromString(BLEConstants.CCCD_DESCRIPTOR)
            val descriptor = char.getDescriptor(cccdUuid) ?: return@let
            val enableValue =
                BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.clone() // defensive copy

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                // Use new API 33+ overload
                bluetoothGatt?.writeDescriptor(descriptor, enableValue)
            } else {
                // Fallback for older APIs (setValue + writeDescriptor)
                @Suppress("DEPRECATION") run {
                    descriptor.value = enableValue
                    bluetoothGatt?.writeDescriptor(descriptor)
                }
            }

            // Enable notifications locally
            bluetoothGatt?.setCharacteristicNotification(char, true)
        }
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun startPollingOrchestration(protocol: EUCProtocol) {
        cancelPollingOrchestration()
        val plan = protocol.getPollingPlan()
        if (!plan.enabled) return

        queryOrchestrationJob = coroutineScope.launch {
            for (query in plan.startupQueries) {
                executeQueryWithRetry(protocol, query)
            }

            plan.periodicQueries.forEach { query ->
                launch {
                    if (query.initialDelayMs > 0L) delay(query.initialDelayMs.milliseconds)
                    while (connectionState == BLEConstants.ConnectionState.CONNECTED) {
                        executeQueryWithRetry(protocol, query)
                        if (query.intervalMs <= 0L) break
                        delay(query.intervalMs.milliseconds)
                    }
                }
            }
        }
    }

    private fun cancelPollingOrchestration() {
        queryOrchestrationJob?.cancel()
        queryOrchestrationJob = null
        pendingQueries.clear()
    }

    @VisibleForTesting(otherwise = PRIVATE)
    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    internal fun startDataFlowCollection(protocol: EUCProtocol) {
        cancelDataFlowCollection()
        dataFlowCollectorJob = coroutineScope.launch {
            protocol.dataFlow.collect { d ->
                dataCallback?.onDataReceived(d)
            }
        }

        writeFlowCollectorJob = coroutineScope.launch {
            protocol.writeFlow.collect { payload ->
                if (payload.isNotEmpty()) {
                    sendCommand(payload, protocol.getWriteCharacteristicUUID())
                }
            }
        }
    }

    @VisibleForTesting(otherwise = PRIVATE)
    internal fun cancelDataFlowCollection() {
        dataFlowCollectorJob?.cancel()
        dataFlowCollectorJob = null
        writeFlowCollectorJob?.cancel()
        writeFlowCollectorJob = null
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun setActiveProtocol(protocol: EUCProtocol, reason: String) {
        if (currentProtocol === protocol) return
        currentProtocol = protocol
        logger.info("BLEManager", "Selected protocol ${protocol.javaClass.simpleName} via $reason")
        startPollingOrchestration(protocol)
        startDataFlowCollection(protocol)
    }

    @VisibleForTesting(otherwise = PRIVATE)
    internal fun prepareProtocolCandidates(device: EUCDevice) {
        metadataMatchedProtocols = protocols.filter { protocol ->
            protocol.canHandle(device)
        }
        frameCandidateProtocols = metadataMatchedProtocols.ifEmpty { protocols.toList() }
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private suspend fun executeQueryWithRetry(protocol: EUCProtocol, query: ProtocolQuerySpec) {
        val retryCount = query.maxRetries.coerceAtLeast(0)
        val totalAttempts = retryCount + MIN_QUERY_ATTEMPTS
        for (attempt in 1..totalAttempts) {
            if (connectionState != BLEConstants.ConnectionState.CONNECTED) return
            val sent = sendProtocolQuery(protocol, query, attempt)
            if (!sent) return

            val timeoutMs = query.responseTimeoutMs.coerceAtLeast(MIN_QUERY_TIMEOUT_MS)
            delay(timeoutMs.milliseconds)

            if (!pendingQueries.containsKey(query.id)) {
                return
            }

            pendingQueries.remove(query.id)
            emitQueryTrace(
                QueryTraceEvent(
                    timestampMs = System.currentTimeMillis(),
                    protocol = protocol.manufacturer,
                    queryId = query.id,
                    commandType = query.commandType,
                    phase = QueryTracePhase.TIMEOUT,
                    attempt = attempt,
                    message = "No matching response after ${timeoutMs}ms"
                )
            )

            if (attempt < totalAttempts) {
                emitQueryTrace(
                    QueryTraceEvent(
                        timestampMs = System.currentTimeMillis(),
                        protocol = protocol.manufacturer,
                        queryId = query.id,
                        commandType = query.commandType,
                        phase = QueryTracePhase.RETRY_SCHEDULED,
                        attempt = attempt + 1,
                        message = "Retry scheduled in ${query.retryBackoffMs}ms"
                    )
                )
                if (query.retryBackoffMs > 0L) delay(query.retryBackoffMs.milliseconds)
            } else {
                emitQueryTrace(
                    QueryTraceEvent(
                        timestampMs = System.currentTimeMillis(),
                        protocol = protocol.manufacturer,
                        queryId = query.id,
                        commandType = query.commandType,
                        phase = QueryTracePhase.FAILED,
                        attempt = attempt,
                        message = "Query exhausted retries"
                    )
                )
            }
        }
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun sendProtocolQuery(
        protocol: EUCProtocol, query: ProtocolQuerySpec, attempt: Int
    ): Boolean {
        if (protocol.getCommandSupport(query.commandType) == CommandSupport.UNSUPPORTED) {
            emitQueryTrace(
                QueryTraceEvent(
                    timestampMs = System.currentTimeMillis(),
                    protocol = protocol.manufacturer,
                    queryId = query.id,
                    commandType = query.commandType,
                    phase = QueryTracePhase.UNSUPPORTED,
                    attempt = attempt,
                    message = "Command is unsupported by protocol support matrix"
                )
            )
            return false
        }

        val payload = protocol.createCommand(query.commandType, query.value)
        if (payload.isEmpty()) {
            emitQueryTrace(
                QueryTraceEvent(
                    timestampMs = System.currentTimeMillis(),
                    protocol = protocol.manufacturer,
                    queryId = query.id,
                    commandType = query.commandType,
                    phase = QueryTracePhase.UNSUPPORTED,
                    attempt = attempt,
                    message = "Command payload is empty"
                )
            )
            return false
        }

        sendCommand(payload, protocol.getWriteCharacteristicUUID())
        pendingQueries[query.id] = PendingQueryState(
            protocolName = protocol.manufacturer,
            query = query,
            attempt = attempt,
            sentAtMs = System.currentTimeMillis()
        )

        emitQueryTrace(
            QueryTraceEvent(
                timestampMs = System.currentTimeMillis(),
                protocol = protocol.manufacturer,
                queryId = query.id,
                commandType = query.commandType,
                phase = QueryTracePhase.SENT,
                attempt = attempt
            )
        )
        return true
    }

    private fun matchPendingQueries(protocol: EUCProtocol, data: ByteArray) {
        if (pendingQueries.isEmpty()) return
        val now = System.currentTimeMillis()
        val matchedIds = mutableListOf<String>()

        for ((id, state) in pendingQueries) {
            if (state.protocolName != protocol.manufacturer) continue
            if (!protocol.matchesQueryResponse(state.query, data)) continue
            matchedIds.add(id)
            val rawLatency = now - state.sentAtMs
            if (rawLatency < 0L) {
                logger.warn(
                    "BLEQueryTrace",
                    "Negative latency detected for query=${state.query.id} protocol=${state.protocolName}; sentAt=${state.sentAtMs} now=$now"
                )
            }
            emitQueryTrace(
                QueryTraceEvent(
                    timestampMs = now,
                    protocol = state.protocolName,
                    queryId = id,
                    commandType = state.query.commandType,
                    phase = QueryTracePhase.RESPONSE_MATCHED,
                    attempt = state.attempt,
                    latencyMs = if (rawLatency < 0L) 0L else rawLatency
                )
            )
        }

        matchedIds.forEach { pendingQueries.remove(it) }
    }

    private fun emitQueryTrace(event: QueryTraceEvent) {
        _queryTraceFlow.tryEmit(event)
        logger.info(
            "BLEQueryTrace",
            "phase=${event.phase} protocol=${event.protocol} query=${event.queryId} command=${event.commandType} attempt=${event.attempt} latencyMs=${event.latencyMs ?: -1} message=${event.message ?: ""}"
        )
    }

    // Cleanup
    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    fun cleanup() {
        cancelReconnectAttempts()
        scanTimeoutJob?.cancel()
        connectionTimeoutJob?.cancel()
        disconnect()
        scanJob?.cancel()
        connectionJob?.cancel()
        coroutineScope.cancel()
    }
}

/**
 * Scan-related callbacks exposed by [BLEManager].
 *
 * Threading contract:
 * - These callbacks are invoked from background execution contexts.
 * - They are not guaranteed to run on the Android main thread.
 *
 * Implications for callers:
 * - Do not update Android views directly from these methods unless you explicitly
 *   switch to the main thread first.
 * - Prefer forwarding these events to a `Flow`, `StateFlow`, `LiveData`, or another
 *   application-level state container consumed by the UI layer.
 */
// Callback interfaces
interface ScanCallback {
    fun onScanStarted() {}
    fun onDeviceDiscovered(device: EUCDevice) {}
    fun onScanCompleted(devices: List<EUCDevice>) {}
}

/**
 * Connection- and GATT-related callbacks exposed by [BLEManager].
 *
 * This callback extends [ScanCallback], so it also receives scan lifecycle events.
 *
 * Threading contract:
 * - Methods may be invoked either from Android BLE callback paths or from the
 *   manager's background coroutine scope.
 * - No method in this callback is guaranteed to run on the Android main thread.
 *
 * Recommended usage:
 * - Perform non-UI work directly here if needed.
 * - For UI updates, switch explicitly to `Dispatchers.Main`, `runOnUiThread`, or
 *   publish the event into a UI-observed state holder.
 */
abstract class ConnectionCallback : io.github.tritbool.euc.ble.core.ScanCallback {
    open fun onConnected() {}
    open fun onDisconnected() {}
    open fun onConnectionFailed(error: BLEException) {}
    open fun onServicesDiscovered(services: List<BluetoothGattService>) {}
    open fun onMtuChanged(mtu: Int) {}
}

/**
 * Default adapter that forwards [ConnectionCallback] events to a [BleBackendListener].
 *
 * Threading contract:
 * - Forwarded events keep the original threading behavior of [BLEManager].
 * - Listener methods are therefore not guaranteed to run on the Android main thread.
 *
 * Consumers of [BleBackendListener] must explicitly switch to the main thread before
 * performing UI work.
 */
class ListenerConnectionCallback(
    private val listener: BleBackendListener?, private val bleManager: BLEManager
) : ConnectionCallback() {

    override fun onScanStarted() {
        listener?.onEvent(BleBackendEvent.ScanStarted)
    }

    override fun onDeviceDiscovered(device: EUCDevice) {
        listener?.onEvent(BleBackendEvent.DeviceDiscovered(device))
    }

    override fun onScanCompleted(devices: List<EUCDevice>) {
        listener?.onEvent(BleBackendEvent.ScanCompleted(devices))
    }

    override fun onConnected() {
        listener?.onEvent(BleBackendEvent.Connected(bleManager.getConnectedDevice()))
    }

    override fun onDisconnected() {
        listener?.onEvent(BleBackendEvent.Disconnected)
    }

    override fun onConnectionFailed(error: BLEException) {
        listener?.onEvent(BleBackendEvent.Error(error))
    }

    override fun onServicesDiscovered(services: List<BluetoothGattService>) {
        listener?.onEvent(BleBackendEvent.ServicesDiscovered(services.map { it.uuid }))
    }

    override fun onMtuChanged(mtu: Int) {
        listener?.onEvent(BleBackendEvent.MtuChanged(mtu))
    }
}

/**
 * Callback receiving decoded [EUCData] frames.
 *
 * Threading contract:
 * - Invoked from the manager's background coroutine scope.
 * - Not guaranteed to run on the Android main thread.
 *
 * Recommended usage:
 * - Perform parsing, aggregation, logging, persistence, or domain processing directly.
 * - For UI updates, switch explicitly to the main thread or publish the value through
 *   a UI-observed state container.
 */
interface DataCallback {
    fun onDataReceived(data: EUCData)
}

/**
 * Callback receiving errors produced by [BLEManager].
 *
 * Threading contract:
 * - Invoked from background execution contexts.
 * - Not guaranteed to run on the Android main thread.
 *
 * Recommended usage:
 * - Handle logging, metrics, and state transitions directly.
 * - Switch explicitly to the main thread before displaying Android UI such as
 *   dialogs, snackbars, or toasts.
 */
interface ErrorCallback {
    fun onError(error: BLEException)
}

enum class QueryTracePhase {
    SENT, RESPONSE_MATCHED, TIMEOUT, RETRY_SCHEDULED, UNSUPPORTED, FAILED
}

data class QueryTraceEvent(
    val timestampMs: Long,
    val protocol: String,
    val queryId: String,
    val commandType: CommandType,
    val phase: QueryTracePhase,
    val attempt: Int,
    val latencyMs: Long? = null,
    val message: String? = null
)