# Copyright 2026, Jiri Eischmann <jiri@eischmann.cz> and the meshy contributors
# SPDX-License-Identifier: GPL-3.0-or-later

"""MeshCore protocol - sync wrappers using meshcore library's packets and parsing logic.

Uses meshcore.packets for constants and follows meshcore.reader's parsing format
for field names and response handling.
"""

import hashlib
import io
import struct
from datetime import datetime

from meshy.meshcore_packets import CommandType, ControlType, PacketType
from meshy.smaz import try_decode_prefixed as _smaz_decode

# Re-export constants that the rest of the app uses
CMD_APP_START = CommandType.APP_START.value
CMD_DEVICE_QUERY = CommandType.DEVICE_QUERY.value
CMD_GET_CONTACTS = CommandType.GET_CONTACTS.value
CMD_GET_DEVICE_TIME = CommandType.GET_DEVICE_TIME.value
CMD_SET_DEVICE_TIME = CommandType.SET_DEVICE_TIME.value
CMD_SEND_SELF_ADVERT = CommandType.SEND_SELF_ADVERT.value
CMD_SET_ADVERT_NAME = CommandType.SET_ADVERT_NAME.value
CMD_ADD_UPDATE_CONTACT = CommandType.ADD_UPDATE_CONTACT.value
CMD_SYNC_NEXT_MESSAGE = CommandType.SYNC_NEXT_MESSAGE.value
CMD_SET_RADIO_PARAMS = CommandType.SET_RADIO_PARAMS.value
CMD_SET_RADIO_TX_POWER = CommandType.SET_RADIO_TX_POWER.value
CMD_RESET_PATH = CommandType.RESET_PATH.value
CMD_SET_ADVERT_LATLON = CommandType.SET_ADVERT_LATLON.value
CMD_REMOVE_CONTACT = CommandType.REMOVE_CONTACT.value
CMD_EXPORT_CONTACT = CommandType.EXPORT_CONTACT.value
CMD_IMPORT_CONTACT = CommandType.IMPORT_CONTACT.value
CMD_REBOOT = 19
CMD_GET_BATT_AND_STORAGE = CommandType.GET_BATT_AND_STORAGE.value
CMD_SEND_LOGIN = CommandType.SEND_LOGIN.value
CMD_SEND_STATUS_REQ = CommandType.SEND_STATUS_REQ.value
CMD_GET_CHANNEL = CommandType.GET_CHANNEL.value
CMD_SET_CHANNEL = CommandType.SET_CHANNEL.value
CMD_SEND_TRACE_PATH = CommandType.SEND_TRACE_PATH.value
CMD_SET_OTHER_PARAMS = CommandType.SET_OTHER_PARAMS.value
CMD_SEND_TXT_MSG = CommandType.SEND_TXT_MSG.value
CMD_SEND_CHANNEL_TXT_MSG = CommandType.SEND_CHANNEL_TXT_MSG.value
CMD_GET_AUTO_ADD_CONFIG = CommandType.GET_AUTOADD_CONFIG.value
CMD_SET_AUTO_ADD_CONFIG = CommandType.SET_AUTOADD_CONFIG.value
CMD_SEND_BINARY_REQ = CommandType.BINARY_REQ.value
CMD_GET_CUSTOM_VAR = CommandType.GET_CUSTOM_VARS.value
CMD_SET_CUSTOM_VAR = CommandType.SET_CUSTOM_VAR.value
CMD_GET_STATS = 56
CMD_SET_DEVICE_PIN = CommandType.SET_DEVICE_PIN.value
CMD_SET_PATH_HASH_MODE = CommandType.SET_PATH_HASH_MODE.value

# Response/push codes from meshcore.packets.PacketType
RESP_CODE_OK = PacketType.OK.value
RESP_CODE_ERR = PacketType.ERROR.value
RESP_CODE_CONTACTS_START = PacketType.CONTACT_START.value
RESP_CODE_CONTACT = PacketType.CONTACT.value
RESP_CODE_END_OF_CONTACTS = PacketType.CONTACT_END.value
RESP_CODE_SELF_INFO = PacketType.SELF_INFO.value
RESP_CODE_SENT = PacketType.MSG_SENT.value
RESP_CODE_CONTACT_MSG_RECV = PacketType.CONTACT_MSG_RECV.value
RESP_CODE_CHANNEL_MSG_RECV = PacketType.CHANNEL_MSG_RECV.value
RESP_CODE_CURR_TIME = PacketType.CURRENT_TIME.value
RESP_CODE_NO_MORE_MESSAGES = PacketType.NO_MORE_MSGS.value
RESP_CODE_EXPORT_CONTACT = PacketType.CONTACT_URI.value
RESP_CODE_BINARY_RESPONSE = PacketType.BINARY_RESPONSE.value
RESP_CODE_BATT_AND_STORAGE = PacketType.BATTERY.value
RESP_CODE_DEVICE_INFO = PacketType.DEVICE_INFO.value
RESP_CODE_CONTACT_MSG_RECV_V3 = 16
RESP_CODE_CHANNEL_MSG_RECV_V3 = 17
RESP_CODE_CHANNEL_INFO = PacketType.CHANNEL_INFO.value
RESP_CODE_AUTO_ADD_CONFIG = PacketType.AUTOADD_CONFIG.value
RESP_CODE_CHANNEL_DATA_RECV = PacketType.CHANNEL_DATA_RECV.value
RESP_CODE_DEFAULT_FLOOD_SCOPE = PacketType.DEFAULT_FLOOD_SCOPE.value
RESP_CODE_STATS = PacketType.STATS.value
RESP_CODE_ADVERT_PATH = PacketType.ADVERT_PATH.value

PUSH_CODE_ADVERT = PacketType.ADVERTISEMENT.value
PUSH_CODE_PATH_UPDATED = PacketType.PATH_UPDATE.value
PUSH_CODE_SEND_CONFIRMED = PacketType.ACK.value
PUSH_CODE_MSG_WAITING = PacketType.MESSAGES_WAITING.value
PUSH_CODE_NEW_ADVERT = PacketType.PUSH_CODE_NEW_ADVERT.value
PUSH_CODE_TRACE_DATA = PacketType.TRACE_DATA.value
PUSH_CODE_LOG_DATA = PacketType.LOG_DATA.value  # 0x88 - raw radio packet
PUSH_CODE_TELEMETRY_RESPONSE = PacketType.TELEMETRY_RESPONSE.value
PUSH_CODE_PATH_DISCOVERY_RESPONSE = PacketType.PATH_DISCOVERY_RESPONSE.value  # 0x8D
PUSH_CODE_LOGIN_SUCCESS = PacketType.LOGIN_SUCCESS.value  # 0x85
PUSH_CODE_LOGIN_FAILED = PacketType.LOGIN_FAILED.value  # 0x86
PUSH_CODE_STATUS_RESPONSE = PacketType.STATUS_RESPONSE.value  # 0x87

