"""Smoke test: signalwire.rest.RestClient against the mock server.

This proves the mock can stand in for the real API for Python's existing
SDK. For each major namespace we exercise one or two methods and assert:

1. The call returns a non-error response (no exception, no 4xx/5xx).
2. The mock journal recorded the request with the right URL prefix and a
   Basic auth header.

Each test is a separate ``def test_*`` so a single namespace failure does
not mask others. When a call fails because the SDK lacks the method, the
spec is missing the endpoint, or response synthesis breaks the parser, the
gap is appended to ``MOCK_SIGNALWIRE_GAPS.md`` and the test is skipped.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable

import pytest
import requests

from signalwire.rest import RestClient

from mock_signalwire import MockServer


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


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


@dataclass
class Gap:
    namespace: str
    operation: str
    category: str  # SDK_MISSING | SPEC_MISSING | SYNTHESIS_BREAKS_PARSER | RUNTIME_ERROR
    detail: str


_GAPS: list[Gap] = []


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


def _flush_gaps() -> None:
    """Write the gaps doc once at session teardown."""
    if not _GAPS:
        if GAPS_DOC_PATH.exists():
            GAPS_DOC_PATH.write_text(
                "# MOCK_SIGNALWIRE_GAPS\n\n"
                "Last run: no gaps. The mock server fully covered the Python "
                "SDK's REST surface exercised by `tests/mock_signalwire/`.\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_SIGNALWIRE_GAPS")
    lines.append("")
    lines.append(
        "Generated by `tests/mock_signalwire/test_python_smoke.py`. Each entry "
        "is a (namespace, operation, gap-category) tuple captured during a "
        "smoke run of `signalwire.rest.RestClient` against the mock server."
    )
    lines.append("")
    lines.append("Categories:")
    lines.append("")
    lines.append("- **SDK_MISSING** — Python SDK does not expose the method we tried to call.")
    lines.append("- **SPEC_MISSING** — OpenAPI spec has no route matching the SDK's URL.")
    lines.append(
        "- **SYNTHESIS_BREAKS_PARSER** — synthesized body causes the SDK's parser "
        "to raise. Either the spec is wrong or the synthesizer is."
    )
    lines.append("- **RUNTIME_ERROR** — anything else (HTTP error, transport, etc.).")
    lines.append("")
    lines.append(f"Total gaps: {len(_GAPS)}")
    lines.append("")
    for cat in ("SDK_MISSING", "SPEC_MISSING", "SYNTHESIS_BREAKS_PARSER", "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("| Namespace | Operation | Detail |")
        lines.append("|-----------|-----------|--------|")
        for g in gaps:
            detail = g.detail.replace("|", "\\|").replace("\n", " ")
            if len(detail) > 240:
                detail = detail[:237] + "..."
            lines.append(f"| `{g.namespace}` | `{g.operation}` | {detail} |")
        lines.append("")
    GAPS_DOC_PATH.write_text("\n".join(lines))


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


# --- Client wired to the mock server --------------------------------------------------


@pytest.fixture
def client(mock_server: MockServer) -> RestClient:
    """A RestClient pointed at the mock.

    We construct the client normally with ``host="127.0.0.1:<port>"`` (the SDK
    will produce ``https://127.0.0.1:<port>``), then overwrite the
    ``HttpClient._base_url`` to the ``http://`` form. The actual HTTP call
    still goes out — we are not patching transport.
    """
    c = RestClient(
        project="test_proj",
        token="test_tok",
        host=f"127.0.0.1:{mock_server.port}",
    )
    c._http._base_url = mock_server.url  # type: ignore[attr-defined]
    return c


def _journal(mock_server: MockServer) -> list[dict[str, Any]]:
    return requests.get(f"{mock_server.url}/__mock__/journal").json()


def _assert_request_journal_ok(
    mock_server: MockServer,
    *,
    method: str,
    path_prefix: str,
) -> dict[str, Any]:
    """Assert exactly one journal entry with given method + path prefix + auth header.

    Returns that entry.
    """
    entries = [
        e for e in _journal(mock_server)
        if e["method"] == method and e["path"].startswith(path_prefix)
    ]
    assert entries, (
        f"no journal entry for {method} {path_prefix!r}; "
        f"saw: {[(e['method'], e['path']) for e in _journal(mock_server)]}"
    )
    e = entries[-1]
    assert e["headers"].get("authorization", "").startswith("Basic "), (
        f"missing or non-Basic Authorization header: {e['headers'].get('authorization')!r}"
    )
    assert e["matched_route"] is not None, (
        f"request hit catch-all 404 — spec gap. path={e['path']}"
    )
    assert e["response_status"] is not None
    assert 200 <= e["response_status"] < 400, (
        f"request returned non-2xx: {e['response_status']} body=mock-synthesized"
    )
    return e


# --- Helper that runs a client call and categorizes any failure -----------------------


@dataclass
class CallResult:
    """Result of a successful smoke call: the SDK return value plus the
    journal entry the mock recorded for it."""
    body: Any
    journal_entry: dict[str, Any]


def _run(
    namespace: str,
    operation: str,
    fn: Callable[[], Any],
    *,
    mock_server: MockServer,
    method: str,
    path_prefix: str,
) -> CallResult:
    """Run ``fn``; on failure, record a gap and ``pytest.skip``.

    On success, returns the SDK's parsed body plus the journal entry. The
    caller is expected to follow up with content-shaped assertions on
    ``result.body`` and/or ``result.journal_entry``.
    """
    try:
        body = fn()
    except AttributeError as exc:
        _record_gap(namespace, operation, "SDK_MISSING", f"{type(exc).__name__}: {exc}")
        pytest.skip(f"SDK_MISSING: {namespace}.{operation}: {exc}")
    except Exception as exc:
        # Distinguish between a 4xx/5xx (likely SPEC_MISSING) and a parser blow-up
        # (likely SYNTHESIS_BREAKS_PARSER).
        from signalwire.rest import SignalWireRestError

        if isinstance(exc, SignalWireRestError) and exc.status_code == 404:
            _record_gap(
                namespace, operation, "SPEC_MISSING",
                f"GET/POST {namespace}.{operation} returned 404 — no spec route for {exc.url}",
            )
            pytest.skip(f"SPEC_MISSING: {namespace}.{operation}: 404 at {exc.url}")
        if isinstance(exc, (json.JSONDecodeError, ValueError, KeyError, TypeError)):
            _record_gap(
                namespace, operation, "SYNTHESIS_BREAKS_PARSER",
                f"{type(exc).__name__}: {exc}",
            )
            pytest.skip(f"SYNTHESIS_BREAKS_PARSER: {namespace}.{operation}: {exc}")
        _record_gap(namespace, operation, "RUNTIME_ERROR", f"{type(exc).__name__}: {exc}")
        pytest.skip(f"RUNTIME_ERROR: {namespace}.{operation}: {exc}")

    journal_entry = _assert_request_journal_ok(mock_server, method=method, path_prefix=path_prefix)
    return CallResult(body=body, journal_entry=journal_entry)


# === Smoke tests per namespace ========================================================


# --- Compat (Twilio-compatible) -------------------------------------------------------


def test_compat_calls_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "compat", "calls.list",
        lambda: client.compat.calls.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/laml/2010-04-01/Accounts/test_proj/Calls",
    )
    # Compat list responses always paginate with a 'page' integer and
    # carry the requested resource collection.
    assert isinstance(res.body, dict)
    assert "calls" in res.body, f"expected 'calls' key, got {sorted(res.body)!r}"
    assert isinstance(res.body["calls"], list)
    assert res.journal_entry["matched_route"] == "compatibility.list_all_calls"


def test_compat_calls_get(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "compat", "calls.get",
        lambda: client.compat.calls.get("CA_TEST_SID"),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/laml/2010-04-01/Accounts/test_proj/Calls/CA_TEST_SID",
    )
    assert isinstance(res.body, dict)
    # A Call resource has a 'sid' field.
    assert "sid" in res.body, f"no 'sid' in body keys {sorted(res.body)!r}"
    # The single-call retrieve route was matched (templated, returns one resource).
    assert res.journal_entry["matched_route"] == "compatibility.retrieve_a_call", \
        f"unexpected matched_route: {res.journal_entry['matched_route']!r}"


def test_compat_calls_create(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "compat", "calls.create",
        lambda: client.compat.calls.create(To="+15555550100", From="+15555550199", Url="https://example.com"),
        mock_server=mock_server,
        method="POST",
        path_prefix="/api/laml/2010-04-01/Accounts/test_proj/Calls",
    )
    assert isinstance(res.body, dict), f"expected dict, got {type(res.body).__name__}"
    # The mock journal must carry our request body.
    sent = res.journal_entry["body"] or {}
    assert sent.get("To") == "+15555550100"
    assert sent.get("From") == "+15555550199"


def test_compat_messages_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "compat", "messages.list",
        lambda: client.compat.messages.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/laml/2010-04-01/Accounts/test_proj/Messages",
    )
    assert isinstance(res.body, dict)
    assert "messages" in res.body, f"expected 'messages' key, got {sorted(res.body)!r}"


def test_compat_messages_create(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "compat", "messages.create",
        lambda: client.compat.messages.create(To="+15555550100", From="+15555550199", Body="hi"),
        mock_server=mock_server,
        method="POST",
        path_prefix="/api/laml/2010-04-01/Accounts/test_proj/Messages",
    )
    assert isinstance(res.body, dict)
    sent = res.journal_entry["body"] or {}
    assert sent.get("Body") == "hi"
    assert sent.get("To") == "+15555550100"


def test_compat_accounts_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "compat", "accounts.list",
        lambda: client.compat.accounts.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/laml/2010-04-01/Accounts",
    )
    assert isinstance(res.body, dict)
    assert "accounts" in res.body
    # Page must be an integer per AccountListResponse schema.
    assert isinstance(res.body["page"], int)


def test_compat_phone_numbers_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "compat", "phone_numbers.list",
        lambda: client.compat.phone_numbers.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/laml/2010-04-01/Accounts/test_proj/IncomingPhoneNumbers",
    )
    assert isinstance(res.body, dict)
    assert "incoming_phone_numbers" in res.body, \
        f"expected 'incoming_phone_numbers' key, got {sorted(res.body)!r}"


# --- Calling --------------------------------------------------------------------------


def test_calling_dial(client: RestClient, mock_server: MockServer) -> None:
    """Calling namespace dispatches all commands through POST /api/calling/calls."""
    res = _run(
        "calling", "dial",
        lambda: client.calling.dial(to_number="+15555550100", from_number="+15555550199"),
        mock_server=mock_server,
        method="POST",
        path_prefix="/api/calling/calls",
    )
    # The wire body must carry a 'command' field per the dispatch protocol.
    sent = res.journal_entry["body"] or {}
    assert sent.get("command") == "dial"
    assert sent.get("params", {}).get("to_number") == "+15555550100"
    assert sent.get("params", {}).get("from_number") == "+15555550199"


# --- Fabric ---------------------------------------------------------------------------


def test_fabric_addresses_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "fabric", "addresses.list",
        lambda: client.fabric.addresses.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/fabric/addresses",
    )
    assert isinstance(res.body, dict)
    # Fabric addresses response contains a 'data' array.
    assert "data" in res.body, f"expected 'data' key, got {sorted(res.body)!r}"
    assert isinstance(res.body["data"], list)
    assert res.journal_entry["matched_route"] == "fabric.list_fabric_addresses"


def test_fabric_ai_agents_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "fabric", "ai_agents.list",
        lambda: client.fabric.ai_agents.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/fabric/resources/ai_agents",
    )
    assert isinstance(res.body, dict)
    assert "data" in res.body and isinstance(res.body["data"], list)


def test_fabric_subscribers_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "fabric", "subscribers.list",
        lambda: client.fabric.subscribers.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/fabric/resources/subscribers",
    )
    assert isinstance(res.body, dict)
    assert "data" in res.body and isinstance(res.body["data"], list)


# --- Video ----------------------------------------------------------------------------


def test_video_rooms_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "video", "rooms.list",
        lambda: client.video.rooms.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/video/rooms",
    )
    assert isinstance(res.body, dict)
    # Video list responses paginate with 'data' + 'links' (Fabric-style).
    assert "data" in res.body, f"expected 'data' key, got {sorted(res.body)!r}"
    assert isinstance(res.body["data"], list)


def test_video_room_sessions_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "video", "room_sessions.list",
        lambda: client.video.room_sessions.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/video/room_sessions",
    )
    assert isinstance(res.body, dict)
    assert "data" in res.body and isinstance(res.body["data"], list)


# --- Logs -----------------------------------------------------------------------------


def test_logs_messages_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "logs", "messages.list",
        lambda: client.logs.messages.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/messaging/logs",
    )
    # logs response is a JSON object (specific shape varies per spec).
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


def test_logs_voice_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "logs", "voice.list",
        lambda: client.logs.voice.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/voice/logs",
    )
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


# --- Project --------------------------------------------------------------------------


def test_project_tokens_create(client: RestClient, mock_server: MockServer) -> None:
    """Project namespace exposes API-token create/update/delete."""
    res = _run(
        "project", "tokens.create",
        lambda: client.project.tokens.create(name="smoke-test", scopes=["voice"]),
        mock_server=mock_server,
        method="POST",
        path_prefix="/api/project/tokens",
    )
    sent = res.journal_entry["body"] or {}
    assert sent.get("name") == "smoke-test"
    assert sent.get("scopes") == ["voice"]


# --- PubSub & Chat --------------------------------------------------------------------


def test_pubsub_create_token(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "pubsub", "create_token",
        lambda: client.pubsub.create_token(channels=["test"]),
        mock_server=mock_server,
        method="POST",
        path_prefix="/api/pubsub/tokens",
    )
    sent = res.journal_entry["body"] or {}
    assert sent.get("channels") == ["test"]


def test_chat_create_token(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "chat", "create_token",
        lambda: client.chat.create_token(channels=["test"]),
        mock_server=mock_server,
        method="POST",
        path_prefix="/api/chat/tokens",
    )
    sent = res.journal_entry["body"] or {}
    assert sent.get("channels") == ["test"]


# --- Datasphere -----------------------------------------------------------------------


def test_datasphere_documents_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "datasphere", "documents.list",
        lambda: client.datasphere.documents.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/datasphere/documents",
    )
    assert isinstance(res.body, (dict, list))
    assert res.journal_entry["matched_route"] is not None


# --- Relay REST namespaces ------------------------------------------------------------


def test_phone_numbers_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "phone_numbers", "list",
        lambda: client.phone_numbers.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/relay/rest/phone_numbers",
    )
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


def test_addresses_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "addresses", "list",
        lambda: client.addresses.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/relay/rest/addresses",
    )
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


def test_queues_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "queues", "list",
        lambda: client.queues.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/relay/rest/queues",
    )
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


def test_recordings_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "recordings", "list",
        lambda: client.recordings.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/relay/rest/recordings",
    )
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


def test_lookup_phone_number(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "lookup", "phone_number",
        lambda: client.lookup.phone_number("+15555550100"),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/relay/rest/lookup/phone_number",
    )
    assert isinstance(res.body, dict)
    # Path captured the e164 we passed.
    assert "+15555550100" in res.journal_entry["path"]


def test_short_codes_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "short_codes", "list",
        lambda: client.short_codes.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/relay/rest/short_codes",
    )
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


def test_imported_numbers_create(client: RestClient, mock_server: MockServer) -> None:
    """ImportedNumbers exposes only ``create`` (no list)."""
    res = _run(
        "imported_numbers", "create",
        lambda: client.imported_numbers.create(
            number="+15555550100",
            sip_username="u",
            sip_password="p",
            sip_proxy="sip.example.com",
        ),
        mock_server=mock_server,
        method="POST",
        path_prefix="/api/relay/rest/imported_phone_numbers",
    )
    sent = res.journal_entry["body"] or {}
    assert sent.get("number") == "+15555550100"
    assert sent.get("sip_username") == "u"


def test_number_groups_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "number_groups", "list",
        lambda: client.number_groups.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/relay/rest/number_groups",
    )
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


def test_verified_callers_list(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "verified_callers", "list",
        lambda: client.verified_callers.list(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/relay/rest/verified_caller_ids",
    )
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


def test_sip_profile_get(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "sip_profile", "get",
        lambda: client.sip_profile.get(),
        mock_server=mock_server,
        method="GET",
        path_prefix="/api/relay/rest/sip_profile",
    )
    assert isinstance(res.body, dict)
    assert res.journal_entry["matched_route"] is not None


def test_mfa_sms(client: RestClient, mock_server: MockServer) -> None:
    res = _run(
        "mfa", "sms",
        lambda: client.mfa.sms(to_number="+15555550100", message="code"),
        mock_server=mock_server,
        method="POST",
        path_prefix="/api/relay/rest/mfa/sms",
    )
    sent = res.journal_entry["body"] or {}
    assert sent.get("to_number") == "+15555550100"
    assert sent.get("message") == "code"
