"""Regression tests for the relay WS transport hardening fix.

Coatue incident 2026-08-18: WAN latency / event-loop stalls tripped the
websockets library's default 20s pong deadline, closing customer-gateway
sockets with `1011 keepalive ping timeout`. On top of the spurious close,
every in-flight outbound then hung for the full _outbound_timeout_s (~30s)
because only disconnect() failed pending futures — an unexpected socket drop
left them stranded — and sends issued while the reconnect supervisor was
backing off registered futures no reader could ever resolve.

Three hardening changes under test:
  1. _read_loop fails all in-flight _pending futures on ANY exit path with
     the dict shape callers expect ({"success": False, ...}).
  2. _request_response fails fast while the reconnect supervisor is
     mid-redial (live supervisor task = the redial window).
  3. connect() passes explicit WAN-friendly keepalive tuning
     (ping_interval=30, ping_timeout=60) to websockets.connect().
"""

from __future__ import annotations

import asyncio

import pytest

import gateway.relay.ws_transport as ws_transport_mod
from gateway.relay.ws_transport import WebSocketRelayTransport, WEBSOCKETS_AVAILABLE

pytestmark = pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed")

if WEBSOCKETS_AVAILABLE:
    from websockets.exceptions import ConnectionClosedError


class _DroppingWS:
    """Fake socket: accepts sends, then the read loop dies mid-iteration —
    the shape of an unexpected close (e.g. 1011 keepalive ping timeout)."""

    def __init__(self, close_code: int | None = None):
        self.sent: list[str] = []
        # Reader blocks here until the test releases it, so the outbound
        # future is registered BEFORE the "socket" drops.
        self.drop = asyncio.Event()
        self._close_code = close_code

    async def send(self, data):
        self.sent.append(data)

    def __aiter__(self):
        return self

    async def __anext__(self):
        await self.drop.wait()
        if self._close_code is not None:
            from websockets.frames import Close

            raise ConnectionClosedError(Close(self._close_code, ""), None)
        raise ConnectionClosedError(None, None)

    async def close(self):
        pass


@pytest.mark.asyncio
async def test_read_loop_exit_fails_pending_futures_promptly():
    """When the socket drops unexpectedly, in-flight _request_response callers
    must get {"success": False, ...} promptly — not block ~30s on a future
    only the (now dead) reader could have resolved."""
    t = WebSocketRelayTransport("ws://unused", "discord", "bot1", outbound_timeout_s=30.0)
    fake = _DroppingWS()
    t._ws = fake
    t._reader = asyncio.create_task(t._read_loop())

    send_task = asyncio.create_task(t.send_outbound({"op": "send_message", "text": "hi"}))
    # Let the outbound frame go out and its future register in _pending.
    for _ in range(50):
        if t._pending:
            break
        await asyncio.sleep(0.01)
    assert t._pending, "outbound future never registered"

    # Drop the socket: the read loop exits on ConnectionClosedError.
    fake.drop.set()

    result = await asyncio.wait_for(send_task, timeout=2.0)
    assert result == {"success": False, "error": "relay transport connection lost"}
    assert t._pending == {}
    await t._reader


@pytest.mark.asyncio
async def test_send_during_redial_window_fails_fast():
    """While the reconnect supervisor is backing off after a drop, a send must
    return an error dict immediately (no RuntimeError, no 30s timeout on an
    unresolvable future). Drives the REAL sequence — reader exit arms the
    supervisor and clears _ws — rather than hand-crafting a stale-_ws state
    the transport can no longer reach."""
    t = WebSocketRelayTransport(
        "ws://unused",
        "discord",
        "bot1",
        reconnect=True,
        reconnect_backoff_s=60.0,  # park the supervisor in backoff
        outbound_timeout_s=30.0,
    )
    fake = _DroppingWS()
    t._ws = fake
    await _run_reader_to_exit(t, fake)
    supervisor = t._supervisor
    try:
        assert supervisor is not None and not supervisor.done(), (
            "reader exit must arm the reconnect supervisor"
        )
        result = await asyncio.wait_for(
            t.send_outbound({"op": "send_message", "text": "hi"}), timeout=1.0
        )
        assert result["success"] is False
        assert t._pending == {}
    finally:
        if supervisor is not None:
            supervisor.cancel()
            try:
                await supervisor
            except asyncio.CancelledError:
                pass


