import json
from typing import IO, Any, Generic, Iterator, TypeVar, cast

import httpx
from pydantic import BaseModel

from .. import errors
from ..ty import Page


class RawIteratorIO(IO[bytes]):
    """
    Creates a IO[bytes] from a bytes iterator.

    Adapted from https://stackoverflow.com/a/12604375/7426717
    """

    _it: Iterator[bytes]
    _memory: bytes

    def __init__(self, it: Iterator[bytes]) -> None:
        self._it = it
        self._memory = b""

        super().__init__()

    def _read1(self, n: int | None = None) -> bytes:
        while not self._memory:
            try:
                next_memory = next(self._it)
            except StopIteration:
                break
            else:
                self._memory = next_memory

        chunk = self._memory[:n]
        self._memory = self._memory[len(chunk) :]

        return chunk

    def read(self, n: int | None = None) -> bytes:
        chunks: list[bytes] = []
        if n is None or n < 0:
            while True:
                m = self._read1()
                if not m:
                    break
                chunks.append(m)
        else:
            while n > 0:
                m = self._read1(n)
                if not m:
                    break
                n -= len(m)
                chunks.append(m)
        return b"".join(chunks)


CHUNK_SIZE = 1024


class BaseClient:
    name: str
    url: str

    def __init__(
        self, sync_client: httpx.Client, async_client: httpx.AsyncClient, path: str
    ) -> None:
        self._sync_client = sync_client
        self._async_client = async_client
        if path.startswith("/"):
            path = path[1:]
        self.name = path.split("/")[-1]
        self.path = f"/{path}"

    def request(
        self,
        method: str,
        *,
        endpoint: str | None = None,
        params: BaseModel | dict[str, Any] | None = None,
        data: BaseModel | dict[str, Any] | None = None,
        headers: dict[str, str] = {},
        page: int | None = None,
        size: int | None = None,
        params_model: type[BaseModel] | None = None,
        body_model: type[BaseModel] | None = None,
        stream: bool = False,
        return_model: type[BaseModel] | None = None,
    ) -> BaseModel | Page[BaseModel] | RawIteratorIO | None:
        req = self._get_validated_request(
            method=method,
            endpoint=endpoint or "",
            params=params,
            data=data,
            params_model=params_model,
            body_model=body_model,
            headers=headers,
            page=page,
            size=size,
        )
        response = self._sync_client.send(req, stream=stream)
        self._raise_and_handle_errors(response)
        content = None
        if (
            response.headers.get("content-type") == "application/json"
            and response.status_code != 204
        ):
            content = response.json()
        if return_model is not None:
            return self._convert_response_to_model(content, return_model)
        return RawIteratorIO(response.iter_bytes(chunk_size=CHUNK_SIZE))

    async def request_async(
        self,
        method: str,
        *,
        endpoint: str | None = None,
        params: BaseModel | dict[str, Any] | None = None,
        data: BaseModel | dict[str, Any] | None = None,
        headers: dict[str, str] = {},
        page: int | None = None,
        size: int | None = None,
        params_model: type[BaseModel] | None = None,
        body_model: type[BaseModel] | None = None,
        stream: bool = False,
        return_model: type[BaseModel] | None = None,
    ) -> BaseModel | Page[BaseModel] | None:
        req = self._get_validated_request(
            method=method,
            endpoint=endpoint or "",
            params=params,
            data=data,
            params_model=params_model,
            body_model=body_model,
            headers=headers,
            page=page,
            size=size,
        )
        response = await self._async_client.send(req, stream=stream)
        self._raise_and_handle_errors(response)
        content = None
        if response.content:
            content = response.json()
        if return_model is not None:
            return self._convert_response_to_model(content, return_model)

    def _get_validated_request(
        self,
        *,
        method: str,
        endpoint: str,
        params: BaseModel | dict[str, Any] | None = None,
        data: BaseModel | dict[str, Any] | None = None,
        params_model: type[BaseModel] | None = None,
        body_model: type[BaseModel] | None = None,
        headers: dict[str, str] | None = None,
        page: int | None = None,
        size: int | None = None,
    ) -> httpx.Request:
        url = self.path
        if endpoint:
            if endpoint.startswith("/"):
                url = endpoint
            else:
                url = f"{self.path}/{endpoint}"
        local_params: dict[str, Any] = {}
        if page is not None:
            local_params["page"] = page
        if size is not None:
            local_params["size"] = size
        if params is not None:
            if isinstance(params, dict):
                params.update(local_params)
                if params_model is not None:
                    # if params_model is passed, use Pydantic to validate params
                    params = params_model(**params)
                    params = params.model_dump()
            elif isinstance(params, BaseModel):
                params = params.model_dump(exclude_none=True, exclude_defaults=True)
                params.update(local_params)
        elif local_params:
            params = local_params
        content = None
        if data is not None:
            if isinstance(data, dict):
                if body_model is not None:
                    body = body_model(**data)
                    content = body.model_dump_json()
                else:
                    content = json.dumps(data)
            elif isinstance(data, BaseModel):
                content = data.model_dump_json()
        req = self._sync_client.build_request(
            method, url, headers=headers, params=params, content=content
        )
        return req

    def _raise_and_handle_errors(self, response: httpx.Response) -> None:
        try:
            response.raise_for_status()
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            error_type = None
            sdk_error = None
            try:
                error_data = e.response.json()
            except json.JSONDecodeError:
                error_data = {}
            if isinstance(error_data, dict):
                detail = error_data.get("detail")
            else:
                detail = error_data
            if detail and isinstance(detail, dict):
                error_type = detail.get("type")
            if error_type:
                sdk_error = getattr(errors, error_type, None)
            if sdk_error:
                raise sdk_error(detail=str(detail))
            elif status == 401:
                raise errors.AuthError(detail=str(detail))
            else:
                raise e

    def _convert_response_to_model(
        self, data: Any, return_model: type[BaseModel]
    ) -> BaseModel | Page[BaseModel] | None:
        """Convert the JSON response to the correct Pydantic model"""
        if data is None:
            return data
        return return_model.model_validate(data)


