"""Friendly runtime-error rewriter for OpenAI-compatible LLM endpoints.

When LM Studio, vLLM, llama.cpp server, OpenRouter, or any other OpenAI-compatible
provider fails at request time, the underlying `openai.*` / `httpx.*` exception
typically does not name the provider, configured base URL, or model in its
message. This helper walks the cause chain to find the root SDK exception and
produces a message that includes that context, while preserving the existing
``Error type: <code>`` token convention used downstream in research_service.py
and ErrorReporter.

The helper deliberately does NOT introduce a new exception class -- the rest of
the pipeline is string-based today and tokens are how Sites B and C
communicate.
"""

from __future__ import annotations

from urllib.parse import urlparse, urlunparse

import httpx
import openai


def _strip_credentials(base_url: str | None) -> str:
    """Return ``base_url`` with any userinfo (``user:password@``) removed.

    Users sometimes embed an API key directly in the base URL (e.g.
    ``https://user:key@host/v1``). We must never echo that back to the UI or
    logs. Falsy / unparseable inputs are returned as ``"<unknown>"``.
    """
    if not base_url:
        return "<unknown>"
    try:
        parsed = urlparse(base_url)
    except Exception:
        return "<unknown>"
    if not parsed.netloc:
        return base_url
    host = parsed.hostname or ""
    if parsed.port:
        host = f"{host}:{parsed.port}"
    return urlunparse(parsed._replace(netloc=host)) or "<unknown>"


def _walk_cause(exc: BaseException) -> BaseException:
    """Walk ``__cause__`` / ``__context__`` to find the deepest non-wrapper
    exception, with a cycle guard.

    LangChain often wraps the underlying ``openai.*`` exception in a generic
    ``Exception`` or ``RuntimeError``; we need the original class to dispatch
    on. If the walk doesn't find anything more specific, the original is
    returned.
    """
    seen: set[int] = set()
    cur: BaseException | None = exc
    deepest: BaseException = exc
    while cur is not None and id(cur) not in seen:
        seen.add(id(cur))
        deepest = cur
        cur = cur.__cause__ or cur.__context__
    return deepest


_DOCKER_HINT = (
    " (from inside Docker, localhost is the container itself -- use "
    "host.docker.internal, the host IP, or run with --network=host to share "
    "the host network namespace)"
)


def _dispatch(
    root: BaseException, provider: str, base_url: str, model: str
) -> tuple[str, str]:
    """Map a root exception to ``(error_code_token, friendly_message)``.

    Returns ``("openai_unknown", <generic message>)`` for any exception we don't
    recognise; callers should still suffix the original ``exc!s`` so no detail
    is lost.
    """

    def _is(cls_name: str) -> bool:
        cls = getattr(openai, cls_name, None)
        return cls is not None and isinstance(root, cls)

    # Timeout family -- must be checked BEFORE APIConnectionError because
    # openai.APITimeoutError subclasses APIConnectionError in openai>=1.x.
    if _is("APITimeoutError") or isinstance(root, httpx.ReadTimeout):
        return (
            "openai_timeout",
            f"{provider} at {base_url} did not respond in time. The server "
            "may be loading a model or overloaded.",
        )

    # Connection-refused / network-unreachable family
    if _is("APIConnectionError") or isinstance(root, httpx.ConnectError):
        return (
            "openai_connection_refused",
            f"Cannot reach {provider} at {base_url}. Check that the server "
            f"is running and the URL is correct.{_DOCKER_HINT}",
        )

    # Auth
    if _is("AuthenticationError"):
        return (
            "openai_auth",
            f"{provider} rejected the API key for {base_url}. Local servers "
            "usually accept any non-empty key; remote providers need a valid "
            "key.",
        )

    # Permission denied
    if _is("PermissionDeniedError"):
        return (
            "openai_permission_denied",
            f"{provider} denied access at {base_url} for model '{model}'.",
        )

    # Model not found (404 from OpenAI-compatible servers)
    if _is("NotFoundError"):
        return (
            "openai_model_not_found",
            f"{provider} at {base_url} does not have model '{model}'. Pick a "
            f"model currently loaded in {provider}.",
        )

    # Bad request (400)
    if _is("BadRequestError"):
        return (
            "openai_bad_request",
            f"{provider} rejected the request to {base_url} for model "
            f"'{model}'.",
        )

    # Any other openai SDK error
    if _is("APIError"):
        return (
            "openai_unknown",
            f"{provider} at {base_url} returned an error for model '{model}'.",
        )

    # Not an openai/httpx class we recognise -- caller should fall through.
    return (
        "openai_unknown",
        f"{provider} at {base_url} returned an error for model '{model}'.",
    )


def is_openai_compat_runtime_error(exc: BaseException) -> bool:
    """Return True iff ``exc`` (or any exception in its cause chain) is an
    ``openai.*`` / ``httpx.*`` runtime error we can rewrite.

    Used at Site B in research_service.py to decide whether to call
    :func:`friendly_openai_compatible_error` instead of the existing
    string-keyword branches.
    """
    root = _walk_cause(exc)
    if isinstance(root, openai.APIError):
        return True
    if isinstance(root, (httpx.ConnectError, httpx.ReadTimeout)):
        return True
    return False


def friendly_openai_compatible_error(
    exc: BaseException,
    *,
    provider: str,
    base_url: str | None,
    model: str | None,
) -> str:
    """Build a user-facing error message for an OpenAI-compatible failure.

    Returns a string of the form::

        <friendly message> (Error type: <code>) | Details: <original exc>

    where ``<code>`` is one of the ``openai_*`` tokens that Site C and
    :class:`~local_deep_research.error_handling.error_reporter.ErrorReporter`
    recognise. The original exception text is always preserved in the
    ``Details:`` suffix so the user (and our logs) never lose information.
    """
    redacted = _strip_credentials(base_url)
    model_repr = model or "<unspecified>"
    provider_repr = provider or "<unknown provider>"
    root = _walk_cause(exc)
    code, friendly = _dispatch(root, provider_repr, redacted, model_repr)
    return f"{friendly} (Error type: {code}) | Details: {exc!s}"
