"""Tests for the Teltonika codec — Codec 8 + Codec 8 Extended.

Coverage strategy:

  * Builder helpers (test-only; not production code) construct valid
    packets from records. The codec is decode-only; encoding is the
    test harness so round-trip property tests can verify
    ``decode(build(records))`` produces the records.
  * Known scenarios: minimal single record, multi-record, real-world
    Baghdad coordinates with known IO IDs.
  * Hypothesis property tests (rule **TS-13**) over the full Codec 8
    parameter space.
  * Negative tests: malformed packet rejection across every failure
    mode the decoder asserts.
"""

from __future__ import annotations

import struct
from datetime import UTC, datetime
from typing import TYPE_CHECKING

import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from mgf.tele.core import (
    ChecksumError,
    MalformedPacketError,
    UnsupportedCodecError,
)

from mgf.tele.teltonika import (
    CODEC_8,
    CODEC_8_EXTENDED,
    decode_avl_packet,
)
from mgf.tele.teltonika._crc import crc16_ibm

if TYPE_CHECKING:
    from hypothesis.strategies import DrawFn


# ---------------------------------------------------------------------------
# Test-only builders
# ---------------------------------------------------------------------------


def _wrap_envelope(codec_id: int, inner: bytes) -> bytes:
    """Wrap a codec inner-block in the universal TCP envelope."""
    payload = bytes([codec_id]) + inner
    crc = crc16_ibm(payload)
    return b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")


def _build_codec8_record(
    *,
    ts_ms: int,
    priority: int = 0,
    lon_raw: int = 0,
    lat_raw: int = 0,
    alt: int = 0,
    angle: int = 0,
    satellites: int = 0,
    speed: int = 0,
    event_io_id: int = 0,
    inputs_1b: dict[int, int] | None = None,
    inputs_2b: dict[int, int] | None = None,
    inputs_4b: dict[int, int] | None = None,
    inputs_8b: dict[int, int] | None = None,
) -> bytes:
    """Pack one Codec-8 record (1-byte IO IDs throughout)."""
    inputs_1b = inputs_1b or {}
    inputs_2b = inputs_2b or {}
    inputs_4b = inputs_4b or {}
    inputs_8b = inputs_8b or {}

    out = bytearray()
    out += ts_ms.to_bytes(8, "big")
    out += bytes([priority])
    out += struct.pack(">iihHBH", lon_raw, lat_raw, alt, angle, satellites, speed)

    n_total = len(inputs_1b) + len(inputs_2b) + len(inputs_4b) + len(inputs_8b)
    out += bytes([event_io_id, n_total])
    for size, table in [(1, inputs_1b), (2, inputs_2b), (4, inputs_4b), (8, inputs_8b)]:
        out += bytes([len(table)])
        for io_id, value in table.items():
            out += bytes([io_id])
            out += value.to_bytes(size, "big", signed=False)
    return bytes(out)


def _build_codec8_packet(records_bytes: list[bytes]) -> bytes:
    n = len(records_bytes)
    inner = bytes([n]) + b"".join(records_bytes) + bytes([n])
    return _wrap_envelope(CODEC_8, inner)


def _build_codec8e_record(
    *,
    ts_ms: int,
    priority: int = 0,
    lon_raw: int = 0,
    lat_raw: int = 0,
    alt: int = 0,
    angle: int = 0,
    satellites: int = 0,
    speed: int = 0,
    event_io_id: int = 0,
    inputs_1b: dict[int, int] | None = None,
    inputs_2b: dict[int, int] | None = None,
    inputs_4b: dict[int, int] | None = None,
    inputs_8b: dict[int, int] | None = None,
    nx_entries: list[tuple[int, bytes]] | None = None,
) -> bytes:
    """Pack one Codec-8 Extended record (2-byte IO IDs + 2-byte counts + NX)."""
    inputs_1b = inputs_1b or {}
    inputs_2b = inputs_2b or {}
    inputs_4b = inputs_4b or {}
    inputs_8b = inputs_8b or {}
    nx_entries = nx_entries or []

    out = bytearray()
    out += ts_ms.to_bytes(8, "big")
    out += bytes([priority])
    out += struct.pack(">iihHBH", lon_raw, lat_raw, alt, angle, satellites, speed)

    n_total = len(inputs_1b) + len(inputs_2b) + len(inputs_4b) + len(inputs_8b)
    out += event_io_id.to_bytes(2, "big")
    out += n_total.to_bytes(2, "big")
    for size, table in [(1, inputs_1b), (2, inputs_2b), (4, inputs_4b), (8, inputs_8b)]:
        out += len(table).to_bytes(2, "big")
        for io_id, value in table.items():
            out += io_id.to_bytes(2, "big")
            out += value.to_bytes(size, "big", signed=False)
    out += len(nx_entries).to_bytes(2, "big")
    for nx_id, nx_payload in nx_entries:
        out += nx_id.to_bytes(2, "big")
        out += len(nx_payload).to_bytes(2, "big")
        out += nx_payload
    return bytes(out)


