"""
Tracer for creating and managing spans.

The tracer is the main interface for creating spans. It handles:
- Span creation with proper parent linking
- Context propagation
- Span lifecycle management
- Export on span end
"""

from __future__ import annotations

import hashlib
import logging
import random
import time as _time
import threading
from collections import OrderedDict
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING

logger = logging.getLogger(__name__)

from risicare_core import (
    Span,
    SpanKind,
    SpanStatus,
    SemanticPhase,
    LLMAttributes,
    generate_span_id,
    generate_trace_id,
    get_current_span,
    get_current_session,
    get_current_agent,
    get_current_phase,
    set_current_span,
    reset_current_span,
    register_span,
    unregister_span,
    utc_now,
)

if TYPE_CHECKING:
    from risicare.client import RisicareClient


# =============================================================================
# No-Op Span (for when tracing is disabled)
# =============================================================================

class _NoOpSpan:
    """
    A no-op span that does nothing.

    Used when tracing is disabled to avoid overhead.
    All methods are no-ops that return immediately.
    """

    __slots__ = ("trace_id", "span_id", "name", "kind", "status", "parent_span_id",
                 "session_id", "agent_id", "semantic_phase", "llm", "attributes",
                 "events", "links", "exceptions", "start_time", "end_time",
                 "status_message")

    def __init__(self) -> None:
        self.trace_id = ""
        self.span_id = ""
        self.name = ""
        self.kind = SpanKind.INTERNAL
        self.status = SpanStatus.UNSET
        self.parent_span_id: Optional[str] = None
        self.session_id: Optional[str] = None
        self.agent_id: Optional[str] = None
        self.semantic_phase: Optional[SemanticPhase] = None
        self.llm: Optional[LLMAttributes] = None
        self.attributes: Dict[str, Any] = {}
        self.events: List[Any] = []
        self.links: List[Any] = []
        self.exceptions: List[Any] = []
        self.start_time = utc_now()
        self.end_time = None
        self.status_message: Optional[str] = None

    @property
    def duration_ms(self) -> Optional[float]:
        return None

    @property
    def is_root(self) -> bool:
        return True

    @property
    def is_error(self) -> bool:
        return False

    @property
    def is_complete(self) -> bool:
        return True

    @property
    def has_exceptions(self) -> bool:
        return False

    def set_attribute(self, key: str, value: Any) -> None:
        pass

    def set_attributes(self, attributes: Dict[str, Any]) -> None:
        pass

    def get_attribute(self, key: str, default: Any = None) -> Any:
        return default

    def add_event(self, name: str, attributes: Optional[Dict[str, Any]] = None,
                  timestamp: Any = None) -> None:
        pass

    def add_link(self, trace_id: str, span_id: str,
                 attributes: Optional[Dict[str, Any]] = None) -> None:
        pass

    def record_exception(self, exception: BaseException, escaped: bool = True) -> None:
        pass

    def set_status(self, status: SpanStatus, message: Optional[str] = None) -> None:
        pass

    def mark_ok(self, message: Optional[str] = None) -> None:
        pass

    def mark_error(self, message: Optional[str] = None) -> None:
        pass

    def end(self, end_time: Any = None) -> None:
        pass

    def to_dict(self) -> Dict[str, Any]:
        return {}

    # Context manager support — allows _NoOpSpan to be used both as a CM
    # (with tracer.start_span("x") as span:) and standalone (span.end())
    # when tracing is disabled.
    def __enter__(self) -> "_NoOpSpan":
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
        return False


# Singleton no-op span to avoid allocations
_NOOP_SPAN = _NoOpSpan()


# =============================================================================
# ManagedSpan — dual-mode span (context manager OR standalone)
# =============================================================================

