"""Tests for FSM Executor."""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

from little_loops.fsm.executor import (
    ActionResult,
    DefaultActionRunner,
    ExecutionResult,
    FSMExecutor,
    SimulationActionRunner,
)
from little_loops.fsm.schema import (
    EvaluateConfig,
    FSMLoop,
    RouteConfig,
    StateConfig,
)


@dataclass
class MockActionRunner:
    """Mock action runner for testing."""

    results: list[tuple[str, dict[str, Any]]] = field(default_factory=list)
    call_index: int = 0
    calls: list[str] = field(default_factory=list)
    default_result: dict[str, Any] = field(
        default_factory=lambda: {"output": "", "stderr": "", "exit_code": 0}
    )

    use_indexed_order: bool = False

    def run(
        self,
        action: str,
        timeout: int,
        is_slash_command: bool,
        on_output_line: Any = None,
    ) -> ActionResult:
        """Return configured result for action."""
        # Suppress unused variable warnings - these match the Protocol signature
        del timeout, is_slash_command, on_output_line
        self.calls.append(action)

        # Use indexed results in order (when results were set as a list)
        if self.use_indexed_order and self.call_index < len(self.results):
            _, result_data = self.results[self.call_index]
            self.call_index += 1
            return ActionResult(
                output=result_data.get("output", ""),
                stderr=result_data.get("stderr", ""),
                exit_code=result_data.get("exit_code", 0),
                duration_ms=result_data.get("duration_ms", 100),
            )

        # Check for specific result by pattern
        for pattern, result_data in self.results:
            if pattern in action or pattern == action:
                return ActionResult(
                    output=result_data.get("output", ""),
                    stderr=result_data.get("stderr", ""),
                    exit_code=result_data.get("exit_code", 0),
                    duration_ms=result_data.get("duration_ms", 100),
                )

        return ActionResult(
            output=self.default_result.get("output", ""),
            stderr=self.default_result.get("stderr", ""),
            exit_code=self.default_result.get("exit_code", 0),
            duration_ms=self.default_result.get("duration_ms", 100),
        )

    def set_result(self, action: str, **kwargs: Any) -> None:
        """Set result for a specific action pattern."""
        self.results.append((action, kwargs))

    def always_return(self, **kwargs: Any) -> None:
        """Set default result for all actions."""
        self.default_result = kwargs


