"""Tests for GET /api/world/state endpoint and /api/world/ws relay."""

import asyncio
import json
import logging
import time
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from dashboard.routes.world import (
    _GROUND_TTL,
    _REMIND_AFTER,
    _STALE_THRESHOLD,
    _bark_and_broadcast,
    _broadcast,
    _build_commitment_context,
    _build_world_state,
    _commitments,
    _conversation,
    _extract_commitments,
    _generate_bark,
    _handle_client_message,
    _heartbeat,
    _log_bark_failure,
    _read_presence_file,
    _record_commitments,
    _refresh_ground,
    _tick_commitments,
    broadcast_world_state,
    close_commitment,
    get_commitments,
    reset_commitments,
    reset_conversation_ground,
)

# ---------------------------------------------------------------------------
# _build_world_state
# ---------------------------------------------------------------------------


def test_build_world_state_maps_fields():
    presence = {
        "version": 1,
        "liveness": "2026-03-19T02:00:00Z",
        "mood": "exploring",
        "current_focus": "reviewing PR",
        "energy": 0.8,
        "confidence": 0.9,
        "active_threads": [{"type": "thinking", "ref": "test", "status": "active"}],
        "recent_events": [],
        "concerns": [],
    }
    result = _build_world_state(presence)

    assert result["timmyState"]["mood"] == "exploring"
    assert result["timmyState"]["activity"] == "reviewing PR"
    assert result["timmyState"]["energy"] == 0.8
    assert result["timmyState"]["confidence"] == 0.9
    assert result["updatedAt"] == "2026-03-19T02:00:00Z"
    assert result["version"] == 1
    assert result["visitorPresent"] is False
    assert len(result["activeThreads"]) == 1


def test_build_world_state_defaults():
    """Missing fields get safe defaults."""
    result = _build_world_state({})
    assert result["timmyState"]["mood"] == "calm"
    assert result["timmyState"]["energy"] == 0.5
    assert result["version"] == 1


# ---------------------------------------------------------------------------
# _read_presence_file
# ---------------------------------------------------------------------------


def test_read_presence_file_missing(tmp_path):
    with patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"):
        assert _read_presence_file() is None


def test_read_presence_file_stale(tmp_path):
    f = tmp_path / "presence.json"
    f.write_text(json.dumps({"version": 1}))
    # Backdate the file
    stale_time = time.time() - _STALE_THRESHOLD - 10
    import os

    os.utime(f, (stale_time, stale_time))
    with patch("dashboard.routes.world.PRESENCE_FILE", f):
        assert _read_presence_file() is None


def test_read_presence_file_fresh(tmp_path):
    f = tmp_path / "presence.json"
    f.write_text(json.dumps({"version": 1, "mood": "focused"}))
    with patch("dashboard.routes.world.PRESENCE_FILE", f):
        result = _read_presence_file()
    assert result is not None
    assert result["version"] == 1


def test_read_presence_file_bad_json(tmp_path):
    f = tmp_path / "presence.json"
    f.write_text("not json {{{")
    with patch("dashboard.routes.world.PRESENCE_FILE", f):
        assert _read_presence_file() is None


# ---------------------------------------------------------------------------
# Full endpoint via TestClient
# ---------------------------------------------------------------------------


@pytest.fixture
def client():
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

    app = FastAPI()
    from dashboard.routes.world import router

    app.include_router(router)
    return TestClient(app)


def test_world_state_endpoint_with_file(client, tmp_path):
    """Endpoint returns data from presence file when fresh."""
    f = tmp_path / "presence.json"
    f.write_text(
        json.dumps(
            {
                "version": 1,
                "liveness": "2026-03-19T02:00:00Z",
                "mood": "exploring",
                "current_focus": "testing",
                "active_threads": [],
                "recent_events": [],
                "concerns": [],
            }
        )
    )
    with patch("dashboard.routes.world.PRESENCE_FILE", f):
        resp = client.get("/api/world/state")

    assert resp.status_code == 200
    data = resp.json()
    assert data["timmyState"]["mood"] == "exploring"
    assert data["timmyState"]["activity"] == "testing"
    assert resp.headers["cache-control"] == "no-cache, no-store"