# Text types
TXT_TYPE_PLAIN = 0
TXT_TYPE_CLI_DATA = 1
TXT_TYPE_SIGNED = 2

# Contact types
ADV_TYPE_CHAT = 1
ADV_TYPE_REPEATER = 2
ADV_TYPE_ROOM = 3
ADV_TYPE_SENSOR = 4

# Sizes
PUB_KEY_SIZE = 32
MAX_PATH_SIZE = 64
MAX_NAME_SIZE = 32
MAX_FRAME_SIZE = 172
MAX_TEXT_PAYLOAD = 160  # firmware payload limit for text messages
APP_PROTOCOL_VERSION = 4

# Message overhead: cmd(1) + type(1) + attempt(1) + ts(4) + pubkey(6) + null(1) + safety(2)
_DM_OVERHEAD = 16
# Channel overhead: cmd(1) + type(1) + chan_idx(1) + ts(4) + null(1) + safety(2)
_CHAN_OVERHEAD = 10


def max_dm_text_bytes() -> int:
    """Max UTF-8 bytes for a direct message."""
    return min(MAX_FRAME_SIZE - _DM_OVERHEAD, MAX_TEXT_PAYLOAD)


def max_channel_text_bytes(sender_name: str) -> int:
    """Max UTF-8 bytes for a channel message (firmware prepends 'name: ')."""
    prefix_len = len(sender_name.encode("utf-8")) + 2  # ": " appended
    return min(MAX_TEXT_PAYLOAD - prefix_len, MAX_FRAME_SIZE - _CHAN_OVERHEAD)


# Binary request types
REQ_TYPE_STATUS = 0x01
REQ_TYPE_TELEMETRY = 0x03
REQ_TYPE_ACL = 0x05
REQ_TYPE_GET_NEIGHBORS = 0x06

# Control types (for zero-hop discovery)
CTRL_NODE_DISCOVER_REQ = ControlType.NODE_DISCOVER_REQ.value  # 0x80
CTRL_NODE_DISCOVER_RESP = ControlType.NODE_DISCOVER_RESP.value  # 0x90

# Response code for control data
RESP_CODE_CONTROL_DATA = PacketType.CONTROL_DATA.value  # 0x8E

CMD_SEND_CONTROL_DATA = CommandType.SEND_CONTROL_DATA.value  # 55

# Auto-add flags
AUTO_ADD_OVERWRITE_OLDEST = 1 << 0
AUTO_ADD_CHAT = 1 << 1
AUTO_ADD_REPEATER = 1 << 2
AUTO_ADD_ROOM_SERVER = 1 << 3
AUTO_ADD_SENSOR = 1 << 4

# Stats sub-types
STATS_TYPE_CORE = 0
STATS_TYPE_RADIO = 1
STATS_TYPE_PACKETS = 2

# Error codes from companion firmware (MyMesh.cpp)
ERROR_CODES = {
    0x01: "Unsupported command",
    0x02: "Not found",
    0x03: "Table full",
    0x04: "Bad state",
    0x05: "File I/O error",
    0x06: "Illegal argument",
}


def _now_ts() -> int:
    return int(datetime.now().timestamp())


def _decode_cstring(data: bytes) -> str:
    try:
        idx = data.index(0)
        return data[:idx].decode("utf-8", errors="replace")
    except ValueError:
        return data.decode("utf-8", errors="replace")


def _parse_path_length_byte(plen: int) -> tuple[int, int]:
    if plen == 255:
        return (-1, 1)
    return (plen & 0x3F, ((plen >> 6) & 0x03) + 1)


def _parse_snr(data: bytes) -> float:
    return int.from_bytes(data, byteorder="little", signed=True) / 4.0


def _parse_latlon(dbuf) -> tuple[float, float]:
    lat = int.from_bytes(dbuf.read(4), byteorder="little", signed=True) / 1e6
    lon = int.from_bytes(dbuf.read(4), byteorder="little", signed=True) / 1e6
    return (lat, lon)


def _build_scope_key(scope: str) -> bytes:
    if not scope or scope in ("0", "None", "*", ""):
        return bytes(16)
    if not scope.startswith("#"):
        scope = "#" + scope
    return hashlib.sha256(scope.encode("utf-8")).digest()[:16]


# ─── Frame Builders ───────────────────────────────────────────────
# Following meshcore_py commands/* patterns for frame construction


def build_app_start(app_name: str = "Meshy", app_version: int = APP_PROTOCOL_VERSION) -> bytes:
    return bytes([CMD_APP_START, app_version]) + bytes(6) + app_name.encode("utf-8") + b"\x00"


def build_device_query(app_version: int = APP_PROTOCOL_VERSION) -> bytes:
    return bytes([CMD_DEVICE_QUERY, app_version])


def build_get_contacts(since: int | None = None) -> bytes:
    frame = bytes([CMD_GET_CONTACTS])
    if since is not None:
        frame += struct.pack("<I", since)
    return frame


def build_get_device_time() -> bytes:
    return bytes([CMD_GET_DEVICE_TIME])


def build_set_device_time(timestamp: int | None = None) -> bytes:
    if timestamp is None:
        timestamp = _now_ts()
    return bytes([CMD_SET_DEVICE_TIME]) + struct.pack("<I", timestamp)


def build_get_batt_and_storage() -> bytes:
    return bytes([CMD_GET_BATT_AND_STORAGE])


def build_sync_next_message() -> bytes:
    return bytes([CMD_SYNC_NEXT_MESSAGE])


def build_send_text_msg(
    recipient_pub_key: bytes, text: str, attempt: int = 0, timestamp: int | None = None
) -> bytes:
    if timestamp is None:
        timestamp = _now_ts()
    frame = bytes([CMD_SEND_TXT_MSG, TXT_TYPE_PLAIN, min(attempt, 255)])
    frame += struct.pack("<I", timestamp)
    frame += recipient_pub_key[:6]
    frame += text.encode("utf-8") + b"\x00"
    return frame


def build_send_channel_text_msg(
    channel_index: int, text: str, timestamp: int | None = None
) -> bytes:
    if timestamp is None:
        timestamp = _now_ts()
    frame = bytes([CMD_SEND_CHANNEL_TXT_MSG, TXT_TYPE_PLAIN, channel_index])
    frame += struct.pack("<I", timestamp)
    frame += text.encode("utf-8") + b"\x00"
    return frame


def build_send_cli_command(
    repeater_pub_key: bytes, command: str, attempt: int = 0, timestamp: int | None = None
) -> bytes:
    if timestamp is None:
        timestamp = _now_ts()
    frame = bytes([CMD_SEND_TXT_MSG, TXT_TYPE_CLI_DATA, min(attempt, 255)])
    frame += struct.pack("<I", timestamp)
    frame += repeater_pub_key[:6]
    frame += command.encode("utf-8") + b"\x00"
    return frame


