# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
# See LICENSE file for details.
"""
D24: Skill-Intent Resonance Router — Hebbian Model Selection.

Tracks which (model, domain, skill) triples succeed or fail on tasks.
Applies Hebbian learning so the system learns which model to use for
which domain. Routing becomes a lookup against earned evidence.

Source: DMN session_20260311T163020 | Usage: pulse skillroute
"""

import json
import logging
from datetime import datetime, timezone
from pathlib import Path

_log = logging.getLogger("pulse.d24_skill_resonance")

try:
    from pulse.core.brain_utils import STATE_DIR, TASK_BOARD_FILE, AGENT_REGISTRY_FILE
except Exception:
    STATE_DIR = Path("state")
    TASK_BOARD_FILE = STATE_DIR / "task_board.json"
    AGENT_REGISTRY_FILE = STATE_DIR / "agent_registry.json"

METRICS_FILE = STATE_DIR / "health" / "skill_resonance.json"

_LEARN_RATE = 0.12        # Hebbian learning rate
_WEIGHT_FLOOR = 0.05
_WEIGHT_CEILING = 1.0
_MAX_PROCESSED = 2000     # cap processed task IDs


def _load_task_board() -> list[dict]:
    """Load all tasks from task board."""
    if not TASK_BOARD_FILE.exists():
        return []
    try:
        data = json.loads(TASK_BOARD_FILE.read_text(encoding="utf-8"))
        tasks = []
        for d in data.get("dispatches", []):
            tasks.extend(d.get("tasks", []))
        return tasks
    except (json.JSONDecodeError, OSError):
        return []


