"""The single translation layer between a request's metadata and the spans.

Every relevant field litellm exposes about a request — the user-facing model,
the model actually dispatched to the provider, the deployment, and the caller's
identity (team, key, end-user) — is parsed **once**, here, out of the
``StandardLoggingPayload`` (or a ``UserAPIKeyAuth`` at the auth boundary). Span
data, baggage promotion, and the mappers then read these typed fields instead of
each digging into the raw ``metadata`` / ``hidden_params`` dicts.

Two models live here because a request's identity is known *before* its model
resolution is:

* :class:`RequestIdentity` — team / key / end-user, seeded into Baggage at the
  auth boundary (``from_user_api_key_auth``), before routing has picked a
  deployment. ``provider_model`` is therefore absent from that early seed and is
  only filled in from the payload once the call closes.
* :class:`RequestContext` — the full picture available at close: the resolved
  request vs. provider model split, plus the response model, model group, model
  id, and api base, wrapping the :class:`RequestIdentity`.

The request-vs-provider model split is the subtle part. On the proxy a caller
asks for a *model group* (e.g. ``gpt-4o``) that routes to a concrete deployment
(e.g. ``azure/my-deployment``); the two are distinct and both worth recording.
``StandardLoggingPayload`` exposes them as:

* ``model_group`` — the user-facing name the caller requested.
* ``model`` — already reconstructed (see ``reconstruct_model_name``) to the name
  litellm dispatched to the provider (the deployment, provider-prefixed).
* ``hidden_params.litellm_model_name`` — a secondary source for the dispatched
  model (populated only on some call paths, e.g. files).

So ``gen_ai.request.model`` is the *group* (falling back to the call model on the
SDK path, which has no group), and ``litellm.provider.model`` is the *dispatched*
model. They coincide on the SDK path, which is correct.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Mapping, cast

from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL
from litellm.integrations.otel.model.semconv import resolve_operation
from litellm.integrations.otel.model.utils import as_str

if TYPE_CHECKING:
    from litellm.types.utils import StandardLoggingPayload


@dataclass(frozen=True)
class RequestIdentity:
    call_id: str | None = None
    team_id: str | None = None
    team_alias: str | None = None
    # The team's free-form metadata, carried raw (empty/missing -> None) and
    # filtered to an operator allowlist only at Baggage-promotion time, so an
    # unconfigured deployment never promotes any of it.
    team_metadata: Mapping[str, Any] | None = None
    key_hash: str | None = None
    end_user: str | None = None
    # The model litellm dispatched to the provider. Only known once the call
    # completes (routing has picked a deployment), so it's absent from the
    # auth-time seed and filled only from the payload.
    provider_model: str | None = None
    metadata: Mapping[str, str] = field(default_factory=dict)

    @classmethod
    def from_payload(cls, payload: "StandardLoggingPayload") -> "RequestIdentity":
        """Parse caller identity out of a closed request's payload metadata.

        ``provider_model`` is resolved here too (see :func:`resolve_provider_model`)
        so the identity carried into Baggage labels every span with the dispatched
        model, not just the user-facing one.
        """
        raw_meta = cast(Mapping[str, object], payload.get("metadata") or {})
        metadata = {
            key: str(value)
            for key, value in raw_meta.items()
            if isinstance(value, (str, bool, int, float))
        }
        return cls(
            call_id=as_str(payload.get("litellm_call_id")) or as_str(payload.get("id")),
            # StandardLoggingMetadata's canonical key is ``user_api_key_team_id``;
            # the bare ``team_id`` is a legacy alias and is often empty, so prefer
            # the canonical key and fall back to the alias.
            team_id=as_str(raw_meta.get("user_api_key_team_id"))
            or as_str(raw_meta.get("team_id")),
            team_alias=as_str(raw_meta.get("user_api_key_team_alias"))
            or as_str(raw_meta.get("team_alias")),
            team_metadata=_team_metadata_dict(
                raw_meta.get("user_api_key_team_metadata")
            ),
            key_hash=as_str(raw_meta.get("user_api_key_hash")),
            end_user=as_str(payload.get("end_user"))
            or as_str(raw_meta.get("user_api_key_end_user_id")),
            provider_model=resolve_provider_model(payload),
            metadata=metadata,
        )

    @classmethod
    def from_user_api_key_auth(cls, auth: object) -> "RequestIdentity":
        """Identity from a ``UserAPIKeyAuth`` (duck-typed to keep this module
        free of a proxy import).

        Used in the pre-call hook to seed Baggage early — before any LLM,
        guardrail, or service span is created — so the whole request's spans
        inherit identity, not just the LLM-call span. Metadata sub-keys use the
        ``user_api_key_*`` names that ``baggage.DEFAULT_BAGGAGE_METADATA_KEYS``
        promotes.
        """
        get = lambda name: getattr(auth, name, None)  # noqa: E731
        metadata = {
            meta_key: str(value)
            for meta_key, attr in (
                ("user_api_key_user_id", "user_id"),
                ("user_api_key_org_id", "org_id"),
                ("user_api_key_alias", "key_alias"),
                ("user_api_key_end_user_id", "end_user_id"),
            )
            if (value := get(attr))
        }
        return cls(
            team_id=as_str(get("team_id")),
            team_alias=as_str(get("team_alias")),
            team_metadata=_team_metadata_dict(get("team_metadata")),
            key_hash=as_str(get("api_key")),
            end_user=as_str(get("end_user_id")),
            # ``provider_model`` is unknown at the auth boundary — routing hasn't
            # picked a deployment yet — so it's only populated from the payload.
            metadata=metadata,
        )


@dataclass(frozen=True)
class RequestContext:
    """The fully-resolved view of a closed request, parsed once from the payload.

    ``request_model`` is the user-facing requested model and ``provider_model``
    (on :attr:`identity`) is the model litellm dispatched to the provider; the two
    differ on the proxy (group vs. deployment) and coincide on the SDK path.
    """

    request_model: str
    response_model: str | None
    model_group: str | None
    model_id: str | None
    api_base: str | None
    identity: RequestIdentity

    @property
    def provider_model(self) -> str | None:
        """The dispatched-model name, carried on the identity for Baggage."""
        return self.identity.provider_model

    @classmethod
    def from_standard_logging_payload(
        cls, payload: "StandardLoggingPayload"
    ) -> "RequestContext":
        raw_meta = cast(Mapping[str, object], payload.get("metadata") or {})
        hidden = cast(Mapping[str, object], payload.get("hidden_params") or {})
        raw_response = payload.get("response")
        response = cast(
            Mapping[str, object], raw_response if isinstance(raw_response, dict) else {}
        )
        model_group = as_str(payload.get("model_group")) or as_str(
            raw_meta.get("model_group")
        )
        return cls(
            # The user asked for the group; fall back to the call model on the SDK
            # path, which has no group. Empty string (never None) so the span name
            # builder and the mapper see a plain string.
            request_model=model_group or as_str(payload.get("model")) or "",
            response_model=as_str(response.get("model")),
            model_group=model_group,
            model_id=as_str(payload.get("model_id"))
            or _model_info_id(raw_meta.get("model_info")),
            api_base=as_str(payload.get("api_base")) or as_str(hidden.get("api_base")),
            identity=RequestIdentity.from_payload(payload),
        )


# --- live-callback kwargs parsing ------------------------------------------- #
#
# The model and helpers below parse the *live* callback ``kwargs`` god object (and
# the raw pre/post-call ``data`` dicts) — the untyped request state that reaches a
# ``CustomLogger`` before, or instead of, a ``StandardLoggingPayload``. They live
# here, with the payload/auth parsers, so every read out of a request's raw dicts
# is in one place rather than scattered across the ``CustomLogger``.


@dataclass(frozen=True)
class LLMCallEvent:
    """The typed view of the live callback ``kwargs`` (``model_call_details``).

    litellm hands every callback an untyped ``kwargs`` god object. The fields the
    OTel logger needs out of it are parsed **once**, here, so the ``CustomLogger``
    reads typed attributes instead of digging into the dict at each boundary.
    """

    # The ``litellm_call_id`` correlating ``pre_call`` with the close callback.
    # Present in ``model_call_details`` at ``pre_call`` and in both the kwargs and
    # the ``standard_logging_object`` at success/failure, so it's a stable key for
    # the open-call carrier — no back-reference to the logging object required (the
    # object isn't reachable from the callback kwargs at ``pre_call`` time).
    call_id: str | None
    # The ``StandardLoggingPayload`` carried on a success/failure callback; ``None``
    # at ``pre_call``, or when the call closed before any payload materialized (so
    # there is nothing to stamp on the span).
    payload: "StandardLoggingPayload | None"
    # The ``standard_callback_dynamic_params`` routing the call to a per-tenant
    # tracer (its own exporter/endpoint), or ``None`` when the call isn't scoped.
    dynamic_params: Any
    # True for synthetic proxy-gate logs (auth / rate-limit rejections): they fire
    # the ``pre_call`` hook but never made an upstream call, so they get no span.
    is_no_upstream_call: bool
    # A best-effort ``"{operation} {model}"`` name known at ``pre_call`` time. The
    # span is renamed from the typed payload at close (``finish_span``); this only
    # needs to be reasonable for a span that never gets closed (a leak).
    provisional_span_name: str

    @classmethod
    def from_dict(cls, kwargs: Mapping[str, Any]) -> "LLMCallEvent":
        raw_payload = kwargs.get("standard_logging_object")
        payload = cast("StandardLoggingPayload", raw_payload) if raw_payload else None
        operation = resolve_operation(as_str(kwargs.get("call_type")))
        model = as_str(kwargs.get("model")) or ""
        return cls(
            call_id=_call_id(payload, kwargs),
            payload=payload,
            dynamic_params=kwargs.get("standard_callback_dynamic_params"),
            is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)),
            provisional_span_name=f"{operation.value} {model}".strip(),
        )


def _call_id(
    payload: "StandardLoggingPayload | None", kwargs: Mapping[str, Any]
) -> str | None:
    """The call id from the payload (when closed) or the bare kwargs (at pre_call)."""
    if payload is not None:
        call_id = as_str(payload.get("litellm_call_id")) or as_str(payload.get("id"))
        if call_id:
            return call_id
    return as_str(kwargs.get("litellm_call_id"))


def model_from_request_data(data: object) -> str | None:
    """The user-facing ``model`` from a pre-call ``data`` dict (``None`` if absent).

    Read at the auth boundary to label early Baggage before routing has resolved
    a deployment; ``data`` is duck-typed since it arrives untyped from the proxy.
    """
    if isinstance(data, Mapping):
        return as_str(data.get("model"))
    return None


def resolve_provider_model(payload: "StandardLoggingPayload") -> str | None:
    """The model litellm dispatched to the provider, from the payload.

    Prefers the explicit ``hidden_params.litellm_model_name`` (set on call paths
    that know it, e.g. files), then the top-level ``model`` — which
    ``reconstruct_model_name`` has already resolved to the deployment's
    provider-prefixed name. Returns ``None`` only when neither is present.
    """
    raw_meta = cast(Mapping[str, object], payload.get("metadata") or {})
    hidden = cast(Mapping[str, object], payload.get("hidden_params") or {})
    return (
        # ``deployment`` survives only on paths that don't strip it from metadata;
        # harmless (and most precise) to prefer it when present.
        as_str(raw_meta.get("deployment"))
        or as_str(hidden.get("litellm_model_name"))
        or as_str(payload.get("model"))
    )


def _model_info_id(model_info: object) -> str | None:
    """The deployment id from a ``metadata.model_info`` sub-dict, if present."""
    if isinstance(model_info, Mapping):
        return as_str(model_info.get("id"))
    return None


def _team_metadata_dict(value: object) -> Mapping[str, Any] | None:
    """The team's free-form metadata as a raw mapping, or ``None`` when missing
    or empty.

    Carried raw on the identity and filtered to an operator allowlist only at
    Baggage-promotion time (see ``baggage.promoted_baggage``), so an empty case
    is dropped rather than carrying a useless ``{}``.
    """
    if isinstance(value, Mapping) and value:
        return dict(value)
    return None
