"""
Compact binary controller → led-driver messages (ESP-NOW friendly).

Header (5 bytes), same for v1 (legacy) and v2 (native binary):

  0: version — 1 = legacy (JSON text blobs); 2 = native binary blobs
  1: brightness — 0–127 scales to device 0–255; 128–255 = leave unchanged
  2: byte length of presets section (0–255)
  3: byte length of select section
  4: byte length of default section

v2 presets blob (no JSON):
  u8 preset_count
  each preset:
    u8 name_len; name utf-8
    u8 pattern_len; pattern utf-8 (``p``)
    u8 color_count; color_count × (u8 r, u8 g, u8 b)
    u16 delay_le (``d``)
    u8 preset_brightness (``b``)
    u8 auto (0/1) (``a``)
    i16 n1..n6 little-endian (``n1``–``n6``)

v2 select blob:
  u8 entry_count
  each:
    u8 device_len; device utf-8
    u8 preset_name_len; preset name utf-8
    u8 has_step (0/1); optional u16 step_le

v2 default blob:
  u8 default_name_len; name utf-8
  u8 target_count
  each: u8 len; target name utf-8

Legacy v1: sections are UTF-8 JSON text (see ``parse_binary_envelope_v1``).

Keep ``5 + lp + ls + ld`` ≤ 245 for a single ESP-NOW frame body.
"""

from __future__ import annotations

import json
import struct
from typing import Any, Dict, List, Optional, Tuple

BINARY_ENVELOPE_VERSION_1 = 1
BINARY_ENVELOPE_VERSION_2 = 2
HEADER_LEN = 5


def brightness_wire_from_0_255(value: int) -> int:
    """Map device brightness 0–255 to wire 0–127."""
    v = max(0, min(255, int(value)))
    return (v * 127 + 127) // 255


