"""Canonical OpenTelemetry GenAI semantic-convention mapper (always active).

Owns the attribute schema for every span kind the engine emits — LLM call,
guardrail, and service — so the engine itself never references attribute keys.

Each span kind declares its schema as a flat ``attribute key -> extractor``
table: one lambda per mapping operation, applied against the typed span data.
"""

from typing import Callable

from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
from litellm.integrations.otel.mappers.utils import (
    collect,
    drop_none,
    output_messages,
    serialize_messages,
)
from litellm.integrations.otel.model.payloads import (
    GuardrailSpanData,
    LLMCallSpanData,
    MCPToolCallSpanData,
    ServiceSpanData,
    ToolDefinition,
)
from litellm.integrations.otel.model.semconv import (
    DB,
    MCP,
    Error,
    GenAI,
    LiteLLM,
    Server,
)
from litellm.integrations.otel.model.spans import db_system


class GenAIMapper:

    _LLM_CALL_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
        GenAI.OPERATION_NAME: lambda d: d.operation.value,
        GenAI.PROVIDER_NAME: lambda d: d.provider or None,
        GenAI.REQUEST_MODEL: lambda d: d.request_model or None,
        GenAI.REQUEST_TEMPERATURE: lambda d: d.request_params.temperature,
        GenAI.REQUEST_TOP_P: lambda d: d.request_params.top_p,
        GenAI.REQUEST_TOP_K: lambda d: d.request_params.top_k,
        GenAI.REQUEST_MAX_TOKENS: lambda d: d.request_params.max_tokens,
        GenAI.REQUEST_FREQUENCY_PENALTY: lambda d: d.request_params.frequency_penalty,
        GenAI.REQUEST_PRESENCE_PENALTY: lambda d: d.request_params.presence_penalty,
        GenAI.REQUEST_STOP_SEQUENCES: lambda d: (
            list(d.request_params.stop_sequences)
            if d.request_params.stop_sequences
            else None
        ),
        GenAI.REQUEST_SEED: lambda d: d.request_params.seed,
        GenAI.INPUT_MESSAGES: lambda d: serialize_messages(d.messages_in),
        GenAI.OUTPUT_MESSAGES: lambda d: serialize_messages(output_messages(d)),
        GenAI.RESPONSE_MODEL: lambda d: d.response_model,
        GenAI.RESPONSE_ID: lambda d: d.response_id,
        GenAI.RESPONSE_FINISH_REASONS: lambda d: (
            list(d.finish_reasons) if d.finish_reasons else None
        ),
        GenAI.USAGE_INPUT_TOKENS: lambda d: d.usage.input_tokens,
        GenAI.USAGE_OUTPUT_TOKENS: lambda d: d.usage.output_tokens,
        Error.TYPE: lambda d: d.error.error_type if d.error else None,
        Server.ADDRESS: lambda d: d.server.address if d.server else None,
        Server.PORT: lambda d: d.server.port if d.server else None,
        LiteLLM.CALL_ID: lambda d: d.identity.call_id or None,
        # The provider/underlying model is only known once routing has picked a
        # deployment, so it can't ride identity Baggage (seeded at auth, before
        # routing) onto the boundary-born LLM span — stamp it directly here.
        LiteLLM.PROVIDER_MODEL: lambda d: d.identity.provider_model or None,
        f"{LiteLLM.COST_PREFIX}total": lambda d: d.response_cost,
        # Per-component cost breakdown (from the StandardLoggingPayload
        # ``cost_breakdown``). Each component is omitted when the source didn't
        # report it, so spans stay sparse rather than carrying zeros.
        f"{LiteLLM.COST_PREFIX}input": lambda d: d.cost.input,
        f"{LiteLLM.COST_PREFIX}output": lambda d: d.cost.output,
        f"{LiteLLM.COST_PREFIX}cache_read": lambda d: d.cost.cache_read,
        f"{LiteLLM.COST_PREFIX}cache_creation": lambda d: d.cost.cache_creation,
        f"{LiteLLM.COST_PREFIX}tool_usage": lambda d: d.cost.tool_usage,
        f"{LiteLLM.COST_PREFIX}original": lambda d: d.cost.original,
        f"{LiteLLM.COST_PREFIX}discount_amount": lambda d: d.cost.discount_amount,
        f"{LiteLLM.COST_PREFIX}discount_percent": lambda d: d.cost.discount_percent,
        f"{LiteLLM.COST_PREFIX}margin_fixed_amount": lambda d: d.cost.margin_fixed_amount,
        f"{LiteLLM.COST_PREFIX}margin_percent": lambda d: d.cost.margin_percent,
        f"{LiteLLM.COST_PREFIX}margin_total_amount": lambda d: d.cost.margin_total_amount,
        LiteLLM.REQUEST_STREAMING: lambda d: d.is_streaming,
    }

    _TOOL_ATTRS: dict[str, Callable[[ToolDefinition], AttrValue | None]] = {
        "name": lambda t: t.name,
        "description": lambda t: t.description or None,
        "parameters": lambda t: t.parameters_json or None,
    }

    _MCP_ATTRS: dict[str, Callable[[MCPToolCallSpanData], AttrValue | None]] = {
        GenAI.OPERATION_NAME: lambda d: d.operation.value,
        MCP.METHOD_NAME: lambda d: d.method,
        MCP.SESSION_ID: lambda d: d.session_id,
        GenAI.TOOL_NAME: lambda d: d.tool_name or None,
        GenAI.TOOL_CALL_ARGUMENTS: lambda d: d.arguments_json,
        GenAI.TOOL_CALL_RESULT: lambda d: d.result_json,
        LiteLLM.MCP_SERVER_NAME: lambda d: d.server_name,
        LiteLLM.CALL_ID: lambda d: d.identity.call_id or None,
        f"{LiteLLM.COST_PREFIX}total": lambda d: d.response_cost,
    }

    _GUARDRAIL_ATTRS: dict[str, Callable[[GuardrailSpanData], AttrValue | None]] = {
        LiteLLM.GUARDRAIL_NAME: lambda d: d.guardrail_name,
        LiteLLM.GUARDRAIL_MODE: lambda d: d.mode,
        LiteLLM.GUARDRAIL_STATUS: lambda d: d.status,
        LiteLLM.GUARDRAIL_PROVIDER: lambda d: d.provider,
        LiteLLM.GUARDRAIL_ACTION: lambda d: d.action,
        LiteLLM.GUARDRAIL_RESPONSE: lambda d: d.response_json,
        LiteLLM.GUARDRAIL_VIOLATION_CATEGORIES: lambda d: (
            list(d.violation_categories) if d.violation_categories else None
        ),
        LiteLLM.GUARDRAIL_CONFIDENCE_SCORE: lambda d: d.confidence_score,
        LiteLLM.GUARDRAIL_RISK_SCORE: lambda d: d.risk_score,
        LiteLLM.GUARDRAIL_MASKED_ENTITY_COUNT: lambda d: d.masked_entity_count,
        LiteLLM.GUARDRAIL_DURATION: lambda d: d.duration,
        LiteLLM.GUARDRAIL_ID: lambda d: d.guardrail_id,
        LiteLLM.GUARDRAIL_POLICY_TEMPLATE: lambda d: d.policy_template,
        LiteLLM.GUARDRAIL_DETECTION_METHOD: lambda d: d.detection_method,
    }

    _SERVICE_ATTRS: dict[str, Callable[[ServiceSpanData], AttrValue | None]] = {
        LiteLLM.SERVICE_NAME: lambda d: d.service_name,
        LiteLLM.SERVICE_CALL_TYPE: lambda d: d.call_type,
    }

    def map(self, data: SpanData) -> AttributeMap:
        match data:
            case LLMCallSpanData():
                return self._llm_call(data)
            case MCPToolCallSpanData():
                return collect(self._MCP_ATTRS, data)
            case GuardrailSpanData():
                return self._guardrail(data)
            case ServiceSpanData():
                return self._service(data)
            case _:
                return {}

    @classmethod
    def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
        attrs = collect(cls._LLM_CALL_ATTRS, data)
        attrs.update(
            drop_none(
                {
                    f"gen_ai.tool.{idx}.{suffix}": extract(tool)
                    for idx, tool in enumerate(data.tools)
                    for suffix, extract in cls._TOOL_ATTRS.items()
                }
            )
        )
        return attrs

    @classmethod
    def _guardrail(cls, data: GuardrailSpanData) -> AttributeMap:
        return collect(cls._GUARDRAIL_ATTRS, data)

    @classmethod
    def _service(cls, data: ServiceSpanData) -> AttributeMap:
        attrs = collect(cls._SERVICE_ATTRS, data)
        # An outbound datastore call (DB_CALL / CLIENT span) also carries db.*
        # semconv. Internal services (router, budget jobs, …) have no db.system,
        # so they get only the litellm.service.* keys above.
        system = db_system(data.service_name)
        if system is not None:
            attrs[DB.SYSTEM_NAME] = system
            if data.call_type:
                attrs[DB.OPERATION_NAME] = data.call_type
        attrs.update(
            {
                f"{LiteLLM.METADATA_PREFIX}{key}": value
                for key, value in data.event_metadata.items()
            }
        )
        return attrs
