"""
tests/test_mcp_server.py — Unit tests for cortex/api/mcp_server.py (M13 MCP)

Strategy:
  - Mock CortexPlugin entirely — no real DB, no real LLM
  - Test every tool handler (all 8 tools)
  - Test both resource handlers
  - Test JSON output shape
  - Test error / degraded-mode paths (None returns from plugin)
  - Test conversation input parsing (plain text vs JSON)
"""
from __future__ import annotations

import asyncio
import json
import logging
import sys
import types
from dataclasses import dataclass, field
from typing import Any, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# Stub return types (mirror CortexPlugin dataclasses without importing cortex)
# ---------------------------------------------------------------------------

@dataclass
class _RememberResult:
    session_id: str
    claims_extracted: int = 0
    claims_stored: int = 0
    entities_resolved: int = 0
    noise_filtered: int = 0
    errors: List[str] = field(default_factory=list)
    ok: bool = True


@dataclass
class _ForgetResult:
    memory_id: str
    deleted: bool = True
    reason: str = "ok"
    errors: List[str] = field(default_factory=list)


@dataclass
class _HealthReport:
    ok: bool = True
    storage: str = "ok"
    modules: dict = field(default_factory=dict)
    errors: List[str] = field(default_factory=list)
    timestamp: str = "2024-01-01T00:00:00"


@dataclass
class _CornerstoneMemory:
    id: str = "cs-123"
    label: str = "test-cornerstone"
    content: str = "Test content"
    sealed_at: str = "2024-01-01T00:00:00"
    content_hash: str = "abc123"


@dataclass
class _RetrievalResult:
    context_block: str = "Relevant memory context here."
    tokens_used: int = 42
    memories: List[Any] = field(default_factory=list)


@dataclass
class _FeedbackRecord:
    id: str = "fb-456"
    memory_id: str = "mem-789"
    helpful: bool = True
    created_at: str = "2024-01-01T00:00:00"


# ---------------------------------------------------------------------------
# Plugin mock factory
# ---------------------------------------------------------------------------

def _make_mock_plugin(
    *,
    remember_result: Optional[_RememberResult] = None,
    forget_result: Optional[_ForgetResult] = None,
    health_result: Optional[_HealthReport] = None,
    cornerstones: Optional[list] = None,
    seal_result: Optional[_CornerstoneMemory] = None,
    unseal_result: Optional[_CornerstoneMemory] = None,
    retrieve_result: Optional[_RetrievalResult] = None,
    feedback_result: Optional[_FeedbackRecord] = None,
) -> MagicMock:
    plugin = MagicMock()
    plugin.remember = AsyncMock(return_value=remember_result or _RememberResult(session_id="sess-1"))
    plugin.forget = AsyncMock(return_value=forget_result or _ForgetResult(memory_id="mem-1"))
    plugin.health = AsyncMock(return_value=health_result or _HealthReport())
    plugin.get_cornerstones = AsyncMock(return_value=cornerstones if cornerstones is not None else [])
    plugin.seal_cornerstone = AsyncMock(return_value=seal_result or _CornerstoneMemory())
    plugin.unseal_cornerstone = AsyncMock(return_value=unseal_result or _CornerstoneMemory(sealed_at=""))
    plugin.retrieve = AsyncMock(return_value=retrieve_result or _RetrievalResult())
    plugin.feedback = AsyncMock(return_value=feedback_result or _FeedbackRecord())
    return plugin


# ---------------------------------------------------------------------------
# Fixture: import mcp_server with CortexPlugin mocked out
# ---------------------------------------------------------------------------

@pytest.fixture()
def mock_plugin():
    """Return a fresh mock plugin for each test."""
    return _make_mock_plugin()


@pytest.fixture()
def mcp_app(mock_plugin):
    """Build the FastMCP app with CortexPlugin stubbed out."""
    # Patch CortexPlugin at the module level that mcp_server imports it from
    with patch("cortex.api.plugin.CortexPlugin", return_value=mock_plugin):
        import cortex.api.mcp_server as srv
        # Force rebuild so the patched plugin is used
        app = srv._build_mcp_server()
        # Inject plugin directly into the internal ref via a fresh build
        # Since _plugin_ref is closure-scoped, we access via the tool manager
        yield app, mock_plugin


# ---------------------------------------------------------------------------
# Helper: call a registered tool by name
# ---------------------------------------------------------------------------

async def _call_tool(app, name: str, **kwargs) -> str:
    """Call a FastMCP tool by name and return its string result.

    FastMCP.call_tool(convert_result=True) returns a 2-tuple:
      (list[ContentBlock], meta_dict)
    where ContentBlock is typically TextContent with a .text attribute.
    """
    result = await app.call_tool(name, kwargs)
    # 2-tuple: (content_list, meta_dict)
    if isinstance(result, tuple) and len(result) == 2:
        content_list, _ = result
        if content_list:
            return getattr(content_list[0], "text", str(content_list[0]))
    # Fallback: list of ContentBlock
    if isinstance(result, list) and result:
        return getattr(result[0], "text", str(result[0]))
    return str(result)


async def _read_resource(app, uri: str) -> str:
    """Read a registered resource by URI and return its content string.

    FastMCP.read_resource returns Iterable[ReadResourceContents].
    Each ReadResourceContents has a .content attribute with the raw text.
    """
    result = await app.read_resource(uri)
    items = list(result)
    if items:
        # ReadResourceContents has .content (str or bytes)
        content = getattr(items[0], "content", None)
        if content is not None:
            return content if isinstance(content, str) else content.decode()
    return str(result)


# ===========================================================================
# Tool tests
# ===========================================================================

class TestCortexRemember:
    @pytest.mark.asyncio
    async def test_plain_text_input(self, mcp_app):
        app, plugin = mcp_app
        plugin.remember = AsyncMock(return_value=_RememberResult(
            session_id="s1", claims_extracted=3, claims_stored=2, ok=True
        ))
        raw = await _call_tool(app, "cortex_remember", conversation="Andrew loves Python")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["claims_extracted"] == 3
        assert data["claims_stored"] == 2
        assert data["session_id"] == "s1"

    @pytest.mark.asyncio
    async def test_json_list_input(self, mcp_app):
        """JSON-encoded message list should be parsed and passed as a list."""
        app, plugin = mcp_app
        messages = [{"role": "user", "content": "I prefer dark mode"}]
        raw = await _call_tool(
            app, "cortex_remember",
            conversation=json.dumps(messages),
        )
        data = json.loads(raw)
        assert data["ok"] is True
        # Verify plugin received a list, not a string
        call_args = plugin.remember.call_args
        passed_conv = call_args.kwargs.get("conversation") or call_args.args[0]
        assert isinstance(passed_conv, list)

    @pytest.mark.asyncio
    async def test_session_id_forwarded(self, mcp_app):
        app, plugin = mcp_app
        await _call_tool(app, "cortex_remember", conversation="test", session_id="my-session")
        call_args = plugin.remember.call_args
        assert call_args.kwargs.get("session_id") == "my-session"

    @pytest.mark.asyncio
    async def test_errors_included_in_output(self, mcp_app):
        app, plugin = mcp_app
        plugin.remember = AsyncMock(return_value=_RememberResult(
            session_id="s2", ok=False, errors=["extraction failed"]
        ))
        raw = await _call_tool(app, "cortex_remember", conversation="text")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "extraction failed" in data["errors"]


