"""
OpenTelemetry integration for Amplitude AI SDK.

Two directions of data flow:

1. **Inbound** — :class:`AmplitudeAgentExporter` consumes OTEL GenAI semantic
   convention spans and maps them to Amplitude ``[Agent]`` events.  Use this
   to get LLM data into Amplitude from *any* OTEL-instrumented tool
   (Langfuse, OpenLIT, Traceloop / OpenLLMetry, manual OTEL, etc.).

2. **Outbound** — :class:`AmplitudeSpanExporter` converts each Amplitude AI
   event into an OTEL span so the data can flow into Jaeger, Datadog,
   Honeycomb, or any OTEL-compatible backend *in addition to* Amplitude.

Inbound usage (OTEL GenAI Bridge)::

    from opentelemetry import trace
    from opentelemetry.sdk.trace import TracerProvider
    from opentelemetry.sdk.trace.export import SimpleSpanProcessor
    from amplitude import Amplitude
    from amplitude_ai.integrations.opentelemetry import AmplitudeAgentExporter

    amplitude = Amplitude("AMP_KEY")

    exporter = AmplitudeAgentExporter(amplitude=amplitude, user_id="user-123")
    provider = TracerProvider()
    provider.add_span_processor(SimpleSpanProcessor(exporter))
    trace.set_tracer_provider(provider)

    # GenAI spans from any OTEL-instrumented tool are now mapped to
    # Amplitude [Agent] events automatically.

Outbound usage::

    from amplitude_ai.integrations.opentelemetry import AmplitudeSpanExporter
    from amplitude_ai import AmplitudeAI, AIConfig

    exporter = AmplitudeSpanExporter()
    ai = AmplitudeAI(
        api_key="AMP_KEY",
        config=AIConfig(on_event_callback=exporter.record_event),
    )
"""

from __future__ import annotations

import json
import logging
import time
from typing import Any, Optional, Sequence

try:
    from opentelemetry import trace
    from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
    from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
    from opentelemetry.trace import StatusCode

    OTEL_AVAILABLE = True
except ImportError:
    OTEL_AVAILABLE = False
    trace = None  # type: ignore[assignment]
    StatusCode = None  # type: ignore[assignment,misc]
    SpanExporter = object  # type: ignore[assignment,misc]
    SpanExportResult = None  # type: ignore[assignment,misc]

from ..core.privacy import PrivacyConfig
from ..core.tracking import (
    EVENT_AI_RESPONSE,
    EVENT_EMBEDDING,
    EVENT_TOOL_CALL,
    EVENT_USER_MESSAGE,
    PROP_AGENT_ID,
    PROP_AGENT_VERSION,
    PROP_COMPONENT_TYPE,
    PROP_CONTEXT,
    PROP_COST_USD,
    PROP_CUSTOMER_ORG_ID,
    PROP_EMBEDDING_DIMENSIONS,
    PROP_ENV,
    PROP_ERROR_MESSAGE,
    PROP_FINISH_REASON,
    PROP_INPUT_TOKENS,
    PROP_IS_ERROR,
    PROP_LATENCY_MS,
    PROP_MAX_OUTPUT_TOKENS,
    PROP_MESSAGE_ID,
    PROP_MODEL_NAME,
    PROP_OUTPUT_TOKENS,
    PROP_PARENT_AGENT_ID,
    PROP_PROVIDER,
    PROP_RUNTIME,
    PROP_SDK_VERSION,
    PROP_SESSION_ID,
    PROP_SYSTEM_PROMPT,
    PROP_SYSTEM_PROMPT_LENGTH,
    PROP_TEMPERATURE,
    PROP_TOOL_NAME,
    PROP_TOOL_SUCCESS,
    PROP_TOP_P,
    PROP_TOTAL_TOKENS,
    PROP_TRACE_ID,
    PROP_TURN_ID,
    SDK_VERSION,
    track_ai_message,
    track_embedding,
    track_tool_call,
    track_user_message,
)
from ..context import get_active_context
from ..exceptions import ProviderError
from ..utils.costs import calculate_cost

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# OTEL GenAI semantic convention attribute names
# https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/
# ---------------------------------------------------------------------------
_OTEL_OPERATION_NAME = "gen_ai.operation.name"
_OTEL_PROVIDER_NAME = "gen_ai.provider.name"
_OTEL_REQUEST_MODEL = "gen_ai.request.model"
_OTEL_RESPONSE_MODEL = "gen_ai.response.model"
_OTEL_INPUT_TOKENS = "gen_ai.usage.input_tokens"
_OTEL_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
_OTEL_FINISH_REASONS = "gen_ai.response.finish_reasons"
_OTEL_RESPONSE_ID = "gen_ai.response.id"
_OTEL_CONVERSATION_ID = "gen_ai.conversation.id"
_OTEL_INPUT_MESSAGES = "gen_ai.input.messages"
_OTEL_OUTPUT_MESSAGES = "gen_ai.output.messages"
_OTEL_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
_OTEL_TEMPERATURE = "gen_ai.request.temperature"
_OTEL_MAX_TOKENS = "gen_ai.request.max_tokens"
_OTEL_TOP_P = "gen_ai.request.top_p"
_OTEL_ERROR_TYPE = "error.type"
_OTEL_AGENT_ID = "gen_ai.agent.id"
_OTEL_AGENT_NAME = "gen_ai.agent.name"

