"""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_matrix_agents_response,
    _build_world_state,
    _commitments,
    _compute_circular_positions,
    _conversation,
    _extract_commitments,
    _generate_bark,
    _get_agent_color,
    _get_agent_shape,
    _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


# ---------------------------------------------------------------------------
# WebSocket Authentication Tests
# ---------------------------------------------------------------------------


class TestWebSocketAuth:
    """Tests for WebSocket token-based authentication."""

    def test_ws_auth_disabled_when_token_unset(self, client):
        """When matrix_ws_token is empty, auth is disabled (dev mode)."""
        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.matrix_ws_token = ""
            with client.websocket_connect("/api/world/ws") as ws:
                # Should receive world_state without auth
                msg = json.loads(ws.receive_text())
                assert msg["type"] == "world_state"

    def test_ws_valid_token_via_query_param(self, client):
        """Valid token via ?token= query param allows connection."""
        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.matrix_ws_token = "secret123"
            with client.websocket_connect("/api/world/ws?token=secret123") as ws:
                # Should receive connection_ack first
                ack = json.loads(ws.receive_text())
                assert ack["type"] == "connection_ack"
                # Then world_state
                msg = json.loads(ws.receive_text())
                assert msg["type"] == "world_state"

    def test_ws_valid_token_via_first_message(self, client):
        """Valid token via first auth message allows connection."""
        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.matrix_ws_token = "secret123"
            with client.websocket_connect("/api/world/ws") as ws:
                # Send auth message
                ws.send_text(json.dumps({"type": "auth", "token": "secret123"}))
                # Should receive connection_ack
                ack = json.loads(ws.receive_text())
                assert ack["type"] == "connection_ack"
                # Then world_state
                msg = json.loads(ws.receive_text())
                assert msg["type"] == "world_state"

    def test_ws_invalid_token_via_query_param(self, client):
        """Invalid token via ?token= closes connection with code 4001."""
        from starlette.websockets import WebSocketDisconnect

        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.matrix_ws_token = "secret123"
            # When auth fails with query param, accept() is called then close()
            # The test client raises WebSocketDisconnect on close
            with pytest.raises(WebSocketDisconnect) as exc_info:
                with client.websocket_connect("/api/world/ws?token=wrongtoken") as ws:
                    # Try to receive - should trigger the close
                    ws.receive_text()
            assert exc_info.value.code == 4001

    def test_ws_invalid_token_via_first_message(self, client):
        """Invalid token via first message closes connection with code 4001."""
        from starlette.websockets import WebSocketDisconnect

        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.matrix_ws_token = "secret123"
            with client.websocket_connect("/api/world/ws") as ws:
                # Send invalid auth message
                ws.send_text(json.dumps({"type": "auth", "token": "wrongtoken"}))
                # Connection should close with 4001
                with pytest.raises(WebSocketDisconnect) as exc_info:
                    ws.receive_text()
            assert exc_info.value.code == 4001

    def test_ws_no_token_when_auth_required(self, client):
        """No token when auth is required closes connection with code 4001."""
        from starlette.websockets import WebSocketDisconnect

        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.matrix_ws_token = "secret123"
            with client.websocket_connect("/api/world/ws") as ws:
                # Send non-auth message without token
                ws.send_text(json.dumps({"type": "visitor_message", "text": "hello"}))
                # Connection should close with 4001
                with pytest.raises(WebSocketDisconnect) as exc_info:
                    ws.receive_text()
            assert exc_info.value.code == 4001

    def test_ws_non_json_first_message_when_auth_required(self, client):
        """Non-JSON first message when auth required closes with 4001."""
        from starlette.websockets import WebSocketDisconnect

        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.matrix_ws_token = "secret123"
            with client.websocket_connect("/api/world/ws") as ws:
                # Send non-JSON message
                ws.send_text("not json")
                # Connection should close with 4001
                with pytest.raises(WebSocketDisconnect) as exc_info:
                    ws.receive_text()
            assert exc_info.value.code == 4001

    def test_ws_existing_behavior_unchanged_when_token_not_configured(self, client, tmp_path):
        """Existing /api/world/ws behavior unchanged when token not configured."""
        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.settings") as mock_settings:
            mock_settings.matrix_ws_token = ""  # Not configured
            with patch("dashboard.routes.world.PRESENCE_FILE", f):
                with client.websocket_connect("/api/world/ws") as ws:
                    # Should receive world_state directly (no connection_ack)
                    msg = json.loads(ws.receive_text())
                    assert msg["type"] == "world_state"
                    assert msg["timmyState"]["mood"] == "exploring"


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


# ---------------------------------------------------------------------------
# Matrix Agent Registry (/api/matrix/agents)
# ---------------------------------------------------------------------------


class TestMatrixAgentRegistry:
    """Tests for the Matrix agent registry endpoint."""

    def test_get_agent_color_known_agents(self):
        """Known agents return their assigned colors."""
        assert _get_agent_color("timmy") == "#FFD700"  # Gold
        assert _get_agent_color("orchestrator") == "#FFD700"  # Gold
        assert _get_agent_color("kimi") == "#06B6D4"  # Cyan
        assert _get_agent_color("claude") == "#A855F7"  # Purple
        assert _get_agent_color("researcher") == "#10B981"  # Emerald
        assert _get_agent_color("coder") == "#EF4444"  # Red

    def test_get_agent_color_unknown_agent(self):
        """Unknown agents return the default gray color."""
        assert _get_agent_color("unknown") == "#9CA3AF"
        assert _get_agent_color("xyz") == "#9CA3AF"

    def test_get_agent_color_case_insensitive(self):
        """Agent ID lookup is case insensitive."""
        assert _get_agent_color("Timmy") == "#FFD700"
        assert _get_agent_color("KIMI") == "#06B6D4"

    def test_get_agent_shape_known_agents(self):
        """Known agents return their assigned shapes."""
        assert _get_agent_shape("timmy") == "sphere"
        assert _get_agent_shape("coder") == "cube"
        assert _get_agent_shape("writer") == "cone"

    def test_get_agent_shape_unknown_agent(self):
        """Unknown agents return the default sphere shape."""
        assert _get_agent_shape("unknown") == "sphere"

    def test_compute_circular_positions(self):
        """Agents are arranged in a circle on the XZ plane."""
        positions = _compute_circular_positions(4, radius=3.0)
        assert len(positions) == 4
        # All positions should have y=0
        for pos in positions:
            assert pos["y"] == 0.0
            assert "x" in pos
            assert "z" in pos
        # First position should be at angle 0 (x=radius, z=0)
        assert positions[0]["x"] == 3.0
        assert positions[0]["z"] == 0.0

    def test_compute_circular_positions_empty(self):
        """Zero agents returns empty positions list."""
        positions = _compute_circular_positions(0)
        assert positions == []

    def test_build_matrix_agents_response_structure(self):
        """Response contains all required fields for each agent."""
        with patch("timmy.agents.loader.list_agents") as mock_list:
            mock_list.return_value = [
                {"id": "timmy", "name": "Timmy", "role": "orchestrator", "status": "available"},
                {"id": "researcher", "name": "Seer", "role": "research", "status": "busy"},
            ]
            result = _build_matrix_agents_response()

        assert len(result) == 2
        # Check first agent
        assert result[0]["id"] == "timmy"
        assert result[0]["display_name"] == "Timmy"
        assert result[0]["role"] == "orchestrator"
        assert result[0]["color"] == "#FFD700"
        assert result[0]["shape"] == "sphere"
        assert result[0]["status"] == "available"
        assert "position" in result[0]
        assert "x" in result[0]["position"]
        assert "y" in result[0]["position"]
        assert "z" in result[0]["position"]

    def test_build_matrix_agents_response_empty(self):
        """Returns empty list when no agents configured."""
        with patch("timmy.agents.loader.list_agents") as mock_list:
            mock_list.return_value = []
            result = _build_matrix_agents_response()
        assert result == []

    def test_build_matrix_agents_response_handles_errors(self):
        """Returns empty list when loader fails."""
        with patch("timmy.agents.loader.list_agents") as mock_list:
            mock_list.side_effect = RuntimeError("Loader failed")
            result = _build_matrix_agents_response()
        assert result == []


@pytest.fixture
def matrix_client():
    """TestClient with matrix router."""
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

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

    app.include_router(matrix_router)
    return TestClient(app)


def test_matrix_agents_endpoint_returns_json(matrix_client):
    """GET /api/matrix/agents returns JSON list."""
    with patch("timmy.agents.loader.list_agents") as mock_list:
        mock_list.return_value = [
            {"id": "timmy", "name": "Timmy", "role": "orchestrator", "status": "available"},
        ]
        resp = matrix_client.get("/api/matrix/agents")

    assert resp.status_code == 200
    data = resp.json()
    assert isinstance(data, list)
    assert len(data) == 1
    assert data[0]["id"] == "timmy"
    assert resp.headers["cache-control"] == "no-cache, no-store"


def test_matrix_agents_endpoint_empty_list(matrix_client):
    """Endpoint returns 200 with empty list when no agents."""
    with patch("timmy.agents.loader.list_agents") as mock_list:
        mock_list.return_value = []
        resp = matrix_client.get("/api/matrix/agents")

    assert resp.status_code == 200
    assert resp.json() == []


def test_matrix_agents_endpoint_graceful_degradation(matrix_client):
    """Endpoint returns empty list when loader fails."""
    with patch("timmy.agents.loader.list_agents") as mock_list:
        mock_list.side_effect = FileNotFoundError("agents.yaml not found")
        resp = matrix_client.get("/api/matrix/agents")

    assert resp.status_code == 200
    assert resp.json() == []


# ---------------------------------------------------------------------------
# Matrix Configuration Endpoint (/api/matrix/config)
# ---------------------------------------------------------------------------


class TestMatrixConfigEndpoint:
    """Tests for the Matrix configuration endpoint."""

    def test_matrix_config_endpoint_returns_json(self, matrix_client):
        """GET /api/matrix/config returns JSON config."""
        resp = matrix_client.get("/api/matrix/config")

        assert resp.status_code == 200
        data = resp.json()
        assert isinstance(data, dict)
        assert "lighting" in data
        assert "environment" in data
        assert "features" in data
        assert resp.headers["cache-control"] == "no-cache, no-store"

    def test_matrix_config_lighting_structure(self, matrix_client):
        """Config has correct lighting section structure."""
        resp = matrix_client.get("/api/matrix/config")
        data = resp.json()

        lighting = data["lighting"]
        assert "ambient_color" in lighting
        assert "ambient_intensity" in lighting
        assert "point_lights" in lighting
        assert isinstance(lighting["point_lights"], list)

        # Check first point light structure
        if lighting["point_lights"]:
            pl = lighting["point_lights"][0]
            assert "color" in pl
            assert "intensity" in pl
            assert "position" in pl
            assert "x" in pl["position"]
            assert "y" in pl["position"]
            assert "z" in pl["position"]

    def test_matrix_config_environment_structure(self, matrix_client):
        """Config has correct environment section structure."""
        resp = matrix_client.get("/api/matrix/config")
        data = resp.json()

        env = data["environment"]
        assert "rain_enabled" in env
        assert "starfield_enabled" in env
        assert "fog_color" in env
        assert "fog_density" in env
        assert isinstance(env["rain_enabled"], bool)
        assert isinstance(env["starfield_enabled"], bool)

    def test_matrix_config_features_structure(self, matrix_client):
        """Config has correct features section structure."""
        resp = matrix_client.get("/api/matrix/config")
        data = resp.json()

        features = data["features"]
        assert "chat_enabled" in features
        assert "visitor_avatars" in features
        assert "pip_familiar" in features
        assert "workshop_portal" in features
        assert isinstance(features["chat_enabled"], bool)


class TestMatrixConfigLoading:
    """Tests for _load_matrix_config function."""

    def test_load_matrix_config_returns_dict(self):
        """_load_matrix_config returns a dictionary."""
        from dashboard.routes.world import _load_matrix_config

        config = _load_matrix_config()
        assert isinstance(config, dict)
        assert "lighting" in config
        assert "environment" in config
        assert "features" in config

    def test_load_matrix_config_has_all_required_sections(self):
        """Config contains all required sections."""
        from dashboard.routes.world import _load_matrix_config

        config = _load_matrix_config()
        lighting = config["lighting"]
        env = config["environment"]
        features = config["features"]

        # Lighting fields
        assert "ambient_color" in lighting
        assert "ambient_intensity" in lighting
        assert "point_lights" in lighting

        # Environment fields
        assert "rain_enabled" in env
        assert "starfield_enabled" in env
        assert "fog_color" in env
        assert "fog_density" in env

        # Features fields
        assert "chat_enabled" in features
        assert "visitor_avatars" in features
        assert "pip_familiar" in features
        assert "workshop_portal" in features

    def test_load_matrix_config_fallback_on_missing_file(self, tmp_path):
        """Returns defaults when matrix.yaml is missing."""
        from dashboard.routes.world import _load_matrix_config

        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.repo_root = str(tmp_path)
            config = _load_matrix_config()

        # Should return defaults
        assert config["lighting"]["ambient_color"] == "#1a1a2e"
        assert config["environment"]["rain_enabled"] is False
        assert config["features"]["chat_enabled"] is True

    def test_load_matrix_config_merges_with_defaults(self, tmp_path):
        """Partial config file is merged with defaults."""
        from dashboard.routes.world import _load_matrix_config

        # Create a partial config file
        config_dir = tmp_path / "config"
        config_dir.mkdir()
        config_file = config_dir / "matrix.yaml"
        config_file.write_text("""
