"""Validator unit + integration tests.

Covers:
- Every valid `workflows/*.dot` fixture passes `validate()`.
- Every `workflows/invalid/*.dot` fixture is rejected with the documented
  error.
- `workflows/failure-no-route.dot` is NOT flagged (SPEC §6.1: runtime
  check, not structural).
- Specific structural rules: exactly one start/exit, edge endpoints
  reference declared nodes, unknown shapes without `type=`, unbounded
  cycles.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from attractor.workflow import (
    ValidationFailed,
    parse,
    validate,
)

# ─── fixture-level integration ──────────────────────────────────────


# [int->REQ-DOD-VALIDATE-ERRORS]
# [unit->REQ-DOT-PARSE-SUBSET]
@pytest.mark.parametrize(
    "fixture",
    [
        "implement",
        "multi-gate",
        "preflight",
        "stylesheet-cascade",
        "tool-only",
        "triage",
        "parallel-verifiers",
        "parallel-agents-merge",
        # failure-no-route is also expected to validate — see dedicated test
    ],
)
def test_validate_accepts_each_valid_fixture(workflows_dir: Path, fixture: str) -> None:
    """Every valid fixture passes `validate()` cleanly."""
    src = (workflows_dir / f"{fixture}.dot").read_text(encoding="utf-8")
    graph = parse(src)
    vgraph = validate(graph)
    # ValidGraph mirrors Graph's shape.
    assert vgraph.nodes == graph.nodes
    assert vgraph.edges == graph.edges


# [int->REQ-DOD-VALIDATE-ERRORS]
# [unit->REQ-EXEC-EDGE-ROUTING]
def test_validate_accepts_failure_no_route_fixture(workflows_dir: Path) -> None:
    """`failure-no-route.dot` lives in `workflows/` (not under `invalid/`).

    SPEC §6.1: "Terminating on failure (run ends incomplete...) is a
    legitimate workflow design." The validator must accept it; the
    runtime is what flags the missing FAILURE edge as `incomplete`.
    """
    src = (workflows_dir / "failure-no-route.dot").read_text(encoding="utf-8")
    graph = parse(src)
    validate(graph)  # must not raise


# [int->REQ-DOD-VALIDATE-ERRORS]
@pytest.mark.parametrize(
    "fixture,expected_substring",
    [
        ("missing-exit", "no exit node"),
        ("cycle-no-max-visits", "unbounded cycle"),
    ],
)
def test_validate_rejects_each_invalid_fixture(
    invalid_workflows_dir: Path, fixture: str, expected_substring: str
) -> None:
    """Every fixture under `workflows/invalid/` raises with a useful
    message that includes the documented substring.
    """
    src = (invalid_workflows_dir / f"{fixture}.dot").read_text(encoding="utf-8")
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any(
        expected_substring in m for m in messages
    ), f"expected substring {expected_substring!r} not in any of: {messages!r}"


# ─── start / exit cardinality ──────────────────────────────────────


# [unit->REQ-DOD-VALIDATE-ERRORS]
def test_validate_rejects_missing_start() -> None:
    """A graph with no Mdiamond is rejected."""
    src = """
    digraph G {
        e [shape=Msquare]
        n [shape=box]
        n -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    assert any("start" in e.message.lower() for e in excinfo.value.errors)


# [unit->REQ-DOD-VALIDATE-ERRORS]
def test_validate_rejects_duplicate_start() -> None:
    """Two Mdiamond nodes is rejected, with span on the second one."""
    src = """
    digraph G {
        s1 [shape=Mdiamond]
        s2 [shape=Mdiamond]
        e [shape=Msquare]
        s1 -> e
        s2 -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    dup_errors = [e for e in excinfo.value.errors if "duplicate start" in e.message]
    assert len(dup_errors) == 1
    # The error points at the second declaration, not the first.
    assert dup_errors[0].line > 0


# [unit->REQ-DOD-VALIDATE-ERRORS]
def test_validate_rejects_missing_exit() -> None:
    """A graph with no Msquare is rejected (this is the
    `missing-exit.dot` integration case scaled down to a unit test).
    """
    src = """
    digraph G {
        s [shape=Mdiamond]
        n [shape=box]
        s -> n
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    assert any("exit" in e.message.lower() for e in excinfo.value.errors)


# [unit->REQ-DOD-VALIDATE-ERRORS]
def test_validate_rejects_duplicate_exit() -> None:
    """Two Msquare nodes is rejected."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e1 [shape=Msquare]
        e2 [shape=Msquare]
        s -> e1
        s -> e2
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    assert any("duplicate exit" in e.message for e in excinfo.value.errors)


# ─── edge endpoint reachability ────────────────────────────────────


# [unit->REQ-DOD-VALIDATE-ERRORS]
def test_validate_rejects_edge_to_undeclared_node() -> None:
    """An edge pointing at a node that isn't declared is rejected."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        s -> nowhere
        s -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    assert any("nowhere" in err.message for err in excinfo.value.errors)


# [unit->REQ-DOD-VALIDATE-ERRORS]
def test_validate_rejects_edge_from_undeclared_node() -> None:
    """An edge whose `src` isn't declared is rejected."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        s -> e
        ghost -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    assert any("ghost" in err.message for err in excinfo.value.errors)


# ─── unknown shape ─────────────────────────────────────────────────


# [unit->REQ-DOT-SHAPE-HANDLERS]
def test_validate_rejects_unknown_shape_without_type_override() -> None:
    """SPEC §5.3 last sentence: unknown shape with no `type=` override
    has no handler to bind to, so the validator rejects it.
    """
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        weird [shape=trapezoid]
        s -> weird -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    assert any("trapezoid" in err.message for err in excinfo.value.errors)


# [unit->REQ-DOT-SHAPE-HANDLERS]
def test_validate_accepts_unknown_shape_with_type_override() -> None:
    """Same setup but with `type="agent"` — validator accepts."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        weird [shape=trapezoid, type="agent"]
        s -> weird -> e
    }
    """
    graph = parse(src)
    validate(graph)  # must not raise


# ─── cycle detection ───────────────────────────────────────────────


# [unit->REQ-EXEC-MAX-VISITS]
def test_validate_rejects_unbounded_cycle() -> None:
    """A cycle whose nodes have no `max_visits` and the graph has no
    `default_max_visits` is rejected (boil down of `cycle-no-max-visits`).
    """
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        a [shape=box]
        b [shape=box]
        s -> a
        a -> b
        b -> a
        a -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    assert any("unbounded cycle" in err.message for err in excinfo.value.errors)


# [unit->REQ-EXEC-MAX-VISITS]
def test_validate_accepts_cycle_with_max_visits_on_a_node() -> None:
    """A `max_visits=N` on any node in the cycle bounds the loop."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        a [shape=box, max_visits=5]
        b [shape=box]
        s -> a
        a -> b
        b -> a
        a -> e
    }
    """
    graph = parse(src)
    validate(graph)  # must not raise


# [unit->REQ-EXEC-MAX-VISITS]
def test_validate_accepts_cycle_with_graph_default_max_visits() -> None:
    """`default_max_visits=N` on the graph bounds every cycle."""
    src = """
    digraph G {
        graph [default_max_visits=5]
        s [shape=Mdiamond]
        e [shape=Msquare]
        a [shape=box]
        b [shape=box]
        s -> a
        a -> b
        b -> a
        a -> e
    }
    """
    graph = parse(src)
    validate(graph)  # must not raise


# [unit->REQ-EXEC-MAX-VISITS]
def test_validate_rejects_self_loop_without_max_visits() -> None:
    """A self-loop is a cycle; same rule applies."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        a [shape=box]
        s -> a
        a -> a
        a -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    assert any("unbounded cycle" in err.message for err in excinfo.value.errors)