def _load_registry() -> dict:
    """Load agent registry."""
    if not AGENT_REGISTRY_FILE.exists():
        return {}
    try:
        return json.loads(AGENT_REGISTRY_FILE.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def _get_model(agent_id: str, registry: dict) -> str:
    """Resolve agent ID to model name.

    Agent IDs can be 'claude-sonnet-4-6_01' (model embedded) or 'claude' (registry lookup).
    """
    # Direct registry lookup
    info = registry.get(agent_id, {})
    if isinstance(info, dict) and info.get("model"):
        return info["model"]
    # Extract model from agent ID: strip trailing _NN worker suffix
    import re
    base = re.sub(r'_\d+$', '', agent_id)
    if base:
        return base
    return ""


def _triple_key(model: str, domain: str, skill: str = "") -> str:
    """Canonical key for a (model, domain, skill) triple."""
    return f"{model}|{domain}|{skill or '_'}"


def _scan_completed(tasks: list[dict], processed: set) -> list[dict]:
    """Find completed/failed tasks not yet processed."""
    results = []
    for t in tasks:
        tid = t.get("task_id", "")
        if not tid or tid in processed:
            continue
        status = t.get("status", "")
        if status not in ("done", "failed"):
            continue
        agent = t.get("completed_by") or t.get("claimed_by") or ""
        if not agent:
            continue
        results.append({
            "task_id": tid,
            "agent": agent,
            "domain": t.get("domain", "general"),
            "success": status == "done",
            "skill": (t.get("required_capabilities") or [""])[0]
                     if t.get("required_capabilities") else "",
        })
    return results


def _update_weights(matrix: dict, triples: list[dict]) -> tuple[int, int]:
    """Apply Hebbian updates. Returns (boosted, decayed)."""
    boosted = decayed = 0
    for tri in triples:
        key = tri["key"]
        if key not in matrix:
            matrix[key] = {"weight": 0.5, "successes": 0, "failures": 0,
                           "model": tri["model"], "domain": tri["domain"]}
        m = matrix[key]
        if tri["success"]:
            m["successes"] += 1
            old = m["weight"]
            m["weight"] = round(old + _LEARN_RATE * (1.0 - old), 4)
            if m["weight"] > old:
                boosted += 1
        else:
            m["failures"] += 1
            old = m["weight"]
            m["weight"] = round(old - _LEARN_RATE * old, 4)
            if m["weight"] < old:
                decayed += 1
        m["weight"] = max(_WEIGHT_FLOOR, min(_WEIGHT_CEILING, m["weight"]))
        m["last_seen"] = datetime.now(timezone.utc).isoformat()
    return boosted, decayed


def _load_state() -> dict:
    if not METRICS_FILE.exists():
        return {"runs": 0, "matrix": {}, "processed_ids": []}
    try:
        return json.loads(METRICS_FILE.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return {"runs": 0, "matrix": {}, "processed_ids": []}


def _save_state(state: dict):
    import os
    METRICS_FILE.parent.mkdir(parents=True, exist_ok=True)
    tmp = METRICS_FILE.with_suffix(".tmp")
    try:
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(state, f, indent=2, ensure_ascii=False)
        os.replace(str(tmp), str(METRICS_FILE))
    except OSError:
        if tmp.exists():
            tmp.unlink()


def get_best_model(domain: str) -> str | None:
    """Query: return model with highest weight for a domain."""
    state = _load_state()
    matrix = state.get("matrix", {})
    best_w, best_m = 0.0, None
    for key, m in matrix.items():
        if m.get("domain") == domain:
            total = m.get("successes", 0) + m.get("failures", 0)
            if total >= 3 and m.get("weight", 0) > best_w:
                best_w = m["weight"]
                best_m = m.get("model")
    return best_m


def run_once(verbose: bool = False) -> dict:
    """Run one resonance cycle: scan outcomes → Hebbian update weights."""
    state = _load_state()
    processed = set(state.get("processed_ids", []))

    tasks = _load_task_board()
    registry = _load_registry()
    completed = _scan_completed(tasks, processed)

    if not completed:
        if verbose:
            _log.info("No new completed tasks to process")
        state["runs"] = state.get("runs", 0) + 1
        state["last_run"] = datetime.now(timezone.utc).isoformat()
        _save_state(state)
        return {"tasks": 0, "status": "no_data"}

    # Build triples
    triples = []
    for c in completed:
        model = _get_model(c["agent"], registry)
        if not model:
            continue
        triples.append({
            "key": _triple_key(model, c["domain"], c["skill"]),
            "model": model, "domain": c["domain"],
            "success": c["success"],
        })

    if verbose:
        _log.info("Processing %d task outcomes (%d with model info)",
                  len(completed), len(triples))

    # Hebbian update
    matrix = state.get("matrix", {})
    boosted, decayed = _update_weights(matrix, triples)
    if verbose:
        _log.info("Hebbian: %d boosted, %d decayed across %d triples",
                  boosted, decayed, len(matrix))

    # Update state
    for c in completed:
        processed.add(c["task_id"])
    state["matrix"] = matrix
    state["processed_ids"] = list(processed)[-_MAX_PROCESSED:]
    state["runs"] = state.get("runs", 0) + 1
    state["last_run"] = datetime.now(timezone.utc).isoformat()
    state["total_updates"] = state.get("total_updates", 0) + boosted + decayed
    _save_state(state)

    return {"tasks": len(completed), "triples": len(triples),
            "boosted": boosted, "decayed": decayed, "status": "ok"}


def cmd_skillroute(args: list):
    """CLI entry point for `pulse skillroute`."""
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    if args and args[0] == "status":
        s = _load_state()
        matrix = s.get("matrix", {})
        top = sorted(matrix.items(), key=lambda x: x[1].get("weight", 0),
                     reverse=True)[:5]
        print(f"[D24] Skill Resonance — Runs: {s.get('runs', 0)}, "
              f"Triples tracked: {len(matrix)}, "
              f"Updates: {s.get('total_updates', 0)}")
        if top:
            print("  Top triples:")
            for key, m in top:
                total = m.get("successes", 0) + m.get("failures", 0)
                print(f"    {key}: w={m['weight']:.3f} "
                      f"({m.get('successes', 0)}W/{m.get('failures', 0)}L, n={total})")
        print(f"  Last run: {s.get('last_run', 'never')}")
        return
    result = run_once(verbose=True)
    print(json.dumps(result, indent=2))


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