"""Provider-specific exception mapping and user-visible diagnostics."""

import json
from dataclasses import dataclass
from typing import Any

import httpx
import openai

from config.constants import PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES
from core.anthropic import get_user_facing_error_message
from providers.exceptions import (
    APIError,
    AuthenticationError,
    InvalidRequestError,
    OverloadedError,
    RateLimitError,
)
from providers.rate_limit import GlobalRateLimiter

_BODY_ATTR = "_fcc_provider_error_body"
_BODY_TRUNCATED_ATTR = "_fcc_provider_error_body_truncated"


@dataclass(frozen=True)
class ProviderErrorDetail:
    """Structured upstream error detail surfaced directly to users."""

    status_code: int | None = None
    body_text: str | None = None
    exception_text: str | None = None
    error_type_hint: str | None = None
    body_truncated: bool = False


def attach_provider_error_body(
    exc: Exception, body: bytes | str, *, truncated: bool = False
) -> None:
    """Attach a streamed HTTP error body to an exception for later formatting."""
    setattr(exc, _BODY_ATTR, body)
    setattr(exc, _BODY_TRUNCATED_ATTR, truncated)


def _status_code_from_exception(exc: Exception) -> int | None:
    status = getattr(exc, "status_code", None)
    if isinstance(status, int):
        return status
    response = getattr(exc, "response", None)
    response_status = getattr(response, "status_code", None)
    if isinstance(response_status, int):
        return response_status
    return None


def _body_from_response(exc: Exception) -> Any:
    response = getattr(exc, "response", None)
    if response is None:
        return None
    try:
        return response.json()
    except ValueError, RuntimeError:
        pass
    try:
        return response.text
    except httpx.ResponseNotRead, RuntimeError:
        return None


def _normalize_body_text(body: Any) -> str | None:
    if body is None:
        return None
    if isinstance(body, bytes):
        text = body.decode("utf-8", errors="replace")
    elif isinstance(body, str):
        text = body
    else:
        try:
            return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
        except TypeError:
            text = str(body)
    stripped = text.strip()
    if not stripped:
        return None
    try:
        parsed = json.loads(stripped)
    except ValueError:
        return stripped
    return json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))


def _cap_text_bytes(text: str, max_bytes: int) -> tuple[str, bool]:
    encoded = text.encode("utf-8", errors="replace")
    if len(encoded) <= max_bytes:
        return text, False
    capped = encoded[:max_bytes].decode("utf-8", errors="replace")
    return f"{capped}\n... [truncated after {max_bytes} bytes]", True


def _error_type_hint_from_body(body: Any, body_text: str | None) -> str | None:
    parsed = body
    if isinstance(parsed, bytes):
        text = parsed.decode("utf-8", errors="replace")
        try:
            parsed = json.loads(text)
        except ValueError:
            parsed = None
    elif isinstance(parsed, str):
        try:
            parsed = json.loads(parsed)
        except ValueError:
            parsed = None
    if isinstance(parsed, dict):
        error = parsed.get("error")
        if isinstance(error, dict):
            for key in ("type", "code"):
                value = error.get(key)
                if isinstance(value, str) and value.strip():
                    return value.strip()
        for key in ("type", "code"):
            value = parsed.get(key)
            if isinstance(value, str) and value.strip():
                return value.strip()
    if (
        body_text
        and "model" in body_text.lower()
        and "unsupported" in body_text.lower()
    ):
        return "upstream_model_error"
    return None


def extract_provider_error_detail(exc: Exception) -> ProviderErrorDetail:
    """Extract copyable upstream status/body/exception detail from provider errors."""
    raw_body = getattr(exc, _BODY_ATTR, None)
    raw_body_truncated = bool(getattr(exc, _BODY_TRUNCATED_ATTR, False))
    if raw_body is None:
        raw_body = getattr(exc, "body", None)
    if raw_body is None:
        raw_body = _body_from_response(exc)

    body_text = _normalize_body_text(raw_body)
    display_truncated = raw_body_truncated
    if body_text is not None:
        body_text, cap_truncated = _cap_text_bytes(
            body_text, PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES
        )
        display_truncated = display_truncated or cap_truncated

    exception_text = str(exc).strip() or None
    if exception_text is not None:
        exception_text, _ = _cap_text_bytes(
            exception_text, PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES
        )

    return ProviderErrorDetail(
        status_code=_status_code_from_exception(exc),
        body_text=body_text,
        exception_text=exception_text,
        error_type_hint=_error_type_hint_from_body(raw_body, body_text),
        body_truncated=display_truncated,
    )


def _provider_error_category(mapped: Exception) -> str | None:
    error_type = getattr(mapped, "error_type", None)
    if isinstance(error_type, str) and error_type.strip():
        return error_type.strip()
    return None


