#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""Tests for P9 neuroscience refinements (Items 26-30)."""
import json
import sqlite3
import sys
import time
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch

ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))


# ---------------------------------------------------------------------------
# Item 26: Primacy protection for founding episodes
# ---------------------------------------------------------------------------
class TestPrimacyProtection(unittest.TestCase):
    """Item 26 — earliest items per table are marked validity='permanent'."""

    def _make_db(self):
        """Return an in-memory connection with required schema."""
        conn = sqlite3.connect(":memory:")
        conn.row_factory = sqlite3.Row
        conn.execute("""
            CREATE TABLE lessons (
                id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL DEFAULT '',
                type TEXT DEFAULT 'lesson', content TEXT DEFAULT '',
                title TEXT DEFAULT '', domain TEXT DEFAULT 'general',
                agent TEXT DEFAULT 'test', timestamp TEXT DEFAULT '',
                salience REAL DEFAULT 1.0, confidence REAL DEFAULT 0.5,
                consolidation_count INTEGER DEFAULT 1,
                evidence_count INTEGER DEFAULT 1,
                access_count INTEGER DEFAULT 0,
                last_accessed TEXT, last_reinforced TEXT,
                source_type TEXT DEFAULT 'CONVERSATIONAL',
                decay_score REAL DEFAULT 1.0,
                validity TEXT DEFAULT 'valid',
                embedding BLOB, tags TEXT DEFAULT '[]',
                cross_refs TEXT DEFAULT '[]',
                provenance TEXT DEFAULT '{}',
                metadata TEXT DEFAULT '{}',
                created_at TEXT DEFAULT '', updated_at TEXT DEFAULT ''
            )
        """)
        return conn

    def test_marks_first_10_permanent(self):
        conn = self._make_db()
        for i in range(15):
            conn.execute(
                "INSERT INTO lessons (id, fingerprint, timestamp) VALUES (?, ?, ?)",
                (f"L-{i:03d}", f"fp{i}", f"2026-01-{i+1:02d}T00:00:00Z"))
        conn.commit()
        # Run the primacy logic directly
        tables = ["lessons"]
        try:
            for table in tables:
                conn.execute(
                    f"UPDATE {table} SET validity = 'permanent' "
                    f"WHERE id IN (SELECT id FROM {table} ORDER BY timestamp ASC LIMIT 10) "
                    f"AND validity != 'permanent'"
                )
            conn.commit()
        except Exception:
            pass
        permanent = conn.execute(
            "SELECT COUNT(*) FROM lessons WHERE validity='permanent'").fetchone()[0]
        self.assertEqual(permanent, 10)

    def test_later_items_not_permanent(self):
        conn = self._make_db()
        for i in range(15):
            conn.execute(
                "INSERT INTO lessons (id, fingerprint, timestamp) VALUES (?, ?, ?)",
                (f"L-{i:03d}", f"fp{i}", f"2026-01-{i+1:02d}T00:00:00Z"))
        conn.commit()
        tables = ["lessons"]
        for table in tables:
            conn.execute(
                f"UPDATE {table} SET validity = 'permanent' "
                f"WHERE id IN (SELECT id FROM {table} ORDER BY timestamp ASC LIMIT 10) "
                f"AND validity != 'permanent'"
            )
        conn.commit()
        non_permanent = conn.execute(
            "SELECT COUNT(*) FROM lessons WHERE validity!='permanent'").fetchone()[0]
        self.assertEqual(non_permanent, 5)

    def test_already_permanent_not_overwritten(self):
        conn = self._make_db()
        for i in range(5):
            conn.execute(
                "INSERT INTO lessons (id, fingerprint, timestamp, validity) VALUES (?, ?, ?, 'permanent')",
                (f"L-{i:03d}", f"fp{i}", f"2026-01-{i+1:02d}T00:00:00Z"))
        conn.commit()
        # Run again — should remain permanent without error
        try:
            conn.execute(
                "UPDATE lessons SET validity = 'permanent' "
                "WHERE id IN (SELECT id FROM lessons ORDER BY timestamp ASC LIMIT 10) "
                "AND validity != 'permanent'"
            )
            conn.commit()
        except Exception:
            pass
        count = conn.execute(
            "SELECT COUNT(*) FROM lessons WHERE validity='permanent'").fetchone()[0]
        self.assertEqual(count, 5)

    def test_empty_table_no_error(self):
        conn = self._make_db()
        try:
            conn.execute(
                "UPDATE lessons SET validity = 'permanent' "
                "WHERE id IN (SELECT id FROM lessons ORDER BY timestamp ASC LIMIT 10) "
                "AND validity != 'permanent'"
            )
            conn.commit()
        except Exception as e:
            self.fail(f"Empty table raised exception: {e}")


