"""Tests for ``arborist.pi_star.protocol`` + ``arborist.pi_star.registry``
(#000015 Phase 1 substrate).

Both modules previously had zero direct tests despite being the
foundation every π* kernel rides on. Every concrete kernel
(``arithmetic@v1``, ``logic-kernel@v1``, ``algebra-symbolic@v1``,
…) implements the :class:`PiStar` Protocol; the registry holds the
``name@version`` → instance map that ``cache_key`` derives from.

Test discipline:
  - Use unique names per test to avoid polluting the global
    REGISTRY (no public unregister; mutation discipline per the
    module docstring).
  - Test the Protocol contract via tiny stub classes that satisfy
    the duck-type without inheriting.
"""

from __future__ import annotations

import hashlib

import pytest

from arborist.pi_star import registry as reg
from arborist.pi_star.protocol import (
    PiStar,
    PiStarError,
    assert_round_trip,
    equivalence_class_id,
    registry_key,
)


# --- stub π* implementations ----------------------------------------


class _IdentityStub:
    """π* that passes raw bytes through unchanged. Trivially
    idempotent. Used as a baseline for the Protocol contract."""

    def __init__(self, name: str = "identity-stub", version: str = "v1",
                 domain: str = "text"):
        self.name = name
        self.version = version
        self.domain = domain

    def canonicalize(self, raw: bytes) -> bytes:
        return raw


class _StripWhitespaceStub:
    """π* that strips whitespace. Idempotent: stripping twice =
    stripping once."""

    name = "strip-ws-stub"
    version = "v1"
    domain = "text"

    def canonicalize(self, raw: bytes) -> bytes:
        return raw.strip()


class _NonIdempotentStub:
    """π* that doubles its input. NOT idempotent — used only to
    test that assert_round_trip catches the violation."""

    name = "double-stub"
    version = "v1"
    domain = "text"

    def canonicalize(self, raw: bytes) -> bytes:
        return raw + raw


class _DomainGuardStub:
    """π* that raises PiStarError on bytes that don't start with 'X:'.
    Used to test that the protocol's contract honors the
    PiStarError-on-out-of-domain rule."""

    name = "domain-guard-stub"
    version = "v1"
    domain = "text"

    def canonicalize(self, raw: bytes) -> bytes:
        if not raw.startswith(b"X:"):
            raise PiStarError(f"out-of-domain input: {raw!r}")
        return raw[2:]


# --- protocol — runtime checkable -----------------------------------


def test_pistar_runtime_checkable():
    """@runtime_checkable Protocol → instances satisfying the
    duck-type pass `isinstance(x, PiStar)` without inheriting."""
    assert isinstance(_IdentityStub(), PiStar)
    assert isinstance(_StripWhitespaceStub(), PiStar)
    # An object with all 3 required attrs + canonicalize method
    # passes the runtime check.


def test_pistar_runtime_check_rejects_missing_method():
    """Object missing `canonicalize` → not a PiStar."""
    class _Incomplete:
        name = "x"
        version = "v1"
        domain = "text"
        # no canonicalize method
    # @runtime_checkable Protocol checks attribute presence at
    # isinstance-time; an object missing the method is rejected.
    assert not isinstance(_Incomplete(), PiStar)


# --- registry_key ---------------------------------------------------


def test_registry_key_format():
    """``registry_key`` returns ``name@version`` exactly."""
    s = _IdentityStub(name="my-kernel", version="v3")
    assert registry_key(s) == "my-kernel@v3"


def test_registry_key_distinct_versions_distinct_keys():
    a = _IdentityStub(name="kern", version="v1")
    b = _IdentityStub(name="kern", version="v2")
    assert registry_key(a) != registry_key(b)


# --- equivalence_class_id ------------------------------------------


def test_equivalence_class_id_sha256_over_canonical_bytes():
    """``equivalence_class_id`` is sha256 over canonical bytes."""
    s = _IdentityStub()
    raw = b"hello world"
    expected = hashlib.sha256(raw).hexdigest()
    assert equivalence_class_id(s, raw) == expected


def test_equivalence_class_id_invariant_under_pre_canonical_form():
    """Two raw inputs that canonicalize to the same bytes → same
    equivalence_class_id. Demonstrates the canonicalization
    fundamental property."""
    s = _StripWhitespaceStub()
    a = b"  hello  "
    b = b"hello"
    # Both canonicalize to b"hello".
    assert equivalence_class_id(s, a) == equivalence_class_id(s, b)


def test_equivalence_class_id_distinguishes_different_canonicals():
    s = _IdentityStub()
    assert equivalence_class_id(s, b"a") != equivalence_class_id(s, b"b")


# --- assert_round_trip ----------------------------------------------


def test_assert_round_trip_passes_on_idempotent():
    """Idempotent π* survives the round-trip check."""
    assert_round_trip(_IdentityStub(), b"any input")
    assert_round_trip(_StripWhitespaceStub(), b"  spaced  ")


