#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""P45 Neuroscience Fixes — N46-N50 tests."""
import json
import sqlite3
import time
import types
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_conn(rows=None):
    """In-memory SQLite that supports the graph_edges queries used in tests."""
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    conn.execute(
        "CREATE TABLE graph_edges "
        "(id INTEGER PRIMARY KEY AUTOINCREMENT, from_node TEXT, to_node TEXT, "
        "edge_type TEXT, confidence REAL, metadata TEXT)"
    )
    conn.execute(
        "CREATE TABLE graph_nodes (id TEXT PRIMARY KEY, text TEXT)"
    )
    conn.execute(
        "CREATE TABLE anti_patterns "
        "(id TEXT PRIMARY KEY, description TEXT, rule TEXT, evidence_count INTEGER, "
        "confidence REAL, domain TEXT, status TEXT)"
    )
    conn.execute(
        "CREATE TABLE knowledge_fts (item_id TEXT, table_name TEXT, content TEXT)"
    )
    if rows:
        for r in rows:
            conn.execute(
                "INSERT INTO graph_edges (from_node, to_node, edge_type, confidence, metadata) "
                "VALUES (?,?,?,?,?)",
                (r["from_node"], r["to_node"], r.get("edge_type", "COACTIVATION"),
                 r["confidence"], r.get("metadata", "{}"))
            )
    conn.commit()
    return conn


# ---------------------------------------------------------------------------
# N46 — Cowan capacity: 3-tier slot assignment
# ---------------------------------------------------------------------------

class TestN46CowanCapacity(unittest.TestCase):
    """Verify 3-tier slot typing: focal / activated / background."""

    def _build_session_with_n_items(self, n):
        """Build a minimal working memory session dict with n items."""
        items = []
        for i in range(n):
            items.append({
                "text": f"item {i}",
                "fingerprint": f"fp{i:04d}",
                "salience": 1.0,
                "accessed_count": 1,
                "first_accessed": "2026-01-01T00:00:00+00:00",
                "last_accessed":  "2026-01-01T00:00:00+00:00",
                "source": "test",
            })
        return {"agent": "test", "items": items, "max_items": 20}

    def test_slot_types_for_15_items(self):
        """Add 15 items; verify 4 focal, 5 activated, 6 background."""
        from pulse.brain.memory.working_memory import _evict

        session = self._build_session_with_n_items(15)
        _evict(session)
        items = session["items"]

        focal_count = sum(1 for it in items if it.get("slot_type") == "focal")
        activated_count = sum(1 for it in items if it.get("slot_type") == "activated")
        background_count = sum(1 for it in items if it.get("slot_type") == "background")

        self.assertEqual(focal_count, 4, f"Expected 4 focal, got {focal_count}")
        self.assertEqual(activated_count, 5, f"Expected 5 activated, got {activated_count}")
        self.assertEqual(background_count, 6, f"Expected 6 background, got {background_count}")

    def test_get_active_contexts_returns_at_most_4(self):
        """get_active_contexts must return at most 4 focal items."""
        from pulse.brain import working_memory as wm

        # Patch _load_session to return a pre-built session
        session = self._build_session_with_n_items(15)
        wm._evict(session)

        with patch.object(wm, "_load_session", return_value=session):
            result = wm.get_active_contexts("test_agent")

        focal_only = all(it.get("slot_type") == "focal" or it.get("priority_locked") for it in result)
        self.assertTrue(focal_only, "get_active_contexts returned non-focal items")
        self.assertLessEqual(len(result), 4, f"Returned {len(result)} items, expected ≤ 4")

    def test_no_active_slot_type_remains(self):
        """The old 'active' slot_type must not appear after eviction."""
        from pulse.brain.memory.working_memory import _evict

        session = self._build_session_with_n_items(10)
        _evict(session)
        for it in session["items"]:
            self.assertNotEqual(it.get("slot_type"), "active",
                                "Old 'active' slot_type still present after N46 fix")


# ---------------------------------------------------------------------------
# N47 — IOR penalty value and expiry
# ---------------------------------------------------------------------------