class ManagedSpan:
    """
    A span that supports both context manager and standalone usage.

    Context manager (recommended — automatic context propagation + lifecycle):

        with tracer.start_span("my-op") as span:
            span.set_attribute("model", "gpt-4o")
            result = llm.invoke(prompt)

    Standalone (manual lifecycle, no automatic context propagation):

        span = tracer.start_span("my-op")
        span.set_attribute("model", "gpt-4o")
        result = llm.invoke(prompt)
        span.end()

    All span attributes and methods (set_attribute, add_event, record_exception,
    set_status, mark_ok, mark_error, trace_id, span_id, etc.) are proxied to
    the underlying Span via __getattr__.
    """

    __slots__ = (
        '_tracer', '_name', '_kind', '_attributes', '_links', '_register',
        '_span',    # Set in __enter__ (CM mode) or lazily by _ensure_span() (standalone)
        '_cm',      # The @contextmanager generator; set only in CM mode
        '_entered', # True once __enter__ has been called
    )

    def __init__(
        self,
        tracer: "Tracer",
        name: str,
        kind: "SpanKind",
        attributes: Optional[Dict[str, Any]],
        links: Optional[list],
        register: bool,
    ) -> None:
        self._tracer = tracer
        self._name = name
        self._kind = kind
        self._attributes = attributes
        self._links = links
        self._register = register
        self._span: Any = None   # None until first use in standalone mode
        self._cm: Any = None
        self._entered = False

    # ------------------------------------------------------------------
    # Context manager protocol
    # ------------------------------------------------------------------

    def __enter__(self) -> "ManagedSpan":
        """
        Enter context manager mode.

        Delegates entirely to _start_span_context() so all lifecycle
        behaviour (sampling, context tokens, exception recording, export)
        is identical to using the @contextmanager form directly.
        """
        self._cm = self._tracer._start_span_context(
            self._name,
            kind=self._kind,
            attributes=self._attributes,
            links=self._links,
            register=self._register,
        )
        # Enter the generator-based CM; it yields the active Span (or _NOOP_SPAN)
        self._span = self._cm.__enter__()
        self._entered = True
        return self  # 'as span:' gives a ManagedSpan; __getattr__ proxies to _span

    def __exit__(
        self,
        exc_type: Any,
        exc_val: Any,
        exc_tb: Any,
    ) -> bool:
        """Exit context manager — full lifecycle handled by _start_span_context."""
        if self._cm is not None:
            return self._cm.__exit__(exc_type, exc_val, exc_tb)
        return False

    # ------------------------------------------------------------------
    # Standalone protocol
    # ------------------------------------------------------------------

    def _ensure_span(self) -> None:
        """Lazily create the span for standalone usage."""
        if self._span is None:
            # create_span() handles sampling: returns _NOOP_SPAN if disabled/
            # not sampled, so standalone callers never cause data leaks.
            self._span = self._tracer.create_span(
                self._name,
                kind=self._kind,
                attributes=self._attributes,
            )

    def end(self) -> None:
        """
        End and export the span (standalone mode).

        No-op when used as a context manager (lifecycle managed by __exit__).
        Idempotent — safe to call multiple times.
        """
        if self._entered:
            # CM mode: lifecycle is managed by __exit__; don't double-end.
            return
        self._ensure_span()
        span = self._span
        # span_id is "" for _NoOpSpan — skip export for no-op spans
        if span is not None and span.span_id:
            span.end()
            self._tracer._export_span(span)

    # ------------------------------------------------------------------
    # Transparent attribute proxy
    # ------------------------------------------------------------------

    def __getattr__(self, name: str) -> Any:
        """
        Proxy all attribute/method access to the underlying Span.

        Called automatically by Python for any attribute not found in
        ManagedSpan itself (i.e. everything except the __slots__ above
        and the methods defined here).
        """
        self._ensure_span()
        span = self._span
        if span is None:
            raise AttributeError(
                f"ManagedSpan has no attribute {name!r} and span is not yet created"
            )
        return getattr(span, name)

    def __setattr__(self, name: str, value: Any) -> None:
        """
        Proxy attribute writes to the underlying Span for non-slot attributes.

        Allows `span.semantic_phase = phase` and similar attribute sets
        from decorators and user code to reach the underlying Span object.
        """
        # Slot attributes are managed by ManagedSpan itself
        if name in self.__slots__:
            object.__setattr__(self, name, value)
        else:
            self._ensure_span()
            if self._span is not None:
                setattr(self._span, name, value)

    def __repr__(self) -> str:
        return f"ManagedSpan(name={self._name!r}, entered={self._entered})"


