"""Tests for engram MCP server: tool registration, daemon communication, proj_tag resolution."""

from __future__ import annotations

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from claude_engram.mcp_server import (
    _resolve_proj_tag,
    mcp,
)

# ---------------------------------------------------------------------------
# _resolve_proj_tag tests
# ---------------------------------------------------------------------------


class TestResolveProjectTag:
    """Tests for _resolve_proj_tag helper."""

    def test_explicit_param_takes_priority(self) -> None:
        assert _resolve_proj_tag("proj:my/repo") == "proj:my/repo"

    def test_explicit_param_strips_whitespace(self) -> None:
        assert _resolve_proj_tag("  proj:my/repo  ") == "proj:my/repo"

    def test_empty_string_falls_through(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("STAR_PROJECT_TAG", raising=False)
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        assert _resolve_proj_tag("") is None

    def test_none_falls_through(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("STAR_PROJECT_TAG", raising=False)
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        assert _resolve_proj_tag(None) is None

    def test_grid_project_tag_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:org/repo")
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        assert _resolve_proj_tag(None) == "proj:org/repo"

    def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:env/repo")
        assert _resolve_proj_tag("proj:explicit/repo") == "proj:explicit/repo"

    def test_claude_project_dir_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("STAR_PROJECT_TAG", raising=False)
        monkeypatch.setenv("CLAUDE_PROJECT_DIR", "/home/user/projects/my-app")
        assert _resolve_proj_tag(None) == "proj:my-app"

    def test_grid_project_tag_over_claude_project_dir(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:from-grid")
        monkeypatch.setenv("CLAUDE_PROJECT_DIR", "/home/user/projects/from-dir")
        assert _resolve_proj_tag(None) == "proj:from-grid"

    def test_no_env_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("STAR_PROJECT_TAG", raising=False)
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        assert _resolve_proj_tag(None) is None


# ---------------------------------------------------------------------------
# Tool registration tests
# ---------------------------------------------------------------------------


class TestToolRegistration:
    """Tests that all MCP tools are registered with correct schemas."""

    @pytest.mark.asyncio()
    async def test_twenty_tools_registered(self) -> None:
        tools = await mcp.list_tools()
        names = {t.name for t in tools}
        assert names == {
            "search_memories",
            "store_memory",
            "recent_memories",
            "delete_memory",
            "health_check",
            "update_counter",
            "get_counters",
            "set_counter",
            "delete_counter",
            "expand_memory",
            "get_memories_batch",
            "consolidate_memories",
            "cache_stats",
            "cache_clear",
            "add_relation",
            "get_relations",
            "traverse_graph",
            "timeline",
            "record_outcome",
            "decision_chain",
        }

    @pytest.mark.asyncio()
    async def test_search_memories_requires_query(self) -> None:
        tools = await mcp.list_tools()
        search = next(t for t in tools if t.name == "search_memories")
        assert "query" in search.inputSchema.get("required", [])

    @pytest.mark.asyncio()
    async def test_store_memory_requires_content(self) -> None:
        tools = await mcp.list_tools()
        store = next(t for t in tools if t.name == "store_memory")
        assert "content" in store.inputSchema.get("required", [])

    @pytest.mark.asyncio()
    async def test_recent_memories_has_no_required_params(self) -> None:
        tools = await mcp.list_tools()
        recent = next(t for t in tools if t.name == "recent_memories")
        assert "required" not in recent.inputSchema

    @pytest.mark.asyncio()
    async def test_delete_memory_requires_memory_id(self) -> None:
        tools = await mcp.list_tools()
        delete = next(t for t in tools if t.name == "delete_memory")
        assert "memory_id" in delete.inputSchema.get("required", [])


# ---------------------------------------------------------------------------
# search_memories tests
# ---------------------------------------------------------------------------


class TestSearchMemoriesTool:
    """Tests for search_memories tool."""

    @pytest.mark.asyncio()
    async def test_returns_formatted_results(self) -> None:
        mock_response = {
            "results": [
                {
                    "id": 1,
                    "content": "Use dataclasses for all models",
                    "category": "decision",
                    "similarity": 0.92,
                    "created_at": "2026-01-29T10:00:00",
                    "proj_tag": "proj:org/repo",
                    "metadata": {},
                },
            ]
        }
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value=mock_response,
        ):
            content_blocks, _ = await mcp.call_tool("search_memories", {"query": "dataclasses"})
            text = content_blocks[0].text
            assert '<memories count="1">' in text
            assert 'similarity="92%"' in text
            assert 'category="decision"' in text
            assert "dataclasses" in text

    @pytest.mark.asyncio()
    async def test_no_results_message(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"results": []},
        ):
            content_blocks, _ = await mcp.call_tool("search_memories", {"query": "nonexistent"})
            assert "No memories found" in content_blocks[0].text

    @pytest.mark.asyncio()
    async def test_daemon_error_returns_text(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"error": "Internal error"},
        ):
            content_blocks, _ = await mcp.call_tool("search_memories", {"query": "test"})
            assert "Search failed" in content_blocks[0].text

    @pytest.mark.asyncio()
    async def test_daemon_unavailable_returns_text(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            side_effect=ConnectionError("Connection refused"),
        ):
            content_blocks, _ = await mcp.call_tool("search_memories", {"query": "test"})
            text = content_blocks[0].text
            assert "unavailable" in text
            assert "engram daemon" in text

    @pytest.mark.asyncio()
    async def test_passes_category_filter(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"results": []},
        ) as mock_send:
            await mcp.call_tool("search_memories", {"query": "test", "category": "lesson"})
            _, kwargs = mock_send.call_args
            assert kwargs["category"] == "lesson"


