"""Tests for :class:`TeltonikaProvider`.

Exercises the TCP session lifecycle without a real socket. Pattern:

  * Reader: an :class:`asyncio.StreamReader` with ``feed_data()`` so the
    test scripts the bytes the device would send.
  * Writer: a :class:`unittest.mock.MagicMock` with ``drain`` /
    ``wait_closed`` as :class:`AsyncMock`. The test asserts on the
    server-side bytes by inspecting ``writer.write.call_args_list``.

Coverage of the handshake state machine, the receive loop, the per-
packet ack, the v1 read-only ``send_command`` reject, and translation
of asyncio I/O exceptions into typed ``TelematicsError`` subclasses.
"""

from __future__ import annotations

import asyncio
import struct
from datetime import UTC
from unittest.mock import AsyncMock, MagicMock

import pytest
from mgf.tele.core import (
    MalformedHandshakeError,
    SessionClosedError,
    SessionHandle,
    TrackerCommand,
    UnknownTrackerError,
)

from mgf.tele.teltonika import (
    CODEC_8,
    TeltonikaProvider,
    TeltonikaSessionHandle,
)
from mgf.tele.teltonika._crc import crc16_ibm

# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------


def _stub_streams(
    *,
    peername: tuple[str, int] | None = ("203.0.113.10", 50000),
) -> tuple[asyncio.StreamReader, MagicMock]:
    """Real reader (so feed_data works) + mock writer.

    ``peername`` is the value returned by ``writer.get_extra_info("peername")``
    — SH-06's first-sight logger reads it. Defaults to a non-routable
    TEST-NET-3 address so test logs don't accidentally look real.
    Pass ``None`` to simulate a closed-socket / no-peer-info case.
    """
    reader = asyncio.StreamReader()
    writer = MagicMock()
    writer.drain = AsyncMock()
    writer.close = MagicMock()
    writer.is_closing = MagicMock(return_value=False)
    writer.wait_closed = AsyncMock()
    writer.get_extra_info = MagicMock(return_value=peername)
    return reader, writer


def _handshake_bytes(imei: str) -> bytes:
    """The bytes the device sends as a TCP handshake."""
    encoded = imei.encode("ascii")
    return len(encoded).to_bytes(2, "big") + encoded


def _build_codec8_record(*, ts_ms: int = 0) -> bytes:
    """Pack one minimal Codec-8 record (no IO)."""
    out = bytearray()
    out += ts_ms.to_bytes(8, "big")
    out += bytes([0])  # priority
    out += struct.pack(">iihHBH", 0, 0, 0, 0, 0, 0)  # GPS all zeros
    out += bytes([0, 0])  # event_io_id, n_total
    out += bytes([0, 0, 0, 0])  # N1 N2 N4 N8 all zero
    return bytes(out)


def _wrap_codec8_envelope(records: list[bytes]) -> bytes:
    """Wrap N Codec-8 records in the universal TCP envelope."""
    n = len(records)
    inner = bytes([n]) + b"".join(records) + bytes([n])
    payload = bytes([CODEC_8]) + inner
    crc = crc16_ibm(payload)
    return b"\x00\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + crc.to_bytes(4, "big")


def _writer_bytes(writer: MagicMock) -> bytes:
    """Concatenate everything the server wrote to the writer."""
    return b"".join(call.args[0] for call in writer.write.call_args_list)


# ---------------------------------------------------------------------------
# Handshake — happy path
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_handshake_accepts_well_formed_imei() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))

    handle = await provider.open_session(reader, writer)

    assert isinstance(handle, TeltonikaSessionHandle)
    assert handle.imei == "356307042441013"
    assert handle.opened_at.tzinfo is UTC
    # Session handle carries the I/O state.
    assert handle.reader is reader
    assert handle.writer is writer
    # Server replied 0x01 accept.
    assert _writer_bytes(writer) == b"\x01"


@pytest.mark.asyncio
async def test_handshake_consults_imei_authoriser_when_present() -> None:
    seen: list[str] = []

    async def authoriser(imei: str) -> bool:
        seen.append(imei)
        return imei == "356307042441013"

    provider = TeltonikaProvider(imei_authoriser=authoriser)
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))

    handle = await provider.open_session(reader, writer)
    assert handle.imei == "356307042441013"
    assert seen == ["356307042441013"]
    assert _writer_bytes(writer) == b"\x01"


# ---------------------------------------------------------------------------
# Handshake — rejection paths
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_handshake_rejects_imei_with_letters() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("ABCDEFGHIJ12345"))  # 15 chars but letters

    with pytest.raises(MalformedHandshakeError, match="malformed IMEI"):
        await provider.open_session(reader, writer)
    assert _writer_bytes(writer) == b"\x00"


