"""Self-tests for ``MockRelay`` — proves the simulator itself behaves before
any spec relies on it.

Every test does a real websocket roundtrip against the mock so we never
``sleep`` for non-determinism: the client sends a frame, the server-side
handler reacts, and the client awaits the reply. If the mock stops working,
these break first instead of letting downstream specs fail with confusing
symptoms.
"""

from __future__ import annotations

import asyncio
import json

import pytest
import websockets

from mock_relay import MockRelay


# ---- helpers ---------------------------------------------------------------


async def _send(url: str, frames: list[list]) -> list[list]:
    """Connect to ``url``, push each frame, collect reply frames until the
    server closes or no frame arrives within 1s of the last send."""
    replies: list[list] = []
    async with websockets.connect(url) as ws:
        for frame in frames:
            await ws.send(json.dumps(frame))
        while True:
            try:
                raw = await asyncio.wait_for(ws.recv(), timeout=1.0)
            except (asyncio.TimeoutError, websockets.ConnectionClosed):
                break
            replies.append(json.loads(raw))
    return replies


def _run(coro):
    return asyncio.run(coro)


@pytest.fixture
def relay():
    """A MockRelay whose handler is set per-test by reassigning ``relay.handler``."""
    r = MockRelay()
    r.start()
    try:
        yield r
    finally:
        r.stop()


# ---- tests -----------------------------------------------------------------


def test_relay_binds_to_loopback_with_dynamic_port():
    r = MockRelay()
    r.start()
    try:
        assert r.url is not None
        assert r.url.startswith("ws://127.0.0.1:")
        port = int(r.url.rsplit(":", 1)[1])
        assert 1024 < port < 65536
    finally:
        r.stop()


def test_inbound_frame_is_recorded(relay):
    _run(_send(relay.url, [["REQ", "sub1", {"kinds": [1]}]]))

    assert relay.received == [["REQ", "sub1", {"kinds": [1]}]]


def test_handler_can_push_reply_frames(relay):
    async def handler(_r, ws, msg):
        if msg[0] == "REQ":
            await ws.send(json.dumps(["EOSE", msg[1]]))

    relay.handler = handler

    replies = _run(_send(relay.url, [["REQ", "x", {}]]))

    assert replies == [["EOSE", "x"]]


def test_multiple_relays_run_in_parallel_without_collision():
    a, b = MockRelay(), MockRelay()
    a.start()
    b.start()
    try:
        assert a.url != b.url
        _run(_send(a.url, [["REQ", "from-a", {}]]))
        _run(_send(b.url, [["REQ", "from-b", {}]]))
        assert a.received == [["REQ", "from-a", {}]]
        assert b.received == [["REQ", "from-b", {}]]
    finally:
        a.stop()
        b.stop()


def test_non_json_frames_are_ignored(relay):
    async def send_garbage(url):
        async with websockets.connect(url) as ws:
            await ws.send("not json {{{")
            await ws.send(json.dumps(["REQ", "ok", {}]))
            await asyncio.sleep(0.05)

    _run(send_garbage(relay.url))

    # Garbage was dropped; the well-formed frame after it landed.
    assert relay.received == [["REQ", "ok", {}]]


def test_disconnect_all_drops_open_connections(relay):
    """``disconnect_all`` lets reconnect-tests simulate a network blip — every
    open client gets a close frame, then can reconnect on a fresh socket."""

    async def open_and_wait_for_drop(url):
        async with websockets.connect(url) as ws:
            await ws.send(json.dumps(["REQ", "live", {}]))
            # Wait until the relay registers the frame, then ask it to drop us.
            while not relay.received:
                await asyncio.sleep(0.01)
            relay.disconnect_all()
            with pytest.raises(websockets.ConnectionClosed):
                await asyncio.wait_for(ws.recv(), timeout=2.0)

    _run(open_and_wait_for_drop(relay.url))