def _build_codec8e_packet(records_bytes: list[bytes]) -> bytes:
    n = len(records_bytes)
    inner = bytes([n]) + b"".join(records_bytes) + bytes([n])
    return _wrap_envelope(CODEC_8_EXTENDED, inner)


# ---------------------------------------------------------------------------
# Codec 8 — known scenarios
# ---------------------------------------------------------------------------


def test_minimal_single_record_round_trip() -> None:
    """Smallest valid Codec-8 packet: one record, no IOs."""
    record_bytes = _build_codec8_record(ts_ms=1_560_166_592_560, priority=1)
    packet = _build_codec8_packet([record_bytes])
    codec_id, records = decode_avl_packet(packet)
    assert codec_id == CODEC_8
    assert len(records) == 1
    r = records[0]
    assert r.codec == "C8"
    assert r.priority == 1
    assert r.ts == datetime(2019, 6, 10, 11, 36, 32, 560_000, tzinfo=UTC)
    assert r.position.lon == 0.0
    assert r.position.lat == 0.0
    assert r.satellites == 0
    assert r.event_io_id == 0
    assert r.inputs == {}


def test_known_baghdad_position_with_ignition_io() -> None:
    """Real-world coords: Baghdad airport, with IO 239 (ignition) = 1."""
    record_bytes = _build_codec8_record(
        ts_ms=1_700_000_000_000,
        lon_raw=int(44.3661 * 1e7),
        lat_raw=int(33.3152 * 1e7),
        alt=34,
        angle=180,
        satellites=12,
        speed=42,
        inputs_1b={239: 1},
    )
    packet = _build_codec8_packet([record_bytes])
    _, records = decode_avl_packet(packet)
    r = records[0]
    assert r.position.lon == pytest.approx(44.3661, rel=1e-6)
    assert r.position.lat == pytest.approx(33.3152, rel=1e-6)
    assert r.position.alt == 34.0
    assert r.heading_deg == 180.0
    assert r.satellites == 12
    assert r.speed_kmh == 42.0
    assert r.inputs == {239: 1}


def test_negative_coordinates_two_complement() -> None:
    """A point in the Western/Southern hemisphere round-trips signed-int32."""
    rec = _build_codec8_record(
        ts_ms=0,
        lon_raw=int(-74.0060 * 1e7),  # New York: 40.7128, -74.0060
        lat_raw=int(40.7128 * 1e7),
    )
    packet = _build_codec8_packet([rec])
    _, records = decode_avl_packet(packet)
    assert records[0].position.lon == pytest.approx(-74.0060, rel=1e-6)
    assert records[0].position.lat == pytest.approx(40.7128, rel=1e-6)


def test_multiple_records_decode_in_order() -> None:
    records = [
        _build_codec8_record(ts_ms=1_700_000_000_000 + i * 1000, satellites=i) for i in range(5)
    ]
    packet = _build_codec8_packet(records)
    _, decoded = decode_avl_packet(packet)
    assert len(decoded) == 5
    for i, r in enumerate(decoded):
        assert r.satellites == i


def test_all_io_widths_decoded() -> None:
    """Each of N1/N2/N4/N8 widths populates the inputs map with the right value."""
    rec = _build_codec8_record(
        ts_ms=0,
        inputs_1b={1: 0xFF},
        inputs_2b={2: 0xFFFF},
        inputs_4b={3: 0xFFFFFFFF},
        inputs_8b={4: 0xFFFFFFFFFFFFFFFF},
    )
    packet = _build_codec8_packet([rec])
    _, records = decode_avl_packet(packet)
    assert records[0].inputs == {
        1: 0xFF,
        2: 0xFFFF,
        3: 0xFFFFFFFF,
        4: 0xFFFFFFFFFFFFFFFF,
    }


