"""
tests/test_plugin.py — Unit tests for CortexPlugin (M13)

Strategy:
  - Mock ALL module dependencies (no real DB, no real LLM)
  - Test WIRING: does remember() call extract → resolve → reconcile → store?
  - Test ERROR HANDLING: graceful degradation when modules fail
  - Test CORNERSTONE PROTECTION: forget() refuses to delete sealed cornerstones
  - Test INTEGRATION: remember() → retrieve() roundtrip (all mocked)
"""
from __future__ import annotations

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

import pytest
import pytest_asyncio


# ---------------------------------------------------------------------------
# Stub types (mirrors the real dataclasses from module PRs)
# ---------------------------------------------------------------------------

@dataclass
class _EntityMention:
    name: str
    entity_type: str = "person"
    aliases: List[str] = field(default_factory=list)


@dataclass
class _ClaimExtraction:
    action: str
    subject: str
    predicate: str
    object_value: str
    confidence: float = 0.9
    temporal: str = "current"
    entities_mentioned: List[_EntityMention] = field(default_factory=list)
    sensitivity: str = "normal"


@dataclass
class _ExtractionResult:
    claims: List[_ClaimExtraction]
    noise_filtered: int = 0
    raw_message_count: int = 1


@dataclass
class _ReconciliationResult:
    candidate_subject: str
    candidate_predicate: str
    candidate_value: str
    action: Any = None

    def __post_init__(self):
        if self.action is None:
            self.action = _Action("ADD")


class _Action:
    def __init__(self, val):
        self.value = val


@dataclass
class _ReconciliationReport:
    results: List[_ReconciliationResult] = field(default_factory=list)


@dataclass
class _CornerstoneMemory:
    id: str
    owner_id: str = "default"
    label: str = "test"
    content: str = "test content"
    golden_copy: str = "test content"
    golden_hash: str = "abc123"
    current_hash: str = "abc123"
    status: str = "active"


@dataclass
class _RetrievalResult:
    items: List[Any] = field(default_factory=list)
    retrieval_id: str = "ret-001"
    mode: str = "auto"
    latency_ms: int = 10
    tokens_used: int = 100


@dataclass
class _MemoryFeedback:
    id: str
    target_id: str
    feedback_type: str


@dataclass
class _FeedbackScore:
    memory_id: str
    score: float = 0.5


# ---------------------------------------------------------------------------
# Module injection helpers
# ---------------------------------------------------------------------------

def _make_fake_module(name: str, **attrs) -> types.ModuleType:
    """Create a sys.modules-injectable fake module."""
    mod = types.ModuleType(name)
    for k, v in attrs.items():
        setattr(mod, k, v)
    return mod


def _make_config() -> MagicMock:
    cfg = MagicMock()
    cfg.storage_db_path = ":memory:"
    cfg.storage_enable_wal = False
    cfg.storage_busy_timeout_ms = 5000
    cfg.extraction_model = "gpt-4.1-nano"
    cfg.reconciliation_model = "gpt-4.1-nano"
    cfg.retrieval_token_budget = 3000
    cfg.total_memory_token_budget = 4000
    cfg.owner_id = "default"
    cfg.cornerstone_token_budget = 800
    cfg.retrieval_default_strategy = "hybrid"
    cfg.retrieval_max_tokens = 4000
    cfg.retrieval_vector_weight = 0.7
    cfg.retrieval_bm25_weight = 0.3
    cfg.coding_noise_filter = True
    cfg.cornerstone_drift_threshold = 0.05
    cfg.max_cornerstone_count = 10
    cfg.dream_cycle_enabled = False
    cfg.max_llm_calls_per_session = 50
    cfg.max_extraction_calls_per_batch = 10
    cfg.max_claims_per_extraction = 10
    cfg.max_entities_per_extraction = 50
    cfg.max_concurrent_extractions = 2
    return cfg


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def mock_config():
    return _make_config()


@pytest.fixture
def mock_storage():
    s = MagicMock()
    s.ping = AsyncMock(return_value=True)
    s.create_session = AsyncMock(return_value=None)
    s.close_session = AsyncMock(return_value=None)
    s.delete_claim = AsyncMock(return_value=None)
    return s


@pytest.fixture
def mock_llm():
    llm = MagicMock()
    llm.complete = AsyncMock(return_value=MagicMock(content="{}"))
    return llm