# Trace-level sampling decision — propagated from root to all children
_trace_sampled: ContextVar[Optional[bool]] = ContextVar("_trace_sampled", default=None)


# =============================================================================
# Global Tracer
# =============================================================================

_tracer: Optional["Tracer"] = None
_tracer_lock = threading.Lock()


def get_tracer() -> "Tracer":
    """
    Get the global tracer instance.

    Returns a no-op tracer if SDK not initialized.
    """
    global _tracer
    with _tracer_lock:
        if _tracer is None:
            _tracer = Tracer()
        return _tracer


def _set_tracer(tracer: "Tracer") -> None:
    """Set the global tracer (internal use)."""
    global _tracer
    with _tracer_lock:
        _tracer = tracer


def _reset_for_testing() -> None:
    """
    Reset global tracer state for testing.

    This is intended for test fixtures only. Do not use in production.
    """
    global _tracer
    with _tracer_lock:
        _tracer = None


# =============================================================================
# Tracer Class
# =============================================================================

class Tracer:
    """
    Tracer for creating and managing spans.

    The tracer provides methods to create spans that represent
    operations in your agent system. Spans are automatically
    linked to form traces.

    Example:
        tracer = get_tracer()

        with tracer.start_span("research") as span:
            span.set_attribute("query", query)
            result = llm.invoke(query)
            span.set_attribute("result_length", len(result))
    """

    def __init__(self, client: Optional["RisicareClient"] = None):
        """
        Initialize the tracer.

        Args:
            client: Optional client for span export.
        """
        self._client = client
        self._debug: bool = False
        self._service_name: Optional[str] = None

    @property
    def is_enabled(self) -> bool:
        """Check if tracing is enabled."""
        if self._client:
            return self._client.is_enabled
        # Check for global client
        from risicare.client import get_client
        client = get_client()
        return client.is_enabled if client else False

    def create_span(
        self,
        name: str,
        kind: SpanKind = SpanKind.INTERNAL,
        attributes: Optional[Dict[str, Any]] = None,
    ) -> Span:
        """
        Create a span with context linking but manual lifecycle.

        Unlike start_span(), this does NOT use a context manager and does NOT
        set the span as current context. The caller is responsible for:
        1. Calling span.end() when the operation completes
        2. Calling tracer.export_span(span) after ending

        Returns _NOOP_SPAN if tracing is disabled or the trace is not sampled.

        Primary use case: streaming responses where the span must outlive its
        creation scope (the generator pattern).

        Args:
            name: Name describing the operation.
            kind: Kind of span (INTERNAL, AGENT, LLM_CALL, etc.).
            attributes: Initial span attributes.

        Returns:
            The new span, or _NOOP_SPAN if disabled/not sampled.
        """
        if not self.is_enabled:
            return _NOOP_SPAN  # type: ignore

        # Check sampling
        parent = get_current_span()
        if parent is None:
            # Root span: make sampling decision
            trace_id = generate_trace_id()
            sampled = self._should_sample(trace_id)
            if not sampled:
                return _NOOP_SPAN  # type: ignore
        else:
            trace_id = None  # _create_span will inherit from parent
            sampled = _trace_sampled.get()
            if sampled is False:
                return _NOOP_SPAN  # type: ignore

        return self._create_span(name, kind, attributes, trace_id=trace_id)

    def export_span(self, span: "Span") -> None:
        """
        Export a manually-managed span.

        Call this after span.end() for spans created with create_span().
        No-op if the span has no span_id (i.e. is a _NoOpSpan).
        """
        if not span.span_id:
            return
        self._export_span(span)

    def _should_sample(self, trace_id: Optional[str] = None) -> bool:
        """Check if this trace should be sampled.

        Uses deterministic hashing of trace_id for consistent decisions
        across distributed systems. Falls back to random if no trace_id.
        """
        client = self._client
        if client is None:
            from risicare.client import get_client
            client = get_client()

        if client is None:
            return True  # No client, sample everything

        sample_rate = client.config.sample_rate
        if sample_rate >= 1.0:
            return True
        if sample_rate <= 0.0:
            return False

        # Deterministic: hash trace_id for consistent decision
        if trace_id:
            hash_val = int(trace_id[:8], 16) / 0xFFFFFFFF
            return hash_val < sample_rate

        return random.random() < sample_rate

    def start_span(
        self,
        name: str,
        kind: SpanKind = SpanKind.INTERNAL,
        attributes: Optional[Dict[str, Any]] = None,
        links: Optional[list] = None,
        register: bool = False,
    ) -> "ManagedSpan":
        """
        Start a new span. Works as both a context manager and standalone.

        **Context manager** (recommended — automatic context propagation,
        exception recording, and lifecycle management):

            with tracer.start_span("llm_call", kind=SpanKind.LLM_CALL) as span:
                span.set_attribute("model", "gpt-4o")
                response = llm.invoke(prompt)

        **Standalone** (manual lifecycle — use when span must outlive its
        creation scope, e.g. streaming):

            span = tracer.start_span("llm_call")
            span.set_attribute("model", "gpt-4o")
            response = llm.invoke(prompt)
            span.end()  # Must call end() to export

        Args:
            name: Name describing the operation.
            kind: Kind of span (INTERNAL, AGENT, LLM_CALL, etc.).
            attributes: Initial span attributes.
            links: Links to related spans.
            register: Whether to register span for ID-based lookup.

        Returns:
            ManagedSpan — supports both context manager and standalone usage.
            Returns _NOOP_SPAN (a no-op singleton) when tracing is disabled.
        """
        if not self.is_enabled:
            return _NOOP_SPAN  # type: ignore[return-value]
        return ManagedSpan(self, name, kind, attributes, links, register)

    @contextmanager
    def _start_span_context(
        self,
        name: str,
        kind: SpanKind = SpanKind.INTERNAL,
        attributes: Optional[Dict[str, Any]] = None,
        links: Optional[list] = None,
        register: bool = False,
    ) -> Iterator[Span]:
        """
        Internal context manager implementation for start_span().

        Used by ManagedSpan.__enter__() to delegate full span lifecycle
        (sampling tokens, context propagation, exception recording, export)
        to a single canonical implementation.

        External callers should use start_span() instead.
        """
        # Check if enabled (fast path for disabled)
        if not self.is_enabled:
            yield _NOOP_SPAN  # type: ignore
            return

        # Head-based trace-coherent sampling
        parent = get_current_span()
        if parent is None:
            # Root span: make sampling decision and store it
            trace_id = generate_trace_id()
            sampled = self._should_sample(trace_id)
            sampling_token = _trace_sampled.set(sampled)
            if not sampled:
                yield _NOOP_SPAN  # type: ignore
                _trace_sampled.reset(sampling_token)
                return
        else:
            trace_id = None  # _create_span will inherit from parent
            sampling_token = None
            # Child span: inherit parent's sampling decision
            sampled = _trace_sampled.get()
            if sampled is False:
                yield _NOOP_SPAN  # type: ignore
                return

        # Create span with context (pass trace_id to avoid double generation)
        span = self._create_span(name, kind, attributes, trace_id=trace_id)

        # Add links
        if links:
            for link in links:
                span.add_link(link.trace_id, link.span_id, link.attributes)

        # Register if requested (for async generator workaround)
        if register:
            register_span(span)

        # Set as current span
        token = set_current_span(span)

        try:
            yield span
            # Mark OK if no exception and status not set
            if span.status == SpanStatus.UNSET:
                span.mark_ok()
        except Exception as e:
            # Record exception
            span.record_exception(e, escaped=True)
            raise
        finally:
            # End span
            span.end()

            # Reset context
            reset_current_span(token)

            # Reset sampling context for root spans
            if sampling_token is not None:
                _trace_sampled.reset(sampling_token)

            # Unregister if registered
            if register:
                unregister_span(span.span_id)

            # Export
            self._export_span(span)

    def start_span_no_context(
        self,
        name: str,
        kind: SpanKind = SpanKind.INTERNAL,
        attributes: Optional[Dict[str, Any]] = None,
        parent_span_id: Optional[str] = None,
        trace_id: Optional[str] = None,
    ) -> Span:
        """
        Start a span without setting it as current context.

        Use this when you need manual span lifecycle management.
        You must call span.end() and export manually.

        Args:
            name: Span name.
            kind: Span kind.
            attributes: Initial attributes.
            parent_span_id: Optional parent span ID.
            trace_id: Optional trace ID.

        Returns:
            The new span (not set as current).
        """
        span = Span(
            trace_id=trace_id or generate_trace_id(),
            span_id=generate_span_id(),
            name=name,
            kind=kind,
            parent_span_id=parent_span_id,
        )

        if attributes:
            span.set_attributes(attributes)

        return span

    def _create_span(
        self,
        name: str,
        kind: SpanKind,
        attributes: Optional[Dict[str, Any]],
        trace_id: Optional[str] = None,
    ) -> Span:
        """Create a span with proper context linking.

        Args:
            name: Span name.
            kind: Span kind.
            attributes: Initial attributes.
            trace_id: Pre-generated trace ID for root spans. If provided and
                this is a root span, this ID is used instead of generating a new
                one. This ensures the same trace_id used for the sampling
                decision is assigned to the span.
        """
        # Get current context
        parent = get_current_span()
        session = get_current_session()
        agent = get_current_agent()
        phase = get_current_phase()

        # Determine trace ID and parent span ID
        if parent:
            actual_trace_id = parent.trace_id
            parent_span_id = parent.span_id
        else:
            actual_trace_id = trace_id or generate_trace_id()
            parent_span_id = None
            # In debug mode, warn developers when LLM calls have no parent span.
            # Each such call becomes its own independent trace (orphan) — a common
            # Tier 1 mistake. Use @trace or `with trace("name"):` to group them.
            if self._debug and kind == SpanKind.LLM_CALL:
                logger.debug(
                    "LLM call '%s' has no parent span — will be its own trace. "
                    "Wrap in @trace or 'with trace(name):' to group related calls.",
                    name,
                )

        # Create span
        span = Span(
            trace_id=actual_trace_id,
            span_id=generate_span_id(),
            name=name,
            kind=kind,
            parent_span_id=parent_span_id,
            session_id=session.session_id if session else None,
            agent_id=agent.agent_id if agent else None,
            agent_name=agent.agent_name if agent else None,
            agent_type=agent.agent_type if agent else None,
            semantic_phase=phase,
        )

        # Set initial attributes
        if attributes:
            span.set_attributes(attributes)

        # Add agent metadata as attributes (for backward compat with worker extraction)
        if agent:
            if agent.agent_role:
                span.set_attribute("agent.role", agent.agent_role)
            if agent.agent_type:
                span.set_attribute("agent.type", agent.agent_type)
            if agent.agent_name:
                span.set_attribute("agent.name", agent.agent_name)
            if agent.parent_agent_id:
                span.set_attribute("parent_agent_id", agent.parent_agent_id)

        # Propagate user_id from session context into span attributes
        # so workers can extract it for session aggregation
        if session and session.user_id:
            span.set_attribute("session.user_id", session.user_id)

        # Set service.name for Tier 1 users who configured service_name in init()
        # but haven't added @agent() yet. When @agent() is used (agent is truthy),
        # the explicit agent identity takes priority and this is skipped.
        if self._service_name and not agent:
            span.set_attribute("service.name", self._service_name)

        return span

    def _export_span(self, span: Span) -> None:
        """Export span via client."""
        client = self._client
        if client is None:
            from risicare.client import get_client
            client = get_client()

        if client:
            client.export_span(span)


