"""Typed errors for the agentrouter Python SDK.

Mirrors the canonical Connect error codes. The :class:`AgentrouterError` carries
the canonical ``code`` (lowercased gRPC status name) so callers can branch
without importing the underlying transport's error type.
"""

from __future__ import annotations

# Canonical lowercase code names -- what Connect-JSON puts in the
# ``"code"`` field of an error envelope.
CANCELLED = "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"

# Codes worth retrying transparently.
RETRYABLE = frozenset({UNAVAILABLE, DEADLINE_EXCEEDED, ABORTED})

# Mapping from HTTP status -> canonical code, used when the server does
# not return a Connect-JSON error envelope.
_HTTP_TO_CODE = {
    400: INVALID_ARGUMENT,
    401: UNAUTHENTICATED,
    403: PERMISSION_DENIED,
    404: NOT_FOUND,
    408: DEADLINE_EXCEEDED,
    409: ABORTED,
    412: FAILED_PRECONDITION,
    413: RESOURCE_EXHAUSTED,
    429: RESOURCE_EXHAUSTED,
    499: CANCELLED,
    500: INTERNAL,
    501: UNIMPLEMENTED,
    503: UNAVAILABLE,
    504: DEADLINE_EXCEEDED,
}


class AgentrouterError(Exception):
    """Raised when an RPC returns a non-OK status.

    Attributes:
        code:    canonical code (e.g. ``"not_found"``, ``"aborted"``).
        message: human-readable server detail.
        reason:  agentrouter-canonical machine-readable reason from the
                 ``reason`` field of the ``google.rpc.ErrorInfo`` detail the
                 platform attaches to every error (e.g. ``"MODEL_NOT_FOUND"``,
                 ``"CROSS_TENANT_DENIED"``). Mirrors the ``reason`` member of
                 the RFC 7807 Problem envelope. Empty string when absent.
        status:  raw HTTP status from the underlying response (when available).
    """

    def __init__(
        self,
        code: str,
        message: str = "",
        *,
        reason: str = "",
        status: int | None = None,
    ):
        super().__init__(message or code)
        self.code = code
        self.message = message
        self.reason = reason
        self.status = status

    def __str__(self) -> str:
        return f"{self.code}: {self.message}" if self.message else self.code

    def is_auth_required(self) -> bool:
        return self.code == UNAUTHENTICATED

    def is_not_found(self) -> bool:
        return self.code == NOT_FOUND

    def is_conflict(self) -> bool:
        return self.code in (ABORTED, ALREADY_EXISTS)

    def is_retryable(self) -> bool:
        return self.code in RETRYABLE


# Backwards-compatibility helpers for callers used to IA's exception type.
FailedPreconditionError = AgentrouterError

# Fully-qualified proto name of google.rpc.ErrorInfo, the detail type the
# platform attaches to every error. On the Connect-JSON wire it appears as a
# ``details[]`` entry: ``{"type": "google.rpc.ErrorInfo", "value": "<base64
# protobuf>", "debug": {"reason": "...", ...}}``.
_ERROR_INFO_TYPE = "google.rpc.ErrorInfo"


def _read_proto_field1_string(data: bytes) -> str:
    """Decode field 1 (a length-delimited string) from a protobuf message.

    A minimal reader for ``google.rpc.ErrorInfo``, whose ``reason`` is field 1
    (``string``). Other fields are skipped; malformed input yields "". This
    avoids a hard dependency on googleapis-common-protos for the one field the
    SDK surfaces.
    """
    pos, n = 0, len(data)
    while pos < n:
        # tag = (field_number << 3) | wire_type, as a varint.
        tag = 0
        shift = 0
        while pos < n:
            b = data[pos]
            pos += 1
            tag |= (b & 0x7F) << shift
            if not (b & 0x80):
                break
            shift += 7
        else:
            return ""
        field_no, wire_type = tag >> 3, tag & 0x07
        if field_no == 1 and wire_type == 2:  # length-delimited
            length = 0
            shift = 0
            while pos < n:
                b = data[pos]
                pos += 1
                length |= (b & 0x7F) << shift
                if not (b & 0x80):
                    break
                shift += 7
            else:
                return ""
            return data[pos : pos + length].decode("utf-8", errors="replace")
        # Skip any other field by its wire type.
        if wire_type == 0:  # varint
            while pos < n and (data[pos] & 0x80):
                pos += 1
            pos += 1
        elif wire_type == 2:  # length-delimited
            length = 0
            shift = 0
            while pos < n:
                b = data[pos]
                pos += 1
                length |= (b & 0x7F) << shift
                if not (b & 0x80):
                    break
                shift += 7
            pos += length
        elif wire_type == 5:  # 32-bit
            pos += 4
        elif wire_type == 1:  # 64-bit
            pos += 8
        else:
            return ""  # groups / unknown -- give up rather than misparse
    return ""


def _reason_from_envelope(envelope: dict) -> str:
    """Extract the google.rpc.ErrorInfo ``reason`` from a Connect-JSON error.

    Prefers the JSON ``debug`` representation connect-go emits for the detail;
    falls back to decoding the base64 ``value`` (binary ErrorInfo) so the
    reason is still recovered if ``debug`` is omitted. Returns "" when no
    ErrorInfo detail is present or it cannot be decoded.
    """
    import base64

    details = envelope.get("details")
    if not isinstance(details, list):
        return ""
    for detail in details:
        if not isinstance(detail, dict) or detail.get("type") != _ERROR_INFO_TYPE:
            continue
        debug = detail.get("debug")
        if isinstance(debug, dict):
            reason = debug.get("reason")
            if isinstance(reason, str) and reason:
                return reason
        value = detail.get("value")
        if isinstance(value, str) and value:
            try:
                raw = base64.b64decode(value)
            except (ValueError, TypeError):
                continue
            reason = _read_proto_field1_string(raw)
            if reason:
                return reason
    return ""


def from_http_response(status: int, body: bytes) -> AgentrouterError:
    """Translate a non-OK HTTP response into a :class:`AgentrouterError`.

    Tries to parse a Connect-JSON error envelope first; falls back to the
    canonical-code mapping for the HTTP status. When the envelope carries a
    ``google.rpc.ErrorInfo`` detail its ``reason`` is surfaced on the error.
    """
    import json

    code = _HTTP_TO_CODE.get(status, UNKNOWN)
    message = ""
    reason = ""
    try:
        envelope = json.loads(body)
        if isinstance(envelope, dict):
            code = str(envelope.get("code", code)) or code
            message = str(envelope.get("message", ""))
            reason = _reason_from_envelope(envelope)
    except (ValueError, TypeError):
        message = body.decode("utf-8", errors="replace")[:200]
    return AgentrouterError(code, message, reason=reason, status=status)