@pytest.mark.asyncio
async def test_handshake_rejects_imei_too_short() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    # Length prefix says 5 — implausible (min 14).
    reader.feed_data((5).to_bytes(2, "big") + b"12345")

    with pytest.raises(MalformedHandshakeError, match="implausible IMEI length"):
        await provider.open_session(reader, writer)


@pytest.mark.asyncio
async def test_handshake_rejects_non_ascii_imei_bytes() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    # 15 bytes including high-bit non-ASCII.
    bad = b"\xff\xfe1234567890123"
    reader.feed_data(len(bad).to_bytes(2, "big") + bad)

    with pytest.raises(MalformedHandshakeError, match="not ASCII"):
        await provider.open_session(reader, writer)


@pytest.mark.asyncio
async def test_handshake_rejects_when_authoriser_returns_false() -> None:
    async def deny_all(imei: str) -> bool:
        return False

    provider = TeltonikaProvider(imei_authoriser=deny_all)
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))

    with pytest.raises(UnknownTrackerError, match="rejected by registry"):
        await provider.open_session(reader, writer)
    assert _writer_bytes(writer) == b"\x00"


@pytest.mark.asyncio
async def test_handshake_eof_during_length_prefix_raises_handshake_error() -> None:
    """Peer closes before the server has read the 2-byte length prefix."""
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_eof()

    with pytest.raises(MalformedHandshakeError, match="length prefix"):
        await provider.open_session(reader, writer)


@pytest.mark.asyncio
async def test_handshake_eof_during_imei_bytes_raises_handshake_error() -> None:
    """Length prefix arrives but IMEI bytes are truncated."""
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data((15).to_bytes(2, "big") + b"123")  # promised 15, sent 3
    reader.feed_eof()

    with pytest.raises(MalformedHandshakeError, match="IMEI bytes"):
        await provider.open_session(reader, writer)


# ---------------------------------------------------------------------------
# receive_packet — happy path + ack
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_receive_packet_decodes_one_packet_and_writes_ack() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    # Handshake.
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)
    writer.write.reset_mock()  # discard the 0x01 from handshake.

    # Feed one packet with two records.
    packet_bytes = _wrap_codec8_envelope(
        [_build_codec8_record(ts_ms=1), _build_codec8_record(ts_ms=2)]
    )
    reader.feed_data(packet_bytes)

    packet = await provider.receive_packet(handle)
    assert packet.parse_status == "ok"
    assert len(packet.records) == 2
    assert packet.raw_bytes == packet_bytes
    assert packet.transport == "tcp"
    assert packet.codec_id == CODEC_8
    # Ack is 4-byte big-endian record count.
    assert _writer_bytes(writer) == (2).to_bytes(4, "big")


@pytest.mark.asyncio
async def test_receive_packet_supports_back_to_back_packets() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)
    writer.write.reset_mock()

    packet1 = _wrap_codec8_envelope([_build_codec8_record(ts_ms=1)])
    packet2 = _wrap_codec8_envelope([_build_codec8_record(ts_ms=2), _build_codec8_record(ts_ms=3)])
    reader.feed_data(packet1 + packet2)

    batch1 = await provider.receive_packet(handle)
    batch2 = await provider.receive_packet(handle)
    assert len(batch1.records) == 1
    assert len(batch2.records) == 2
    # Two acks: 1 then 2 records.
    assert _writer_bytes(writer) == (1).to_bytes(4, "big") + (2).to_bytes(4, "big")


# ---------------------------------------------------------------------------
# receive_packet — decode failures become parse_status, NOT exceptions (A-01)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_receive_packet_translates_eof_to_session_closed() -> None:
    """Socket-level EOF still raises — there's no packet to persist."""
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)
    reader.feed_eof()

    with pytest.raises(SessionClosedError, match="peer closed mid-packet"):
        await provider.receive_packet(handle)


@pytest.mark.asyncio
async def test_receive_packet_returns_malformed_status_not_exception() -> None:
    """A-01: decode failures encode in parse_status; bytes preserved."""
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)
    writer.write.reset_mock()

    bad_bytes = (
        b"\xff\xff\xff\xff"
        + (3).to_bytes(4, "big")
        + bytes([CODEC_8, 0, 0])
        + (crc16_ibm(bytes([CODEC_8, 0, 0])).to_bytes(4, "big"))
    )
    reader.feed_data(bad_bytes)

    packet = await provider.receive_packet(handle)
    assert packet.parse_status == "malformed"
    assert packet.records == []
    assert packet.raw_bytes == bad_bytes
    assert packet.parse_error is not None
    # No ack sent — Teltonika protocol: device retransmits on no-ack.
    assert _writer_bytes(writer) == b""