class TestFSMExecutorBasic:
    """Basic executor tests."""

    def test_simple_success_path(self) -> None:
        """check → done on first success."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="pytest",
                    on_yes="done",
                    on_no="fix",
                ),
                "done": StateConfig(terminal=True),
                "fix": StateConfig(action="fix.sh", next="check"),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("pytest", exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.final_state == "done"
        assert result.iterations == 1
        assert result.terminated_by == "terminal"
        assert "pytest" in mock_runner.calls

    def test_fix_retry_loop(self) -> None:
        """check → fix → check → done with retry."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="pytest",
                    on_yes="done",
                    on_no="fix",
                ),
                "fix": StateConfig(action="fix.sh", next="check"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        # First check fails, fix succeeds, second check passes
        mock_runner.results = [
            ("pytest", {"exit_code": 1}),
            ("fix.sh", {"exit_code": 0}),
            ("pytest", {"exit_code": 0}),
        ]
        mock_runner.use_indexed_order = True

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.final_state == "done"
        assert result.iterations == 3
        assert result.terminated_by == "terminal"

    def test_max_iterations_respected(self) -> None:
        """Loop terminates at max_iterations."""
        fsm = FSMLoop(
            name="test",
            initial="loop",
            max_iterations=3,
            states={
                "loop": StateConfig(
                    action="fail.sh",
                    on_yes="done",
                    on_no="loop",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=1)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.iterations == 3
        assert result.terminated_by == "max_iterations"

    def test_unconditional_next_transition(self) -> None:
        """State with 'next' transitions unconditionally."""
        fsm = FSMLoop(
            name="test",
            initial="step1",
            states={
                "step1": StateConfig(action="echo step1", next="step2"),
                "step2": StateConfig(action="echo step2", next="done"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.final_state == "done"
        assert result.iterations == 2
        assert len(mock_runner.calls) == 2

    def test_next_routed_state_updates_prev_result(self) -> None:
        """Action output from a next-routed state is captured in prev_result."""
        # fix (next-routed) → done (terminal, no action)
        # prev_result should reflect fix.sh's output, not be left stale/None.
        fsm = FSMLoop(
            name="test",
            initial="fix",
            states={
                "fix": StateConfig(action="fix.sh", next="done"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("fix.sh", output="fix output", exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        assert executor.prev_result is not None
        assert executor.prev_result["output"] == "fix output"

    def test_no_action_state(self) -> None:
        """State without action proceeds to routing."""
        fsm = FSMLoop(
            name="test",
            initial="decide",
            states={
                "decide": StateConfig(on_yes="done"),  # No action
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.final_state == "done"
        assert result.iterations == 1
        assert len(mock_runner.calls) == 0


class TestActionType:
    """Tests for action_type field behavior in executor."""

    def test_action_type_prompt_runs_action(self) -> None:
        """action_type=prompt executes the action even without / prefix."""
        fsm = FSMLoop(
            name="test",
            initial="analyze",
            states={
                "analyze": StateConfig(
                    action="Analyze the code",
                    action_type="prompt",
                    on_yes="done",
                    on_no="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0, output="Analysis complete")

        from little_loops.fsm.evaluators import EvaluationResult

        with patch(
            "little_loops.fsm.executor.evaluate_llm_structured",
            return_value=EvaluationResult(verdict="yes", details={}),
        ):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            executor.run()

        assert "Analyze the code" in mock_runner.calls

    def test_action_type_shell_runs_action(self) -> None:
        """action_type=shell executes the action as shell."""
        fsm = FSMLoop(
            name="test",
            initial="run",
            states={
                "run": StateConfig(
                    action="/usr/bin/ls",
                    action_type="shell",
                    on_yes="done",
                    on_no="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        assert "/usr/bin/ls" in mock_runner.calls

    def test_action_type_slash_command_runs_action(self) -> None:
        """action_type=slash_command executes the action via Claude CLI."""
        fsm = FSMLoop(
            name="test",
            initial="commit",
            states={
                "commit": StateConfig(
                    action="/ll:commit",
                    action_type="slash_command",
                    on_yes="done",
                    on_no="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        from little_loops.fsm.evaluators import EvaluationResult

        with patch(
            "little_loops.fsm.executor.evaluate_llm_structured",
            return_value=EvaluationResult(verdict="yes", details={}),
        ):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            executor.run()

        assert "/ll:commit" in mock_runner.calls

    def test_action_type_none_uses_heuristic_slash(self) -> None:
        """Without action_type, / prefix triggers slash command detection."""
        fsm = FSMLoop(
            name="test",
            initial="cmd",
            states={
                "cmd": StateConfig(
                    action="/ll:help",
                    on_yes="done",
                    on_no="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        from little_loops.fsm.evaluators import EvaluationResult

        with patch(
            "little_loops.fsm.executor.evaluate_llm_structured",
            return_value=EvaluationResult(verdict="yes", details={}),
        ):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            executor.run()

        assert "/ll:help" in mock_runner.calls

    def test_action_type_none_uses_heuristic_shell(self) -> None:
        """Without action_type, non-/ prefix triggers shell execution."""
        fsm = FSMLoop(
            name="test",
            initial="cmd",
            states={
                "cmd": StateConfig(
                    action="echo hello",
                    on_yes="done",
                    on_no="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        assert "echo hello" in mock_runner.calls


class TestActionTypeMcpTool:
    """Tests for action_type=mcp_tool execution in the executor."""

    def _make_mcp_fsm(
        self, params: dict | None = None, route_verdicts: dict | None = None
    ) -> FSMLoop:
        """Build a minimal FSM with one mcp_tool state."""
        route_verdicts = route_verdicts or {
            "success": "done",
            "tool_error": "done",
            "not_found": "done",
            "timeout": "done",
        }
        return FSMLoop(
            name="test",
            initial="fetch",
            states={
                "fetch": StateConfig(
                    action="browser/navigate",
                    action_type="mcp_tool",
                    params=params or {"url": "https://example.com"},
                    route=RouteConfig(routes=route_verdicts),
                ),
                "done": StateConfig(terminal=True),
            },
        )

    def test_mcp_tool_does_not_call_action_runner(self) -> None:
        """mcp_tool states bypass action_runner entirely."""
        fsm = self._make_mcp_fsm()
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        success_envelope = json.dumps({"isError": False, "content": []})
        with patch("little_loops.fsm.executor.FSMExecutor._run_subprocess") as mock_sub:
            mock_sub.return_value = ActionResult(
                output=success_envelope, stderr="", exit_code=0, duration_ms=50
            )
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            result = executor.run()

        # action_runner should not have been called
        assert len(mock_runner.calls) == 0
        # subprocess was called with mcp-call
        mock_sub.assert_called_once()
        cmd = mock_sub.call_args[0][0]
        assert cmd[0] == "mcp-call"
        assert cmd[1] == "browser/navigate"
        assert result.final_state == "done"

    def test_mcp_tool_routes_success(self) -> None:
        """mcp_tool state with isError=false routes to success."""
        fsm = self._make_mcp_fsm(route_verdicts={"success": "done"})
        success_envelope = json.dumps(
            {"isError": False, "content": [{"type": "text", "text": "ok"}]}
        )

        with patch("little_loops.fsm.executor.FSMExecutor._run_subprocess") as mock_sub:
            mock_sub.return_value = ActionResult(
                output=success_envelope, stderr="", exit_code=0, duration_ms=50
            )
            executor = FSMExecutor(fsm, action_runner=MockActionRunner())
            result = executor.run()

        assert result.final_state == "done"

    def test_mcp_tool_routes_tool_error(self) -> None:
        """mcp_tool state with isError=true routes to tool_error."""
        fsm = self._make_mcp_fsm(route_verdicts={"tool_error": "done"})
        error_envelope = json.dumps(
            {"isError": True, "content": [{"type": "text", "text": "fail"}]}
        )

        with patch("little_loops.fsm.executor.FSMExecutor._run_subprocess") as mock_sub:
            mock_sub.return_value = ActionResult(
                output=error_envelope, stderr="", exit_code=1, duration_ms=50
            )
            executor = FSMExecutor(fsm, action_runner=MockActionRunner())
            result = executor.run()

        assert result.final_state == "done"

    def test_mcp_tool_routes_not_found(self) -> None:
        """mcp_tool state with exit 127 routes to not_found."""
        fsm = self._make_mcp_fsm(route_verdicts={"not_found": "done"})

        with patch("little_loops.fsm.executor.FSMExecutor._run_subprocess") as mock_sub:
            mock_sub.return_value = ActionResult(
                output="", stderr="", exit_code=127, duration_ms=10
            )
            executor = FSMExecutor(fsm, action_runner=MockActionRunner())
            result = executor.run()

        assert result.final_state == "done"

    def test_mcp_tool_routes_timeout(self) -> None:
        """mcp_tool state with exit 124 routes to timeout."""
        fsm = self._make_mcp_fsm(route_verdicts={"timeout": "done"})

        with patch("little_loops.fsm.executor.FSMExecutor._run_subprocess") as mock_sub:
            mock_sub.return_value = ActionResult(
                output="", stderr="", exit_code=124, duration_ms=30000
            )
            executor = FSMExecutor(fsm, action_runner=MockActionRunner())
            result = executor.run()

        assert result.final_state == "done"

    def test_mcp_tool_params_interpolated(self) -> None:
        """mcp_tool params are interpolated with context before calling mcp-call."""
        fsm = FSMLoop(
            name="test",
            initial="fetch",
            context={"target_url": "https://example.com/page"},
            states={
                "fetch": StateConfig(
                    action="browser/navigate",
                    action_type="mcp_tool",
                    params={"url": "${context.target_url}"},
                    route=RouteConfig(routes={"success": "done", "tool_error": "done"}),
                ),
                "done": StateConfig(terminal=True),
            },
        )

        success_envelope = json.dumps({"isError": False, "content": []})
        with patch("little_loops.fsm.executor.FSMExecutor._run_subprocess") as mock_sub:
            mock_sub.return_value = ActionResult(
                output=success_envelope, stderr="", exit_code=0, duration_ms=50
            )
            executor = FSMExecutor(fsm, action_runner=MockActionRunner())
            executor.run()

        # The params JSON passed to mcp-call should have the interpolated URL
        cmd = mock_sub.call_args[0][0]
        params_arg = json.loads(cmd[2])
        assert params_arg["url"] == "https://example.com/page"

    def test_mcp_tool_default_evaluator_is_mcp_result(self) -> None:
        """mcp_tool state without explicit evaluate uses mcp_result by default."""
        fsm = FSMLoop(
            name="test",
            initial="fetch",
            states={
                "fetch": StateConfig(
                    action="browser/navigate",
                    action_type="mcp_tool",
                    params={"url": "https://example.com"},
                    # No explicit evaluate — should default to mcp_result
                    route=RouteConfig(routes={"success": "done", "tool_error": "done"}),
                ),
                "done": StateConfig(terminal=True),
            },
        )

        success_envelope = json.dumps({"isError": False, "content": []})
        with patch("little_loops.fsm.executor.FSMExecutor._run_subprocess") as mock_sub:
            mock_sub.return_value = ActionResult(
                output=success_envelope, stderr="", exit_code=0, duration_ms=50
            )
            executor = FSMExecutor(fsm, action_runner=MockActionRunner())
            result = executor.run()

        assert result.final_state == "done"


class TestVariableInterpolation:
    """Tests for variable interpolation in executor."""

    def test_context_interpolation(self) -> None:
        """${context.*} resolves in action."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            context={"target_dir": "src/"},
            states={
                "check": StateConfig(
                    action="mypy ${context.target_dir}",
                    on_yes="done",
                    on_no="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        assert "mypy src/" in mock_runner.calls

    def test_state_iteration_interpolation(self) -> None:
        """${state.iteration} resolves correctly."""
        fsm = FSMLoop(
            name="test",
            initial="log",
            max_iterations=2,
            states={
                "log": StateConfig(
                    action="echo iteration ${state.iteration}",
                    on_yes="log",
                    on_no="log",
                ),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        # Iteration is 1-based
        assert "echo iteration 1" in mock_runner.calls
        assert "echo iteration 2" in mock_runner.calls


class TestCapture:
    """Tests for output capture."""

    def test_capture_stores_output(self) -> None:
        """capture: saves action result."""
        fsm = FSMLoop(
            name="test",
            initial="measure",
            states={
                "measure": StateConfig(
                    action="count.sh",
                    capture="errors",
                    next="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("count.sh", output="42", exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert "errors" in result.captured
        assert result.captured["errors"]["output"] == "42"
        assert result.captured["errors"]["exit_code"] == 0

    def test_multiple_captures(self) -> None:
        """Multiple states can capture different values."""
        fsm = FSMLoop(
            name="test",
            initial="count_errors",
            states={
                "count_errors": StateConfig(
                    action="count_errors.sh",
                    capture="errors",
                    next="count_warnings",
                ),
                "count_warnings": StateConfig(
                    action="count_warnings.sh",
                    capture="warnings",
                    next="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("count_errors.sh", output="5")
        mock_runner.set_result("count_warnings.sh", output="10")

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.captured["errors"]["output"] == "5"
        assert result.captured["warnings"]["output"] == "10"


class TestCaptureWorkflow:
    """Tests for capture-then-use workflow in execution."""

    def test_captured_output_used_in_next_state(self) -> None:
        """Output captured in state A is interpolated in state B action."""
        fsm = FSMLoop(
            name="test",
            initial="fetch",
            states={
                "fetch": StateConfig(
                    action="echo secret-value-123",
                    capture="token",
                    next="use",
                ),
                "use": StateConfig(
                    action='echo "Using: ${captured.token.output}"',
                    next="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.results = [
            ("echo secret-value-123", {"output": "secret-value-123", "exit_code": 0}),
            ("echo", {"output": "Using: secret-value-123", "exit_code": 0}),
        ]
        mock_runner.use_indexed_order = True

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        # Verify the interpolated action was called with the captured value
        assert len(mock_runner.calls) == 2
        assert 'echo "Using: secret-value-123"' in mock_runner.calls[1]

    def test_captured_exit_code_interpolation(self) -> None:
        """Captured exit code can be interpolated in subsequent action."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="check.sh",
                    capture="result",
                    on_yes="done",
                    on_no="report",
                ),
                "report": StateConfig(
                    action='echo "Exit was: ${captured.result.exit_code}"',
                    next="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        # Exit code 1 -> failure verdict -> routes to report
        mock_runner.results = [
            ("check.sh", {"output": "", "exit_code": 1}),
            ("echo", {"output": "Exit was: 1", "exit_code": 0}),
        ]
        mock_runner.use_indexed_order = True

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        # Verify exit code was interpolated
        assert 'echo "Exit was: 1"' in mock_runner.calls[1]

    def test_multiple_captures_available_in_later_state(self) -> None:
        """Multiple captures from different states all available in final state."""
        fsm = FSMLoop(
            name="test",
            initial="step1",
            states={
                "step1": StateConfig(
                    action="first.sh",
                    capture="a",
                    next="step2",
                ),
                "step2": StateConfig(
                    action="second.sh",
                    capture="b",
                    next="step3",
                ),
                "step3": StateConfig(
                    action='echo "${captured.a.output} and ${captured.b.output}"',
                    next="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.results = [
            ("first.sh", {"output": "first", "exit_code": 0}),
            ("second.sh", {"output": "second", "exit_code": 0}),
            ("echo", {"output": "first and second", "exit_code": 0}),
        ]
        mock_runner.use_indexed_order = True

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        # Verify both captures were interpolated
        assert 'echo "first and second"' in mock_runner.calls[2]

    def test_missing_capture_returns_error(self) -> None:
        """Referencing non-existent capture returns error in result."""
        fsm = FSMLoop(
            name="test",
            initial="use",
            states={
                "use": StateConfig(
                    action='echo "${captured.nonexistent.output}"',
                    next="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        # Executor catches interpolation error and returns it
        assert result.terminated_by == "error"
        assert result.error is not None
        assert "not found in captured" in result.error

    def test_capture_with_special_characters(self) -> None:
        """Captured output with quotes and newlines handled correctly."""
        fsm = FSMLoop(
            name="test",
            initial="fetch",
            states={
                "fetch": StateConfig(
                    action="get_data.sh",
                    capture="data",
                    next="use",
                ),
                "use": StateConfig(
                    action='process "${captured.data.output}"',
                    next="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        # Output with quotes and newlines
        special_output = 'line1\nline2 with "quotes"'
        mock_runner.results = [
            ("get_data.sh", {"output": special_output, "exit_code": 0}),
            ("process", {"output": "processed", "exit_code": 0}),
        ]
        mock_runner.use_indexed_order = True

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        # Verify special characters were interpolated correctly
        assert f'process "{special_output}"' in mock_runner.calls[1]

    def test_captured_stderr_interpolation(self) -> None:
        """Captured stderr can be interpolated."""
        fsm = FSMLoop(
            name="test",
            initial="run",
            states={
                "run": StateConfig(
                    action="command.sh",
                    capture="cmd",
                    on_yes="done",
                    on_no="log_error",
                ),
                "log_error": StateConfig(
                    action='echo "Error: ${captured.cmd.stderr}"',
                    next="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.results = [
            ("command.sh", {"output": "", "stderr": "file not found", "exit_code": 1}),
            ("echo", {"output": "Error: file not found", "exit_code": 0}),
        ]
        mock_runner.use_indexed_order = True

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        # Verify stderr was interpolated
        assert 'echo "Error: file not found"' in mock_runner.calls[1]

    def test_captured_duration_interpolation(self) -> None:
        """Captured duration_ms can be interpolated."""
        fsm = FSMLoop(
            name="test",
            initial="measure",
            states={
                "measure": StateConfig(
                    action="slow_task.sh",
                    capture="timing",
                    next="report",
                ),
                "report": StateConfig(
                    action='echo "Took: ${captured.timing.duration_ms}ms"',
                    next="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.results = [
            ("slow_task.sh", {"output": "", "exit_code": 0, "duration_ms": 1500}),
            ("echo", {"output": "Took: 1500ms", "exit_code": 0}),
        ]
        mock_runner.use_indexed_order = True

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.run()

        # Verify duration was interpolated
        assert 'echo "Took: 1500ms"' in mock_runner.calls[1]


class TestRouting:
    """Tests for verdict routing."""

    def test_full_route_table(self) -> None:
        """Full route table with custom verdicts."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="check.sh",
                    evaluate=EvaluateConfig(
                        type="output_contains",
                        pattern="BLOCKED",
                    ),
                    route=RouteConfig(
                        routes={"yes": "done", "no": "blocked"},
                    ),
                ),
                "done": StateConfig(terminal=True),
                "blocked": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("check.sh", output="BLOCKED: need help", exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        # Pattern found = yes, routes to "done"
        assert result.final_state == "done"

    def test_route_default(self) -> None:
        """Route default catches unmatched verdicts."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="check.sh",
                    route=RouteConfig(
                        routes={"yes": "done"},
                        default="fallback",
                    ),
                ),
                "done": StateConfig(terminal=True),
                "fallback": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("check.sh", exit_code=1)  # no verdict

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.final_state == "fallback"

    def test_current_state_token(self) -> None:
        """$current token routes back to current state."""
        fsm = FSMLoop(
            name="test",
            initial="retry",
            max_iterations=3,
            states={
                "retry": StateConfig(
                    action="flaky.sh",
                    on_yes="done",
                    on_no="$current",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=1)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.iterations == 3
        assert result.terminated_by == "max_iterations"

    def test_no_valid_route_terminates_with_error(self) -> None:
        """Missing route causes error termination."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="check.sh",
                    on_yes="done",
                    # No on_failure route
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("check.sh", exit_code=1)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.terminated_by == "error"
        assert result.error == "No valid transition"

    def test_on_partial_routes_correctly(self) -> None:
        """on_partial routes when evaluator returns 'partial' verdict."""
        # output_contains "PARTIAL" → success verdict, not partial.
        # To test on_partial we need an evaluator that returns "partial".
        # Use a custom route table with "partial" verdict instead.
        fsm2 = FSMLoop(
            name="test",
            initial="evaluate",
            states={
                "evaluate": StateConfig(
                    action="check.sh",
                    route=RouteConfig(routes={"partial": "fix", "yes": "done"}),
                    on_partial="fix",  # also set shorthand (unused when route present)
                ),
                "fix": StateConfig(terminal=True),
                "done": StateConfig(terminal=True),
            },
        )
        # Simulate exit_code returning "yes" → routes to "done" via route table
        mock_runner = MockActionRunner()
        mock_runner.set_result("check.sh", exit_code=0)
        executor = FSMExecutor(fsm2, action_runner=mock_runner)
        result = executor.run()
        assert result.final_state == "done"

    def test_on_partial_shorthand_routes_to_fix_state(self) -> None:
        """on_partial shorthand routes to fix state when verdict is 'partial'."""
        # We need to produce a "partial" verdict. Use a mock that patches the evaluator.
        fsm = FSMLoop(
            name="test",
            initial="evaluate",
            states={
                "evaluate": StateConfig(
                    action="/some-slash-command",
                    action_type="slash_command",
                    on_yes="done",
                    on_no="done",
                    on_partial="fix",
                ),
                "fix": StateConfig(terminal=True),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("/some-slash-command", output="partial result", exit_code=0)

        partial_eval = MagicMock()
        from little_loops.fsm.evaluators import EvaluationResult

        partial_eval.return_value = EvaluationResult(
            verdict="partial", details={"confidence": 0.6, "confident": False}
        )

        with patch("little_loops.fsm.executor.evaluate_llm_structured", partial_eval):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            result = executor.run()

        assert result.final_state == "fix"
        assert result.terminated_by == "terminal"

    def test_on_partial_missing_falls_through_to_error(self) -> None:
        """When partial verdict has no on_partial handler, execution errors."""
        fsm = FSMLoop(
            name="test",
            initial="evaluate",
            states={
                "evaluate": StateConfig(
                    action="/some-slash-command",
                    action_type="slash_command",
                    on_yes="done",
                    on_no="done",
                    # No on_partial — partial verdict should find no route
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("/some-slash-command", output="partial result", exit_code=0)

        partial_eval = MagicMock()
        from little_loops.fsm.evaluators import EvaluationResult

        partial_eval.return_value = EvaluationResult(
            verdict="partial", details={"confidence": 0.6, "confident": False}
        )

        with patch("little_loops.fsm.executor.evaluate_llm_structured", partial_eval):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            result = executor.run()

        assert result.terminated_by == "error"
        assert result.error == "No valid transition"

    def test_on_blocked_shorthand_routes_to_fix_state(self) -> None:
        """on_blocked shorthand routes to fix state when verdict is 'blocked'."""
        fsm = FSMLoop(
            name="test",
            initial="evaluate",
            states={
                "evaluate": StateConfig(
                    action="/some-slash-command",
                    action_type="slash_command",
                    on_yes="done",
                    on_no="done",
                    on_blocked="fix",
                ),
                "fix": StateConfig(terminal=True),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("/some-slash-command", output="blocked result", exit_code=0)

        blocked_eval = MagicMock()
        from little_loops.fsm.evaluators import EvaluationResult

        blocked_eval.return_value = EvaluationResult(
            verdict="blocked", details={"confidence": 0.9, "confident": False}
        )

        with patch("little_loops.fsm.executor.evaluate_llm_structured", blocked_eval):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            result = executor.run()

        assert result.final_state == "fix"
        assert result.terminated_by == "terminal"

    def test_on_blocked_missing_falls_through_to_error(self) -> None:
        """When blocked verdict has no on_blocked handler, execution errors."""
        fsm = FSMLoop(
            name="test",
            initial="evaluate",
            states={
                "evaluate": StateConfig(
                    action="/some-slash-command",
                    action_type="slash_command",
                    on_yes="done",
                    on_no="done",
                    # No on_blocked — blocked verdict should find no route
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("/some-slash-command", output="blocked result", exit_code=0)

        blocked_eval = MagicMock()
        from little_loops.fsm.evaluators import EvaluationResult

        blocked_eval.return_value = EvaluationResult(
            verdict="blocked", details={"confidence": 0.9, "confident": False}
        )

        with patch("little_loops.fsm.executor.evaluate_llm_structured", blocked_eval):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            result = executor.run()

        assert result.terminated_by == "error"
        assert result.error == "No valid transition"


class TestEvents:
    """Tests for event emission."""

    def test_events_emitted(self) -> None:
        """Event callback receives all lifecycle events."""
        events: list[dict[str, Any]] = []
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="test.sh",
                    on_yes="done",
                    on_no="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, event_callback=events.append, action_runner=mock_runner)
        executor.run()

        event_types = [e["event"] for e in events]
        assert "loop_start" in event_types
        assert "state_enter" in event_types
        assert "action_start" in event_types
        assert "action_complete" in event_types
        assert "evaluate" in event_types
        assert "route" in event_types
        assert "loop_complete" in event_types

    def test_event_includes_timestamp(self) -> None:
        """All events include timestamp."""
        events: list[dict[str, Any]] = []
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(action="test.sh", next="done"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()

        executor = FSMExecutor(fsm, event_callback=events.append, action_runner=mock_runner)
        executor.run()

        for event in events:
            assert "ts" in event

    def test_loop_complete_event_details(self) -> None:
        """loop_complete event includes final state and iteration count."""
        events: list[dict[str, Any]] = []
        fsm = FSMLoop(
            name="test-loop",
            initial="check",
            states={
                "check": StateConfig(action="test.sh", on_yes="done"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, event_callback=events.append, action_runner=mock_runner)
        executor.run()

        complete_event = next(e for e in events if e["event"] == "loop_complete")
        assert complete_event["final_state"] == "done"
        assert complete_event["iterations"] == 1
        assert complete_event["terminated_by"] == "terminal"


class TestMaintainMode:
    """Tests for maintain mode."""

    def test_maintain_restarts_after_terminal(self) -> None:
        """maintain: true restarts from initial after terminal."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            maintain=True,
            max_iterations=3,
            states={
                "check": StateConfig(
                    action="check.sh",
                    on_yes="done",
                    on_no="check",
                ),
                "done": StateConfig(terminal=True, on_maintain="check"),
            },
        )
        mock_runner = MockActionRunner()
        # All succeed, so should restart from check due to maintain mode
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        # Should hit max_iterations due to restart cycle
        assert result.terminated_by == "max_iterations"
        assert result.iterations == 3

    def test_maintain_uses_on_maintain_target(self) -> None:
        """on_maintain specifies restart target."""
        fsm = FSMLoop(
            name="test",
            initial="start",
            maintain=True,
            max_iterations=3,
            states={
                "start": StateConfig(action="start.sh", next="check"),
                "check": StateConfig(
                    action="check.sh",
                    on_yes="done",
                ),
                "done": StateConfig(terminal=True, on_maintain="check"),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        # Should restart from "check" (on_maintain), not "start" (initial)
        # Calls: start.sh, check.sh, check.sh (restart), check.sh (restart)
        assert result.terminated_by == "max_iterations"

    def test_maintain_route_event_emitted(self) -> None:
        """Restart in maintain mode emits a route event with reason='maintain'."""
        events: list[dict[str, Any]] = []
        fsm = FSMLoop(
            name="test",
            initial="check",
            maintain=True,
            max_iterations=2,
            states={
                "check": StateConfig(action="check.sh", on_yes="done"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, event_callback=events.append, action_runner=mock_runner)
        executor.run()

        route_events = [e for e in events if e.get("event") == "route"]
        assert any(e.get("reason") == "maintain" for e in route_events)


class TestEvaluators:
    """Tests for evaluator integration."""

    def test_exit_code_evaluator(self) -> None:
        """Exit code evaluator maps codes correctly."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="test.sh",
                    evaluate=EvaluateConfig(type="exit_code"),
                    on_yes="pass",
                    on_no="fail",
                    on_error="error",
                ),
                "pass": StateConfig(terminal=True),
                "fail": StateConfig(terminal=True),
                "error": StateConfig(terminal=True),
            },
        )

        # Test exit code 0 -> success
        mock_runner = MockActionRunner()
        mock_runner.set_result("test.sh", exit_code=0)
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "pass"

        # Test exit code 1 -> failure
        mock_runner = MockActionRunner()
        mock_runner.set_result("test.sh", exit_code=1)
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "fail"

        # Test exit code 2+ -> error
        mock_runner = MockActionRunner()
        mock_runner.set_result("test.sh", exit_code=2)
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "error"

    def test_output_numeric_evaluator(self) -> None:
        """Output numeric evaluator compares values."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="count.sh",
                    evaluate=EvaluateConfig(
                        type="output_numeric",
                        operator="eq",
                        target=0,
                    ),
                    on_yes="done",
                    on_no="fix",
                ),
                "done": StateConfig(terminal=True),
                "fix": StateConfig(terminal=True),
            },
        )

        # Test value equals target -> success
        mock_runner = MockActionRunner()
        mock_runner.set_result("count.sh", output="0")
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "done"

        # Test value not equal -> failure
        mock_runner = MockActionRunner()
        mock_runner.set_result("count.sh", output="5")
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "fix"

    def test_output_contains_evaluator(self) -> None:
        """Output contains evaluator matches patterns."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="grep.sh",
                    evaluate=EvaluateConfig(
                        type="output_contains",
                        pattern="SUCCESS",
                    ),
                    on_yes="done",
                    on_no="retry",
                ),
                "done": StateConfig(terminal=True),
                "retry": StateConfig(terminal=True),
            },
        )

        # Test pattern found -> success
        mock_runner = MockActionRunner()
        mock_runner.set_result("grep.sh", output="Test result: SUCCESS")
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "done"

        # Test pattern not found -> failure
        mock_runner = MockActionRunner()
        mock_runner.set_result("grep.sh", output="Test result: FAILED")
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "retry"

    def test_output_json_evaluator_determines_state(self) -> None:
        """output_json evaluator extracts field and routes state."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="api.sh",
                    evaluate=EvaluateConfig(
                        type="output_json",
                        path=".status",
                        operator="eq",
                        target="ready",
                    ),
                    on_yes="done",
                    on_no="retry",
                ),
                "done": StateConfig(terminal=True),
                "retry": StateConfig(terminal=True),
            },
        )

        # Test string value matches -> success
        mock_runner = MockActionRunner()
        mock_runner.set_result("api.sh", output='{"status": "ready", "count": 5}')
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "done"

        # Test string value doesn't match -> failure
        mock_runner = MockActionRunner()
        mock_runner.set_result("api.sh", output='{"status": "pending", "count": 5}')
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "retry"

    def test_output_json_nested_path(self) -> None:
        """output_json handles nested JSON paths."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="result.sh",
                    evaluate=EvaluateConfig(
                        type="output_json",
                        path=".data.items.0.value",
                        operator="eq",
                        target=42,
                    ),
                    on_yes="done",
                    on_no="retry",
                ),
                "done": StateConfig(terminal=True),
                "retry": StateConfig(terminal=True),
            },
        )

        mock_runner = MockActionRunner()
        mock_runner.set_result(
            "result.sh",
            output='{"data": {"items": [{"value": 42}, {"value": 100}]}}',
        )
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "done"

    def test_output_json_numeric_comparison(self) -> None:
        """output_json uses numeric comparison for numeric values."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="count.sh",
                    evaluate=EvaluateConfig(
                        type="output_json",
                        path=".count",
                        operator="lt",
                        target=10,
                    ),
                    on_yes="done",
                    on_no="retry",
                ),
                "done": StateConfig(terminal=True),
                "retry": StateConfig(terminal=True),
            },
        )

        # count=5 < 10 -> success
        mock_runner = MockActionRunner()
        mock_runner.set_result("count.sh", output='{"count": 5}')
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "done"

        # count=15 not < 10 -> failure
        mock_runner = MockActionRunner()
        mock_runner.set_result("count.sh", output='{"count": 15}')
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "retry"

    def test_convergence_evaluator_detects_target(self) -> None:
        """convergence evaluator returns target verdict when value within tolerance."""
        fsm = FSMLoop(
            name="test",
            initial="optimize",
            states={
                "optimize": StateConfig(
                    action="measure.sh",
                    evaluate=EvaluateConfig(
                        type="convergence",
                        target=0,
                        tolerance=1.0,
                        direction="minimize",
                    ),
                    route=RouteConfig(
                        routes={"target": "done", "progress": "optimize", "stall": "stuck"},
                    ),
                ),
                "done": StateConfig(terminal=True),
                "stuck": StateConfig(terminal=True),
            },
        )

        # Value 0.5 within tolerance 1.0 of target 0 -> target verdict
        mock_runner = MockActionRunner()
        mock_runner.set_result("measure.sh", output="0.5")
        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "done"

    def test_convergence_evaluator_tracks_progress(self) -> None:
        """convergence evaluator tracks progress across iterations."""
        fsm = FSMLoop(
            name="test",
            initial="measure",
            max_iterations=3,
            states={
                "measure": StateConfig(
                    action="count.sh",
                    evaluate=EvaluateConfig(
                        type="convergence",
                        target=0,
                        tolerance=0,
                        direction="minimize",
                        previous="${prev.output}",
                    ),
                    route=RouteConfig(
                        routes={"target": "done", "progress": "measure", "stall": "stuck"},
                    ),
                ),
                "done": StateConfig(terminal=True),
                "stuck": StateConfig(terminal=True),
            },
        )

        mock_runner = MockActionRunner()
        # Simulate decreasing values: 10 -> 5 -> 0 (progress, progress, target)
        mock_runner.results = [
            ("count.sh", {"output": "10"}),  # First: progress (no previous)
            ("count.sh", {"output": "5"}),  # Second: progress (10 -> 5)
            ("count.sh", {"output": "0"}),  # Third: target reached
        ]
        mock_runner.use_indexed_order = True

        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "done"
        assert result.iterations == 3

    def test_convergence_evaluator_detects_stall(self) -> None:
        """convergence evaluator returns stall when no progress made."""
        fsm = FSMLoop(
            name="test",
            initial="measure",
            max_iterations=2,
            states={
                "measure": StateConfig(
                    action="count.sh",
                    evaluate=EvaluateConfig(
                        type="convergence",
                        target=0,
                        tolerance=0,
                        direction="minimize",
                        previous="${prev.output}",
                    ),
                    route=RouteConfig(
                        routes={"target": "done", "progress": "measure", "stall": "stuck"},
                    ),
                ),
                "done": StateConfig(terminal=True),
                "stuck": StateConfig(terminal=True),
            },
        )

        mock_runner = MockActionRunner()
        # No progress: 10 -> 10 (stall)
        mock_runner.results = [
            ("count.sh", {"output": "10"}),  # First: progress (no previous)
            ("count.sh", {"output": "10"}),  # Second: stall (no change)
        ]
        mock_runner.use_indexed_order = True

        result = FSMExecutor(fsm, action_runner=mock_runner).run()
        assert result.final_state == "stuck"
        assert result.iterations == 2

    def test_llm_structured_evaluator_routes_on_verdict(self) -> None:
        """llm_structured evaluator calls LLM and routes based on verdict."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="deploy.sh",
                    evaluate=EvaluateConfig(
                        type="llm_structured",
                        prompt="Did the deployment succeed?",
                    ),
                    on_yes="done",
                    on_no="retry",
                ),
                "done": StateConfig(terminal=True),
                "retry": StateConfig(terminal=True),
            },
        )

        mock_cli_result = MagicMock()
        mock_cli_result.returncode = 0
        mock_cli_result.stderr = ""
        mock_cli_result.stdout = json.dumps(
            {
                "result": json.dumps(
                    {
                        "verdict": "yes",
                        "confidence": 0.95,
                        "reason": "Deployment completed successfully",
                    }
                ),
            }
        )

        mock_runner = MockActionRunner()
        mock_runner.set_result("deploy.sh", output="Deployed to production")

        with patch(
            "little_loops.fsm.evaluators.subprocess.run", return_value=mock_cli_result
        ) as mock_run:
            result = FSMExecutor(fsm, action_runner=mock_runner).run()

        assert result.final_state == "done"
        # Verify CLI was called
        mock_run.assert_called_once()

    def test_llm_structured_evaluator_failure_verdict(self) -> None:
        """llm_structured evaluator routes to failure on failure verdict."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="test.sh",
                    evaluate=EvaluateConfig(type="llm_structured"),
                    on_yes="done",
                    on_no="retry",
                ),
                "done": StateConfig(terminal=True),
                "retry": StateConfig(terminal=True),
            },
        )

        mock_cli_result = MagicMock()
        mock_cli_result.returncode = 0
        mock_cli_result.stderr = ""
        mock_cli_result.stdout = json.dumps(
            {
                "result": json.dumps(
                    {
                        "verdict": "no",
                        "confidence": 0.9,
                        "reason": "Tests failed",
                    }
                ),
            }
        )

        mock_runner = MockActionRunner()
        mock_runner.set_result("test.sh", output="3 tests failed")

        with patch("little_loops.fsm.evaluators.subprocess.run", return_value=mock_cli_result):
            result = FSMExecutor(fsm, action_runner=mock_runner).run()

        assert result.final_state == "retry"

    def test_llm_structured_evaluator_blocked_verdict(self) -> None:
        """llm_structured evaluator routes blocked verdict to configured state."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="deploy.sh",
                    evaluate=EvaluateConfig(type="llm_structured"),
                    route=RouteConfig(
                        routes={
                            "yes": "done",
                            "no": "retry",
                            "blocked": "needs_help",
                        },
                    ),
                ),
                "done": StateConfig(terminal=True),
                "retry": StateConfig(terminal=True),
                "needs_help": StateConfig(terminal=True),
            },
        )

        mock_cli_result = MagicMock()
        mock_cli_result.returncode = 0
        mock_cli_result.stderr = ""
        mock_cli_result.stdout = json.dumps(
            {
                "result": json.dumps(
                    {
                        "verdict": "blocked",
                        "confidence": 0.85,
                        "reason": "Missing permissions",
                    }
                ),
            }
        )

        mock_runner = MockActionRunner()
        mock_runner.set_result("deploy.sh", output="Permission denied")

        with patch("little_loops.fsm.evaluators.subprocess.run", return_value=mock_cli_result):
            result = FSMExecutor(fsm, action_runner=mock_runner).run()

        assert result.final_state == "needs_help"


class TestExecutionResult:
    """Tests for ExecutionResult."""

    def test_to_dict(self) -> None:
        """to_dict() serializes all fields."""
        result = ExecutionResult(
            final_state="done",
            iterations=5,
            terminated_by="terminal",
            duration_ms=1234,
            captured={"errors": {"output": "0"}},
            error=None,
        )

        d = result.to_dict()

        assert d["final_state"] == "done"
        assert d["iterations"] == 5
        assert d["terminated_by"] == "terminal"
        assert d["duration_ms"] == 1234
        assert d["captured"] == {"errors": {"output": "0"}}
        assert "error" not in d

    def test_to_dict_with_error(self) -> None:
        """to_dict() includes error when present."""
        result = ExecutionResult(
            final_state="fix",
            iterations=3,
            terminated_by="error",
            duration_ms=500,
            captured={},
            error="Something went wrong",
        )

        d = result.to_dict()

        assert d["error"] == "Something went wrong"


class TestErrorHandling:
    """Tests for error handling."""

    def test_exception_during_execution_returns_error_result(self) -> None:
        """Exception during execution terminates with error."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="test.sh",
                    on_yes="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )

        class FailingRunner:
            def run(
                self, action: str, timeout: int, is_slash_command: bool, on_output_line: Any = None
            ) -> ActionResult:
                del action, timeout, is_slash_command, on_output_line
                raise RuntimeError("Connection failed")

        executor = FSMExecutor(fsm, action_runner=FailingRunner())  # type: ignore[arg-type]
        result = executor.run()

        assert result.terminated_by == "error"
        assert "Connection failed" in (result.error or "")


class TestTimeoutHandling:
    """Tests for timeout handling."""

    def test_action_timeout_exit_code_124_routes_to_error(self) -> None:
        """Exit code 124 from action timeout routes to on_error."""
        fsm = FSMLoop(
            name="test",
            initial="slow",
            states={
                "slow": StateConfig(
                    action="slow_command.sh",
                    on_yes="done",
                    on_no="retry",
                    on_error="error",
                ),
                "done": StateConfig(terminal=True),
                "retry": StateConfig(terminal=True),
                "error": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        # Exit code 124 is returned on timeout - maps to "error" by default evaluator
        mock_runner.set_result("slow_command.sh", exit_code=124, stderr="Action timed out")

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        # Exit code 124 >= 2, so maps to "error" verdict
        assert result.final_state == "error"
        assert result.terminated_by == "terminal"

    def test_action_timeout_emits_event_with_exit_code_124(self) -> None:
        """action_complete event includes exit code 124 on timeout."""
        events: list[dict[str, Any]] = []
        fsm = FSMLoop(
            name="test",
            initial="slow",
            states={
                "slow": StateConfig(
                    action="slow_command.sh",
                    on_yes="done",
                    on_error="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("slow_command.sh", exit_code=124)

        executor = FSMExecutor(fsm, event_callback=events.append, action_runner=mock_runner)
        executor.run()

        action_complete = next(e for e in events if e["event"] == "action_complete")
        assert action_complete["exit_code"] == 124

    def test_action_timeout_captured_result(self) -> None:
        """Timeout result is captured when state has capture configured."""
        fsm = FSMLoop(
            name="test",
            initial="slow",
            states={
                "slow": StateConfig(
                    action="slow_command.sh",
                    capture="slow_result",
                    on_yes="done",
                    on_error="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result(
            "slow_command.sh",
            exit_code=124,
            stderr="Action timed out",
            output="",
            duration_ms=30000,
        )

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert "slow_result" in result.captured
        assert result.captured["slow_result"]["exit_code"] == 124
        assert result.captured["slow_result"]["stderr"] == "Action timed out"

    def test_loop_timeout_stops_execution(self) -> None:
        """Loop terminates when total time exceeds timeout."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            timeout=5,  # 5 second total timeout
            states={
                "check": StateConfig(
                    action="check.sh",
                    on_yes="done",
                    on_no="check",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=1)  # Always fail to keep looping

        # Mock time to simulate timeout after first iteration
        start_time = 1000.0
        # First call: start_time_ms, second: check timeout (after 6s)
        time_values = [start_time, start_time, start_time + 6.0]
        call_count = [0]

        def mock_time() -> float:
            result = time_values[min(call_count[0], len(time_values) - 1)]
            call_count[0] += 1
            return result

        with patch("little_loops.fsm.executor.time.time", side_effect=mock_time):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            result = executor.run()

        assert result.terminated_by == "timeout"

    def test_loop_timeout_emits_loop_complete_event(self) -> None:
        """loop_complete event includes terminated_by: timeout."""
        events: list[dict[str, Any]] = []
        fsm = FSMLoop(
            name="test",
            initial="check",
            timeout=5,
            states={
                "check": StateConfig(
                    action="check.sh",
                    on_yes="done",
                    on_no="check",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=1)

        start_time = 1000.0
        time_values = [start_time, start_time, start_time + 6.0]
        call_count = [0]

        def mock_time() -> float:
            result = time_values[min(call_count[0], len(time_values) - 1)]
            call_count[0] += 1
            return result

        with patch("little_loops.fsm.executor.time.time", side_effect=mock_time):
            executor = FSMExecutor(fsm, event_callback=events.append, action_runner=mock_runner)
            executor.run()

        loop_complete = next(e for e in events if e["event"] == "loop_complete")
        assert loop_complete["terminated_by"] == "timeout"

    def test_loop_timeout_preserves_state(self) -> None:
        """Loop timeout returns correct final_state and iterations."""
        fsm = FSMLoop(
            name="test",
            initial="step1",
            timeout=5,
            states={
                "step1": StateConfig(action="step1.sh", next="step2"),
                "step2": StateConfig(action="step2.sh", next="step1"),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        start_time = 1000.0
        # Timeout check happens BEFORE each iteration starts
        # - start_time_ms = first call
        # - iteration 1: check timeout (ok), execute step1, route to step2
        # - iteration 2: check timeout (exceeds), return timeout
        time_values = [
            start_time,  # run() start (start_time_ms)
            start_time,  # first timeout check (ok)
            start_time + 6.0,  # second timeout check (exceeds 5s)
        ]
        call_count = [0]

        def mock_time() -> float:
            result = time_values[min(call_count[0], len(time_values) - 1)]
            call_count[0] += 1
            return result

        with patch("little_loops.fsm.executor.time.time", side_effect=mock_time):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            result = executor.run()

        assert result.terminated_by == "timeout"
        # One iteration completes (step1 -> step2), then timeout before step2 can execute
        assert result.iterations == 1
        # Current state is step2 (routed there after step1 completed)
        assert result.final_state == "step2"


class TestSignalHandling:
    """Tests for graceful shutdown via signal handling."""

    def test_request_shutdown_sets_flag(self) -> None:
        """request_shutdown() sets the internal flag."""
        fsm = FSMLoop(
            name="test",
            initial="start",
            states={
                "start": StateConfig(terminal=True),
            },
        )
        executor = FSMExecutor(fsm)

        assert executor._shutdown_requested is False
        executor.request_shutdown()
        assert executor._shutdown_requested is True

    def test_shutdown_terminates_before_first_iteration(self) -> None:
        """Shutdown requested before run() starts terminates immediately."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            max_iterations=10,
            states={
                "check": StateConfig(action="pytest", on_yes="done", on_no="check"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.request_shutdown()  # Request shutdown before run

        result = executor.run()

        assert result.terminated_by == "signal"
        assert result.iterations == 0
        assert result.final_state == "check"  # Never moved from initial
        assert len(mock_runner.calls) == 0  # No actions executed

    def test_shutdown_terminates_after_current_iteration(self) -> None:
        """Shutdown during execution completes current iteration then stops."""
        fsm = FSMLoop(
            name="test",
            initial="step1",
            max_iterations=10,
            states={
                "step1": StateConfig(action="step1.sh", next="step2"),
                "step2": StateConfig(action="step2.sh", next="step3"),
                "step3": StateConfig(action="step3.sh", next="done"),
                "done": StateConfig(terminal=True),
            },
        )

        # Track calls and trigger shutdown after first action
        shutdown_executor: FSMExecutor | None = None
        call_count = [0]

        class ShutdownAfterFirstActionRunner:
            calls: list[str] = []

            def run(
                self,
                action: str,
                timeout: int,
                is_slash_command: bool,
                on_output_line: Any = None,
            ) -> ActionResult:
                del timeout, is_slash_command, on_output_line
                self.calls.append(action)
                call_count[0] += 1

                # Shutdown after second action completes
                if call_count[0] == 2 and shutdown_executor:
                    shutdown_executor.request_shutdown()

                return ActionResult(output="ok", stderr="", exit_code=0, duration_ms=100)

        runner = ShutdownAfterFirstActionRunner()
        executor = FSMExecutor(fsm, action_runner=runner)
        shutdown_executor = executor

        result = executor.run()

        assert result.terminated_by == "signal"
        # Two iterations completed before shutdown was detected
        assert result.iterations == 2
        assert result.final_state == "step3"
        assert len(runner.calls) == 2  # step1.sh and step2.sh executed

    def test_shutdown_emits_loop_complete_event(self) -> None:
        """Shutdown emits loop_complete event with terminated_by=signal."""
        fsm = FSMLoop(
            name="test-signal",
            initial="check",
            states={
                "check": StateConfig(action="check.sh", on_yes="done", on_no="check"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        events: list[dict[str, Any]] = []

        def capture_event(event: dict[str, Any]) -> None:
            events.append(event)

        executor = FSMExecutor(fsm, event_callback=capture_event, action_runner=mock_runner)
        executor.request_shutdown()

        result = executor.run()

        assert result.terminated_by == "signal"

        # Find loop_complete event
        loop_complete_events = [e for e in events if e.get("event") == "loop_complete"]
        assert len(loop_complete_events) == 1
        assert loop_complete_events[0]["terminated_by"] == "signal"

    def test_shutdown_preserves_captured_values(self) -> None:
        """Shutdown preserves captured values from completed iterations."""
        fsm = FSMLoop(
            name="test",
            initial="capture_step",
            max_iterations=10,
            states={
                "capture_step": StateConfig(
                    action="get_value.sh",
                    capture="my_value",
                    next="process_step",
                ),
                "process_step": StateConfig(action="process.sh", next="capture_step"),
            },
        )

        shutdown_executor: FSMExecutor | None = None
        call_count = [0]

        class CaptureAndShutdownRunner:
            calls: list[str] = []

            def run(
                self,
                action: str,
                timeout: int,
                is_slash_command: bool,
                on_output_line: Any = None,
            ) -> ActionResult:
                del timeout, is_slash_command, on_output_line
                self.calls.append(action)
                call_count[0] += 1

                # Shutdown after first iteration (capture_step + process_step)
                if call_count[0] == 2 and shutdown_executor:
                    shutdown_executor.request_shutdown()

                if "get_value" in action:
                    return ActionResult(
                        output="captured_data_123", stderr="", exit_code=0, duration_ms=100
                    )
                return ActionResult(output="ok", stderr="", exit_code=0, duration_ms=100)

        runner = CaptureAndShutdownRunner()
        executor = FSMExecutor(fsm, action_runner=runner)
        shutdown_executor = executor

        result = executor.run()

        assert result.terminated_by == "signal"
        assert "my_value" in result.captured
        assert result.captured["my_value"]["output"] == "captured_data_123"

    def test_shutdown_checked_before_max_iterations(self) -> None:
        """Shutdown is checked before max_iterations check."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            max_iterations=1,  # Would trigger max_iterations
            states={
                "check": StateConfig(action="check.sh", on_yes="done", on_no="check"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        # Both conditions true: shutdown requested AND at max_iterations
        executor._shutdown_requested = True
        executor.iteration = 1  # At max_iterations

        result = executor.run()

        # Signal takes precedence over max_iterations
        assert result.terminated_by == "signal"

    def test_shutdown_checked_before_timeout(self) -> None:
        """Shutdown is checked before timeout check."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            max_iterations=100,
            timeout=1,  # 1 second timeout
            states={
                "check": StateConfig(action="check.sh", on_yes="done", on_no="check"),
                "done": StateConfig(terminal=True),
            },
        )

        executor = FSMExecutor(fsm)
        executor._shutdown_requested = True
        # Simulate timeout by setting start_time far in the past
        executor.start_time_ms = 0
        executor.started_at = "2024-01-01T00:00:00+00:00"

        # Mock time to return a value that exceeds timeout
        with patch("little_loops.fsm.executor.time.time", return_value=10.0):
            result = executor.run()

        # Signal takes precedence over timeout
        assert result.terminated_by == "signal"

    def test_sigkill_on_next_state_triggers_shutdown(self) -> None:
        """SIGKILL (exit_code=-9) on a prompt action with state.next triggers shutdown, not next routing."""
        fsm = FSMLoop(
            name="test",
            initial="score_issues",
            states={
                "score_issues": StateConfig(action="/score", next="refine_issues"),
                "refine_issues": StateConfig(action="/refine", next="score_issues"),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("/score", output="", exit_code=-9)  # SIGKILL

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        # Should terminate via signal, not route to refine_issues
        assert result.terminated_by == "signal"
        assert result.final_state == "score_issues"
        # refine_issues must never have been entered
        assert all("/refine" not in call for call in mock_runner.calls)

    def test_sigkill_on_next_state_routes_via_on_error_if_configured(self) -> None:
        """SIGKILL on a state with state.next and on_error routes to on_error, not next."""
        fsm = FSMLoop(
            name="test",
            initial="score_issues",
            states={
                "score_issues": StateConfig(
                    action="/score", next="refine_issues", on_error="handle_error"
                ),
                "refine_issues": StateConfig(action="/refine", next="score_issues"),
                "handle_error": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("/score", output="", exit_code=-9)  # SIGKILL

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.final_state == "handle_error"
        assert all("/refine" not in call for call in mock_runner.calls)

    def test_normal_exit_on_next_state_still_routes_normally(self) -> None:
        """Non-negative exit codes on a state.next action still route to next normally."""
        fsm = FSMLoop(
            name="test",
            initial="step1",
            states={
                "step1": StateConfig(action="/step1", next="done"),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("/step1", output="ok", exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.final_state == "done"
        assert result.terminated_by != "signal"


class TestSimulationActionRunner:
    """Tests for SimulationActionRunner."""

    def test_scenario_all_pass(self) -> None:
        """All-pass scenario returns exit code 0 for all calls."""
        runner = SimulationActionRunner(scenario="all-pass")
        results = [
            runner.run("cmd1", timeout=60, is_slash_command=False).exit_code,
            runner.run("cmd2", timeout=60, is_slash_command=False).exit_code,
            runner.run("cmd3", timeout=60, is_slash_command=False).exit_code,
        ]
        assert results == [0, 0, 0]

    def test_scenario_all_fail(self) -> None:
        """All-fail scenario returns exit code 1 for all calls."""
        runner = SimulationActionRunner(scenario="all-fail")
        results = [
            runner.run("cmd1", timeout=60, is_slash_command=False).exit_code,
            runner.run("cmd2", timeout=60, is_slash_command=False).exit_code,
            runner.run("cmd3", timeout=60, is_slash_command=False).exit_code,
        ]
        assert results == [1, 1, 1]

    def test_scenario_all_error(self) -> None:
        """All-error scenario returns exit code 2 for all calls."""
        runner = SimulationActionRunner(scenario="all-error")
        results = [
            runner.run("cmd1", timeout=60, is_slash_command=False).exit_code,
            runner.run("cmd2", timeout=60, is_slash_command=False).exit_code,
            runner.run("cmd3", timeout=60, is_slash_command=False).exit_code,
        ]
        assert results == [2, 2, 2]

    def test_scenario_first_fail(self) -> None:
        """First-fail scenario returns 1 first, then 0."""
        runner = SimulationActionRunner(scenario="first-fail")
        results = [
            runner.run("cmd1", timeout=60, is_slash_command=False).exit_code,
            runner.run("cmd2", timeout=60, is_slash_command=False).exit_code,
            runner.run("cmd3", timeout=60, is_slash_command=False).exit_code,
        ]
        assert results == [1, 0, 0]

    def test_scenario_alternating(self) -> None:
        """Alternating scenario returns 1, 0, 1, 0..."""
        runner = SimulationActionRunner(scenario="alternating")
        results = [
            runner.run("cmd", timeout=60, is_slash_command=False).exit_code for _ in range(4)
        ]
        assert results == [1, 0, 1, 0]

    def test_records_calls(self) -> None:
        """Runner records all calls."""
        runner = SimulationActionRunner(scenario="all-pass")
        runner.run("cmd1", timeout=60, is_slash_command=False)
        runner.run("cmd2", timeout=60, is_slash_command=True)
        assert runner.calls == ["cmd1", "cmd2"]
        assert runner.call_count == 2

    def test_returns_simulated_output(self) -> None:
        """Runner returns simulated output string."""
        runner = SimulationActionRunner(scenario="all-pass")
        result = runner.run("echo hello", timeout=60, is_slash_command=False)
        assert "simulated" in result.output.lower()
        assert "echo hello" in result.output

    def test_returns_zero_duration(self) -> None:
        """Simulation returns 0 duration since nothing executed."""
        runner = SimulationActionRunner(scenario="all-pass")
        result = runner.run("sleep 10", timeout=60, is_slash_command=False)
        assert result.duration_ms == 0

    def test_returns_empty_stderr(self) -> None:
        """Simulation returns empty stderr."""
        runner = SimulationActionRunner(scenario="all-fail")
        result = runner.run("failing_cmd", timeout=60, is_slash_command=False)
        assert result.stderr == ""

    def test_with_fsm_executor(self) -> None:
        """SimulationActionRunner integrates with FSMExecutor."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            max_iterations=5,
            states={
                "check": StateConfig(
                    action="run_check",
                    on_yes="done",
                    on_no="fix",
                ),
                "fix": StateConfig(action="run_fix", next="check"),
                "done": StateConfig(terminal=True),
            },
        )
        # first-fail: first check fails, fix runs, second check passes
        sim_runner = SimulationActionRunner(scenario="first-fail")
        executor = FSMExecutor(fsm, action_runner=sim_runner)
        result = executor.run()

        assert result.terminated_by == "terminal"
        assert result.final_state == "done"
        # Should have: check (fail) -> fix -> check (pass) -> done
        assert sim_runner.call_count == 3
        assert "run_check" in sim_runner.calls[0]
        assert "run_fix" in sim_runner.calls[1]
        assert "run_check" in sim_runner.calls[2]


class TestExecutionResultToDict:
    """Tests for ExecutionResult.to_dict edge cases."""

    def test_to_dict_with_handoff(self) -> None:
        """to_dict includes handoff flag when True."""
        result = ExecutionResult(
            final_state="done",
            iterations=1,
            terminated_by="handoff",
            duration_ms=100,
            captured={},
            handoff=True,
        )
        d = result.to_dict()
        assert d["handoff"] is True

    def test_to_dict_with_continuation_prompt(self) -> None:
        """to_dict includes continuation_prompt when set."""
        result = ExecutionResult(
            final_state="done",
            iterations=1,
            terminated_by="handoff",
            duration_ms=100,
            captured={},
            continuation_prompt="Continue from here",
        )
        d = result.to_dict()
        assert d["continuation_prompt"] == "Continue from here"

    def test_to_dict_without_optional_fields(self) -> None:
        """to_dict omits optional fields when not set."""
        result = ExecutionResult(
            final_state="done",
            iterations=1,
            terminated_by="terminal",
            duration_ms=100,
            captured={},
        )
        d = result.to_dict()
        assert "handoff" not in d
        assert "continuation_prompt" not in d
        assert "error" not in d


class TestHandoffDetection:
    """Tests for handoff signal detection in executor."""

    def test_handoff_signal_terminates_loop(self) -> None:
        """Executor returns with handoff when signal detected."""
        from little_loops.fsm.signal_detector import DetectedSignal, SignalDetector

        fsm = FSMLoop(
            name="test",
            initial="work",
            states={
                "work": StateConfig(
                    action="echo CONTEXT_HANDOFF: continue here",
                    on_yes="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )

        mock_runner = MockActionRunner()
        mock_runner.set_result("echo", output="CONTEXT_HANDOFF: continue here")

        mock_detector = MagicMock(spec=SignalDetector)
        mock_detector.detect_first.return_value = DetectedSignal(
            signal_type="handoff",
            payload="continue here",
            raw_match="CONTEXT_HANDOFF: continue here",
        )

        executor = FSMExecutor(
            fsm,
            action_runner=mock_runner,
            signal_detector=mock_detector,
        )
        result = executor.run()

        assert result.terminated_by == "handoff"
        assert result.handoff is True
        assert result.continuation_prompt == "continue here"

    def test_handoff_with_handler(self) -> None:
        """Executor invokes handoff handler when configured."""
        from little_loops.fsm.handoff_handler import HandoffHandler
        from little_loops.fsm.signal_detector import DetectedSignal, SignalDetector

        fsm = FSMLoop(
            name="test",
            initial="work",
            states={
                "work": StateConfig(
                    action="echo CONTEXT_HANDOFF: prompt",
                    on_yes="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )

        mock_runner = MockActionRunner()
        mock_runner.set_result("echo", output="CONTEXT_HANDOFF: prompt")

        mock_detector = MagicMock(spec=SignalDetector)
        mock_detector.detect_first.return_value = DetectedSignal(
            signal_type="handoff",
            payload="prompt",
            raw_match="CONTEXT_HANDOFF: prompt",
        )

        mock_handler = MagicMock(spec=HandoffHandler)
        mock_handler_result = MagicMock()
        mock_handler_result.spawned_process = None
        mock_handler.handle.return_value = mock_handler_result

        executor = FSMExecutor(
            fsm,
            action_runner=mock_runner,
            signal_detector=mock_detector,
            handoff_handler=mock_handler,
        )
        result = executor.run()

        assert result.terminated_by == "handoff"
        mock_handler.handle.assert_called_once_with("test", "prompt")


class TestFatalErrorAndStopSignals:
    """Tests for FATAL_ERROR and LOOP_STOP signal handling in executor."""

    def test_fatal_error_signal_terminates_with_error(self) -> None:
        """FATAL_ERROR signal terminates executor with terminated_by='error'."""
        from little_loops.fsm.signal_detector import DetectedSignal, SignalDetector

        fsm = FSMLoop(
            name="test",
            initial="work",
            states={
                "work": StateConfig(
                    action="echo FATAL_ERROR: disk full",
                    on_yes="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )

        mock_runner = MockActionRunner()
        mock_runner.set_result("echo", output="FATAL_ERROR: disk full")

        mock_detector = MagicMock(spec=SignalDetector)
        mock_detector.detect_first.return_value = DetectedSignal(
            signal_type="error",
            payload="disk full",
            raw_match="FATAL_ERROR: disk full",
        )

        executor = FSMExecutor(
            fsm,
            action_runner=mock_runner,
            signal_detector=mock_detector,
        )
        result = executor.run()

        assert result.terminated_by == "error"
        assert result.error == "disk full"

    def test_fatal_error_signal_does_not_continue_to_next_state(self) -> None:
        """FATAL_ERROR stops execution before transitioning to next state."""
        from little_loops.fsm.signal_detector import DetectedSignal, SignalDetector

        fsm = FSMLoop(
            name="test",
            initial="work",
            states={
                "work": StateConfig(
                    action="echo FATAL_ERROR: abort",
                    on_yes="next",
                ),
                "next": StateConfig(
                    action="echo should not run",
                    on_yes="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )

        mock_runner = MockActionRunner()
        mock_runner.set_result("echo", output="FATAL_ERROR: abort")

        mock_detector = MagicMock(spec=SignalDetector)
        mock_detector.detect_first.return_value = DetectedSignal(
            signal_type="error",
            payload="abort",
            raw_match="FATAL_ERROR: abort",
        )

        executor = FSMExecutor(
            fsm,
            action_runner=mock_runner,
            signal_detector=mock_detector,
        )
        result = executor.run()

        assert result.terminated_by == "error"
        # Only the first action ran (one detect_first call)
        assert mock_detector.detect_first.call_count == 1

    def test_loop_stop_signal_requests_graceful_shutdown(self) -> None:
        """LOOP_STOP signal causes graceful shutdown (terminated_by='signal')."""
        from little_loops.fsm.signal_detector import DetectedSignal, SignalDetector

        fsm = FSMLoop(
            name="test",
            initial="work",
            states={
                "work": StateConfig(
                    action="echo LOOP_STOP: goal achieved",
                    on_yes="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )

        mock_runner = MockActionRunner()
        mock_runner.set_result("echo", output="LOOP_STOP: goal achieved")

        mock_detector = MagicMock(spec=SignalDetector)
        mock_detector.detect_first.return_value = DetectedSignal(
            signal_type="stop",
            payload="goal achieved",
            raw_match="LOOP_STOP: goal achieved",
        )

        executor = FSMExecutor(
            fsm,
            action_runner=mock_runner,
            signal_detector=mock_detector,
        )
        result = executor.run()

        assert result.terminated_by == "signal"


class TestRoutingEdgeCases:
    """Tests for routing edge cases in executor."""

    def test_route_with_error_verdict_uses_error_route(self) -> None:
        """Error verdict routes to error state when configured in route table."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="check.sh",
                    route=RouteConfig(
                        routes={"yes": "done"},
                        error="error_state",
                    ),
                ),
                "done": StateConfig(terminal=True),
                "error_state": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("check.sh", exit_code=2)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.final_state == "error_state"

    def test_route_with_default_fallback(self) -> None:
        """Unmatched verdict uses default route."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="check.sh",
                    route=RouteConfig(
                        routes={"yes": "done"},
                        default="fallback",
                    ),
                ),
                "done": StateConfig(terminal=True),
                "fallback": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("check.sh", exit_code=1)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.final_state == "fallback"

    def test_no_valid_transition_returns_error(self) -> None:
        """Returns error when no valid transition found."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            states={
                "check": StateConfig(
                    action="check.sh",
                    route=RouteConfig(
                        routes={"custom_verdict": "done"},
                    ),
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.set_result("check.sh", exit_code=1)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.terminated_by == "error"


class TestMaintainModeExecutor:
    """Tests for maintain mode in executor."""

    def test_maintain_mode_restarts_on_null_transition(self) -> None:
        """Maintain mode returns to initial when transition is null."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            max_iterations=3,
            maintain=True,
            states={
                "check": StateConfig(
                    action="check.sh",
                    on_yes="done",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        # Should loop back from terminal to initial via maintain
        assert result.terminated_by == "max_iterations"
        assert result.iterations == 3


class TestShutdownRequest:
    """Tests for graceful shutdown."""

    def test_shutdown_terminates_loop(self) -> None:
        """Shutdown request terminates the loop."""
        fsm = FSMLoop(
            name="test",
            initial="work",
            states={
                "work": StateConfig(
                    action="work.sh",
                    on_yes="work",
                ),
            },
        )
        mock_runner = MockActionRunner()

        executor = FSMExecutor(fsm, action_runner=mock_runner)
        executor.request_shutdown()
        result = executor.run()

        assert result.terminated_by == "signal"


class TestBackoff:
    """Tests for backoff sleep between iterations."""

    def test_backoff_sleep_called_between_iterations(self) -> None:
        """Executor sleeps for backoff duration after each iteration."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            backoff=0.01,  # tiny real value so deadline advances naturally
            max_iterations=2,
            states={
                "check": StateConfig(
                    action="check.sh",
                    on_yes="check",
                    on_no="check",
                ),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        sleep_calls: list[float] = []

        # Mock sleep to capture calls without blocking; let time.time() advance naturally
        with patch("little_loops.fsm.executor.time.sleep", side_effect=sleep_calls.append):
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            executor.run()

        assert len(sleep_calls) > 0, "Expected time.sleep to be called during backoff"

    def test_no_backoff_no_sleep(self) -> None:
        """Executor does not sleep when backoff is None."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            backoff=None,
            max_iterations=3,
            states={
                "check": StateConfig(
                    action="check.sh",
                    on_yes="done",
                    on_no="check",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        with patch("little_loops.fsm.executor.time.sleep") as mock_sleep:
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            executor.run()

        mock_sleep.assert_not_called()

    def test_zero_backoff_no_sleep(self) -> None:
        """Executor does not sleep when backoff is 0."""
        fsm = FSMLoop(
            name="test",
            initial="check",
            backoff=0.0,
            max_iterations=3,
            states={
                "check": StateConfig(
                    action="check.sh",
                    on_yes="done",
                    on_no="check",
                ),
                "done": StateConfig(terminal=True),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        with patch("little_loops.fsm.executor.time.sleep") as mock_sleep:
            executor = FSMExecutor(fsm, action_runner=mock_runner)
            executor.run()

        mock_sleep.assert_not_called()

    def test_shutdown_during_backoff_terminates_cleanly(self) -> None:
        """Shutdown request during backoff sleep stops the loop cleanly."""
        fsm = FSMLoop(
            name="test",
            initial="work",
            backoff=60.0,  # Large backoff so we'd block if not interruptible
            max_iterations=10,
            states={
                "work": StateConfig(
                    action="work.sh",
                    on_yes="work",
                    on_no="work",
                ),
            },
        )
        mock_runner = MockActionRunner()
        mock_runner.always_return(exit_code=0)

        executor = FSMExecutor(fsm, action_runner=mock_runner)

        real_time = __import__("time").time
        call_count = [0]

        def mock_time() -> float:
            call_count[0] += 1
            t = real_time()
            # After a few calls, trigger shutdown to simulate interrupt during backoff
            if call_count[0] > 5:
                executor.request_shutdown()
            return t

        with patch("little_loops.fsm.executor.time.time", side_effect=mock_time):
            with patch("little_loops.fsm.executor.time.sleep"):
                result = executor.run()

        assert result.terminated_by == "signal"


class TestDefaultActionRunnerProcessTracking:
    """Tests for DefaultActionRunner._current_process tracking (BUG-592)."""

    def _make_mock_process(self, output_lines: list[str], exit_code: int = 0) -> MagicMock:
        """Build a mock Popen process with the given stdout lines."""
        mock_process = MagicMock()
        mock_process.stdout = iter(output_lines)
        mock_process.returncode = exit_code
        mock_process.stderr.read.return_value = ""
        return mock_process

    def test_current_process_cleared_after_successful_run(self) -> None:
        """_current_process is None after run() completes normally."""
        runner = DefaultActionRunner()
        mock_process = self._make_mock_process(["output\n"])

        with patch("subprocess.Popen", return_value=mock_process):
            runner.run("echo hello", timeout=10, is_slash_command=False)

        assert runner._current_process is None

    def test_current_process_cleared_after_timeout(self) -> None:
        """_current_process is None even when action times out."""
        import subprocess as sp

        runner = DefaultActionRunner()
        mock_process = MagicMock()
        mock_process.stdout = iter([])
        mock_process.returncode = -9
        # First wait (with timeout) raises TimeoutExpired; second wait (bare) succeeds
        mock_process.wait.side_effect = [sp.TimeoutExpired("cmd", 1), None]

        with patch("subprocess.Popen", return_value=mock_process):
            result = runner.run("slow-cmd", timeout=1, is_slash_command=False)

        assert runner._current_process is None
        assert result.exit_code == 124

    def test_current_process_initially_none(self) -> None:
        """_current_process is None before any action is run."""
        runner = DefaultActionRunner()
        assert runner._current_process is None


class TestFSMExecutorProcessTracking:
    """Tests for FSMExecutor._current_process tracking (BUG-818)."""

    def _make_executor(self) -> FSMExecutor:
        fsm = FSMLoop(
            name="test",
            initial="start",
            states={"start": StateConfig(action="echo hello", terminal=True)},
        )
        return FSMExecutor(fsm)

    def test_current_process_initially_none(self) -> None:
        """_current_process is None before any subprocess is run."""
        executor = self._make_executor()
        assert executor._current_process is None

    def test_current_process_cleared_after_successful_run(self) -> None:
        """_current_process is None after _run_subprocess completes normally."""
        executor = self._make_executor()
        mock_process = MagicMock()
        mock_process.stdout = iter(["output\n"])
        mock_process.returncode = 0

        with patch("subprocess.Popen", return_value=mock_process):
            executor._run_subprocess(["echo", "hello"], timeout=10)

        assert executor._current_process is None

    def test_current_process_cleared_after_timeout(self) -> None:
        """_current_process is None even when _run_subprocess times out."""
        import subprocess as sp

        executor = self._make_executor()
        mock_process = MagicMock()
        mock_process.stdout = iter([])
        mock_process.returncode = -9
        mock_process.wait.side_effect = [sp.TimeoutExpired("cmd", 1), None]

        with patch("subprocess.Popen", return_value=mock_process):
            result = executor._run_subprocess(["slow-cmd"], timeout=1)

        assert executor._current_process is None
        assert result.exit_code == 124


class TestDefaultActionRunnerStderrDrain:
    """Tests for BUG-618: stderr pipe deadlock on large stderr output."""

    def test_large_stderr_does_not_deadlock(self) -> None:
        """Subprocess writing >64KB to stderr must not deadlock."""
        runner = DefaultActionRunner()
        # Write 128KB to stderr while also writing a small amount to stdout
        result = runner.run(
            'python3 -c "'
            "import sys; "
            "sys.stderr.write('e' * 131072); "
            "sys.stderr.flush(); "
            "print('done')\"",
            timeout=10,
            is_slash_command=False,
        )
        assert result.exit_code == 0
        assert "done" in result.output
        assert len(result.stderr) >= 131072

    def test_stderr_content_returned_on_timeout(self) -> None:
        """Actual stderr content is returned even when action times out."""
        runner = DefaultActionRunner()
        # Write stderr, close stdout explicitly so the parent's stdout loop exits,
        # then sleep past the timeout so process.wait() raises TimeoutExpired.
        result = runner.run(
            'python3 -c "'
            "import sys, os, time; "
            "sys.stderr.write('error content'); "
            "sys.stderr.flush(); "
            "os.close(sys.stdout.fileno()); "
            'time.sleep(10)"',
            timeout=1,
            is_slash_command=False,
        )
        assert result.exit_code == 124
        assert "error content" in result.stderr


class TestPerStateRetryLimits:
    """Tests for ENH-713: per-state retry limits (max_retries / on_retry_exhausted)."""

    def _make_fsm(
        self,
        max_retries: int,
        action_results: list[tuple[str, dict]],
    ) -> tuple[FSMLoop, MockActionRunner]:
        """Build a simple execute→skip_item→done FSM with max_retries on execute."""
        fsm = FSMLoop(
            name="retry-test",
            initial="execute",
            states={
                "execute": StateConfig(
                    action="do_work.sh",
                    on_no="execute",
                    on_yes="done",
                    max_retries=max_retries,
                    on_retry_exhausted="skip_item",
                ),
                "skip_item": StateConfig(action="echo skipped", next="done"),
                "done": StateConfig(terminal=True),
            },
        )
        runner = MockActionRunner()
        runner.results = action_results
        runner.use_indexed_order = True
        return fsm, runner

    def test_retry_exhausted_routes_to_on_retry_exhausted(self) -> None:
        """After max_retries+1 consecutive entries, executor routes to on_retry_exhausted."""
        # max_retries=2 → execute runs 3 times (initial + 2 retries), then skip_item
        fsm, runner = self._make_fsm(
            max_retries=2,
            action_results=[
                ("do_work.sh", {"exit_code": 1}),  # iter 1: failure → retry
                ("do_work.sh", {"exit_code": 1}),  # iter 2: failure → retry
                ("do_work.sh", {"exit_code": 1}),  # iter 3: failure → retry (exhausted next)
                ("echo skipped", {"exit_code": 0}),  # skip_item
            ],
        )
        executor = FSMExecutor(fsm, action_runner=runner)
        result = executor.run()

        assert result.final_state == "done"
        assert result.terminated_by == "terminal"
        # do_work.sh called exactly 3 times (initial + 2 retries)
        assert runner.calls.count("do_work.sh") == 3
        assert "echo skipped" in runner.calls

    def test_retry_succeeds_before_exhaustion(self) -> None:
        """If the state succeeds before max_retries, it routes normally (not to exhausted)."""
        fsm, runner = self._make_fsm(
            max_retries=5,
            action_results=[
                ("do_work.sh", {"exit_code": 1}),  # fail
                ("do_work.sh", {"exit_code": 1}),  # fail
                ("do_work.sh", {"exit_code": 0}),  # success → done (no skip)
            ],
        )
        executor = FSMExecutor(fsm, action_runner=runner)
        result = executor.run()

        assert result.final_state == "done"
        assert result.terminated_by == "terminal"
        assert runner.calls.count("do_work.sh") == 3
        assert "echo skipped" not in runner.calls

    def test_retry_counter_resets_on_different_state(self) -> None:
        """Retry counter for a state resets when a different state is entered between entries."""
        # execute fails → other_state → execute fails again. Since the counter
        # reset when other_state was entered, this should not trigger exhaustion.
        fsm = FSMLoop(
            name="reset-test",
            initial="execute",
            states={
                "execute": StateConfig(
                    action="do_work.sh",
                    on_no="other_state",
                    on_yes="done",
                    max_retries=1,
                    on_retry_exhausted="skip_item",
                ),
                "other_state": StateConfig(action="echo other", next="execute"),
                "skip_item": StateConfig(action="echo skipped", next="done"),
                "done": StateConfig(terminal=True),
            },
        )
        runner = MockActionRunner()
        # execute fails → other_state → execute fails → other_state → execute succeeds
        runner.results = [
            ("do_work.sh", {"exit_code": 1}),  # fail → other_state (counter not yet at max)
            ("echo other", {"exit_code": 0}),  # other_state → execute (resets execute counter)
            ("do_work.sh", {"exit_code": 1}),  # fail → other_state (counter reset, not exhausted)
            ("echo other", {"exit_code": 0}),  # other_state → execute
            ("do_work.sh", {"exit_code": 0}),  # success → done
        ]
        runner.use_indexed_order = True

        executor = FSMExecutor(fsm, action_runner=runner)
        result = executor.run()

        assert result.final_state == "done"
        assert result.terminated_by == "terminal"
        assert runner.calls.count("do_work.sh") == 3
        assert "echo skipped" not in runner.calls

    def test_retry_counter_increments_on_consecutive_reentry(self) -> None:
        """Verify consecutive re-entries increment the counter as expected."""
        # max_retries=1 → execute runs at most 2 times (initial + 1 retry)
        fsm, runner = self._make_fsm(
            max_retries=1,
            action_results=[
                ("do_work.sh", {"exit_code": 1}),  # fail (count=0 → retry allowed)
                ("do_work.sh", {"exit_code": 1}),  # fail (count=1 → retry allowed, last)
                ("do_work.sh", {"exit_code": 1}),  # would be fail (count=2 > 1 → exhausted)
                ("echo skipped", {"exit_code": 0}),
            ],
        )
        executor = FSMExecutor(fsm, action_runner=runner)
        result = executor.run()

        assert result.final_state == "done"
        assert result.terminated_by == "terminal"
        # With max_retries=1: execute runs 2 times (initial + 1 retry), then exhausted
        assert runner.calls.count("do_work.sh") == 2
        assert "echo skipped" in runner.calls

    def test_retry_exhausted_event_emitted(self) -> None:
        """retry_exhausted event is emitted with correct state, retries, and next."""
        emitted_events: list[dict] = []

        def capture_event(event: dict) -> None:
            emitted_events.append(event)

        fsm, runner = self._make_fsm(
            max_retries=1,
            action_results=[
                ("do_work.sh", {"exit_code": 1}),
                ("do_work.sh", {"exit_code": 1}),
                ("echo skipped", {"exit_code": 0}),
            ],
        )
        executor = FSMExecutor(fsm, action_runner=runner, event_callback=capture_event)
        executor.run()

        retry_events = [e for e in emitted_events if e.get("event") == "retry_exhausted"]
        assert len(retry_events) == 1
        evt = retry_events[0]
        assert evt["state"] == "execute"
        assert evt["next"] == "skip_item"
        assert evt["retries"] > 0

    def test_retry_counts_preserved_in_loop_state(self) -> None:
        """_retry_counts values are accessible on the executor after execution."""
        fsm, runner = self._make_fsm(
            max_retries=3,
            action_results=[
                ("do_work.sh", {"exit_code": 1}),  # count → 1
                ("do_work.sh", {"exit_code": 1}),  # count → 2
                ("do_work.sh", {"exit_code": 0}),  # success → done (counter NOT reset here)
            ],
        )
        executor = FSMExecutor(fsm, action_runner=runner)
        executor.run()

        # After success transition to done, execute's counter stays in _retry_counts
        # (it resets only when a DIFFERENT state is processed next iteration)
        # Just verify the executor ran without error and reached terminal state
        assert executor.current_state == "done"


class TestInterpolationErrorHandling:
    """Tests for friendly InterpolationError messages in the executor."""

    def test_missing_context_variable_produces_friendly_message(self) -> None:
        """InterpolationError from a missing context var yields a clear error, not a raw traceback."""
        fsm = FSMLoop(
            name="general-task",
            initial="execute",
            states={
                "execute": StateConfig(
                    action="${context.input}",
                    next="done",
                ),
                "done": StateConfig(terminal=True),
                "failed": StateConfig(terminal=True),
            },
        )
        # No context provided — context.input is missing
        mock_runner = MockActionRunner()
        executor = FSMExecutor(fsm, action_runner=mock_runner)
        result = executor.run()

        assert result.terminated_by == "error"
        assert result.error is not None
        assert "context" in result.error.lower() or "missing" in result.error.lower()
        assert "input" in result.error
        # Should not be a raw Python exception message like "Path 'input' not found in context"
        assert "--context" in result.error or "ll-loop run" in result.error


class TestDefaultTimeout:
    """Tests for loop-level default_timeout fallback chain."""

    @dataclass
    class TimeoutCapturingRunner:
        """Action runner that records the timeout passed to run()."""

        captured_timeouts: list[int] = field(default_factory=list)

        def run(
            self,
            action: str,
            timeout: int,
            is_slash_command: bool,
            on_output_line: Any = None,
        ) -> ActionResult:
            self.captured_timeouts.append(timeout)
            return ActionResult(output="ok", stderr="", exit_code=0, duration_ms=10)

    def _make_fsm(
        self, state_timeout: int | None = None, default_timeout: int | None = None
    ) -> FSMLoop:
        """Build a minimal FSMLoop with a single prompt state."""
        state_kwargs: dict[str, Any] = {
            "action_type": "prompt",
            "action": "echo hi",
            "next": "done",
        }
        if state_timeout is not None:
            state_kwargs["timeout"] = state_timeout
        return FSMLoop(
            name="test",
            initial="work",
            default_timeout=default_timeout,
            states={
                "work": StateConfig(**state_kwargs),
                "done": StateConfig(terminal=True),
            },
        )

    def test_state_timeout_used_when_set(self) -> None:
        """Per-state timeout takes precedence over default_timeout."""
        fsm = self._make_fsm(state_timeout=300, default_timeout=3600)
        runner = self.TimeoutCapturingRunner()
        FSMExecutor(fsm, action_runner=runner).run()
        assert runner.captured_timeouts == [300]

    def test_default_timeout_used_when_state_has_none(self) -> None:
        """default_timeout is used when state has no timeout."""
        fsm = self._make_fsm(state_timeout=None, default_timeout=600)
        runner = self.TimeoutCapturingRunner()
        FSMExecutor(fsm, action_runner=runner).run()
        assert runner.captured_timeouts == [600]

    def test_hardcoded_fallback_when_neither_set(self) -> None:
        """Hardcoded 3600s fallback applies when neither state nor loop timeout is set."""
        fsm = self._make_fsm(state_timeout=None, default_timeout=None)
        runner = self.TimeoutCapturingRunner()
        FSMExecutor(fsm, action_runner=runner).run()
        assert runner.captured_timeouts == [3600]


class TestSubLoopExecution:
    """Tests for hierarchical FSM sub-loop execution (FEAT-659)."""

    def test_sub_loop_success_routes_to_on_success(self, tmp_path: Path) -> None:
        """Sub-loop that succeeds routes parent to on_yes state."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        (loops_dir / "child.yaml").write_text(
            "name: child\ninitial: done\nstates:\n  done:\n    terminal: true"
        )
        parent_fsm = FSMLoop(
            name="parent",
            initial="run_child",
            states={
                "run_child": StateConfig(loop="child", on_yes="success", on_no="fail"),
                "success": StateConfig(terminal=True),
                "fail": StateConfig(terminal=True),
            },
        )
        executor = FSMExecutor(parent_fsm, loops_dir=loops_dir)
        result = executor.run()
        assert result.final_state == "success"
        assert result.terminated_by == "terminal"

    def test_sub_loop_failure_routes_to_on_failure(self, tmp_path: Path) -> None:
        """Sub-loop that fails routes parent to on_no state."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        # Child loop that always fails by hitting max_iterations
        (loops_dir / "failing.yaml").write_text(
            "name: failing\ninitial: loop\nmax_iterations: 1\nstates:\n"
            "  loop:\n    action: 'false'\n    on_yes: done\n    on_no: loop\n"
            "  done:\n    terminal: true"
        )
        parent_fsm = FSMLoop(
            name="parent",
            initial="run_child",
            states={
                "run_child": StateConfig(loop="failing", on_yes="success", on_no="failed"),
                "success": StateConfig(terminal=True),
                "failed": StateConfig(terminal=True),
            },
        )
        executor = FSMExecutor(parent_fsm, loops_dir=loops_dir)
        result = executor.run()
        assert result.final_state == "failed"

    def test_sub_loop_context_passthrough(self, tmp_path: Path) -> None:
        """Parent context is passed to child when context_passthrough is True."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        # Child loop that captures a variable using parent's context
        (loops_dir / "echo-child.yaml").write_text(
            "name: echo-child\ninitial: step\nstates:\n"
            "  step:\n"
            "    action: 'echo ${context.greeting}'\n"
            "    capture: child_out\n"
            "    next: done\n"
            "  done:\n    terminal: true"
        )
        parent_fsm = FSMLoop(
            name="parent",
            initial="run_child",
            context={"greeting": "hello"},
            states={
                "run_child": StateConfig(
                    loop="echo-child",
                    context_passthrough=True,
                    on_yes="success",
                    on_no="fail",
                ),
                "success": StateConfig(terminal=True),
                "fail": StateConfig(terminal=True),
            },
        )
        executor = FSMExecutor(parent_fsm, loops_dir=loops_dir)
        result = executor.run()
        assert result.final_state == "success"
        # Child captures should be merged back under the state name
        assert "run_child" in executor.captured

    def test_sub_loop_missing_loop_with_on_error(self, tmp_path: Path) -> None:
        """Missing child loop routes to on_error when set."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        parent_fsm = FSMLoop(
            name="parent",
            initial="run_child",
            states={
                "run_child": StateConfig(
                    loop="nonexistent",
                    on_yes="success",
                    on_no="fail",
                    on_error="error_state",
                ),
                "success": StateConfig(terminal=True),
                "fail": StateConfig(terminal=True),
                "error_state": StateConfig(terminal=True),
            },
        )
        executor = FSMExecutor(parent_fsm, loops_dir=loops_dir)
        result = executor.run()
        assert result.final_state == "error_state"

    def test_sub_loop_missing_loop_without_on_error(self, tmp_path: Path) -> None:
        """Missing child loop finishes with error when no on_error set."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        parent_fsm = FSMLoop(
            name="parent",
            initial="run_child",
            states={
                "run_child": StateConfig(
                    loop="nonexistent",
                    on_yes="success",
                    on_no="fail",
                ),
                "success": StateConfig(terminal=True),
                "fail": StateConfig(terminal=True),
            },
        )
        executor = FSMExecutor(parent_fsm, loops_dir=loops_dir)
        result = executor.run()
        assert result.terminated_by == "error"

    def test_sub_loop_without_action_runs_child(self, tmp_path: Path) -> None:
        """Sub-loop state without action runs the child loop directly."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        (loops_dir / "simple.yaml").write_text(
            "name: simple\ninitial: done\nstates:\n  done:\n    terminal: true"
        )
        mock_runner = MockActionRunner()
        parent_fsm = FSMLoop(
            name="parent",
            initial="run_child",
            states={
                "run_child": StateConfig(loop="simple", on_yes="done"),
                "done": StateConfig(terminal=True),
            },
        )
        executor = FSMExecutor(parent_fsm, action_runner=mock_runner, loops_dir=loops_dir)
        result = executor.run()
        assert result.final_state == "done"
        # MockActionRunner should NOT have been called (sub-loop doesn't use action_runner)
        assert len(mock_runner.calls) == 0

    def test_sub_loop_events_forwarded_to_parent_callback(self, tmp_path: Path) -> None:
        """Child executor events are forwarded to parent event_callback with depth=1."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        # Child has a non-terminal initial state so state_enter is emitted
        (loops_dir / "child.yaml").write_text(
            "name: child\ninitial: step\nstates:\n"
            "  step:\n    next: done\n"
            "  done:\n    terminal: true"
        )
        parent_fsm = FSMLoop(
            name="parent",
            initial="run_child",
            states={
                "run_child": StateConfig(loop="child", on_yes="success", on_no="fail"),
                "success": StateConfig(terminal=True),
                "fail": StateConfig(terminal=True),
            },
        )
        events: list[dict[str, Any]] = []
        executor = FSMExecutor(parent_fsm, loops_dir=loops_dir, event_callback=events.append)
        executor.run()
        child_events = [e for e in events if e.get("depth") == 1]
        assert len(child_events) > 0, "Expected child events forwarded with depth=1"
        event_types = [e["event"] for e in child_events]
        assert "state_enter" in event_types

    def test_sub_loop_depth_propagates_to_nested_sub_loops(self, tmp_path: Path) -> None:
        """Nested sub-loops increment depth at each level (depth=2 for grandchild)."""
        loops_dir = tmp_path / ".loops"
        loops_dir.mkdir()
        # grandchild: has a non-terminal initial state so state_enter is emitted
        (loops_dir / "grandchild.yaml").write_text(
            "name: grandchild\ninitial: step\nstates:\n"
            "  step:\n    next: done\n"
            "  done:\n    terminal: true"
        )
        # child: delegates to grandchild
        (loops_dir / "child.yaml").write_text(
            "name: child\ninitial: run_grandchild\nstates:\n"
            "  run_grandchild:\n    loop: grandchild\n    on_yes: done\n    on_no: done\n"
            "  done:\n    terminal: true"
        )
        parent_fsm = FSMLoop(
            name="parent",
            initial="run_child",
            states={
                "run_child": StateConfig(loop="child", on_yes="success", on_no="fail"),
                "success": StateConfig(terminal=True),
                "fail": StateConfig(terminal=True),
            },
        )
        events: list[dict[str, Any]] = []
        executor = FSMExecutor(parent_fsm, loops_dir=loops_dir, event_callback=events.append)
        executor.run()
        depth2_events = [e for e in events if e.get("depth") == 2]
        assert len(depth2_events) > 0, "Expected grandchild events forwarded with depth=2"
