"""
Tests for MODULE 6a: CircadianEngine — Scheduler + Trigger Logic

Tests verify:
  - Fatigue math (update_fatigue formula)
  - State transitions (AWAKE → DROWSY → SLEEPING, etc.)
  - wake() setup: session created, cornerstones loaded, orphans recovered
  - should_nap() threshold logic
  - nap() resets fatigue to 0.4, flushes claims
  - sleep() transitions to SLEEPING, fires hooks
  - dream() recovers orphans, returns DreamReport
  - Manual triggers: force_sleep, force_dream
  - Background scheduler management (idle watcher, dream scheduler)
  - Lifecycle hooks (on_wake, on_sleep)

All storage operations use MockStorageAdapter — no real SQLite required.
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cortex.circadian.engine import (
    CircadianEngine,
    CircadianState,
    WakeReport,
    NapReport,
    SleepReport,
    DreamReport,
    _FATIGUE_AFTER_NAP,
    _FATIGUE_COMPACTION_BUMP,
    _FATIGUE_ERROR_BUMP,
    _FATIGUE_TIME_INCREMENT,
    _FATIGUE_TIME_INTERVAL_SEC,
)
from cortex.types import SessionRecord, SemanticClaim


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

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


def _make_session(
    session_id: str = "sess-001",
    owner_id: str = "default",
    fatigue: float = 0.0,
    status: str = "awake",
    started_at: Optional[str] = None,
    compaction_count: int = 0,
) -> SessionRecord:
    return SessionRecord(
        id=f"rec-{session_id}",
        owner_id=owner_id,
        session_id=session_id,
        started_at=started_at or _now_iso(),
        status=status,
        fatigue_score=fatigue,
        compaction_count=compaction_count,
    )


# ---------------------------------------------------------------------------
# Mock storage adapter
# ---------------------------------------------------------------------------

class MockStorageAdapter:
    """In-memory storage adapter for CircadianEngine tests."""

    def __init__(self):
        self._sessions: Dict[str, SessionRecord] = {}
        self._claims: Dict[str, List[SemanticClaim]] = {}  # session_id → claims
        self._orphans: List[SessionRecord] = []

    # --- session ops ---

    async def create_session(
        self, session_id: str, owner_id: str = "default"
    ) -> SessionRecord:
        rec = _make_session(session_id=session_id, owner_id=owner_id)
        self._sessions[session_id] = rec
        return rec

    async def update_session(self, session_id: str, **kwargs) -> SessionRecord:
        rec = self._sessions.get(session_id)
        if rec is None:
            raise KeyError(f"Session not found: {session_id}")
        for k, v in kwargs.items():
            if hasattr(rec, k):
                setattr(rec, k, v)
        return rec

    async def get_active_session(self, owner_id: str = "default") -> Optional[SessionRecord]:
        for rec in reversed(list(self._sessions.values())):
            if rec.owner_id == owner_id and rec.status == "awake":
                return rec
        return None

    async def get_active_session_by_id(self, session_id: str) -> Optional[SessionRecord]:
        return self._sessions.get(session_id)

    async def get_orphaned_sessions(self, idle_seconds: int) -> List[SessionRecord]:
        return list(self._orphans)

    async def get_interrupted_sessions(
        self, owner_id: Optional[str] = None
    ) -> List[SessionRecord]:
        results = [
            s for s in self._sessions.values()
            if s.status in ("sleeping", "dreaming") and s.ended_at is None
        ]
        if owner_id is not None:
            results = [s for s in results if s.owner_id == owner_id]
        return results

    async def get_session_claims(self, session_id: str) -> List[SemanticClaim]:
        return self._claims.get(session_id, [])

    # --- helpers for test setup ---

    def add_claim(self, session_id: str, n: int = 1) -> None:
        claims = self._claims.setdefault(session_id, [])
        for i in range(n):
            claims.append(SemanticClaim(
                id=f"claim-{session_id}-{i}",
                owner_id="default",
                subject="test",
                predicate="has",
                object_value="value",
                source_session=session_id,
            ))

    def add_orphan(self, session: SessionRecord) -> None:
        self._orphans.append(session)


# ---------------------------------------------------------------------------
# Mock cornerstone guardian
# ---------------------------------------------------------------------------

class MockCornerstoneGuardian:
    def __init__(self, cornerstones=None, token_counts=None):
        self._cornerstones = cornerstones or []
        self._token_counts = token_counts or {}

    async def load_all(self, owner_id: str = "default"):
        for cs in self._cornerstones:
            if not hasattr(cs, "token_count"):
                cs.token_count = self._token_counts.get(getattr(cs, "id", ""), 50)
        return self._cornerstones


# ---------------------------------------------------------------------------
# Mock LLM provider
# ---------------------------------------------------------------------------

class MockLLMProvider:
    def __init__(self, response_text: str = "Nap summary."):
        self._response_text = response_text

    async def complete(self, system: str, user: str, **kwargs):
        resp = MagicMock()
        resp.text = self._response_text
        return resp


# ---------------------------------------------------------------------------
# Mock cornerstone memory object
# ---------------------------------------------------------------------------

@dataclass
class MockCornerstone:
    id: str = "cs-001"
    label: str = "I am Eva"
    content: str = "An AI assistant."
    token_count: int = 50
    owner_id: str = "default"


# ---------------------------------------------------------------------------
# Engine factory
# ---------------------------------------------------------------------------

def make_engine(
    storage=None,
    cornerstones=None,
    llm_response="Nap summary.",
    nap_threshold=0.7,
    sleep_threshold=0.9,
    idle_timeout_sec=1800,
    dream_hour_utc=3,
    dream_enabled=True,
    owner_id="default",
) -> CircadianEngine:
    storage = storage or MockStorageAdapter()
    cs_list = [MockCornerstone()] if cornerstones is None else cornerstones
    guardian = MockCornerstoneGuardian(cornerstones=cs_list)
    llm = MockLLMProvider(response_text=llm_response)

    config = MagicMock()
    config.session_fatigue_nap_threshold = nap_threshold
    config.session_fatigue_sleep_threshold = sleep_threshold
    config.dream_cycle_idle_timeout_sec = idle_timeout_sec
    config.dream_cycle_hour_utc = dream_hour_utc
    config.dream_cycle_enabled = dream_enabled
    config.owner_id = owner_id

    return CircadianEngine(
        storage=storage,
        config=config,
        llm=llm,
        cornerstone_guardian=guardian,
    )


# ===========================================================================
# Tests: wake()
# ===========================================================================

class TestWake:
    @pytest.mark.asyncio
    async def test_wake_creates_session(self):
        storage = MockStorageAdapter()
        engine = make_engine(storage=storage)
        report = await engine.wake("sess-001")
        assert isinstance(report, WakeReport)
        assert report.session_id == "sess-001"
        assert "sess-001" in storage._sessions

    @pytest.mark.asyncio
    async def test_wake_loads_cornerstones(self):
        cs = [MockCornerstone(id="cs-1", token_count=42), MockCornerstone(id="cs-2", token_count=58)]
        engine = make_engine(cornerstones=cs)
        report = await engine.wake("sess-002")
        assert report.cornerstones_loaded == 2
        assert report.cornerstone_tokens == 100

    @pytest.mark.asyncio
    async def test_wake_no_cornerstones(self):
        engine = make_engine(cornerstones=[])
        report = await engine.wake("sess-003")
        assert report.cornerstones_loaded == 0
        assert report.cornerstone_tokens == 0

    @pytest.mark.asyncio
    async def test_wake_recovers_orphans(self):
        storage = MockStorageAdapter()
        # Pre-load an orphaned session
        orphan = _make_session(session_id="orphan-1", status="awake")
        storage._sessions["orphan-1"] = orphan
        storage.add_orphan(orphan)
        engine = make_engine(storage=storage)
        report = await engine.wake("sess-new")
        assert report.orphans_recovered == 1
        # Orphaned session should now be sleeping
        assert storage._sessions["orphan-1"].status == "sleeping"

    @pytest.mark.asyncio
    async def test_wake_sets_awake_state(self):
        engine = make_engine()
        await engine.wake("sess-state")
        assert engine.get_state("sess-state") == CircadianState.AWAKE

    @pytest.mark.asyncio
    async def test_wake_fires_hooks(self):
        engine = make_engine()
        hook_calls = []

        async def my_hook(session_id):
            hook_calls.append(session_id)

        engine.on_wake(my_hook)
        await engine.wake("sess-hook")
        assert "sess-hook" in hook_calls

    @pytest.mark.asyncio
    async def test_wake_report_has_context_or_none(self):
        engine = make_engine()
        report = await engine.wake("sess-ctx")
        # context_retrieved is either a string or None — both acceptable
        assert report.context_retrieved is None or isinstance(report.context_retrieved, str)


# ===========================================================================
# Tests: update_fatigue()
# ===========================================================================

class TestUpdateFatigue:
    @pytest.mark.asyncio
    async def test_fatigue_context_ratio_only(self):
        storage = MockStorageAdapter()
        await storage.create_session("sess-f1")
        engine = make_engine(storage=storage)

        # No events, context_fill_ratio = 0.5, session just started (0 elapsed intervals)
        fatigue = await engine.update_fatigue("sess-f1", context_fill_ratio=0.5)
        # Expected: 0.5 + 0 time increments (just started)
        assert 0.49 <= fatigue <= 0.51

    @pytest.mark.asyncio
    async def test_fatigue_compaction_bump(self):
        storage = MockStorageAdapter()
        await storage.create_session("sess-f2")
        engine = make_engine(storage=storage)

        fatigue = await engine.update_fatigue(
            "sess-f2",
            context_fill_ratio=0.3,
            compaction_event=True,
        )
        expected = 0.3 + _FATIGUE_COMPACTION_BUMP
        assert abs(fatigue - expected) < 0.01

    @pytest.mark.asyncio
    async def test_fatigue_error_bump(self):
        storage = MockStorageAdapter()
        await storage.create_session("sess-f3")
        engine = make_engine(storage=storage)

        fatigue = await engine.update_fatigue(
            "sess-f3",
            context_fill_ratio=0.2,
            error_event=True,
        )
        expected = 0.2 + _FATIGUE_ERROR_BUMP
        assert abs(fatigue - expected) < 0.01

    @pytest.mark.asyncio
    async def test_fatigue_combined_bumps(self):
        storage = MockStorageAdapter()
        await storage.create_session("sess-f4")
        engine = make_engine(storage=storage)

        fatigue = await engine.update_fatigue(
            "sess-f4",
            context_fill_ratio=0.3,
            compaction_event=True,
            error_event=True,
        )
        expected = 0.3 + _FATIGUE_COMPACTION_BUMP + _FATIGUE_ERROR_BUMP
        assert abs(fatigue - expected) < 0.01

    @pytest.mark.asyncio
    async def test_fatigue_clamped_to_1(self):
        storage = MockStorageAdapter()
        await storage.create_session("sess-f5")
        engine = make_engine(storage=storage)

        # Multiple bumps should clamp at 1.0
        fatigue = await engine.update_fatigue(
            "sess-f5",
            context_fill_ratio=0.9,
            compaction_event=True,
            error_event=True,
        )
        assert fatigue <= 1.0

    @pytest.mark.asyncio
    async def test_fatigue_time_increment(self):
        storage = MockStorageAdapter()
        # Create session with started_at 15 minutes ago (= 3 intervals)
        started_at = (datetime.now(timezone.utc) - timedelta(minutes=15)).isoformat()
        session = _make_session(session_id="sess-f6", started_at=started_at)
        storage._sessions["sess-f6"] = session
        engine = make_engine(storage=storage)

        fatigue = await engine.update_fatigue("sess-f6", context_fill_ratio=0.0)
        # 3 intervals × 0.01 = 0.03
        assert abs(fatigue - 0.03) < 0.015  # allow some timing tolerance

    @pytest.mark.asyncio
    async def test_fatigue_persisted_to_storage(self):
        storage = MockStorageAdapter()
        await storage.create_session("sess-f7")
        engine = make_engine(storage=storage)
        fatigue = await engine.update_fatigue("sess-f7", context_fill_ratio=0.6)
        stored = await storage.get_active_session_by_id("sess-f7")
        assert abs(stored.fatigue_score - fatigue) < 0.01

    @pytest.mark.asyncio
    async def test_fatigue_compaction_count_incremented(self):
        storage = MockStorageAdapter()
        await storage.create_session("sess-f8")
        engine = make_engine(storage=storage)
        await engine.update_fatigue("sess-f8", context_fill_ratio=0.3, compaction_event=True)
        stored = await storage.get_active_session_by_id("sess-f8")
        assert stored.compaction_count == 1

    @pytest.mark.asyncio
    async def test_fatigue_returns_zero_unknown_session(self):
        engine = make_engine()
        fatigue = await engine.update_fatigue("no-such-session", context_fill_ratio=0.5)
        assert fatigue == 0.0

    @pytest.mark.asyncio
    async def test_fatigue_triggers_drowsy_state(self):
        storage = MockStorageAdapter()
        await storage.create_session("sess-drowsy")
        engine = make_engine(storage=storage, nap_threshold=0.7)
        # Manually set AWAKE state
        engine._states["sess-drowsy"] = CircadianState.AWAKE
        # Push fatigue above nap threshold
        await engine.update_fatigue("sess-drowsy", context_fill_ratio=0.8, compaction_event=True)
        assert engine.get_state("sess-drowsy") == CircadianState.DROWSY


# ===========================================================================
# Tests: should_nap()
# ===========================================================================

class TestShouldNap:
    @pytest.mark.asyncio
    async def test_should_nap_true_above_threshold(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-nap1", fatigue=0.75)
        storage._sessions["sess-nap1"] = session
        engine = make_engine(storage=storage, nap_threshold=0.7)
        assert await engine.should_nap("sess-nap1") is True

    @pytest.mark.asyncio
    async def test_should_nap_false_below_threshold(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-nap2", fatigue=0.5)
        storage._sessions["sess-nap2"] = session
        engine = make_engine(storage=storage, nap_threshold=0.7)
        assert await engine.should_nap("sess-nap2") is False

    @pytest.mark.asyncio
    async def test_should_nap_false_at_threshold(self):
        """should_nap uses strict > so exactly at threshold = False."""
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-nap3", fatigue=0.7)
        storage._sessions["sess-nap3"] = session
        engine = make_engine(storage=storage, nap_threshold=0.7)
        assert await engine.should_nap("sess-nap3") is False

    @pytest.mark.asyncio
    async def test_should_nap_custom_threshold(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-nap4", fatigue=0.55)
        storage._sessions["sess-nap4"] = session
        engine = make_engine(storage=storage, nap_threshold=0.5)
        assert await engine.should_nap("sess-nap4") is True

    @pytest.mark.asyncio
    async def test_should_nap_unknown_session(self):
        engine = make_engine()
        # Unknown session has fatigue=0.0 → always False
        assert await engine.should_nap("ghost-session") is False


# ===========================================================================
# Tests: nap()
# ===========================================================================

class TestNap:
    @pytest.mark.asyncio
    async def test_nap_resets_fatigue(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-n1", fatigue=0.85)
        storage._sessions["sess-n1"] = session
        engine = make_engine(storage=storage)
        report = await engine.nap("sess-n1")
        assert report.fatigue_after == _FATIGUE_AFTER_NAP
        stored = await storage.get_active_session_by_id("sess-n1")
        assert stored.fatigue_score == _FATIGUE_AFTER_NAP

    @pytest.mark.asyncio
    async def test_nap_returns_correct_before_fatigue(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-n2", fatigue=0.78)
        storage._sessions["sess-n2"] = session
        engine = make_engine(storage=storage)
        report = await engine.nap("sess-n2")
        assert report.fatigue_before == 0.78

    @pytest.mark.asyncio
    async def test_nap_flushes_claims(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-n3", fatigue=0.8)
        storage._sessions["sess-n3"] = session
        storage.add_claim("sess-n3", n=5)
        engine = make_engine(storage=storage)
        report = await engine.nap("sess-n3")
        assert report.claims_flushed == 5

    @pytest.mark.asyncio
    async def test_nap_generates_summary(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-n4", fatigue=0.75)
        storage._sessions["sess-n4"] = session
        engine = make_engine(storage=storage, llm_response="Session checkpoint done.")
        report = await engine.nap("sess-n4")
        assert report.summary == "Session checkpoint done."

    @pytest.mark.asyncio
    async def test_nap_sets_state_awake(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-n5", fatigue=0.8)
        storage._sessions["sess-n5"] = session
        engine = make_engine(storage=storage)
        engine._states["sess-n5"] = CircadianState.DROWSY
        await engine.nap("sess-n5")
        assert engine.get_state("sess-n5") == CircadianState.AWAKE

    @pytest.mark.asyncio
    async def test_nap_raises_on_unknown_session(self):
        engine = make_engine()
        with pytest.raises(ValueError, match="not found"):
            await engine.nap("no-such-session")

    @pytest.mark.asyncio
    async def test_nap_updates_storage_status(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-n6", fatigue=0.9)
        storage._sessions["sess-n6"] = session
        engine = make_engine(storage=storage)
        await engine.nap("sess-n6")
        stored = await storage.get_active_session_by_id("sess-n6")
        assert stored.status == CircadianState.AWAKE.value


# ===========================================================================
# Tests: get_fatigue()
# ===========================================================================

class TestGetFatigue:
    @pytest.mark.asyncio
    async def test_get_fatigue_returns_stored_value(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-gf1", fatigue=0.42)
        storage._sessions["sess-gf1"] = session
        engine = make_engine(storage=storage)
        fatigue = await engine.get_fatigue("sess-gf1")
        assert fatigue == 0.42

    @pytest.mark.asyncio
    async def test_get_fatigue_unknown_session_returns_zero(self):
        engine = make_engine()
        assert await engine.get_fatigue("ghost") == 0.0


# ===========================================================================
# Tests: sleep()
# ===========================================================================

class TestSleep:
    @pytest.mark.asyncio
    async def test_sleep_transitions_to_sleeping(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-s1")
        storage._sessions["sess-s1"] = session
        engine = make_engine(storage=storage)
        engine._states["sess-s1"] = CircadianState.AWAKE
        await engine.sleep("sess-s1")
        assert engine.get_state("sess-s1") == CircadianState.SLEEPING

    @pytest.mark.asyncio
    async def test_sleep_updates_storage_status(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-s2")
        storage._sessions["sess-s2"] = session
        engine = make_engine(storage=storage)
        await engine.sleep("sess-s2")
        stored = await storage.get_active_session_by_id("sess-s2")
        assert stored.status == CircadianState.SLEEPING.value

    @pytest.mark.asyncio
    async def test_sleep_fires_hooks(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-s3")
        storage._sessions["sess-s3"] = session
        engine = make_engine(storage=storage)
        hook_calls = []

        def my_hook(session_id):
            hook_calls.append(session_id)

        engine.on_sleep(my_hook)
        await engine.sleep("sess-s3")
        assert "sess-s3" in hook_calls

    @pytest.mark.asyncio
    async def test_sleep_returns_report(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-s4")
        storage._sessions["sess-s4"] = session
        engine = make_engine(storage=storage)
        report = await engine.sleep("sess-s4")
        assert isinstance(report, SleepReport)
        assert report.session_id == "sess-s4"

    @pytest.mark.asyncio
    async def test_sleep_unknown_session_returns_stub(self):
        engine = make_engine()
        report = await engine.sleep("no-such")
        assert isinstance(report, SleepReport)
        assert report.session_id == "no-such"

    @pytest.mark.asyncio
    async def test_force_sleep_manual_trigger(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-s5")
        storage._sessions["sess-s5"] = session
        engine = make_engine(storage=storage)
        report = await engine.force_sleep("sess-s5")
        assert engine.get_state("sess-s5") == CircadianState.SLEEPING


# ===========================================================================
# Tests: dream()
# ===========================================================================

class TestDream:
    @pytest.mark.asyncio
    async def test_dream_returns_report(self):
        engine = make_engine()
        report = await engine.dream()
        assert isinstance(report, DreamReport)

    @pytest.mark.asyncio
    async def test_dream_recovers_orphans(self):
        storage = MockStorageAdapter()
        orphan = _make_session(session_id="orphan-dream")
        storage._sessions["orphan-dream"] = orphan
        storage.add_orphan(orphan)
        engine = make_engine(storage=storage)
        report = await engine.dream()
        assert report.orphans_recovered == 1
        assert storage._sessions["orphan-dream"].status == "sleeping"

    @pytest.mark.asyncio
    async def test_dream_journal_not_empty(self):
        engine = make_engine()
        report = await engine.dream()
        assert report.journal != ""

    @pytest.mark.asyncio
    async def test_force_dream_manual_trigger(self):
        engine = make_engine()
        report = await engine.force_dream()
        assert isinstance(report, DreamReport)


# ===========================================================================
# Tests: state machine transitions
# ===========================================================================

class TestStateMachine:
    @pytest.mark.asyncio
    async def test_awake_to_drowsy_on_high_fatigue(self):
        storage = MockStorageAdapter()
        await storage.create_session("sess-sm1")
        engine = make_engine(storage=storage, nap_threshold=0.7)
        engine._states["sess-sm1"] = CircadianState.AWAKE
        # Push above threshold
        await engine.update_fatigue("sess-sm1", context_fill_ratio=0.85, compaction_event=True)
        assert engine.get_state("sess-sm1") == CircadianState.DROWSY

    @pytest.mark.asyncio
    async def test_drowsy_to_awake_after_nap(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-sm2", fatigue=0.8)
        storage._sessions["sess-sm2"] = session
        engine = make_engine(storage=storage)
        engine._states["sess-sm2"] = CircadianState.DROWSY
        await engine.nap("sess-sm2")
        assert engine.get_state("sess-sm2") == CircadianState.AWAKE

    @pytest.mark.asyncio
    async def test_awake_to_sleeping_via_sleep(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sess-sm3")
        storage._sessions["sess-sm3"] = session
        engine = make_engine(storage=storage)
        engine._states["sess-sm3"] = CircadianState.AWAKE
        await engine.sleep("sess-sm3")
        assert engine.get_state("sess-sm3") == CircadianState.SLEEPING

    @pytest.mark.asyncio
    async def test_get_state_defaults_to_awake(self):
        engine = make_engine()
        # Unknown session defaults to AWAKE
        state = engine.get_state("unknown-sess")
        assert state == CircadianState.AWAKE


# ===========================================================================
# Tests: lifecycle hooks
# ===========================================================================

class TestLifecycleHooks:
    @pytest.mark.asyncio
    async def test_sync_wake_hook_called(self):
        engine = make_engine()
        calls = []
        engine.on_wake(lambda sid: calls.append(("wake", sid)))
        await engine.wake("hook-sess")
        assert ("wake", "hook-sess") in calls

    @pytest.mark.asyncio
    async def test_async_wake_hook_called(self):
        engine = make_engine()
        calls = []

        async def async_hook(sid):
            calls.append(("wake", sid))

        engine.on_wake(async_hook)
        await engine.wake("async-hook-sess")
        assert ("wake", "async-hook-sess") in calls

    @pytest.mark.asyncio
    async def test_sync_sleep_hook_called(self):
        storage = MockStorageAdapter()
        session = _make_session(session_id="sleep-hook-sess")
        storage._sessions["sleep-hook-sess"] = session
        engine = make_engine(storage=storage)
        calls = []
        engine.on_sleep(lambda sid: calls.append(("sleep", sid)))
        await engine.sleep("sleep-hook-sess")
        assert ("sleep", "sleep-hook-sess") in calls

    @pytest.mark.asyncio
    async def test_hook_exception_does_not_propagate(self):
        engine = make_engine()

        def bad_hook(sid):
            raise RuntimeError("Hook error!")

        engine.on_wake(bad_hook)
        # Should not raise
        report = await engine.wake("hook-err-sess")
        assert isinstance(report, WakeReport)


# ===========================================================================
# Tests: scheduler helpers
# ===========================================================================

class TestSchedulerHelpers:
    def test_seconds_until_dream_hour_positive(self):
        engine = make_engine(dream_hour_utc=3)
        seconds = engine._seconds_until_dream_hour()
        assert seconds > 0
        assert seconds <= 86400  # ≤ 24h

    def test_seconds_until_dream_hour_at_target_schedules_tomorrow(self):
        """When current time is past dream hour, next occurrence is tomorrow."""
        engine = make_engine(dream_hour_utc=0)
        # Hour 0 = midnight; unless we're exactly at midnight, this is past today
        seconds = engine._seconds_until_dream_hour()
        assert 0 < seconds <= 86400

    @pytest.mark.asyncio
    async def test_ensure_idle_watcher_starts_task(self):
        engine = make_engine()
        # No task initially
        assert engine._idle_watcher_task is None
        engine._ensure_idle_watcher()
        assert engine._idle_watcher_task is not None
        # Cleanup
        await engine.shutdown()

    @pytest.mark.asyncio
    async def test_ensure_dream_scheduler_starts_task(self):
        engine = make_engine(dream_enabled=True)
        assert engine._dream_scheduler_task is None
        engine._ensure_dream_scheduler()
        assert engine._dream_scheduler_task is not None
        # Cleanup
        await engine.shutdown()

    @pytest.mark.asyncio
    async def test_shutdown_cancels_tasks(self):
        engine = make_engine()
        engine._ensure_idle_watcher()
        engine._ensure_dream_scheduler()
        await engine.shutdown()
        assert engine._idle_watcher_task.done()
        assert engine._dream_scheduler_task.done()


# ===========================================================================
# Tests: touch_activity()
# ===========================================================================

class TestTouchActivity:
    @pytest.mark.asyncio
    async def test_touch_activity_updates_timestamp(self):
        engine = make_engine()
        engine._last_activity["sess-ta"] = 0.0  # far in the past
        await engine.touch_activity("sess-ta")
        now = datetime.now(timezone.utc).timestamp()
        assert abs(engine._last_activity["sess-ta"] - now) < 2


# ===========================================================================
# Tests: interrupted consolidation recovery (#88)
# ===========================================================================

class TestInterruptedSessionRecovery:
    """
    Verify that sessions stuck in 'sleeping' or 'dreaming' with no ended_at
    are recovered on the next wake() call (and dream() call).

    Covers GitHub Issue #88: process crash mid-sleep (step 3/5) or mid-dream
    (step 5/9) leaves sessions in a terminal-but-incomplete state.
    """

    def _make_stuck_session(
        self,
        session_id: str,
        status: str,
        owner_id: str = "default",
    ) -> SessionRecord:
        """Return a SessionRecord stuck in the given status with no ended_at."""
        return SessionRecord(
            id=f"rec-{session_id}",
            owner_id=owner_id,
            session_id=session_id,
            started_at=_now_iso(),
            status=status,
            ended_at=None,
        )

    # ------------------------------------------------------------------
    # wake() recovery
    # ------------------------------------------------------------------

    @pytest.mark.asyncio
    async def test_wake_recovers_stuck_sleeping_session(self):
        """Sessions with status='sleeping' and no ended_at are closed on wake()."""
        storage = MockStorageAdapter()
        stuck = self._make_stuck_session("stuck-sleep-1", status="sleeping")
        storage._sessions["stuck-sleep-1"] = stuck

        engine = make_engine(storage=storage)
        report = await engine.wake("new-sess-1")

        # Recovery count is reported
        assert report.orphans_recovered >= 1

        # Session is now closed
        recovered = await storage.get_active_session_by_id("stuck-sleep-1")
        assert recovered.status == "completed"
        assert recovered.ended_at is not None

    @pytest.mark.asyncio
    async def test_wake_recovers_stuck_dreaming_session(self):
        """Sessions with status='dreaming' and no ended_at are closed on wake()."""
        storage = MockStorageAdapter()
        stuck = self._make_stuck_session("stuck-dream-1", status="dreaming")
        storage._sessions["stuck-dream-1"] = stuck

        engine = make_engine(storage=storage)
        report = await engine.wake("new-sess-2")

        assert report.orphans_recovered >= 1

        recovered = await storage.get_active_session_by_id("stuck-dream-1")
        assert recovered.status == "completed"
        assert recovered.ended_at is not None

    @pytest.mark.asyncio
    async def test_wake_recovers_both_sleeping_and_dreaming(self):
        """Both stuck-sleeping and stuck-dreaming sessions are recovered in one wake()."""
        storage = MockStorageAdapter()
        stuck_sleep = self._make_stuck_session("stuck-s", status="sleeping")
        stuck_dream = self._make_stuck_session("stuck-d", status="dreaming")
        storage._sessions["stuck-s"] = stuck_sleep
        storage._sessions["stuck-d"] = stuck_dream

        engine = make_engine(storage=storage)
        report = await engine.wake("new-sess-3")

        assert report.orphans_recovered >= 2
        assert (await storage.get_active_session_by_id("stuck-s")).status == "completed"
        assert (await storage.get_active_session_by_id("stuck-d")).status == "completed"

    @pytest.mark.asyncio
    async def test_wake_does_not_recover_already_ended_session(self):
        """Sessions with ended_at already set are not touched."""
        storage = MockStorageAdapter()
        # This session has ended_at set — it's properly closed
        completed = SessionRecord(
            id="rec-done",
            owner_id="default",
            session_id="done-sess",
            started_at=_now_iso(),
            status="sleeping",
            ended_at=_now_iso(),  # already closed
        )
        storage._sessions["done-sess"] = completed

        engine = make_engine(storage=storage)
        report = await engine.wake("new-sess-4")

        # No interrupted recovery for this session
        assert report.orphans_recovered == 0
        # Status unchanged
        untouched = await storage.get_active_session_by_id("done-sess")
        assert untouched.status == "sleeping"

    @pytest.mark.asyncio
    async def test_wake_sets_ended_at_on_recovery(self):
        """Recovered sessions have ended_at set to a valid ISO timestamp."""
        storage = MockStorageAdapter()
        stuck = self._make_stuck_session("stuck-ts", status="sleeping")
        storage._sessions["stuck-ts"] = stuck

        engine = make_engine(storage=storage)
        before = datetime.now(timezone.utc)
        await engine.wake("new-sess-ts")
        after = datetime.now(timezone.utc)

        recovered = await storage.get_active_session_by_id("stuck-ts")
        assert recovered.ended_at is not None
        # ended_at should be parseable and within test window
        ended_dt = datetime.fromisoformat(recovered.ended_at)
        if ended_dt.tzinfo is None:
            ended_dt = ended_dt.replace(tzinfo=timezone.utc)
        assert before <= ended_dt <= after

    # ------------------------------------------------------------------
    # dream() recovery
    # ------------------------------------------------------------------

    @pytest.mark.asyncio
    async def test_dream_recovers_stuck_sleeping_session(self):
        """dream() also recovers sessions stuck in 'sleeping' with no ended_at."""
        storage = MockStorageAdapter()
        stuck = self._make_stuck_session("dream-stuck-s", status="sleeping")
        storage._sessions["dream-stuck-s"] = stuck

        engine = make_engine(storage=storage)
        report = await engine.dream()

        assert report.orphans_recovered >= 1
        recovered = await storage.get_active_session_by_id("dream-stuck-s")
        assert recovered.status == "completed"
        assert recovered.ended_at is not None

    @pytest.mark.asyncio
    async def test_dream_recovers_stuck_dreaming_session(self):
        """dream() recovers sessions stuck in 'dreaming' with no ended_at."""
        storage = MockStorageAdapter()
        stuck = self._make_stuck_session("dream-stuck-d", status="dreaming")
        storage._sessions["dream-stuck-d"] = stuck

        engine = make_engine(storage=storage)
        report = await engine.dream()

        assert report.orphans_recovered >= 1
        recovered = await storage.get_active_session_by_id("dream-stuck-d")
        assert recovered.status == "completed"
        assert recovered.ended_at is not None

    # ------------------------------------------------------------------
    # _recover_interrupted_sessions() unit tests
    # ------------------------------------------------------------------

    @pytest.mark.asyncio
    async def test_recover_interrupted_returns_count(self):
        """_recover_interrupted_sessions() returns the number of sessions closed."""
        storage = MockStorageAdapter()
        storage._sessions["int-1"] = self._make_stuck_session("int-1", status="sleeping")
        storage._sessions["int-2"] = self._make_stuck_session("int-2", status="dreaming")

        engine = make_engine(storage=storage)
        count = await engine._recover_interrupted_sessions()
        assert count == 2

    @pytest.mark.asyncio
    async def test_recover_interrupted_empty_when_none_stuck(self):
        """Returns 0 when no sessions are stuck mid-consolidation."""
        storage = MockStorageAdapter()
        # All sessions are properly ended or awake
        storage._sessions["ok-1"] = _make_session("ok-1", status="awake")
        storage._sessions["ok-2"] = _make_session("ok-2", status="sleeping")
        # Give ok-2 an ended_at so it's not stuck
        storage._sessions["ok-2"].ended_at = _now_iso()

        engine = make_engine(storage=storage)
        count = await engine._recover_interrupted_sessions()
        assert count == 0

    @pytest.mark.asyncio
    async def test_recover_interrupted_owner_id_filter(self):
        """owner_id filter isolates recovery to the specified owner."""
        storage = MockStorageAdapter()
        storage._sessions["mine"] = self._make_stuck_session(
            "mine", status="sleeping", owner_id="alice"
        )
        storage._sessions["theirs"] = self._make_stuck_session(
            "theirs", status="sleeping", owner_id="bob"
        )

        engine = make_engine(storage=storage)
        count = await engine._recover_interrupted_sessions(owner_id="alice")
        assert count == 1

        # Only alice's session updated
        assert (await storage.get_active_session_by_id("mine")).status == "completed"
        assert (await storage.get_active_session_by_id("theirs")).status == "sleeping"

    @pytest.mark.asyncio
    async def test_recover_interrupted_graceful_on_adapter_missing_method(self):
        """If the adapter doesn't implement get_interrupted_sessions, returns 0 (no crash)."""
        storage = MockStorageAdapter()
        # Simulate an older adapter that raises AttributeError/NotImplementedError
        storage.get_interrupted_sessions = MagicMock(side_effect=AttributeError("not implemented"))

        engine = make_engine(storage=storage)
        # Should not raise
        count = await engine._recover_interrupted_sessions()
        assert count == 0


