"""Tests for backend.semantic_graph.sympy_translator (end-to-end graph assertions)."""

from __future__ import annotations

import pytest

from backend.model.semantic_graph import SemanticGraph, SemanticGraphNode
from backend.semantic_graph.sympy_translator import (
    latex_to_semantic_graph,
    parse_var_overrides,
    operator_kind,
    _preprocess_latex,
    _split_on_top_level_comma,
    _extract_parenthetical_annotations,
    _inject_annotations,
    _collapse_braket_notation,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _find_node(graph: SemanticGraph, **attrs) -> SemanticGraphNode | None:
    """Find a node in the graph matching all given attrs."""
    for node in graph.nodes:
        if all(getattr(node, k, None) == v for k, v in attrs.items()):
            return node
    return None


def _find_nodes(graph: SemanticGraph, **attrs) -> list[SemanticGraphNode]:
    """Find all nodes matching given attrs."""
    return [n for n in graph.nodes
            if all(getattr(n, k, None) == v for k, v in attrs.items())]


def _has_edge(graph: SemanticGraph, src: str, dst: str) -> bool:
    """Check if an edge exists from src to dst."""
    return any(e.from_ == src and e.to == dst for e in graph.edges)


# ---------------------------------------------------------------------------
# Graph structure
# ---------------------------------------------------------------------------

class TestGraphStructure:
    def test_returns_nodes_and_edges(self):
        g = latex_to_semantic_graph("x + y")
        assert g.nodes is not None
        assert g.edges is not None
        assert g.classification is not None

    def test_simple_addition(self):
        g = latex_to_semantic_graph("x + y")
        assert _find_node(g, id="x", type="scalar")
        assert _find_node(g, id="y", type="scalar")
        assert _find_node(g, type="operator", op="add")

    def test_simple_multiplication(self):
        # Parser emits structural fields only — labels/quantities are filled
        # by the enricher, not pre-baked from a hardcoded symbol table.
        g = latex_to_semantic_graph("m \\cdot a")
        assert _find_node(g, id="m", type="scalar")
        assert _find_node(g, id="a", type="scalar")
        assert _find_node(g, type="operator", op="multiply")

    def test_equation(self):
        g = latex_to_semantic_graph("F = m \\cdot a")
        assert _find_node(g, id="F", type="scalar")
        assert _find_node(g, type="relation", op="equals")

    def test_power(self):
        g = latex_to_semantic_graph("x^2")
        assert _find_node(g, id="x")
        assert _find_node(g, type="operator", op="power")

    def test_power_inverse_label(self):
        r"""Power node with exponent=-1 should display as \\dfrac{1}{(\\cdot)}."""
        g = latex_to_semantic_graph(r"\frac{Q}{V}")
        pw = _find_node(g, type="operator", op="power")
        assert pw is not None
        assert pw.exponent == "-1"
        assert pw.latex == r"\dfrac{1}{(\cdot)}"

    def test_edges_connect_operands_to_operator(self):
        g = latex_to_semantic_graph("x + y")
        add_node = _find_node(g, type="operator", op="add")
        assert add_node is not None
        assert _has_edge(g, "x", add_node.id)
        assert _has_edge(g, "y", add_node.id)


# ---------------------------------------------------------------------------
# Known variables & metadata
# ---------------------------------------------------------------------------

class TestKnownVariables:
    def test_bare_symbol_defaults_to_scalar(self):
        g = latex_to_semantic_graph("F")
        node = _find_node(g, id="F")
        assert node is not None
        assert node.type == "scalar"
        assert node.latex == "F"
        for forbidden in ("label", "emoji", "quantity", "dimension", "unit", "role", "value"):
            assert getattr(node, forbidden, None) is None, f"{forbidden!r} should not be parser-set"

    def test_vec_accent_assigns_vector_type(self):
        from backend.semantic_graph import SemanticGraphService
        svc = SemanticGraphService()
        g = svc.latex_to_graph(r"\vec{F} = m \vec{a}")
        f_node = _find_node(g, id="F")
        assert f_node is not None
        assert f_node.type == "vector"
        assert f_node.latex == r"\vec{F}"
        a_node = _find_node(g, id="a")
        assert a_node is not None
        assert a_node.type == "vector"
        assert a_node.latex == r"\vec{a}"

    def test_vec_accent_via_latex_to_semantic_graph(self):
        """latex_to_semantic_graph must also restore accents (not only SemanticGraphService)."""
        g = latex_to_semantic_graph(r"\vec{F} = m \vec{a}")
        f_node = _find_node(g, id="F")
        assert f_node is not None, "node F not found"
        assert f_node.type == "vector"
        assert f_node.latex == r"\vec{F}"
        a_node = _find_node(g, id="a")
        assert a_node is not None, "node a not found"
        assert a_node.type == "vector"
        assert a_node.latex == r"\vec{a}"
        # No separate 'vec' node should exist
        vec_node = _find_node(g, id="vec")
        assert vec_node is None, "standalone 'vec' node should not exist"

    def test_accent_subexpr_restored_on_leaf_nodes(self):
        """Leaf symbol nodes must have subexpr containing accent notation."""
        # vec
        g = latex_to_semantic_graph(r"\vec{F} = m \vec{a}")
        f_node = _find_node(g, id="F")
        assert f_node is not None, "node F not found"
        assert f_node.subexpr == r"\vec{F}", (
            f"Expected subexpr='\\vec{{F}}', got {f_node.subexpr!r}"
        )
        a_node = _find_node(g, id="a")
        assert a_node is not None, "node a not found"
        assert a_node.subexpr == r"\vec{a}", (
            f"Expected subexpr='\\vec{{a}}', got {a_node.subexpr!r}"
        )
        # hat
        g2 = latex_to_semantic_graph(r"\hat{H} = T + V")
        h_node = _find_node(g2, id="H")
        assert h_node is not None, "node H not found"
        assert h_node.subexpr == r"\hat{H}", (
            f"Expected subexpr='\\hat{{H}}', got {h_node.subexpr!r}"
        )

    def test_accent_subexpr_restored_in_compound_expressions(self):
        """Operator/relation nodes' subexpr must contain accent notation (any accent type)."""
        # vec accents in compound subexpr
        g = latex_to_semantic_graph(r"\vec{F} = m \vec{a}")
        eq_node = next(
            (n for n in g.nodes if n.type == "relation" or "equals" in (n.id or "")),
            None,
        )
        if eq_node and eq_node.subexpr:
            assert r"\vec{F}" in eq_node.subexpr, (
                f"equals subexpr missing \\vec{{F}}: {eq_node.subexpr!r}"
            )
            assert r"\vec{a}" in eq_node.subexpr, (
                f"equals subexpr missing \\vec{{a}}: {eq_node.subexpr!r}"
            )
        # hat accent in compound subexpr
        g2 = latex_to_semantic_graph(r"\hat{H} = T + V")
        eq_node2 = next(
            (n for n in g2.nodes if n.type == "relation" or "equals" in (n.id or "")),
            None,
        )
        if eq_node2 and eq_node2.subexpr:
            assert r"\hat{H}" in eq_node2.subexpr, (
                f"equals subexpr missing \\hat{{H}}: {eq_node2.subexpr!r}"
            )

    def test_hat_accent_restores_latex(self):
        """\\hat{H} should produce a single node with latex='\\hat{H}'."""
        g = latex_to_semantic_graph(r"\hat{H} = T + V")
        h_node = _find_node(g, id="H")
        assert h_node is not None, "node H not found"
        assert h_node.latex == r"\hat{H}"
        hat_node = _find_node(g, id="hat")
        assert hat_node is None, "standalone 'hat' node should not exist"

    def test_bar_accent_subexpr_restored(self):
        """\\bar{x} leaf node subexpr must carry the accent notation."""
        g = latex_to_semantic_graph(r"\bar{x} = \frac{1}{N} \sum x_i")
        x_bar = _find_node(g, id="x")
        assert x_bar is not None, "node x not found"
        assert x_bar.latex == r"\bar{x}", (
            f"Expected latex='\\bar{{x}}', got {x_bar.latex!r}"
        )
        assert x_bar.subexpr == r"\bar{x}", (
            f"Expected subexpr='\\bar{{x}}', got {x_bar.subexpr!r}"
        )

    def test_unknown_variable_gets_defaults(self):
        g = latex_to_semantic_graph("Q")
        node = _find_node(g, id="Q")
        # Unknown symbols intentionally have no ``label`` — the SymPy
        # identifier would just duplicate the rendered ``latex`` field.
        assert node.label is None
        assert node.type == "scalar"

    def test_symbol_deduplication(self):
        g = latex_to_semantic_graph("x + x")
        x_nodes = _find_nodes(g, id="x")
        assert len(x_nodes) == 1

    def test_subscripted_greek_subexpr_keeps_latex_command(self):
        # Regression for gh-197: hover tooltip rendered \rho_0 as plain
        # text "rho_0" because the node's `subexpr` came straight from
        # `sympy.latex(Symbol("rho_{0}"))`, which drops the backslash.
        # The subexpr must carry the same LaTeX command as `latex`.
        g = latex_to_semantic_graph(r"\rho_0 + 1")
        node = _find_node(g, id="rho_0")
        assert node.latex == r"\rho_{0}"
        assert node.subexpr == r"\rho_{0}"

    def test_compound_mul_subexpr_recovers_greek_command(self):
        # Regression for gh-197: a non-root Mul containing a Greek-named
        # Symbol must propagate the backslash through `_subexpr_ordered`'s
        # per-factor recursion. Pre-fix, factors fell through to
        # `sympy.latex(Symbol("rho_{0}"))` and joined the compound string
        # as bare `rho_{0}`. The Mul must not be the root — root nodes
        # get `subexpr` from the original input verbatim, masking the bug.
        g = latex_to_semantic_graph(r"\rho_0 V + 1")
        muls = _find_nodes(g, type="operator", op="multiply")
        assert muls
        sub = muls[0].subexpr or ""
        assert r"\rho_{0}" in sub, sub
        assert "rho_{0}" not in sub.replace(r"\rho_{0}", ""), sub

    def test_non_greek_subscripted_symbol_not_falsely_prefixed(self):
        # Negative case: `x_0` is a plain ASCII variable, not a LaTeX
        # macro. `_symbol_latex` must NOT invent a `\x` command for it.
        g = latex_to_semantic_graph(r"x_0 + 1")
        node = _find_node(g, id="x_0")
        assert node.latex == "x_{0}"
        assert node.subexpr == "x_{0}"

    def test_mathbb_symbol_unwrapped_without_leaking_command_node(self):
        g = latex_to_semantic_graph(r"\alpha \in \mathbb{C}")
        c = _find_node(g, id="C")
        assert c is not None
        assert c.latex == r"\mathbb{C}"
        assert _find_node(g, id="mathbb") is None

    def test_bold_symbol_unwrapped_without_leaking_command_node(self):
        g = latex_to_semantic_graph(r"\mathbf{x} + 1")
        x = _find_node(g, id="x")
        assert x is not None
        assert x.latex == r"\mathbf{x}"
        assert _find_node(g, id="mathbf") is None


# ---------------------------------------------------------------------------
# Numbers and constants
# ---------------------------------------------------------------------------

class TestNumbersAndConstants:
    def test_integer(self):
        g = latex_to_semantic_graph("2 x")
        assert _find_node(g, type="number")

    def test_fraction(self):
        g = latex_to_semantic_graph("\\frac{1}{2}")
        # Should produce a number node or a division
        assert len(g.nodes) > 0

    def test_float_label_no_precision_noise(self):
        """Regression: Float(7.2) label was '7.20000000000000' (gh-145)."""
        g = latex_to_semantic_graph("7.2 x")
        num = _find_node(g, type="number")
        assert num is not None
        assert num.label == "7.2"

    def test_negative_integer_label_preserved(self):
        """Regression: -122 label should stay '-122', not be split (gh-145)."""
        g = latex_to_semantic_graph("-122")
        num = _find_node(g, type="number", label="-122")
        assert num is not None

    def test_negative_fraction_subexpr_no_digit_concat(self):
        """Regression: -122/7.2 multiply subexpr was '-1 122 ...' (gh-145).

        When SymPy's as_ordered_factors splits Integer(-122) into -1*122,
        the LaTeX join produces '-1122' visually.  The fix uses expr.args
        which keeps -122 unified.
        """
        g = latex_to_semantic_graph(r"e^{-122/7.2}")
        mul = _find_node(g, type="operator", op="multiply")
        assert mul is not None
        assert "-1 122" not in (mul.subexpr or "")

    def test_float_exponent_no_precision_noise(self):
        """Regression: x^{7.2} exponent was '7.20000000000000' (gh-145)."""
        g = latex_to_semantic_graph("x^{7.2}")
        pw = _find_node(g, type="operator", op="power")
        assert pw is not None
        assert pw.exponent == "7.2"


# ---------------------------------------------------------------------------
# Functions
# ---------------------------------------------------------------------------

class TestFunctions:
    def test_sin(self):
        g = latex_to_semantic_graph("\\sin(x)")
        assert _find_node(g, type="function", op="sin")
        assert _find_node(g, id="x")

    def test_cos(self):
        g = latex_to_semantic_graph("\\cos(\\theta)")
        assert _find_node(g, type="function", op="cos")

    def test_sqrt(self):
        g = latex_to_semantic_graph("\\sqrt{x}")
        assert _find_node(g, type="operator", op="power") or \
               _find_node(g, type="function", op="sqrt")

    def test_text_subscript_function_name_restored(self):
        # x_{\text{ph}} is collapsed to x_{Xi_{0}} before parsing; the
        # placeholder must not leak into the function node's op/latex/id.
        g = latex_to_semantic_graph(r"x_{\text{ph}}(t) = x_e + (t - t_e)")
        fn = _find_node(g, type="function")
        assert fn is not None
        assert fn.latex == r"x_{\text{ph}}"
        assert fn.op == "x_{ph}"
        assert "Xi" not in fn.id
        assert "Xi" not in (fn.op or "")

    def test_standalone_text_function_name_unaffected(self):
        g = latex_to_semantic_graph(r"\text{Res}(f)")
        fn = _find_node(g, type="function", op="Res")
        assert fn is not None


# ---------------------------------------------------------------------------
# Calculus (Integrals, Sums, Derivatives)
# ---------------------------------------------------------------------------

class TestCalculus:
    def test_integral_with_limits(self):
        g = latex_to_semantic_graph("\\int_0^1 x^2 dx")
        integral_node = _find_node(g, type="operator", op="integral")
        assert integral_node is not None
        assert _find_node(g, id="x") is not None
        assert _find_node(g, label="0") is not None
        assert _find_node(g, label="1") is not None

    def test_sum_with_limits(self):
        g = latex_to_semantic_graph("\\sum_{n=1}^\\infty n")
        sum_node = _find_node(g, type="operator", op="sum")
        assert sum_node is not None
        assert _find_node(g, id="n") is not None
        assert _find_node(g, label="1") is not None
        assert _find_node(g, label="infinity") is not None

    def test_sum_lower_bound_equals_subexpr(self):
        g = latex_to_semantic_graph("\\sum_{n=1}^\\infty n")
        eq_node = _find_node(g, type="relation", op="equals")
        assert eq_node is not None
        assert eq_node.subexpr is not None
        assert "n" in eq_node.subexpr
        assert "=" in eq_node.subexpr
        assert "1" in eq_node.subexpr

class TestLimits:
    def test_limit_basic_structure(self):
        g = latex_to_semantic_graph(r"\lim_{x \to 0} \frac{\sin x}{x}")
        limit_node = _find_node(g, type="operator", op="limit")
        assert limit_node is not None
        tends_to = _find_node(g, type="operator", op="tends_to")
        assert tends_to is not None

    def test_tends_to_subexpr(self):
        g = latex_to_semantic_graph(r"\lim_{x \to 0} x^2")
        tends_to = _find_node(g, type="operator", op="tends_to")
        assert tends_to is not None
        assert tends_to.subexpr is not None
        assert r"\to" in tends_to.subexpr
        assert "x" in tends_to.subexpr
        assert "0" in tends_to.subexpr

    def test_tends_to_subexpr_infinity(self):
        g = latex_to_semantic_graph(r"\lim_{n \to \infty} \frac{1}{n}")
        tends_to = _find_node(g, type="operator", op="tends_to")
        assert tends_to is not None
        assert tends_to.subexpr is not None
        assert r"\to" in tends_to.subexpr
        assert "n" in tends_to.subexpr
        assert "infty" in tends_to.subexpr or "\\infty" in tends_to.subexpr


class TestDerivatives:
    def test_first_order_derivative(self):
        g = latex_to_semantic_graph("\\frac{d v}{d t}")
        deriv = _find_node(g, type="operator", op="derivative")
        assert deriv is not None
        assert "t" in (deriv.with_respect_to or "")

    def test_dot_notation_preprocessed(self):
        result, _ = _preprocess_latex(r"\dot{x}")
        assert r"\frac" in result
        assert "d t" in result or "dt" in result

    def test_dot_notation_graph(self):
        g = latex_to_semantic_graph(r"\dot{x}")
        deriv = _find_node(g, type="operator", op="derivative")
        assert deriv is not None
        assert "t" in (deriv.with_respect_to or "")

    def test_ddot_notation_preprocessed(self):
        result, _ = _preprocess_latex(r"\ddot{x}")
        assert result.count(r"\frac") == 2

    def test_ddot_notation_graph(self):
        g = latex_to_semantic_graph(r"\ddot{x}")
        deriv = _find_node(g, type="operator", op="derivative")
        assert deriv is not None
        # SymPy collapses nested derivatives; ddot produces Derivative(x, t, t)
        assert "t" in (deriv.with_respect_to or "")

    def test_higher_order_derivative(self):
        result, _ = _preprocess_latex(r"\frac{d^2 y}{dy^2}")
        assert result.count(r"\frac") == 2

    def test_higher_order_derivative_graph(self):
        g = latex_to_semantic_graph(r"\frac{d^2 y}{d y^2}")
        deriv = _find_node(g, type="operator", op="derivative")
        assert deriv is not None
        assert "y" in (deriv.with_respect_to or "")

    def test_higher_order_derivative_braced(self):
        result, _ = _preprocess_latex(r"\frac{d^{2} y}{dy^{2}}")
        assert result.count(r"\frac") == 2

    def test_higher_order_partial_braced(self):
        result, _ = _preprocess_latex(r"\frac{\partial^{3} u}{\partial x^{3}}")
        assert result.count(r"\frac") == 3

    def test_ordinary_derivative_not_partial(self):
        """d/dt must stay op='derivative', not 'partial_derivative'."""
        g = latex_to_semantic_graph(r"\frac{d v}{d t}")
        assert _find_node(g, type="operator", op="derivative") is not None
        assert _find_node(g, type="operator", op="partial_derivative") is None


# ---------------------------------------------------------------------------
# Relation operators (proportional, implies, iff, maps_to, approximately)
# ---------------------------------------------------------------------------

class TestRelations:
    """Relations that SymPy's parse_latex cannot handle natively."""

    def test_proportional(self):
        g = latex_to_semantic_graph(r"F \propto m a")
        rel = _find_node(g, type="relation", op="proportional")
        assert rel is not None
        assert rel.label == "proportional to"
        assert rel.emoji == "∝"
        assert _find_node(g, id="F")
        assert _find_node(g, type="operator", op="multiply")

    def test_implies(self):
        g = latex_to_semantic_graph(r"x > 0 \implies x^2 > 0")
        rel = _find_node(g, type="operator", op="implies")
        assert rel is not None
        assert rel.label == "implies"
        assert rel.emoji == "⇒"

    def test_rightarrow_implies(self):
        g = latex_to_semantic_graph(r"x > 0 \Rightarrow x^2 > 0")
        rel = _find_node(g, type="operator", op="implies")
        assert rel is not None
        assert rel.emoji == "⇒"

    def test_iff(self):
        g = latex_to_semantic_graph(r"x = 0 \iff x^2 = 0")
        rel = _find_node(g, type="operator", op="iff")
        assert rel is not None
        assert rel.label == "if and only if"
        assert rel.emoji == "⇔"

    def test_leftrightarrow_iff(self):
        g = latex_to_semantic_graph(r"A \Leftrightarrow B")
        rel = _find_node(g, type="operator", op="iff")
        assert rel is not None
        assert rel.emoji == "⇔"
        assert _find_node(g, id="A")
        assert _find_node(g, id="B")

    def test_approximately(self):
        g = latex_to_semantic_graph(r"\pi \approx 3.14")
        rel = _find_node(g, type="relation", op="approximately")
        assert rel is not None
        assert rel.label == "approximately equal"
        assert rel.emoji == "≈"
        # parse_latex emits ``\pi`` as a Symbol named ``pi`` (not the
        # sympy.pi NumberSymbol), so the parser routes it through the
        # symbol path — KNOWN_VARIABLES["pi"] still pins type=constant.
        assert _find_node(g, id="pi", type="constant")

    def test_maps_to(self):
        g = latex_to_semantic_graph(r"x \to y")
        rel = _find_node(g, type="relation", op="maps_to")
        assert rel is not None
        assert rel.label == "maps to"
        assert rel.emoji == "→"
        assert _find_node(g, id="x")
        assert _find_node(g, id="y")

    def test_maps_to_function(self):
        g = latex_to_semantic_graph(r"f(x) \to f(x+1)")
        rel = _find_node(g, type="relation", op="maps_to")
        assert rel is not None

    def test_relation_has_two_edges(self):
        """Every relation node should connect LHS and RHS."""
        g = latex_to_semantic_graph(r"F \propto m a")
        rel = _find_node(g, type="relation", op="proportional")
        incoming = [e for e in g.edges if e.to == rel.id]
        assert len(incoming) == 2

    def test_leftmost_relation_wins(self):
        """When multiple relations appear, split on the leftmost one."""
        g = latex_to_semantic_graph(r"x \to y \implies z")
        # \to is leftmost, so it should be the relation
        rel = _find_node(g, type="relation", op="maps_to")
        assert rel is not None


class TestComparisonOperators:
    """Asymmetric comparison operators (>, <, >=, <=) — issue #242."""

    def test_greater_than(self):
        g = latex_to_semantic_graph(r"x > 0")
        op = _find_node(g, type="relation", op="greater_than")
        assert op is not None
        assert _find_node(g, id="x")

    def test_less_than(self):
        g = latex_to_semantic_graph(r"x < 5")
        op = _find_node(g, type="relation", op="less_than")
        assert op is not None
        assert _find_node(g, id="x")

    def test_geq(self):
        g = latex_to_semantic_graph(r"x \geq y + 1")
        op = _find_node(g, type="relation", op="greater_equal")
        assert op is not None

    def test_leq(self):
        g = latex_to_semantic_graph(r"a \leq b")
        op = _find_node(g, type="relation", op="less_equal")
        assert op is not None
        assert _find_node(g, id="a")
        assert _find_node(g, id="b")

    def test_gt_latex_command(self):
        """\\gt is not handled by SymPy — routed through RELATION_MAP."""
        g = latex_to_semantic_graph(r"x \gt 0")
        rel = _find_node(g, type="relation", op="greater_than")
        assert rel is not None

    def test_lt_latex_command(self):
        """\\lt is not handled by SymPy — routed through RELATION_MAP."""
        g = latex_to_semantic_graph(r"x \lt 5")
        rel = _find_node(g, type="relation", op="less_than")
        assert rel is not None

    def test_comparison_in_comma_constraint(self):
        """Comparison as a constraint clause after comma."""
        g = latex_to_semantic_graph(r"f(x) = x^2, x > 0")
        gt = _find_node(g, type="relation", op="greater_than")
        eq = _find_node(g, type="relation", op="equals")
        assert gt is not None
        assert eq is not None

    def test_comparison_has_two_children(self):
        g = latex_to_semantic_graph(r"x > 0")
        op = _find_node(g, type="relation", op="greater_than")
        incoming = [e for e in g.edges if e.to == op.id]
        assert len(incoming) == 2

    def test_comparison_edges_have_lhs_rhs_roles(self):
        g = latex_to_semantic_graph(r"x > 0")
        op = _find_node(g, type="relation", op="greater_than")
        edges = [e for e in g.edges if e.to == op.id]
        roles = {e.role for e in edges}
        assert roles == {"lhs", "rhs"}
        lhs_edge = next(e for e in edges if e.role == "lhs")
        assert lhs_edge.from_ == "x"

    def test_equals_edges_have_no_roles(self):
        """Symmetric operators should not have role tags."""
        g = latex_to_semantic_graph(r"x = 0")
        op = _find_node(g, type="relation", op="equals")
        edges = [e for e in g.edges if e.to == op.id]
        assert all(e.role is None for e in edges)

    def test_relation_map_comparison_edges_have_roles(self):
        """\\gt routed through RELATION_MAP should also get lhs/rhs roles."""
        g = latex_to_semantic_graph(r"x \gt 0")
        rel = _find_node(g, type="relation", op="greater_than")
        edges = [e for e in g.edges if e.to == rel.id]
        roles = {e.role for e in edges}
        assert roles == {"lhs", "rhs"}


class TestRelationOperandComma:
    """Comma inside an \\implies / \\iff operand groups as a conjunction
    on that side, not as a top-level statement separator (#208).

    Previously the comma split ran before relation detection, so
    ``A \\implies B, C`` parsed as two parallel rooted clauses
    (``A \\implies B`` and ``C``) — the second consequent was orphaned
    from the implies node entirely.
    """

    def _reaches(self, graph: SemanticGraph, src: str, dst: str):
        """Return True iff a directed edge path exists from *src* to *dst*."""
        adj: dict[str, list[str]] = {}
        for e in graph.edges:
            adj.setdefault(e.from_, []).append(e.to)
        seen = {src}
        stack = [src]
        while stack:
            cur = stack.pop()
            if cur == dst:
                return True
            for nxt in adj.get(cur, []):
                if nxt not in seen:
                    seen.add(nxt)
                    stack.append(nxt)
        return False

    def test_implies_with_comma_rhs_groups_as_conjunction(self):
        g = latex_to_semantic_graph(r"x > 0 \implies y = 1, z = 2")
        impl = _find_node(g, type="operator", op="implies")
        assert impl is not None
        # Both consequent clauses must reach the implies node.
        equals_nodes = _find_nodes(g, type="relation", op="equals")
        assert len(equals_nodes) == 2
        for eq in equals_nodes:
            assert self._reaches(g, eq.id, impl.id), (
                f"clause {eq.subexpr!r} does not reach implies"
            )
        # And they reach it through a synthetic ``and`` conjunction node.
        conj = _find_node(g, type="operator", op="and")
        assert conj is not None
        assert self._reaches(g, conj.id, impl.id)

    def test_iff_with_comma_rhs_groups_as_conjunction(self):
        g = latex_to_semantic_graph(r"P \iff A, B")
        iff = _find_node(g, type="operator", op="iff")
        conj = _find_node(g, type="operator", op="and")
        assert iff is not None and conj is not None
        assert self._reaches(g, conj.id, iff.id)
        assert self._reaches(g, "A", iff.id)
        assert self._reaches(g, "B", iff.id)

    def test_implies_with_comma_lhs_groups_as_conjunction(self):
        """Comma on the LHS of an implication groups the antecedents."""
        g = latex_to_semantic_graph(r"A, B \implies C")
        impl = _find_node(g, type="operator", op="implies")
        conj = _find_node(g, type="operator", op="and")
        assert impl is not None and conj is not None
        assert self._reaches(g, "A", conj.id)
        assert self._reaches(g, "B", conj.id)
        assert self._reaches(g, conj.id, impl.id)
        assert self._reaches(g, "C", impl.id)

    def test_function_arg_comma_inside_implies_unaffected(self):
        """Comma inside a function-argument group ``f(x, y)`` stays a
        function arg — it's not at top level on the side, so no
        conjunction node is emitted."""
        g = latex_to_semantic_graph(r"x = 1 \implies f(x, y) = 0")
        # No ``and`` conjunction should appear — comma is depth>0.
        assert _find_node(g, type="operator", op="and") is None
        impl = _find_node(g, type="operator", op="implies")
        assert impl is not None

    def test_top_level_comma_without_relation_unchanged(self):
        """Without a top-level relation, comma still acts as a statement
        separator — no synthetic ``and`` node."""
        g = latex_to_semantic_graph(r"a = 1, b = 2")
        assert _find_node(g, type="operator", op="and") is None
        equals_nodes = _find_nodes(g, type="relation", op="equals")
        assert len(equals_nodes) == 2


class TestSubjectGroupComma:
    r"""Subject-grouping commas: ``\alpha, \beta \in \mathbb{C}`` means both
    α and β belong to ℂ.  The comma between α and β groups them as a
    composite LHS for the ``\in`` relation, not as a statement separator."""

    def _reaches(self, graph: SemanticGraph, src: str, dst: str):
        adj: dict[str, list[str]] = {}
        for e in graph.edges:
            adj.setdefault(e.from_, []).append(e.to)
        seen = {src}
        stack = [src]
        while stack:
            cur = stack.pop()
            if cur == dst:
                return True
            for nxt in adj.get(cur, []):
                if nxt not in seen:
                    seen.add(nxt)
                    stack.append(nxt)
        return False

    def test_alpha_beta_in_C(self):
        r"""``\alpha, \beta \in \mathbb{C}`` produces an element_of relation
        with a conjunction (and) LHS grouping α and β."""
        g = latex_to_semantic_graph(r"\alpha, \beta \in \mathbb{C}")
        elem = _find_node(g, type="relation", op="element_of")
        assert elem is not None
        conj = _find_node(g, type="operator", op="and")
        assert conj is not None, "expected an 'and' conjunction for the composite LHS"
        assert self._reaches(g, "alpha", conj.id)
        assert self._reaches(g, "beta", conj.id)
        assert self._reaches(g, conj.id, elem.id)
        assert self._reaches(g, "C", elem.id)

    def test_three_variables_in_R(self):
        r"""``x, y, z \in \mathbb{R}`` — chained subject grouping."""
        g = latex_to_semantic_graph(r"x, y, z \in \mathbb{R}")
        elem = _find_node(g, type="relation", op="element_of")
        assert elem is not None
        conj = _find_node(g, type="operator", op="and")
        assert conj is not None
        for var in ("x", "y", "z"):
            assert self._reaches(g, var, elem.id), f"{var} should reach element_of"

    def test_multi_statement_with_subject_group(self):
        r"""``a = 1, \quad \alpha, \beta \in \mathbb{C}`` should produce
        two independent statements: the equality and the element_of."""
        g = latex_to_semantic_graph(
            r"a = 1, \quad \alpha, \beta \in \mathbb{C}"
        )
        eq = _find_node(g, type="relation", op="equals")
        elem = _find_node(g, type="relation", op="element_of")
        assert eq is not None, "expected an equals node for a = 1"
        assert elem is not None, "expected an element_of node for α, β ∈ ℂ"

    def test_subject_group_does_not_merge_expressions(self):
        r"""``x + 1, y \in S`` — the ``x + 1`` clause has operators,
        so it should NOT be merged with ``y \in S``."""
        g = latex_to_semantic_graph(r"x + 1, y \in S")
        elem = _find_node(g, type="relation", op="element_of")
        assert elem is not None
        add = _find_node(g, type="operator", op="add")
        assert add is not None, "x + 1 should parse as a separate clause with add"

    def test_quad_comma_separates_statements(self):
        r"""``\quad`` after a comma is the strongest statement boundary.
        ``\alpha,\beta\in\mathbb{C}`` stays intact because the comma
        between α and β has no ``\quad``."""
        g = latex_to_semantic_graph(
            r"a = 1, \quad \alpha,\beta\in\mathbb{C}, "
            r"\quad |\alpha|^2 + |\beta|^2 = 1"
        )
        equals = _find_nodes(g, type="relation", op="equals")
        elem = _find_node(g, type="relation", op="element_of")
        assert len(equals) == 2, f"expected 2 equals nodes, got {len(equals)}"
        assert elem is not None, "expected element_of for α,β ∈ ℂ"
        conj = _find_node(g, type="operator", op="and")
        assert conj is not None, "expected 'and' conjunction grouping α and β"
        assert self._reaches(g, "alpha", conj.id)
        assert self._reaches(g, "beta", conj.id)
        assert self._reaches(g, conj.id, elem.id)


class TestStatementSeparators:
    r"""Scene-level regression tests for the unified statement separator
    that handles both ``\\`` and ``, \quad`` in a single pass."""

    def _reaches(self, graph: SemanticGraph, src: str, dst: str):
        adj: dict[str, list[str]] = {}
        for e in graph.edges:
            adj.setdefault(e.from_, []).append(e.to)
        seen = {src}
        stack = [src]
        while stack:
            cur = stack.pop()
            if cur == dst:
                return True
            for nxt in adj.get(cur, []):
                if nxt not in seen:
                    seen.add(nxt)
                    stack.append(nxt)
        return False

    def test_general_qubit_step(self):
        r"""'Start with a general qubit' scene step uses ``, \quad`` to
        separate three statements:
        ``|\psi\rangle = \alpha|0\rangle + \beta|1\rangle``,
        ``\alpha,\beta\in\mathbb{C}``, and
        ``|\alpha|^2 + |\beta|^2 = 1``."""
        g = latex_to_semantic_graph(
            r"\htmlClass{hl-gen}{\lvert\psi\rangle = \alpha\lvert 0\rangle"
            r" + \beta\lvert 1\rangle}, \quad \alpha,\beta\in\mathbb{C},"
            r" \quad \lvert\alpha\rvert^2 + \lvert\beta\rvert^2 = 1"
        )
        equals = _find_nodes(g, type="relation", op="equals")
        elem = _find_node(g, type="relation", op="element_of")
        assert len(equals) == 2, f"expected 2 equals nodes, got {len(equals)}"
        assert elem is not None, "expected element_of for α,β ∈ ℂ"
        conj = _find_node(g, type="operator", op="and")
        assert conj is not None, "expected 'and' conjunction grouping α and β"
        assert self._reaches(g, "alpha", conj.id)
        assert self._reaches(g, "beta", conj.id)
        assert self._reaches(g, conj.id, elem.id)

    def test_borns_rule_step(self):
        r"""'Apply Born's rule' scene step uses ``\\`` to separate two
        equations: ``p(0) = \cos^2(\theta/2)`` and
        ``p(1) = \sin^2(\theta/2)``."""
        g = latex_to_semantic_graph(
            r"p(0)=\lvert\langle 0\vert\psi\rangle\rvert^2="
            r"\htmlClass{hl-p0}{\cos^2\!\left(\frac{\theta}{2}\right)}"
            r" \\ "
            r"p(1)=\lvert\langle 1\vert\psi\rangle\rvert^2="
            r"\htmlClass{hl-p1}{\sin^2\!\left(\frac{\theta}{2}\right)}"
        )
        equals = _find_nodes(g, type="relation", op="equals")
        assert len(equals) >= 2, (
            f"expected at least 2 equals nodes (one per line), got {len(equals)}"
        )


class TestTextCommand:
    """\\text{NAME} should become a single text node, not decomposed into
    per-character multiplications (SymPy's parse_latex default behavior)."""

    def test_text_becomes_single_constant(self):
        g = latex_to_semantic_graph(r"T = \text{const}")
        node = _find_node(g, type="text", label="const")
        assert node is not None
        assert node.latex == r"\text{const}"
        # No stray per-character symbols (c, o, n, s) should appear.
        for letter in ("c", "o", "n", "s"):
            assert _find_node(g, id=letter) is None, f"stray {letter!r} symbol leaked"

    def test_text_with_implies(self):
        """Regression: T = \\text{const} \\implies dP = k_B T / m · dρ
        previously decomposed 'const' into c·o·n·s·t inside the LHS equation."""
        g = latex_to_semantic_graph(
            r"T = \text{const} \implies dP = \frac{k_B T}{m}\, d\rho"
        )
        assert _find_node(g, type="text", label="const") is not None
        assert _find_node(g, type="operator", op="implies") is not None
        # Two equals nodes: one per side of the implication.
        equals_nodes = [n for n in g.nodes
                        if n.type == "relation" and n.op == "equals"]
        assert len(equals_nodes) == 2

    def test_repeated_text_dedups(self):
        """Same \\text{...} content should map to one node, not duplicate."""
        g = latex_to_semantic_graph(r"\text{foo} + \text{foo} = \text{bar}")
        foo_nodes = [n for n in g.nodes
                     if n.type == "text" and n.label == "foo"]
        assert len(foo_nodes) == 1
        assert _find_node(g, type="text", label="bar") is not None


# ---------------------------------------------------------------------------
# Complex real-world formulas
# ---------------------------------------------------------------------------

class TestComplexFormulas:
    """Parse real physics/math formulas and verify the graph captures key
    structural features (node types, operators, symbols, classification)."""

    def test_euler_identity(self):
        """e^{i pi} + 1 = 0 — symbols, constants, power, addition, equality.
        Note: parse_latex treats 'e', 'i', and 'pi' as plain Symbols, so all
        three flow through the symbol path. KNOWN_VARIABLES["pi"] still pins
        type=constant; ``e`` / ``i`` default to scalar."""
        g = latex_to_semantic_graph(r"e^{i \pi} + 1 = 0")
        assert _find_node(g, type="relation", op="equals")
        assert _find_node(g, id="e")
        assert _find_node(g, id="pi", type="constant")  # label/emoji are enricher's job
        assert _find_node(g, type="operator", op="power")
        assert _find_node(g, type="operator", op="add")
        assert g.classification.kind == "algebraic"

    def test_kinetic_energy(self):
        """K = 1/2 m v^2 — equation with fraction, multiplication, power."""
        g = latex_to_semantic_graph(r"K = \frac{1}{2} m v^2")
        assert _find_node(g, type="relation", op="equals")
        assert _find_node(g, id="K")
        assert _find_node(g, id="m")  # parser doesn't pre-fill labels
        assert _find_node(g, id="v")
        assert _find_node(g, type="operator", op="power")
        assert _find_node(g, type="operator", op="multiply")

    def test_gaussian_integral(self):
        """integral from -inf to inf of e^{-x^2} dx = sqrt(pi) —
        definite integral, exponential, constant, equality."""
        g = latex_to_semantic_graph(
            r"\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}"
        )
        assert _find_node(g, type="relation", op="equals")
        assert _find_node(g, type="operator", op="integral")
        assert _find_node(g, type="operator", op="power")
        # The integrand ``e^{-x^2}`` absorbs the exponent as a literal, so ``x``
        # is not a standalone node — the integration variable is carried by the
        # first-class differential node (``dx``), which grounds the integral.
        assert _find_node(g, id="dx", type="differential")
        assert g.classification.kind == "algebraic"

    def test_wave_equation_pde(self):
        """u_tt = c^2 u_xx — second-order PDE, partial derivatives."""
        g = latex_to_semantic_graph(
            r"\frac{\partial^2 u}{\partial t^2} = c^2 \frac{\partial^2 u}{\partial x^2}"
        )
        derivs = _find_nodes(g, type="operator", op="partial_derivative")
        assert len(derivs) == 2
        wrt_vars = {d.with_respect_to for d in derivs}
        assert "t" in wrt_vars
        assert "x" in wrt_vars
        c = g.classification
        assert c.kind == "PDE"
        assert c.order == 2
        assert set(c.independent_variables) == {"t", "x"}

    def test_harmonic_oscillator_ode(self):
        """x'' + omega^2 x = 0 — second-order linear ODE."""
        g = latex_to_semantic_graph(
            r"\frac{d^2 x}{dt^2} + \omega^2 x = 0"
        )
        assert _find_node(g, type="operator", op="derivative")
        assert _find_node(g, id="omega")  # label/quantity left for the enricher
        c = g.classification
        assert c.kind == "ODE"
        assert c.order == 2
        assert c.linear is True

    def test_schrodinger_time_independent(self):
        """E psi = -(h^2/2m) psi'' + V psi — ODE with many operators."""
        g = latex_to_semantic_graph(
            r"E \psi = -\frac{h^2}{2m} \frac{d^2 \psi}{dx^2} + V \psi"
        )
        assert _find_node(g, id="psi")
        assert _find_node(g, id="h")
        assert _find_node(g, type="operator", op="derivative")
        assert _find_node(g, type="relation", op="equals")
        c = g.classification
        assert c.kind == "ODE"
        assert c.order == 2

    def test_coulomb_law(self):
        """F = k q1 q2 / r^2 — subscripted variables, fractions."""
        g = latex_to_semantic_graph(r"F = k \frac{q_1 q_2}{r^2}")
        assert _find_node(g, type="relation", op="equals")
        assert _find_node(g, id="F")
        assert _find_node(g, id="r")
        assert _find_node(g, type="operator", op="power")
        # q_1 and q_2 are distinct symbols
        nodes = g.nodes
        q_nodes = [n for n in nodes if n.id.startswith("q_")]
        assert len(q_nodes) == 2

    def test_taylor_series_sin(self):
        """sin(x) = sum — function, summation, factorial, power."""
        g = latex_to_semantic_graph(
            r"\sin(x) = \sum_{n=0}^{\infty} \frac{(-1)^n}{(2n+1)!} x^{2n+1}"
        )
        assert _find_node(g, type="function", op="sin")
        assert _find_node(g, type="operator", op="sum")
        assert _find_node(g, type="relation", op="equals")
        # Should have factorial node
        assert _find_node(g, op="factorial")
        assert g.classification.kind == "algebraic"

    def test_lorentz_factor(self):
        """gamma = 1/sqrt(1 - v^2/c^2) — nested fractions, sqrt via power."""
        g = latex_to_semantic_graph(
            r"\gamma = \frac{1}{\sqrt{1 - \frac{v^2}{c^2}}}"
        )
        assert _find_node(g, id="gamma")
        assert _find_node(g, id="v")  # parser emits structural fields only
        assert _find_node(g, id="c")
        assert _find_node(g, type="relation", op="equals")
        assert g.classification.kind == "algebraic"

    def test_quadratic_formula(self):
        """x = (-b +/- sqrt(b^2 - 4ac)) / 2a — covers many node types."""
        g = latex_to_semantic_graph(
            r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"
        )
        assert _find_node(g, type="relation", op="equals")
        assert _find_node(g, id="x")
        assert _find_node(g, id="a")
        assert _find_node(g, id="b")
        assert _find_node(g, id="c")
        # Must have both addition and multiplication
        assert _find_node(g, type="operator", op="add")
        assert _find_node(g, type="operator", op="multiply")
        assert _find_node(g, type="operator", op="power")
        # Graph should be non-trivial
        assert len(g.nodes) >= 10
        assert len(g.edges) >= 10


# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------

class TestClassification:
    def test_algebraic_expression(self):
        g = latex_to_semantic_graph("x^2 + y^2")
        assert g.classification.kind == "algebraic"

    def test_ode_detected(self):
        g = latex_to_semantic_graph("\\frac{d v}{d t} = a")
        c = g.classification
        assert c.kind == "ODE"
        assert c.order == 1

    def test_algebraic_equation(self):
        g = latex_to_semantic_graph("E = m c^2")
        assert g.classification.kind == "algebraic"

    def test_mixed_partial_derivative_order(self):
        """Mixed partial d²u/(dx dt) should report order 2, not 1."""
        g = latex_to_semantic_graph(
            r"\frac{\partial}{\partial x}\frac{\partial u}{\partial t} = 0"
        )
        c = g.classification
        assert c.kind == "PDE"
        assert c.order == 2


# ---------------------------------------------------------------------------
# Variable overrides
# ---------------------------------------------------------------------------

class TestOverrides:
    def test_parse_var_overrides_single(self):
        result = parse_var_overrides(["m:unit=kg,tooltip=Mass"])
        assert result == {"m": {"unit": "kg", "tooltip": "Mass"}}

    def test_parse_var_overrides_multiple(self):
        result = parse_var_overrides([
            "m:unit=kg",
            "a:unit=m/s²",
        ])
        assert result["m"]["unit"] == "kg"
        assert result["a"]["unit"] == "m/s²"

    def test_parse_var_overrides_none(self):
        assert parse_var_overrides(None) == {}

    def test_parse_var_overrides_invalid_format(self):
        with pytest.raises(ValueError, match="Invalid --var format"):
            parse_var_overrides(["bad"])

    def test_parse_var_overrides_invalid_property(self):
        with pytest.raises(ValueError, match="Invalid property"):
            parse_var_overrides(["m:noequalssign"])

    def test_overrides_applied_to_graph(self):
        # Overrides still win — authors can pin any field explicitly.
        # The parser no longer pre-fills ``label`` for ``m``, so the
        # override is the only source of label / unit metadata.
        g = latex_to_semantic_graph("F = m \\cdot a", overrides={
            "m": {"label": "mass", "unit": "kg", "description": "Inertial mass"},
        })
        m_node = _find_node(g, id="m")
        assert m_node.unit == "kg"
        assert m_node.description == "Inertial mass"
        assert m_node.label == "mass"


# ---------------------------------------------------------------------------
# Edge semantics (direct / inverse)
# ---------------------------------------------------------------------------

def _edge(graph: SemanticGraph, src: str, dst: str):
    """Return the edge matching (src -> dst), or None."""
    for e in graph.edges:
        if e.from_ == src and e.to == dst:
            return e
    return None


class TestEdgeSemantics:
    """Heuristics:

    - ``base → Pow(n)`` edges stay untagged at parse time. The
      proportionality semantics live on the *outgoing* edge from a
      power node and are recovered by the renderer at draw time
      (it reads ``exponent`` off the power node and tags the
      downstream edge ``direct``/``inverse`` accordingly). This
      keeps the parsed graph lean and gives a single source of
      truth for the heuristic.
    - Every factor of a ``Mul`` is tagged ``direct`` at parse time —
      each operand is linearly proportional to the product, and
      multiply has many incoming edges so we can't recover this at
      render time without inspecting the source side.
    - Addition/subtraction/equality edges stay untagged: summands
      compose additively, not proportionally.
    """

    def test_pow_edges_are_not_tagged_at_parse_time(self):
        # The base→power edge stays plain. The renderer reads
        # ``exponent`` off the power node and applies the semantic
        # to the *outgoing* edge instead — see
        # ``scripts/graph_to_mermaid.semantic_graph_to_mermaid``.
        for latex_src in ("y = a / b", r"y = \frac{a}{b}", "y = x^2",
                          "y = x^3", r"F = \frac{1}{r^2}"):
            g = latex_to_semantic_graph(latex_src)
            pow_nodes = _find_nodes(g, type="operator", op="power")
            assert pow_nodes, f"expected a power node for {latex_src!r}"
            for pn in pow_nodes:
                incoming = [e for e in g.edges if e.to == pn.id]
                assert incoming, "power node should have an incoming edge"
                for edge in incoming:
                    assert edge.semantic is None, (
                        f"{latex_src!r}: pow incoming edge should not be tagged "
                        f"at parse time (got {edge})"
                    )
                    assert edge.weight is None, (
                        f"{latex_src!r}: pow incoming edge should not have a "
                        f"weight at parse time (got {edge})"
                    )

    def test_addition_has_no_semantic_tags(self):
        g = latex_to_semantic_graph("y = a + b")
        add_edges = [e for e in g.edges if e.to.startswith("__add")]
        assert add_edges, "expected at least one add-edge"
        for edge in add_edges:
            assert edge.semantic is None
            assert edge.weight is None

    def test_multiplication_tags_factors_as_direct(self):
        # ``a · t`` — both operands are linearly proportional to the
        # product, so every factor edge gets ``direct`` + weight 1.
        g = latex_to_semantic_graph(r"y = a \cdot t")
        mul_edges = [e for e in g.edges if e.to.startswith("__multiply")]
        assert len(mul_edges) == 2
        for edge in mul_edges:
            assert edge.semantic == "direct"
            assert edge.weight == 1.0

    def test_edge_weight_survives_schema_validation(self):
        from scripts.graph_to_mermaid import validate_graph
        g = latex_to_semantic_graph(r"v = \frac{x}{t^2}")
        errs = validate_graph(g.model_dump(by_alias=True, exclude_none=True))
        assert errs == [], f"unexpected schema errors: {errs}"

    def test_multiply_skips_tagging_for_inverse_pow_children(self):
        # ``a/b`` is ``Mul(a, Pow(b, -1))``. The factor edge for ``a``
        # is still ``direct`` but the edge from the inverse-power child
        # must stay plain so the renderer's power-source inference can
        # paint it ``inverse``.
        g = latex_to_semantic_graph(r"y = a / b")
        mul_node = _find_node(g, type="operator", op="multiply")
        assert mul_node is not None
        a_edge = next(e for e in g.edges
                      if e.to == mul_node.id and e.from_ == "a")
        assert a_edge.semantic == "direct"
        assert a_edge.weight == 1.0
        pow_edge = next(e for e in g.edges
                        if e.to == mul_node.id and e.from_.startswith("__power"))
        assert pow_edge.semantic is None, (
            f"inverse-pow → multiply edge must stay plain (got {pow_edge})"
        )
        assert pow_edge.weight is None

    def test_symbolic_negative_pow_absorbs_exponent(self):
        # ``x^{-n}`` should produce a single power node with
        # ``exponent="-n"`` and just one incoming edge from the base —
        # no extra ``__negate``/``n`` children. This is what lets the
        # renderer paint the outgoing edge ``inverse``.
        g = latex_to_semantic_graph(r"y = x^{-n}")
        pow_nodes = _find_nodes(g, type="operator", op="power")
        assert len(pow_nodes) == 1
        pn = pow_nodes[0]
        assert pn.exponent == "-n"
        incoming = [e for e in g.edges if e.to == pn.id]
        assert len(incoming) == 1
        assert incoming[0].from_ == "x"
        # And no negate/exponent helper nodes leaked through.
        assert not _find_nodes(g, type="operator", op="negation")


# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------

class TestErrors:
    def test_invalid_latex_raises(self):
        with pytest.raises(ValueError, match="Failed to parse"):
            latex_to_semantic_graph("\\frac{}")


# ---------------------------------------------------------------------------
# Comma-separated clauses (issue #144)
# ---------------------------------------------------------------------------

class TestCommaSplit:
    """Low-level brace-aware splitting."""

    def test_no_comma_returns_single_clause(self):
        assert _split_on_top_level_comma("x + y") == ["x + y"]

    def test_top_level_comma_splits(self):
        assert _split_on_top_level_comma("a = 1, b = 2") == ["a = 1", "b = 2"]

    def test_three_clauses(self):
        assert _split_on_top_level_comma("a, b, c") == ["a", "b", "c"]

    def test_comma_inside_braces_not_split(self):
        # \text{const, extra} must stay as a single clause — the comma
        # is brace-nested.
        assert _split_on_top_level_comma(r"\text{const, extra}") == [
            r"\text{const, extra}"
        ]

    def test_comma_inside_parens_not_split(self):
        # Function argument lists must not split.
        assert _split_on_top_level_comma("f(x, y)") == ["f(x, y)"]

    def test_comma_inside_brackets_not_split(self):
        # e.g. interval notation or matrix indexing.
        assert _split_on_top_level_comma("A[i, j]") == ["A[i, j]"]

    def test_mixed_nested_and_top_level(self):
        # Top-level comma splits; nested commas are preserved intact.
        assert _split_on_top_level_comma("f(x, y) = 0, g(a, b) = 1") == [
            "f(x, y) = 0",
            "g(a, b) = 1",
        ]

    def test_multi_function_call_all_args_preserved(self):
        """Multiple function calls in one expression — every argument
        comma is inside a ``(...)`` and must be preserved."""
        assert _split_on_top_level_comma("f(x, y) + g(a, b, c)") == [
            "f(x, y) + g(a, b, c)"
        ]

    def test_multi_index_subscript_not_split(self):
        r"""``A_{i, j}`` — the comma is inside a subscript brace group
        (brace depth 1) and must not be treated as a separator."""
        assert _split_on_top_level_comma("A_{i, j}") == ["A_{i, j}"]
        # Even in a larger expression with other top-level operators.
        assert _split_on_top_level_comma("A_{i, j} + B_{k, l}") == [
            "A_{i, j} + B_{k, l}"
        ]

    def test_set_literal_not_split(self):
        r"""``\{1, 2, 3\}`` — LaTeX set notation. The escaped ``\{`` and
        ``\}`` still count as brace-depth boundaries so the enclosed
        commas are nested (not separators)."""
        assert _split_on_top_level_comma(r"\{1, 2, 3\}") == [r"\{1, 2, 3\}"]

    def test_ordered_pair_not_split(self):
        """``(a, b)`` — pair notation. The inner comma is paren-nested."""
        assert _split_on_top_level_comma("(a, b)") == ["(a, b)"]

    def test_integral_with_thin_space_and_top_level_comma(self):
        r"""``\int f(x)\,dx, g = 0`` — contains ``\,`` (not a separator,
        via backslash-parity) inside an integral AND a real top-level
        comma. Only the real one should split."""
        assert _split_on_top_level_comma(r"\int f(x)\,dx, g = 0") == [
            r"\int f(x)\,dx",
            "g = 0",
        ]

    def test_trailing_comma_produces_no_empty_clause(self):
        assert _split_on_top_level_comma("a = 1,") == ["a = 1"]

    def test_leading_comma_dropped(self):
        assert _split_on_top_level_comma(", b = 2") == ["b = 2"]

    def test_latex_thin_space_not_split(self):
        r"""``\,`` is a LaTeX thin-space command — the trailing comma is
        part of the command, not a clause separator."""
        assert _split_on_top_level_comma(r"a \, b") == [r"a \, b"]
        # And combined with a real top-level comma, only the real one splits.
        assert _split_on_top_level_comma(r"a \, b, c") == [r"a \, b", "c"]

    def test_double_backslash_before_comma_still_splits(self):
        r"""``\\,`` — escaped backslash followed by a literal comma. The
        comma is NOT escaped, so the split should happen."""
        assert _split_on_top_level_comma(r"a \\, b") == [r"a \\", "b"]


class TestCommaSeparatedClauses:
    """Full graph behaviour for comma-separated statements (issue #144)."""

    def test_issue_144_exact_example(self):
        r"""The exact example from issue #144 must parse both clauses.
        Before the fix, the second clause was silently dropped by SymPy."""
        g = latex_to_semantic_graph(
            r"\frac{dh}{dt} = -V \sin \gamma, \quad \gamma = \text{const}"
        )
        # Both clauses present: the first has a Derivative, the second
        # has a 'const' text node.
        assert _find_nodes(g, type="operator", op="derivative"), (
            "first clause (derivative) must survive"
        )
        assert _find_node(g, label="const"), (
            "second clause (γ = const) must survive — this is the bug"
        )
        # Graph carries two independent statements — no parent/relation node.
        assert g.classification.kind == "statements"
        assert g.classification.count == 2

    def test_no_parent_relation_node_is_emitted(self):
        """Per author intent (issue #144 follow-up): the comma is a pure
        statement separator, not a logical operator. The graph must NOT
        carry a parent ``and`` / ``comma`` / ``conjunction`` relation node
        artificially joining the clauses."""
        g = latex_to_semantic_graph("a = 1, b = 2")
        assert _find_node(g, type="operator", op="and") is None
        assert _find_node(g, type="relation", op="comma") is None
        assert _find_node(g, type="relation", op="conjunction") is None

    def test_simultaneous_definitions(self):
        """a = 1, b = 2 — two independent equations as separate statements."""
        g = latex_to_semantic_graph("a = 1, b = 2")
        # Both variables a and b are present.
        assert _find_node(g, id="a") is not None
        assert _find_node(g, id="b") is not None
        # Two equals nodes, one per clause.
        equals_nodes = _find_nodes(g, type="relation", op="equals")
        assert len(equals_nodes) == 2
        # The graph has exactly two roots (nodes with no outgoing edges
        # among the operator/relation nodes) — the two equals nodes.
        out_set = {e.from_ for e in g.edges}
        op_roots = [n for n in g.nodes
                    if n.type in ("operator", "relation")
                    and n.id not in out_set]
        assert len(op_roots) == 2, (
            f"expected two statement roots, got {len(op_roots)}: {op_roots}"
        )

    def test_simultaneous_definitions_truly_disconnected(self):
        """`a = 1, b = 2` shares no variables — the two statement
        sub-graphs must be genuinely disconnected (no path between them)."""
        g = latex_to_semantic_graph("a = 1, b = 2")
        # Build undirected adjacency and check connected components.
        from collections import defaultdict
        adj = defaultdict(set)
        for e in g.edges:
            adj[e.from_].add(e.to)
            adj[e.to].add(e.from_)
        node_ids = {n.id for n in g.nodes}
        visited = set()

        def walk(start):
            stack, seen = [start], set()
            while stack:
                n = stack.pop()
                if n in seen:
                    continue
                seen.add(n)
                stack.extend(adj[n] - seen)
            return seen

        components = 0
        for nid in node_ids:
            if nid not in visited:
                comp = walk(nid)
                visited |= comp
                components += 1
        assert components == 2, (
            f"expected 2 disconnected components, got {components}"
        )

    def test_constraint_after_equation(self):
        """f(x) = x^2, x > 0 — equation plus domain constraint."""
        g = latex_to_semantic_graph("y = x^2, x > 0")
        # x must be a single shared node across both clauses — shared
        # variables dedup even without a parent relation node.
        x_nodes = _find_nodes(g, id="x")
        assert len(x_nodes) == 1, (
            "x should be shared between the equation and the constraint, "
            f"got {len(x_nodes)} nodes"
        )

    def test_three_clauses_all_present(self):
        g = latex_to_semantic_graph("a = 1, b = 2, c = 3")
        equals_nodes = _find_nodes(g, type="relation", op="equals")
        assert len(equals_nodes) == 3
        assert g.classification.count == 3

    def test_classification_lists_per_clause_kinds(self):
        """Per-clause classifications are preserved under
        ``classification.clauses`` so downstream consumers can see e.g.
        that clause 0 is algebraic and clause 1 is too, without walking
        the subtrees."""
        g = latex_to_semantic_graph("a = 1, b = 2")
        cls = g.classification
        assert cls.kind == "statements"
        assert cls.count == 2
        assert cls.clauses is not None
        assert len(cls.clauses) == 2
        # Every clause must have its own ``kind`` field populated.
        for sub in cls.clauses:
            assert sub.kind is not None

    def test_four_clauses_all_present(self):
        """Any number of commas — the splitter iterates, the builder
        prefixes operator ids ``c0_``, ``c1_``, …, and the graph holds
        one independent subtree per clause."""
        g = latex_to_semantic_graph("a = 1, b = 2, c = 3, d = 4")
        equals_nodes = _find_nodes(g, type="relation", op="equals")
        assert len(equals_nodes) == 4
        assert g.classification.count == 4
        # All four clause equals-node ids must be uniquely prefixed.
        equals_ids = {n.id for n in equals_nodes}
        assert equals_ids == {
            "c0___equals_1", "c1___equals_1", "c2___equals_1", "c3___equals_1",
        }

    def test_multi_clause_mixed_variable_sharing(self):
        """Three clauses where two share a variable and the third doesn't:
        ``a = 1, a + b = 5, c = 3``. The graph should have exactly two
        connected components (clauses 0+1 glued through shared ``a``;
        clause 2 standalone)."""
        g = latex_to_semantic_graph("a = 1, a + b = 5, c = 3")
        assert g.classification.count == 3
        # Count connected components via undirected traversal.
        from collections import defaultdict
        adj = defaultdict(set)
        for e in g.edges:
            adj[e.from_].add(e.to)
            adj[e.to].add(e.from_)
        node_ids = {n.id for n in g.nodes}
        visited = set()

        def walk(start):
            stack, seen = [start], set()
            while stack:
                n = stack.pop()
                if n in seen:
                    continue
                seen.add(n)
                stack.extend(adj[n] - seen)
            return seen

        components = 0
        for nid in node_ids:
            if nid not in visited:
                visited |= walk(nid)
                components += 1
        assert components == 2, (
            f"expected 2 components (a-clauses merged via shared 'a', "
            f"c standalone); got {components}"
        )
        # ``a`` must be shared — single node referenced by both c0 and c1 equals.
        a_nodes = _find_nodes(g, id="a")
        assert len(a_nodes) == 1

    def test_comma_inside_text_not_split(self):
        r"""Commas inside \text{...} must not trigger a split —
        otherwise \text{a, b} would be broken into two bogus clauses."""
        g = latex_to_semantic_graph(r"x = \text{foo, bar}")
        # Single equation — not multi-statement.
        assert g.classification.kind != "statements"
        equals_nodes = _find_nodes(g, type="relation", op="equals")
        assert len(equals_nodes) == 1

    def test_comma_inside_function_args_not_split(self):
        """f(x, y) — function argument commas must not split."""
        g = latex_to_semantic_graph("f(x, y)")
        # Single statement — the comma is a function-arg separator, not a
        # statement separator.
        assert g.classification.kind != "statements"

    def test_failed_clause_raises_not_silently_dropped(self):
        """Per issue #137: parse failures must surface, not silently drop.
        When one clause is malformed, the whole expression should raise."""
        with pytest.raises(ValueError):
            latex_to_semantic_graph(r"a = 1, \frac{}")

    def test_shared_variable_dedup_across_clauses(self):
        r"""γ appearing in both clauses must collapse to one node,
        consistent with existing in-clause variable dedup."""
        g = latex_to_semantic_graph(
            r"\frac{dh}{dt} = -V \sin \gamma, \gamma = \text{const}"
        )
        gamma_nodes = _find_nodes(g, id="gamma")
        assert len(gamma_nodes) == 1, (
            f"gamma should be shared across clauses, got {len(gamma_nodes)}"
        )

    def test_clause_subexprs_strip_leading_spacing_commands(self):
        r"""Authors write ``a, \quad b`` for visual spacing. The ``\quad``
        is visual only — it must not leak into the second clause's root
        subexpr (would render as empty whitespace in the UI)."""
        g = latex_to_semantic_graph(
            r"\frac{dh}{dt} = -V \sin \gamma, \quad \gamma = \text{const}"
        )
        equals_nodes = _find_nodes(g, type="relation", op="equals")
        subexprs = {(n.subexpr or "") for n in equals_nodes}
        # Neither equals subexpr should start with a leading \quad/\qquad/etc.
        for s in subexprs:
            assert not s.lstrip().startswith("\\quad"), (
                f"leading \\quad leaked into clause subexpr: {s!r}"
            )
            assert not s.lstrip().startswith("\\qquad"), (
                f"leading \\qquad leaked into clause subexpr: {s!r}"
            )
        # And the γ = const clause should be clean.
        assert any("\\gamma = \\text{const}" in s for s in subexprs)

    def test_distinct_text_per_clause_not_merged(self):
        r"""Regression for Copilot review on PR #155: each clause runs
        ``_collapse_text_commands`` independently, so ``\text{foo}`` in
        clause 0 and ``\text{bar}`` in clause 1 would both produce the
        symbol id ``Xi_{0}``. Without per-clause namespacing of text
        placeholders, the merge step would incorrectly dedup them into
        a single text node — clause 1's ``bar`` would disappear."""
        g = latex_to_semantic_graph(r"x = \text{foo}, y = \text{bar}")
        text_nodes = _find_nodes(g, type="text")
        labels = sorted(n.label for n in text_nodes)
        assert labels == ["bar", "foo"], (
            f"expected two distinct text nodes (foo, bar), got {labels!r} "
            f"— Xi_{{N}} placeholder collision across clauses"
        )
        # Both equals nodes must survive, each with its own text operand.
        equals_nodes = _find_nodes(g, type="relation", op="equals")
        assert len(equals_nodes) == 2

    def test_same_text_per_clause_each_gets_its_own_node(self):
        r"""``\text{foo}`` appearing in two clauses should produce two
        distinct text nodes — each clause is an independent statement
        and text placeholders are per-clause, not globally shared."""
        g = latex_to_semantic_graph(r"x = \text{foo}, y = \text{foo}")
        text_nodes = _find_nodes(g, type="text", label="foo")
        assert len(text_nodes) == 2, (
            f"expected two independent 'foo' text nodes (one per clause), "
            f"got {len(text_nodes)}"
        )

    def test_each_clause_has_its_own_clean_subexpr(self):
        r"""Each clause's root node should carry only that clause's LaTeX
        as its subexpr — not the full comma-joined expression. No clause
        should leak the other clause's content into its subexpr."""
        g = latex_to_semantic_graph(
            r"\frac{dh}{dt} = -V \sin \gamma, \quad \gamma = \text{const}"
        )
        equals_nodes = _find_nodes(g, type="relation", op="equals")
        subexprs = [(n.subexpr or "") for n in equals_nodes]
        # Neither subexpr should contain a comma (that would mean the
        # whole original expression leaked in).
        for s in subexprs:
            assert "," not in s, (
                f"clause subexpr should not carry the top-level comma: {s!r}"
            )
        # First clause has the derivative/sin, second has \text{const}.
        has_deriv = any("dh" in s and "dt" in s for s in subexprs)
        has_const = any("\\text{const}" in s for s in subexprs)
        assert has_deriv and has_const, (
            f"each clause should own a distinct subexpr, got {subexprs!r}"
        )


# ---------------------------------------------------------------------------
# Compound symbols (\Delta t, \Delta x, etc.) — regression for #179
# ---------------------------------------------------------------------------

class TestCompoundSymbols:
    def test_delta_t_is_single_symbol(self):
        """``\\Delta t`` must collapse to one node, not Δ multiplied by t."""
        g = latex_to_semantic_graph(r"v = \Delta t")
        # No multiply operator should appear — Δt is one identifier.
        muls = _find_nodes(g, type="operator", op="multiply")
        assert not muls, (
            f"\\Delta t should not produce a multiply node, got {muls!r}"
        )
        # No standalone Delta or t node — only the compound and v.
        delta_nodes = _find_nodes(g, latex=r"\Delta")
        assert not delta_nodes, "\\Delta must not be split off as its own node"
        # The compound symbol's latex field should be the original LaTeX.
        compound = _find_node(g, latex=r"\Delta t")
        assert compound is not None, "expected a node with latex = '\\Delta t'"
        assert compound.subexpr == r"\Delta t"

    def test_delta_x_over_delta_t(self):
        """``\\Delta x / \\Delta t`` produces two compound nodes, no split."""
        g = latex_to_semantic_graph(r"v = \frac{\Delta x}{\Delta t}")
        compounds = sorted(
            n.latex for n in g.nodes
            if n.latex in (r"\Delta x", r"\Delta t")
        )
        assert compounds == [r"\Delta t", r"\Delta x"], (
            f"both \\Delta x and \\Delta t should be present as compounds, "
            f"got {compounds!r}"
        )
        # No standalone Δ or stray t/x sharing the name with the compound.
        assert not _find_nodes(g, latex=r"\Delta")

    def test_lone_delta_unchanged(self):
        """Bare ``\\Delta`` (e.g. discriminant) must remain a single Δ."""
        g = latex_to_semantic_graph(r"\Delta = b^2 - 4ac")
        delta = _find_node(g, latex=r"\Delta")
        assert delta is not None, "bare \\Delta should still produce a Δ node"
        # And no compound-collapsing should have triggered.
        thetas = [n for n in g.nodes
                  if isinstance(n.id, str) and n.id.startswith("Theta_{")]
        assert not thetas, "no compound placeholder should be created for lone \\Delta"

    def test_partial_derivative_still_parses(self):
        """``\\partial`` must NOT be collapsed — derivatives still need it."""
        g = latex_to_semantic_graph(r"\frac{\partial u}{\partial x} = 0")
        derivs = _find_nodes(g, type="operator", op="partial_derivative")
        assert derivs, (
            "\\partial u / \\partial x should still be recognized as a "
            "derivative, not a fraction of compound symbols"
        )

    def test_delta_with_greek_operand(self):
        """``\\Delta\\theta`` collapses to one node with the right LaTeX."""
        g = latex_to_semantic_graph(r"\Delta\theta = 5")
        compound = _find_node(g, latex=r"\Delta \theta")
        assert compound is not None, (
            "\\Delta\\theta should collapse to a single Δθ node"
        )

    def test_delta_with_letter_subscript(self):
        """``\\Delta t_0`` collapses with the subscript absorbed into the
        placeholder, not left dangling as ``\\Theta_{N}_0`` (invalid).
        """
        g = latex_to_semantic_graph(r"v = \Delta t_0")
        compound = _find_node(g, latex=r"\Delta t_0")
        assert compound is not None, (
            "\\Delta t_0 should collapse to a single node, "
            "with the subscript absorbed into the compound"
        )
        # No spurious standalone Delta/t/0 nodes.
        assert not _find_nodes(g, latex=r"\Delta")

    def test_delta_with_greek_subscript(self):
        """``\\Delta\\theta_0`` collapses despite the trailing subscript —
        regression for the ``\\b`` boundary that previously prevented
        Greek-operand matches when followed by ``_``.
        """
        g = latex_to_semantic_graph(r"v = \Delta\theta_0")
        compound = _find_node(g, latex=r"\Delta \theta_0")
        assert compound is not None, (
            "\\Delta\\theta_0 should collapse — the regex must allow a "
            "trailing subscript after a Greek operand"
        )

    def test_delta_with_braced_subscript(self):
        """``\\Delta\\theta_{ij}`` collapses with the braced subscript
        absorbed into the compound.
        """
        g = latex_to_semantic_graph(r"v = \Delta\theta_{ij}")
        compound = _find_node(g, latex=r"\Delta \theta_{ij}")
        assert compound is not None

    def test_delta_with_superscript(self):
        """``\\Delta\\theta^2`` collapses with the superscript absorbed."""
        g = latex_to_semantic_graph(r"v = \Delta\theta^2")
        compound = _find_node(g, latex=r"\Delta \theta^2")
        assert compound is not None

    def test_nabla_does_not_collapse_function_application(self):
        """``\\nabla f(x,y)`` keeps the gradient operator + function shape;
        ``\\nabla`` must not be collapsed onto the following identifier.

        Regression for the ``gradient-descent-terrain`` scene cluster,
        which uses ``\\nabla f(...)`` throughout — collapsing would make
        ``f(x,y)`` parse as a function call on the compound symbol
        ``\\nabla f`` instead of the gradient operator applied to ``f``
        evaluated at ``(x, y)``.
        """
        g = latex_to_semantic_graph(r"\nabla f(x,y)")
        # No compound placeholder should be created — ``\nabla`` stands alone.
        thetas = [n for n in g.nodes
                  if isinstance(n.id, str) and n.id.startswith("Theta_{")]
        assert not thetas, (
            "\\nabla must not be collapsed onto its operand; got "
            f"placeholder nodes {thetas!r}"
        )
        # ``\nabla`` should still appear as its own node.
        nabla = _find_node(g, latex=r"\nabla")
        assert nabla is not None, "\\nabla should remain a standalone node"

    def test_compound_in_power_atomicity(self):
        """``(\\Delta t)^2`` must render with the compound braced — the
        exponent has to bind to the whole ``\\Delta t``, not to ``t`` alone.

        SymPy emits ``\\Theta_{0}^{2}`` for the Pow node's subexpr; the
        restoration step needs to wrap the multi-token replacement in
        braces so the result reads as ``(Δt)²`` rather than ``Δ(t²)``.
        """
        g = latex_to_semantic_graph(r"y = (\Delta t)^2")
        power_subexprs = [
            (n.subexpr or "") for n in g.nodes
            if n.op == "power"
        ]
        assert power_subexprs, "expected a power node in the graph"
        for s in power_subexprs:
            assert r"{\Delta t}" in s, (
                "the compound symbol inside a power must be braced for "
                f"correct precedence, got subexpr={s!r}"
            )
            assert r"\Delta t^" not in s, (
                "unbraced ``\\Delta t^...`` reads as ``Δ(t^...)`` in "
                "LaTeX — exponent must apply to the whole compound"
            )

    def test_compound_with_thin_space_macro(self):
        """``\\Delta\\,t`` — physics typographic spacing — collapses
        identically to ``\\Delta t``.

        Regression for an authoring pattern where the typesetter inserts
        a thin space between the prefix and operand; without macro-aware
        whitespace handling, the regex doesn't fire and SymPy falls back
        to the implicit-multiplication split.
        """
        g = latex_to_semantic_graph(r"v = \Delta\,t")
        compound = _find_node(g, latex=r"\Delta t")
        assert compound is not None, (
            "\\Delta\\,t should collapse to a single \\Delta t node — "
            "spacing macros must not block the compound-symbol rule"
        )
        assert not _find_nodes(g, type="operator", op="multiply"), (
            "\\Delta\\,t must not produce an implicit-multiplication node"
        )

    def test_compound_with_quad_macro(self):
        """``\\Delta \\quad t`` — collapses despite the wide spacing macro."""
        g = latex_to_semantic_graph(r"v = \Delta \quad t")
        compound = _find_node(g, latex=r"\Delta t")
        assert compound is not None

    def test_user_override_does_not_corrupt_text_macros(self):
        """User-supplied overrides keyed on a real symbol (e.g. ``t``)
        must NOT bleed into ``\\text{...}``, ``\\tan``, ``\\left``, etc.

        Regression for the placeholder-restoration over-reach: the old
        loop ran ``str.replace`` for every override regardless of whether
        the key was a synthetic ``Theta_{N}``/``Xi_{N}`` sentinel, which
        meant overriding a single letter would silently chew matching
        characters out of unrelated LaTeX macros.
        """
        g = latex_to_semantic_graph(
            r"\text{const} + t",
            overrides={"t": {"latex": r"\mathrm{t}"}},
        )
        # No node's latex/subexpr should contain the corrupted form
        # ``\ex`` (what's left after a ``\mathrm{t}`` ate the ``t`` of
        # ``\text``). Check both the override is honored *and* \text
        # survives intact.
        const = _find_node(g, latex=r"\text{const}")
        assert const is not None, (
            "user override on 't' must not corrupt \\text{const}"
        )
        for node in g.nodes:
            for field in ("latex", "subexpr"):
                value = getattr(node, field, None)
                if isinstance(value, str):
                    assert r"\ex" not in value or r"\text" in value, (
                        f"\\text macro corrupted in {field}={value!r}"
                    )

    def test_explicit_product_not_collapsed(self):
        """``\\Delta \\cdot t`` and ``\\Delta \\times t`` mean Δ * t —
        the explicit operator must defeat the compound-symbol heuristic.

        This is the disambiguation contract: adjacency = single symbol,
        ``\\cdot`` / ``\\times`` = explicit multiplication.
        """
        for op_latex in (r"\cdot", r"\times"):
            g = latex_to_semantic_graph(rf"v = \Delta {op_latex} t")
            assert _find_nodes(g, type="operator", op="multiply"), (
                f"\\Delta {op_latex} t should produce a multiply node"
            )
            assert _find_node(g, latex=r"\Delta") is not None, (
                f"\\Delta {op_latex} t must keep \\Delta as its own node"
            )
            assert _find_node(g, latex="t") is not None, (
                f"\\Delta {op_latex} t must keep t as its own node"
            )

    def test_compound_in_relation_subexpr(self):
        """``\\Delta t \\propto x`` must not leak placeholder tokens
        (``\\Theta_{0}``) into the LHS root node's ``subexpr``."""
        g = latex_to_semantic_graph(r"\Delta t \propto x")
        rel = _find_node(g, type="relation")
        assert rel is not None, "should produce a relation node"
        for node in g.nodes:
            for field in ("latex", "subexpr"):
                value = getattr(node, field, None)
                if isinstance(value, str):
                    assert "Theta" not in value, (
                        f"placeholder leaked into {field}={value!r}"
                    )


# ---------------------------------------------------------------------------
# Parenthetical annotation extraction
# ---------------------------------------------------------------------------

class TestParentheticalAnnotations:
    """Tests for _extract_parenthetical_annotations and annotation node injection."""

    def test_simple_text_annotation(self):
        latex = r"F = m \cdot a \quad (v_e \text{ constant})"
        cleaned, anns = _extract_parenthetical_annotations(latex)
        assert cleaned == r"F = m \cdot a"
        assert len(anns) == 1
        assert anns[0]["type"] == "annotation"
        assert "constant" in anns[0]["label"]
        assert r"\text" in anns[0]["latex"]

    def test_no_annotation(self):
        latex = r"x^2 + y^2 = r^2"
        cleaned, anns = _extract_parenthetical_annotations(latex)
        assert cleaned == latex
        assert anns == []

    def test_plain_math_parentheses_not_extracted(self):
        latex = r"(a + b)^2 = a^2 + 2ab + b^2"
        cleaned, anns = _extract_parenthetical_annotations(latex)
        assert cleaned == latex
        assert anns == []

    def test_qquad_spacing(self):
        latex = r"E = mc^2 \qquad (c \text{ speed of light})"
        cleaned, anns = _extract_parenthetical_annotations(latex)
        assert cleaned == r"E = mc^2"
        assert len(anns) == 1
        assert "speed of light" in anns[0]["label"]

    def test_multiple_annotations(self):
        latex = r"a = b \quad (x \text{ pos}) \quad (y \text{ neg})"
        cleaned, anns = _extract_parenthetical_annotations(latex)
        assert cleaned == r"a = b"
        assert len(anns) == 2

    def test_inject_annotations_adds_nodes(self):
        graph = {"nodes": [{"id": "x", "type": "variable"}], "edges": []}
        anns = [
            {"latex": r"v_e \text{ constant}", "label": "v_e constant", "type": "annotation"},
        ]
        _inject_annotations(graph, anns)
        assert len(graph["nodes"]) == 2
        anno = graph["nodes"][1]
        assert anno["id"] == "__annotation_0"
        assert anno["type"] == "annotation"
        assert anno["label"] == "v_e constant"

    def test_inject_empty_annotations(self):
        graph = {"nodes": [{"id": "x", "type": "variable"}], "edges": []}
        _inject_annotations(graph, [])
        assert len(graph["nodes"]) == 1

    def test_end_to_end_annotation_in_graph(self):
        g = latex_to_semantic_graph(r"F = m a \quad (m \text{ constant})")
        anno = _find_node(g, type="annotation")
        assert anno is not None, "annotation node should be in graph"
        assert anno.id == "__annotation_0"
        assert "constant" in anno.label

    def test_annotation_does_not_break_equation_parsing(self):
        g = latex_to_semantic_graph(r"v = v_0 + a t \quad (a \text{ constant})")
        eq = _find_node(g, op="equals")
        assert eq is not None, "equation should still parse correctly"
        anno = _find_node(g, type="annotation")
        assert anno is not None

    # Side conditions without \text — issue #435
    def test_side_condition_extracted(self):
        latex = r"T_0 = h \qquad (c = 1)"
        cleaned, anns = _extract_parenthetical_annotations(latex)
        assert cleaned == r"T_0 = h"
        assert len(anns) == 1
        assert anns[0]["label"] == "c = 1"

    def test_side_condition_requires_quad_separator(self):
        latex = r"a = b (c = 1)"
        cleaned, anns = _extract_parenthetical_annotations(latex)
        assert cleaned == latex
        assert anns == []

    def test_trailing_function_call_not_extracted(self):
        latex = r"y = f \qquad (x)"
        cleaned, anns = _extract_parenthetical_annotations(latex)
        assert cleaned == latex
        assert anns == []

    def test_end_to_end_side_condition_not_chained(self):
        g = latex_to_semantic_graph(r"T_0 = h \qquad (c = 1)")
        anno = _find_node(g, type="annotation")
        assert anno is not None, "side condition should become an annotation node"
        assert anno.label == "c = 1"
        eq = _find_node(g, op="equals")
        assert eq is not None
        operands = [e.from_ for e in g.edges if e.to == eq.id]
        assert len(operands) == 2, (
            f"(c = 1) must not be chained into the main equality: {operands}"
        )
        assert _find_node(g, type="number", value=1) is None


# ---------------------------------------------------------------------------
# Dirac bra-ket notation (issue #211)
# ---------------------------------------------------------------------------

class TestBraketCollapse:
    """Unit tests for _collapse_braket_notation()."""

    def test_braket_inner_product(self):
        rewritten, overrides = _collapse_braket_notation(r"\langle\phi|\psi\rangle")
        assert len(overrides) == 1
        key = list(overrides.keys())[0]
        assert key.startswith("Phi_{")
        # The inner product is an *operator* (like ``+`` or ``=``)
        # with ``op="inner_product"`` — not a separate type.
        assert overrides[key]["type"] == "operator"
        assert overrides[key]["op"] == "inner_product"
        # ``latex`` is the compact skeleton (node display label);
        # ``original_latex`` is the full ⟨bra|ket⟩ used as the node's
        # subexpr and as the substitution payload for upstream wrappers.
        assert overrides[key]["latex"] == r"\langle \cdot\,|\,\cdot\rangle"
        assert overrides[key]["original_latex"] == r"\langle\phi|\psi\rangle"
        assert overrides[key]["bra_content"] == r"\phi"
        assert overrides[key]["ket_content"] == r"\psi"

    def test_braket_constant_kept_in_skeleton(self):
        """Numeric basis labels stay verbatim in the operator skeleton."""
        _, overrides = _collapse_braket_notation(r"\langle 0|\psi\rangle")
        key = list(overrides.keys())[0]
        # Bra is constant ``0`` (kept in label), ket is symbolic ``\psi`` (slot).
        assert overrides[key]["latex"] == r"\langle 0\,|\,\cdot\rangle"
        assert overrides[key]["original_latex"] == r"\langle 0|\psi\rangle"

    def test_braket_with_numbers(self):
        rewritten, overrides = _collapse_braket_notation(r"\langle 0|1\rangle")
        assert len(overrides) == 1
        assert r"\Phi_{0}" in rewritten

    def test_braket_dedup(self):
        rewritten, overrides = _collapse_braket_notation(
            r"\langle\phi|\psi\rangle + \langle\phi|\psi\rangle"
        )
        assert len(overrides) == 1, "same braket should dedup"

    def test_no_braket_no_change(self):
        latex = r"x + y = z"
        rewritten, overrides = _collapse_braket_notation(latex)
        assert rewritten == latex
        assert len(overrides) == 0

    def test_ket_not_collapsed(self):
        """Standalone kets should NOT be collapsed — SymPy handles them."""
        latex = r"|\psi\rangle"
        rewritten, overrides = _collapse_braket_notation(latex)
        assert rewritten == latex
        assert len(overrides) == 0

    def test_braket_inside_equation(self):
        rewritten, overrides = _collapse_braket_notation(
            r"\langle\phi|\psi\rangle = 0"
        )
        assert len(overrides) == 1
        assert "= 0" in rewritten


class TestDiracNotation:
    """Integration tests for ket/bra/braket in semantic graphs."""

    def test_issue_211_ket_equation(self):
        """The exact LaTeX from issue #211 — kets should be atomic nodes."""
        g = latex_to_semantic_graph(
            r"|\psi\rangle = e^{i\gamma_\alpha}\left(r_\alpha|0\rangle "
            r"+ r_\beta e^{i(\gamma_\beta - \gamma_\alpha)}|1\rangle\right)"
        )
        kets = [n for n in g.nodes if n.type == "ket"]
        assert len(kets) == 3, f"expected 3 kets, got {len(kets)}"
        eq = _find_node(g, op="equals")
        assert eq is not None, "equation root should exist"
        add = _find_node(g, op="add")
        assert add is not None, "addition should exist"

    def test_simple_ket(self):
        g = latex_to_semantic_graph(r"|\psi\rangle")
        kets = [n for n in g.nodes if n.type == "ket"]
        assert len(kets) == 1
        assert r"\rangle" in kets[0].latex

    def test_ket_addition(self):
        g = latex_to_semantic_graph(r"|0\rangle + |1\rangle")
        kets = [n for n in g.nodes if n.type == "ket"]
        assert len(kets) == 2
        add = _find_node(g, op="add")
        assert add is not None

    def test_bra_standalone(self):
        g = latex_to_semantic_graph(r"\langle\phi|")
        bras = [n for n in g.nodes if n.type == "bra"]
        assert len(bras) == 1
        assert r"\langle" in bras[0].latex

    def test_braket_inner_product_equation(self):
        g = latex_to_semantic_graph(r"\langle\phi|\psi\rangle = 0")
        # Inner product is an operator with ``op="inner_product"`` —
        # filter by op so we don't pick up ``=`` or ``+`` operators.
        brakets = [n for n in g.nodes if n.op == "inner_product"]
        assert len(brakets) == 1, "one inner product → one operator node"
        # Symbolic operands are wired in via edges, not baked into the label.
        bk = brakets[0]
        assert bk.type == "operator"
        bk_id = bk.id
        operand_ids = {e.from_ for e in g.edges if e.to == bk_id}
        assert "phi" in operand_ids and "psi" in operand_ids
        eq = _find_node(g, op="equals")
        assert eq is not None

    def test_braket_constant_bra_is_baked_in(self):
        """``⟨0|ψ⟩`` — the ``0`` stays in the operator label, not as an edge."""
        g = latex_to_semantic_graph(r"\langle 0|\psi\rangle = c")
        brakets = [n for n in g.nodes if n.op == "inner_product"]
        assert len(brakets) == 1
        assert brakets[0].type == "operator"
        bk_id = brakets[0].id
        operand_ids = {e.from_ for e in g.edges if e.to == bk_id}
        # Only ψ flows in. The constant ``0`` lives in the operator label.
        assert operand_ids == {"psi"}, f"expected only psi, got {operand_ids}"

    def test_braket_shared_psi_across_clauses(self):
        """``⟨0|ψ⟩`` and ``⟨1|ψ⟩`` in two clauses share one ψ node."""
        g = latex_to_semantic_graph(
            r"a = \langle 0|\psi\rangle \\ b = \langle 1|\psi\rangle"
        )
        psi_nodes = [n for n in g.nodes if n.id == "psi"]
        assert len(psi_nodes) == 1, "ψ should be a single shared node"
        psi_edges = [e for e in g.edges if e.from_ == "psi"]
        assert len(psi_edges) == 2, "ψ should connect to both brakets"

    def test_braket_node_latex_is_compact_skeleton(self):
        """Node label is the compact skeleton; subexpr carries the full form.

        Mirrors how ``cos`` works: the node displays a compact operator
        symbol (``\\cos``, ``⟨0|·⟩``) while ``subexpr`` carries the full
        applied form (``\\cos(θ/2)``, ``⟨0|ψ⟩``) for the details panel
        and TTS narration.  Putting the full applied form in ``latex``
        bloats the node label and breaks visual consistency with all
        the other operators (``cos``, ``|·|``, ``(·)²``).
        """
        g = latex_to_semantic_graph(r"\langle 0|\psi\rangle = c")
        bk = next(n for n in g.nodes if n.op == "inner_product")
        # Compact skeleton on the node label.
        assert bk.latex == r"\langle 0\,|\,\cdot\rangle"
        # Full applied form in subexpr — what the details panel shows.
        assert bk.subexpr == r"\langle 0|\psi\rangle"


class TestOperatorKind:
    """Tests for operator_kind() — one representative op per kind,
    plus the default-by-type fallback."""

    @pytest.mark.parametrize("op,expected_kind", [
        ("add", "arithmetic"),
        ("multiply", "arithmetic"),
        ("power", "arithmetic"),
        ("sin", "function"),
        ("exp", "function"),
        ("sqrt", "function"),
        ("equals", "comparison"),
        ("less_than", "comparison"),
        ("implies", "logical"),
        ("conjunction", "logical"),
        ("sum", "aggregate"),
        ("integral", "aggregate"),
        ("derivative", "aggregate"),
        ("inner_product", "quantum"),
    ])
    def test_known_ops(self, op, expected_kind):
        node = {"type": "operator", "op": op}
        assert operator_kind(node) == expected_kind

    def test_function_type_defaults_to_function_kind(self):
        node = {"type": "function", "op": "unknown_func"}
        assert operator_kind(node) == "function"

    def test_operator_type_defaults_to_arithmetic(self):
        node = {"type": "operator", "op": "unknown_op"}
        assert operator_kind(node) == "arithmetic"

    def test_relation_type_defaults_to_arithmetic(self):
        node = {"type": "relation", "op": "unknown_rel"}
        assert operator_kind(node) == "arithmetic"

    def test_non_op_returns_none(self):
        assert operator_kind({"type": "scalar", "op": "add"}) is None
        assert operator_kind({"type": "vector"}) is None
        assert operator_kind({}) is None

    def test_braket_upstream_subexpr_has_full_braket(self):
        """``|⟨0|ψ⟩|²`` — Abs / power / equals subexprs contain the full braket.

        Regression: ``_restore_placeholders`` previously substituted a
        skeleton (``⟨0|·⟩``) into upstream LaTeX, making parents read
        ``|⟨0|·⟩|²`` instead of real math.
        """
        g = latex_to_semantic_graph(
            r"p = \lvert\langle 0|\psi\rangle\rvert^2"
        )
        # Every operator/function whose subexpr involves the braket
        # must contain the full ``\psi``, never a ``\cdot`` placeholder.
        wrappers = [
            n for n in g.nodes
            if "langle" in (n.subexpr or "")
        ]
        assert wrappers, "expected at least one wrapper node referencing the braket"
        for n in wrappers:
            sx = n.subexpr
            assert r"\psi" in sx, f"node {n.id} subexpr lost ψ: {sx!r}"
            assert r"\cdot" not in sx, (
                f"node {n.id} subexpr leaks operator skeleton: {sx!r}"
            )

    def test_braket_edge_roles(self):
        """Bra-side operands get ``role='lhs'``, ket-side ``role='rhs'``."""
        g = latex_to_semantic_graph(r"\langle\phi|\psi\rangle = 0")
        bk_id = next(n.id for n in g.nodes if n.op == "inner_product")
        edges = {e.from_: e.role for e in g.edges if e.to == bk_id}
        assert edges.get("phi") == "lhs", f"phi should be bra (lhs): {edges}"
        assert edges.get("psi") == "rhs", f"psi should be ket (rhs): {edges}"

    def test_braket_pure_constants_have_no_operand_edges(self):
        """``⟨0|1⟩`` — both sides constant → no operand edges into the braket."""
        g = latex_to_semantic_graph(r"\langle 0|1\rangle = c")
        bk_id = next(n.id for n in g.nodes if n.op == "inner_product")
        # Only the upstream ``=`` edge points away from the braket; nothing
        # symbolic flows in.  Edges *to* the braket must be empty.
        in_edges = [e for e in g.edges if e.to == bk_id]
        assert in_edges == [], f"expected no operand edges, got {in_edges}"

    def test_ket_equation_has_equals(self):
        g = latex_to_semantic_graph(r"|\psi\rangle = |0\rangle")
        eq = _find_node(g, op="equals")
        assert eq is not None
        kets = [n for n in g.nodes if n.type == "ket"]
        assert len(kets) == 2

    def test_ket_with_subscript(self):
        g = latex_to_semantic_graph(r"|\psi_n\rangle")
        kets = [n for n in g.nodes if n.type == "ket"]
        assert len(kets) == 1

    def test_ket_coefficient_multiply(self):
        """Coefficient × ket should produce multiply node."""
        g = latex_to_semantic_graph(r"\alpha|0\rangle + \beta|1\rangle")
        kets = [n for n in g.nodes if n.type == "ket"]
        assert len(kets) == 2
        muls = [n for n in g.nodes if n.op == "multiply"]
        assert len(muls) >= 2, "each coefficient×ket should be a multiply"