def _append_request_id_lines(lines: list[str], request_id: str | None) -> None:
    if request_id:
        lines.extend(("", f"Request ID: {request_id}"))


def format_provider_error_message(
    mapped: Exception,
    detail: ProviderErrorDetail,
    *,
    provider_name: str,
    read_timeout_s: float | None,
    request_id: str | None = None,
) -> str:
    """Return a copyable user-facing provider error including upstream detail."""
    stable_message = get_user_facing_error_message(
        mapped, read_timeout_s=read_timeout_s
    )
    has_upstream_detail = detail.status_code is not None or detail.body_text is not None
    if not has_upstream_detail:
        lines = [stable_message]
        if detail.exception_text and detail.exception_text != stable_message:
            lines.extend(("", "Provider exception:", detail.exception_text))
        _append_request_id_lines(lines, request_id)
        return "\n".join(lines)

    if detail.status_code == 405:
        lines = [
            f"Upstream provider {provider_name} rejected the request method "
            "or endpoint (HTTP 405)."
        ]
    elif detail.status_code is not None:
        lines = [
            f"Upstream provider {provider_name} returned HTTP {detail.status_code}."
        ]
    else:
        lines = [f"Upstream provider {provider_name} returned an error."]

    category = detail.error_type_hint or _provider_error_category(mapped)
    if category:
        lines.append(f"Category: {category}")
    if stable_message and stable_message != lines[0]:
        lines.append(f"Mapped message: {stable_message}")

    lines.extend(("", "Upstream error:"))
    if detail.body_text:
        lines.append(detail.body_text)
    else:
        lines.append("(empty upstream error body)")
    if detail.body_truncated and (
        detail.body_text is None
        or f"truncated after {PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES} bytes"
        not in detail.body_text
    ):
        lines.append(
            f"... [truncated after {PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES} bytes]"
        )

    _append_request_id_lines(lines, request_id)
    return "\n".join(lines)


def user_visible_message_for_mapped_provider_error(
    mapped: Exception,
    *,
    provider_name: str,
    read_timeout_s: float | None,
    detail: ProviderErrorDetail | None = None,
    request_id: str | None = None,
) -> str:
    """Return the user-visible string after :func:`map_error` (405 + mapped types)."""
    if detail is not None:
        return format_provider_error_message(
            mapped,
            detail,
            provider_name=provider_name,
            read_timeout_s=read_timeout_s,
            request_id=request_id,
        )
    if getattr(mapped, "status_code", None) == 405:
        return (
            f"Upstream provider {provider_name} rejected the request method "
            "or endpoint (HTTP 405)."
        )
    return get_user_facing_error_message(mapped, read_timeout_s=read_timeout_s)


def map_error(
    e: Exception, *, rate_limiter: GlobalRateLimiter | None = None
) -> Exception:
    """Map OpenAI or HTTPX exception to specific ProviderError.

    Streaming transports should pass their scoped limiter (``self._global_rate_limiter``)
    so reactive 429 handling applies to the correct provider. Tests may omit
    ``rate_limiter`` to use the process-wide singleton.
    """
    message = get_user_facing_error_message(e)
    limiter = rate_limiter or GlobalRateLimiter.get_instance()

    if isinstance(e, openai.AuthenticationError):
        return AuthenticationError(message, raw_error=str(e))
    if isinstance(e, openai.RateLimitError):
        limiter.set_blocked(60)
        return RateLimitError(message, raw_error=str(e))
    if isinstance(e, openai.BadRequestError):
        return InvalidRequestError(message, raw_error=str(e))
    if isinstance(e, openai.InternalServerError):
        raw_message = str(e)
        sdk_status = getattr(e, "status_code", None)
        if "overloaded" in raw_message.lower() or "capacity" in raw_message.lower():
            return OverloadedError(message, raw_error=raw_message)
        if isinstance(sdk_status, int) and 500 <= sdk_status <= 599:
            stable = APIError("_", status_code=sdk_status)
            return APIError(
                get_user_facing_error_message(stable),
                status_code=sdk_status,
                raw_error=str(e),
            )
        return APIError(message, status_code=500, raw_error=str(e))
    if isinstance(e, openai.APIError):
        return APIError(
            message, status_code=getattr(e, "status_code", 500), raw_error=str(e)
        )

    if isinstance(e, httpx.HTTPStatusError):
        status = e.response.status_code
        if status in (401, 403):
            return AuthenticationError(message, raw_error=str(e))
        if status == 429:
            limiter.set_blocked(60)
            return RateLimitError(message, raw_error=str(e))
        if status == 400:
            return InvalidRequestError(message, raw_error=str(e))
        if status >= 500:
            if status in (502, 503, 504):
                return OverloadedError(message, raw_error=str(e))
            return APIError(message, status_code=status, raw_error=str(e))
        return APIError(message, status_code=status, raw_error=str(e))

    return e
