"""End-to-end tests for budget exhaustion, grace period, and ctl tool invocation.

Tests exercise the budget+grace+ctl pipeline through AgentRunner with mock LLM.
They verify that budget enforcement, grace window, and ctl tools work together
as a system.

Uses real filesystem (tmp_state_dir). Mocks LLM responses to simulate tool calls.
Does NOT make real LLM API calls.
"""

# @invar:allow file_size: E2E tests require extensive setup and mocking

import asyncio
import json
from pathlib import Path
from typing import Any

import pytest
from returns.result import Failure, Success

from anima.core.budget import format_budget_status, grace_system_message
from anima.core.lifecycle import LifecycleEvent
from anima.core.schemas import (
    AgentState,
    AgentStateData,
    JobSpec,
    LifecycleType,
    MaxDepthExceededError,
    PersonaSpec,
    TokenUsage,
)
from anima.core.tracker import BudgetTracker
from anima.shell.agent import AgentRunner, create_ctl_tools
from anima.shell.ctl import (
    ctl_fail,
    ctl_finish,
    ctl_request_approval,
    ctl_spawn_subagent,
    ctl_suspend_until,
)
from anima.shell.state import (
    read_log,
    read_state,
    transition_state,
    write_state,
)


@pytest.fixture(autouse=True)
def _stub_spawn_launcher(monkeypatch: pytest.MonkeyPatch) -> None:
    """Keep ctl.spawn calls in this suite hermetic."""

    def _launch_stub(**_kwargs: object) -> Success[int]:
        return Success(4242)

    monkeypatch.setattr("anima.shell.ctl_spawn.launch_agent_subprocess", _launch_stub)


# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Create a temporary state directory and set ANIMA_STATE."""
    state_dir = tmp_path / "anima_state"
    state_dir.mkdir()
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))
    return state_dir


