# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/what-structural-signals-indicate-a-rewrite-operator-has-invalidated-a-retained-span-under-self-editing-context-policies.md
# Remnant Warrant:    https://corpusfetch.com/c/what-structural-signals-indicate-a-rewrite-operator-has-invalidated-a-retained-span-under-self-editing-context-policies.warrant.json
# Markdown SHA-256:   5c25deaaecc78bc4e8c8358fbb7474d929900e286b9e1fc193e81582d372d427
# Code AST SHA-256:   d6b38904e37b640b3d38406356198fa220f5605e2f4c3dd96973fefdbf10636d
# Verified Classes:   StaleSpanError, Span, DerivedArtifact, ContextBuffer
# Defensive Guards:   4 fail-closed invariants (zero external deps)

import hashlib
from dataclasses import dataclass, field


class StaleSpanError(RuntimeError):
    """Raised when a derived artifact is used against a span whose content changed."""


@dataclass
class Span:
    span_id: str
    content: str
    version: int = 0
    content_hash: str = field(default="")

    def __post_init__(self):
        self.content_hash = self._digest(self.content)

    @staticmethod
    def _digest(text: str) -> str:
        return hashlib.sha256(text.encode("utf-8")).hexdigest()


@dataclass
class DerivedArtifact:
    span_id: str
    version: int
    content_hash: str
    offsets: tuple  # (start, end) token offsets into the span


class ContextBuffer:
    def __init__(self):
        self._spans: dict[str, Span] = {}

    def add(self, span: Span) -> None:
        self._spans[span.span_id] = span

    def rewrite(self, span_id: str, new_content: str, op: str) -> Span:
        span = self._spans[span_id]
        # Copy-on-rewrite: new ID, old span tombstoned but readable.
        new_span = Span(span_id=f"{span_id}@{op}:{span.version + 1}", content=new_content)
        new_span.version = span.version + 1
        self._spans[new_span.span_id] = new_span
        return new_span

    def derive(self, span_id: str, offsets: tuple) -> DerivedArtifact:
        span = self._spans[span_id]
        return DerivedArtifact(span.span_id, span.version, span.content_hash, offsets)

    def use(self, artifact: DerivedArtifact, span_id: str) -> str:
        span = self._spans.get(span_id)
        if span is None:
            raise StaleSpanError(f"span {span_id} no longer present")
        if span.version != artifact.version or span.content_hash != artifact.content_hash:
            raise StaleSpanError(
                f"artifact built at version {artifact.version}/{artifact.content_hash[:8]} "
                f"but span {span_id} is now version {span.version}/{span.content_hash[:8]}"
            )
        start, end = artifact.offsets
        if end > len(span.content):
            raise StaleSpanError(
                f"offsets {artifact.offsets} exceed current length {len(span.content)}"
            )
        return span.content[start:end]


def safe_use(buffer: ContextBuffer, artifact: DerivedArtifact, span_id: str) -> str:
    # Guard against in-place mutation sneaking past copy-on-rewrite.
    span = buffer._spans.get(span_id)
    if span is not None and span.content_hash != artifact.content_hash:
        raise StaleSpanError(
            f"content hash divergence at {span_id}: span mutated in place"
        )
    return buffer.use(artifact, span_id)
