"""
Synrix Agent Runtime — Developer API
The developer-facing interface. Clean, fast, intuitive.

Usage:
    from synrix_runtime import AgentRuntime
    agent = AgentRuntime("my_agent", agent_type="researcher")
    agent.remember("user_preference", {"format": "bullet_points"})
    value = agent.recall("user_preference")
"""

import logging
import time
import json
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Any, List, Optional
from dataclasses import dataclass, field

logger = logging.getLogger("synrix.runtime")

# Bounded thread pool for background enrichment (fact extraction + NER).
# Prevents thread explosion: max 4 concurrent LLM calls, extras queue up.
_enrichment_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="enrich")

# Track ALL pending enrichment futures so flush() can wait for completion.
_all_pending_futures: list = []
_pending_lock = threading.Lock()

# Loop detection: tracks recent write embeddings per agent (in-memory, no DB queries)
_repeat_tracker: dict = {}  # agent_id -> [{"embedding": ndarray, "time": float, "key": str}, ...]
_repeat_tracker_lock = threading.Lock()


@dataclass
class MemoryResult:
    node_id: Optional[int]
    key: str
    latency_us: float
    timestamp: float
    success: bool = True
    loop_warning: Optional[dict] = None


@dataclass
class RecallResult:
    value: Any
    key: str
    latency_us: float
    found: bool


@dataclass
class SearchResult:
    items: list
    count: int
    latency_us: float


@dataclass
class SnapshotResult:
    label: str
    keys_captured: int
    latency_us: float
    size_bytes: int = 0


@dataclass
class RestoreResult:
    label: str
    keys_restored: int
    recovery_time_us: float


@dataclass
class HandoffResult:
    task_id: str
    latency_us: float
    success: bool = True


@dataclass
class TaskResult:
    task_id: str
    payload: dict
    latency_us: float
    found: bool = True


@dataclass
class HistoryResult:
    key: str
    versions: list
    current_version: int
    latency_us: float


@dataclass
class GraphResult:
    entity: str
    entity_type: str
    relationships: list
    latency_us: float
    found: bool


@dataclass
class AgentStats:
    agent_id: str
    total_operations: int = 0
    total_writes: int = 0
    total_reads: int = 0
    total_queries: int = 0
    avg_write_latency_us: float = 0.0
    avg_read_latency_us: float = 0.0
    crash_count: int = 0
    memory_node_count: int = 0
    performance_score: float = 100.0
    uptime_seconds: float = 0.0