@pytest.fixture
def work_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Create a working directory with personas, change cwd to it."""
    w_dir = tmp_path / "work"
    w_dir.mkdir()
    p_dir = w_dir / "personas"
    p_dir.mkdir()

    # Parent persona (can spawn any)
    parent = PersonaSpec(
        id="parent", description="A test persona.",
        prompt="You are a parent agent.",
        model="claude-sonnet-4-20250514",
        can_spawn=True,
    )
    (p_dir / "parent.json").write_text(parent.model_dump_json())

    # Worker persona (can spawn specific)
    worker = PersonaSpec(
        id="worker", description="A test persona.",
        prompt="You are a worker agent.",
        model="claude-sonnet-4-20250514",
        can_spawn=["helper"],
    )
    (p_dir / "worker.json").write_text(worker.model_dump_json())

    # Restricted persona (cannot spawn)
    restricted = PersonaSpec(
        id="restricted", description="A test persona.",
        prompt="You are a restricted agent.",
        model="claude-sonnet-4-20250514",
        can_spawn=False,
    )
    (p_dir / "restricted.json").write_text(restricted.model_dump_json())

    # Helper persona
    helper = PersonaSpec(
        id="helper", description="A test persona.",
        prompt="You are a helper agent.",
        model="claude-sonnet-4-20250514",
    )
    (p_dir / "helper.json").write_text(helper.model_dump_json())

    # Daemon persona
    daemon = PersonaSpec(
        id="daemon", description="A test persona.",
        prompt="You are a daemon agent.",
        model="claude-sonnet-4-20250514",
    )
    (p_dir / "daemon.json").write_text(daemon.model_dump_json())

    monkeypatch.chdir(w_dir)
    return w_dir


@pytest.fixture
def personas_dir(work_dir: Path) -> Path:
    """Return personas directory."""
    return work_dir / "personas"


@pytest.fixture
def running_agent(tmp_state_dir: Path, work_dir: Path) -> AgentStateData:
    """Create a running agent state."""
    state = AgentStateData(
        trace_id="tr_test",
        instance_id="inst_running",
        persona_id="test",
        persona_ref="personas/parent.json",
        state=AgentState.running,
        lifecycle=LifecycleType.ephemeral,
        depth=0,
        max_depth=3,
    )
    write_state("inst_running", state)
    return state


@pytest.fixture
def daemon_agent(tmp_state_dir: Path, work_dir: Path) -> AgentStateData:
    """Create a daemon agent state."""
    state = AgentStateData(
        trace_id="tr_test",
        instance_id="inst_daemon",
        persona_id="daemon",
        persona_ref="personas/daemon.json",
        state=AgentState.running,
        lifecycle=LifecycleType.daemon,
        depth=0,
        max_depth=3,
    )
    write_state("inst_daemon", state)
    return state


# =============================================================================
# Helper Classes for Mocking
# =============================================================================


class MockUsage:
    """Mock usage object from pydantic-ai."""

    def __init__(self, input_tokens: int = 0, output_tokens: int = 0):
        self.input_tokens = input_tokens
        self.output_tokens = output_tokens


class MockModelRequestNode:
    """Mock ModelRequestNode for turn tracking."""

    def __init__(self):
        self.tool_call_results = []


class MockCallToolsNode:
    """Mock CallToolsNode for tool call tracking."""

    def __init__(self, tool_call_results: list | None = None):
        self.tool_call_results = tool_call_results or []


class MockRun:
    """Mock pydantic-ai run context."""

    def __init__(self, usage: MockUsage | None = None):
        self._usage = usage or MockUsage()

    def usage(self):
        return self._usage


class MockAgentRun:
    """Mock async context manager for agent.iter()."""

    def __init__(
        self,
        nodes: list[Any],
        usage_sequence: list[MockUsage] | None = None,
    ):
        self._nodes = nodes
        self._usage_sequence = usage_sequence or []
        self._usage_index = 0
        self._entered = False

    async def __aenter__(self):
        self._entered = True
        return self

    async def __aexit__(self, *args):
        pass

    def __aiter__(self):
        return self._iter_nodes()

    async def _iter_nodes(self):
        for i, node in enumerate(self._nodes):
            # Set usage for this iteration
            if i < len(self._usage_sequence):
                self._current_usage = self._usage_sequence[i]
            yield node

    def usage(self):
        if hasattr(self, "_current_usage"):
            return self._current_usage
        if self._usage_sequence:
            return self._usage_sequence[-1]
        return MockUsage()


# =============================================================================
# Test 1: Budget Exhaustion Scenarios
# =============================================================================


class TestBudgetExhaustionScenarios:
    """Tests for budget exhaustion and grace window behavior."""

    def test_budget_tracker_exhaustion_triggers_grace(self):
        """BudgetTracker transitions to grace mode when budget exhausted."""
        tracker = BudgetTracker(100, grace_budget=50)

        # Not exhausted, not in grace
        assert tracker.is_exhausted() is False
        assert tracker.should_enter_grace() is False

        # Exhaust budget
        tracker.record_usage(100)
        assert tracker.is_exhausted() is True
        assert tracker.should_enter_grace() is True

        # Enter grace
        tracker.enter_grace()
        assert tracker.in_grace is True
        assert tracker.is_exhausted() is False  # Reset in grace
        assert tracker.remaining() == 50  # Grace budget available

    def test_budget_tracker_grace_message_injection(self):
        """grace_system_message contains ctl_finish instruction."""
        msg = grace_system_message(500)
        assert "ctl_finish" in msg
        assert "grace" in msg.lower()
        assert "500" in msg

    def test_budget_tracker_grace_finish_completes_agent(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """Agent in grace mode that calls ctl_finish completes successfully."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_grace_finish",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            depth=0,
            max_depth=3,
        )
        instance_id = "inst_grace_finish"
        write_state(instance_id, state)

        # Simulate grace mode and finish
        result = asyncio.run(ctl_finish(instance_id))

        assert isinstance(result, Success)

        # Verify state transition
        state_result = read_state(instance_id)
        assert isinstance(state_result, Success)
        final_state = state_result.unwrap()
        assert final_state.state == AgentState.completed

    def test_budget_tracker_grace_no_finish_fails_agent(self):
        """BudgetTracker correctly tracks grace exhaustion."""
        tracker = BudgetTracker(100, grace_budget=50)

        # Exhaust budget and enter grace
        tracker.record_usage(100)
        tracker.enter_grace()
        assert tracker.in_grace is True

        # Use all grace budget without finish
        tracker.record_usage(50)
        assert tracker.is_grace_exhausted() is True

        # Total used should be 150 (100 + 50)
        assert tracker.used == 150

    def test_budget_tracker_unlimited_never_enters_grace(self):
        """BudgetTracker with unlimited budget never enters grace."""
        tracker = BudgetTracker(None, grace_budget=500)

        # Record massive usage
        tracker.record_usage(1_000_000)

        # Should never be exhausted, never need grace
        assert tracker.is_exhausted() is False
        assert tracker.should_enter_grace() is False
        assert tracker.remaining() is None  # Unlimited

        # Enter grace does nothing
        tracker.enter_grace()
        assert tracker.in_grace is False