lighting:
  ambient_color: "#ff0000"
  ambient_intensity: 0.8
environment:
  rain_enabled: true
""")

        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.repo_root = str(tmp_path)
            config = _load_matrix_config()

        # Custom values
        assert config["lighting"]["ambient_color"] == "#ff0000"
        assert config["lighting"]["ambient_intensity"] == 0.8
        assert config["environment"]["rain_enabled"] is True

        # Defaults preserved
        assert config["features"]["chat_enabled"] is True
        assert config["environment"]["starfield_enabled"] is True
        assert len(config["lighting"]["point_lights"]) == 3

    def test_load_matrix_config_handles_invalid_yaml(self, tmp_path):
        """Returns defaults when YAML is invalid."""
        from dashboard.routes.world import _load_matrix_config

        config_dir = tmp_path / "config"
        config_dir.mkdir()
        config_file = config_dir / "matrix.yaml"
        config_file.write_text("not: valid: yaml: [{")

        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.repo_root = str(tmp_path)
            config = _load_matrix_config()

        # Should return defaults despite invalid YAML
        assert "lighting" in config
        assert "environment" in config
        assert "features" in config

    def test_load_matrix_config_custom_point_lights(self, tmp_path):
        """Custom point lights override defaults completely."""
        from dashboard.routes.world import _load_matrix_config

        config_dir = tmp_path / "config"
        config_dir.mkdir()
        config_file = config_dir / "matrix.yaml"
        config_file.write_text("""
