#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""
Hebbian Co-activation Learning for Knowledge Items (Phase 3).

"Neurons that fire together wire together" — Hebb 1949.

When two knowledge items are retrieved in the same session AND the task
succeeds, they get linked with a COACTIVATION edge. This is different from
STDP (which learns ORDER): Hebbian learns CO-OCCURRENCE regardless of order.

Over time, the brain discovers which items are synergistic — retrieving one
should suggest the other.

hebbian_balancer.py handles agent-task routing. This module handles
knowledge-knowledge associations.
"""
import json
import logging
from datetime import datetime, timezone
from pulse.core.paths import get_state_dir

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

COACT_FILE = get_state_dir() / "stdp" / "coactivation_buffer.json"
HEBBIAN_LTP = 0.06    # Strengthen on co-retrieval + success
HEBBIAN_LTD = -0.03   # Weaken on co-retrieval + failure
MIN_COACT_ITEMS = 2   # Need at least 2 items to form pairs


def record_coactivation(item_ids: list[str]):
    """Buffer co-activated item IDs for later Hebbian learning."""
    if len(item_ids) < MIN_COACT_ITEMS:
        return
    try:
        COACT_FILE.parent.mkdir(parents=True, exist_ok=True)
        buffer = {}
        if COACT_FILE.exists():
            buffer = json.loads(COACT_FILE.read_text(encoding="utf-8"))
        # Item 58: 5-minute co-occurrence window (_HEBB_WINDOW=300s) — not 1-hour bucket
        _dt58 = datetime.now(timezone.utc)
        _min58 = (_dt58.minute // 5) * 5
        session_key = _dt58.strftime(f"%Y%m%d_%H") + f"_{_min58:02d}"
        existing = buffer.get(session_key, [])
        # Add new IDs, dedup
        seen = set(existing)
        for iid in item_ids:
            if iid not in seen:
                existing.append(iid)
                seen.add(iid)
        buffer[session_key] = existing[-50:]  # Cap per session
        # Cap total sessions
        if len(buffer) > 24:
            for k in sorted(buffer.keys())[:-24]:
                del buffer[k]
        COACT_FILE.write_text(json.dumps(buffer), encoding="utf-8")
    except Exception as e:
        log.debug("Hebbian record failed: %s", e)



MAX_OUTGOING_WEIGHT_SUM = 3.0  # Turrigiano 1998: synaptic scaling cap


def _normalize_outgoing_edges(conn, node_id: str):
    """Synaptic scaling: cap total outgoing COACTIVATION weight to MAX_OUTGOING_WEIGHT_SUM (Turrigiano 1998)."""
    try:
        rows = conn.execute(
            "SELECT id, confidence FROM graph_edges "
            "WHERE from_node=? AND edge_type='COACTIVATION'",
            (node_id,)
        ).fetchall()
        if not rows:
            return
        total = sum(float(r["confidence"]) for r in rows)
        if total <= MAX_OUTGOING_WEIGHT_SUM:
            return
        scale = MAX_OUTGOING_WEIGHT_SUM / total
        for r in rows:
            new_conf = round(float(r["confidence"]) * scale, 4)
            conn.execute("UPDATE graph_edges SET confidence=? WHERE id=?", (new_conf, r["id"]))
    except Exception as e:
        log.debug("Synaptic scaling failed for %s: %s", node_id, e)

def apply_hebbian(session_key: str, success: bool) -> int:
    """Apply Hebbian learning: strengthen/weaken COACTIVATION edges."""
    try:
        from pulse.core.brain_db import get_conn
    except ImportError:
        return 0
    if not COACT_FILE.exists():
        return 0
    try:
        buffer = json.loads(COACT_FILE.read_text(encoding="utf-8"))
    except Exception:
        return 0
    items = buffer.get(session_key, [])
    if len(items) < MIN_COACT_ITEMS:
        return 0
    conn = get_conn()
    delta = HEBBIAN_LTP if success else HEBBIAN_LTD
    updated = 0
    # Create pairs (unordered — both directions get same weight)
    for i in range(len(items)):
        for j in range(i + 1, min(len(items), i + 10)):  # Cap pairs per item
            updated += _upsert_coact_edge(conn, items[i], items[j], delta)
            _normalize_outgoing_edges(conn, min(items[i], items[j]))
    conn.commit()
    # Clean up
    if session_key in buffer:
        del buffer[session_key]
        try:
            COACT_FILE.write_text(json.dumps(buffer), encoding="utf-8")
        except Exception:
            pass
    log.info("Hebbian: %d COACTIVATION edges updated (success=%s)", updated, success)
    return updated


def _upsert_coact_edge(conn, id_a: str, id_b: str, delta: float) -> int:
    """Create or update a COACTIVATION edge (bidirectional)."""
    # Normalize order for consistency
    from_id, to_id = (id_a, id_b) if id_a < id_b else (id_b, id_a)
    existing = conn.execute(
        "SELECT id, confidence FROM graph_edges "
        "WHERE from_node=? AND to_node=? AND edge_type='COACTIVATION'",
        (from_id, to_id)
    ).fetchone()
    if existing:
        new_conf = max(0.0, min(1.0, existing["confidence"] + delta))
        if new_conf < 0.01:
            conn.execute("DELETE FROM graph_edges WHERE id=?", (existing["id"],))
        else:
            conn.execute("UPDATE graph_edges SET confidence=? WHERE id=?",
                         (round(new_conf, 4), existing["id"]))
    else:
        if delta > 0:
            now = datetime.now(timezone.utc).isoformat()
            conn.execute(
                "INSERT INTO graph_edges (from_node, to_node, edge_type, confidence, metadata) "
                "VALUES (?, ?, 'COACTIVATION', ?, ?)",
                (from_id, to_id, round(max(0.05, delta), 4),
                 json.dumps({"source": "hebbian", "created": now}))
            )
    return 1