def test_world_state_endpoint_fallback(client, tmp_path):
    """Endpoint falls back to live state when file missing."""
    with (
        patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"),
        patch("timmy.workshop_state.get_state_dict") as mock_get,
    ):
        mock_get.return_value = {
            "version": 1,
            "liveness": "2026-03-19T02:00:00Z",
            "mood": "calm",
            "current_focus": "",
            "active_threads": [],
            "recent_events": [],
            "concerns": [],
        }
        resp = client.get("/api/world/state")

    assert resp.status_code == 200
    assert resp.json()["timmyState"]["mood"] == "calm"


def test_world_state_endpoint_full_fallback(client, tmp_path):
    """Endpoint returns safe defaults when everything fails."""
    with (
        patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"),
        patch(
            "timmy.workshop_state.get_state_dict",
            side_effect=RuntimeError("boom"),
        ),
    ):
        resp = client.get("/api/world/state")

    assert resp.status_code == 200
    data = resp.json()
    assert data["timmyState"]["mood"] == "calm"
    assert data["version"] == 1


# ---------------------------------------------------------------------------
# broadcast_world_state
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_broadcast_world_state_sends_timmy_state():
    """broadcast_world_state sends timmy_state JSON to connected clients."""
    from dashboard.routes.world import _ws_clients

    ws = AsyncMock()
    _ws_clients.append(ws)
    try:
        presence = {
            "version": 1,
            "mood": "exploring",
            "current_focus": "testing",
            "energy": 0.8,
            "confidence": 0.9,
        }
        await broadcast_world_state(presence)

        ws.send_text.assert_called_once()
        msg = json.loads(ws.send_text.call_args[0][0])
        assert msg["type"] == "timmy_state"
        assert msg["mood"] == "exploring"
        assert msg["activity"] == "testing"
    finally:
        _ws_clients.clear()


@pytest.mark.asyncio
async def test_broadcast_world_state_removes_dead_clients():
    """Dead WebSocket connections are cleaned up on broadcast."""
    from dashboard.routes.world import _ws_clients

    dead_ws = AsyncMock()
    dead_ws.send_text.side_effect = ConnectionError("gone")
    _ws_clients.append(dead_ws)
    try:
        await broadcast_world_state({"mood": "idle"})
        assert dead_ws not in _ws_clients
    finally:
        _ws_clients.clear()


def test_world_ws_endpoint_accepts_connection(client):
    """WebSocket endpoint at /api/world/ws accepts connections."""
    with client.websocket_connect("/api/world/ws"):
        pass  # Connection accepted — just close it


def test_world_ws_sends_snapshot_on_connect(client, tmp_path):
    """WebSocket sends a world_state snapshot immediately on connect."""
    f = tmp_path / "presence.json"
    f.write_text(
        json.dumps(
            {
                "version": 1,
                "liveness": "2026-03-19T02:00:00Z",
                "mood": "exploring",
                "current_focus": "testing",
                "active_threads": [],
                "recent_events": [],
                "concerns": [],
            }
        )
    )
    with patch("dashboard.routes.world.PRESENCE_FILE", f):
        with client.websocket_connect("/api/world/ws") as ws:
            msg = json.loads(ws.receive_text())

    assert msg["type"] == "world_state"
    assert msg["timmyState"]["mood"] == "exploring"
    assert msg["timmyState"]["activity"] == "testing"
    assert "updatedAt" in msg


# ---------------------------------------------------------------------------
# Visitor chat — bark engine
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_handle_client_message_ignores_non_json():
    """Non-JSON messages are silently ignored."""
    await _handle_client_message("not json")  # should not raise


@pytest.mark.asyncio
async def test_handle_client_message_ignores_unknown_type():
    """Unknown message types are ignored."""
    await _handle_client_message(json.dumps({"type": "unknown"}))


@pytest.mark.asyncio
async def test_handle_client_message_ignores_empty_text():
    """Empty visitor_message text is ignored."""
    await _handle_client_message(json.dumps({"type": "visitor_message", "text": "  "}))


@pytest.mark.asyncio
async def test_generate_bark_returns_response():
    """_generate_bark returns the chat response."""
    reset_conversation_ground()
    with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat:
        mock_chat.return_value = "Woof! Good to see you."
        result = await _generate_bark("Hey Timmy!")

    assert result == "Woof! Good to see you."
    mock_chat.assert_called_once_with("Hey Timmy!", session_id="workshop")


