from __future__ import annotations

import json
import random
import uuid
from collections.abc import Awaitable, Callable, Mapping
from json import JSONDecodeError
from types import MappingProxyType, SimpleNamespace
from typing import (
    TYPE_CHECKING,
    Any,
    ClassVar,
    Generic,
    Self,
    TypeGuard,
    TypeVar,
    cast,
    overload,
)
from urllib.parse import urljoin

from pydantic import BaseModel, ValidationError

from ._cache import (
    AsyncResolver,
    K,
    SyncResolver,
    V,
)
from ._constants import BASE_URL, USER_AGENT
from ._enums import AppId
from ._internal._schemas import APIEnvelope, ClientConfig, ProxySpec
from .exceptions import (
    AccountBlockedError,
    AccountCancelOncePerDayError,
    AccountForbiddenError,
    AccountNotFoundError,
    AccountNotLoggedInError,
    AccountNotMatchError,
    APIError,
    ArtModelNoIsEmptyError,
    AuthTokenInvalidError,
    BalanceNotEnoughError,
    ComfyuiApiNodeCannotBeCanceledError,
    DoNotTurnOffPermissionToPublishWorksToTheCommunityError,
    FeatureRequiresPaymentError,
    ImageCountExceedLimitError,
    InvalidPasswordError,
    OperationNotAllowedError,
    ParamsError,
    ProcessingError,
    PromptHaveSensitiveWordsError,
    ServerError,
    TaskCompletionCannotBeCanceledError,
    TaskHangLimitExceededError,
    TouristChatNumLimitError,
    UsernameError,
)

if TYPE_CHECKING:
    from curl_cffi.requests.impersonate import BrowserTypeLiteral
    from curl_cffi.requests.utils import HttpVersionLiteral

    from ._transport_sse import SSEEvent

T = TypeVar("T", bound=BaseModel)


SessionT = TypeVar("SessionT")