@pytest.mark.asyncio
async def test_send_allowed_once_redial_installs_fresh_socket(monkeypatch):
    """The moment _dial_and_start() installs a fresh socket and its reader,
    the transport is genuinely usable — even though the supervisor task has
    not finished unwinding (it is still awaiting the hello sends). A send in
    that window must be ACCEPTED, not rejected as 'reconnecting': gating
    sends on supervisor state rejected real traffic on a live socket."""

    class _LiveWS:
        def __init__(self):
            self.sent: list[str] = []
            self.hello_seen = asyncio.Event()
            self.release = asyncio.Event()

        async def send(self, data):
            self.sent.append(data)
            if '"hello"' in data:
                # Inside _dial_and_start, AFTER _ws and the reader are
                # installed. Park here to hold the window open.
                self.hello_seen.set()
                await self.release.wait()

        def __aiter__(self):
            return self

        async def __anext__(self):
            await asyncio.sleep(3600)

        async def close(self):
            pass

    live = _LiveWS()

    async def _fake_connect(url, **kwargs):
        return live

    monkeypatch.setattr(ws_transport_mod.websockets, "connect", _fake_connect)

    t = WebSocketRelayTransport(
        "ws://unused",
        "discord",
        "bot1",
        reconnect=True,
        reconnect_backoff_s=0.01,
        outbound_timeout_s=5.0,
    )
    # Arm the supervisor exactly as the reader's fall-through does.
    t._supervisor = asyncio.create_task(t._reconnect_loop())
    await asyncio.wait_for(live.hello_seen.wait(), timeout=2.0)
    try:
        assert t._ws is live and not t._supervisor.done()

        send_task = asyncio.create_task(
            t.send_outbound({"op": "send_message", "text": "hi"})
        )
        # The send must reach the live socket (registered + frame written),
        # not fail fast: wait for the outbound frame to land.
        for _ in range(100):
            if any('"outbound"' in s for s in live.sent):
                break
            await asyncio.sleep(0.01)
        assert any('"outbound"' in s for s in live.sent), (
            "send was rejected during the post-dial window despite a live "
            "socket and running reader"
        )

        # Resolve it via the reader path shape: answer directly.
        rid = next(iter(t._pending))
        t._pending[rid].set_result({"success": True})
        assert (await asyncio.wait_for(send_task, timeout=2.0)) == {"success": True}
    finally:
        live.release.set()
        await asyncio.wait_for(t._supervisor, timeout=2.0)
        if t._reader is not None:
            t._reader.cancel()
            try:
                await t._reader
            except asyncio.CancelledError:
                pass


@pytest.mark.asyncio
async def test_connect_passes_wan_keepalive_tuning(monkeypatch):
    """connect() must pass ping_interval=30 / ping_timeout=60 explicitly —
    the library defaults (20/20) caused spurious 1011 keepalive closes over
    WAN paths (Coatue 2026-08-18). Both call sites (with/without auth
    headers) are exercised."""
    captured: list[dict] = []

    class _IdleWS:
        async def send(self, data):
            pass

        def __aiter__(self):
            return self

        async def __anext__(self):
            await asyncio.sleep(3600)

        async def close(self):
            pass

    async def _fake_connect(url, **kwargs):
        captured.append(kwargs)
        return _IdleWS()

    monkeypatch.setattr(ws_transport_mod.websockets, "connect", _fake_connect)

    # Site 1: no upgrade secret -> the headerless connect() call.
    t = WebSocketRelayTransport("ws://unused", "discord", "bot1")
    await t.connect()
    await t.disconnect(budget_s=0)

    # Site 2: secret + gateway_id -> the additional_headers connect() call.
    t2 = WebSocketRelayTransport(
        "ws://unused", "discord", "bot1", gateway_id="gw-1", upgrade_secret="s3cret"
    )
    await t2.connect()
    await t2.disconnect(budget_s=0)

    assert len(captured) == 2
    no_header_kwargs, header_kwargs = captured
    assert "additional_headers" not in no_header_kwargs
    assert "additional_headers" in header_kwargs
    for kwargs in captured:
        assert kwargs.get("ping_interval") == 30
        assert kwargs.get("ping_timeout") == 60