def build_send_self_advert(flood: bool = False) -> bytes:
    if flood:
        return bytes([CMD_SEND_SELF_ADVERT, 1])
    return bytes([CMD_SEND_SELF_ADVERT])


def build_set_advert_name(name: str) -> bytes:
    return bytes([CMD_SET_ADVERT_NAME]) + name.encode("utf-8")[: MAX_NAME_SIZE - 1]


def build_set_advert_latlon(lat: float, lon: float) -> bytes:
    frame = bytes([CMD_SET_ADVERT_LATLON])
    frame += struct.pack("<i", round(lat * 1e6))
    frame += struct.pack("<i", round(lon * 1e6))
    frame += struct.pack("<i", 0)  # altitude (reserved)
    return frame


def build_remove_contact(pub_key: bytes) -> bytes:
    return bytes([CMD_REMOVE_CONTACT]) + pub_key


def build_add_update_contact(
    pub_key: bytes,
    contact_type: int = ADV_TYPE_CHAT,
    flags: int = 0,
    path_len: int = 0,
    path: bytes = b"",
    name: str = "",
    lat: float | None = None,
    lon: float | None = None,
    last_modified: int | None = None,
) -> bytes:
    frame = bytes([CMD_ADD_UPDATE_CONTACT])
    frame += pub_key
    frame += bytes([contact_type, flags, path_len])
    path_padded = bytearray(MAX_PATH_SIZE)
    path_padded[: len(path)] = path[:MAX_PATH_SIZE]
    frame += bytes(path_padded)
    name_bytes = name.encode("utf-8")[: MAX_NAME_SIZE - 1]
    frame += name_bytes + b"\x00" * (MAX_NAME_SIZE - len(name_bytes))
    frame += struct.pack("<I", _now_ts())
    has_location = lat is not None and lon is not None
    if has_location or last_modified is not None:
        frame += struct.pack("<i", round(lat * 1e6) if has_location else 0)
        frame += struct.pack("<i", round(lon * 1e6) if has_location else 0)
        if last_modified is not None:
            frame += struct.pack("<I", last_modified)
    return frame


def build_reset_path(pub_key: bytes) -> bytes:
    return bytes([CMD_RESET_PATH]) + pub_key


def build_export_contact(pub_key: bytes) -> bytes:
    return bytes([CMD_EXPORT_CONTACT]) + pub_key


def build_import_contact(contact_frame: bytes) -> bytes:
    return bytes([CMD_IMPORT_CONTACT]) + contact_frame


def build_get_channel(channel_index: int) -> bytes:
    return bytes([CMD_GET_CHANNEL, channel_index])


def build_set_channel(channel_index: int, name: str, psk: bytes) -> bytes:
    name_bytes = name.encode("utf-8")[:32]
    name_bytes = name_bytes.ljust(32, b"\x00")
    psk_padded = bytearray(16)
    psk_padded[: len(psk)] = psk[:16]
    return bytes([CMD_SET_CHANNEL, channel_index]) + name_bytes + bytes(psk_padded)


def build_set_radio_params(
    freq_hz: int, bw_hz: int, sf: int, cr: int, client_repeat: bool | None = None
) -> bytes:
    # freq_hz and bw_hz are in kHz (matching meshcore_py: freq*1000, bw*1000)
    frame = bytes([CMD_SET_RADIO_PARAMS])
    frame += struct.pack("<I", freq_hz)
    frame += struct.pack("<I", bw_hz)
    frame += bytes([sf, cr])
    if client_repeat is not None:
        frame += bytes([1 if client_repeat else 0])
    return frame


def build_set_radio_tx_power(power_dbm: int) -> bytes:
    return bytes([CMD_SET_RADIO_TX_POWER]) + struct.pack("<i", power_dbm)[:4]


def build_reboot() -> bytes:
    return bytes([CMD_REBOOT]) + b"reboot"


def build_factory_reset() -> bytes:
    return bytes([CommandType.FACTORY_RESET.value]) + b"reset"


def build_set_path_hash_mode(mode: int) -> bytes:
    """Set path hash mode: 0=1-byte, 1=2-byte, 2=3-byte."""
    return bytes([CMD_SET_PATH_HASH_MODE, 0, mode])


def build_set_device_pin(pin: int) -> bytes:
    return bytes([CMD_SET_DEVICE_PIN]) + struct.pack("<I", pin)


def build_set_other_params(
    manual_add_contacts: int = 0,
    telemetry_flags: int = 0,
    advert_loc_policy: int = 0,
    multi_acks: int = 0,
) -> bytes:
    """Set other params.

    Format: [0x26][manual_add_contacts][telemetry_mode][advert_loc_policy][multi_acks]
    """
    return bytes(
        [CMD_SET_OTHER_PARAMS, manual_add_contacts, telemetry_flags, advert_loc_policy, multi_acks]
    )


def build_get_custom_vars() -> bytes:
    return bytes([CMD_GET_CUSTOM_VAR])


def build_set_custom_var(key_value: str) -> bytes:
    """Set a custom variable. Format: 'key:value' (e.g. 'gps:1')."""
    return bytes([CMD_SET_CUSTOM_VAR]) + key_value.encode("utf-8") + b"\x00"


RESP_CODE_CUSTOM_VARS = PacketType.CUSTOM_VARS.value


def parse_custom_vars(data: bytes) -> dict:
    """Parse CUSTOM_VARS response. Format: 'key1:val1,key2:val2,...'"""
    if len(data) < 2 or data[0] != RESP_CODE_CUSTOM_VARS:
        return {}
    raw = data[1:].decode("utf-8", "ignore").rstrip("\x00")
    if not raw:
        return {}
    result = {}
    for pair in raw.split(","):
        parts = pair.split(":", 1)
        if len(parts) == 2:
            result[parts[0]] = parts[1]
    return result


def build_set_flood_scope(scope: str = "", unscoped: bool = False) -> bytes:
    """Set session flood scope (cmd 54). Empty string or '*' to disable, '#name' for hashtag scope.

    When *unscoped* is True (fw v12+), sends with byte[1]=1 to explicitly
    override the default scope for this message.
    """
    cmd = CommandType.SET_FLOOD_SCOPE.value
    flags = 1 if unscoped else 0
    return bytes([cmd, flags]) + _build_scope_key(scope)


def build_set_default_flood_scope(scope: str = "") -> bytes:
    """Set persistent default flood scope (cmd 63). Empty to clear, '#name' for hashtag scope."""
    cmd = CommandType.SET_DEFAULT_FLOOD_SCOPE.value
    scope_key = _build_scope_key(scope)
    if scope_key == bytes(16):
        return bytes([cmd])
    if not scope.startswith("#"):
        scope = "#" + scope
    scope_name = scope.encode("utf-8")[:31].ljust(31, b"\x00")
    return bytes([cmd]) + scope_name + scope_key


