"""Tests for timmy.agentic_loop — multi-step task execution engine."""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from timmy.agentic_loop import (
    AgenticResult,
    AgenticStep,
    _parse_steps,
)

# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------


class TestAgenticStep:
    """Unit tests for the AgenticStep dataclass."""

    def test_creation(self):
        step = AgenticStep(
            step_num=1,
            description="Do thing",
            result="Done",
            status="completed",
            duration_ms=42,
        )
        assert step.step_num == 1
        assert step.description == "Do thing"
        assert step.result == "Done"
        assert step.status == "completed"
        assert step.duration_ms == 42

    def test_failed_status(self):
        step = AgenticStep(
            step_num=2, description="Bad step", result="Error", status="failed", duration_ms=10
        )
        assert step.status == "failed"

    def test_adapted_status(self):
        step = AgenticStep(
            step_num=3, description="Retried", result="OK", status="adapted", duration_ms=100
        )
        assert step.status == "adapted"


class TestAgenticResult:
    """Unit tests for the AgenticResult dataclass."""

    def test_defaults(self):
        result = AgenticResult(task_id="abc", task="Test", summary="Done")
        assert result.steps == []
        assert result.status == "completed"
        assert result.total_duration_ms == 0

    def test_with_steps(self):
        s = AgenticStep(step_num=1, description="A", result="B", status="completed", duration_ms=5)
        result = AgenticResult(task_id="x", task="T", summary="S", steps=[s])
        assert len(result.steps) == 1


# ---------------------------------------------------------------------------
# _parse_steps — pure function, highly testable
# ---------------------------------------------------------------------------


class TestParseSteps:
    """Unit tests for the plan parser."""

    def test_numbered_with_dots(self):
        text = "1. First step\n2. Second step\n3. Third step"
        steps = _parse_steps(text)
        assert steps == ["First step", "Second step", "Third step"]

    def test_numbered_with_parens(self):
        text = "1) Do this\n2) Do that"
        steps = _parse_steps(text)
        assert steps == ["Do this", "Do that"]

    def test_mixed_numbering(self):
        text = "1. Step one\n2) Step two\n3. Step three"
        steps = _parse_steps(text)
        assert len(steps) == 3

    def test_indented_steps(self):
        text = "  1. Indented step\n  2. Also indented"
        steps = _parse_steps(text)
        assert len(steps) == 2
        assert steps[0] == "Indented step"

    def test_no_numbered_steps_fallback(self):
        text = "Do this first\nThen do that\nFinally wrap up"
        steps = _parse_steps(text)
        assert len(steps) == 3
        assert steps[0] == "Do this first"

    def test_empty_string(self):
        steps = _parse_steps("")
        assert steps == []

    def test_blank_lines_ignored_in_fallback(self):
        text = "Step A\n\n\nStep B\n"
        steps = _parse_steps(text)
        assert steps == ["Step A", "Step B"]

    def test_strips_whitespace(self):
        text = "1.  Lots of space  \n2.  Also spaced  "
        steps = _parse_steps(text)
        assert steps[0] == "Lots of space"
        assert steps[1] == "Also spaced"

    def test_preamble_ignored_when_numbered(self):
        text = "Here is the plan:\n1. Step one\n2. Step two"
        steps = _parse_steps(text)
        assert steps == ["Step one", "Step two"]


# ---------------------------------------------------------------------------
# _get_loop_agent — singleton pattern
# ---------------------------------------------------------------------------


class TestGetLoopAgent:
    """Tests for the agent singleton."""

    def test_creates_agent_once(self):
        import timmy.agentic_loop as mod

        mod._loop_agent = None
        mock_agent = MagicMock()
        with patch("timmy.agent.create_timmy", return_value=mock_agent) as mock_create:
            agent = mod._get_loop_agent()
            assert agent is mock_agent
            mock_create.assert_called_once()

            # Second call should reuse singleton
            agent2 = mod._get_loop_agent()
            assert agent2 is mock_agent
            mock_create.assert_called_once()

        mod._loop_agent = None  # cleanup

    def test_reuses_existing(self):
        import timmy.agentic_loop as mod

        sentinel = MagicMock()
        mod._loop_agent = sentinel
        assert mod._get_loop_agent() is sentinel
        mod._loop_agent = None  # cleanup


# ---------------------------------------------------------------------------
# _broadcast_progress — best-effort WebSocket broadcast
# ---------------------------------------------------------------------------


class TestBroadcastProgress:
    """Tests for the WebSocket broadcast helper."""

    @pytest.mark.asyncio
    async def test_successful_broadcast(self):
        from timmy.agentic_loop import _broadcast_progress

        mock_ws = MagicMock()
        mock_ws.broadcast = AsyncMock()
        mock_module = MagicMock()
        mock_module.ws_manager = mock_ws
        with patch.dict("sys.modules", {"infrastructure.ws_manager.handler": mock_module}):
            await _broadcast_progress("test.event", {"key": "value"})
            mock_ws.broadcast.assert_awaited_once_with("test.event", {"key": "value"})

    @pytest.mark.asyncio
    async def test_import_error_swallowed(self):
        """When ws_manager import fails, broadcast silently succeeds."""
        import sys

        from timmy.agentic_loop import _broadcast_progress

        # Remove the module so import fails
        saved = sys.modules.pop("infrastructure.ws_manager.handler", None)
        try:
            with patch.dict("sys.modules", {"infrastructure": None}):
                # Should not raise — errors are swallowed
                await _broadcast_progress("fail.event", {})
        finally:
            if saved is not None:
                sys.modules["infrastructure.ws_manager.handler"] = saved


