"""Wire-level tests: the Python client against a real localhost gRPC server.

Every other test in this suite substitutes a fake *stub* object for
``pb_grpc.MxAccessGatewayStub``, so nothing between the client wrapper and the
generated stub is exercised: no HTTP/2 framing, no protobuf serialization, no
call metadata, no gRPC status translation. That leaves a class of contract break
— a field the gateway populates but the client never decodes, metadata the
client believes it sends but does not, a status code it maps differently once it
arrives as a real ``grpc.RpcError`` — invisible to the default suite.

These tests close that gap by serving the real ``mxaccess_gateway.v1.MxAccessGateway``
service from an in-process ``grpc.aio`` server bound to ``127.0.0.1:0`` and
driving the ordinary public client API against it. The bytes on the wire are the
real ones; only the gateway's *behavior* is canned. No MXAccess, no worker, no
network beyond loopback, so this runs everywhere the normal suite runs.

See ``docs/GatewayTesting.md`` (Client Wire Tests) for the shared pattern and its
counterpart in the .NET client.
"""

from __future__ import annotations

import socket
from collections.abc import AsyncIterator, Awaitable, Callable

import grpc
import pytest
import pytest_asyncio

from zb_mom_ww_mxgateway import ClientOptions, GatewayClient
from zb_mom_ww_mxgateway.errors import MxGatewayAuthorizationError
from zb_mom_ww_mxgateway.events import ReplayGap
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2_grpc as pb_grpc

API_KEY = "mxgw_wiretest_secret"
SESSION_ID = "wire-session-1"
SERVER_HANDLE = 4242
ITEM_HANDLE = 77


def _free_port() -> int:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.bind(("127.0.0.1", 0))
        return int(sock.getsockname()[1])


def _ok() -> pb.ProtocolStatus:
    return pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK)


class FakeGateway(pb_grpc.MxAccessGatewayServicer):
    """Canned gateway serving the four session RPCs over a real transport.

    Replies are shaped like the gateway's own: an OK ``ProtocolStatus``, the
    echoed session id, and the typed payload the client wrapper reads (for
    example ``RegisterReply.server_handle``). Set ``deny`` to make ``Invoke``
    abort with ``PERMISSION_DENIED`` so the client's gRPC-status mapping is
    exercised against a genuine ``grpc.RpcError`` rather than a hand-built one.
    """

    def __init__(self, *, deny: bool = False, replay_gap: pb.ReplayGap | None = None) -> None:
        self.deny = deny
        self.replay_gap = replay_gap
        self.endpoint = ""
        self.metadata_by_method: dict[str, str] = {}
        self.open_request: pb.OpenSessionRequest | None = None
        self.invoke_request: pb.MxCommandRequest | None = None
        self.stream_request: pb.StreamEventsRequest | None = None
        self.close_request: pb.CloseSessionRequest | None = None

    def _record(self, method: str, context: grpc.aio.ServicerContext) -> None:
        for key, value in context.invocation_metadata() or ():
            if key == "authorization":
                self.metadata_by_method[method] = value

    async def OpenSession(  # noqa: N802 - generated gRPC method name
        self, request: pb.OpenSessionRequest, context: grpc.aio.ServicerContext
    ) -> pb.OpenSessionReply:
        """Answer ``OpenSession`` with a fully populated reply."""
        self._record("OpenSession", context)
        self.open_request = request
        return pb.OpenSessionReply(
            session_id=SESSION_ID,
            backend_name="fake-backend",
            worker_process_id=1234,
            worker_protocol_version=1,
            capabilities=["events", "invoke"],
            gateway_protocol_version=3,
            protocol_status=_ok(),
        )

    async def Invoke(  # noqa: N802 - generated gRPC method name
        self, request: pb.MxCommandRequest, context: grpc.aio.ServicerContext
    ) -> pb.MxCommandReply:
        """Answer ``Invoke`` with a Register reply, or deny when configured."""
        self._record("Invoke", context)
        self.invoke_request = request
        if self.deny:
            await context.abort(grpc.StatusCode.PERMISSION_DENIED, "invoke scope required")
        return pb.MxCommandReply(
            session_id=request.session_id,
            correlation_id=request.client_correlation_id,
            kind=request.command.kind,
            protocol_status=_ok(),
            hresult=0,
            register=pb.RegisterReply(server_handle=SERVER_HANDLE),
        )

    async def StreamEvents(  # noqa: N802 - generated gRPC method name
        self, request: pb.StreamEventsRequest, context: grpc.aio.ServicerContext
    ) -> AsyncIterator[pb.MxEvent]:
        """Stream an optional replay-gap sentinel followed by one data change."""
        self._record("StreamEvents", context)
        self.stream_request = request
        if self.replay_gap is not None:
            # The sentinel shape the gateway emits: family unspecified, body
            # unset, only replay_gap populated.
            yield pb.MxEvent(session_id=request.session_id, replay_gap=self.replay_gap)
        yield pb.MxEvent(
            session_id=request.session_id,
            family=pb.MX_EVENT_FAMILY_ON_DATA_CHANGE,
            server_handle=SERVER_HANDLE,
            item_handle=ITEM_HANDLE,
            value=pb.MxValue(int32_value=17),
            quality=192,
            worker_sequence=9,
            on_data_change=pb.OnDataChangeEvent(),
        )

    async def CloseSession(  # noqa: N802 - generated gRPC method name
        self, request: pb.CloseSessionRequest, context: grpc.aio.ServicerContext
    ) -> pb.CloseSessionReply:
        """Answer ``CloseSession`` with a closed final state."""
        self._record("CloseSession", context)
        self.close_request = request
        return pb.CloseSessionReply(
            session_id=request.session_id,
            final_state=pb.SESSION_STATE_CLOSED,
            protocol_status=_ok(),
        )