# ---------------------------------------------------------------------------
# Codec 8 — Hypothesis property test
# ---------------------------------------------------------------------------


@st.composite
def _codec8_record_bytes(draw: DrawFn) -> tuple[bytes, dict[int, int]]:
    """Strategy: a packed Codec-8 record + the inputs map it should decode to."""
    ts_ms = draw(st.integers(min_value=0, max_value=2_000_000_000_000))
    priority = draw(st.integers(min_value=0, max_value=2))
    lon_raw = draw(st.integers(min_value=-1_800_000_000, max_value=1_800_000_000))
    lat_raw = draw(st.integers(min_value=-900_000_000, max_value=900_000_000))
    alt = draw(st.integers(min_value=-32_768, max_value=32_767))
    angle = draw(st.integers(min_value=0, max_value=360))
    satellites = draw(st.integers(min_value=0, max_value=255))
    speed = draw(st.integers(min_value=0, max_value=1_000))
    event_io_id = draw(st.integers(min_value=0, max_value=255))

    # IO IDs are byte-keyed in Codec 8; pick disjoint sets across widths so
    # the inputs map round-trips deterministically (last write wins
    # otherwise, which is a property of dict, not of the codec).
    used_ids: set[int] = set()

    def _disjoint_dict(value_max: int, max_size: int) -> dict[int, int]:
        out: dict[int, int] = {}
        for _ in range(draw(st.integers(min_value=0, max_value=max_size))):
            io_id = draw(
                st.integers(min_value=0, max_value=255).filter(lambda x: x not in used_ids)
            )
            used_ids.add(io_id)
            out[io_id] = draw(st.integers(min_value=0, max_value=value_max))
        return out

    inputs_1b = _disjoint_dict(0xFF, 4)
    inputs_2b = _disjoint_dict(0xFFFF, 4)
    inputs_4b = _disjoint_dict(0xFFFFFFFF, 3)
    inputs_8b = _disjoint_dict(0xFFFFFFFFFFFFFFFF, 2)
    expected_inputs = {**inputs_1b, **inputs_2b, **inputs_4b, **inputs_8b}

    record_bytes = _build_codec8_record(
        ts_ms=ts_ms,
        priority=priority,
        lon_raw=lon_raw,
        lat_raw=lat_raw,
        alt=alt,
        angle=angle,
        satellites=satellites,
        speed=speed,
        event_io_id=event_io_id,
        inputs_1b=inputs_1b,
        inputs_2b=inputs_2b,
        inputs_4b=inputs_4b,
        inputs_8b=inputs_8b,
    )
    return record_bytes, expected_inputs


@given(st.lists(_codec8_record_bytes(), min_size=1, max_size=5))
@settings(max_examples=100)
def test_codec8_round_trip_property(items: list[tuple[bytes, dict[int, int]]]) -> None:
    """For any synthesised valid Codec-8 packet, decode produces the right
    number of records and each one's inputs map matches what was encoded."""
    records_bytes = [b for b, _ in items]
    expected_inputs_per_record = [m for _, m in items]
    packet = _build_codec8_packet(records_bytes)
    codec_id, decoded = decode_avl_packet(packet)
    assert codec_id == CODEC_8
    assert len(decoded) == len(items)
    for r, expected_inputs in zip(decoded, expected_inputs_per_record, strict=True):
        assert r.codec == "C8"
        assert dict(r.inputs) == expected_inputs


# ---------------------------------------------------------------------------
# Codec 8 — malformed-packet rejection
# ---------------------------------------------------------------------------


def test_too_short_packet_rejected() -> None:
    with pytest.raises(MalformedPacketError, match="too short"):
        decode_avl_packet(b"")
    with pytest.raises(MalformedPacketError, match="too short"):
        decode_avl_packet(b"\x00" * 10)


