# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""D22: Prediction Resonance — Hebbian Prediction Promotion."""
import json
import logging
import time
from datetime import datetime, timezone
from pathlib import Path

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

from pulse.core.brain_utils import STATE_DIR, PATTERNS_JSONL

ALERTS_FILE = STATE_DIR / "prediction_alerts.json"
METRICS_FILE = STATE_DIR / "prediction_metrics.json"
RESONANCE_FILE = STATE_DIR / "health" / "pattern_resonance.json"

_HEBBIAN_RATE = 0.10       # confidence pull toward resonance score
_PROMOTE_THRESHOLD = 0.7   # resonance score for schema promotion
_ARCHIVE_THRESHOLD = -0.3  # resonance score for archival
_MIN_EVENTS = 3            # need N outcomes before scoring a pattern
_CONFIDENCE_FLOOR = 0.1
_CONFIDENCE_CEILING = 1.0


def _load_predictions() -> list[dict]:
    """Load resolved prediction alerts."""
    if not ALERTS_FILE.exists():
        return []
    try:
        data = json.loads(ALERTS_FILE.read_text(encoding="utf-8"))
        alerts = data.get("alerts", []) if isinstance(data, dict) else data
        return [a for a in alerts if a.get("outcome") in
                ("true_positive", "false_positive", "true_negative", "auto_expired_stale")]
    except (json.JSONDecodeError, OSError):
        return []


def _load_patterns() -> list[dict]:
    """Load all patterns — SQLite-first, JSONL fallback."""
    try:
        from pulse.core.brain_db import get_conn
        conn = get_conn()
        rows = conn.execute("SELECT * FROM patterns").fetchall()
        if rows:
            return [dict(r) for r in rows]
    except Exception:
        pass
    # Fallback: JSONL
    if not PATTERNS_JSONL.exists():
        return []
    try:
        return [json.loads(ln) for ln in PATTERNS_JSONL.read_text("utf-8").splitlines() if ln.strip()]
    except (OSError, json.JSONDecodeError):
        return []

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


def _save_state(state: dict):
    import os
    RESONANCE_FILE.parent.mkdir(parents=True, exist_ok=True)
    tmp = RESONANCE_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(RESONANCE_FILE))
    except OSError:
        if tmp.exists():
            tmp.unlink()


def _match_predictions_to_patterns(predictions: list[dict],
                                    patterns: list[dict]) -> dict[str, list[str]]:
    """Link predictions to patterns by keyword overlap.

    Returns {pattern_id: [outcome1, outcome2, ...]}
    """
    links: dict[str, list[str]] = {}
    for pred in predictions:
        pred_text = pred.get("prediction", "").lower()
        pred_words = set(pred_text.split())
        if len(pred_words) < 3:
            continue
        outcome = pred.get("outcome", "")
        # Find patterns whose content overlaps with prediction text
        for pat in patterns:
            pid = pat.get("id", "")
            if not pid:
                continue
            pat_text = (pat.get("content") or pat.get("title") or "").lower()
            pat_words = set(pat_text.split())
            if not pat_words:
                continue
            overlap = len(pred_words & pat_words) / min(len(pred_words), len(pat_words))
            if overlap >= 0.3:
                links.setdefault(pid, []).append(outcome)
    return links


def _compute_resonance(links: dict[str, list[str]],
                        matrix: dict) -> dict[str, dict]:
    """Compute resonance scores from prediction-pattern links."""
    for pid, outcomes in links.items():
        if pid not in matrix:
            matrix[pid] = {"tp": 0, "fp": 0, "tn": 0, "score": 0.0}
        for o in outcomes:
            if o == "true_positive":
                matrix[pid]["tp"] += 1
            elif o == "false_positive":
                matrix[pid]["fp"] += 1
            elif o == "true_negative":
                matrix[pid]["tn"] += 1

    # Recompute scores
    for pid, m in matrix.items():
        total = m["tp"] + m["fp"]
        if total >= _MIN_EVENTS:
            m["score"] = round((m["tp"] - m["fp"]) / (total + 1), 3)
        else:
            m["score"] = 0.0
    return matrix


def _apply_hebbian(patterns: list[dict], matrix: dict) -> tuple[int, int]:
    """Apply Hebbian updates to pattern confidence based on resonance.

    Returns (boosted_count, decayed_count).
    """
    boosted = decayed = 0
    for pat in patterns:
        pid = pat.get("id", "")
        if pid not in matrix:
            continue
        m = matrix[pid]
        if m["tp"] + m["fp"] < _MIN_EVENTS:
            continue
        score = m["score"]
        conf = pat.get("confidence", 0.5)
        # Hebbian pull toward resonance
        new_conf = conf + _HEBBIAN_RATE * (score - conf + 0.5)
        new_conf = max(_CONFIDENCE_FLOOR, min(_CONFIDENCE_CEILING, round(new_conf, 3)))
        if new_conf != conf:
            pat["confidence"] = new_conf
            pat["_resonance_updated"] = True
            if new_conf > conf:
                boosted += 1
            else:
                decayed += 1
    return boosted, decayed