lighting:
  point_lights:
    - color: "#FFFFFF"
      intensity: 2.0
      position: { x: 1, y: 2, z: 3 }
""")

        with patch("dashboard.routes.world.settings") as mock_settings:
            mock_settings.repo_root = str(tmp_path)
            config = _load_matrix_config()

        # Should have custom point lights, not defaults
        lights = config["lighting"]["point_lights"]
        assert len(lights) == 1
        assert lights[0]["color"] == "#FFFFFF"
        assert lights[0]["intensity"] == 2.0
        assert lights[0]["position"] == {"x": 1, "y": 2, "z": 3}


# ---------------------------------------------------------------------------
# Matrix Thoughts Endpoint (/api/matrix/thoughts)
# ---------------------------------------------------------------------------


class TestMatrixThoughtsEndpoint:
    """Tests for the Matrix thoughts endpoint."""

    def test_thoughts_endpoint_returns_json(self, matrix_client):
        """GET /api/matrix/thoughts returns JSON list."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = [
                MagicMock(
                    id="test-1",
                    content="First thought",
                    created_at="2026-03-21T10:00:00Z",
                    parent_id=None,
                ),
            ]
            resp = matrix_client.get("/api/matrix/thoughts")

        assert resp.status_code == 200
        data = resp.json()
        assert isinstance(data, list)
        assert len(data) == 1
        assert resp.headers["cache-control"] == "no-cache, no-store"

    def test_thoughts_endpoint_default_limit(self, matrix_client):
        """Default limit is 10 thoughts."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = []
            matrix_client.get("/api/matrix/thoughts")

        # Should call with default limit of 10
        mock_engine.get_recent_thoughts.assert_called_once_with(limit=10)

    def test_thoughts_endpoint_custom_limit(self, matrix_client):
        """Custom limit can be specified via query param."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = []
            matrix_client.get("/api/matrix/thoughts?limit=25")

        mock_engine.get_recent_thoughts.assert_called_once_with(limit=25)

    def test_thoughts_endpoint_max_limit_capped(self, matrix_client):
        """Limit is capped at 50 maximum."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = []
            matrix_client.get("/api/matrix/thoughts?limit=100")

        mock_engine.get_recent_thoughts.assert_called_once_with(limit=50)

    def test_thoughts_endpoint_min_limit(self, matrix_client):
        """Limit less than 1 is clamped to 1."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = []
            matrix_client.get("/api/matrix/thoughts?limit=0")

        mock_engine.get_recent_thoughts.assert_called_once_with(limit=1)

    def test_thoughts_endpoint_response_structure(self, matrix_client):
        """Response has all required fields with correct types."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = [
                MagicMock(
                    id="thought-uuid-1",
                    content="This is a test thought",
                    created_at="2026-03-21T10:00:00Z",
                    parent_id="parent-uuid",
                ),
            ]
            resp = matrix_client.get("/api/matrix/thoughts")

        data = resp.json()
        assert len(data) == 1
        thought = data[0]
        assert thought["id"] == "thought-uuid-1"
        assert thought["text"] == "This is a test thought"
        assert thought["created_at"] == "2026-03-21T10:00:00Z"
        assert thought["chain_id"] == "parent-uuid"

    def test_thoughts_endpoint_null_parent_id(self, matrix_client):
        """Root thoughts have chain_id as null."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = [
                MagicMock(
                    id="root-thought",
                    content="Root thought",
                    created_at="2026-03-21T10:00:00Z",
                    parent_id=None,
                ),
            ]
            resp = matrix_client.get("/api/matrix/thoughts")

        data = resp.json()
        assert data[0]["chain_id"] is None

    def test_thoughts_endpoint_text_truncation(self, matrix_client):
        """Text is truncated to 500 characters."""
        long_content = "A" * 1000
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = [
                MagicMock(
                    id="long-thought",
                    content=long_content,
                    created_at="2026-03-21T10:00:00Z",
                    parent_id=None,
                ),
            ]
            resp = matrix_client.get("/api/matrix/thoughts")

        data = resp.json()
        assert len(data[0]["text"]) == 500
        assert data[0]["text"] == "A" * 500

    def test_thoughts_endpoint_empty_list(self, matrix_client):
        """Endpoint returns 200 with empty list when no thoughts."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = []
            resp = matrix_client.get("/api/matrix/thoughts")

        assert resp.status_code == 200
        assert resp.json() == []

    def test_thoughts_endpoint_graceful_degradation(self, matrix_client):
        """Returns empty list when thinking engine fails."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.side_effect = RuntimeError("Engine down")
            resp = matrix_client.get("/api/matrix/thoughts")

        assert resp.status_code == 200
        assert resp.json() == []

    def test_thoughts_endpoint_multiple_thoughts(self, matrix_client):
        """Multiple thoughts are returned in order from thinking engine."""
        with patch("timmy.thinking.thinking_engine") as mock_engine:
            mock_engine.get_recent_thoughts.return_value = [
                MagicMock(
                    id="t1",
                    content="First",
                    created_at="2026-03-21T10:00:00Z",
                    parent_id=None,
                ),
                MagicMock(
                    id="t2",
                    content="Second",
                    created_at="2026-03-21T10:01:00Z",
                    parent_id="t1",
                ),
            ]
            resp = matrix_client.get("/api/matrix/thoughts")

        data = resp.json()
        assert len(data) == 2
        assert data[0]["id"] == "t1"
        assert data[1]["id"] == "t2"