def build_get_advert_path(pub_key: bytes) -> bytes:
    """Get the advertisement path cached for a contact. Uses full 32-byte key."""
    return bytes([CommandType.GET_ADVERT_PATH.value, 0]) + pub_key[:PUB_KEY_SIZE]


def build_path_discovery(pub_key: bytes) -> bytes:
    """Send a path discovery request for a contact."""
    return bytes([CommandType.PATH_DISCOVERY.value, 0]) + pub_key


def parse_advert_path_response(data: bytes) -> dict | None:
    """Parse RESP_CODE_ADVERT_PATH (0x16).
    Format: [code][timestamp×4 LE][path_len_byte][path_bytes...]
    """
    if len(data) < 6 or data[0] != PacketType.ADVERT_PATH.value:
        return None
    timestamp = struct.unpack_from("<I", data, 1)[0]
    path_len_byte = data[5]
    if path_len_byte == 255:
        return {"path_length": -1, "path": b"", "path_hash_size": 1, "timestamp": timestamp}
    hop_count = path_len_byte & 0x3F
    hash_size = ((path_len_byte >> 6) & 0x03) + 1
    path_byte_len = hop_count * hash_size
    path_data = data[6 : 6 + path_byte_len] if path_byte_len > 0 else b""
    return {
        "path_length": hop_count,
        "path_hash_size": hash_size,
        "path": path_data,
        "timestamp": timestamp,
        "hops": [
            path_data[i : i + hash_size].hex().upper() for i in range(0, len(path_data), hash_size)
        ]
        if path_data
        else [],
    }


def build_trace_path(tag: int, path_bytes: bytes, flags: int = 0) -> bytes:
    """Build a SEND_TRACE_PATH request.
    Format: [cmd][tag×4 LE][auth×4 LE][flag][path_hops...]
    flags encodes path hash size: 0=1-byte, 1=2-byte, 2=4-byte, 3=8-byte.
    """
    frame = bytes([CMD_SEND_TRACE_PATH])
    frame += struct.pack("<I", tag)
    frame += struct.pack("<I", 0)  # auth
    frame += bytes([flags])
    frame += path_bytes
    return frame


def parse_trace_response(data: bytes) -> dict | None:
    """Parse PUSH_CODE_TRACE_DATA (0x89) response.
    Format: [code][reserved][path_byte_len][flags][tag×4][auth×4][path_data...][snr_values...]
    flags lower 2 bits encode hash size: 0→1B, 1→2B, 2→4B, 3→8B.
    """
    if len(data) < 12 or data[0] != PUSH_CODE_TRACE_DATA:
        return None
    path_byte_len = data[2]
    flags = data[3]
    path_hash_size = 1 << (flags & 3)
    hop_count = path_byte_len // path_hash_size if path_hash_size else path_byte_len
    tag = data[4:8]
    # path data starts at offset 12
    offset = 12
    if offset + path_byte_len > len(data):
        return None
    path_data = data[offset : offset + path_byte_len]
    snr_raw = data[offset + path_byte_len :]
    snr_values = [_parse_snr(bytes([b])) for b in snr_raw]
    # Split path into per-hop hashes
    path_hops = [
        path_data[i : i + path_hash_size]
        for i in range(0, hop_count * path_hash_size, path_hash_size)
    ]
    return {
        "tag": tag,
        "path_hops": path_hops,
        "path_hash_size": path_hash_size,
        "snr_values": snr_values,
    }


def build_send_login(recipient_pub_key: bytes, password: str) -> bytes:
    return bytes([CMD_SEND_LOGIN]) + recipient_pub_key + password.encode("utf-8") + b"\x00"


def build_send_status_req(recipient_pub_key: bytes) -> bytes:
    return bytes([CMD_SEND_STATUS_REQ]) + recipient_pub_key


def build_get_auto_add_config() -> bytes:
    return bytes([CMD_GET_AUTO_ADD_CONFIG])


def build_set_auto_add_config(
    chat: bool = False,
    repeater: bool = False,
    room: bool = False,
    sensor: bool = False,
    overwrite_oldest: bool = False,
    max_hops: int = 0,
) -> bytes:
    flags = 0
    if chat:
        flags |= AUTO_ADD_CHAT
    if repeater:
        flags |= AUTO_ADD_REPEATER
    if room:
        flags |= AUTO_ADD_ROOM_SERVER
    if sensor:
        flags |= AUTO_ADD_SENSOR
    if overwrite_oldest:
        flags |= AUTO_ADD_OVERWRITE_OLDEST
    return bytes([CMD_SET_AUTO_ADD_CONFIG, flags, min(max_hops, 64)])


def build_send_discover_req(
    type_filter: int = 0xFF, prefix_only: bool = True, tag: int | None = None
) -> bytes:
    """Send a zero-hop NODE_DISCOVER_REQ control packet.

    Args:
        type_filter: bitmask of ADV_TYPE_* bits (0xFF = all types)
        prefix_only: if True, responders send 8-byte key prefix; if False, full 32-byte key
        tag: random correlation tag (auto-generated if None)
    """
    import random

    if tag is None:
        tag = random.randint(0, 0xFFFFFFFF)
    flags = CTRL_NODE_DISCOVER_REQ  # 0x80 = upper nibble is sub_type 8
    if prefix_only:
        flags |= 0x01  # bit 0 = prefix_only
    frame = bytes([CMD_SEND_CONTROL_DATA, flags, type_filter])
    frame += struct.pack("<I", tag)
    return frame


def parse_discover_response(data: bytes) -> dict | None:
    """Parse a NODE_DISCOVER_RESP from a CONTROL_DATA push (0x8E).

    Companion protocol format:
      0x8E + 2 prefix bytes + path_len(1) + path + flags(1) + snr(1) + tag(4) + pubkey
    """
    if len(data) < 8:
        return None
    # data[0] = 0x8E, data[1:3] = prefix bytes, data[3] = path_length
    offset = 3  # skip code + 2 prefix bytes
    path_len_byte = data[offset]
    hop_count = path_len_byte & 0x3F
    hash_size = ((path_len_byte >> 6) & 0x03) + 1
    offset += 1
    offset += hop_count * hash_size  # skip path hops

    if offset + 6 > len(data):  # need flags + snr + tag(4)
        return None

    flags = data[offset]
    sub_type = (flags >> 4) & 0x0F
    if sub_type != 0x09:  # DISCOVER_RESP sub_type = 9
        return None

    node_type = flags & 0x0F
    snr = _parse_snr(data[offset + 1 : offset + 2])
    tag = int.from_bytes(data[offset + 2 : offset + 6], "little", signed=False)
    pub_key_hex = data[offset + 6 :].hex()

    return {
        "node_type": node_type,
        "snr": snr,
        "tag": tag,
        "pub_key_hex": pub_key_hex,
    }


