"""Hostile-input safety tests for the Teltonika AVL decoder.

This module is the adversarial complement to ``tests/test_codec.py``.
The decoder is the highest-risk surface in the library: it parses
untrusted bytes that arrive on the wire from physical devices (or
anyone who can reach the TCP listener). The safety contract under test:

    For ANY input bytes, the public decode functions must EITHER return
    a valid result OR raise a typed error from :mod:`mgf.tele.core`
    (a :class:`TelematicsError` subclass — e.g. ``MalformedPacketError``,
    ``ChecksumError``, ``UnsupportedCodecError``). They must NEVER raise
    a raw ``IndexError`` / ``struct.error`` / ``OverflowError`` /
    ``MemoryError``, never read past the buffer, never hang, and never
    allocate unbounded memory.

The cases here mutate an otherwise-valid packet by exactly ONE hostile
field so the failure is attributable to that mutation. Cases already
covered in ``test_codec.py`` (too-short, bad-preamble, length
disagreement, CRC mismatch, NumData1≠NumData2, N-of-Total mismatch,
section-count-over-cap, NX-count-over-cap, truncated-NX-payload) are
deliberately NOT duplicated here; this file targets the *lying-length*
and *random-fuzz* axes those don't reach.
"""

from __future__ import annotations

import struct

import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from mgf.tele.core import TelematicsError

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

pytestmark = pytest.mark.fuzz

# A raw exception in any of these classes escaping the decoder is a
# robustness/security bug: the byte parser leaked a low-level error
# instead of a typed, catchable domain error.
_RAW_PARSER_FAULTS = (IndexError, struct.error, OverflowError, MemoryError)


# ---------------------------------------------------------------------------
# Low-level builders (kept local so a mutation can violate an invariant the
# test_codec.py builders enforce — e.g. an inner block whose NumData1 lies).
# ---------------------------------------------------------------------------


def _envelope(codec_id: int, inner: bytes) -> bytes:
    """Wrap a codec inner-block in a structurally valid TCP envelope.

    Preamble, data-field-length and CRC are all correct, so the decoder
    is forced past the envelope checks and into the inner-block parser —
    which is the surface these tests probe.
    """
    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 _gps_header(ts_ms: int = 0) -> bytes:
    """The fixed 24-byte record prefix: timestamp + priority + GPS element."""
    out = bytearray()
    out += ts_ms.to_bytes(8, "big")
    out += bytes([0])  # priority
    out += struct.pack(">iihHBH", 0, 0, 0, 0, 0, 0)  # lon/lat/alt/angle/sat/speed
    return bytes(out)


def _valid_codec8_record() -> bytes:
    """A minimal valid Codec-8 record: GPS header + empty IO element."""
    out = bytearray(_gps_header())
    out += bytes([0, 0])  # event_io_id=0, n_total=0
    out += bytes([0, 0, 0, 0])  # N1=N2=N4=N8=0
    return bytes(out)


def _valid_codec8e_record() -> bytes:
    """A minimal valid Codec-8E record: GPS header + empty IO + empty NX."""
    out = bytearray(_gps_header())
    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
    out += (0).to_bytes(2, "big")  # NX
    return bytes(out)


def _known_valid_codec8_packet() -> bytes:
    """A complete, decodable single-record Codec-8 packet (fuzz seed)."""
    rec = _valid_codec8_record()
    inner = bytes([1]) + rec + bytes([1])
    return _envelope(CODEC_8, inner)


# ---------------------------------------------------------------------------
# Case 1 — lying record count (NumData1 claims more than the stream holds)
# ---------------------------------------------------------------------------


def test_lying_record_count_truncated_mid_record_rejected() -> None:
    """NumData1 claims 3 records but the stream holds one + a stub.

    The second record's GPS header runs off the end of the inner block.
    The decoder must raise a typed error, never a raw ``IndexError`` from
    indexing past the buffer.
    """
    rec = _valid_codec8_record()
    # Claim 3 records; supply 1 full record + 10 stray bytes (< the 24-byte
    # minimum header), then a NumData2. The per-record header guard
    # (cursor + 24 > len) must fire on record #2.
    inner = bytes([3]) + rec + b"\x00" * 10 + bytes([3])
    pkt = _envelope(CODEC_8, inner)
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


def test_lying_record_count_codec8e_truncated_mid_record_rejected() -> None:
    """Codec-8E variant of the lying-record-count truncation."""
    rec = _valid_codec8e_record()
    inner = bytes([2]) + rec + b"\x00" * 5 + bytes([2])
    pkt = _envelope(CODEC_8_EXTENDED, inner)
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