ServeGateway = Callable[..., Awaitable[FakeGateway]]


@pytest_asyncio.fixture
async def serve_gateway() -> AsyncIterator[ServeGateway]:
    """Yield a factory that serves a :class:`FakeGateway` on loopback.

    Each call starts its own server on a free port and records it for teardown,
    so a test can serve a differently-configured gateway without a fixture per
    variant.
    """
    servers: list[grpc.aio.Server] = []

    async def _start(**kwargs: object) -> FakeGateway:
        fake = FakeGateway(**kwargs)  # type: ignore[arg-type]
        server = grpc.aio.server()
        pb_grpc.add_MxAccessGatewayServicer_to_server(fake, server)
        port = _free_port()
        server.add_insecure_port(f"127.0.0.1:{port}")
        await server.start()
        servers.append(server)
        fake.endpoint = f"127.0.0.1:{port}"
        return fake

    try:
        yield _start
    finally:
        for server in servers:
            await server.stop(grace=None)


async def _connect(fake: FakeGateway) -> GatewayClient:
    return await GatewayClient.connect(
        ClientOptions(
            endpoint=fake.endpoint,
            api_key=API_KEY,
            plaintext=True,
            call_timeout=10.0,
        )
    )


@pytest.mark.asyncio
async def test_session_round_trip_decodes_real_wire_bytes(serve_gateway: ServeGateway) -> None:
    """Open, invoke, stream, and close against a real server over loopback."""
    wire_gateway = await serve_gateway()
    client = await _connect(wire_gateway)
    try:
        session = await client.open_session(client_session_name="wire-test")
        assert session.session_id == SESSION_ID
        assert session.open_reply.backend_name == "fake-backend"
        assert list(session.open_reply.capabilities) == ["events", "invoke"]

        server_handle = await session.register("wire-test-client")
        assert server_handle == SERVER_HANDLE
        assert wire_gateway.invoke_request is not None
        assert wire_gateway.invoke_request.command.kind == pb.MX_COMMAND_KIND_REGISTER
        assert wire_gateway.invoke_request.command.register.client_name == "wire-test-client"

        events = [event async for event in session.stream_events()]
        assert len(events) == 1
        event = events[0]
        assert not isinstance(event, ReplayGap)
        assert event.family == pb.MX_EVENT_FAMILY_ON_DATA_CHANGE
        assert event.server_handle == SERVER_HANDLE
        assert event.item_handle == ITEM_HANDLE
        assert event.value.int32_value == 17
        assert event.quality == 192
        assert event.worker_sequence == 9
        assert event.HasField("on_data_change")

        close_reply = await session.close()
        assert close_reply.final_state == pb.SESSION_STATE_CLOSED
        assert wire_gateway.close_request is not None
        assert wire_gateway.close_request.session_id == SESSION_ID
    finally:
        await client.close()


@pytest.mark.asyncio
async def test_api_key_reaches_the_server_on_every_rpc(serve_gateway: ServeGateway) -> None:
    """The bearer header is on the wire for unary and streaming calls alike.

    Stub-substituting tests can only assert what the client *passes*; this
    asserts what the server *receives*, which is the property that matters.
    """
    wire_gateway = await serve_gateway()
    client = await _connect(wire_gateway)
    try:
        session = await client.open_session(client_session_name="wire-test")
        await session.register("wire-test-client")
        async for _ in session.stream_events():
            break
        await session.close()
    finally:
        await client.close()

    expected = f"Bearer {API_KEY}"
    assert wire_gateway.metadata_by_method == {
        "OpenSession": expected,
        "Invoke": expected,
        "StreamEvents": expected,
        "CloseSession": expected,
    }


@pytest.mark.asyncio
async def test_replay_gap_sentinel_survives_the_wire(serve_gateway: ServeGateway) -> None:
    """A resumed stream surfaces the gateway's sentinel as a typed ``ReplayGap``."""
    replay_gap_gateway = await serve_gateway(
        replay_gap=pb.ReplayGap(requested_after_sequence=3, oldest_available_sequence=8)
    )
    client = await _connect(replay_gap_gateway)
    try:
        session = await client.open_session(client_session_name="wire-test")
        items = [item async for item in session.stream_events(after_worker_sequence=3)]
    finally:
        await client.close()

    assert len(items) == 2
    gap = items[0]
    assert isinstance(gap, ReplayGap)
    assert gap.requested_after_sequence == 3
    assert gap.oldest_available_sequence == 8
    assert gap.resume_after_worker_sequence == 7
    assert not isinstance(items[1], ReplayGap)
    assert items[1].family == pb.MX_EVENT_FAMILY_ON_DATA_CHANGE

    assert replay_gap_gateway.stream_request is not None
    assert replay_gap_gateway.stream_request.after_worker_sequence == 3


@pytest.mark.asyncio
async def test_permission_denied_maps_to_authorization_error(
    serve_gateway: ServeGateway,
) -> None:
    """A real ``PERMISSION_DENIED`` status becomes the typed client error."""
    denying_gateway = await serve_gateway(deny=True)
    client = await _connect(denying_gateway)
    try:
        session = await client.open_session(client_session_name="wire-test")
        with pytest.raises(MxGatewayAuthorizationError):
            await session.register("wire-test-client")
    finally:
        await client.close()