def test_bad_preamble_rejected() -> None:
    inner = bytes([CODEC_8, 0, 0])
    crc = crc16_ibm(inner)
    bad = b"\xff\xff\xff\xff" + (3).to_bytes(4, "big") + inner + crc.to_bytes(4, "big")
    with pytest.raises(MalformedPacketError, match="preamble"):
        decode_avl_packet(bad)


def test_length_disagreement_rejected() -> None:
    inner = bytes([CODEC_8, 0, 0])
    crc = crc16_ibm(inner)
    bad = b"\x00\x00\x00\x00" + (99).to_bytes(4, "big") + inner + crc.to_bytes(4, "big")
    with pytest.raises(MalformedPacketError, match="disagreement"):
        decode_avl_packet(bad)


def test_crc_mismatch_rejected() -> None:
    inner = bytes([CODEC_8, 0, 0])
    bad = b"\x00\x00\x00\x00" + (3).to_bytes(4, "big") + inner + (0xDEAD).to_bytes(4, "big")
    with pytest.raises(ChecksumError, match="mismatch"):
        decode_avl_packet(bad)


def test_crc_high_bits_nonzero_rejected() -> None:
    inner = bytes([CODEC_8, 0, 0])
    crc = crc16_ibm(inner)
    bad = (
        b"\x00\x00\x00\x00" + (3).to_bytes(4, "big") + inner + (0xFFFF0000 | crc).to_bytes(4, "big")
    )
    with pytest.raises(MalformedPacketError, match="high 16 bits"):
        decode_avl_packet(bad)


def test_numdata1_ne_numdata2_rejected() -> None:
    record_bytes = _build_codec8_record(ts_ms=0)
    inner = bytes([1]) + record_bytes + bytes([2])  # NumData1=1, NumData2=2
    payload = bytes([CODEC_8]) + inner
    crc = crc16_ibm(payload)
    bad = b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")
    with pytest.raises(MalformedPacketError, match=r"NumData1.*NumData2"):
        decode_avl_packet(bad)


def test_n_total_disagrees_with_section_sums_rejected() -> None:
    """Forge a record where n_total ≠ N1+N2+N4+N8."""
    out = bytearray()
    out += (0).to_bytes(8, "big")  # ts_ms
    out += bytes([0])  # priority
    out += struct.pack(">iihHBH", 0, 0, 0, 0, 0, 0)  # GPS all zeros
    out += bytes([0, 99])  # event_io_id=0, n_total=99 (lying)
    out += bytes([0, 0, 0, 0])  # N1=0, N2=0, N4=0, N8=0 — sum=0, not 99
    forged_record = bytes(out)
    inner = bytes([1]) + forged_record + bytes([1])
    payload = bytes([CODEC_8]) + inner
    crc = crc16_ibm(payload)
    bad = b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")
    with pytest.raises(MalformedPacketError, match="N-of-Total-IO"):
        decode_avl_packet(bad)


# ---------------------------------------------------------------------------
# SH-05 — codec count caps (defence-in-depth against hostile packets)
# ---------------------------------------------------------------------------


def test_codec8_section_count_over_cap_rejected() -> None:
    """SH-05 — a Codec-8 IO section claiming 0xFF entries is refused.

    The 1-byte field's wire max is 255, which matches our explicit cap
    of 256; this test forges 255 — comfortably above any real-device
    telemetry shape — and asserts the decoder refuses on the explicit
    cap, not on the buffer-truncation fallback.
    """
    out = bytearray()
    out += (0).to_bytes(8, "big")  # ts_ms
    out += bytes([0])  # priority
    out += struct.pack(">iihHBH", 0, 0, 0, 0, 0, 0)
    out += bytes([0, 0])  # event_io_id=0, n_total=0
    # Forge N1 = 255. Don't include the 255 payload entries — the cap
    # check fires before the buffer-truncation check that would also
    # complain.
    out += bytes([0xFF, 0, 0, 0])  # N1=255, N2=0, N4=0, N8=0
    forged = bytes(out)
    inner = bytes([1]) + forged + bytes([1])
    payload = bytes([CODEC_8]) + inner
    crc = crc16_ibm(payload)
    pkt = b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")
    with pytest.raises(MalformedPacketError, match=r"N1 count 255 exceeds"):
        decode_avl_packet(pkt)


