"""
tests/test_sdk.py — Unit tests for cortex.sdk.Cortex (Python SDK).

Strategy:
  - Mock CortexPlugin internals so tests are fast and require no DB/LLM.
  - Test SDK method signatures, delegation, and return-value shaping.
  - Test lifecycle: initialize → wake → remember → retrieve → sleep.
  - Test identity: seal_cornerstone, check_drift.
  - Test diagnostics: health, stats, export.
  - Test class method: from_config.
  - At least 12 tests total.
"""
from __future__ import annotations

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

import pytest
import pytest_asyncio


# ---------------------------------------------------------------------------
# Stub dataclasses matching the real plugin return types
# ---------------------------------------------------------------------------

@dataclass
class _WakeReport:
    session_id: str
    cornerstones_loaded: int = 0
    errors: List[str] = field(default_factory=list)


@dataclass
class _SleepReport:
    session_id: str
    memories_summarised: int = 0
    errors: List[str] = field(default_factory=list)


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


@dataclass
class _RetrievalResult:
    context_block: str = "User prefers dark mode."
    claims: List[Any] = field(default_factory=list)
    total_tokens: int = 10


@dataclass
class _HealthReport:
    ok: bool = True
    storage: str = "ok"
    modules: Dict[str, str] = field(default_factory=lambda: {"storage": "ok", "llm": "ok"})
    errors: List[str] = field(default_factory=list)
    timestamp: str = "2026-01-01T00:00:00"


@dataclass
class _CornerstoneMemory:
    id: str = "cs-001"
    label: str = "core-identity"
    content: str = "I am Eva."
    owner_id: str = "default"


@dataclass
class _DriftReport:
    cornerstone_id: str = "cs-001"
    drift_score: float = 0.0
    action: str = "HOLD"


@dataclass
class _Claim:
    id: str = "claim-001"
    subject: str = "user"
    predicate: str = "prefers"
    object_value: str = "dark mode"
    confidence: float = 0.9
    created_at: str = "2026-01-01T00:00:00"
    status: str = "active"


@dataclass
class _FeedbackRecord:
    id: str = "fb-001"
    memory_id: str = "claim-001"
    helpful: bool = True


# ---------------------------------------------------------------------------
# Helpers: build a fully-mocked CortexPlugin
# ---------------------------------------------------------------------------

def _make_mock_plugin(
    *,
    wake_report: Optional[_WakeReport] = None,
    sleep_report: Optional[_SleepReport] = None,
    remember_result: Optional[_RememberResult] = None,
    retrieval_result: Optional[_RetrievalResult] = None,
    health_report: Optional[_HealthReport] = None,
    seal_result: Optional[_CornerstoneMemory] = None,
    cornerstones: Optional[List[Any]] = None,
    claims: Optional[List[Any]] = None,
    feedback_result: Optional[_FeedbackRecord] = None,
) -> MagicMock:
    """Return a MagicMock that mimics CortexPlugin."""
    plugin = MagicMock()

    # Config
    plugin.config = MagicMock()
    plugin.config.owner_id = "default"
    plugin.config.llm_provider_priority = ["openai"]
    plugin.config.embedding_model = "text-embedding-3-small"
    plugin.config.extraction_model = "gpt-4.1-nano-2025-04-14"

    # Storage mock
    plugin.storage = AsyncMock()
    plugin.storage.initialize = AsyncMock()
    plugin.storage.count_claims = AsyncMock(return_value=5)
    plugin.storage.count_entities = AsyncMock(return_value=3)
    plugin.storage.count_sessions = AsyncMock(return_value=2)
    plugin.storage.get_claims_by_status = AsyncMock(
        return_value=claims or [_Claim()]
    )

    plugin.llm = MagicMock()

    # Main methods
    plugin.wake = AsyncMock(return_value=wake_report or _WakeReport(session_id="test-session"))
    plugin.sleep = AsyncMock(return_value=sleep_report or _SleepReport(session_id="test-session"))
    plugin.remember = AsyncMock(return_value=remember_result or _RememberResult(session_id="test-session"))
    plugin.retrieve = AsyncMock(return_value=retrieval_result or _RetrievalResult())
    plugin.health = AsyncMock(return_value=health_report or _HealthReport())
    plugin.seal_cornerstone = AsyncMock(return_value=seal_result or _CornerstoneMemory())
    plugin.get_cornerstones = AsyncMock(return_value=cornerstones or [_CornerstoneMemory()])
    plugin.feedback = AsyncMock(return_value=feedback_result or _FeedbackRecord())

    return plugin


