"""Parser unit tests.

Covers:
- Happy paths against each `workflows/*.dot` fixture (integration-shaped
  but at the unit level — the parser is pure).
- Rejection of out-of-scope syntax: `strict`, undirected `graph`, `--`
  edges, multiple top-level digraphs, HTML labels.
- (line, column) precision on errors — SPEC §16 DoD lives or dies here.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from attractor.workflow import NodeKind, ParseError, parse

# ─── happy paths on fixtures ────────────────────────────────────────


# [unit->REQ-DOT-PARSE-SUBSET]
@pytest.mark.parametrize(
    "fixture",
    [
        "implement",
        "multi-gate",
        "preflight",
        "stylesheet-cascade",
        "tool-only",
        "triage",
        "failure-no-route",
    ],
)
def test_parser_accepts_each_valid_fixture(workflows_dir: Path, fixture: str) -> None:
    """Every fixture under `workflows/` must parse without error."""
    src = (workflows_dir / f"{fixture}.dot").read_text(encoding="utf-8")
    graph = parse(src)
    assert graph.nodes, f"{fixture}: parsed graph has no nodes"
    assert graph.edges, f"{fixture}: parsed graph has no edges"


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_accepts_minimal_digraph() -> None:
    """The smallest meaningful graph — start, exit, one edge — parses."""
    src = """
    digraph Tiny {
        start [shape=Mdiamond, label="Start"]
        exit  [shape=Msquare, label="Exit"]
        start -> exit
    }
    """
    g = parse(src)
    assert g.name == "Tiny"
    assert len(g.nodes) == 2
    assert len(g.edges) == 1


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_preserves_source_order() -> None:
    """Nodes and edges appear in `Graph.nodes`/`Graph.edges` in source
    order — SPEC §11.1 ("duplicate edge labels route to the
    first-declared edge") depends on this.
    """
    src = """
    digraph Order {
        c [shape=Mdiamond]
        a [shape=box]
        b [shape=Msquare]
        c -> a
        c -> b
        a -> b
    }
    """
    g = parse(src)
    assert [n.id for n in g.nodes] == ["c", "a", "b"]
    assert [(e.src, e.dst) for e in g.edges] == [("c", "a"), ("c", "b"), ("a", "b")]


# ─── shape → handler mapping ────────────────────────────────────────


# [unit->REQ-DOT-SHAPE-HANDLERS]
@pytest.mark.parametrize(
    "shape,kind",
    [
        ("Mdiamond", NodeKind.START),
        ("Msquare", NodeKind.EXIT),
        ("box", NodeKind.AGENT),
        ("hexagon", NodeKind.HUMAN),
        ("parallelogram", NodeKind.TOOL),
    ],
)
def test_parser_maps_shape_to_kind(shape: str, kind: NodeKind) -> None:
    """Each documented shape maps to its handler kind (§5.3)."""
    src = f"""
    digraph G {{
        s [shape=Mdiamond]
        e [shape=Msquare]
        n [shape={shape}]
        s -> n -> e
    }}
    """
    g = parse(src)
    node = next(n for n in g.nodes if n.id == "n")
    assert node.kind == kind


# [unit->REQ-DOT-SHAPE-HANDLERS]
def test_parser_uses_box_for_unshaped_node() -> None:
    """A node with no `shape=` defaults to `box` → AGENT (Graphviz default)."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        agent_node [label="Plain"]
        s -> agent_node -> e
    }
    """
    g = parse(src)
    node = next(n for n in g.nodes if n.id == "agent_node")
    assert node.kind == NodeKind.AGENT


# [unit->REQ-DOT-SHAPE-HANDLERS]
def test_parser_type_attribute_overrides_shape() -> None:
    """`type="..."` wins over `shape=...` (§5.3 last sentence)."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        weird [shape=trapezoid, type="agent"]
        s -> weird -> e
    }
    """
    g = parse(src)
    node = next(n for n in g.nodes if n.id == "weird")
    assert node.kind == NodeKind.AGENT


# [unit->REQ-DOT-SHAPE-HANDLERS]
def test_parser_unknown_type_value_errors() -> None:
    """`type="bogus"` is rejected by the parser — there's no handler."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        bad [type="bogus"]
        s -> bad -> e
    }
    """
    with pytest.raises(ParseError) as excinfo:
        parse(src)
    assert "bogus" in excinfo.value.message


# ─── attribute parsing ──────────────────────────────────────────────


# [unit->REQ-DOT-ATTRIBUTES-V01]
def test_parser_attributes_separated_by_commas() -> None:
    """Attribute lists may be comma-separated."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        n [label="Plan", class=heavy, prompt="Do the thing."]
        s -> n -> e
    }
    """
    g = parse(src)
    n = next(x for x in g.nodes if x.id == "n")
    assert n.label == "Plan"
    assert n.class_ == "heavy"
    assert n.attributes["prompt"] == "Do the thing."


# [unit->REQ-DOT-ATTRIBUTES-V01]
def test_parser_attributes_separated_by_whitespace() -> None:
    """Attribute lists may be whitespace-separated (the SPEC §5.4 example
    uses that style)."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        n [
            label = "Plan"
            class = heavy
            prompt = "Do the thing."
        ]
        s -> n -> e
    }
    """
    g = parse(src)
    n = next(x for x in g.nodes if x.id == "n")
    assert n.label == "Plan"
    assert n.class_ == "heavy"


# [unit->REQ-DOT-ATTRIBUTES-V01]
def test_parser_preserves_unknown_attributes() -> None:
    """SPEC §5.5: unrecognized attributes are parsed but ignored.

    The parser keeps them in `Node.attributes` so renderer-hint tools
    can read them without affecting execution.
    """
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        n [label="x", color="red", style=filled, fillcolor="lightblue"]
        s -> n -> e
    }
    """
    g = parse(src)
    n = next(x for x in g.nodes if x.id == "n")
    assert n.attributes["color"] == "red"
    assert n.attributes["style"] == "filled"
    assert n.attributes["fillcolor"] == "lightblue"