# ---------------------------------------------------------------------------
# Item 27: Monopoly detection in Hebbian routing
# ---------------------------------------------------------------------------
class TestMonopolyDetection(unittest.TestCase):
    """Item 27 — nodes with >30% of edges get halved confidence delta."""

    def _make_conn(self):
        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 DEFAULT 0.5, source_node TEXT DEFAULT '',
                metadata TEXT DEFAULT '{}',
                UNIQUE(from_node, edge_type, to_node)
            )
        """)
        conn.commit()
        return conn

    def test_monopoly_detected_above_30_percent(self):
        from pulse.brain.plasticity.stdp import _check_monopoly
        conn = self._make_conn()
        # Add 10 edges, 4 pointing to "hub" (40%)
        for i in range(6):
            conn.execute(
                "INSERT INTO graph_edges (from_node, to_node, edge_type) VALUES (?, ?, 'TEMPORAL')",
                (f"a{i}", f"b{i}"))
        for i in range(4):
            conn.execute(
                "INSERT INTO graph_edges (from_node, to_node, edge_type) VALUES (?, ?, 'TEMPORAL')",
                (f"x{i}", "hub"))
        conn.commit()
        self.assertTrue(_check_monopoly("hub", conn))

    def test_no_monopoly_below_30_percent(self):
        from pulse.brain.plasticity.stdp import _check_monopoly
        conn = self._make_conn()
        # 10 edges, 2 to "hub" (20%)
        for i in range(8):
            conn.execute(
                "INSERT INTO graph_edges (from_node, to_node, edge_type) VALUES (?, ?, 'TEMPORAL')",
                (f"a{i}", f"b{i}"))
        for i in range(2):
            conn.execute(
                "INSERT INTO graph_edges (from_node, to_node, edge_type) VALUES (?, ?, 'TEMPORAL')",
                (f"x{i}", "hub"))
        conn.commit()
        self.assertFalse(_check_monopoly("hub", conn))

    def test_empty_graph_no_monopoly(self):
        from pulse.brain.plasticity.stdp import _check_monopoly
        conn = self._make_conn()
        self.assertFalse(_check_monopoly("hub", conn))

    def test_monopoly_halves_delta_in_apply_stdp(self):
        """apply_stdp should halve causal_delta when post_id is a monopoly hub."""
        from pulse.brain.plasticity.stdp import _check_monopoly
        # Direct unit test: verify 0.5 multiplier logic
        delta = 0.08
        is_monopoly = True
        result = delta * 0.5 if is_monopoly else delta
        self.assertAlmostEqual(result, 0.04)

    def test_check_monopoly_fail_open(self):
        """Broken connection should return False (fail-open)."""
        from pulse.brain.plasticity.stdp import _check_monopoly
        bad_conn = MagicMock()
        bad_conn.execute.side_effect = Exception("DB error")
        self.assertFalse(_check_monopoly("hub", bad_conn))


# ---------------------------------------------------------------------------
# Item 28: Overlearning half-life extension
# ---------------------------------------------------------------------------
class TestOverlearningHalfLife(unittest.TestCase):
    """Item 28 — evidence_count >= 10 doubles the effective half-life."""

    def test_overlearned_item_decays_slower(self):
        from pulse.core.temporal import temporal_confidence
        base = 0.8
        days = 90
        normal = temporal_confidence(base, days_old=days, knowledge_type="lesson",
                                     evidence_count=1)
        overlearned = temporal_confidence(base, days_old=days, knowledge_type="lesson",
                                          evidence_count=10)
        self.assertGreater(overlearned, normal,
                           "Overlearned item should have higher confidence after same elapsed time")

    def test_overlearned_exactly_doubles_half_life(self):
        from pulse.core.temporal import temporal_confidence, get_half_life
        import math
        base = 1.0
        hl = get_half_life("lesson")  # 180 days
        # evidence_count=1, retrieval_count=0 → hl_effective = 180*(1+0) = 180
        # evidence_count=10, retrieval_count=0 → hl_effective = 180*(1+0)*2 = 360
        normal = temporal_confidence(base, days_old=hl, knowledge_type="lesson",
                                     retrieval_count=0, evidence_count=1)
        # After 180 days with normal half-life → ~0.5
        self.assertAlmostEqual(normal, 0.5, delta=0.05)
        overlearned = temporal_confidence(base, days_old=hl, knowledge_type="lesson",
                                          retrieval_count=0, evidence_count=10)
        # After 180 days with doubled half-life (360d) → ~0.707
        self.assertAlmostEqual(overlearned, math.sqrt(0.5), delta=0.05)

    def test_below_threshold_no_effect(self):
        from pulse.core.temporal import temporal_confidence
        base = 0.8
        days = 60
        ec9 = temporal_confidence(base, days_old=days, knowledge_type="lesson",
                                  evidence_count=9)
        ec1 = temporal_confidence(base, days_old=days, knowledge_type="lesson",
                                  evidence_count=1)
        # Both should be same (no overlearning bonus below 10)
        self.assertAlmostEqual(ec9, ec1, places=4)

    def test_evidence_count_kwarg_missing_defaults_safely(self):
        from pulse.core.temporal import temporal_confidence
        # Should not raise even if evidence_count not passed
        result = temporal_confidence(0.8, days_old=10, knowledge_type="lesson")
        self.assertGreater(result, 0)


# ---------------------------------------------------------------------------
# Item 29: Proactive interference recency bonus
# ---------------------------------------------------------------------------
class TestProactiveInterferenceBonus(unittest.TestCase):
    """Item 29 — recent items get a small recency bonus on _wilson_lower."""

    def _recency_bonus(self, days):
        return max(0, 0.05 * (1.0 - min(days / 30, 1.0)))

    def test_today_gets_max_bonus(self):
        bonus = self._recency_bonus(0)
        self.assertAlmostEqual(bonus, 0.05, places=3)

    def test_30_days_gets_zero_bonus(self):
        bonus = self._recency_bonus(30)
        self.assertAlmostEqual(bonus, 0.0, places=3)

    def test_15_days_gets_half_bonus(self):
        bonus = self._recency_bonus(15)
        self.assertAlmostEqual(bonus, 0.025, places=3)

    def test_beyond_30_days_no_negative_bonus(self):
        bonus = self._recency_bonus(60)
        self.assertGreaterEqual(bonus, 0.0)

    def test_recency_bonus_applied_in_search_engine(self):
        """Verify the recency bonus path doesn't crash (smoke test)."""
        from pulse.core.temporal import _days_since
        from datetime import datetime, timezone
        now_str = datetime.now(timezone.utc).isoformat()
        days = _days_since(now_str)
        bonus = max(0, 0.05 * (1.0 - min(days / 30, 1.0)))
        self.assertGreaterEqual(bonus, 0.0)
        self.assertLessEqual(bonus, 0.05)