# =============================================================================
# Test 2: CTL Tool Invocation
# =============================================================================


class TestCtlToolInvocation:
    """Tests for ctl.* tool invocation through AgentRunner."""

    @pytest.mark.asyncio
    async def test_ctl_finish_via_agent(self, tmp_state_dir: Path, work_dir: Path):
        """ctl_finish through agent loop transitions to completed."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_ctl_finish",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            depth=0,
            max_depth=3,
        )
        instance_id = "inst_ctl_finish"
        write_state("inst_ctl_finish", state)

        # Call ctl_finish directly (simulating LLM tool call)
        result = await ctl_finish(instance_id, result_ref="results/output.json")

        assert isinstance(result, Success)

        # Verify state
        state_result = read_state(instance_id)
        assert isinstance(state_result, Success)
        final_state = state_result.unwrap()
        assert final_state.state == AgentState.completed
        assert final_state.result_ref == "results/output.json"

    @pytest.mark.asyncio
    async def test_ctl_fail_via_agent(self, tmp_state_dir: Path, work_dir: Path):
        """ctl_fail through agent loop transitions to failed."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_ctl_fail",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            depth=0,
            max_depth=3,
        )
        instance_id = "inst_ctl_fail"
        write_state(instance_id, state)

        # Call ctl_fail
        result = await ctl_fail(instance_id, error="Simulated failure")

        assert isinstance(result, Success)

        # Verify state
        state_result = read_state(instance_id)
        assert isinstance(state_result, Success)
        final_state = state_result.unwrap()
        assert final_state.state == AgentState.failed
        assert final_state.error == "Simulated failure"

    @pytest.mark.asyncio
    async def test_ctl_request_approval_via_agent(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """ctl_request_approval blocks then returns approval payload."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_approval",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            depth=0,
            max_depth=3,
        )
        instance_id = "inst_approval"
        write_state(instance_id, state)

        approval_task = asyncio.create_task(
            ctl_request_approval(
                instance_id, reason="Need approval for destructive operation"
            )
        )

        await asyncio.sleep(0.2)
        assert not approval_task.done()

        pending_state_result = read_state(instance_id)
        assert isinstance(pending_state_result, Success)
        pending_state = pending_state_result.unwrap()
        assert pending_state.state == AgentState.awaiting_approval

        approve_result = transition_state(instance_id, LifecycleEvent.approve)
        assert isinstance(approve_result, Success)

        result = await asyncio.wait_for(approval_task, timeout=1.0)

        assert isinstance(result, Success)
        assert result.unwrap() == {"approved": True, "note": None}

        # Verify state
        state_result = read_state(instance_id)
        assert isinstance(state_result, Success)
        final_state = state_result.unwrap()
        assert final_state.state == AgentState.running

        # Verify log
        log_result = read_log(instance_id)
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()
        approval_entries = [
            e for e in entries if e.get("event") == "approval_requested"
        ]
        assert len(approval_entries) >= 1
        assert "destructive operation" in approval_entries[-1]["details"]["reason"]

    @pytest.mark.asyncio
    async def test_ctl_suspend_via_agent(self, tmp_state_dir: Path, work_dir: Path):
        """ctl_suspend_until transitions to suspended."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_suspend",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            depth=0,
            max_depth=3,
        )
        instance_id = "inst_suspend"
        write_state(instance_id, state)

        resume_time = "2026-03-01T15:00:00Z"
        result = await ctl_suspend_until(instance_id, resume_after=resume_time)

        assert isinstance(result, Success)

        # Verify state
        state_result = read_state(instance_id)
        assert isinstance(state_result, Success)
        final_state = state_result.unwrap()
        assert final_state.state == AgentState.suspended
        assert final_state.resume_after == resume_time

    @pytest.mark.asyncio
    async def test_ctl_spawn_subagent_via_agent(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """ctl_spawn_subagent creates child agent and updates parent."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_spawn_parent",
            persona_id="parent",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            depth=0,
            max_depth=3,
        )
        instance_id = "inst_spawn_parent"
        write_state("inst_spawn_parent", state)

        result = await ctl_spawn_subagent(
            instance_id,
            "personas/helper.json",
            "Subtask: help with X",
        )

        assert isinstance(result, Success)
        child_id = result.unwrap()
        assert child_id.startswith("inst_")

        # Verify parent updated
        parent_result = read_state(instance_id)
        assert isinstance(parent_result, Success)
        parent_state = parent_result.unwrap()
        assert child_id in parent_state.children

        # Verify child created
        agents_dir = tmp_state_dir / "agents"
        child_dirs = [
            d for d in agents_dir.iterdir() if d.is_dir() and d.name != instance_id
        ]
        assert len(child_dirs) == 1

        child_state_result = read_state(child_dirs[0].name)
        assert isinstance(child_state_result, Success)
        child_state = child_state_result.unwrap()
        # parent_id is set from the ctl_spawn_subagent parameter (instance_id directory key)
        assert child_state.parent_id == instance_id  # "inst_spawn_parent"
        assert child_state.depth == 1
        assert child_state.max_depth == 3


# =============================================================================
# Test 3: Depth Enforcement
# =============================================================================


class TestDepthEnforcement:
    """Tests for max_depth enforcement in spawn_subagent."""

    @pytest.mark.asyncio
    async def test_spawn_within_depth_limit(self, tmp_state_dir: Path, work_dir: Path):
        """Spawn succeeds when within depth limit."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_depth_ok",
            persona_id="parent",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            depth=0,
            max_depth=3,
        )
        instance_id = "inst_depth_ok"
        write_state(instance_id, state)

        # Spawn at depth 1 (max is 3)
        result = await ctl_spawn_subagent(
            instance_id,
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Success)
        result.unwrap()

        # Child depth should be 1
        agents_dir = tmp_state_dir / "agents"
        child_dirs = [
            d for d in agents_dir.iterdir() if d.is_dir() and d.name != instance_id
        ]
        child_state = read_state(child_dirs[0].name).unwrap()
        assert child_state.depth == 1

    @pytest.mark.asyncio
    async def test_spawn_at_depth_limit_rejected(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """Spawn rejected when would exceed max_depth."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_depth_limit",
            persona_id="parent",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            depth=2,  # At depth 2
            max_depth=3,  # Max depth is 3, so spawn at depth 3 would exceed
        )
        instance_id = "inst_depth_limit"
        write_state(instance_id, state)

        # Try to spawn at depth 3 (would exceed limit since depth + 1 >= max_depth)
        result = await ctl_spawn_subagent(
            instance_id,
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Failure)
        error = result.failure()
        assert isinstance(error, MaxDepthExceededError)
        assert "depth" in str(error).lower()

    @pytest.mark.asyncio
    async def test_max_depth_from_jobspec(self, tmp_state_dir: Path, work_dir: Path):
        """AgentRunner uses max_depth from JobSpec."""

        # Create agent with custom max_depth in JobSpec
        persona = PersonaSpec(
            id="test", description="A test persona.",
            prompt="Test prompt",
            model="gpt-4",
        )
        job = JobSpec(
            persona_ref="test@v1",
            instance_id="inst_custom_depth",
            max_depth=5,
        )

        runner = AgentRunner(
            instance_id="inst_custom_depth",
            task="Test task",
            persona=persona,
            job=job,
        )

        # Verify max_depth from JobSpec is used
        assert runner.job.max_depth == 5

        # BudgetTracker should also use job settings
        assert runner.budget_tracker.grace_budget == job.finalize_grace_budget


# =============================================================================
# Test 4: Daemon Guard Behavior
# =============================================================================


class TestDaemonGuard:
    """Tests for daemon lifecycle guard behavior."""

    @pytest.mark.asyncio
    async def test_daemon_agent_ignores_finish(
        self, tmp_state_dir: Path, work_dir: Path, daemon_agent: AgentStateData
    ):
        """ctl_finish on daemon agent returns success but state stays running."""
        # Call finish on daemon
        result = await ctl_finish("inst_daemon")

        # Returns success (no error)
        assert isinstance(result, Success)

        # But state is still running (not completed)
        state_result = read_state("inst_daemon")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.running
        assert state.lifecycle == LifecycleType.daemon

        # Verify log shows finish_ignored
        log_result = read_log("inst_daemon")
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()
        ignored_entries = [e for e in entries if e.get("event") == "finish_ignored"]
        assert len(ignored_entries) >= 1
        assert "daemon" in ignored_entries[-1]["details"]["reason"]

    @pytest.mark.asyncio
    async def test_ephemeral_agent_finishes(
        self, tmp_state_dir: Path, work_dir: Path, running_agent: AgentStateData
    ):
        """ctl_finish on ephemeral agent transitions to completed."""
        # Verify agent is ephemeral
        assert running_agent.lifecycle == LifecycleType.ephemeral

        # Call finish
        result = await ctl_finish("inst_running")

        # Returns success
        assert isinstance(result, Success)

        # State is completed
        state_result = read_state("inst_running")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.completed

    @pytest.mark.asyncio
    async def test_daemon_can_be_failed(
        self, tmp_state_dir: Path, work_dir: Path, daemon_agent: AgentStateData
    ):
        """ctl_fail works on daemon agent (forced failure)."""
        result = await ctl_fail("inst_daemon", error="Forced daemon failure")

        # Daemon can be failed (terminal state)
        assert isinstance(result, Success)

        # State is failed
        state_result = read_state("inst_daemon")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.failed
        assert "Forced daemon failure" in state.error


# =============================================================================
# Test 5: AgentRunner Budget Integration
# =============================================================================


class TestAgentRunnerBudgetIntegration:
    """Tests for AgentRunner budget tracking integration."""

    def test_runner_budget_tracker_initialization(self):
        """AgentRunner initializes BudgetTracker from JobSpec."""
        persona = PersonaSpec(
            id="test", description="A test persona.",
            prompt="Test",
            model="gpt-4",
        )
        job = JobSpec(
            persona_ref="test@v1",
            instance_id="inst_test",
            max_tokens=10000,
            finalize_grace_budget=100,
        )

        runner = AgentRunner(
            instance_id="inst_test",
            task="Test task",
            persona=persona,
            job=job,
        )

        # Budget from job should take precedence over persona
        assert runner.budget_tracker.budget_limit == 10000
        assert runner.budget_tracker.grace_budget == 100

    def test_runner_budget_tracker_default_grace(self):
        """AgentRunner uses default grace_budget if not in JobSpec."""
        persona = PersonaSpec(
            id="test", description="A test persona.",
            prompt="Test",
            model="gpt-4",
        )
        job = JobSpec(
            persona_ref="test@v1",
            instance_id="inst_test",
        )

        runner = AgentRunner(
            instance_id="inst_test",
            task="Test task",
            persona=persona,
            job=job,
        )

        # Default grace_budget is 500
        assert runner.budget_tracker.grace_budget == 500

    def test_runner_grace_state_tracking(self):
        """AgentRunner tracks grace window state."""
        runner = AgentRunner(
            instance_id="inst_test",
            task="Test task",
        )

        # Initial state
        assert runner.in_grace_window is False
        assert runner.grace_exhausted_without_finish is False
        assert runner.grace_message_injected is False

    def test_runner_unlimited_budget(self):
        """AgentRunner with no budget has unlimited BudgetTracker."""
        # No job, no persona budget
        runner = AgentRunner(
            instance_id="inst_test",
            task="Test task",
        )

        # BudgetTracker should be unlimited
        assert runner.budget_tracker.budget_limit is None


# =============================================================================
# Test 6: Budget Exhaustion in Agent Loop
# =============================================================================


class TestBudgetExhaustionInLoop:
    """Tests for budget exhaustion handling in agent loop."""

    def test_check_budget_exhaustion_unlimited_returns_none(self):
        """_check_budget_exhaustion returns None for unlimited budget."""
        runner = AgentRunner(
            instance_id="inst_test",
            task="Test task",
        )

        # Record usage (unlimited budget)
        runner.budget_tracker.record_usage(1000000)

        # Should return None (no exhaustion)
        result = runner._check_budget_exhaustion()
        assert result is None
        assert runner.in_grace_window is False

    def test_check_budget_exhaustion_enters_grace(self):
        """_check_budget_exhaustion enters grace mode when budget exhausted."""
        runner = AgentRunner(
            instance_id="inst_test",
            task="Test task",
        )
        runner.budget_tracker = BudgetTracker(100, grace_budget=50)

        # Exhaust budget
        runner.budget_tracker.record_usage(100)

        # Check should enter grace
        result = runner._check_budget_exhaustion()
        assert result is None
        assert runner.in_grace_window is True

    def test_check_budget_exhaustion_grace_exhaustion_fails(
        self, tmp_path: Path, monkeypatch
    ):
        """_check_budget_exhaustion returns Failure when grace exhausted."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        runner = AgentRunner(
            instance_id="inst_test",
            task="Test task",
        )
        runner.budget_tracker = BudgetTracker(100, grace_budget=50)

        # Create agent state
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_grace_exhaust",
            persona_id="test",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
        )
        write_state("inst_grace_exhaust", state)

        # Exhaust budget and enter grace
        runner.budget_tracker.record_usage(100)
        runner.budget_tracker.enter_grace()
        runner.in_grace_window = True

        # Exhaust grace budget
        runner.budget_tracker.record_usage(50)

        # Check should fail
        result = runner._check_budget_exhaustion()
        assert isinstance(result, Failure)
        assert "grace" in str(result.failure()).lower()

    def test_check_budget_exhaustion_without_grace_kills(
        self, tmp_path: Path, monkeypatch
    ):
        """_check_budget_exhaustion returns Failure(BudgetExhaustedError) when exhausted without grace."""
        monkeypatch.setenv("ANIMA_STATE", str(tmp_path / ".anima"))

        runner = AgentRunner(
            instance_id="inst_test",
            task="Test task",
        )
        runner.budget_tracker = BudgetTracker(100, grace_budget=0)  # No grace

        # Create agent state
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_no_grace",
            persona_id="test",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
        )
        write_state("inst_no_grace", state)

        # Exhaust budget (no grace available)
        runner.budget_tracker.record_usage(100)

        # Check should return BudgetExhaustedError
        result = runner._check_budget_exhaustion()
        assert isinstance(result, Failure)
        error = result.failure()
        # Per spec-compliance-v2.budget-error-type: BudgetExhaustedError code 310
        assert error.code == 310
        assert "Budget exhausted" in error.message