_Creating = TypeVar("_Creating", bound=BaseModel)
_Reading = TypeVar("_Reading", bound=BaseModel)
_Updating = TypeVar("_Updating", bound=BaseModel)
_Deleting = TypeVar("_Deleting", bound=BaseModel)
_Summary = TypeVar("_Summary", bound=BaseModel)
_Detail = TypeVar("_Detail", bound=BaseModel)


class ModelClient(
    BaseClient,
    Generic[_Creating, _Reading, _Updating, _Deleting, _Summary, _Detail],
):
    Creating: type[_Creating]
    Reading: type[_Reading]
    Updating: type[_Updating]
    Deleting: type[_Deleting]
    Summary: type[_Summary]
    Detail: type[_Detail]

    def __init__(
        self, sync_client: httpx.Client, async_client: httpx.AsyncClient, path: str
    ) -> None:
        super().__init__(sync_client, async_client, path)
        self.plural_path = f"{self.path}s"

    # TODO: Eventually the `create`, `read`, `update` routes should all
    # have _Detail models and they won't return Union[_Summary], _Detail] but
    # explicitly return `_Detail`. This is temporary until the PAM API is finalized
    def create(
        self, data: _Creating | None = None, **kwargs: Any
    ) -> _Summary | _Detail:
        data = data if data else self.Creating(**kwargs)
        has_detail, return_model = self._has_detail()
        # Using a type ignore here because the 'detail' stuff is messy and needs to be
        # refactored anyway
        res = self.request(
            "POST", endpoint="create", data=data, return_model=return_model  # type: ignore
        )
        return cast(_Detail, res) if has_detail else cast(_Summary, res)

    async def create_async(
        self, data: _Creating | None = None, **kwargs: Any
    ) -> _Summary | _Detail:
        data = data if data else self.Creating(**kwargs)
        has_detail, return_model = self._has_detail()
        res = await self.request_async(
            "POST", endpoint="create", data=data, return_model=return_model  # type: ignore
        )
        return cast(_Detail, res) if has_detail else cast(_Summary, res)

    def read(self, query: _Reading | None = None, **kwargs: Any) -> _Summary | _Detail:
        data = query if query else self.Reading(**kwargs)
        has_detail, return_model = self._has_detail()
        res = self.request("POST", endpoint="read", data=data, return_model=return_model)  # type: ignore
        return cast(_Detail, res) if has_detail else cast(_Summary, res)

    async def read_async(
        self, query: _Reading | None = None, **kwargs: Any
    ) -> _Summary | _Detail:
        data = query if query else self.Reading(**kwargs)
        has_detail, return_model = self._has_detail()
        res = await self.request_async("POST", endpoint="read", data=data, return_model=return_model)  # type: ignore
        return cast(_Detail, res) if has_detail else cast(_Summary, res)

    def exists(
        self, query: _Reading | None = None, **kwargs: Any
    ) -> _Summary | _Detail | None:
        data = query if query else self.Reading(**kwargs)
        has_detail, return_model = self._has_detail()
        try:
            res = self.request("POST", endpoint="read", data=data, return_model=return_model)  # type: ignore
        except errors.NotFound:
            res = None
        return cast(_Detail | None, res) if has_detail else cast(_Summary | None, res)

    async def exists_async(
        self, query: _Reading | None = None, **kwargs: Any
    ) -> _Summary | _Detail | None:
        data = query if query else self.Reading(**kwargs)
        has_detail, return_model = self._has_detail()
        try:
            res = await self.request_async(
                "POST", endpoint="read", data=data, return_model=return_model  # type: ignore
            )
        except errors.NotFound:
            res = None
        return cast(_Detail | None, res) if has_detail else cast(_Summary | None, res)

    def update(self, data: _Updating | None = None, **kwargs: Any) -> _Detail:
        data = data if data else self.Updating(**kwargs)
        has_detail, return_model = self._has_detail()
        res = self.request(
            "POST",
            endpoint="update",
            data=data,
            return_model=return_model,  # type: ignore
        )
        return res  # type: ignore

    async def update_async(
        self, data: _Updating | None = None, **kwargs: Any
    ) -> _Summary:
        data = data if data else self.Updating(**kwargs)
        has_detail, return_model = self._has_detail()
        res = await self.request_async(
            "POST",
            endpoint="update",
            data=data,
            return_model=return_model,  # type: ignore
        )
        return res  # type: ignore

    def delete(self, **kwargs: Any) -> None:
        self.request("POST", endpoint="delete", data=kwargs, body_model=self.Deleting)

    async def delete_async(self, **kwargs: Any) -> None:
        await self.request_async(
            "POST", endpoint="delete", data=kwargs, body_model=self.Deleting
        )

    def all(
        self,
        query: _Reading | None = None,
        *,
        page: int | None = None,
        size: int | None = None,
        **kwargs: Any,
    ) -> Page[_Summary]:
        data = query if query else self.Reading(**kwargs)
        res = self.request(
            "POST",
            endpoint=f"{self.plural_path}/read-all",
            data=data,
            page=page,
            size=size,
            return_model=Page[self.Summary],  # type: ignore
        )
        return cast(Page[_Summary], res)

    async def all_async(
        self,
        query: _Reading | None = None,
        *,
        page: int | None = None,
        size: int | None = None,
        **kwargs: Any,
    ) -> Page[_Summary]:
        data = query if query else self.Reading(**kwargs)
        res = await self.request_async(
            "POST",
            endpoint=f"{self.plural_path}/read-all",
            data=data,
            page=page,
            size=size,
            return_model=Page[self.Summary],  # type: ignore
        )
        return cast(Page[_Summary], res)

    def all_readable(
        self,
        query: _Reading | None = None,
        **kwargs: Any,
    ) -> Page[_Summary]:
        data = query if query else self.Reading(**kwargs)
        res = self.request(
            "POST",
            endpoint=f"{self.plural_path}/read-all",
            data=data,
            return_model=Page[self.Summary],  # type: ignore
        )
        return cast(Page[_Summary], res)

    async def all_readable_async(
        self,
        query: _Reading | None = None,
        **kwargs: Any,
    ) -> Page[_Summary]:
        data = query if query else self.Reading(**kwargs)
        res = await self.request_async(
            "POST",
            endpoint=f"{self.plural_path}/read-all",
            data=data,
            return_model=Page[self.Summary],  # type: ignore
        )
        return cast(Page[_Summary], res)

    def _raise_and_handle_errors(self, response: httpx.Response) -> None:
        """Translate errors by status. This allows SDK consumers to get the original
        error from Prodigy Teams, not just the status.
        Does nothing if the response was successful
        """
        prefix = self.__class__.__name__
        try:
            super()._raise_and_handle_errors(response)
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            try:
                error_data = e.response.json()
            except json.JSONDecodeError:
                error_data = {}
            detail = error_data.get("detail", "")
            error_code = error_data.get("error")
            # Try legacy format: detail as dict with "type" key
            if not error_code and isinstance(detail, dict):
                error_code = detail.get("type")
            # Map structured error codes to SDK exceptions
            if error_code == "not_found":
                raise getattr(errors, f"{prefix}NotFound", errors.NotFound)(
                    detail=str(detail)
                )
            elif error_code == "forbidden":
                raise getattr(errors, f"{prefix}Forbidden", errors.Forbidden)(
                    detail=str(detail)
                )
            elif error_code == "already_exists":
                raise getattr(errors, f"{prefix}Exists", errors.Exists)(
                    detail=str(detail)
                )
            elif error_code == "invalid_input":
                raise getattr(errors, f"{prefix}Invalid", errors.Invalid)(
                    detail=str(detail)
                )
            elif error_code == "auth_error" or status == 401:
                raise errors.AuthError(detail=str(detail))
            # Fallback to status-code-based mapping
            elif status == 400:
                raise getattr(errors, f"{prefix}Invalid", errors.Invalid)
            elif status == 403:
                raise getattr(errors, f"{prefix}Forbidden", errors.Forbidden)
            elif status == 404:
                raise getattr(errors, f"{prefix}NotFound", errors.NotFound)
            elif status == 422:
                raise getattr(errors, f"{prefix}Invalid", errors.Invalid)(
                    detail=str(detail)
                )
            else:
                raise e

    def _has_detail(self) -> tuple[bool, type[_Summary] | type[_Detail]]:
        if hasattr(self, "Detail"):
            return True, cast(type[_Detail], self.Detail)
        else:
            return False, cast(type[_Detail], self.Summary)


