# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/where-must-an-instance-distinguish-resumable-sse-stream-from-replayable-streamable-http-session-the-event-id-cursor-vs-last-event-id-mcp-session-id-in.md
# Remnant Warrant:    https://corpusfetch.com/c/where-must-an-instance-distinguish-resumable-sse-stream-from-replayable-streamable-http-session-the-event-id-cursor-vs-last-event-id-mcp-session-id-in.warrant.json
# Markdown SHA-256:   eb523278c298de69177948ae70d0dd174e964de9587a73c834b9db89e813201c
# Code AST SHA-256:   ee9751d4f13bbc702c99c4b48dfa394b06a8e614f45c1a757fd2b1dee3edd571
# Verified Classes:   ResumeNamespaceMismatch, ResumeAttempt
# Defensive Guards:   3 fail-closed invariants (zero external deps)

"""Guard: refuse to send a Streamable-HTTP resume when the cursor namespace
does not provably match the session namespace. Prevents silent message loss
during SSE -> Streamable-HTTP migration."""

from dataclasses import dataclass


class ResumeNamespaceMismatch(RuntimeError):
    """Raised when Last-Event-ID and Mcp-Session-Id do not address the same
    replay buffer, so a 'successful' 200 would be a silent gap."""


@dataclass(frozen=True)
class ResumeAttempt:
    session_id: str | None          # Mcp-Session-Id held by the client
    last_event_id: str | None       # Last-Event-ID held by the client
    cursor_namespace: str           # which buffer the event id indexes
    session_namespace: str          # which buffer the session id selects
    pending_request_ids: frozenset[str]


def build_resume_headers(attempt: ResumeAttempt) -> dict[str, str]:
    if attempt.last_event_id is None:
        # Nothing to resume; a fresh GET is a new stream, not a resumption.
        return {}

    if attempt.session_id is None:
        # Legacy-SSE client migrated without a session id: the cursor
        # addresses a buffer the server will not select. This is the
        # classic silent-gap condition.
        raise ResumeNamespaceMismatch(
            "Last-Event-ID present but Mcp-Session-Id absent; "
            "cursor namespace cannot be bound to a session. "
            "Re-initialize or fall back to idempotent re-issue."
        )

    if attempt.cursor_namespace != attempt.session_namespace:
        raise ResumeNamespaceMismatch(
            f"cursor namespace {attempt.cursor_namespace!r} != "
            f"session namespace {attempt.session_namespace!r}; "
            "resume would select a different replay buffer."
        )

    return {
        "Mcp-Session-Id": attempt.session_id,
        "Last-Event-ID": attempt.last_event_id,
    }


def interpret_resume_response(
    attempt: ResumeAttempt,
    replayed_event_ids: list[str],
) -> str:
    """Classify a 200 resume response. Empty replay is NOT automatically 'caught up'."""
    if replayed_event_ids:
        return "resumed"

    if attempt.pending_request_ids:
        # Empty replay while requests are outstanding: the transport cannot
        # prove no messages were missed. Treat as loss-suspect.
        raise ResumeNamespaceMismatch(
            "Empty replay with pending requests "
            f"{sorted(attempt.pending_request_ids)}; "
            "cannot confirm no message loss. Re-issue idempotently."
        )

    return "caught_up"
