"""Base HTTP client layer — handles auth, retries, envelope unwrap, error mapping."""

from __future__ import annotations

import math
import random
import time
from typing import Any, Mapping, Type, TypeVar, Union, overload

import anyio
import httpx
import pydantic

from rulebook._constants import (
    API_KEY_HEADER,
    DEFAULT_CONNECTION_LIMITS,
    DEFAULT_MAX_RETRIES,
    DEFAULT_TIMEOUT,
    INITIAL_RETRY_DELAY,
    MAX_RETRY_DELAY,
)
from rulebook._exceptions import (
    APIConnectionError,
    APIStatusError,
    APITimeoutError,
    AuthenticationError,
    BadRequestError,
    InternalServerError,
    NotFoundError,
    PermissionDeniedError,
    RateLimitError,
    UnprocessableEntityError,
)
from rulebook._types import NOT_GIVEN, Headers, NotGiven, Query, RequestOptions, is_given

_T = TypeVar("_T")

_RETRYABLE_STATUS_CODES = {408, 409, 429, 500, 502, 503, 504}


def _parse_model(cast_to: Type[_T], data: object) -> _T:
    """Parse raw data into a Pydantic model or list of models."""
    origin = getattr(cast_to, "__origin__", None)

    # Handle list[Model]
    if origin is list:
        item_type = cast_to.__args__[0]  # type: ignore[attr-defined]
        if isinstance(data, list):
            return [_parse_model(item_type, item) for item in data]  # type: ignore[return-value]
        return data  # type: ignore[return-value]

    # Handle Pydantic BaseModel (plain class)
    if isinstance(cast_to, type) and issubclass(cast_to, pydantic.BaseModel):
        if isinstance(data, dict):
            return cast_to.model_validate(data)
        return data  # type: ignore[return-value]

    # Handle generic Pydantic BaseModel (e.g. PaginatedResponse[FeeScheduleResult])
    if origin is not None and isinstance(origin, type) and issubclass(origin, pydantic.BaseModel):
        if isinstance(data, dict):
            return cast_to.model_validate(data)  # type: ignore[union-attr]
        return data  # type: ignore[return-value]

    return data  # type: ignore[return-value]


def _model_has_data_field(cast_to: type) -> bool:
    """Return True if *cast_to* is a Pydantic model that declares its own ``data`` field.

    When this is the case, the full response body (including pagination metadata)
    should be passed to the model instead of just the unwrapped ``data`` value.

    Handles both plain classes (``PaginatedResponse``) and generic aliases
    (``PaginatedResponse[FeeScheduleResult]``).
    """
    origin = getattr(cast_to, "__origin__", None)
    cls = origin if origin is not None else cast_to
    return (
        isinstance(cls, type)
        and issubclass(cls, pydantic.BaseModel)
        and "data" in cls.model_fields
    )


def _calculate_retry_delay(retry_count: int, retry_after: str | None) -> float:
    """Calculate delay for retry with exponential backoff and jitter."""
    if retry_after is not None:
        try:
            return float(retry_after)
        except ValueError:
            pass

    delay = min(INITIAL_RETRY_DELAY * (2**retry_count), MAX_RETRY_DELAY)
    jitter = delay * 0.25 * random.random()
    return delay + jitter