def _make_cortex_with_mock_plugin(**kwargs) -> "Cortex":
    """Return a Cortex instance whose _plugin is replaced with a mock."""
    from cortex.sdk import Cortex

    with patch("cortex.sdk.Cortex._build_plugin", return_value=_make_mock_plugin(**kwargs)):
        c = Cortex(db_path=":memory:")
    return c


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

class TestCortexInit:
    """Test Cortex initialisation and class construction."""

    def test_default_construction(self):
        """Cortex can be created with all defaults — no exceptions."""
        with patch("cortex.sdk.Cortex._build_plugin", return_value=_make_mock_plugin()):
            from cortex.sdk import Cortex
            c = Cortex()
        assert c._db_path == "cortex.db"
        assert c._profile == "companion"
        assert c._llm_provider == "openai"

    def test_custom_params(self):
        """Custom db_path, profile, and llm_provider are stored."""
        with patch("cortex.sdk.Cortex._build_plugin", return_value=_make_mock_plugin()):
            from cortex.sdk import Cortex
            c = Cortex(
                db_path="/tmp/test.db",
                profile="developer",
                llm_provider="anthropic",
                embed_model="embed-v3",
                extract_model="claude-3",
            )
        assert c._db_path == "/tmp/test.db"
        assert c._profile == "developer"
        assert c._llm_provider == "anthropic"
        assert c._embed_model == "embed-v3"
        assert c._extract_model == "claude-3"

    def test_repr(self):
        """__repr__ contains key fields."""
        c = _make_cortex_with_mock_plugin()
        r = repr(c)
        assert "Cortex(" in r
        assert ":memory:" in r

    @pytest.mark.asyncio
    async def test_initialize_calls_storage(self):
        """initialize() calls storage.initialize()."""
        c = _make_cortex_with_mock_plugin()
        await c.initialize()
        c._plugin.storage.initialize.assert_called_once()


class TestWakeSleep:
    """Test session lifecycle methods."""

    @pytest.mark.asyncio
    async def test_wake_returns_report(self):
        """wake() returns a WakeReport with the session_id."""
        c = _make_cortex_with_mock_plugin()
        report = await c.wake(session_id="my-session")
        assert report.session_id == "test-session"
        c._plugin.wake.assert_called_once_with(session_id="my-session")

    @pytest.mark.asyncio
    async def test_wake_stores_active_session_id(self):
        """wake() stores session_id on the instance for later calls."""
        c = _make_cortex_with_mock_plugin(
            wake_report=_WakeReport(session_id="active-session")
        )
        await c.wake()
        assert c._active_session_id == "active-session"

    @pytest.mark.asyncio
    async def test_sleep_returns_report(self):
        """sleep() returns a SleepReport."""
        c = _make_cortex_with_mock_plugin()
        c._active_session_id = "test-session"
        report = await c.sleep()
        assert report.session_id == "test-session"

    @pytest.mark.asyncio
    async def test_sleep_clears_active_session(self):
        """sleep() clears _active_session_id after closing."""
        c = _make_cortex_with_mock_plugin()
        c._active_session_id = "some-session"
        await c.sleep()
        assert c._active_session_id is None

    @pytest.mark.asyncio
    async def test_wake_sleep_full_lifecycle(self):
        """wake → remember → sleep lifecycle completes without errors."""
        c = _make_cortex_with_mock_plugin(
            wake_report=_WakeReport(session_id="lifecycle-session"),
            sleep_report=_SleepReport(session_id="lifecycle-session"),
        )
        wake_r = await c.wake(session_id="lifecycle-session")
        assert wake_r.session_id == "lifecycle-session"

        rem_r = await c.remember("Andrew lives in Bangkok.")
        assert rem_r.ok is True

        sleep_r = await c.sleep(session_id="lifecycle-session")
        assert sleep_r.session_id == "lifecycle-session"
        assert c._active_session_id is None


