"""Integration tests for per-node tool / MCP scoping (REQ-AGENT-NODE-SCOPE).

Proves the DOT attribute → AgentConfig wiring end-to-end without a live
Anthropic API key by monkeypatching the engine's bound reference at
``attractor.engine.engine.run_agent_node`` with a stub that captures
the AgentConfig it receives. The engine module does
``from attractor.agent import run_agent_node`` at import time, so
patching the source module (``attractor.agent.runner``) is too late —
the engine's local reference has already been resolved.

The live parallel-agents-merge.dot dogfood run is left to manual
testing until a CI environment with a real key is available (issue #4).
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

import pytest

from attractor.agent.config import AgentConfig, AgentOutcome, OutcomeStatus
from attractor.engine import Engine, RunStatus
from attractor.workflow import parse, validate

# ─── minimal workflow with a scoped agent node ──────────────────────────

_SCOPED_AGENT_WORKFLOW = """\
digraph ScopedAgent {
    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare,  label="Exit"]

    worker [
        label         = "Worker",
        prompt        = "do a task",
        allowed_tools = "Bash, Read"
    ]

    start -> worker -> exit [label="SUCCESS"]
}
"""

_UNSCOPED_AGENT_WORKFLOW = """\
digraph UnscopedAgent {
    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare,  label="Exit"]

    worker [
        label  = "Worker",
        prompt = "do a task"
    ]

    start -> worker -> exit [label="SUCCESS"]
}
"""


# ─── helper ─────────────────────────────────────────────────────────────


def _make_stub_runner(
    captured: list[AgentConfig],
) -> Any:
    """Return an async stub that records the AgentConfig it receives."""

    async def _stub(config: AgentConfig) -> AgentOutcome:
        captured.append(config)
        return AgentOutcome(
            status=OutcomeStatus.SUCCESS, captured_output="stub ok"
        )

    return _stub


# ─── unit-level: parser → AgentConfig field values ──────────────────────


# [unit->REQ-AGENT-NODE-SCOPE]
def test_node_allowed_tools_parsed_from_dot_attribute() -> None:
    """allowed_tools DOT attr → AgentConfig.node_allowed_tools tuple."""
    graph = validate(parse(_SCOPED_AGENT_WORKFLOW))
    worker = next(n for n in graph.nodes if n.id == "worker")
    raw = worker.attributes.get("allowed_tools")
    assert raw is not None
    parsed = tuple(t.strip() for t in raw.split(",") if t.strip())
    assert parsed == ("Bash", "Read")


# [unit->REQ-AGENT-NODE-SCOPE]
def test_absent_allowed_tools_yields_none() -> None:
    """When no allowed_tools attr is present the tuple should be None."""
    graph = validate(parse(_UNSCOPED_AGENT_WORKFLOW))
    worker = next(n for n in graph.nodes if n.id == "worker")
    assert "allowed_tools" not in worker.attributes


# ─── integration: Engine.run → run_agent_node receives correct config ───


@pytest.mark.asyncio
# [int->REQ-AGENT-NODE-SCOPE]
async def test_engine_passes_node_allowed_tools_to_agent_config(
    seeded_repo: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Engine parses the DOT ``allowed_tools`` attr and populates
    ``AgentConfig.node_allowed_tools``; the stub runner captures the config
    so we can assert without a live API key.
    """
    captured: list[AgentConfig] = []
    monkeypatch.setattr(
        "attractor.engine.engine.run_agent_node",
        _make_stub_runner(captured),
    )

    engine = Engine(seeded_repo)
    graph = validate(parse(_SCOPED_AGENT_WORKFLOW))
    status = await engine.run(graph)

    assert status == RunStatus.COMPLETED
    assert len(captured) == 1, "expected exactly one agent node invocation"
    cfg = captured[0]
    assert cfg.node_allowed_tools == ("Bash", "Read"), (
        f"expected ('Bash', 'Read'), got {cfg.node_allowed_tools!r}"
    )
    # report_outcome is prepended by the runner, not stored in AgentConfig.
    # node_mcp_servers is absent → None.
    assert cfg.node_mcp_servers is None


