"""Reliability tests: provider session lifecycle + error-hierarchy
completeness.

Drives the real :class:`MockTelematicsProvider` code paths and pins the
*documented* contracts read from the source:

  * ``open_session`` -> N x ``receive_packet`` -> ``close_session`` is the
    happy lifecycle; ``receive_packet`` after the script is exhausted
    raises :class:`MockExhaustedError` (a ``TelematicsError``); a second
    ``close_session`` is a silent no-op (idempotent).
  * Every concrete :class:`TelematicsError` subclass constructs from a
    plain message, preserves it on ``str(exc)``, and is catchable as both
    :class:`TelematicsError` and ``mgf.common``'s ``AppError`` (rule
    EH-01). These are device-session errors — they carry NO ``http_status``
    (that surface lives on ``mgf.common``'s ``HttpError`` family), and the
    tests pin that absence so a future drift is deliberate.
"""

from __future__ import annotations

from unittest.mock import MagicMock

import pytest
from mgf.common.exceptions import AppError
from tests.conftest import make_record

from mgf.tele.core import (
    ChecksumError,
    MalformedHandshakeError,
    MalformedPacketError,
    ReceivedPacket,
    SessionClosedError,
    TelematicsError,
    UnknownTrackerError,
    UnsupportedCodecError,
)
from mgf.tele.core.mock import MockExhaustedError, MockTelematicsProvider

# Every concrete subclass declared in _errors.py (read from __all__), plus
# the mock's own MockExhaustedError which also roots at TelematicsError.
_CORE_SUBCLASSES = [
    ChecksumError,
    MalformedHandshakeError,
    MalformedPacketError,
    SessionClosedError,
    UnknownTrackerError,
    UnsupportedCodecError,
]
_ALL_SUBCLASSES = [*_CORE_SUBCLASSES, MockExhaustedError]


# ---------------------------------------------------------------------------
# 5. Provider lifecycle
# ---------------------------------------------------------------------------


async def test_full_lifecycle_open_receive_all_then_close() -> None:
    """open → drain every scripted batch → close. Each receive returns a
    well-formed ReceivedPacket carrying the scripted records, in order."""
    b1 = [make_record(speed_kmh=11.0)]
    b2 = [make_record(speed_kmh=22.0), make_record(speed_kmh=33.0)]
    provider = MockTelematicsProvider(batches=[b1, b2], imei="111222333")

    handle = await provider.open_session(MagicMock(), MagicMock())
    assert handle.imei == "111222333"

    pkt1 = await provider.receive_packet(handle)
    pkt2 = await provider.receive_packet(handle)
    assert isinstance(pkt1, ReceivedPacket)
    assert [r.speed_kmh for r in pkt1.records] == [11.0]
    assert [r.speed_kmh for r in pkt2.records] == [22.0, 33.0]
    assert pkt1.codec_id == 8  # default codec id propagated to the envelope

    await provider.close_session(handle)  # clean teardown after a full drain


async def test_receive_after_exhaustion_raises_telematics_error() -> None:
    """Past the script, receive_packet raises MockExhaustedError — and that
    is catchable as the TelematicsError category (a consumer draining a loop
    catches the category, not the concrete type)."""
    provider = MockTelematicsProvider(batches=[[make_record()]])
    handle = await provider.open_session(MagicMock(), MagicMock())
    await provider.receive_packet(handle)  # drains the only batch

    with pytest.raises(TelematicsError):
        await provider.receive_packet(handle)


async def test_receive_after_exhaustion_message_names_the_session() -> None:
    """The exhaustion error names the offending session imei so a stuck test
    is diagnosable from the message alone."""
    provider = MockTelematicsProvider(batches=[], imei="999888777")
    handle = await provider.open_session(MagicMock(), MagicMock())
    with pytest.raises(MockExhaustedError) as ei:
        await provider.receive_packet(handle)
    assert "999888777" in str(ei.value)


