"""Telnyx :class:`TelephonyProvider` — number provisioning and call control.

Thin async wrapper over Telnyx's v2 REST API used by :mod:`getpatter.client`
to provision numbers, place outbound calls and hang up. Media streaming is
attached separately from ``call.answered`` (see :mod:`telephony.telnyx`).
"""

import logging

import httpx

from getpatter.providers.base import TelephonyProvider

logger = logging.getLogger("getpatter.providers.telnyx_adapter")

TELNYX_API_BASE = "https://api.telnyx.com/v2"


class TelnyxAdapter(TelephonyProvider):
    """:class:`TelephonyProvider` implementation backed by Telnyx Call Control."""

    def __init__(self, api_key: str, connection_id: str = ""):
        self.api_key = api_key
        self.connection_id = connection_id
        self._client = httpx.AsyncClient(
            base_url=TELNYX_API_BASE,
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=30.0,
        )

    def __repr__(self) -> str:
        return f"TelnyxAdapter(connection_id={self.connection_id!r})"

    async def __aenter__(self) -> "TelnyxAdapter":
        return self

    async def __aexit__(self, *args: object) -> None:
        await self.close()

    async def provision_number(self, country: str) -> str:
        """Search and order an available Telnyx number for the given ISO country."""
        # Telnyx search filter uses nested ``filter[phone_number][country_code]``
        # (not ``filter[country_code]``). See:
        # https://developers.telnyx.com/api/numbers/list-available-phone-numbers
        resp = await self._client.get(
            "/available_phone_numbers",
            params={
                "filter[phone_number][country_code]": country,
                "filter[limit]": 1,
            },
        )
        resp.raise_for_status()
        numbers = resp.json()["data"]
        if not numbers:
            raise ValueError(f"No numbers for {country}")
        chosen = numbers[0]["phone_number"]
        order_body: dict = {"phone_numbers": [{"phone_number": chosen}]}
        # When a Call Control Application is bound to this adapter, attach
        # it to the order so the newly-purchased number is ready to place
        # and receive calls without a follow-up PATCH.
        if self.connection_id:
            order_body["connection_id"] = self.connection_id
        buy = await self._client.post("/number_orders", json=order_body)
        buy.raise_for_status()
        return chosen

    async def configure_number(self, number: str, webhook_url: str) -> None:
        """Associate number with the Call Control Application.

        ``connection_id`` lives on ``PATCH /phone_numbers/{id}`` — the
        ``/voice`` sub-resource only covers voice settings and silently
        ignores unknown body fields, so the previous single ``/voice`` PATCH
        returned 200 without ever linking the number to the Call Control app
        (inbound calls didn't route until fixed in the portal). Send the
        association to the base endpoint and keep voice settings on
        ``/voice``.

        ``number`` may be the phone_number ID or the E.164 string; the API
        accepts both identifiers but the phone_number ID is preferred.
        """
        from urllib.parse import quote as _quote

        encoded = _quote(number, safe="")
        assoc = await self._client.patch(
            f"/phone_numbers/{encoded}",
            json={"connection_id": self.connection_id},
        )
        if assoc.status_code >= 400:
            import logging as _logging

            _logging.getLogger("getpatter").warning(
                "Telnyx connection association returned %s: %s",
                assoc.status_code,
                assoc.text[:300],
            )
        payload = {
            "tech_prefix_enabled": False,
        }
        resp = await self._client.patch(
            f"/phone_numbers/{encoded}/voice",
            json=payload,
        )
        if resp.status_code >= 400:
            # Surface the server-side error body so misconfigured
            # connection_ids / unknown numbers don't fail silently.
            import logging as _logging

            _logging.getLogger("getpatter").warning(
                "Telnyx configure_number returned %s: %s",
                resp.status_code,
                resp.text[:300],
            )
        resp.raise_for_status()

    async def initiate_call(
        self,
        from_number: str,
        to_number: str,
        stream_url: str,
        *,
        ring_timeout: int | None = None,
        client_state: str | None = None,
        machine_detection: bool = False,
    ) -> str:
        """Place an outbound Call Control dial.

        NOTE: ``stream_url`` / ``stream_track`` are NOT accepted by
        ``POST /calls``. Telnyx attaches media streaming after the call is
        answered via ``POST /calls/{id}/actions/streaming_start``, triggered
        from the ``call.answered`` webhook for outgoing legs (the inbound
        path folds streaming into ``actions/answer`` on ``call.initiated``).
        The ``stream_url`` argument is retained for interface parity
        (``TelephonyProvider``) but is intentionally unused here.

        See the ``call.answered`` handler in ``server.py`` for the full flow.

        Args:
            from_number: Caller E.164 number (must be owned on the connection).
            to_number: Callee E.164 number.
            stream_url: Unused — media stream is attached after ``call.answered``.
            ring_timeout: Max seconds to wait before the leg is marked no-answer.
            client_state: Optional opaque string echoed back on every webhook
                for this call. Encoded as base64 per Telnyx contract.
        """
        del stream_url  # see docstring — retained only for TelephonyProvider parity
        payload: dict = {
            "connection_id": self.connection_id,
            "from": from_number,
            "to": to_number,
        }
        if ring_timeout is not None:
            payload["timeout_secs"] = int(ring_timeout)
        if machine_detection:
            # ``greeting_end`` is the production-recommended mode: Telnyx
            # returns the human/machine classification on
            # ``call.machine.detection.ended`` AND emits a follow-up
            # ``call.machine.greeting.ended`` once the answering-machine
            # greeting reaches the beep, so a downstream voicemail-drop
            # can speak immediately after the prompt.
            payload["answering_machine_detection"] = "greeting_end"
        if client_state:
            # Telnyx expects client_state as base64-encoded opaque string.
            import base64 as _b64

            payload["client_state"] = _b64.b64encode(
                client_state.encode("utf-8")
            ).decode("ascii")
        resp = await self._client.post("/calls", json=payload)
        resp.raise_for_status()
        return resp.json()["data"]["call_control_id"]

    async def end_call(self, call_id: str, *, command_id: str | None = None) -> None:
        """Hang up an active Telnyx call.

        ``command_id`` provides idempotency on retries (Telnyx will ignore a
        duplicate action with the same ``command_id``). When omitted, a
        UUID4 is generated automatically.
        """
        import uuid as _uuid
        from urllib.parse import quote as _quote

        body = {"command_id": command_id or str(_uuid.uuid4())}
        await self._client.post(
            f"/calls/{_quote(call_id, safe='')}/actions/hangup", json=body
        )

    def record_call_end_cost(self, *, duration_seconds: float, direction: str) -> None:
        """Emit ``patter.cost.telephony_minutes`` on the active span.

        Called by the embedded server's bridge cleanup once the call's
        wall-clock duration is known.
        """
        try:
            from getpatter.observability.attributes import record_patter_attrs

            record_patter_attrs(
                {
                    "patter.cost.telephony_minutes": duration_seconds / 60.0,
                    "patter.telephony": "telnyx",
                    "patter.direction": direction,
                }
            )
        except Exception:  # pragma: no cover — defense in depth
            logger.debug("record_call_end_cost failed", exc_info=True)

    async def close(self) -> None:
        """Close the underlying HTTP client."""
        await self._client.aclose()