@pytest.mark.asyncio
# [int->REQ-AGENT-NODE-SCOPE]
async def test_engine_passes_none_when_allowed_tools_absent(
    seeded_repo: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """When no ``allowed_tools`` attr is on the node, AgentConfig receives
    ``node_allowed_tools=None`` — preserving the v0.1 default behaviour.
    """
    captured: list[AgentConfig] = []
    monkeypatch.setattr(
        "attractor.engine.engine.run_agent_node",
        _make_stub_runner(captured),
    )

    engine = Engine(seeded_repo)
    graph = validate(parse(_UNSCOPED_AGENT_WORKFLOW))
    status = await engine.run(graph)

    assert status == RunStatus.COMPLETED
    assert len(captured) == 1
    cfg = captured[0]
    assert cfg.node_allowed_tools is None
    assert cfg.node_mcp_servers is None


# ─── read_only enforcement (REQ-EXEC-READONLY-NODE) ─────────────────────

from attractor.agent import READ_ONLY_TOOL_NAMES  # noqa: E402
from attractor.engine.engine import (  # noqa: E402
    _resolve_read_only,  # pyright: ignore[reportPrivateUsage]
)

_READONLY_AGENT_WORKFLOW = """\
digraph ReadOnlyAgent {
    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare,  label="Exit"]
    worker [label="Worker", prompt="search the tree", read_only=true]
    start -> worker -> exit [label="SUCCESS"]
}
"""

_GRAPH_DEFAULT_READONLY_WORKFLOW = """\
digraph DefaultReadOnly {
    graph [ default_read_only = true ]
    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare,  label="Exit"]
    worker [label="Worker", prompt="search the tree"]
    start -> worker -> exit [label="SUCCESS"]
}
"""

_READONLY_WITH_EXPLICIT_TOOLS = """\
digraph ReadOnlyExplicit {
    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare,  label="Exit"]
    worker [label="Worker", prompt="x", read_only=true, allowed_tools="bash, read"]
    start -> worker -> exit [label="SUCCESS"]
}
"""


# [unit->REQ-EXEC-READONLY-NODE]
def test_resolve_read_only_precedence() -> None:
    """Per-node read_only wins; else graph default; else false."""
    assert _resolve_read_only({}, {"read_only": "true"}) is True
    assert _resolve_read_only({"default_read_only": "true"}, {"read_only": "false"}) is False
    assert _resolve_read_only({"default_read_only": "true"}, {}) is True
    assert _resolve_read_only({}, {}) is False


@pytest.mark.asyncio
# [unit->REQ-EXEC-READONLY-NODE]
async def test_read_only_agent_gets_readonly_tool_allowlist(
    seeded_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A read_only agent node with no allowed_tools is enforced read-only."""
    captured: list[AgentConfig] = []
    monkeypatch.setattr(
        "attractor.engine.engine.run_agent_node", _make_stub_runner(captured)
    )
    engine = Engine(seeded_repo)
    status = await engine.run(validate(parse(_READONLY_AGENT_WORKFLOW)))
    assert status == RunStatus.COMPLETED
    assert captured[0].node_allowed_tools == READ_ONLY_TOOL_NAMES


@pytest.mark.asyncio
# [unit->REQ-EXEC-READONLY-NODE]
async def test_graph_default_read_only_enforces_tools(
    seeded_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """default_read_only=true makes an unmarked agent node read-only."""
    captured: list[AgentConfig] = []
    monkeypatch.setattr(
        "attractor.engine.engine.run_agent_node", _make_stub_runner(captured)
    )
    engine = Engine(seeded_repo)
    status = await engine.run(validate(parse(_GRAPH_DEFAULT_READONLY_WORKFLOW)))
    assert status == RunStatus.COMPLETED
    assert captured[0].node_allowed_tools == READ_ONLY_TOOL_NAMES


@pytest.mark.asyncio
# [unit->REQ-EXEC-READONLY-NODE]
async def test_explicit_allowed_tools_overrides_read_only(
    seeded_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """An explicit allowed_tools wins over the read_only auto-restriction."""
    captured: list[AgentConfig] = []
    monkeypatch.setattr(
        "attractor.engine.engine.run_agent_node", _make_stub_runner(captured)
    )
    engine = Engine(seeded_repo)
    status = await engine.run(validate(parse(_READONLY_WITH_EXPLICIT_TOOLS)))
    assert status == RunStatus.COMPLETED
    assert captured[0].node_allowed_tools == ("bash", "read")
