"""Smoke test: ``signalwire.relay.RelayClient`` against the mock relay server.

This proves the mock can stand in for the real RELAY for the Python SDK.
For each major surface — connect, execute, dial, send_message, reconnect —
we exercise one call and assert:

1. The SDK call returns a non-error response.
2. The mock journal recorded the inbound frame with the right method.
3. (Where applicable) scripted events from the mock made it through the
   SDK's event dispatch.

Each test is a separate ``def test_*`` so a single namespace failure does
not mask others. When a Python relay test pattern can't be supported by
the mock, the gap is appended to ``MOCK_RELAY_GAPS.md`` and the test is
skipped.
"""

from __future__ import annotations

import asyncio
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from unittest.mock import patch

import pytest
import requests

# Force the SDK to allow the in-process tests to construct multiple clients
# without tripping the connection-limit guard.
os.environ.setdefault("RELAY_MAX_CONNECTIONS", "16")

from signalwire.relay import RelayClient, RelayError  # noqa: E402

from mock_relay import MockRelayServer  # noqa: E402


GAPS_DOC_PATH = Path(__file__).resolve().parents[2] / "MOCK_RELAY_GAPS.md"


# ---------------------------------------------------------------------------
# Gap recording
# ---------------------------------------------------------------------------


@dataclass
class Gap:
    surface: str
    detail: str
    category: str  # SDK_MISSING | MOCK_GAP | RUNTIME_ERROR


_GAPS: list[Gap] = []


def _record_gap(surface: str, category: str, detail: str) -> None:
    _GAPS.append(Gap(surface=surface, category=category, detail=detail))


def _flush_gaps() -> None:
    if not _GAPS:
        GAPS_DOC_PATH.write_text(
            "# MOCK_RELAY_GAPS\n\n"
            "Last run: no gaps. The mock relay fully covered the Python "
            "SDK's RELAY surface exercised by `tests/mock_relay/`.\n"
        )
        return

    by_category: dict[str, list[Gap]] = {}
    for g in _GAPS:
        by_category.setdefault(g.category, []).append(g)

    lines: list[str] = []
    lines.append("# MOCK_RELAY_GAPS")
    lines.append("")
    lines.append(
        "Generated by `tests/mock_relay/test_python_smoke.py`. Each entry is a "
        "(surface, category) tuple captured during a smoke run of "
        "`signalwire.relay.RelayClient` against the mock relay server."
    )
    lines.append("")
    lines.append("Categories:")
    lines.append("")
    lines.append("- **SDK_MISSING** — Python SDK does not expose the surface we tried to call.")
    lines.append("- **MOCK_GAP** — feature relay.md describes but the mock can't simulate.")
    lines.append("- **RUNTIME_ERROR** — anything else (transport, parser, etc.).")
    lines.append("")
    lines.append(f"Total gaps: {len(_GAPS)}")
    lines.append("")
    for cat in ("SDK_MISSING", "MOCK_GAP", "RUNTIME_ERROR"):
        gaps = by_category.get(cat, [])
        lines.append(f"## {cat} ({len(gaps)})")
        lines.append("")
        if not gaps:
            lines.append("_(none)_")
            lines.append("")
            continue
        lines.append("| Surface | Detail |")
        lines.append("|---------|--------|")
        for g in gaps:
            detail = g.detail.replace("|", "\\|").replace("\n", " ")
            if len(detail) > 240:
                detail = detail[:237] + "..."
            lines.append(f"| `{g.surface}` | {detail} |")
        lines.append("")
    GAPS_DOC_PATH.write_text("\n".join(lines))


@pytest.fixture(scope="session", autouse=True)
def _flush_gaps_at_end():
    yield
    _flush_gaps()


# ---------------------------------------------------------------------------
# Plumbing — tell the Python SDK to use ws:// against the mock instead of wss://
# ---------------------------------------------------------------------------


def _ws_patch_uri(mock_relay_server: MockRelayServer):
    """Return a contextmanager that overrides the SDK's wss://host URI to ws://host:port.

    The Python client builds its URI as ``f"wss://{self.host}"`` inside
    ``RelayClient.connect``. We monkeypatch ``websockets.connect`` (the
    function the client actually calls) so it ignores the wss URI it was
    given and dials our local ws:// instead.
    """
    target_url = mock_relay_server.ws_url
    real_connect = __import__("websockets").connect

    def proxy(uri, *args, **kwargs):
        # Ignore any URI the SDK passed, dial the mock instead.
        return real_connect(target_url, *args, **kwargs)

    return patch("signalwire.relay.client.websockets.connect", side_effect=proxy)


