#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""
Embedding Drift Detector — Brain V8 Phase 1

Tails agent capture logs, embeds responses via the vector engine, and detects
when agents operate outside the brain's known envelope (novel behavior).
Uses ONLY local cosine similarity — zero LLM calls, zero API cost.

Usage:
  python pulse/daemons/neural/drift_detector.py              # Continuous daemon
  python pulse/daemons/neural/drift_detector.py --once -v    # Single scan
  python pulse/daemons/neural/drift_detector.py --status     # Show stats
"""
from __future__ import annotations

import hashlib
import json
import logging
import re
import signal
import sys
import time
from pathlib import Path

ROOT_DIR = Path(__file__).resolve().parent.parent.parent.parent
if str(ROOT_DIR) not in sys.path:
    sys.path.insert(0, str(ROOT_DIR))

from pulse.core.brain_utils import STATE_DIR, now_iso
from pulse.core.brain_io import atomic_update_json, load_json_dict

log = logging.getLogger("pulse.drift_detector")

POLL_INTERVAL = 12
DRIFT_THRESHOLD = 0.55
EDGE_THRESHOLD = 0.70
MIN_TEXT_LENGTH = 50
CHUNK_SIZE = 600
CHUNK_OVERLAP = 100
DEDUP_WINDOW = 50
BATCH_EMBED_LIMIT = 20
MAX_ALERTS_KEPT = 50

STATE_FILE = STATE_DIR / "drift_detector_state.json"
ALERTS_FILE = STATE_DIR / "prediction_alerts.json"
LOG_PATTERN = re.compile(
    r"^\[([^\]]+)\]\s+(USER|CLAUDE|GEMINI|CODEX|GROK|WORKER|AGENT):\s+(.+?)(?:\s*\|\s*SIG:\s*\w+)?$"
)
KNOWN_LOGS = ["pulse_captured_claude.log", "pulse_captured_gemini.log", "pulse_captured_codex.log"]


def _chunk_text(text: str) -> list[str]:
    """Slice text into overlapping semantic windows. Never truncates."""
    if len(text) <= CHUNK_SIZE:
        return [text]
    chunks = []
    start = 0
    while start < len(text):
        end = start + CHUNK_SIZE
        chunks.append(text[start:end])
        start = end - CHUNK_OVERLAP
    return chunks


def _parse_agent_responses(lines: list[str]) -> list[str]:
    """Extract agent response texts from log lines (skip USER lines)."""
    responses = []
    for line in lines:
        m = LOG_PATTERN.match(line.strip())
        if m and m.group(2) != "USER":
            content = m.group(3).strip()
            if len(content) >= MIN_TEXT_LENGTH:
                responses.append(content)
    return responses


def _md5_short(text: str) -> str:
    return hashlib.md5(text[:200].encode()).hexdigest()[:12]


class DriftDetector:
    def __init__(self, threshold: float = DRIFT_THRESHOLD, verbose: bool = False):
        self.threshold = threshold
        self.verbose = verbose
        self._running = True
        self._index_cache: dict | None = None
        self._state = self._load_state()
        signal.signal(signal.SIGINT, self._shutdown)
        signal.signal(signal.SIGTERM, self._shutdown)

    def _shutdown(self, *_):
        self._running = False

    def _load_state(self) -> dict:
        state = load_json_dict(STATE_FILE)
        if not state:
            state = {"positions": {}, "stats": {"total_scanned": 0, "total_drifts": 0, "total_chunks": 0, "started_at": now_iso()}, "recent_hashes": []}
        return state

    def _save_state(self):
        from pulse.core.brain_io import save_json
        save_json(STATE_FILE, self._state)

    def _ensure_index(self) -> list[dict]:
        """Load or build the embedding index. Cached in memory."""
        if self._index_cache is not None:
            return self._index_cache
        from pulse.brain.index.rule_embeddings import EMBED_INDEX_FILE, build_rule_index
        if not EMBED_INDEX_FILE.exists():
            log.info("No embedding index — building...")
            build_rule_index(force=True, verbose=self.verbose)
        try:
            data = json.loads(EMBED_INDEX_FILE.read_text(encoding="utf-8"))
            rules = [r for r in data.get("rules", []) if r.get("vector")]
            self._index_cache = rules
            log.info("Loaded %d rules from embedding index", len(rules))
            return rules
        except Exception as e:
            log.error("Failed to load index: %s", e)
            return []

    def _discover_logs(self) -> list[Path]:
        """Find all capture log files in state/."""
        logs = [STATE_DIR / name for name in KNOWN_LOGS]
        for f in STATE_DIR.glob("pulse_captured_worker_*.log"):
            logs.append(f)
        return [f for f in logs if f.exists()]

    def _tail_file(self, filepath: Path) -> list[str]:
        """Read new lines since last position (bridge daemon pattern)."""
        key = str(filepath)
        pos = self._state["positions"].get(key, 0)
        try:
            size = filepath.stat().st_size
            if size < pos:
                pos = 0
            if size == pos:
                return []
            with open(filepath, "r", encoding="utf-8", errors="replace") as f:
                f.seek(pos)
                lines = f.readlines()
            self._state["positions"][key] = size
            return lines
        except OSError:
            return []

    def _check_drift(self, responses: list[str]):
        """Core: chunk → batch embed → cosine scan → threshold check."""
        from pulse.brain.index.rule_embeddings import embed_texts, _cosine_similarity
        rules = self._ensure_index()
        if not rules:
            return
        chunk_map: list[tuple[int, str]] = []
        for i, resp in enumerate(responses):
            for chunk in _chunk_text(resp):
                if len(chunk_map) < BATCH_EMBED_LIMIT:
                    chunk_map.append((i, chunk))
        if not chunk_map:
            return
        texts = [c[1] for c in chunk_map]
        try:
            vectors = embed_texts(texts)
        except RuntimeError as e:
            log.warning("Embedding failed: %s", e)
            return
        self._state["stats"]["total_chunks"] = self._state["stats"].get("total_chunks", 0) + len(vectors)
        resp_min_sim: dict[int, float] = {}
        resp_worst_chunk: dict[int, str] = {}
        for idx, (resp_i, chunk_text) in enumerate(chunk_map):
            vec = vectors[idx]
            max_sim = max((_cosine_similarity(vec, r["vector"]) for r in rules), default=0.0)
            if resp_i not in resp_min_sim or max_sim < resp_min_sim[resp_i]:
                resp_min_sim[resp_i] = max_sim
                resp_worst_chunk[resp_i] = chunk_text
        for resp_i, min_sim in resp_min_sim.items():
            self._state["stats"]["total_scanned"] = self._state["stats"].get("total_scanned", 0) + 1
            if min_sim >= self.threshold and min_sim >= EDGE_THRESHOLD:
                continue
            severity = "high" if min_sim < self.threshold else "medium"
            resp_text = responses[resp_i]
            h = _md5_short(resp_text)
            recent = self._state.get("recent_hashes", [])
            if h in recent:
                continue
            recent.append(h)
            if len(recent) > DEDUP_WINDOW:
                recent = recent[-DEDUP_WINDOW:]
            self._state["recent_hashes"] = recent
            self._state["stats"]["total_drifts"] = self._state["stats"].get("total_drifts", 0) + 1
            worst = resp_worst_chunk.get(resp_i, "")[:200]
            self._emit_alert(min_sim, severity, worst, resp_text)
            if severity == "high":
                self._trigger_capture(resp_text)
            if self.verbose:
                log.info("[%s] sim=%.3f worst='%s...'", severity.upper(), min_sim, worst[:60])

    def _emit_alert(self, similarity: float, severity: str, worst_chunk: str, full_text: str):
        ts = now_iso()
        task_id = f"drift_{int(time.time())}_{_md5_short(full_text)}"
        alert = {
            "task_id": task_id,
            "task_title": f"[DRIFT] Agent outside brain envelope (sim={similarity:.2f})",
            "prediction": f"Agent response has low similarity ({similarity:.2f}) to known brain rules. Worst chunk: '{worst_chunk}'",
            "confidence": round(1.0 - similarity, 3),
            "risk_components": ["drift_detector"],
            "severity": severity,
            "timestamp": ts,
            "status": "active",
            "source": "drift_detector_v1",
        }

        def _updater(data):
            alerts = data.get("alerts", [])
            alerts.insert(0, alert)
            data["alerts"] = alerts[:MAX_ALERTS_KEPT]
            return data

        atomic_update_json(ALERTS_FILE, _updater, default={"alerts": [], "metrics": {}})

    def _trigger_capture(self, text: str):
        try:
            from pulse.brain.capture.auto_capture import capture
            capture(text[:1000], source="drift_detector", category="drift_evidence")
        except Exception as e:
            log.debug("auto_capture failed: %s", e)

    def run_once(self):
        """Single scan across all logs."""
        all_responses: list[str] = []
        for logfile in self._discover_logs():
            lines = self._tail_file(logfile)
            if lines:
                all_responses.extend(_parse_agent_responses(lines))
        if all_responses:
            self._check_drift(all_responses)
            if self.verbose:
                log.info("Scanned %d responses", len(all_responses))
        elif self.verbose:
            log.info("No new lines")
        self._save_state()

    def run(self):
        log.info("Drift Detector started (threshold=%.2f, poll=%ds)", self.threshold, POLL_INTERVAL)
        while self._running:
            try:
                self.run_once()
            except Exception as e:
                log.error("Cycle error: %s", e)
            time.sleep(POLL_INTERVAL)
        log.info("Drift Detector stopped")
        self._save_state()

    def show_status(self):
        s = self._state.get("stats", {})
        print(f"  Drift Detector Status")
        print(f"  Started:  {s.get('started_at', 'never')}")
        print(f"  Scanned:  {s.get('total_scanned', 0)} responses")
        print(f"  Chunks:   {s.get('total_chunks', 0)} embedded")
        print(f"  Drifts:   {s.get('total_drifts', 0)} detected")
        print(f"  Logs:     {len(self._state.get('positions', {}))} files tracked")


if __name__ == "__main__":
    if sys.platform == "win32":
        import io
        if hasattr(sys.stdout, 'reconfigure'):
            sys.stdout.reconfigure(encoding='utf-8', errors='replace')
        if hasattr(sys.stderr, 'reconfigure'):
            sys.stderr.reconfigure(encoding='utf-8', errors='replace')
    logging.basicConfig(level=logging.DEBUG if "-v" in sys.argv or "--verbose" in sys.argv else logging.INFO, format="%(asctime)s [DRIFT] %(message)s")
    threshold = DRIFT_THRESHOLD
    if "--threshold" in sys.argv:
        idx = sys.argv.index("--threshold")
        if idx + 1 < len(sys.argv):
            threshold = float(sys.argv[idx + 1])
    detector = DriftDetector(threshold=threshold, verbose="-v" in sys.argv or "--verbose" in sys.argv)
    if "--status" in sys.argv:
        detector.show_status()
    elif "--once" in sys.argv:
        detector.run_once()
    else:
        detector.run()
