import gzip
import inspect
import json
import logging
import struct
import typing

from httpcore import (
    ConnectionPool,
    AsyncConnectionPool,
    RemoteProtocolError,
    Response,
)
from enum import Flag, Enum
from typing import Callable, Optional, Dict, Any, Generator, Tuple
from google.protobuf import json_format


class EnvelopeFlags(Flag):
    compressed = 0b00000001
    end_stream = 0b00000010


class Code(Enum):
    canceled = "canceled"
    unknown = "unknown"
    invalid_argument = "invalid_argument"
    deadline_exceeded = "deadline_exceeded"
    not_found = "not_found"
    already_exists = "already_exists"
    permission_denied = "permission_denied"
    resource_exhausted = "resource_exhausted"
    failed_precondition = "failed_precondition"
    aborted = "aborted"
    out_of_range = "out_of_range"
    unimplemented = "unimplemented"
    internal = "internal"
    unavailable = "unavailable"
    data_loss = "data_loss"
    unauthenticated = "unauthenticated"


def make_error_from_http_code(http_code: int):
    error_code_map = {
        400: Code.invalid_argument,
        401: Code.unauthenticated,
        403: Code.permission_denied,
        404: Code.not_found,
        409: Code.already_exists,
        413: Code.resource_exhausted,
        429: Code.resource_exhausted,
        499: Code.canceled,
        500: Code.internal,
        501: Code.unimplemented,
        502: Code.unavailable,
        503: Code.unavailable,
        504: Code.deadline_exceeded,
        505: Code.unimplemented,
    }

    return error_code_map.get(http_code, Code.unknown)


class ConnectException(Exception):
    def __init__(self, status: Code, message: str):
        self.status = status
        self.message = message


envelope_header_length = 5
envelope_header_pack = ">BI"


def encode_envelope(*, flags: EnvelopeFlags, data):
    return encode_envelope_header(flags=flags.value, data=data) + data


def encode_envelope_header(*, flags, data):
    return struct.pack(envelope_header_pack, flags, len(data))


def decode_envelope_header(header):
    flags, data_len = struct.unpack(envelope_header_pack, header)
    return EnvelopeFlags(flags), data_len


def error_for_response(http_resp: Response):
    try:
        error = json.loads(http_resp.content)
        return make_error(error)
    except (json.decoder.JSONDecodeError, KeyError):
        error = {"code": http_resp.status, "message": http_resp.content.decode("utf-8")}
        return make_error(error)


def make_error(error):
    status = None
    try:
        code_value = error.get("code")
        # return error code from http status code
        if isinstance(code_value, int):
            status = make_error_from_http_code(code_value)
        else:
            status = Code(code_value)
    except (KeyError, ValueError):
        status = Code.unknown

    return ConnectException(status, error.get("message", ""))


def _sync_retry(func, exc, retries):
    def retry(*args, **kwargs):
        for _ in range(retries):
            try:
                return func(*args, **kwargs)
            except exc:
                continue

        return func(*args, **kwargs)

    return retry


def _async_retry(func, exc, retries):
    async def retry(*args, **kwargs):
        for _ in range(retries):
            try:
                return await func(*args, **kwargs)
            except exc:
                continue

        return await func(*args, **kwargs)

    return retry


def _retry(exc: typing.Type[Exception], retries: int):
    def decorator(func):
        if inspect.iscoroutinefunction(func):
            return _async_retry(func, exc, retries)

        return _sync_retry(func, exc, retries)

    return decorator


class GzipCompressor:
    name = "gzip"
    decompress = gzip.decompress
    compress = gzip.compress


class JSONCodec:
    content_type = "json"

    @staticmethod
    def encode(msg):
        return json_format.MessageToJson(msg).encode("utf8")

    @staticmethod
    def decode(data, *, msg_type):
        msg = msg_type()
        json_format.Parse(data.decode("utf8"), msg, ignore_unknown_fields=True)
        return msg


class ProtobufCodec:
    content_type = "proto"

    @staticmethod
    def encode(msg):
        return msg.SerializeToString()

    @staticmethod
    def decode(data, *, msg_type):
        msg = msg_type()
        msg.ParseFromString(data)
        return msg


