"""
tests/test_sleep_consolidator.py — Unit tests for MODULE 6b: SleepConsolidator

Tests cover:
  - sleep() full 5-step pipeline
  - dream() full 9-step pipeline
  - SleepReport / DreamReport field population
  - Cornerstone drift check and auto-restore during sleep
  - Contradiction detection and resolution during dream
  - Memory decay integration (dream)
  - Orphan recovery during dream
  - Entity merge suggestions (dream)
  - Feedback application (dream, direct storage path)
  - Cornerstone evolution proposals (dream)
  - Graceful degradation when optional deps are None
  - LLM failure handling (non-fatal)

All storage, LLM, drift, and deprecation calls are mocked — no real DB.
"""

from __future__ import annotations

import json
import uuid
import pytest
import pytest_asyncio
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

from cortex.circadian.sleep import (
    SleepConsolidator,
    SleepReport,
    DreamReport,
    _parse_dt,
    _parse_json_dict,
    _parse_json_list,
)


# ---------------------------------------------------------------------------
# Helpers — shared test data factories
# ---------------------------------------------------------------------------

def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _hours_ago(h: float) -> str:
    return (datetime.now(timezone.utc) - timedelta(hours=h)).isoformat()


def _make_claim(
    session_id: str = "sess-1",
    memory_type: str = "session",
    status: str = "active",
    subject: str = "user",
    predicate: str = "prefers",
    object_value: str = "dark mode",
    confidence: float = 0.5,
    owner_id: str = "default",
    hours_old: float = 0.5,
) -> MagicMock:
    c = MagicMock()
    c.id = str(uuid.uuid4())
    c.owner_id = owner_id
    c.session_id = session_id
    c.memory_type = memory_type
    c.status = status
    c.subject = subject
    c.predicate = predicate
    c.object_value = object_value
    c.confidence = confidence
    c.created_at = _hours_ago(hours_old)
    c.updated_at = _now()
    c.last_accessed = _now()
    c.access_count = 1
    c.superseded_by = None
    return c


def _make_cornerstone(cs_id: str = "cs-1", label: str = "identity") -> MagicMock:
    cs = MagicMock()
    cs.id = cs_id
    cs.owner_id = "default"
    cs.label = label
    cs.content = "This is my golden identity content."
    cs.golden_copy = cs.content
    cs.golden_hash = "abc123"
    cs.current_hash = "abc123"
    cs.status = "active"
    return cs


def _make_drift_report(
    cs_id: str = "cs-1",
    label: str = "identity",
    drift_score: float = 0.05,
    action: str = "none",
) -> MagicMock:
    dr = MagicMock()
    dr.cornerstone_id = cs_id
    dr.label = label
    dr.drift_score = drift_score
    dr.semantic_sim = 0.98
    dr.length_ratio = 0.99
    dr.phrase_preservation = 0.97
    dr.action = action
    dr.checked_at = _now()
    return dr


def _make_session(session_id: str = "sess-1", owner_id: str = "default") -> MagicMock:
    s = MagicMock()
    s.id = str(uuid.uuid4())
    s.session_id = session_id
    s.owner_id = owner_id
    s.started_at = _hours_ago(2)
    s.status = "awake"
    s.fatigue_score = 0.9
    return s


def _make_feedback(owner_id: str = "default") -> MagicMock:
    fb = MagicMock()
    fb.id = str(uuid.uuid4())
    fb.owner_id = owner_id
    fb.target_type = "claim"
    fb.target_id = str(uuid.uuid4())
    fb.feedback_type = "wrong"
    fb.status = "pending"
    return fb


# ---------------------------------------------------------------------------
# Mock dependencies
# ---------------------------------------------------------------------------