# =============================================================================
# Convenience Functions
# =============================================================================

def start_span(
    name: str,
    kind: SpanKind = SpanKind.INTERNAL,
    attributes: Optional[Dict[str, Any]] = None,
    register: bool = False,
) -> "ManagedSpan":
    """
    Start a new span using the global tracer.

    Convenience function equivalent to get_tracer().start_span(...).
    Supports both context manager and standalone usage — see Tracer.start_span().

    Args:
        name: Span name.
        kind: Span kind.
        attributes: Initial attributes.
        register: Whether to register for ID-based lookup.

    Returns:
        ManagedSpan — use as a context manager or call .end() manually.
    """
    return get_tracer().start_span(
        name=name,
        kind=kind,
        attributes=attributes,
        register=register,
    )


# ---------------------------------------------------------------------------
# Client-side dedup for report_error() standalone path
# ---------------------------------------------------------------------------
# When report_error() is called outside a trace context, each call creates a
# new span+trace. Without dedup, a tight loop would flood the pipeline.
# Inside a trace context, dedup is unnecessary because the error is recorded
# on an existing span (multiple errors on one span = one trace = one diagnosis).
_error_seen: OrderedDict = OrderedDict()   # fingerprint → monotonic timestamp
_ERROR_DEDUP_TTL = 300       # suppress duplicates for 5 minutes
_ERROR_DEDUP_MAX = 1000      # max fingerprints to track