@pytest.fixture
def mock_extractor():
    e = MagicMock()
    claims = [
        _ClaimExtraction(
            action="ADD",
            subject="user",
            predicate="prefers",
            object_value="dark mode",
            entities_mentioned=[_EntityMention(name="user")],
        )
    ]
    e.extract = AsyncMock(return_value=_ExtractionResult(claims=claims, noise_filtered=0))
    return e


@pytest.fixture
def mock_entity_resolver():
    r = MagicMock()
    r.resolve = AsyncMock(return_value=MagicMock(entity_id="ent-001"))
    return r


@pytest.fixture
def mock_reconciler():
    rec = MagicMock()
    result = _ReconciliationResult("user", "prefers", "dark mode", _Action("ADD"))
    result.resulting_claim_id = "claim-001"
    report = _ReconciliationReport(results=[result])
    rec.reconcile = AsyncMock(return_value=report)
    return rec


@pytest.fixture
def mock_cornerstone_guardian():
    g = MagicMock()
    g.get_all = AsyncMock(return_value=[])
    g.load_all = AsyncMock(return_value=[])
    g.is_cornerstone_subject = AsyncMock(return_value=False)
    g.seal = AsyncMock(return_value=_CornerstoneMemory(id="cs-001"))
    g.unseal = AsyncMock(return_value=_CornerstoneMemory(id="cs-001", status="unsealed"))
    return g


@pytest.fixture
def mock_retrieval():
    ret = MagicMock()
    ret.retrieve = AsyncMock(return_value=_RetrievalResult())
    return ret


@pytest.fixture
def mock_token_budget():
    tb = MagicMock()
    tb.create_budget = MagicMock(return_value=MagicMock(remaining=MagicMock(return_value=3000)))
    return tb


@pytest.fixture
def mock_feedback_system():
    fs = MagicMock()
    fs.submit_feedback = AsyncMock(return_value=_MemoryFeedback(
        id="fb-001", target_id="claim-001", feedback_type="helpful"
    ))
    return fs


@pytest.fixture
def plugin(
    mock_config,
    mock_storage,
    mock_llm,
    mock_extractor,
    mock_entity_resolver,
    mock_reconciler,
    mock_cornerstone_guardian,
    mock_retrieval,
    mock_token_budget,
    mock_feedback_system,
):
    """
    Build a CortexPlugin with all module dependencies injected via mocks.
    Bypasses __init__ module loading entirely.
    """
    from cortex.api.plugin import CortexPlugin
    p = object.__new__(CortexPlugin)
    p._init_errors = []
    p._session_id = None
    p._llm_call_count = 0
    p.config = mock_config
    p.storage = mock_storage
    p.llm = mock_llm
    p.extractor = mock_extractor
    p.entity_resolver = mock_entity_resolver
    p.reconciler = mock_reconciler
    p.cornerstone_guardian = mock_cornerstone_guardian
    p.retrieval = mock_retrieval
    p.token_budget_manager = mock_token_budget
    p.feedback_system = mock_feedback_system
    p.event_emitter = None
    p._webhook_dispatcher = None
    p._metrics = None
    p._flags = None
    return p


# ---------------------------------------------------------------------------
# Tests: __init__ wiring
# ---------------------------------------------------------------------------

class TestPluginInit:

    def test_init_records_errors_but_does_not_raise(self):
        """
        Plugin with all modules set to None must not raise on construction.

        We build the plugin via object.__new__ (bypassing __init__) to simulate
        a worst-case where every module import failed — the same state that
        CortexPlugin.__init__ ends up in when all imports fail.
        """
        from cortex.api.plugin import CortexPlugin

        p = object.__new__(CortexPlugin)
        p._init_errors = ["storage: ImportError", "llm: ImportError"]
        p._session_id = None
        p._llm_call_count = 0
        p.config = _make_config()
        for attr in ("storage", "llm", "extractor", "entity_resolver",
                     "reconciler", "cornerstone_guardian", "token_budget_manager",
                     "retrieval", "feedback_system", "event_emitter",
                     "_webhook_dispatcher"):
            setattr(p, attr, None)

        # All modules are None — the plugin must still be a valid object
        assert p is not None
        assert len(p._init_errors) == 2
        # And every module is None (graceful degradation baseline)
        assert p.storage is None
        assert p.extractor is None
        assert p.retrieval is None

    def test_all_modules_wired(self, plugin):
        """Fully wired plugin has all modules non-None."""
        for attr in ("storage", "llm", "extractor", "entity_resolver",
                     "reconciler", "cornerstone_guardian", "token_budget_manager",
                     "retrieval", "feedback_system"):
            assert getattr(plugin, attr) is not None, f"{attr} should be wired"


