"""Unit tests for AgentConfig / AgentOutcome shape and revisit-context format.

These tests do not spawn pi — they validate the plain-data contract
the engine depends on, plus the §6.3 context format the runner
prepends to revisit prompts.
"""

from __future__ import annotations

import dataclasses
import json
from pathlib import Path

from attractor.agent import (
    DEFAULT_MAX_TURNS,
    DEFAULT_TIMEOUT_SECONDS,
    AgentConfig,
    AgentOutcome,
    OutcomeStatus,
    format_revisit_context,
)


# [unit->REQ-AGENT-SESSION-API]
# [unit->REQ-LLM-MODEL-STRINGS]
def test_agent_config_required_fields_are_typed() -> None:
    """AgentConfig accepts the §6.2 inputs the engine produces."""
    cfg = AgentConfig(
        model="claude-haiku-4-5",
        workflow_goal="Ship v0.1 in a weekend.",
        node_prompt="Write the agent module.",
        cwd=Path("/tmp/attractor-run-1"),
    )
    assert cfg.model == "claude-haiku-4-5"
    assert cfg.workflow_goal == "Ship v0.1 in a weekend."
    assert cfg.node_prompt == "Write the agent module."
    assert cfg.cwd == Path("/tmp/attractor-run-1")
    assert cfg.max_turns == DEFAULT_MAX_TURNS
    assert cfg.timeout_seconds == DEFAULT_TIMEOUT_SECONDS == 30 * 60
    assert cfg.revisit_context is None
    assert cfg.extra_allowed_tools == ()


# [unit->REQ-AGENT-NODE-SCOPE]
def test_node_allowed_tools_defaults_to_none() -> None:
    """New per-node scope fields default to None (backwards-compat)."""
    cfg = AgentConfig(
        model="x",
        workflow_goal=None,
        node_prompt="p",
        cwd=Path("."),
    )
    assert cfg.node_allowed_tools is None
    assert cfg.node_mcp_servers is None


# [unit->REQ-AGENT-NODE-SCOPE]
def test_node_allowed_tools_roundtrips_when_set() -> None:
    """Per-node tool list is stored as a tuple exactly as supplied."""
    cfg = AgentConfig(
        model="x",
        workflow_goal=None,
        node_prompt="p",
        cwd=Path("."),
        node_allowed_tools=("Bash(git merge:*)", "Read", "Edit"),
        node_mcp_servers=("some-server",),
    )
    assert cfg.node_allowed_tools == ("Bash(git merge:*)", "Read", "Edit")
    assert cfg.node_mcp_servers == ("some-server",)


# [unit->REQ-AGENT-SESSION-API]
def test_agent_config_is_frozen_for_checkpoint_safety() -> None:
    """The engine treats inputs as immutable — mutation would break replay."""
    cfg = AgentConfig(
        model="x",
        workflow_goal=None,
        node_prompt="p",
        cwd=Path("."),
    )
    try:
        cfg.model = "y"  # type: ignore[misc]
    except dataclasses.FrozenInstanceError:
        return
    raise AssertionError("AgentConfig must be frozen.")


# [unit->REQ-AGENT-SESSION-API]
def test_agent_outcome_carries_all_four_path_payloads() -> None:
    """A single AgentOutcome dataclass covers all §6.2 paths."""
    success = AgentOutcome(
        status=OutcomeStatus.SUCCESS, captured_output="all green"
    )
    failure = AgentOutcome(
        status=OutcomeStatus.FAILURE,
        captured_output="3 tests failed",
        usage_input_tokens=1234,
        usage_output_tokens=42,
    )
    assert success.status == OutcomeStatus.SUCCESS
    assert success.usage_input_tokens == 0
    assert failure.status == OutcomeStatus.FAILURE
    assert failure.usage_output_tokens == 42


# [unit->REQ-EXEC-REVISIT-CONTEXT]
def test_format_revisit_context_emits_xml_wrapped_json() -> None:
    """The §6.3 payload renders as ``<context>{...}</context>``."""
    rendered = format_revisit_context(
        {
            "visit_n": 2,
            "prior_outcome": "FAILURE",
            "captured_output": "cargo test failed: 3 cases",
        }
    )
    assert rendered.startswith("<context>")
    assert rendered.endswith("</context>")
    body = rendered.removeprefix("<context>\n").removesuffix("\n</context>")
    parsed = json.loads(body)
    assert parsed == {
        "visit_n": 2,
        "prior_outcome": "FAILURE",
        "captured_output": "cargo test failed: 3 cases",
    }


# [unit->REQ-EXEC-REVISIT-CONTEXT]
def test_format_revisit_context_handles_empty_dict() -> None:
    """Empty payloads still render as ``<context>{}</context>``."""
    rendered = format_revisit_context({})
    body = rendered.removeprefix("<context>\n").removesuffix("\n</context>")
    assert json.loads(body) == {}


# [unit->REQ-EXEC-REVISIT-CONTEXT]
def test_format_revisit_context_stringifies_non_json_values() -> None:
    """Path objects in the payload are coerced rather than crashing."""
    rendered = format_revisit_context({"cwd": Path("/tmp/x")})
    body = rendered.removeprefix("<context>\n").removesuffix("\n</context>")
    parsed = json.loads(body)
    # Path stringifies through json.dumps default=str.
    assert isinstance(parsed["cwd"], str)
    assert parsed["cwd"].endswith("x")