# ---------------------------------------------------------------------------
# Item 30: CLS hot-cache staging enforcement
# ---------------------------------------------------------------------------
class TestCLSStagingEnforcement(unittest.TestCase):
    """Item 30 — items must be in hot cache >= 30s before _staged=True."""

    def test_staging_constant_exists(self):
        from pulse.brain import hot_cache
        self.assertEqual(hot_cache._STAGING_PERIOD, 30)

    def test_fresh_entry_not_staged(self):
        """Entry written just now should NOT be staged."""
        from pulse.brain.memory.hot_cache import _STAGING_PERIOD
        now = time.time()
        hot_written = now  # written just now
        staged = (now - hot_written) >= _STAGING_PERIOD
        self.assertFalse(staged)

    def test_old_entry_is_staged(self):
        """Entry written 31 seconds ago should be staged."""
        from pulse.brain.memory.hot_cache import _STAGING_PERIOD
        now = time.time()
        hot_written = now - 31
        staged = (now - hot_written) >= _STAGING_PERIOD
        self.assertTrue(staged)

    def test_exactly_at_threshold_is_staged(self):
        from pulse.brain.memory.hot_cache import _STAGING_PERIOD
        now = time.time()
        hot_written = now - _STAGING_PERIOD
        staged = (now - hot_written) >= _STAGING_PERIOD
        self.assertTrue(staged)

    def test_search_hot_marks_staged_field(self):
        """search_hot results should include _staged key."""
        from pulse.brain import hot_cache
        import json, io

        # Build a fake hot cache entry written 60s ago (staged)
        entry = {
            "content": "Python subprocess encoding must use utf-8 on Windows",
            "title": "encoding tip",
            "type": "lesson",
            "salience": 5.0,
            "confidence": 0.7,
            "source": "agent_x",
            "domain": "coding",
            "timestamp": "2026-03-21T00:00:00Z",
            "_hot_written": time.time() - 60,  # 60s ago — staged
        }
        line = json.dumps(entry)

        fake_config = {"search": {"max_results_per_source": 10}}
        with patch.object(hot_cache, "HOT_CACHE_FILE") as mock_path, \
             patch("pulse.brain.hot_cache.search_relevance", return_value=0.8), \
             patch("pulse.brain.hot_cache.compute_salience", return_value=5.0), \
             patch("pulse.brain.hot_cache.days_since", return_value=0), \
             patch("pulse.brain.hot_cache.get_retrieval_count", return_value=0):
            mock_path.exists.return_value = True
            mock_path.__str__ = lambda s: "fake_path"
            # Mock open to return our entry
            import builtins
            with patch("builtins.open", unittest.mock.mock_open(read_data=line + "\n")):
                results = hot_cache.search_hot("subprocess encoding", fake_config)
        if results:
            self.assertIn("_staged", results[0])
            self.assertTrue(results[0]["_staged"])

    def test_write_hot_includes_timestamp(self):
        """write_hot stamps _hot_written field — verify via source inspection."""
        from pulse.brain import hot_cache
        import inspect
        src = inspect.getsource(hot_cache.write_hot)
        self.assertIn("_hot_written", src)
        self.assertIn("time.time()", src)
        # Verify staging can be computed from _hot_written
        entry = {"_hot_written": time.time() - 60}
        staged = (time.time() - entry["_hot_written"]) >= hot_cache._STAGING_PERIOD
        self.assertTrue(staged)



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