class MockStorage:
    """Minimal async mock storage for SleepConsolidator tests."""

    def __init__(
        self,
        session_claims: Optional[List] = None,
        active_session=None,
        orphaned_sessions: Optional[List] = None,
        all_claims: Optional[List] = None,
        cornerstones: Optional[List] = None,
        pending_feedback: Optional[List] = None,
    ):
        self._session_claims = session_claims or []
        self._active_session = active_session
        self._orphaned_sessions = orphaned_sessions or []
        self._all_claims = all_claims or []
        self._cornerstones = cornerstones or []
        self._pending_feedback = pending_feedback or []
        # Track calls for assertion
        self.update_session_calls: List[dict] = []
        self.update_claim_calls: List[dict] = []
        self.apply_feedback_calls: List[str] = []

    async def get_session_claims(self, session_id: str) -> List:
        return self._session_claims

    async def update_session(self, session_id: str, **kwargs) -> MagicMock:
        self.update_session_calls.append({"session_id": session_id, **kwargs})
        rec = MagicMock()
        rec.session_id = session_id
        return rec

    async def get_active_session(self, owner_id: str = "default") -> Optional[MagicMock]:
        return self._active_session

    async def get_orphaned_sessions(self, idle_seconds: int) -> List:
        return self._orphaned_sessions

    async def get_claims_by_status(self, status: str, owner_id: str = "default") -> List:
        return [c for c in self._all_claims if c.status == status]

    async def get_cornerstones(
        self, owner_id: str = "default", status: str = "active"
    ) -> List:
        return self._cornerstones

    async def get_pending_feedback(self, owner_id: str = "default") -> List:
        return self._pending_feedback

    async def apply_feedback(self, feedback_id: str) -> MagicMock:
        self.apply_feedback_calls.append(feedback_id)
        fb = MagicMock()
        fb.id = feedback_id
        fb.status = "applied"
        return fb

    async def update_claim(self, claim_id: str, **kwargs) -> MagicMock:
        self.update_claim_calls.append({"claim_id": claim_id, **kwargs})
        return MagicMock()


class MockDeprecationPipeline:
    def __init__(
        self,
        promoted: int = 2,
        deprecated: int = 1,
        kept_session: int = 0,
        auto_noise: int = 0,
        decay_cooling: int = 3,
        decay_deprecated: int = 1,
        decay_archived: int = 0,
        confidence_reduced: int = 2,
    ):
        self._promoted = promoted
        self._deprecated = deprecated
        self._kept_session = kept_session
        self._auto_noise = auto_noise
        self._decay_cooling = decay_cooling
        self._decay_deprecated = decay_deprecated
        self._decay_archived = decay_archived
        self._confidence_reduced = confidence_reduced

        self.review_session_calls: List[str] = []
        self.run_decay_calls: List[str] = []

    async def review_session(self, session_id: str) -> MagicMock:
        self.review_session_calls.append(session_id)
        report = MagicMock()
        report.session_id = session_id
        report.promoted = self._promoted
        report.deprecated = self._deprecated
        report.kept_session = self._kept_session
        report.auto_noise = self._auto_noise
        return report

    async def run_decay(self, owner_id: str = "default") -> MagicMock:
        self.run_decay_calls.append(owner_id)
        report = MagicMock()
        report.owner_id = owner_id
        report.transitioned_to_cooling = self._decay_cooling
        report.transitioned_to_deprecated = self._decay_deprecated
        report.transitioned_to_archived = self._decay_archived
        report.confidence_reduced = self._confidence_reduced
        return report


class MockDriftCalculator:
    def __init__(
        self,
        drift_reports: Optional[List] = None,
        fail: bool = False,
    ):
        self._drift_reports = drift_reports or []
        self._fail = fail
        self.check_all_calls: List[str] = []
        self.restore_calls: List[str] = []

    async def check_all(self, owner_id: str = "default") -> List:
        self.check_all_calls.append(owner_id)
        if self._fail:
            raise RuntimeError("drift calculator failure (simulated)")
        return self._drift_reports

    async def restore(self, cs_id: str) -> MagicMock:
        self.restore_calls.append(cs_id)
        return MagicMock()


class MockCornerstoneGuardian:
    def __init__(self):
        self.propose_evolution_calls: List[dict] = []

    async def propose_evolution(
        self,
        label: str,
        proposed_content: str,
        reason: str = "",
        owner_id: str = "default",
        **kwargs,
    ) -> MagicMock:
        self.propose_evolution_calls.append(
            {"label": label, "content": proposed_content, "reason": reason, "owner_id": owner_id}
        )
        return MagicMock()


class MockEntityResolver:
    def __init__(self, suggestions: Optional[List] = None):
        self._suggestions = suggestions or []
        self.suggest_merges_calls: List[str] = []

    async def suggest_merges(self, owner_id: str = "default", **kwargs) -> List:
        self.suggest_merges_calls.append(owner_id)
        return self._suggestions


class MockFeedbackSystem:
    def __init__(self, applied: int = 3, fail: bool = False):
        self._applied = applied
        self._fail = fail
        self.process_pending_calls: List[str] = []

    async def process_pending(self, owner_id: Optional[str] = None) -> int:
        self.process_pending_calls.append(owner_id or "default")
        if self._fail:
            raise RuntimeError("feedback system failure (simulated)")
        return self._applied


