"""Langfuse integration — adds an OTel SpanProcessor to Langfuse's TracerProvider
so all Langfuse trace/generation/span data is also forwarded to Context0.

Works with Langfuse Python SDK v3+ (OTel-native).
Catches ALL usage patterns: @observe, LangfuseOpenAI, propagate_attributes(),
start_as_current_observation(), framework integrations, etc.

Usage:
    from llm_observatory import configure, wrap_langfuse

    configure(api_key="your-api-key")
    wrap_langfuse()
"""

from __future__ import annotations

import json
import logging
import re
import threading
import uuid
from datetime import datetime, timezone
from typing import Any

from llm_observatory.config import get_config
from llm_observatory.transport import get_transport, flush_transport, shutdown_transport

logger = logging.getLogger("llm_observatory")

# ── Constants ────────────────────────────────────────────────────

# Attribute prefixes that indicate a Langfuse-originated span.
# All Langfuse SDK patterns set at least one of these.
_LANGFUSE_ATTR_PREFIXES = ("langfuse.", "user.id", "session.id")

_HEX32_RE = re.compile(r"^[0-9a-f]{32}$", re.IGNORECASE)

# Observation types that represent real LLM work (generations).
_GENERATION_TYPES = frozenset({"generation", "embedding"})

# Types that indicate a pure wrapper/trace root — no meaningful observation.
# Langfuse defaults @observe() to type "span" when no explicit as_type is given.
# A root with one of these types only emits a trace event. All other types
# (generation, embedding, agent, tool, chain, etc.) are intentional observations.
_WRAPPER_TYPES = frozenset({None, "span"})

# ── State ────────────────────────────────────────────────────────

_installed = False
_lock = threading.Lock()

# trace_id_hex → UUID (deterministic via _hex_to_uuid)
_trace_uuid_by_trace_id: dict[str, str] = {}
# span_id_hex → random event UUID (on_start → on_end lifetime)
_live_span_event_id_by_span_id: dict[str, str] = {}
# trace_id_hex → count of live Langfuse spans (refcount)
_open_span_count_by_trace_id: dict[str, int] = {}
# Bounded fallback for on_end without on_start (degraded path)
_fallback_id_mapping: dict[str, str] = {}
_MAX_FALLBACK_ID_MAPPINGS = 1_000

# trace_id → (has_user, timestamp) — tracks whether we've emitted a trace event
# and the timestamp used, so re-emission uses the same timestamp for ClickHouse dedup.
_emitted_traces: dict[str, tuple[bool, str]] = {}
_MAX_EMITTED_TRACES = 10_000

# Set of trace_id UUIDs that have a pre-registered root wrapper span.
# When a root wrapper exists, child spans skip trace emission — the root
# wrapper will emit the authoritative trace event with name/userId/metadata.
_traces_with_root: set[str] = set()


# ── Public API ───────────────────────────────────────────────────

def wrap_langfuse(client: Any = None) -> Any:
    """Hook into Langfuse's OTel pipeline so all trace/generation/span data
    is also sent to Context0.

    Returns the client as-is for chaining. Installation is idempotent.
    Works with every Langfuse usage pattern — no code changes needed.

    Requires Langfuse Python SDK v3.0.0+ (OTel-native).
    """
    try:
        _try_install_processor()
    except Exception as exc:
        logger.debug("wrap_langfuse: failed to install processor: %s", exc)
    return client


def create_span_processor() -> "Context0SpanProcessor":
    """Returns a Context0 SpanProcessor that can be passed directly to the
    OTel TracerProvider constructor or add_span_processor().

    This is the stable alternative to wrap_langfuse() — it uses the official
    OTel public API instead of post-init installation.

    Usage::

        from llm_observatory import configure, create_span_processor
        from opentelemetry.sdk.trace import TracerProvider

        configure(api_key="...")
        provider = TracerProvider()
        provider.add_span_processor(LangfuseSpanProcessor())
        provider.add_span_processor(create_span_processor())
    """
    global _installed
    _installed = True
    return Context0SpanProcessor()