# ─── error aggregation ─────────────────────────────────────────────


# [unit->REQ-DOD-VALIDATE-ERRORS]
def test_validate_accumulates_multiple_errors() -> None:
    """The validator reports every detected error in one shot rather
    than failing on the first one — UX from `attractor validate`'s
    perspective.
    """
    src = """
    digraph G {
        n [shape=box]
        n -> nowhere
        n -> elsewhere
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    # No start, no exit, two missing endpoints → at least 4 errors.
    assert len(excinfo.value.errors) >= 4


# ─── parallel region — fixture integration ─────────────────────────────


# [unit->REQ-DOT-PARALLEL-SHAPES]
@pytest.mark.parametrize(
    "fixture",
    [
        "parallel-verifiers",
        "parallel-agents-merge",
    ],
)
def test_validate_accepts_parallel_fixtures(workflows_dir: Path, fixture: str) -> None:
    """Both parallel-region workflow fixtures pass validate() cleanly."""
    src = (workflows_dir / f"{fixture}.dot").read_text(encoding="utf-8")
    graph = parse(src)
    validate(graph)  # must not raise


# ─── parallel region — happy-path unit tests ───────────────────────────

_MINIMAL_PARALLEL = """
digraph G {
    s [shape=Mdiamond]
    e [shape=Msquare]
    f [shape=component]
    b1 [shape=box]
    b2 [shape=box]
    j [shape=tripleoctagon]
    s -> f
    f -> b1 [label="branch1"]
    f -> b2 [label="branch2"]
    b1 -> j
    b2 -> j
    j -> e [label="SUCCESS"]
}
"""


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_accepts_minimal_parallel_region() -> None:
    """1 fanout, 2 branches, 1 join — Rules 1+2+6+7 all satisfied."""
    graph = parse(_MINIMAL_PARALLEL)
    validate(graph)  # must not raise


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_accepts_serial_parallel_regions() -> None:
    """Two non-overlapping parallel regions in series — Rule 4."""
    src = """
    digraph G {
        s  [shape=Mdiamond]
        e  [shape=Msquare]
        f1 [shape=component]
        a1 [shape=box]
        a2 [shape=box]
        j1 [shape=tripleoctagon]
        mid [shape=box]
        f2 [shape=component]
        b1 [shape=box]
        b2 [shape=box]
        j2 [shape=tripleoctagon]
        s  -> f1
        f1 -> a1 [label="x"]
        f1 -> a2 [label="y"]
        a1 -> j1
        a2 -> j1
        j1 -> mid [label="SUCCESS"]
        mid -> f2
        f2 -> b1 [label="p"]
        f2 -> b2 [label="q"]
        b1 -> j2
        b2 -> j2
        j2 -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    validate(graph)  # must not raise


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_accepts_parallel_with_bounded_loop() -> None:
    """A max_visits-bounded loop inside a branch — Rule 5."""
    src = """
    digraph G {
        s  [shape=Mdiamond]
        e  [shape=Msquare]
        f  [shape=component]
        verify [shape=parallelogram, script="pytest", max_visits=3]
        fix    [shape=box]
        other  [shape=box]
        j  [shape=tripleoctagon]
        s      -> f
        f      -> verify [label="v"]
        f      -> other  [label="o"]
        verify -> j
        verify -> fix     [label="FAILURE"]
        fix    -> verify
        other  -> j
        j      -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    validate(graph)  # must not raise


# ─── parallel region — Rule 1: fanout↔join pairing ─────────────────────


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_rejects_fanout_with_no_join() -> None:
    """Rule 1: a component with no reachable tripleoctagon is rejected."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        f [shape=component]
        b1 [shape=box]
        b2 [shape=box]
        s  -> f
        f  -> b1 [label="x"]
        f  -> b2 [label="y"]
        b1 -> e
        b2 -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any("no matching join" in m for m in messages), messages


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_rejects_fanout_with_two_joins() -> None:
    """Rule 1: a component reaching two tripleoctagons is rejected."""
    src = """
    digraph G {
        s  [shape=Mdiamond]
        e  [shape=Msquare]
        f  [shape=component]
        b1 [shape=box]
        b2 [shape=box]
        j1 [shape=tripleoctagon]
        j2 [shape=tripleoctagon]
        s  -> f
        f  -> b1 [label="x"]
        f  -> b2 [label="y"]
        b1 -> j1
        b2 -> j2
        j1 -> e [label="SUCCESS"]
        j2 -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any("exactly one" in m for m in messages), messages


# ─── parallel region — OQ2: join needs ≥ 2 incoming from region ────────


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_rejects_join_with_single_incoming_edge() -> None:
    """OQ2: a tripleoctagon with only 1 incoming edge from its region is rejected."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        f [shape=component]
        b [shape=box]
        j [shape=tripleoctagon]
        s -> f
        f -> b [label="only"]
        b -> j
        j -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any("at least 2" in m for m in messages), messages


# ─── parallel region — Rule 2: no path escapes ─────────────────────────


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_rejects_branch_escaping_to_exit() -> None:
    """Rule 2: a branch edge that goes directly to exit escapes the region."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        f [shape=component]
        b1 [shape=box]
        b2 [shape=box]
        j [shape=tripleoctagon]
        s  -> f
        f  -> b1 [label="x"]
        f  -> b2 [label="y"]
        b1 -> j
        b2 -> e
        j  -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any("escapes parallel region" in m for m in messages), messages


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_rejects_branch_escaping_to_outside_node() -> None:
    """Rule 2: a branch routing to a node outside the region is rejected."""
    src = """
    digraph G {
        s      [shape=Mdiamond]
        e      [shape=Msquare]
        f      [shape=component]
        b1     [shape=box]
        b2     [shape=box]
        j      [shape=tripleoctagon]
        outside [shape=box]
        s      -> f
        f      -> b1 [label="x"]
        f      -> b2 [label="y"]
        b1     -> j
        b2     -> outside
        outside -> e
        j      -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any("escapes parallel region" in m for m in messages), messages


# ─── parallel region — Rule 3: no nested component ─────────────────────


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_rejects_nested_fanout() -> None:
    """Rule 3: a component inside a parallel region's branch is rejected."""
    src = """
    digraph G {
        s   [shape=Mdiamond]
        e   [shape=Msquare]
        f   [shape=component]
        b1  [shape=box]
        inner [shape=component]
        ib  [shape=box]
        ij  [shape=tripleoctagon]
        j   [shape=tripleoctagon]
        s    -> f
        f    -> b1    [label="x"]
        f    -> inner [label="y"]
        b1   -> j
        inner -> ib   [label="a"]
        ib   -> ij
        ij   -> j
        j    -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any("nested" in m for m in messages), messages


# ─── parallel region — Rule 6: component attribute restrictions ─────────


# [unit->REQ-DOT-PARALLEL-SHAPES]
@pytest.mark.parametrize("forbidden_attr", ["prompt", "script", "goal_gate", "class"])
def test_validate_rejects_component_with_forbidden_attr(forbidden_attr: str) -> None:
    """Rule 6: component nodes must not carry prompt/script/goal_gate/class."""
    src = f"""
    digraph G {{
        s [shape=Mdiamond]
        e [shape=Msquare]
        f [shape=component, {forbidden_attr}="something"]
        b1 [shape=box]
        b2 [shape=box]
        j [shape=tripleoctagon]
        s  -> f
        f  -> b1 [label="x"]
        f  -> b2 [label="y"]
        b1 -> j
        b2 -> j
        j  -> e [label="SUCCESS"]
    }}
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any(forbidden_attr in m for m in messages), messages


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_rejects_component_duplicate_labels() -> None:
    """Rule 6: duplicate outgoing edge labels on a component are rejected."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        f [shape=component]
        b1 [shape=box]
        b2 [shape=box]
        j [shape=tripleoctagon]
        s  -> f
        f  -> b1 [label="same"]
        f  -> b2 [label="same"]
        b1 -> j
        b2 -> j
        j  -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any("duplicate" in m for m in messages), messages


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_accepts_fanout_with_unlabeled_distinct_destinations() -> None:
    """Rule 6 / SPEC §6.10.1: a fanout with unlabeled edges to distinct
    destinations is valid — the effective branch name is the destination id,
    and distinct destinations give distinct effective names.
    """
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        f [shape=component]
        a [shape=box]
        b [shape=box]
        c [shape=box]
        j [shape=tripleoctagon]
        s -> f
        f -> a
        f -> b
        f -> c
        a -> j
        b -> j
        c -> j
        j -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    validate(graph)  # must not raise


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_rejects_fanout_effective_name_collision() -> None:
    """Rule 6 / SPEC §6.10.1: a labeled edge whose label matches another
    edge's effective name (the destination id) is rejected — both edges
    resolve to the same effective branch name.

    Example: `f -> b [label="a"]` has effective name "a";
             `f -> a`             has effective name "a" (unlabeled → dst).
    Collision → rejected.
    """
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        f [shape=component]
        a [shape=box]
        b [shape=box]
        j [shape=tripleoctagon]
        s -> f
        f -> b [label="a"]
        f -> a
        b -> j
        a -> j
        j -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any("duplicate" in m for m in messages), messages


# ─── parallel region — Rule 7: join incoming edges must be unlabeled ────


# [unit->REQ-DOT-PARALLEL-SHAPES]
def test_validate_rejects_labeled_edge_into_join() -> None:
    """Rule 7: a labeled edge terminating at a tripleoctagon is rejected."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        f [shape=component]
        b1 [shape=box]
        b2 [shape=box]
        j [shape=tripleoctagon]
        s  -> f
        f  -> b1 [label="x"]
        f  -> b2 [label="y"]
        b1 -> j  [label="done"]
        b2 -> j
        j  -> e [label="SUCCESS"]
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [e.message for e in excinfo.value.errors]
    assert any("tripleoctagon" in m or "labeled" in m for m in messages), messages


# ─── per-node scope attribute checks (REQ-AGENT-NODE-SCOPE) ────────────


# [unit->REQ-AGENT-NODE-SCOPE]
def test_validate_accepts_allowed_tools_on_agent_node() -> None:
    """allowed_tools on a box (agent) node passes validate() cleanly."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        a [shape=box, allowed_tools="Bash, Read"]
        s -> a -> e
    }
    """
    graph = parse(src)
    validate(graph)  # must not raise


# [unit->REQ-AGENT-NODE-SCOPE]
def test_validate_rejects_allowed_tools_on_tool_node() -> None:
    """allowed_tools on a parallelogram (tool) node is a validation error."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        t [shape=parallelogram, script="echo hi", allowed_tools="Bash"]
        s -> t -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [err.message for err in excinfo.value.errors]
    assert any("allowed_tools" in m for m in messages), messages


# [unit->REQ-AGENT-NODE-SCOPE]
def test_validate_rejects_mcp_servers_on_human_node() -> None:
    """mcp_servers on a hexagon (human-gate) node is a validation error."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        h [shape=hexagon, prompt="Approve?", mcp_servers="some-server"]
        s -> h -> e [label="approve"]
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [err.message for err in excinfo.value.errors]
    assert any("mcp_servers" in m for m in messages), messages


# [unit->REQ-AGENT-NODE-SCOPE]
def test_validate_rejects_hooks_on_tool_node() -> None:
    """hooks on a tool node is a validation error (deferred attr)."""
    src = """
    digraph G {
        s [shape=Mdiamond]
        e [shape=Msquare]
        t [shape=parallelogram, script="echo hi", hooks="some-hook"]
        s -> t -> e
    }
    """
    graph = parse(src)
    with pytest.raises(ValidationFailed) as excinfo:
        validate(graph)
    messages = [err.message for err in excinfo.value.errors]
    assert any("hooks" in m for m in messages), messages


# ─── read_only / default_read_only (REQ-EXEC-READONLY-NODE) ─────────────


# [unit->REQ-EXEC-READONLY-NODE]
def test_validate_accepts_read_only_on_agent_and_tool() -> None:
    """read_only=true/false is valid on agent and tool nodes."""
    src = """\
digraph RO {
    graph [ default_read_only = true ]
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    a [label="A", prompt="search", read_only=true]
    t [shape=parallelogram, label="T", script="echo hi", read_only=false]
    start -> a -> t -> exit [label="SUCCESS"]
}
"""
    validate(parse(src))  # must not raise


# [unit->REQ-EXEC-READONLY-NODE]
def test_validate_rejects_bad_read_only_value() -> None:
    src = """\
digraph RO {
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    a [label="A", prompt="x", read_only=maybe]
    start -> a -> exit [label="SUCCESS"]
}
"""
    with pytest.raises(ValidationFailed) as excinfo:
        validate(parse(src))
    assert any("read_only" in e.message and "true" in e.message for e in excinfo.value.errors)


# [unit->REQ-EXEC-READONLY-NODE]
def test_validate_rejects_bad_default_read_only_value() -> None:
    src = """\
digraph RO {
    graph [ default_read_only = yes ]
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    a [label="A", prompt="x"]
    start -> a -> exit [label="SUCCESS"]
}
"""
    with pytest.raises(ValidationFailed) as excinfo:
        validate(parse(src))
    assert any("default_read_only" in e.message for e in excinfo.value.errors)


# [unit->REQ-EXEC-READONLY-NODE]
def test_validate_rejects_read_only_on_non_agent_tool_node() -> None:
    """read_only on a human-gate node is a validation error."""
    src = """\
digraph RO {
    start  [shape=Mdiamond, label="S"]
    exit   [shape=Msquare,  label="E"]
    gate   [shape=hexagon, label="G", read_only=true]
    start -> gate
    gate -> exit [label="ok"]
}
"""
    with pytest.raises(ValidationFailed) as excinfo:
        validate(parse(src))
    assert any(
        "read_only" in e.message and "agent or tool" in e.message
        for e in excinfo.value.errors
    )