def test_codec8e_section_count_over_cap_rejected() -> None:
    """SH-05 — Codec-8E's 2-byte N count refuses values over the cap."""
    from mgf.tele.teltonika.codec import CODEC_8_EXTENDED

    out = bytearray()
    out += (0).to_bytes(8, "big")  # ts_ms
    out += bytes([0])  # priority
    out += struct.pack(">iihHBH", 0, 0, 0, 0, 0, 0)
    out += (0).to_bytes(2, "big")  # event_io_id (2-byte)
    out += (0).to_bytes(2, "big")  # n_total (2-byte)
    # N1 = 1024 (over our 256 cap; wire format allows up to 65535).
    out += (1024).to_bytes(2, "big")
    out += (0).to_bytes(2, "big")  # N2
    out += (0).to_bytes(2, "big")  # N4
    out += (0).to_bytes(2, "big")  # N8
    out += (0).to_bytes(2, "big")  # NX
    forged = bytes(out)
    inner = bytes([1]) + forged + bytes([1])
    payload = bytes([CODEC_8_EXTENDED]) + inner
    crc = crc16_ibm(payload)
    pkt = b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")
    with pytest.raises(MalformedPacketError, match=r"N1 count 1024 exceeds"):
        decode_avl_packet(pkt)


def test_codec8e_nx_count_over_cap_rejected() -> None:
    """SH-05 — Codec-8E NX (variable-length section) count refused over cap."""
    from mgf.tele.teltonika.codec import CODEC_8_EXTENDED

    out = bytearray()
    out += (0).to_bytes(8, "big")
    out += bytes([0])
    out += struct.pack(">iihHBH", 0, 0, 0, 0, 0, 0)
    out += (0).to_bytes(2, "big")  # event_io_id
    out += (0).to_bytes(2, "big")  # n_total
    out += (0).to_bytes(2, "big")  # N1
    out += (0).to_bytes(2, "big")  # N2
    out += (0).to_bytes(2, "big")  # N4
    out += (0).to_bytes(2, "big")  # N8
    out += (200).to_bytes(2, "big")  # NX = 200 (over the 64 cap)
    forged = bytes(out)
    inner = bytes([1]) + forged + bytes([1])
    payload = bytes([CODEC_8_EXTENDED]) + inner
    crc = crc16_ibm(payload)
    pkt = b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")
    with pytest.raises(MalformedPacketError, match=r"NX count 200 exceeds"):
        decode_avl_packet(pkt)


# ---------------------------------------------------------------------------
# Unsupported / unknown codec rejection
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("codec_id", [0x0C, 0x0D, 0x0E, 0x0F, 0x10])
def test_known_unsupported_codecs_rejected(codec_id: int) -> None:
    inner = bytes([0, 0])  # NumData1 + NumData2 (minimum valid envelope)
    payload = bytes([codec_id]) + inner
    crc = crc16_ibm(payload)
    bad = b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")
    with pytest.raises(UnsupportedCodecError, match=f"0x{codec_id:02x}"):
        decode_avl_packet(bad)


def test_unknown_codec_id_rejected() -> None:
    inner = bytes([0, 0])
    payload = bytes([0x99]) + inner
    crc = crc16_ibm(payload)
    bad = b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")
    with pytest.raises(MalformedPacketError, match="unknown codec id"):
        decode_avl_packet(bad)


# ---------------------------------------------------------------------------
# Codec 8 Extended
# ---------------------------------------------------------------------------


def test_codec8e_minimal_record_round_trip() -> None:
    rec = _build_codec8e_record(ts_ms=1_700_000_000_000)
    packet = _build_codec8e_packet([rec])
    codec_id, decoded = decode_avl_packet(packet)
    assert codec_id == CODEC_8_EXTENDED
    assert len(decoded) == 1
    assert decoded[0].codec == "C8E"


def test_codec8e_2byte_io_ids_decoded() -> None:
    """Codec 8E exists specifically to lift the 1-byte IO-ID limit."""
    rec = _build_codec8e_record(
        ts_ms=1_700_000_000_000,
        inputs_2b={1234: 0xABCD, 5678: 0x1111},
    )
    packet = _build_codec8e_packet([rec])
    _, decoded = decode_avl_packet(packet)
    assert dict(decoded[0].inputs) == {1234: 0xABCD, 5678: 0x1111}