def build_get_neighbors(
    repeater_pub_key: bytes, max_results: int = 10, offset: int = 0, key_len: int = 4
) -> bytes:
    """Build a binary request to get neighbors from a repeater."""
    frame = bytes([CMD_SEND_BINARY_REQ])
    frame += repeater_pub_key
    frame += bytes(
        [
            REQ_TYPE_GET_NEIGHBORS,
            0x00,  # version/reserved
            max_results,  # max number of neighbors
        ]
    )
    frame += struct.pack("<H", offset)  # offset (16-bit LE)
    frame += bytes(
        [
            0x00,  # order by
            key_len,  # public key prefix length
        ]
    )
    return frame


def build_get_acl(repeater_pub_key: bytes) -> bytes:
    """Build a binary request to get ACL from a repeater."""
    frame = bytes([CMD_SEND_BINARY_REQ])
    frame += repeater_pub_key
    frame += bytes([REQ_TYPE_ACL, 0x00, 0x00])
    return frame


def build_get_telemetry(repeater_pub_key: bytes) -> bytes:
    """Build a binary request to get telemetry from a repeater/sensor."""
    frame = bytes([CMD_SEND_BINARY_REQ])
    frame += repeater_pub_key
    frame += bytes([REQ_TYPE_TELEMETRY])
    return frame


def build_get_telemetry_chat(pub_key: bytes = None) -> bytes:
    """Build a telemetry request for a chat companion node.

    Uses CMD_SEND_TELEMETRY_REQ (0x27) instead of CMD_SEND_BINARY_REQ.
    Response arrives as PUSH_CODE_TELEMETRY_RESPONSE (0x8B).
    If pub_key is None, requests self-telemetry from the connected device.
    """
    cmd = CommandType.SEND_TELEMETRY_REQ.value
    frame = bytes([cmd])
    if pub_key and len(pub_key) >= 32:
        frame += bytes(3)  # reserved
        frame += pub_key[:32]
    else:
        frame += bytes(3)  # reserved (self-telemetry)
    return frame


ANON_REQ_REGIONS = 0x01


def build_anon_req_regions(pub_key: bytes, path_length: int, path: bytes) -> bytes:
    """Build an anonymous request for regions from a repeater.

    Format: [CMD_SEND_ANON_REQ][pub_key_32][req_type][path_len][path_reversed]
    """
    cmd = CommandType.SEND_ANON_REQ.value
    frame = bytes([cmd])
    frame += pub_key[:32]
    frame += bytes([ANON_REQ_REGIONS])
    frame += bytes([path_length])
    frame += bytes(reversed(path[:path_length])) if path_length > 0 else b""
    return frame


def parse_status_response(data: bytes) -> dict | None:
    """Parse PUSH_CODE_STATUS_RESPONSE (0x87) — 52/56-byte binary status.
    Frame: [code(1)][reserved(1)][sender_prefix(6)][status_data(52+)]
    """
    if len(data) < 60:  # 8 header + 52 payload
        return None
    # Skip 8 bytes: code(1) + reserved(1) + sender_prefix(6)
    d = data[8:]
    return {
        "battery_mv": struct.unpack("<H", d[0:2])[0],
        "queue_len": struct.unpack("<H", d[2:4])[0],
        "noise_floor": struct.unpack("<h", d[4:6])[0],
        "last_rssi": struct.unpack("<h", d[6:8])[0],
        "packets_recv": struct.unpack("<I", d[8:12])[0],
        "packets_sent": struct.unpack("<I", d[12:16])[0],
        "tx_air_secs": struct.unpack("<I", d[16:20])[0],
        "uptime_secs": struct.unpack("<I", d[20:24])[0],
        "flood_tx": struct.unpack("<I", d[24:28])[0],
        "direct_tx": struct.unpack("<I", d[28:32])[0],
        "flood_rx": struct.unpack("<I", d[32:36])[0],
        "direct_rx": struct.unpack("<I", d[36:40])[0],
        "error_events": struct.unpack("<H", d[40:42])[0],
        "last_snr": struct.unpack("<h", d[42:44])[0] / 4.0,
        "dup_direct": struct.unpack("<H", d[44:46])[0] if len(d) >= 46 else 0,
        "dup_flood": struct.unpack("<H", d[46:48])[0] if len(d) >= 48 else 0,
        "rx_air_secs": struct.unpack("<I", d[48:52])[0] if len(d) >= 52 else 0,
        "recv_errors": struct.unpack("<I", d[52:56])[0] if len(d) >= 56 else None,
    }


def parse_neighbors_response(data: bytes, key_len: int = 4) -> dict:
    """Parse a neighbors binary response (0x8C).
    Frame: [code(1)][reserved(1)][tag(4)][total_count(2 LE)][results_count(2 LE)][entries...]
    Each entry: key_prefix(key_len) + last_heard(4 LE) + snr(int8).
    Returns dict with 'total', 'neighbors' list.
    """
    if len(data) < 10:
        return {"total": 0, "neighbors": []}
    # Skip 6-byte header: code(1) + reserved(1) + tag(4)
    total_count = struct.unpack("<H", data[6:8])[0]
    results_count = struct.unpack("<H", data[8:10])[0]
    buf = data[10:]
    neighbors = []
    entry_size = key_len + 4 + 1
    for _ in range(results_count):
        if len(buf) < entry_size:
            break
        key_prefix = buf[:key_len].hex()
        last_heard = struct.unpack("<I", buf[key_len : key_len + 4])[0]
        snr = _parse_snr(buf[key_len + 4 : key_len + 5])
        neighbors.append(
            {
                "pub_key_hex": key_prefix,
                "last_heard": last_heard,
                "snr": snr,
            }
        )
        buf = buf[entry_size:]
    return {"total": total_count, "neighbors": neighbors}


def build_get_stats(sub_type: int = STATS_TYPE_CORE) -> bytes:
    """Build a stats request. sub_type: 0=Core, 1=Radio, 2=Packets."""
    return bytes([CMD_GET_STATS, sub_type])


def parse_stats_response(data: bytes) -> dict | None:
    """Parse stats response (code 0x18). Byte 1 is sub-type."""
    if len(data) < 4:
        return None
    sub_type = data[1]
    result = {"sub_type": sub_type}

    if sub_type == STATS_TYPE_CORE and len(data) >= 11:
        result["battery_mv"] = int.from_bytes(data[2:4], "little", signed=False)
        result["uptime_secs"] = int.from_bytes(data[4:8], "little", signed=False)
        result["errors"] = int.from_bytes(data[8:10], "little", signed=False)
        result["queue_len"] = data[10]

    elif sub_type == STATS_TYPE_RADIO and len(data) >= 14:
        result["noise_floor"] = int.from_bytes(data[2:4], "little", signed=True)
        result["last_rssi"] = int.from_bytes(data[4:5], "little", signed=True)
        result["last_snr"] = _parse_snr(data[5:6])
        result["tx_air_secs"] = int.from_bytes(data[6:10], "little", signed=False)
        result["rx_air_secs"] = int.from_bytes(data[10:14], "little", signed=False)

    elif sub_type == STATS_TYPE_PACKETS and len(data) >= 26:
        result["recv"] = int.from_bytes(data[2:6], "little", signed=False)
        result["sent"] = int.from_bytes(data[6:10], "little", signed=False)
        result["flood_tx"] = int.from_bytes(data[10:14], "little", signed=False)
        result["direct_tx"] = int.from_bytes(data[14:18], "little", signed=False)
        result["flood_rx"] = int.from_bytes(data[18:22], "little", signed=False)
        result["direct_rx"] = int.from_bytes(data[22:26], "little", signed=False)
        if len(data) >= 30:
            result["recv_errors"] = int.from_bytes(data[26:30], "little", signed=False)

    return result