# =============================================================================
# Test 7: CTL Tools Factory
# =============================================================================


class TestCtlToolsFactory:
    """Tests for create_ctl_tools factory function."""

    def test_create_ctl_tools_returns_list(self):
        """create_ctl_tools returns a list of Tool objects."""
        runner = AgentRunner(instance_id="test_agent", task="Validate ctl tool wiring")

        tools = create_ctl_tools("test_agent", runner)

        assert isinstance(tools, list)
        tool_names = [t.name for t in tools]
        assert "ctl_finish" in tool_names
        assert "ctl_request_approval" in tool_names
        assert "ctl_spawn" in tool_names

    def test_create_ctl_tools_functions_are_async(self):
        """CTL tool functions are async."""
        runner = AgentRunner(instance_id="test_agent", task="Validate async ctl tools")

        tools = create_ctl_tools("test_agent", runner)

        for tool in tools:
            # Tools have function attribute
            assert hasattr(tool, "function")
            # Function is a coroutine function
            import asyncio

            assert asyncio.iscoroutinefunction(tool.function)


# =============================================================================
# Test 8: Comprehensive E2E Scenarios
# =============================================================================


class TestComprehensiveE2EScenarios:
    """End-to-end scenarios combining budget, grace, and ctl tools."""

    @pytest.mark.asyncio
    async def test_budget_exhaustion_then_grace_then_finish(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """Full flow: budget exhaustion -> grace entry -> ctl_finish -> completed."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_full_flow",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
        )
        instance_id = "inst_full_flow"
        write_state(instance_id, state)

        # Simulate budget exhaustion and grace entry
        tracker = BudgetTracker(100, grace_budget=50)
        tracker.record_usage(100)
        assert tracker.should_enter_grace() is True
        tracker.enter_grace()
        assert tracker.in_grace is True

        # Simulate grace message injection
        grace_msg = grace_system_message(50)
        assert "ctl_finish" in grace_msg

        # Simulate using some grace budget, then finish
        tracker.record_usage(25)
        assert tracker.is_grace_exhausted() is False

        # Agent calls ctl_finish
        result = await ctl_finish(instance_id, result_ref="results/grace_output.json")
        assert isinstance(result, Success)

        # Verify final state
        state_result = read_state(instance_id)
        final_state = state_result.unwrap()
        assert final_state.state == AgentState.completed
        assert final_state.result_ref == "results/grace_output.json"

    @pytest.mark.asyncio
    async def test_daemon_cannot_finish_but_can_fail(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """Daemon agent: finish ignored, fail works."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_daemon_flow",
            persona_id="daemon",
            persona_ref="personas/daemon.json",
            state=AgentState.running,
            lifecycle=LifecycleType.daemon,
        )
        instance_id = "inst_daemon_flow"
        write_state(instance_id, state)

        # Daemon tries to finish (ignored)
        result1 = await ctl_finish(instance_id)
        assert isinstance(result1, Success)

        state1 = read_state(instance_id).unwrap()
        assert state1.state == AgentState.running  # Still running

        # Daemon encounters fatal error -> fail
        result2 = await ctl_fail(instance_id, error="Daemon failure")
        assert isinstance(result2, Success)

        state2 = read_state(instance_id).unwrap()
        assert state2.state == AgentState.failed

    @pytest.mark.asyncio
    async def test_spawn_chain_respects_depth(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """Spawn chain respects max_depth at each level."""
        # Parent at depth 0
        parent_state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_chain_0",
            persona_id="parent",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            depth=0,
            max_depth=3,
        )
        parent_id = "inst_chain_0"
        write_state(parent_id, parent_state)

        # Spawn child at depth 1
        result1 = await ctl_spawn_subagent(
            parent_id,
            "personas/parent.json",  # Parent can spawn
            "Child task",
        )
        assert isinstance(result1, Success)
        result1.unwrap()

        # Find child 1 state
        agents_dir = tmp_state_dir / "agents"
        child1_dirs = [
            d for d in agents_dir.iterdir() if d.is_dir() and d.name != parent_id
        ]
        child1_state = read_state(child1_dirs[0].name).unwrap()
        assert child1_state.depth == 1
        assert child1_state.max_depth == 3

        # Update parent reference and spawn at depth 2
        child1_state.persona_ref = "personas/parent.json"
        child1_state.state = AgentState.running
        write_state(child1_state.instance_id, child1_state)

        result2 = await ctl_spawn_subagent(
            child1_state.instance_id,
            "personas/helper.json",
            "Grandchild task",
        )
        assert isinstance(result2, Success)

        # Find grandchild
        child2_dirs = [
            d
            for d in agents_dir.iterdir()
            if d.is_dir() and d.name not in [parent_id, child1_state.instance_id]
        ]
        assert len(child2_dirs) >= 1

    @pytest.mark.asyncio
    async def test_approval_flow_blocks_then_resumes(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """Agent requests approval, blocks, then resumes when approved."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_approval_flow",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
        )
        instance_id = "inst_approval_flow"
        write_state(instance_id, state)

        approval_task = asyncio.create_task(
            ctl_request_approval(
                instance_id,
                reason="Need approval for external API call",
            )
        )

        await asyncio.sleep(0.2)
        assert not approval_task.done()

        # State is awaiting_approval while blocked
        state1 = read_state(instance_id).unwrap()
        assert state1.state == AgentState.awaiting_approval

        approve_result = transition_state(instance_id, LifecycleEvent.approve)
        assert isinstance(approve_result, Success)

        result = await asyncio.wait_for(approval_task, timeout=1.0)
        assert isinstance(result, Success)
        assert result.unwrap() == {"approved": True, "note": None}

        # State is resumed to running after approval
        state2 = read_state(instance_id).unwrap()
        assert state2.state == AgentState.running

        # Verify log
        log = read_log(instance_id).unwrap()
        approval_entries = [e for e in log if e.get("event") == "approval_requested"]
        assert len(approval_entries) >= 1

    @pytest.mark.asyncio
    async def test_suspend_flow_with_timer(self, tmp_state_dir: Path, work_dir: Path):
        """Agent suspends with resume_after timestamp."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_suspend_flow",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
        )
        instance_id = "inst_suspend_flow"
        write_state(instance_id, state)

        # Suspend with timer
        resume_time = "2026-03-01T18:00:00Z"
        result = await ctl_suspend_until(instance_id, resume_after=resume_time)
        assert isinstance(result, Success)

        # State verification
        state1 = read_state(instance_id).unwrap()
        assert state1.state == AgentState.suspended
        assert state1.resume_after == resume_time

        # Verify log
        log = read_log(instance_id).unwrap()
        suspend_entries = [e for e in log if e.get("event") == "suspended"]
        assert len(suspend_entries) >= 1


