"""cortex/circadian/sleep_consolidator.py — SleepConsolidator class.

Extracted from sleep.py. Contains the full sleep and dream consolidation logic.
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
import traceback
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import TYPE_CHECKING, Any, List, Optional

if TYPE_CHECKING:
    from cortex.storage.adapter import StorageAdapter
    from cortex.config import CortexConfig
    from cortex.types import LLMProvider, SemanticClaim, MergeSuggestion
    from cortex.identity.cornerstone import CornerstoneGuardian
    from cortex.identity.drift_calculator import DriftCalculator, DriftReport
    from cortex.deprecation.pipeline import DeprecationPipeline
    from cortex.core.entity_resolution import EntityResolver
    from cortex.core.feedback import FeedbackSystem

from cortex.circadian.sleep import (
    SleepReport,
    DreamReport,
    _SESSION_SUMMARY_SYSTEM,
    _SESSION_SUMMARY_USER,
    _CONTRADICTION_SYSTEM,
    _CONTRADICTION_USER,
    _CORNERSTONE_PROPOSAL_SYSTEM,
    _CORNERSTONE_PROPOSAL_USER,
)
from cortex.types import SEMANTIC_MEMORY_TYPES

logger = logging.getLogger(__name__)


class SleepConsolidator:
    """
    M6b: Full sleep and dream consolidation logic.

    Designed to be called from CircadianEngine via on_sleep() hook,
    or directly for testing / manual triggers.

    Args:
        storage:               StorageAdapter for persistence.
        config:                CortexConfig with lifecycle parameters.
        llm:                   LLMProvider for summaries and analysis.
        cornerstone_guardian:  CornerstoneGuardian for cornerstone operations.
        drift_calculator:      DriftCalculator for drift detection/restoration.
        deprecation_pipeline:  DeprecationPipeline for memory lifecycle.
        entity_resolver:       EntityResolver for suggest_merges() (optional).
        feedback_system:       FeedbackSystem for apply_pending() (optional).
        peer_manager:          PeerManager for peer refresh during dreams (optional).
    """

    def __init__(
        self,
        storage: "StorageAdapter",
        config: "CortexConfig",
        llm: "LLMProvider",
        cornerstone_guardian: "CornerstoneGuardian",
        drift_calculator: "DriftCalculator",
        deprecation_pipeline: "DeprecationPipeline",
        entity_resolver: Optional["EntityResolver"] = None,
        feedback_system: Optional["FeedbackSystem"] = None,
        event_emitter: Optional[object] = None,
        peer_manager: Optional[object] = None,
    ) -> None:
        self.storage = storage
        self.config = config
        self.llm = llm
        self.cornerstone_guardian = cornerstone_guardian
        self.drift_calculator = drift_calculator
        self.deprecation_pipeline = deprecation_pipeline
        self.entity_resolver = entity_resolver
        self.feedback_system = feedback_system
        self.event_emitter = event_emitter
        self.peer_manager = peer_manager

        # Config helpers
        self._owner_id: str = getattr(config, "owner_id", "default")
        self._idle_timeout_sec: int = getattr(config, "dream_cycle_idle_timeout_sec", 3600)
        self._decay_window_hours: int = getattr(config, "dream_decay_window_hours", 24)
        self._promoted_confidence: float = getattr(config, "promoted_confidence_default", 0.8)
        # Number of claims to process before yielding to the event loop (#488)
        self._consolidation_batch_size: int = getattr(
            config, "sleep_consolidation_batch_size", 50
        )

    # ------------------------------------------------------------------
    # Public API — sleep()
    # ------------------------------------------------------------------

    async def sleep(self, session_id: str) -> SleepReport:
        """
        End-of-session consolidation (target: <3 minutes).

        Pipeline:
          1. SESSION MEMORY DUMP
          2. DEPRECATION REVIEW
          3. CORNERSTONE DRIFT CHECK
          4. QUICK CONSOLIDATION
          5. SLEEP CONFIRMATION
        """
        logger.info("[M6b] sleep() — session=%s", session_id)
        t_start = time.monotonic()

        report = SleepReport(session_id=session_id)

        # Step 1: SESSION MEMORY DUMP
        logger.debug("[M6b] step 1/5 — session memory dump for %s", session_id)
        session_claims = await self._step_session_dump(session_id, report)

        # Step 2: DEPRECATION REVIEW
        logger.debug("[M6b] step 2/5 — deprecation review for %s", session_id)
        await self._step_deprecation_review(session_id, report)

        # Step 3: CORNERSTONE DRIFT CHECK
        logger.debug("[M6b] step 3/5 — cornerstone drift check for %s", session_id)
        await self._step_drift_check(report)

        # Step 4: QUICK CONSOLIDATION
        logger.debug("[M6b] step 4/5 — quick consolidation for %s", session_id)
        await self._step_quick_consolidation(session_id, session_claims, report)

        # Step 5: SLEEP CONFIRMATION
        logger.debug("[M6b] step 5/5 — sleep confirmation for %s", session_id)
        await self._step_sleep_confirmation(session_id, report)

        report.duration_ms = int((time.monotonic() - t_start) * 1000)
        report.completed_at = datetime.now(timezone.utc).isoformat()

        # Emit events for promoted and deprecated memories
        if self.event_emitter is not None:
            try:
                from cortex.events.emitter import CortexEvent, EventType
                if report.claims_promoted > 0:
                    await self.event_emitter.emit(CortexEvent(
                        event_type=EventType.MEMORY_PROMOTED,
                        owner_id=self._owner_id,
                        payload={
                            "session_id": session_id,
                            "claims_promoted": report.claims_promoted,
                        },
                        session_id=session_id,
                    ))
                if report.claims_pruned > 0:
                    await self.event_emitter.emit(CortexEvent(
                        event_type=EventType.MEMORY_DEPRECATED,
                        owner_id=self._owner_id,
                        payload={
                            "session_id": session_id,
                            "claims_pruned": report.claims_pruned,
                        },
                        session_id=session_id,
                    ))
            except Exception as exc:
                logger.debug("[M6b] event emit failed (sleep events): %s", exc)

        logger.info(
            "[M6b] sleep() complete — session=%s promoted=%d pruned=%d drift_checks=%d duration_ms=%d",
            session_id, report.claims_promoted, report.claims_pruned,
            len(report.drift_checks), report.duration_ms,
        )
        return report

    # ------------------------------------------------------------------
    # Sleep pipeline steps (private)
    # ------------------------------------------------------------------

    async def _step_session_dump(
        self,
        session_id: str,
        report: SleepReport,
    ) -> List["SemanticClaim"]:
        """
        Step 1: SESSION MEMORY DUMP
        - Retrieve all claims sourced from this session
        - Generate a session summary via LLM
        - Tag all session claims as 'cooling' if still 'active'
        """
        claims = await self.storage.get_session_claims(session_id)
        report.claims_created = len(claims)

        if not claims:
            logger.debug("[M6b] no session claims found for %s", session_id)
            report.summary = "(no session claims)"
            return claims

        # Generate session summary via LLM
        try:
            claims_json = json.dumps(
                [
                    {
                        "id": c.id,
                        "subject": c.subject,
                        "predicate": c.predicate,
                        "object": c.object_value,
                        "confidence": c.confidence,
                    }
                    for c in claims[:50]  # cap at 50 to avoid token explosion
                ],
                ensure_ascii=False,
                indent=2,
            )
            from cortex.llm.router import resolve_routed_model
            _routed_model = resolve_routed_model(self.llm, "session_summary")
            _resp = await self.llm.complete(
                system=_SESSION_SUMMARY_SYSTEM,
                user=_SESSION_SUMMARY_USER.format(claims_json=claims_json),
                response_format="text",
                **({"model": _routed_model} if _routed_model else {}),
            )
            summary = _resp.text if hasattr(_resp, "text") else str(_resp)
            report.summary = summary.strip()
        except Exception as exc:
            logger.warning("[M6b] LLM session summary failed: %s", exc)
            report.summary = f"(summary unavailable: {exc})"

        logger.debug(
            "[M6b] session dump complete: %d claims, summary=%r",
            len(claims), report.summary[:80],
        )
        return claims

    async def _step_deprecation_review(
        self,
        session_id: str,
        report: SleepReport,
    ) -> None:
        """
        Step 2: DEPRECATION REVIEW
        - Run DeprecationPipeline.review_session() on all session claims
        - Record promoted / pruned counts in report
        """
        try:
            dep_report = await self.deprecation_pipeline.review_session(session_id)
            report.claims_promoted += dep_report.promoted
            report.claims_pruned += dep_report.deprecated
            logger.debug(
                "[M6b] deprecation review: promoted=%d kept=%d deprecated=%d noise=%d",
                dep_report.promoted, dep_report.kept_session,
                dep_report.deprecated, dep_report.auto_noise,
            )
        except Exception as exc:
            logger.error("[M6b] deprecation review failed for session %s: %s", session_id, exc)

    async def _step_drift_check(self, report: SleepReport) -> None:
        """
        Step 3: CORNERSTONE DRIFT CHECK
        - Run DriftCalculator.check_all() on all cornerstones
        - Auto-restore cornerstones with action='restore' or 'alert'
        """
        try:
            drift_reports = await self.drift_calculator.check_all(owner_id=self._owner_id)
            report.drift_checks = list(drift_reports)

            for dr in drift_reports:
                if dr.action in ("restore", "alert"):
                    try:
                        await self.drift_calculator.restore(dr.cornerstone_id)
                        logger.info(
                            "[M6b] restored cornerstone %s (drift=%.3f action=%s)",
                            dr.cornerstone_id, dr.drift_score, dr.action,
                        )
                    except Exception as exc:
                        logger.error(
                            "[M6b] failed to restore cornerstone %s: %s",
                            dr.cornerstone_id, exc,
                        )
                elif dr.action == "warn":
                    logger.warning(
                        "[M6b] cornerstone drift warning: %s label=%r drift=%.3f",
                        dr.cornerstone_id, dr.label, dr.drift_score,
                    )

            logger.debug("[M6b] drift check complete: %d cornerstones checked", len(drift_reports))
        except Exception as exc:
            logger.error("[M6b] drift check failed: %s", exc)

    async def _step_quick_consolidation(
        self,
        session_id: str,
        session_claims: List["SemanticClaim"],
        report: SleepReport,
    ) -> None:
        """
        Step 4: QUICK CONSOLIDATION
        - Fold promoted claims into the semantic layer (memory_type → 'semantic')
        - Update entity relationships for promoted claims
        - Create memory edges between related promoted claims
        """
        if not session_claims:
            return

        promoted_claims = [
            c for c in session_claims
            if c.memory_type in SEMANTIC_MEMORY_TYPES and c.status == "active"
        ]

        for idx, claim in enumerate(promoted_claims):
            # Yield to the event loop every batch_size claims to avoid blocking (#488)
            if idx > 0 and idx % self._consolidation_batch_size == 0:
                logger.debug(
                    "[M6b] quick consolidation: yielding event loop at claim %d/%d",
                    idx, len(promoted_claims),
                )
                await asyncio.sleep(0)

            try:
                # Ensure confidence is at the promoted level minimum
                new_confidence = max(claim.confidence, self._promoted_confidence)
                if new_confidence != claim.confidence:
                    await self.storage.update_claim(
                        claim.id,
                        confidence=new_confidence,
                        updated_at=datetime.now(timezone.utc).isoformat(),
                    )

                # Changelog: record session memory promotion
                try:
                    history = await self.storage.get_changelog(claim.id)
                    next_ver = len(history) + 1
                    await self.storage.log_changelog(
                        claim_id=claim.id,
                        version=next_ver,
                        content_snapshot=claim.object_value,
                        action="promoted",
                        reason="sleep cycle quick consolidation — session → semantic",
                        changed_by="sleep_cycle",
                        diff_summary=None,
                    )
                except Exception as exc:
                    logger.warning("[M6b] changelog log failed for claim %s: %s", claim.id, exc)

            except Exception as exc:
                logger.warning("[M6b] consolidation update failed for claim %s: %s", claim.id, exc)

        logger.debug(
            "[M6b] quick consolidation: %d/%d claims promoted into semantic layer",
            len(promoted_claims), len(session_claims),
        )

    async def _step_sleep_confirmation(
        self,
        session_id: str,
        report: SleepReport,
    ) -> None:
        """
        Step 5: SLEEP CONFIRMATION
        - Update session_record with sleep stats and summary
        - Set status='sleeping'
        """
        try:
            sleep_report_json = json.dumps(
                {
                    "claims_created": report.claims_created,
                    "claims_promoted": report.claims_promoted,
                    "claims_pruned": report.claims_pruned,
                    "drift_checks": len(report.drift_checks),
                    "summary": report.summary,
                    "completed_at": report.completed_at,
                },
                ensure_ascii=False,
            )
            await self.storage.update_session(
                session_id,
                status="sleeping",
                memories_created=report.claims_created,
                memories_promoted=report.claims_promoted,
                memories_pruned=report.claims_pruned,
                summary=report.summary,
                sleep_report=sleep_report_json,
                ended_at=datetime.now(timezone.utc).isoformat(),
            )
            logger.debug("[M6b] session %s status → sleeping", session_id)
        except Exception as exc:
            logger.error("[M6b] sleep confirmation failed for session %s: %s", session_id, exc)

    # ------------------------------------------------------------------
    # Dream step helper — per-step error isolation
    # ------------------------------------------------------------------

    async def _run_dream_step(self, name: str, coro, report: "DreamReport") -> Any:
        """Run a single dream step with error isolation and timing."""
        t0 = time.monotonic()
        try:
            result = await coro
            elapsed = int((time.monotonic() - t0) * 1000)
            logger.info("[M6b] dream step %s — completed in %dms", name, elapsed)
            report.step_results.append(
                {"name": name, "status": "success", "duration_ms": elapsed}
            )
            return result
        except Exception as exc:
            elapsed = int((time.monotonic() - t0) * 1000)
            logger.error(
                "[M6b] dream step %s — FAILED after %dms: %s", name, elapsed, exc
            )
            report.step_results.append(
                {
                    "name": name,
                    "status": "failed",
                    "duration_ms": elapsed,
                    "error": str(exc),
                }
            )
            return None

    # ------------------------------------------------------------------
    # Public API — dream()
    # ------------------------------------------------------------------

    async def dream(self, owner_id: str = "default") -> DreamReport:
        """
        Nightly deep consolidation — Electric Sheep (target: <10 minutes).

        Pipeline:
          1. ORPHAN RECOVERY
          2. DEEP CONSOLIDATION
          3. CORNERSTONE EVOLUTION
          4. RECONSOLIDATION
          5. MEMORY DECAY
          6. ENTITY MERGE SUGGESTIONS
          7. BRAIN GRAPH REFRESH
          8. APPLY PENDING FEEDBACK
          9. DREAM JOURNAL
        """
        logger.info("[M6b] dream() — owner=%s", owner_id)
        t_start = time.monotonic()

        report = DreamReport(owner_id=owner_id)
        journal_lines: List[str] = [
            f"[M6b] Dream cycle started at {datetime.now(timezone.utc).isoformat()} UTC"
        ]

        # Emit DREAM_STARTED event
        if self.event_emitter is not None:
            try:
                from cortex.events.emitter import CortexEvent, EventType
                await self.event_emitter.emit(CortexEvent(
                    event_type=EventType.DREAM_STARTED,
                    owner_id=owner_id,
                    payload={"owner_id": owner_id},
                ))
            except Exception as exc:
                logger.debug("[M6b] event emit failed (DREAM_STARTED): %s", exc)

        # Step 1: ORPHAN RECOVERY
        logger.debug("[M6b] dream step 1/10 — orphan recovery")
        orphans_recovered = await self._run_dream_step(
            "orphan_recovery", self._dream_recover_orphans(owner_id), report
        )
        report.orphans_recovered = orphans_recovered or 0
        journal_lines.append(f"Orphan recovery: {report.orphans_recovered} sessions recovered")

        # Step 2: DEEP CONSOLIDATION
        logger.debug("[M6b] dream step 2/10 — deep consolidation")
        claims_consolidated = await self._run_dream_step(
            "deep_consolidation", self._dream_deep_consolidation(owner_id), report
        )
        report.claims_consolidated = claims_consolidated or 0
        journal_lines.append(f"Deep consolidation: {report.claims_consolidated} claims reviewed")

        # Step 3: CORNERSTONE EVOLUTION
        logger.debug("[M6b] dream step 3/10 — cornerstone evolution")
        proposals = await self._run_dream_step(
            "cornerstone_evolution", self._dream_cornerstone_evolution(owner_id), report
        )
        report.cornerstone_proposals = proposals or 0
        journal_lines.append(f"Cornerstone proposals: {report.cornerstone_proposals} queued for review")

        # Step 4: RECONSOLIDATION
        logger.debug("[M6b] dream step 4/10 — reconsolidation")
        contradictions = await self._run_dream_step(
            "reconsolidation", self._dream_reconsolidation(owner_id), report
        )
        report.contradictions_resolved = contradictions or 0
        journal_lines.append(f"Reconsolidation: {report.contradictions_resolved} contradictions resolved")

        # Step 5: MEMORY DECAY (returns tuple)
        logger.debug("[M6b] dream step 5/10 — memory decay")
        decay_result = await self._run_dream_step(
            "memory_decay", self._dream_memory_decay(owner_id), report
        )
        if decay_result is not None:
            decayed, archived = decay_result
        else:
            decayed, archived = 0, 0
        report.claims_decayed = decayed
        report.claims_archived = archived
        journal_lines.append(f"Memory decay: {decayed} decayed, {archived} archived")

        # Step 6: ENTITY MERGE SUGGESTIONS
        logger.debug("[M6b] dream step 6/10 — entity merge suggestions")
        merges = await self._run_dream_step(
            "entity_merges", self._dream_entity_merges(owner_id), report
        )
        report.entity_merge_suggestions = merges or 0
        journal_lines.append(f"Entity merges: {report.entity_merge_suggestions} suggestions stored")

        # Step 7: PEER REFRESH (returns tuple)
        logger.debug("[M6b] dream step 7/10 — peer refresh")
        peer_result = await self._run_dream_step(
            "peer_refresh", self._dream_peer_refresh(owner_id), report
        )
        if peer_result is not None:
            peers_refreshed, peer_changes = peer_result
        else:
            peers_refreshed, peer_changes = 0, []
        report.peers_refreshed = peers_refreshed
        report.peer_changes = peer_changes
        journal_lines.append(
            f"Peer refresh: {peers_refreshed} peers refreshed, "
            f"{len(peer_changes)} trait changes detected"
        )

        # Step 8: BRAIN GRAPH REFRESH
        logger.debug("[M6b] dream step 8/10 — brain graph refresh")
        refreshed = await self._run_dream_step(
            "brain_graph_refresh", self._dream_brain_graph_refresh(owner_id), report
        )
        report.brain_graphs_refreshed = refreshed or 0
        journal_lines.append(f"Brain graph refresh: {report.brain_graphs_refreshed} graphs checked")

        # Step 9: APPLY PENDING FEEDBACK
        logger.debug("[M6b] dream step 9/10 — apply pending feedback")
        applied = await self._run_dream_step(
            "apply_feedback", self._dream_apply_feedback(owner_id), report
        )
        report.feedback_applied = applied or 0
        journal_lines.append(f"Feedback applied: {report.feedback_applied} records processed")

        # Step 10: DREAM JOURNAL (M6c — Curiosity Seeds + Cross-Memory Connections)
        logger.debug("[M6b] dream step 10/10 — curiosity seeds + dream journal")
        curiosity_report = await self._run_dream_step(
            "curiosity_and_journal",
            self._dream_curiosity_and_journal(
                owner_id=owner_id,
                dream_stats={
                    "orphans_recovered": report.orphans_recovered,
                    "claims_consolidated": report.claims_consolidated,
                    "contradictions_resolved": report.contradictions_resolved,
                    "claims_decayed": report.claims_decayed,
                    "claims_archived": report.claims_archived,
                    "cornerstone_proposals": report.cornerstone_proposals,
                    "entity_merge_suggestions": report.entity_merge_suggestions,
                    "brain_graphs_refreshed": report.brain_graphs_refreshed,
                    "feedback_applied": report.feedback_applied,
                    "peers_refreshed": report.peers_refreshed,
                    "peer_changes": len(report.peer_changes),
                },
            ),
            report,
        )

        report.duration_ms = int((time.monotonic() - t_start) * 1000)
        report.completed_at = datetime.now(timezone.utc).isoformat()

        if curiosity_report is not None and curiosity_report.journal_md:
            report.journal = curiosity_report.journal_md
            journal_lines.append(
                f"Curiosity seeds planted: {len(curiosity_report.seeds)}"
            )
            journal_lines.append(
                f"Cross-memory edges created: {curiosity_report.edges_created}"
            )
        else:
            journal_lines.append(
                f"Dream cycle complete in {report.duration_ms}ms at {report.completed_at}"
            )
            report.journal = "\n".join(journal_lines)

        # Emit DREAM_COMPLETED event
        if self.event_emitter is not None:
            try:
                from cortex.events.emitter import CortexEvent, EventType
                await self.event_emitter.emit(CortexEvent(
                    event_type=EventType.DREAM_COMPLETED,
                    owner_id=owner_id,
                    payload={
                        "duration_ms": report.duration_ms,
                        "claims_consolidated": report.claims_consolidated,
                        "contradictions_resolved": report.contradictions_resolved,
                        "claims_decayed": report.claims_decayed,
                    },
                ))
            except Exception as exc:
                logger.debug("[M6b] event emit failed (DREAM_COMPLETED): %s", exc)

        # Persist dream report to session_record (latest sleeping session)
        await self._persist_dream_report(owner_id, report)

        steps_succeeded = sum(1 for s in report.step_results if s["status"] == "success")
        steps_failed = sum(1 for s in report.step_results if s["status"] == "failed")

        logger.info(
            "[M6b] dream() complete — owner=%s orphans=%d consolidated=%d "
            "contradictions=%d decayed=%d archived=%d proposals=%d merges=%d "
            "feedback=%d duration_ms=%d steps_succeeded=%d steps_failed=%d",
            owner_id, report.orphans_recovered, report.claims_consolidated,
            report.contradictions_resolved, report.claims_decayed,
            report.claims_archived, report.cornerstone_proposals,
            report.entity_merge_suggestions, report.feedback_applied,
            report.duration_ms, steps_succeeded, steps_failed,
        )
        return report

    # ------------------------------------------------------------------
    # Dream pipeline steps (private)
    # ------------------------------------------------------------------

    async def _dream_recover_orphans(self, owner_id: str) -> int:
        """
        Step 1: ORPHAN RECOVERY
        Find sessions that are still 'awake' past idle_timeout and sleep them.
        """
        orphans_recovered = 0
        try:
            orphaned = await self.storage.get_orphaned_sessions(
                idle_seconds=self._idle_timeout_sec
            )
            for orphan in orphaned:
                if orphan.owner_id != owner_id:
                    continue
                try:
                    logger.info(
                        "[M6b] recovering orphaned session %s (owner=%s)",
                        orphan.session_id, orphan.owner_id,
                    )
                    await self.sleep(orphan.session_id)
                    orphans_recovered += 1
                except Exception as exc:
                    logger.error(
                        "[M6b] failed to recover orphan session %s: %s",
                        orphan.session_id, exc,
                    )
        except Exception as exc:
            logger.error("[M6b] orphan recovery failed: %s", exc)
        return orphans_recovered

    async def _dream_deep_consolidation(self, owner_id: str) -> int:
        """
        Step 2: DEEP CONSOLIDATION
        Review all session memories from past 24h, look for patterns/contradictions.
        Returns count of claims reviewed.
        """
        claims_reviewed = 0
        try:
            # Get all active session claims across recent sessions
            session_claims = await self.storage.get_claims_by_status(
                "active", owner_id=owner_id
            )
            # Filter to session-type memories created in past 24h
            cutoff = datetime.now(timezone.utc) - timedelta(hours=self._decay_window_hours)
            recent_session_claims = [
                c for c in session_claims
                if c.memory_type == "session"
                and c.created_at is not None
                and _parse_dt(c.created_at) >= cutoff
            ]
            claims_reviewed = len(recent_session_claims)
            logger.debug(
                "[M6b] deep consolidation: %d recent session claims in past %dh",
                claims_reviewed, self._decay_window_hours,
            )
        except Exception as exc:
            logger.error("[M6b] deep consolidation scan failed: %s", exc)
        return claims_reviewed

    async def _dream_cornerstone_evolution(self, owner_id: str) -> int:
        """
        Step 3: CORNERSTONE EVOLUTION
        Propose new cornerstones based on recurring high-confidence patterns.
        Returns count of proposals queued.
        """
        proposals_created = 0
        try:
            # Gather high-confidence semantic claims from the past 24h
            all_claims = await self.storage.get_claims_by_status("active", owner_id=owner_id)
            cutoff = datetime.now(timezone.utc) - timedelta(hours=self._decay_window_hours)
            high_confidence_claims = [
                c for c in all_claims
                if c.confidence >= 0.75
                and c.memory_type in SEMANTIC_MEMORY_TYPES
                and c.created_at is not None
                and _parse_dt(c.created_at) >= cutoff
            ]

            if not high_confidence_claims:
                logger.debug("[M6b] no high-confidence claims for cornerstone proposals")
                return 0

            # Gather existing cornerstones to avoid re-proposing
            existing = await self.storage.get_cornerstones(owner_id=owner_id)

            claims_json = json.dumps(
                [
                    {
                        "subject": c.subject,
                        "predicate": c.predicate,
                        "object": c.object_value,
                        "confidence": c.confidence,
                    }
                    for c in high_confidence_claims[:30]
                ],
                ensure_ascii=False,
                indent=2,
            )
            cornerstones_json = json.dumps(
                [{"label": cs.label, "content": cs.content[:200]} for cs in existing],
                ensure_ascii=False,
                indent=2,
            )

            from cortex.llm.router import resolve_routed_model
            _cs_model = resolve_routed_model(self.llm, "cornerstone_proposal")
            _resp = await self.llm.complete(
                system=_CORNERSTONE_PROPOSAL_SYSTEM,
                user=_CORNERSTONE_PROPOSAL_USER.format(
                    claims_json=claims_json,
                    cornerstones_json=cornerstones_json,
                ),
                response_format="json",
                **({"model": _cs_model} if _cs_model else {}),
            )
            response = _resp.text if hasattr(_resp, "text") else str(_resp)

            proposals = _parse_json_list(response)
            for prop in proposals[:3]:  # cap at 3
                label = prop.get("label", "").strip()
                content = prop.get("content", "").strip()
                reason = prop.get("reason", "")
                if label and content:
                    try:
                        # Propose evolution (queued for operator review)
                        await self.cornerstone_guardian.propose_evolution(
                            label=label,
                            proposed_content=content,
                            reason=reason,
                            owner_id=owner_id,
                        )
                        proposals_created += 1
                        logger.info(
                            "[M6b] cornerstone proposal queued: %r", label
                        )
                    except Exception as exc:
                        logger.warning(
                            "[M6b] cornerstone proposal failed for %r: %s", label, exc
                        )

        except Exception as exc:
            logger.error("[M6b] cornerstone evolution failed: %s", exc)
        return proposals_created

    async def _dream_reconsolidation(self, owner_id: str) -> int:
        """
        Step 4: RECONSOLIDATION
        Detect contradictions in semantic memory, resolve via LLM evidence.
        Returns count of contradictions resolved.
        """
        contradictions_resolved = 0
        try:
            # Get all active semantic claims, grouped by subject
            all_claims = await self.storage.get_claims_by_status("active", owner_id=owner_id)
            semantic_claims = [c for c in all_claims if c.memory_type in SEMANTIC_MEMORY_TYPES]

            # Group by subject
            by_subject: dict[str, list] = {}
            for claim in semantic_claims:
                by_subject.setdefault(claim.subject, []).append(claim)

            for subject, claims in by_subject.items():
                if len(claims) < 2:
                    continue  # can't have contradictions with one claim
                try:
                    result = await self._detect_and_resolve_contradictions(subject, claims)
                    contradictions_resolved += result
                except Exception as exc:
                    logger.warning(
                        "[M6b] reconsolidation failed for subject %r: %s", subject, exc
                    )

        except Exception as exc:
            logger.error("[M6b] reconsolidation scan failed: %s", exc)
        return contradictions_resolved

    async def _detect_and_resolve_contradictions(
        self,
        subject: str,
        claims: List["SemanticClaim"],
    ) -> int:
        """Check one subject group for contradictions and resolve them."""
        claims_json = json.dumps(
            [
                {
                    "id": c.id,
                    "predicate": c.predicate,
                    "object": c.object_value,
                    "confidence": c.confidence,
                    "created_at": c.created_at,
                }
                for c in claims
            ],
            ensure_ascii=False,
            indent=2,
        )

        from cortex.llm.router import resolve_routed_model
        _contra_model = resolve_routed_model(self.llm, "contradiction_analysis")
        _resp = await self.llm.complete(
            system=_CONTRADICTION_SYSTEM,
            user=_CONTRADICTION_USER.format(subject=subject, claims_json=claims_json),
            response_format="json",
            **({"model": _contra_model} if _contra_model else {}),
        )
        response = _resp.text if hasattr(_resp, "text") else str(_resp)

        data = _parse_json_dict(response)
        contradictions = data.get("contradictions", [])
        resolved = 0

        for contradiction in contradictions:
            resolution = contradiction.get("resolution", "flag_both")
            claim_a_id = contradiction.get("claim_a_id")
            claim_b_id = contradiction.get("claim_b_id")
            reason = contradiction.get("reason", "")

            if not claim_a_id or not claim_b_id:
                continue

            try:
                now = datetime.now(timezone.utc).isoformat()
                if resolution == "supersede_a":
                    # claim_a is superseded by claim_b
                    await self.storage.update_claim(
                        claim_a_id,
                        status="deprecated",
                        superseded_by=claim_b_id,
                        updated_at=now,
                    )
                    # Changelog: dream reconsolidation updated claim_a
                    try:
                        history = await self.storage.get_changelog(claim_a_id)
                        next_ver = len(history) + 1
                        await self.storage.log_changelog(
                            claim_id=claim_a_id,
                            version=next_ver,
                            content_snapshot=None,
                            action="updated",
                            reason=f"dream reconsolidation: {reason}",
                            changed_by="dream_cycle",
                            diff_summary=f"superseded by {claim_b_id}: {reason}",
                        )
                    except Exception as _exc:
                        logger.warning("[M6b] changelog log failed for %s: %s", claim_a_id, _exc)
                    resolved += 1
                    logger.info(
                        "[M6b] claim %s superseded by %s (%s)", claim_a_id, claim_b_id, reason
                    )
                elif resolution == "supersede_b":
                    # claim_b is superseded by claim_a
                    await self.storage.update_claim(
                        claim_b_id,
                        status="deprecated",
                        superseded_by=claim_a_id,
                        updated_at=now,
                    )
                    # Changelog: dream reconsolidation updated claim_b
                    try:
                        history = await self.storage.get_changelog(claim_b_id)
                        next_ver = len(history) + 1
                        await self.storage.log_changelog(
                            claim_id=claim_b_id,
                            version=next_ver,
                            content_snapshot=None,
                            action="updated",
                            reason=f"dream reconsolidation: {reason}",
                            changed_by="dream_cycle",
                            diff_summary=f"superseded by {claim_a_id}: {reason}",
                        )
                    except Exception as _exc:
                        logger.warning("[M6b] changelog log failed for %s: %s", claim_b_id, _exc)
                    resolved += 1
                    logger.info(
                        "[M6b] claim %s superseded by %s (%s)", claim_b_id, claim_a_id, reason
                    )
                else:
                    # flag_both — reduce confidence on both, mark for review
                    for cid in (claim_a_id, claim_b_id):
                        matching = [c for c in claims if c.id == cid]
                        if matching:
                            old_conf = matching[0].confidence
                            new_conf = max(0.3, old_conf * 0.7)
                            await self.storage.update_claim(
                                cid,
                                confidence=new_conf,
                                updated_at=now,
                            )
                            # Changelog: confidence reduced by dream cycle
                            try:
                                history = await self.storage.get_changelog(cid)
                                next_ver = len(history) + 1
                                await self.storage.log_changelog(
                                    claim_id=cid,
                                    version=next_ver,
                                    content_snapshot=None,
                                    action="updated",
                                    reason=f"dream reconsolidation flag_both: {reason}",
                                    changed_by="dream_cycle",
                                    diff_summary=f"confidence {old_conf:.3f} → {new_conf:.3f}",
                                )
                            except Exception as _exc:
                                logger.warning("[M6b] changelog log failed for %s: %s", cid, _exc)
                    logger.info(
                        "[M6b] contradiction flagged (both reduced): %s vs %s (%s)",
                        claim_a_id, claim_b_id, reason,
                    )
                    # Flagging both still counts as a resolution action
                    resolved += 1

            except Exception as exc:
                logger.warning(
                    "[M6b] could not resolve contradiction %s vs %s: %s",
                    claim_a_id, claim_b_id, exc,
                )

        return resolved

    async def _dream_memory_decay(self, owner_id: str) -> tuple[int, int]:
        """
        Step 5: MEMORY DECAY
        Run DeprecationPipeline.run_decay() for time-based confidence reduction.
        Returns (claims_decayed, claims_archived).
        """
        claims_decayed = 0
        claims_archived = 0
        try:
            decay_report = await self.deprecation_pipeline.run_decay(owner_id=owner_id)
            claims_decayed = (
                decay_report.transitioned_to_cooling
                + decay_report.transitioned_to_deprecated
                + decay_report.confidence_reduced
            )
            claims_archived = decay_report.transitioned_to_archived
            logger.debug(
                "[M6b] decay: cooling=%d deprecated=%d archived=%d confidence_reduced=%d",
                decay_report.transitioned_to_cooling,
                decay_report.transitioned_to_deprecated,
                decay_report.transitioned_to_archived,
                decay_report.confidence_reduced,
            )
        except Exception as exc:
            logger.error("[M6b] memory decay failed: %s", exc)
        return claims_decayed, claims_archived

    async def _dream_entity_merges(self, owner_id: str) -> int:
        """
        Step 6: ENTITY MERGE SUGGESTIONS
        Run entity_resolver.suggest_merges() and store results.
        Returns count of suggestions generated.
        """
        if self.entity_resolver is None:
            logger.debug("[M6b] entity_resolver not configured, skipping merge suggestions")
            return 0

        try:
            suggestions = await self.entity_resolver.suggest_merges(owner_id=owner_id)
            # Suggestions are returned but not auto-applied (operator review required)
            # They are stored in-memory and available via the resolver's return value.
            # If storage supports a merge_suggestions table, we'd persist here.
            logger.debug(
                "[M6b] entity merge suggestions: %d pairs found", len(suggestions)
            )
            return len(suggestions)
        except Exception as exc:
            logger.error("[M6b] entity merge suggestions failed: %s", exc)
            return 0

    async def _dream_peer_refresh(
        self, owner_id: str
    ) -> tuple[int, list[dict]]:
        """
        Step 7: PEER REFRESH
        For each active peer (claims in last 7 days, max 20), refresh their
        profile representation via PeerManager.build_representation() and
        detect trait changes since the last dream cycle.

        Returns:
            (peers_refreshed, peer_changes) where peer_changes is a list of
            dicts {peer_id, name, changes} for peers whose traits changed.
        """
        if self.peer_manager is None:
            logger.debug("[M6b] peer_manager not configured, skipping peer refresh")
            return 0, []

        peers_refreshed = 0
        peer_changes: list[dict] = []

        try:
            active_peers = await self.peer_manager.get_active_peers(
                owner_id=owner_id, days=7, limit=20,
            )

            for peer_info in active_peers:
                peer_id = peer_info["peer_id"]
                display_name = peer_info["display_name"]
                entity_id = peer_info.get("entity_id")
                old_rep = peer_info.get("representation", {})

                try:
                    # Gather claims referencing this peer's entity
                    claims: list = []
                    if entity_id and hasattr(self.storage, "get_claims_by_entity"):
                        claims = await self.storage.get_claims_by_entity(
                            entity_id,
                            owner_id=owner_id,
                        )

                    if not claims:
                        logger.debug(
                            "[M6b] peer %s (%s): no active claims, skipping",
                            peer_id, display_name,
                        )
                        continue

                    # Build refreshed representation
                    new_rep = await self.peer_manager.build_representation(
                        peer_id, claims,
                    )
                    peers_refreshed += 1

                    # Detect trait changes
                    changes = self._detect_trait_changes(old_rep, new_rep)
                    if changes:
                        peer_changes.append({
                            "peer_id": peer_id,
                            "name": display_name,
                            "changes": changes,
                        })
                        logger.info(
                            "[M6b] peer %s (%s): %d trait changes detected",
                            peer_id, display_name, len(changes),
                        )

                except Exception as exc:
                    logger.warning(
                        "[M6b] peer refresh failed for %s (%s): %s",
                        peer_id, display_name, exc,
                    )

        except Exception as exc:
            logger.error("[M6b] peer refresh failed: %s", exc)

        logger.debug(
            "[M6b] peer refresh complete: %d refreshed, %d with changes",
            peers_refreshed, len(peer_changes),
        )
        return peers_refreshed, peer_changes

    @staticmethod
    def _detect_trait_changes(
        old_rep: dict, new_rep: dict
    ) -> list[str]:
        """Compare old and new representations to detect trait changes.

        Checks each of the four trait dimensions for additions.

        Returns:
            List of human-readable change descriptions.
        """
        changes: list[str] = []
        dimensions = [
            "communication_style",
            "topics_of_interest",
            "preferences",
            "relationship_dynamics",
        ]
        for dim in dimensions:
            old_values = set(
                v.lower() for v in old_rep.get(dim, [])
            )
            new_values = set(
                v.lower() for v in new_rep.get(dim, [])
            )
            added = new_values - old_values
            for val in sorted(added):
                changes.append(f"+{dim}: {val}")
        return changes

    async def _dream_brain_graph_refresh(self, owner_id: str) -> int:
        """
        Step 7: BRAIN GRAPH REFRESH
        Check for stale brain graph nodes (source hashes changed).
        Returns count of graphs checked (not rebuilt — rebuild is M10).
        """
        graphs_checked = 0
        try:
            # We check all brain graph nodes to detect staleness
            # Actual rebuild execution is M10 — we only flag here.
            # For now: count graphs we'd need to check (stub-safe).
            # Full implementation would call storage.get_stale_brain_graphs()
            # when that API is available.
            logger.debug("[M6b] brain graph refresh check (M10 handles actual rebuild)")
            graphs_checked = 0  # no-op until M10 API is available
        except Exception as exc:
            logger.error("[M6b] brain graph refresh failed: %s", exc)
        return graphs_checked

    async def _dream_apply_feedback(self, owner_id: str) -> int:
        """
        Step 8: APPLY PENDING FEEDBACK
        Process all memory_feedback records with status='pending'.
        Returns count of feedback records applied.
        """
        if self.feedback_system is None:
            # Fallback: use storage directly if feedback_system not injected
            try:
                pending = await self.storage.get_pending_feedback(owner_id=owner_id)
                applied = 0
                for record in pending:
                    try:
                        await self.storage.apply_feedback(record.id)
                        applied += 1
                    except Exception as exc:
                        logger.warning(
                            "[M6b] failed to apply feedback %s: %s", record.id, exc
                        )
                logger.debug("[M6b] feedback (direct): %d applied", applied)
                return applied
            except Exception as exc:
                logger.error("[M6b] feedback application failed: %s", exc)
                return 0

        try:
            applied = await self.feedback_system.process_pending(owner_id=owner_id)
            logger.debug("[M6b] feedback (FeedbackSystem): %d applied", applied)
            return applied
        except Exception as exc:
            logger.error("[M6b] FeedbackSystem.process_pending failed: %s", exc)
            return 0

    async def _dream_curiosity_and_journal(
        self, owner_id: str, dream_stats: dict
    ) -> Optional["CuriosityReport"]:
        """
        Step 9: CURIOSITY SEEDS + CROSS-MEMORY CONNECTIONS + DREAM JOURNAL (M6c)
        Runs CuriosityEngine on the most recent session's claims.
        Returns CuriosityReport (or None on failure).
        """
        try:
            from cortex.circadian.curiosity import CuriosityEngine  # local import — no circular dep
            engine = CuriosityEngine(
                storage=self.storage,
                llm=self.llm,
                config=self.config,
            )
            # Fetch recent session claims for this owner
            try:
                active = await self.storage.get_active_session(owner_id=owner_id)
                session_claims: List = []
                if active:
                    session_claims = await self.storage.get_session_claims(
                        active.session_id
                    )
                else:
                    # Fallback: get claims from status='active' without a specific session
                    session_claims = await self.storage.get_claims_by_status(
                        "active", owner_id=owner_id
                    )
            except Exception as exc:
                logger.warning("[M6c] could not fetch session claims: %s", exc)
                session_claims = []

            report = await engine.run(
                owner_id=owner_id,
                session_claims=session_claims,
                dream_stats=dream_stats,
            )
            return report
        except Exception as exc:
            logger.error("[M6b] _dream_curiosity_and_journal failed: %s", exc)
            return None

    async def _persist_dream_report(self, owner_id: str, report: DreamReport) -> None:
        """Persist dream report JSON to the most recent sleeping session."""
        try:
            # Find the most recent session for this owner to attach the dream report
            active = await self.storage.get_active_session(owner_id=owner_id)
            if active:
                dream_json = json.dumps(
                    {
                        "orphans_recovered": report.orphans_recovered,
                        "claims_consolidated": report.claims_consolidated,
                        "contradictions_resolved": report.contradictions_resolved,
                        "claims_decayed": report.claims_decayed,
                        "claims_archived": report.claims_archived,
                        "cornerstone_proposals": report.cornerstone_proposals,
                        "entity_merge_suggestions": report.entity_merge_suggestions,
                        "brain_graphs_refreshed": report.brain_graphs_refreshed,
                        "feedback_applied": report.feedback_applied,
                        "peers_refreshed": report.peers_refreshed,
                        "peer_changes": report.peer_changes,
                        "duration_ms": report.duration_ms,
                        "completed_at": report.completed_at,
                    },
                    ensure_ascii=False,
                )
                await self.storage.update_session(
                    active.session_id,
                    dream_report=dream_json,
                    status="dreaming",
                )
        except Exception as exc:
            logger.warning("[M6b] could not persist dream report: %s", exc)


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _parse_dt(dt_str: str) -> datetime:
    """Parse an ISO datetime string to a timezone-aware datetime."""
    dt = datetime.fromisoformat(dt_str)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt


def _parse_json_list(text: str) -> list:
    """Safely parse LLM JSON response as a list."""
    text = text.strip()
    try:
        data = json.loads(text)
        if isinstance(data, list):
            return data
        if isinstance(data, dict) and "proposals" in data:
            return data["proposals"]
        return []
    except json.JSONDecodeError:
        logger.warning("[M6b] JSON parse failed (list): %r", text[:200])
        return []


def _parse_json_dict(text: str) -> dict:
    """Safely parse LLM JSON response as a dict."""
    text = text.strip()
    try:
        data = json.loads(text)
        if isinstance(data, dict):
            return data
        return {}
    except json.JSONDecodeError:
        logger.warning("[M6b] JSON parse failed (dict): %r", text[:200])
        return {}
