from __future__ import annotations

import asyncio
import logging
from collections import deque
from types import SimpleNamespace
from typing import Any

import pytest

import cmvr_edge_ai.connectors.cmvr_es.camera as camera_module
from cmvr_edge_ai.connectors.cmvr_es import (
    CmvrCameraRgbStreamSource,
    CmvrServiceError,
)
from cmvr_edge_ai.contracts import ImageFrame
from cmvr_edge_ai.core import Emission


class _FakeCall:
    def __init__(self, *events: Any) -> None:
        self._events = deque(events)
        self.cancel_count = 0

    def __aiter__(self) -> _FakeCall:
        return self

    async def __anext__(self) -> Any:
        if not self._events:
            raise StopAsyncIteration
        event = self._events.popleft()
        if isinstance(event, BaseException):
            raise event
        return event

    def cancel(self) -> bool:
        self.cancel_count += 1
        return True


class _BlockingCall:
    def __init__(self) -> None:
        self.entered = asyncio.Event()
        self._released = asyncio.Event()
        self.cancel_count = 0

    def __aiter__(self) -> _BlockingCall:
        return self

    async def __anext__(self) -> Any:
        self.entered.set()
        await self._released.wait()
        raise asyncio.CancelledError

    def cancel(self) -> bool:
        self.cancel_count += 1
        self._released.set()
        return True


class _ImmediateRetryEvent(asyncio.Event):
    """Exercise reconnect paths without waiting for wall-clock backoff."""

    async def wait(self) -> bool:
        if self.is_set():
            return True
        raise TimeoutError


class _OneProgressTickEvent:
    """Cause exactly one heartbeat tick, then terminate its logging loop."""

    def __init__(self) -> None:
        self._done = False

    def is_set(self) -> bool:
        return self._done

    async def wait(self) -> bool:
        self._done = True
        raise TimeoutError


class _FakeStub:
    def __init__(
        self,
        *calls: Any,
        start_feedbacks: tuple[Any, ...] = (),
    ) -> None:
        self._calls = deque(calls)
        self._start_feedbacks = deque(start_feedbacks)
        self.events: list[str] = []
        self.start_requests: list[Any] = []
        self.start_timeouts: list[float | None] = []
        self.start_count = 0
        self.open_count = 0

    async def StartCamera(
        self,
        request: Any,
        *,
        timeout: float | None = None,
    ) -> Any:
        self.events.append("start")
        self.start_requests.append(request)
        self.start_timeouts.append(timeout)
        self.start_count += 1
        if not self._start_feedbacks:
            return _start_feedback()
        feedback = self._start_feedbacks.popleft()
        if isinstance(feedback, BaseException):
            raise feedback
        return feedback

    def GetRGBImageStream(self, request_stream: Any) -> Any:
        # The connector owns request-stream lifetime.  This fake only exercises
        # response and reconnect behavior, so protobuf request objects are not
        # imported or inspected.
        del request_stream
        self.events.append("stream")
        self.open_count += 1
        if not self._calls:
            raise AssertionError("camera connector opened an unexpected stream")
        return self._calls.popleft()


def _start_feedback(
    *,
    success: bool = True,
    error_message: str = "",
) -> Any:
    return SimpleNamespace(
        header=SimpleNamespace(
            success=success,
            error_message=error_message,
        )
    )


def _response(
    remote_sequence: int,
    *,
    data: bytes = b"encoded-frame",
    codec: str = "H264",
    key_frame: bool = False,
    width: int = 640,
    height: int = 480,
    seconds: int = 0,
    nanos: int = 0,
    success: bool = True,
    error_message: str = "",
) -> Any:
    return SimpleNamespace(
        header=SimpleNamespace(
            success=success,
            error_message=error_message,
            timestamp=SimpleNamespace(seconds=seconds, nanos=nanos),
        ),
        color_frame=SimpleNamespace(
            data=data,
            width=width,
            height=height,
            codec=codec,
            is_key_frame=key_frame,
        ),
        intrinsics=SimpleNamespace(
            fx=100.0,
            fy=101.0,
            cx=50.0,
            cy=51.0,
            coeffs=(0.1, 0.2, 0.3, 0.4, 0.5),
        ),
        seq_no=remote_sequence,
    )


