"""Internal HTTP layer.

Owns retries, idempotency-key generation, error normalisation, and the
shared ``requests.Session`` per :class:`GateClient`. Resources call
:meth:`HttpClient.request` and consume already-parsed JSON.
"""

from __future__ import annotations

import json
import random
import time
import uuid
from typing import Any, Dict, Literal, Optional, Union

import requests

from .errors import (
    APIConnectionError,
    GateError,
    RateLimitError,
    TimeoutError,
    build_error_from_response,
)

Method = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]

_RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})


class HttpClient:
    def __init__(
        self,
        *,
        api_key: str,
        base_url: str,
        timeout_seconds: float,
        max_retries: int,
        user_agent: str,
        session: Optional[requests.Session] = None,
    ) -> None:
        self._api_key = api_key
        self._base_url = base_url.rstrip("/")
        self._timeout = timeout_seconds
        self._max_retries = max_retries
        self._user_agent = user_agent
        # Injectable for tests; default to a fresh Session so connection
        # pooling kicks in across calls.
        self._session = session or requests.Session()

    def request(
        self,
        *,
        method: Method,
        path: str,
        body: Any = None,
        idempotency_key: Optional[str] = None,
        timeout: Optional[float] = None,
        max_retries: Optional[int] = None,
        extra_headers: Optional[Dict[str, str]] = None,
    ) -> Any:
        budget = self._max_retries if max_retries is None else max_retries
        attempt = 0
        last_error: Optional[GateError] = None

        while attempt <= budget:
            try:
                return self._attempt(
                    method=method,
                    path=path,
                    body=body,
                    idempotency_key=idempotency_key,
                    timeout=timeout or self._timeout,
                    extra_headers=extra_headers,
                )
            except GateError as err:
                last_error = err
                if attempt == budget or not self._is_retryable(err, method):
                    raise
                self._sleep(self._compute_delay(attempt, err))
                attempt += 1

        # Unreachable — loop returns or raises above.
        raise last_error or GateError("request failed")

    # ---- internals ---------------------------------------------------

    def _attempt(
        self,
        *,
        method: Method,
        path: str,
        body: Any,
        idempotency_key: Optional[str],
        timeout: float,
        extra_headers: Optional[Dict[str, str]],
    ) -> Any:
        url = f"{self._base_url}{path}"
        headers = self._build_headers(method=method, body=body, idempotency_key=idempotency_key)
        if extra_headers:
            headers.update(extra_headers)

        try:
            response = self._session.request(
                method=method,
                url=url,
                headers=headers,
                data=json.dumps(body) if body is not None else None,
                timeout=timeout,
            )
        except requests.exceptions.Timeout as err:
            raise TimeoutError(f"Request to {method} {path} timed out after {timeout}s") from err
        except requests.exceptions.RequestException as err:
            raise APIConnectionError(str(err) or repr(err)) from err

        request_id = response.headers.get("X-Request-Id")
        parsed = self._safe_parse(response.text)

        if not response.ok:
            error = build_error_from_response(response.status_code, parsed, request_id)
            if isinstance(error, RateLimitError):
                retry_after = self._parse_retry_after(response.headers.get("Retry-After"))
                if retry_after is not None:
                    error.retry_after_seconds = retry_after
            raise error

        if response.status_code in (204, 205):
            return None
        return parsed

    def _build_headers(
        self,
        *,
        method: Method,
        body: Any,
        idempotency_key: Optional[str],
    ) -> Dict[str, str]:
        headers: Dict[str, str] = {
            "Accept": "application/json",
            "Authorization": f"Bearer {self._api_key}",
            "User-Agent": self._user_agent,
        }
        if body is not None:
            headers["Content-Type"] = "application/json"
        if method in _WRITE_METHODS:
            headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
        return headers

    def _is_retryable(self, err: GateError, method: str) -> bool:
        # Connection / timeout errors might mean the request never hit
        # the server. Idempotency-Key protects writes from double-submit.
        if err.code in ("connection_error", "timeout"):
            return True
        if err.status_code in _RETRYABLE_STATUS:
            return method == "GET" or method in _WRITE_METHODS
        return False

    def _compute_delay(self, attempt: int, err: GateError) -> float:
        if isinstance(err, RateLimitError) and err.retry_after_seconds is not None:
            return min(err.retry_after_seconds, 30.0)
        # 2.0 ** (not 2 **): int**int is typed Any by mypy (result sign-
        # dependent), which would poison this float return under strict.
        base = min(0.5 * (2.0 ** attempt), 30.0)
        jitter = random.random() * base * 0.25
        return base + jitter

    def _parse_retry_after(self, raw: Optional[str]) -> Optional[float]:
        if not raw:
            return None
        try:
            return max(0.0, float(raw))
        except ValueError:
            pass
        # HTTP-date form (rare for our API; spec-compliant).
        try:
            from email.utils import parsedate_to_datetime

            dt = parsedate_to_datetime(raw)
            return max(0.0, dt.timestamp() - time.time())
        except (TypeError, ValueError):
            return None

    def _safe_parse(self, text: str) -> Any:
        if not text:
            return None
        try:
            return json.loads(text)
        except ValueError:
            return text

    def _sleep(self, seconds: float) -> None:  # pragma: no cover — patched in tests
        time.sleep(seconds)


__all__ = ["HttpClient", "Method"]
