"""``arithmetic@v1`` π* — exact rational arithmetic canonicalizer.

Domain: ``arithmetic``. Implements SQD whitepaper §14.1's
``π*_Q : Σ* → ℚ ∪ {⊥}`` projection: parse an arithmetic expression
string, evaluate it exactly using :class:`fractions.Fraction`, and
serialize the result as ``"<numerator>/<denominator>"`` in lowest
terms.

Why this matters
----------------
SQD §14 frames math as "operations over π*-canonical invariant
objects: integers, algebraic expressions, proof states, constraint
graphs." Until this commit, arborist had logic kernels (Syllogism,
Truthtables, Transitivity) but no arithmetic π*. The classic
SQD-whitepaper test case ``"0.1 + 0.2 == 0.3"`` was unverifiable.

This canonicalizer handles it: ``"0.1+0.2"`` parses to
``Fraction(1,10) + Fraction(1,5) = Fraction(3,10)`` exactly. No
floating-point drift.

Allowed surface
---------------
- Integer literals: ``1``, ``42``, ``-7``
- Decimal literals: ``0.1``, ``3.14`` — parsed via
  :class:`decimal.Decimal` for **exact** rational interpretation.
- Binary operators: ``+ - * /``
- Unary operators: ``+ -``
- Integer exponents: ``a ** n`` where ``n`` is integer
- Parentheses

Rejected (raises :class:`PiStarError`)
--------------------------------------
- Identifiers / variables (this is closed-form arithmetic).
- Non-integer exponents (would yield irrational; out of ℚ scope).
- Division by zero.
- Function calls, comparisons, logical ops.
- Unicode operators outside the allowlist.

Equivalence classes preserved
-----------------------------
- ``"1+2"`` ≡ ``"3"``
- ``"0.1"`` ≡ ``"1/10"``
- ``"0.5"`` ≡ ``"1/2"``
- ``"0.1+0.2"`` ≡ ``"0.3"``
- ``"(1+2)*3"`` ≡ ``"9"``
- ``"6/4"`` ≡ ``"3/2"`` (lowest terms)
- ``"-2"`` ≡ ``"-2/1"``

Equivalence classes kept distinct
---------------------------------
- ``"1+2"`` ≢ ``"4"``
- ``"0.1+0.2"`` ≢ ``"0.30000000000000004"`` (only float-binary
  arithmetic produces the latter; we never enter float space).

Round-trip property
-------------------
Idempotent: ``canonicalize("3/10")`` produces ``b"3/10"`` and
re-canonicalizing those bytes yields the same. The output is valid
input syntax (`a/b` is a valid rational expression).

Versioning
----------
``arithmetic@v1`` pins this algorithm. Any change to the operator
allowlist, exponent semantics, or output format requires a new
version.

Source: ticket #000015 §1.7 reserved an arithmetic carrier; this
commit graduates it. Closes the math half of the 2026-05-08 fox
roadmap note ("did we implement the math and logic stuff?").
"""

from __future__ import annotations

import ast
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction

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


@dataclass
class ArithmeticV1:
    name: str = "arithmetic"
    version: str = "v1"
    domain: str = "arithmetic"

    def canonicalize(self, raw: bytes) -> bytes:
        if not isinstance(raw, (bytes, bytearray)):
            raise PiStarError(
                "arithmetic@v1 expects bytes; got "
                f"{type(raw).__name__}"
            )
        try:
            source = 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
        source = source.strip()
        if not source:
            raise PiStarError("arithmetic@v1 input is empty")

        # Allow `a/b` rational form with negative numerator. Detect
        # before ast.parse so divisions of integers yield exact
        # rationals (ast would parse them as Div which we evaluate to
        # Fraction; this works either way but the early path is
        # simpler).
        try:
            tree = ast.parse(source, mode="eval")
        except SyntaxError as exc:
            raise PiStarError(
                f"arithmetic@v1 input is not valid expression: {exc}"
            ) from exc

        result = _eval_node(tree.body)
        if not isinstance(result, Fraction):
            raise PiStarError(
                f"arithmetic@v1 evaluation returned non-Fraction: {type(result).__name__}"
            )
        # Canonical form: "<num>/<den>" in lowest terms. Fraction
        # already maintains coprime invariant.
        return f"{result.numerator}/{result.denominator}".encode("utf-8")


_ALLOWED_BINOPS = {
    ast.Add: lambda l, r: l + r,
    ast.Sub: lambda l, r: l - r,
    ast.Mult: lambda l, r: l * r,
}


def _eval_node(node) -> Fraction:
    if isinstance(node, ast.Constant):
        v = node.value
        if isinstance(v, bool):
            # bool subclasses int — exclude explicitly to avoid
            # accidentally accepting True/False literals.
            raise PiStarError("arithmetic@v1 rejects boolean literals")
        if isinstance(v, int):
            return Fraction(v)
        if isinstance(v, float):
            # Round-trip via Decimal(str(...)) gives the *intended*
            # rational (e.g., 0.1 → 1/10, not 1/10 + ε).
            return Fraction(Decimal(str(v)))
        raise PiStarError(
            f"arithmetic@v1 rejects literal of type {type(v).__name__}"
        )
    if isinstance(node, ast.UnaryOp):
        operand = _eval_node(node.operand)
        if isinstance(node.op, ast.UAdd):
            return operand
        if isinstance(node.op, ast.USub):
            return -operand
        raise PiStarError(
            f"arithmetic@v1 rejects unary op {type(node.op).__name__}"
        )
    if isinstance(node, ast.BinOp):
        left = _eval_node(node.left)
        right = _eval_node(node.right)
        op_type = type(node.op)
        if op_type in _ALLOWED_BINOPS:
            return _ALLOWED_BINOPS[op_type](left, right)
        if op_type is ast.Div:
            if right == 0:
                raise PiStarError("arithmetic@v1 division by zero")
            return left / right
        if op_type is ast.Pow:
            if right.denominator != 1:
                raise PiStarError(
                    "arithmetic@v1 rejects non-integer exponent "
                    f"({right}); would leave ℚ"
                )
            return left ** int(right.numerator)
        raise PiStarError(
            f"arithmetic@v1 rejects binary op {op_type.__name__}"
        )
    if isinstance(node, ast.Name):
        raise PiStarError(
            f"arithmetic@v1 rejects identifier {node.id!r}; closed-form only"
        )
    if isinstance(node, ast.Call):
        raise PiStarError("arithmetic@v1 rejects function calls")
    raise PiStarError(
        f"arithmetic@v1 rejects node type {type(node).__name__}"
    )


register(ArithmeticV1())