def parse_error_response(data: bytes) -> dict | None:
    """Parse error response (code 0x01). Byte 1 is error code."""
    if len(data) < 2:
        return {"code": 0, "message": "Unknown error"}
    code = data[1]
    return {
        "code": code,
        "message": ERROR_CODES.get(code, f"Unknown error (0x{code:02x})"),
    }


# ─── Frame Parsers ────────────────────────────────────────────────
# Sync parsing following meshcore.reader.MessageReader.handle_rx field names


def identify_response(data: bytes) -> int | None:
    if not data:
        return None
    return data[0]


def parse_contact_frame(data: bytes) -> dict | None:
    """Parse contact frame, matching meshcore reader field names."""
    if len(data) < 100:
        return None
    code = data[0]
    if code not in (RESP_CODE_CONTACT, PUSH_CODE_NEW_ADVERT):
        return None
    dbuf = io.BytesIO(data[1:])
    c = {}
    c["public_key"] = dbuf.read(32).hex()
    c["type"] = dbuf.read(1)[0]
    c["flags"] = dbuf.read(1)[0]
    plen = int.from_bytes(dbuf.read(1), signed=False, byteorder="little")
    c["out_path_len"], c["path_hash_size"] = _parse_path_length_byte(plen)
    raw_path = dbuf.read(64)
    # Extract meaningful path bytes based on hop count and hash size
    hop_count = max(c["out_path_len"], 0)
    hash_size = c["path_hash_size"]
    path_byte_len = hop_count * hash_size
    c["out_path"] = raw_path[:path_byte_len].hex() if path_byte_len > 0 else ""
    c["adv_name"] = dbuf.read(32).decode("utf-8", "ignore").replace("\0", "")
    c["last_advert"] = int.from_bytes(dbuf.read(4), byteorder="little")
    c["adv_lat"], c["adv_lon"] = _parse_latlon(dbuf)
    lastmod_bytes = dbuf.read(4)
    if lastmod_bytes and len(lastmod_bytes) == 4:
        c["lastmod"] = int.from_bytes(lastmod_bytes, byteorder="little")
    return c


def parse_self_info(data: bytes) -> dict | None:
    """Parse SELF_INFO, matching meshcore reader field names."""
    if len(data) < 36 or data[0] != RESP_CODE_SELF_INFO:
        return None
    dbuf = io.BytesIO(data[1:])
    si = {}
    si["adv_type"] = dbuf.read(1)[0]
    si["tx_power"] = int.from_bytes(dbuf.read(1), signed=True, byteorder="little")
    si["max_tx_power"] = dbuf.read(1)[0]
    si["public_key"] = dbuf.read(32).hex()
    si["adv_lat"], si["adv_lon"] = _parse_latlon(dbuf)
    si["multi_acks"] = dbuf.read(1)[0]
    si["adv_loc_policy"] = dbuf.read(1)[0]
    telemetry_mode = dbuf.read(1)[0]
    si["telemetry_mode_env"] = (telemetry_mode >> 4) & 0b11
    si["telemetry_mode_loc"] = (telemetry_mode >> 2) & 0b11
    si["telemetry_mode_base"] = telemetry_mode & 0b11
    si["manual_add_contacts"] = dbuf.read(1)[0] > 0
    si["radio_freq"] = int.from_bytes(dbuf.read(4), byteorder="little") / 1000
    si["radio_bw"] = int.from_bytes(dbuf.read(4), byteorder="little") / 1000
    si["radio_sf"] = dbuf.read(1)[0]
    si["radio_cr"] = dbuf.read(1)[0]
    si["name"] = dbuf.read().decode("utf-8", "ignore").rstrip("\x00")
    return si


def parse_device_info(data: bytes) -> dict | None:
    """Parse DEVICE_INFO, matching meshcore reader field names."""
    if len(data) < 4 or data[0] != RESP_CODE_DEVICE_INFO:
        return None
    dbuf = io.BytesIO(data[1:])
    res = {}
    fw_ver = dbuf.read(1)[0]
    res["fw_ver"] = fw_ver
    if fw_ver >= 3:
        res["max_contacts"] = dbuf.read(1)[0] * 2
        res["max_channels"] = dbuf.read(1)[0]
        res["ble_pin"] = int.from_bytes(dbuf.read(4), byteorder="little")
        res["fw_build"] = dbuf.read(12).decode("utf-8", "ignore").replace("\0", "")
        res["model"] = dbuf.read(40).decode("utf-8", "ignore").replace("\0", "")
        res["ver"] = dbuf.read(20).decode("utf-8", "ignore").replace("\0", "")
    if fw_ver >= 9:
        rpt = dbuf.read(1)
        if len(rpt) > 0:
            res["repeat"] = rpt[0] != 0
    if fw_ver >= 10:
        phm = dbuf.read(1)
        if len(phm) > 0:
            res["path_hash_mode"] = phm[0]
    return res


def parse_batt_and_storage(data: bytes) -> dict | None:
    """Parse battery/storage, matching meshcore reader field names."""
    if len(data) < 3 or data[0] != RESP_CODE_BATT_AND_STORAGE:
        return None
    dbuf = io.BytesIO(data[1:])
    result = {}
    result["level"] = int.from_bytes(dbuf.read(2), byteorder="little")
    if len(data) > 3:
        result["used_kb"] = int.from_bytes(dbuf.read(4), byteorder="little")
        result["total_kb"] = int.from_bytes(dbuf.read(4), byteorder="little")
    return result


def parse_contact_message(data: bytes) -> dict | None:
    """Parse incoming DM, matching meshcore reader field names."""
    if len(data) < 10:
        return None
    code = data[0]
    if code not in (RESP_CODE_CONTACT_MSG_RECV, RESP_CODE_CONTACT_MSG_RECV_V3):
        return None
    dbuf = io.BytesIO(data[1:])
    res = {}
    res["type"] = "PRIV"
    if code == RESP_CODE_CONTACT_MSG_RECV_V3:
        res["SNR"] = _parse_snr(dbuf.read(1))
        dbuf.read(2)  # reserved
    res["pubkey_prefix"] = dbuf.read(6).hex()
    plen = dbuf.read(1)[0]
    res["path_len"], res["path_hash_size"] = _parse_path_length_byte(plen)
    txt_type = dbuf.read(1)[0]
    res["txt_type"] = txt_type
    res["sender_timestamp"] = int.from_bytes(dbuf.read(4), byteorder="little")
    if txt_type == 2:
        res["signature"] = dbuf.read(4).hex()
    res["text"] = _smaz_decode(dbuf.read().decode("utf-8", "ignore").rstrip("\x00"))
    return res


