#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""
Semantic Sleep Cycle — V6 RAG + V5 fallback.
V6: Embed → find new → top-5 vector search → surgical LLM merge (<2k tokens/call)
V5: Full brain → single Premium LLM consolidation (100k+ tokens)
Usage: python -m pulse.daemons.synthesis.sleep_cycle [--dry-run|--dispatch|--v5]
"""
from __future__ import annotations

import io, json, logging, os, sys, time
from datetime import datetime, timezone
from pathlib import Path

if os.name == "nt" and __name__ == "__main__":
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")

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

from pulse.core.brain_utils import HIPPOCAMPUS_DIR, now_iso as _now_iso

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

BRAIN_FILES = {
    "lessons": HIPPOCAMPUS_DIR / "Lessons" / "auto_lessons.md",
    "failures": HIPPOCAMPUS_DIR / "Failures" / "auto_fails.md",
    "patterns": HIPPOCAMPUS_DIR / "Patterns" / "auto_patterns.md",
}
ARCHIVE_DIR = HIPPOCAMPUS_DIR / "Archive"

MERGE_PROMPT = """You are the PULSE Brain performing a surgical rule merge.
NEW RULES (not yet consolidated):
{new_rules}
SIMILAR EXISTING RULES (from vector search):
{existing_rules}
For each new rule: MERGE if an existing rule covers same concept (produce ONE improved version), or APPEND if genuinely novel.
OUTPUT strict JSON only (no markdown fences):
{{"actions": [{{"new_rule": "text", "action": "merge"|"append", "result": "final text", "merged_with_idx": N|null}}]}}"""

V5_PROMPT = """You are the PULSE Brain Consolidation Engine. Produce a CONSOLIDATED version:
1. MERGE redundant rules 2. REMOVE contradictions 3. COMBINE overly-specific into general
4. PRESERVE unique insights 5. KEEP bullet format (- prefix). Reduce 30-60%, lose ZERO knowledge.
## Lessons ({lessons_count}): {lessons}
## Failures ({failures_count}): {failures}
## Patterns ({patterns_count}): {patterns}
OUTPUT strict JSON: {{"lessons": ["..."], "failures": ["..."], "patterns": ["..."],
"stats": {{"original_count": N, "consolidated_count": M, "reduction_pct": X}}}}"""


def _read_brain_section(filepath: Path) -> tuple[str, int]:
    """Read a brain file, return (bullet text, item_count)."""
    if not filepath.exists():
        return "", 0
    content = filepath.read_text(encoding="utf-8", errors="replace")
    items = [line for line in content.splitlines() if line.strip().startswith("- ")]
    text = "\n".join(items) if items else content
    return text[:30000], len(items)


def _write_consolidated(filepath: Path, items: list[str], section_name: str):
    """Write consolidated items back to a brain file."""
    header = f"---\ntype: auto-extracted\nlast_consolidated: {_now_iso()[:10]}\n---\n\n# {section_name}\n\n---\n\n"
    body = "\n".join(f"- {item}" for item in items if isinstance(item, str) and len(item.strip()) >= 20)
    filepath.write_text(header + body + "\n", encoding="utf-8")


def _archive_brain():
    """Archive current brain files before overwrite."""
    import shutil
    ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    for key, src in BRAIN_FILES.items():
        if src.exists() and src.stat().st_size > 200:
            shutil.copy2(src, ARCHIVE_DIR / f"{src.stem}_presleep_{ts}.md")


def _post_write_cleanup(verbose: bool = False):
    """Clear dedup cache + rebuild indexes after brain write."""
    try:
        from pulse.brain.save_writers import _DEDUP_CACHE
        _DEDUP_CACHE.clear()
    except Exception:
        pass
    try:
        from pulse.brain.search_index import build_index
        build_index(verbose=verbose)
    except Exception:
        pass
    try:
        from pulse.brain.rule_embeddings import build_rule_index
        build_rule_index(force=True, verbose=verbose)
    except Exception:
        pass


def _run_rag_cycle(dry_run: bool = False, verbose: bool = False) -> dict:
    """V6 RAG path: embed → find new rules → surgical LLM merge."""
    from pulse.brain.rule_embeddings import build_rule_index, get_new_rules, search_similar
    from pulse.core.llm_router import dispatch, extract_json

    start = time.time()
    if verbose:
        log.info("V6 RAG Sleep Cycle starting...")

    # 1. Build/refresh embedding index
    idx_result = build_rule_index(verbose=verbose)
    if idx_result.get("status") == "empty":
        return {"status": "skipped", "reason": "No brain rules to embed", "mode": "rag"}

    # 2. Find new (un-indexed) rules
    new_rules = get_new_rules()
    if not new_rules:
        # Nothing new — rebuild index anyway to stay fresh
        build_rule_index(force=True, verbose=verbose)
        return {"status": "skipped", "reason": "No new rules since last cycle", "mode": "rag",
                "elapsed_seconds": round(time.time() - start, 2)}

    if verbose:
        log.info("Found %d new rules to consolidate", len(new_rules))

    # 3. Process in micro-batches of 10
    merged_count = 0
    appended_count = 0
    all_results: list[dict] = []

    for i in range(0, len(new_rules), 10):
        batch = new_rules[i:i + 10]

        # Find similar existing rules for each new rule in batch
        existing_set: dict[str, dict] = {}
        for rule in batch:
            similar = search_similar(rule["text"], top_k=5)
            for s in similar:
                existing_set[s["text"]] = s

        new_text = "\n".join(f"- {r['text']}" for r in batch)
        existing_text = "\n".join(
            f"{j+1}. [{s['source']}] (sim={s['similarity']}) {s['text']}"
            for j, s in enumerate(existing_set.values())
        ) or "(no similar rules found)"

        prompt = MERGE_PROMPT.format(new_rules=new_text, existing_rules=existing_text)

        if verbose:
            log.info("Batch %d: %d new rules, %d similar, ~%d tokens",
                     i // 10 + 1, len(batch), len(existing_set), len(prompt) // 4)

        raw = dispatch(prompt, task_type="fast")
        result = extract_json(raw)

        for action in result.get("actions", []):
            if not isinstance(action, dict):
                continue
            act = action.get("action", "append")
            text = action.get("result", "")
            if isinstance(text, str) and len(text.strip()) >= 20:
                all_results.append({"action": act, "text": text.strip(),
                                    "source": batch[0]["source"] if batch else "lessons"})
                if act == "merge":
                    merged_count += 1
                else:
                    appended_count += 1

    if verbose:
        log.info("RAG results: %d merged, %d appended", merged_count, appended_count)

    if dry_run:
        if verbose:
            for r in all_results[:10]:
                log.info("  [%s] %s", r["action"].upper(), r["text"][:80])
        return {"status": "dry_run", "mode": "rag", "merged": merged_count,
                "appended": appended_count, "elapsed_seconds": round(time.time() - start, 2)}

    # 4. Apply: read current brain, apply merges, write back
    _archive_brain()
    name_map = {"lessons": "Lessons", "failures": "Failures", "patterns": "Patterns"}

    for section_key, filepath in BRAIN_FILES.items():
        text, count = _read_brain_section(filepath)
        current_items = [line.strip()[2:] for line in text.splitlines() if line.strip().startswith("- ")]
        new_for_section = [r["text"] for r in all_results if r["source"] == section_key and r["action"] == "append"]
        merged_for_section = [r["text"] for r in all_results if r["source"] == section_key and r["action"] == "merge"]
        final_items = current_items + new_for_section + merged_for_section
        # Dedup by exact text
        seen = set()
        deduped = []
        for item in final_items:
            if item not in seen:
                seen.add(item)
                deduped.append(item)
        if deduped:
            _write_consolidated(filepath, deduped, name_map[section_key])
            if verbose:
                log.info("  %s: %d -> %d items", section_key, count, len(deduped))

    _post_write_cleanup(verbose=verbose)
    elapsed = round(time.time() - start, 2)
    if verbose:
        log.info("V6 RAG Sleep Cycle complete in %ss", elapsed)

    return {"status": "completed", "mode": "rag", "merged": merged_count,
            "appended": appended_count, "elapsed_seconds": elapsed}


def _run_full_brain_cycle(dry_run: bool = False, verbose: bool = False) -> dict:
    """V5 fallback: full brain → single Premium LLM consolidation."""
    from pulse.core.llm_router import dispatch, extract_json
    start = time.time()
    sections, total_items = {}, 0
    for name, fp in BRAIN_FILES.items():
        text, count = _read_brain_section(fp)
        sections[name] = {"text": text, "count": count, "path": fp}
        total_items += count
    if total_items < 20:
        return {"status": "skipped", "reason": f"Brain too small ({total_items})", "mode": "v5"}
    prompt = V5_PROMPT.format(
        lessons=sections["lessons"]["text"], lessons_count=sections["lessons"]["count"],
        failures=sections["failures"]["text"], failures_count=sections["failures"]["count"],
        patterns=sections["patterns"]["text"], patterns_count=sections["patterns"]["count"])
    result = extract_json(dispatch(prompt, task_type="premium"))
    consolidated = {}
    for key in ("lessons", "failures", "patterns"):
        items = result.get(key, [])
        consolidated[key] = [s.strip() for s in (items if isinstance(items, list) else [])
                             if isinstance(s, str) and len(s.strip()) >= 20]
    new_total = sum(len(v) for v in consolidated.values())
    reduction = round((1 - new_total / total_items) * 100, 1) if total_items > 0 else 0
    if reduction > 80:
        return {"status": "refused", "reason": f"{reduction}% reduction too aggressive", "mode": "v5"}
    if not dry_run:
        _archive_brain()
        nm = {"lessons": "Lessons", "failures": "Failures", "patterns": "Patterns"}
        for key, items in consolidated.items():
            if items:
                _write_consolidated(sections[key]["path"], items, nm[key])
        _post_write_cleanup(verbose=verbose)
    return {"status": "dry_run" if dry_run else "completed", "mode": "v5",
            "original_items": total_items, "consolidated_items": new_total,
            "reduction_pct": reduction, "elapsed_seconds": round(time.time() - start, 2)}


def run_sleep_cycle(dry_run: bool = False, verbose: bool = False, force_v5: bool = False) -> dict:
    """Main entry: try V6 RAG first, fall back to V5 full-brain."""
    if force_v5:
        return _run_full_brain_cycle(dry_run, verbose)
    try:
        return _run_rag_cycle(dry_run, verbose)
    except Exception as e:
        log.warning("V6 RAG failed (%s), falling back to V5", e)
        if verbose:
            log.info("Attempting V5 full-brain fallback...")
        return _run_full_brain_cycle(dry_run, verbose)


def dispatch_sleep_task() -> str:
    """Post the Sleep Cycle as a task to the Swarm board."""
    from pulse.tasks.pulse_dispatcher import TaskDispatcher
    tid = f"sleep_{int(time.time())}"
    task = {
        "task_id": tid, "title": "[SLEEP_CYCLE] Nightly Brain Consolidation (V6 RAG)",
        "description": "Vectorized RAG consolidation: embed → find new → surgical merge.",
        "domain": "brain_consolidation", "complexity": "high",
        "recommended_tier": "premium", "status": "open",
        "required_capabilities": [], "depends_on": [],
        "claimed_by": None, "claimed_at": None, "files_touched": [],
        "outcome": None, "memory_data": {"type": "SLEEP_CYCLE"},
    }
    TaskDispatcher(verbose=False).dispatch(
        prompt="[SLEEP_CYCLE] Nightly brain consolidation (V6 RAG)",
        tasks=[task], founder_override=True)
    return tid


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(asctime)s [SLEEP] %(message)s")

    dry_run = "--dry-run" in sys.argv
    verbose = "--verbose" in sys.argv or "-v" in sys.argv or dry_run
    force_v5 = "--v5" in sys.argv

    if "--dispatch" in sys.argv:
        tid = dispatch_sleep_task()
        print(f"Dispatched Sleep Cycle task: {tid}")
    else:
        result = run_sleep_cycle(dry_run=dry_run, verbose=verbose, force_v5=force_v5)
        print(json.dumps(result, indent=2))
