"""REQ-LLM-AUTH-ENV: an agent node that cannot dispatch must end the run
cleanly (a routable FAILURE outcome) — never crash with an uncaught
exception that leaves the journal without `RunFinalized`.

Authentication is delegated to pi (SPEC §9.4), so Attractor never
inspects credentials. The engine-level contract this file pins is the
*failure routing*: when the agent layer cannot run a node (here forced
deterministically by pointing the runner at a `pi` binary that does
not exist — the same `AgentError` path a missing/unconfigured pi would
take), the engine turns it into a FAILURE outcome and either ends the
run INCOMPLETE (no FAILURE edge) or routes through the FAILURE edge
when the workflow defines one.

Tool-only and human-gate-only workflows never dispatch an agent node,
so they need no provider at all.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from attractor.agent import pi_rpc
from attractor.engine import Engine, OutcomeStatus, RunStatus
from attractor.workflow import parse, validate

# A binary name that will never resolve on PATH, forcing
# PiRpcProcess.spawn to raise AgentError deterministically.
_MISSING_PI = "attractor-pi-does-not-exist-xyz"


@pytest.fixture()
def _force_agent_dispatch_failure(  # pyright: ignore[reportUnusedFunction]  -- consumed via usefixtures() string below
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Make every agent-node dispatch fail at spawn time (AgentError)."""
    monkeypatch.setattr(pi_rpc, "PI_BINARY", _MISSING_PI)


@pytest.mark.asyncio
# [int->REQ-LLM-AUTH-ENV]
async def test_tool_only_run_works_without_a_provider(seeded_repo: Path) -> None:
    """No agent nodes → no provider / pi required."""
    src = """\
digraph T {
    graph [ default_max_visits = 1 ]
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    t     [shape=parallelogram, script="echo hi > out"]
    start -> t -> exit
}
"""
    engine = Engine(seeded_repo)
    graph = validate(parse(src))
    status = await engine.run(graph)
    assert status == RunStatus.COMPLETED


@pytest.mark.asyncio
@pytest.mark.usefixtures("_force_agent_dispatch_failure")
# [int->REQ-LLM-AUTH-ENV]
async def test_agent_dispatch_failure_ends_incomplete_no_failure_edge(
    seeded_repo: Path,
) -> None:
    """Agent dispatch failure should NOT propagate as an uncaught
    exception. The agent layer raises `AgentError`; the engine catches
    it at the dispatch boundary, treats it as a FAILURE outcome, and
    routes via the missing `label="FAILURE"` edge — which ends the run
    INCOMPLETE per SPEC §6.1, with a clean `RunFinalized` entry.
    """
    src = """\
digraph A {
    graph [ default_max_visits = 1 ]
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    work  [label="W", prompt="Do something."]
    start -> work -> exit
}
"""
    engine = Engine(seeded_repo)
    graph = validate(parse(src))
    status = await engine.run(graph)
    assert status == RunStatus.INCOMPLETE
    summary = engine.show(_only_run_id(engine))
    work = next((r for r in summary.history if r.node_id == "work"), None)
    assert work is not None, "work node should appear in history"
    assert work.status == OutcomeStatus.FAILURE
    # The captured_output names the failure so `attractor run show` can
    # diagnose it; the invariant is non-empty + clean finalization.
    assert work.captured_output


@pytest.mark.asyncio
@pytest.mark.usefixtures("_force_agent_dispatch_failure")
# [int->REQ-LLM-AUTH-ENV]
# [int->REQ-EXEC-EDGE-ROUTING]
async def test_agent_failure_routes_via_failure_edge_when_present(
    seeded_repo: Path,
) -> None:
    """When the workflow defines a FAILURE edge from an agent node, an
    AgentError-class failure routes through it instead of ending the run.

    This is the §6.6 fix-up pattern applied to agent setup failures:
    workflow authors can plan for "what if the agent layer is
    misconfigured" by routing to a tool node that prints install
    instructions or pages an operator.
    """
    src = """\
digraph A {
    graph [ default_max_visits = 1 ]
    start [shape=Mdiamond, label="S"]
    exit  [shape=Msquare,  label="E"]
    work  [label="W", prompt="Do something."]
    diagnose [
        shape  = parallelogram,
        label  = "Diagnose",
        script = "echo 'agent failed - check pi install' > diagnose.log"
    ]
    start -> work -> exit
    work  -> diagnose [label="FAILURE"]
    diagnose -> exit
}
"""
    engine = Engine(seeded_repo)
    graph = validate(parse(src))
    status = await engine.run(graph)
    # No goal_gates, so reaching exit via the diagnose path completes
    # the run successfully — even though `work` failed.
    assert status == RunStatus.COMPLETED
    summary = engine.show(_only_run_id(engine))
    work_rec = next((r for r in summary.history if r.node_id == "work"), None)
    diagnose_rec = next((r for r in summary.history if r.node_id == "diagnose"), None)
    assert work_rec is not None and work_rec.status == OutcomeStatus.FAILURE
    assert diagnose_rec is not None and diagnose_rec.status == OutcomeStatus.SUCCESS


def _only_run_id(engine: Engine) -> str:
    """Return the single run-id known to this engine instance.

    Tests build a fresh `seeded_repo` per case, so the repo has at most
    one run; the worktree dir is removed on COMPLETED runs (§6.7), so
    fall back to enumerating refs when `list()` is empty.
    """
    handles = engine.list()
    if handles:
        return handles[0].run_id
    import pygit2  # local import to keep top-level deps narrow

    repo = pygit2.Repository(str(engine._repo_root))  # pyright: ignore[reportPrivateUsage]
    for ref in repo.references:
        if ref.startswith("refs/heads/attractor/run/") and ref.endswith("/state"):
            return ref.removeprefix("refs/heads/attractor/run/").removesuffix("/state")
    raise AssertionError("no run found in seeded_repo")