# ---------------------------------------------------------------------------
# Case 2 — lying IO element count (a section count exceeds bytes remaining)
# ---------------------------------------------------------------------------


def test_lying_io_count_runs_off_buffer_rejected() -> None:
    """An N2 section claims more 3-byte entries than the buffer holds.

    The count (5) is under the SH-05 cap, so this exercises the
    *buffer-truncation* guard (``cursor + n*entry_size > len(inner)``),
    not the count-cap guard already covered in test_codec.py.
    """
    out = bytearray(_gps_header())
    out += bytes([0, 5])  # event_io_id=0, n_total=5
    out += bytes([0])  # N1 = 0
    out += bytes([5])  # N2 = 5  -> needs 5 * (1 id + 2 val) = 15 bytes
    out += b"\x01\x00\x02"  # only one 3-byte entry actually present
    # (N4 / N8 section bytes deliberately omitted — buffer ends here)
    forged = bytes(out)
    inner = bytes([1]) + forged + bytes([1])
    pkt = _envelope(CODEC_8, inner)
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


def test_lying_io_count_codec8e_runs_off_buffer_rejected() -> None:
    """Codec-8E N4 section claims more 6-byte entries than remain."""
    out = bytearray(_gps_header())
    out += (0).to_bytes(2, "big")  # event_io_id
    out += (10).to_bytes(2, "big")  # n_total (lying but count guard fires first)
    out += (0).to_bytes(2, "big")  # N1
    out += (0).to_bytes(2, "big")  # N2
    out += (10).to_bytes(2, "big")  # N4 = 10 -> needs 10 * (2 id + 4 val) = 60 bytes
    out += b"\x00\x00\x00\x00\x00\x00"  # only one 6-byte entry present
    forged = bytes(out)
    inner = bytes([1]) + forged + bytes([1])
    pkt = _envelope(CODEC_8_EXTENDED, inner)
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


# ---------------------------------------------------------------------------
# Case 3 — lying value-width section (header consumes the count byte then the
# entry payload overruns the buffer by exactly one entry's worth)
# ---------------------------------------------------------------------------


def test_lying_value_width_overruns_by_one_entry_rejected() -> None:
    """An N8 section header is present but the 8-byte value is truncated.

    N8 = 1 means one (1-byte id + 8-byte value) entry. We supply the id
    and only 4 of the 8 value bytes, so the value read would walk 4 bytes
    past the end. Must be a typed error, not a silent short read.
    """
    out = bytearray(_gps_header())
    out += bytes([0, 1])  # event_io_id=0, n_total=1
    out += bytes([0, 0, 0])  # N1=0, N2=0, N4=0
    out += bytes([1])  # N8 = 1 -> needs 1 + 8 = 9 bytes
    out += bytes([0x42])  # io id present...
    out += b"\x00\x00\x00\x00"  # ...but only 4 of 8 value bytes
    forged = bytes(out)
    inner = bytes([1]) + forged + bytes([1])
    pkt = _envelope(CODEC_8, inner)
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


def test_lying_io_header_truncated_before_counts_rejected() -> None:
    """The IO element header itself (event id + n_total) is truncated.

    The GPS header is complete (24 bytes) but the record ends immediately
    after, so the ``cursor + 2`` IO-header guard must catch it.
    """
    out = bytearray(_gps_header())  # exactly 24 bytes, then nothing
    forged = bytes(out)
    inner = bytes([1]) + forged + bytes([1])
    pkt = _envelope(CODEC_8, inner)
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


# ---------------------------------------------------------------------------
# Case 4 — oversized AVL data-length field (claims a payload far larger than
# the packet provides; must not trigger a huge read / hang / allocation)
# ---------------------------------------------------------------------------


def test_oversized_data_length_field_rejected_fast() -> None:
    """The 4-byte data-field-length claims ~4 GiB; packet is tiny.

    The envelope length-disagreement check must fire on the mismatch
    without slicing or allocating the claimed size. We assert both the
    typed error AND that it returns essentially instantly.
    """
    inner = bytes([CODEC_8, 0, 0])
    crc = crc16_ibm(inner)
    huge = 0xFFFF_FFFF  # 4 GiB - 1, vastly larger than the real payload
    pkt = b"\x00\x00\x00\x00" + huge.to_bytes(4, "big") + inner + crc.to_bytes(4, "big")
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


def test_oversized_data_length_matched_by_padding_still_bounded() -> None:
    """data_field_length agrees with a large body of junk after the codec id.

    Here the length field is *honest* about the (large) body, so the
    envelope check passes and the inner parser runs. A junk body must
    still resolve to a typed error (codec dispatch / inner-block parse),
    never a raw fault — and the SH-05 caps keep iteration bounded.
    """
    body = b"\x42" * 4096  # codec id 0x42 is unknown -> typed reject expected
    crc = crc16_ibm(body)
    pkt = b"\x00\x00\x00\x00" + len(body).to_bytes(4, "big") + body + crc.to_bytes(4, "big")
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