class BaseClient(Generic[SessionT]):
    """Common plumbing for sync/async clients.

    Handles configuration, URL/header construction, and uniform response parsing
    into ``APIEnvelope``. Also maps API error codes to concrete exceptions.

    Attributes:
        ERRORS: Mapping of API error codes to exception classes.
    """

    ERRORS: ClassVar[dict[int, type[APIError]]] = {
        10100: ParamsError,
        10200: ServerError,
        10405: ProcessingError,
        20000: AccountNotLoggedInError,
        20001: AccountNotFoundError,
        20002: AuthTokenInvalidError,
        20004: AccountForbiddenError,
        20005: InvalidPasswordError,
        20009: UsernameError,
        20602: AccountCancelOncePerDayError,
        40011: DoNotTurnOffPermissionToPublishWorksToTheCommunityError,
        60002: AccountNotMatchError,
        60003: TouristChatNumLimitError,
        70000: OperationNotAllowedError,
        70002: TaskHangLimitExceededError,
        70004: TaskCompletionCannotBeCanceledError,
        70010: FeatureRequiresPaymentError,
        70026: PromptHaveSensitiveWordsError,
        70052: ComfyuiApiNodeCannotBeCanceledError,
        70061: ImageCountExceedLimitError,
        80008: BalanceNotEnoughError,
        80318: AccountBlockedError,
    }

    def __init__(
        self,
        base_url: str = BASE_URL,
        token: str | None = None,
        *,
        app_id: AppId = AppId.WEB,
        timeout: float = 30.0,
        connect_timeout: float | None = None,
        read_timeout: float | None = None,
        cache_ttl_seconds: float | None = None,
        user_agent: str = USER_AGENT,
        http_version: HttpVersionLiteral = "v2",
        default_headers: Mapping[str, str] | None = None,
        proxy: str | None = None,
        proxies: ProxySpec | None = None,
        impersonate: BrowserTypeLiteral | None = None,
        session: SessionT | None = None,
    ) -> None:
        """
        Args:
            base_url: Base API endpoint URL.
            token: Authentication token included in every request header.
            timeout: Default request timeout in seconds. Overridden per-axis by
                ``connect_timeout`` and ``read_timeout``.
            connect_timeout: TCP connection timeout in seconds. Overrides the
                connect half of ``timeout`` when set.
            read_timeout: Response body read timeout in seconds. Overrides the
                read half of ``timeout`` when set.
            cache_ttl_seconds: TTL for in-memory cached responses; ``None``
                disables caching.
            user_agent: Custom ``User-Agent`` header value.
            http_version: HTTP protocol version (``"v1"``, ``"v2"``, or ``"v3"``).
            default_headers: Extra headers merged into every request.
            proxy: Proxy URL applied to all requests.
            proxies: Per-protocol proxy map (``curl_cffi`` ``ProxySpec``).
            impersonate: Browser fingerprint to impersonate (e.g. ``"chrome"``,
                ``"firefox"``).
            session: Pre-built session instance to use instead of the default one.
        """
        effective_timeout: float | tuple[float, float]
        if connect_timeout is not None or read_timeout is not None:
            effective_timeout = (
                connect_timeout if connect_timeout is not None else timeout,
                read_timeout if read_timeout is not None else timeout,
            )
        else:
            effective_timeout = timeout

        self._cfg = ClientConfig(
            base_url=base_url,
            token=token,
            app_id=app_id,
            timeout=effective_timeout,
            cache_ttl_seconds=cache_ttl_seconds,
            user_agent=user_agent,
            http_version=http_version,
            default_headers=dict(default_headers or {}),
            proxy=proxy,
            proxies=proxies,
            impersonate=impersonate,
        )

        self._account_id: str | None = None
        self._session: SessionT = (
            session if session is not None else self._init_session()
        )
        self._init_resources()

    def _init_session(self) -> SessionT:
        raise NotImplementedError

    def _init_resources(self) -> None:
        """Override in subclasses to attach resources."""

    @property
    def token(self) -> str | None:
        """Current authentication token (if any)."""
        return self._cfg.token

    def set_account_id(self, account_id: str | None) -> None:
        """Override the cached account ID without making an API call."""
        self._account_id = account_id

    @property
    def default_headers(self) -> Mapping[str, Any]:
        """Read-only view of the client-level default headers."""
        return MappingProxyType(self._cfg.default_headers)

    @property
    def cfg(self) -> ClientConfig:
        """Current client configuration."""
        return self._cfg

    def set_default_headers(self, headers: Mapping[str, Any]) -> None:
        """Merge additional headers into the client-level defaults.

        Existing keys are overwritten; keys absent from ``headers`` are preserved.

        Args:
            headers: Headers to merge. Values are coerced to strings.
        """
        merged = {
            **self._cfg.default_headers,
            **{k: str(v) for k, v in headers.items()},
        }
        self._cfg = self._cfg.model_copy(update={"default_headers": merged})

    def clear_default_headers(self) -> None:
        """Remove all client-level default headers."""
        self._cfg = self._cfg.model_copy(update={"default_headers": {}})

    def copy(self, *, clear_token: bool = True) -> Self:
        """Return a new client instance with the same configuration.

        Args:
            clear_token: When ``True``, the copy is created without the auth token.

        Returns:
            A new client of the same type with copied settings.
        """
        data = self._cfg.model_dump()
        if clear_token:
            data["token"] = None

        return self.__class__(**data)

    def generate_seed(self, seed: int | None = None) -> int:
        """Return ``seed`` as-is, or a random integer in ``[0, 999_999_999]`` when ``None``."""
        return seed if seed is not None else random.randint(0, 999_999_999)  # noqa: S311

    def generate_x_device_id(self, device_id: str | None = None) -> str:
        """Return ``device_id`` as-is, or a freshly generated UUID4 string when ``None``."""
        return device_id or str(uuid.uuid4())

    def set_token(self, token: str | None) -> None:
        """Replace the current auth token (``None`` removes authentication)."""
        self._cfg = self._cfg.model_copy(update={"token": token})

    def parse_envelope(
        self, ev: SSEEvent, obj: dict[str, Any] | None
    ) -> APIEnvelope[Any]:
        """Parse an SSE event's JSON payload as an ``APIEnvelope``.

        Intended for use as a ``parser`` argument to ``request_stream``.

        Args:
            ev: The raw SSE event.
            obj: Decoded JSON payload from the event data, or ``None`` if absent.

        Returns:
            Validated ``APIEnvelope`` wrapping the event payload.

        Raises:
            ValueError: If ``obj`` is ``None``.
        """
        if obj is None:
            raise ValueError(f"SSE event {ev.event!r} has no JSON object in data")
        return APIEnvelope[Any].model_validate(obj)

    def parse_sse_stream(
        self, ev: SSEEvent, obj: dict[str, Any] | None
    ) -> SimpleNamespace:
        """Wrap an SSE event into a ``SimpleNamespace`` for generic stream consumption.

        Intended for use as a ``parser`` argument to ``request_stream``.

        Args:
            ev: The raw SSE event.
            obj: Decoded JSON payload from the event data, or ``None`` if absent.

        Returns:
            A ``SimpleNamespace`` with attributes ``event``, ``data``, ``id``,
            ``retry``, and ``headers``. ``data`` is ``obj`` when available,
            otherwise the raw string ``ev.data``.
        """
        return SimpleNamespace(
            event=ev.event,
            data=obj if obj is not None else ev.data,
            id=ev.id,
            retry=ev.retry,
            headers=ev.headers,
        )

    def make_async_resolver(
        self, fetcher: Callable[[K], Awaitable[V]]
    ) -> AsyncResolver[K, V]:
        """Create an async TTL cache backed by ``fetcher``.

        Args:
            fetcher: Async callable that produces a value for a given key.

        Returns:
            ``AsyncResolver`` configured with the client's ``cache_ttl_seconds``.
        """
        return AsyncResolver(fetcher, ttl_seconds=self._cfg.cache_ttl_seconds)

    def make_sync_resolver(self, fetcher: Callable[[K], V]) -> SyncResolver[K, V]:
        """Create a sync TTL cache backed by ``fetcher``.

        Args:
            fetcher: Callable that produces a value for a given key.

        Returns:
            ``SyncResolver`` configured with the client's ``cache_ttl_seconds``.
        """
        return SyncResolver(fetcher, ttl_seconds=self._cfg.cache_ttl_seconds)

    def _build_url(self, path: str) -> str:
        """Return absolute URL for a relative API path.

        Args:
            path: Relative endpoint path (with or without leading slash).

        Returns:
            Absolute URL composed with the configured ``base_url``.
        """
        if path.startswith(("http://", "https://", "//")):
            return "https:" + path if path.startswith("//") else path
        base = self._cfg.base_url.rstrip("/") + "/"

        return urljoin(base, path.lstrip("/"))

    def _build_headers(self, extra: Mapping[str, str] | None) -> dict[str, str]:
        """Compose request headers for an outgoing call.

        Merges defaults (UA, Accept), client-level ``default_headers``, optional
        auth ``token``, and per-call ``extra``.

        Args:
            extra: Extra headers to merge for this request.

        Returns:
            Final header dictionary.
        """
        headers = {
            "User-Agent": self._cfg.user_agent,
            "Accept": "application/json",
            "x-app-id": self._cfg.app_id.value,
        }

        headers.update(self._cfg.default_headers)

        if self._cfg.token:
            headers["token"] = self._cfg.token
        if extra:
            headers.update(extra)

        return headers

    def _resolve_error_class(self, code: int | None, msg: str | None) -> type[APIError]:
        if code is None:
            return APIError

        if code == 10200:
            m = (msg or "").lower()

            if "art_model_no is empty" in m or "art_model_no" in m:
                return ArtModelNoIsEmptyError

            return ServerError

        return self.ERRORS.get(code, APIError)

    @overload
    def _handle_response(
        self, status: int, body: bytes, response_model: type[T]
    ) -> APIEnvelope[T]: ...
    @overload
    def _handle_response(
        self, status: int, body: bytes, response_model: None = None
    ) -> APIEnvelope[None]: ...

    def _handle_response(
        self,
        status: int,
        body: bytes,
        response_model: type[T] | None = None,
    ) -> APIEnvelope[T] | APIEnvelope[None]:
        if status >= 400:
            raise APIError(status, body)

        if status in (204, 205) or not body:
            return APIEnvelope[Any].model_validate(
                {"status": {"code": 10000, "msg": "", "request_id": ""}, "data": None}
            )

        if isinstance(body, bytearray):
            body = bytes(body)
        elif isinstance(body, str):
            body = body.encode("utf-8")

        try:
            payload = json.loads(body)
        except (UnicodeDecodeError, JSONDecodeError) as e:
            raise APIError(status, body, msg=f"Non-JSON response: {e}") from e

        try:
            env: APIEnvelope[Any] = APIEnvelope[Any].model_validate(payload)
        except ValidationError as e:
            raise APIError(status, body, msg=f"Invalid envelope: {e}") from e

        code = env.status.code
        if code is not None and code != 10000:
            err_cls = self._resolve_error_class(code, env.status.msg)
            raise err_cls(status, body, code=code, msg=env.status.msg)

        if response_model is not None and isinstance(env.data, dict):
            parsed_data = response_model.model_validate(env.value)
            return env.model_copy(update={"data": parsed_data})

        return env

    def _handle_stream_response(self, status: int, body: bytes) -> APIEnvelope[Any]:
        try:
            payload = json.loads(body)
        except UnicodeDecodeError as e:
            raise APIError(status, body, msg=f"Non-JSON response in stream: {e}") from e
        except json.JSONDecodeError as e:
            raise APIError(status, body, msg=f"Invalid JSON in stream: {e}") from e

        try:
            env: APIEnvelope[Any] = APIEnvelope[Any].model_validate(payload)
        except ValidationError as e:
            raise APIError(status, body, msg=f"Invalid envelope in stream: {e}") from e

        return env

    def _is_str_key_mapping(self, x: object) -> TypeGuard[Mapping[str, Any]]:
        if not isinstance(x, Mapping):
            return False
        m = cast("Mapping[object, object]", x)

        return all(isinstance(k, str) for k in m)