# ---------------------------------------------------------------------------
# Matrix Bark Endpoint (/api/matrix/bark)
# ---------------------------------------------------------------------------


class TestMatrixBarkEndpoint:
    """Tests for the Matrix bark HTTP fallback endpoint."""

    def setup_method(self):
        """Reset rate limiting state before each test."""
        from dashboard.routes.world import _bark_last_request

        _bark_last_request.clear()

    def teardown_method(self):
        """Clean up rate limiting state after each test."""
        from dashboard.routes.world import _bark_last_request

        _bark_last_request.clear()

    def test_bark_endpoint_requires_text(self, matrix_client):
        """POST /api/matrix/bark returns 422 if text is missing."""
        resp = matrix_client.post("/api/matrix/bark", json={"text": "", "visitor_id": "test123"})

        assert resp.status_code == 422
        data = resp.json()
        assert "error" in data

    def test_bark_endpoint_requires_visitor_id(self, matrix_client):
        """POST /api/matrix/bark returns 422 if visitor_id is missing."""
        resp = matrix_client.post("/api/matrix/bark", json={"text": "Hello", "visitor_id": ""})

        assert resp.status_code == 422
        data = resp.json()
        assert "error" in data

    def test_bark_endpoint_returns_bark_format(self, matrix_client):
        """POST /api/matrix/bark returns bark in produce_bark format."""
        with patch(
            "dashboard.routes.world._generate_bark",
            new_callable=AsyncMock,
            return_value="Woof! Hello there!",
        ):
            resp = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "Hey Timmy!", "visitor_id": "visitor_123"},
            )

        assert resp.status_code == 200
        data = resp.json()

        # Check produce_bark format
        assert data["type"] == "bark"
        assert data["agent_id"] == "timmy"
        assert "data" in data
        assert data["data"]["text"] == "Woof! Hello there!"
        assert data["data"]["style"] == "speech"
        assert "ts" in data
        assert resp.headers["cache-control"] == "no-cache, no-store"

    def test_bark_endpoint_uses_generate_bark(self, matrix_client):
        """POST /api/matrix/bark uses _generate_bark for response generation."""
        with patch(
            "dashboard.routes.world._generate_bark",
            new_callable=AsyncMock,
            return_value="Generated response",
        ) as mock_generate:
            resp = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "Test message", "visitor_id": "v1"},
            )

        assert resp.status_code == 200
        mock_generate.assert_called_once_with("Test message")

    def test_bark_endpoint_rate_limit_blocks_second_request(self, matrix_client):
        """Second request within 3 seconds returns 429."""
        with patch(
            "dashboard.routes.world._generate_bark",
            new_callable=AsyncMock,
            return_value="Response",
        ):
            # First request should succeed
            resp1 = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "First", "visitor_id": "rate_test_visitor"},
            )
            assert resp1.status_code == 200

            # Second request within 3 seconds should be rate limited
            resp2 = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "Second", "visitor_id": "rate_test_visitor"},
            )
            assert resp2.status_code == 429
            data = resp2.json()
            assert "error" in data
            assert "Rate limit" in data["error"]

    def test_bark_endpoint_rate_limit_per_visitor(self, matrix_client):
        """Rate limiting is per-visitor_id, not global."""
        with patch(
            "dashboard.routes.world._generate_bark",
            new_callable=AsyncMock,
            return_value="Response",
        ):
            # First visitor makes a request
            resp1 = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "Hello", "visitor_id": "visitor_a"},
            )
            assert resp1.status_code == 200

            # Different visitor can still make a request
            resp2 = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "Hello", "visitor_id": "visitor_b"},
            )
            assert resp2.status_code == 200

    def test_bark_endpoint_rate_limit_retry_after_header(self, matrix_client):
        """429 response includes Retry-After header."""
        with patch(
            "dashboard.routes.world._generate_bark",
            new_callable=AsyncMock,
            return_value="Response",
        ):
            # First request
            matrix_client.post(
                "/api/matrix/bark",
                json={"text": "First", "visitor_id": "retry_test_visitor"},
            )

            # Second request (rate limited)
            resp = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "Second", "visitor_id": "retry_test_visitor"},
            )

        assert resp.status_code == 429
        assert "Retry-After" in resp.headers
        # Should be approximately 3 seconds
        retry_after = int(resp.headers["Retry-After"])
        assert 1 <= retry_after <= 4

    def test_bark_endpoint_graceful_fallback_on_error(self, matrix_client):
        """When _generate_bark fails, returns graceful fallback."""
        with patch(
            "dashboard.routes.world._generate_bark",
            new_callable=AsyncMock,
            side_effect=RuntimeError("LLM unavailable"),
        ):
            resp = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "Hello", "visitor_id": "error_test_visitor"},
            )

        assert resp.status_code == 200
        data = resp.json()
        assert data["type"] == "bark"
        assert "tangled" in data["data"]["text"]

    def test_bark_endpoint_strips_whitespace(self, matrix_client):
        """Whitespace is stripped from text and visitor_id."""
        with patch(
            "dashboard.routes.world._generate_bark",
            new_callable=AsyncMock,
            return_value="Response",
        ) as mock_generate:
            resp = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "  Hello Timmy!  ", "visitor_id": "  visitor_123  "},
            )

        assert resp.status_code == 200
        # Should be called with stripped text
        mock_generate.assert_called_once_with("Hello Timmy!")

    def test_bark_endpoint_response_truncation(self, matrix_client):
        """Long responses are truncated to 280 characters."""
        long_response = "A" * 500
        with patch(
            "dashboard.routes.world._generate_bark",
            new_callable=AsyncMock,
            return_value=long_response,
        ):
            resp = matrix_client.post(
                "/api/matrix/bark",
                json={"text": "Tell me a long story", "visitor_id": "long_test"},
            )

        assert resp.status_code == 200
        data = resp.json()
        assert len(data["data"]["text"]) == 280