class TestRememberRetrieve:
    """Test the remember + retrieve roundtrip."""

    @pytest.mark.asyncio
    async def test_remember_returns_result(self):
        """remember() returns a RememberResult with ok=True."""
        c = _make_cortex_with_mock_plugin()
        result = await c.remember("User prefers dark mode")
        assert result.ok is True
        assert result.claims_extracted == 2
        assert result.claims_stored == 2

    @pytest.mark.asyncio
    async def test_remember_passes_content_to_plugin(self):
        """remember() delegates to plugin.remember with the content."""
        c = _make_cortex_with_mock_plugin()
        await c.remember("test content", session_id="s1")
        c._plugin.remember.assert_called_once_with(
            conversation="test content", session_id="s1"
        )

    @pytest.mark.asyncio
    async def test_retrieve_returns_result(self):
        """retrieve() returns a RetrievalResult with context_block."""
        c = _make_cortex_with_mock_plugin()
        result = await c.retrieve("What does the user prefer?")
        assert result.context_block == "User prefers dark mode."

    @pytest.mark.asyncio
    async def test_remember_retrieve_roundtrip(self):
        """remember + retrieve sequence both succeed and return expected shapes."""
        c = _make_cortex_with_mock_plugin(
            remember_result=_RememberResult(
                session_id="s1", claims_extracted=1, claims_stored=1, entities_resolved=0,
                noise_filtered=0, ok=True
            ),
            retrieval_result=_RetrievalResult(context_block="dark mode preference"),
        )
        await c.wake(session_id="s1")
        rem = await c.remember("User prefers dark mode", session_id="s1")
        ctx = await c.retrieve("user preference", owner_id="default")

        assert rem.ok is True
        assert rem.claims_stored == 1
        assert ctx.context_block == "dark mode preference"

    @pytest.mark.asyncio
    async def test_ask_returns_result(self):
        """ask() returns a result with answer or context."""
        c = _make_cortex_with_mock_plugin()
        c._plugin.ask = AsyncMock(return_value=type("R", (), {"answer": "Tokyo", "sources": [], "confidence": 0.8, "reasoning": ""})())
        result = await c.ask("Where does Andrew live?")
        # ask() now delegates to dialectic engine or falls back to retrieve
        assert result is not None


class TestCornerstones:
    """Test cornerstone sealing."""

    @pytest.mark.asyncio
    async def test_seal_cornerstone_returns_cornerstone(self):
        """seal_cornerstone() returns a CornerstoneMemory."""
        c = _make_cortex_with_mock_plugin(
            seal_result=_CornerstoneMemory(label="my-identity", content="I am Eva.")
        )
        cs = await c.seal_cornerstone("my-identity", "I am Eva.")
        assert cs.label == "my-identity"
        assert cs.content == "I am Eva."

    @pytest.mark.asyncio
    async def test_seal_cornerstone_calls_plugin(self):
        """seal_cornerstone() delegates to plugin.seal_cornerstone."""
        c = _make_cortex_with_mock_plugin()
        await c.seal_cornerstone("identity", "core content")
        c._plugin.seal_cornerstone.assert_called_once_with(
            memory_id="identity",
            label="identity",
            content="core content",
        )

    @pytest.mark.asyncio
    async def test_check_drift_returns_list(self):
        """check_drift() returns a list (may be empty if DriftCalculator fails to init)."""
        c = _make_cortex_with_mock_plugin()
        # DriftCalculator will fail to init (no real LLM/storage) → returns []
        result = await c.check_drift()
        assert isinstance(result, list)


class TestHealth:
    """Test health() diagnostics."""

    @pytest.mark.asyncio
    async def test_health_returns_dict(self):
        """health() returns a dict with 'ok', 'storage', 'modules', 'errors'."""
        c = _make_cortex_with_mock_plugin()
        h = await c.health()
        assert isinstance(h, dict)
        assert "ok" in h
        assert "storage" in h
        assert "modules" in h
        assert "errors" in h

    @pytest.mark.asyncio
    async def test_health_ok_when_modules_healthy(self):
        """health() returns ok=True when all modules report 'ok'."""
        c = _make_cortex_with_mock_plugin(
            health_report=_HealthReport(ok=True, storage="ok", modules={"storage": "ok"})
        )
        h = await c.health()
        assert h["ok"] is True

    @pytest.mark.asyncio
    async def test_health_not_ok_when_storage_down(self):
        """health() returns ok=False when storage is unavailable."""
        c = _make_cortex_with_mock_plugin(
            health_report=_HealthReport(
                ok=False,
                storage="error: connection refused",
                modules={"storage": "error: connection refused"},
                errors=["storage health: connection refused"],
            )
        )
        h = await c.health()
        assert h["ok"] is False
        assert len(h["errors"]) > 0


