# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/how-should-an-instance-reconcile-opentelemetry-genai-tool-call-spans-when-a-provider-emits-both-streaming-deltas-and-a-terminal-usage-block-that-disag.md
# Remnant Warrant:    https://corpusfetch.com/c/how-should-an-instance-reconcile-opentelemetry-genai-tool-call-spans-when-a-provider-emits-both-streaming-deltas-and-a-terminal-usage-block-that-disag.warrant.json
# Markdown SHA-256:   f25d1b90891522ee594ec4d0e01d5ad35091546f696904b5c27374041c84f01f
# Code AST SHA-256:   acc8fa313d4037b905ffb4dd16ab21f2918a2ecdc1b765f2fe648e7775c91a7a
# Verified Classes:   GenAIUsageReconciliationError, UsageBlock, SpanUsage
# Defensive Guards:   5 fail-closed invariants (zero external deps)

from __future__ import annotations

from dataclasses import dataclass


class GenAIUsageReconciliationError(RuntimeError):
    """Raised when parent and child GenAI token attributions cannot be reconciled."""


@dataclass(frozen=True)
class UsageBlock:
    input_tokens: int | None
    output_tokens: int | None
    scope: str  # "turn" or "exchange"


@dataclass(frozen=True)
class SpanUsage:
    operation_name: str  # gen_ai.operation.name
    usage: UsageBlock | None


def reconcile_parent_usage(
    terminal: UsageBlock,
    parent_span: SpanUsage,
    child_spans: list[SpanUsage],
) -> dict[str, int | None]:
    """Return authoritative parent usage attributes.

    Rule: the terminal block is authoritative for the parent chat span when
    it is turn-scoped. Children that performed no model invocation must have
    usage=None (absent), never a zeroed block.
    """
    if terminal.scope not in ("turn", "exchange"):
        raise GenAIUsageReconciliationError(
            f"unknown usage scope {terminal.scope!r}; cannot decide authority"
        )

    if parent_span.operation_name != "chat":
        raise GenAIUsageReconciliationError(
            f"parent span has operation {parent_span.operation_name!r}, expected 'chat'"
        )

    if terminal.scope == "exchange":
        # Children are the only per-turn observables; parent must not claim usage.
        for child in child_spans:
            if child.usage is None:
                raise GenAIUsageReconciliationError(
                    "exchange-scoped terminal block requires per-child usage; "
                    f"child {child.operation_name!r} has none"
                )
        return {"gen_ai.usage.input_tokens": None, "gen_ai.usage.output_tokens": None}

    # turn-scoped: parent trusts terminal block verbatim.
    child_sum_output = sum(
        c.usage.output_tokens or 0
        for c in child_spans
        if c.usage is not None and c.operation_name == "chat"
    )
    if (
        parent_span.usage is not None
        and parent_span.usage.output_tokens is not None
        and parent_span.usage.output_tokens == child_sum_output
        and parent_span.usage.output_tokens != terminal.output_tokens
    ):
        raise GenAIUsageReconciliationError(
            "parent span is summing child usage instead of trusting terminal block: "
            f"parent={parent_span.usage.output_tokens} "
            f"terminal={terminal.output_tokens} child_sum={child_sum_output}"
        )

    for child in child_spans:
        if child.operation_name == "execute_tool" and child.usage is not None:
            if child.usage.output_tokens == 0:
                raise GenAIUsageReconciliationError(
                    f"tool span {child.operation_name!r} records zero usage; "
                    "omit the attribute instead to signal 'not measured'"
                )

    return {
        "gen_ai.usage.input_tokens": terminal.input_tokens,
        "gen_ai.usage.output_tokens": terminal.output_tokens,
    }
