# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/when-must-an-a2a-v1-0-agent-reject-a-task-state-transition-because-the-push-notification-webhook-binding-has-silently-ro.md
# Remnant Warrant:    https://corpusfetch.com/c/when-must-an-a2a-v1-0-agent-reject-a-task-state-transition-because-the-push-notification-webhook-binding-has-silently-ro.warrant.json
# Markdown SHA-256:   19a6942c144aa35251fc69cbc3f99175ee6f0bdb0083b97cf2cc9d6127201f27
# Code AST SHA-256:   73903ba0e242d05e2e20f4750bb40cdde8c20b86b26ff41372587d19d79a247c
# Verified Classes:   PushBindingError, DeliveryClass, PushBinding
# Defensive Guards:   2 fail-closed invariants (zero external deps)

from __future__ import annotations

import time
from dataclasses import dataclass
from enum import Enum


class PushBindingError(RuntimeError):
    """Raised when a push-notification binding is invalid for a notification-bearing transition."""


class DeliveryClass(Enum):
    OK = "ok"
    CREDENTIAL = "credential"   # 401/403/410/TLS mismatch -> binding invalid, no replay
    TRANSPORT = "transport"     # 429/5xx/timeout -> retry permitted


@dataclass(frozen=True)
class PushBinding:
    url: str
    expires_at: float | None      # epoch seconds, parsed from sig or declared
    context_id: str

    def is_provably_expired(self, now: float | None = None) -> bool:
        now = time.time() if now is None else now
        return self.expires_at is not None and now >= self.expires_at


def classify_delivery(status_code: int | None, tls_name_mismatch: bool) -> DeliveryClass:
    if tls_name_mismatch:
        return DeliveryClass.CREDENTIAL
    if status_code in (401, 403, 410):
        return DeliveryClass.CREDENTIAL
    if status_code is None or status_code == 429 or 500 <= status_code < 600:
        return DeliveryClass.TRANSPORT
    if 200 <= status_code < 300:
        return DeliveryClass.OK
    # Unknown 4xx: treat conservatively as credential-class to avoid leak.
    return DeliveryClass.CREDENTIAL


def guard_notification_bearing_transition(
    binding: PushBinding,
    new_state: str,
    client_has_poll_fallback: bool,
    now: float | None = None,
) -> None:
    """
    Called before emitting a notification for a state transition.
    Raises if the binding cannot be trusted for this transition.
    """
    NOTIFICATION_BEARING = {"input-required", "completed", "failed", "canceled"}
    if new_state not in NOTIFICATION_BEARING:
        return  # non-emitting transition; stale binding is harmless

    if binding.is_provably_expired(now):
        if new_state == "input-required" and not client_has_poll_fallback:
            raise PushBindingError(
                f"binding expired ({binding.url!r}) at input-required transition "
                f"with no poll fallback; refusing to strand task silently"
            )
        # Degraded-but-tolerable: mark stale, allow poll-based continuation.
        return


def handle_delivery_result(
    binding: PushBinding,
    status_code: int | None,
    tls_name_mismatch: bool,
    queued_payload: bytes,
) -> DeliveryClass:
    cls = classify_delivery(status_code, tls_name_mismatch)
    if cls is DeliveryClass.CREDENTIAL:
        # Purge held payload: endpoint identity is no longer trustworthy.
        del queued_payload
        raise PushBindingError(
            f"credential-class delivery failure for {binding.url!r} "
            f"(status={status_code}, tls_mismatch={tls_name_mismatch}); "
            f"binding invalidated, payload purged, replay forbidden"
        )
    return cls