# =============================================================================
# Test 9: Edge Cases
# =============================================================================


class TestBudgetEdgeCases:
    """Edge case tests for budget and grace behavior."""

    def test_budget_tracker_zero_grace_budget(self):
        """BudgetTracker with zero grace_budget never enters grace."""
        tracker = BudgetTracker(100, grace_budget=0)
        tracker.record_usage(100)

        assert tracker.is_exhausted() is True
        assert tracker.should_enter_grace() is False

        tracker.enter_grace()
        assert tracker.in_grace is False

    def test_budget_tracker_exact_budget_usage(self):
        """BudgetTracker correctly handles exact budget usage."""
        tracker = BudgetTracker(100, grace_budget=50)
        tracker.record_usage(100)  # Exactly at budget

        assert tracker.used == 100
        assert tracker.remaining() == 0
        assert tracker.is_exhausted() is True

    def test_budget_tracker_over_budget(self):
        """BudgetTracker handles usage exceeding budget."""
        tracker = BudgetTracker(100, grace_budget=50)
        tracker.record_usage(150)  # Over budget

        assert tracker.used == 150
        assert tracker.remaining() == 0
        assert tracker.is_exhausted() is True

    def test_grace_message_varies_by_budget(self):
        """grace_system_message includes budget amount."""
        msg_small = grace_system_message(10)
        msg_large = grace_system_message(10000)

        assert "10" in msg_small
        assert "10000" in msg_large

    def test_budget_status_unlimited_format(self):
        """format_budget_status shows 'unlimited' for unlimited budget."""
        tracker = BudgetTracker(None)
        status = format_budget_status(tracker)

        assert "unlimited" in status.lower()

    def test_budget_status_grace_format(self):
        """format_budget_status shows grace mode correctly."""
        tracker = BudgetTracker(100, grace_budget=50)
        tracker.record_usage(100)
        tracker.enter_grace()
        tracker.record_usage(20)

        status = format_budget_status(tracker)

        assert "grace" in status.lower()
        assert "20" in status  # Grace used
        assert "30" in status  # Grace remaining