class TestCortexRetrieve:
    @pytest.mark.asyncio
    async def test_basic_retrieval(self, mcp_app):
        app, plugin = mcp_app
        plugin.retrieve = AsyncMock(return_value=_RetrievalResult(
            context_block="User prefers Python.", tokens_used=10, memories=["x"]
        ))
        raw = await _call_tool(app, "cortex_retrieve", query="language preference")
        data = json.loads(raw)
        assert data["ok"] is True
        assert "Python" in data["context"]
        assert data["tokens_used"] == 10
        assert data["memories_count"] == 1

    @pytest.mark.asyncio
    async def test_token_budget_forwarded(self, mcp_app):
        app, plugin = mcp_app
        await _call_tool(app, "cortex_retrieve", query="test", token_budget=500)
        call_args = plugin.retrieve.call_args
        assert call_args.kwargs.get("token_budget") == 500

    @pytest.mark.asyncio
    async def test_unavailable_pipeline(self, mcp_app):
        app, plugin = mcp_app
        plugin.retrieve = AsyncMock(return_value=None)
        raw = await _call_tool(app, "cortex_retrieve", query="test")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "unavailable" in data["error"].lower()


class TestCortexForget:
    @pytest.mark.asyncio
    async def test_successful_delete(self, mcp_app):
        app, plugin = mcp_app
        plugin.forget = AsyncMock(return_value=_ForgetResult(
            memory_id="m-123", deleted=True, reason="ok"
        ))
        raw = await _call_tool(app, "cortex_forget", memory_id="m-123")
        data = json.loads(raw)
        assert data["deleted"] is True
        assert data["memory_id"] == "m-123"

    @pytest.mark.asyncio
    async def test_cornerstone_protection(self, mcp_app):
        app, plugin = mcp_app
        plugin.forget = AsyncMock(return_value=_ForgetResult(
            memory_id="cs-1", deleted=False,
            reason="Memory is a sealed cornerstone — unseal first"
        ))
        raw = await _call_tool(app, "cortex_forget", memory_id="cs-1")
        data = json.loads(raw)
        assert data["deleted"] is False
        assert "cornerstone" in data["reason"].lower()

    @pytest.mark.asyncio
    async def test_hard_delete_forwarded(self, mcp_app):
        app, plugin = mcp_app
        await _call_tool(app, "cortex_forget", memory_id="m-1", hard_delete=True)
        call_args = plugin.forget.call_args
        assert call_args.kwargs.get("hard_delete") is True


class TestCortexCornerstonesList:
    @pytest.mark.asyncio
    async def test_empty_list(self, mcp_app):
        app, plugin = mcp_app
        plugin.get_cornerstones = AsyncMock(return_value=[])
        raw = await _call_tool(app, "cortex_cornerstones_list")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["count"] == 0
        assert data["cornerstones"] == []

    @pytest.mark.asyncio
    async def test_with_cornerstones(self, mcp_app):
        app, plugin = mcp_app
        cs = _CornerstoneMemory(id="cs-1", label="Identity", content="I am Eva")
        plugin.get_cornerstones = AsyncMock(return_value=[cs])
        raw = await _call_tool(app, "cortex_cornerstones_list")
        data = json.loads(raw)
        assert data["count"] == 1
        assert data["cornerstones"][0]["id"] == "cs-1"
        assert data["cornerstones"][0]["label"] == "Identity"

    @pytest.mark.asyncio
    async def test_dict_cornerstones(self, mcp_app):
        """Accept dict-shaped cornerstones too."""
        app, plugin = mcp_app
        plugin.get_cornerstones = AsyncMock(return_value=[
            {"id": "cs-dict", "label": "Dict CS", "content": "x"}
        ])
        raw = await _call_tool(app, "cortex_cornerstones_list")
        data = json.loads(raw)
        assert data["cornerstones"][0]["id"] == "cs-dict"


class TestCortexCornerstonesSeal:
    @pytest.mark.asyncio
    async def test_successful_seal(self, mcp_app):
        app, plugin = mcp_app
        cs = _CornerstoneMemory(id="cs-new", label="NewCS", sealed_at="2024-01-02")
        plugin.seal_cornerstone = AsyncMock(return_value=cs)
        raw = await _call_tool(
            app, "cortex_cornerstones_seal",
            memory_id="cs-new", label="NewCS", content="content"
        )
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["id"] == "cs-new"

    @pytest.mark.asyncio
    async def test_unavailable_guardian(self, mcp_app):
        app, plugin = mcp_app
        plugin.seal_cornerstone = AsyncMock(return_value=None)
        raw = await _call_tool(app, "cortex_cornerstones_seal", memory_id="x")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "unavailable" in data["error"].lower()


class TestCortexCornerstonesUnseal:
    @pytest.mark.asyncio
    async def test_successful_unseal(self, mcp_app):
        app, plugin = mcp_app
        cs = _CornerstoneMemory(id="cs-1", label="CS1", sealed_at="")
        plugin.unseal_cornerstone = AsyncMock(return_value=cs)
        raw = await _call_tool(app, "cortex_cornerstones_unseal", memory_id="cs-1")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["id"] == "cs-1"

    @pytest.mark.asyncio
    async def test_unavailable_guardian(self, mcp_app):
        app, plugin = mcp_app
        plugin.unseal_cornerstone = AsyncMock(return_value=None)
        raw = await _call_tool(app, "cortex_cornerstones_unseal", memory_id="x")
        data = json.loads(raw)
        assert data["ok"] is False


class TestCortexHealth:
    @pytest.mark.asyncio
    async def test_healthy_system(self, mcp_app):
        app, plugin = mcp_app
        plugin.health = AsyncMock(return_value=_HealthReport(
            ok=True,
            storage="ok",
            modules={"storage": "ok", "llm": "ok"},
            timestamp="2024-01-01T00:00:00",
        ))
        raw = await _call_tool(app, "cortex_health")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["storage"] == "ok"
        assert "storage" in data["modules"]

    @pytest.mark.asyncio
    async def test_degraded_system(self, mcp_app):
        app, plugin = mcp_app
        plugin.health = AsyncMock(return_value=_HealthReport(
            ok=False,
            storage="error: connection refused",
            modules={"storage": "error", "llm": "unavailable"},
            errors=["storage health: connection refused"],
        ))
        raw = await _call_tool(app, "cortex_health")
        data = json.loads(raw)
        assert data["ok"] is False
        assert len(data["errors"]) > 0


class TestCortexFeedback:
    @pytest.mark.asyncio
    async def test_helpful_feedback(self, mcp_app):
        app, plugin = mcp_app
        plugin.feedback = AsyncMock(return_value=_FeedbackRecord(
            id="fb-1", memory_id="mem-1", helpful=True
        ))
        raw = await _call_tool(
            app, "cortex_feedback",
            memory_id="mem-1", helpful=True, context="Very relevant"
        )
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["helpful"] is True
        assert data["memory_id"] == "mem-1"

    @pytest.mark.asyncio
    async def test_unhelpful_feedback(self, mcp_app):
        app, plugin = mcp_app
        plugin.feedback = AsyncMock(return_value=_FeedbackRecord(
            id="fb-2", memory_id="mem-2", helpful=False
        ))
        raw = await _call_tool(
            app, "cortex_feedback",
            memory_id="mem-2", helpful=False
        )
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["helpful"] is False

    @pytest.mark.asyncio
    async def test_unavailable_feedback_system(self, mcp_app):
        app, plugin = mcp_app
        plugin.feedback = AsyncMock(return_value=None)
        raw = await _call_tool(
            app, "cortex_feedback", memory_id="m", helpful=True
        )
        data = json.loads(raw)
        assert data["ok"] is False