# [unit->REQ-DOT-ATTRIBUTES-V01]
def test_parser_handles_quoted_multiline_strings() -> None:
    """The `model_stylesheet` attribute spans multiple lines."""
    src = """
    digraph G {
        graph [
            model_stylesheet = "
                * { model: claude-haiku-4-5; }
            "
        ]
        s [shape=Mdiamond]
        e [shape=Msquare]
        s -> e
    }
    """
    g = parse(src)
    assert "model_stylesheet" in g.attributes
    assert "claude-haiku-4-5" in g.attributes["model_stylesheet"]


# [unit->REQ-DOT-ATTRIBUTES-V01]
def test_parser_accepts_numeric_attribute_values() -> None:
    """Numbers can appear unquoted (e.g. `max_visits=5`)."""
    src = """
    digraph G {
        graph [default_max_visits = 7]
        s [shape=Mdiamond]
        e [shape=Msquare]
        n [shape=parallelogram, script="echo hi", max_visits=3]
        s -> n -> e
    }
    """
    g = parse(src)
    assert g.attributes["default_max_visits"] == "7"
    n = next(x for x in g.nodes if x.id == "n")
    assert n.attributes["max_visits"] == "3"


# ─── edge handling ──────────────────────────────────────────────────


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_edge_chain_expands_to_pairs() -> None:
    """`a -> b -> c` produces two edges (a,b) and (b,c)."""
    src = """
    digraph G {
        a [shape=Mdiamond]
        b [shape=box]
        c [shape=Msquare]
        a -> b -> c [label="approve"]
    }
    """
    g = parse(src)
    assert [(e.src, e.dst, e.label) for e in g.edges] == [
        ("a", "b", "approve"),
        ("b", "c", "approve"),
    ]


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_semicolons_optional() -> None:
    """Statements may end with `;` or whitespace alone."""
    src = """
    digraph G {
        s [shape=Mdiamond];
        e [shape=Msquare]
        s -> e;
    }
    """
    g = parse(src)
    assert len(g.nodes) == 2
    assert len(g.edges) == 1


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_strips_line_comments() -> None:
    """`// comment` is stripped before parsing."""
    src = """
    digraph G {
        s [shape=Mdiamond]  // start node
        e [shape=Msquare]   // exit node
        // edge below
        s -> e
    }
    """
    g = parse(src)
    assert len(g.nodes) == 2
    assert len(g.edges) == 1


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_strips_block_comments() -> None:
    """`/* ... */` is stripped before parsing."""
    src = """
    digraph G {
        s [shape=Mdiamond] /* the start */
        /* sketchpad:
           e [shape=Msquare, label="OLD"]
        */
        e [shape=Msquare]
        s -> e
    }
    """
    g = parse(src)
    assert len(g.nodes) == 2
    # Make sure the commented-out duplicate didn't sneak through.
    assert sum(1 for n in g.nodes if n.id == "e") == 1


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_preserves_string_with_slash_slash() -> None:
    """`//` inside a quoted string is NOT a comment."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        n [shape=parallelogram, script="echo https://example.com"]
        s -> n -> e
    }
    """
    g = parse(src)
    n = next(x for x in g.nodes if x.id == "n")
    assert n.attributes["script"] == "echo https://example.com"


