"""LLM trace domain model for observability.

An ``LLMTrace`` records a single LLM provider call with token usage,
cost, latency, and optional tool-call metadata.  Traces are keyed by
a ULID ``trace_id`` and linked to a ``plan_id`` and optional
``decision_id`` for drill-down analysis.

``LLMTraceQuery`` provides query/filter parameters for trace retrieval
from the repository layer.

Based on ``docs/specification.md`` Observability section and
Forgejo issue #500.
"""

from __future__ import annotations

import re
from datetime import datetime
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator

# ULID is 26 characters, Crockford's base32
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"


class LLMTrace(BaseModel):
    """Immutable record of a single LLM provider call.

    Each trace captures the full observability surface for one
    LLM invocation: identity (trace/plan/decision), actor and
    provider metadata, token usage, cost, latency, tool calls,
    context fingerprint, streaming flag, retry count, and any
    error message.

    Attributes:
        trace_id: ULID uniquely identifying this trace entry.
        plan_id: ULID of the plan that owns this trace.
        decision_id: Optional ULID of the decision that triggered
            this call.
        actor: Name of the actor that made the call.
        provider: LLM provider identifier (e.g. ``openai``,
            ``anthropic``).
        model: Model name (e.g. ``gpt-4o``, ``claude-3-opus``).
        prompt_tokens: Number of tokens in the prompt.
        completion_tokens: Number of tokens in the completion.
        cost_usd: Estimated cost in USD for this call.
        latency_ms: Wall-clock latency in milliseconds.
        tool_calls: List of tool call descriptors returned by the
            model, if any.
        context_hash: SHA-256 fingerprint of the context window
            sent to the model, for deduplication analysis.
        streaming: Whether the call used streaming mode.
        retry_count: Number of retries before success (0 = first
            attempt succeeded).
        error: Error message if the call failed, ``None`` on success.
    """

    trace_id: str = Field(
        ...,
        min_length=26,
        max_length=26,
        description="ULID uniquely identifying this trace entry",
    )
    plan_id: str = Field(
        ...,
        min_length=26,
        max_length=26,
        description="ULID of the plan that owns this trace",
    )
    decision_id: str | None = Field(
        default=None,
        description="Optional ULID of the decision that triggered this call",
    )
    actor: str = Field(
        ...,
        min_length=1,
        max_length=255,
        description="Name of the actor that made the call",
    )
    provider: str = Field(
        ...,
        min_length=1,
        max_length=128,
        description="LLM provider identifier (e.g. openai, anthropic)",
    )
    model: str = Field(
        ...,
        min_length=1,
        max_length=255,
        description="Model name (e.g. gpt-4o, claude-3-opus)",
    )
    prompt_tokens: int = Field(
        ...,
        ge=0,
        description="Number of tokens in the prompt",
    )
    completion_tokens: int = Field(
        ...,
        ge=0,
        description="Number of tokens in the completion",
    )
    cost_usd: float = Field(
        ...,
        ge=0.0,
        description="Estimated cost in USD for this call",
    )
    latency_ms: float = Field(
        ...,
        ge=0.0,
        description="Wall-clock latency in milliseconds",
    )
    tool_calls: list[dict[str, Any]] = Field(
        default_factory=list,
        description="Tool call descriptors returned by the model",
    )
    context_hash: str | None = Field(
        default=None,
        max_length=64,
        description="SHA-256 fingerprint of the context window",
    )
    streaming: bool = Field(
        default=False,
        description="Whether the call used streaming mode",
    )
    retry_count: int = Field(
        default=0,
        ge=0,
        description="Number of retries before success",
    )
    error: str | None = Field(
        default=None,
        description="Error message if the call failed",
    )
    timestamp: datetime = Field(
        default_factory=datetime.utcnow,
        description="When the trace was recorded",
    )

    @field_validator("trace_id", "plan_id")
    @classmethod
    def _validate_ulid(cls: type[LLMTrace], v: str) -> str:
        """Ensure ULID fields match Crockford's base32 pattern."""
        if not re.match(ULID_PATTERN, v):
            raise ValueError(f"Invalid ULID: {v!r}")
        return v

    @field_validator("decision_id")
    @classmethod
    def _validate_optional_ulid(cls: type[LLMTrace], v: str | None) -> str | None:
        """Validate optional ULID field."""
        if v is not None and not re.match(ULID_PATTERN, v):
            raise ValueError(f"Invalid ULID: {v!r}")
        return v

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
    )


class LLMTraceQuery(BaseModel):
    """Query parameters for filtering LLM traces.

    All filter fields are optional; omitted fields match everything.

    Attributes:
        plan_id: Filter by plan ULID.
        decision_id: Filter by decision ULID.
        actor: Filter by actor name.
        provider: Filter by provider identifier.
        model: Filter by model name.
        min_cost_usd: Minimum cost threshold.
        max_cost_usd: Maximum cost threshold.
        min_latency_ms: Minimum latency threshold.
        max_latency_ms: Maximum latency threshold.
        has_error: If set, filter by error presence.
        limit: Maximum number of results (default 100).
        offset: Pagination offset (default 0).
    """

    plan_id: str | None = Field(default=None, description="Filter by plan ULID")
    decision_id: str | None = Field(default=None, description="Filter by decision ULID")
    actor: str | None = Field(default=None, description="Filter by actor name")
    provider: str | None = Field(
        default=None, description="Filter by provider identifier"
    )
    model: str | None = Field(default=None, description="Filter by model name")
    min_cost_usd: float | None = Field(
        default=None, ge=0.0, description="Minimum cost threshold"
    )
    max_cost_usd: float | None = Field(
        default=None, ge=0.0, description="Maximum cost threshold"
    )
    min_latency_ms: float | None = Field(
        default=None, ge=0.0, description="Minimum latency threshold"
    )
    max_latency_ms: float | None = Field(
        default=None, ge=0.0, description="Maximum latency threshold"
    )
    has_error: bool | None = Field(default=None, description="Filter by error presence")
    limit: int = Field(default=100, ge=1, le=10000, description="Max results")
    offset: int = Field(default=0, ge=0, description="Pagination offset")

    model_config = ConfigDict(
        str_strip_whitespace=True,
    )