@pytest.mark.asyncio
async def test_generate_bark_fallback_on_error():
    """_generate_bark returns canned response when chat fails."""
    reset_conversation_ground()
    with patch(
        "timmy.session.chat",
        new_callable=AsyncMock,
        side_effect=RuntimeError("no model"),
    ):
        result = await _generate_bark("Hello?")

    assert "tangled" in result


@pytest.mark.asyncio
async def test_bark_and_broadcast_sends_thinking_then_speech():
    """_bark_and_broadcast sends thinking indicator then speech."""
    from dashboard.routes.world import _ws_clients

    ws = AsyncMock()
    _ws_clients.append(ws)
    _conversation.clear()
    reset_conversation_ground()
    try:
        with patch(
            "timmy.session.chat",
            new_callable=AsyncMock,
            return_value="All good here!",
        ):
            await _bark_and_broadcast("How are you?")

        # Should have sent two messages: thinking + speech
        assert ws.send_text.call_count == 2
        thinking = json.loads(ws.send_text.call_args_list[0][0][0])
        speech = json.loads(ws.send_text.call_args_list[1][0][0])

        assert thinking["type"] == "timmy_thinking"
        assert speech["type"] == "timmy_speech"
        assert speech["text"] == "All good here!"
        assert len(speech["recentExchanges"]) == 1
        assert speech["recentExchanges"][0]["visitor"] == "How are you?"
    finally:
        _ws_clients.clear()
        _conversation.clear()


@pytest.mark.asyncio
async def test_broadcast_removes_dead_clients():
    """Dead clients are cleaned up during broadcast."""
    from dashboard.routes.world import _ws_clients

    dead = AsyncMock()
    dead.send_text.side_effect = ConnectionError("gone")
    _ws_clients.append(dead)
    try:
        await _broadcast(json.dumps({"type": "timmy_speech", "text": "test"}))
        assert dead not in _ws_clients
    finally:
        _ws_clients.clear()


@pytest.mark.asyncio
async def test_conversation_buffer_caps_at_max():
    """Conversation buffer only keeps the last _MAX_EXCHANGES entries."""
    from dashboard.routes.world import _MAX_EXCHANGES, _ws_clients

    ws = AsyncMock()
    _ws_clients.append(ws)
    _conversation.clear()
    reset_conversation_ground()
    try:
        with patch(
            "timmy.session.chat",
            new_callable=AsyncMock,
            return_value="reply",
        ):
            for i in range(_MAX_EXCHANGES + 2):
                await _bark_and_broadcast(f"msg {i}")

        assert len(_conversation) == _MAX_EXCHANGES
        # Oldest messages should have been evicted
        assert _conversation[0]["visitor"] == f"msg {_MAX_EXCHANGES + 2 - _MAX_EXCHANGES}"
    finally:
        _ws_clients.clear()
        _conversation.clear()


def test_log_bark_failure_logs_exception(caplog):
    """_log_bark_failure logs errors from failed bark tasks."""

    loop = asyncio.new_event_loop()

    async def _fail():
        raise RuntimeError("bark boom")

    task = loop.create_task(_fail())
    loop.run_until_complete(asyncio.sleep(0.01))
    loop.close()
    with caplog.at_level(logging.ERROR):
        _log_bark_failure(task)
    assert "bark boom" in caplog.text


def test_log_bark_failure_ignores_cancelled():
    """_log_bark_failure silently ignores cancelled tasks."""

    task = MagicMock(spec=asyncio.Task)
    task.cancelled.return_value = True
    _log_bark_failure(task)  # should not raise


# ---------------------------------------------------------------------------
# Conversation grounding (#322)
# ---------------------------------------------------------------------------