# ===========================================================================
# Resource tests
# ===========================================================================

class TestMemoryStatsResource:
    @pytest.mark.asyncio
    async def test_returns_health_data(self, mcp_app):
        app, plugin = mcp_app
        plugin.health = AsyncMock(return_value=_HealthReport(
            ok=True, storage="ok",
            modules={"storage": "ok"}, timestamp="2024-01-01"
        ))
        # Remove storage attr to test the health-only path
        plugin.storage = None
        raw = await _read_resource(app, "cortex://memory/stats")
        data = json.loads(raw)
        assert "health" in data
        assert data["health"]["ok"] is True

    @pytest.mark.asyncio
    async def test_valid_json_output(self, mcp_app):
        app, plugin = mcp_app
        raw = await _read_resource(app, "cortex://memory/stats")
        # Must be valid JSON
        data = json.loads(raw)
        assert isinstance(data, dict)


class TestCornerstonesListResource:
    @pytest.mark.asyncio
    async def test_empty_cornerstones(self, mcp_app):
        app, plugin = mcp_app
        plugin.get_cornerstones = AsyncMock(return_value=[])
        raw = await _read_resource(app, "cortex://cornerstones/list")
        data = json.loads(raw)
        assert data["count"] == 0
        assert data["cornerstones"] == []

    @pytest.mark.asyncio
    async def test_with_sealed_cornerstones(self, mcp_app):
        app, plugin = mcp_app
        cs_list = [
            _CornerstoneMemory(id="cs-1", label="Identity", content="I am Eva"),
            _CornerstoneMemory(id="cs-2", label="Purpose", content="To help Andrew"),
        ]
        plugin.get_cornerstones = AsyncMock(return_value=cs_list)
        raw = await _read_resource(app, "cortex://cornerstones/list")
        data = json.loads(raw)
        assert data["count"] == 2
        ids = [cs["id"] for cs in data["cornerstones"]]
        assert "cs-1" in ids
        assert "cs-2" in ids


# ===========================================================================
# Tool registration tests
# ===========================================================================

class TestToolRegistration:
    @pytest.mark.asyncio
    async def test_all_tools_registered(self, mcp_app):
        app, _ = mcp_app
        tools = await app.list_tools()
        tool_names = {t.name for t in tools}
        expected = {
            "cortex_remember",
            "cortex_retrieve",
            "cortex_forget",
            "cortex_cornerstones_list",
            "cortex_cornerstones_seal",
            "cortex_cornerstones_unseal",
            "cortex_health",
            "cortex_feedback",
            # Issue #66 tools
            "brain_graph_query",
            "brain_graph_build",
            "session_wake",
            "session_sleep",
            "dream",
            "stats",
            "cortex_ask",
            # Issue #322 tools
            "entities_list",
            "entities_get",
            "memory_search",
            "memory_stats",
            "commitments_list",
            "open_loops_list",
        }
        assert expected.issubset(tool_names), f"Missing tools: {expected - tool_names}"

    @pytest.mark.asyncio
    async def test_all_tools_have_descriptions(self, mcp_app):
        app, _ = mcp_app
        tools = await app.list_tools()
        for tool in tools:
            assert tool.description, f"Tool {tool.name!r} has no description"

    @pytest.mark.asyncio
    async def test_all_resources_registered(self, mcp_app):
        app, _ = mcp_app
        resources = await app.list_resources()
        uris = {str(r.uri) for r in resources}
        assert "cortex://memory/stats" in uris
        assert "cortex://cornerstones/list" in uris

    def test_server_name(self, mcp_app):
        app, _ = mcp_app
        assert app.name == "cortex"


# ===========================================================================
# Issue #66 — Brain-graph + session management tool tests
# ===========================================================================

# ---------------------------------------------------------------------------
# Additional stub return types
# ---------------------------------------------------------------------------

@dataclass
class _WakeReport:
    session_id: str = "sess-wake-1"
    cornerstones_loaded: int = 3
    errors: List[str] = field(default_factory=list)


@dataclass
class _SleepReport:
    session_id: str = "sess-sleep-1"
    memories_summarised: int = 5
    errors: List[str] = field(default_factory=list)


@dataclass
class _BrainGraphNode:
    node_id: str = "node-1"
    label: str = "concept"
    content: str = "Eva is an AI assistant"
    node_type: str = "concept"


@dataclass
class _BrainGraphResult:
    nodes: List[Any] = field(default_factory=list)
    context: str = "Brain graph context block."
    traversal_path: List[str] = field(default_factory=list)
    token_count: int = 42
    graph_id: Optional[str] = "graph-1"


@dataclass
class _BrainGraphIndex:
    nodes: List[Any] = field(default_factory=list)
    edges: List[Any] = field(default_factory=list)
    graph_id: str = "graph-1"
    id: str = "graph-1"


@dataclass
class _DreamReport:
    claims_consolidated: int = 10
    curiosity_seeds: int = 3
    journal: str = "Dream cycle completed."
    duration_ms: int = 1200
    orphans_recovered: int = 2
    contradictions_resolved: int = 1
    claims_decayed: int = 0
    claims_archived: int = 0
    cornerstone_proposals: int = 0
    entity_merge_suggestions: int = 0
    brain_graphs_refreshed: int = 1
    feedback_applied: int = 0


# ---------------------------------------------------------------------------
# Extended mock_plugin factory that includes #66 methods
# ---------------------------------------------------------------------------

def _make_mock_plugin_v2(
    *,
    wake_result: Optional[_WakeReport] = None,
    sleep_result: Optional[_SleepReport] = None,
    storage: Optional[Any] = None,
) -> MagicMock:
    """Extend the base mock with wake/sleep + storage."""
    plugin = _make_mock_plugin()
    plugin.wake = AsyncMock(return_value=wake_result or _WakeReport())
    plugin.sleep = AsyncMock(return_value=sleep_result or _SleepReport())
    plugin.storage = storage
    plugin.llm = MagicMock()
    plugin.config = MagicMock()
    plugin.config.owner_id = "default"
    return plugin


@pytest.fixture()
def mock_plugin_v2():
    storage = MagicMock()
    storage.count_claims = AsyncMock(return_value=120)
    storage.count_sessions = AsyncMock(return_value=8)
    return _make_mock_plugin_v2(storage=storage)


@pytest.fixture()
def mcp_app_v2(mock_plugin_v2):
    """Build the FastMCP app with extended mock (wake/sleep/storage)."""
    with patch("cortex.api.plugin.CortexPlugin", return_value=mock_plugin_v2):
        import cortex.api.mcp_server as srv
        # reload to pick up new fixture
        import importlib
        srv = importlib.reload(srv)
        app = srv._build_mcp_server()
        yield app, mock_plugin_v2


# ===========================================================================
# session_wake tests
# ===========================================================================