class AgentRuntime:
    """
    The Synrix Agent Runtime.

    Usage:
        from synrix_runtime import AgentRuntime
        agent = AgentRuntime("my_agent", agent_type="researcher")
        agent.remember("user_preference", {"format": "bullet_points"})
        value = agent.recall("user_preference")
    """

    def __init__(self, agent_id: str, agent_type: str = "generic", metadata: dict = None):
        self.agent_id = agent_id
        self.agent_type = agent_type
        self.metadata = metadata or {}
        self._write_count = 0
        self._read_count = 0
        self._query_count = 0
        self._started_at = time.time()

        # Connect to Synrix — share daemon's backend for shared memory
        start = time.perf_counter_ns()
        try:
            from synrix_runtime.core.daemon import RuntimeDaemon
            self._daemon = RuntimeDaemon.get_instance()
            if not self._daemon.running:
                self._daemon.start()
            # Reuse the daemon's backend so all agents share the same store
            self.backend = self._daemon.backend
            self._daemon.register_agent(agent_id, agent_type, self.metadata)
        except Exception:
            from synrix.agent_backend import get_synrix_backend
            from synrix_runtime.config import SynrixConfig
            config = SynrixConfig.from_env()
            self.backend = get_synrix_backend(**config.get_backend_kwargs())
            self._daemon = None
        connect_us = (time.perf_counter_ns() - start) / 1000

        # Initialize metrics collector
        try:
            from synrix_runtime.monitoring.metrics import MetricsCollector
            self._metrics = MetricsCollector(self.backend)
        except Exception:
            self._metrics = None

        # Start heartbeat thread
        self._heartbeat_running = True
        self._heartbeat_thread = threading.Thread(
            target=self._heartbeat_loop, name=f"heartbeat-{agent_id}", daemon=True
        )
        self._heartbeat_thread.start()

        print(f"[{agent_id}] Runtime connected in {connect_us:.1f}us | type={agent_type}")

    def remember(self, key: str, value: Any, tags: list = None) -> MemoryResult:
        """Store a memory in Synrix.

        Fast path: writes to DB immediately (no embedding), returns in <10ms.
        Slow path (background): computes embedding, updates node, extracts facts.
        Semantic search finds the memory once background processing completes (~2-5s).
        """
        full_key = f"agents:{self.agent_id}:{key}"
        payload = value if isinstance(value, dict) else {"value": value}
        if tags:
            payload["_tags"] = tags

        # Extract text for background processing (cheap, no model needed)
        text_for_nlp = self._value_to_text(value)

        # Compute embedding NOW so memory is immediately searchable
        embedding = None
        if text_for_nlp and len(text_for_nlp.strip()) > 0:
            try:
                from synrix.embeddings import EmbeddingModel
                emb_model = EmbeddingModel.get()
                if emb_model:
                    embedding = emb_model.encode(text_for_nlp)
            except Exception as e:
                logger.error("Inline embedding error: %s", e)

        # Loop detection: check if this is a repeated write
        loop_warning = None
        if embedding is not None:
            try:
                loop_warning = self._check_write_loop(embedding, key)
            except Exception:
                pass

        # Write to DB with embedding (searchable immediately)
        start = time.perf_counter_ns()
        node_id = self.backend.write(
            full_key, payload,
            metadata={"type": "agent_memory", "agent_id": self.agent_id},
            embedding=embedding,
        )
        latency_us = (time.perf_counter_ns() - start) / 1000

        # SLOW PATH: embedding + fact extraction + NER all run in background
        if node_id is not None and text_for_nlp and len(text_for_nlp.strip()) > 0:
            def _enrich_background(backend, agent_id, nid, nkey, text, llm_config=None):
                # All writes in this function use _background=True to avoid
                # acquiring the Python-level _write_lock. SQLite WAL mode +
                # busy_timeout handles write serialization natively. This
                # prevents background enrichment from blocking foreground writes.

                # Step 1: Embedding already computed and stored during write.
                # Nothing to do here — memory is already searchable.

                # Step 2: LLM fact extraction → embed each fact for better search
                try:
                    from synrix.fact_extractor import FactExtractor
                    from synrix.embeddings import EmbeddingModel
                    logger.info("Fact extraction starting for node %s (config=%s)", nid, llm_config)
                    fact_extractor = FactExtractor.get(config=llm_config)
                    emb_model = EmbeddingModel.get()
                    if fact_extractor is None:
                        logger.warning("FactExtractor.get() returned None — no LLM available")
                    if emb_model is None:
                        logger.warning("EmbeddingModel.get() returned None")
                    if fact_extractor and emb_model:
                        fact_result = fact_extractor.extract_facts(text)
                        logger.info("Fact extraction result: %d facts, used_llm=%s, provider=%s, time=%.0fms",
                                   len(fact_result.facts), fact_result.used_llm, fact_result.provider, fact_result.extraction_time_ms)
                        if fact_result.facts:
                            fact_rows = []
                            for fact_text in fact_result.facts:
                                fact_emb = emb_model.encode(fact_text)
                                fact_rows.append({"text": fact_text, "embedding": fact_emb})
                            backend.store_fact_embeddings(
                                node_id=nid, node_name=nkey, facts=fact_rows,
                                _background=True,
                            )
                            logger.info("Stored %d facts for node %s (used_llm=%s)", len(fact_rows), nid, fact_result.used_llm)
                        else:
                            logger.warning("No facts extracted — provider=%s", fact_result.provider)
                except Exception as e:
                    logger.error("Fact extraction error: %s", e, exc_info=True)

                # Step 3: spaCy entity extraction → knowledge graph
                try:
                    from synrix.extractor import EntityExtractor
                    extractor = EntityExtractor.get()
                    if extractor:
                        result = extractor.extract(text)
                        self._store_extraction(result, nid, _background=True)
                except Exception:
                    pass

            # Load per-tenant LLM config if available
            _llm_config = getattr(self, '_llm_config', None)
            # Submit to bounded pool (max 4 concurrent) — extras queue, never explode
            future = _enrichment_pool.submit(
                _enrich_background,
                self.backend, self.agent_id, node_id, full_key, text_for_nlp, _llm_config,
            )
            # Track future globally so flush() can wait for all pending enrichment
            with _pending_lock:
                _all_pending_futures.append(future)
                # Prune completed futures to prevent unbounded memory growth
                if len(_all_pending_futures) > 100:
                    _all_pending_futures[:] = [f for f in _all_pending_futures if not f.done()]

        self._write_count += 1
        if self._metrics:
            self._metrics.record_write(self.agent_id, key, latency_us, node_id is not None, node_id)

        return MemoryResult(
            node_id=node_id,
            key=key,
            latency_us=latency_us,
            timestamp=time.time(),
            success=node_id is not None,
            loop_warning=loop_warning,
        )

    def flush(self, timeout: float = 120.0) -> dict:
        """Wait for ALL pending background enrichment to complete.

        Drains the global enrichment queue — all agents, not just this one.
        Call after a batch of writes to ensure embeddings/facts are ready for search.
        Returns stats about completed/failed/timed-out futures.
        """
        from concurrent.futures import wait
        with _pending_lock:
            # Grab all pending futures and clear the list
            futures = [f for f in _all_pending_futures if not f.done()]
            _all_pending_futures.clear()

        if not futures:
            return {"pending": 0, "completed": 0, "failed": 0, "timed_out": 0}

        done, not_done = wait(futures, timeout=timeout)
        completed = sum(1 for f in done if not f.exception())
        failed = sum(1 for f in done if f.exception())
        timed_out = len(not_done)

        for f in done:
            exc = f.exception()
            if exc:
                logger.error("Enrichment failed: %s", exc)

        return {
            "pending": len(futures),
            "completed": completed,
            "failed": failed,
            "timed_out": timed_out,
        }

    def _value_to_text(self, value: Any) -> Optional[str]:
        """Extract searchable text from a memory value."""
        if isinstance(value, str):
            return value
        if isinstance(value, dict):
            parts = []
            for k in ("value", "text", "content", "description", "message"):
                v = value.get(k)
                if isinstance(v, str):
                    parts.append(v)
            return " ".join(parts) if parts else json.dumps(value, default=str)
        return str(value) if value is not None else None

    def _store_extraction(self, extraction, source_node_id: int, _background: bool = False):
        """Store extracted entities and relationships in the knowledge graph.

        Args:
            _background: If True, skip Python write lock on SQLite operations.
                         Used by background enrichment to avoid blocking foreground writes.
        """
        entity_ids = {}
        for ent_name, ent_type in extraction.entities:
            eid = self.backend.add_entity(
                name=ent_name, entity_type=ent_type,
                source_node_id=source_node_id,
                _background=_background,
            )
            if eid is not None:
                entity_ids[ent_name] = eid

        for subj, rel, obj in extraction.relationships:
            src_id = entity_ids.get(subj)
            tgt_id = entity_ids.get(obj)
            if src_id is None:
                src_id = self.backend.add_entity(
                    name=subj, entity_type="UNKNOWN",
                    source_node_id=source_node_id,
                    _background=_background,
                )
                if src_id:
                    entity_ids[subj] = src_id
            if tgt_id is None:
                tgt_id = self.backend.add_entity(
                    name=obj, entity_type="UNKNOWN",
                    source_node_id=source_node_id,
                    _background=_background,
                )
                if tgt_id:
                    entity_ids[obj] = tgt_id
            if src_id and tgt_id:
                self.backend.add_relationship(
                    source_entity_id=src_id, target_entity_id=tgt_id,
                    relation=rel, source_node_id=source_node_id,
                    _background=_background,
                )

    def recall(self, key: str) -> RecallResult:
        """Recall a memory from Synrix."""
        full_key = f"agents:{self.agent_id}:{key}"

        start = time.perf_counter_ns()
        result = self.backend.read(full_key)
        latency_us = (time.perf_counter_ns() - start) / 1000

        found = result is not None
        value = None
        if result:
            data = result.get("data", {})
            value = data.get("value", data)

        self._read_count += 1
        if self._metrics:
            self._metrics.record_read(self.agent_id, key, latency_us, found)

        return RecallResult(value=value, key=key, latency_us=latency_us, found=found)

    def search(self, prefix: str, limit: int = 50) -> SearchResult:
        """Search agent memory by prefix."""
        full_prefix = f"agents:{self.agent_id}:{prefix}" if prefix else f"agents:{self.agent_id}:"

        start = time.perf_counter_ns()
        results = self.backend.query_prefix(full_prefix, limit=limit)
        latency_us = (time.perf_counter_ns() - start) / 1000

        items = []
        for r in results:
            key = r.get("key", "")
            # Strip the agent prefix for cleaner results
            short_key = key.replace(f"agents:{self.agent_id}:", "", 1)
            data = r.get("data", {})
            value = data.get("value", data)
            items.append({"key": short_key, "value": value, "node_id": r.get("id")})

        self._query_count += 1
        if self._metrics:
            self._metrics.record_query(self.agent_id, prefix, latency_us, len(items))

        return SearchResult(items=items, count=len(items), latency_us=latency_us)

    def recall_similar(self, query: str, limit: int = 10) -> SearchResult:
        """Search agent memories by semantic similarity.

        Requires sentence-transformers to be installed.
        Returns empty results if embeddings are not available.
        """
        start = time.perf_counter_ns()
        # Search scoped to this agent's data only via SQL prefix filter
        agent_prefix = f"agents:{self.agent_id}:"
        results = self.backend.semantic_search(
            query, limit=limit, name_prefix=agent_prefix
        )
        latency_us = (time.perf_counter_ns() - start) / 1000

        items = []
        for r in results:
            key = r.get("key", "")
            short_key = key.replace(agent_prefix, "", 1)
            data = r.get("data", {})
            value = data.get("value", data)
            entry = {
                "key": short_key,
                "value": value,
                "score": r.get("score", 0.0),
                "node_id": r.get("id"),
            }
            if "matched_fact" in r:
                entry["matched_fact"] = r["matched_fact"]
            items.append(entry)

        self._query_count += 1
        return SearchResult(items=items, count=len(items), latency_us=latency_us)

    def recall_history(self, key: str) -> HistoryResult:
        """Get the full temporal history of a memory key.

        Returns all versions including when they were valid.
        """
        full_key = f"agents:{self.agent_id}:{key}"

        start = time.perf_counter_ns()
        results = self.backend.get_history(full_key)
        latency_us = (time.perf_counter_ns() - start) / 1000

        versions = []
        current_version = 0
        for r in results:
            data = r.get("data", {})
            value = data.get("value", data)
            v = r.get("version", 1)
            if v > current_version:
                current_version = v
            versions.append({
                "value": value,
                "version": v,
                "valid_from": r.get("valid_from"),
                "valid_until": r.get("valid_until"),
            })

        self._read_count += 1
        return HistoryResult(
            key=key, versions=versions,
            current_version=current_version, latency_us=latency_us,
        )

    def related(self, entity_name: str) -> GraphResult:
        """Query the knowledge graph for an entity and its relationships."""
        start = time.perf_counter_ns()
        result = self.backend.query_entity(entity_name)
        latency_us = (time.perf_counter_ns() - start) / 1000

        if result is None:
            return GraphResult(
                entity=entity_name, entity_type="",
                relationships=[], latency_us=latency_us, found=False,
            )

        return GraphResult(
            entity=result["name"],
            entity_type=result["entity_type"],
            relationships=result.get("relationships", []),
            latency_us=latency_us,
            found=True,
        )

    # -----------------------------------------------------------------
    # TTL / Auto-Expire
    # -----------------------------------------------------------------

    def remember_with_ttl(self, key: str, value: Any, ttl_seconds: int,
                          tags: list = None) -> MemoryResult:
        """Store a memory that auto-expires after ttl_seconds.

        The memory is written normally but tagged with an expiry timestamp.
        Expired memories are filtered out on read and cleaned up periodically.
        """
        if tags is None:
            tags = []
        tags.append("__ttl")

        # Wrap value with expiry metadata
        expires_at = time.time() + ttl_seconds
        if isinstance(value, dict):
            value = {**value, "__expires_at": expires_at, "__ttl_seconds": ttl_seconds}
        else:
            value = {"value": value, "__expires_at": expires_at, "__ttl_seconds": ttl_seconds}

        return self.remember(key, value, tags=tags)

    def cleanup_expired(self) -> dict:
        """Remove all expired TTL memories for this agent. Returns count deleted."""
        prefix = f"agents:{self.agent_id}:"
        all_items = self.backend.query_prefix(prefix, limit=10000)
        now = time.time()
        deleted = 0
        for item in all_items:
            data = item.get("data", {})
            val = data.get("value", data)
            if isinstance(val, dict) and "__expires_at" in val:
                if val["__expires_at"] < now:
                    key = item.get("key", "")
                    try:
                        self.backend.delete_node(key)
                        deleted += 1
                    except Exception:
                        pass
        return {"deleted": deleted, "agent_id": self.agent_id}

    # -----------------------------------------------------------------
    # Memory Importance Scoring
    # -----------------------------------------------------------------

    def remember_important(self, key: str, value: Any, importance: str = "normal",
                           tags: list = None) -> MemoryResult:
        """Store a memory with importance level: critical, normal, or low.

        Critical memories are boosted in search results.
        """
        if importance not in ("critical", "normal", "low"):
            importance = "normal"
        if tags is None:
            tags = []
        tags.append(f"__importance:{importance}")

        if isinstance(value, dict):
            value = {**value, "__importance": importance}
        else:
            value = {"value": value, "__importance": importance}

        return self.remember(key, value, tags=tags)

    # -----------------------------------------------------------------
    # Conflict Detection
    # -----------------------------------------------------------------

    def detect_conflicts(self, key: str, new_value: Any, threshold: float = 0.7) -> dict:
        """Check if a new memory contradicts existing memories.

        Uses semantic similarity to find potentially conflicting information.
        Returns conflicts found with similarity scores.

        Args:
            key: The key being written
            new_value: The new value to check
            threshold: Similarity threshold (0-1) to flag as potential conflict

        Returns:
            dict with 'conflicts' list and 'has_conflicts' bool
        """
        text = self._value_to_text(new_value)
        if not text:
            return {"has_conflicts": False, "conflicts": []}

        # Search for semantically similar existing memories (scoped to this agent)
        agent_prefix = f"agents:{self.agent_id}:"
        try:
            results = self.backend.semantic_search(
                text, limit=20, name_prefix=agent_prefix
            )
        except Exception:
            return {"has_conflicts": False, "conflicts": []}

        conflicts = []
        full_key = f"agents:{self.agent_id}:{key}"
        same_key_exists = False
        for r in results:
            existing_key = r.get("key", "")
            existing_data = r.get("data", {})
            existing_val = existing_data.get("value", existing_data)
            # Check if the same key already has a value (overwrite detection)
            if existing_key == full_key:
                same_key_exists = True
                conflicts.append({
                    "existing_key": key,
                    "existing_value": existing_val,
                    "similarity_score": 1.0,
                    "conflict_type": "key_overwrite",
                    "message": f"Key '{key}' already exists and will be overwritten",
                    "matched_fact": r.get("matched_fact"),
                })
                continue
            score = r.get("score", 0)
            if score >= threshold:
                conflicts.append({
                    "existing_key": existing_key.replace(f"agents:{self.agent_id}:", "", 1),
                    "existing_value": existing_val,
                    "similarity_score": score,
                    "conflict_type": "semantic_similarity",
                    "matched_fact": r.get("matched_fact"),
                })

        # Also check key existence even if not in semantic results
        if not same_key_exists:
            try:
                existing = self.backend.read(full_key)
                if existing:
                    existing_data = existing.get("data", {})
                    existing_val = existing_data.get("value", existing_data)
                    conflicts.insert(0, {
                        "existing_key": key,
                        "existing_value": existing_val,
                        "similarity_score": 1.0,
                        "conflict_type": "key_overwrite",
                        "message": f"Key '{key}' already exists and will be overwritten",
                    })
            except Exception:
                pass

        return {
            "has_conflicts": len(conflicts) > 0,
            "conflicts": conflicts,
            "new_key": key,
            "checked_against": len(results),
        }

    def remember_safe(self, key: str, value: Any, tags: list = None,
                      conflict_threshold: float = 0.85) -> dict:
        """Write a memory but warn if it conflicts with existing memories.

        Returns the write result plus any detected conflicts.
        """
        conflict_check = self.detect_conflicts(key, value, threshold=conflict_threshold)
        result = self.remember(key, value, tags=tags)
        return {
            "write": {
                "node_id": result.node_id,
                "key": result.key,
                "latency_us": result.latency_us,
                "success": result.success,
            },
            "conflicts": conflict_check,
        }

    # -----------------------------------------------------------------
    # Automatic Loop Detection
    # -----------------------------------------------------------------

    def _check_write_loop(self, embedding, key: str) -> Optional[dict]:
        """Check if this write is part of a repetitive loop.

        Compares the new embedding against recent writes (in-memory, no DB query).
        Returns a warning dict if 3+ similar writes detected in 5 minutes, else None.
        """
        try:
            import numpy as np
        except ImportError:
            return None

        now = time.time()
        cutoff = now - 300  # 5-minute window
        threshold = 0.92

        with _repeat_tracker_lock:
            entries = _repeat_tracker.get(self.agent_id, [])
            # Prune old entries
            entries = [e for e in entries if e["time"] >= cutoff]

            # Compare new embedding against recent ones
            similar_count = 0
            emb_array = np.frombuffer(embedding, dtype=np.float32) if isinstance(embedding, bytes) else np.array(embedding, dtype=np.float32)
            norm = np.linalg.norm(emb_array)
            if norm > 0:
                emb_array = emb_array / norm

            for entry in entries:
                prev = entry["embedding"]
                score = float(np.dot(emb_array, prev))
                if score >= threshold:
                    similar_count += 1

            # Add current embedding to tracker
            entries.append({"embedding": emb_array, "time": now, "key": key})
            # Keep max 20 entries per agent
            if len(entries) > 20:
                entries = entries[-20:]
            _repeat_tracker[self.agent_id] = entries

        if similar_count >= 2:  # Current + 2 previous = 3 similar writes
            # Write anomaly alert
            try:
                ts = int(now * 1000000)
                self.backend.write(
                    f"alerts:{self.agent_id}:{ts}",
                    {
                        "agent_id": self.agent_id,
                        "type": "repeat_loop",
                        "severity": "warning",
                        "detail": f"Agent stored similar content {similar_count + 1} times in 5 minutes (key: {key})",
                        "current_value": similar_count + 1,
                        "threshold": 3,
                        "timestamp": now,
                    },
                    metadata={"type": "anomaly_alert"},
                )
            except Exception:
                pass
            return {
                "type": "repeat_loop",
                "message": f"Repeated write detected: {similar_count + 1} similar stores in 5 minutes",
                "repeat_count": similar_count + 1,
            }
        return None

    def _check_decision_loop(self, decision: str, reasoning: str) -> Optional[dict]:
        """Check if this decision repeats a recent one.

        Queries last 5 decisions and compares semantically.
        Returns a warning dict if a similar decision was made in 10 minutes, else None.
        """
        now = time.time()
        cutoff = now - 600  # 10-minute window

        try:
            from synrix.embeddings import EmbeddingModel
            import numpy as np

            emb_model = EmbeddingModel.get()
            if not emb_model:
                return None

            # Embed the new decision
            decision_text = f"{decision} {reasoning}"
            new_emb = emb_model.encode(decision_text)
            if isinstance(new_emb, bytes):
                new_vec = np.frombuffer(new_emb, dtype=np.float32)
            else:
                new_vec = np.array(new_emb, dtype=np.float32)
            norm = np.linalg.norm(new_vec)
            if norm > 0:
                new_vec = new_vec / norm

            # Get recent decisions from audit trail
            recent = self.backend.query_prefix(f"audit:{self.agent_id}:", limit=5)
            for item in recent:
                data = item.get("data", {})
                val = data.get("value", data)
                if not isinstance(val, dict):
                    try:
                        val = json.loads(val) if isinstance(val, str) else {}
                    except Exception:
                        continue

                ts = val.get("timestamp", 0)
                if ts < cutoff:
                    continue

                prev_decision = val.get("decision", "")
                prev_reasoning = val.get("reasoning", "")
                if not prev_decision:
                    continue

                prev_text = f"{prev_decision} {prev_reasoning}"
                prev_emb = emb_model.encode(prev_text)
                if isinstance(prev_emb, bytes):
                    prev_vec = np.frombuffer(prev_emb, dtype=np.float32)
                else:
                    prev_vec = np.array(prev_emb, dtype=np.float32)
                prev_norm = np.linalg.norm(prev_vec)
                if prev_norm > 0:
                    prev_vec = prev_vec / prev_norm

                score = float(np.dot(new_vec, prev_vec))
                if score >= 0.90:
                    # Write anomaly alert
                    try:
                        alert_ts = int(now * 1000000)
                        self.backend.write(
                            f"alerts:{self.agent_id}:{alert_ts}",
                            {
                                "agent_id": self.agent_id,
                                "type": "decision_loop",
                                "severity": "warning",
                                "detail": f"Agent repeated decision: '{decision[:80]}' (similarity: {score:.2f})",
                                "current_value": score,
                                "threshold": 0.90,
                                "timestamp": now,
                            },
                            metadata={"type": "anomaly_alert"},
                        )
                    except Exception:
                        pass
                    return {
                        "type": "decision_loop",
                        "message": f"Repeated decision detected (similarity: {score:.2f})",
                        "similarity": score,
                        "previous_decision": prev_decision[:100],
                    }
        except Exception as e:
            logger.debug("Decision loop check error: %s", e)
        return None

    # -----------------------------------------------------------------
    # Usage Analytics
    # -----------------------------------------------------------------

    def usage_analytics(self) -> dict:
        """Get detailed usage analytics for this agent.

        Returns memory count, most accessed keys, storage breakdown, etc.
        """
        prefix = f"agents:{self.agent_id}:"
        all_items = self.backend.query_prefix(prefix, limit=10000)

        total_memories = 0
        total_size_bytes = 0
        tags_count = {}
        importance_breakdown = {"critical": 0, "normal": 0, "low": 0}
        ttl_count = 0
        expired_count = 0
        now = time.time()

        for item in all_items:
            key = item.get("key", "")
            if ":snapshots:" in key:
                continue
            total_memories += 1
            data = item.get("data", {})
            total_size_bytes += len(json.dumps(data, default=str).encode())

            val = data.get("value", data)
            if isinstance(val, dict):
                # Check importance
                imp = val.get("__importance", "normal")
                if imp in importance_breakdown:
                    importance_breakdown[imp] += 1
                else:
                    importance_breakdown["normal"] += 1

                # Check TTL
                if "__expires_at" in val:
                    ttl_count += 1
                    if val["__expires_at"] < now:
                        expired_count += 1

                # Count tags
                tags = val.get("_tags", [])
                for tag in tags:
                    if not tag.startswith("__"):
                        tags_count[tag] = tags_count.get(tag, 0) + 1

        return {
            "agent_id": self.agent_id,
            "total_memories": total_memories,
            "total_size_bytes": total_size_bytes,
            "total_size_human": f"{total_size_bytes / 1024:.1f} KB" if total_size_bytes < 1048576 else f"{total_size_bytes / 1048576:.1f} MB",
            "importance": importance_breakdown,
            "ttl_memories": ttl_count,
            "expired_memories": expired_count,
            "top_tags": dict(sorted(tags_count.items(), key=lambda x: -x[1])[:10]),
            "writes": self._write_count,
            "reads": self._read_count,
            "queries": self._query_count,
            "uptime_seconds": time.time() - self._started_at,
        }

    def snapshot(self, label: str = None) -> SnapshotResult:
        """Take a snapshot of all current memory."""
        if label is None:
            label = f"snap_{int(time.time()*1000000)}"

        start = time.perf_counter_ns()

        # Get all current memory keys
        all_keys = self.backend.query_prefix(f"agents:{self.agent_id}:", limit=500)
        snapshot_data = {}
        for item in all_keys:
            key = item.get("key", "")
            if ":snapshots:" not in key:
                data = item.get("data", {})
                snapshot_data[key] = data.get("value", data)

        # Write snapshot — use _background=True to avoid blocking on _write_lock
        # during heavy enrichment periods
        snapshot_payload = {
            "label": label,
            "agent_id": self.agent_id,
            "keys": snapshot_data,
            "key_count": len(snapshot_data),
            "created_at": time.time(),
        }
        size_bytes = len(json.dumps(snapshot_payload).encode())
        try:
            raw_client = self.backend.client
            raw_client.add_node(
                name=f"agents:{self.agent_id}:snapshots:{label}",
                data=json.dumps(snapshot_payload),
                collection=self.backend.collection,
                node_type="snapshot",
                metadata={"type": "snapshot", "agent_id": self.agent_id},
                _background=True,
            )
        except Exception as e:
            logger.warning("Snapshot write with _background failed (%s), retrying normal", e)
            self.backend.write(
                f"agents:{self.agent_id}:snapshots:{label}",
                snapshot_payload,
                metadata={"type": "snapshot", "agent_id": self.agent_id}
            )
        latency_us = (time.perf_counter_ns() - start) / 1000

        if self._metrics:
            self._metrics.record_snapshot(self.agent_id, label, len(snapshot_data), latency_us)

        return SnapshotResult(
            label=label,
            keys_captured=len(snapshot_data),
            latency_us=latency_us,
            size_bytes=size_bytes,
        )

    def restore(self, label: str = None) -> RestoreResult:
        """Restore from a named snapshot or the latest one."""
        start = time.perf_counter_ns()

        if label:
            result = self.backend.read(f"agents:{self.agent_id}:snapshots:{label}")
        else:
            # Find latest snapshot
            snapshots = self.backend.query_prefix(f"agents:{self.agent_id}:snapshots:", limit=50)
            if not snapshots:
                return RestoreResult(label="none", keys_restored=0, recovery_time_us=0)
            snapshots.sort(key=lambda x: x.get("data", {}).get("value", {}).get("created_at", 0)
                          if isinstance(x.get("data", {}).get("value"), dict)
                          else x.get("data", {}).get("timestamp", 0), reverse=True)
            result = snapshots[0]
            data = result.get("data", {})
            val = data.get("value", data)
            label = val.get("label", "unknown") if isinstance(val, dict) else "unknown"

        if result is None:
            return RestoreResult(label=label or "none", keys_restored=0, recovery_time_us=0)

        # Extract snapshot data
        data = result.get("data", {})
        val = data.get("value", data)
        keys_data = val.get("keys", {}) if isinstance(val, dict) else {}

        # Restore each key — use raw client with _background=True to avoid
        # blocking on _write_lock during heavy enrichment periods
        restored = 0
        raw_client = self.backend.client
        for key, value in keys_data.items():
            try:
                raw_client.add_node(
                    name=key,
                    data=json.dumps(value) if isinstance(value, dict) else json.dumps({"value": value}),
                    collection=self.backend.collection,
                    node_type="memory",
                    metadata={"type": "restored", "agent_id": self.agent_id},
                    _background=True,
                )
            except Exception:
                # Fallback to normal write if _background fails
                self.backend.write(key, value, metadata={"type": "restored", "agent_id": self.agent_id})
            restored += 1

        recovery_us = (time.perf_counter_ns() - start) / 1000

        if self._metrics:
            self._metrics.record_recovery(self.agent_id, recovery_us, restored)

        return RestoreResult(label=label, keys_restored=restored, recovery_time_us=recovery_us)

    def share(self, key: str, value: Any, space: str = "global") -> MemoryResult:
        """Write to shared memory space."""
        full_key = f"shared:{space}:{key}"
        payload = value if isinstance(value, dict) else {"value": value}
        payload["_author"] = self.agent_id
        payload["_shared_at"] = time.time()

        start = time.perf_counter_ns()
        node_id = self.backend.write(full_key, payload, metadata={"type": "shared_memory", "space": space, "author": self.agent_id})
        latency_us = (time.perf_counter_ns() - start) / 1000

        # Write changelog
        ts = int(time.time() * 1000000)
        self.backend.write(
            f"shared:{space}:changelog:{ts}",
            {"key": key, "author": self.agent_id, "action": "write", "timestamp": time.time()},
            metadata={"type": "shared_changelog"}
        )

        self._write_count += 1
        if self._metrics:
            self._metrics.record_write(self.agent_id, f"shared:{space}:{key}", latency_us, True, node_id)

        return MemoryResult(node_id=node_id, key=key, latency_us=latency_us, timestamp=time.time())

    def read_shared(self, key: str, space: str = "global") -> RecallResult:
        """Read from shared memory space."""
        full_key = f"shared:{space}:{key}"

        start = time.perf_counter_ns()
        result = self.backend.read(full_key)
        latency_us = (time.perf_counter_ns() - start) / 1000

        found = result is not None
        value = None
        if result:
            data = result.get("data", {})
            value = data.get("value", data)

        self._read_count += 1
        if self._metrics:
            self._metrics.record_read(self.agent_id, f"shared:{space}:{key}", latency_us, found)

        return RecallResult(value=value, key=key, latency_us=latency_us, found=found)

    def subscribe_shared(self, space: str, callback: callable):
        """Poll a shared space for new keys and call callback on changes."""
        seen_keys = set()

        def _poll():
            while self._heartbeat_running:
                try:
                    results = self.backend.query_prefix(f"shared:{space}:", limit=200)
                    for r in results:
                        key = r.get("key", "")
                        if key not in seen_keys:
                            seen_keys.add(key)
                            data = r.get("data", {})
                            value = data.get("value", data)
                            try:
                                callback(key, value)
                            except Exception:
                                pass
                except Exception:
                    pass
                time.sleep(1)

        t = threading.Thread(target=_poll, name=f"sub-{space}-{self.agent_id}", daemon=True)
        t.start()

    def handoff(self, task_id: str, to_agent: str, payload: dict) -> HandoffResult:
        """Hand off a task to another agent."""
        handoff_data = {
            "task_id": task_id,
            "from_agent": self.agent_id,
            "to_agent": to_agent,
            "payload": payload,
            "status": "pending",
            "created_at": time.time(),
        }

        start = time.perf_counter_ns()
        self.backend.write(
            f"tasks:handoff:{task_id}",
            handoff_data,
            metadata={"type": "task_handoff", "from": self.agent_id, "to": to_agent}
        )
        latency_us = (time.perf_counter_ns() - start) / 1000

        # Audit event
        try:
            from synrix_runtime.monitoring.audit import AuditSystem
            audit = AuditSystem(self.backend)
            audit.log_handoff(self.agent_id, to_agent, task_id, payload, {})
        except Exception:
            pass

        if self._metrics:
            self._metrics.record_handoff(self.agent_id, to_agent, task_id, latency_us)

        return HandoffResult(task_id=task_id, latency_us=latency_us)

    def claim_task(self, task_id: str) -> TaskResult:
        """Claim a pending task."""
        start = time.perf_counter_ns()
        result = self.backend.read(f"tasks:handoff:{task_id}")
        latency_us = (time.perf_counter_ns() - start) / 1000

        if result is None:
            return TaskResult(task_id=task_id, payload={}, latency_us=latency_us, found=False)

        data = result.get("data", {})
        val = data.get("value", data)

        # Update status
        if isinstance(val, dict):
            val["status"] = "claimed"
            val["claimed_by"] = self.agent_id
            val["claimed_at"] = time.time()
        self.backend.write(f"tasks:handoff:{task_id}", val, metadata={"type": "task_claimed"})

        return TaskResult(task_id=task_id, payload=val, latency_us=latency_us)

    def complete_task(self, task_id: str, result: dict) -> TaskResult:
        """Mark a task as complete with result."""
        completion = {
            "task_id": task_id,
            "completed_by": self.agent_id,
            "result": result,
            "completed_at": time.time(),
        }

        start = time.perf_counter_ns()
        self.backend.write(f"tasks:complete:{task_id}", completion, metadata={"type": "task_complete"})
        latency_us = (time.perf_counter_ns() - start) / 1000

        # Audit
        try:
            from synrix_runtime.monitoring.audit import AuditSystem
            audit = AuditSystem(self.backend)
            audit.log_handoff(self.agent_id, "system", task_id, completion, {})
        except Exception:
            pass

        return TaskResult(task_id=task_id, payload=completion, latency_us=latency_us)

    def log_decision(self, decision: str, reasoning: str, context: dict = None):
        """Log a decision with full audit trail."""
        ts = int(time.time() * 1000000)

        # Loop detection: check if this decision repeats a recent one
        decision_loop = None
        try:
            decision_loop = self._check_decision_loop(decision, reasoning)
        except Exception:
            pass

        # Capture memory snapshot at decision time (with timeout protection)
        memory_snapshot = {}
        try:
            import concurrent.futures
            def _capture_snapshot():
                snap = {}
                all_keys = self.backend.query_prefix(f"agents:{self.agent_id}:", limit=50)
                for item in all_keys:
                    key = item.get("key", "")
                    if ":snapshots:" not in key:
                        data = item.get("data", {})
                        snap[key] = data.get("value", data)
                return snap
            with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
                future = pool.submit(_capture_snapshot)
                memory_snapshot = future.result(timeout=10.0)
        except Exception:
            pass  # Log decision even without snapshot

        decision_data = {
            "agent_id": self.agent_id,
            "decision": decision,
            "reasoning": reasoning,
            "context": context or {},
            "memory_snapshot": memory_snapshot,
            "timestamp": time.time(),
            "loop_warning": decision_loop,
        }

        # Write audit record in background — never block the response.
        # Submit to enrichment pool so it doesn't compete with API executor.
        def _write_audit():
            try:
                import json as _json
                raw_client = self.backend.client if hasattr(self.backend, 'client') else self.backend
                collection = self.backend.collection if hasattr(self.backend, 'collection') else 'agent_memory'
                raw_client.add_node(
                    name=f"audit:{self.agent_id}:{ts}:decision",
                    data=_json.dumps(decision_data, default=str),
                    collection=collection,
                )
            except Exception as e:
                logger.error("Decision audit write failed: %s", e)

        _enrichment_pool.submit(_write_audit)

    def get_stats(self) -> AgentStats:
        """Get complete performance statistics."""
        if self._metrics:
            m = self._metrics.get_agent_metrics(self.agent_id)
            return AgentStats(
                agent_id=self.agent_id,
                total_operations=m.total_operations,
                total_writes=m.total_writes,
                total_reads=m.total_reads,
                total_queries=m.total_queries,
                avg_write_latency_us=m.avg_write_latency_us,
                avg_read_latency_us=m.avg_read_latency_us,
                crash_count=m.crash_count,
                memory_node_count=m.memory_node_count,
                performance_score=m.performance_score,
                uptime_seconds=time.time() - self._started_at,
            )
        return AgentStats(
            agent_id=self.agent_id,
            total_operations=self._write_count + self._read_count + self._query_count,
            total_writes=self._write_count,
            total_reads=self._read_count,
            total_queries=self._query_count,
            uptime_seconds=time.time() - self._started_at,
        )

    def _heartbeat_loop(self):
        """Background heartbeat — writes to Synrix every 5 seconds."""
        while self._heartbeat_running:
            try:
                now = time.time()
                self.backend.write(
                    f"runtime:agents:{self.agent_id}:heartbeat",
                    {"value": now},
                    metadata={"type": "heartbeat"}
                )
                self.backend.write(
                    f"runtime:agents:{self.agent_id}:last_active",
                    {"value": now},
                    metadata={"type": "timestamp"}
                )
            except Exception:
                pass
            time.sleep(5)

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.shutdown()

    def shutdown(self):
        """Gracefully shut down the agent."""
        self._heartbeat_running = False

        # Final snapshot
        try:
            self.snapshot("shutdown_auto")
        except Exception:
            pass

        # Deregister
        try:
            if self._daemon:
                self._daemon.deregister_agent(self.agent_id)
        except Exception:
            pass

        print(f"[{self.agent_id}] Shutdown complete | writes={self._write_count} reads={self._read_count}")
