"""CortexService — orchestrator for ingest, recall, dream, stats, timeline."""

import logging
import os
import threading
import time

from neo_cortex.classifier import Classifier
from neo_cortex.embedder import JinaEmbedder
from neo_cortex.memory_index import MemoryIndex
from neo_cortex.models import (
    Activity,
    DreamResult,
    IngestRequest,
    MemoryRecord,
    RecalledMemory,
    RecallResult,
    StructuredFields,
    TimelineResult,
)
from neo_cortex.store import MemoryStore

logger = logging.getLogger(__name__)

# Direct file logging
_DATA_DIR = os.environ.get("CORTEX_DATA_DIR") or os.getcwd()
_LOG_DIR = os.path.join(_DATA_DIR, "cortex_db") if os.environ.get("CORTEX_DATA_DIR") else _DATA_DIR
_LOG_PATH = os.path.join(_LOG_DIR, "neo-cortex.log")


def _bl(msg: str) -> None:
    try:
        with open(_LOG_PATH, "a", encoding="utf-8") as f:
            f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} [CORTEX] {msg}\n")
            f.flush()
    except Exception:
        pass

# Static noise patterns — tier 1 filter (free, instant)
_id_counter = 0
_id_lock = threading.Lock()


def _next_id_suffix() -> int:
    """Thread-safe monotonic counter for unique memory IDs."""
    global _id_counter
    with _id_lock:
        _id_counter += 1
        return _id_counter


# Static noise patterns — tier 1 filter (free, instant)
NOISE_PATTERNS = frozenset({
    "ciao", "/clear", "eccoti", "kill", "kill?", "esci", "killa ti",
    "puoi killare", "restarta ti", "indovina", "laco",
    "hey", "hello", "hi", "buongiorno", "buonasera", "salve",
    "ok", "si", "no", "va bene", "grazie", "thanks", "yes", "yep",
    "nope", "nah", "sure", "done", "fatto", "perfetto", "bene",
    "vai", "avanti", "continua", "stop",
})