class Client:
    def __init__(
        self,
        *,
        pool: Optional[ConnectionPool] = None,
        async_pool: Optional[AsyncConnectionPool] = None,
        url: str,
        response_type,
        compressor=None,
        json: Optional[bool] = False,
        headers: Optional[Dict[str, str]] = None,
        logger: Optional[logging.Logger] = None,
    ):
        if headers is None:
            headers = {}

        self.pool = pool
        self.async_pool = async_pool
        self.url = url
        self._codec = JSONCodec if json else ProtobufCodec
        self._response_type = response_type
        self._compressor = compressor
        self._headers = headers
        self._connection_retries = 3
        self._logger = logger

    def _log_request(self) -> None:
        if self._logger is not None:
            self._logger.info(f"Request: POST {self.url}")

    def _log_response(self, status: int) -> None:
        if self._logger is None:
            return
        if status >= 400:
            self._logger.error(f"Response: {status} {self.url}")
        else:
            self._logger.info(f"Response: {status} {self.url}")

    def _log_stream_message(self) -> None:
        if self._logger is not None:
            self._logger.debug(f"Response stream: {self.url}")

    def _prepare_unary_request(
        self,
        req,
        request_timeout=None,
        headers: Optional[dict] = None,
        **opts,
    ) -> dict:
        data = self._codec.encode(req)

        if self._compressor is not None:
            data = self._compressor.compress(data)

        if headers is None:
            headers = {}

        extensions = (
            None
            if request_timeout is None
            else {
                "timeout": {
                    "connect": request_timeout,
                    "pool": request_timeout,
                    "read": request_timeout,
                    "write": request_timeout,
                }
            }
        )

        return {
            "method": "POST",
            "url": self.url,
            "content": data,
            "extensions": extensions,
            "headers": {
                **self._headers,
                **headers,
                **opts.get("headers", {}),
                "connect-protocol-version": "1",
                "content-encoding": (
                    "identity" if self._compressor is None else self._compressor.name
                ),
                "content-type": f"application/{self._codec.content_type}",
            },
        }

    def _process_unary_response(
        self,
        http_resp: Response,
    ):
        self._log_response(http_resp.status)

        if http_resp.status != 200:
            raise error_for_response(http_resp)

        content = http_resp.content

        if self._compressor is not None:
            content = self._compressor.decompress(content)

        return self._codec.decode(
            content,
            msg_type=self._response_type,
        )

    @_retry(RemoteProtocolError, 3)
    async def acall_unary(
        self,
        req,
        request_timeout=None,
        headers: Optional[dict] = None,
        **opts,
    ):
        if self.async_pool is None:
            raise ValueError("async_pool is required")

        req_data = self._prepare_unary_request(
            req,
            request_timeout,
            headers,
            **opts,
        )

        self._log_request()
        res = await self.async_pool.request(**req_data)
        return self._process_unary_response(res)

    @_retry(RemoteProtocolError, 3)
    def call_unary(
        self,
        req,
        request_timeout=None,
        headers: Optional[dict] = None,
        **opts,
    ):
        if self.pool is None:
            raise ValueError("pool is required")

        req_data = self._prepare_unary_request(
            req,
            request_timeout,
            headers,
            **opts,
        )

        self._log_request()
        res = self.pool.request(**req_data)
        return self._process_unary_response(res)

    def _create_stream_timeout(self, timeout: Optional[float]):
        if timeout:
            return {"connect-timeout-ms": str(int(timeout * 1000))}
        return {}

    def _prepare_server_stream_request(
        self,
        req,
        request_timeout=None,
        timeout=None,
        headers: Optional[dict] = None,
        **opts,
    ) -> dict:
        headers = headers or {}
        data = self._codec.encode(req)
        flags = EnvelopeFlags(0)

        # `request_timeout` bounds connection setup and request sending, but NOT the
        # stream read: a stream can stay open for the whole command `timeout` (minutes
        # or, when disabled, indefinitely), so we deliberately leave `read` unset.
        # The command `timeout` is enforced server-side via the `connect-timeout-ms`
        # header (see `_create_stream_timeout`), which returns a clean `deadline_exceeded`.
        # This mirrors the JS SDK, which has no per-chunk read timeout either — setting
        # `read` to the command `timeout` would race that server response and surface a
        # raw transport `ReadTimeout` instead.
        timeout_ext = {}
        if request_timeout is not None:
            timeout_ext["connect"] = request_timeout
            timeout_ext["pool"] = request_timeout
            timeout_ext["write"] = request_timeout
        extensions = {"timeout": timeout_ext} if timeout_ext else None

        if self._compressor is not None:
            data = self._compressor.compress(data)
            flags |= EnvelopeFlags.compressed

        stream_timeout = self._create_stream_timeout(timeout)

        return {
            "method": "POST",
            "url": self.url,
            "content": encode_envelope(
                flags=flags,
                data=data,
            ),
            "extensions": extensions,
            "headers": {
                **self._headers,
                **headers,
                **opts.get("headers", {}),
                **stream_timeout,
                "connect-protocol-version": "1",
                "connect-content-encoding": (
                    "identity" if self._compressor is None else self._compressor.name
                ),
                "content-type": f"application/connect+{self._codec.content_type}",
            },
        }

    # Note: no retry here — generator functions don't execute until iterated, so a
    # call-level retry never fires, and retrying mid-stream would replay delivered events.
    async def acall_server_stream(
        self,
        req,
        request_timeout=None,
        timeout=None,
        headers: Optional[dict] = None,
        **opts,
    ):
        if self.async_pool is None:
            raise ValueError("async_pool is required")

        req_data = self._prepare_server_stream_request(
            req,
            request_timeout,
            timeout,
            headers,
            **opts,
        )

        parser = ServerStreamParser(
            decode=self._codec.decode,
            response_type=self._response_type,
        )

        self._log_request()
        async with self.async_pool.stream(**req_data) as http_resp:
            if http_resp.status != 200:
                self._log_response(http_resp.status)
                await http_resp.aread()
                raise error_for_response(http_resp)

            async for chunk in http_resp.aiter_stream():
                for parsed in parser.parse(chunk):
                    self._log_stream_message()
                    yield parsed

    def call_server_stream(
        self,
        req,
        request_timeout=None,
        timeout=None,
        headers: Optional[dict] = None,
        **opts,
    ):
        if self.pool is None:
            raise ValueError("pool is required")

        req_data = self._prepare_server_stream_request(
            req,
            request_timeout,
            timeout,
            headers,
            **opts,
        )

        parser = ServerStreamParser(
            decode=self._codec.decode,
            response_type=self._response_type,
        )

        self._log_request()
        with self.pool.stream(**req_data) as http_resp:
            if http_resp.status != 200:
                self._log_response(http_resp.status)
                http_resp.read()
                raise error_for_response(http_resp)

            for chunk in http_resp.iter_stream():
                for parsed in parser.parse(chunk):
                    self._log_stream_message()
                    yield parsed

    def call_client_stream(self, req, **opts):
        raise NotImplementedError("client stream not supported")

    def acall_client_stream(self, req, **opts):
        raise NotImplementedError("client stream not supported")

    def call_bidi_stream(self, req, **opts):
        raise NotImplementedError("bidi stream not supported")

    def acall_bidi_stream(self, req, **opts):
        raise NotImplementedError("bidi stream not supported")


