"""Reusable base HTTP client and auth providers."""

from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Awaitable
from logging import getLogger
from os import getenv
from types import TracebackType
from typing import Any, Callable, Union, cast

import httpx
from httpx import HTTPStatusError
from typing_extensions import Self

logger = getLogger(__name__)

JSON_TYPE = Union[dict[str, Any], list[Any], str, int, float, bool, None]
TokenFactory = Callable[[], Union[str, None, Awaitable[Union[str, None]]]]


class TokenProvider(ABC):
    @abstractmethod
    async def get_token(self) -> str | None:
        raise NotImplementedError

    def invalidate_token(self) -> None:
        return None


class StaticTokenProvider(TokenProvider):
    def __init__(self, token: str):
        self.token = token or ""

    async def get_token(self) -> str | None:
        return self.token


class CallableTokenProvider(TokenProvider):
    def __init__(self, factory: TokenFactory):
        self.factory = factory

    async def get_token(self) -> str | None:
        token = self.factory()
        if token is None or isinstance(token, str):
            return token
        return await token


class ServiceTokenProvider(TokenProvider):
    def __init__(
        self,
        auth_url: str | None = None,
        client_id: str | None = None,
        client_secret: str | None = None,
        token_path: str = "/v1/auth/token",
        timeout: int = 30,
    ):
        self._token: str | None = None
        self.auth_url = auth_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.token_path = token_path
        self.timeout = timeout

    async def get_token(self) -> str | None:
        if self._token is None:
            self._token = await self._get_token()
        return self._token

    def _resolved_credentials(self) -> tuple[str, str, str]:
        url = self.auth_url or getenv("AUTH_URL")
        cid = self.client_id or getenv("AUTH_CLIENT_ID")
        secret = self.client_secret or getenv("AUTH_CLIENT_SECRET")
        if not url:
            raise ValueError("Auth URL is not configured")
        if not cid:
            raise ValueError("Auth client_id is not configured")
        if not secret:
            raise ValueError("Auth client_secret is not configured")
        return url, cid, secret

    async def _get_token(self) -> str:
        auth_url, client_id, client_secret = self._resolved_credentials()
        async with httpx.AsyncClient(base_url=auth_url, timeout=self.timeout) as client:
            response = await client.post(
                self.token_path,
                json={
                    "grant_type": "client_credentials",
                    "client_id": client_id,
                    "client_secret": client_secret,
                },
            )
            response.raise_for_status()
            logger.info("Successfully obtained service token")
            payload = response.json()
            access_token = (
                payload.get("access_token") if isinstance(payload, dict) else None
            )
            if not isinstance(access_token, str):
                raise ValueError("Auth response did not include a valid access_token")
            return access_token

    def invalidate_token(self) -> None:
        self._token = None


class PlainTokenProvider(StaticTokenProvider):
    pass


