"""A very small HTTP client.

Two providers need to make requests, and neither should hand-roll urllib. More
importantly: with the call injected, what a provider *would send* is testable
without a live Evolution instance or Meta credentials — which is most of what
can go wrong in an integration.

Still the standard library. A dependency-free client for four endpoints is worth
more than the ergonomics of `requests`, and the signing implementation stays
auditable without reading a framework first.
"""

from __future__ import annotations

import json as jsonlib
import ssl
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable


class HttpError(RuntimeError):
    """A request that could not be completed, or was refused."""

    def __init__(self, message: str, *, status: int | None = None, body: bytes = b""):
        super().__init__(message)
        self.status = status
        self.body = body


@dataclass(frozen=True, slots=True)
class HttpResponse:
    status: int
    body: bytes
    headers: dict[str, str] = field(default_factory=dict)

    def json(self) -> Any:
        try:
            return jsonlib.loads(self.body.decode("utf-8"))
        except (ValueError, UnicodeDecodeError) as exc:
            # An HTML error page rather than JSON usually means the request never
            # reached the API — a proxy, a login page, a wrong base URL.
            raise HttpError("Response was not JSON.", status=self.status, body=self.body) from exc

    @property
    def ok(self) -> bool:
        return 200 <= self.status < 300


@runtime_checkable
class HttpTransport(Protocol):
    def request(
        self,
        method: str,
        url: str,
        *,
        headers: dict[str, str] | None = None,
        json: Any | None = None,
        data: bytes | None = None,
        timeout: float = 30.0,
    ) -> HttpResponse: ...


def user_agent() -> str:
    """How this client names itself, in the shape a web host will accept.

    urllib's default is `Python-urllib/3.13`, and shared hosting refuses it.
    Measured against a real customer's cPanel host: the default and a plain
    `TaxPilot-AI/1.0` both had the connection reset before any response, a bare
    `Mozilla/5.0` got a 403 from the firewall, and only the `Mozilla/5.0
    (compatible; …)` form reached the application at all. Nothing was wrong with
    the request — the firewall decided from the header alone.

    That failure is worth avoiding for its symptom as much as its cause. A reset
    connection surfaces as "cannot reach the CMS or authenticate", which points
    at the URL, the certificate and the credential — three things that were all
    correct — and says nothing about the one thing that was not.

    The `(compatible; …)` convention is what well-behaved crawlers have used for
    decades to get past exactly these filters. It still says truthfully what
    this is and which build, so a server operator reading their access log sees
    TaxPilot rather than an anonymous browser.

    Public because the Agent API client builds its own request rather than going
    through this transport — it signs the body and needs the bytes it signed on
    the wire unchanged. One definition, two callers; a second copy would be
    fixed once and left broken in the other place.
    """
    from app.release.version import VERSION

    return f"Mozilla/5.0 (compatible; TaxPilot-AI/{VERSION}; +https://taxpilot.tmsoagency.com)"


class UrllibTransport:
    """The real one."""

    def __init__(self, verify_tls: bool = True) -> None:
        self._verify_tls = verify_tls

    def request(
        self,
        method: str,
        url: str,
        *,
        headers: dict[str, str] | None = None,
        json: Any | None = None,
        data: bytes | None = None,
        timeout: float = 30.0,
    ) -> HttpResponse:
        sent = dict(headers or {})
        body = data

        # setdefault, not assignment: a caller that has a reason to identify
        # itself differently keeps it.
        sent.setdefault("User-Agent", user_agent())

        if json is not None:
            body = jsonlib.dumps(json).encode("utf-8")
            sent.setdefault("Content-Type", "application/json")

        request = urllib.request.Request(  # noqa: S310 - scheme checked below
            url, data=body, headers=sent, method=method.upper()
        )

        if request.type not in {"http", "https"}:
            # A provider base URL is configuration, and configuration can be
            # wrong. file:// here would read local files instead of calling out.
            raise HttpError(f"Refusing a {request.type} URL.")

        context = None if self._verify_tls else ssl._create_unverified_context()  # noqa: S323

        try:
            with urllib.request.urlopen(request, timeout=timeout, context=context) as response:  # noqa: S310
                return HttpResponse(
                    status=response.status,
                    body=response.read(),
                    headers={k.lower(): v for k, v in response.headers.items()},
                )
        except urllib.error.HTTPError as exc:
            # Returned rather than raised: a 4xx from WhatsApp carries a reason
            # the caller needs to read and act on, and turning it into an
            # exception discards the body that explains it.
            return HttpResponse(
                status=exc.code,
                body=exc.read(),
                headers={k.lower(): v for k, v in (exc.headers or {}).items()},
            )
        except urllib.error.URLError as exc:
            raise HttpError(f"Could not reach {urllib.parse.urlparse(url).netloc}: {exc.reason}") from exc
