"""code-py-ast@v1 π* tests.

Python source → deterministic AST S-expression. KATs mirror the
docstring's equivalence-class examples: whitespace / comment /
quote-style / numeric-formatting invariance vs identifier and
operator distinctness. Plus invalid-input cone (non-Python,
non-bytes) and determinism.

Note: this projection is projective, not invertible — the canonical
S-expression is NOT valid Python, so re-canonicalizing the output
raises (covered by an explicit test).

Backfills the test-coverage gap for arborist/pi_star/code.py
identified during the 2026-05-10 zero-coverage sweep.
"""

from __future__ import annotations

import pytest

from arborist.pi_star import get
from arborist.pi_star.protocol import PiStarError


@pytest.fixture
def ps():
    return get("code-py-ast@v1")


def _canon(ps, src: str) -> bytes:
    return ps.canonicalize(src.encode("utf-8"))


# --- registry presence + metadata ------------------------------------


def test_registry_contains_code_py_ast():
    ps = get("code-py-ast@v1")
    assert ps.name == "code-py-ast"
    assert ps.version == "v1"
    assert ps.domain == "code"


# --- positive shape --------------------------------------------------


def test_empty_module(ps):
    out = _canon(ps, "")
    # Module(body=[] type_ignores=[]) — minimal AST shape.
    assert out.startswith(b"(Module ")
    assert b"body=[]" in out


def test_single_expression(ps):
    out = _canon(ps, "1 + 2")
    assert b"BinOp" in out
    assert b"Add" in out
    assert b"value=1" in out
    assert b"value=2" in out


def test_assignment(ps):
    out = _canon(ps, "x = 1")
    assert b"Assign" in out
    assert b"'x'" in out
    assert b"value=1" in out


def test_function_def(ps):
    out = _canon(ps, "def foo(a, b):\n    return a + b\n")
    assert b"FunctionDef" in out
    assert b"'foo'" in out
    assert b"Return" in out


# --- equivalence classes preserved -----------------------------------


@pytest.mark.parametrize(
    "a,b",
    [
        # Whitespace / indentation
        ("x=1", "x = 1"),
        ("x = 1", "x  =  1"),
        # Comments
        ("x = 1", "x = 1  # comment"),
        ("# leading\nx = 1", "x = 1"),
        # Quote style
        ("x = 'foo'", 'x = "foo"'),
        # Trailing semicolons
        ("x = 1", "x = 1;"),
        # Numeric formatting (underscores in int literals)
        ("x = 1000", "x = 1_000"),
    ],
)
def test_equivalent_sources_collapse(ps, a, b):
    assert _canon(ps, a) == _canon(ps, b)


# --- equivalence classes kept distinct -------------------------------


@pytest.mark.parametrize(
    "a,b",
    [
        # Identifier names
        ("x = 1", "y = 1"),
        # Operator types
        ("a + b", "a - b"),
        ("a * b", "a / b"),
        # Argument order in calls
        ("f(a, b)", "f(b, a)"),
        # Literal vs variable
        ("x = 1", "x = 2"),
        # String vs bytes
        ("x = 'a'", "x = b'a'"),
    ],
)
def test_distinct_sources_stay_distinct(ps, a, b):
    assert _canon(ps, a) != _canon(ps, b)


# --- determinism + ordering ------------------------------------------


def test_repeated_canonicalize_is_stable(ps):
    src = "class X:\n    def f(self): return 42\n    y = [1, 2, 3]\n"
    a = _canon(ps, src)
    b = _canon(ps, src)
    c = _canon(ps, src)
    assert a == b == c


def test_field_order_is_lexical(ps):
    """_canonical_sexp sorts node._fields for cross-version stability."""
    out = _canon(ps, "x = 1")
    # In an Assign node, lexical sort puts 'targets' before 'value'.
    body_idx = out.index(b"Assign")
    targets_idx = out.index(b"targets=", body_idx)
    value_idx = out.index(b"value=", body_idx)
    assert targets_idx < value_idx


# --- source-position fields excluded ---------------------------------


def test_line_position_does_not_change_canonical(ps):
    """lineno / col_offset are not in `_fields` so they're excluded;
    the same statement at different line numbers canonicalizes the same."""
    a = _canon(ps, "x = 1")
    b = _canon(ps, "\n\nx = 1")
    assert a == b


def test_position_fields_excluded(ps):
    """Source-position metadata MUST NOT appear in canonical output."""
    out = _canon(ps, "x = 1\ny = 2\n")
    # Confirm none of the excluded fields leaked into the encoding.
    for excluded in (b"lineno=", b"col_offset=", b"end_lineno=", b"end_col_offset="):
        assert excluded not in out, f"position field {excluded!r} leaked"


# --- invalid-input cone ----------------------------------------------


def test_non_python_raises(ps):
    with pytest.raises(PiStarError, match="not valid Python"):
        ps.canonicalize(b"def (")


def test_unbalanced_paren_raises(ps):
    with pytest.raises(PiStarError, match="not valid Python"):
        ps.canonicalize(b"x = (1 + 2")


def test_non_bytes_raises(ps):
    with pytest.raises(PiStarError, match="expects bytes"):
        ps.canonicalize("x = 1")  # type: ignore[arg-type]


def test_bytearray_accepted(ps):
    """bytearray is an accepted subtype per the bytes/bytearray guard."""
    out = ps.canonicalize(bytearray(b"x = 1"))
    assert b"Assign" in out
    assert b"value=1" in out


# --- projective (not invertible) -------------------------------------


def test_canonical_output_is_not_python_source(ps):
    """Re-canonicalizing the canonical S-expression raises — by design."""
    canonical = _canon(ps, "x = 1")
    # The S-expression starts with `(Module ...)` which isn't a Python
    # expression. ast.parse rejects it.
    with pytest.raises(PiStarError, match="not valid Python"):
        ps.canonicalize(canonical)


# --- value types ------------------------------------------------------


def test_bool_serialized_as_bool_not_int(ps):
    """bool subclasses int — handler must dispatch on bool first."""
    out = _canon(ps, "x = True")
    # If bool fell through to int, we'd see `value=1`. Must see True.
    assert b"value=True" in out
    assert b"value=1" not in out


def test_none_handled(ps):
    out = _canon(ps, "x = None")
    assert b"value=None" in out


def test_float_literal(ps):
    out = _canon(ps, "x = 3.14")
    assert b"value=3.14" in out


def test_complex_literal(ps):
    out = _canon(ps, "x = 1j")
    assert b"value=1j" in out


def test_bytes_literal(ps):
    out = _canon(ps, "x = b'hi'")
    assert b"value=b'hi'" in out