class BaseHTTPClient:
    def __init__(
        self,
        base_url: str,
        token: str | None = None,
        timeout: int = 30,
        token_provider: TokenProvider | None = None,
        auth_retry_attempts: int = 3,
        default_headers: dict[str, str] | None = None,
        follow_redirects: bool = True,
        require_auth: bool = True,
    ):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.default_headers = default_headers or {"Content-Type": "application/json"}
        self.follow_redirects = follow_redirects

        if token is not None and token_provider is not None:
            raise ValueError("Use either token or token_provider, not both")

        if token_provider is not None:
            self.token_provider: TokenProvider | None = token_provider
        elif token is not None:
            self.token_provider = StaticTokenProvider(token)
        elif require_auth:
            raise ValueError("token is required when token_provider is not set")
        else:
            self.token_provider = None

        self.auth_retry_attempts = auth_retry_attempts
        self.client = httpx.AsyncClient(timeout=timeout)

    async def _get_headers(
        self,
        request_headers: dict[str, str] | None = None,
    ) -> dict[str, str]:
        headers = dict(self.default_headers)
        if request_headers:
            headers.update(request_headers)

        if self.token_provider is not None:
            token = await self.token_provider.get_token()
            if token:
                headers["Authorization"] = f"Bearer {token}"

        return headers

    async def _request(self, method: str, endpoint: str, **kwargs: Any) -> JSON_TYPE:
        url = f"{self.base_url}/{endpoint.lstrip('/')}"
        request_headers = kwargs.pop("headers", None)
        for attempt in range(1, self.auth_retry_attempts + 1):
            headers = await self._get_headers(request_headers)
            response = await self.client.request(
                method,
                url,
                headers=headers,
                follow_redirects=self.follow_redirects,
                **kwargs,
            )
            try:
                response.raise_for_status()
                if response.status_code == 204 or method.upper() == "DELETE":
                    return None
                content_type = response.headers.get("content-type", "")
                if "application/json" in content_type:
                    return cast(JSON_TYPE, response.json())
                return response.text
            except HTTPStatusError as exc:
                status_code = exc.response.status_code
                logger.error(
                    "BaseHTTPClient - _request response: "
                    f"{status_code} - {response.text}"
                )
                should_retry = (
                    self.token_provider is not None
                    and status_code in {401, 403}
                    and attempt < self.auth_retry_attempts
                )
                if should_retry:
                    if self.token_provider is not None:
                        self.token_provider.invalidate_token()
                    continue
                raise
        return None

    async def get(
        self, endpoint: str, params: dict[str, Any] | None = None
    ) -> JSON_TYPE:
        return await self._request("GET", endpoint, params=params)

    async def get_bytes(
        self, endpoint: str, params: dict[str, Any] | None = None
    ) -> bytes:
        url = f"{self.base_url}/{endpoint.lstrip('/')}"
        for attempt in range(1, self.auth_retry_attempts + 1):
            headers = await self._get_headers()
            response = await self.client.request(
                "GET",
                url,
                headers=headers,
                follow_redirects=self.follow_redirects,
                params=params,
            )
            try:
                response.raise_for_status()
                return response.content
            except HTTPStatusError as exc:
                status_code = exc.response.status_code
                logger.error(
                    "BaseHTTPClient - get_bytes response: %s - %s",
                    status_code,
                    response.text,
                )
                should_retry = (
                    self.token_provider is not None
                    and status_code in {401, 403}
                    and attempt < self.auth_retry_attempts
                )
                if should_retry:
                    if self.token_provider is not None:
                        self.token_provider.invalidate_token()
                    continue
                raise
        return b""

    async def post(
        self,
        endpoint: str,
        data: dict[str, Any] | None = None,
        json: list[Any] | dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
    ) -> JSON_TYPE:
        return await self._request(
            "POST", endpoint, data=data, json=json, params=params
        )

    async def patch(
        self,
        endpoint: str,
        json: list[Any] | dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
    ) -> JSON_TYPE:
        return await self._request("PATCH", endpoint, json=json, params=params)

    async def put(
        self,
        endpoint: str,
        json: list[Any] | dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
    ) -> JSON_TYPE:
        return await self._request("PUT", endpoint, json=json, params=params)

    async def delete(self, endpoint: str) -> JSON_TYPE:
        return await self._request("DELETE", endpoint)

    async def close(self) -> None:
        """Close and recycle the underlying HTTP client.

        The client remains fully usable after this call — a fresh
        ``httpx.AsyncClient`` is created automatically.  This is the
        preferred teardown path for short-lived callers (e.g. a Celery
        task) when a long-lived object (e.g. a process-level singleton)
        may still hold a reference to this instance.
        """
        await self.client.aclose()
        self.client = httpx.AsyncClient(timeout=self.timeout)

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

        After this call the client is no longer usable.  Use this when
        the instance itself is being torn down (e.g. application
        shutdown) and no further requests will be made.
        """
        await self.client.aclose()

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

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        await self.aclose()