def _reset_patch_state() -> None:
    """Reset internal state — for tests."""
    global _installed
    with _lock:
        _installed = False
        _emitted_traces.clear()
        _trace_uuid_by_trace_id.clear()
        _live_span_event_id_by_span_id.clear()
        _open_span_count_by_trace_id.clear()
        _fallback_id_mapping.clear()
        _traces_with_root.clear()


# ── Processor installation ───────────────────────────────────────

def _try_install_processor() -> None:
    global _installed
    if _installed:
        return

    # Use the standard OTel public API: get the global TracerProvider and call
    # add_span_processor(). Unlike the JS SDK, Python OTel kept this as a
    # public method. For a fully explicit setup, use create_span_processor()
    # and pass it to the TracerProvider constructor instead.
    try:
        from opentelemetry import trace as otel_trace  # type: ignore[import-untyped]
        from opentelemetry.sdk.trace import TracerProvider  # type: ignore[import-untyped]

        proxy = otel_trace.get_tracer_provider()
        # ProxyTracerProvider wraps the real provider
        tp = getattr(proxy, "_real_tracer_provider", proxy)
        if isinstance(tp, TracerProvider):
            tp.add_span_processor(Context0SpanProcessor())
            _installed = True
            logger.debug("wrap_langfuse: installed Context0SpanProcessor")
            return
    except Exception as exc:
        logger.debug("wrap_langfuse: failed: %s", exc)

    logger.warning("wrap_langfuse: could not find a TracerProvider to attach to — Langfuse data will NOT be forwarded to Context0")


# ── OTel SpanProcessor ───────────────────────────────────────────

class Context0SpanProcessor:
    """OTel SpanProcessor that converts Langfuse spans to Context0 events."""

    def on_start(self, span: Any, parent_context: Any = None) -> None:
        try:
            _register_langfuse_span(span)
        except Exception:
            pass  # never crash the customer's app

    def _on_ending(self, span: Any) -> None:
        pass

    def on_end(self, span: Any) -> None:
        try:
            cfg = get_config()
            if not cfg.get("enabled"):
                return
            event = _span_to_event(span)
            if event is not None:
                get_transport().enqueue(event)
        except Exception as exc:
            logger.debug("Context0SpanProcessor.on_end: %s", exc)

    def shutdown(self) -> None:
        try:
            flush_transport()
            shutdown_transport()
        except Exception:
            pass  # never crash

    def force_flush(self, timeout_millis: int | None = None) -> bool:
        try:
            flush_transport()
        except Exception:
            pass  # never crash
        return True


# ── Langfuse span registration (on_start) ────────────────────────
#
# Registers all Langfuse spans in on_start for proper lifecycle management.
# on_start fires parent-first, on_end fires child-first. We use on_start to:
# 1. Filter out non-Langfuse spans (HTTP, DB, custom spans from Strategy 2)
# 2. Compute/cache deterministic trace UUIDs from 32-hex OTel trace_ids
# 3. Register span event IDs (random UUIDs for non-root, trace UUID for root wrappers)
# 4. Track refcounts for cleanup