def _source(stub: Any, **params: Any) -> CmvrCameraRgbStreamSource:
    configured = {
        "endpoint": "cmvr_es",
        "device_id": "camera-1",
        "pixel_format": "BGR8",
        "reconnect_initial_s": 0.001,
        "reconnect_max_s": 0.002,
        **params,
    }
    source = CmvrCameraRgbStreamSource("camera", configured)
    # setup() is deliberately bypassed: these unit tests exercise stream state
    # without importing generated protobuf bindings or constructing gRPC objects.
    source._stub = stub  # type: ignore[attr-defined]
    source._start_request = object()  # type: ignore[attr-defined]
    source._stream_request = object()  # type: ignore[attr-defined]
    source._shutdown_event = asyncio.Event()  # type: ignore[attr-defined]
    source._rpc_timeout_s = 2.5  # type: ignore[attr-defined]
    return source


def test_reconnect_uses_local_monotonic_sequence_and_new_session_id(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    session_ids = iter(("session-a", "session-b"))
    monkeypatch.setattr(
        camera_module,
        "uuid4",
        lambda: SimpleNamespace(hex=next(session_ids)),
    )
    first_call = _FakeCall(
        _response(
            900,
            data=b"first",
            key_frame=True,
            seconds=12,
            nanos=34,
        )
    )
    second_call = _FakeCall(_response(3, data=b"second"))

    stub = _FakeStub(first_call, second_call)

    async def exercise():  # type: ignore[no-untyped-def]
        source = _source(stub)
        stream = source.messages()
        first = await asyncio.wait_for(anext(stream), timeout=1)
        second = await asyncio.wait_for(anext(stream), timeout=1)
        await stream.aclose()
        return first, second

    first, second = asyncio.run(exercise())

    assert isinstance(first, Emission)
    assert isinstance(second, Emission)
    assert first.port == second.port == "frames"
    assert [first.envelope.sequence, second.envelope.sequence] == [0, 1]
    assert [first.envelope.session_id, second.envelope.session_id] == [
        "session-a",
        "session-b",
    ]
    assert [
        first.envelope.attributes["remote_sequence"],
        second.envelope.attributes["remote_sequence"],
    ] == [900, 3]
    assert first.envelope.attributes["stream_session_id"] == "session-a"
    assert second.envelope.attributes["stream_session_id"] == "session-b"
    assert first.envelope.source_id == second.envelope.source_id == "camera-1"
    assert first.envelope.captured_at_ns == 12_000_000_034
    assert second.envelope.captured_at_ns is None
    assert isinstance(first.envelope.payload, ImageFrame)
    assert first.envelope.payload.data == b"first"
    assert first.envelope.payload.codec == "H264"
    assert first.envelope.payload.is_key_frame is True
    assert first.envelope.payload.pixel_format == "BGR8"
    assert first.envelope.payload.intrinsics is not None
    assert first.envelope.payload.intrinsics.coefficients == (
        0.1,
        0.2,
        0.3,
        0.4,
        0.5,
    )
    assert first_call.cancel_count == 1
    assert second_call.cancel_count == 1
    assert stub.events == ["start", "stream", "start", "stream"]
    assert stub.start_count == stub.open_count == 2
    assert stub.start_timeouts == [2.5, 2.5]
    assert all(request is not None for request in stub.start_requests)


def test_transport_failure_reconnects_to_a_new_call() -> None:
    disconnected = _FakeCall(ConnectionError("temporary disconnect"))
    recovered = _FakeCall(_response(77, data=b"recovered", key_frame=True))
    stub = _FakeStub(disconnected, recovered)

    async def exercise() -> Emission:
        source = _source(stub)
        stream = source.messages()
        result = await asyncio.wait_for(anext(stream), timeout=1)
        await stream.aclose()
        assert isinstance(result, Emission)
        return result

    emission = asyncio.run(exercise())

    assert stub.open_count == 2
    assert stub.start_count == 2
    assert stub.events == ["start", "stream", "start", "stream"]
    assert disconnected.cancel_count == 1
    assert recovered.cancel_count == 1
    assert emission.envelope.sequence == 0
    assert emission.envelope.attributes["remote_sequence"] == 77
    assert emission.envelope.payload.data == b"recovered"


def test_start_camera_rejection_prevents_stream_open() -> None:
    stub = _FakeStub(
        _FakeCall(_response(1)),
        start_feedbacks=(
            _start_feedback(
                success=False,
                error_message="camera is unavailable",
            ),
        ),
    )

    async def exercise() -> None:
        source = _source(stub, reconnect=False)
        with pytest.raises(CmvrServiceError, match="camera is unavailable"):
            await asyncio.wait_for(anext(source.messages()), timeout=1)

    asyncio.run(exercise())

    assert stub.events == ["start"]
    assert stub.start_count == 1
    assert stub.start_timeouts == [2.5]
    assert stub.open_count == 0


def test_start_camera_transport_failure_is_retried_before_stream_open() -> None:
    recovered = _FakeCall(_response(5, data=b"after-start-retry"))
    stub = _FakeStub(
        recovered,
        start_feedbacks=(
            ConnectionError("StartCamera unavailable"),
            _start_feedback(),
        ),
    )

    async def exercise() -> Emission:
        source = _source(stub)
        emission = await asyncio.wait_for(anext(source.messages()), timeout=1)
        await source.stop()
        assert isinstance(emission, Emission)
        return emission

    emission = asyncio.run(exercise())

    assert stub.events == ["start", "start", "stream"]
    assert stub.start_count == 2
    assert stub.start_timeouts == [2.5, 2.5]
    assert stub.open_count == 1
    assert emission.envelope.payload.data == b"after-start-retry"


def test_camera_stream_logs_the_complete_lifecycle(
    caplog: pytest.LogCaptureFixture,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    now_ns = 0

    def advancing_clock() -> int:
        nonlocal now_ns
        now_ns += 2_000_000_000
        return now_ns

    elapsed_s = 0.0

    def advancing_timer() -> float:
        nonlocal elapsed_s
        elapsed_s += 0.05
        return elapsed_s

    monkeypatch.setattr(camera_module, "monotonic_ns", advancing_clock)
    monkeypatch.setattr(camera_module, "perf_counter", advancing_timer)
    first_session = _FakeCall(
        _response(10, data=b"first", key_frame=True),
        _response(11, data=b"second"),
        ConnectionError("stream interrupted"),
    )
    recovered_session = _FakeCall(_response(12, data=b"recovered"))
    stub = _FakeStub(first_session, recovered_session)

    async def exercise() -> None:
        source = _source(stub, stream_log_interval_s=1)
        source._shutdown_event = _ImmediateRetryEvent()  # type: ignore[attr-defined]
        stream = source.messages()
        await anext(stream)
        await anext(stream)
        await anext(stream)
        await source.stop()
        await stream.aclose()

    with caplog.at_level(logging.INFO, logger=camera_module.__name__):
        asyncio.run(exercise())

    messages = [record.getMessage() for record in caplog.records]
    expected_phrases = (
        "camera start requested",
        "camera start succeeded",
        "camera stream opening",
        "camera stream first frame",
        "camera stream disconnected",
        "camera source stopping",
        "camera source stopped",
    )
    for phrase in expected_phrases:
        assert any(phrase in message for message in messages), messages

    assert sum("camera start requested" in message for message in messages) == 2
    assert sum("camera start succeeded" in message for message in messages) == 2
    assert sum("camera stream opening" in message for message in messages) == 2
    first_frame_messages = [
        message for message in messages if "camera stream first frame" in message
    ]
    assert len(first_frame_messages) == 2
    assert "remote_sequence=10" in first_frame_messages[0]
    assert "remote_sequence=12" in first_frame_messages[1]


def test_camera_stream_progress_log_reports_window_statistics(
    caplog: pytest.LogCaptureFixture,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(camera_module, "monotonic_ns", lambda: 3_000_000_000)
    stats = camera_module._StreamStats(
        session_id="session-progress",
        opened_ns=0,
        window_started_ns=1_000_000_000,
        frames=3,
        received_bytes=600,
        key_frames=1,
        window_frames=2,
        window_bytes=500,
        window_key_frames=1,
        last_frame_ns=2_500_000_000,
        last_remote_sequence=42,
        codec="H264",
        width=640,
        height=480,
    )
    source = _source(_FakeStub(), stream_log_interval_s=1)

    async def exercise() -> None:
        await source._log_stream_progress(  # type: ignore[attr-defined]
            stats,
            _OneProgressTickEvent(),  # type: ignore[arg-type]
        )

    with caplog.at_level(logging.INFO, logger=camera_module.__name__):
        asyncio.run(exercise())

    messages = [
        record.getMessage()
        for record in caplog.records
        if "camera stream progress" in record.getMessage()
    ]
    assert len(messages) == 1
    message = messages[0]
    assert "session=session-progress" in message
    assert "total_frames=3" in message
    assert "window_frames=2" in message
    assert "window_bytes=500" in message
    assert "last_remote_sequence=42" in message
    assert "codec=H264" in message
    assert "width=640" in message
    assert "height=480" in message


def test_reconnect_false_propagates_the_disconnect() -> None:
    failed_call = _FakeCall(ConnectionError("camera cable disconnected"))
    stub = _FakeStub(failed_call)

    async def exercise() -> None:
        source = _source(stub, reconnect=False)
        with pytest.raises(ConnectionError, match="camera cable disconnected"):
            await asyncio.wait_for(anext(source.messages()), timeout=1)

    asyncio.run(exercise())

    assert stub.open_count == 1
    assert stub.start_count == 1
    assert stub.events == ["start", "stream"]
    assert failed_call.cancel_count == 1


def test_max_reconnect_attempts_raises_after_the_allowed_retries() -> None:
    first_failure = _FakeCall(ConnectionError("first disconnect"))
    second_failure = _FakeCall(ConnectionError("second disconnect"))
    stub = _FakeStub(first_failure, second_failure)

    async def exercise() -> None:
        source = _source(stub, max_reconnect_attempts=1)
        with pytest.raises(
            CmvrServiceError,
            match="exceeded max_reconnect_attempts",
        ):
            await asyncio.wait_for(anext(source.messages()), timeout=1)

    asyncio.run(exercise())

    assert stub.open_count == 2
    assert stub.start_count == 2
    assert stub.events == ["start", "stream", "start", "stream"]
    assert first_failure.cancel_count == 1
    assert second_failure.cancel_count == 1


def test_stop_cancels_the_active_stream_call() -> None:
    async def exercise():  # type: ignore[no-untyped-def]
        call = _BlockingCall()
        stub = _FakeStub(call)
        source = _source(stub)
        stream = source.messages()
        pending = asyncio.create_task(anext(stream))
        await asyncio.wait_for(call.entered.wait(), timeout=1)

        await source.stop()
        with pytest.raises(asyncio.CancelledError):
            await asyncio.wait_for(pending, timeout=1)
        await stream.aclose()
        return call, source, stub

    call, source, stub = asyncio.run(exercise())

    assert call.cancel_count == 1
    assert stub.events == ["start", "stream"]
    assert source._call is None  # type: ignore[attr-defined]


@pytest.mark.parametrize(
    ("params", "message"),
    [
        ({"typo": True}, "unknown camera source parameter"),
        ({"device_id": None}, "non-empty device_id"),
        ({"device_id": "  "}, "non-empty device_id"),
        ({"device_id": 1}, "non-empty device_id"),
        ({"pixel_format": ""}, "pixel_format must be a non-empty string"),
        ({"pixel_format": 1}, "pixel_format must be a non-empty string"),
        ({"reconnect": "true"}, "reconnect must be a boolean"),
        ({"reconnect": 1}, "reconnect must be a boolean"),
        ({"reconnect_initial_s": True}, "must be a positive number"),
        ({"reconnect_initial_s": 0}, "must be a positive number"),
        ({"reconnect_initial_s": -1}, "must be a positive number"),
        ({"reconnect_initial_s": float("nan")}, "must be a positive number"),
        ({"reconnect_initial_s": float("inf")}, "must be a positive number"),
        ({"reconnect_max_s": 0}, "must be a positive number"),
        ({"reconnect_max_s": float("nan")}, "must be a positive number"),
        ({"stream_log_interval_s": True}, "must be a positive number"),
        ({"stream_log_interval_s": 0}, "must be a positive number"),
        ({"stream_log_interval_s": -1}, "must be a positive number"),
        ({"stream_log_interval_s": float("nan")}, "must be a positive number"),
        ({"stream_log_interval_s": float("inf")}, "must be a positive number"),
        (
            {"reconnect_initial_s": 2, "reconnect_max_s": 1},
            "reconnect_max_s must be >= reconnect_initial_s",
        ),
        ({"max_reconnect_attempts": True}, "must be an integer or null"),
        ({"max_reconnect_attempts": 1.5}, "must be an integer or null"),
        ({"max_reconnect_attempts": "1"}, "must be an integer or null"),
        ({"max_reconnect_attempts": -1}, "must not be negative"),
    ],
)
def test_camera_reconnect_parameters_are_strictly_validated(
    params: dict[str, Any],
    message: str,
) -> None:
    configured: dict[str, Any] = {"device_id": "camera-1"}
    configured.update(params)

    with pytest.raises(ValueError, match=message):
        CmvrCameraRgbStreamSource("camera", configured)


def test_numeric_strings_are_accepted_for_environment_expanded_delays() -> None:
    source = CmvrCameraRgbStreamSource(
        "camera",
        {
            "device_id": "camera-1",
            "reconnect_initial_s": "0.25",
            "reconnect_max_s": "2.5",
            "max_reconnect_attempts": 0,
            "stream_log_interval_s": "12.5",
        },
    )

    assert source._reconnect_initial_s == 0.25  # type: ignore[attr-defined]
    assert source._reconnect_max_s == 2.5  # type: ignore[attr-defined]
    assert source._max_reconnect_attempts == 0  # type: ignore[attr-defined]
    assert source._stream_log_interval_s == 12.5  # type: ignore[attr-defined]
