"""Unit tests for AntiStallMonitor."""

from unittest.mock import AsyncMock, patch

import pytest

from navi.core.anti_stall import AntiStallMonitor
from navi.llm.base import Message, ToolCallRequest
from tests.conftest_factory import make_profile


class TestPreTurn:
    @pytest.mark.asyncio
    async def test_no_message_when_not_stalled(self):
        profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
        monitor = AntiStallMonitor(profile)
        result = await monitor.pre_turn("s1", iteration=1)
        assert result is None

    @pytest.mark.asyncio
    async def test_anti_stall_no_todo(self):
        profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=3)
        monitor = AntiStallMonitor(profile)
        monitor.stall_no_todo = 3
        msg = await monitor.pre_turn("s1", iteration=1)
        assert msg is not None
        assert msg.role == "system"
        assert "no todo progress for 3 iterations" in msg.content
        assert "Anti-stall warning" in msg.content

    @pytest.mark.asyncio
    async def test_anti_stall_repeat_tools(self):
        profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=3)
        monitor = AntiStallMonitor(profile)
        monitor.stall_repeat_tools = 3
        msg = await monitor.pre_turn("s1", iteration=1)
        assert msg is not None
        assert "identical tool calls repeated 3 times" in msg.content

    @pytest.mark.asyncio
    async def test_anti_stall_skipped_on_first_iteration(self):
        profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=1)
        monitor = AntiStallMonitor(profile)
        monitor.stall_no_todo = 10
        msg = await monitor.pre_turn("s1", iteration=0)
        assert msg is None

    @pytest.mark.asyncio
    async def test_adaptive_replan_injected_before_anti_stall(self):
        """When both replan_msg and stall are queued, replan takes precedence."""
        profile = make_profile(
            anti_stall_enabled=True,
            anti_stall_threshold=1,
            adaptive_replan_enabled=True,
        )
        monitor = AntiStallMonitor(profile)
        monitor.replan_msg = "Please replan."
        monitor.stall_no_todo = 5
        msg = await monitor.pre_turn("s1", iteration=1)
        assert msg is not None
        assert "Please replan." in msg.content
        assert monitor.replan_msg is None  # consumed

    @pytest.mark.asyncio
    async def test_disabled_anti_stall_returns_none(self):
        profile = make_profile(anti_stall_enabled=False, adaptive_replan_enabled=False)
        monitor = AntiStallMonitor(profile)
        monitor.stall_no_todo = 100
        msg = await monitor.pre_turn("s1", iteration=5)
        assert msg is None


