"""Internal HTTP helper for Adobe IMS token endpoint calls.

Supports configurable timeouts, retries with exponential backoff,
rate-limit handling (429), and response validation.

Allows configurable IMS base URL for staging/alternative environments,
and optional HTTP client injection for proxy, TLS, and connection pooling.
"""

from __future__ import annotations

import asyncio
import base64
import logging
import threading
import time
from email.utils import parsedate_to_datetime
from typing import Any
from urllib.parse import urlencode

import httpx
from ._errors import (
    AuthenticationError,
    NetworkError,
    RateLimitError,
    TokenExpiredError,
)

logger = logging.getLogger("frameio.auth")

# Default Adobe IMS base URL (production). Override via ims_base_url for staging.
DEFAULT_IMS_BASE_URL = "https://ims-na1.adobelogin.com"

# Default buffer: refresh 60 seconds before actual expiry
DEFAULT_REFRESH_BUFFER_SECONDS = 60


def _build_urls(ims_base_url: str) -> tuple[str, str, str]:
    """Build authorize, token, and revoke URLs from an IMS base URL."""
    base = ims_base_url.rstrip("/")
    return (
        f"{base}/ims/authorize/v2",
        f"{base}/ims/token/v3",
        f"{base}/ims/revoke",
    )


# Module-level URLs (default production); use _build_urls() when ims_base_url is custom
_, _TOKEN_URL, _REVOKE_URL = _build_urls(DEFAULT_IMS_BASE_URL)

# OAuth 2.0 RFC 6749 specifies space-separated scopes
DEFAULT_SCOPES = "openid email profile offline_access additional_info.roles"
S2S_SCOPES = "openid AdobeID frame.s2s.all"

# Defaults
# Default fallback when Adobe IMS doesn't return expires_in
DEFAULT_EXPIRES_IN = 86400

DEFAULT_TIMEOUT = 30.0  # seconds
DEFAULT_MAX_RETRIES = 2
_RETRY_BACKOFF_SCHEDULE = (1.0, 2.0, 5.0, 10.0)
_RETRYABLE_STATUS_CODES = {500, 502, 503, 504}
_MAX_RETRY_AFTER_SECONDS = 60.0

# Reusable client cache — keyed by timeout so different timeouts get
# different clients (most apps will only ever use one timeout value).
_clients: dict[float, httpx.Client] = {}
_clients_lock = threading.Lock()


def _get_client(timeout: float = DEFAULT_TIMEOUT) -> httpx.Client:
    """Get or create a reusable httpx.Client for the given timeout.

    Thread-safe: always acquires the lock to avoid relying on GIL semantics.
    """
    with _clients_lock:
        if timeout not in _clients:
            _clients[timeout] = httpx.Client(timeout=timeout)
        return _clients[timeout]


def close_clients() -> None:
    """Close all cached httpx clients, releasing TCP connections and file descriptors.

    Call this during application shutdown if you are not providing your own
    ``http_client`` instances.  For the async clients, use :func:`aclose_clients`.
    """
    with _clients_lock:
        for c in _clients.values():
            c.close()
        _clients.clear()


async def aclose_clients() -> None:
    """Close all cached async httpx clients.

    Async counterpart of :func:`close_clients`.
    """
    with _async_clients_lock:
        clients = list(_async_clients.values())
        _async_clients.clear()
    for c in clients:
        await c.aclose()


def _parse_retry_after(header: str | None) -> float | None:
    """Parse a Retry-After header value (seconds or HTTP-date)."""
    if header is None:
        return None
    try:
        return float(header)
    except ValueError:
        pass
    try:
        dt = parsedate_to_datetime(header)
        delay = (dt.timestamp() - time.time())
        return max(delay, 0.0)
    except Exception:
        return None


def _validate_token_response(data: Any) -> dict[str, Any]:
    """Validate that a token response has the required fields."""
    if not isinstance(data, dict):
        raise AuthenticationError(
            "invalid_response",
            "Token endpoint returned non-object response.",
        )
    if "access_token" not in data or not isinstance(data["access_token"], str) or not data["access_token"]:
        raise AuthenticationError(
            "invalid_response",
            "Token response missing or empty 'access_token'.",
        )
    token_type = data.get("token_type")
    if token_type is None:
        logger.debug("Token response missing 'token_type' field.")
    elif token_type.lower() != "bearer":
        logger.warning("Unexpected token_type: %s (expected 'bearer').", token_type)

    expires_in = data.get("expires_in")
    if expires_in is not None:
        if not isinstance(expires_in, (int, float)) or expires_in <= 0:
            logger.warning(
                "Invalid expires_in value (%s), using default.", expires_in
            )
            data["expires_in"] = None  # will trigger default in _store_tokens
    return data