@pytest.mark.asyncio
async def test_receive_packet_returns_checksum_fail_status() -> None:
    """CRC mismatch → parse_status='checksum_fail', no ack."""
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)
    writer.write.reset_mock()

    inner = bytes([CODEC_8, 0, 0])
    bad = b"\x00\x00\x00\x00" + (3).to_bytes(4, "big") + inner + (0xDEAD).to_bytes(4, "big")
    reader.feed_data(bad)

    packet = await provider.receive_packet(handle)
    assert packet.parse_status == "checksum_fail"
    assert packet.raw_bytes == bad
    assert _writer_bytes(writer) == b""


# ---------------------------------------------------------------------------
# SH-04 — frame-size cap (refuse oversized data_field_length)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_receive_packet_refuses_frame_over_cap() -> None:
    """SH-04 — a length field > max_frame_bytes raises before readexactly.

    Without the cap, a hostile actor on the unauthenticated ingest
    socket could send ``0x7FFFFFFF`` and drive the process to a ~2 GiB
    allocation. The cap rejects the frame BEFORE the readexactly that
    would do the allocation; the test never feeds the oversized
    payload so an unbounded read would hang forever — getting a
    MalformedPacketError back is the proof the check fires first.
    """
    provider = TeltonikaProvider(max_frame_bytes=1024)
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)
    writer.write.reset_mock()

    # Preamble + a length field claiming 1 MiB (way over the 1 KiB cap).
    # NO payload bytes follow — the test would deadlock if the cap
    # didn't fire before readexactly.
    reader.feed_data(b"\x00\x00\x00\x00" + (1024 * 1024).to_bytes(4, "big"))

    from mgf.tele.core import MalformedPacketError

    with pytest.raises(MalformedPacketError, match="exceeds max_frame_bytes"):
        await provider.receive_packet(handle)


@pytest.mark.asyncio
async def test_receive_packet_accepts_frame_at_cap() -> None:
    """SH-04 — exactly at-the-cap frames work; only > cap is refused."""
    # A real packet inside a generous cap should still decode cleanly.
    provider = TeltonikaProvider(max_frame_bytes=64 * 1024)
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)
    writer.write.reset_mock()

    packet_bytes = _wrap_codec8_envelope([_build_codec8_record(ts_ms=1)])
    reader.feed_data(packet_bytes)

    packet = await provider.receive_packet(handle)
    assert packet.parse_status == "ok"


def test_provider_rejects_nonpositive_max_frame_bytes() -> None:
    """Constructor argument validation — config errors fail loud."""
    with pytest.raises(ValueError, match="max_frame_bytes"):
        TeltonikaProvider(max_frame_bytes=0)
    with pytest.raises(ValueError, match="max_frame_bytes"):
        TeltonikaProvider(max_frame_bytes=-1)


@pytest.mark.asyncio
async def test_receive_packet_rejects_non_teltonika_session_handle() -> None:
    """Catch the case where the wrong provider's session handle was passed."""
    provider = TeltonikaProvider()
    foreign_handle = SessionHandle(imei="x", opened_at=__import__("datetime").datetime.now(UTC))
    with pytest.raises(TypeError, match="non-Teltonika session"):
        await provider.receive_packet(foreign_handle)


# ---------------------------------------------------------------------------
# send_command — v1 read-only
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_send_command_raises_in_v1_read_only() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)

    with pytest.raises(NotImplementedError, match="read-only"):
        await provider.send_command(handle, TrackerCommand(payload=b"hello"))


# ---------------------------------------------------------------------------
# close_session — idempotency + already-closed peer
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_close_session_calls_writer_close_and_waits() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)

    await provider.close_session(handle)

    writer.close.assert_called_once()
    writer.wait_closed.assert_awaited_once()


@pytest.mark.asyncio
async def test_close_session_is_idempotent_when_writer_already_closing() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)
    writer.is_closing.return_value = True

    await provider.close_session(handle)
    # writer.close should NOT fire when already closing.
    writer.close.assert_not_called()


@pytest.mark.asyncio
async def test_close_session_swallows_already_disconnected_peer_errors() -> None:
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    handle = await provider.open_session(reader, writer)
    writer.wait_closed.side_effect = ConnectionResetError("peer reset")

    # Should NOT raise — closing was the goal.
    await provider.close_session(handle)