class SyncAPIClient:
    """Synchronous HTTP client with auth, retries, and response parsing."""

    _base_url: str
    _api_key: str
    _max_retries: int
    _timeout: httpx.Timeout
    _custom_headers: Mapping[str, str]
    _custom_query: Mapping[str, object]
    _client: httpx.Client

    def __init__(
        self,
        *,
        api_key: str,
        base_url: str,
        max_retries: int = DEFAULT_MAX_RETRIES,
        timeout: Union[float, httpx.Timeout, None, NotGiven] = NOT_GIVEN,
        custom_headers: Mapping[str, str] | None = None,
        custom_query: Mapping[str, object] | None = None,
        http_client: httpx.Client | None = None,
    ) -> None:
        self._api_key = api_key
        self._base_url = base_url.rstrip("/")
        self._max_retries = max_retries
        self._custom_headers = custom_headers or {}
        self._custom_query = custom_query or {}

        if isinstance(timeout, NotGiven) or timeout is NOT_GIVEN:
            self._timeout = DEFAULT_TIMEOUT
        elif isinstance(timeout, httpx.Timeout):
            self._timeout = timeout
        elif timeout is None:
            self._timeout = httpx.Timeout(timeout=None)
        else:
            self._timeout = httpx.Timeout(timeout=timeout, connect=5.0)

        if http_client is not None:
            self._client = http_client
        else:
            self._client = httpx.Client(
                base_url=self._base_url,
                timeout=self._timeout,
                limits=DEFAULT_CONNECTION_LIMITS,
                follow_redirects=True,
            )

    @property
    def base_url(self) -> str:
        return self._base_url

    @base_url.setter
    def base_url(self, value: str) -> None:
        self._base_url = value.rstrip("/")

    @property
    def timeout(self) -> httpx.Timeout:
        return self._timeout

    @property
    def max_retries(self) -> int:
        return self._max_retries

    @property
    def auth_headers(self) -> dict[str, str]:
        return {API_KEY_HEADER: self._api_key}

    @property
    def default_headers(self) -> dict[str, str]:
        return {
            "Accept": "application/json",
            "User-Agent": "rulebook-python/0.2.0",
            **self.auth_headers,
            **self._custom_headers,
        }

    def get(
        self,
        path: str,
        *,
        cast_to: Type[_T],
        options: RequestOptions | None = None,
    ) -> _T:
        """Make a GET request and return the parsed response."""
        return self._request("GET", path, cast_to=cast_to, options=options)

    def _request(
        self,
        method: str,
        path: str,
        *,
        cast_to: Type[_T],
        options: RequestOptions | None = None,
    ) -> _T:
        options = options or {}

        headers = {**self.default_headers}
        if "headers" in options:
            headers.update(options["headers"])

        params: dict[str, object] = {**self._custom_query}
        if "params" in options:
            params.update(options["params"])

        timeout = options.get("timeout", self._timeout)
        max_retries = options.get("max_retries", self._max_retries)

        url = f"{self._base_url}{path}"

        return self._retry_request(
            method=method,
            url=url,
            headers=headers,
            params=params,
            timeout=timeout,
            max_retries=max_retries,
            cast_to=cast_to,
        )

    def _retry_request(
        self,
        *,
        method: str,
        url: str,
        headers: dict[str, str],
        params: dict[str, object],
        timeout: Any,
        max_retries: int,
        cast_to: Type[_T],
    ) -> _T:
        request = self._client.build_request(
            method=method,
            url=url,
            headers=headers,
            params={k: v for k, v in params.items() if v is not None} if params else None,
            timeout=timeout,
        )

        retries_remaining = max_retries

        while True:
            try:
                response = self._client.send(request)
            except httpx.TimeoutException as exc:
                if retries_remaining > 0:
                    retries_remaining -= 1
                    time.sleep(
                        _calculate_retry_delay(max_retries - retries_remaining, None)
                    )
                    continue
                raise APITimeoutError(request, cause=exc) from exc
            except httpx.ConnectError as exc:
                if retries_remaining > 0:
                    retries_remaining -= 1
                    time.sleep(
                        _calculate_retry_delay(max_retries - retries_remaining, None)
                    )
                    continue
                raise APIConnectionError(request=request, cause=exc) from exc

            if response.status_code in _RETRYABLE_STATUS_CODES and retries_remaining > 0:
                retries_remaining -= 1
                retry_after = response.headers.get("retry-after")
                time.sleep(
                    _calculate_retry_delay(max_retries - retries_remaining, retry_after)
                )
                continue

            break

        return self._process_response(response, cast_to=cast_to)

    def _process_response(self, response: httpx.Response, *, cast_to: Type[_T]) -> _T:
        """Unwrap the API envelope, handle errors, and parse into the target type."""
        try:
            body = response.json()
        except Exception:
            body = None

        if response.status_code >= 400:
            message = "Unknown error"
            error_code: str | None = None
            details: list[object] | None = None
            if isinstance(body, dict):
                error = body.get("error")
                if isinstance(error, dict):
                    message = error.get("message", message)
                    raw_code = error.get("code")
                    if isinstance(raw_code, str):
                        error_code = raw_code
                    raw_details = error.get("details")
                    if isinstance(raw_details, list):
                        details = raw_details
                else:
                    message = body.get("message", str(error) if error else message)
            raise self._make_status_error(
                str(message), response=response, body=body,
                error_code=error_code, details=details,
            )

        # When cast_to is a Pydantic model that has its own ``data`` field
        # (e.g. PaginatedResponse), pass the full body so the model can
        # capture both the items list and pagination metadata.
        if _model_has_data_field(cast_to):
            return _parse_model(cast_to, body)

        # Unwrap the { data, meta } envelope
        if isinstance(body, dict) and "data" in body:
            data = body["data"]
            # Merge scalar meta fields (e.g. record_count) into the data
            # dict so non-paginated models can access them.
            meta = body.get("meta")
            if isinstance(data, dict) and isinstance(meta, dict):
                for key, value in meta.items():
                    if key not in data:
                        data[key] = value
        else:
            data = body

        return _parse_model(cast_to, data)

    def _process_response_raw(self, response: httpx.Response) -> httpx.Response:
        """Return raw response, raising on error status."""
        if response.status_code >= 400:
            try:
                body = response.json()
            except Exception:
                body = None
            message = "Unknown error"
            error_code: str | None = None
            details: list[object] | None = None
            if isinstance(body, dict):
                error = body.get("error")
                if isinstance(error, dict):
                    message = error.get("message", message)
                    raw_code = error.get("code")
                    if isinstance(raw_code, str):
                        error_code = raw_code
                    raw_details = error.get("details")
                    if isinstance(raw_details, list):
                        details = raw_details
                else:
                    message = body.get("message", str(error) if error else message)
            raise self._make_status_error(
                str(message), response=response, body=body,
                error_code=error_code, details=details,
            )
        return response

    def _make_status_error(
        self,
        message: str,
        *,
        response: httpx.Response,
        body: object | None,
        error_code: str | None = None,
        details: list[object] | None = None,
    ) -> APIStatusError:
        """Map HTTP status code to the appropriate exception class."""
        kwargs = dict(response=response, body=body, error_code=error_code, details=details)
        if response.status_code == 400:
            return BadRequestError(message, **kwargs)
        if response.status_code == 401:
            return AuthenticationError(message, **kwargs)
        if response.status_code == 403:
            return PermissionDeniedError(message, **kwargs)
        if response.status_code == 404:
            return NotFoundError(message, **kwargs)
        if response.status_code == 422:
            return UnprocessableEntityError(message, **kwargs)
        if response.status_code == 429:
            return RateLimitError(message, **kwargs)
        if response.status_code >= 500:
            return InternalServerError(message, **kwargs)
        return APIStatusError(message, **kwargs)

    def close(self) -> None:
        self._client.close()

    def __enter__(self) -> SyncAPIClient:
        return self

    def __exit__(self, *args: Any) -> None:
        self.close()