# ---------------------------------------------------------------------------
# Tests: remember() pipeline
# ---------------------------------------------------------------------------

class TestRemember:

    @pytest.mark.asyncio
    async def test_remember_calls_pipeline_in_order(self, plugin):
        """
        remember() must call: extract → resolve_entity → reconcile
        in that order, using the results of the previous step.
        """
        call_order = []

        async def record_extract(*a, **kw):
            call_order.append("extract")
            claims = [_ClaimExtraction("ADD", "u", "likes", "cats",
                                        entities_mentioned=[_EntityMention("u")])]
            return _ExtractionResult(claims=claims)

        async def record_resolve(*a, **kw):
            call_order.append("resolve")
            return MagicMock()

        async def record_reconcile(*a, **kw):
            call_order.append("reconcile")
            return _ReconciliationReport(
                results=[_ReconciliationResult("u", "likes", "cats", _Action("ADD"))]
            )

        plugin.extractor.extract = record_extract
        plugin.entity_resolver.resolve = record_resolve
        plugin.reconciler.reconcile = record_reconcile

        result = await plugin.remember(["user: I like cats"], session_id="s1")

        assert call_order == ["extract", "resolve", "reconcile"], (
            f"Pipeline order wrong: {call_order}"
        )
        assert result.claims_extracted == 1
        assert result.claims_stored == 1
        assert result.session_id == "s1"

    @pytest.mark.asyncio
    async def test_remember_returns_result_on_extraction_failure(self, plugin):
        """If extraction fails, remember() returns a result with error and 0 claims."""
        plugin.extractor.extract = AsyncMock(side_effect=RuntimeError("LLM timeout"))

        result = await plugin.remember(["anything"])

        assert result.claims_extracted == 0
        assert result.claims_stored == 0
        assert any("Extraction failed" in e for e in result.errors)

    @pytest.mark.asyncio
    async def test_remember_continues_when_entity_resolution_fails(self, plugin):
        """Entity resolution failure → logged error, but reconciliation still runs."""
        plugin.entity_resolver.resolve = AsyncMock(
            side_effect=RuntimeError("resolver crash")
        )

        result = await plugin.remember(["I like dogs"])

        # Reconciliation should still be called
        plugin.reconciler.reconcile.assert_called_once()
        assert any("Entity resolution failed" in e for e in result.errors)

    @pytest.mark.asyncio
    async def test_remember_returns_error_when_reconciliation_fails(self, plugin):
        """Reconciliation failure → result with error, 0 claims stored."""
        plugin.reconciler.reconcile = AsyncMock(
            side_effect=RuntimeError("DB write error")
        )

        result = await plugin.remember(["anything"])

        assert result.claims_stored == 0
        assert any("Reconciliation failed" in e for e in result.errors)

    @pytest.mark.asyncio
    async def test_remember_empty_extraction_returns_early(self, plugin):
        """No claims extracted → reconcile is never called."""
        plugin.extractor.extract = AsyncMock(
            return_value=_ExtractionResult(claims=[], noise_filtered=5)
        )

        result = await plugin.remember(["print('hello')"])  # coding noise

        plugin.reconciler.reconcile.assert_not_called()
        assert result.noise_filtered == 5
        assert result.claims_extracted == 0

    @pytest.mark.asyncio
    async def test_remember_zero_claim_valid_input_sets_warning_flag(self, plugin):
        plugin.extractor.extract = AsyncMock(
            return_value=_ExtractionResult(claims=[], noise_filtered=0)
        )

        result = await plugin.remember([{"role": "user", "content": "I prefer dark mode."}])

        assert result.ok is True
        assert result.claims_extracted == 0
        assert result.zero_claim_warning is True
        assert any("zero claims" in w.lower() for w in result.warnings)

    @pytest.mark.asyncio
    async def test_remember_without_extractor(self, plugin):
        """When extractor is None, remember() returns gracefully with error."""
        plugin.extractor = None

        result = await plugin.remember(["anything"])

        assert result.claims_extracted == 0
        assert any("not available" in e for e in result.errors)

    @pytest.mark.asyncio
    async def test_remember_without_reconciler(self, plugin):
        """When reconciler is None, remember() extracts but logs error."""
        plugin.reconciler = None

        result = await plugin.remember(["I love Python"])

        assert result.claims_extracted == 1  # extraction ran
        assert result.claims_stored == 0
        assert any("not available" in e for e in result.errors)

    @pytest.mark.asyncio
    async def test_remember_increments_session_counters(self, plugin):
        """remember() must increment turn_count and memories_created for the session."""
        plugin.storage.increment_session_counters = AsyncMock(return_value=MagicMock())

        result = await plugin.remember(["user: I like cats"], session_id="s1", owner_id="owner-a")

        assert result.ok is True
        plugin.storage.increment_session_counters.assert_awaited_once_with(
            session_id="s1",
            owner_id="owner-a",
            turn_delta=1,
            memories_created_delta=result.claims_stored,
        )