def token_request(
    data: dict[str, str],
    *,
    timeout: float = DEFAULT_TIMEOUT,
    max_retries: int = DEFAULT_MAX_RETRIES,
    client: httpx.Client | None = None,
    token_url: str | None = None,
) -> dict[str, Any]:
    """Send a ``POST`` to the Adobe IMS token endpoint.

    Args:
        data: Form-encoded body parameters.
        timeout: HTTP request timeout in seconds (used only when client is None).
        max_retries: Maximum number of retries for transient failures.
        client: Optional httpx.Client for proxy, TLS, or connection pooling.
        token_url: Optional token endpoint URL (for alternative IMS environments).

    Returns:
        Parsed JSON response with at least ``access_token`` and ``expires_in``.

    Raises:
        AuthenticationError: On any non-2xx response from Adobe IMS.
        TokenExpiredError: When the error indicates the refresh token is expired.
        NetworkError: On timeout or connection failures.
        RateLimitError: When rate-limited and retries are exhausted.

    Note:
        Rate-limit retries (429) are tracked separately from error retries
        (5xx / network failures), so a 429 does not consume the error retry
        budget and vice versa.
    """
    http_client = client if client is not None else _get_client(timeout)
    url = token_url if token_url is not None else _TOKEN_URL
    error_attempts = 0
    rate_limit_attempts = 0
    max_error_attempts = max_retries + 1
    max_rate_limit_attempts = max_retries + 1
    # Safety limit to prevent infinite loops
    max_total = max_error_attempts + max_rate_limit_attempts

    for _ in range(max_total):
        try:
            logger.debug(
                "Token request (errors=%d/%d, rate_limits=%d/%d)",
                error_attempts,
                max_error_attempts,
                rate_limit_attempts,
                max_rate_limit_attempts,
            )
            response = http_client.post(
                url,
                data=data,
                headers={"Content-Type": "application/x-www-form-urlencoded"},
            )
        except (httpx.TimeoutException, httpx.ConnectError) as exc:
            error_attempts += 1
            logger.warning(
                "Network error on attempt %d: %s", error_attempts, exc
            )
            if error_attempts < max_error_attempts:
                backoff = _RETRY_BACKOFF_SCHEDULE[
                    min(error_attempts - 1, len(_RETRY_BACKOFF_SCHEDULE) - 1)
                ]
                logger.debug("Retrying in %.1fs...", backoff)
                time.sleep(backoff)
                continue
            raise NetworkError(
                f"Token request failed after {max_error_attempts} attempts: {exc}"
            ) from exc

        # Rate limit handling — separate budget from error retries
        if response.status_code == 429:
            rate_limit_attempts += 1
            retry_after = _parse_retry_after(
                response.headers.get("retry-after")
            )
            logger.warning(
                "Rate limited (429), attempt %d/%d. Retry-After: %s",
                rate_limit_attempts,
                max_rate_limit_attempts,
                retry_after,
            )
            if rate_limit_attempts < max_rate_limit_attempts:
                wait_time = retry_after if retry_after is not None else _RETRY_BACKOFF_SCHEDULE[
                    min(rate_limit_attempts - 1, len(_RETRY_BACKOFF_SCHEDULE) - 1)
                ]
                if wait_time > _MAX_RETRY_AFTER_SECONDS:
                    logger.debug(
                        "Capping Retry-After from %.1fs to %.1fs",
                        wait_time,
                        _MAX_RETRY_AFTER_SECONDS,
                    )
                    wait_time = _MAX_RETRY_AFTER_SECONDS
                time.sleep(wait_time)
                continue
            raise RateLimitError(retry_after)

        # Retryable server errors
        if response.status_code in _RETRYABLE_STATUS_CODES:
            error_attempts += 1
            logger.warning(
                "Server error %d, attempt %d/%d",
                response.status_code,
                error_attempts,
                max_error_attempts,
            )
            if error_attempts < max_error_attempts:
                backoff = _RETRY_BACKOFF_SCHEDULE[
                    min(error_attempts - 1, len(_RETRY_BACKOFF_SCHEDULE) - 1)
                ]
                time.sleep(backoff)
                continue
            # Fall through to error handling below

        # Non-retryable error
        if response.status_code != 200:
            body = {}
            if response.headers.get("content-type", "").startswith(
                "application/json"
            ):
                try:
                    body = response.json()
                except Exception:
                    body = {}
            error_code = body.get("error", f"http_{response.status_code}")
            error_desc = body.get("error_description")

            # Detect expired refresh token specifically
            if (
                error_code in ("invalid_grant", "invalid_token")
                and error_desc
                and "expired" in error_desc.lower()
            ):
                logger.error("Token expired: %s", error_code)
                raise TokenExpiredError(f"{error_code} — {error_desc}")

            logger.error("Authentication error: %s", error_code)
            raise AuthenticationError(error_code, error_desc)

        # Success
        try:
            body = response.json()
        except Exception as exc:
            raise AuthenticationError(
                "invalid_response",
                f"Token endpoint returned non-JSON body: {exc}",
            ) from exc
        result = _validate_token_response(body)
        logger.info(
            "Token acquired (length=%d)", len(result["access_token"])
        )
        return result

    # Should not reach here, but just in case
    raise NetworkError("Token request failed after all retries.")