# Operations that map to specific event types
_OP_CHAT = "chat"
_OP_TEXT_COMPLETION = "text_completion"
_OP_GENERATE_CONTENT = "generate_content"
_OP_EMBEDDINGS = "embeddings"
_OP_EXECUTE_TOOL = "execute_tool"
_OP_INVOKE_AGENT = "invoke_agent"
_OP_CREATE_AGENT = "create_agent"


# ===================================================================
# Inbound: OTEL GenAI Spans → Amplitude [Agent] Events
# ===================================================================


class AmplitudeAgentExporter(SpanExporter):  # type: ignore[misc]
    """Consume OTEL GenAI semantic convention spans and emit Amplitude events.

    Works with **any** tool that emits OTEL spans following the
    `GenAI semantic conventions <https://opentelemetry.io/docs/specs/semconv/gen-ai/>`_:

    - Langfuse (v3+)
    - OpenLIT
    - Traceloop / OpenLLMetry
    - OpenAI Python SDK (with OTEL instrumentation)
    - Manual ``opentelemetry`` instrumentation

    Add this exporter alongside your existing OTEL setup::

        from opentelemetry.sdk.trace.export import SimpleSpanProcessor
        from amplitude_ai.integrations.opentelemetry import AmplitudeAgentExporter

        provider.add_span_processor(
            SimpleSpanProcessor(AmplitudeAgentExporter(amplitude=amp, user_id="u1"))
        )

    If an :class:`~amplitude_ai.client.Session` context manager is active,
    the exporter automatically inherits ``session_id``, ``trace_id``,
    ``agent_id``, ``turn_id``, and other fields via the same ``ContextVar``
    mechanism that provider wrappers use.

    **OTEL GenAI Semantic Convention → Amplitude Property Mapping**

    The following table shows how OTEL GenAI span attributes map to
    Amplitude ``[Agent]`` event properties.  Attributes not listed are
    ignored (they can be captured by adding a second OTEL exporter for
    your preferred tracing backend).

    .. list-table::
       :header-rows: 1

       * - OTEL GenAI Attribute
         - Amplitude Property
         - Notes
       * - ``gen_ai.operation.name``
         - (event routing)
         - ``chat``/``text_completion``/``generate_content`` → User Message + AI Response;
           ``embeddings`` → Embedding; ``execute_tool`` → Tool Call
       * - ``gen_ai.request.model``
         - ``[Agent] Model Name``
         - Fallback; ``gen_ai.response.model`` is preferred when present
       * - ``gen_ai.response.model``
         - ``[Agent] Model Name``
         - Preferred (often contains the versioned model name)
       * - ``gen_ai.provider.name``
         - ``[Agent] Provider``
         -
       * - ``gen_ai.usage.input_tokens``
         - ``[Agent] Input Tokens``
         -
       * - ``gen_ai.usage.output_tokens``
         - ``[Agent] Output Tokens``
         -
       * - (computed) input + output
         - ``[Agent] Total Tokens``
         - Auto-summed when both are present
       * - ``gen_ai.response.finish_reasons``
         - ``[Agent] Finish Reason``
         - First element of the array
       * - ``gen_ai.input.messages``
         - (user message content)
         - Last ``role=user`` message extracted; respects ``privacy_config``
       * - ``gen_ai.output.messages``
         - (AI response content)
         - First output message; respects ``privacy_config``
       * - ``gen_ai.system_instructions``
         - ``[Agent] System Prompt``
         - Respects ``privacy_config``
       * - ``gen_ai.request.temperature``
         - ``[Agent] Temperature``
         -
       * - ``gen_ai.request.max_tokens``
         - ``[Agent] Max Output Tokens``
         -
       * - ``gen_ai.request.top_p``
         - ``[Agent] Top P``
         -
       * - ``gen_ai.conversation.id``
         - ``[Agent] Session ID``
         - Fallback when no ``SessionContext`` is active
       * - ``gen_ai.response.id``
         - (not mapped)
         - Use ``[Agent] Message ID`` (auto-generated UUID) instead
       * - ``gen_ai.embeddings.dimension.count``
         - ``[Agent] Embedding Dimensions``
         - Only for ``embeddings`` operations
       * - ``error.type``
         - ``[Agent] Is Error`` + ``[Agent] Error Message``
         -
       * - Span duration (ns)
         - ``[Agent] Latency Ms``
         - Computed from ``start_time`` / ``end_time``
       * - (computed) model + tokens
         - ``[Agent] Cost USD``
         - Auto-calculated via built-in ``genai-prices`` database
       * - ``enduser.id``
         - ``user_id``
         - Fallback when no ``SessionContext`` or default user_id

    **Attributes not mapped by this exporter** (available via native SDK
    provider wrappers or custom ``event_properties``):

    - Cache tokens (``cache_read_tokens``, ``cache_creation_tokens``) --
      not in the OTEL GenAI spec
    - Reasoning content and tokens -- not in the OTEL GenAI spec
    - Time to first byte (TTFB) -- not available from span timestamps
    - Streaming detection -- not distinguishable from span attributes
    - Event graph linking (``parent_message_id``) -- OTEL spans have
      parent-child via trace context, but not message-level linking

    .. deprecated:: 0.7.0
       ``AmplitudeGenAIExporter`` was renamed to ``AmplitudeAgentExporter``.
       The old name is kept as an alias for backward compatibility.

    Parameters
    ----------
    amplitude:
        The Amplitude analytics client.
    user_id:
        Default user ID for events when no ``ContextVar`` session is active
        and no ``enduser.id`` attribute is present on the span.
    privacy_config:
        Privacy/content filtering configuration.
    allowed_scopes:
        If provided, only process spans whose ``InstrumentationScope.name``
        is in this set.  By default, any span with ``gen_ai.*`` attributes
        is processed regardless of scope.
    blocked_scopes:
        Scopes to explicitly ignore (e.g. ``{"fastapi", "sqlalchemy"}``).
    """

    def __init__(
        self,
        amplitude: Any,
        user_id: str | None = None,
        privacy_config: PrivacyConfig | None = None,
        allowed_scopes: set[str] | None = None,
        blocked_scopes: set[str] | None = None,
    ) -> None:
        if not OTEL_AVAILABLE:
            raise ProviderError(
                "OpenTelemetry SDK not installed. "
                "Install with: pip install opentelemetry-api opentelemetry-sdk"
            )
        self._amplitude = amplitude
        self._default_user_id = user_id
        self._privacy_config = privacy_config or PrivacyConfig()
        self._allowed_scopes = allowed_scopes
        self._blocked_scopes = blocked_scopes or set()

    # ------------------------------------------------------------------
    # SpanExporter interface
    # ------------------------------------------------------------------

    def export(self, spans: Sequence[Any]) -> Any:
        """Process a batch of completed spans.

        Only spans with ``gen_ai.*`` attributes (and passing scope
        filters) are converted to Amplitude events.
        """
        for span in spans:
            try:
                if self._should_process(span):
                    self._map_and_track(span)
            except Exception:
                logger.debug("Failed to process OTEL span", exc_info=True)

        return SpanExportResult.SUCCESS  # type: ignore[union-attr]

    def shutdown(self) -> None:
        """Shutdown hook (SpanExporter interface)."""

    def force_flush(self, timeout_millis: int = 30000) -> bool:
        """Flush hook (SpanExporter interface)."""
        return True

    # ------------------------------------------------------------------
    # Filtering
    # ------------------------------------------------------------------

    def _should_process(self, span: Any) -> bool:
        """Determine whether a span is a GenAI span we should process."""
        # Check scope filters
        scope_name = getattr(
            getattr(span, "instrumentation_scope", None), "name", ""
        ) or ""

        if scope_name in self._blocked_scopes:
            return False

        if self._allowed_scopes is not None:
            if scope_name not in self._allowed_scopes:
                return False

        # Must have at least one gen_ai.* attribute
        attrs = getattr(span, "attributes", None) or {}
        return any(k.startswith("gen_ai.") for k in attrs)

    # ------------------------------------------------------------------
    # Mapping: OTEL GenAI span → Amplitude [Agent] events
    # ------------------------------------------------------------------

    def _map_and_track(self, span: Any) -> None:
        """Map a single OTEL GenAI span to one or more Amplitude [Agent] events."""
        attrs = dict(getattr(span, "attributes", None) or {})
        operation = attrs.get(_OTEL_OPERATION_NAME, "")

        # --- Read ContextVar session context ---
        ctx = get_active_context()

        # --- Resolve user_id ---
        user_id = self._resolve_user_id(attrs, ctx)
        if not user_id:
            logger.debug("Skipping OTEL span: no user_id available")
            return

        session_id = (
            (ctx.session_id if ctx else None)
            or attrs.get(_OTEL_CONVERSATION_ID)
        )
        trace_id = ctx.trace_id if ctx else None
        if not trace_id:
            trace_id = self._extract_otel_trace_id(span)
        turn_id = ctx.next_turn_id() if ctx else None
        agent_id = ctx.agent_id if ctx else None
        parent_agent_id = ctx.parent_agent_id if ctx else None
        env = ctx.env if ctx else None
        customer_org_id = ctx.customer_org_id if ctx else None
        agent_version = ctx.agent_version if ctx else None
        context_dict = ctx.context if ctx else None
        groups = ctx.groups if ctx else None

        # --- Common fields ---
        model = str(
            attrs.get(_OTEL_RESPONSE_MODEL)
            or attrs.get(_OTEL_REQUEST_MODEL)
            or "unknown"
        )
        provider = str(
            attrs.get(_OTEL_PROVIDER_NAME)
            or attrs.get("gen_ai.system", "unknown")
        )
        input_tokens = self._safe_int(attrs.get(_OTEL_INPUT_TOKENS))
        output_tokens = self._safe_int(attrs.get(_OTEL_OUTPUT_TOKENS))
        total_tokens = (input_tokens or 0) + (output_tokens or 0) if (input_tokens or output_tokens) else None
        finish_reasons = attrs.get(_OTEL_FINISH_REASONS)
        finish_reason = finish_reasons[0] if isinstance(finish_reasons, (list, tuple)) and finish_reasons else None

        # Latency from span timestamps (nanoseconds → milliseconds)
        latency_ms = self._compute_latency_ms(span)

        # Cost from model + tokens
        cost_usd = calculate_cost(
            model_name=model,
            input_tokens=input_tokens or 0,
            output_tokens=output_tokens or 0,
        ) if (input_tokens or output_tokens) else None

        # Error detection
        is_error = bool(attrs.get(_OTEL_ERROR_TYPE))
        error_message = str(attrs.get(_OTEL_ERROR_TYPE, "")) if is_error else None

        # Model configuration
        temperature = self._safe_float(attrs.get(_OTEL_TEMPERATURE))
        max_tokens = self._safe_int(attrs.get(_OTEL_MAX_TOKENS)) or None
        top_p = self._safe_float(attrs.get(_OTEL_TOP_P))

        # Content extraction
        user_content = self._extract_user_content(attrs)
        ai_content = self._extract_ai_content(attrs)
        system_prompt = self._extract_system_prompt(attrs)

        # Build shared event_properties for agent context
        extra_props: dict[str, Any] = {}
        if agent_id:
            extra_props[PROP_AGENT_ID] = agent_id
        if parent_agent_id:
            extra_props[PROP_PARENT_AGENT_ID] = parent_agent_id
        if env:
            extra_props[PROP_ENV] = env
        if customer_org_id:
            extra_props[PROP_CUSTOMER_ORG_ID] = customer_org_id
        if agent_version:
            extra_props[PROP_AGENT_VERSION] = agent_version
        if context_dict:
            extra_props[PROP_CONTEXT] = json.dumps(context_dict)

        # --- Extract agent attributes from span (OTEL GenAI agent conventions) ---
        otel_agent_id = attrs.get(_OTEL_AGENT_ID)
        otel_agent_name = attrs.get(_OTEL_AGENT_NAME)
        if otel_agent_id and not agent_id:
            agent_id = str(otel_agent_id)
            extra_props[PROP_AGENT_ID] = agent_id
        if otel_agent_name:
            extra_props["[Agent] Agent Name"] = str(otel_agent_name)

        # --- Route by operation type ---
        if operation in (_OP_CHAT, _OP_TEXT_COMPLETION, _OP_GENERATE_CONTENT, ""):
            # Chat / completion → User Message + AI Response
            if user_content:
                track_user_message(
                    amplitude=self._amplitude,
                    user_id=user_id,
                    message_content=user_content,
                    session_id=session_id,
                    trace_id=trace_id,
                    turn_id=turn_id or 1,
                    agent_id=agent_id,
                    parent_agent_id=parent_agent_id,
                    customer_org_id=customer_org_id,
                    agent_version=agent_version,
                    context=context_dict,
                    env=env,
                    event_properties=extra_props if extra_props else None,
                    groups=groups,
                    privacy_config=self._privacy_config,
                )

            track_ai_message(
                amplitude=self._amplitude,
                user_id=user_id,
                model_name=model,
                provider=provider,
                response_content=ai_content or "",
                latency_ms=latency_ms,
                session_id=session_id,
                trace_id=trace_id,
                turn_id=turn_id if turn_id is not None else 2,
                input_tokens=input_tokens,
                output_tokens=output_tokens,
                total_tokens=total_tokens,
                total_cost_usd=cost_usd,
                is_error=is_error,
                error_message=error_message,
                finish_reason=finish_reason,
                system_prompt=system_prompt,
                temperature=temperature,
                max_output_tokens=max_tokens,
                top_p=top_p,
                agent_id=agent_id,
                parent_agent_id=parent_agent_id,
                customer_org_id=customer_org_id,
                agent_version=agent_version,
                context=context_dict,
                env=env,
                event_properties=extra_props if extra_props else None,
                groups=groups,
                privacy_config=self._privacy_config,
            )

        elif operation == _OP_EMBEDDINGS:
            dimensions = self._safe_int(
                attrs.get("gen_ai.embeddings.dimension.count")
            ) or None

            track_embedding(
                amplitude=self._amplitude,
                user_id=user_id,
                model=model,
                provider=provider,
                latency_ms=latency_ms,
                session_id=session_id,
                trace_id=trace_id,
                turn_id=turn_id,
                input_tokens=input_tokens,
                dimensions=dimensions,
                agent_id=agent_id,
                parent_agent_id=parent_agent_id,
                customer_org_id=customer_org_id,
                agent_version=agent_version,
                context=context_dict,
                env=env,
                groups=groups,
            )

        elif operation == _OP_EXECUTE_TOOL:
            tool_name = str(attrs.get("gen_ai.tool.name", ""))
            if not tool_name:
                tool_name = getattr(span, "name", "unknown_tool")
                if tool_name.startswith("execute_tool "):
                    tool_name = tool_name[len("execute_tool "):]

            track_tool_call(
                amplitude=self._amplitude,
                user_id=user_id,
                tool_name=tool_name,
                success=not is_error,
                latency_ms=latency_ms,
                session_id=session_id,
                trace_id=trace_id,
                turn_id=turn_id or 1,
                agent_id=agent_id,
                parent_agent_id=parent_agent_id,
                customer_org_id=customer_org_id,
                agent_version=agent_version,
                context=context_dict,
                env=env,
                error_message=error_message,
                event_properties=extra_props if extra_props else None,
                groups=groups,
                privacy_config=self._privacy_config,
            )

        elif operation in (_OP_INVOKE_AGENT, _OP_CREATE_AGENT):
            track_ai_message(
                amplitude=self._amplitude,
                user_id=user_id,
                model_name=model,
                provider=provider,
                response_content=ai_content or "",
                latency_ms=latency_ms,
                session_id=session_id,
                trace_id=trace_id,
                turn_id=turn_id if turn_id is not None else 1,
                input_tokens=input_tokens,
                output_tokens=output_tokens,
                total_tokens=total_tokens,
                total_cost_usd=cost_usd,
                is_error=is_error,
                error_message=error_message,
                finish_reason=finish_reason,
                system_prompt=system_prompt,
                temperature=temperature,
                max_output_tokens=max_tokens,
                top_p=top_p,
                agent_id=agent_id,
                parent_agent_id=parent_agent_id,
                customer_org_id=customer_org_id,
                agent_version=agent_version,
                context=context_dict,
                env=env,
                event_properties=extra_props if extra_props else None,
                groups=groups,
                privacy_config=self._privacy_config,
            )

        else:
            logger.debug("Unknown GenAI operation '%s', mapping as AI Response", operation)
            track_ai_message(
                amplitude=self._amplitude,
                user_id=user_id,
                model_name=model,
                provider=provider,
                response_content=ai_content or "",
                latency_ms=latency_ms,
                session_id=session_id,
                trace_id=trace_id,
                turn_id=turn_id or 1,
                input_tokens=input_tokens,
                output_tokens=output_tokens,
                total_tokens=total_tokens,
                total_cost_usd=cost_usd,
                is_error=is_error,
                error_message=error_message,
                finish_reason=finish_reason,
                agent_id=agent_id,
                parent_agent_id=parent_agent_id,
                customer_org_id=customer_org_id,
                agent_version=agent_version,
                context=context_dict,
                env=env,
                event_properties=extra_props if extra_props else None,
                groups=groups,
                privacy_config=self._privacy_config,
            )

    # ------------------------------------------------------------------
    # Content extraction helpers
    # ------------------------------------------------------------------

    def _extract_user_content(self, attrs: dict[str, Any]) -> str | None:
        """Extract the last user message from gen_ai.input.messages."""
        raw = attrs.get(_OTEL_INPUT_MESSAGES)
        messages = self._parse_json_attr(raw)
        if not messages:
            return None
        # Find the last user message
        for msg in reversed(messages):
            if isinstance(msg, dict) and msg.get("role") == "user":
                return self._extract_text_from_parts(msg)
        return None

    def _extract_ai_content(self, attrs: dict[str, Any]) -> str | None:
        """Extract AI response text from gen_ai.output.messages."""
        raw = attrs.get(_OTEL_OUTPUT_MESSAGES)
        messages = self._parse_json_attr(raw)
        if not messages:
            return None
        # Take the first output message (first choice)
        if messages and isinstance(messages[0], dict):
            return self._extract_text_from_parts(messages[0])
        return None

    def _extract_system_prompt(self, attrs: dict[str, Any]) -> str | None:
        """Extract system instructions from gen_ai.system_instructions."""
        raw = attrs.get(_OTEL_SYSTEM_INSTRUCTIONS)
        if raw is None:
            return None
        if isinstance(raw, str):
            try:
                parsed = json.loads(raw)
                if isinstance(parsed, list):
                    raw = parsed
                else:
                    return raw
            except (json.JSONDecodeError, TypeError):
                return raw
        if not isinstance(raw, list):
            return None
        parts = []
        for item in raw:
            if isinstance(item, dict):
                content = item.get("content", "")
                if content:
                    parts.append(str(content))
            elif isinstance(item, str):
                parts.append(item)
        return "\n".join(parts) if parts else None

    @staticmethod
    def _extract_text_from_parts(msg: dict[str, Any]) -> str | None:
        """Extract text content from a message with 'parts' or 'content'."""
        # Direct content string
        content = msg.get("content")
        if isinstance(content, str):
            return content

        # Parts array (OTEL GenAI convention)
        parts = msg.get("parts", [])
        if not isinstance(parts, list):
            return None

        text_parts = []
        for part in parts:
            if isinstance(part, dict) and part.get("type") == "text":
                text = part.get("content", "")
                if text:
                    text_parts.append(str(text))
        return "\n".join(text_parts) if text_parts else None

    # ------------------------------------------------------------------
    # Resolution helpers
    # ------------------------------------------------------------------

    def _resolve_user_id(self, attrs: dict[str, Any], ctx: Any = None) -> str | None:
        """Resolve user_id from: ContextVar > span attributes > default."""
        if ctx and ctx.user_id:
            return ctx.user_id
        # OTEL convention for end user
        enduser = attrs.get("enduser.id")
        if enduser:
            return str(enduser)
        return self._default_user_id

    @staticmethod
    def _extract_otel_trace_id(span: Any) -> str | None:
        """Extract the OTEL trace_id from the span context as a hex string."""
        span_ctx = getattr(span, "context", None)
        if span_ctx is None:
            return None
        trace_id = getattr(span_ctx, "trace_id", None)
        if trace_id and isinstance(trace_id, int) and trace_id != 0:
            return format(trace_id, "032x")
        return None

    @staticmethod
    def _compute_latency_ms(span: Any) -> float:
        """Compute latency from span start/end timestamps (nanoseconds)."""
        start_ns = getattr(span, "start_time", None)
        end_ns = getattr(span, "end_time", None)
        if start_ns and end_ns and isinstance(start_ns, int) and isinstance(end_ns, int):
            return round((end_ns - start_ns) / 1_000_000, 2)
        return 0.0

    @staticmethod
    def _safe_int(value: Any) -> int | None:
        """Convert to int, returning None if not possible."""
        if value is None:
            return None
        try:
            return int(value)
        except (TypeError, ValueError):
            return None

    @staticmethod
    def _safe_float(value: Any) -> float | None:
        """Convert to float, returning None if not possible."""
        if value is None:
            return None
        try:
            return float(value)
        except (TypeError, ValueError):
            return None

    @staticmethod
    def _parse_json_attr(value: Any) -> list[Any] | None:
        """Parse a JSON attribute that may be a string, list, or None."""
        if value is None:
            return None
        if isinstance(value, list):
            return value
        if isinstance(value, str):
            try:
                parsed = json.loads(value)
                if isinstance(parsed, list):
                    return parsed
            except (json.JSONDecodeError, TypeError):
                pass
        return None