def brightness_0_255_from_wire(wire: int) -> int:
    """Map wire 0–127 to device brightness 0–255."""
    w = max(0, min(127, int(wire)))
    return min(255, (w * 255) // 127)


def _clamp_i16(x: int) -> int:
    x = int(x)
    return max(-32768, min(32767, x))


def _colors_to_rgb_list(colors: Any) -> List[Tuple[int, int, int]]:
    out: List[Tuple[int, int, int]] = []
    if not colors:
        return out
    for c in colors:
        if isinstance(c, str):
            h = c.strip().lstrip("#")
            if len(h) >= 6:
                out.append(
                    (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
                )
        elif isinstance(c, (list, tuple)) and len(c) >= 3:
            out.append((int(c[0]), int(c[1]), int(c[2])))
    return out


def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
    pname = name.encode("utf-8")
    if len(pname) > 250:
        raise ValueError("preset name too long")
    pattern = str(preset.get("p") or preset.get("pattern", "off")).encode("utf-8")
    if len(pattern) > 250:
        raise ValueError("pattern string too long")
    rgbs = _colors_to_rgb_list(preset.get("c") or preset.get("colors") or [])
    if len(rgbs) > 255:
        raise ValueError("too many colours")
    delay = max(0, min(65535, int(preset.get("d") or preset.get("delay", 100))))
    br = max(0, min(255, int(preset.get("b") or preset.get("brightness", 127))))
    auto = 1 if preset.get("a", preset.get("auto", True)) else 0
    parts = [
        bytes([len(pname)]),
        pname,
        bytes([len(pattern)]),
        pattern,
        bytes([len(rgbs)]),
    ]
    for r, g, b in rgbs:
        parts.append(bytes([r & 255, g & 255, b & 255]))
    n1 = _clamp_i16(preset.get("n1", 0))
    n2 = _clamp_i16(preset.get("n2", 0))
    n3 = _clamp_i16(preset.get("n3", 0))
    n4 = _clamp_i16(preset.get("n4", 0))
    n5 = _clamp_i16(preset.get("n5", 0))
    n6 = _clamp_i16(preset.get("n6", 0))
    parts.append(
        struct.pack(
            "<HBBhhhhhh",
            delay,
            br,
            auto,
            n1,
            n2,
            n3,
            n4,
            n5,
            n6,
        )
    )
    return b"".join(parts)


def _pack_presets_blob(presets: Dict[str, Any]) -> bytes:
    items = [(k, v) for k, v in presets.items() if isinstance(v, dict)]
    out = [bytes([len(items)])]
    for name, pdata in items:
        out.append(_pack_preset_dict(str(name), pdata))
    return b"".join(out)


def _pack_select_blob(select: Dict[str, Any]) -> bytes:
    out = [bytes([len(select)])]
    for device, sel in select.items():
        dev_b = str(device).encode("utf-8")
        if len(dev_b) > 250:
            raise ValueError("device name too long")
        if isinstance(sel, (list, tuple)) and sel:
            pn = str(sel[0]).encode("utf-8")
            step = sel[1] if len(sel) > 1 else None
        else:
            pn = str(sel).encode("utf-8")
            step = None
        if len(pn) > 250:
            raise ValueError("preset name too long")
        if step is None:
            out.append(
                bytes([len(dev_b)])
                + dev_b
                + bytes([len(pn)])
                + pn
                + bytes([0])
            )
        else:
            s = int(step)
            if s < 0 or s > 65535:
                raise ValueError("step out of range")
            out.append(
                bytes([len(dev_b)])
                + dev_b
                + bytes([len(pn)])
                + pn
                + bytes([1])
                + struct.pack("<H", s)
            )
    return b"".join(out)


def _pack_default_blob(default: str, targets: Optional[list]) -> bytes:
    name_b = str(default).encode("utf-8")
    if len(name_b) > 250:
        raise ValueError("default name too long")
    tlist = list(targets) if targets else []
    if len(tlist) > 255:
        raise ValueError("too many targets")
    out = [bytes([len(name_b)]), name_b, bytes([len(tlist)])]
    for t in tlist:
        tb = str(t).encode("utf-8")
        if len(tb) > 250:
            raise ValueError("target name too long")
        out.append(bytes([len(tb)]))
        out.append(tb)
    return b"".join(out)


def pack_binary_envelope_v2(
    *,
    presets: Optional[Dict[str, Any]] = None,
    select: Optional[Dict[str, Any]] = None,
    default: Optional[str] = None,
    default_targets: Optional[list] = None,
    brightness_0_255: Optional[int] = None,
) -> bytes:
    """Build a v2 envelope (native binary sections, no JSON)."""
    presets_bytes = (
        _pack_presets_blob(presets) if presets is not None and presets else b""
    )
    select_bytes = (
        _pack_select_blob(select) if select is not None and select else b""
    )
    default_bytes = (
        _pack_default_blob(default, default_targets)
        if default is not None
        else b""
    )

    lp = len(presets_bytes)
    ls = len(select_bytes)
    ld = len(default_bytes)
    if lp > 255 or ls > 255 or ld > 255:
        raise ValueError("binary envelope section exceeds 255 bytes")

    br_wire = (
        255
        if brightness_0_255 is None
        else brightness_wire_from_0_255(brightness_0_255)
    )
    header = bytes([BINARY_ENVELOPE_VERSION_2, br_wire, lp, ls, ld])
    return header + presets_bytes + select_bytes + default_bytes


def pack_binary_envelope_v1(
    *,
    presets: Optional[Dict[str, Any]] = None,
    select: Optional[Dict[str, Any]] = None,
    default: Optional[str] = None,
    default_targets: Optional[list] = None,
    brightness_0_255: Optional[int] = None,
) -> bytes:
    """Legacy: JSON UTF-8 fragments (version byte 1). Prefer ``pack_binary_envelope_v2``."""
    if presets is None:
        presets_bytes = b""
    else:
        presets_bytes = json.dumps(presets, separators=(",", ":")).encode("utf-8")

    if select is None:
        select_bytes = b""
    else:
        select_bytes = json.dumps(select, separators=(",", ":")).encode("utf-8")

    default_obj: Optional[Dict[str, Any]] = None
    if default is not None:
        default_obj = {
            "default": default,
            "targets": list(default_targets) if default_targets else [],
        }
    default_bytes = (
        json.dumps(default_obj, separators=(",", ":")).encode("utf-8")
        if default_obj is not None
        else b""
    )

    lp = len(presets_bytes)
    ls = len(select_bytes)
    ld = len(default_bytes)
    if lp > 255 or ls > 255 or ld > 255:
        raise ValueError("binary envelope fragment exceeds 255 bytes")

    br_wire = (
        255
        if brightness_0_255 is None
        else brightness_wire_from_0_255(brightness_0_255)
    )
    header = bytes([BINARY_ENVELOPE_VERSION_1, br_wire, lp, ls, ld])
    return header + presets_bytes + select_bytes + default_bytes


def _decode_preset_record(
    buf: bytes, off: int
) -> Tuple[str, Dict[str, Any], int]:
    if off + 1 > len(buf):
        raise ValueError("truncated")
    nl = buf[off]
    off += 1
    if off + nl > len(buf):
        raise ValueError("truncated")
    name = buf[off : off + nl].decode("utf-8")
    off += nl
    if off + 1 > len(buf):
        raise ValueError("truncated")
    pl = buf[off]
    off += 1
    if off + pl > len(buf):
        raise ValueError("truncated")
    pattern = buf[off : off + pl].decode("utf-8")
    off += pl
    if off + 1 > len(buf):
        raise ValueError("truncated")
    nc = buf[off]
    off += 1
    if off + nc * 3 > len(buf):
        raise ValueError("truncated")
    colors: List[str] = []
    for _ in range(nc):
        r, g, b = buf[off], buf[off + 1], buf[off + 2]
        off += 3
        colors.append(f"#{r:02x}{g:02x}{b:02x}")
    if off + 16 > len(buf):
        raise ValueError("truncated")
    delay, br, auto, n1, n2, n3, n4, n5, n6 = struct.unpack_from(
        "<HBBhhhhhh", buf, off
    )
    off += 16
    preset = {
        "p": pattern,
        "c": colors,
        "d": delay,
        "b": br,
        "a": bool(auto),
        "n1": n1,
        "n2": n2,
        "n3": n3,
        "n4": n4,
        "n5": n5,
        "n6": n6,
    }
    return name, preset, off


def _decode_presets_blob(chunk: bytes) -> Dict[str, Any]:
    if not chunk:
        return {}
    off = 0
    if off + 1 > len(chunk):
        raise ValueError("truncated")
    count = chunk[off]
    off += 1
    out: Dict[str, Any] = {}
    for _ in range(count):
        name, preset, off = _decode_preset_record(chunk, off)
        out[name] = preset
    if off != len(chunk):
        raise ValueError("presets blob length mismatch")
    return out


def _decode_select_blob(chunk: bytes) -> Dict[str, Any]:
    if not chunk:
        return {}
    off = 0
    if off + 1 > len(chunk):
        raise ValueError("truncated")
    count = chunk[off]
    off += 1
    out: Dict[str, Any] = {}
    for _ in range(count):
        if off + 1 > len(chunk):
            raise ValueError("truncated")
        dl = chunk[off]
        off += 1
        if off + dl > len(chunk):
            raise ValueError("truncated")
        device = chunk[off : off + dl].decode("utf-8")
        off += dl
        if off + 1 > len(chunk):
            raise ValueError("truncated")
        pl = chunk[off]
        off += 1
        if off + pl > len(chunk):
            raise ValueError("truncated")
        pname = chunk[off : off + pl].decode("utf-8")
        off += pl
        if off + 1 > len(chunk):
            raise ValueError("truncated")
        has_step = chunk[off]
        off += 1
        if has_step:
            if off + 2 > len(chunk):
                raise ValueError("truncated")
            step = struct.unpack_from("<H", chunk, off)[0]
            off += 2
            out[device] = [pname, step]
        else:
            out[device] = [pname]
    if off != len(chunk):
        raise ValueError("select blob length mismatch")
    return out


def _decode_default_blob(chunk: bytes) -> Tuple[Optional[str], list]:
    if not chunk:
        return None, []
    off = 0
    if off + 1 > len(chunk):
        raise ValueError("truncated")
    nl = chunk[off]
    off += 1
    if off + nl > len(chunk):
        raise ValueError("truncated")
    default_name = chunk[off : off + nl].decode("utf-8") if nl else ""
    off += nl
    if off + 1 > len(chunk):
        raise ValueError("truncated")
    nt = chunk[off]
    off += 1
    targets: List[str] = []
    for _ in range(nt):
        if off + 1 > len(chunk):
            raise ValueError("truncated")
        tl = chunk[off]
        off += 1
        if off + tl > len(chunk):
            raise ValueError("truncated")
        targets.append(chunk[off : off + tl].decode("utf-8"))
        off += tl
    if off != len(chunk):
        raise ValueError("default blob length mismatch")
    return default_name, targets


def parse_binary_envelope_v2(buf: bytes) -> Optional[Dict[str, Any]]:
    """Decode native-binary v2 envelope into the v1 API dict shape."""
    if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
        return None
    if buf[0] != BINARY_ENVELOPE_VERSION_2:
        return None
    lp, ls, ld = buf[2], buf[3], buf[4]
    need = HEADER_LEN + lp + ls + ld
    if len(buf) != need:
        return None

    off = HEADER_LEN
    presets_chunk = buf[off : off + lp]
    off += lp
    select_chunk = buf[off : off + ls]
    off += ls
    default_chunk = buf[off : off + ld]

    data: Dict[str, Any] = {"v": "1"}
    br = buf[1]
    if br < 128:
        data["b"] = brightness_0_255_from_wire(br)

    try:
        if lp:
            data["presets"] = _decode_presets_blob(bytes(presets_chunk))
        if ls:
            data["select"] = _decode_select_blob(bytes(select_chunk))
        if ld:
            dname, targets = _decode_default_blob(bytes(default_chunk))
            data["default"] = dname
            data["targets"] = targets
    except (ValueError, UnicodeError):
        return None

    return data


def parse_binary_envelope_v1(buf: bytes) -> Optional[Dict[str, Any]]:
    """
    Decode legacy v1 bytes (JSON text blobs) into a v1 API dict.
    Returns None if ``buf`` is not a valid v1 envelope.
    """
    if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
        return None
    if buf[0] != BINARY_ENVELOPE_VERSION_1:
        return None
    lp, ls, ld = buf[2], buf[3], buf[4]
    need = HEADER_LEN + lp + ls + ld
    if len(buf) != need:
        return None

    off = HEADER_LEN
    presets_chunk = buf[off : off + lp]
    off += lp
    select_chunk = buf[off : off + ls]
    off += ls
    default_chunk = buf[off : off + ld]

    data: Dict[str, Any] = {"v": "1"}

    br = buf[1]
    if br < 128:
        data["b"] = brightness_0_255_from_wire(br)

    if lp:
        try:
            data["presets"] = json.loads(presets_chunk.decode("utf-8"))
        except (ValueError, UnicodeError):
            return None
    if ls:
        try:
            data["select"] = json.loads(select_chunk.decode("utf-8"))
        except (ValueError, UnicodeError):
            return None
    if ld:
        try:
            extra = json.loads(default_chunk.decode("utf-8"))
        except (ValueError, UnicodeError):
            return None
        if isinstance(extra, dict):
            for k, v in extra.items():
                data[k] = v

    return data


def parse_binary_envelope(buf: bytes) -> Optional[Dict[str, Any]]:
    """Try v2 (native binary), then v1 (JSON fragments)."""
    d = parse_binary_envelope_v2(buf)
    if d is not None:
        return d
    return parse_binary_envelope_v1(buf)