"""``calculus-derivative@v1`` π* — symbolic-derivative canonicalizer.

Domain: ``calculus``. Computes the n-th derivative of a symbolic
expression with respect to a named variable, then re-canonicalizes
the result through the same expand+srepr recipe as
``algebra-symbolic@v1``.

Why this matters
----------------
Ticket #000030 §2.2 Phase 2: derivative is the smallest layer above
``algebra-symbolic@v1`` that adds a JSON-shaped input contract.
Composes naturally with ``arithmetic@v1`` for "evaluate the
derivative at a point" workflows via
:func:`arborist.pi_star.compose`.

Input shape (JSON, UTF-8)
-------------------------
::

    {"f": "<expression-text>", "x": "<variable-name>", "n": <order>}

- ``f`` — the expression to differentiate (parsed via
  :func:`sympy.sympify`).
- ``x`` — the variable name (creates a fresh
  :class:`sympy.Symbol`; if ``f`` mentions a different name for the
  same role, behavior depends on SymPy's name-binding).
- ``n`` — derivative order. Optional, defaults to ``1``. Must be a
  non-negative integer.

Equivalence classes preserved
-----------------------------
- ``d/dx(x**2)`` ≡ ``2*x``
- ``d²/dx²(x**3)`` ≡ ``6*x``
- ``d/dx(sin(x))`` ≡ ``cos(x)``
- ``d/dx(x*y)`` ≡ ``y`` (treats ``y`` as a constant w.r.t. ``x``).

Output canonical bytes
----------------------
``sp.srepr(sp.expand(sp.diff(f, x, n)))`` UTF-8 encoded — identical
recipe to ``algebra-symbolic@v1`` so the two compose cleanly under
:func:`arborist.pi_star.compose.compose`.

Round-trip property
-------------------
The output of ``canonicalize`` is an algebraic expression in srepr
form, NOT a JSON dict. Re-applying ``calculus-derivative@v1`` to its
own output would fail the JSON-shape check. This is intentional;
the natural follow-up π* is ``algebra-symbolic@v1`` (e.g., to
re-expand or compare two derivatives), and ``compose`` handles the
chain.

Optional dependency
-------------------
Same as ``algebra-symbolic@v1``: SymPy via the ``[math]`` extras.
When SymPy is absent, :func:`canonicalize` raises
:class:`PiStarError`. Tests gate on
:func:`pytest.importorskip("sympy")`.

Source: ticket #000030 §2.2 Phase 2.
"""

from __future__ import annotations

import json
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 = (
    "calculus-derivative@v1 requires sympy; install with "
    "`pip install 'arborist[math]'`"
)


@dataclass
class CalculusDerivativeV1:
    name: str = "calculus-derivative"
    version: str = "v1"
    domain: str = "calculus"

    def canonicalize(self, raw: bytes) -> bytes:
        if sp is None:
            raise PiStarError(_SYMPY_REQUIRED_MSG)
        if not isinstance(raw, (bytes, bytearray)):
            raise PiStarError(
                "calculus-derivative@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

        try:
            obj = json.loads(text)
        except json.JSONDecodeError as exc:
            raise PiStarError(
                f"calculus-derivative@v1 input must be JSON object: {exc}"
            ) from exc
        if not isinstance(obj, dict):
            raise PiStarError(
                f"calculus-derivative@v1 input must be JSON object; got {type(obj).__name__}"
            )

        for k in ("f", "x"):
            if k not in obj:
                raise PiStarError(f"calculus-derivative@v1 missing field {k!r}")
            if not isinstance(obj[k], str) or not obj[k].strip():
                raise PiStarError(
                    f"calculus-derivative@v1 field {k!r} must be a non-empty string"
                )

        n = obj.get("n", 1)
        if not isinstance(n, int) or isinstance(n, bool) or n < 0:
            raise PiStarError(
                "calculus-derivative@v1 'n' must be a non-negative integer"
            )

        x_name = obj["x"].strip()
        x_sym = sp.Symbol(x_name)
        try:
            f_expr = sp.sympify(obj["f"], locals={x_name: x_sym})
        except (sp.SympifyError, SyntaxError, TypeError) as exc:
            raise PiStarError(
                f"calculus-derivative@v1 cannot parse f={obj['f']!r}: {exc}"
            ) from exc
        # Reject relationals + boolean operators; see algebra_symbolic.py
        # for the rationale on `isinstance(expr, sp.Expr)` vs Boolean.
        if not isinstance(f_expr, sp.Expr):
            raise PiStarError(
                f"calculus-derivative@v1 rejects boolean/relational f={obj['f']!r}"
            )

        try:
            deriv = sp.diff(f_expr, x_sym, n)
            canonical = sp.expand(deriv)
        except (TypeError, ValueError) as exc:
            raise PiStarError(
                f"calculus-derivative@v1 diff/expand failed: {exc}"
            ) from exc

        return sp.srepr(canonical).encode("utf-8")


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