# =============================================================================
# Test 10: State Consistency
# =============================================================================


class TestStateConsistency:
    """Tests for state consistency after ctl operations."""

    @pytest.mark.asyncio
    async def test_finish_updates_all_state_files(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """ctl_finish updates state.json, result.json, and log.jsonl."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_state",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
            token_usage=TokenUsage(prompt_tokens=100, completion_tokens=50),
        )
        instance_id = "inst_state"
        write_state(instance_id, state)

        # Finish
        await ctl_finish(instance_id, result_ref="results/out.json")

        # Verify state.json
        state_result = read_state(instance_id)
        final_state = state_result.unwrap()
        assert final_state.state == AgentState.completed
        assert final_state.result_ref == "results/out.json"
        assert final_state.token_usage.total_tokens == 150

        # Verify result.json exists
        result_file = tmp_state_dir / "agents" / instance_id / "result.json"
        assert result_file.exists()
        result_data = json.loads(result_file.read_text())
        assert result_data["final_state"] == "completed"

        # Verify log.jsonl has finish event
        log = read_log(instance_id).unwrap()
        finish_events = [e for e in log if e.get("event") == "finished"]
        assert len(finish_events) >= 1

    @pytest.mark.asyncio
    async def test_multiple_transitions_logged(
        self, tmp_state_dir: Path, work_dir: Path
    ):
        """Multiple state transitions produce log entries."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_multi",
            persona_id="test",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            lifecycle=LifecycleType.ephemeral,
        )
        instance_id = "inst_multi"
        write_state(instance_id, state)

        # Transition 1: suspend
        await ctl_suspend_until(instance_id)

        # Transition 2: manually set back to running for next transition
        state2 = read_state(instance_id).unwrap()
        state2.state = AgentState.running
        state2.resume_after = None
        write_state(instance_id, state2)

        # Transition 3: request approval (and approve to unblock)
        approval_task = asyncio.create_task(
            ctl_request_approval(instance_id, reason="Need approval")
        )
        await asyncio.sleep(0.2)
        approve_result = transition_state(instance_id, LifecycleEvent.approve)
        assert isinstance(approve_result, Success)
        approval_result = await asyncio.wait_for(approval_task, timeout=1.0)
        assert isinstance(approval_result, Success)

        # Verify log has all events
        log = read_log(instance_id).unwrap()
        events = [e.get("event") for e in log]
        assert "suspended" in events
        # Note: state_write events are also logged
        assert "approval_requested" in events