# ---------------------------------------------------------------------------
# store_memory tests
# ---------------------------------------------------------------------------


class TestStoreMemoryTool:
    """Tests for store_memory tool."""

    @pytest.mark.asyncio()
    async def test_returns_confirmation(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"id": 42, "status": "stored"},
        ):
            content_blocks, _ = await mcp.call_tool(
                "store_memory", {"content": "Test memory", "category": "lesson"}
            )
            text = content_blocks[0].text
            assert 'id="42"' in text
            assert 'category="lesson"' in text

    @pytest.mark.asyncio()
    async def test_metadata_dict_passed_to_daemon(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"id": 43, "status": "stored"},
        ) as mock_send:
            await mcp.call_tool(
                "store_memory",
                {
                    "content": "File owned",
                    "category": "file_ownership",
                    "metadata": {"file": "/src/main.py"},
                },
            )
            _, kwargs = mock_send.call_args
            assert kwargs["metadata"] == {"file": "/src/main.py"}

    @pytest.mark.asyncio()
    async def test_daemon_unavailable_returns_text(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            side_effect=ConnectionError("Connection refused"),
        ):
            content_blocks, _ = await mcp.call_tool(
                "store_memory", {"content": "Test", "category": "lesson"}
            )
            assert "unavailable" in content_blocks[0].text

    @pytest.mark.asyncio()
    async def test_default_category_is_lesson(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"id": 1, "status": "stored"},
        ) as mock_send:
            await mcp.call_tool("store_memory", {"content": "A lesson"})
            _, kwargs = mock_send.call_args
            assert kwargs["category"] == "lesson"


# ---------------------------------------------------------------------------
# recent_memories tests
# ---------------------------------------------------------------------------


class TestRecentMemoriesTool:
    """Tests for recent_memories tool."""

    @pytest.mark.asyncio()
    async def test_returns_formatted_list(self) -> None:
        mock_response = {
            "results": [
                {
                    "id": 10,
                    "content": "Recent decision",
                    "category": "decision",
                    "created_at": "2026-01-29T10:00:00",
                    "proj_tag": None,
                    "metadata": {},
                },
                {
                    "id": 9,
                    "content": "Older lesson",
                    "category": "lesson",
                    "created_at": "2026-01-28T10:00:00",
                    "proj_tag": "proj:org/repo",
                    "metadata": {},
                },
            ]
        }
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value=mock_response,
        ):
            content_blocks, _ = await mcp.call_tool("recent_memories", {})
            text = content_blocks[0].text
            assert '<memories count="2">' in text
            assert 'category="decision"' in text
            assert 'category="lesson"' in text
            assert 'scope="global"' in text  # None proj_tag renders as "global"

    @pytest.mark.asyncio()
    async def test_no_results_message(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"results": []},
        ):
            content_blocks, _ = await mcp.call_tool("recent_memories", {})
            assert "No recent memories" in content_blocks[0].text

    @pytest.mark.asyncio()
    async def test_passes_category_and_limit(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"results": []},
        ) as mock_send:
            await mcp.call_tool("recent_memories", {"category": "lesson", "limit": 3})
            _, kwargs = mock_send.call_args
            assert kwargs["category"] == "lesson"
            assert kwargs["limit"] == 3


# ---------------------------------------------------------------------------
# proj_tag passthrough tests
# ---------------------------------------------------------------------------


class TestProjectTagPassthrough:
    """Tests that proj_tag resolution flows through all tools correctly."""

    @pytest.mark.asyncio()
    async def test_search_passes_resolved_env_tag(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:test/repo")
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"results": []},
        ) as mock_send:
            await mcp.call_tool("search_memories", {"query": "test"})
            _, kwargs = mock_send.call_args
            assert kwargs["proj_tag"] == "proj:test/repo"

    @pytest.mark.asyncio()
    async def test_explicit_proj_tag_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:env/repo")
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"results": []},
        ) as mock_send:
            await mcp.call_tool(
                "search_memories",
                {"query": "test", "proj_tag": "proj:explicit/repo"},
            )
            _, kwargs = mock_send.call_args
            assert kwargs["proj_tag"] == "proj:explicit/repo"

    @pytest.mark.asyncio()
    async def test_store_passes_resolved_env_tag(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:test/repo")
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"id": 1, "status": "stored"},
        ) as mock_send:
            await mcp.call_tool("store_memory", {"content": "test"})
            _, kwargs = mock_send.call_args
            assert kwargs["proj_tag"] == "proj:test/repo"

    @pytest.mark.asyncio()
    async def test_no_proj_tag_when_env_unset(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("STAR_PROJECT_TAG", raising=False)
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"results": []},
        ) as mock_send:
            await mcp.call_tool("search_memories", {"query": "test"})
            _, kwargs = mock_send.call_args
            assert "proj_tag" not in kwargs