class TestStats:
    """Test stats() and export()."""

    @pytest.mark.asyncio
    async def test_stats_returns_expected_keys(self):
        """stats() returns a dict with claims, entities, cornerstones, sessions."""
        c = _make_cortex_with_mock_plugin()
        s = await c.stats()
        assert "claims" in s
        assert "entities" in s
        assert "cornerstones" in s
        assert "sessions" in s

    @pytest.mark.asyncio
    async def test_stats_counts_are_ints(self):
        """stats() returns numeric counts."""
        c = _make_cortex_with_mock_plugin()
        s = await c.stats()
        assert isinstance(s["claims"], int)
        assert isinstance(s["entities"], int)

    @pytest.mark.asyncio
    async def test_export_returns_list(self):
        """export() returns a list of claim dicts."""
        c = _make_cortex_with_mock_plugin(claims=[_Claim()])
        result = await c.export(format="json")
        assert isinstance(result, list)
        if result:
            assert "id" in result[0]
            assert "subject" in result[0]


class TestFeedback:
    """Test feedback submission."""

    @pytest.mark.asyncio
    async def test_feedback_helpful(self):
        """feedback() with 'helpful' maps to helpful=True."""
        c = _make_cortex_with_mock_plugin()
        fb = await c.feedback("claim-001", "helpful")
        c._plugin.feedback.assert_called_once_with(
            memory_id="claim-001",
            helpful=True,
            context=None,
        )
        assert fb is not None

    @pytest.mark.asyncio
    async def test_feedback_unhelpful(self):
        """feedback() with 'unhelpful' maps to helpful=False."""
        c = _make_cortex_with_mock_plugin()
        await c.feedback("claim-001", "unhelpful", value="wrong context")
        c._plugin.feedback.assert_called_once_with(
            memory_id="claim-001",
            helpful=False,
            context="wrong context",
        )


class TestFromConfig:
    """Test Cortex.from_config classmethod."""

    def test_from_config_reads_db_path(self, tmp_path):
        """from_config() reads db_path from [cortex.storage] section."""
        config = tmp_path / "cortex.toml"
        config.write_text(
            '[cortex]\nprofile = "developer"\n\n'
            '[cortex.storage]\ndb_path = "/tmp/sdk_test.db"\n\n'
            '[cortex.llm]\nproviders = ["anthropic"]\n'
        )
        with patch("cortex.sdk.Cortex._build_plugin", return_value=_make_mock_plugin()):
            from cortex.sdk import Cortex
            c = Cortex.from_config(str(config))

        assert c._db_path == "/tmp/sdk_test.db"
        assert c._profile == "developer"
        assert c._llm_provider == "anthropic"

    def test_from_config_defaults_on_minimal_file(self, tmp_path):
        """from_config() uses SDK defaults when sections are absent."""
        config = tmp_path / "cortex.toml"
        config.write_text('[cortex]\n')
        with patch("cortex.sdk.Cortex._build_plugin", return_value=_make_mock_plugin()):
            from cortex.sdk import Cortex
            c = Cortex.from_config(str(config))

        assert c._db_path == "cortex.db"
        assert c._profile == "companion"

    def test_from_config_missing_file_raises(self):
        """from_config() raises FileNotFoundError for non-existent path."""
        with patch("cortex.sdk.Cortex._build_plugin", return_value=_make_mock_plugin()):
            from cortex.sdk import Cortex
            with pytest.raises((FileNotFoundError, OSError)):
                Cortex.from_config("/nonexistent/path/cortex.toml")


class TestProfileProviders:
    """Test internal profile → provider mapping."""

    def test_companion_profile_uses_openai(self):
        from cortex.sdk import _providers_for_profile
        assert _providers_for_profile("companion", "openai") == ["openai"]

    def test_explicit_provider_overrides_profile(self):
        from cortex.sdk import _providers_for_profile
        assert _providers_for_profile("companion", "anthropic") == ["anthropic"]

    def test_local_profile_uses_ollama(self):
        from cortex.sdk import _providers_for_profile
        assert _providers_for_profile("local", "openai") == ["ollama"]

    def test_unknown_profile_falls_back_to_openai(self):
        from cortex.sdk import _providers_for_profile
        assert _providers_for_profile("unknown_profile", "openai") == ["openai"]


