"""π* protocol: the contract every canonicalizer satisfies.

Per ticket #000015 §2.2:

- ``name`` + ``version`` form the registry key (``name@version``).
- ``domain`` declares the input bucket (``text``, ``code``, ``logic``,
  ``time-series``, ``tabular``, ``world``, ``claim-lattice``, ...).
- ``canonicalize(raw)`` produces canonical bytes; raises
  :class:`PiStarError` when ``raw`` is outside the projection's domain.
- ``equivalence_class_id(raw)`` returns SHA-256 over
  ``canonicalize(raw)`` — the equivalence-class label.

Determinism: every π* MUST satisfy ``canonicalize(canonicalize(x))
== canonicalize(x)`` (round-trip stability). The
:func:`assert_round_trip` helper exercises this on test inputs.
"""

from __future__ import annotations

import hashlib
from typing import Protocol, runtime_checkable


class PiStarError(Exception):
    """Raised when a π* is asked to canonicalize bytes outside its domain."""


@runtime_checkable
class PiStar(Protocol):
    """The π* contract.

    Implementations declare ``name``, ``version``, ``domain`` as
    class-level attributes (or instance attributes). ``canonicalize``
    is the single required method.
    """

    name: str
    version: str
    domain: str

    def canonicalize(self, raw: bytes) -> bytes:
        """Return canonical bytes; raise PiStarError on undefined input."""
        ...


def equivalence_class_id(pi_star: PiStar, raw: bytes) -> str:
    """SHA-256 over ``canonicalize(raw)``."""
    return hashlib.sha256(pi_star.canonicalize(raw)).hexdigest()


def assert_round_trip(pi_star: PiStar, raw: bytes) -> None:
    """Raise AssertionError if ``canonicalize`` is not idempotent on ``raw``."""
    once = pi_star.canonicalize(raw)
    twice = pi_star.canonicalize(once)
    if once != twice:
        raise AssertionError(
            f"π* {pi_star.name}@{pi_star.version} not idempotent on input"
        )


def registry_key(pi_star: PiStar) -> str:
    """Stable ``name@version`` key used by :mod:`registry`."""
    return f"{pi_star.name}@{pi_star.version}"