# ---------------------------------------------------------------------------
# delete_memory tests
# ---------------------------------------------------------------------------


class TestDeleteMemory:
    """Tests for delete_memory tool."""

    @pytest.mark.asyncio()
    async def test_calls_daemon_with_delete_action(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"deleted": True, "status": "ok"},
        ) as mock_send:
            await mcp.call_tool("delete_memory", {"memory_id": 42})
            # proj_tag is resolved from env if CLAUDE_PROJECT_DIR is set
            mock_send.assert_called_once()

    @pytest.mark.asyncio()
    async def test_success_returns_confirmation(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"deleted": True, "status": "ok"},
        ):
            content_blocks, _ = await mcp.call_tool("delete_memory", {"memory_id": 42})
            text = content_blocks[0].text
            assert 'id="42"' in text
            assert 'status="success"' in text

    @pytest.mark.asyncio()
    async def test_not_found_returns_message(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"deleted": False, "status": "ok"},
        ):
            content_blocks, _ = await mcp.call_tool("delete_memory", {"memory_id": 999})
            text = content_blocks[0].text
            assert 'id="999"' in text
            assert 'status="not_found"' in text

    @pytest.mark.asyncio()
    async def test_daemon_error_returns_message(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"error": "Permission denied"},
        ):
            content_blocks, _ = await mcp.call_tool("delete_memory", {"memory_id": 1})
            text = content_blocks[0].text
            assert "Delete failed" in text
            assert "Permission denied" in text

    @pytest.mark.asyncio()
    async def test_daemon_unavailable_returns_text(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            side_effect=ConnectionError("Connection refused"),
        ):
            content_blocks, _ = await mcp.call_tool("delete_memory", {"memory_id": 1})
            text = content_blocks[0].text
            assert "unavailable" in text
            assert "engram daemon" in text

    @pytest.mark.asyncio()
    async def test_passes_resolved_proj_tag_to_daemon(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """delete_memory should resolve proj_tag and pass it to daemon."""
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:test/repo")
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"deleted": True, "status": "ok"},
        ) as mock_send:
            await mcp.call_tool("delete_memory", {"memory_id": 42})
            _, kwargs = mock_send.call_args
            assert kwargs["proj_tag"] == "proj:test/repo"

    @pytest.mark.asyncio()
    async def test_explicit_proj_tag_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Explicit proj_tag parameter should override env-resolved tag."""
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:env/repo")
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"deleted": True, "status": "ok"},
        ) as mock_send:
            await mcp.call_tool(
                "delete_memory", {"memory_id": 42, "proj_tag": "proj:explicit/repo"}
            )
            _, kwargs = mock_send.call_args
            assert kwargs["proj_tag"] == "proj:explicit/repo"

    @pytest.mark.asyncio()
    async def test_no_proj_tag_when_env_unset(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Without env vars and no explicit param, proj_tag should not be sent."""
        monkeypatch.delenv("STAR_PROJECT_TAG", raising=False)
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"deleted": True, "status": "ok"},
        ) as mock_send:
            await mcp.call_tool("delete_memory", {"memory_id": 42})
            _, kwargs = mock_send.call_args
            # proj_tag should not be in kwargs when unresolvable
            assert "proj_tag" not in kwargs


# ---------------------------------------------------------------------------
# H2: MCP server socket timeout tests
# ---------------------------------------------------------------------------


