# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/how-should-an-instance-reconcile-genai-agent-span-token-accounting-when-tool-call-child-spans-double-count-completion-tokens-across-nested-agent-invoc.md
# Remnant Warrant:    https://corpusfetch.com/c/how-should-an-instance-reconcile-genai-agent-span-token-accounting-when-tool-call-child-spans-double-count-completion-tokens-across-nested-agent-invoc.warrant.json
# Markdown SHA-256:   0c149f213f505f53015f5ac5ed5eb1cd532b0e8121e3467d022fac4529942f17
# Code AST SHA-256:   086d48c1314a978bba81fa60167f891a57401f728f044b332d2a2e9b7e41558f
# Verified Classes:   GenAIUsageAttributionError, Span
# Defensive Guards:   3 fail-closed invariants (zero external deps)

from dataclasses import dataclass, field
from typing import Iterable


class GenAIUsageAttributionError(RuntimeError):
    """Raised when a GenAI span's usage cannot be attributed to exactly one owner."""


@dataclass
class Span:
    span_id: str
    parent_id: str | None
    operation: str  # "chat", "execute_tool", "invoke_agent"
    output_tokens: int | None = None
    children: list["Span"] = field(default_factory=list)


def _has_descendant_chat(span: Span) -> bool:
    stack = list(span.children)
    while stack:
        node = stack.pop()
        if node.operation == "chat":
            return True
        stack.extend(node.children)
    return False


def attribute_turn_usage(root: Span) -> int:
    """Sum output tokens owned by exactly one chat span under root.

    Usage on non-chat spans is advisory and excluded. A non-chat span with
    descendant chat spans is a container; a non-chat span with usage but no
    descendant chat spans is a re-emission and must not be summed.
    """
    total = 0
    stack = [root]
    while stack:
        span = stack.pop()
        if span.operation == "chat":
            if span.output_tokens is None:
                raise GenAIUsageAttributionError(
                    f"chat span {span.span_id} missing gen_ai.usage.output_tokens"
                )
            total += span.output_tokens
        elif span.operation == "execute_tool":
            if span.output_tokens is not None and not _has_descendant_chat(span):
                # Re-emitted parent usage with no inference beneath it: advisory only.
                pass
            elif span.output_tokens is not None and _has_descendant_chat(span):
                # Parent usage re-emitted above real inference: exclude to avoid double-count.
                pass
        elif span.operation == "invoke_agent":
            if span.output_tokens is not None:
                raise GenAIUsageAttributionError(
                    f"invoke_agent span {span.span_id} must not carry usage attributes"
                )
        else:
            raise GenAIUsageAttributionError(
                f"unknown gen_ai.operation.name={span.operation!r} on {span.span_id}"
            )
        stack.extend(span.children)
    return total
