"""
Zero-dependency HTTP transport layer using :mod:`urllib`.

Handles authentication, timeouts, retry logic, and structured error parsing.
Mirrors the behaviour of the JavaScript SDK's ``HTTP`` class.
"""

from __future__ import annotations

import json
import socket
import time
import urllib.error
import urllib.request
from typing import Any, Dict, Optional, Type, TypeVar

from ._endpoints import API_BASE_URL
from .types.shared import RetryOptions, SkytellsError

T = TypeVar("T")

DEFAULT_TIMEOUT_MS: int = 60_000
DEFAULT_RETRY_ON = [429, 500, 502, 503, 504]
_MAX_BODY_LOG: int = 500


class HTTP:
    """
    Internal HTTP client.

    Uses :mod:`urllib.request` exclusively — no third-party dependencies.

    Args:
        api_key:  Skytells API key (sent as ``x-api-key`` header).
        base_url: API base URL (default: :data:`~skytells._endpoints.API_BASE_URL`).
        timeout:  Request timeout in **milliseconds** (default: 60 000).
        headers:  Additional headers merged into every request.
        retry:    :class:`~skytells.types.shared.RetryOptions` instance.
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: str = API_BASE_URL,
        timeout: int = DEFAULT_TIMEOUT_MS,
        headers: Optional[Dict[str, str]] = None,
        retry: Optional[RetryOptions] = None,
    ) -> None:
        self._api_key = api_key
        self._base_url = base_url.rstrip("/")
        self._timeout_s: float = timeout / 1000.0
        self._custom_headers: Dict[str, str] = headers or {}
        self._retry: RetryOptions = retry or RetryOptions()

    # ── Public interface ──────────────────────────────────────────────

    def request(
        self,
        method: str,
        path: str,
        data: Optional[Dict[str, Any]] = None,
    ) -> Any:
        """
        Execute an HTTP request with automatic retry on transient errors.

        Args:
            method: HTTP method — ``"GET"``, ``"POST"``, or ``"DELETE"``.
            path:   API path (e.g. ``"/predict"``).
            data:   Request body (serialised as JSON for POST requests).

        Returns:
            The parsed JSON response body.

        Raises:
            SkytellsError: For all API and network failures.
        """
        last_error: Optional[SkytellsError] = None

        for attempt in range(self._retry.retries + 1):
            try:
                return self._execute(method, path, data)
            except SkytellsError as exc:
                last_error = exc
                if exc.http_status in self._retry.retry_on and attempt < self._retry.retries:
                    delay_s = (self._retry.retry_delay / 1000.0) * (attempt + 1)
                    time.sleep(delay_s)
                    continue
                raise

        raise last_error  # type: ignore[misc]

    # ── Internal ──────────────────────────────────────────────────────

    def _execute(
        self,
        method: str,
        path: str,
        data: Optional[Dict[str, Any]] = None,
    ) -> Any:
        url = f"{self._base_url}{path}"

        headers: Dict[str, str] = {
            "Content-Type": "application/json",
            "Accept": "application/json",
            **self._custom_headers,
        }
        if self._api_key:
            headers["x-api-key"] = self._api_key

        body: Optional[bytes] = None
        if method == "POST" and data is not None:
            body = json.dumps(data).encode("utf-8")

        req = urllib.request.Request(
            url,
            data=body,
            headers=headers,
            method=method,
        )

        try:
            with urllib.request.urlopen(req, timeout=self._timeout_s) as resp:
                return self._parse_response(resp)

        except urllib.error.HTTPError as exc:
            return self._handle_http_error(exc)

        except urllib.error.URLError as exc:
            reason = exc.reason
            if isinstance(reason, socket.timeout):
                raise SkytellsError(
                    f"Request timed out after {int(self._timeout_s * 1000)}ms",
                    "REQUEST_TIMEOUT",
                    f"The request took longer than {int(self._timeout_s * 1000)}ms to complete",
                    408,
                )
            raise SkytellsError(
                str(reason) if reason else "Network error occurred",
                "NETWORK_ERROR",
                "A network error occurred while communicating with the API",
                0,
            )

        except TimeoutError:
            raise SkytellsError(
                f"Request timed out after {int(self._timeout_s * 1000)}ms",
                "REQUEST_TIMEOUT",
                f"The request took longer than {int(self._timeout_s * 1000)}ms to complete",
                408,
            )

        except OSError as exc:
            raise SkytellsError(
                str(exc),
                "NETWORK_ERROR",
                "A network error occurred while communicating with the API",
                0,
            )

    # ── Response parsing helpers ──────────────────────────────────────

    def _parse_response(self, resp: Any) -> Any:
        """Parse a successful (2xx) urllib response."""
        content_type: str = resp.headers.get("Content-Type", "") or ""
        raw_body: bytes = resp.read()

        if "application/json" not in content_type:
            snippet = self._truncate(raw_body.decode("utf-8", errors="replace"))
            raise SkytellsError(
                f"Server responded with non-JSON content ({content_type})",
                "SERVER_ERROR",
                f"Status: {resp.status}, Content: {snippet}",
                resp.status,
            )

        parsed = self._decode_json(raw_body, resp.status)

        if isinstance(parsed, dict) and parsed.get("status") is False:
            self._raise_from_body(parsed, resp.status)

        return parsed

    def _handle_http_error(self, exc: urllib.error.HTTPError) -> Any:
        """Handle an HTTP error response (4xx / 5xx)."""
        content_type: str = exc.headers.get("Content-Type", "") or ""
        try:
            raw_body = exc.read()
        except Exception:
            raw_body = b""

        if "application/json" not in content_type:
            snippet = self._truncate(raw_body.decode("utf-8", errors="replace"))
            raise SkytellsError(
                f"Server responded with non-JSON content ({content_type})",
                "SERVER_ERROR",
                f"Status: {exc.code}, Content: {snippet}",
                exc.code,
            )

        parsed = self._decode_json(raw_body, exc.code)
        self._raise_from_body(parsed, exc.code)

    def _raise_from_body(self, body: Any, http_status: int) -> None:
        """Raise a :class:`SkytellsError` from a parsed error response body."""
        if isinstance(body, dict):
            if "error" in body and isinstance(body["error"], dict):
                err = body["error"]
                raise SkytellsError(
                    err.get("message") or body.get("response") or "API error occurred",
                    err.get("error_id") or "UNKNOWN_ERROR",
                    err.get("details") or body.get("response") or "No additional details",
                    err.get("http_status") or http_status,
                )
            if "response" in body:
                raise SkytellsError(
                    body["response"],
                    "API_ERROR",
                    body["response"],
                    http_status,
                )
        raise SkytellsError(
            f"HTTP error {http_status}",
            "HTTP_ERROR",
            f"The server returned status code {http_status}",
            http_status,
        )

    def _decode_json(self, raw: bytes, http_status: int) -> Any:
        """Decode JSON bytes, raising ``INVALID_JSON`` on failure."""
        try:
            return json.loads(raw)
        except (json.JSONDecodeError, ValueError):
            snippet = self._truncate(raw.decode("utf-8", errors="replace"))
            raise SkytellsError(
                "Invalid JSON response",
                "INVALID_JSON",
                f"The server returned invalid JSON. Status: {http_status}, Content: {snippet}",
                http_status,
            )

    @staticmethod
    def _truncate(text: str) -> str:
        if len(text) > _MAX_BODY_LOG:
            return text[:_MAX_BODY_LOG] + "... [truncated]"
        return text