class TestSendToDaemonTimeout:
    """Tests for _send_to_daemon timeout handling."""

    @pytest.mark.asyncio()
    async def test_connection_timeout_returns_error(self) -> None:
        """Connection timeout returns error dict via _send_to_daemon."""
        from claude_engram.mcp_server import _send_to_daemon

        # Patch open_unix_connection to a no-op coroutine so no real socket is created.
        async def _fake_conn(*a, **k):  # noqa: ARG001
            pass

        def _timeout_wait_for(coro, *, timeout=None):  # noqa: ARG001
            # Close the coroutine to prevent "never awaited" RuntimeWarning
            if asyncio.iscoroutine(coro):
                coro.close()
            raise TimeoutError

        with (
            patch("claude_engram.mcp_server.asyncio.open_unix_connection", _fake_conn),
            patch("claude_engram.mcp_server.asyncio.wait_for", side_effect=_timeout_wait_for),
        ):
            result = await _send_to_daemon("ping")
            assert result["status"] == "error"
            assert "timed out" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_read_timeout_returns_error(self) -> None:
        """Read timeout returns error dict via _send_to_daemon."""
        from claude_engram.mcp_server import _send_to_daemon

        mw = MagicMock()
        mw.write = MagicMock()
        mw.close = MagicMock()
        mw.wait_closed = AsyncMock()
        mw.drain = AsyncMock()
        mr = MagicMock()

        async def _slow_readline():
            await asyncio.sleep(999)

        mr.readline = _slow_readline

        # Patch open_unix_connection to return our mocks (avoids creating a real
        # unawaited coroutine). The reader's slow readline triggers the wait_for
        # timeout in _send_to_daemon. We also reduce the timeout to keep tests fast.
        async def _fake_conn(*a, **k):  # noqa: ARG001
            return (mr, mw)

        original_wait_for = asyncio.wait_for

        async def _fast_wait_for(coro, *, timeout=None):  # noqa: ARG001
            # Use a very short timeout for the test regardless of what _send_to_daemon passes
            return await original_wait_for(coro, timeout=0.05)

        with (
            patch("claude_engram.mcp_server.asyncio.open_unix_connection", _fake_conn),
            patch("claude_engram.mcp_server.asyncio.wait_for", _fast_wait_for),
        ):
            result = await _send_to_daemon("ping")
            assert result["status"] == "error"
            assert "timed out" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_empty_response_returns_error(self) -> None:
        """Empty response returns error dict."""
        mw = MagicMock()
        mw.write = MagicMock()
        mw.close = MagicMock()
        mw.wait_closed = AsyncMock()
        mw.drain = AsyncMock()
        mr = AsyncMock()
        mr.readline = AsyncMock(return_value=b"")

        mock_conn = AsyncMock(return_value=(mr, mw))
        with patch(
            "claude_engram.mcp_server.asyncio.open_unix_connection",
            new=mock_conn,
        ):
            from claude_engram.mcp_server import _send_to_daemon

            result = await _send_to_daemon("ping")
            assert result["status"] == "error"
            assert "empty" in result["error"].lower()


# ---------------------------------------------------------------------------
# update_counter MCP tool tests
# ---------------------------------------------------------------------------


class TestUpdateCounterTool:
    """Tests for update_counter MCP tool."""

    @pytest.mark.asyncio()
    async def test_update_counter_requires_key(self) -> None:
        tools = await mcp.list_tools()
        tool = next(t for t in tools if t.name == "update_counter")
        assert "key" in tool.inputSchema.get("required", [])

    @pytest.mark.asyncio()
    async def test_update_counter_returns_confirmation(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "key": "serena", "value": 5, "proj_tag": ""},
        ):
            content_blocks, _ = await mcp.call_tool("update_counter", {"key": "serena", "delta": 1})
            text = content_blocks[0].text
            assert "serena" in text
            assert "5" in text

    @pytest.mark.asyncio()
    async def test_update_counter_daemon_error(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "error", "error": "Missing key"},
        ):
            content_blocks, _ = await mcp.call_tool("update_counter", {"key": "test"})
            text = content_blocks[0].text
            assert "failed" in text.lower()

    @pytest.mark.asyncio()
    async def test_update_counter_daemon_unavailable(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            side_effect=ConnectionError("Connection refused"),
        ):
            content_blocks, _ = await mcp.call_tool("update_counter", {"key": "test"})
            text = content_blocks[0].text
            assert "unavailable" in text


# ---------------------------------------------------------------------------
# get_counters MCP tool tests
# ---------------------------------------------------------------------------


class TestGetCountersTool:
    """Tests for get_counters MCP tool."""

    @pytest.mark.asyncio()
    async def test_get_counters_no_required_params(self) -> None:
        tools = await mcp.list_tools()
        tool = next(t for t in tools if t.name == "get_counters")
        assert "required" not in tool.inputSchema

    @pytest.mark.asyncio()
    async def test_get_counters_single_key(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "key": "serena", "value": 42},
        ):
            content_blocks, _ = await mcp.call_tool("get_counters", {"key": "serena"})
            text = content_blocks[0].text
            assert "serena" in text
            assert "42" in text

    @pytest.mark.asyncio()
    async def test_get_counters_all(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "counters": {"a": 1, "b": 2}},
        ):
            content_blocks, _ = await mcp.call_tool("get_counters", {})
            text = content_blocks[0].text
            assert "a" in text
            assert "b" in text

    @pytest.mark.asyncio()
    async def test_get_counters_empty(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "counters": {}},
        ):
            content_blocks, _ = await mcp.call_tool("get_counters", {})
            text = content_blocks[0].text
            assert 'count="0"' in text


# ---------------------------------------------------------------------------
# set_counter MCP tool tests
# ---------------------------------------------------------------------------