def parse_channel_message(data: bytes) -> dict | None:
    """Parse incoming channel message, matching meshcore reader field names."""
    if len(data) < 8:
        return None
    code = data[0]
    if code not in (RESP_CODE_CHANNEL_MSG_RECV, RESP_CODE_CHANNEL_MSG_RECV_V3):
        return None
    dbuf = io.BytesIO(data[1:])
    res = {}
    res["type"] = "CHAN"
    if code == RESP_CODE_CHANNEL_MSG_RECV_V3:
        res["SNR"] = _parse_snr(dbuf.read(1))
        dbuf.read(2)  # reserved
    res["channel_idx"] = dbuf.read(1)[0]
    plen = dbuf.read(1)[0]
    res["path_len"], res["path_hash_size"] = _parse_path_length_byte(plen)
    res["txt_type"] = dbuf.read(1)[0]
    res["path_bytes"] = ""  # Path bytes come via LOG_DATA (0x88), not here
    res["sender_timestamp"] = int.from_bytes(dbuf.read(4), byteorder="little", signed=False)
    text = dbuf.read().strip(b"\0")
    res["text"] = text.decode("utf-8", "ignore")
    # Channel messages have format "name: message"
    if ": " in res["text"]:
        res["sender_name"], res["msg_text"] = res["text"].split(": ", 1)
    else:
        res["sender_name"] = ""
        res["msg_text"] = res["text"]
    res["msg_text"] = _smaz_decode(res["msg_text"])
    return res


def parse_channel_data(data: bytes) -> dict | None:
    """Parse CHANNEL_DATA_RECV (binary data on group channels, companion v1.15.0+).

    Frame: [code][SNR(i8)][reserved(2)][chan_idx][path_len][data_type(u16LE)][data_len][payload]
    """
    if len(data) < 9 or data[0] != RESP_CODE_CHANNEL_DATA_RECV:
        return None
    dbuf = io.BytesIO(data[1:])
    res = {}
    res["SNR"] = _parse_snr(dbuf.read(1))
    dbuf.read(2)  # reserved
    res["channel_idx"] = dbuf.read(1)[0]
    plen = dbuf.read(1)[0]
    res["path_len"], res["path_hash_size"] = _parse_path_length_byte(plen)
    res["data_type"] = int.from_bytes(dbuf.read(2), byteorder="little")
    res["data_len"] = dbuf.read(1)[0]
    res["payload"] = dbuf.read(res["data_len"]).hex()
    return res


def parse_channel_info(data: bytes) -> dict | None:
    """Parse channel info, matching meshcore reader field names."""
    if len(data) < 50 or data[0] != RESP_CODE_CHANNEL_INFO:
        return None
    dbuf = io.BytesIO(data[1:])
    res = {}
    res["channel_idx"] = dbuf.read(1)[0]
    name_bytes = dbuf.read(32)
    null_pos = name_bytes.find(0)
    if null_pos >= 0:
        res["channel_name"] = name_bytes[:null_pos].decode("utf-8", "ignore")
    else:
        res["channel_name"] = name_bytes.decode("utf-8", "ignore")
    res["channel_secret"] = dbuf.read(16)
    return res


def parse_sent_response(data: bytes) -> dict | None:
    """Parse RESP_CODE_SENT, matching meshcore reader field names."""
    if len(data) < 2 or data[0] != RESP_CODE_SENT:
        return None
    dbuf = io.BytesIO(data[1:])
    res = {}
    res["type"] = dbuf.read(1)[0]
    res["expected_ack"] = dbuf.read(4)
    res["suggested_timeout"] = int.from_bytes(dbuf.read(4), byteorder="little")
    return res


def parse_push_send_confirmed(data: bytes) -> dict | None:
    """Parse ACK push."""
    if len(data) < 5 or data[0] != PUSH_CODE_SEND_CONFIRMED:
        return None
    res = {}
    res["code"] = data[1:5].hex()
    if len(data) >= 9:
        res["trip_time_ms"] = struct.unpack_from("<I", data, 5)[0]
    return res


def parse_auto_add_config(data: bytes) -> dict | None:
    """Parse auto-add config response."""
    if len(data) < 2 or data[0] != RESP_CODE_AUTO_ADD_CONFIG:
        return None
    config = data[1]
    max_hops = data[2] if len(data) >= 3 else 0
    return {"config": config, "max_hops": max_hops}


def parse_default_flood_scope(data: bytes) -> dict | None:
    """Parse DEFAULT_FLOOD_SCOPE response (PacketType 28).

    48-byte frame = scope set: [code][scope_name(31)][scope_key(16)].
    1-byte frame = no scope configured.
    """
    if not data or data[0] != RESP_CODE_DEFAULT_FLOOD_SCOPE:
        return None
    res = {}
    if len(data) >= 48:
        res["scope_name"] = data[1:32].decode("utf-8", "ignore").replace("\0", "")
        res["scope_key"] = data[32:48].hex()
    return res


def parse_curr_time(data: bytes) -> int | None:
    if len(data) < 5 or data[0] != RESP_CODE_CURR_TIME:
        return None
    return struct.unpack_from("<I", data, 1)[0]


# ─── Cayenne LPP telemetry parsing ──────────────────────────

LPP_DIGITAL_INPUT = 0  # 1 byte
LPP_DIGITAL_OUTPUT = 1  # 1 byte
LPP_ANALOG_INPUT = 2  # 2 bytes, 0.01 signed
LPP_ANALOG_OUTPUT = 3  # 2 bytes, 0.01 signed
LPP_GENERIC_SENSOR = 100  # 4 bytes, unsigned
LPP_LUMINOSITY = 101  # 2 bytes, 1 lux unsigned
LPP_PRESENCE = 102  # 1 byte, bool
LPP_TEMPERATURE = 103  # 2 bytes, 0.1°C signed
LPP_HUMIDITY = 104  # 1 byte, 0.5% unsigned
LPP_ACCELEROMETER = 113  # 6 bytes, 2 per axis, 0.001G
LPP_PRESSURE = 115  # 2 bytes, 0.1 hPa unsigned
LPP_VOLTAGE = 116  # 2 bytes, 0.01V unsigned
LPP_CURRENT = 117  # 2 bytes, 0.001A unsigned
LPP_FREQUENCY = 118  # 4 bytes, 1Hz unsigned
LPP_PERCENTAGE = 120  # 1 byte, 1-100%
LPP_ALTITUDE = 121  # 2 bytes, 1m signed
LPP_CONCENTRATION = 125  # 2 bytes, 1 ppm unsigned
LPP_POWER = 128  # 2 bytes, 1W unsigned
LPP_DISTANCE = 130  # 4 bytes, 0.001m unsigned
LPP_ENERGY = 131  # 4 bytes, 0.001kWh unsigned
LPP_DIRECTION = 132  # 2 bytes, 1deg unsigned
LPP_UNIX_TIME = 133  # 4 bytes, unsigned
LPP_GYROMETER = 134  # 6 bytes, 2 per axis, 0.01°/s
LPP_COLOUR = 135  # 3 bytes, RGB
LPP_GPS = 136  # 3x3 bytes (lat, lon, alt)
LPP_SWITCH = 142  # 1 byte, 0/1

