#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""
Procedure Daemon — Brain V6 Phase 2

Reads lessons + patterns + failures from the brain, uses LLM (Ollama cloud → paid
fallback) to extract step-by-step procedures and workflows.
Writes to Hippocampus/Evidence/Metrics/procedural_log.json.

Usage: python -m pulse.daemons.synthesis.procedure_daemon [--once] [--batch] [--verbose] [--dry-run]
  --batch: Sweep entire brain in chunks (multiple LLM calls). Use for initial population.
  --once:  Single chunk extraction then exit.
  Daemon mode (default): Sliding window — advances through brain each 6h cycle.
"""
from __future__ import annotations

import json
import logging
import sys
import time
from pathlib import Path

from pulse.core.brain_utils import HIPPOCAMPUS_DIR, now_iso
from pulse.core.llm_router import dispatch, extract_json

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

PROCEDURES_FILE = HIPPOCAMPUS_DIR / "Evidence" / "Metrics" / "procedural_log.json"
LESSONS_FILE = HIPPOCAMPUS_DIR / "Lessons" / "auto_lessons.md"
PATTERNS_FILE = HIPPOCAMPUS_DIR / "Patterns" / "auto_patterns.md"
FAILS_FILE = HIPPOCAMPUS_DIR / "Failures" / "auto_fails.md"
STATE_FILE = HIPPOCAMPUS_DIR / ".procedure_daemon_state.json"
CYCLE_INTERVAL = 6 * 3600  # 6 hours
CHUNK_SIZE = 80  # titles per LLM call

EXTRACT_PROMPT = """You are analyzing a knowledge brain. Extract 5-15 STEP-BY-STEP PROCEDURES from these lessons, patterns, and failures.

A procedure is a WORKFLOW with clear steps that agents should follow. Examples:
- "Deploy code: 1) Run tests 2) Check git status 3) Commit with message 4) Push to remote"
- "Brain query: 1) Formulate query 2) Run brain_query.py 3) Apply knowledge 4) Save learnings"
- "Fix bug: 1) Reproduce issue 2) Check anti-patterns 3) Implement fix 4) Test 5) Commit"

LESSONS (batch {batch_label}):
{lessons}

PATTERNS (batch {batch_label}):
{patterns}

FAILURES (batch {batch_label}):
{failures}

EXISTING PROCEDURES (do NOT duplicate):
{existing}

Output ONLY valid JSON:
{{"procedures": [{{"name": "Procedure Name", "description": "When to use this", "steps": ["Step 1", "Step 2", "Step 3"], "domain": "category", "confidence": 0.7}}]}}