def _error_fingerprint(exc: BaseException) -> str:
    """Compute a short fingerprint for dedup (type + first 200 chars of message)."""
    key = f"{type(exc).__name__}:{str(exc)[:200]}"
    return hashlib.sha256(key.encode()).hexdigest()[:16]


def _is_duplicate_standalone_error(fp: str) -> bool:
    """Return True if this error was recently reported as a standalone span."""
    now = _time.monotonic()
    # Evict expired entries
    while _error_seen:
        oldest_key, oldest_time = next(iter(_error_seen.items()))
        if now - oldest_time > _ERROR_DEDUP_TTL:
            _error_seen.pop(oldest_key)
        else:
            break
    if fp in _error_seen:
        return True
    if len(_error_seen) >= _ERROR_DEDUP_MAX:
        _error_seen.popitem(last=False)
    _error_seen[fp] = now
    return False


def report_error(
    exception: BaseException,
    *,
    name: Optional[str] = None,
    attributes: Optional[Dict[str, Any]] = None,
) -> None:
    """
    Report a caught exception to the Risicare self-healing pipeline.

    Use this when your code catches an exception that you want Risicare to
    diagnose and potentially auto-fix. Without this call, caught exceptions
    are invisible to the self-healing engine.

    **Inside a traced context** (recommended):
        The exception is recorded on the current span. The span's status is
        set to ERROR and the exception details (type, message, stacktrace)
        are attached for downstream diagnosis.

    **Outside any traced context**:
        A standalone error span is created, the exception is recorded on it,
        and the span is immediately ended and exported. Duplicate errors
        (same type + message) are suppressed for 5 minutes to prevent
        flooding the pipeline from tight loops.

    This function never raises. If tracing is disabled or an internal error
    occurs, it silently returns.

    Example::

        from risicare import report_error

        try:
            result = llm.invoke(prompt)
        except openai.RateLimitError as e:
            report_error(e)          # Feed to self-healing pipeline
            return cached_response   # Handle locally

    Args:
        exception: The caught exception.
        name: Optional span name (only used when no current span exists).
            Defaults to ``"error:{ExceptionType}"``.
        attributes: Optional extra attributes to attach to the error span.
    """
    try:
        tracer = get_tracer()
        if tracer is None or not tracer.is_enabled:
            return

        exc_type = type(exception).__name__
        exc_msg = str(exception)[:2000]

        span = get_current_span()

        if span is not None and hasattr(span, "record_exception"):
            # Inside a traced context — record on current span (no dedup needed;
            # multiple errors on one span produce one trace = one diagnosis).
            span.record_exception(exception, escaped=False)
            span.set_attribute("error", True)
            span.set_attribute("error.type", exc_type)
            span.set_attribute("error.message", exc_msg)
            span.set_attribute("risicare.reported_error", True)
        else:
            # Outside any context — standalone span. Dedup to prevent flooding.
            fp = _error_fingerprint(exception)
            if _is_duplicate_standalone_error(fp):
                logger.debug("report_error: duplicate suppressed (fp=%s)", fp[:8])
                return

            error_name = name or f"error:{exc_type}"
            error_span = tracer._create_span(
                error_name,
                kind=SpanKind.INTERNAL,
                attributes=None,
            )
            error_span.record_exception(exception, escaped=False)
            error_span.set_attribute("error", True)
            error_span.set_attribute("error.type", exc_type)
            error_span.set_attribute("error.message", exc_msg)
            error_span.set_attribute("risicare.reported_error", True)
            if attributes:
                error_span.set_attributes(attributes)
            error_span.mark_error(exc_msg)
            error_span.end()
            tracer._export_span(error_span)

    except Exception:
        # Never crash the host application
        logger.debug("report_error failed", exc_info=True)


