"""``combinatorics@v1`` π* — pure-integer counting kernel.

Domain: ``combinatorics``. A tighter sibling of
``algebra-symbolic@v1``: same input parser (SymPy ``sympify``),
narrower output discipline. Any input whose canonical reduction is
not a non-negative ``sp.Integer`` raises :class:`PiStarError`.

Why this matters
----------------
Ticket #000032. ``algebra-symbolic@v1`` already accepts inputs
like ``binomial(5, 2)`` and returns ``Integer(10)``, but it
silently accepts cases that fall outside classical counting
semantics:

- Symbolic inputs (``binomial(n, k)`` → ``binomial(n, k)``) — no
  concrete count is implied; the answer is opaque.
- Generalized inputs (``binomial(-3, 2) → 6``,
  ``binomial(Rational(1,2), 3) → 1/16``) — SymPy extends
  combinatorial primitives via the Gamma function; classical
  counting interpretation rejects negative or non-integer
  arguments.

A combinatorics kernel should fail closed on all of these. That
discipline is what distinguishes ``combinatorics@v1`` from the
expand-only sibling.

Allowed surface
---------------
SymPy combinatorial primitives that produce non-negative integers
on integer inputs: ``binomial``, ``factorial``, ``ff`` (falling),
``rf`` (rising), ``catalan``, ``bell``, ``stirling``,
``partition``. Plus arithmetic compositions over those primitives:
``3*binomial(5, 2) + factorial(4) = 54`` is accepted and
canonicalizes to ``b"54"``.

Rejected (raises :class:`PiStarError`)
--------------------------------------
- Symbolic results (free symbols remain after simplify).
- Negative integer results (counting domain).
- Non-integer results (rationals, floats, complex).
- Boolean / relational inputs (use ``logic-kernel@v1``).
- Empty / unparseable inputs.

Output canonical bytes
----------------------
``str(int(reduced)).encode("utf-8")`` — a plain decimal literal
like ``b"10"``, ``b"5040"``. Compose with ``arithmetic@v1`` for
byte-identical agreement with the rational route
(``b"10/1"``); the multi-modality witness (#000028) uses that
composition to pin equivalence-class agreement when both routes
fire on the same question.

Round-trip property
-------------------
Idempotent: ``canonicalize(canonicalize(x))`` matches
``canonicalize(x)`` because the output is a plain decimal literal,
which sympify parses as the same ``Integer`` and simplify is a
no-op on ``Integer``.

Optional dependency
-------------------
SymPy via the ``[math]`` extras (``pip install 'arborist[math]'``).
When SymPy is absent, :func:`canonicalize` raises ``PiStarError``
rather than crashing at import. Tests gate on
:func:`pytest.importorskip("sympy")`.

Source: ticket #000032 — landed 2026-05-09.
"""

from __future__ import annotations

from dataclasses import dataclass

from arborist.pi_star.protocol import PiStarError
from arborist.pi_star.registry import register


try:
    import sympy as sp  # type: ignore[import-not-found]
except ImportError:  # pragma: no cover — optional extra
    sp = None  # type: ignore[assignment]


_SYMPY_REQUIRED_MSG = (
    "combinatorics@v1 requires sympy; install with "
    "`pip install 'arborist[math]'`"
)


@dataclass
class CombinatoricsV1:
    name: str = "combinatorics"
    version: str = "v1"
    domain: str = "combinatorics"

    def canonicalize(self, raw: bytes) -> bytes:
        if sp is None:
            raise PiStarError(_SYMPY_REQUIRED_MSG)
        if not isinstance(raw, (bytes, bytearray)):
            raise PiStarError(
                "combinatorics@v1 expects bytes; got "
                f"{type(raw).__name__}"
            )
        try:
            text = raw.decode("utf-8", errors="surrogatepass")
        except UnicodeDecodeError as exc:  # pragma: no cover — defensive
            raise PiStarError(f"input not valid UTF-8: {exc}") from exc
        text = text.strip()
        if not text:
            raise PiStarError("combinatorics@v1 input is empty")

        try:
            expr = sp.sympify(text)
        except (sp.SympifyError, SyntaxError, TypeError) as exc:
            raise PiStarError(
                f"combinatorics@v1 cannot parse {text!r}: {exc}"
            ) from exc

        # Reject relationals + boolean operators; same Expr-vs-Boolean
        # filter as algebra_symbolic.py (Symbol inherits from Boolean,
        # so the right rejection is "not an Expr" rather than "is Boolean").
        if not isinstance(expr, sp.Expr):
            raise PiStarError(
                f"combinatorics@v1 rejects boolean/relational input "
                f"{text!r}; use logic-kernel@v1 for that shape"
            )

        try:
            reduced = sp.simplify(expr)
        except (TypeError, ValueError) as exc:
            raise PiStarError(
                f"combinatorics@v1 simplify failed on {text!r}: {exc}"
            ) from exc

        if not isinstance(reduced, sp.Integer):
            raise PiStarError(
                f"combinatorics@v1 result is not a concrete integer: "
                f"{reduced!r} (input was {text!r})"
            )
        if reduced < 0:
            raise PiStarError(
                f"combinatorics@v1 result is negative: {int(reduced)} "
                f"(input was {text!r})"
            )

        return str(int(reduced)).encode("utf-8")


if sp is not None:
    register(CombinatoricsV1())