# ---------------------------------------------------------------------------
# Tests: retrieve()
# ---------------------------------------------------------------------------

class TestRetrieve:

    @pytest.mark.asyncio
    async def test_retrieve_delegates_to_pipeline(self, plugin):
        """retrieve() calls RetrievalPipeline.retrieve() with query and owner_id."""
        plugin.retrieval.retrieve = AsyncMock(return_value=_RetrievalResult())

        result = await plugin.retrieve("What does the user prefer?", token_budget=1000)

        plugin.retrieval.retrieve.assert_called_once()
        call_kwargs = plugin.retrieval.retrieve.call_args
        assert "query" in call_kwargs.kwargs or call_kwargs.args
        assert result is not None

    @pytest.mark.asyncio
    async def test_retrieve_returns_none_when_pipeline_unavailable(self, plugin):
        """retrieve() returns None gracefully when retrieval is None."""
        plugin.retrieval = None

        result = await plugin.retrieve("anything")

        assert result is None

    @pytest.mark.asyncio
    async def test_retrieve_returns_none_on_pipeline_error(self, plugin):
        """retrieve() swallows pipeline errors and returns None."""
        plugin.retrieval.retrieve = AsyncMock(side_effect=Exception("search crash"))

        result = await plugin.retrieve("anything")

        assert result is None

    @pytest.mark.asyncio
    async def test_retrieve_uses_config_budget_as_default(self, plugin):
        """retrieve() uses config.retrieval_token_budget when budget not specified."""
        plugin.config.retrieval_token_budget = 2500
        plugin.retrieval.retrieve = AsyncMock(return_value=_RetrievalResult())

        await plugin.retrieve("query with no explicit budget")

        plugin.retrieval.retrieve.assert_called_once()


# ---------------------------------------------------------------------------
# Tests: forget()
# ---------------------------------------------------------------------------

class TestForget:

    @pytest.mark.asyncio
    async def test_forget_refuses_sealed_cornerstone(self, plugin):
        """forget() returns deleted=False when memory is a sealed cornerstone."""
        plugin.cornerstone_guardian.is_cornerstone_subject = AsyncMock(return_value=True)

        result = await plugin.forget("cs-001")

        assert result.deleted is False
        assert "cornerstone" in result.reason.lower()
        plugin.storage.delete_claim.assert_not_called()

    @pytest.mark.asyncio
    async def test_forget_deletes_non_cornerstone_memory(self, plugin):
        """forget() successfully deletes a normal (non-cornerstone) memory."""
        plugin.cornerstone_guardian.is_cornerstone_subject = AsyncMock(return_value=False)
        plugin.storage.delete_claim = AsyncMock(return_value=None)

        result = await plugin.forget("claim-123")

        assert result.deleted is True
        plugin.storage.delete_claim.assert_called_once()

    @pytest.mark.asyncio
    async def test_forget_protection_check_failure_prevents_delete(self, plugin):
        """If cornerstone check itself throws, refuse deletion (fail-safe)."""
        plugin.cornerstone_guardian.is_cornerstone_subject = AsyncMock(
            side_effect=RuntimeError("DB error")
        )

        result = await plugin.forget("claim-abc")

        assert result.deleted is False
        assert "Cannot verify" in result.reason
        plugin.storage.delete_claim.assert_not_called()

    @pytest.mark.asyncio
    async def test_forget_storage_error_returns_failed_result(self, plugin):
        """Storage.delete_claim error → ForgetResult with deleted=False."""
        plugin.cornerstone_guardian.is_cornerstone_subject = AsyncMock(return_value=False)
        plugin.storage.delete_claim = AsyncMock(side_effect=RuntimeError("write failed"))

        result = await plugin.forget("claim-xyz")

        assert result.deleted is False
        assert result.errors

    @pytest.mark.asyncio
    async def test_forget_without_storage(self, plugin):
        """forget() returns error result when storage is None."""
        plugin.storage = None

        result = await plugin.forget("claim-000")

        assert result.deleted is False