def score(
    trace_id: str,
    name: str,
    value: float,
    *,
    span_id: Optional[str] = None,
    comment: Optional[str] = None,
) -> None:
    """
    Record a custom evaluation score on a trace.

    Sends the score to the Risicare server where it appears in the dashboard
    linked to the trace. Use this for custom quality metrics, user feedback,
    or any evaluation logic you run in your own code.

    This function never raises. If the server is unreachable or returns an
    error, a warning is logged but your application continues normally.

    Example::

        from risicare import score

        risicare.score(
            trace_id="trace-abc123",
            name="factual_accuracy",
            value=0.92,
            comment="Verified against source documents",
        )

    Args:
        trace_id: The trace to score.
        name: Score name (e.g., "accuracy", "user_satisfaction").
        value: Numeric score value.
        span_id: Optional span within the trace to score.
        comment: Optional human-readable explanation.
    """
    try:
        from risicare.client import get_client

        client = get_client()
        if client is None or not client.is_enabled:
            return

        config = client.config
        if not config.api_key:
            logger.debug("score: no API key configured")
            return

        endpoint = config.endpoint.rstrip("/")
        url = f"{endpoint}/api/v1/scores"

        payload = {
            "trace_id": trace_id,
            "name": name,
            "score": value,
            "source": "sdk",
        }
        if span_id is not None:
            payload["span_id"] = span_id
        if comment is not None:
            payload["comment"] = comment

        import json

        body = json.dumps(payload).encode("utf-8")
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {config.api_key}",
        }

        try:
            import httpx

            with httpx.Client(timeout=5.0) as http:
                response = http.post(url, content=body, headers=headers)
                if response.status_code >= 400:
                    logger.debug(
                        "score: server returned %d: %s",
                        response.status_code,
                        response.text[:200],
                    )
        except ImportError:
            # httpx not available — fall back to urllib
            import urllib.request

            req = urllib.request.Request(url, data=body, headers=headers, method="POST")
            try:
                urllib.request.urlopen(req, timeout=5)
            except Exception as e:
                logger.debug("score: urllib request failed: %s", e)

    except Exception:
        # Never crash the host application
        logger.debug("score failed", exc_info=True)