_ReadingLatest = TypeVar("_ReadingLatest", bound=BaseModel)
_ListingLatest = TypeVar("_ListingLatest", bound=BaseModel)


class VersionedModelClient(
    Generic[
        _Creating,
        _Reading,
        _Updating,
        _Deleting,
        _Summary,
        _Detail,
        _ReadingLatest,
        _ListingLatest,
    ],
    ModelClient[
        _Creating,
        _Reading,
        _Updating,
        _Deleting,
        _Summary,
        _Detail,
    ],
):
    Creating: type[_Creating]
    Reading: type[_Reading]
    Updating: type[_Updating]
    Deleting: type[_Deleting]
    Summary: type[_Summary]
    Detail: type[_Detail]
    ReadingLatest: type[_ReadingLatest]
    ListingLatest: type[_ListingLatest]

    def all_latest(self, *, body: _ListingLatest) -> Page[_Summary]:
        res = self.request(
            "POST",
            endpoint="all-latest",
            data=body,
            return_model=Page[self.Summary],  # type: ignore
        )
        return cast(Page[_Summary], res)

    async def all_latest_async(self, *, body: _ListingLatest) -> Page[_Summary]:
        res = await self.request_async(
            "POST",
            endpoint="all-latest",
            data=body,
            return_model=Page[self.Summary],  # type: ignore
        )
        return cast(Page[_Summary], res)  # type: ignore

    def latest(self, *, body: _ReadingLatest) -> _Summary:
        res = self.request(
            "POST",
            endpoint="latest",
            data=body,
            return_model=self.Detail,
        )
        return cast(_Summary, res)

    async def latest_async(self, *, body: _ReadingLatest) -> _Summary:
        res = await self.request_async(
            "POST",
            endpoint="latest",
            data=body,
            return_model=self.Detail,
        )
        return cast(_Summary, res)