class TestN47IORPenalty(unittest.TestCase):
    """Verify IOR penalty is 0.15 and expires after 30 seconds."""

    def test_penalty_is_0_15_immediately_after_broadcast(self):
        from pulse.brain.memory.working_memory import _mark_broadcast, _get_ior_penalty, _RECENTLY_BROADCAST

        _RECENTLY_BROADCAST.clear()
        _mark_broadcast(["test_item_ior"])
        penalty = _get_ior_penalty("test_item_ior")
        self.assertAlmostEqual(penalty, 0.15, places=5,
                               msg=f"IOR penalty should be 0.15, got {penalty}")

    def test_penalty_is_zero_after_30s(self):
        """Mock time so the timestamp appears 31 seconds old."""
        from pulse.brain import working_memory as wm

        wm._RECENTLY_BROADCAST.clear()
        # Insert a timestamp that is 31 seconds in the past
        old_ts = time.time() - 31
        wm._RECENTLY_BROADCAST["stale_item"] = old_ts

        penalty = wm._get_ior_penalty("stale_item")
        self.assertEqual(penalty, 0.0,
                         f"IOR penalty should be 0.0 after 31s, got {penalty}")

    def test_penalty_zero_for_unbroadcast_item(self):
        from pulse.brain.memory.working_memory import _get_ior_penalty, _RECENTLY_BROADCAST

        _RECENTLY_BROADCAST.clear()
        self.assertEqual(_get_ior_penalty("never_broadcast"), 0.0)


# ---------------------------------------------------------------------------
# N48 — Synaptic scaling / Hebbian weight normalization
# ---------------------------------------------------------------------------

class TestN48SynapticScaling(unittest.TestCase):
    """Verify outgoing COACTIVATION weights are capped at sum=3.0."""

    def _insert_coact_edges(self, conn, node_a, targets, confidence=1.0):
        for t in targets:
            from_id, to_id = (node_a, t) if node_a < t else (t, node_a)
            conn.execute(
                "INSERT INTO graph_edges (from_node, to_node, edge_type, confidence, metadata) "
                "VALUES (?,?,'COACTIVATION',?,?)",
                (from_id, to_id, confidence, "{}")
            )
        conn.commit()

    def test_sum_clamped_to_3(self):
        """5 COACTIVATION edges at conf=1.0 (sum=5.0) → after scaling sum=3.0."""
        from pulse.brain.plasticity.hebbian_knowledge import _normalize_outgoing_edges, MAX_OUTGOING_WEIGHT_SUM

        conn = _make_conn()
        # Use lexicographically smallest as from_node so normalized edges go that way
        node_a = "aaa"
        targets = ["bbb", "ccc", "ddd", "eee", "fff"]
        self._insert_coact_edges(conn, node_a, targets, confidence=1.0)

        _normalize_outgoing_edges(conn, node_a)
        conn.commit()

        rows = conn.execute(
            "SELECT confidence FROM graph_edges WHERE from_node=? AND edge_type='COACTIVATION'",
            (node_a,)
        ).fetchall()
        total = sum(float(r[0]) for r in rows)
        self.assertAlmostEqual(total, MAX_OUTGOING_WEIGHT_SUM, places=3,
                               msg=f"Total weight after scaling should be ~3.0, got {total}")

    def test_ratios_preserved(self):
        """Proportional scaling must preserve relative ratios between edges."""
        from pulse.brain.plasticity.hebbian_knowledge import _normalize_outgoing_edges

        conn = _make_conn()
        # Use edges with unequal weights to test ratio preservation
        node_a = "aaa"
        confs = [2.0, 1.0, 1.0, 0.5, 0.5]  # sum=5.0
        targets = ["bbb", "ccc", "ddd", "eee", "fff"]
        for t, c in zip(targets, confs):
            from_id, to_id = (node_a, t) if node_a < t else (t, node_a)
            conn.execute(
                "INSERT INTO graph_edges (from_node, to_node, edge_type, confidence, metadata) "
                "VALUES (?,?,'COACTIVATION',?,?)", (from_id, to_id, c, "{}")
            )
        conn.commit()

        _normalize_outgoing_edges(conn, node_a)
        conn.commit()

        rows = conn.execute(
            "SELECT from_node, to_node, confidence FROM graph_edges "
            "WHERE from_node=? AND edge_type='COACTIVATION' ORDER BY to_node",
            (node_a,)
        ).fetchall()
        scaled = [float(r[2]) for r in rows]
        # First edge had double weight of second — ratio should still be ~2:1
        self.assertAlmostEqual(scaled[0] / scaled[1], 2.0, places=2,
                               msg=f"Ratio not preserved: {scaled}")

    def test_no_scaling_below_cap(self):
        """If sum <= 3.0, normalization must not change anything."""
        from pulse.brain.plasticity.hebbian_knowledge import _normalize_outgoing_edges

        conn = _make_conn()
        node_a = "aaa"
        conn.execute(
            "INSERT INTO graph_edges (from_node, to_node, edge_type, confidence, metadata) "
            "VALUES ('aaa','bbb','COACTIVATION',0.5,'{}')"
        )
        conn.commit()
        _normalize_outgoing_edges(conn, node_a)
        conn.commit()
        row = conn.execute(
            "SELECT confidence FROM graph_edges WHERE from_node='aaa'"
        ).fetchone()
        self.assertAlmostEqual(float(row[0]), 0.5, places=5,
                               msg="Edge below cap should not be scaled")