def _register_langfuse_span(span: Any) -> None:
    """Register a Langfuse span's IDs in lifecycle maps."""
    attrs = dict(getattr(span, "attributes", None) or {})

    raw_name = getattr(span, "name", None)
    name_obj: dict[str, Any] | None = raw_name if isinstance(raw_name, dict) else None

    ctx = getattr(span, "context", None)
    if ctx is None:
        return

    trace_id_hex = format(ctx.trace_id, "032x")
    span_id_hex = format(ctx.span_id, "016x")

    # Determine if root
    has_parent = getattr(span, "parent", None) is not None

    # Filter: skip non-Langfuse child spans (HTTP, DB, custom OTel spans).
    # Root spans are always registered — they're cheap and we need them to
    # correctly name the trace event. With propagate_attributes(), root spans
    # have a plain string name and no Langfuse attributes at on_start time
    # (attributes are set between on_start and on_end). If a root turns out to
    # not be a Langfuse span, _span_to_event filters it at on_end.
    if has_parent and not name_obj and not _has_langfuse_attributes(attrs):
        return

    # Observation type: prefer OTel attribute, fall back to Langfuse options object
    obs_type = attrs.get("langfuse.observation.type") or (name_obj.get("asType") if name_obj else None)

    with _lock:
        # Compute/cache deterministic trace UUID
        if trace_id_hex not in _trace_uuid_by_trace_id:
            _trace_uuid_by_trace_id[trace_id_hex] = _hex_to_uuid(trace_id_hex)
        trace_uuid = _trace_uuid_by_trace_id[trace_id_hex]

        # Increment refcount
        _open_span_count_by_trace_id[trace_id_hex] = _open_span_count_by_trace_id.get(trace_id_hex, 0) + 1

        # Register span event ID.
        # Root spans without an explicit generation/embedding type are wrappers.
        # This covers all Langfuse patterns:
        #   - name_obj: start_active_observation({ name, userId, ... })
        #   - propagate_attributes: string name, no attrs at on_start, obs_type unknown
        #   - explicit asType="span" or no asType
        # Root generations (asType="generation") are NOT wrappers.
        is_root_wrapper = not has_parent and (obs_type is None or obs_type in _WRAPPER_TYPES)
        if is_root_wrapper:
            # Root wrapper: spanId maps to trace UUID
            _live_span_event_id_by_span_id[span_id_hex] = trace_uuid
            _traces_with_root.add(trace_uuid)
        else:
            # All other spans: random UUID
            _live_span_event_id_by_span_id[span_id_hex] = str(uuid.uuid4())


# ── Span → Event conversion ─────────────────────────────────────

