from __future__ import annotations

import asyncio
import inspect
import random
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Mapping

import httpx

from .errors import APIError
from .models import (
    APIResponse,
    AuthMode,
    ChallengeHeaderInput,
    ChallengeHeaderProvider,
    RequestOptions,
    ResponseMeta,
    RetryOptions,
    TokenProvider,
)


@dataclass
class RuntimeOptions:
    base_url: str
    timeout: float = 15.0
    retry: RetryOptions = field(default_factory=RetryOptions)
    session_token: TokenProvider | None = None
    operator_token: TokenProvider | None = None
    challenge_headers: ChallengeHeaderProvider | None = None
    idempotency_key_generator: Any = None
    default_headers: dict[str, str] | None = None


def _default_idempotency_key() -> str:
    return f"idem_{uuid.uuid4()}"


def _normalize_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
    out: dict[str, str] = {}
    if not headers:
        return out
    for key, value in headers.items():
        out[key.lower()] = value
    return out


class _BaseRuntime:
    def __init__(self, options: RuntimeOptions) -> None:
        self.base_url = options.base_url.rstrip("/")
        self.timeout = options.timeout
        self.retry = options.retry
        self.session_token = options.session_token
        self.operator_token = options.operator_token
        self.challenge_headers = options.challenge_headers
        self.idempotency_key_generator = options.idempotency_key_generator or _default_idempotency_key
        self.default_headers = _normalize_headers(options.default_headers)

    @staticmethod
    def _can_retry(
        method: str,
        idempotent: bool,
        retryable: bool,
        headers: Mapping[str, str],
        error: Exception,
    ) -> bool:
        if not retryable:
            return False

        method_u = method.upper()
        is_safe_method = method_u in {"GET", "HEAD"}
        has_idempotency = "idempotency-key" in {k.lower(): v for k, v in headers.items()}
        is_safe_mutation = method_u in {"POST", "PUT", "DELETE"} and idempotent and has_idempotency
        if not (is_safe_method or is_safe_mutation):
            return False

        if isinstance(error, APIError):
            return 500 <= error.status < 600
        return isinstance(error, (httpx.TimeoutException, httpx.TransportError))

    @staticmethod
    def _parse_response(response: httpx.Response) -> APIResponse:
        raw_body = response.text
        if raw_body.strip():
            try:
                payload = response.json()
            except ValueError:
                payload = {"raw": raw_body}
        else:
            payload = {}

        if not isinstance(payload, dict):
            payload = {"raw": payload}

        request_id = payload.get("request_id")
        if request_id is not None:
            request_id = str(request_id)

        meta = ResponseMeta(
            status=response.status_code,
            headers={k.lower(): v for k, v in response.headers.items()},
            request_id=request_id,
        )

        if response.status_code >= 400:
            code = payload.get("code") if isinstance(payload.get("code"), str) else None
            message = payload.get("message") if isinstance(payload.get("message"), str) else f"request failed with status {response.status_code}"
            meta_payload = payload.get("meta") if isinstance(payload.get("meta"), dict) else None
            raise APIError(
                status=response.status_code,
                code=code,
                message=message,
                request_id=request_id,
                meta=meta_payload,
                raw_body=raw_body,
                payload=payload,
            )

        return APIResponse(data=payload, meta=meta)

    def _auth_provider(self, auth: AuthMode) -> TokenProvider | None:
        if auth == "session":
            return self.session_token
        if auth == "operator":
            return self.operator_token
        return None


