# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/what-happens-when-an-instance-of-a-genai-agent-span-is-exported-before-its-tool-call-child-spans-are-finalized.md
# Remnant Warrant:    https://corpusfetch.com/c/what-happens-when-an-instance-of-a-genai-agent-span-is-exported-before-its-tool-call-child-spans-are-finalized.warrant.json
# Markdown SHA-256:   c1a6ebdd34fc61f1046622749bc76152032a471f3853235455cb87405a3c366f
# Code AST SHA-256:   cc49e011f93ef4f35595117242e22801be4a59a8810625c384725224ae8edbb8
# Verified Classes:   GenAiSpanOrderingError, SpanRecord
# Defensive Guards:   3 fail-closed invariants (zero external deps)

from __future__ import annotations

from dataclasses import dataclass


class GenAiSpanOrderingError(RuntimeError):
    """Raised when a GenAI span tree violates the parent/child temporal invariant."""


@dataclass(frozen=True)
class SpanRecord:
    name: str
    operation_name: str  # gen_ai.operation.name
    span_id: str
    parent_span_id: str | None
    start_ns: int
    end_ns: int
    tool_call_id: str | None = None
    turn_id: str | None = None


def validate_agent_turn(spans: list[SpanRecord]) -> None:
    """Enforce: every execute_tool span has an ancestor chat span ending after it,
    or carries an explicit turn-correlation attribute."""
    by_id = {s.span_id: s for s in spans}

    def ancestors(span: SpanRecord):
        cur = span
        while cur.parent_span_id is not None:
            parent = by_id.get(cur.parent_span_id)
            if parent is None:
                return
            yield parent
            cur = parent

    for span in spans:
        if span.operation_name != "execute_tool":
            continue

        # Tool call id must be present at start for event correlation.
        if span.tool_call_id is None:
            raise GenAiSpanOrderingError(
                f"execute_tool span {span.span_id} missing gen_ai.tool.call.id; "
                "event-to-span joins will fail under reordering"
            )

        chat_ancestor = next(
            (a for a in ancestors(span) if a.operation_name == "chat"), None
        )
        if chat_ancestor is None:
            if span.turn_id is None:
                raise GenAiSpanOrderingError(
                    f"execute_tool span {span.span_id} has no chat ancestor and no "
                    "gen_ai.agent.turn.id; trace is unjoinable"
                )
            continue

        if chat_ancestor.end_ns < span.end_ns:
            raise GenAiSpanOrderingError(
                f"temporal inversion: chat span {chat_ancestor.span_id} ends at "
                f"{chat_ancestor.end_ns} before tool span {span.span_id} ends at "
                f"{span.end_ns}; agent turn span was closed at model-return"
            )