class TestSessionWake:
    @pytest.mark.asyncio
    async def test_basic_wake(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        plugin.wake = AsyncMock(return_value=_WakeReport(
            session_id="s-wake", cornerstones_loaded=4
        ))
        raw = await _call_tool(app, "session_wake")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["session_id"] == "s-wake"
        assert data["cornerstones_loaded"] == 4
        assert data["errors"] == []

    @pytest.mark.asyncio
    async def test_custom_session_id_forwarded(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        await _call_tool(app, "session_wake", session_id="my-custom-session")
        call_args = plugin.wake.call_args
        assert call_args.kwargs.get("session_id") == "my-custom-session"

    @pytest.mark.asyncio
    async def test_wake_with_errors(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        plugin.wake = AsyncMock(return_value=_WakeReport(
            session_id="s-err", cornerstones_loaded=0, errors=["cornerstone load failed"]
        ))
        raw = await _call_tool(app, "session_wake")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "cornerstone load failed" in data["errors"]

    @pytest.mark.asyncio
    async def test_wake_exception_handled(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        plugin.wake = AsyncMock(side_effect=RuntimeError("DB unavailable"))
        raw = await _call_tool(app, "session_wake")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "DB unavailable" in data["error"]


# ===========================================================================
# session_sleep tests
# ===========================================================================

class TestSessionSleep:
    @pytest.mark.asyncio
    async def test_basic_sleep(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        plugin.sleep = AsyncMock(return_value=_SleepReport(
            session_id="s-sleep", memories_summarised=7
        ))
        raw = await _call_tool(app, "session_sleep")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["session_id"] == "s-sleep"
        assert data["claims_consolidated"] == 7
        assert isinstance(data["duration_ms"], int)

    @pytest.mark.asyncio
    async def test_session_id_forwarded(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        await _call_tool(app, "session_sleep", session_id="close-me")
        call_args = plugin.sleep.call_args
        assert call_args.kwargs.get("session_id") == "close-me"

    @pytest.mark.asyncio
    async def test_sleep_with_errors(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        plugin.sleep = AsyncMock(return_value=_SleepReport(
            session_id="s-err", memories_summarised=0, errors=["consolidation failed"]
        ))
        raw = await _call_tool(app, "session_sleep")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "consolidation failed" in data["errors"]

    @pytest.mark.asyncio
    async def test_sleep_exception_handled(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        plugin.sleep = AsyncMock(side_effect=RuntimeError("session not found"))
        raw = await _call_tool(app, "session_sleep")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "session not found" in data["error"]


# ===========================================================================
# dream tests
# ===========================================================================

class TestDream:
    @pytest.mark.asyncio
    async def test_dream_not_implemented(self, mcp_app_v2):
        """When CircadianEngine is not available, errors should be populated."""
        app, plugin = mcp_app_v2
        # CircadianEngine import will fail in test environment
        raw = await _call_tool(app, "dream")
        data = json.loads(raw)
        # Either ok (if CircadianEngine mock works) or error reported gracefully
        assert "steps_completed" in data
        assert "curiosity_seeds" in data
        assert "claims_consolidated" in data
        assert "journal" in data
        assert "duration_ms" in data
        assert "errors" in data

    @pytest.mark.asyncio
    async def test_dream_engine_success(self, mcp_app_v2):
        """When CircadianEngine is available, results should be populated."""
        app, plugin = mcp_app_v2
        mock_engine = MagicMock()
        mock_engine.dream = AsyncMock(return_value=_DreamReport())
        mock_consolidator = MagicMock()

        with patch.dict("sys.modules", {
            "cortex.circadian.engine": MagicMock(CircadianEngine=MagicMock(return_value=mock_engine)),
            "cortex.circadian.sleep": MagicMock(SleepConsolidator=MagicMock(return_value=mock_consolidator)),
        }):
            raw = await _call_tool(app, "dream")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["claims_consolidated"] == 10
        assert data["curiosity_seeds"] == 3
        assert data["journal"] == "Dream cycle completed."
        assert data["steps_completed"] >= 1  # orphans_recovered + brain_graphs_refreshed

    @pytest.mark.asyncio
    async def test_dream_engine_exception(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        mock_engine = MagicMock()
        mock_engine.dream = AsyncMock(side_effect=RuntimeError("engine crash"))
        mock_consolidator = MagicMock()

        with patch.dict("sys.modules", {
            "cortex.circadian.engine": MagicMock(CircadianEngine=MagicMock(return_value=mock_engine)),
            "cortex.circadian.sleep": MagicMock(SleepConsolidator=MagicMock(return_value=mock_consolidator)),
        }):
            raw = await _call_tool(app, "dream")
        data = json.loads(raw)
        assert data["ok"] is False
        assert any("engine crash" in e for e in data["errors"])


# ===========================================================================
# stats tests
# ===========================================================================

class TestStats:
    @pytest.mark.asyncio
    async def test_basic_stats(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        plugin.storage.count_claims = AsyncMock(return_value=120)
        plugin.storage.count_sessions = AsyncMock(return_value=8)
        plugin.get_cornerstones = AsyncMock(return_value=[
            _CornerstoneMemory(), _CornerstoneMemory(id="cs-2")
        ])
        raw = await _call_tool(app, "stats")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["total_claims"] == 120
        assert data["total_sessions"] == 8
        assert data["total_cornerstones"] == 2
        assert data["storage_ok"] is True

    @pytest.mark.asyncio
    async def test_stats_no_count_methods(self, mcp_app_v2):
        """Storage without count methods should still return ok with zeros."""
        app, plugin = mcp_app_v2
        minimal_storage = MagicMock(spec=[])  # no count_claims / count_sessions
        plugin.storage = minimal_storage
        plugin.get_cornerstones = AsyncMock(return_value=[])
        raw = await _call_tool(app, "stats")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["total_claims"] == 0
        assert data["total_sessions"] == 0

    @pytest.mark.asyncio
    async def test_stats_no_storage(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        plugin.storage = None
        raw = await _call_tool(app, "stats")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "error" in data

    @pytest.mark.asyncio
    async def test_stats_exception(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        plugin.storage.count_claims = AsyncMock(side_effect=RuntimeError("db locked"))
        raw = await _call_tool(app, "stats")
        data = json.loads(raw)
        assert data["ok"] is False
        assert any("db locked" in e for e in data["errors"])


# ===========================================================================
# brain_graph_query tests
# ===========================================================================

class TestBrainGraphQuery:
    @pytest.mark.asyncio
    async def test_empty_query_rejected(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        raw = await _call_tool(app, "brain_graph_query", query="   ")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "non-empty" in data["error"].lower()

    @pytest.mark.asyncio
    async def test_not_implemented_handled(self, mcp_app_v2):
        """BrainGraphQueryEngine import failure should be caught gracefully."""
        app, plugin = mcp_app_v2
        raw = await _call_tool(app, "brain_graph_query", query="what is Eva?")
        data = json.loads(raw)
        assert "ok" in data
        assert "nodes" in data
        assert "edges" in data
        assert "context" in data
        assert "errors" in data

    @pytest.mark.asyncio
    async def test_successful_query(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        node = _BrainGraphNode()
        mock_engine = MagicMock()
        mock_result = _BrainGraphResult(
            nodes=[node],
            context="Eva is an AI assistant",
            traversal_path=["node-1", "node-2"],
            token_count=15,
        )
        mock_engine.query = AsyncMock(return_value=mock_result)

        with patch.dict("sys.modules", {
            "cortex.brain_graph.query": MagicMock(
                BrainGraphQueryEngine=MagicMock(return_value=mock_engine)
            ),
        }):
            raw = await _call_tool(
                app, "brain_graph_query",
                query="what is Eva?", max_depth=2, limit=5
            )
        data = json.loads(raw)
        assert data["ok"] is True
        assert len(data["nodes"]) == 1
        assert data["context"] == "Eva is an AI assistant"
        assert data["token_count"] == 15
        assert len(data["edges"]) == 1  # traversal_path has 2 nodes → 1 edge
        assert data["edges"][0] == {"from": "node-1", "to": "node-2"}

    @pytest.mark.asyncio
    async def test_query_params_forwarded(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        mock_engine = MagicMock()
        mock_engine.query = AsyncMock(return_value=_BrainGraphResult())

        with patch.dict("sys.modules", {
            "cortex.brain_graph.query": MagicMock(
                BrainGraphQueryEngine=MagicMock(return_value=mock_engine)
            ),
        }):
            await _call_tool(
                app, "brain_graph_query",
                query="test query", max_depth=5, limit=20
            )

        call_kwargs = mock_engine.query.call_args.kwargs
        assert call_kwargs["max_depth"] == 5
        assert call_kwargs["token_limit"] == 20 * 50


# ===========================================================================
# brain_graph_build tests
# ===========================================================================

class TestBrainGraphBuild:
    @pytest.mark.asyncio
    async def test_no_source_rejected(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        raw = await _call_tool(app, "brain_graph_build")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "source_path" in data["error"] or "text" in data["error"]

    @pytest.mark.asyncio
    async def test_not_implemented_handled(self, mcp_app_v2):
        """BrainGraphBuilder import failure should be caught gracefully."""
        app, plugin = mcp_app_v2
        raw = await _call_tool(app, "brain_graph_build", source_path="/some/path")
        data = json.loads(raw)
        assert "ok" in data
        assert "nodes_created" in data
        assert "edges_created" in data
        assert "errors" in data

    @pytest.mark.asyncio
    async def test_successful_build_from_path(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        node = _BrainGraphNode()
        mock_builder = MagicMock()
        mock_result = _BrainGraphIndex(nodes=[node, node], edges=[MagicMock()])
        mock_builder.build = AsyncMock(return_value=mock_result)

        with patch.dict("sys.modules", {
            "cortex.brain_graph.builder": MagicMock(
                BrainGraphBuilder=MagicMock(return_value=mock_builder)
            ),
        }):
            raw = await _call_tool(
                app, "brain_graph_build", source_path="/docs/README.md"
            )
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["nodes_created"] == 2
        assert data["edges_created"] == 1
        assert data["graph_id"] == "graph-1"

    @pytest.mark.asyncio
    async def test_successful_build_from_text(self, mcp_app_v2):
        app, plugin = mcp_app_v2
        mock_builder = MagicMock()
        mock_result = _BrainGraphIndex(nodes=[_BrainGraphNode()], edges=[], graph_id="graph-text")
        mock_builder.build = AsyncMock(return_value=mock_result)

        with patch.dict("sys.modules", {
            "cortex.brain_graph.builder": MagicMock(
                BrainGraphBuilder=MagicMock(return_value=mock_builder)
            ),
        }):
            raw = await _call_tool(
                app, "brain_graph_build",
                text="Eva is an AI agent built for 100Yen Org.",
                source_id="readme"
            )
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["nodes_created"] == 1
        assert data["graph_id"] == "graph-text"
        # Verify text mode args forwarded
        call_kwargs = mock_builder.build.call_args.kwargs
        assert call_kwargs.get("source_type") == "text"
        assert call_kwargs.get("source_id") == "readme"


# ===========================================================================
# Updated registration test to include #66 tools
# ===========================================================================

class TestToolRegistrationV2:
    @pytest.mark.asyncio
    async def test_new_tools_registered(self, mcp_app_v2):
        app, _ = mcp_app_v2
        tools = await app.list_tools()
        tool_names = {t.name for t in tools}
        new_tools = {
            "brain_graph_query",
            "brain_graph_build",
            "session_wake",
            "session_sleep",
            "dream",
            "stats",
            "cortex_ask",
        }
        assert new_tools.issubset(tool_names), f"Missing new tools: {new_tools - tool_names}"

    @pytest.mark.asyncio
    async def test_all_new_tools_have_descriptions(self, mcp_app_v2):
        app, _ = mcp_app_v2
        tools = await app.list_tools()
        new_tools = {"brain_graph_query", "brain_graph_build", "session_wake",
                     "session_sleep", "dream", "stats"}
        for tool in tools:
            if tool.name in new_tools:
                assert tool.description, f"Tool {tool.name!r} has no description"


# ===========================================================================
# Issue #322 — Enhanced memory toolkit tests
# ===========================================================================

# ---------------------------------------------------------------------------
# Entity stubs
# ---------------------------------------------------------------------------

@dataclass
class _Entity:
    id: str = "ent-1"
    owner_id: str = "default"
    canonical_name: str = "Andrew"
    entity_type: str = "person"
    description: Optional[str] = "The main user"
    mention_count: int = 42
    first_seen: Optional[str] = "2024-01-01T00:00:00"
    last_seen: Optional[str] = "2024-06-01T00:00:00"
    created_at: Optional[str] = "2024-01-01T00:00:00"
    updated_at: Optional[str] = "2024-06-01T00:00:00"
    visibility_status: str = "promoted"
    connection_count: int = 5
    last_promoted_at: Optional[str] = None
    promotion_reason: Optional[str] = None


@dataclass
class _EntityAlias:
    id: str = "alias-1"
    entity_id: str = "ent-1"
    alias: str = "andy"
    alias_type: str = "name"
    source: str = "extracted"
    confidence: float = 1.0
    created_at: Optional[str] = "2024-01-01T00:00:00"


@dataclass
class _Claim:
    id: str = "claim-1"
    owner_id: str = "default"
    content: str = "Andrew prefers Python"
    category: str = "preferences"
    salience: str = "medium"
    status: str = "active"
    created_at: str = "2024-01-01T00:00:00"


from cortex.commitments.models import Commitment, OpenLoop


def _make_commitment(idx: int = 1) -> Commitment:
    return Commitment(
        id=f"cmt-{idx}",
        owner_id="default",
        content=f"Finish task {idx}",
        due_at="2026-04-01T00:00:00",
        status="open",
        source_turn_id=None,
        created_at="2024-01-01T00:00:00",
        closed_at=None,
    )


def _make_open_loop(idx: int = 1) -> OpenLoop:
    return OpenLoop(
        id=f"loop-{idx}",
        owner_id="default",
        content=f"Follow up on item {idx}",
        context="Context here",
        status="open",
        carry_count=2,
        created_at="2024-01-01T00:00:00",
        resolved_at=None,
    )


# ---------------------------------------------------------------------------
# Extended plugin fixture with entity/claims/commitment storage
# ---------------------------------------------------------------------------

def _make_mock_plugin_v3() -> MagicMock:
    plugin = _make_mock_plugin_v2()
    storage = MagicMock()

    # entities
    storage.list_entities = AsyncMock(return_value=[_Entity(), _Entity(id="ent-2", canonical_name="Eva")])
    storage.count_entities = AsyncMock(return_value=2)
    storage.get_entity = AsyncMock(return_value=_Entity())
    storage.get_entity_aliases = AsyncMock(return_value=[_EntityAlias()])
    storage.get_claims_by_entity = AsyncMock(return_value=[_Claim()])
    storage.get_entity_claim_count = AsyncMock(return_value=10)
    storage.get_entity_relationships = AsyncMock(return_value=[])

    # claims
    storage.search_claims = AsyncMock(return_value=[_Claim(), _Claim(id="claim-2")])
    storage.list_claims = AsyncMock(return_value=[_Claim(), _Claim(id="claim-2")])
    storage.count_claims = AsyncMock(return_value=50)
    storage.count_sessions = AsyncMock(return_value=8)

    plugin.storage = storage
    plugin.config = MagicMock()
    plugin.config.owner_id = "default"
    plugin.get_cornerstones = AsyncMock(return_value=[_CornerstoneMemory()])
    return plugin


@pytest.fixture()
def mock_plugin_v3():
    return _make_mock_plugin_v3()


@pytest.fixture()
def mcp_app_v3(mock_plugin_v3):
    with patch("cortex.api.plugin.CortexPlugin", return_value=mock_plugin_v3):
        import cortex.api.mcp_server as srv
        import importlib
        srv = importlib.reload(srv)
        app = srv._build_mcp_server()
        yield app, mock_plugin_v3


# ===========================================================================
# entities_list tests
# ===========================================================================

class TestEntitiesList:
    @pytest.mark.asyncio
    async def test_basic_list(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list")
        data = json.loads(raw)
        assert data["ok"] is True
        assert len(data["entities"]) == 2
        assert data["total"] == 2

    @pytest.mark.asyncio
    async def test_with_entity_type_filter(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", entity_type="person")
        data = json.loads(raw)
        assert data["ok"] is True
        call_kwargs = plugin.storage.list_entities.call_args.kwargs
        assert call_kwargs.get("entity_type") == "person"

    @pytest.mark.asyncio
    async def test_with_search_filter(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", search="Andrew")
        data = json.loads(raw)
        assert data["ok"] is True
        call_kwargs = plugin.storage.list_entities.call_args.kwargs
        assert call_kwargs.get("search") == "Andrew"

    @pytest.mark.asyncio
    async def test_limit_capped_at_200(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", limit=999)
        data = json.loads(raw)
        assert data["limit"] == 200

    @pytest.mark.asyncio
    async def test_no_storage(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage = None
        raw = await _call_tool(app, "entities_list")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "storage" in data["error"].lower()

    @pytest.mark.asyncio
    async def test_storage_error_handled(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage.list_entities = AsyncMock(side_effect=RuntimeError("db error"))
        raw = await _call_tool(app, "entities_list")
        data = json.loads(raw)
        assert data["ok"] is False
        assert any("db error" in e for e in data["errors"])


# ===========================================================================
# entities_get tests
# ===========================================================================

class TestEntitiesGet:
    @pytest.mark.asyncio
    async def test_basic_get(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_get", entity_id="ent-1")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["entity"]["canonical_name"] == "Andrew"
        assert len(data["entity"]["aliases"]) == 1
        assert len(data["entity"]["claims"]) == 1
        assert data["entity"]["claim_count"] == 1

    @pytest.mark.asyncio
    async def test_entity_not_found(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage.get_entity = AsyncMock(return_value=None)
        raw = await _call_tool(app, "entities_get", entity_id="ent-999")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "not found" in data["error"].lower()

    @pytest.mark.asyncio
    async def test_empty_entity_id_rejected(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_get", entity_id="")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "non-empty" in data["error"].lower()

    @pytest.mark.asyncio
    async def test_no_storage(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage = None
        raw = await _call_tool(app, "entities_get", entity_id="ent-1")
        data = json.loads(raw)
        assert data["ok"] is False

    @pytest.mark.asyncio
    async def test_includes_relationships(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_get", entity_id="ent-1")
        data = json.loads(raw)
        assert "relationships" in data["entity"]


# ===========================================================================
# memory_search tests
# ===========================================================================

class TestMemorySearch:
    @pytest.mark.asyncio
    async def test_basic_search(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "memory_search", query="python preference")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["query"] == "python preference"
        assert "claims" in data
        assert "count" in data

    @pytest.mark.asyncio
    async def test_category_filter(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "memory_search", query="", category="preferences")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["filters"]["category"] == "preferences"

    @pytest.mark.asyncio
    async def test_salience_filter(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "memory_search", query="", salience="high")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["filters"]["salience"] == "high"

    @pytest.mark.asyncio
    async def test_limit_capped_at_100(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "memory_search", query="test", limit=999)
        data = json.loads(raw)
        # With query, search_claims is used; without query, list_claims is used
        call_used = plugin.storage.search_claims.call_args or plugin.storage.list_claims.call_args
        assert call_used is not None
        call_kwargs = call_used.kwargs
        assert call_kwargs.get("limit") == 100

    @pytest.mark.asyncio
    async def test_no_storage(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage = None
        raw = await _call_tool(app, "memory_search", query="test")
        data = json.loads(raw)
        assert data["ok"] is False

    @pytest.mark.asyncio
    async def test_returns_filters_in_response(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(
            app, "memory_search",
            query="test", category="identity", salience="high",
            status="active", after="2024-01-01"
        )
        data = json.loads(raw)
        assert data["filters"]["category"] == "identity"
        assert data["filters"]["salience"] == "high"
        assert data["filters"]["status"] == "active"
        assert data["filters"]["after"] == "2024-01-01"


# ===========================================================================
# memory_stats tests
# ===========================================================================

class TestMemoryStatsTool:
    @pytest.mark.asyncio
    async def test_basic_stats(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage.count_claims = AsyncMock(return_value=50)
        plugin.storage.count_sessions = AsyncMock(return_value=8)
        plugin.storage.count_entities = AsyncMock(return_value=15)
        plugin.get_cornerstones = AsyncMock(return_value=[_CornerstoneMemory()])
        raw = await _call_tool(app, "memory_stats")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["storage_ok"] is True
        assert data["total_cornerstones"] == 1
        assert "total_claims" in data
        assert "total_sessions" in data
        assert "total_entities" in data

    @pytest.mark.asyncio
    async def test_no_storage(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage = None
        raw = await _call_tool(app, "memory_stats")
        data = json.loads(raw)
        assert data["ok"] is False
        assert any("storage" in e.lower() for e in data["errors"])

    @pytest.mark.asyncio
    async def test_storage_exception_handled(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage.count_claims = AsyncMock(side_effect=RuntimeError("db locked"))
        raw = await _call_tool(app, "memory_stats")
        data = json.loads(raw)
        assert data["ok"] is False
        assert any("db locked" in e for e in data["errors"])

    @pytest.mark.asyncio
    async def test_all_stat_keys_present(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "memory_stats")
        data = json.loads(raw)
        for key in ("total_claims", "active_claims", "deprecated_claims",
                    "archived_claims", "total_entities", "total_cornerstones",
                    "total_sessions", "storage_ok", "errors"):
            assert key in data, f"Missing key: {key}"


# ===========================================================================
# commitments_list tests
# ===========================================================================

class TestCommitmentsList:
    @pytest.mark.asyncio
    async def test_basic_list(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_commitments",
            new=AsyncMock(return_value=[_make_commitment(1), _make_commitment(2)])
        ):
            raw = await _call_tool(app, "commitments_list")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["count"] == 2
        assert data["commitments"][0]["content"] == "Finish task 1"

    @pytest.mark.asyncio
    async def test_status_filter(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_commitments",
            new=AsyncMock(return_value=[_make_commitment(1)])
        ) as mock_fn:
            raw = await _call_tool(app, "commitments_list", status="open")
        data = json.loads(raw)
        assert data["filters"]["status"] == "open"

    @pytest.mark.asyncio
    async def test_empty_list(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_commitments",
            new=AsyncMock(return_value=[])
        ):
            raw = await _call_tool(app, "commitments_list")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["count"] == 0
        assert data["commitments"] == []

    @pytest.mark.asyncio
    async def test_no_storage(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage = None
        raw = await _call_tool(app, "commitments_list")
        data = json.loads(raw)
        assert data["ok"] is False

    @pytest.mark.asyncio
    async def test_commitment_fields_present(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_commitments",
            new=AsyncMock(return_value=[_make_commitment(1)])
        ):
            raw = await _call_tool(app, "commitments_list")
        data = json.loads(raw)
        item = data["commitments"][0]
        for field in ("id", "content", "status", "due_at", "created_at"):
            assert field in item, f"Missing field: {field}"

    @pytest.mark.asyncio
    async def test_limit_capped(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_commitments",
            new=AsyncMock(return_value=[])
        ) as mock_fn:
            raw = await _call_tool(app, "commitments_list", limit=999)
        data = json.loads(raw)
        assert data["ok"] is True  # capped limit doesn't break


# ===========================================================================
# open_loops_list tests
# ===========================================================================

class TestOpenLoopsList:
    @pytest.mark.asyncio
    async def test_basic_list(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_open_loops",
            new=AsyncMock(return_value=[_make_open_loop(1), _make_open_loop(2)])
        ):
            raw = await _call_tool(app, "open_loops_list")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["count"] == 2
        assert data["open_loops"][0]["content"] == "Follow up on item 1"

    @pytest.mark.asyncio
    async def test_default_status_is_open(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_open_loops",
            new=AsyncMock(return_value=[])
        ):
            raw = await _call_tool(app, "open_loops_list")
        data = json.loads(raw)
        assert data["filters"]["status"] == "open"

    @pytest.mark.asyncio
    async def test_all_status_filter(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_open_loops",
            new=AsyncMock(return_value=[])
        ):
            raw = await _call_tool(app, "open_loops_list", status=None)
        data = json.loads(raw)
        assert data["filters"]["status"] is None

    @pytest.mark.asyncio
    async def test_stale_flag(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        stale_loop = _make_open_loop(1)
        stale_loop.carry_count = 6  # above STALE_THRESHOLD
        with patch(
            "cortex.commitments.store.list_open_loops",
            new=AsyncMock(return_value=[stale_loop])
        ):
            raw = await _call_tool(app, "open_loops_list")
        data = json.loads(raw)
        assert data["open_loops"][0]["stale"] is True

    @pytest.mark.asyncio
    async def test_not_stale_below_threshold(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_open_loops",
            new=AsyncMock(return_value=[_make_open_loop(1)])  # carry_count=2
        ):
            raw = await _call_tool(app, "open_loops_list")
        data = json.loads(raw)
        assert data["open_loops"][0]["stale"] is False

    @pytest.mark.asyncio
    async def test_no_storage(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage = None
        raw = await _call_tool(app, "open_loops_list")
        data = json.loads(raw)
        assert data["ok"] is False

    @pytest.mark.asyncio
    async def test_open_loop_fields_present(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        with patch(
            "cortex.commitments.store.list_open_loops",
            new=AsyncMock(return_value=[_make_open_loop(1)])
        ):
            raw = await _call_tool(app, "open_loops_list")
        data = json.loads(raw)
        item = data["open_loops"][0]
        for field in ("id", "content", "context", "status", "carry_count", "stale"):
            assert field in item, f"Missing field: {field}"


# ===========================================================================
# Updated full registration test
# ===========================================================================

class TestToolRegistrationV3:
    @pytest.mark.asyncio
    async def test_all_322_tools_registered(self, mcp_app_v3):
        app, _ = mcp_app_v3
        tools = await app.list_tools()
        tool_names = {t.name for t in tools}
        new_tools = {
            "entities_list",
            "entities_get",
            "memory_search",
            "memory_stats",
            "commitments_list",
            "open_loops_list",
        }
        assert new_tools.issubset(tool_names), f"Missing #322 tools: {new_tools - tool_names}"

    @pytest.mark.asyncio
    async def test_all_322_tools_have_descriptions(self, mcp_app_v3):
        app, _ = mcp_app_v3
        tools = await app.list_tools()
        tool_names_322 = {
            "entities_list", "entities_get", "memory_search",
            "memory_stats", "commitments_list", "open_loops_list",
        }
        for tool in tools:
            if tool.name in tool_names_322:
                assert tool.description, f"Tool {tool.name!r} has no description"

    @pytest.mark.asyncio
    async def test_total_tool_count(self, mcp_app_v3):
        """Verify total tool count is 21 (15 original + 6 new)."""
        app, _ = mcp_app_v3
        tools = await app.list_tools()
        assert len(tools) == 21, f"Expected 21 tools, got {len(tools)}: {[t.name for t in tools]}"


# ===========================================================================
# MCP Server Audit Bug Fixes Tests (B-MCP1 through B-MCP8)
# ===========================================================================


class TestBMCP1SortByInjection:
    """B-MCP1: sort_by must be validated against allowlist."""

    @pytest.mark.asyncio
    async def test_valid_sort_by_accepted(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        for field in ("last_seen", "mention_count", "created_at"):
            raw = await _call_tool(app, "entities_list", sort_by=field)
            data = json.loads(raw)
            assert data["ok"] is True
            call_kwargs = plugin.storage.list_entities.call_args.kwargs
            assert call_kwargs["sort_by"] == field

    @pytest.mark.asyncio
    async def test_invalid_sort_by_defaults_to_last_seen(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", sort_by="id; DROP TABLE entities;--")
        data = json.loads(raw)
        assert data["ok"] is True
        call_kwargs = plugin.storage.list_entities.call_args.kwargs
        assert call_kwargs["sort_by"] == "last_seen"

    @pytest.mark.asyncio
    async def test_empty_sort_by_defaults(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", sort_by="")
        data = json.loads(raw)
        assert data["ok"] is True
        call_kwargs = plugin.storage.list_entities.call_args.kwargs
        assert call_kwargs["sort_by"] == "last_seen"


class TestBMCP2SearchSanitization:
    """B-MCP2: search parameter must be length-capped."""

    @pytest.mark.asyncio
    async def test_short_search_passes_through(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", search="Andrew")
        data = json.loads(raw)
        assert data["ok"] is True
        call_kwargs = plugin.storage.list_entities.call_args.kwargs
        assert call_kwargs["search"] == "Andrew"

    @pytest.mark.asyncio
    async def test_long_search_truncated_to_500(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        long_search = "x" * 1000
        raw = await _call_tool(app, "entities_list", search=long_search)
        data = json.loads(raw)
        assert data["ok"] is True
        call_kwargs = plugin.storage.list_entities.call_args.kwargs
        assert len(call_kwargs["search"]) == 500

    @pytest.mark.asyncio
    async def test_exactly_500_passes_through(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        search_500 = "a" * 500
        raw = await _call_tool(app, "entities_list", search=search_500)
        data = json.loads(raw)
        assert data["ok"] is True
        call_kwargs = plugin.storage.list_entities.call_args.kwargs
        assert len(call_kwargs["search"]) == 500


class TestBMCP3EnumValidation:
    """B-MCP3: entity_type and visibility_status must be validated."""

    @pytest.mark.asyncio
    async def test_valid_entity_types_accepted(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        for et in ("person", "project", "concept"):
            raw = await _call_tool(app, "entities_list", entity_type=et)
            data = json.loads(raw)
            assert data["ok"] is True

    @pytest.mark.asyncio
    async def test_invalid_entity_type_rejected(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", entity_type="hacker")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "invalid entity_type" in data["error"].lower()

    @pytest.mark.asyncio
    async def test_valid_visibility_accepted(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        for vis in ("extracted", "promoted"):
            raw = await _call_tool(app, "entities_list", visibility_status=vis)
            data = json.loads(raw)
            assert data["ok"] is True

    @pytest.mark.asyncio
    async def test_invalid_visibility_rejected(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", visibility_status="admin")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "invalid visibility_status" in data["error"].lower()

    @pytest.mark.asyncio
    async def test_none_entity_type_passes(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", entity_type=None)
        data = json.loads(raw)
        assert data["ok"] is True

    @pytest.mark.asyncio
    async def test_none_visibility_passes(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list", visibility_status=None)
        data = json.loads(raw)
        assert data["ok"] is True


class TestBMCP4ForgetEmptyGuard:
    """B-MCP4: cortex_forget must reject empty/None memory_id."""

    @pytest.mark.asyncio
    async def test_empty_string_rejected(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "cortex_forget", memory_id="")
        data = json.loads(raw)
        assert data["ok"] is False
        assert data["deleted"] is False
        assert "non-empty" in data["error"].lower()

    @pytest.mark.asyncio
    async def test_whitespace_only_rejected(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "cortex_forget", memory_id="   ")
        data = json.loads(raw)
        assert data["ok"] is False
        assert data["deleted"] is False

    @pytest.mark.asyncio
    async def test_valid_memory_id_passes(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "cortex_forget", memory_id="mem-valid-123")
        data = json.loads(raw)
        assert data["deleted"] is True


class TestBMCP5AskModeValidation:
    """B-MCP5: cortex_ask mode must be validated."""

    @pytest.mark.asyncio
    async def test_fast_mode_accepted(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.ask = AsyncMock(return_value=MagicMock(
            answer="test", sources=[], confidence=0.9,
            reasoning="", ok=True, errors=[]
        ))
        raw = await _call_tool(app, "cortex_ask", query="test", mode="fast")
        data = json.loads(raw)
        assert data["ok"] is True

    @pytest.mark.asyncio
    async def test_agentic_mode_accepted(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.ask = AsyncMock(return_value=MagicMock(
            answer="test", sources=[], confidence=0.9,
            reasoning="", ok=True, errors=[]
        ))
        raw = await _call_tool(app, "cortex_ask", query="test", mode="agentic")
        data = json.loads(raw)
        assert data["ok"] is True

    @pytest.mark.asyncio
    async def test_invalid_mode_rejected(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "cortex_ask", query="test", mode="malicious")
        data = json.loads(raw)
        assert data["ok"] is False
        assert "invalid mode" in data["error"].lower()


class TestBMCP6EmptyEntityId:
    """B-MCP6: Empty entity ID should not trigger claim_count lookup."""

    @pytest.mark.asyncio
    async def test_empty_id_gets_zero_claim_count(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        # Return entity with empty id
        empty_ent = _Entity(id="")
        plugin.storage.list_entities = AsyncMock(return_value=[empty_ent])
        raw = await _call_tool(app, "entities_list")
        data = json.loads(raw)
        assert data["ok"] is True
        assert data["entities"][0]["claim_count"] == 0
        # Verify get_entity_claim_count was NOT called with empty string
        for call in plugin.storage.get_entity_claim_count.call_args_list:
            assert call.args[0] != "", "get_entity_claim_count should not be called with empty id"

    @pytest.mark.asyncio
    async def test_valid_id_gets_real_claim_count(self, mcp_app_v3):
        app, plugin = mcp_app_v3
        plugin.storage.get_entity_claim_count = AsyncMock(return_value=42)
        raw = await _call_tool(app, "entities_list")
        data = json.loads(raw)
        for ent in data["entities"]:
            if ent.get("id") and ent["id"] != "":
                assert ent["claim_count"] == 42


class TestBMCP7DictConversionFallback:
    """B-MCP7: dict(ent) fallback should handle __slots__ objects."""

    @pytest.mark.asyncio
    async def test_slots_object_handled(self, mcp_app_v3):
        """Object with __slots__ and no __dict__ should be handled via dict()."""
        app, plugin = mcp_app_v3

        class SlotsEntity:
            __slots__ = ("id", "canonical_name", "entity_type")
            def __init__(self):
                self.id = "slot-1"
                self.canonical_name = "SlotEntity"
                self.entity_type = "concept"
            def keys(self):
                return self.__slots__
            def __getitem__(self, key):
                return getattr(self, key)

        plugin.storage.list_entities = AsyncMock(return_value=[SlotsEntity()])
        plugin.storage.get_entity_claim_count = AsyncMock(return_value=5)
        raw = await _call_tool(app, "entities_list")
        data = json.loads(raw)
        assert data["ok"] is True
        assert len(data["entities"]) == 1
        assert data["entities"][0]["id"] == "slot-1"

    @pytest.mark.asyncio
    async def test_unconvertible_object_skipped(self, mcp_app_v3):
        """Object that can't be converted to dict should be skipped."""
        app, plugin = mcp_app_v3

        class BadEntity:
            __slots__ = ()
            # No __dict__, no keys(), no __getitem__

        plugin.storage.list_entities = AsyncMock(return_value=[BadEntity(), _Entity()])
        raw = await _call_tool(app, "entities_list")
        data = json.loads(raw)
        assert data["ok"] is True
        # BadEntity should be skipped, only _Entity remains
        assert len(data["entities"]) == 1

    @pytest.mark.asyncio
    async def test_normal_dataclass_still_works(self, mcp_app_v3):
        """Normal dataclass with __dict__ should still work fine."""
        app, plugin = mcp_app_v3
        raw = await _call_tool(app, "entities_list")
        data = json.loads(raw)
        assert data["ok"] is True
        assert len(data["entities"]) == 2


class TestBMCP8StorageMethodValidation:
    """B-MCP8: Storage methods should be validated at init time."""

    def test_validate_storage_methods_logs_warnings(self, caplog):
        """Missing required methods should produce warnings."""
        from cortex.api.mcp_server import _validate_storage_methods
        minimal_storage = MagicMock(spec=[])  # no methods at all
        with caplog.at_level(logging.WARNING):
            _validate_storage_methods(minimal_storage)
        # Should warn about missing required methods
        assert any("REQUIRED" in record.message for record in caplog.records)

    def test_validate_storage_methods_none_storage(self, caplog):
        """None storage should log a warning."""
        from cortex.api.mcp_server import _validate_storage_methods
        with caplog.at_level(logging.WARNING):
            _validate_storage_methods(None)
        assert any("None" in record.message for record in caplog.records)

    def test_validate_storage_methods_complete_storage(self, caplog):
        """Complete storage should not produce any warnings."""
        from cortex.api.mcp_server import _validate_storage_methods
        storage = MagicMock()  # MagicMock has all attributes by default
        with caplog.at_level(logging.WARNING):
            _validate_storage_methods(storage)
        # Should not have any "missing" warnings (MagicMock has all attrs)
        assert not any("missing" in record.message.lower() for record in caplog.records)

    def test_constants_defined(self):
        """Verify the module-level constants are properly defined."""
        from cortex.api.mcp_server import (
            ALLOWED_SORT_FIELDS,
            ALLOWED_ENTITY_TYPES,
            ALLOWED_VISIBILITY,
            ALLOWED_ASK_MODES,
            _REQUIRED_STORAGE_METHODS,
            _OPTIONAL_STORAGE_METHODS,
        )
        assert "last_seen" in ALLOWED_SORT_FIELDS
        assert "person" in ALLOWED_ENTITY_TYPES
        assert "extracted" in ALLOWED_VISIBILITY
        assert "fast" in ALLOWED_ASK_MODES
        assert "count_claims" in _REQUIRED_STORAGE_METHODS
        assert "list_entities" in _OPTIONAL_STORAGE_METHODS