async def test_close_session_is_idempotent_after_full_lifecycle() -> None:
    """close after a real session, then close AGAIN — the second is a silent
    no-op (the ABC documents close_session as idempotent)."""
    provider = MockTelematicsProvider(batches=[[make_record()]])
    handle = await provider.open_session(MagicMock(), MagicMock())
    await provider.receive_packet(handle)
    await provider.close_session(handle)
    await provider.close_session(handle)  # idempotent — must not raise


async def test_close_session_without_receiving_is_a_noop() -> None:
    """Open then immediately close (no packets received) must not raise — and
    closing an unknown/never-opened handle is also tolerated."""
    provider = MockTelematicsProvider(batches=[[make_record()]])
    handle = await provider.open_session(MagicMock(), MagicMock())
    await provider.close_session(handle)
    # A handle the provider never tracked — still a no-op.
    await provider.close_session(handle)


async def test_independent_sessions_drain_independently() -> None:
    """Two providers (two sessions) keep separate scripts — draining one does
    not affect the other (no shared mutable batch state)."""
    p_a = MockTelematicsProvider(batches=[[make_record(speed_kmh=1.0)]], imei="A")
    p_b = MockTelematicsProvider(batches=[[make_record(speed_kmh=2.0)]], imei="B")
    ha = await p_a.open_session(MagicMock(), MagicMock())
    hb = await p_b.open_session(MagicMock(), MagicMock())

    pkt_a = await p_a.receive_packet(ha)
    assert [r.speed_kmh for r in pkt_a.records] == [1.0]
    # A is now exhausted; B is untouched.
    with pytest.raises(MockExhaustedError):
        await p_a.receive_packet(ha)
    pkt_b = await p_b.receive_packet(hb)
    assert [r.speed_kmh for r in pkt_b.records] == [2.0]


# ---------------------------------------------------------------------------
# 6. Error-hierarchy completeness
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("exc_type", _ALL_SUBCLASSES)
def test_subclass_constructs_from_message_and_preserves_it(
    exc_type: type[TelematicsError],
) -> None:
    """Every concrete subclass takes a plain message and round-trips it via
    str() (AppError carries args through to the human-readable string)."""
    exc = exc_type("the wire was wrong")
    assert str(exc) == "the wire was wrong"
    assert exc.args == ("the wire was wrong",)


@pytest.mark.parametrize("exc_type", _ALL_SUBCLASSES)
def test_subclass_is_catchable_as_both_telematics_and_app_error(
    exc_type: type[TelematicsError],
) -> None:
    """EH-01 — each shape is catchable as the local category AND as the
    federation-wide AppError (a consumer's top-level handler treats every
    device-session failure uniformly)."""
    assert issubclass(exc_type, TelematicsError)
    assert issubclass(exc_type, AppError)

    with pytest.raises(TelematicsError):
        raise exc_type("x")
    with pytest.raises(AppError):
        raise exc_type("x")


@pytest.mark.parametrize("exc_type", _CORE_SUBCLASSES)
def test_subclass_carries_no_spurious_http_status(
    exc_type: type[TelematicsError],
) -> None:
    """These are device-session errors, NOT HTTP errors: they must NOT carry
    an http_status (that surface belongs to mgf.common's HttpError family).
    Pinning the absence makes any future addition a deliberate decision."""
    exc = exc_type("boom")
    assert not hasattr(exc, "http_status")


def test_telematics_error_set_is_complete() -> None:
    """Pin the exact concrete-subclass set exported from the package, so a new
    failure shape (or a removed one) forces a deliberate test update."""
    import mgf.tele.core as core

    exported = {
        name for name in core.__all__ if name.endswith("Error") and name != "TelematicsError"
    }
    assert exported == {
        "ChecksumError",
        "MalformedHandshakeError",
        "MalformedPacketError",
        "SessionClosedError",
        "UnknownTrackerError",
        "UnsupportedCodecError",
    }