# ---------------------------------------------------------------------------
# Case 5 — Codec-8E NX section claiming a huge variable-length payload
# ---------------------------------------------------------------------------


def test_codec8e_nx_huge_payload_length_rejected() -> None:
    """A single NX entry declares a 0xFFFF-byte payload but supplies none.

    NX count (1) is within the cap, so this drives the per-entry
    ``cursor + var_len > len(inner)`` guard. The decoder must NOT attempt
    to read/skip 65535 bytes that don't exist — typed error expected.
    """
    out = bytearray(_gps_header())
    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 (within cap)
    out += (1234).to_bytes(2, "big")  # NX io id
    out += (0xFFFF).to_bytes(2, "big")  # claimed payload length 65535
    # ...and zero payload bytes supplied.
    forged = bytes(out)
    inner = bytes([1]) + forged + bytes([1])
    pkt = _envelope(CODEC_8_EXTENDED, inner)
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


def test_codec8e_nx_entry_header_truncated_rejected() -> None:
    """NX count claims 1 entry but the 4-byte entry header is truncated."""
    out = bytearray(_gps_header())
    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
    out += (1).to_bytes(2, "big")  # NX = 1
    out += b"\x12"  # only 1 byte of the 4-byte (id + len) header
    forged = bytes(out)
    inner = bytes([1]) + forged + bytes([1])
    pkt = _envelope(CODEC_8_EXTENDED, inner)
    with pytest.raises(TelematicsError):
        decode_avl_packet(pkt)


# ---------------------------------------------------------------------------
# Case 6 — Hypothesis fuzz: random single-mutation of a known-valid packet
# ---------------------------------------------------------------------------


@st.composite
def _mutated_packet(draw: st.DrawFn) -> bytes:
    """Start from a valid Codec-8 packet; apply ONE random hostile mutation.

    Mutation drawn from:
      * flip   — overwrite a random byte with a random value;
      * trunc  — cut the packet at a random offset;
      * inflate— overwrite a random byte with 0xFF (inflates whatever
                 count / length field happens to live there).
    """
    base = bytearray(_known_valid_codec8_packet())
    kind = draw(st.sampled_from(("flip", "trunc", "inflate")))
    if kind == "trunc":
        cut = draw(st.integers(min_value=0, max_value=len(base)))
        return bytes(base[:cut])
    idx = draw(st.integers(min_value=0, max_value=len(base) - 1))
    if kind == "flip":
        base[idx] = draw(st.integers(min_value=0, max_value=255))
    else:  # inflate
        base[idx] = 0xFF
    return bytes(base)


@settings(
    max_examples=200,
    deadline=400,  # ms per example — a single decode can't be allowed to hang
    suppress_health_check=[HealthCheck.too_slow],
)
@given(_mutated_packet())
def test_single_mutation_never_raises_raw_fault(packet: bytes) -> None:
    """Any one-mutation packet either decodes or raises a TelematicsError.

    It must NEVER leak a raw IndexError / struct.error / OverflowError /
    MemoryError out of the byte parser, and (via the per-example deadline)
    must never hang.
    """
    try:
        decode_avl_packet(packet)
    except TelematicsError:
        pass  # the contract's allowed failure mode
    except _RAW_PARSER_FAULTS as exc:  # pragma: no cover - only on a real bug
        pytest.fail(
            f"decoder leaked a raw {type(exc).__name__} on hostile input "
            f"(len={len(packet)}): {exc!r} — packet={packet.hex()}"
        )


@settings(
    max_examples=200,
    deadline=400,
    suppress_health_check=[HealthCheck.too_slow],
)
@given(st.binary(min_size=0, max_size=512))
def test_arbitrary_bytes_never_raise_raw_fault(packet: bytes) -> None:
    """Fully-random bytes (no valid seed) — same safety contract.

    This widens the net beyond single-mutations: structurally there is
    no guarantee of a valid preamble/length, so almost everything is
    rejected, but the *manner* of rejection must always be typed.
    """
    try:
        decode_avl_packet(packet)
    except TelematicsError:
        pass
    except _RAW_PARSER_FAULTS as exc:  # pragma: no cover - only on a real bug
        pytest.fail(
            f"decoder leaked a raw {type(exc).__name__} on random input "
            f"(len={len(packet)}): {exc!r} — packet={packet.hex()}"
        )
