"""
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 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",
        }
        assert expected == 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"