class MockConfig:
    owner_id = "default"
    dream_cycle_idle_timeout_sec = 3600
    dream_decay_window_hours = 24
    promoted_confidence_default = 0.8
    session_fatigue_nap_threshold = 0.7
    session_fatigue_sleep_threshold = 0.9


class MockLLM:
    """LLM that returns predictable JSON or text."""

    def __init__(
        self,
        summary_text: str = "Session summary: user prefers dark mode.",
        contradiction_json: Optional[str] = None,
        proposal_json: Optional[str] = None,
        fail: bool = False,
    ):
        self._summary_text = summary_text
        self._contradiction_json = contradiction_json or '{"contradictions": []}'
        self._proposal_json = proposal_json or "[]"
        self._fail = fail
        self.complete_calls: List[dict] = []

    async def complete(
        self,
        system: str,
        user: str,
        model: str = None,
        response_format: str = "json",
    ) -> str:
        self.complete_calls.append({"system": system[:50], "format": response_format})
        if self._fail:
            raise RuntimeError("LLM failure (simulated)")
        if response_format == "text":
            return self._summary_text
        # Detect which prompt this is by system prefix
        if "contradiction" in system.lower():
            return self._contradiction_json
        if "cornerstone" in system.lower() or "identity analyst" in system.lower():
            return self._proposal_json
        return "{}"

    async def embed(self, texts: List[str], model: str = None) -> List[List[float]]:
        return [[0.1] * 1536 for _ in texts]


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

@pytest.fixture
def config():
    return MockConfig()


@pytest.fixture
def simple_consolidator(config):
    """A consolidator with no session claims and healthy drifts."""
    storage = MockStorage()
    deprecation = MockDeprecationPipeline()
    drift = MockDriftCalculator()
    guardian = MockCornerstoneGuardian()
    llm = MockLLM()
    return SleepConsolidator(
        storage=storage,
        config=config,
        llm=llm,
        cornerstone_guardian=guardian,
        drift_calculator=drift,
        deprecation_pipeline=deprecation,
    )


# ---------------------------------------------------------------------------
# Tests — sleep() pipeline
# ---------------------------------------------------------------------------