class TestSetCounterTool:
    """Tests for set_counter MCP tool."""

    @pytest.mark.asyncio()
    async def test_set_counter_requires_key_and_value(self) -> None:
        tools = await mcp.list_tools()
        tool = next(t for t in tools if t.name == "set_counter")
        required = tool.inputSchema.get("required", [])
        assert "key" in required
        assert "value" in required

    @pytest.mark.asyncio()
    async def test_set_counter_returns_confirmation(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "key": "metric", "value": 42, "proj_tag": ""},
        ):
            content_blocks, _ = await mcp.call_tool("set_counter", {"key": "metric", "value": 42})
            text = content_blocks[0].text
            assert "metric" in text
            assert "42" in text

    @pytest.mark.asyncio()
    async def test_set_counter_daemon_error(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "error", "error": "Missing key"},
        ):
            content_blocks, _ = await mcp.call_tool("set_counter", {"key": "test", "value": 10})
            text = content_blocks[0].text
            assert "failed" in text.lower()

    @pytest.mark.asyncio()
    async def test_set_counter_daemon_unavailable(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            side_effect=ConnectionError("Connection refused"),
        ):
            content_blocks, _ = await mcp.call_tool("set_counter", {"key": "test", "value": 10})
            text = content_blocks[0].text
            assert "unavailable" in text

    @pytest.mark.asyncio()
    async def test_set_counter_passes_proj_tag(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:test/repo")
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "key": "k", "value": 1, "proj_tag": "proj:test/repo"},
        ) as mock_send:
            await mcp.call_tool("set_counter", {"key": "k", "value": 1})
            _, kwargs = mock_send.call_args
            assert kwargs["proj_tag"] == "proj:test/repo"


# ---------------------------------------------------------------------------
# delete_counter MCP tool tests
# ---------------------------------------------------------------------------


class TestDeleteCounterTool:
    """Tests for delete_counter MCP tool."""

    @pytest.mark.asyncio()
    async def test_delete_counter_requires_key(self) -> None:
        tools = await mcp.list_tools()
        tool = next(t for t in tools if t.name == "delete_counter")
        assert "key" in tool.inputSchema.get("required", [])

    @pytest.mark.asyncio()
    async def test_delete_counter_success(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "deleted": True},
        ):
            content_blocks, _ = await mcp.call_tool("delete_counter", {"key": "old_metric"})
            text = content_blocks[0].text
            assert "old_metric" in text
            assert "deleted" in text.lower()

    @pytest.mark.asyncio()
    async def test_delete_counter_not_found(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "deleted": False},
        ):
            content_blocks, _ = await mcp.call_tool("delete_counter", {"key": "ghost"})
            text = content_blocks[0].text
            assert 'key="ghost"' in text
            assert 'status="not_found"' in text

    @pytest.mark.asyncio()
    async def test_delete_counter_daemon_error(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "error", "error": "Internal error"},
        ):
            content_blocks, _ = await mcp.call_tool("delete_counter", {"key": "test"})
            text = content_blocks[0].text
            assert "failed" in text.lower()

    @pytest.mark.asyncio()
    async def test_delete_counter_daemon_unavailable(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            side_effect=ConnectionError("Connection refused"),
        ):
            content_blocks, _ = await mcp.call_tool("delete_counter", {"key": "test"})
            text = content_blocks[0].text
            assert "unavailable" in text

    @pytest.mark.asyncio()
    async def test_delete_counter_passes_proj_tag(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:test/repo")
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "deleted": True},
        ) as mock_send:
            await mcp.call_tool("delete_counter", {"key": "k"})
            _, kwargs = mock_send.call_args
            assert kwargs["proj_tag"] == "proj:test/repo"


# ---------------------------------------------------------------------------
# XML output format tests
# ---------------------------------------------------------------------------