def _identify_promotions(matrix: dict) -> list[str]:
    """Find pattern IDs with resonance above promotion threshold."""
    return [pid for pid, m in matrix.items()
            if m["score"] >= _PROMOTE_THRESHOLD and m["tp"] + m["fp"] >= _MIN_EVENTS]


def _identify_archives(matrix: dict) -> list[str]:
    """Find pattern IDs with resonance below archive threshold."""
    return [pid for pid, m in matrix.items()
            if m["score"] <= _ARCHIVE_THRESHOLD and m["tp"] + m["fp"] >= _MIN_EVENTS]


def _rewrite_patterns(patterns: list[dict]):
    """Rewrite modified patterns back to JSONL."""
    modified = [p for p in patterns if p.pop("_resonance_updated", False)]
    if not modified:
        return
    # Build id→confidence map for update
    mod_map = {p["id"]: p for p in modified if p.get("id")}
    if not mod_map:
        return
    # Primary: update confidence directly in SQLite
    try:
        from pulse.core.brain_db import get_conn
        conn = get_conn()
        with conn:
            for item_id, item in mod_map.items():
                conn.execute(
                    "UPDATE patterns SET confidence = ? WHERE id = ?",
                    (float(item["confidence"]), item_id))
        return
    except Exception:
        pass  # SQLite unavailable — fall through to JSONL
    # Fallback: rewrite JSONL
    try:
        from pulse.core.brain_io import _acquire
        with _acquire(PATTERNS_JSONL):
            lines = []
            with open(PATTERNS_JSONL, "r", encoding="utf-8") as f:
                for raw in f:
                    raw = raw.strip()
                    if not raw:
                        continue
                    try:
                        item = json.loads(raw)
                        eid = item.get("id", "")
                        if eid in mod_map:
                            item["confidence"] = mod_map[eid]["confidence"]
                        lines.append(json.dumps(item, ensure_ascii=False))
                    except json.JSONDecodeError:
                        lines.append(raw)
            with open(PATTERNS_JSONL, "w", encoding="utf-8") as f:
                f.write("\n".join(lines) + "\n" if lines else "")
    except Exception as e:
        _log.warning("Failed to rewrite patterns: %s", e)


def run_once(verbose: bool = False) -> dict:
    """Run one resonance cycle: sweep → score → promote/archive."""
    state = _load_state()
    processed = set(state.get("processed_ids", []))

    # Load resolved predictions (only new ones)
    predictions = _load_predictions()
    new_preds = [p for p in predictions
                 if p.get("task_id", "") not in processed]
    if not new_preds:
        if verbose:
            _log.info("No new resolved predictions to process")
        state["runs"] = state.get("runs", 0) + 1
        state["last_run"] = datetime.now(timezone.utc).isoformat()
        _save_state(state)
        return {"predictions": 0, "status": "no_data"}

    if verbose:
        _log.info("Processing %d new prediction outcomes", len(new_preds))

    # Load patterns + compute resonance
    patterns = _load_patterns()
    links = _match_predictions_to_patterns(new_preds, patterns)
    matrix = _compute_resonance(links, state.get("matrix", {}))

    # Apply Hebbian updates
    boosted, decayed = _apply_hebbian(patterns, matrix)
    if verbose:
        _log.info("Hebbian: %d boosted, %d decayed", boosted, decayed)

    # Identify promotions and archives
    to_promote = _identify_promotions(matrix)
    to_archive = _identify_archives(matrix)
    if verbose and to_promote:
        _log.info("High-resonance patterns for promotion: %d", len(to_promote))
    if verbose and to_archive:
        _log.info("Low-resonance patterns for archival: %d", len(to_archive))

    # Rewrite updated patterns
    _rewrite_patterns(patterns)

    # Update state
    for p in new_preds:
        processed.add(p.get("task_id", ""))
    state["matrix"] = matrix
    state["processed_ids"] = list(processed)[-500:]
    state["runs"] = state.get("runs", 0) + 1
    state["last_run"] = datetime.now(timezone.utc).isoformat()
    state["promoted_total"] = state.get("promoted_total", 0) + len(to_promote)
    state["archived_total"] = state.get("archived_total", 0) + len(to_archive)
    _save_state(state)

    return {"predictions": len(new_preds), "patterns_linked": len(links),
            "boosted": boosted, "decayed": decayed,
            "promoted": len(to_promote), "archived": len(to_archive),
            "status": "ok"}


def cmd_resonance(args: list):
    """CLI entry point for `pulse resonance`."""
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    if args and args[0] == "status":
        s = _load_state()
        matrix = s.get("matrix", {})
        scored = [m for m in matrix.values() if m.get("tp", 0) + m.get("fp", 0) >= _MIN_EVENTS]
        avg_score = sum(m["score"] for m in scored) / len(scored) if scored else 0
        print(f"[D22] Prediction Resonance — Runs: {s.get('runs', 0)}, "
              f"Patterns tracked: {len(matrix)}, "
              f"Scored: {len(scored)}")
        print(f"  Avg resonance: {avg_score:.3f}, "
              f"Promoted: {s.get('promoted_total', 0)}, "
              f"Archived: {s.get('archived_total', 0)}")
        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_resonance(sys.argv[1:])