# ---------------------------------------------------------------------------
# Matrix Memory Search Endpoint (/api/matrix/memory/search)
# ---------------------------------------------------------------------------


class TestMatrixMemorySearchEndpoint:
    """Tests for the Matrix memory search endpoint."""

    def setup_method(self):
        """Reset rate limiting state before each test."""
        from dashboard.routes.world import _memory_search_last_request

        _memory_search_last_request.clear()

    def teardown_method(self):
        """Clean up rate limiting state after each test."""
        from dashboard.routes.world import _memory_search_last_request

        _memory_search_last_request.clear()

    def test_memory_search_requires_query(self, matrix_client):
        """GET /api/matrix/memory/search returns 400 if q is missing."""
        resp = matrix_client.get("/api/matrix/memory/search")

        assert resp.status_code == 400
        data = resp.json()
        assert "error" in data
        assert "'q' is required" in data["error"]

    def test_memory_search_rejects_empty_query(self, matrix_client):
        """GET /api/matrix/memory/search returns 400 if q is empty."""
        resp = matrix_client.get("/api/matrix/memory/search?q=")

        assert resp.status_code == 400
        data = resp.json()
        assert "error" in data

    def test_memory_search_rejects_whitespace_query(self, matrix_client):
        """GET /api/matrix/memory/search returns 400 if q is whitespace."""
        resp = matrix_client.get("/api/matrix/memory/search?q=   ")

        assert resp.status_code == 400
        data = resp.json()
        assert "error" in data

    def test_memory_search_returns_json_array(self, matrix_client):
        """GET /api/matrix/memory/search returns JSON array of results."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = [
                MagicMock(
                    content="Bitcoin is a decentralized digital currency",
                    relevance_score=0.92,
                    timestamp="2026-03-21T10:00:00Z",
                    context_type="conversation",
                ),
            ]
            resp = matrix_client.get("/api/matrix/memory/search?q=bitcoin")

        assert resp.status_code == 200
        data = resp.json()
        assert isinstance(data, list)
        assert len(data) == 1
        assert resp.headers["cache-control"] == "no-cache, no-store"

    def test_memory_search_result_structure(self, matrix_client):
        """Each result has required fields with correct types."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = [
                MagicMock(
                    content="Bitcoin sovereignty content here",
                    relevance_score=0.85,
                    timestamp="2026-03-21T10:00:00Z",
                    context_type="fact",
                ),
            ]
            resp = matrix_client.get("/api/matrix/memory/search?q=sovereignty")

        data = resp.json()
        assert len(data) == 1
        result = data[0]
        assert "text" in result
        assert "relevance" in result
        assert "created_at" in result
        assert "context_type" in result
        assert isinstance(result["text"], str)
        assert isinstance(result["relevance"], (int, float))
        assert isinstance(result["created_at"], str)
        assert isinstance(result["context_type"], str)

    def test_memory_search_text_truncation(self, matrix_client):
        """Text is truncated to 200 characters with ellipsis."""
        long_content = "A" * 300
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = [
                MagicMock(
                    content=long_content,
                    relevance_score=0.75,
                    timestamp="2026-03-21T10:00:00Z",
                    context_type="conversation",
                ),
            ]
            resp = matrix_client.get("/api/matrix/memory/search?q=test")

        data = resp.json()
        assert len(data[0]["text"]) == 203  # 200 chars + "..."
        assert data[0]["text"].endswith("...")

    def test_memory_search_relevance_rounding(self, matrix_client):
        """Relevance score is rounded to 4 decimal places."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = [
                MagicMock(
                    content="Test content",
                    relevance_score=0.123456789,
                    timestamp="2026-03-21T10:00:00Z",
                    context_type="conversation",
                ),
            ]
            resp = matrix_client.get("/api/matrix/memory/search?q=test")

        data = resp.json()
        # Should be rounded to 4 decimal places
        assert data[0]["relevance"] == 0.1235

    def test_memory_search_max_results(self, matrix_client):
        """Endpoint returns max 5 results."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            # Return more than 5 results
            mock_search.return_value = [
                MagicMock(
                    content=f"Memory {i}",
                    relevance_score=0.9 - (i * 0.05),
                    timestamp="2026-03-21T10:00:00Z",
                    context_type="conversation",
                )
                for i in range(10)
            ]
            resp = matrix_client.get("/api/matrix/memory/search?q=test")

        data = resp.json()
        # Should be limited to 5 results
        assert len(data) <= 5

    def test_memory_search_passes_limit_to_search(self, matrix_client):
        """Endpoint passes correct limit to search_memories."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = []
            matrix_client.get("/api/matrix/memory/search?q=bitcoin")

        mock_search.assert_called_once_with("bitcoin", limit=5)

    def test_memory_search_empty_results(self, matrix_client):
        """Endpoint returns empty array when no memories found."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = []
            resp = matrix_client.get("/api/matrix/memory/search?q=nonexistent")

        assert resp.status_code == 200
        assert resp.json() == []

    def test_memory_search_graceful_degradation(self, matrix_client):
        """Returns empty array when search fails."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.side_effect = RuntimeError("Database error")
            resp = matrix_client.get("/api/matrix/memory/search?q=test")

        assert resp.status_code == 200
        assert resp.json() == []

    def test_memory_search_rate_limit_blocks_second_request(self, matrix_client):
        """Second request within 5 seconds returns 429."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = []
            # First request should succeed
            resp1 = matrix_client.get("/api/matrix/memory/search?q=first")
            assert resp1.status_code == 200

            # Second request within 5 seconds should be rate limited
            resp2 = matrix_client.get("/api/matrix/memory/search?q=second")
            assert resp2.status_code == 429
            data = resp2.json()
            assert "error" in data
            assert "Rate limit" in data["error"]

    def test_memory_search_rate_limit_per_ip(self, matrix_client):
        """Rate limiting is per-IP address."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = []
            # First request from one IP
            resp1 = matrix_client.get(
                "/api/matrix/memory/search?q=test",
                headers={"X-Forwarded-For": "1.2.3.4"},
            )
            assert resp1.status_code == 200

            # Same request from different IP should succeed
            resp2 = matrix_client.get(
                "/api/matrix/memory/search?q=test",
                headers={"X-Forwarded-For": "5.6.7.8"},
            )
            assert resp2.status_code == 200

    def test_memory_search_rate_limit_uses_client_host(self, matrix_client):
        """Rate limiting falls back to client.host when no X-Forwarded-For."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = []
            # First request
            resp1 = matrix_client.get("/api/matrix/memory/search?q=first")
            assert resp1.status_code == 200

            # Second request should be rate limited (same client)
            resp2 = matrix_client.get("/api/matrix/memory/search?q=second")
            assert resp2.status_code == 429

    def test_memory_search_rate_limit_retry_after_header(self, matrix_client):
        """429 response includes Retry-After header."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = []
            # First request
            matrix_client.get("/api/matrix/memory/search?q=first")

            # Second request (rate limited)
            resp = matrix_client.get("/api/matrix/memory/search?q=second")

        assert resp.status_code == 429
        assert "Retry-After" in resp.headers
        retry_after = int(resp.headers["Retry-After"])
        assert 1 <= retry_after <= 6  # Should be around 5 seconds

    def test_memory_search_multiple_results_ordering(self, matrix_client):
        """Results maintain order from search_memories."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = [
                MagicMock(
                    content="First memory",
                    relevance_score=0.95,
                    timestamp="2026-03-21T10:00:00Z",
                    context_type="fact",
                ),
                MagicMock(
                    content="Second memory",
                    relevance_score=0.85,
                    timestamp="2026-03-21T10:01:00Z",
                    context_type="conversation",
                ),
            ]
            resp = matrix_client.get("/api/matrix/memory/search?q=test")

        data = resp.json()
        assert len(data) == 2
        assert data[0]["text"] == "First memory"
        assert data[1]["text"] == "Second memory"

    def test_memory_search_url_encoding(self, matrix_client):
        """Query parameter can be URL encoded."""
        with patch("dashboard.routes.world.search_memories") as mock_search:
            mock_search.return_value = []
            resp = matrix_client.get("/api/matrix/memory/search?q=bitcoin%20sovereignty")

        assert resp.status_code == 200
        mock_search.assert_called_once_with("bitcoin sovereignty", limit=5)