# ---------------------------------------------------------------------------
# Write paths translate broken-pipe to SessionClosedError
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_handshake_response_write_translates_broken_pipe() -> None:
    """If the device hangs up between sending IMEI and receiving the ack,
    the server-side write/drain raises BrokenPipeError → SessionClosedError."""
    provider = TeltonikaProvider()
    reader, writer = _stub_streams()
    reader.feed_data(_handshake_bytes("356307042441013"))
    writer.drain.side_effect = BrokenPipeError("peer hung up")

    with pytest.raises(SessionClosedError, match="peer closed before write"):
        await provider.open_session(reader, writer)


# ---------------------------------------------------------------------------
# SH-06 — first-sight (IMEI, source-IP) logging
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_first_sight_logs_new_imei_ip_pair(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """First time we see (IMEI, IP) → INFO log carrying structured extras."""
    provider = TeltonikaProvider()
    reader, writer = _stub_streams(peername=("203.0.113.10", 50000))
    reader.feed_data(_handshake_bytes("356307042441013"))

    with caplog.at_level("INFO", logger="mgf.tele.teltonika.provider"):
        await provider.open_session(reader, writer)

    first_sight_records = [r for r in caplog.records if "first-sight" in r.getMessage()]
    assert len(first_sight_records) == 1
    rec = first_sight_records[0]
    # ``extra={...}`` keys are set on the LogRecord as attributes, but
    # LogRecord doesn't declare them statically — use getattr to keep
    # mypy --strict quiet.
    assert getattr(rec, "imei", None) == "356307042441013"
    assert getattr(rec, "source_ip", None) == "203.0.113.10"
    assert getattr(rec, "event", None) == "first_sight"


@pytest.mark.asyncio
async def test_first_sight_dedupes_repeat_pair(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Same (IMEI, IP) twice → log exactly once."""
    provider = TeltonikaProvider()
    # Two open_session calls with the same peername — second is a no-log.
    for _ in range(2):
        reader, writer = _stub_streams(peername=("203.0.113.10", 50000))
        reader.feed_data(_handshake_bytes("356307042441013"))
        with caplog.at_level(
            "INFO",
            logger="mgf.tele.teltonika.provider",
        ):
            await provider.open_session(reader, writer)

    first_sight_records = [r for r in caplog.records if "first-sight" in r.getMessage()]
    assert len(first_sight_records) == 1


@pytest.mark.asyncio
async def test_first_sight_logs_each_new_ip_for_same_imei(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Same IMEI from two different IPs → log twice (forensic signal)."""
    provider = TeltonikaProvider()
    for ip in ("203.0.113.10", "198.51.100.20"):
        reader, writer = _stub_streams(peername=(ip, 50000))
        reader.feed_data(_handshake_bytes("356307042441013"))
        with caplog.at_level(
            "INFO",
            logger="mgf.tele.teltonika.provider",
        ):
            await provider.open_session(reader, writer)

    first_sight_records = [r for r in caplog.records if "first-sight" in r.getMessage()]
    assert len(first_sight_records) == 2
    assert {getattr(r, "source_ip", None) for r in first_sight_records} == {
        "203.0.113.10",
        "198.51.100.20",
    }


@pytest.mark.asyncio
async def test_first_sight_skipped_when_peername_unavailable(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """No peer info (closed socket) → no log, no exception."""
    provider = TeltonikaProvider()
    reader, writer = _stub_streams(peername=None)
    reader.feed_data(_handshake_bytes("356307042441013"))

    with caplog.at_level(
        "INFO",
        logger="mgf.tele.teltonika.provider",
    ):
        # Must succeed — the session is otherwise valid.
        await provider.open_session(reader, writer)

    first_sight_records = [r for r in caplog.records if "first-sight" in r.getMessage()]
    assert first_sight_records == []


@pytest.mark.asyncio
async def test_first_sight_not_recorded_for_rejected_imei(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Registry-rejected IMEI must not enter the tracking table.

    Lets the bounded cache resist a scanner that probes unregistered
    IMEIs to grow the per-process table.
    """

    async def deny_all(imei: str) -> bool:
        return False

    provider = TeltonikaProvider(imei_authoriser=deny_all)
    reader, writer = _stub_streams(peername=("203.0.113.10", 50000))
    reader.feed_data(_handshake_bytes("356307042441013"))

    with (
        caplog.at_level(
            "INFO",
            logger="mgf.tele.teltonika.provider",
        ),
        pytest.raises(UnknownTrackerError),
    ):
        await provider.open_session(reader, writer)

    first_sight_records = [r for r in caplog.records if "first-sight" in r.getMessage()]
    assert first_sight_records == []
    # Internal state must be empty — the rejected IMEI did not enter
    # the tracking table.
    assert "356307042441013" not in provider._seen_imei_ip_pairs