class TestPostTurn:
    @pytest.mark.asyncio
    async def test_todo_progress_resets_stall_counter(self):
        profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
        monitor = AntiStallMonitor(profile)
        monitor.stall_no_todo = 5
        monitor._todo_snapshot = frozenset({("task1", "pending")})
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=frozenset({("task1", "done")})),
        ):
            await monitor.post_turn("s1", [])
        assert monitor.stall_no_todo == 0

    @pytest.mark.asyncio
    async def test_no_todo_progress_increments_stall_counter(self):
        profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
        monitor = AntiStallMonitor(profile)
        snapshot = frozenset({("task1", "pending")})
        monitor._todo_snapshot = snapshot
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=snapshot),
        ):
            await monitor.post_turn("s1", [])
        assert monitor.stall_no_todo == 1

    @pytest.mark.asyncio
    async def test_repeated_tool_calls_increment_repeat_counter(self):
        profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
        monitor = AntiStallMonitor(profile)
        tc = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"})
        # first call sets baseline
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=frozenset()),
        ):
            await monitor.post_turn("s1", [tc])
        assert monitor.stall_repeat_tools == 0
        # identical call again
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=frozenset()),
        ):
            await monitor.post_turn("s1", [tc])
        assert monitor.stall_repeat_tools == 1

    @pytest.mark.asyncio
    async def test_different_tool_calls_reset_repeat_counter(self):
        profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
        monitor = AntiStallMonitor(profile)
        tc1 = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"})
        tc2 = ToolCallRequest(id="2", name="fs", arguments={"path": "/other"})
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=frozenset()),
        ):
            await monitor.post_turn("s1", [tc1])
            await monitor.post_turn("s1", [tc2])
        assert monitor.stall_repeat_tools == 0

    @pytest.mark.asyncio
    async def test_adaptive_replan_queues_message(self):
        profile = make_profile(adaptive_replan_enabled=True)
        monitor = AntiStallMonitor(profile)
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=frozenset()),
        ), patch(
            "navi.tools.todo.get_failed_steps",
            new=AsyncMock(return_value=frozenset({(1, "step A")})),
        ):
            await monitor.post_turn("s1", [])
        assert monitor.replan_msg is not None
        assert "step 1" in monitor.replan_msg
        assert "step A" in monitor.replan_msg

    @pytest.mark.asyncio
    async def test_adaptive_replan_no_new_failures(self):
        profile = make_profile(adaptive_replan_enabled=True)
        monitor = AntiStallMonitor(profile)
        monitor.known_failed = frozenset({(1, "step A")})
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=frozenset()),
        ), patch(
            "navi.tools.todo.get_failed_steps",
            new=AsyncMock(return_value=frozenset({(1, "step A")})),
        ):
            await monitor.post_turn("s1", [])
        assert monitor.replan_msg is None

    @pytest.mark.asyncio
    async def test_long_step_nudge_queued_at_threshold(self):
        """adaptive_replan + a step in_progress for ``adaptive_long_step_threshold``
        iterations (non-repeating tool calls, no new failures) → nudge the model
        to split the step. Fires earlier than the general anti-stall warning."""
        profile = make_profile(
            anti_stall_enabled=True,
            anti_stall_threshold=8,
            adaptive_replan_enabled=True,
            adaptive_long_step_threshold=4,
        )
        monitor = AntiStallMonitor(profile)
        snapshot = frozenset({("task1", "in_progress")})
        monitor._todo_snapshot = snapshot
        monitor.stall_no_todo = 3  # one more no-progress iteration -> 4 == threshold
        tc = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"})
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=snapshot),
        ), patch(
            "navi.tools.todo.get_failed_steps",
            new=AsyncMock(return_value=frozenset()),
        ):
            await monitor.post_turn("s1", [tc])
        assert monitor.replan_msg is not None
        assert "split it now" in monitor.replan_msg
        assert "Adaptive re-plan" in monitor.replan_msg

    @pytest.mark.asyncio
    async def test_long_step_silent_below_threshold(self):
        """Below the long-step threshold, no nudge is queued."""
        profile = make_profile(
            anti_stall_enabled=True,
            adaptive_replan_enabled=True,
            adaptive_long_step_threshold=4,
        )
        monitor = AntiStallMonitor(profile)
        snapshot = frozenset({("task1", "in_progress")})
        monitor._todo_snapshot = snapshot
        monitor.stall_no_todo = 1  # -> 2, below threshold 4
        tc = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"})
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=snapshot),
        ), patch(
            "navi.tools.todo.get_failed_steps",
            new=AsyncMock(return_value=frozenset()),
        ):
            await monitor.post_turn("s1", [tc])
        assert monitor.replan_msg is None

    @pytest.mark.asyncio
    async def test_long_step_silent_when_model_silent(self):
        """No tool calls this turn (model produced text only) → the long-step
        nudge stays silent; the anti-stall path handles a genuinely stuck run."""
        profile = make_profile(
            anti_stall_enabled=True,
            adaptive_replan_enabled=True,
            adaptive_long_step_threshold=4,
        )
        monitor = AntiStallMonitor(profile)
        snapshot = frozenset({("task1", "in_progress")})
        monitor._todo_snapshot = snapshot
        monitor.stall_no_todo = 3  # -> 4 == threshold, but no tool calls
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=snapshot),
        ), patch(
            "navi.tools.todo.get_failed_steps",
            new=AsyncMock(return_value=frozenset()),
        ):
            await monitor.post_turn("s1", [])
        assert monitor.replan_msg is None

    @pytest.mark.asyncio
    async def test_long_step_failed_takes_precedence(self):
        """New failures take precedence over the long-step nudge."""
        profile = make_profile(
            anti_stall_enabled=True,
            adaptive_replan_enabled=True,
            adaptive_long_step_threshold=4,
        )
        monitor = AntiStallMonitor(profile)
        snapshot = frozenset({("task1", "in_progress")})
        monitor._todo_snapshot = snapshot
        monitor.stall_no_todo = 3  # -> 4 == threshold
        tc = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"})
        with patch(
            "navi.tools.todo.get_task_snapshot",
            new=AsyncMock(return_value=snapshot),
        ), patch(
            "navi.tools.todo.get_failed_steps",
            new=AsyncMock(return_value=frozenset({(2, "step B")})),
        ):
            await monitor.post_turn("s1", [tc])
        assert monitor.replan_msg is not None
        assert "just failed" in monitor.replan_msg
        assert "split it now" not in monitor.replan_msg

    @pytest.mark.asyncio
    async def test_disabled_anti_stall_does_not_fetch_snapshot(self):
        profile = make_profile(anti_stall_enabled=False, adaptive_replan_enabled=False)
        monitor = AntiStallMonitor(profile)
        with patch("navi.tools.todo.get_task_snapshot") as mock_snap:
            await monitor.post_turn("s1", [])
        mock_snap.assert_not_called()
