"""Tests for Heartbeat v2 — WorldInterface-driven cognitive loop.

Acceptance criteria:
- With MockWorldAdapter: heartbeat runs, logs show observe→reason→act→reflect
- Without adapter: existing think_once() behaviour unchanged
- WebSocket broadcasts include current action and reasoning summary
"""

from unittest.mock import AsyncMock, patch

import pytest

from infrastructure.world.adapters.mock import MockWorldAdapter
from infrastructure.world.types import ActionStatus
from loop.heartbeat import CycleRecord, Heartbeat


@pytest.fixture
def mock_adapter():
    adapter = MockWorldAdapter(
        location="Balmora",
        entities=["Guard", "Merchant"],
        events=["player_entered"],
    )
    adapter.connect()
    return adapter


class TestHeartbeatWithAdapter:
    """With MockWorldAdapter: heartbeat runs full embodied cycle."""

    @pytest.mark.asyncio
    async def test_run_once_returns_cycle_record(self, mock_adapter):
        hb = Heartbeat(world=mock_adapter)
        record = await hb.run_once()
        assert isinstance(record, CycleRecord)
        assert record.cycle_id == 1

    @pytest.mark.asyncio
    async def test_observation_populated(self, mock_adapter):
        hb = Heartbeat(world=mock_adapter)
        record = await hb.run_once()
        assert record.observation["location"] == "Balmora"
        assert "Guard" in record.observation["entities"]
        assert "player_entered" in record.observation["events"]

    @pytest.mark.asyncio
    async def test_action_dispatched_to_world(self, mock_adapter):
        """Act phase should dispatch to world.act() for non-idle actions."""
        hb = Heartbeat(world=mock_adapter)
        record = await hb.run_once()
        # The default loop phases don't set an explicit action, so it
        # falls through to "idle" → NOOP. That's correct behaviour —
        # the real LLM-powered reason phase will set action metadata.
        assert record.action_status in (
            ActionStatus.NOOP.value,
            ActionStatus.SUCCESS.value,
        )

    @pytest.mark.asyncio
    async def test_reflect_notes_present(self, mock_adapter):
        hb = Heartbeat(world=mock_adapter)
        record = await hb.run_once()
        assert "Balmora" in record.reflect_notes

    @pytest.mark.asyncio
    async def test_cycle_count_increments(self, mock_adapter):
        hb = Heartbeat(world=mock_adapter)
        await hb.run_once()
        await hb.run_once()
        assert hb.cycle_count == 2
        assert len(hb.history) == 2

    @pytest.mark.asyncio
    async def test_duration_recorded(self, mock_adapter):
        hb = Heartbeat(world=mock_adapter)
        record = await hb.run_once()
        assert record.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_on_cycle_callback(self, mock_adapter):
        received = []

        async def callback(record):
            received.append(record)

        hb = Heartbeat(world=mock_adapter, on_cycle=callback)
        await hb.run_once()
        assert len(received) == 1
        assert received[0].cycle_id == 1


class TestHeartbeatWithoutAdapter:
    """Without adapter: existing think_once() behaviour unchanged."""

    @pytest.mark.asyncio
    async def test_passive_cycle(self):
        hb = Heartbeat(world=None)
        record = await hb.run_once()
        assert record.action_taken == "think"
        assert record.action_status == "noop"
        assert "Passive" in record.reflect_notes

    @pytest.mark.asyncio
    async def test_passive_no_observation(self):
        hb = Heartbeat(world=None)
        record = await hb.run_once()
        assert record.observation == {}


class TestHeartbeatLifecycle:
    def test_interval_property(self):
        hb = Heartbeat(interval=60.0)
        assert hb.interval == 60.0
        hb.interval = 10.0
        assert hb.interval == 10.0

    def test_interval_minimum(self):
        hb = Heartbeat()
        hb.interval = 0.1
        assert hb.interval == 1.0

    def test_world_property(self):
        hb = Heartbeat()
        assert hb.world is None
        adapter = MockWorldAdapter()
        hb.world = adapter
        assert hb.world is adapter

    def test_stop_sets_flag(self):
        hb = Heartbeat()
        assert not hb.is_running
        hb.stop()
        assert not hb.is_running


class TestHeartbeatBroadcast:
    """WebSocket broadcasts include action and reasoning summary."""

    @pytest.mark.asyncio
    async def test_broadcast_called(self, mock_adapter):
        with patch(
            "loop.heartbeat.ws_manager",
            create=True,
        ) as mock_ws:
            mock_ws.broadcast = AsyncMock()
            # Patch the import inside heartbeat
            with patch("infrastructure.ws_manager.handler.ws_manager") as ws_mod:
                ws_mod.broadcast = AsyncMock()
                hb = Heartbeat(world=mock_adapter)
                await hb.run_once()
                ws_mod.broadcast.assert_called_once()
                call_args = ws_mod.broadcast.call_args
                assert call_args[0][0] == "heartbeat.cycle"
                data = call_args[0][1]
                assert "action" in data
                assert "reasoning_summary" in data
                assert "observation" in data


class TestHeartbeatLog:
    """Verify logging of observe→reason→act→reflect cycle."""

    @pytest.mark.asyncio
    async def test_embodied_cycle_logs(self, mock_adapter, caplog):
        import logging

        with caplog.at_level(logging.INFO):
            hb = Heartbeat(world=mock_adapter)
            await hb.run_once()

        messages = caplog.text
        assert "Phase 1 (Gather)" in messages
        assert "Phase 2 (Reason)" in messages
        assert "Phase 3 (Act)" in messages
        assert "Heartbeat cycle #1 complete" in messages
