InMotionProtocol.kt

package io.github.tritbool.euc.ble.protocols

import io.github.tritbool.euc.ble.core.AndroidLogger
import io.github.tritbool.euc.ble.core.BLEConstants
import io.github.tritbool.euc.ble.core.ByteUtils
import io.github.tritbool.euc.ble.core.Logger
import io.github.tritbool.euc.ble.models.BMSData
import io.github.tritbool.euc.ble.models.EUCData
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.receiveAsFlow
import java.util.UUID
import kotlin.math.roundToInt

/**
 * Single InMotion protocol entrypoint with auto-detected internal dialects (legacy V1 and V2).
 */
class InMotionProtocol(private val logger: Logger = AndroidLogger()) : EUCProtocol {

    companion object {

        private const val TAG = "InMotionProtocol"
        private val HEADER = BLEConstants.INMOTION_FRAME_HEADER
        private val LEGACY_TAIL = BLEConstants.INMOTION_LEGACY_TAIL
        private const val FLAG_INITIAL = 0x11
        private const val FLAG_DEFAULT = 0x14
        private const val FLAG_EXTENDED = 0x16

        private const val COMMAND_MAIN_INFO = 0x02
        private const val COMMAND_REAL_TIME_INFO = 0x04
        private const val COMMAND_BATTERY_INFO = 0x05
        private const val COMMAND_TOTAL_STATS = 0x11
        private const val COMMAND_CONTROL = 0x60

        private const val MIN_FRAME_SIZE = 5
        private const val MAX_LEN = 240

        /** InMotion V2 escape marker byte. Any 0xAA or 0xA5 in the payload is
         *  transmitted as 0xA5 followed by the original byte. */
        private const val ESCAPE_MARKER = 0xA5

        private const val LEGACY_CURRENT_OFFSET = 39
        private const val LEGACY_VOLTAGE_OFFSET = 43
        private const val LEGACY_TEMP_OFFSET = 51
        private const val LEGACY_MOTOR_TEMP_OFFSET = 53
        private const val LEGACY_TOTAL_DISTANCE_OFFSET = 63
        private const val LEGACY_TRIP_DISTANCE_OFFSET = 83
        private const val LEGACY_SPEED_OFFSET = 95
        private const val LEGACY_RIDE_TIME_OFFSET = 103
        private const val LEGACY_BATTERY_OFFSET = 154

        private const val LEGACY_SPEED_DIVISOR = 820.0
        private const val LEGACY_SPEED_MIN = -80.0
        private const val LEGACY_SPEED_MAX = 80.0
        private const val LEGACY_BATTERY_BASE_VOLTAGE = 55.0
        private const val LEGACY_BATTERY_VOLTAGE_RANGE = 30.0

        /** P6 motor torque constant (N·m per amp of phase current), recovered by
         *  correlating the InMotion app's Phase Current vs Motor Torque readings
         *  across a labelled ride. phase_A = torque_Nm / this.
         *  (Source: eucplanet commit 32385baa, verified over a 75x torque range.) */
        private const val P6_KT_NM_PER_A = 0.586
        private const val P6_STATS_QUERY_INTERVAL_MS = 4_000L

        /** Minimum realtime payload size for V14/P6/V13/V11 (78 bytes). */
        private const val V2_REALTIME_MIN_SIZE = 78

        /** Minimum realtime payload size for V12 HS/HT/PRO/S (56 bytes). The V12
         *  uses a more compact layout: speed/torque/pwm shift earlier, a single
         *  battery field at offset 24, and state/light bytes at 54/55. */
        private const val V12_REALTIME_MIN_SIZE = 56

        private val V12_MODEL_NAMES = setOf(
            "InMotion V12 HS",
            "InMotion V12 HT",
            "InMotion V12 PRO",
            "InMotion V12S"
        )

        private val V14_BMS_PACK_ADDRESSES = listOf(0x24, 0x25, 0x26, 0x27)

        // --- InMotion V1 wire-format constants ---
        // V1 frames: AA AA <escaped 16-byte CAN frame> <escaped checksum> 55 55
        // CAN ID written little-endian at bytes 0..3 of the 16-byte prefix.
        // checksum = sum(all 16 CAN bytes) mod 256, also escaped.
        // Source: eucplanet InMotionV1Protocol.kt + InMotionV1Commands.kt

        private const val V1_HEADER: Byte = 0xAA.toByte()
        private const val V1_TRAILER: Byte = 0x55.toByte()
        private const val V1_ESCAPE: Byte = 0xA5.toByte()

        // Metadata bytes fixed for all phone→wheel frames.
        private const val V1_LEN_NORMAL: Byte = 0x08
        private const val V1_CHANNEL_PHONE: Byte = 0x05
        private const val V1_FORMAT_STANDARD: Byte = 0x00
        private const val V1_TYPE_DATA: Byte = 0x00

        // CAN IDs used by the protocol.
        private const val V1_CAN_FAST_INFO  = 0x0F550113
        private const val V1_CAN_HEADLIGHT  = 0x0F55010D
        private const val V1_CAN_REMOTE_CTRL = 0x0F550116
        private const val V1_CAN_PIN        = 0x0F550307

        private const val V1_DEFAULT_PIN = "000000"
        private const val V1_FACTORY_PASSWORD = "INMOTI"
    }


    override val manufacturer: String = "InMotion"
    override val supportedCommandTypes: Set<CommandType> = setOf(
        CommandType.LIGHT_ON,
        CommandType.LIGHT_OFF,
        CommandType.LIGHT_BRIGHTNESS,
        CommandType.BEEP,
        CommandType.LOCK,
        CommandType.UNLOCK,
        CommandType.POWER_OFF,
        CommandType.REQUEST_SERIAL,
        CommandType.REQUEST_FIRMWARE,
        CommandType.REQUEST_BATTERY_INFO,
        CommandType.CUSTOM
    )

    override fun getServiceUUID(): UUID {
        return if (lastDetectedDialect == Dialect.V2) {
            UUID.fromString(BLEConstants.INMOTION_V2_SERVICE_UUID)
        } else {
            UUID.fromString(BLEConstants.INMOTION_SERVICE_UUID)
        }
    }

    override fun getDataCharacteristicUUID(): UUID {
        return if (lastDetectedDialect == Dialect.V2) {
            UUID.fromString(BLEConstants.INMOTION_V2_READ_CHARACTERISTIC)
        } else {
            UUID.fromString(BLEConstants.INMOTION_READ_CHARACTERISTIC)
        }
    }

    override fun getWriteCharacteristicUUID(): UUID {
        return if (lastDetectedDialect == Dialect.V2) {
            UUID.fromString(BLEConstants.INMOTION_V2_WRITE_CHARACTERISTIC)
        } else {
            UUID.fromString(BLEConstants.INMOTION_WRITE_CHARACTERISTIC)
        }
    }

    /**
     * InMotion exposes two possible data characteristics depending on the dialect detected
     * at runtime. Both must be enabled for notifications at connection time so that the
     * protocol can determine which dialect is in use from the incoming frames:
     * - V1 (legacy): [BLEConstants.INMOTION_READ_CHARACTERISTIC] (`0000ffe4`)
     * - V2 (modern): [BLEConstants.INMOTION_V2_READ_CHARACTERISTIC] (`6e400003`)
     */
    override fun getCandidateDataCharacteristicUUIDs(): List<UUID> = listOf(
        UUID.fromString(BLEConstants.INMOTION_READ_CHARACTERISTIC),
        UUID.fromString(BLEConstants.INMOTION_V2_READ_CHARACTERISTIC)
    )

    private val _channel = Channel<EUCData>(capacity = Channel.UNLIMITED)
    override val dataFlow: Flow<EUCData> = _channel.receiveAsFlow()
    private val _writeChannel = Channel<ByteArray>(capacity = Channel.UNLIMITED)
    override val writeFlow: Flow<ByteArray> = _writeChannel.receiveAsFlow()