# ---------------------------------------------------------------------------
# Tests: Dream cycle (issue #274)
# ---------------------------------------------------------------------------

@dataclass
class _DreamReport:
    owner_id: str = "default"
    duration_ms: int = 200
    orphans_recovered: int = 1
    claims_consolidated: int = 4
    contradictions_resolved: int = 2
    claims_decayed: int = 3
    claims_archived: int = 0
    cornerstone_proposals: int = 1
    entity_merge_suggestions: int = 0
    brain_graphs_refreshed: int = 1
    feedback_applied: int = 0
    journal: str = "## Dream journal\n- Consolidated 4 claims"


class TestDream:
    """SDK dream() must delegate to CortexPlugin.dream() for the full cycle."""

    def _make_cortex_with_dream(self, dream_report=None):
        """Return a Cortex whose _plugin.dream is wired to return dream_report."""
        mock_plugin = _make_mock_plugin()
        mock_plugin.dream = AsyncMock(return_value=dream_report or _DreamReport())
        from cortex.sdk import Cortex
        with patch("cortex.sdk.Cortex._build_plugin", return_value=mock_plugin):
            c = Cortex(db_path=":memory:")
        return c

    @pytest.mark.asyncio
    async def test_dream_delegates_to_plugin(self):
        """sdk.dream() must call plugin.dream(), not just a drift check."""
        c = self._make_cortex_with_dream()
        result = await c.dream()
        c._plugin.dream.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_dream_returns_dict_with_ok(self):
        """Return value is a dict with ok=True."""
        c = self._make_cortex_with_dream()
        result = await c.dream()
        assert isinstance(result, dict)
        assert result["ok"] is True

    @pytest.mark.asyncio
    async def test_dream_includes_claims_consolidated(self):
        """claims_consolidated populated from DreamReport."""
        c = self._make_cortex_with_dream(_DreamReport(claims_consolidated=7))
        result = await c.dream()
        assert result["claims_consolidated"] == 7

    @pytest.mark.asyncio
    async def test_dream_includes_claims_decayed(self):
        """claims_decayed populated from DreamReport."""
        c = self._make_cortex_with_dream(_DreamReport(claims_decayed=5))
        result = await c.dream()
        assert result["claims_decayed"] == 5

    @pytest.mark.asyncio
    async def test_dream_includes_journal(self):
        """journal populated from DreamReport."""
        c = self._make_cortex_with_dream(_DreamReport(journal="## Dream log"))
        result = await c.dream()
        assert result["journal"] == "## Dream log"

    @pytest.mark.asyncio
    async def test_dream_steps_completed_counts_nonzero_fields(self):
        """steps_completed = count of non-zero step fields."""
        report = _DreamReport(
            orphans_recovered=1,
            claims_consolidated=3,
            contradictions_resolved=0,
            claims_decayed=2,
            claims_archived=0,
            cornerstone_proposals=0,
            entity_merge_suggestions=0,
            brain_graphs_refreshed=0,
            feedback_applied=0,
        )
        c = self._make_cortex_with_dream(report)
        result = await c.dream()
        # orphans_recovered=1, claims_consolidated=3, claims_decayed=2 → 3 non-zero
        assert result["steps_completed"] == 3

    @pytest.mark.asyncio
    async def test_dream_owner_id_passed_to_plugin(self):
        """owner_id is forwarded to plugin.dream()."""
        c = self._make_cortex_with_dream()
        await c.dream(owner_id="eva")
        c._plugin.dream.assert_awaited_once_with(owner_id="eva")

    @pytest.mark.asyncio
    async def test_dream_full_cycle_not_just_drift(self):
        """plugin.dream() is called — not check_drift alone."""
        c = self._make_cortex_with_dream()
        c._plugin.check_drift = AsyncMock(return_value=[])  # should NOT be sole call
        await c.dream()
        # plugin.dream must have been called (full cycle)
        c._plugin.dream.assert_awaited_once()
        # check_drift should NOT have been called (drift is part of the engine, not sdk)
        c._plugin.check_drift.assert_not_awaited()