def _span_to_event(span: Any) -> dict[str, Any] | None:
    """Convert an OTel ReadableSpan (with Langfuse attributes) to a Context0 event."""
    attrs = dict(span.attributes or {})

    # Langfuse passes the full options object as span.name (a dict, not a string).
    raw_name = getattr(span, "name", None)
    name_obj: dict[str, Any] | None = raw_name if isinstance(raw_name, dict) else None

    # Guard: skip non-Langfuse spans (HTTP, DB, custom spans from Strategy 2)
    if not name_obj and not _has_langfuse_attributes(attrs):
        return None

    # Determine observation type: prefer Langfuse options object, then OTel attribute.
    # Langfuse sets langfuse.observation.type="span" for all types in attributes,
    # but the actual type (generation, embedding, etc.) is in name_obj["asType"].
    obs_type = (name_obj.get("asType") if name_obj else None) or attrs.get("langfuse.observation.type")

    # Read IDs
    ctx = getattr(span, "context", None)
    if ctx is None:
        return None
    trace_id_hex = format(ctx.trace_id, "032x")
    span_id_hex = format(ctx.span_id, "016x")
    parent_id_hex = format(span.parent.span_id, "016x") if span.parent else None

    # Resolve IDs using lifecycle maps (on_start → on_end), with fallback
    safe_trace_id = _resolve_trace_uuid(trace_id_hex)
    safe_event_id = _resolve_span_event_id(span_id_hex)
    safe_parent_id = _resolve_span_event_id(parent_id_hex) if parent_id_hex else safe_trace_id

    # Timing (OTel uses nanoseconds)
    start_ns = span.start_time or 0
    end_ns = span.end_time or start_ns
    start_time = datetime.fromtimestamp(start_ns / 1e9, tz=timezone.utc).isoformat()
    end_time = datetime.fromtimestamp(end_ns / 1e9, tz=timezone.utc).isoformat()
    latency_ms = int((end_ns - start_ns) / 1e6)

    # Extract span name from Langfuse options object or use string name
    span_name_str: str = (name_obj.get("name") if name_obj else None) or (span.name if isinstance(span.name, str) else "langfuse-span")

    # Metadata — merge from Langfuse options object AND OTel attributes.
    # Langfuse v4 passes userId/sessionId/tags on the options object (span.name dict),
    # not as OTel span attributes. Fall back to attributes for older patterns.
    user_id = (name_obj.get("userId") if name_obj else None) or attrs.get("user.id") or attrs.get("langfuse.user.id")
    session_id = (name_obj.get("sessionId") if name_obj else None) or attrs.get("session.id") or attrs.get("langfuse.session.id")
    raw_tags = (name_obj.get("tags") if name_obj else None) or attrs.get("langfuse.trace.tags")
    if raw_tags is not None:
        tags = [str(t) for t in raw_tags] if isinstance(raw_tags, (list, tuple)) else [str(raw_tags)]
    else:
        tags = []

    metadata: dict[str, Any] = {}
    if user_id:
        metadata["user_id"] = str(user_id)
    if session_id:
        metadata["session_id"] = str(session_id)
    if tags:
        metadata["tags"] = tags

    # Custom metadata extraction — Langfuse options object, JSON blob, OR flattened keys
    custom: dict[str, str] = {}

    # Strategy 0: Langfuse options object (name_obj["metadata"])
    name_metadata = name_obj.get("metadata") if name_obj else None
    if isinstance(name_metadata, dict):
        for k, v in name_metadata.items():
            if v is not None:
                custom[k] = str(v) if not isinstance(v, str) else v

    # Strategy 1: single JSON blob (e.g. langfuse.observation.metadata = '{"key":"val"}')
    custom_meta_raw = attrs.get("langfuse.observation.metadata")
    if custom_meta_raw is not None:
        custom_meta = custom_meta_raw
        if isinstance(custom_meta_raw, str):
            try:
                custom_meta = json.loads(custom_meta_raw)
            except (json.JSONDecodeError, ValueError):
                custom_meta = None
        if isinstance(custom_meta, dict) and custom_meta:
            custom.update({k: str(v) if not isinstance(v, str) else v for k, v in custom_meta.items() if v is not None})

    # Strategy 2: flattened keys (Langfuse Python v3 sets langfuse.observation.metadata.{key} and
    # langfuse.trace.metadata.{key} as individual OTel attributes)
    for prefix in ("langfuse.observation.metadata.", "langfuse.trace.metadata."):
        for attr_key, attr_val in attrs.items():
            if attr_key.startswith(prefix) and attr_val is not None:
                short_key = attr_key[len(prefix):]
                if short_key:
                    custom[short_key] = str(attr_val) if not isinstance(attr_val, str) else attr_val

    if custom:
        metadata["custom"] = custom

    # Input/Output from attributes
    inp_raw = attrs.get("langfuse.observation.input") or attrs.get("langfuse.trace.input")
    out_raw = attrs.get("langfuse.observation.output") or attrs.get("langfuse.trace.output")
    inp_str: str | None = None
    out_str: str | None = None
    if inp_raw is not None:
        inp_str = inp_raw if isinstance(inp_raw, str) else json.dumps(inp_raw)
    if out_raw is not None:
        out_str = out_raw if isinstance(out_raw, str) else json.dumps(out_raw)

    # Root span handling: in Langfuse, the root span IS the trace.
    # A root with a wrapper type (None, "span") only emits a trace event enriched
    # with its data. All other root types (generation, embedding, agent, tool,
    # chain, etc.) are real observations — emit both trace + observation.
    if parent_id_hex is None and obs_type in _WRAPPER_TYPES:
        # Emit trace event enriched with this wrapper's data (input, output, timing)
        _emit_trace(safe_trace_id, span_name_str, start_time, end_time, latency_ms, metadata, inp_str, out_str)
        _cleanup_span(trace_id_hex, span_id_hex)
        return None

    # Emit trace event for child spans or standalone root generations.
    # Skip if a root wrapper exists — it will emit the authoritative trace event
    # with the correct name/userId/metadata in its own on_end. Emitting here would
    # create a race: the child's trace (with wrong name, no userId) and the root's
    # re-emission go through SQS independently, and ReplacingMergeTree may keep
    # the wrong one depending on ingestion order.
    if safe_trace_id not in _traces_with_root:
        _emit_trace(safe_trace_id, span_name_str, start_time, end_time, 0, metadata, None, None)

    # Span type mapping
    span_type = "generation" if obs_type in _GENERATION_TYPES else "span"

    # Level / status
    level = attrs.get("langfuse.observation.level")
    span_status = "error" if level == "ERROR" else "ok"

    # Model — from Langfuse options object, OTel attributes, or gen_ai fallback
    model = (name_obj.get("model") if name_obj else None) or attrs.get("langfuse.observation.model.name") or attrs.get("gen_ai.request.model") or ""
    usage = _extract_usage_from_attrs(attrs)

    # Parse input — for generations, this is typically the messages array
    messages: list[Any] = []
    if inp_str is not None:
        try:
            parsed_input = json.loads(inp_str)
            if isinstance(parsed_input, list):
                messages = parsed_input
        except (json.JSONDecodeError, ValueError):
            pass

    # Parse output — for generations, this is typically {role, content}
    response: dict[str, Any] = {}
    if out_str is not None:
        try:
            parsed_output = json.loads(out_str)
            if isinstance(parsed_output, dict) and "content" in parsed_output:
                response = {
                    "id": "",
                    "model": model,
                    "choices": [{"index": 0, "message": parsed_output, "finish_reason": "stop"}],
                }
            else:
                response = {
                    "id": "",
                    "model": model,
                    "choices": [{"index": 0, "message": {"role": "assistant", "content": out_str}, "finish_reason": "stop"}],
                }
        except (json.JSONDecodeError, ValueError):
            response = {
                "id": "",
                "model": model,
                "choices": [{"index": 0, "message": {"role": "assistant", "content": out_str}, "finish_reason": "stop"}],
            }

    # Build event
    now = datetime.now(timezone.utc).isoformat()
    event: dict[str, Any] = {
        "event_id": safe_event_id,
        "timestamp": now,
        "trace_id": safe_trace_id,
        "parent_id": safe_parent_id,
        "span_type": span_type,
        "span_name": span_name_str or f"langfuse-{span_type}",
        "span_start_time": start_time,
        "span_end_time": end_time,
        "span_status": span_status,
        "request": {"model": model, "messages": messages, "stream": False},
        "response": response,
        "usage": usage,
        "metrics": {"latency_ms": latency_ms},
        "metadata": metadata,
    }

    if inp_str is not None:
        event["span_input"] = inp_str
    if out_str is not None:
        event["span_output"] = out_str

    # Cleanup lifecycle maps for this span
    _cleanup_span(trace_id_hex, span_id_hex)

    return event


