"""Unit tests for `attractor.workflow.serialize.to_dot`.

Covers the round-trip property (parse → serialize → parse yields an
equivalent Graph), quoting + escaping, and source-order preservation.
The CLI surface lives in `tests/test_cli/test_render.py`.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from attractor.workflow import Graph, parse, to_dot, validate

_SIMPLE = """\
digraph Simple {
    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare,  label="Exit"]
    work  [shape=parallelogram, script="echo work"]
    start -> work -> exit
}
"""

_WITH_GRAPH_ATTRS = """\
digraph A {
    graph [
        goal = "Demo workflow"
        default_max_visits = 3
    ]
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    work  [shape=parallelogram, script="true"]
    start -> work -> exit
}
"""


def _roundtrip(source: str) -> Graph:
    """Parse, serialize, parse again. Returns the second parse."""
    first = parse(source)
    emitted = to_dot(first)
    return parse(emitted)


# [unit->REQ-CLI-RENDER]
class TestRoundTrip:
    """parse → to_dot → parse must yield a structurally equivalent Graph."""

    def test_simple_workflow_preserves_nodes_and_edges(self) -> None:
        original = parse(_SIMPLE)
        roundtripped = _roundtrip(_SIMPLE)

        assert roundtripped.name == original.name
        assert [n.id for n in roundtripped.nodes] == [n.id for n in original.nodes]
        assert [(e.src, e.dst) for e in roundtripped.edges] == [
            (e.src, e.dst) for e in original.edges
        ]
        # Node kinds (derived from shape=) survive the round trip.
        assert [n.kind for n in roundtripped.nodes] == [n.kind for n in original.nodes]

    def test_round_trip_validates(self) -> None:
        """Output of `to_dot` on a valid graph is itself a valid graph."""
        valid = validate(parse(_SIMPLE))
        emitted = to_dot(valid)
        # No exception:
        validate(parse(emitted))

    def test_graph_attributes_preserved(self) -> None:
        roundtripped = _roundtrip(_WITH_GRAPH_ATTRS)
        assert roundtripped.attributes["goal"] == "Demo workflow"
        # DOT attribute values are always strings post-parse; the
        # integer literal becomes the string "3".
        assert roundtripped.attributes["default_max_visits"] == "3"

    def test_edge_labels_preserved(self) -> None:
        source = """\
digraph EL {
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    a [shape=parallelogram, script="true"]
    b [shape=parallelogram, script="true"]
    start -> a
    a -> exit [label="SUCCESS"]
    a -> b    [label="FAILURE"]
    b -> a
}
"""
        roundtripped = _roundtrip(source)
        labels_by_pair = {
            (e.src, e.dst): e.label for e in roundtripped.edges
        }
        assert labels_by_pair[("a", "exit")] == "SUCCESS"
        assert labels_by_pair[("a", "b")] == "FAILURE"
        assert labels_by_pair[("b", "a")] is None


# [unit->REQ-CLI-RENDER]
class TestQuoting:
    """Attribute values are always quoted; embedded special chars escape."""

    @pytest.mark.parametrize(
        ("source_value", "needle"),
        [
            ("simple", '"simple"'),
            ("has spaces", '"has spaces"'),
            ('with\\"quote', '"with\\"quote"'),
        ],
    )
    def test_parser_round_trip_quotes_specials(
        self, source_value: str, needle: str
    ) -> None:
        """Values that survive the parser must come back out canonically quoted."""
        source = f"""\
digraph Q {{
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    work  [shape=parallelogram, script="{source_value}"]
    start -> work -> exit
}}
"""
        emitted = to_dot(parse(source))
        assert needle in emitted

    def test_escapes_literal_newline_and_backslash_in_value(self) -> None:
        """Serializer-direct test: values carrying chars the parser can't
        produce (literal LF, backslash) still escape cleanly.

        Builds the `Graph` in code so the parser's escape semantics
        (which strip backslashes rather than interpret them) don't
        mask the serializer's responsibility to round-trip safely.
        """
        from attractor.workflow import Node, NodeKind, Span

        graph = Graph(
            name="Direct",
            attributes={},
            nodes=[
                Node(
                    id="start",
                    kind=NodeKind.START,
                    label=None,
                    class_=None,
                    attributes={
                        "shape": "Mdiamond",
                        "script": "line1\nline2",
                        "path": "C:\\Users\\x",
                    },
                    span=Span(1, 1),
                ),
            ],
            edges=[],
        )
        emitted = to_dot(graph)
        assert '"line1\\nline2"' in emitted
        assert '"C:\\\\Users\\\\x"' in emitted
        # Output must be a single line per statement (no stray LF leaking in).
        assert "\n\n}" not in emitted

    def test_ends_with_single_newline(self) -> None:
        emitted = to_dot(parse(_SIMPLE))
        assert emitted.endswith("\n")
        assert not emitted.endswith("\n\n")


# [unit->REQ-CLI-RENDER]
class TestOrderPreservation:
    """Source order must survive serialization (parser is order-preserving)."""

    def test_node_order(self) -> None:
        source = """\
digraph O {
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    z [shape=parallelogram, script="true"]
    a [shape=parallelogram, script="true"]
    m [shape=parallelogram, script="true"]
    start -> z -> a -> m -> exit
}
"""
        emitted = to_dot(parse(source))
        # Emitted order matches source order, not alphabetical.
        z_idx = emitted.index("\n    z ")
        a_idx = emitted.index("\n    a ")
        m_idx = emitted.index("\n    m ")
        assert z_idx < a_idx < m_idx

    def test_edge_order(self) -> None:
        """Source order matters for duplicate-label routing (§11.1)."""
        source = """\
digraph EO {
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    a [shape=hexagon, label="A"]
    b [shape=parallelogram, script="true"]
    start -> a
    a -> exit [label="x"]
    a -> b    [label="x"]
    b -> exit
}
"""
        roundtripped = _roundtrip(source)
        # The two a->* edges with label "x" must be in source order.
        a_edges = [e for e in roundtripped.edges if e.src == "a"]
        assert [e.dst for e in a_edges] == ["exit", "b"]


# [unit->REQ-CLI-RENDER]
class TestFixturesRoundTrip:
    """Every workflow under `workflows/` must round-trip through to_dot."""

    def test_all_workflow_fixtures_roundtrip(
        self, workflows_dir: Path
    ) -> None:
        files = sorted(workflows_dir.glob("*.dot"))
        assert files, "no .dot fixtures found"
        for path in files:
            source = path.read_text(encoding="utf-8")
            graph = parse(source)
            emitted = to_dot(graph)
            # Re-parse must succeed and yield the same node/edge counts.
            reparsed = parse(emitted)
            assert len(reparsed.nodes) == len(graph.nodes), path
            assert len(reparsed.edges) == len(graph.edges), path