def test_assert_round_trip_raises_on_non_idempotent():
    """Non-idempotent π* → AssertionError naming the kernel."""
    with pytest.raises(AssertionError, match="double-stub@v1"):
        assert_round_trip(_NonIdempotentStub(), b"doubles")


def test_assert_round_trip_propagates_pi_star_error():
    """If `canonicalize` itself raises PiStarError on the input,
    that propagates (round-trip can't be checked on out-of-domain
    input — caller's responsibility to gate)."""
    with pytest.raises(PiStarError, match="out-of-domain"):
        assert_round_trip(_DomainGuardStub(), b"no prefix")


# --- domain-guard contract ------------------------------------------


def test_canonicalize_raises_pi_star_error_on_undefined_input():
    """Per the protocol docstring: 'canonicalize raises PiStarError
    when raw is outside the projection's domain.'"""
    s = _DomainGuardStub()
    with pytest.raises(PiStarError):
        s.canonicalize(b"missing prefix")


def test_canonicalize_succeeds_within_domain():
    s = _DomainGuardStub()
    out = s.canonicalize(b"X:in-domain")
    assert out == b"in-domain"


# --- registry — register / get / list_keys / domains ----------------


@pytest.fixture
def isolated_registry(monkeypatch):
    """Replace REGISTRY with an empty dict for the duration of a
    test, restoring on teardown. Avoids polluting the global
    registry (which has no public unregister per the module
    docstring discipline)."""
    monkeypatch.setattr(reg, "REGISTRY", {})


def test_register_inserts_and_get_retrieves(isolated_registry):
    s = _IdentityStub(name="reg-test-kernel", version="v1")
    reg.register(s)
    assert reg.get("reg-test-kernel@v1") is s


def test_register_idempotent_for_same_instance(isolated_registry):
    """Re-registering the SAME instance is a no-op (no error)."""
    s = _IdentityStub(name="idem-kernel", version="v1")
    reg.register(s)
    reg.register(s)  # must not raise
    assert reg.get("idem-kernel@v1") is s


def test_register_rejects_different_instance_at_same_key(isolated_registry):
    """Two different instances claiming the same name@version →
    PiStarError (cache_key invariant: name@version is content-pinned)."""
    a = _IdentityStub(name="dup-kernel", version="v1")
    b = _IdentityStub(name="dup-kernel", version="v1")  # different instance
    reg.register(a)
    with pytest.raises(PiStarError, match="already bound"):
        reg.register(b)


def test_register_allows_different_versions_same_name(isolated_registry):
    """Same name, different version → distinct keys, both register OK.
    Lets a v2 kernel coexist with v1 (cache_key invariant unaffected)."""
    a = _IdentityStub(name="multi-version-kernel", version="v1")
    b = _IdentityStub(name="multi-version-kernel", version="v2")
    reg.register(a)
    reg.register(b)
    assert reg.get("multi-version-kernel@v1") is a
    assert reg.get("multi-version-kernel@v2") is b


def test_get_raises_keyerror_on_unknown_key(isolated_registry):
    with pytest.raises(KeyError, match="unknown"):
        reg.get("never-registered@v1")


def test_list_keys_returns_sorted_keys(isolated_registry):
    """Sorted return order is part of the contract — bench harnesses
    iterate the list expecting deterministic order."""
    reg.register(_IdentityStub(name="zeta", version="v1"))
    reg.register(_IdentityStub(name="alpha", version="v1"))
    reg.register(_IdentityStub(name="mu", version="v1"))
    keys = reg.list_keys()
    assert keys == sorted(keys)
    assert keys == ["alpha@v1", "mu@v1", "zeta@v1"]


def test_domains_groups_by_domain(isolated_registry):
    reg.register(_IdentityStub(name="text-kern-a", version="v1", domain="text"))
    reg.register(_IdentityStub(name="text-kern-b", version="v1", domain="text"))
    reg.register(_IdentityStub(name="logic-kern", version="v1", domain="logic"))
    domains = reg.domains()
    assert set(domains.keys()) == {"text", "logic"}
    assert domains["text"] == ["text-kern-a@v1", "text-kern-b@v1"]
    assert domains["logic"] == ["logic-kern@v1"]


def test_domains_per_domain_keys_sorted(isolated_registry):
    """Each domain's key list is sorted (deterministic for cache_key
    derivation downstream)."""
    reg.register(_IdentityStub(name="z-kern", version="v1", domain="text"))
    reg.register(_IdentityStub(name="a-kern", version="v1", domain="text"))
    reg.register(_IdentityStub(name="m-kern", version="v1", domain="text"))
    domains = reg.domains()
    assert domains["text"] == sorted(domains["text"])


def test_domains_empty_when_registry_empty(isolated_registry):
    assert reg.domains() == {}
