#!/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.
"""
from __future__ import annotations

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

from pulse.core.brain_utils import (
    HIPPOCAMPUS_DIR, LESSONS_JSONL, PATTERNS_JSONL, FAILS_JSONL,
    now_iso,
)
from pulse.core.llm_router import dispatch, extract_json
from pulse.daemons.synthesis.core.procedure_ops import get_all_titles, load_state, save_state

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"
from pulse.core.brain_utils import STATE_DIR as _STATE_DIR
STATE_FILE = _STATE_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"

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}}]}}
"""

MD_JSONL_MAP = {
    LESSONS_FILE: LESSONS_JSONL,
    PATTERNS_FILE: PATTERNS_JSONL,
    FAILS_FILE: FAILS_JSONL,
}


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


def _load_procedures() -> dict:
    """Load existing procedures from SQLite (JSON deprecated)."""
    try:
        from pulse.core.brain_db import get_conn
        conn = get_conn()
        rows = conn.execute("SELECT content as name, description, metadata, domain, confidence, timestamp as created FROM procedures").fetchall()
        procedures = []
        for r in rows:
            p = dict(r)
            if isinstance(p.get("metadata"), str):
                try:
                    meta = json.loads(p["metadata"])
                    p["steps"] = meta.get("steps", [])
                except Exception: p["steps"] = []
            elif isinstance(p.get("metadata"), dict):
                p["steps"] = p["metadata"].get("steps", [])
            p["source"] = "procedure_daemon"
            procedures.append(p)
        return {"procedures": procedures}
    except Exception as e:
        log.debug("Failed to load procedures from SQLite: %s", e)
        return {"procedures": []}


def _save_procedures(data: dict):
    """Authoritative write to SQLite (JSON deprecated)."""
    procs = data.get("procedures", []) if isinstance(data, dict) else data
    if not procs: return
    try:
        from pulse.brain.sqlite_writers import bulk_upsert_procedures
        bulk_upsert_procedures(procs, source="procedure_daemon")
        log.info("Synced %d procedures to SQLite", len(procs))
    except Exception as _exc:
        log.warning("SQLite procedure upsert failed: %s", _exc)


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", batch_label, len(lesson_titles), len(pattern_titles), len(fail_titles))
    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, all_patterns, all_fails = get_all_titles(LESSONS_FILE, MD_JSONL_MAP), get_all_titles(PATTERNS_FILE, MD_JSONL_MAP), get_all_titles(FAILS_FILE, MD_JSONL_MAP)
    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):
        added = _extract_one_chunk(lesson_chunks[i] if i < len(lesson_chunks) else [], pattern_chunks[i] if i < len(pattern_chunks) else [], fail_chunks[i] if i < len(fail_chunks) else [], f"{i+1}/{n_chunks}", current, existing_names, dry_run, verbose)
        total_added += added
        if added > 0 and not dry_run: _save_procedures(current)
    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_l, all_p, all_f = get_all_titles(LESSONS_FILE, MD_JSONL_MAP), get_all_titles(PATTERNS_FILE, MD_JSONL_MAP), get_all_titles(FAILS_FILE, MD_JSONL_MAP)
    if not all_l and not all_p: return {"status": "empty", "new": 0}
    state = load_state(STATE_FILE)
    l_off, p_off, f_off = state.get("lesson_offset", 0) % max(len(all_l), 1), state.get("pattern_offset", 0) % max(len(all_p), 1), state.get("fail_offset", 0) % max(len(all_f), 1)
    current = _load_procedures(); existing_names = {p.get("name", "").lower() for p in current.get("procedures", [])}
    added = _extract_one_chunk(all_l[l_off:l_off+CHUNK_SIZE], all_p[p_off:p_off+CHUNK_SIZE], all_f[f_off:f_off+CHUNK_SIZE], f"L:{l_off} P:{p_off} F:{f_off}", current, existing_names, dry_run, verbose)
    if added > 0 and not dry_run: _save_procedures(current)
    new_l, new_p, new_f = (l_off+CHUNK_SIZE) if (l_off+CHUNK_SIZE) < len(all_l) else 0, (p_off+CHUNK_SIZE) if (p_off+CHUNK_SIZE) < len(all_p) else 0, (f_off+CHUNK_SIZE) if (f_off+CHUNK_SIZE) < len(all_f) else 0
    save_state({"lesson_offset": new_l, "pattern_offset": new_p, "fail_offset": new_f}, STATE_FILE)
    return {"status": "ok", "new": added, "total": len(current.get("procedures", []))}


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: log.info("Cycle complete: %s", run_procedure_extraction(dry_run=dry_run, verbose=verbose))
        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")
    _a = sys.argv; _v, _d = "--verbose" in _a, "--dry-run" in _a
    if "--batch" in _a: print(json.dumps(run_procedure_batch(dry_run=_d, verbose=_v), indent=2))
    elif "--once" in _a: print(json.dumps(run_procedure_extraction(dry_run=_d, verbose=_v), indent=2))
    else: run_daemon(verbose=_v, dry_run=_d)