class TestConversationGrounding:
    """Tests for conversation grounding — prevent topic drift."""

    def setup_method(self):
        reset_conversation_ground()

    def teardown_method(self):
        reset_conversation_ground()

    def test_refresh_ground_sets_topic_on_first_message(self):
        """First visitor message becomes the grounding anchor."""
        import dashboard.routes.world as w

        _refresh_ground("Tell me about the Bible")
        assert w._ground_topic == "Tell me about the Bible"
        assert w._ground_set_at > 0

    def test_refresh_ground_keeps_topic_on_subsequent_messages(self):
        """Subsequent messages don't overwrite the anchor."""
        import dashboard.routes.world as w

        _refresh_ground("Tell me about the Bible")
        _refresh_ground("What about Genesis?")
        assert w._ground_topic == "Tell me about the Bible"

    def test_refresh_ground_resets_after_ttl(self):
        """Anchor expires after _GROUND_TTL seconds of inactivity."""
        import dashboard.routes.world as w

        _refresh_ground("Tell me about the Bible")
        # Simulate TTL expiry
        w._ground_set_at = time.time() - _GROUND_TTL - 1
        _refresh_ground("Now tell me about cooking")
        assert w._ground_topic == "Now tell me about cooking"

    def test_refresh_ground_truncates_long_messages(self):
        """Anchor text is capped at 120 characters."""
        import dashboard.routes.world as w

        long_msg = "x" * 200
        _refresh_ground(long_msg)
        assert len(w._ground_topic) == 120

    def test_reset_conversation_ground_clears_state(self):
        """reset_conversation_ground clears the anchor."""
        import dashboard.routes.world as w

        _refresh_ground("Some topic")
        reset_conversation_ground()
        assert w._ground_topic is None
        assert w._ground_set_at == 0.0

    @pytest.mark.asyncio
    async def test_generate_bark_prepends_ground_topic(self):
        """When grounded, the topic is prepended to the visitor message."""
        _refresh_ground("Tell me about prayer")
        with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat:
            mock_chat.return_value = "Great question!"
            await _generate_bark("What else can you share?")

        call_text = mock_chat.call_args[0][0]
        assert "[Workshop conversation topic: Tell me about prayer]" in call_text
        assert "What else can you share?" in call_text

    @pytest.mark.asyncio
    async def test_generate_bark_no_prefix_for_first_message(self):
        """First message (which IS the anchor) is not prefixed."""
        _refresh_ground("Tell me about prayer")
        with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat:
            mock_chat.return_value = "Sure!"
            await _generate_bark("Tell me about prayer")

        call_text = mock_chat.call_args[0][0]
        assert "[Workshop conversation topic:" not in call_text
        assert call_text == "Tell me about prayer"

    @pytest.mark.asyncio
    async def test_bark_and_broadcast_sets_ground(self):
        """_bark_and_broadcast sets the ground topic automatically."""
        import dashboard.routes.world as w
        from dashboard.routes.world import _ws_clients

        ws = AsyncMock()
        _ws_clients.append(ws)
        _conversation.clear()
        try:
            with patch(
                "timmy.session.chat",
                new_callable=AsyncMock,
                return_value="Interesting!",
            ):
                await _bark_and_broadcast("What is grace?")
            assert w._ground_topic == "What is grace?"
        finally:
            _ws_clients.clear()
            _conversation.clear()


# ---------------------------------------------------------------------------
# Conversation grounding — commitment tracking (rescued from PR #408)
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=False)
def _clean_commitments():
    """Reset commitments before and after each commitment test."""
    reset_commitments()
    yield
    reset_commitments()


class TestExtractCommitments:
    def test_extracts_ill_pattern(self):
        text = "I'll draft the skeleton ticket in 30 minutes."
        result = _extract_commitments(text)
        assert len(result) == 1
        assert "draft the skeleton ticket" in result[0]

    def test_extracts_i_will_pattern(self):
        result = _extract_commitments("I will review that PR tomorrow.")
        assert len(result) == 1
        assert "review that PR tomorrow" in result[0]

    def test_extracts_let_me_pattern(self):
        result = _extract_commitments("Let me write up a summary for you.")
        assert len(result) == 1
        assert "write up a summary" in result[0]

    def test_skips_short_matches(self):
        result = _extract_commitments("I'll do it.")
        # "do it" is 5 chars — should be skipped (needs > 5)
        assert result == []

    def test_no_commitments_in_normal_text(self):
        result = _extract_commitments("The weather is nice today.")
        assert result == []

    def test_truncates_long_commitments(self):
        long_phrase = "a" * 200
        result = _extract_commitments(f"I'll {long_phrase}.")
        assert len(result) == 1
        assert len(result[0]) == 120


class TestRecordCommitments:
    def test_records_new_commitment(self, _clean_commitments):
        _record_commitments("I'll draft the ticket now.")
        assert len(get_commitments()) == 1
        assert get_commitments()[0]["messages_since"] == 0

    def test_avoids_duplicate_commitments(self, _clean_commitments):
        _record_commitments("I'll draft the ticket now.")
        _record_commitments("I'll draft the ticket now.")
        assert len(get_commitments()) == 1

    def test_caps_at_max(self, _clean_commitments):
        from dashboard.routes.world import _MAX_COMMITMENTS

        for i in range(_MAX_COMMITMENTS + 3):
            _record_commitments(f"I'll handle commitment number {i} right away.")
        assert len(get_commitments()) <= _MAX_COMMITMENTS