class TestXmlOutputFormat:
    """Tests for XML-structured output from search_memories and recent_memories."""

    @pytest.mark.asyncio()
    async def test_search_memories_xml_structure(self) -> None:
        """search_memories returns XML with <memories> root and <memory> children."""
        mock_response = {
            "results": [
                {
                    "id": 1,
                    "content": "Use dataclasses for all models",
                    "category": "decision",
                    "similarity": 0.92,
                    "created_at": "2026-01-29T10:00:00",
                    "proj_tag": "proj:org/repo",
                    "metadata": {},
                },
                {
                    "id": 2,
                    "content": "Always validate input",
                    "category": "lesson",
                    "similarity": 0.85,
                    "created_at": "2026-01-28T08:00:00",
                    "proj_tag": None,
                    "metadata": {},
                },
            ]
        }
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value=mock_response,
        ):
            content_blocks, _ = await mcp.call_tool("search_memories", {"query": "models"})
            text = content_blocks[0].text

            # Root element
            assert '<memories count="2">' in text
            assert "</memories>" in text

            # First memory with all attributes including scope from proj_tag
            assert 'id="1"' in text
            assert 'category="decision"' in text
            assert 'similarity="92%"' in text
            assert 'scope="proj:org/repo"' in text
            assert 'created="2026-01-29T10:00:00"' in text
            assert "Use dataclasses for all models" in text
            assert "</memory>" in text

            # Second memory with scope="global" when proj_tag is None
            assert 'id="2"' in text
            assert 'similarity="85%"' in text
            assert 'scope="global"' in text
            assert "Always validate input" in text

    @pytest.mark.asyncio()
    async def test_recent_memories_xml_structure(self) -> None:
        """recent_memories returns XML without similarity attribute."""
        mock_response = {
            "results": [
                {
                    "id": 10,
                    "content": "Recent decision content",
                    "category": "decision",
                    "created_at": "2026-01-29T10:00:00",
                    "proj_tag": None,
                    "metadata": {},
                },
                {
                    "id": 9,
                    "content": "Older lesson content",
                    "category": "lesson",
                    "created_at": "2026-01-28T10:00:00",
                    "proj_tag": "proj:org/repo",
                    "metadata": {},
                },
            ]
        }
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value=mock_response,
        ):
            content_blocks, _ = await mcp.call_tool("recent_memories", {})
            text = content_blocks[0].text

            # Root element
            assert '<memories count="2">' in text
            assert "</memories>" in text

            # First memory WITHOUT similarity attribute
            assert 'id="10"' in text
            assert 'category="decision"' in text
            assert 'scope="global"' in text
            assert 'created="2026-01-29T10:00:00"' in text
            assert "Recent decision content" in text

            # Second memory
            assert 'id="9"' in text
            assert 'category="lesson"' in text
            assert 'scope="proj:org/repo"' in text
            assert "Older lesson content" in text

            # Verify no similarity attribute anywhere (recent has no similarity)
            assert "similarity=" not in text

    @pytest.mark.asyncio()
    async def test_search_memories_xml_escapes_content(self) -> None:
        """XML-special characters in content are properly escaped."""
        mock_response = {
            "results": [
                {
                    "id": 5,
                    "content": 'Use <script> tag & "quotes" for <b>bold</b>',
                    "category": "lesson",
                    "similarity": 0.75,
                    "created_at": "2026-01-29T10:00:00",
                    "proj_tag": None,
                    "metadata": {},
                },
            ]
        }
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value=mock_response,
        ):
            content_blocks, _ = await mcp.call_tool("search_memories", {"query": "script"})
            text = content_blocks[0].text

            # Content should be escaped so XML structure is not broken
            assert "&lt;script&gt;" in text
            assert "&amp;" in text
            assert "&lt;b&gt;bold&lt;/b&gt;" in text

            # The XML structure tags should NOT be escaped
            assert "<memories" in text
            assert "<memory" in text
            assert "</memory>" in text
            assert "</memories>" in text

    @pytest.mark.asyncio()
    async def test_search_empty_still_plain_text(self) -> None:
        """Empty search results return plain text, not XML."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"results": []},
        ):
            content_blocks, _ = await mcp.call_tool("search_memories", {"query": "nonexistent"})
            text = content_blocks[0].text
            assert "No memories found" in text
            assert "<memories" not in text

    @pytest.mark.asyncio()
    async def test_recent_empty_still_plain_text(self) -> None:
        """Empty recent results return plain text, not XML."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"results": []},
        ):
            content_blocks, _ = await mcp.call_tool("recent_memories", {})
            text = content_blocks[0].text
            assert "No recent memories" in text
            assert "<memories" not in text


# ---------------------------------------------------------------------------
# XML output for store_memory, delete_memory, health_check, counters
# ---------------------------------------------------------------------------


class TestStoreMemoryXmlOutput:
    """Tests for XML-structured output from store_memory."""

    @pytest.mark.asyncio()
    async def test_store_memory_returns_xml(self) -> None:
        """store_memory returns XML with <memory_stored> root."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"id": 42, "status": "stored"},
        ):
            content_blocks, _ = await mcp.call_tool(
                "store_memory", {"content": "Test memory", "category": "lesson"}
            )
            text = content_blocks[0].text
            assert '<memory_stored id="42"' in text
            assert 'category="lesson"' in text
            assert "</memory_stored>" in text

    @pytest.mark.asyncio()
    async def test_store_memory_includes_scope(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """store_memory XML includes scope attribute."""
        monkeypatch.setenv("STAR_PROJECT_TAG", "proj:test/repo")
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"id": 1, "status": "stored"},
        ):
            content_blocks, _ = await mcp.call_tool("store_memory", {"content": "Test"})
            text = content_blocks[0].text
            assert 'scope="proj:test/repo"' in text

    @pytest.mark.asyncio()
    async def test_store_memory_global_scope(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """store_memory XML shows global scope when no proj_tag."""
        monkeypatch.delenv("STAR_PROJECT_TAG", raising=False)
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"id": 1, "status": "stored"},
        ):
            content_blocks, _ = await mcp.call_tool("store_memory", {"content": "Test"})
            text = content_blocks[0].text
            assert 'scope="global"' in text


class TestDeleteMemoryXmlOutput:
    """Tests for XML-structured output from delete_memory."""

    @pytest.mark.asyncio()
    async def test_delete_memory_success_returns_xml(self) -> None:
        """delete_memory returns XML with <memory_deleted> on success."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"deleted": True, "status": "ok"},
        ):
            content_blocks, _ = await mcp.call_tool("delete_memory", {"memory_id": 42})
            text = content_blocks[0].text
            assert '<memory_deleted id="42"' in text
            assert 'status="success"' in text
            assert "</memory_deleted>" in text

    @pytest.mark.asyncio()
    async def test_delete_memory_not_found_returns_xml(self) -> None:
        """delete_memory returns XML with status=not_found when not found."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"deleted": False, "status": "ok"},
        ):
            content_blocks, _ = await mcp.call_tool("delete_memory", {"memory_id": 999})
            text = content_blocks[0].text
            assert '<memory_deleted id="999"' in text
            assert 'status="not_found"' in text
            assert "</memory_deleted>" in text


class TestHealthCheckXmlOutput:
    """Tests for XML-structured output from health_check."""

    @pytest.mark.asyncio()
    async def test_health_check_returns_xml(self) -> None:
        """health_check returns XML with <health_status> root."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={
                "health": {
                    "db_size_bytes": 1048576,
                    "memory_count": 100,
                    "wal_size_bytes": 4096,
                    "socket_path": "/tmp/engram.sock",
                    "socket_exists": True,
                    "pid": 12345,
                    "pid_file_exists": True,
                    "embedding_model_loaded": True,
                    "cache_size": 50,
                    "checkpoint_sessions": 2,
                }
            },
        ):
            content_blocks, _ = await mcp.call_tool("health_check", {})
            text = content_blocks[0].text
            assert "<health_status>" in text
            assert "</health_status>" in text
            assert "<database" in text
            assert 'memories="100"' in text
            assert "<socket" in text
            assert "<embedding_model" in text