    private val _rawFrameFlow = MutableSharedFlow<ByteArray>(
        extraBufferCapacity = BLEConstants.DEFAULT_FLOW_BUFFER_CAPACITY,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    override val rawFrameFlow: Flow<ByteArray> = _rawFrameFlow.asSharedFlow()


    private val parseLock = Any()
    private val v2Buffer = ArrayList<Byte>()
    private val legacyBuffer = ArrayList<Byte>()

    private enum class Dialect { UNKNOWN, LEGACY_V1, V2 }

    @Volatile
    private var lastDetectedDialect: Dialect = Dialect.UNKNOWN

    @Volatile
    private var modelName: String = "InMotion"

    @Volatile
    private var serialNumber: String? = null

    @Volatile
    private var firmwareVersion: String? = null

    @Volatile
    private var totalDistanceKm: Double? = null

    @Volatile
    private var v2SessionStartTimestampMs: Long? = null

    @Volatile
    private var hasSeenV2MainInfo: Boolean = false

    @Volatile
    private var hasSeenV2Realtime: Boolean = false

    @Volatile
    private var hasSeenLegacyRealtime: Boolean = false

    @Volatile
    private var lastKnownBmsSnapshot = InMotionBmsSnapshot()

    @Volatile
    private var totalRideTimeSeconds: Long? = null

    @Volatile
    private var totalPowerOnTimeSeconds: Long? = null

    @Volatile
    private var lastP6StatsQueryAtMs: Long = 0L

    private data class InMotionBmsSnapshot(
        val voltage: Double? = null,
        val current: Double? = null,
        val temperatures: List<Double>? = null,
        val packVoltages: List<Double>? = null,
        val cellVoltages: List<Double>? = null,
        val packCellVoltages: Map<Int, List<Double>> = emptyMap()
    )

    override fun decode(data: ByteArray): EUCData? {
        if (data.isEmpty()) return null
        _rawFrameFlow.tryEmit(data.clone())
        var lastDecoded: EUCData? = null

        val v2Frames = extractV2Frames(data)
        for (frame in v2Frames) {
            val decoded = parseV2Frame(frame) ?: continue
            lastDecoded = decoded
            _channel.trySend(decoded)
        }

        val legacyFrames = extractLegacyFrames(data)
        for (frame in legacyFrames) {
            val decoded = parseLegacyFrame(frame) ?: continue
            lastDecoded = decoded
            _channel.trySend(decoded)
        }
        return lastDecoded
    }

    fun setDialect(version: Int) {
        android.util.Log.e("InMotionProtocol", "setDialect called version=$version")
        when (version) {
            0, 1, 2 -> {
                logger.info(TAG,"DIALECT SET TO ${Dialect.entries[version]} ")
                lastDetectedDialect = Dialect.entries[version]
            }

            else -> {}
        }

    }

    private fun extractV2Frames(chunk: ByteArray): List<ByteArray> {
        if (!isLikelyV2Chunk(chunk) && v2Buffer.isEmpty()) return emptyList()
        synchronized(parseLock) {
            for (b in chunk) v2Buffer.add(b)
            val out = mutableListOf<ByteArray>()

            while (true) {
                val headerIndex = findHeader(v2Buffer)
                if (headerIndex < 0) {
                    if (v2Buffer.size > 1) {
                        val keep = v2Buffer.last()
                        v2Buffer.clear()
                        v2Buffer.add(keep)
                    }
                    break
                }

                if (headerIndex > 0) {
                    repeat(headerIndex) { v2Buffer.removeAt(0) }
                }

                if (v2Buffer.size < MIN_FRAME_SIZE) break

                // The InMotion V2 wire format escapes 0xAA→{0xA5,0xAA} and 0xA5→{0xA5,0xA5}
                // inside the payload. The AA AA header bytes and the trailing checksum byte
                // are NOT escaped. We must unescape the payload to:
                //   (a) correctly determine the frame boundary (escapes inflate raw byte count)
                //   (b) verify the checksum, which is computed over the UNESCAPED bytes
                //   (c) parse field offsets correctly

                // Step 1: unescape the first two payload bytes (FLAG and LEN) to learn the
                // unescaped payload length. FLAG values (0x11/0x14/0x16) are never escape
                // markers, so this is safe.
                val flagLen = unescapeNBytes(v2Buffer, startIdx = 2, count = 2) ?: break
                val (headerBytes, _) = flagLen
                val flags = headerBytes[0].toInt() and 0xFF
                val len = headerBytes[1].toInt() and 0xFF

                if (flags != FLAG_INITIAL && flags != FLAG_DEFAULT && flags != FLAG_EXTENDED) {
                    v2Buffer.removeAt(0)
                    continue
                }
                if (len !in 1..MAX_LEN) {
                    v2Buffer.removeAt(0)
                    continue
                }

                // Step 2: find the index of the checksum byte by consuming exactly `len`
                // unescaped bytes (FLAG and LEN already counted as 2; total = len+2 from pos 2).
                // We already consumed 2 above, so we need `len` more from where the FLAG/LEN
                // scan left off. But it's simpler to scan from scratch for all len+2 bytes.
                val checksumIdx = findChecksumIndex(v2Buffer, startIdx = 2, targetUnescaped = len + 2)
                if (checksumIdx < 0) break  // frame incomplete, wait for more data

                // Step 3: verify the checksum over the unescaped payload bytes
                if (!isValidChecksumEscaped(v2Buffer, fromIdx = 2, toIdx = checksumIdx)) {
                    v2Buffer.removeAt(0)
                    continue
                }

                // Step 4: build an unescaped frame for parsing:
                //   [AA AA] [unescaped FLAG LEN CMD DATA...] [checksum]
                val unescapedPayload = unescapeRange(v2Buffer, fromIdx = 2, toIdx = checksumIdx)
                val unescapedFrame = ByteArray(2 + unescapedPayload.size + 1)
                unescapedFrame[0] = HEADER[0]
                unescapedFrame[1] = HEADER[1]
                unescapedPayload.copyInto(unescapedFrame, 2)
                unescapedFrame[unescapedFrame.size - 1] = v2Buffer[checksumIdx]

                out.add(unescapedFrame)
                repeat(checksumIdx + 1) { v2Buffer.removeAt(0) }
            }
            return out
        }
    }

    /**
     * Unescape exactly [count] bytes starting at [startIdx] in [buffer], handling
     * 0xA5 escape sequences (0xA5 XX → real byte XX).
     *
     * @return Pair(unescaped bytes, raw bytes consumed) or null if buffer has insufficient data.
     */
    private fun unescapeNBytes(buffer: List<Byte>, startIdx: Int, count: Int): Pair<ByteArray, Int>? {
        val result = ByteArray(count)
        var rawIdx = startIdx
        var n = 0
        while (rawIdx < buffer.size && n < count) {
            val b = buffer[rawIdx].toInt() and 0xFF
            if (b == ESCAPE_MARKER) {
                if (rawIdx + 1 >= buffer.size) return null  // incomplete escape
                result[n] = buffer[rawIdx + 1]
                rawIdx += 2
            } else {
                result[n] = buffer[rawIdx]
                rawIdx++
            }
            n++
        }
        if (n < count) return null
        return Pair(result, rawIdx - startIdx)
    }

    /**
     * Scan [buffer] from [startIdx], consuming escape sequences, until [targetUnescaped]
     * unescaped bytes have been processed. Returns the index of the next byte after the
     * last unescaped byte (i.e. the checksum byte position), or -1 if insufficient data.
     */
    private fun findChecksumIndex(buffer: List<Byte>, startIdx: Int, targetUnescaped: Int): Int {
        var rawIdx = startIdx
        var n = 0
        while (rawIdx < buffer.size && n < targetUnescaped) {
            val b = buffer[rawIdx].toInt() and 0xFF
            if (b == ESCAPE_MARKER) {
                if (rawIdx + 1 >= buffer.size) return -1  // incomplete escape
                rawIdx += 2
            } else {
                rawIdx++
            }
            n++
        }
        if (n < targetUnescaped) return -1
        if (rawIdx >= buffer.size) return -1  // checksum byte not yet received
        return rawIdx
    }

    /**
     * Verify the InMotion V2 checksum: XOR of all unescaped bytes in [buffer] from
     * [fromIdx] to [toIdx]-1 (exclusive), compared to [buffer][toIdx].
     */
    private fun isValidChecksumEscaped(buffer: List<Byte>, fromIdx: Int, toIdx: Int): Boolean {
        var xor = 0
        var rawIdx = fromIdx
        while (rawIdx < toIdx) {
            val b = buffer[rawIdx].toInt() and 0xFF
            if (b == ESCAPE_MARKER && rawIdx + 1 < toIdx) {
                xor = xor xor (buffer[rawIdx + 1].toInt() and 0xFF)
                rawIdx += 2
            } else {
                xor = xor xor b
                rawIdx++
            }
        }
        return xor == (buffer[toIdx].toInt() and 0xFF)
    }

    /**
     * Unescape all bytes in [buffer] from [fromIdx] to [toIdx]-1 (exclusive),
     * collapsing 0xA5 escape sequences into their real byte values.
     */
    private fun unescapeRange(buffer: List<Byte>, fromIdx: Int, toIdx: Int): ByteArray {
        val result = mutableListOf<Byte>()
        var rawIdx = fromIdx
        while (rawIdx < toIdx) {
            val b = buffer[rawIdx].toInt() and 0xFF
            if (b == ESCAPE_MARKER && rawIdx + 1 < toIdx) {
                result.add(buffer[rawIdx + 1])
                rawIdx += 2
            } else {
                result.add(buffer[rawIdx])
                rawIdx++
            }
        }
        return result.toByteArray()
    }

    private fun extractLegacyFrames(chunk: ByteArray): List<ByteArray> {
        if (!isLikelyLegacyChunk(chunk) && legacyBuffer.isEmpty()) return emptyList()
        synchronized(parseLock) {
            for (b in chunk) legacyBuffer.add(b)
            val out = mutableListOf<ByteArray>()

            while (true) {
                val headerIndex = findHeader(legacyBuffer)
                if (headerIndex < 0) {
                    if (legacyBuffer.size > 1) {
                        val keep = legacyBuffer.last()
                        legacyBuffer.clear()
                        legacyBuffer.add(keep)
                    }
                    break
                }
                if (headerIndex > 0) {
                    repeat(headerIndex) { legacyBuffer.removeAt(0) }
                }

                val frameEndIndex = findTail(legacyBuffer)
                if (frameEndIndex < 0) break

                val frameSize = frameEndIndex + LEGACY_TAIL.size
                val frame = ByteArray(frameSize) { i -> legacyBuffer[i] }
                out.add(frame)
                repeat(frameSize) { legacyBuffer.removeAt(0) }
            }
            return out
        }
    }

    private fun findHeader(source: List<Byte>): Int {
        if (source.size < 2) return -1
        for (i in 0 until source.size - 1) {
            if (source[i] == HEADER[0] && source[i + 1] == HEADER[1]) return i
        }
        return -1
    }

    private fun findTail(source: List<Byte>): Int {
        if (source.size < 4) return -1
        for (i in 2 until source.size - 1) {
            if (source[i] == LEGACY_TAIL[0] && source[i + 1] == LEGACY_TAIL[1]) return i
        }
        return -1
    }

    private fun isLikelyV2Chunk(chunk: ByteArray): Boolean {
        if (chunk.size < MIN_FRAME_SIZE) return false
        if (chunk[0] != HEADER[0] || chunk[1] != HEADER[1]) return false
        val flags = chunk[2].toInt() and 0xFF
        if (flags != FLAG_INITIAL && flags != FLAG_DEFAULT && flags != FLAG_EXTENDED) return false
        val len = chunk[3].toInt() and 0xFF
        return len in 1..MAX_LEN
    }

    private fun isLikelyLegacyChunk(chunk: ByteArray): Boolean {
        if (chunk.size >= 2 && chunk[0] == HEADER[0] && chunk[1] == HEADER[1]) {
            if (chunk.size >= 4) {
                val flags = chunk[2].toInt() and 0xFF
                val len = chunk[3].toInt() and 0xFF
                if ((flags == FLAG_INITIAL || flags == FLAG_DEFAULT || flags == FLAG_EXTENDED) && len in 1..MAX_LEN) {
                    return false
                }
            }
            return true
        }
        return chunk.size >= 2 && chunk[chunk.size - 2] == LEGACY_TAIL[0] && chunk[chunk.size - 1] == LEGACY_TAIL[1]
    }

    private fun parseV2Frame(frame: ByteArray): EUCData? {
        val flags = frame[2].toInt() and 0xFF
        if (flags != FLAG_INITIAL && flags != FLAG_DEFAULT && flags != FLAG_EXTENDED) return null

        val len = frame[3].toInt() and 0xFF
        if (len <= 0) return null

        val command = frame[4].toInt() and 0x7F
        val payload = if (len > 1) frame.copyOfRange(5, 5 + (len - 1)) else ByteArray(0)

        return when (command) {
            COMMAND_MAIN_INFO -> {
                lastDetectedDialect = Dialect.V2
                parseMainInfo(payload)
                hasSeenV2MainInfo = true
                null
            }

            COMMAND_TOTAL_STATS -> {
                lastDetectedDialect = Dialect.V2
                parseTotalStats(payload)
                null
            }

            COMMAND_REAL_TIME_INFO -> parseRealTime(payload, frame)?.also {
                lastDetectedDialect = Dialect.V2
                hasSeenV2Realtime = true
            }

            COMMAND_BATTERY_INFO -> {
                lastDetectedDialect = Dialect.V2
                parseBatteryInfo(payload)
                null
            }

            in V14_BMS_PACK_ADDRESSES -> {
                lastDetectedDialect = Dialect.V2
                parseV14PackCellsResponse(command, payload)
                null
            }

            else -> null
        }
    }

    private fun parseLegacyFrame(frame: ByteArray): EUCData? {
        if (frame.size < 8) return null
        if (frame[0] != HEADER[0] || frame[1] != HEADER[1]) return null

        return when (frame[2].toInt() and 0xFF) {
            0x14 -> {
                lastDetectedDialect = Dialect.LEGACY_V1
                parseLegacyInfo(frame)
                null
            }

            0x13 -> parseLegacyRealtime(frame)?.also {
                lastDetectedDialect = Dialect.LEGACY_V1
                hasSeenLegacyRealtime = true
            }

            else -> null
        }
    }

    private fun parseLegacyInfo(frame: ByteArray) {
        if (frame.size < 48) return

        // Legacy captures encode model marker and serial seed in the same block.
        serialNumber = decodeLegacySerial(frame)
        modelName = mapLegacyModel(frame.getOrNull(19)?.toInt()?.and(0xFF) ?: 0)
        firmwareVersion = decodeLegacyFirmware(frame)
    }

    private fun decodeLegacySerial(frame: ByteArray): String? {
        if (frame.size < 27) return null
        val serialBytes = frame.copyOfRange(19, 27).reversedArray()
        return serialBytes.joinToString("") { "%02X".format(it) }.ifEmpty { null }
    }

    private fun mapLegacyModel(modelCode: Int): String {
        return when (modelCode) {
            0x1B -> "InMotion V5F"
            0x0E -> "InMotion V8F"
            0x06 -> "InMotion V8S"
            else -> "InMotion"
        }
    }

    private fun decodeLegacyFirmware(frame: ByteArray): String? {
        if (frame.size < 48) return null
        val b43 = frame[43].toInt() and 0xFF
        val b44 = frame[44].toInt() and 0xFF
        val b45 = frame[45].toInt() and 0xFF
        val b46 = frame[46].toInt() and 0xFF
        val b47 = frame[47].toInt() and 0xFF

        return if (b43 == 0 && b47 > 0 && b46 > 0) {
            val build = b44
            val minor = b46
            val major = b47
            "$major.$minor.$build"
        } else {
            val build = ByteUtils.getUnsignedShortLE(frame, 43)
            val minor = b45
            val major = b46
            "$major.$minor.$build"
        }
    }

    private fun parseLegacyRealtime(frame: ByteArray): EUCData? {
        if (frame.size < 67) return null

        val voltage =
            (ByteUtils.tryGetUnsignedShortLE(frame, LEGACY_VOLTAGE_OFFSET) ?: return null) / 100.0
        val current =
            (ByteUtils.tryGetSignedShortLE(frame, LEGACY_CURRENT_OFFSET)?.toInt() ?: 0) / 100.0
        val speedRaw = ByteUtils.tryGetSignedShortLE(frame, LEGACY_SPEED_OFFSET)?.toInt() ?: 0
        val speed = (speedRaw / LEGACY_SPEED_DIVISOR).coerceIn(LEGACY_SPEED_MIN, LEGACY_SPEED_MAX)
        val tripDistanceKm =
            (ByteUtils.tryGetUnsignedIntLE(frame, LEGACY_TRIP_DISTANCE_OFFSET)?.toDouble()
                ?: 0.0) / 1000.0
        val totalDistance =
            (ByteUtils.tryGetUnsignedIntLE(frame, LEGACY_TOTAL_DISTANCE_OFFSET)?.toDouble()
                ?: 0.0) / 1000.0
        val battery = if (frame.size > LEGACY_BATTERY_OFFSET) {
            (frame[LEGACY_BATTERY_OFFSET].toInt() and 0xFF).coerceIn(0, 100)
        } else {
            (((voltage - LEGACY_BATTERY_BASE_VOLTAGE) / LEGACY_BATTERY_VOLTAGE_RANGE) * 100.0).roundToInt()
                .coerceIn(0, 100)
        }

        val temperature = ByteUtils.tryGetSignedByte(frame, LEGACY_TEMP_OFFSET)?.toDouble() ?: 0.0
        val motorTemp = ByteUtils.tryGetSignedByte(frame, LEGACY_MOTOR_TEMP_OFFSET)?.toDouble()
        val rideTimeSeconds =
            ByteUtils.tryGetUnsignedIntLE(frame, LEGACY_RIDE_TIME_OFFSET) ?: 0L

        if (totalDistance > 0.0) totalDistanceKm = totalDistance

        val modeFromLegacy = when {
            current < 0 -> "charging"
            speed != 0.0 -> "active"
            else -> "idle"
        }

        return EUCData(
            speed = speed,
            voltage = voltage,
            current = current,
            temperature = temperature,
            batteryLevel = battery,
            distance = tripDistanceKm,
            power = voltage * current,
            timestamp = System.currentTimeMillis(),
            rawData = frame,
            manufacturer = manufacturer,
            model = modelName,
            serialNumber = serialNumber,
            firmwareVersion = firmwareVersion,
            isCharging = false,
            rideTime = rideTimeSeconds,
            cellVoltages = null,
            motorTemperature = motorTemp,
            totalDistance = totalDistanceKm,
            mode = modeFromLegacy,
        )
            .also { decoded ->
                updateBmsSnapshot(
                    voltage = decoded.voltage,
                    current = decoded.current,
                    temperatures = listOfNotNull(
                        decoded.temperature,
                        decoded.motorTemperature
                    )
                )
            }
    }

    private fun parseMainInfo(payload: ByteArray) {
        if (payload.isEmpty()) return
        when (payload[0].toInt() and 0xFF) {
            0x01 -> { // car type
                if (payload.size >= 4) {
                    val series = payload[2].toInt() and 0xFF
                    val type = payload[3].toInt() and 0xFF
                    modelName = when {
                        series == 6 && type == 1 -> "InMotion V11"
                        series == 6 && type == 2 -> "InMotion V11Y"
                        series == 7 && type == 1 -> "InMotion V12 HS"
                        series == 7 && type == 2 -> "InMotion V12 HT"
                        series == 7 && type == 3 -> "InMotion V12 PRO"
                        series == 8 && type == 1 -> "InMotion V13"
                        series == 8 && type == 2 -> "InMotion V13 PRO"
                        series == 9 && type == 1 -> "InMotion V14 50GB"
                        series == 9 && type == 2 -> "InMotion V14 50S"
                        series == 11 && type == 1 -> "InMotion V12S"
                        series == 12 && type == 1 -> "InMotion V9"
                        series == 13 && type == 1 -> "InMotion P6"
                        else -> "InMotion $series.$type"
                    }
                    enqueueP6StatsQueryIfDue(force = true)
                }
            }

            0x02 -> { // serial
                if (payload.size >= 17) {
                    serialNumber =
                        payload.copyOfRange(1, 17).decodeToString().trim('\u0000').ifEmpty { null }
                }
            }

            0x06 -> { // versions
                if (payload.size >= 24) {
                    val drv3 = ByteUtils.getUnsignedShortLE(payload, 2)
                    val drv2 = ByteUtils.getUnsignedByte(payload, 4)
                    val drv1 = ByteUtils.getUnsignedByte(payload, 5)
                    val main3 = ByteUtils.getUnsignedShortLE(payload, 11)
                    val main2 = ByteUtils.getUnsignedByte(payload, 13)
                    val main1 = ByteUtils.getUnsignedByte(payload, 14)
                    val ble3 = ByteUtils.getUnsignedShortLE(payload, 20)
                    val ble2 = ByteUtils.getUnsignedByte(payload, 22)
                    val ble1 = ByteUtils.getUnsignedByte(payload, 23)
                    firmwareVersion =
                        "Main:$main1.$main2.$main3 Drv:$drv1.$drv2.$drv3 BLE:$ble1.$ble2.$ble3"
                }
            }
        }
    }

    private fun parseTotalStats(payload: ByteArray) {
        val totalMeters = decodeTotalMeters(payload) ?: return
        if (totalMeters >= 0) totalDistanceKm = totalMeters / 1000.0
        // Offsets 12 and 16 carry cumulative ride-time and power-on-time in seconds
        // (uint32 LE). Only latch onto values that fit within a plausible lifetime
        // (20 years ≈ 630 million seconds).
        val maxLifetimeSeconds = 630_000_000L
        ByteUtils.tryGetUnsignedIntLE(payload, 12)
            ?.takeIf { it in 0L..maxLifetimeSeconds }
            ?.let { totalRideTimeSeconds = it }
        ByteUtils.tryGetUnsignedIntLE(payload, 16)
            ?.takeIf { it in 0L..maxLifetimeSeconds }
            ?.let { totalPowerOnTimeSeconds = it }
    }

    private fun decodeTotalMeters(payload: ByteArray): Long? {
        if (payload.size < 4) return null
        return if (isPrefixedTotalStatsEncoding(payload)) {
            ByteUtils.tryGetUnsignedIntLE(payload, 1)?.times(10L)
        } else {
            ByteUtils.tryGetSignedIntLE(payload, 0)?.toLong()?.times(10L)
        }
    }

    private fun isPrefixedTotalStatsEncoding(payload: ByteArray): Boolean {
        return payload.size >= 5 && payload[0] == payload[1]
    }

    private fun isV12Model(): Boolean = modelName in V12_MODEL_NAMES

    private fun parseRealTime(payload: ByteArray, rawFrame: ByteArray): EUCData? {
        return if (isV12Model()) {
            parseRealTimeV12(payload, rawFrame)
        } else {
            parseRealTimeV14(payload, rawFrame)
        }
    }

    /**
     * V12 HS / HT / PRO / S realtime telemetry (command 0x04, payload ≥ 56 bytes).
     *
     * Layout (all multi-byte fields are uint16/int16 LE unless noted):
     *   offset  0..1   voltage      uint16  ×0.01 V
     *   offset  2..3   current      int16   ×0.01 A
     *   offset  4..5   speed        int16   ×0.01 km/h (signed)
     *   offset  6..7   torque       int16   ×0.01 N·m
     *   offset  8..9   pwm          int16   ×0.01 %
     *   offset 10..11  motorPower   int16   W
     *   offset 16..17  pitchAngle   int16   ×0.01°
     *   offset 20..21  rollAngle    int16   ×0.01°
     *   offset 22..23  mileage      uint16  ×0.01 km (trip distance)
     *   offset 24..25  batLevel     uint16  ×0.01 %
     *   offset 30..31  dynSpeedLimit uint16 ×0.01 km/h
     *   offset 40      MOS temp     uint8   (offset80: byte + 80 – 256 → °C)
     *   offset 41      MOT temp     uint8
     *   offset 43      BOARD temp   uint8
     *   offset 44      CPU temp     uint8   (0x00 = sensor absent)
     *   offset 45      IMU temp     uint8
     *   offset 54      state byte   bits 0..2 = pcMode, bit 7 = charging
     *   offset 55      light byte   bit 0 = low beam, bit 1 = high beam
     *
     * Reference: eucplanet InMotionV2ParserV12.kt (InMotionV2ParserV12.parseTelemetry).
     */
    private fun parseRealTimeV12(payload: ByteArray, rawFrame: ByteArray): EUCData? {
        if (payload.size < V12_REALTIME_MIN_SIZE) return null

        val voltage = ByteUtils.getUnsignedShortLE(payload, 0) / 100.0
        val current = ByteUtils.getSignedShortLE(payload, 2) / 100.0
        val speed = ByteUtils.getSignedShortLE(payload, 4) / 100.0
        val torque = ByteUtils.getSignedShortLE(payload, 6) / 100.0
        val pwm = ByteUtils.getSignedShortLE(payload, 8) / 100.0
        val pitchAngle = ByteUtils.getSignedShortLE(payload, 16) / 100.0
        val rollAngle = ByteUtils.getSignedShortLE(payload, 20) / 100.0
        val tripKm = ByteUtils.getUnsignedShortLE(payload, 22) / 100.0
        val batteryRaw = ByteUtils.getUnsignedShortLE(payload, 24)
        val batteryPercent = (batteryRaw / 100.0).roundToInt().coerceIn(0, 100)
        val dynSpeedLimit = ByteUtils.tryGetUnsignedShortLE(payload, 30)
            ?.let { it / 100.0 }?.takeIf { it > 0.0 }

        // Temperatures use the offset-80 encoding (same helper as V14/P6):
        //   byte value = (desired_Celsius + 256 - 80)  (unsigned uint8)
        //   decoded    = (raw + 80 - 256)              (signed Celsius)
        // Offsets: 40=MOS, 41=MOT, 43=BOARD, 44=CPU, 45=IMU. Skip 42 (BAT, always 0).
        val mosTemp = decodeTemperature(payload[40])
        val motTemp = decodeTemperature(payload[41])
        val boardTemp = decodeTemperature(payload[43])
        // CPU sensor reports 0x00 when absent; decodeTemperature(0x00) = -176, so filter that.
        val cpuTempRaw = ByteUtils.tryGetUnsignedByte(payload, 44)
        val cpuTempC = cpuTempRaw?.let { decodeTemperature(it.toByte()) }?.takeIf { it > -100 }
        val imuTempC = ByteUtils.tryGetUnsignedByte(payload, 45)
            ?.let { decodeTemperature(it.toByte()) }

        val stateByte = payload[54].toInt() and 0xFF
        val isCharging = (stateByte and 0x80) != 0

        val modeString = when {
            isCharging -> "charging"
            (stateByte and 0x01) == 1 -> "active"
            (stateByte and 0x02) == 2 -> "calibration"
            else -> "idle"
        }

        val now = System.currentTimeMillis()
        val cellVoltages = getCombinedCellVoltages(lastKnownBmsSnapshot)

        return EUCData(
            speed = speed,
            voltage = voltage,
            current = current,
            temperature = mosTemp.toDouble(),
            batteryLevel = batteryPercent,
            distance = tripKm,
            power = voltage * current,
            pwm = pwm,
            torque = torque,
            timestamp = now,
            rawData = rawFrame,
            manufacturer = manufacturer,
            model = modelName,
            serialNumber = serialNumber,
            firmwareVersion = firmwareVersion,
            isCharging = isCharging,
            rideTime = deriveV2RideTimeSeconds(now),
            cellVoltages = cellVoltages,
            motorTemperature = motTemp.toDouble(),
            mosfetTemperature = mosTemp.toDouble(),
            boardTemperature = boardTemp.toDouble(),
            imuTemperature = imuTempC?.toDouble(),
            totalDistance = totalDistanceKm,
            totalRideTimeSeconds = totalRideTimeSeconds,
            totalPowerOnTimeSeconds = totalPowerOnTimeSeconds,
            angle = pitchAngle,
            roll = rollAngle,
            speedLimit = dynSpeedLimit,
            mode = modeString,
        ).also { decoded ->
            updateBmsSnapshot(
                voltage = decoded.voltage,
                current = decoded.current,
                temperatures = listOfNotNull(
                    decoded.temperature,
                    decoded.motorTemperature,
                    decoded.mosfetTemperature,
                    decoded.boardTemperature,
                    decoded.imuTemperature
                )
            )
        }
    }

    private fun parseRealTimeV14(payload: ByteArray, rawFrame: ByteArray): EUCData? {
        if (payload.size < V2_REALTIME_MIN_SIZE) return null

        val isP6 = modelName == "InMotion P6"
        if (isP6) enqueueP6StatsQueryIfDue()
        val voltage = ByteUtils.getUnsignedShortLE(payload, 0) / 100.0
        val current = ByteUtils.getSignedShortLE(payload, 2) / 100.0
        val speed = ByteUtils.getSignedShortLE(payload, 8) / 100.0
        val torque = ByteUtils.tryGetSignedShortLE(payload, 12)?.let { it / 100.0 }
        // Phase current is not transmitted by the P6; the InMotion app derives it from
        // torque using the motor's torque constant. Verified against a same-ride
        // video+btsnoop by eucplanet (commit 32385baa): phase = torque / 0.586 Nm/A
        // reproduces app readings within rounding over a 75x torque range.
        // Kept signed (negative on regen) to match how current and torque are shown.
        val phaseCurrent = if (modelName == "InMotion P6") torque?.div(P6_KT_NM_PER_A) else null
        val pwm = (ByteUtils.tryGetSignedShortLE(payload, 14)?.toDouble() ?: 0.0) / 100.0

        // Pitch and roll angles per the V14 telemetry layout (verified against
        // eucplanet's InMotionV2Parser reference implementation).
        val pitchAngle = ByteUtils.tryGetSignedShortLE(payload, 20)?.let { it / 100.0 }
        val rollAngle = ByteUtils.tryGetSignedShortLE(payload, 22)?.let { it / 100.0 }

        val distanceKm = (ByteUtils.getUnsignedShortLE(payload, 28) * 10.0) / 1000.0

        val battery = if (isP6) {
            val battery1 = ByteUtils.tryGetUnsignedShortLE(payload, 20)?.let { it / 100.0 }
            val battery2 = ByteUtils.tryGetUnsignedShortLE(payload, 22)?.let { it / 100.0 }
            val avg = when {
                battery1 != null && battery2 != null && (battery1 > 0.0 || battery2 > 0.0) -> (battery1 + battery2) / 2.0
                battery1 != null && battery1 > 0.0 -> battery1
                battery2 != null && battery2 > 0.0 -> battery2
                else -> null
            }
            (avg ?: 0.0).roundToInt().coerceIn(0, 100)
        } else {
            val battery1 = ByteUtils.getUnsignedShortLE(payload, 34)
            val battery2 = ByteUtils.getUnsignedShortLE(payload, 36)
            // Use the higher of the two battery banks to match the InMotion app display.
            // The two banks track independently and averaging reads ~2% low vs the
            // manufacturer app (per eucplanet research).
            (maxOf(battery1, battery2) / 100.0).roundToInt().coerceIn(0, 100)
        }

        // Dynamic speed limit at offset 40 (reported in 0.01 km/h units).
        val dynSpeedLimit = ByteUtils.tryGetUnsignedShortLE(payload, 40)
            ?.let { it / 100.0 }
            ?.takeIf { it > 0.0 }

        val mosTemp = decodeTemperature(payload[58])
        val boardTemp = decodeTemperature(payload[59])
        val imuTemp = ByteUtils.tryGetUnsignedByte(payload, 63)?.let { decodeTemperature(it.toByte()) }
        val p6MotorTemp = if (isP6) {
            ByteUtils.tryGetSignedByte(payload, 31)?.let { decodeP6SignedOffset80Temperature(it) }
        } else {
            null
        }
        val telemetryTemp = if (isP6) boardTemp.toDouble() else mosTemp.toDouble()
        val telemetryMotorTemp = p6MotorTemp ?: boardTemp.toDouble()
        val stateByte = payload[74].toInt() and 0xFF
        val isCharging = ((stateByte shr 7) and 0x01) == 1
        val now = System.currentTimeMillis()
        val rideTimeFromPayload = ByteUtils.tryGetUnsignedIntLE(payload, 24)
            ?.takeIf { it in 0L..604_800L }
        val rideTimeSeconds = rideTimeFromPayload ?: deriveV2RideTimeSeconds(now)
        val modeString = when {
            isCharging -> "charging"
            (stateByte and 0x01) == 1 -> "active"
            (stateByte and 0x02) == 2 -> "calibration"
            else -> "idle"
        }

        return EUCData(
            speed = speed,
            voltage = voltage,
            current = current,
            temperature = telemetryTemp,
            batteryLevel = battery,
            distance = distanceKm,
            power = voltage * current,
            pwm = pwm,
            torque = torque,
            phaseCurrent = phaseCurrent,
            timestamp = now,
            rawData = rawFrame,
            manufacturer = manufacturer,
            model = modelName,
            serialNumber = serialNumber,
            firmwareVersion = firmwareVersion,
            isCharging = isCharging,
            rideTime = rideTimeSeconds,
            cellVoltages = getCombinedCellVoltages(lastKnownBmsSnapshot),
            motorTemperature = telemetryMotorTemp,
            mosfetTemperature = mosTemp.toDouble(),
            boardTemperature = boardTemp.toDouble(),
            imuTemperature = imuTemp?.toDouble(),
            totalDistance = totalDistanceKm,
            totalRideTimeSeconds = totalRideTimeSeconds,
            totalPowerOnTimeSeconds = totalPowerOnTimeSeconds,
            angle = pitchAngle,
            roll = rollAngle,
            speedLimit = dynSpeedLimit,
            mode = modeString,
        )
            .also { decoded ->
                updateBmsSnapshot(
                    voltage = decoded.voltage,
                    current = decoded.current,
                    temperatures = listOfNotNull(
                        decoded.temperature,
                        decoded.motorTemperature,
                        decoded.mosfetTemperature,
                        decoded.boardTemperature,
                        decoded.imuTemperature
                    )
                )
            }
    }

    private fun enqueueP6StatsQueryIfDue(force: Boolean = false) {
        if (lastDetectedDialect != Dialect.V2 || modelName != "InMotion P6") return
        val now = System.currentTimeMillis()
        if (!force && now - lastP6StatsQueryAtMs < P6_STATS_QUERY_INTERVAL_MS) return
        lastP6StatsQueryAtMs = now
        _writeChannel.trySend(buildMessage(FLAG_EXTENDED, COMMAND_REAL_TIME_INFO, byteArrayOf()))
    }

    private fun deriveV2RideTimeSeconds(nowMs: Long): Long {
        val start = v2SessionStartTimestampMs ?: nowMs.also { v2SessionStartTimestampMs = it }
        return ((nowMs - start) / 1000L).coerceAtLeast(0L)
    }

    /**
     * Decode the BATTERY_INFO response (command 0x05).
     *
     * Two layouts are handled:
     *
     * 1. V14 per-cell response (`payload[0] == 0x02`, `payload[1] == 0x82`): 32 cell voltages
     *    as uint16-LE millivolts starting at offset 2. Requires at least 2 + 64 = 66 bytes.
     *    Reverse-engineered from Nordic sniffer captures of the InMotion Android app against a
     *    V14 Adventure (eucplanet InMotionV2Parser.parseV14PackCells).
     *
     * 2. Legacy 4-pack summary: 4 × uint16-LE centivolts at byte strides of 8. This was the
     *    original path and is kept as the fallback.
     */
    private fun parseBatteryInfo(payload: ByteArray) {
        // Try V14 per-cell format first.
        val cells = parseV14PackCells(payload)
        if (cells != null) {
            updateBmsSnapshot(cellVoltages = cells)
            return
        }

        // Fall back to 4-pack voltage summary.
        if (payload.size < 32) return
        val packVoltages = mutableListOf<Double>()
        var offset = 0
        repeat(4) {
            val packCentiVolts = ByteUtils.tryGetUnsignedShortLE(payload, offset) ?: 0
            if (packCentiVolts > 0) {
                packVoltages.add(packCentiVolts / 100.0)
            }
            offset += 8
        }
        if (packVoltages.isNotEmpty()) {
            updateBmsSnapshot(packVoltages = packVoltages)
        }
    }

    /**
     * Decode the V14 per-pack cells response prefix `02 82` followed by 32 × uint16-LE
     * millivolt values (e.g. `03 10` = 0x1003 = 4099 mV = 4.099 V).
     *
     * Returns a list of 32 voltages in volts on success, null when the prefix or length
     * doesn't match (so the caller can fall through to the legacy path).
     */
    private fun parseV14PackCells(payload: ByteArray): List<Double>? {
        if (payload.size < 2 + 64) return null
        if (payload[0] != 0x02.toByte()) return null
        if ((payload[1].toInt() and 0xFF) != 0x82) return null
        val cells = mutableListOf<Double>()
        var off = 2
        repeat(32) {
            val mv = (payload[off].toInt() and 0xFF) or
                ((payload[off + 1].toInt() and 0xFF) shl 8)
            cells.add(mv / 1000.0)
            off += 2
        }
        return cells
    }

    private fun parseV14PackCellsResponse(command: Int, payload: ByteArray) {
        val cells = parseV14PackCells(payload) ?: return
        val packIndex = (command - V14_BMS_PACK_ADDRESSES.first()) + 1
        updateBmsSnapshot(packCellVoltages = mapOf(packIndex to cells))
    }

    private fun updateBmsSnapshot(
        voltage: Double? = null,
        current: Double? = null,
        temperatures: List<Double>? = null,
        packVoltages: List<Double>? = null,
        cellVoltages: List<Double>? = null,
        packCellVoltages: Map<Int, List<Double>>? = null
    ) {
        synchronized(parseLock) {
            val currentSnapshot = lastKnownBmsSnapshot
            lastKnownBmsSnapshot = InMotionBmsSnapshot(
                voltage = voltage ?: currentSnapshot.voltage,
                current = current ?: currentSnapshot.current,
                temperatures = (temperatures ?: currentSnapshot.temperatures)?.takeIf { it.isNotEmpty() },
                packVoltages = (packVoltages ?: currentSnapshot.packVoltages)?.takeIf { it.isNotEmpty() },
                cellVoltages = (cellVoltages ?: currentSnapshot.cellVoltages)?.takeIf { it.isNotEmpty() },
                packCellVoltages = if (packCellVoltages.isNullOrEmpty()) {
                    currentSnapshot.packCellVoltages
                } else {
                    currentSnapshot.packCellVoltages + packCellVoltages
                }
            )
        }
    }

    private fun getCombinedCellVoltages(snapshot: InMotionBmsSnapshot): List<Double>? {
        if (snapshot.packCellVoltages.isNotEmpty()) {
            return snapshot.packCellVoltages
                .toSortedMap()
                .values
                .flatten()
                .takeIf { it.isNotEmpty() }
        }
        return snapshot.cellVoltages?.takeIf { it.isNotEmpty() }
    }

    private fun allowsActivePolling(): Boolean {
        return lastDetectedDialect != Dialect.LEGACY_V1
    }

    /**
     * Command dispatcher for legacy InMotion V1 wheels. Uses V1 CAN frames
     * wrapped in `AA AA … 55 55` framing (eucplanet InMotionV1Commands).
     */
    private fun createV1Command(commandType: CommandType, value: Any): ByteArray {
        return when (commandType) {
            CommandType.LIGHT_ON -> buildV1LightFrame(true)
            CommandType.LIGHT_OFF -> buildV1LightFrame(false)
            CommandType.BEEP -> buildV1HornFrame()
            CommandType.LOCK -> buildV1LockFrame(true)
            CommandType.UNLOCK -> buildV1LockFrame(false)
            CommandType.CUSTOM -> (value as? ByteArray)?.clone() ?: byteArrayOf()
            else -> byteArrayOf()
        }
    }

    private fun decodeTemperature(raw: Byte): Int = (raw.toInt() and 0xFF) + 80 - 256

    // P6 motor temp uses a signed-byte +80°C encoding in the field consumed here:
    // 0x00 -> 80°C, 0xB0(-80) -> 0°C, 0xFF(-1) -> 79°C. This intentionally
    // differs from decodeTemperature(), which assumes an unsigned-byte input.
    private fun decodeP6SignedOffset80Temperature(raw: Int): Double =
        (raw + 80).toDouble()

    override fun createCommand(commandType: CommandType, value: Any): ByteArray {
        // Route legacy V1 commands through the V1 CAN frame builder.
        if (lastDetectedDialect == Dialect.LEGACY_V1) {
            return createV1Command(commandType, value)
        }
        if (!allowsActivePolling()) return byteArrayOf()
        // While dialect is unknown, only allow the V2 probe and pre-built CUSTOM frames
        // (the V1 handshake queries) to avoid spamming V2-only requests against legacy devices.
        if (lastDetectedDialect == Dialect.UNKNOWN &&
            commandType != CommandType.REQUEST_FIRMWARE &&
            commandType != CommandType.CUSTOM) {
            return byteArrayOf()
        }
        return when (commandType) {
            // V12 HS/HT/PRO/S use a two-beam form [0x50, low, high]; the standard
            // single-byte form is silently ignored on those models (eucplanet InMotionV2Adapter).
            CommandType.LIGHT_ON -> if (isV12Model()) {
                buildMessage(FLAG_DEFAULT, COMMAND_CONTROL, byteArrayOf(0x50, 0x01, 0x01))
            } else {
                buildMessage(FLAG_DEFAULT, COMMAND_CONTROL, byteArrayOf(0x50, 0x01))
            }

            CommandType.LIGHT_OFF -> if (isV12Model()) {
                buildMessage(FLAG_DEFAULT, COMMAND_CONTROL, byteArrayOf(0x50, 0x00, 0x00))
            } else {
                buildMessage(FLAG_DEFAULT, COMMAND_CONTROL, byteArrayOf(0x50, 0x00))
            }

            CommandType.LIGHT_BRIGHTNESS -> {
                val brightness = (value as? Int)?.coerceIn(0, 100) ?: return byteArrayOf()
                buildMessage(FLAG_DEFAULT, COMMAND_CONTROL, byteArrayOf(0x2b, brightness.toByte()))
            }

            CommandType.BEEP -> buildMessage(FLAG_DEFAULT, COMMAND_CONTROL, byteArrayOf(0x18, 0x00))
            CommandType.LOCK -> buildMessage(FLAG_DEFAULT, COMMAND_CONTROL, byteArrayOf(0x31, 0x01))
            CommandType.UNLOCK -> buildMessage(
                FLAG_DEFAULT,
                COMMAND_CONTROL,
                byteArrayOf(0x31, 0x00)
            )

            CommandType.POWER_OFF -> buildMessage(
                FLAG_DEFAULT,
                COMMAND_CONTROL,
                byteArrayOf(0x77, 0x01)
            )
            // InMotion V2 returns model/serial/firmware from the same MAIN_INFO page; both queries use the same request.
            CommandType.REQUEST_SERIAL,
            CommandType.REQUEST_FIRMWARE -> buildMessage(
                FLAG_INITIAL,
                COMMAND_MAIN_INFO,
                byteArrayOf()
            )

            CommandType.REQUEST_BATTERY_INFO -> buildMessage(
                FLAG_DEFAULT,
                COMMAND_REAL_TIME_INFO,
                byteArrayOf()
            )

            CommandType.CUSTOM -> (value as? ByteArray)?.clone() ?: byteArrayOf()

            else -> byteArrayOf()
        }
    }

    override fun getPollingPlan(): ProtocolPollingPlan {
        // Legacy InMotion wheels are telemetry-push based in this library path, so
        // active polling should stay disabled once legacy dialect is identified.
        if (!allowsActivePolling()) {
            return ProtocolPollingPlan.disabled()
        }
        return ProtocolPollingPlan(
            enabled = true,
            startupQueries = listOf(
                // V1 PIN handshake: sent before any V2 probe so locked V1 wheels unlock
                // and begin streaming. V2 wheels ignore these frames (different framing).
                // eucplanet InMotionV1Adapter.initSequence() sends exactly these three
                // frames before polling begins.
                ProtocolQuerySpec(
                    id = "inmotion.v1-factory-password",
                    commandType = CommandType.CUSTOM,
                    value = buildV1FactoryPasswordFrame(),
                    initialDelayMs = 0L,
                    responseTimeoutMs = 400L,
                    maxRetries = 2
                ),
                ProtocolQuerySpec(
                    id = "inmotion.v1-pin",
                    commandType = CommandType.CUSTOM,
                    value = buildV1PinFrame(),
                    initialDelayMs = 50L,
                    responseTimeoutMs = 400L,
                    maxRetries = 2
                ),
                ProtocolQuerySpec(
                    id = "inmotion.dialect-probe",
                    commandType = CommandType.REQUEST_FIRMWARE,
                    initialDelayMs = 150L,
                    responseTimeoutMs = 800L,
                    maxRetries = 1
                )
            ),
            periodicQueries = listOf(
                ProtocolQuerySpec(
                    id = "inmotion.realtime",
                    commandType = CommandType.REQUEST_BATTERY_INFO,
                    intervalMs = 1_000L,
                    responseTimeoutMs = 1_200L,
                    maxRetries = 1
                ),
                ProtocolQuerySpec(
                    id = "inmotion.v14-pack-1-cells",
                    commandType = CommandType.CUSTOM,
                    value = buildV14PackCellsQuery(0x24),
                    initialDelayMs = 1_000L,
                    intervalMs = 4_000L,
                    responseTimeoutMs = 1_200L,
                    maxRetries = 1
                ),
                ProtocolQuerySpec(
                    id = "inmotion.v14-pack-2-cells",
                    commandType = CommandType.CUSTOM,
                    value = buildV14PackCellsQuery(0x25),
                    initialDelayMs = 2_000L,
                    intervalMs = 4_000L,
                    responseTimeoutMs = 1_200L,
                    maxRetries = 1
                ),
                ProtocolQuerySpec(
                    id = "inmotion.v14-pack-3-cells",
                    commandType = CommandType.CUSTOM,
                    value = buildV14PackCellsQuery(0x26),
                    initialDelayMs = 3_000L,
                    intervalMs = 4_000L,
                    responseTimeoutMs = 1_200L,
                    maxRetries = 1
                ),
                ProtocolQuerySpec(
                    id = "inmotion.v14-pack-4-cells",
                    commandType = CommandType.CUSTOM,
                    value = buildV14PackCellsQuery(0x27),
                    initialDelayMs = 4_000L,
                    intervalMs = 4_000L,
                    responseTimeoutMs = 1_200L,
                    maxRetries = 1
                )
            )
        )
    }

    override fun matchesQueryResponse(query: ProtocolQuerySpec, data: ByteArray): Boolean {
        if (data.size < 5 || data[0] != HEADER[0] || data[1] != HEADER[1]) return false
        val command = data[4].toInt() and 0x7F
        return when (query.commandType) {
            CommandType.REQUEST_SERIAL,
            CommandType.REQUEST_FIRMWARE -> command == COMMAND_MAIN_INFO

            CommandType.REQUEST_BATTERY_INFO ->
                command == COMMAND_REAL_TIME_INFO || command == COMMAND_TOTAL_STATS || command == COMMAND_BATTERY_INFO
            CommandType.CUSTOM -> matchesCustomQueryResponse(query, command, data)
            else -> false
        }
    }

    private fun matchesCustomQueryResponse(query: ProtocolQuerySpec, command: Int, data: ByteArray): Boolean {
        val request = query.value as? ByteArray ?: return false
        if (request.size < 7 || request[4].toInt() and 0xFF != COMMAND_MAIN_INFO) return false
        val packAddress = request[5].toInt() and 0xFF
        val subCommand = request[6].toInt() and 0xFF
        if (packAddress !in V14_BMS_PACK_ADDRESSES || subCommand != 0x02) return false
        if (command != packAddress) return false
        // When the response bit (0x80) makes cmdByte equal 0xA5 or 0xAA (the two escape-
        // trigger values), the device encodes the cmd byte as {0xA5, cmdByte} so the
        // first payload byte is at position 6; otherwise it is at position 5.
        val cmdByte = packAddress or 0x80
        val escaped = data.size > 5 &&
            (data[4].toInt() and 0xFF) == 0xA5 &&
            (data[5].toInt() and 0xFF) == cmdByte
        val payloadOffset = if (escaped) 6 else 5
        if (data.size < payloadOffset + 2) return false
        return (data[payloadOffset].toInt() and 0xFF) == 0x02 &&
            (data[payloadOffset + 1].toInt() and 0xFF) == 0x82
    }

    private fun buildV14PackCellsQuery(packAddress: Int): ByteArray =
        buildMessage(FLAG_EXTENDED, COMMAND_MAIN_INFO, byteArrayOf(packAddress.toByte(), 0x02))

    // --- InMotion V1 frame builder -----------------------------------------

    /**
     * Build a V1 BLE frame from a 32-bit CAN ID and an 8-byte payload:
     * `AA AA <escaped 16-byte CAN prefix> <escaped checksum> 55 55`.
     *
     * Ported from eucplanet InMotionV1Protocol.buildFrame() / wrap().
     */
    private fun buildV1Frame(canId: Int, data: ByteArray): ByteArray {
        require(data.size == 8) { "V1 CAN data must be 8 bytes" }
        val can = ByteArray(16)
        can[0] = (canId and 0xFF).toByte()
        can[1] = ((canId ushr 8) and 0xFF).toByte()
        can[2] = ((canId ushr 16) and 0xFF).toByte()
        can[3] = ((canId ushr 24) and 0xFF).toByte()
        data.copyInto(can, 4)
        can[12] = V1_LEN_NORMAL
        can[13] = V1_CHANNEL_PHONE
        can[14] = V1_FORMAT_STANDARD
        can[15] = V1_TYPE_DATA

        var checksum = 0
        for (b in can) checksum = (checksum + (b.toInt() and 0xFF)) and 0xFF

        val out = java.io.ByteArrayOutputStream(40)
        out.write(V1_HEADER.toInt() and 0xFF)
        out.write(V1_HEADER.toInt() and 0xFF)
        for (b in can) v1WriteEscaped(out, b)
        v1WriteEscaped(out, checksum.toByte())
        out.write(V1_TRAILER.toInt() and 0xFF)
        out.write(V1_TRAILER.toInt() and 0xFF)
        return out.toByteArray()
    }

    private fun v1WriteEscaped(out: java.io.ByteArrayOutputStream, b: Byte) {
        when (b) {
            V1_HEADER, V1_TRAILER, V1_ESCAPE -> {
                out.write(V1_ESCAPE.toInt() and 0xFF)
                out.write(b.toInt() and 0xFF)
            }
            else -> out.write(b.toInt() and 0xFF)
        }
    }

    /** Factory handshake password frame ("INMOTI"); must be sent first on every connect. */
    private fun buildV1FactoryPasswordFrame(): ByteArray = buildV1PasswordFrame(V1_FACTORY_PASSWORD)

    /** User PIN frame (default "000000"); sent after the factory password. */
    private fun buildV1PinFrame(pin: String = V1_DEFAULT_PIN): ByteArray = buildV1PasswordFrame(pin)

    private fun buildV1PasswordFrame(password: String): ByteArray {
        val data = ByteArray(8)
        for (i in 0 until minOf(6, password.length)) data[i] = password[i].code.toByte()
        return buildV1Frame(V1_CAN_PIN, data)
    }

    /** Fast-info query: fills the payload with 0xFF per the V1 spec. */
    private fun buildV1FastInfoFrame(): ByteArray =
        buildV1Frame(V1_CAN_FAST_INFO, ByteArray(8) { 0xFF.toByte() })

    /** Headlight on/off command for V1 wheels. */
    private fun buildV1LightFrame(on: Boolean): ByteArray =
        buildV1Frame(V1_CAN_HEADLIGHT, byteArrayOf(if (on) 0x01 else 0x00, 0, 0, 0, 0, 0, 0, 0))

    /** Horn / beep command for V1 wheels using the dedicated opcode (V8F / V8S / V10 / Glide 3).
     *  Wheels without the dedicated horn opcode silently ignore it. */
    private fun buildV1HornFrame(): ByteArray =
        buildV1Frame(V1_CAN_REMOTE_CTRL, byteArrayOf(0xB2.toByte(), 0, 0, 0, 0x11, 0, 0, 0))

    /** Software lock command for V1 wheels (sub-commands 0x03 / 0x04 of the remote-control group). */
    private fun buildV1LockFrame(locked: Boolean): ByteArray =
        buildV1Frame(V1_CAN_REMOTE_CTRL,
            byteArrayOf(0xB2.toByte(), 0, 0, 0, if (locked) 0x03 else 0x04, 0, 0, 0))

    private fun buildMessage(flag: Int, command: Int, data: ByteArray): ByteArray {
        val len = data.size + 1
        val body = ByteArray(3 + data.size)
        body[0] = flag.toByte()
        body[1] = len.toByte()
        body[2] = command.toByte()
        if (data.isNotEmpty()) data.copyInto(body, destinationOffset = 3)

        var xor = 0
        for (b in body) xor = xor xor (b.toInt() and 0xFF)
        val checksum = xor.toByte()

        return HEADER + body + byteArrayOf(checksum)
    }

    override fun isDeviceReady(data: EUCData): Boolean {
        if (data.voltage <= BLEConstants.MIN_READY_VOLTAGE_V || data.batteryLevel <= 0) return false
        return when (lastDetectedDialect) {
            Dialect.V2 -> hasSeenV2MainInfo && hasSeenV2Realtime
            Dialect.LEGACY_V1 -> hasSeenLegacyRealtime
            Dialect.UNKNOWN -> false
        }
    }

    override fun getBMSData(): List<BMSData>? {
        val snapshot = lastKnownBmsSnapshot
        val voltage = snapshot.voltage
        val current = snapshot.current
        val temperatures = snapshot.temperatures
        val packVoltages = snapshot.packVoltages
        val cellVoltages = getCombinedCellVoltages(snapshot)
        val packCellVoltages = snapshot.packCellVoltages
        if (voltage == null && current == null && temperatures.isNullOrEmpty()
            && packVoltages.isNullOrEmpty() && cellVoltages.isNullOrEmpty()
        ) {
            return null
        }
        if (packCellVoltages.isNotEmpty()) {
            return packCellVoltages.toSortedMap().map { (index, cells) ->
                BMSData(
                    bmsIndex = index,
                    voltage = packVoltages?.getOrNull(index - 1),
                    current = if (index == 1) current else null,
                    remainingCapacity = null,
                    factoryCapacity = null,
                    cycles = null,
                    temperatures = if (index == 1) temperatures?.takeIf { it.isNotEmpty() } else null,
                    cellVoltages = cells
                )
            }
        }
        // V14 per-cell path: return one BMSData entry with all 32 cell voltages.
        if (!cellVoltages.isNullOrEmpty()) {
            return listOf(
                BMSData(
                    bmsIndex = 1,
                    voltage = voltage,
                    current = current,
                    remainingCapacity = null,
                    factoryCapacity = null,
                    cycles = null,
                    temperatures = temperatures?.takeIf { it.isNotEmpty() },
                    cellVoltages = cellVoltages
                )
            )
        }
        if (!packVoltages.isNullOrEmpty()) {
            return packVoltages.mapIndexed { index, packVoltage ->
                BMSData(
                    bmsIndex = index + 1,
                    voltage = packVoltage,
                    current = if (index == 0) current else null,
                    remainingCapacity = null,
                    factoryCapacity = null,
                    cycles = null,
                    temperatures = if (index == 0) temperatures?.takeIf { it.isNotEmpty() } else null,
                    cellVoltages = null
                )
            }
        }
        return listOf(
            BMSData(
                bmsIndex = 1,
                voltage = voltage,
                current = current,
                remainingCapacity = null,
                factoryCapacity = null,
                cycles = null,
                temperatures = temperatures?.takeIf { it.isNotEmpty() },
                cellVoltages = null
            )
        )
    }

    override fun close() {
        // No resources to clean up
    }
}