LPP_TYPE_NAMES = {
    LPP_DIGITAL_INPUT: "Digital Input",
    LPP_DIGITAL_OUTPUT: "Digital Output",
    LPP_ANALOG_INPUT: "Analog Input",
    LPP_ANALOG_OUTPUT: "Analog Output",
    LPP_GENERIC_SENSOR: "Generic Sensor",
    LPP_LUMINOSITY: "Luminosity",
    LPP_PRESENCE: "Presence",
    LPP_TEMPERATURE: "Temperature",
    LPP_HUMIDITY: "Humidity",
    LPP_ACCELEROMETER: "Accelerometer",
    LPP_PRESSURE: "Pressure",
    LPP_VOLTAGE: "Voltage",
    LPP_CURRENT: "Current",
    LPP_FREQUENCY: "Frequency",
    LPP_PERCENTAGE: "Percentage",
    LPP_ALTITUDE: "Altitude",
    LPP_CONCENTRATION: "Concentration",
    LPP_POWER: "Power",
    LPP_DISTANCE: "Distance",
    LPP_ENERGY: "Energy",
    LPP_DIRECTION: "Direction",
    LPP_UNIX_TIME: "Time",
    LPP_GYROMETER: "Gyrometer",
    LPP_COLOUR: "Colour",
    LPP_GPS: "GPS",
    LPP_SWITCH: "Switch",
}

LPP_TYPE_UNITS = {
    LPP_ANALOG_INPUT: "V",
    LPP_ANALOG_OUTPUT: "V",
    LPP_LUMINOSITY: "lux",
    LPP_TEMPERATURE: "°C",
    LPP_HUMIDITY: "%",
    LPP_ACCELEROMETER: "G",
    LPP_PRESSURE: "hPa",
    LPP_VOLTAGE: "V",
    LPP_CURRENT: "A",
    LPP_FREQUENCY: "Hz",
    LPP_PERCENTAGE: "%",
    LPP_ALTITUDE: "m",
    LPP_CONCENTRATION: "ppm",
    LPP_POWER: "W",
    LPP_DISTANCE: "m",
    LPP_ENERGY: "kWh",
    LPP_DIRECTION: "°",
    LPP_GYROMETER: "°/s",
}


def parse_cayenne_lpp(data: bytes) -> list[dict]:
    """Parse Cayenne LPP encoded telemetry data.

    Returns list of dicts with 'channel', 'type', 'name', 'value', 'unit' keys.
    """
    results = []
    buf = io.BytesIO(data)
    while buf.tell() < len(data) - 1:
        channel = buf.read(1)
        type_byte = buf.read(1)
        if len(channel) < 1 or len(type_byte) < 1:
            break
        ch = channel[0]
        tp = type_byte[0]
        if ch == 0 and tp == 0:
            break
        name = LPP_TYPE_NAMES.get(tp, f"Type {tp}")
        unit = LPP_TYPE_UNITS.get(tp, "")
        try:
            if tp in (LPP_DIGITAL_INPUT, LPP_DIGITAL_OUTPUT, LPP_PRESENCE, LPP_SWITCH):
                val = buf.read(1)[0]
            elif tp in (LPP_ANALOG_INPUT, LPP_ANALOG_OUTPUT):
                val = struct.unpack(">h", buf.read(2))[0] / 100.0
            elif tp == LPP_GENERIC_SENSOR:
                val = struct.unpack(">I", buf.read(4))[0]
            elif tp == LPP_LUMINOSITY:
                val = struct.unpack(">H", buf.read(2))[0]
            elif tp == LPP_TEMPERATURE:
                val = struct.unpack(">h", buf.read(2))[0] / 10.0
            elif tp == LPP_HUMIDITY:
                val = buf.read(1)[0] / 2.0
            elif tp == LPP_PERCENTAGE:
                val = buf.read(1)[0]
            elif tp in (LPP_ACCELEROMETER, LPP_GYROMETER):
                raw = buf.read(6)
                scale = 0.001 if tp == LPP_ACCELEROMETER else 0.01
                val = {
                    "x": struct.unpack(">h", raw[0:2])[0] * scale,
                    "y": struct.unpack(">h", raw[2:4])[0] * scale,
                    "z": struct.unpack(">h", raw[4:6])[0] * scale,
                }
            elif tp == LPP_PRESSURE:
                val = struct.unpack(">H", buf.read(2))[0] / 10.0
            elif tp == LPP_VOLTAGE:
                val = struct.unpack(">h", buf.read(2))[0] / 100.0
            elif tp == LPP_CURRENT:
                val = struct.unpack(">h", buf.read(2))[0] / 1000.0
            elif tp in (LPP_FREQUENCY, LPP_UNIX_TIME):
                val = struct.unpack(">I", buf.read(4))[0]
            elif tp == LPP_ALTITUDE:
                val = struct.unpack(">h", buf.read(2))[0]
            elif tp in (LPP_CONCENTRATION, LPP_POWER, LPP_DIRECTION):
                val = struct.unpack(">H", buf.read(2))[0]
            elif tp in (LPP_DISTANCE, LPP_ENERGY):
                val = struct.unpack(">I", buf.read(4))[0] / 1000.0
            elif tp == LPP_COLOUR:
                raw = buf.read(3)
                val = {"r": raw[0], "g": raw[1], "b": raw[2]}
            elif tp == LPP_GPS:
                raw = buf.read(9)
                lat = int.from_bytes(raw[0:3], "big", signed=True) / 10000.0
                lon = int.from_bytes(raw[3:6], "big", signed=True) / 10000.0
                alt = int.from_bytes(raw[6:9], "big", signed=True) / 100.0
                val = {"lat": lat, "lon": lon, "alt": alt}
            else:
                # Unknown type — can't determine size, stop parsing
                break
            results.append(
                {
                    "channel": ch,
                    "type": tp,
                    "name": name,
                    "value": val,
                    "unit": unit,
                }
            )
        except (struct.error, IndexError):
            break
    return results