# ---------------------------------------------------------------------------
# Matrix Health Endpoint (/api/matrix/health)
# ---------------------------------------------------------------------------


class TestMatrixHealthEndpoint:
    """Tests for the Matrix health endpoint."""

    def test_health_endpoint_returns_json(self, matrix_client):
        """GET /api/matrix/health returns JSON response."""
        resp = matrix_client.get("/api/matrix/health")

        assert resp.status_code == 200
        data = resp.json()
        assert isinstance(data, dict)
        assert "status" in data
        assert "version" in data
        assert "capabilities" in data
        assert resp.headers["cache-control"] == "no-cache, no-store"

    def test_health_endpoint_status_field(self, matrix_client):
        """Response contains status field with valid values."""
        resp = matrix_client.get("/api/matrix/health")
        data = resp.json()

        assert data["status"] in ["ok", "degraded"]

    def test_health_endpoint_version_field(self, matrix_client):
        """Response contains version string."""
        resp = matrix_client.get("/api/matrix/health")
        data = resp.json()

        assert isinstance(data["version"], str)
        assert len(data["version"]) > 0

    def test_health_endpoint_capabilities_structure(self, matrix_client):
        """Capabilities dict has all required features."""
        resp = matrix_client.get("/api/matrix/health")
        data = resp.json()

        caps = data["capabilities"]
        assert isinstance(caps, dict)

        # All required capabilities
        required_caps = ["thinking", "memory", "bark", "familiar", "lightning"]
        for cap in required_caps:
            assert cap in caps, f"Missing capability: {cap}"
            assert isinstance(caps[cap], bool), f"Capability {cap} should be bool"

    def test_health_endpoint_returns_200_when_degraded(self, matrix_client):
        """Returns 200 even when some capabilities are unavailable."""
        # Mock all capability checks to return False
        with patch.multiple(
            "dashboard.routes.world",
            _check_capability_thinking=lambda: False,
            _check_capability_memory=lambda: False,
            _check_capability_bark=lambda: False,
            _check_capability_familiar=lambda: False,
            _check_capability_lightning=lambda: False,
        ):
            resp = matrix_client.get("/api/matrix/health")

        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "degraded"

    def test_health_endpoint_ok_when_core_caps_available(self, matrix_client):
        """Status is 'ok' when core capabilities are available."""
        with patch.multiple(
            "dashboard.routes.world",
            _check_capability_thinking=lambda: True,
            _check_capability_memory=lambda: True,
            _check_capability_bark=lambda: True,
            _check_capability_familiar=lambda: False,
            _check_capability_lightning=lambda: False,
        ):
            resp = matrix_client.get("/api/matrix/health")

        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "ok"
        assert data["capabilities"]["thinking"] is True
        assert data["capabilities"]["memory"] is True
        assert data["capabilities"]["bark"] is True
        assert data["capabilities"]["familiar"] is False
        assert data["capabilities"]["lightning"] is False


