#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""D10: Skill Dependency Synthesizer — Proven chain detection & composite skill proposals.

Pipeline: MATRIX -> DETECT -> SYNTHESIZE -> PROPOSE. 24h interval via orchestrator.
Scans worker sessions for skill co-occurrences, detects proven chains
(failure-weighted), synthesizes composites via LLM, outputs proposal sessions.
"""
from __future__ import annotations

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

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

from pulse.core.paths import get_root_dir
from pulse.core.brain_io import _acquire

ROOT = get_root_dir()
STATE_FILE = ROOT / "state" / "skill_synthesizer_state.json"
SESSIONS_DIR = ROOT / "state" / "worker_sessions"
PROPOSALS_DIR = ROOT / "Prefrontal_Cortex" / "Working_Memory" / "Proposals"
SKILLS_DIR = ROOT / ".cursor" / "skills"

MIN_CO_OCCURRENCES = 3    # min co-occurrence count
MIN_SUCCESS_RATE = 0.70   # 70% weighted success for "proven"
FAIL_WEIGHT = 2.5         # failures count 2.5x (OG spec)
MAX_CHAIN_SIZE = 5        # max skills in a composite
TOP_CHAINS = 5            # synthesize top N per run

_DEFAULT_STATE = {"runs": 0, "last_run": None, "chains_found": 0,
                  "proposals_generated": 0, "last_sessions_scanned": 0}


def _load_state() -> dict:
    if not STATE_FILE.exists():
        return dict(_DEFAULT_STATE)
    try:
        with _acquire(STATE_FILE):
            return json.loads(STATE_FILE.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return dict(_DEFAULT_STATE)


def _save_state(state: dict):
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    with _acquire(STATE_FILE):
        STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")


def _weighted_score(s: int, f: int) -> float:
    return s / (s + f * FAIL_WEIGHT) if (s + f) > 0 else 0.0


def _build_co_matrix() -> tuple[dict, int]:
    """Scan worker_sessions/*.json for skill co-occurrences."""
    matrix: dict[str, dict] = {}
    scanned = 0
    if not SESSIONS_DIR.exists():
        return matrix, 0
    for sf in SESSIONS_DIR.glob("*.json"):
        try:
            session = json.loads(sf.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            continue
        skills = session.get("skills_used", [])
        if not isinstance(skills, list) or len(skills) < 2:
            continue
        scanned += 1
        ok = session.get("success", False)
        domain, tid = session.get("domain", "general"), session.get("task_id", "")
        for a, b in combinations(sorted(set(skills)), 2):
            key = f"{a}|{b}"
            if key not in matrix:
                matrix[key] = {"skills": [a, b], "count": 0, "successes": 0,
                               "failures": 0, "domains": set(), "tasks": []}
            m = matrix[key]
            m["count"] += 1
            m["successes" if ok else "failures"] += 1
            m["domains"].add(domain)
            if tid and len(m["tasks"]) < 20:
                m["tasks"].append(tid)
    for v in matrix.values():
        v["domains"] = sorted(v["domains"])
    return matrix, scanned


def _detect_chains(matrix: dict) -> list[dict]:
    """Find proven skill chains via clique detection with failure-weighted scoring."""
    qual = {k: s for k, s in matrix.items() if s["count"] >= MIN_CO_OCCURRENCES}
    if not qual:
        return []
    # Build adjacency
    adj: dict[str, set[str]] = {}
    for stats in qual.values():
        a, b = stats["skills"]
        adj.setdefault(a, set()).add(b)
        adj.setdefault(b, set()).add(a)
    chains = []
    # Size-2 cliques (qualifying pairs)
    for stats in qual.values():
        w = _weighted_score(stats["successes"], stats["failures"])
        if w >= MIN_SUCCESS_RATE:
            chains.append({"skills": sorted(stats["skills"]), "score": round(w, 3),
                           "count": stats["count"], "successes": stats["successes"],
                           "failures": stats["failures"], "domains": stats["domains"]})
    # Size 3..MAX_CHAIN_SIZE cliques
    all_skills = sorted(adj.keys())
    for size in range(3, min(MAX_CHAIN_SIZE + 1, len(all_skills) + 1)):
        for combo in combinations(all_skills, size):
            pairs = list(combinations(combo, 2))
            pair_data = [qual.get(f"{a}|{b}") for a, b in pairs]
            if not all(pair_data):
                continue
            ts = sum(p["successes"] for p in pair_data)
            tf = sum(p["failures"] for p in pair_data)
            tc = sum(p["count"] for p in pair_data) // len(pairs)
            w = _weighted_score(ts, tf)
            if w >= MIN_SUCCESS_RATE and tc >= MIN_CO_OCCURRENCES:
                doms: set[str] = set()
                for p in pair_data:
                    doms.update(p["domains"])
                chains.append({"skills": list(combo), "score": round(w, 3),
                               "count": tc, "successes": ts, "failures": tf,
                               "domains": sorted(doms)})
    # Deduplicate: remove strict subsets of larger chains
    chains.sort(key=lambda c: (-len(c["skills"]), -c["score"]))
    deduped, seen = [], []
    for c in chains:
        ss = set(c["skills"])
        if not any(ss < s for s in seen):
            deduped.append(c)
            seen.append(ss)
    deduped.sort(key=lambda c: -c["score"])
    return deduped[:TOP_CHAINS * 2]


def _load_skill_content(name: str) -> str:
    if not SKILLS_DIR.exists():
        return ""
    for cat in SKILLS_DIR.iterdir():
        md = cat / f"{name}.md"
        if cat.is_dir() and md.exists():
            try:
                return md.read_text(encoding="utf-8")[:2000]
            except OSError:
                pass
    return ""


def _synthesize_chain(chain: dict) -> dict | None:
    """LLM generates composite skill from a proven chain."""
    try:
        from pulse.core.llm_router import dispatch, extract_json
    except ImportError:
        return None
    skills = chain["skills"]
    excerpts = "\n".join(
        f"### {s}\n{_load_skill_content(s)[:800] or '(no .md)'}" for s in skills)
    prompt = (
        f"Given these skills PROVEN to work well together:\n"
        f"Skills: {', '.join(skills)} | Success: {chain['score']:.0%} | "
        f"Co-occurrences: {chain['count']} | Domains: {', '.join(chain['domains'])}\n\n"
        f"Skill content:\n{excerpts}\n\n"
        f"Synthesize a COMPOSITE SKILL merging their capabilities.\n"
        f'Output ONLY JSON: {{"title": "<NAME>", "composite_skill": "<3-8 sentences>", '
        f'"constituent_skills": ["<list>"], "recommended_domains": ["<list>"], '
        f'"rationale": "<1-2 sentences>"}}'
    )
    try:
        raw = dispatch(prompt, task_type="fast")
        result = extract_json(raw)
        if isinstance(result, dict) and result.get("title"):
            result.update(chain_score=chain["score"], chain_count=chain["count"],
                          chain_successes=chain["successes"], chain_failures=chain["failures"])
            return result
    except Exception:
        pass
    return None


def _build_session(proposals: list[dict]) -> dict:
    now = datetime.now(timezone.utc)
    ideas = []
    for p in proposals:
        ideas.append({
            "title": p.get("title", "Composite Skill"),
            "idea": (f"**Composite:** {p.get('composite_skill', '')}\n\n"
                     f"**Constituents:** {', '.join(p.get('constituent_skills', []))}\n"
                     f"**Domains:** {', '.join(p.get('recommended_domains', []))}\n"
                     f"**Rationale:** {p.get('rationale', '')}\n"
                     f"**Evidence:** {p.get('chain_score', 0):.0%} success, "
                     f"{p.get('chain_count', 0)} co-occurrences"),
            "model": "d10_synthesizer", "provider": "pulse", "status": "pending",
            "impact": f"{p.get('chain_score', 0):.0%} | "
                      f"{p.get('chain_successes', 0)}W/{p.get('chain_failures', 0)}L",
        })
    return {"id": f"session_d10_{now.strftime('%Y%m%d_%H%M%S')}",
            "created": now.isoformat(), "mode": "skill_synthesis",
            "panel_size": len(proposals), "source_files": [], "ideas": ideas}


def run_once(verbose: bool = False, dry_run: bool = False) -> dict:
    """Full D10 cycle: MATRIX -> DETECT -> SYNTHESIZE -> PROPOSE."""
    state, t0 = _load_state(), time.time()
    vp = print if verbose else lambda *a: None

    vp("[D10] Building co-occurrence matrix...")
    matrix, scanned = _build_co_matrix()
    vp(f"[D10] Scanned {scanned} sessions, {len(matrix)} skill pairs")

    def _finish(chains=0, proposals=0):
        state.update(last_run=datetime.now(timezone.utc).isoformat(),
                     last_sessions_scanned=scanned, chains_found=chains,
                     last_elapsed_s=round(time.time() - t0, 1))
        state["runs"] += 1
        _save_state(state)
        vp(f"[D10] Done in {state['last_elapsed_s']}s")
        return {"scanned": scanned, "pairs": len(matrix),
                "chains": chains, "proposals": proposals}

    if not matrix:
        vp("[D10] No sessions with 2+ skills.")
        return _finish()

    vp("[D10] Detecting proven chains...")
    chains = _detect_chains(matrix)
    vp(f"[D10] {len(chains)} proven chains")
    for c in chains[:5]:
        vp(f"  {'+'.join(c['skills'])} score={c['score']:.0%} n={c['count']}")
    if not chains:
        vp("[D10] No chains meet threshold.")
        return _finish()

    proposals = []
    if not dry_run:
        vp(f"[D10] Synthesizing top {min(TOP_CHAINS, len(chains))} chains...")
        for chain in chains[:TOP_CHAINS]:
            r = _synthesize_chain(chain)
            if r:
                proposals.append(r)
                vp(f"  -> {r.get('title', '?')}")
    else:
        vp("[D10] Dry run -- skipping LLM synthesis")

    if proposals:
        PROPOSALS_DIR.mkdir(parents=True, exist_ok=True)
        session = _build_session(proposals)
        path = PROPOSALS_DIR / f"{session['id']}.json"
        path.write_text(json.dumps(session, indent=2, ensure_ascii=False), encoding="utf-8")
        vp(f"[D10] Saved {len(proposals)} proposals -> {path.name}")
        state["proposals_generated"] = state.get("proposals_generated", 0) + len(proposals)

    return _finish(chains=len(chains), proposals=len(proposals))


def show_status():
    """Print D10 synthesizer stats."""
    s = _load_state()
    last = (s.get("last_run") or "never")[:19]
    print(f"\n  D10 Skill Dependency Synthesizer\n  {'='*40}")
    for k, v in [("Runs", s.get("runs", 0)), ("Last run", last),
                 ("Sessions", f"{s.get('last_sessions_scanned', 0)} scanned"),
                 ("Chains", f"{s.get('chains_found', 0)} proven"),
                 ("Proposals", f"{s.get('proposals_generated', 0)} total")]:
        print(f"  {k + ':':<12} {v}")
    if s.get("last_elapsed_s"):
        print(f"  {'Elapsed:':<12} {s['last_elapsed_s']}s")
    print()


def cmd_synthesize(args: list[str]):
    """CLI entry: pulse synthesize [--dry-run] [-v] [--once] [status]."""
    if args and args[0] == "status":
        return show_status()
    verbose = "-v" in args or "--verbose" in args
    run_once(verbose=verbose, dry_run="--dry-run" in args)


if __name__ == "__main__":
    cmd_synthesize(sys.argv[1:])