def revoke_request(
    token: str,
    client_id: str,
    client_secret: str | None = None,
    *,
    timeout: float = DEFAULT_TIMEOUT,
    client: httpx.Client | None = None,
    revoke_url: str | None = None,
) -> None:
    """Revoke an access or refresh token via Adobe IMS.

    This is best-effort — errors are logged but not raised.

    For confidential clients (``client_secret`` provided), authentication uses
    HTTP Basic ``Authorization: Basic Base64(client_id:client_secret)``.
    For public clients (no ``client_secret``), ``client_id`` is sent as a
    query parameter instead.

    Args:
        token: The access or refresh token to revoke.
        client_id: OAuth client ID.
        client_secret: OAuth client secret (optional for public clients).
        timeout: HTTP request timeout in seconds (used only when client is None).
        client: Optional httpx.Client for proxy, TLS, or connection pooling.
        revoke_url: Optional revoke endpoint URL (for alternative IMS environments).
    """
    http_client = client if client is not None else _get_client(timeout)
    url = revoke_url if revoke_url is not None else _REVOKE_URL

    headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}
    if client_secret:
        credentials = base64.b64encode(
            f"{client_id}:{client_secret}".encode()
        ).decode()
        headers["Authorization"] = f"Basic {credentials}"
    else:
        url = f"{url}?{urlencode({'client_id': client_id})}"

    try:
        response = http_client.post(url, data={"token": token}, headers=headers)
        if response.status_code != 200:
            logger.warning(
                "Token revocation returned status %d", response.status_code
            )
    except Exception as exc:
        logger.warning("Token revocation failed: %s", exc)


# Async client cache (same pattern as sync)
_async_clients: dict[float, httpx.AsyncClient] = {}
_async_clients_lock = threading.Lock()


def _get_async_client(timeout: float = DEFAULT_TIMEOUT) -> httpx.AsyncClient:
    """Get or create a reusable httpx.AsyncClient for the given timeout.

    Thread-safe: always acquires the lock to avoid relying on GIL semantics.
    """
    with _async_clients_lock:
        if timeout not in _async_clients:
            _async_clients[timeout] = httpx.AsyncClient(timeout=timeout)
        return _async_clients[timeout]