class TestCountersXmlOutput:
    """Tests for XML-structured output from counter tools."""

    @pytest.mark.asyncio()
    async def test_update_counter_returns_xml(self) -> None:
        """update_counter returns XML with <counter> element."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "key": "serena", "value": 5, "proj_tag": ""},
        ):
            content_blocks, _ = await mcp.call_tool("update_counter", {"key": "serena", "delta": 1})
            text = content_blocks[0].text
            assert '<counter key="serena"' in text
            assert 'value="5"' in text
            assert "</counter>" in text

    @pytest.mark.asyncio()
    async def test_get_counters_single_returns_xml(self) -> None:
        """get_counters with key returns XML with single <counter>."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "key": "serena", "value": 42},
        ):
            content_blocks, _ = await mcp.call_tool("get_counters", {"key": "serena"})
            text = content_blocks[0].text
            assert '<counter key="serena"' in text
            assert 'value="42"' in text

    @pytest.mark.asyncio()
    async def test_get_counters_all_returns_xml(self) -> None:
        """get_counters without key returns XML with <counters> root."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "counters": {"a": 1, "b": 2}},
        ):
            content_blocks, _ = await mcp.call_tool("get_counters", {})
            text = content_blocks[0].text
            assert "<counters" in text
            assert "</counters>" in text
            assert '<counter key="a" value="1"/>' in text
            assert '<counter key="b" value="2"/>' in text

    @pytest.mark.asyncio()
    async def test_set_counter_returns_xml(self) -> None:
        """set_counter returns XML with <counter> element."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "key": "metric", "value": 100},
        ):
            content_blocks, _ = await mcp.call_tool("set_counter", {"key": "metric", "value": 100})
            text = content_blocks[0].text
            assert '<counter key="metric"' in text
            assert 'value="100"' in text

    @pytest.mark.asyncio()
    async def test_delete_counter_success_returns_xml(self) -> None:
        """delete_counter returns XML with status=deleted on success."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "deleted": True},
        ):
            content_blocks, _ = await mcp.call_tool("delete_counter", {"key": "old"})
            text = content_blocks[0].text
            assert '<counter_deleted key="old"' in text
            assert 'status="deleted"' in text

    @pytest.mark.asyncio()
    async def test_delete_counter_not_found_returns_xml(self) -> None:
        """delete_counter returns XML with status=not_found when not found."""
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "deleted": False},
        ):
            content_blocks, _ = await mcp.call_tool("delete_counter", {"key": "ghost"})
            text = content_blocks[0].text
            assert '<counter_deleted key="ghost"' in text
            assert 'status="not_found"' in text


# ---------------------------------------------------------------------------
# Decision→outcome tracking MCP tool tests (Phase 13b)
# ---------------------------------------------------------------------------


class TestRecordOutcomeTool:
    """Tests for record_outcome MCP tool."""

    @pytest.mark.asyncio()
    async def test_record_outcome_requires_decision_and_outcome_ids(self) -> None:
        tools = await mcp.list_tools()
        tool = next(t for t in tools if t.name == "record_outcome")
        required = tool.inputSchema.get("required", [])
        assert "decision_id" in required
        assert "outcome_id" in required

    @pytest.mark.asyncio()
    async def test_record_outcome_returns_xml(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "relation_id": 42, "created": True},
        ):
            content_blocks, _ = await mcp.call_tool(
                "record_outcome",
                {"decision_id": 1, "outcome_id": 2, "confidence": 0.85},
            )
            text = content_blocks[0].text
            assert "<outcome_recorded" in text
            assert 'decision="1"' in text
            assert 'outcome="2"' in text
            assert 'relation_id="42"' in text
            assert 'confidence="0.85"' in text
            assert 'status="created"' in text

    @pytest.mark.asyncio()
    async def test_record_outcome_already_exists(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "relation_id": None, "created": False},
        ):
            content_blocks, _ = await mcp.call_tool(
                "record_outcome",
                {"decision_id": 1, "outcome_id": 2},
            )
            text = content_blocks[0].text
            assert 'status="already_exists"' in text

    @pytest.mark.asyncio()
    async def test_record_outcome_error(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"error": "confidence must be between 0.0 and 1.0"},
        ):
            content_blocks, _ = await mcp.call_tool(
                "record_outcome",
                {"decision_id": 1, "outcome_id": 2, "confidence": 1.5},
            )
            text = content_blocks[0].text
            assert "Record outcome failed" in text

    @pytest.mark.asyncio()
    async def test_record_outcome_daemon_unavailable(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            side_effect=ConnectionError("Connection refused"),
        ):
            content_blocks, _ = await mcp.call_tool(
                "record_outcome",
                {"decision_id": 1, "outcome_id": 2},
            )
            text = content_blocks[0].text
            assert "unavailable" in text

    @pytest.mark.asyncio()
    async def test_record_outcome_sends_optional_fields(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"status": "ok", "relation_id": 99, "created": True},
        ) as mock_send:
            await mcp.call_tool(
                "record_outcome",
                {
                    "decision_id": 1,
                    "outcome_id": 2,
                    "confidence": 0.75,
                    "measurement_method": "user_confirmed",
                    "impact_score": 0.6,
                    "measured_at": "2026-02-12T10:00:00Z",
                    "causation_id": 1,
                    "correlation_id": "exp-001",
                },
            )
            _, kwargs = mock_send.call_args
            assert kwargs["measurement_method"] == "user_confirmed"
            assert kwargs["impact_score"] == 0.6
            assert kwargs["causation_id"] == 1
            assert kwargs["correlation_id"] == "exp-001"


class TestDecisionChainTool:
    """Tests for decision_chain MCP tool."""

    @pytest.mark.asyncio()
    async def test_decision_chain_requires_start_id(self) -> None:
        tools = await mcp.list_tools()
        tool = next(t for t in tools if t.name == "decision_chain")
        required = tool.inputSchema.get("required", [])
        assert "start_id" in required

    @pytest.mark.asyncio()
    async def test_decision_chain_returns_xml(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={
                "status": "ok",
                "start_id": 1,
                "outcomes": [
                    {
                        "memory_id": 2,
                        "depth": 1,
                        "via_id": 1,
                        "confidence": 0.85,
                        "impact_score": 0.7,
                        "summary": "Outcome A summary",
                        "category": "outcome",
                    },
                    {
                        "memory_id": 3,
                        "depth": 2,
                        "via_id": 2,
                        "confidence": 0.6,
                        "impact_score": None,
                        "summary": "Cascading outcome",
                        "category": "outcome",
                    },
                ],
                "max_depth": 2,
            },
        ):
            content_blocks, _ = await mcp.call_tool("decision_chain", {"start_id": 1, "depth": 3})
            text = content_blocks[0].text
            assert '<decision_chain start="1"' in text
            assert 'outcomes="2"' in text
            assert 'max_depth="2"' in text
            assert '<outcome id="2"' in text
            assert 'confidence="0.85"' in text
            assert 'impact="0.7"' in text
            assert "Outcome A summary" in text
            assert '<outcome id="3"' in text
            assert 'via="2"' in text
            assert "</decision_chain>" in text

    @pytest.mark.asyncio()
    async def test_decision_chain_empty(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={
                "status": "ok",
                "start_id": 99,
                "outcomes": [],
                "max_depth": 0,
            },
        ):
            content_blocks, _ = await mcp.call_tool("decision_chain", {"start_id": 99})
            text = content_blocks[0].text
            assert '<decision_chain start="99"' in text
            assert 'outcomes="0"' in text
            assert "No outcomes found" in text

    @pytest.mark.asyncio()
    async def test_decision_chain_error(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            return_value={"error": "Missing required field: start_id"},
        ):
            content_blocks, _ = await mcp.call_tool("decision_chain", {"start_id": 1})
            text = content_blocks[0].text
            assert "Decision chain failed" in text

    @pytest.mark.asyncio()
    async def test_decision_chain_daemon_unavailable(self) -> None:
        with patch(
            "claude_engram.mcp_server._send_to_daemon",
            new_callable=AsyncMock,
            side_effect=ConnectionError("Connection refused"),
        ):
            content_blocks, _ = await mcp.call_tool("decision_chain", {"start_id": 1})
            text = content_blocks[0].text
            assert "unavailable" in text