# ---------------------------------------------------------------------------
# N49 — BCM sliding threshold in STDP
# ---------------------------------------------------------------------------

class TestN49BCMThreshold(unittest.TestCase):
    """Verify BCM theta_M rises with high activity and falls with low activity."""

    def _seed_burst_timestamps(self, count, within_seconds=60):
        """Fill _BURST_TIMESTAMPS with `count` recent events."""
        from pulse.brain import stdp as stdp_mod
        now = time.time()
        stdp_mod._BURST_TIMESTAMPS.clear()
        stdp_mod._BURST_TIMESTAMPS.extend([now - i * (within_seconds / max(count, 1))
                                           for i in range(count)])

    def test_high_activity_raises_threshold(self):
        """20 events in 5min → theta_M close to 1.0 (LTP harder)."""
        from pulse.brain.plasticity.stdp import _get_bcm_theta, _BURST_TIMESTAMPS, _BCM_MAX_EXPECTED

        self._seed_burst_timestamps(20)
        theta = _get_bcm_theta()
        expected_approx = (20 / _BCM_MAX_EXPECTED) ** 2  # = 1.0
        self.assertGreaterEqual(theta, 0.8,
                                f"High-activity theta should be high (>=0.8), got {theta:.4f}")

    def test_low_activity_lowers_threshold(self):
        """2 events in 5min → theta_M close to 0 (LTP easier)."""
        from pulse.brain.plasticity.stdp import _get_bcm_theta, _BURST_TIMESTAMPS, _BCM_MAX_EXPECTED

        self._seed_burst_timestamps(2)
        theta = _get_bcm_theta()
        self.assertLessEqual(theta, 0.05,
                             f"Low-activity theta should be low (<=0.05), got {theta:.4f}")

    def test_bcm_functions_exist_and_old_ones_removed(self):
        """_get_bcm_theta must exist; old _get_metaplasticity_rate/_get_frequency_scale must be gone."""
        import pulse.brain.plasticity.stdp as stdp_mod
        self.assertTrue(hasattr(stdp_mod, "_get_bcm_theta"),
                        "_get_bcm_theta not found in stdp module")
        self.assertFalse(hasattr(stdp_mod, "_get_metaplasticity_rate"),
                         "_get_metaplasticity_rate should have been removed by N49")
        self.assertFalse(hasattr(stdp_mod, "_get_frequency_scale"),
                         "_get_frequency_scale should have been removed by N49")


# ---------------------------------------------------------------------------
# N50 — Graded inhibition in gate system
# ---------------------------------------------------------------------------