async def token_request_async(
    data: dict[str, str],
    *,
    timeout: float = DEFAULT_TIMEOUT,
    max_retries: int = DEFAULT_MAX_RETRIES,
    client: httpx.AsyncClient | None = None,
    token_url: str | None = None,
) -> dict[str, Any]:
    """Async: Send a ``POST`` to the Adobe IMS token endpoint.

    Args:
        data: Form-encoded body parameters.
        timeout: HTTP request timeout in seconds (used only when client is None).
        max_retries: Maximum number of retries for transient failures.
        client: Optional httpx.AsyncClient for proxy, TLS, or connection pooling.
        token_url: Optional token endpoint URL (for alternative IMS environments).

    Returns:
        Parsed JSON response with at least ``access_token`` and ``expires_in``.
    """
    http_client = client if client is not None else _get_async_client(timeout)
    url = token_url if token_url is not None else _TOKEN_URL
    error_attempts = 0
    rate_limit_attempts = 0
    max_error_attempts = max_retries + 1
    max_rate_limit_attempts = max_retries + 1
    max_total = max_error_attempts + max_rate_limit_attempts

    for _ in range(max_total):
        try:
            logger.debug(
                "Token request async (errors=%d/%d, rate_limits=%d/%d)",
                error_attempts,
                max_error_attempts,
                rate_limit_attempts,
                max_rate_limit_attempts,
            )
            response = await http_client.post(
                url,
                data=data,
                headers={"Content-Type": "application/x-www-form-urlencoded"},
            )
        except (httpx.TimeoutException, httpx.ConnectError) as exc:
            error_attempts += 1
            logger.warning(
                "Network error on attempt %d: %s", error_attempts, exc
            )
            if error_attempts < max_error_attempts:
                backoff = _RETRY_BACKOFF_SCHEDULE[
                    min(error_attempts - 1, len(_RETRY_BACKOFF_SCHEDULE) - 1)
                ]
                logger.debug("Retrying in %.1fs...", backoff)
                await asyncio.sleep(backoff)
                continue
            raise NetworkError(
                f"Token request failed after {max_error_attempts} attempts: {exc}"
            ) from exc

        if response.status_code == 429:
            rate_limit_attempts += 1
            retry_after = _parse_retry_after(
                response.headers.get("retry-after")
            )
            logger.warning(
                "Rate limited (429), attempt %d/%d. Retry-After: %s",
                rate_limit_attempts,
                max_rate_limit_attempts,
                retry_after,
            )
            if rate_limit_attempts < max_rate_limit_attempts:
                wait_time = retry_after if retry_after is not None else _RETRY_BACKOFF_SCHEDULE[
                    min(rate_limit_attempts - 1, len(_RETRY_BACKOFF_SCHEDULE) - 1)
                ]
                if wait_time > _MAX_RETRY_AFTER_SECONDS:
                    logger.debug(
                        "Capping Retry-After from %.1fs to %.1fs",
                        wait_time,
                        _MAX_RETRY_AFTER_SECONDS,
                    )
                    wait_time = _MAX_RETRY_AFTER_SECONDS
                await asyncio.sleep(wait_time)
                continue
            raise RateLimitError(retry_after)

        if response.status_code in _RETRYABLE_STATUS_CODES:
            error_attempts += 1
            logger.warning(
                "Server error %d, attempt %d/%d",
                response.status_code,
                error_attempts,
                max_error_attempts,
            )
            if error_attempts < max_error_attempts:
                backoff = _RETRY_BACKOFF_SCHEDULE[
                    min(error_attempts - 1, len(_RETRY_BACKOFF_SCHEDULE) - 1)
                ]
                await asyncio.sleep(backoff)
                continue

        if response.status_code != 200:
            body = {}
            if response.headers.get("content-type", "").startswith(
                "application/json"
            ):
                try:
                    body = response.json()
                except Exception:
                    body = {}
            error_code = body.get("error", f"http_{response.status_code}")
            error_desc = body.get("error_description")

            if (
                error_code in ("invalid_grant", "invalid_token")
                and error_desc
                and "expired" in error_desc.lower()
            ):
                logger.error("Token expired: %s", error_code)
                raise TokenExpiredError(f"{error_code} — {error_desc}")

            logger.error("Authentication error: %s", error_code)
            raise AuthenticationError(error_code, error_desc)

        # Success
        try:
            body = response.json()
        except Exception as exc:
            raise AuthenticationError(
                "invalid_response",
                f"Token endpoint returned non-JSON body: {exc}",
            ) from exc
        result = _validate_token_response(body)
        logger.info(
            "Token acquired (length=%d)", len(result["access_token"])
        )
        return result

    raise NetworkError("Token request failed after all retries.")


async def revoke_request_async(
    token: str,
    client_id: str,
    client_secret: str | None = None,
    *,
    timeout: float = DEFAULT_TIMEOUT,
    client: httpx.AsyncClient | None = None,
    revoke_url: str | None = None,
) -> None:
    """Async: Revoke an access or refresh token via Adobe IMS.

    This is best-effort — errors are logged but not raised.

    For confidential clients (``client_secret`` provided), authentication uses
    HTTP Basic ``Authorization: Basic Base64(client_id:client_secret)``.
    For public clients (no ``client_secret``), ``client_id`` is sent as a
    query parameter instead.

    Args:
        token: The access or refresh token to revoke.
        client_id: OAuth client ID.
        client_secret: OAuth client secret (optional for public clients).
        timeout: HTTP request timeout in seconds (used only when client is None).
        client: Optional httpx.AsyncClient for proxy, TLS, or connection pooling.
        revoke_url: Optional revoke endpoint URL (for alternative IMS environments).
    """
    http_client = client if client is not None else _get_async_client(timeout)
    url = revoke_url if revoke_url is not None else _REVOKE_URL

    headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}
    if client_secret:
        credentials = base64.b64encode(
            f"{client_id}:{client_secret}".encode()
        ).decode()
        headers["Authorization"] = f"Basic {credentials}"
    else:
        url = f"{url}?{urlencode({'client_id': client_id})}"

    try:
        response = await http_client.post(url, data={"token": token}, headers=headers)
        if response.status_code != 200:
            logger.warning(
                "Token revocation returned status %d", response.status_code
            )
    except Exception as exc:
        logger.warning("Token revocation failed: %s", exc)