class TestMatrixHealthCapabilityChecks:
    """Tests for individual capability check functions."""

    def test_check_thinking_returns_true_when_available(self):
        """_check_capability_thinking returns True when thinking engine is available."""
        from dashboard.routes.world import _check_capability_thinking

        # Mock thinking engine with _db attribute
        mock_engine = MagicMock()
        mock_engine._db = "/path/to/thoughts.db"

        with patch("timmy.thinking.thinking_engine", mock_engine):
            result = _check_capability_thinking()

        assert result is True

    def test_check_thinking_returns_false_when_no_db(self):
        """_check_capability_thinking returns False when _db is None."""
        from dashboard.routes.world import _check_capability_thinking

        mock_engine = MagicMock()
        mock_engine._db = None

        with patch("timmy.thinking.thinking_engine", mock_engine):
            result = _check_capability_thinking()

        assert result is False

    def test_check_memory_returns_true_when_hot_memory_exists(self, tmp_path):
        """_check_capability_memory returns True when HOT_MEMORY_PATH exists."""
        from dashboard.routes.world import _check_capability_memory

        # Create a temporary memory path
        mock_path = MagicMock()
        mock_path.exists.return_value = True

        with patch("timmy.memory_system.HOT_MEMORY_PATH", mock_path):
            result = _check_capability_memory()

        assert result is True

    def test_check_memory_returns_false_when_not_exists(self):
        """_check_capability_memory returns False when HOT_MEMORY_PATH doesn't exist."""
        from dashboard.routes.world import _check_capability_memory

        mock_path = MagicMock()
        mock_path.exists.return_value = False

        with patch("timmy.memory_system.HOT_MEMORY_PATH", mock_path):
            result = _check_capability_memory()

        assert result is False

    def test_check_bark_returns_true_when_produce_bark_available(self):
        """_check_capability_bark returns True when produce_bark is callable."""
        from dashboard.routes.world import _check_capability_bark

        with patch(
            "infrastructure.presence.produce_bark",
            return_value={"type": "bark"},
        ):
            result = _check_capability_bark()

        assert result is True

    def test_check_familiar_returns_true_when_pip_available(self):
        """_check_capability_familiar returns True when pip_familiar is available."""
        from dashboard.routes.world import _check_capability_familiar

        mock_pip = MagicMock()

        with patch("timmy.familiar.pip_familiar", mock_pip):
            result = _check_capability_familiar()

        assert result is True

    def test_check_familiar_returns_false_when_pip_none(self):
        """_check_capability_familiar returns False when pip_familiar is None."""
        from dashboard.routes.world import _check_capability_familiar

        with patch("timmy.familiar.pip_familiar", None):
            result = _check_capability_familiar()

        assert result is False

    def test_check_lightning_returns_false(self):
        """_check_capability_lightning returns False (disabled per health.py)."""
        from dashboard.routes.world import _check_capability_lightning

        result = _check_capability_lightning()

        assert result is False

    def test_health_response_time_is_fast(self, matrix_client):
        """Health endpoint responds quickly (<100ms for lightweight checks)."""
        import time

        start = time.time()
        resp = matrix_client.get("/api/matrix/health")
        elapsed = time.time() - start

        assert resp.status_code == 200
        # Should be very fast since checks are lightweight
        assert elapsed < 1.0  # Generous timeout for test environments