# ===========================================================================
# Tests: report dataclasses
# ===========================================================================

class TestReportDataclasses:
    def test_wake_report_fields(self):
        r = WakeReport(
            session_id="s",
            cornerstones_loaded=3,
            cornerstone_tokens=150,
            context_retrieved="Some context",
            orphans_recovered=0,
        )
        assert r.session_id == "s"
        assert r.cornerstones_loaded == 3

    def test_nap_report_defaults(self):
        r = NapReport(session_id="s", fatigue_before=0.8)
        assert r.fatigue_after == 0.4
        assert r.claims_flushed == 0
        assert r.summary == ""

    def test_sleep_report_defaults(self):
        r = SleepReport(session_id="s")
        assert r.duration_ms == 0
        assert r.drift_checks == []

    def test_dream_report_defaults(self):
        r = DreamReport(owner_id="default")
        assert r.orphans_recovered == 0
        assert r.journal == ""


# ===========================================================================
# Tests: dream timeout config (#429)
# ===========================================================================

class TestDreamTimeoutConfig:
    """Verify that dream_cycle_timeout_sec from CortexConfig is respected.

    Regression test for #429: defaults.toml used 'dream_timeout_sec' while
    engine.py read 'dream_cycle_timeout_sec', causing the config value to be
    silently ignored and the hardcoded 600s fallback always used.
    """

    @pytest.mark.asyncio
    async def test_dream_scheduler_uses_config_timeout(self):
        """Engine reads dream_cycle_timeout_sec from config, not hardcoded 600."""
        engine = make_engine()
        custom_timeout = 42  # deliberately not 600
        engine.config.dream_cycle_timeout_sec = custom_timeout

        captured_timeout: list = []

        async def capturing_wait_for(coro, timeout):
            captured_timeout.append(timeout)
            raise asyncio.CancelledError

        with patch("asyncio.wait_for", side_effect=capturing_wait_for), \
             patch.object(engine, "_seconds_until_dream_hour", return_value=0):
            try:
                await engine._dream_scheduler_loop()
            except asyncio.CancelledError:
                pass

        assert captured_timeout, "asyncio.wait_for was never called"
        assert captured_timeout[0] == custom_timeout, (
            f"Expected timeout={custom_timeout}, got {captured_timeout[0]}. "
            "Engine may be using hardcoded 600s instead of config value."
        )

    @pytest.mark.asyncio
    async def test_dream_scheduler_defaults_to_600_when_config_none(self):
        """When config is None, engine falls back to hardcoded 600s."""
        engine = make_engine()
        engine.config = None  # simulate missing config

        captured_timeout: list = []

        async def capturing_wait_for(coro, timeout):
            captured_timeout.append(timeout)
            raise asyncio.CancelledError

        with patch("asyncio.wait_for", side_effect=capturing_wait_for), \
             patch.object(engine, "_seconds_until_dream_hour", return_value=0):
            try:
                await engine._dream_scheduler_loop()
            except asyncio.CancelledError:
                pass

        assert captured_timeout, "asyncio.wait_for was never called"
        assert captured_timeout[0] == 600, (
            f"Expected fallback 600s, got {captured_timeout[0]}"
        )

    def test_config_py_loader_maps_dream_cycle_timeout_sec(self):
        """CortexConfig loader reads 'dream_cycle_timeout_sec' from TOML dict."""
        from cortex.config import CortexConfig

        overlay = {"cortex": {"circadian": {"dream_cycle_timeout_sec": 1200}}}
        cfg = CortexConfig.from_toml(overlay=overlay)
        assert cfg.dream_cycle_timeout_sec == 1200, (
            f"Expected 1200, got {cfg.dream_cycle_timeout_sec}. "
            "Loader may still be reading old key 'dream_timeout_sec'."
        )

    def test_config_py_old_key_no_longer_loaded(self):
        """Old key 'dream_timeout_sec' in TOML is no longer mapped (use dream_cycle_timeout_sec)."""
        from cortex.config import CortexConfig

        overlay = {"cortex": {"circadian": {"dream_timeout_sec": 9999}}}
        cfg = CortexConfig.from_toml(overlay=overlay)
        # Should keep default (600) because old key is no longer mapped
        assert cfg.dream_cycle_timeout_sec != 9999, (
            "Old key 'dream_timeout_sec' is still being mapped. "
            "Update the loader to use 'dream_cycle_timeout_sec'."
        )