def _http_journal(mock_relay_server: MockRelayServer) -> list[dict[str, Any]]:
    return requests.get(f"{mock_relay_server.http_url}/__mock__/journal", timeout=5).json()


def _arm_dial(mock_relay_server: MockRelayServer, **kwargs) -> None:
    requests.post(
        f"{mock_relay_server.http_url}/__mock__/scenarios/dial",
        json=kwargs,
        timeout=5,
    )


def _arm_method(mock_relay_server: MockRelayServer, method: str, events: list[dict[str, Any]]) -> None:
    requests.post(
        f"{mock_relay_server.http_url}/__mock__/scenarios/{method}",
        json=events,
        timeout=5,
    )


# ---------------------------------------------------------------------------
# Smoke tests
# ---------------------------------------------------------------------------


def test_connect_returns_protocol(mock_relay_server: MockRelayServer) -> None:
    """``client.connect()`` succeeds and the journal logs a signalwire.connect."""
    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(
                project="smoke-project",
                token="smoke-token",
                host=mock_relay_server.relay_host,
                contexts=["default"],
            )
            try:
                await client.connect()
                # The SDK stores the issued protocol string.
                assert client.relay_protocol.startswith("signalwire_"), \
                    f"unexpected protocol: {client.relay_protocol!r}"
            finally:
                await client.disconnect()

    asyncio.run(go())

    j = _http_journal(mock_relay_server)
    connect_recv = [e for e in j if e["direction"] == "recv" and e["method"] == "signalwire.connect"]
    assert connect_recv, "no signalwire.connect entry in journal"
    p = connect_recv[-1]["frame"]["params"]
    assert p["authentication"]["project"] == "smoke-project"
    assert p["authentication"]["token"] == "smoke-token"


def test_execute_calling_play_returns_200_and_echo(mock_relay_server: MockRelayServer) -> None:
    """``client.execute("calling.play", ...)`` returns a 200 and the journal carries the request."""
    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(project="p", token="t", host=mock_relay_server.relay_host)
            try:
                await client.connect()
                result = await client.execute(
                    "calling.play",
                    {
                        "node_id": "n1",
                        "call_id": "c1",
                        "control_id": "ctl1",
                        "play": [{"type": "tts", "params": {"text": "hi"}}],
                    },
                )
                assert result["code"] == "200"
                assert result["call_id"] == "c1"
                assert result["control_id"] == "ctl1"
            finally:
                await client.disconnect()

    asyncio.run(go())

    j = _http_journal(mock_relay_server)
    # Python's RelayClient uses the flat-Blade form: the JSON-RPC method
    # field IS "calling.play", and params holds the inner payload directly.
    play_recv = [
        e for e in j
        if e["direction"] == "recv" and e["method"] == "calling.play"
    ]
    assert play_recv, f"no calling.play frame in journal: methods={[e['method'] for e in j]}"
    p = play_recv[-1]["frame"]["params"]
    assert p["call_id"] == "c1"
    assert p["control_id"] == "ctl1"
    assert p["play"][0]["type"] == "tts"


def test_execute_returns_error_on_invalid_params(mock_relay_server: MockRelayServer) -> None:
    """The mock validates against the C# schema and rejects missing required fields."""
    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(project="p", token="t", host=mock_relay_server.relay_host)
            try:
                await client.connect()
                with pytest.raises(RelayError):
                    # Missing call_id, control_id, play — required.
                    await client.execute("calling.play", {"node_id": "n1"})
            finally:
                await client.disconnect()

    asyncio.run(go())