# ─── error cases ────────────────────────────────────────────────────


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_rejects_strict_keyword() -> None:
    """`strict digraph G { ... }` is rejected (§5.2)."""
    src = "strict digraph G { s [shape=Mdiamond] e [shape=Msquare] s -> e }"
    with pytest.raises(ParseError) as excinfo:
        parse(src)
    assert "strict" in excinfo.value.message.lower()


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_rejects_undirected_graph_keyword() -> None:
    """`graph G { ... }` (undirected) is rejected (§5.2)."""
    src = "graph G { a -- b }"
    with pytest.raises(ParseError) as excinfo:
        parse(src)
    msg = excinfo.value.message.lower()
    assert "digraph" in msg or "undirected" in msg


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_rejects_undirected_edge_operator() -> None:
    """`--` inside a digraph is rejected with a clear message."""
    src = """
    digraph G {
        a [shape=Mdiamond]
        b [shape=Msquare]
        a -- b
    }
    """
    with pytest.raises(ParseError) as excinfo:
        parse(src)
    assert "--" in excinfo.value.message or "undirected" in excinfo.value.message


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_rejects_html_labels() -> None:
    """`label=<...>` (HTML label) is rejected (§5.2)."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        n [label=<<b>bold</b>>]
        s -> n -> e
    }
    """
    with pytest.raises(ParseError) as excinfo:
        parse(src)
    assert "HTML" in excinfo.value.message


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_rejects_multiple_top_level_graphs() -> None:
    """Only one `digraph` per file (§5.2)."""
    src = """
    digraph A { s [shape=Mdiamond] e [shape=Msquare] s -> e }
    digraph B { s [shape=Mdiamond] e [shape=Msquare] s -> e }
    """
    with pytest.raises(ParseError) as excinfo:
        parse(src)
    assert "one" in excinfo.value.message.lower() or "digraph" in excinfo.value.message.lower()


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_rejects_empty_input() -> None:
    """An empty source string is rejected (no `digraph` to parse)."""
    with pytest.raises(ParseError):
        parse("")


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_rejects_unterminated_string() -> None:
    """An unclosed `"..."` produces a ParseError with a useful message."""
    src = 'digraph G { n [label="never closed }\n'
    with pytest.raises(ParseError) as excinfo:
        parse(src)
    assert "string" in excinfo.value.message.lower()


# [unit->REQ-DOT-PARSE-SUBSET]
def test_parser_rejects_missing_attribute_value() -> None:
    """`label=` with no value is rejected with a column-precise error."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        n [label=]
        s -> n -> e
    }
    """
    with pytest.raises(ParseError) as excinfo:
        parse(src)
    assert "value" in excinfo.value.message.lower()


# [unit->REQ-DOD-VALIDATE-ERRORS]
def test_parse_error_carries_line_and_column() -> None:
    """ParseError exposes 1-based `line` and `column` for diagnostics
    rendering (DoD: actionable errors).
    """
    src = "digraph G {\n    a -- b\n}\n"
    with pytest.raises(ParseError) as excinfo:
        parse(src)
    # The `--` is on the third character position of line 2 (after
    # the leading indent).
    assert excinfo.value.line == 2
    assert excinfo.value.column >= 1