class TestSleepPipeline:

    @pytest.mark.asyncio
    async def test_sleep_returns_sleep_report(self, config):
        """sleep() returns a populated SleepReport."""
        claims = [_make_claim(memory_type="session")]
        storage = MockStorage(session_claims=claims)
        cons = SleepConsolidator(
            storage=storage,
            config=config,
            llm=MockLLM(),
            cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(promoted=2, deprecated=1),
        )
        report = await cons.sleep("sess-1")
        assert isinstance(report, SleepReport)
        assert report.session_id == "sess-1"
        assert report.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_sleep_populates_claims_created(self, config):
        """sleep() sets claims_created to count of session claims."""
        claims = [_make_claim() for _ in range(5)]
        storage = MockStorage(session_claims=claims)
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        report = await cons.sleep("sess-1")
        assert report.claims_created == 5

    @pytest.mark.asyncio
    async def test_sleep_populates_claims_promoted_and_pruned(self, config):
        """sleep() accumulates promoted/pruned from DeprecationPipeline."""
        claims = [_make_claim()]
        storage = MockStorage(session_claims=claims)
        dep = MockDeprecationPipeline(promoted=3, deprecated=2)
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=dep,
        )
        report = await cons.sleep("sess-1")
        assert report.claims_promoted == 3
        assert report.claims_pruned == 2
        assert dep.review_session_calls == ["sess-1"]

    @pytest.mark.asyncio
    async def test_sleep_drift_check_called(self, config):
        """sleep() calls drift_calculator.check_all()."""
        drift = MockDriftCalculator(
            drift_reports=[_make_drift_report(action="none")]
        )
        cons = SleepConsolidator(
            storage=MockStorage(), config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=drift,
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        report = await cons.sleep("sess-1")
        assert len(drift.check_all_calls) == 1
        assert len(report.drift_checks) == 1

    @pytest.mark.asyncio
    async def test_sleep_restores_drifted_cornerstone(self, config):
        """sleep() calls drift_calculator.restore() for restore-action cornerstones."""
        dr_restore = _make_drift_report(cs_id="cs-bad", drift_score=0.4, action="restore")
        dr_ok = _make_drift_report(cs_id="cs-ok", drift_score=0.05, action="none")
        drift = MockDriftCalculator(drift_reports=[dr_restore, dr_ok])
        cons = SleepConsolidator(
            storage=MockStorage(), config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=drift,
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        await cons.sleep("sess-1")
        assert "cs-bad" in drift.restore_calls
        assert "cs-ok" not in drift.restore_calls

    @pytest.mark.asyncio
    async def test_sleep_restores_alert_cornerstone(self, config):
        """sleep() also restores cornerstones with action='alert'."""
        dr_alert = _make_drift_report(cs_id="cs-alert", drift_score=0.6, action="alert")
        drift = MockDriftCalculator(drift_reports=[dr_alert])
        cons = SleepConsolidator(
            storage=MockStorage(), config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=drift,
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        await cons.sleep("sess-1")
        assert "cs-alert" in drift.restore_calls

    @pytest.mark.asyncio
    async def test_sleep_updates_session_status(self, config):
        """sleep() updates session status to 'sleeping' in storage."""
        storage = MockStorage()
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        await cons.sleep("sess-123")
        status_updates = [
            c for c in storage.update_session_calls
            if c.get("status") == "sleeping"
        ]
        assert len(status_updates) >= 1
        assert status_updates[0]["session_id"] == "sess-123"

    @pytest.mark.asyncio
    async def test_sleep_with_no_claims(self, config):
        """sleep() handles empty session gracefully."""
        storage = MockStorage(session_claims=[])
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        report = await cons.sleep("empty-sess")
        assert report.claims_created == 0
        assert report.session_id == "empty-sess"
        assert "(no session claims)" in report.summary

    @pytest.mark.asyncio
    async def test_sleep_llm_failure_is_nonfatal(self, config):
        """sleep() continues even if LLM summary fails."""
        claims = [_make_claim()]
        storage = MockStorage(session_claims=claims)
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=MockLLM(fail=True), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        report = await cons.sleep("sess-1")
        # Should still complete with a degraded summary
        assert report.session_id == "sess-1"
        assert "unavailable" in report.summary or report.summary

    @pytest.mark.asyncio
    async def test_sleep_drift_failure_is_nonfatal(self, config):
        """sleep() continues even if drift_calculator.check_all() raises."""
        drift = MockDriftCalculator(fail=True)
        storage = MockStorage(session_claims=[_make_claim()])
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=drift,
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        report = await cons.sleep("sess-1")
        # drift_checks should be empty (failure was caught)
        assert report.drift_checks == []
        assert report.session_id == "sess-1"

    @pytest.mark.asyncio
    async def test_sleep_quick_consolidation_promotes_semantic_claims(self, config):
        """sleep() updates promoted claims to at least promoted_confidence (0.8)."""
        low_conf_semantic = _make_claim(
            memory_type="semantic", status="active", confidence=0.5
        )
        storage = MockStorage(session_claims=[low_conf_semantic])
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        await cons.sleep("sess-1")
        # update_claim should have been called to boost confidence
        boost_calls = [
            c for c in storage.update_claim_calls
            if c.get("claim_id") == low_conf_semantic.id
            and c.get("confidence", 0) >= 0.8
        ]
        assert len(boost_calls) == 1

    @pytest.mark.asyncio
    async def test_sleep_does_not_boost_already_high_confidence(self, config):
        """sleep() does not lower confidence that's already above promoted threshold."""
        high_conf_semantic = _make_claim(
            memory_type="semantic", status="active", confidence=0.95
        )
        storage = MockStorage(session_claims=[high_conf_semantic])
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        await cons.sleep("sess-1")
        boost_calls = [
            c for c in storage.update_claim_calls
            if c.get("claim_id") == high_conf_semantic.id
        ]
        # Should not lower confidence (no call needed since 0.95 >= 0.8)
        for call in boost_calls:
            assert call.get("confidence", 0.95) >= 0.95

    @pytest.mark.asyncio
    async def test_sleep_report_has_summary(self, config):
        """sleep() report summary is non-empty when LLM succeeds."""
        claims = [_make_claim()]
        storage = MockStorage(session_claims=claims)
        llm = MockLLM(summary_text="User likes dark mode and Python.")
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=llm, cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        report = await cons.sleep("sess-1")
        assert "dark mode" in report.summary or report.summary  # LLM text passed through

    @pytest.mark.asyncio
    async def test_sleep_report_duration_is_positive(self, config):
        """sleep() report has positive duration_ms."""
        cons = SleepConsolidator(
            storage=MockStorage(), config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        report = await cons.sleep("sess-1")
        assert report.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_sleep_report_completed_at_is_set(self, config):
        """sleep() report has a completed_at timestamp."""
        cons = SleepConsolidator(
            storage=MockStorage(), config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=MockDriftCalculator(),
            deprecation_pipeline=MockDeprecationPipeline(),
        )
        report = await cons.sleep("sess-1")
        # Should be parseable as ISO datetime
        dt = datetime.fromisoformat(report.completed_at)
        assert dt is not None


# ---------------------------------------------------------------------------
# Tests — dream() pipeline
# ---------------------------------------------------------------------------

class TestDreamPipeline:

    def _make_cons(
        self,
        config,
        storage: Optional[MockStorage] = None,
        dep: Optional[MockDeprecationPipeline] = None,
        drift: Optional[MockDriftCalculator] = None,
        guardian: Optional[MockCornerstoneGuardian] = None,
        llm: Optional[MockLLM] = None,
        entity_resolver=None,
        feedback_system=None,
    ) -> SleepConsolidator:
        return SleepConsolidator(
            storage=storage or MockStorage(),
            config=config,
            llm=llm or MockLLM(),
            cornerstone_guardian=guardian or MockCornerstoneGuardian(),
            drift_calculator=drift or MockDriftCalculator(),
            deprecation_pipeline=dep or MockDeprecationPipeline(),
            entity_resolver=entity_resolver,
            feedback_system=feedback_system,
        )

    @pytest.mark.asyncio
    async def test_dream_returns_dream_report(self, config):
        """dream() returns a DreamReport."""
        cons = self._make_cons(config)
        report = await cons.dream("default")
        assert isinstance(report, DreamReport)
        assert report.owner_id == "default"

    @pytest.mark.asyncio
    async def test_dream_orphan_recovery(self, config):
        """dream() recovers orphaned sessions by calling sleep() on them."""
        orphan = _make_session("orphan-sess")
        storage = MockStorage(
            orphaned_sessions=[orphan],
            session_claims=[],  # orphan's claims
        )
        dep = MockDeprecationPipeline()
        drift = MockDriftCalculator()
        cons = self._make_cons(config, storage=storage, dep=dep, drift=drift)
        report = await cons.dream("default")
        assert report.orphans_recovered == 1
        # sleep() should have called update_session with status=sleeping
        sleep_calls = [
            c for c in storage.update_session_calls
            if c.get("session_id") == "orphan-sess" and c.get("status") == "sleeping"
        ]
        assert len(sleep_calls) >= 1

    @pytest.mark.asyncio
    async def test_dream_orphan_recovery_filters_by_owner(self, config):
        """dream() only recovers orphans belonging to the correct owner."""
        orphan_default = _make_session("sess-default", "default")
        orphan_other = _make_session("sess-other", "other-owner")
        storage = MockStorage(orphaned_sessions=[orphan_default, orphan_other])
        cons = self._make_cons(config, storage=storage)
        report = await cons.dream("default")
        # Only the default-owner orphan should be recovered
        assert report.orphans_recovered == 1

    @pytest.mark.asyncio
    async def test_dream_memory_decay_called(self, config):
        """dream() calls DeprecationPipeline.run_decay()."""
        dep = MockDeprecationPipeline(
            decay_cooling=5, decay_deprecated=2, decay_archived=1, confidence_reduced=3
        )
        cons = self._make_cons(config, dep=dep)
        report = await cons.dream("default")
        assert "default" in dep.run_decay_calls
        assert report.claims_decayed == 5 + 2 + 3  # cooling + deprecated + confidence_reduced
        assert report.claims_archived == 1

    @pytest.mark.asyncio
    async def test_dream_entity_merge_suggestions_with_resolver(self, config):
        """dream() calls entity_resolver.suggest_merges() when provided."""
        merges = [MagicMock(), MagicMock()]  # 2 suggestions
        resolver = MockEntityResolver(suggestions=merges)
        cons = self._make_cons(config, entity_resolver=resolver)
        report = await cons.dream("default")
        assert "default" in resolver.suggest_merges_calls
        assert report.entity_merge_suggestions == 2

    @pytest.mark.asyncio
    async def test_dream_entity_merge_skipped_without_resolver(self, config):
        """dream() gracefully skips entity merge when entity_resolver is None."""
        cons = self._make_cons(config, entity_resolver=None)
        report = await cons.dream("default")
        assert report.entity_merge_suggestions == 0  # no crash, just skipped

    @pytest.mark.asyncio
    async def test_dream_feedback_applied_via_feedback_system(self, config):
        """dream() applies pending feedback via FeedbackSystem when provided."""
        fb_system = MockFeedbackSystem(applied=7)
        cons = self._make_cons(config, feedback_system=fb_system)
        report = await cons.dream("default")
        assert "default" in fb_system.process_pending_calls
        assert report.feedback_applied == 7

    @pytest.mark.asyncio
    async def test_dream_feedback_applied_via_storage_fallback(self, config):
        """dream() applies feedback directly via storage when feedback_system is None."""
        pending = [_make_feedback(), _make_feedback()]
        storage = MockStorage(pending_feedback=pending)
        cons = self._make_cons(config, storage=storage, feedback_system=None)
        report = await cons.dream("default")
        assert report.feedback_applied == 2
        assert len(storage.apply_feedback_calls) == 2

    @pytest.mark.asyncio
    async def test_dream_feedback_system_failure_is_nonfatal(self, config):
        """dream() continues even if FeedbackSystem.process_pending() raises."""
        fb_system = MockFeedbackSystem(fail=True)
        cons = self._make_cons(config, feedback_system=fb_system)
        report = await cons.dream("default")
        assert report.feedback_applied == 0  # failed but didn't crash

    @pytest.mark.asyncio
    async def test_dream_report_has_journal(self, config):
        """dream() report journal is non-empty and contains cycle info."""
        cons = self._make_cons(config)
        report = await cons.dream("default")
        assert report.journal
        # M6c generates a markdown dream journal — check for journal header or cycle info
        assert "Dream Journal" in report.journal or "Dream cycle" in report.journal or len(report.journal) > 10

    @pytest.mark.asyncio
    async def test_dream_report_duration_positive(self, config):
        """dream() report has positive duration_ms."""
        cons = self._make_cons(config)
        report = await cons.dream("default")
        assert report.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_dream_report_completed_at_set(self, config):
        """dream() report has a completed_at timestamp."""
        cons = self._make_cons(config)
        report = await cons.dream("default")
        dt = datetime.fromisoformat(report.completed_at)
        assert dt is not None

    @pytest.mark.asyncio
    async def test_dream_cornerstone_evolution_no_claims(self, config):
        """dream() cornerstone evolution returns 0 when no high-confidence claims."""
        # No claims → no proposals
        storage = MockStorage(all_claims=[], cornerstones=[])
        cons = self._make_cons(config, storage=storage)
        report = await cons.dream("default")
        assert report.cornerstone_proposals == 0

    @pytest.mark.asyncio
    async def test_dream_cornerstone_evolution_proposes_from_llm(self, config):
        """dream() queues cornerstone proposals returned by LLM."""
        # High-confidence semantic claims from last 24h
        hc_claim = _make_claim(
            memory_type="semantic", status="active", confidence=0.9, hours_old=1
        )
        storage = MockStorage(
            all_claims=[hc_claim],
            cornerstones=[],
        )
        proposal_json = json.dumps([
            {"label": "User prefers Python", "content": "User strongly prefers Python.", "reason": "Repeated preference"}
        ])
        llm = MockLLM(proposal_json=proposal_json)
        guardian = MockCornerstoneGuardian()
        cons = self._make_cons(config, storage=storage, llm=llm, guardian=guardian)
        report = await cons.dream("default")
        assert report.cornerstone_proposals >= 1
        assert len(guardian.propose_evolution_calls) >= 1
        labels = [c["label"] for c in guardian.propose_evolution_calls]
        assert "User prefers Python" in labels

    @pytest.mark.asyncio
    async def test_dream_reconsolidation_supersede_a(self, config):
        """dream() resolves contradiction by superseding claim_a."""
        claim_a = _make_claim(subject="pref", predicate="is", object_value="dark", confidence=0.6, status="active", memory_type="semantic")
        claim_b = _make_claim(subject="pref", predicate="is", object_value="light", confidence=0.9, status="active", memory_type="semantic")
        storage = MockStorage(all_claims=[claim_a, claim_b])

        contradiction_json = json.dumps({
            "contradictions": [{
                "claim_a_id": claim_a.id,
                "claim_b_id": claim_b.id,
                "reason": "contradictory preferences",
                "resolution": "supersede_a"
            }]
        })
        llm = MockLLM(contradiction_json=contradiction_json)
        cons = self._make_cons(config, storage=storage, llm=llm)
        report = await cons.dream("default")
        assert report.contradictions_resolved >= 1
        # claim_a should be deprecated
        deprecated_calls = [
            c for c in storage.update_claim_calls
            if c.get("claim_id") == claim_a.id and c.get("status") == "deprecated"
        ]
        assert len(deprecated_calls) >= 1

    @pytest.mark.asyncio
    async def test_dream_reconsolidation_supersede_b(self, config):
        """dream() resolves contradiction by superseding claim_b."""
        claim_a = _make_claim(subject="lang", predicate="is", object_value="Python", confidence=0.9, status="active", memory_type="semantic")
        claim_b = _make_claim(subject="lang", predicate="is", object_value="Ruby", confidence=0.5, status="active", memory_type="semantic")
        storage = MockStorage(all_claims=[claim_a, claim_b])

        contradiction_json = json.dumps({
            "contradictions": [{
                "claim_a_id": claim_a.id,
                "claim_b_id": claim_b.id,
                "reason": "contradictory language prefs",
                "resolution": "supersede_b"
            }]
        })
        llm = MockLLM(contradiction_json=contradiction_json)
        cons = self._make_cons(config, storage=storage, llm=llm)
        report = await cons.dream("default")
        deprecated_calls = [
            c for c in storage.update_claim_calls
            if c.get("claim_id") == claim_b.id and c.get("status") == "deprecated"
        ]
        assert len(deprecated_calls) >= 1

    @pytest.mark.asyncio
    async def test_dream_reconsolidation_flag_both_reduces_confidence(self, config):
        """dream() flag_both resolution reduces confidence on both claims."""
        claim_a = _make_claim(subject="pref", predicate="color", object_value="red", confidence=0.7, status="active", memory_type="semantic")
        claim_b = _make_claim(subject="pref", predicate="color", object_value="blue", confidence=0.7, status="active", memory_type="semantic")
        storage = MockStorage(all_claims=[claim_a, claim_b])

        contradiction_json = json.dumps({
            "contradictions": [{
                "claim_a_id": claim_a.id,
                "claim_b_id": claim_b.id,
                "reason": "unclear preference",
                "resolution": "flag_both"
            }]
        })
        llm = MockLLM(contradiction_json=contradiction_json)
        cons = self._make_cons(config, storage=storage, llm=llm)
        report = await cons.dream("default")
        # confidence should be reduced on both
        reduced_calls = [
            c for c in storage.update_claim_calls
            if c.get("claim_id") in (claim_a.id, claim_b.id)
            and "confidence" in c
        ]
        assert len(reduced_calls) >= 2
        for call in reduced_calls:
            assert call["confidence"] < 0.7

    @pytest.mark.asyncio
    async def test_dream_reconsolidation_no_contradiction(self, config):
        """dream() handles no contradictions gracefully."""
        claim = _make_claim(subject="pref", status="active", memory_type="semantic")
        storage = MockStorage(all_claims=[claim])
        llm = MockLLM(contradiction_json='{"contradictions": []}')
        cons = self._make_cons(config, storage=storage, llm=llm)
        report = await cons.dream("default")
        # No contradiction → no update_claim with status=deprecated
        deprecated_calls = [c for c in storage.update_claim_calls if c.get("status") == "deprecated"]
        assert len(deprecated_calls) == 0

    @pytest.mark.asyncio
    async def test_dream_deep_consolidation_count(self, config):
        """dream() counts recent session-type claims in consolidation."""
        recent = _make_claim(memory_type="session", status="active", hours_old=1)
        old = _make_claim(memory_type="session", status="active", hours_old=30)  # older than 24h
        storage = MockStorage(all_claims=[recent, old])
        cons = self._make_cons(config, storage=storage)
        report = await cons.dream("default")
        # Only recent (within 24h) should count
        assert report.claims_consolidated == 1

    @pytest.mark.asyncio
    async def test_dream_persist_report_to_active_session(self, config):
        """dream() saves dream_report JSON to the active session if available."""
        active = _make_session("active-sess")
        storage = MockStorage(active_session=active)
        cons = self._make_cons(config, storage=storage)
        await cons.dream("default")
        dream_updates = [
            c for c in storage.update_session_calls
            if c.get("session_id") == "active-sess" and "dream_report" in c
        ]
        assert len(dream_updates) >= 1


# ---------------------------------------------------------------------------
# Tests — SleepReport / DreamReport dataclasses
# ---------------------------------------------------------------------------

class TestDataclasses:

    def test_sleep_report_defaults(self):
        r = SleepReport(session_id="s1")
        assert r.session_id == "s1"
        assert r.duration_ms == 0
        assert r.claims_created == 0
        assert r.claims_promoted == 0
        assert r.claims_pruned == 0
        assert r.drift_checks == []
        assert r.summary == ""
        assert r.completed_at  # not empty

    def test_dream_report_defaults(self):
        r = DreamReport(owner_id="alice")
        assert r.owner_id == "alice"
        assert r.duration_ms == 0
        assert r.orphans_recovered == 0
        assert r.claims_consolidated == 0
        assert r.contradictions_resolved == 0
        assert r.claims_decayed == 0
        assert r.claims_archived == 0
        assert r.cornerstone_proposals == 0
        assert r.entity_merge_suggestions == 0
        assert r.brain_graphs_refreshed == 0
        assert r.feedback_applied == 0
        assert r.journal == ""
        assert r.completed_at  # not empty


# ---------------------------------------------------------------------------
# Tests — helper functions
# ---------------------------------------------------------------------------

class TestHelpers:

    def test_parse_dt_naive_becomes_utc(self):
        naive = "2025-01-15T10:00:00"
        dt = _parse_dt(naive)
        assert dt.tzinfo is not None

    def test_parse_dt_aware_preserved(self):
        aware = "2025-01-15T10:00:00+00:00"
        dt = _parse_dt(aware)
        assert dt.tzinfo is not None

    def test_parse_json_list_valid(self):
        result = _parse_json_list('[{"a": 1}, {"b": 2}]')
        assert result == [{"a": 1}, {"b": 2}]

    def test_parse_json_list_invalid_returns_empty(self):
        result = _parse_json_list("not json")
        assert result == []

    def test_parse_json_list_dict_with_proposals_key(self):
        result = _parse_json_list('{"proposals": [{"x": 1}]}')
        assert result == [{"x": 1}]

    def test_parse_json_dict_valid(self):
        result = _parse_json_dict('{"contradictions": []}')
        assert result == {"contradictions": []}

    def test_parse_json_dict_invalid_returns_empty(self):
        result = _parse_json_dict("broken{{")
        assert result == {}

    def test_parse_json_dict_non_dict_returns_empty(self):
        result = _parse_json_dict("[1, 2, 3]")
        assert result == {}


# ---------------------------------------------------------------------------
# Tests — integration: sleep() as dream() orphan recovery
# ---------------------------------------------------------------------------

class TestOrphanRecovery:

    @pytest.mark.asyncio
    async def test_orphan_recovery_calls_sleep_pipeline(self, config):
        """Orphan sessions go through full sleep() consolidation during dream()."""
        orphan = _make_session("orphan-1", "default")
        orphan_claims = [_make_claim(session_id="orphan-1")]
        storage = MockStorage(
            orphaned_sessions=[orphan],
            session_claims=orphan_claims,
        )
        dep = MockDeprecationPipeline(promoted=1)
        drift = MockDriftCalculator()
        cons = SleepConsolidator(
            storage=storage, config=config,
            llm=MockLLM(), cornerstone_guardian=MockCornerstoneGuardian(),
            drift_calculator=drift, deprecation_pipeline=dep,
        )
        report = await cons.dream("default")
        assert report.orphans_recovered == 1
        # review_session called for the orphan
        assert "orphan-1" in dep.review_session_calls


# ---------------------------------------------------------------------------
# Tests — module exports
# ---------------------------------------------------------------------------

class TestModuleExports:

    def test_sleep_consolidator_importable(self):
        from cortex.circadian.sleep import SleepConsolidator
        assert SleepConsolidator is not None

    def test_sleep_report_importable(self):
        from cortex.circadian.sleep import SleepReport
        assert SleepReport is not None

    def test_dream_report_importable(self):
        from cortex.circadian.sleep import DreamReport
        assert DreamReport is not None

    def test_package_exports(self):
        import cortex.circadian as pkg
        assert hasattr(pkg, "SleepConsolidator")
        assert hasattr(pkg, "SleepReport")
        assert hasattr(pkg, "DreamReport")