def test_dial_resolves_via_scripted_dial_event(mock_relay_server: MockRelayServer) -> None:
    """``client.dial(...)`` waits for the calling.call.dial event to set the call_id."""
    _arm_dial(
        mock_relay_server,
        tag="dial-tag-smoke",
        winner_call_id="winner-smoke",
        states=["created", "ringing", "answered"],
        node_id="node-mock-1",
        device={"type": "phone", "params": {"to_number": "+15551112222", "from_number": "+15553334444"}},
        delay_ms=1,
    )

    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(project="p", token="t", host=mock_relay_server.relay_host)
            try:
                await client.connect()
                call = await client.dial(
                    [[{"type": "phone", "params": {"to_number": "+15551112222", "from_number": "+15553334444"}}]],
                    tag="dial-tag-smoke",
                    dial_timeout=5.0,
                )
                assert call.call_id == "winner-smoke"
                assert call.tag == "dial-tag-smoke"
                # The Call.state field reflects "answered" by the time dial() returns.
                assert call.state == "answered"
            finally:
                await client.disconnect()

    asyncio.run(go())

    j = _http_journal(mock_relay_server)
    dial_recv = [
        e for e in j if e["direction"] == "recv" and e["method"] == "calling.dial"
    ]
    assert dial_recv, f"no calling.dial frame in journal: methods={[e['method'] for e in j]}"
    p = dial_recv[-1]["frame"]["params"]
    assert p["tag"] == "dial-tag-smoke"


def test_messaging_send_returns_message_with_id(mock_relay_server: MockRelayServer) -> None:
    """``client.send_message(...)`` works against the permissive messaging schema."""
    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(project="p", token="t", host=mock_relay_server.relay_host)
            try:
                await client.connect()
                msg = await client.send_message(
                    to_number="+15551112222",
                    from_number="+15553334444",
                    body="hello from the mock",
                    tags=["smoke"],
                )
                assert msg.message_id, f"no message_id: {msg}"
                assert msg.body == "hello from the mock"
                assert msg.to_number == "+15551112222"
            finally:
                await client.disconnect()

    asyncio.run(go())

    j = _http_journal(mock_relay_server)
    send_recv = [
        e for e in j if e["direction"] == "recv" and e["method"] == "messaging.send"
    ]
    assert send_recv, f"no messaging.send frame in journal: methods={[e['method'] for e in j]}"
    p = send_recv[-1]["frame"]["params"]
    assert p["body"] == "hello from the mock"
    assert p["to_number"] == "+15551112222"
    assert p["tags"] == ["smoke"]


def test_play_event_routes_to_pending_action(mock_relay_server: MockRelayServer) -> None:
    """A scripted calling.call.play event resolves a registered PlayAction."""
    _arm_method(
        mock_relay_server, "calling.play",
        [
            {"emit": {"state": "playing"}, "delay_ms": 1},
            {"emit": {"state": "finished"}, "delay_ms": 5},
        ],
    )

    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(project="p", token="t", host=mock_relay_server.relay_host)
            try:
                await client.connect()
                # The Python SDK exposes call.play_async(), but here we drive the
                # raw execute and assert the events arrive — the
                # MockRelayServer journal is the contract.
                await client.execute(
                    "calling.play",
                    {
                        "node_id": "n1",
                        "call_id": "c-x",
                        "control_id": "ctl-x",
                        "play": [{"type": "silence", "params": {"duration": 1}}],
                    },
                )
                # Give the events a moment to fly through the recv loop.
                await asyncio.sleep(0.1)
            finally:
                await client.disconnect()

    asyncio.run(go())

    j = _http_journal(mock_relay_server)
    sent_events = [
        e for e in j
        if e["direction"] == "send"
        and e["frame"].get("method") == "signalwire.event"
        and e["frame"]["params"]["event_type"] == "calling.call.play"
    ]
    assert len(sent_events) == 2, \
        f"expected 2 play events, got {len(sent_events)}: {[e['frame']['params']['params'] for e in sent_events]}"
    assert sent_events[0]["frame"]["params"]["params"]["state"] == "playing"
    assert sent_events[1]["frame"]["params"]["params"]["state"] == "finished"
    # The mock injected the call_id from the request.
    assert sent_events[0]["frame"]["params"]["params"]["call_id"] == "c-x"