# ── Trace emission ───────────────────────────────────────────────

def _emit_trace(
    trace_id: str,
    name: str,
    start_time: str,
    end_time: str,
    latency_ms: int,
    metadata: dict[str, Any],
    span_input: str | None,
    span_output: str | None,
) -> None:
    """Emit (or re-emit) a synthetic trace event for a given trace_id.

    In Langfuse, the root span IS the trace. We synthesize a separate "trace"
    event for Context0's trace-based schema. The trace event is enriched with
    data from the root wrapper span (input, output, timing) when available.

    Re-emission happens when a later span provides userId that wasn't available
    on the first emission. The timestamp is preserved for ClickHouse dedup.
    """
    has_user = bool(metadata.get("user_id"))
    with _lock:
        prev = _emitted_traces.get(trace_id)

        # Already emitted with user metadata — nothing to upgrade
        if prev is not None and prev[0] is True:
            return
        # Already emitted without user, and this span also has no user — skip
        if prev is not None and not prev[0] and not has_user:
            return

        cfg = get_config()
        if not cfg.get("enabled"):
            return

        # Reuse the timestamp from the first emission so both rows share the same
        # ClickHouse ORDER BY key (project_id, timestamp, event_id), allowing
        # ReplacingMergeTree(ingested_at) to keep only the latest version.
        timestamp = prev[1] if prev is not None else datetime.now(timezone.utc).isoformat()

        if len(_emitted_traces) >= _MAX_EMITTED_TRACES:
            # Only evict completed entries (has_user=True). Clearing all entries
            # loses in-flight (False, timestamp) entries, causing re-emission
            # with a new timestamp that breaks ReplacingMergeTree dedup.
            to_remove = [k for k, v in _emitted_traces.items() if v[0] is True]
            for k in to_remove:
                del _emitted_traces[k]
            # If still over limit (all entries are in-flight), evict oldest half
            if len(_emitted_traces) >= _MAX_EMITTED_TRACES:
                keys = list(_emitted_traces.keys())
                for k in keys[: len(keys) // 2]:
                    del _emitted_traces[k]
        _emitted_traces[trace_id] = (has_user, timestamp)

    event: dict[str, Any] = {
        "event_id": trace_id,
        "timestamp": timestamp,
        "trace_id": trace_id,
        "span_type": "trace",
        "span_name": name or "langfuse-trace",
        "span_start_time": start_time,
        "span_end_time": end_time or start_time,
        "span_status": "ok",
        "request": {"model": "", "messages": [], "stream": False},
        "response": {},
        "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
        "metrics": {"latency_ms": latency_ms},
        "metadata": metadata,
    }

    if span_input is not None:
        event["span_input"] = span_input
    if span_output is not None:
        event["span_output"] = span_output

    get_transport().enqueue(event)


# ── Helpers ──────────────────────────────────────────────────────

def _first_of(d: dict, *keys: str, default: Any = None) -> Any:
    """Return the first value from *d* whose key exists and is not None."""
    for k in keys:
        v = d.get(k)
        if v is not None:
            return v
    return default


def _extract_usage_from_attrs(attrs: dict[str, Any]) -> dict[str, int]:
    """Extract usage from OTel span attributes."""
    # Try Langfuse usage_details first
    raw = attrs.get("langfuse.observation.usage_details")
    if raw:
        if isinstance(raw, str):
            try:
                raw = json.loads(raw)
            except (json.JSONDecodeError, ValueError):
                raw = None

        if isinstance(raw, dict):
            prompt = _first_of(raw, "prompt_tokens", "promptTokens", "input", "input_tokens", default=0)
            completion = _first_of(raw, "completion_tokens", "completionTokens", "output", "output_tokens", default=0)
            total = _first_of(raw, "total_tokens", "totalTokens", "total")
            if total is None:
                total = (prompt if isinstance(prompt, (int, float)) else 0) + (completion if isinstance(completion, (int, float)) else 0)
            return {
                "prompt_tokens": int(prompt) if isinstance(prompt, (int, float)) else 0,
                "completion_tokens": int(completion) if isinstance(completion, (int, float)) else 0,
                "total_tokens": int(total) if isinstance(total, (int, float)) else 0,
            }

    # Fallback to GenAI semantic conventions
    p = _first_of(attrs, "gen_ai.usage.prompt_tokens", "gen_ai.usage.input_tokens", default=0)
    c = _first_of(attrs, "gen_ai.usage.completion_tokens", "gen_ai.usage.output_tokens", default=0)
    return {
        "prompt_tokens": int(p) if isinstance(p, (int, float)) else 0,
        "completion_tokens": int(c) if isinstance(c, (int, float)) else 0,
        "total_tokens": (int(p) if isinstance(p, (int, float)) else 0) + (int(c) if isinstance(c, (int, float)) else 0),
    }


def _hex_to_uuid(hex_str: str) -> str:
    """Convert a 32-hex OTel trace_id to a deterministic UUID by inserting hyphens.

    "aaaabbbbccccddddeeeeffff11112222" → "aaaabbbb-cccc-dddd-eeee-ffff11112222"
    """
    # Already has hyphens in UUID format
    if "-" in hex_str:
        try:
            uuid.UUID(hex_str)
            return hex_str
        except (ValueError, AttributeError):
            pass
    # 32-hex without hyphens — insert them deterministically
    if _HEX32_RE.match(hex_str):
        return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
    return str(uuid.uuid4())


def _has_langfuse_attributes(attrs: dict[str, Any]) -> bool:
    """Check if any attribute key starts with a Langfuse prefix."""
    for key in attrs:
        for prefix in _LANGFUSE_ATTR_PREFIXES:
            if key.startswith(prefix):
                return True
    return False


def _resolve_trace_uuid(trace_id_hex: str) -> str:
    """Resolve trace UUID: deterministic from 32-hex, cached across spans."""
    with _lock:
        existing = _trace_uuid_by_trace_id.get(trace_id_hex)
        if existing:
            return existing
        # Fallback: on_end without on_start (e.g. processor added mid-trace)
        generated = _hex_to_uuid(trace_id_hex)
        _trace_uuid_by_trace_id[trace_id_hex] = generated
        return generated


def _resolve_span_event_id(span_id_hex: str) -> str:
    """Resolve span event ID: live map → fallback map → generate random."""
    with _lock:
        # Live map (registered in on_start)
        live = _live_span_event_id_by_span_id.get(span_id_hex)
        if live:
            return live
        # Fallback map (bounded, for degraded path)
        fallback = _fallback_id_mapping.get(span_id_hex)
        if fallback:
            return fallback
        # Generate and cache in fallback
        generated = str(uuid.uuid4())
        if len(_fallback_id_mapping) >= _MAX_FALLBACK_ID_MAPPINGS:
            _evict_oldest_half(_fallback_id_mapping)
        _fallback_id_mapping[span_id_hex] = generated
        return generated


def _cleanup_span(trace_id_hex: str, span_id_hex: str) -> None:
    """Clean up lifecycle maps after a span's on_end completes."""
    with _lock:
        _live_span_event_id_by_span_id.pop(span_id_hex, None)
        # Only clean up trace UUID if we're tracking refcounts (on_start was called).
        # Without on_start (degraded path), leave trace UUID cached for sibling spans.
        tracked = _open_span_count_by_trace_id.get(trace_id_hex)
        if tracked is not None:
            count = tracked - 1
            if count <= 0:
                _open_span_count_by_trace_id.pop(trace_id_hex, None)
                trace_uuid = _trace_uuid_by_trace_id.pop(trace_id_hex, None)
                # Clean up _traces_with_root to prevent unbounded growth
                if trace_uuid is not None:
                    _traces_with_root.discard(trace_uuid)
            else:
                _open_span_count_by_trace_id[trace_id_hex] = count


def _evict_oldest_half(mapping: dict[str, str]) -> None:
    """Evict the oldest half of a dict. Must be called while holding _lock.

    Python dicts (3.7+) iterate in insertion order, so the first half
    contains the oldest entries — the ones most likely already consumed.
    """
    keys = list(mapping.keys())
    for k in keys[: len(keys) // 2]:
        del mapping[k]


# ── Test helpers ─────────────────────────────────────────────────

def _process_span(span: Any) -> dict[str, Any] | None:
    """Process a span directly — for tests."""
    return _span_to_event(span)