class SyncRuntime(_BaseRuntime):
    def __init__(self, options: RuntimeOptions, client: httpx.Client | None = None) -> None:
        super().__init__(options)
        self.client = client or httpx.Client(timeout=self.timeout)

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

    def _resolve_provider_sync(self, provider: Any) -> Any:
        if provider is None:
            return None
        if isinstance(provider, str):
            return provider
        if callable(provider):
            value = provider()
            if inspect.isawaitable(value):
                return asyncio.run(value)
            return value
        return provider

    def request(
        self,
        *,
        method: str,
        path: str,
        auth: AuthMode,
        idempotent: bool,
        challenge: bool,
        options: RequestOptions | None = None,
        body: Any = None,
        params: dict[str, Any] | None = None,
        retryable: bool = True,
    ) -> APIResponse:
        options = options or RequestOptions()
        retry = options.retry or self.retry

        headers = {
            "content-type": "application/json",
            **self.default_headers,
        }

        resolved_auth = options.auth or auth
        token_provider = self._auth_provider(resolved_auth)
        token = self._resolve_provider_sync(token_provider)
        if resolved_auth != "none" and not token:
            raise APIError(status=0, code="AUTH_TOKEN_MISSING", message=f"missing {resolved_auth} token provider")
        if token:
            headers["authorization"] = f"Bearer {token}"

        if challenge and self.challenge_headers:
            challenge_input = ChallengeHeaderInput(method=method, path=path, auth=resolved_auth, body=body)
            extra_headers = self._resolve_provider_sync(lambda: self.challenge_headers(challenge_input))
            if isinstance(extra_headers, Mapping):
                headers.update(_normalize_headers(extra_headers))

        headers.update(_normalize_headers(options.headers))

        if method.upper() in {"POST", "PUT", "DELETE"} and idempotent:
            if options.idempotency_key:
                headers["idempotency-key"] = options.idempotency_key
            elif "idempotency-key" not in headers:
                headers["idempotency-key"] = self.idempotency_key_generator()

        attempts = 0
        while True:
            attempts += 1
            try:
                response = self.client.request(
                    method=method,
                    url=f"{self.base_url}{path}",
                    headers=headers,
                    params=params,
                    json=body,
                    timeout=options.timeout or self.timeout,
                )
                return self._parse_response(response)
            except Exception as exc:  # noqa: BLE001
                if attempts > retry.max_retries or not self._can_retry(method, idempotent, retryable, headers, exc):
                    raise
                time.sleep((retry.base_delay_ms * attempts) / 1000.0 + random.random() / 1000.0)


class AsyncRuntime(_BaseRuntime):
    def __init__(self, options: RuntimeOptions, client: httpx.AsyncClient | None = None) -> None:
        super().__init__(options)
        self.client = client or httpx.AsyncClient(timeout=self.timeout)

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

    async def _resolve_provider_async(self, provider: Any) -> Any:
        if provider is None:
            return None
        if isinstance(provider, str):
            return provider
        if callable(provider):
            value = provider()
            if inspect.isawaitable(value):
                return await value
            return value
        return provider

    async def request(
        self,
        *,
        method: str,
        path: str,
        auth: AuthMode,
        idempotent: bool,
        challenge: bool,
        options: RequestOptions | None = None,
        body: Any = None,
        params: dict[str, Any] | None = None,
        retryable: bool = True,
    ) -> APIResponse:
        options = options or RequestOptions()
        retry = options.retry or self.retry

        headers = {
            "content-type": "application/json",
            **self.default_headers,
        }

        resolved_auth = options.auth or auth
        token_provider = self._auth_provider(resolved_auth)
        token = await self._resolve_provider_async(token_provider)
        if resolved_auth != "none" and not token:
            raise APIError(status=0, code="AUTH_TOKEN_MISSING", message=f"missing {resolved_auth} token provider")
        if token:
            headers["authorization"] = f"Bearer {token}"

        if challenge and self.challenge_headers:
            challenge_input = ChallengeHeaderInput(method=method, path=path, auth=resolved_auth, body=body)
            extra_headers = await self._resolve_provider_async(lambda: self.challenge_headers(challenge_input))
            if isinstance(extra_headers, Mapping):
                headers.update(_normalize_headers(extra_headers))

        headers.update(_normalize_headers(options.headers))

        if method.upper() in {"POST", "PUT", "DELETE"} and idempotent:
            if options.idempotency_key:
                headers["idempotency-key"] = options.idempotency_key
            elif "idempotency-key" not in headers:
                headers["idempotency-key"] = self.idempotency_key_generator()

        attempts = 0
        while True:
            attempts += 1
            try:
                response = await self.client.request(
                    method=method,
                    url=f"{self.base_url}{path}",
                    headers=headers,
                    params=params,
                    json=body,
                    timeout=options.timeout or self.timeout,
                )
                return self._parse_response(response)
            except Exception as exc:  # noqa: BLE001
                if attempts > retry.max_retries or not self._can_retry(method, idempotent, retryable, headers, exc):
                    raise
                await asyncio.sleep((retry.base_delay_ms * attempts) / 1000.0 + random.random() / 1000.0)