class TestTickAndContext:
    def test_tick_increments_messages_since(self, _clean_commitments):
        _commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
        _tick_commitments()
        _tick_commitments()
        assert _commitments[0]["messages_since"] == 2

    def test_context_empty_when_no_overdue(self, _clean_commitments):
        _commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
        assert _build_commitment_context() == ""

    def test_context_surfaces_overdue_commitments(self, _clean_commitments):
        _commitments.append(
            {
                "text": "draft the skeleton ticket",
                "created_at": 0,
                "messages_since": _REMIND_AFTER,
            }
        )
        ctx = _build_commitment_context()
        assert "draft the skeleton ticket" in ctx
        assert "Open commitments" in ctx

    def test_context_only_includes_overdue(self, _clean_commitments):
        _commitments.append({"text": "recent thing", "created_at": 0, "messages_since": 1})
        _commitments.append(
            {
                "text": "old thing",
                "created_at": 0,
                "messages_since": _REMIND_AFTER,
            }
        )
        ctx = _build_commitment_context()
        assert "old thing" in ctx
        assert "recent thing" not in ctx


class TestCloseCommitment:
    def test_close_valid_index(self, _clean_commitments):
        _commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
        assert close_commitment(0) is True
        assert len(get_commitments()) == 0

    def test_close_invalid_index(self, _clean_commitments):
        assert close_commitment(99) is False


class TestGroundingIntegration:
    @pytest.mark.asyncio
    async def test_bark_records_commitments_from_reply(self, _clean_commitments):
        from dashboard.routes.world import _ws_clients

        ws = AsyncMock()
        _ws_clients.append(ws)
        _conversation.clear()
        try:
            with patch(
                "timmy.session.chat",
                new_callable=AsyncMock,
                return_value="I'll draft the ticket for you!",
            ):
                await _bark_and_broadcast("Can you help?")

            assert len(get_commitments()) == 1
            assert "draft the ticket" in get_commitments()[0]["text"]
        finally:
            _ws_clients.clear()
            _conversation.clear()

    @pytest.mark.asyncio
    async def test_bark_prepends_context_after_n_messages(self, _clean_commitments):
        """After _REMIND_AFTER messages, commitment context is prepended."""
        _commitments.append(
            {
                "text": "draft the skeleton ticket",
                "created_at": 0,
                "messages_since": _REMIND_AFTER - 1,
            }
        )

        with patch(
            "timmy.session.chat",
            new_callable=AsyncMock,
            return_value="Sure thing!",
        ) as mock_chat:
            # This tick will push messages_since to _REMIND_AFTER
            await _generate_bark("Any updates?")
            # _generate_bark doesn't tick — _bark_and_broadcast does.
            # But we pre-set messages_since to _REMIND_AFTER - 1,
            # so we need to tick once to make it overdue.
            _tick_commitments()
            await _generate_bark("Any updates?")

            # Second call should have context prepended
            last_call = mock_chat.call_args_list[-1]
            sent_text = last_call[0][0]
            assert "draft the skeleton ticket" in sent_text
            assert "Open commitments" in sent_text


# ---------------------------------------------------------------------------
# WebSocket heartbeat ping (rescued from PR #399)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_heartbeat_sends_ping():
    """Heartbeat sends a ping JSON frame after the interval elapses."""
    ws = AsyncMock()

    with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
        # Let the first sleep complete, then raise to exit the loop
        call_count = 0

        async def sleep_side_effect(_interval):
            nonlocal call_count
            call_count += 1
            if call_count > 1:
                raise ConnectionError("stop")

        mock_sleep.side_effect = sleep_side_effect
        await _heartbeat(ws)

    ws.send_text.assert_called_once()
    msg = json.loads(ws.send_text.call_args[0][0])
    assert msg["type"] == "ping"


@pytest.mark.asyncio
async def test_heartbeat_exits_on_dead_connection():
    """Heartbeat exits cleanly when the WebSocket is dead."""
    ws = AsyncMock()
    ws.send_text.side_effect = ConnectionError("gone")

    with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock):
        await _heartbeat(ws)  # should not raise