# ---------------------------------------------------------------------------
# run_agentic_loop — integration-style tests with mocked agent
# ---------------------------------------------------------------------------


class TestRunAgenticLoop:
    """Tests for the main agentic loop."""

    @pytest.fixture(autouse=True)
    def _reset_agent(self):
        import timmy.agentic_loop as mod

        mod._loop_agent = None
        yield
        mod._loop_agent = None

    def _mock_agent(self, responses):
        """Create a mock agent that returns responses in sequence."""
        agent = MagicMock()
        run_results = []
        for r in responses:
            mock_result = MagicMock()
            mock_result.content = r
            run_results.append(mock_result)
        agent.run = MagicMock(side_effect=run_results)
        return agent

    @pytest.mark.asyncio
    async def test_successful_two_step_task(self):
        from timmy.agentic_loop import run_agentic_loop

        agent = self._mock_agent(
            [
                "1. Step one\n2. Step two",  # planning
                "Step one done",  # execution step 1
                "Step two done",  # execution step 2
            ]
        )

        with (
            patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
            patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
            patch("timmy.session._clean_response", side_effect=lambda x: x),
        ):
            result = await run_agentic_loop("Test task", max_steps=5)

        assert result.status == "completed"
        assert len(result.steps) == 2
        assert result.steps[0].status == "completed"
        assert result.steps[1].status == "completed"
        assert result.total_duration_ms >= 0

    @pytest.mark.asyncio
    async def test_planning_failure(self):
        from timmy.agentic_loop import run_agentic_loop

        agent = MagicMock()
        agent.run = MagicMock(side_effect=RuntimeError("LLM down"))

        with (
            patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
            patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
        ):
            result = await run_agentic_loop("Broken task", max_steps=3)

        assert result.status == "failed"
        assert "Planning failed" in result.summary

    @pytest.mark.asyncio
    async def test_empty_plan(self):
        from timmy.agentic_loop import run_agentic_loop

        agent = self._mock_agent([""])  # empty plan

        with (
            patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
            patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
        ):
            result = await run_agentic_loop("Empty plan task", max_steps=3)

        assert result.status == "failed"
        assert "no steps" in result.summary.lower()

    @pytest.mark.asyncio
    async def test_step_failure_triggers_adaptation(self):
        from timmy.agentic_loop import run_agentic_loop

        agent = MagicMock()
        call_count = 0

        def mock_run(prompt, **kwargs):
            nonlocal call_count
            call_count += 1
            result = MagicMock()
            if call_count == 1:
                result.content = "1. Only step"
            elif call_count == 2:
                raise RuntimeError("Step failed")
            else:
                result.content = "Adapted successfully"
            return result

        agent.run = mock_run

        with (
            patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
            patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
            patch("timmy.session._clean_response", side_effect=lambda x: x),
        ):
            result = await run_agentic_loop("Failing task", max_steps=5)

        assert len(result.steps) == 1
        assert result.steps[0].status == "adapted"
        assert "[Adapted]" in result.steps[0].description

    @pytest.mark.asyncio
    async def test_max_steps_truncation(self):
        from timmy.agentic_loop import run_agentic_loop

        agent = self._mock_agent(
            [
                "1. A\n2. B\n3. C\n4. D\n5. E",  # 5 steps planned
                "Done A",
                "Done B",
            ]
        )

        with (
            patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
            patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
            patch("timmy.session._clean_response", side_effect=lambda x: x),
        ):
            result = await run_agentic_loop("Big task", max_steps=2)

        assert result.status == "partial"  # was truncated
        assert len(result.steps) == 2

    @pytest.mark.asyncio
    async def test_on_progress_callback(self):
        from timmy.agentic_loop import run_agentic_loop

        agent = self._mock_agent(
            [
                "1. Only step",
                "Step done",
            ]
        )
        progress_calls = []

        async def track_progress(desc, step_num, total):
            progress_calls.append((desc, step_num, total))

        with (
            patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
            patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
            patch("timmy.session._clean_response", side_effect=lambda x: x),
        ):
            await run_agentic_loop("Callback task", max_steps=5, on_progress=track_progress)

        assert len(progress_calls) == 1
        assert progress_calls[0][1] == 1  # step_num

    @pytest.mark.asyncio
    async def test_default_max_steps_from_settings(self):
        from timmy.agentic_loop import run_agentic_loop

        agent = self._mock_agent(["1. Step one", "Done"])

        mock_settings = MagicMock()
        mock_settings.max_agent_steps = 7

        with (
            patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
            patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
            patch("timmy.session._clean_response", side_effect=lambda x: x),
            patch("config.settings", mock_settings),
        ):
            result = await run_agentic_loop("Settings task")

        assert result.status == "completed"

    @pytest.mark.asyncio
    async def test_task_id_generated(self):
        from timmy.agentic_loop import run_agentic_loop

        agent = self._mock_agent(["1. Step", "OK"])

        with (
            patch("timmy.agentic_loop._get_loop_agent", return_value=agent),
            patch("timmy.agentic_loop._broadcast_progress", new_callable=AsyncMock),
            patch("timmy.session._clean_response", side_effect=lambda x: x),
        ):
            result = await run_agentic_loop("ID task", max_steps=5)

        assert result.task_id  # non-empty
        assert len(result.task_id) == 8  # uuid[:8]