# ---------------------------------------------------------------------------
# Tests: cornerstones
# ---------------------------------------------------------------------------

class TestCornerstones:

    @pytest.mark.asyncio
    async def test_get_cornerstones_delegates_to_guardian(self, plugin):
        cs = _CornerstoneMemory(id="cs-1")
        plugin.cornerstone_guardian.get_all = AsyncMock(return_value=[cs])

        result = await plugin.get_cornerstones()

        assert len(result) == 1
        assert result[0].id == "cs-1"

    @pytest.mark.asyncio
    async def test_get_cornerstones_returns_empty_on_error(self, plugin):
        plugin.cornerstone_guardian.get_all = AsyncMock(side_effect=RuntimeError("oops"))

        result = await plugin.get_cornerstones()

        assert result == []

    @pytest.mark.asyncio
    async def test_get_cornerstones_returns_empty_without_guardian(self, plugin):
        plugin.cornerstone_guardian = None

        result = await plugin.get_cornerstones()

        assert result == []

    @pytest.mark.asyncio
    async def test_seal_cornerstone_delegates_to_guardian(self, plugin):
        cs = _CornerstoneMemory(id="cs-new")
        plugin.cornerstone_guardian.seal = AsyncMock(return_value=cs)

        result = await plugin.seal_cornerstone("cs-new", label="Identity", content="I am Eva")

        assert result.id == "cs-new"
        plugin.cornerstone_guardian.seal.assert_called_once()

    @pytest.mark.asyncio
    async def test_seal_cornerstone_returns_none_on_error(self, plugin):
        plugin.cornerstone_guardian.seal = AsyncMock(side_effect=RuntimeError("seal fail"))

        result = await plugin.seal_cornerstone("id", label="L", content="C")

        assert result is None

    @pytest.mark.asyncio
    async def test_unseal_cornerstone_delegates_to_guardian(self, plugin):
        cs = _CornerstoneMemory(id="cs-1", status="unsealed")
        plugin.cornerstone_guardian.unseal = AsyncMock(return_value=cs)

        result = await plugin.unseal_cornerstone("cs-1")

        assert result.status == "unsealed"


# ---------------------------------------------------------------------------
# Tests: session lifecycle
# ---------------------------------------------------------------------------

class TestSessionLifecycle:

    @pytest.mark.asyncio
    async def test_wake_sets_session_id(self, plugin):
        report = await plugin.wake(session_id="sess-42")

        assert report.session_id == "sess-42"
        assert plugin._session_id == "sess-42"

    @pytest.mark.asyncio
    async def test_wake_creates_session_in_storage(self, plugin):
        await plugin.wake(session_id="s99")

        plugin.storage.create_session.assert_called_once()

    @pytest.mark.asyncio
    async def test_wake_loads_cornerstones(self, plugin):
        cs = _CornerstoneMemory(id="cs-1")
        plugin.cornerstone_guardian.load_all = AsyncMock(return_value=[cs, cs])

        report = await plugin.wake()

        assert report.cornerstones_loaded == 2

    @pytest.mark.asyncio
    async def test_wake_handles_storage_error_gracefully(self, plugin):
        plugin.storage.create_session = AsyncMock(side_effect=RuntimeError("DB busy"))

        report = await plugin.wake()

        # Should still succeed (errors recorded, not raised)
        assert report.session_id is not None
        assert any("Failed to record session start" in e for e in report.errors)

    @pytest.mark.asyncio
    async def test_sleep_closes_session(self, plugin):
        plugin._session_id = "sess-wake"

        report = await plugin.sleep()

        assert report.session_id == "sess-wake"
        plugin.storage.close_session.assert_called_once()
        assert plugin._session_id is None

    @pytest.mark.asyncio
    async def test_sleep_handles_storage_error_gracefully(self, plugin):
        plugin.storage.close_session = AsyncMock(side_effect=RuntimeError("DB error"))

        report = await plugin.sleep(session_id="sess-x")

        assert report.session_id == "sess-x"
        assert any("Failed to close session" in e for e in report.errors)


