from __future__ import annotations

from typing import Mapping, Type, get_args
from urllib.parse import urljoin
import json

from pydantic import ValidationError
from ._models import ClientConfig, APIEnvelope, HttpVersion
from ._exceptions import (
    APIError,
    AccountNotFoundError,
    InvalidPasswordError,
    AuthTokenInvalidError,
)


class BaseClient:
    """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: dict[int, Type[APIError]] = {
        20001: AccountNotFoundError,
        20002: AuthTokenInvalidError,
        20005: InvalidPasswordError,
    }

    def __init__(self, config: ClientConfig) -> None:
        """Initialize base client with configuration.

        Args:
            config: Client configuration (base URL, timeouts, headers, token).

        Raises:
            ValueError: If `http_version` is not one of the supported literals.
        """
        self._cfg = config
        if self._cfg.http_version not in get_args(HttpVersion):
            raise ValueError

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

    def set_token(self, token: str | None) -> None:
        """Update the authentication token in the client configuration.

        Args:
            token: New token value or `None` to clear it.
        """
        self._cfg = self._cfg.model_copy(update={"token": token})

    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:
            str: Absolute URL composed with the configured `base_url`.
        """
        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:
            dict[str, str]: Final header dictionary.
        """
        headers = {
            "User-Agent": self._cfg.user_agent,
            "Accept": "application/json",
        }

        headers.update(self._cfg.default_headers)

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

        return headers

    def _handle_response(self, status: int, body: bytes) -> APIEnvelope:
        """Normalize raw HTTP response into `APIEnvelope` or raise errors.

        Handles empty/no-content responses, converts various byte-like inputs,
        validates JSON, constructs the `APIEnvelope`, and maps error codes to
        exceptions via `ERRORS`.

        Args:
            status: HTTP status code.
            body: Raw response body as bytes (or byte-like).

        Returns:
            APIEnvelope: Parsed and validated response envelope.

        Raises:
            APIError: For HTTP >= 400, non-JSON responses, invalid envelope
                shape, or unmapped error codes.
            AccountNotFoundError: If code 20001 is returned.
            AuthTokenInvalidError: If code 20002 is returned.
            InvalidPasswordError: If code 20005 is returned.
        """
        if status >= 400:
            raise APIError(status, body)

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

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

        try:
            payload = json.loads(body.decode("utf-8"))
        except Exception:
            raise APIError(status, body, msg="Non-JSON response")

        try:
            env = APIEnvelope.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.ERRORS.get(code, APIError)
            raise err_cls(status, body, code=code, msg=env.status.msg)

        return env