DataLen = int


class ServerStreamParser:
    def __init__(
        self,
        decode: Callable,
        response_type: Any,
    ):
        self.decode = decode
        self.response_type = response_type

        self.buffer: bytes = b""
        self._header: Optional[tuple[EnvelopeFlags, DataLen]] = None

    def shift_buffer(self, size: int):
        buffer = self.buffer[:size]
        self.buffer = self.buffer[size:]
        return buffer

    @property
    def header(self) -> Tuple[EnvelopeFlags, DataLen]:
        if self._header:
            return self._header

        header_data = self.shift_buffer(envelope_header_length)
        self._header = decode_envelope_header(header_data)

        return self._header

    @header.deleter
    def header(self):
        self._header = None

    def parse(self, chunk: bytes) -> Generator[Any, None, None]:
        self.buffer += chunk

        # Once the header is consumed, the remaining payload can be shorter
        # than the header length, so only require a full header when we still
        # need to read one.
        while self._header is not None or len(self.buffer) >= envelope_header_length:
            flags, data_len = self.header

            if data_len > len(self.buffer):
                break

            data = self.shift_buffer(data_len)

            if EnvelopeFlags.end_stream in flags:
                data = json.loads(data)

                if "error" in data:
                    raise make_error(data["error"])

                return

            yield self.decode(data, msg_type=self.response_type)
            del self.header