# ---------------------------------------------------------------------------
# Tests: feedback()
# ---------------------------------------------------------------------------

class TestFeedback:

    @pytest.mark.asyncio
    async def test_feedback_submits_helpful(self, plugin):
        plugin.feedback_system.submit_feedback = AsyncMock(
            return_value=_MemoryFeedback("fb-1", "mem-1", "helpful")
        )

        result = await plugin.feedback("mem-1", helpful=True, context="Great!")

        plugin.feedback_system.submit_feedback.assert_called_once_with(
            memory_id="mem-1",
            helpful=True,
            context="Great!",
            target_type="claim",
        )
        assert result.feedback_type == "helpful"

    @pytest.mark.asyncio
    async def test_feedback_submits_harmful(self, plugin):
        plugin.feedback_system.submit_feedback = AsyncMock(
            return_value=_MemoryFeedback("fb-2", "mem-2", "harmful")
        )

        result = await plugin.feedback("mem-2", helpful=False)

        assert result.feedback_type == "harmful"

    @pytest.mark.asyncio
    async def test_feedback_returns_none_without_system(self, plugin):
        plugin.feedback_system = None

        result = await plugin.feedback("mem-x", helpful=True)

        assert result is None

    @pytest.mark.asyncio
    async def test_feedback_returns_none_on_error(self, plugin):
        plugin.feedback_system.submit_feedback = AsyncMock(
            side_effect=RuntimeError("write failed")
        )

        result = await plugin.feedback("mem-y", helpful=True)

        assert result is None


# ---------------------------------------------------------------------------
# Tests: health()
# ---------------------------------------------------------------------------

class TestHealth:

    @pytest.mark.asyncio
    async def test_health_ok_all_modules_present(self, plugin):
        plugin.storage.ping = AsyncMock(return_value=True)

        report = await plugin.health()

        assert report.ok is True
        assert report.storage == "ok"
        assert all(v == "ok" for v in report.modules.values())

    @pytest.mark.asyncio
    async def test_health_degraded_when_storage_fails(self, plugin):
        plugin.storage.ping = AsyncMock(side_effect=RuntimeError("no connection"))

        report = await plugin.health()

        assert report.ok is False
        assert "error" in report.storage

    @pytest.mark.asyncio
    async def test_health_shows_unavailable_for_missing_module(self, plugin):
        plugin.extractor = None

        report = await plugin.health()

        assert report.modules.get("extraction") == "unavailable"
        assert report.ok is False

    @pytest.mark.asyncio
    async def test_health_includes_init_errors(self, plugin):
        plugin._init_errors = ["config: bad path"]

        report = await plugin.health()

        assert any("config" in e for e in report.errors)


# ---------------------------------------------------------------------------
# Integration-style: remember → retrieve roundtrip
# ---------------------------------------------------------------------------

class TestRememberRetrieveRoundtrip:

    @pytest.mark.asyncio
    async def test_roundtrip_with_mocks(self, plugin):
        """
        remember() followed by retrieve() completes without error.
        The pipeline is: extract → resolve → reconcile, then retrieve.
        """
        # Step 1: remember
        await plugin.wake(session_id="integration-session")
        remember_result = await plugin.remember(
            [{"role": "user", "content": "I love hiking on weekends"}],
            session_id="integration-session",
        )

        assert remember_result.claims_extracted >= 1
        assert remember_result.claims_stored >= 1
        assert not any("not available" in e for e in remember_result.errors)

        # Step 2: retrieve
        retrieve_result = await plugin.retrieve(
            "What activities does the user enjoy?",
            token_budget=2000,
        )

        assert retrieve_result is not None
        plugin.retrieval.retrieve.assert_called_once()

        # Step 3: sleep
        sleep_report = await plugin.sleep("integration-session")
        assert sleep_report.session_id == "integration-session"

    @pytest.mark.asyncio
    async def test_roundtrip_partial_failure_still_completes(self, plugin):
        """
        Even if entity resolution fails, the remember+retrieve roundtrip completes.
        """
        plugin.entity_resolver.resolve = AsyncMock(side_effect=RuntimeError("er down"))

        remember_result = await plugin.remember(["Andrew prefers light themes"])

        # Extraction and reconciliation still ran
        assert remember_result.claims_extracted == 1
        plugin.reconciler.reconcile.assert_called_once()

        # Retrieve still works
        retrieve_result = await plugin.retrieve("theme preference")
        assert retrieve_result is not None