class CortexService:
    """Orchestrates memory ingestion, recall, and lifecycle."""

    def __init__(
        self,
        store: MemoryStore,
        embedder: JinaEmbedder,
        classifier: Classifier,
        index: MemoryIndex | None = None,
        dedup: 'DedupStore | None' = None,
        graph: 'ConceptGraph | None' = None,
    ):
        self._store = store
        self._embedder = embedder
        self._classifier = classifier
        self._index = index
        self._dedup = dedup
        self._graph = graph
        self._dream_count = 0

    @property
    def index(self) -> MemoryIndex | None:
        return self._index

    @property
    def dream_count(self) -> int:
        return self._dream_count

    async def _resolve_conflicts(
        self, text: str, embedding: list[float]
    ) -> dict:
        """Query similar memories, ask LLM to decide ADD/UPDATE/NOOP."""
        if not self._index or self._store.count() == 0:
            return {"action": "add", "target_id": None, "reason": "no index"}

        similar = self._store.query(embedding, n=3)
        strong = [(r, s) for r, s in similar if s > 0.7]
        if not strong:
            return {"action": "add", "target_id": None, "reason": "no conflict"}

        enriched = [(rec, sim, self._index.get_structured(rec.id)) for rec, sim in strong]
        _bl(f"conflict check against {len(enriched)} similar memories")
        for rec, sim, sf in enriched:
            title = sf.title if sf else rec.topic
            _bl(f"  candidate: {rec.id} sim={sim:.3f} title='{title}'")

        decision = await self._classifier.resolve_conflict(text, enriched)
        _bl(f"conflict decision → {decision['action']} ({decision.get('reason', '')})")
        return decision

    async def ingest(self, request: IngestRequest) -> str | None:
        """Ingest a conversation turn.

        Flow: noise check → embed → classify → store.
        Returns memory ID or None if filtered as noise.
        """
        _bl(f"ingest: session={request.session_id[:8]}, turn={request.turn_number}")
        _bl(f"ingest: question_len={len(request.question)}, answer_len={len(request.answer)}")

        # Tier 1: static noise filter
        q = request.question.strip().lower()
        if q.startswith("[system]"):
            _bl("ingest: filtered [system]")
            return None
        if q.rstrip("!?.,;:") in NOISE_PATTERNS:
            _bl(f"ingest: filtered noise pattern '{q[:30]}'")
            return None

        # Tier 2: LLM noise filter for short ambiguous messages
        if len(q) <= 50:
            _bl("ingest: checking noise via Groq")
            if await self._classifier.is_noise(request.question):
                _bl("ingest: filtered by Groq noise")
                return None
            _bl("ingest: passed noise check")

        # Build combined text
        combined = f"Q: {request.question}\nA: {request.answer}"
        if len(combined) > 8000:
            combined = combined[:8000]
        _bl(f"ingest: combined_len={len(combined)}")

        # Dedup check
        if self._dedup and self._dedup.is_duplicate(combined):
            _bl("ingest: filtered by dedup (near-duplicate)")
            return None

        # Embed
        _bl("ingest: calling embed_passage")
        embedding = await self._embedder.embed_passage(combined)
        _bl(f"ingest: embedding OK, dim={len(embedding)}")

        # Classify
        _bl("ingest: calling classify")
        classification = await self._classifier.classify(
            request.question, request.answer
        )
        _bl(f"ingest: classify OK → {classification.project}/{classification.topic}")

        # Tier 4: Conflict resolution — check if this updates an existing memory
        decision = await self._resolve_conflicts(combined, embedding)

        if decision["action"] == "noop":
            _bl("ingest: filtered by conflict resolution (redundant)")
            return None

        if decision["action"] == "update" and decision.get("target_id"):
            target_id = decision["target_id"]
            _bl(f"ingest: updating existing memory {target_id}")

            ts = time.time()
            record = MemoryRecord(
                id=target_id,
                session_id=request.session_id,
                timestamp=ts,
                turn_number=request.turn_number,
                question=request.question[:500],
                answer_preview=request.answer[:500],
                document=combined,
                project=classification.project,
                topic=classification.topic,
                activity=classification.activity,
                energy=1.0,
                model=request.model,
                source=request.source,
                tools_used=request.tools_used,
            )
            structured = StructuredFields(
                title=classification.title,
                summary=classification.summary,
                facts=classification.facts,
                concepts=classification.concepts,
                files_touched=classification.files_touched,
            )

            self._store.update(target_id, record, embedding)
            self._index.update(record, structured)

            if self._dedup:
                self._dedup.add(combined, target_id)

            _bl(f"ingest: UPDATED → {target_id}")
            logger.info("Updated memory %s [%s/%s]", target_id, classification.project, classification.topic)
            return target_id

        # Build record (ADD path — no conflict)
        ts = time.time()
        memory_id = f"v3_{request.session_id[:8]}_{request.turn_number}_{int(ts) % 100000}"
        _bl(f"ingest: memory_id={memory_id}")

        record = MemoryRecord(
            id=memory_id,
            session_id=request.session_id,
            timestamp=ts,
            turn_number=request.turn_number,
            question=request.question[:500],
            answer_preview=request.answer[:500],
            document=combined,
            project=classification.project,
            topic=classification.topic,
            activity=classification.activity,
            energy=1.0,
            model=request.model,
            source=request.source,
            tools_used=request.tools_used,
        )

        # Store in ChromaDB
        _bl("ingest: calling store.insert")
        self._store.insert(record, embedding)
        _bl("ingest: store.insert OK")

        # Register in dedup store
        if self._dedup:
            self._dedup.add(combined, memory_id)
            _bl("ingest: dedup hash stored")

        # Dual-write to SQLite index
        structured = None
        if self._index:
            _bl("ingest: writing SQLite index")
            structured = StructuredFields(
                title=classification.title,
                summary=classification.summary,
                facts=classification.facts,
                concepts=classification.concepts,
                files_touched=classification.files_touched,
            )
            self._index.insert(record, structured)
            _bl("ingest: SQLite index OK")

        # Add concepts to graph
        if self._graph and structured and structured.concepts:
            self._graph.add_memory(memory_id, structured.concepts)
            _bl(f"ingest: graph updated with {len(structured.concepts)} concepts")

        _bl(f"ingest: DONE → {memory_id}")
        logger.info("Ingested memory %s [%s/%s]", memory_id, classification.project, classification.topic)
        return memory_id

    async def ingest_episode(self, text: str, session_id: str) -> str | None:
        """Ingest a multi-turn episode. NO noise filter — the digestor has full context."""
        if not text.strip():
            return None

        _bl(f"ingest_episode: session={session_id[:8]}, len={len(text)}")

        # Truncate
        if len(text) > 8000:
            text = text[:8000]

        # Dedup
        if self._dedup and self._dedup.is_duplicate(text):
            _bl("ingest_episode: filtered by dedup")
            return None

        # Embed
        embedding = await self._embedder.embed_passage(text)

        # Classify — sees entire episode context
        classification = await self._classifier.classify(
            "[episode]", text[:800]
        )
        _bl(f"ingest_episode: classify → {classification.project}/{classification.topic}")

        # Conflict resolution — same mechanism as ingest()
        decision = await self._resolve_conflicts(text, embedding)

        if decision["action"] == "noop":
            _bl("ingest_episode: filtered by conflict resolution (redundant)")
            if self._dedup:
                self._dedup.add(text, "noop")
            return None

        if decision["action"] == "update" and decision.get("target_id"):
            target_id = decision["target_id"]
            _bl(f"ingest_episode: updating existing memory {target_id}")

            ts = time.time()
            record = MemoryRecord(
                id=target_id,
                session_id=session_id,
                timestamp=ts,
                turn_number=0,
                question="[episode]",
                answer_preview=text[:500],
                document=text,
                project=classification.project,
                topic=classification.topic,
                activity=classification.activity,
                energy=1.0,
                source="digestor",
            )
            structured = StructuredFields(
                title=classification.title,
                summary=classification.summary,
                facts=classification.facts,
                concepts=classification.concepts,
                files_touched=classification.files_touched,
            )

            self._store.update(target_id, record, embedding)
            self._index.update(record, structured)
            if self._dedup:
                self._dedup.add(text, target_id)
            if self._graph and structured.concepts:
                self._graph.add_memory(target_id, structured.concepts)

            _bl(f"ingest_episode: UPDATED → {target_id}")
            logger.info("Updated episode %s [%s/%s]", target_id, classification.project, classification.topic)
            return target_id

        # ADD path — no conflict
        ts = time.time()
        memory_id = f"ep_{session_id[:8]}_{int(ts) % 100000}"

        record = MemoryRecord(
            id=memory_id,
            session_id=session_id,
            timestamp=ts,
            turn_number=0,
            question="[episode]",
            answer_preview=text[:500],
            document=text,
            project=classification.project,
            topic=classification.topic,
            activity=classification.activity,
            energy=1.0,
            source="digestor",
        )

        self._store.insert(record, embedding)

        if self._dedup:
            self._dedup.add(text, memory_id)

        structured = None
        if self._index:
            structured = StructuredFields(
                title=classification.title,
                summary=classification.summary,
                facts=classification.facts,
                concepts=classification.concepts,
                files_touched=classification.files_touched,
            )
            self._index.insert(record, structured)

        if self._graph and structured and structured.concepts:
            self._graph.add_memory(memory_id, structured.concepts)

        _bl(f"ingest_episode: DONE → {memory_id}")
        logger.info("Ingested episode %s [%s/%s]", memory_id, classification.project, classification.topic)
        return memory_id

    async def ingest_knowledge(self, topic: str, content: str,
                               entry_type: str = "feature",
                               project: str = "general",
                               session_id: str = "distiller",
                               concepts: list[str] | None = None) -> str | None:
        """Ingest a single distilled knowledge entry (from Gemini).

        No Groq classify call needed — Gemini already provided structured fields.
        Still runs: embed, dedup, conflict resolution, store, index, graph.
        """
        if not content.strip():
            return None

        _bl(f"ingest_knowledge: topic='{topic}', project={project}")

        # Dedup
        if self._dedup and self._dedup.is_duplicate(content):
            _bl("ingest_knowledge: filtered by dedup")
            return None

        # Embed
        embedding = await self._embedder.embed_passage(content)

        # Conflict resolution
        decision = await self._resolve_conflicts(content, embedding)

        # Map entry_type to Activity enum
        type_to_activity = {
            "bugfix": Activity.BUGFIX,
            "feature": Activity.FEATURE,
            "architecture": Activity.RESEARCH,
            "decision": Activity.DISCUSSION,
            "config": Activity.CONFIG,
            "lesson": Activity.LEARNING,
            "preference": Activity.BEHAVIOR,
        }
        activity = type_to_activity.get(entry_type, Activity.DISCUSSION)

        if decision["action"] == "noop":
            _bl("ingest_knowledge: filtered by conflict resolution (redundant)")
            if self._dedup:
                self._dedup.add(content, "noop")
            return None

        if decision["action"] == "update" and decision.get("target_id"):
            target_id = decision["target_id"]
            old_structured = self._index.get_structured(target_id) if self._index else None
            old_title = old_structured.title if old_structured else "?"
            _bl(f"ingest_knowledge: UPDATE '{old_title}' → '{topic}' (target={target_id}, reason={decision.get('reason', '')})")

            ts = time.time()
            record = MemoryRecord(
                id=target_id,
                session_id=session_id,
                timestamp=ts,
                question=topic,
                answer_preview=content[:500],
                document=content,
                project=project,
                topic=topic[:100],
                activity=activity,
                energy=1.0,
                source="distiller",
            )
            real_concepts = concepts if concepts else [project, entry_type]
            structured = StructuredFields(
                title=topic,
                summary=content[:300],
                facts=[content],
                concepts=real_concepts,
            )

            self._store.update(target_id, record, embedding)
            self._index.update(record, structured)
            if self._dedup:
                self._dedup.add(content, target_id)
            if self._graph and structured.concepts:
                self._graph.add_memory(target_id, structured.concepts)

            _bl(f"ingest_knowledge: UPDATED → {target_id}")
            return target_id

        # ADD path
        ts = time.time()
        memory_id = f"kn_{session_id[:8]}_{int(ts) % 100000}"

        record = MemoryRecord(
            id=memory_id,
            session_id=session_id,
            timestamp=ts,
            question=topic,
            answer_preview=content[:500],
            document=content,
            project=project,
            topic=topic[:100],
            activity=activity,
            energy=1.0,
            source="distiller",
        )

        self._store.insert(record, embedding)

        if self._dedup:
            self._dedup.add(content, memory_id)

        if self._index:
            real_concepts = concepts if concepts else [project, entry_type]
            structured = StructuredFields(
                title=topic,
                summary=content[:300],
                facts=[content],
                concepts=real_concepts,
            )
            self._index.insert(record, structured)

            if self._graph and structured.concepts:
                self._graph.add_memory(memory_id, structured.concepts)

        _bl(f"ingest_knowledge: DONE → {memory_id}")
        logger.info("Ingested knowledge %s [%s/%s]", memory_id, project, topic[:50])
        return memory_id

    async def ingest_knowledge_fast(self, topic: str, content: str,
                                     entry_type: str = "feature",
                                     project: str = "general",
                                     session_id: str = "distiller",
                                     concepts: list[str] | None = None) -> str | None:
        """Ingest knowledge entry — always ADD, no conflict resolution.

        Records similar pairs in pending_merges for later consolidation.
        Used by the digestor for phase-1 fast ingest.
        """
        if not content.strip():
            return None

        _bl(f"ingest_knowledge_fast: topic='{topic}', project={project}")

        # Dedup
        if self._dedup and self._dedup.is_duplicate(content):
            _bl("ingest_knowledge_fast: filtered by dedup")
            return None

        # Embed
        embedding = await self._embedder.embed_passage(content)

        # Map entry_type to Activity enum
        type_to_activity = {
            "bugfix": Activity.BUGFIX,
            "feature": Activity.FEATURE,
            "architecture": Activity.RESEARCH,
            "decision": Activity.DISCUSSION,
            "config": Activity.CONFIG,
            "lesson": Activity.LEARNING,
            "preference": Activity.BEHAVIOR,
        }
        activity = type_to_activity.get(entry_type, Activity.DISCUSSION)

        # Always ADD — unique ID via monotonic counter
        ts = time.time()
        seq = _next_id_suffix()
        memory_id = f"kn_{session_id[:8]}_{int(ts) % 100000}_{seq}"

        record = MemoryRecord(
            id=memory_id,
            session_id=session_id,
            timestamp=ts,
            question=topic,
            answer_preview=content[:500],
            document=content,
            project=project,
            topic=topic[:100],
            activity=activity,
            energy=1.0,
            source="distiller",
        )

        self._store.insert(record, embedding)

        if self._dedup:
            self._dedup.add(content, memory_id)

        real_concepts = concepts if concepts else [project, entry_type]
        structured = None
        if self._index:
            structured = StructuredFields(
                title=topic,
                summary=content[:300],
                facts=[content],
                concepts=real_concepts,
            )
            self._index.insert(record, structured)

            if self._graph and structured.concepts:
                self._graph.add_memory(memory_id, structured.concepts)

            # Record similar pairs for consolidation
            if self._store.count() > 1:
                from neo_cortex.config import CONSOLIDATION_SIMILARITY_THRESHOLD
                similar = self._store.query(embedding, n=5)
                for sim_rec, sim_score in similar:
                    if sim_rec.id != memory_id and sim_score > CONSOLIDATION_SIMILARITY_THRESHOLD:
                        self._index.add_pending_merge(memory_id, sim_rec.id, sim_score)
                        _bl(f"ingest_knowledge_fast: pending_merge {memory_id} ↔ {sim_rec.id} sim={sim_score:.3f}")

        _bl(f"ingest_knowledge_fast: DONE → {memory_id}")
        logger.info("Fast-ingested knowledge %s [%s/%s]", memory_id, project, topic[:50])
        return memory_id

    async def recall(self, query: str, n: int = 5, smart: bool = True) -> RecallResult:
        """Recall memories relevant to a query.

        Smart mode: analyze query → embed refined → search with filters → aggregate.
        Basic mode: embed query → vector search → return.
        """
        if smart:
            return await self._smart_recall(query, n)
        return await self._basic_recall(query, n)

    async def _basic_recall(self, query: str, n: int) -> RecallResult:
        try:
            embedding = await self._embedder.embed_query(query)
            results = self._store.query(embedding, n=n)
            memories = [RecalledMemory(record=r, similarity=s) for r, s in results]
            self._reinforce(memories)
            return RecallResult(query=query, count=len(memories), memories=memories)
        except Exception as e:
            logger.warning("Embedding failed, falling back to FTS: %s", e)
            return self._fts_recall(query, n)

    def _fts_recall(self, query: str, n: int) -> RecallResult:
        if not self._index:
            return RecallResult(query=query, count=0, memories=[])
        compact = self._index.search_fts(query, n=n)
        ids = [c.id for c in compact]
        records = self._index.get_by_ids(ids)
        memories = [RecalledMemory(record=r, similarity=0.5) for r in records]
        return RecallResult(query=query, count=len(memories), memories=memories)

    async def _smart_recall(self, query: str, n: int) -> RecallResult:
        MIN_SIMILARITY = 0.2   # discard garbage results below this
        WEAK_THRESHOLD = 0.4   # blend FTS5 when best vector score is below this

        # Analyze query
        analysis = await self._classifier.analyze_query(query)

        try:
            # Embed refined query
            embedding = await self._embedder.embed_query(analysis.refined_query)

            # Build where filters
            where = self._build_where(analysis)

            # Fetch 3x candidates for re-ranking
            fetch_n = min(n * 3, 20)
            results = self._store.query(embedding, n=fetch_n, where=where)

            # If filtered returns too few, retry without filter
            if len(results) < 2 and where:
                results = self._store.query(embedding, n=fetch_n)

            # Filter garbage: drop results below MIN_SIMILARITY
            results = [(r, s) for r, s in results if s >= MIN_SIMILARITY]

            memories = [RecalledMemory(record=r, similarity=s) for r, s in results]

            # FTS5 blend: when vector quality is weak, merge keyword matches
            best_sim = memories[0].similarity if memories else 0.0
            if best_sim < WEAK_THRESHOLD and self._index:
                memories = self._fts_blend(memories, query, n)

            # Concept boost: merge in concept-matched memories from index
            if self._index:
                memories = self._concept_boost(memories, analysis.refined_query, fetch_n)

            # Graph boost: spreading activation finds related memories
            if self._graph:
                memories = self._graph_boost(memories, analysis.refined_query)

        except Exception as e:
            logger.warning("Smart recall embedding failed, falling back to FTS: %s", e)
            fts_result = self._fts_recall(query, n)
            memories = fts_result.memories

        # Reinforce recalled memories
        self._reinforce(memories[:n])

        # Aggregate to MBEL
        mbel = await self._classifier.aggregate_to_mbel(memories[:n])

        return RecallResult(
            query=query,
            count=len(memories[:n]),
            memories=memories[:n],
            mbel=mbel,
        )

    def _fts_blend(
        self, memories: list[RecalledMemory], query: str, n: int
    ) -> list[RecalledMemory]:
        """Blend FTS5 keyword results into weak vector results."""
        FTS_BASE_SIMILARITY = 0.45  # FTS matches get a reasonable score

        fts_hits = self._index.search_fts(query, n=n)
        if not fts_hits:
            return memories

        seen_ids = {m.record.id for m in memories}
        fts_ids = [h.id for h in fts_hits if h.id not in seen_ids]
        if not fts_ids:
            return memories

        records = self._index.get_by_ids(fts_ids)
        for rec in records:
            memories.append(RecalledMemory(record=rec, similarity=FTS_BASE_SIMILARITY))

        memories.sort(key=lambda m: m.similarity, reverse=True)
        return memories

    def _concept_boost(
        self, memories: list[RecalledMemory], query: str, max_n: int
    ) -> list[RecalledMemory]:
        """Boost recall results using concept-based search from SQLite index."""
        CONCEPT_BOOST = 0.15

        # Extract keywords from query as concept candidates
        words = [w.lower().strip() for w in query.split() if len(w) > 2]

        # Find concept-matched memory IDs
        concept_ids: set[str] = set()
        for word in words:
            hits = self._index.search_by_concept(word, n=max_n)
            concept_ids.update(h.id for h in hits)

        if not concept_ids:
            return memories

        # Boost similarity for memories that also match concepts
        boosted = []
        seen_ids: set[str] = set()
        for m in memories:
            new_sim = m.similarity
            if m.record.id in concept_ids:
                new_sim = min(1.0, m.similarity + CONCEPT_BOOST)
            boosted.append(RecalledMemory(record=m.record, similarity=new_sim))
            seen_ids.add(m.record.id)

        # Add concept-only matches not in vector results
        missing_ids = concept_ids - seen_ids
        if missing_ids:
            records = self._index.get_by_ids(list(missing_ids)[:5])
            for rec in records:
                boosted.append(RecalledMemory(record=rec, similarity=CONCEPT_BOOST))

        # Re-sort by similarity descending
        boosted.sort(key=lambda m: m.similarity, reverse=True)
        return boosted

    def _reinforce(self, memories: list[RecalledMemory]) -> None:
        """Boost energy of recalled memories (retrieval reinforcement)."""
        REINFORCEMENT_ETA = 0.1
        for m in memories:
            old_energy = m.record.energy
            new_energy = min(1.0, old_energy + REINFORCEMENT_ETA * (1.0 - old_energy))
            if abs(new_energy - old_energy) > 0.001:
                self._store.update_energy(m.record.id, new_energy)
                if self._index:
                    self._index.update_energy(m.record.id, new_energy)
            # Reinforce stability + track access
            if self._index:
                self._index.update_last_accessed(m.record.id, time.time())

    def _graph_boost(
        self, memories: list[RecalledMemory], query: str
    ) -> list[RecalledMemory]:
        """Boost results using spreading activation from the concept graph."""
        GRAPH_BOOST = 0.1

        words = [w.lower().strip() for w in query.split() if len(w) > 2]
        related_mids = self._graph.get_related_memories(words, n=10)
        if not related_mids:
            return memories

        related_set = set(related_mids)
        for i, m in enumerate(memories):
            if m.record.id in related_set:
                memories[i] = RecalledMemory(
                    record=m.record,
                    similarity=min(1.0, m.similarity + GRAPH_BOOST),
                )

        memories.sort(key=lambda m: m.similarity, reverse=True)
        return memories

    def _build_where(self, analysis: 'QueryAnalysis') -> dict | None:
        from neo_cortex.models import QueryAnalysis as _QA  # noqa: avoid circular at module level

        conditions = []
        if analysis.project:
            conditions.append({"project": {"$eq": analysis.project}})
        if analysis.activity_filter:
            conditions.append({"activity": {"$eq": analysis.activity_filter}})
        if analysis.time_hint == "recent":
            cutoff = time.time() - (7 * 24 * 3600)
            conditions.append({"timestamp": {"$gte": cutoff}})
        elif analysis.time_hint == "old":
            cutoff = time.time() - (7 * 24 * 3600)
            conditions.append({"timestamp": {"$lt": cutoff}})

        if not conditions:
            return None
        if len(conditions) == 1:
            return conditions[0]
        return {"$and": conditions}

    async def dream(self) -> DreamResult:
        """Run one dream cycle: apply Ebbinghaus decay to all memories."""
        from neo_cortex.decay import ebbinghaus_weight

        all_meta = self._store.get_all_metadata()
        if len(all_meta) < 2:
            return DreamResult(status="skipped", dream_count=self._dream_count, cluster=[])

        now = time.time()
        high_energy_ids = []

        for meta in all_meta:
            mid = meta["id"]
            old_energy = meta.get("energy", 1.0)
            timestamp = meta.get("timestamp", now)
            oldness_days = (now - timestamp) / 86400

            # Get stability from index if available, else default
            stability = 1.0
            if self._index:
                dream_data = self._index.get_all_for_dream()
                idx_meta = next((d for d in dream_data if d["id"] == mid), None)
                if idx_meta:
                    stability = idx_meta["stability"]

            new_energy = ebbinghaus_weight(oldness_days, stability)
            if abs(new_energy - old_energy) > 0.001:
                self._store.update_energy(mid, new_energy)
                if self._index:
                    self._index.update_energy(mid, new_energy)

            if new_energy >= 0.7:
                high_energy_ids.append(mid)

        self._dream_count += 1
        return DreamResult(
            status="completed",
            dream_count=self._dream_count,
            cluster=high_energy_ids,
        )

    async def spontaneous(self, n: int = 3) -> list[MemoryRecord]:
        """Surface important memories not accessed recently (spontaneous recall)."""
        if not self._index:
            return []
        dream_data = self._index.get_all_for_dream()
        if not dream_data:
            return []

        now = time.time()
        seven_days = 7 * 24 * 3600
        candidates = []
        for d in dream_data:
            if d["energy"] < 0.5:
                continue
            last = d["last_accessed"] or d["timestamp"]
            if (now - last) < seven_days:
                continue
            # Score: energy * staleness_factor
            staleness = min((now - last) / seven_days, 10.0)
            score = d["energy"] * staleness
            candidates.append((d["id"], score))

        candidates.sort(key=lambda x: x[1], reverse=True)
        ids = [c[0] for c in candidates[:n]]
        if not ids:
            return []
        return self._index.get_by_ids(ids)

    def stats(self) -> 'CortexStats':
        from neo_cortex.models import CortexStats as _CS  # noqa
        return self._store.stats(self._dream_count)

    async def timeline(self, n: int = 10, project: str | None = None, smart: bool = False) -> TimelineResult:
        memories = self._store.timeline(limit=n, project=project)
        mbel = None
        if smart and memories:
            recalled = [RecalledMemory(record=m, similarity=1.0) for m in memories]
            mbel = await self._classifier.aggregate_to_mbel(recalled)
        return TimelineResult(count=len(memories), memories=memories, mbel=mbel)