class AsyncAPIClient:
    """Asynchronous HTTP client with auth, retries, and response parsing."""

    _base_url: str
    _api_key: str
    _max_retries: int
    _timeout: httpx.Timeout
    _custom_headers: Mapping[str, str]
    _custom_query: Mapping[str, object]
    _client: httpx.AsyncClient

    def __init__(
        self,
        *,
        api_key: str,
        base_url: str,
        max_retries: int = DEFAULT_MAX_RETRIES,
        timeout: Union[float, httpx.Timeout, None, NotGiven] = NOT_GIVEN,
        custom_headers: Mapping[str, str] | None = None,
        custom_query: Mapping[str, object] | None = None,
        http_client: httpx.AsyncClient | None = None,
    ) -> None:
        self._api_key = api_key
        self._base_url = base_url.rstrip("/")
        self._max_retries = max_retries
        self._custom_headers = custom_headers or {}
        self._custom_query = custom_query or {}

        if isinstance(timeout, NotGiven) or timeout is NOT_GIVEN:
            self._timeout = DEFAULT_TIMEOUT
        elif isinstance(timeout, httpx.Timeout):
            self._timeout = timeout
        elif timeout is None:
            self._timeout = httpx.Timeout(timeout=None)
        else:
            self._timeout = httpx.Timeout(timeout=timeout, connect=5.0)

        if http_client is not None:
            self._client = http_client
        else:
            self._client = httpx.AsyncClient(
                base_url=self._base_url,
                timeout=self._timeout,
                limits=DEFAULT_CONNECTION_LIMITS,
                follow_redirects=True,
            )

    @property
    def base_url(self) -> str:
        return self._base_url

    @base_url.setter
    def base_url(self, value: str) -> None:
        self._base_url = value.rstrip("/")

    @property
    def timeout(self) -> httpx.Timeout:
        return self._timeout

    @property
    def max_retries(self) -> int:
        return self._max_retries

    @property
    def auth_headers(self) -> dict[str, str]:
        return {API_KEY_HEADER: self._api_key}

    @property
    def default_headers(self) -> dict[str, str]:
        return {
            "Accept": "application/json",
            "User-Agent": "rulebook-python/0.2.0",
            **self.auth_headers,
            **self._custom_headers,
        }

    async def get(
        self,
        path: str,
        *,
        cast_to: Type[_T],
        options: RequestOptions | None = None,
    ) -> _T:
        """Make an async GET request and return the parsed response."""
        return await self._request("GET", path, cast_to=cast_to, options=options)

    async def _request(
        self,
        method: str,
        path: str,
        *,
        cast_to: Type[_T],
        options: RequestOptions | None = None,
    ) -> _T:
        options = options or {}

        headers = {**self.default_headers}
        if "headers" in options:
            headers.update(options["headers"])

        params: dict[str, object] = {**self._custom_query}
        if "params" in options:
            params.update(options["params"])

        timeout = options.get("timeout", self._timeout)
        max_retries = options.get("max_retries", self._max_retries)

        url = f"{self._base_url}{path}"

        return await self._retry_request(
            method=method,
            url=url,
            headers=headers,
            params=params,
            timeout=timeout,
            max_retries=max_retries,
            cast_to=cast_to,
        )

    async def _retry_request(
        self,
        *,
        method: str,
        url: str,
        headers: dict[str, str],
        params: dict[str, object],
        timeout: Any,
        max_retries: int,
        cast_to: Type[_T],
    ) -> _T:
        request = self._client.build_request(
            method=method,
            url=url,
            headers=headers,
            params={k: v for k, v in params.items() if v is not None} if params else None,
            timeout=timeout,
        )

        retries_remaining = max_retries

        while True:
            try:
                response = await self._client.send(request)
            except httpx.TimeoutException as exc:
                if retries_remaining > 0:
                    retries_remaining -= 1
                    await anyio.sleep(
                        _calculate_retry_delay(max_retries - retries_remaining, None)
                    )
                    continue
                raise APITimeoutError(request, cause=exc) from exc
            except httpx.ConnectError as exc:
                if retries_remaining > 0:
                    retries_remaining -= 1
                    await anyio.sleep(
                        _calculate_retry_delay(max_retries - retries_remaining, None)
                    )
                    continue
                raise APIConnectionError(request=request, cause=exc) from exc

            if response.status_code in _RETRYABLE_STATUS_CODES and retries_remaining > 0:
                retries_remaining -= 1
                retry_after = response.headers.get("retry-after")
                await anyio.sleep(
                    _calculate_retry_delay(max_retries - retries_remaining, retry_after)
                )
                continue

            break

        return self._process_response(response, cast_to=cast_to)

    def _process_response(self, response: httpx.Response, *, cast_to: Type[_T]) -> _T:
        """Unwrap the API envelope, handle errors, and parse into the target type."""
        try:
            body = response.json()
        except Exception:
            body = None

        if response.status_code >= 400:
            message = "Unknown error"
            error_code: str | None = None
            details: list[object] | None = None
            if isinstance(body, dict):
                error = body.get("error")
                if isinstance(error, dict):
                    message = error.get("message", message)
                    raw_code = error.get("code")
                    if isinstance(raw_code, str):
                        error_code = raw_code
                    raw_details = error.get("details")
                    if isinstance(raw_details, list):
                        details = raw_details
                else:
                    message = body.get("message", str(error) if error else message)
            raise self._make_status_error(
                str(message), response=response, body=body,
                error_code=error_code, details=details,
            )

        if _model_has_data_field(cast_to):
            return _parse_model(cast_to, body)

        if isinstance(body, dict) and "data" in body:
            data = body["data"]
            meta = body.get("meta")
            if isinstance(data, dict) and isinstance(meta, dict):
                for key, value in meta.items():
                    if key not in data:
                        data[key] = value
        else:
            data = body

        return _parse_model(cast_to, data)

    def _make_status_error(
        self,
        message: str,
        *,
        response: httpx.Response,
        body: object | None,
        error_code: str | None = None,
        details: list[object] | None = None,
    ) -> APIStatusError:
        kwargs = dict(response=response, body=body, error_code=error_code, details=details)
        if response.status_code == 400:
            return BadRequestError(message, **kwargs)
        if response.status_code == 401:
            return AuthenticationError(message, **kwargs)
        if response.status_code == 403:
            return PermissionDeniedError(message, **kwargs)
        if response.status_code == 404:
            return NotFoundError(message, **kwargs)
        if response.status_code == 422:
            return UnprocessableEntityError(message, **kwargs)
        if response.status_code == 429:
            return RateLimitError(message, **kwargs)
        if response.status_code >= 500:
            return InternalServerError(message, **kwargs)
        return APIStatusError(message, **kwargs)

    def _process_response_raw(self, response: httpx.Response) -> httpx.Response:
        """Return raw response, raising on error status."""
        if response.status_code >= 400:
            try:
                body = response.json()
            except Exception:
                body = None
            message = "Unknown error"
            error_code: str | None = None
            details: list[object] | None = None
            if isinstance(body, dict):
                error = body.get("error")
                if isinstance(error, dict):
                    message = error.get("message", message)
                    raw_code = error.get("code")
                    if isinstance(raw_code, str):
                        error_code = raw_code
                    raw_details = error.get("details")
                    if isinstance(raw_details, list):
                        details = raw_details
                else:
                    message = body.get("message", str(error) if error else message)
            raise self._make_status_error(
                str(message), response=response, body=body,
                error_code=error_code, details=details,
            )
        return response

    async def close(self) -> None:
        await self._client.aclose()

    async def __aenter__(self) -> AsyncAPIClient:
        return self

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