def test_reconnect_with_protocol_string_resumes_session(mock_relay_server: MockRelayServer) -> None:
    """Disconnect then reconnect with the same protocol — server reports session_restored."""
    issued_protocol: dict[str, str] = {}

    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(project="p", token="t", host=mock_relay_server.relay_host)
            try:
                await client.connect()
                issued_protocol["v"] = client.relay_protocol
            finally:
                await client.disconnect()

            # Same client, same protocol, fresh socket.
            client2 = RelayClient(project="p", token="t", host=mock_relay_server.relay_host)
            client2._relay_protocol = issued_protocol["v"]
            try:
                await client2.connect()
                assert client2.relay_protocol == issued_protocol["v"]
            finally:
                await client2.disconnect()

    asyncio.run(go())

    # Verify in the journal that the second connect carried the protocol field.
    j = _http_journal(mock_relay_server)
    second_connects = [
        e for e in j
        if e["direction"] == "recv"
        and e["method"] == "signalwire.connect"
        and e["frame"]["params"].get("protocol") == issued_protocol["v"]
    ]
    assert second_connects, \
        f"resume connect frame missing protocol={issued_protocol['v']}; saw: " \
        f"{[e['frame']['params'].get('protocol') for e in j if e['method']=='signalwire.connect']}"


def test_ping_round_trip(mock_relay_server: MockRelayServer) -> None:
    """``signalwire.ping`` returns a result; the SDK's internal ping path works."""
    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(project="p", token="t", host=mock_relay_server.relay_host)
            try:
                await client.connect()
                # Drive a ping through the same internal API the client uses.
                from signalwire.relay.constants import METHOD_SIGNALWIRE_PING
                result = await client._send_request(METHOD_SIGNALWIRE_PING, {})
                assert "timestamp" in result
            finally:
                await client.disconnect()

    asyncio.run(go())


def test_inbound_call_via_push(mock_relay_server: MockRelayServer) -> None:
    """End-to-end: HTTP push of an inbound call → SDK's on_call handler fires
    → SDK sends ``calling.answer`` → mock journals the answer."""
    handler_called = asyncio.Event()
    seen: dict[str, Any] = {}

    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(
                project="inbound-p",
                token="inbound-t",
                host=mock_relay_server.relay_host,
                contexts=["default"],
            )

            @client.on_call
            async def on_call(call):
                seen["call_id"] = call.call_id
                seen["direction"] = call.direction
                # The Python Call surface exposes ``device`` as the raw dict
                # the server sent; from/to live inside its ``params``.
                seen["from"] = (call.device or {}).get("params", {}).get("from_number")
                seen["to"] = (call.device or {}).get("params", {}).get("to_number")
                seen["state_at_entry"] = call.state
                # SDK answers the inbound call; mock will journal a calling.answer.
                await call.answer()
                seen["state_after_answer"] = call.state
                handler_called.set()

            try:
                await client.connect()
                # Push an inbound call via the HTTP control plane.
                await asyncio.to_thread(
                    lambda: requests.post(
                        f"{mock_relay_server.http_url}/__mock__/inbound_call",
                        json={
                            "call_id": "smoke-inbound-1",
                            "from_number": "+15551110000",
                            "to_number": "+15552220000",
                            "context": "default",
                            "auto_states": ["created"],
                        },
                        timeout=5,
                    )
                )
                # Wait for the handler to fire.
                await asyncio.wait_for(handler_called.wait(), timeout=5)
                # Give the calling.answer round trip a moment to land in the journal.
                await asyncio.sleep(0.1)
            finally:
                await client.disconnect()

    asyncio.run(go())

    # Handler observed the right call.
    assert seen["call_id"] == "smoke-inbound-1"
    assert seen["direction"] == "inbound"
    assert seen["from"] == "+15551110000"
    assert seen["to"] == "+15552220000"
    assert seen["state_at_entry"] == "created"

    # Mock journal recorded the SDK's outbound calling.answer.
    j = _http_journal(mock_relay_server)
    answers = [
        e for e in j
        if e["direction"] == "recv" and e["method"] == "calling.answer"
    ]
    assert answers, f"no calling.answer in journal: methods={[e['method'] for e in j]}"
    p = answers[-1]["frame"]["params"]
    assert p["call_id"] == "smoke-inbound-1"