def test_codec8e_event_io_id_2byte() -> None:
    rec = _build_codec8e_record(ts_ms=0, event_io_id=0x1234)
    packet = _build_codec8e_packet([rec])
    _, decoded = decode_avl_packet(packet)
    assert decoded[0].event_io_id == 0x1234


def test_codec8e_nx_section_validated_but_skipped() -> None:
    """NX entries don't enter the inputs map but their layout MUST validate."""
    rec = _build_codec8e_record(
        ts_ms=0,
        nx_entries=[(9999, b"\x01\x02\x03\x04\x05")],
    )
    packet = _build_codec8e_packet([rec])
    _, decoded = decode_avl_packet(packet)
    assert dict(decoded[0].inputs) == {}


def test_codec8e_truncated_nx_payload_rejected() -> None:
    """An NX header claiming 100 bytes but with only 5 actual bytes raises."""
    out = bytearray()
    out += (0).to_bytes(8, "big")  # ts_ms
    out += bytes([0])  # priority
    out += struct.pack(">iihHBH", 0, 0, 0, 0, 0, 0)
    out += (0).to_bytes(2, "big")  # event_io_id
    out += (0).to_bytes(2, "big")  # n_total
    out += (0).to_bytes(2, "big") * 4  # N1 N2 N4 N8 all zero
    out += (1).to_bytes(2, "big")  # NX = 1
    out += (1234).to_bytes(2, "big")  # NX io id
    out += (100).to_bytes(2, "big")  # claimed length 100
    out += b"\x01\x02\x03\x04\x05"  # only 5 bytes provided
    forged_record = bytes(out)
    inner = bytes([1]) + forged_record + bytes([1])
    payload = bytes([CODEC_8_EXTENDED]) + inner
    crc = crc16_ibm(payload)
    bad = b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")
    with pytest.raises(MalformedPacketError, match="NX entry payload"):
        decode_avl_packet(bad)


@st.composite
def _codec8e_record_bytes(draw: DrawFn) -> tuple[bytes, dict[int, int]]:
    """Strategy: a packed Codec-8E record + its expected inputs map."""
    ts_ms = draw(st.integers(min_value=0, max_value=2_000_000_000_000))
    priority = draw(st.integers(min_value=0, max_value=2))
    used_ids: set[int] = set()

    def _disjoint_dict(value_max: int, max_size: int) -> dict[int, int]:
        out: dict[int, int] = {}
        for _ in range(draw(st.integers(min_value=0, max_value=max_size))):
            io_id = draw(
                st.integers(min_value=0, max_value=0xFFFF).filter(lambda x: x not in used_ids)
            )
            used_ids.add(io_id)
            out[io_id] = draw(st.integers(min_value=0, max_value=value_max))
        return out

    inputs_1b = _disjoint_dict(0xFF, 3)
    inputs_2b = _disjoint_dict(0xFFFF, 3)
    inputs_4b = _disjoint_dict(0xFFFFFFFF, 2)
    inputs_8b = _disjoint_dict(0xFFFFFFFFFFFFFFFF, 1)
    expected = {**inputs_1b, **inputs_2b, **inputs_4b, **inputs_8b}

    record_bytes = _build_codec8e_record(
        ts_ms=ts_ms,
        priority=priority,
        inputs_1b=inputs_1b,
        inputs_2b=inputs_2b,
        inputs_4b=inputs_4b,
        inputs_8b=inputs_8b,
    )
    return record_bytes, expected


@given(st.lists(_codec8e_record_bytes(), min_size=1, max_size=4))
@settings(max_examples=80)
def test_codec8e_round_trip_property(
    items: list[tuple[bytes, dict[int, int]]],
) -> None:
    records_bytes = [b for b, _ in items]
    expected_inputs_per_record = [m for _, m in items]
    packet = _build_codec8e_packet(records_bytes)
    codec_id, decoded = decode_avl_packet(packet)
    assert codec_id == CODEC_8_EXTENDED
    assert len(decoded) == len(items)
    for r, expected_inputs in zip(decoded, expected_inputs_per_record, strict=True):
        assert r.codec == "C8E"
        assert dict(r.inputs) == expected_inputs