class TestN50GradedInhibition(unittest.TestCase):
    """Verify 3-level graded inhibition: hard_block / soft_block / advisory."""

    def _make_anti_pattern_db(self, confidence, evidence_count=10, use_never=True):
        """Return a Path to a temp SQLite with one anti-pattern at given confidence.

        use_never=True  → rule contains NEVER (severity 2x multiplier applies).
        use_never=False → plain rule text (severity 1x, effective_conf == confidence).
        """
        import tempfile, os
        tmp = Path(tempfile.mktemp(suffix=".db"))
        conn = sqlite3.connect(str(tmp))
        conn.execute(
            "CREATE TABLE anti_patterns "
            "(id TEXT, description TEXT, rule TEXT, evidence_count INTEGER, "
            "confidence REAL, domain TEXT, status TEXT)"
        )
        conn.execute(
            "CREATE VIRTUAL TABLE knowledge_fts USING fts5(item_id, table_name, content)"
        )
        if use_never:
            rule_text = "NEVER use sys.exit in library code"
        else:
            rule_text = "Avoid using sys.exit in library code (use raise instead)"
        conn.execute(
            "INSERT INTO anti_patterns VALUES (?,?,?,?,?,?,?)",
            ("ap1", rule_text, rule_text, evidence_count, confidence, "general", "active")
        )
        conn.execute(
            "INSERT INTO knowledge_fts VALUES (?,?,?)",
            ("ap1", "anti_patterns", rule_text)
        )
        conn.commit()
        conn.close()
        return tmp

    def test_hard_block_at_0_99(self):
        """confidence=0.99 (NEVER rule, severity*2) → hard_block via gate1_graded."""
        from pulse.brain.integrity.gate_checks import gate1_anti_pattern, gate1_graded

        db = self._make_anti_pattern_db(confidence=0.99)
        try:
            keywords = ["sysexit", "library"]
            graded = gate1_graded(keywords, "sys.exit(0)", db)
            self.assertIsNotNone(graded, "graded dict should not be None for a match")
            self.assertEqual(graded["level"], "hard_block",
                             f"Expected hard_block, got {graded['level']}")
            # Backward-compat 2-tuple still works
            blocked, msg = gate1_anti_pattern(keywords, "sys.exit(0)", db)
            self.assertTrue(blocked)
        finally:
            db.unlink(missing_ok=True)

    def test_soft_block_at_0_85(self):
        """confidence=0.85 (no NEVER multiplier) → soft_block via gate1_graded."""
        from pulse.brain.integrity.gate_checks import gate1_graded

        # use_never=False keeps effective_conf == 0.85 (no 2x severity boost)
        db = self._make_anti_pattern_db(confidence=0.85, use_never=False)
        try:
            keywords = ["sysexit", "library"]
            graded = gate1_graded(keywords, "sys.exit(0)", db)
            self.assertIsNotNone(graded)
            self.assertEqual(graded["level"], "soft_block",
                             f"Expected soft_block, got {graded['level']}")
        finally:
            db.unlink(missing_ok=True)

    def test_advisory_at_0_70(self):
        """confidence=0.70 → advisory (may or may not block at gate threshold)."""
        from pulse.brain.integrity.gate_checks import _graded_gate_result, _HARD_BLOCK_THRESHOLD, _SOFT_BLOCK_THRESHOLD

        graded = _graded_gate_result(0.70, "advisory message")
        self.assertEqual(graded["level"], "advisory",
                         f"Expected advisory, got {graded['level']}")
        self.assertAlmostEqual(graded["confidence"], 0.70, places=3)
        self.assertIn("message", graded)

    def test_no_match_returns_none_graded(self):
        """No matching anti-pattern → gate1_graded returns None."""
        from pulse.brain.integrity.gate_checks import gate1_graded

        db = self._make_anti_pattern_db(confidence=0.99)
        try:
            graded = gate1_graded([], "safe code", db)
            self.assertIsNone(graded)
            # 2-tuple backward compat
            from pulse.brain.integrity.gate_checks import gate1_anti_pattern
            blocked, msg = gate1_anti_pattern([], "safe code", db)
            self.assertFalse(blocked)
            self.assertIsNone(msg)
        finally:
            db.unlink(missing_ok=True)

    def test_graded_gate_result_helper(self):
        """Direct unit test of _graded_gate_result for all three levels."""
        from pulse.brain.integrity.gate_checks import _graded_gate_result

        hard = _graded_gate_result(0.99, "hard msg")
        self.assertEqual(hard["level"], "hard_block")
        self.assertEqual(hard["confidence"], 0.99)

        soft = _graded_gate_result(0.85, "soft msg")
        self.assertEqual(soft["level"], "soft_block")

        adv = _graded_gate_result(0.70, "adv msg")
        self.assertEqual(adv["level"], "advisory")


if __name__ == "__main__":
    unittest.main()