def test_scripted_inbound_flow(mock_relay_server: MockRelayServer) -> None:
    """Full scripted flow:
    1. push calling.call.receive (state=created)
    2. SDK's on_call handler fires
    3. SDK sends calling.answer
    4. push calling.call.state (state=answered)
    5. SDK's on_answered fires
    6. SDK sends calling.play
    7. push calling.call.play (state=playing → finished)
    8. play action resolves"""
    answered_event = asyncio.Event()
    play_completed = asyncio.Event()
    seen: dict[str, Any] = {}

    async def go():
        with _ws_patch_uri(mock_relay_server):
            client = RelayClient(
                project="flow-p",
                token="flow-t",
                host=mock_relay_server.relay_host,
                contexts=["default"],
            )

            @client.on_call
            async def on_call(call):
                seen["call_id"] = call.call_id
                # Answer to advance to "answered".
                await call.answer()

                # Wait until the scripted ``answered`` push lands.
                async def watch_state():
                    while True:
                        if call.state == "answered":
                            return
                        await asyncio.sleep(0.05)

                await asyncio.wait_for(watch_state(), timeout=5)
                seen["state_after_answer_push"] = call.state
                answered_event.set()

                # Now drive a play. The mock's default scenario for calling.play
                # synthesises a 200 response; the play_completed event is fired
                # when we receive the scripted "finished" play event below.
                play = await call.play(
                    [{"type": "tts", "params": {"text": "hello"}}]
                )
                seen["play_kind"] = type(play).__name__
                play_completed.set()

            try:
                await client.connect()

                # Stage 1: arm a play scenario so the play() call gets state events.
                await asyncio.to_thread(
                    lambda: requests.post(
                        f"{mock_relay_server.http_url}/__mock__/scenarios/calling.play",
                        json=[
                            {"emit": {"state": "playing"}, "delay_ms": 5},
                            {"emit": {"state": "finished"}, "delay_ms": 5},
                        ],
                        timeout=5,
                    )
                )

                # Stage 2: push the inbound call notification.
                await asyncio.to_thread(
                    lambda: requests.post(
                        f"{mock_relay_server.http_url}/__mock__/inbound_call",
                        json={
                            "call_id": "flow-call-1",
                            "from_number": "+15551110000",
                            "to_number": "+15552220000",
                            "auto_states": ["created"],
                        },
                        timeout=5,
                    )
                )

                # Stage 3: wait briefly for SDK to send calling.answer, then
                # push the ``answered`` state update so the handler advances.
                await asyncio.sleep(0.2)
                await asyncio.to_thread(
                    lambda: requests.post(
                        f"{mock_relay_server.http_url}/__mock__/push",
                        json={
                            "frame": {
                                "jsonrpc": "2.0",
                                "id": "answered-evt",
                                "method": "signalwire.event",
                                "params": {
                                    "event_type": "calling.call.state",
                                    "params": {
                                        "call_id": "flow-call-1",
                                        "node_id": "mock-relay-node-1",
                                        "tag": "",
                                        "call_state": "answered",
                                        "direction": "inbound",
                                        "device": {
                                            "type": "phone",
                                            "params": {
                                                "from_number": "+15551110000",
                                                "to_number": "+15552220000",
                                            },
                                        },
                                    },
                                },
                            }
                        },
                        timeout=5,
                    )
                )

                await asyncio.wait_for(answered_event.wait(), timeout=5)
                await asyncio.wait_for(play_completed.wait(), timeout=5)
            finally:
                await client.disconnect()

    asyncio.run(go())

    assert seen["call_id"] == "flow-call-1"
    assert seen["state_after_answer_push"] == "answered"
    # SDK returned a PlayAction.
    assert seen["play_kind"] == "PlayAction"

    # Journal confirms the full conversation.
    j = _http_journal(mock_relay_server)
    methods_recv = [e["method"] for e in j if e["direction"] == "recv"]
    # Must contain at least: connect, answer, play.
    assert "signalwire.connect" in methods_recv
    assert "calling.answer" in methods_recv
    assert "calling.play" in methods_recv


def test_unauthenticated_connect_is_rejected(mock_relay_server: MockRelayServer) -> None:
    """Empty creds (after env scrubbing) trigger the mock's AUTH_REQUIRED path.

    We send the connect frame manually because the SDK refuses to construct a
    client without creds.
    """
    import json
    import uuid
    import websockets

    async def go():
        ws = await websockets.connect(mock_relay_server.ws_url)
        try:
            req_id = str(uuid.uuid4())
            await ws.send(json.dumps({
                "jsonrpc": "2.0", "id": req_id, "method": "signalwire.connect",
                "params": {
                    "version": {"major": 2, "minor": 0, "revision": 0},
                    "agent": "test/1.0",
                    "authentication": {"project": "", "token": ""},
                },
            }))
            resp = json.loads(await ws.recv())
            assert "error" in resp
            assert resp["error"]["data"]["signalwire_error_code"] == "AUTH_REQUIRED"
        finally:
            await ws.close()

    asyncio.run(go())
