"""Read-only branches share the run worktree in a parallel region.

REQ-EXEC-READONLY-NODE / SPEC §6.7+§6.10: an enforced read-only (agent)
branch reads the trigger-time snapshot (the one run worktree) instead
of getting its own isolated sub-worktree. A mutating branch still
isolates. We prove this behaviorally via the `cwd` each branch's agent
receives (shared branches see the run worktree path; isolated branches
see a `.../branch/<name>/` sub-worktree path), plus a unit test of the
static shareability deriver.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

import pytest

from attractor.agent import AgentConfig, AgentOutcome, OutcomeStatus
from attractor.engine import Engine, RunStatus
from attractor.engine.engine import (
    _branch_read_only_shareable,  # pyright: ignore[reportPrivateUsage]
)
from attractor.workflow import parse, validate

# All-read-only fan-out: default_read_only flips every agent branch to
# enforced read-only, so all three branches share the run worktree.
_RO_FANOUT = """\
digraph ROFan {
    graph [ default_read_only = true, default_max_visits = 3 ]
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    fan   [shape=component, label="fan"]
    a [label="A", prompt="search a"]
    b [label="B", prompt="search b"]
    c [label="C", prompt="search c"]
    join  [shape=tripleoctagon, label="J"]
    start -> fan
    fan -> a [label="a"]
    fan -> b [label="b"]
    fan -> c [label="c"]
    a -> join
    b -> join
    c -> join
    join -> exit [label="SUCCESS"]
}
"""

# Mixed fan-out: a, b are read-only (default); w opts back into writing.
_MIXED_FANOUT = """\
digraph MixedFan {
    graph [ default_read_only = true, default_max_visits = 3 ]
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    fan   [shape=component, label="fan"]
    a [label="A", prompt="search a"]
    b [label="B", prompt="search b"]
    w [label="W", prompt="write w", read_only=false]
    join  [shape=tripleoctagon, label="J"]
    start -> fan
    fan -> a [label="a"]
    fan -> b [label="b"]
    fan -> w [label="w"]
    a -> join
    b -> join
    w -> join
    join -> exit [label="SUCCESS"]
}
"""


def _make_cwd_runner(captured: dict[str, str]) -> Any:
    """Stub that records each agent node's prompt → cwd."""

    async def _stub(config: AgentConfig) -> AgentOutcome:
        captured[config.node_prompt] = str(config.cwd)
        return AgentOutcome(status=OutcomeStatus.SUCCESS, captured_output="ok")

    return _stub


# [unit->REQ-EXEC-READONLY-NODE]
def test_branch_shareable_deriver() -> None:
    """The static deriver: all-read-only-agent branch shares; tool or
    writer in the branch forces isolation."""
    ro = validate(parse(_RO_FANOUT))
    assert _branch_read_only_shareable(ro, "a", "join") is True

    mixed = validate(parse(_MIXED_FANOUT))
    assert _branch_read_only_shareable(mixed, "a", "join") is True
    # `w` is read_only=false → not shareable.
    assert _branch_read_only_shareable(mixed, "w", "join") is False

    # A tool node in the branch is unenforceable → not shareable even if
    # everything is read-only.
    tool_branch = """\
digraph T {
    graph [ default_read_only = true ]
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    fan   [shape=component]
    a [shape=parallelogram, label="A", script="echo hi"]
    b [label="B", prompt="search b"]
    join  [shape=tripleoctagon]
    start -> fan
    fan -> a [label="a"]
    fan -> b [label="b"]
    a -> join
    b -> join
    join -> exit [label="SUCCESS"]
}
"""
    tg = validate(parse(tool_branch))
    # tool branch `a` is unenforceable → not shareable; agent branch `b` is.
    assert _branch_read_only_shareable(tg, "a", "join") is False
    assert _branch_read_only_shareable(tg, "b", "join") is True


@pytest.mark.asyncio
# [int->REQ-EXEC-READONLY-NODE]
async def test_readonly_branches_share_run_worktree(
    seeded_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """All three read-only agent branches receive the same cwd — the run
    worktree — proving no per-branch sub-worktrees were created."""
    captured: dict[str, str] = {}
    monkeypatch.setattr(
        "attractor.engine.engine.run_agent_node", _make_cwd_runner(captured)
    )
    engine = Engine(seeded_repo)
    status = await engine.run(validate(parse(_RO_FANOUT)))

    assert status == RunStatus.COMPLETED
    cwds = {captured["search a"], captured["search b"], captured["search c"]}
    assert len(cwds) == 1, f"read-only branches should share one cwd, got {cwds}"
    only = next(iter(cwds))
    assert "branch" not in Path(only).parts, (
        f"shared branch cwd should be the run worktree, not a sub-worktree: {only}"
    )


@pytest.mark.asyncio
# [int->REQ-EXEC-READONLY-NODE]
async def test_mixed_fanout_isolates_only_the_writer(
    seeded_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Read-only branches share the run worktree; the writing branch gets
    its own isolated sub-worktree."""
    captured: dict[str, str] = {}
    monkeypatch.setattr(
        "attractor.engine.engine.run_agent_node", _make_cwd_runner(captured)
    )
    engine = Engine(seeded_repo)
    status = await engine.run(validate(parse(_MIXED_FANOUT)))

    assert status == RunStatus.COMPLETED
    ro_a, ro_b, writer = captured["search a"], captured["search b"], captured["write w"]
    assert ro_a == ro_b, "read-only branches should share one cwd"
    assert "branch" not in Path(ro_a).parts, "read-only branches read the run worktree"
    assert "branch" in Path(writer).parts, (
        "the writing branch must get an isolated sub-worktree"
    )
    assert writer != ro_a