# ===================================================================
# Outbound: Amplitude Events → OTEL Spans
# ===================================================================


class AmplitudeSpanExporter:
    """Bridge Amplitude AI events *outward* to OpenTelemetry spans.

    There are two usage modes:

    1. **Callback mode** (recommended): set ``AIConfig.on_event_callback``
       to :meth:`record_event` and every tracked event becomes an OTel span.
    2. **Processor mode**: add this object as a ``SpanProcessor`` to your
       ``TracerProvider`` if you want to intercept spans created elsewhere.

    In callback mode the class is a *producer* of spans.  In processor
    mode it is a *consumer* (no-op by default).
    """

    _tracer: Any = None  # set lazily on first use

    def __init__(self, tracer_name: str = "amplitude-ai") -> None:
        if not OTEL_AVAILABLE:
            raise ProviderError(
                "OpenTelemetry SDK not installed. "
                "Install with: pip install opentelemetry-api opentelemetry-sdk"
            )
        self._tracer_name = tracer_name
        self._tracer = trace.get_tracer(tracer_name)  # type: ignore[union-attr]

    # ------------------------------------------------------------------
    # Callback mode — convert Amplitude events into OTel spans
    # ------------------------------------------------------------------

    def record_event(self, event: Any, status_code: int = 200, message: Optional[str] = None) -> None:
        """Convert an Amplitude ``BaseEvent`` into an OTel span.

        Designed to be used as ``AIConfig.on_event_callback``::

            exporter = AmplitudeSpanExporter()
            config = AIConfig(on_event_callback=exporter.record_event)
        """
        if not OTEL_AVAILABLE or self._tracer is None:
            return

        event_type = getattr(event, "event_type", "unknown")
        props = getattr(event, "event_properties", {}) or {}

        span = self._tracer.start_span(
            name=event_type,
            attributes=self._flatten_properties(props, prefix="amp"),
        )

        # Map the Amplitude delivery status to OTel status
        if status_code >= 400 or props.get(PROP_IS_ERROR):
            span.set_status(StatusCode.ERROR, message or "Event delivery failed")  # type: ignore[union-attr]
        else:
            span.set_status(StatusCode.OK)  # type: ignore[union-attr]

        # Add useful metadata
        user_id = getattr(event, "user_id", None)
        if user_id:
            span.set_attribute("enduser.id", user_id)

        session_id = props.get(PROP_SESSION_ID)
        if session_id:
            span.set_attribute("amp.session_id", session_id)

        model = props.get(PROP_MODEL_NAME)
        if model:
            span.set_attribute("gen_ai.request.model", model)

        provider = props.get(PROP_PROVIDER)
        if provider:
            span.set_attribute("gen_ai.system", provider)

        latency = props.get(PROP_LATENCY_MS)
        if latency is not None:
            span.set_attribute("gen_ai.response.latency_ms", latency)

        span.end()

    # ------------------------------------------------------------------
    # SpanProcessor mode — forward existing spans (no-op by default)
    # ------------------------------------------------------------------

    def on_start(self, span: Any, parent_context: Any = None) -> None:
        """Called when a span starts (SpanProcessor interface)."""

    def on_end(self, span: Any) -> None:
        """Called when a span ends (SpanProcessor interface)."""

    def shutdown(self) -> None:
        """Shutdown hook (SpanProcessor interface)."""

    def force_flush(self, timeout_millis: int = 30000) -> bool:
        """Flush hook (SpanProcessor interface)."""
        return True

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _flatten_properties(
        props: dict[str, Any],
        prefix: str = "",
    ) -> dict[str, Any]:
        """Flatten nested dicts into dot-separated OTel attributes.

        OTel attributes must be primitives or sequences of primitives.
        """
        flat: dict[str, Any] = {}
        for key, value in props.items():
            attr_key = f"{prefix}.{key}" if prefix else key
            if isinstance(value, dict):
                flat.update(
                    AmplitudeSpanExporter._flatten_properties(value, attr_key)
                )
            elif isinstance(value, (str, int, float, bool)):
                flat[attr_key] = value
            elif isinstance(value, (list, tuple)):
                # OTel supports homogeneous sequences of primitives
                if all(isinstance(v, (str, int, float, bool)) for v in value):
                    flat[attr_key] = list(value)
                else:
                    flat[attr_key] = str(value)
            elif value is not None:
                flat[attr_key] = str(value)
        return flat


# Backward-compatibility alias
AmplitudeGenAIExporter = AmplitudeAgentExporter