Rules:
- Extract procedures that have clear evidence in the brain data
- Each procedure needs 2-6 concrete steps
- Domain: architecture, testing, deployment, security, brain, agents, swarm, general
- Confidence 0.5-1.0 based on evidence strength
- Do NOT repeat existing procedures
- Output ONLY the JSON object, nothing else"""


def _get_all_titles(filepath: Path) -> list[str]:
    """Extract ALL section titles from a markdown brain file."""
    if not filepath.exists():
        return []
    text = filepath.read_text(encoding="utf-8", errors="replace")
    sections = text.split("\n## ")
    return [s.split("\n")[0].strip() for s in sections[1:] if s.strip()]


def _titles_to_text(titles: list[str]) -> str:
    return "\n".join(f"- {t}" for t in titles)


def _load_state() -> dict:
    if STATE_FILE.exists():
        try:
            return json.loads(STATE_FILE.read_text(encoding="utf-8"))
        except Exception:
            pass
    return {"lesson_offset": 0, "pattern_offset": 0, "fail_offset": 0}


def _save_state(state: dict):
    try:
        STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
    except Exception:
        pass


def _load_procedures() -> dict:
    if not PROCEDURES_FILE.exists():
        return {"procedures": []}
    try:
        data = json.loads(PROCEDURES_FILE.read_text(encoding="utf-8"))
        if isinstance(data, list):
            return {"procedures": data}
        return data if isinstance(data, dict) else {"procedures": []}
    except Exception:
        return {"procedures": []}


def _save_procedures(data: dict):
    PROCEDURES_FILE.parent.mkdir(parents=True, exist_ok=True)
    PROCEDURES_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")


def _extract_one_chunk(lesson_titles: list[str], pattern_titles: list[str],
                       fail_titles: list[str], batch_label: str,
                       current: dict, existing_names: set,
                       dry_run: bool, verbose: bool) -> int:
    """Run one LLM extraction on a chunk of titles. Returns count of items added."""
    if not lesson_titles and not pattern_titles and not fail_titles:
        return 0

    procs = current.get("procedures", [])
    existing_summary = "\n".join(
        f"- {p['name']}" for p in procs[-30:] if p.get("name")
    ) or "(none yet)"

    prompt = EXTRACT_PROMPT.format(
        lessons=_titles_to_text(lesson_titles) or "(none)",
        patterns=_titles_to_text(pattern_titles) or "(none)",
        failures=_titles_to_text(fail_titles) or "(none)",
        existing=existing_summary, batch_label=batch_label,
    )

    if verbose:
        log.info("Chunk %s: %d lessons + %d patterns + %d failures (%d chars)",
                 batch_label, len(lesson_titles), len(pattern_titles), len(fail_titles), len(prompt))

    try:
        raw = dispatch(prompt, task_type="fast")
        result = extract_json(raw)
    except Exception as e:
        log.error("LLM dispatch failed on chunk %s: %s", batch_label, e)
        return 0

    added = 0
    for proc in result.get("procedures", []):
        name = proc.get("name", "").strip()
        if not name or name.lower() in existing_names:
            continue
        proc.setdefault("confidence", 0.7)
        proc.setdefault("domain", "general")
        proc.setdefault("steps", [])
        proc["created"] = now_iso()
        proc["source"] = "procedure_daemon"
        proc["executions"] = 0
        proc["successes"] = 0
        proc["failures"] = 0
        procs.append(proc)
        existing_names.add(name.lower())
        added += 1
    return added


def run_procedure_batch(dry_run: bool = False, verbose: bool = False) -> dict:
    """Batch mode: sweep entire brain in chunks with multiple LLM calls."""
    all_lessons = _get_all_titles(LESSONS_FILE)
    all_patterns = _get_all_titles(PATTERNS_FILE)
    all_fails = _get_all_titles(FAILS_FILE)
    log.info("Batch mode: %d lessons + %d patterns + %d failures",
             len(all_lessons), len(all_patterns), len(all_fails))

    current = _load_procedures()
    procs = current.get("procedures", [])
    existing_names = {p.get("name", "").lower() for p in procs}
    total_added = 0

    lesson_chunks = [all_lessons[i:i + CHUNK_SIZE] for i in range(0, max(len(all_lessons), 1), CHUNK_SIZE)]
    pattern_chunks = [all_patterns[i:i + CHUNK_SIZE] for i in range(0, max(len(all_patterns), 1), CHUNK_SIZE)]
    fail_chunks = [all_fails[i:i + CHUNK_SIZE] for i in range(0, max(len(all_fails), 1), CHUNK_SIZE)]
    n_chunks = max(len(lesson_chunks), len(pattern_chunks), len(fail_chunks))

    for i in range(n_chunks):
        l_chunk = lesson_chunks[i] if i < len(lesson_chunks) else []
        p_chunk = pattern_chunks[i] if i < len(pattern_chunks) else []
        f_chunk = fail_chunks[i] if i < len(fail_chunks) else []
        label = f"{i + 1}/{n_chunks}"
        added = _extract_one_chunk(l_chunk, p_chunk, f_chunk, label, current, existing_names, dry_run, verbose)
        total_added += added
        log.info("Chunk %s: +%d procedures (running total: %d)", label, added, total_added)

        if added > 0 and not dry_run:
            current["procedures"] = procs
            _save_procedures(current)

    log.info("Batch complete: %d new procedures (total: %d)", total_added, len(procs))
    return {"status": "ok", "new": total_added, "total": len(procs), "chunks": n_chunks}


def run_procedure_extraction(dry_run: bool = False, verbose: bool = False) -> dict:
    """Single chunk extraction with sliding window for daemon mode."""
    all_lessons = _get_all_titles(LESSONS_FILE)
    all_patterns = _get_all_titles(PATTERNS_FILE)
    all_fails = _get_all_titles(FAILS_FILE)

    if not all_lessons and not all_patterns:
        log.info("No brain data to extract procedures from")
        return {"status": "empty", "new": 0}

    state = _load_state()
    l_off = state.get("lesson_offset", 0) % max(len(all_lessons), 1)
    p_off = state.get("pattern_offset", 0) % max(len(all_patterns), 1)
    f_off = state.get("fail_offset", 0) % max(len(all_fails), 1)

    l_chunk = all_lessons[l_off:l_off + CHUNK_SIZE]
    p_chunk = all_patterns[p_off:p_off + CHUNK_SIZE]
    f_chunk = all_fails[f_off:f_off + CHUNK_SIZE]

    current = _load_procedures()
    procs = current.get("procedures", [])
    existing_names = {p.get("name", "").lower() for p in procs}

    label = f"offset L:{l_off} P:{p_off} F:{f_off}"
    added = _extract_one_chunk(l_chunk, p_chunk, f_chunk, label, current, existing_names, dry_run, verbose)

    if added > 0 and not dry_run:
        current["procedures"] = procs
        _save_procedures(current)
        log.info("Added %d new procedures (total: %d)", added, len(procs))

    # Advance window
    new_l = (l_off + CHUNK_SIZE) if (l_off + CHUNK_SIZE) < len(all_lessons) else 0
    new_p = (p_off + CHUNK_SIZE) if (p_off + CHUNK_SIZE) < len(all_patterns) else 0
    new_f = (f_off + CHUNK_SIZE) if (f_off + CHUNK_SIZE) < len(all_fails) else 0
    _save_state({"lesson_offset": new_l, "pattern_offset": new_p, "fail_offset": new_f})

    return {"status": "ok", "new": added, "total": len(procs),
            "window": {"lessons": f"{l_off}-{l_off + len(l_chunk)}/{len(all_lessons)}",
                       "patterns": f"{p_off}-{p_off + len(p_chunk)}/{len(all_patterns)}",
                       "failures": f"{f_off}-{f_off + len(f_chunk)}/{len(all_fails)}"}}


def run_daemon(verbose: bool = False, dry_run: bool = False):
    """Main daemon loop — sliding window extraction every CYCLE_INTERVAL."""
    log.info("Procedure Daemon started (interval=%dh, chunk=%d)", CYCLE_INTERVAL // 3600, CHUNK_SIZE)
    while True:
        try:
            result = run_procedure_extraction(dry_run=dry_run, verbose=verbose)
            log.info("Cycle complete: %s", result)
        except Exception as e:
            log.error("Cycle failed: %s", e)
        time.sleep(CYCLE_INTERVAL)


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(asctime)s [PROCEDURE] %(message)s")
    once = "--once" in sys.argv
    batch = "--batch" in sys.argv
    verbose = "--verbose" in sys.argv
    dry_run = "--dry-run" in sys.argv

    if batch:
        result = run_procedure_batch(dry_run=dry_run, verbose=verbose)
        print(json.dumps(result, indent=2))
    elif once:
        result = run_procedure_extraction(dry_run=dry_run, verbose=verbose)
        print(json.dumps(result, indent=2))
    else:
        run_daemon(verbose=verbose, dry_run=dry_run)