async def _run_reader_to_exit(t: WebSocketRelayTransport, fake: _DroppingWS) -> None:
    """Start the reader on ``fake``, drop the socket, and wait for the reader
    to fully unwind — the state every post-drop assertion depends on."""
    t._reader = asyncio.create_task(t._read_loop())
    await asyncio.sleep(0)
    fake.drop.set()
    await t._reader


@pytest.mark.asyncio
async def test_read_loop_without_socket_still_fails_pending():
    """If the reader is ever scheduled with no socket (lifecycle bug), it must
    still settle in-flight waiters on its way out — the old `assert` escaped
    before the fail-pending cleanup and left them to the full 30s timeout."""
    t = WebSocketRelayTransport("ws://unused", "discord", "bot1", outbound_timeout_s=30.0)
    loop = asyncio.get_running_loop()
    fut: asyncio.Future = loop.create_future()
    t._pending["rid"] = fut
    t._ws = None

    await t._read_loop()  # must not raise

    assert fut.done()
    assert fut.result() == {"success": False, "error": "relay transport connection lost"}
    assert t._pending == {}


@pytest.mark.asyncio
async def test_send_after_terminal_4401_revocation_fails_fast():
    """A terminal 4401 revocation deliberately arms NO reconnect supervisor,
    so the reader's exit is the LAST liveness transition this transport will
    ever make. If _ws still points at the dead socket afterwards, the
    revocation path's own fatal-error notification send wedges for the full
    _outbound_timeout_s. The reader must leave _ws cleared so the
    not-connected guard answers instantly."""
    t = WebSocketRelayTransport(
        "ws://unused", "discord", "bot1", reconnect=True, outbound_timeout_s=30.0
    )
    fake = _DroppingWS(close_code=4401)
    t._ws = fake
    t._handshake_succeeded = True  # prior handshake -> 4401 is a revocation
    await _run_reader_to_exit(t, fake)

    assert t._auth_revoked is True
    assert t._supervisor is None  # revocation must not re-dial
    assert t._ws is None, "dead socket handle must not survive the reader"

    result = await asyncio.wait_for(
        t.send_outbound({"op": "send_message", "text": "hi"}), timeout=2.0
    )
    assert result["success"] is False
    assert t._pending == {}


@pytest.mark.asyncio
async def test_send_after_drop_with_reconnect_disabled_fails_fast():
    """reconnect=False transports never arm a supervisor either — the same
    stranded-_ws wedge as the revocation path, reachable by configuration."""
    t = WebSocketRelayTransport(
        "ws://unused", "discord", "bot1", reconnect=False, outbound_timeout_s=30.0
    )
    fake = _DroppingWS()
    t._ws = fake
    await _run_reader_to_exit(t, fake)

    assert t._ws is None, "dead socket handle must not survive the reader"

    result = await asyncio.wait_for(
        t.send_outbound({"op": "send_message", "text": "hi"}), timeout=2.0
    )
    assert result["success"] is False
    assert t._pending == {}


@pytest.mark.asyncio
async def test_send_raising_socket_returns_error_dict():
    """The socket can die BETWEEN the `_ws is None` liveness guard and the
    actual write (the reader's finally hasn't cleared the handle yet). The
    write then raises ConnectionClosed — but send_outbound's contract is a
    result dict, and RelayAdapter.send consumes it with no try. The raise
    must be converted to {"success": False, ...}, with no future left in
    _pending."""

    class _RaisingWS:
        """Send raises (already dead); the reader hasn't noticed yet."""

        def __init__(self):
            self.reader_release = asyncio.Event()

        async def send(self, data):
            raise ConnectionClosedError(None, None)

        def __aiter__(self):
            return self

        async def __anext__(self):
            await self.reader_release.wait()
            raise ConnectionClosedError(None, None)

        async def close(self):
            pass

    t = WebSocketRelayTransport("ws://unused", "discord", "bot1", outbound_timeout_s=5.0)
    fake = _RaisingWS()
    t._ws = fake
    t._reader = asyncio.create_task(t._read_loop())
    await asyncio.sleep(0)

    result = await asyncio.wait_for(
        t.send_outbound({"op": "send_message", "text": "hi"}), timeout=2.0
    )
    assert result["success"] is False
    assert "relay send failed" in result["error"]
    assert t._pending == {}

    fake.reader_release.set()
    await t._reader
