#!/usr/bin/env python3
"""
Audit tests: session_replay integration with STDP and Hebbian.

Verifies the wiring between:
  session_replay.py → stdp.py  (temporal TEMPORAL edges)
  session_replay.py → hebbian_knowledge.py  (co-activation COACTIVATION edges)

Coverage gaps this file addresses (not in test_phase3_capabilities.py):
  1. STDP creates TEMPORAL edges on successful replay (real SQLite)
  2. STDP weakens edges on failed replay (real SQLite)
  3. Hebbian creates COACTIVATION edges on successful replay (real SQLite)
  4. Hebbian weakens edges on failed replay (real SQLite)
  5. Both legs called per session, not short-circuited on empty _resolve_item_ids
  6. STDP exception is swallowed — replay counter still increments
  7. Hebbian exception is swallowed — STDP leg already counted
  8. session_key consistency: record_coactivation and apply_hebbian use matching key
  9. Negative replay reduces salience (real SQLite UPDATE)
  10. _resolve_item_ids feeds resolved IDs into STDP + Hebbian (mock-based)
  11. Failed session routes success=False to both STDP and Hebbian
  12. STDP anti-causal edge is weaker than causal edge (constants verified)
"""
import json
import sqlite3
import sys
import tempfile
import unittest
from datetime import datetime, timezone, timedelta
from pathlib import Path
from unittest.mock import patch, MagicMock, call, ANY

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))


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

def _build_test_db() -> sqlite3.Connection:
    """In-memory SQLite with the schema required by STDP and Hebbian."""
    conn = sqlite3.connect(":memory:", check_same_thread=False)
    conn.row_factory = sqlite3.Row
    conn.execute("""
        CREATE TABLE IF NOT EXISTS graph_edges (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            from_node TEXT NOT NULL,
            to_node    TEXT NOT NULL,
            edge_type  TEXT NOT NULL,
            confidence REAL DEFAULT 0.5,
            source_node TEXT DEFAULT '',
            metadata   TEXT DEFAULT '{}',
            UNIQUE(from_node, edge_type, to_node)
        )
    """)
    conn.execute("""
        CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
            item_id UNINDEXED, content, title, domain, agent, table_name
        )
    """)
    for tbl in ("lessons", "failures", "patterns"):
        conn.execute(f"""
            CREATE TABLE IF NOT EXISTS {tbl} (
                id        TEXT PRIMARY KEY,
                content   TEXT DEFAULT '',
                salience  REAL NOT NULL DEFAULT 1.0,
                confidence REAL NOT NULL DEFAULT 0.5
            )
        """)
    conn.commit()
    return conn


def _seed_fts(conn: sqlite3.Connection, item_id: str, content: str, table: str):
    """Insert a row into FTS so _resolve_item_ids can find it."""
    conn.execute(
        "INSERT INTO knowledge_fts (item_id, content, title, domain, agent, table_name) "
        "VALUES (?, ?, '', 'general', 'test', ?)",
        (item_id, content, table),
    )
    conn.execute(
        f"INSERT OR IGNORE INTO {table} (id, content, salience) VALUES (?, ?, 1.0)",
        (item_id, content),
    )
    conn.commit()


def _make_session(session_id: str, items: list, task_id: str | None = None) -> dict:
    now = datetime.now(timezone.utc)
    return {
        "session_id": session_id,
        "task_id": task_id,
        "started_at": now.isoformat(),
        "items": items,
    }


# ---------------------------------------------------------------------------
# 1-4: STDP direct integration (real SQLite, temp state files)
# ---------------------------------------------------------------------------
class TestSTDPDirectIntegration(unittest.TestCase):
    """STDP record_retrieval + apply_stdp with a real in-memory DB."""

    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.sessions_file = self.tmp / "session_retrievals.json"
        self.conn = _build_test_db()

    def tearDown(self):
        import shutil
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _stdp_patched(self):
        """Context manager: patch STDP files to tmp dir."""
        return patch.multiple(
            "pulse.brain.stdp",
            STDP_DIR=self.tmp,
            SESSIONS_FILE=self.sessions_file,
        )

    def test_stdp_ltp_creates_temporal_edge(self):
        """Successful replay: TEMPORAL edge with positive confidence is created."""
        from pulse.brain.plasticity.stdp import record_retrieval, apply_stdp

        sid = "audit_ltp_001"
        with self._stdp_patched(), \
                patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            record_retrieval(sid, "node_A")
            record_retrieval(sid, "node_B")
            updated = apply_stdp(sid, success=True)

        self.assertGreater(updated, 0, "LTP should create at least one edge")
        row = self.conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='node_A' AND to_node='node_B' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(row, "Causal TEMPORAL edge A->B must exist")
        self.assertGreater(row["confidence"], 0, "LTP confidence must be positive")

    def test_stdp_ltd_weakens_existing_edge(self):
        """Failed replay: existing TEMPORAL edge gets reduced confidence."""
        # Pre-seed a positive edge
        self.conn.execute(
            "INSERT INTO graph_edges (from_node, to_node, edge_type, confidence, metadata) "
            "VALUES ('nA', 'nB', 'TEMPORAL', 0.5, '{}')"
        )
        self.conn.commit()

        from pulse.brain.plasticity.stdp import record_retrieval, apply_stdp
        sid = "audit_ltd_002"
        with patch.multiple("pulse.brain.stdp",
                            STDP_DIR=self.tmp,
                            SESSIONS_FILE=self.sessions_file), \
                patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            record_retrieval(sid, "nA")
            record_retrieval(sid, "nB")
            apply_stdp(sid, success=False)

        row = self.conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='nA' AND to_node='nB' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(row, "Edge should still exist after gentle LTD")
        self.assertLess(row["confidence"], 0.5, "LTD should reduce confidence")

    def test_stdp_anti_causal_weaker_than_causal(self):
        """Anti-causal edge B->A should be weaker than causal A->B after repeated LTP.

        NOTE: On first creation, _upsert_temporal_edge uses max(0.1, delta) as the
        initial confidence floor.  LTP_DELTA=0.08 and anti-causal=0.08*0.3=0.024 are
        BOTH below 0.1, so the first session creates both edges at 0.1 (equal).
        Asymmetry only becomes visible on the second session (increment path, no floor).
        """
        from pulse.brain.plasticity.stdp import record_retrieval, apply_stdp, LTP_DELTA, ANTI_CAUSAL_RATIO

        # Session 1: creates both edges at floor=0.1 (equal, due to max(0.1, delta))
        sid1 = "audit_asym_s1"
        with patch.multiple("pulse.brain.stdp",
                            STDP_DIR=self.tmp,
                            SESSIONS_FILE=self.sessions_file), \
                patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            record_retrieval(sid1, "P")
            record_retrieval(sid1, "Q")
            apply_stdp(sid1, success=True)

        # Session 2: increments existing edges; asymmetry now visible
        sid2 = "audit_asym_s2"
        with patch.multiple("pulse.brain.stdp",
                            STDP_DIR=self.tmp,
                            SESSIONS_FILE=self.sessions_file), \
                patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            record_retrieval(sid2, "P")
            record_retrieval(sid2, "Q")
            apply_stdp(sid2, success=True)

        causal = self.conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='P' AND to_node='Q' AND edge_type='TEMPORAL'"
        ).fetchone()
        anti = self.conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='Q' AND to_node='P' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(causal, "Causal edge P->Q must exist")
        self.assertIsNotNone(anti, "Anti-causal edge Q->P must exist")
        self.assertGreater(causal["confidence"], anti["confidence"],
                           "After 2 LTP sessions, causal must exceed anti-causal "
                           f"(causal={causal['confidence']:.4f}, "
                           f"anti={anti['confidence']:.4f})")

    def test_stdp_session_cleaned_after_apply(self):
        """Session entry is deleted from SESSIONS_FILE after apply_stdp."""
        from pulse.brain.plasticity.stdp import record_retrieval, apply_stdp

        sid = "audit_cleanup_004"
        with patch.multiple("pulse.brain.stdp",
                            STDP_DIR=self.tmp,
                            SESSIONS_FILE=self.sessions_file), \
                patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            record_retrieval(sid, "X")
            record_retrieval(sid, "Y")
            self.assertTrue(self.sessions_file.exists())
            apply_stdp(sid, success=True)

        if self.sessions_file.exists():
            data = json.loads(self.sessions_file.read_text(encoding="utf-8"))
            self.assertNotIn(sid, data, "Session should be removed after STDP apply")


# ---------------------------------------------------------------------------
# 5-7: Hebbian direct integration (real SQLite, temp state files)
# ---------------------------------------------------------------------------
class TestHebbianDirectIntegration(unittest.TestCase):
    """hebbian_knowledge record_coactivation + apply_hebbian with real DB."""

    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.coact_file = self.tmp / "coactivation_buffer.json"
        self.conn = _build_test_db()

    def tearDown(self):
        import shutil
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _hebb_patched(self):
        return patch("pulse.brain.hebbian_knowledge.COACT_FILE", self.coact_file)

    def test_hebbian_ltp_creates_coactivation_edge(self):
        """Successful co-retrieval creates COACTIVATION edge."""
        from pulse.brain.plasticity.hebbian_knowledge import record_coactivation, apply_hebbian

        key = datetime.now(timezone.utc).strftime("%Y%m%d_%H")
        with self._hebb_patched(), \
                patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            record_coactivation(["item_X", "item_Y"])
            updated = apply_hebbian(key, success=True)

        self.assertGreater(updated, 0, "LTP should create at least one COACTIVATION edge")
        # Canonical order: item_X < item_Y alphabetically
        from_id, to_id = sorted(["item_X", "item_Y"])
        row = self.conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node=? AND to_node=? AND edge_type='COACTIVATION'",
            (from_id, to_id),
        ).fetchone()
        self.assertIsNotNone(row, "COACTIVATION edge must exist after LTP")
        self.assertGreater(row["confidence"], 0)

    def test_hebbian_ltd_weakens_existing_edge(self):
        """Failed co-retrieval weakens an existing COACTIVATION edge."""
        from_id, to_id = sorted(["item_A", "item_B"])
        self.conn.execute(
            "INSERT INTO graph_edges (from_node, to_node, edge_type, confidence, metadata) "
            "VALUES (?, ?, 'COACTIVATION', 0.4, '{}')",
            (from_id, to_id),
        )
        self.conn.commit()

        from pulse.brain.plasticity.hebbian_knowledge import record_coactivation, apply_hebbian

        key = datetime.now(timezone.utc).strftime("%Y%m%d_%H")
        with self._hebb_patched(), \
                patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            record_coactivation(["item_A", "item_B"])
            apply_hebbian(key, success=False)

        row = self.conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node=? AND to_node=? AND edge_type='COACTIVATION'",
            (from_id, to_id),
        ).fetchone()
        self.assertIsNotNone(row)
        self.assertLess(row["confidence"], 0.4, "LTD should reduce COACTIVATION confidence")

    def test_hebbian_canonical_order_enforced(self):
        """Edge is always stored as (smaller_id, larger_id), not both directions."""
        from pulse.brain.plasticity.hebbian_knowledge import record_coactivation, apply_hebbian

        key = datetime.now(timezone.utc).strftime("%Y%m%d_%H")
        # "zzz" > "aaa" — canonical order should be aaa->zzz
        with self._hebb_patched(), \
                patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            record_coactivation(["zzz_item", "aaa_item"])
            apply_hebbian(key, success=True)

        rows = self.conn.execute(
            "SELECT from_node, to_node FROM graph_edges WHERE edge_type='COACTIVATION'"
        ).fetchall()
        self.assertGreater(len(rows), 0)
        for row in rows:
            self.assertLessEqual(row["from_node"], row["to_node"],
                                 "COACTIVATION edges must use canonical order (from<=to)")

    def test_hebbian_session_cleaned_after_apply(self):
        """Session key is removed from buffer after apply_hebbian."""
        from pulse.brain.plasticity.hebbian_knowledge import record_coactivation, apply_hebbian

        key = datetime.now(timezone.utc).strftime("%Y%m%d_%H")
        with self._hebb_patched(), \
                patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            record_coactivation(["m1", "m2"])
            apply_hebbian(key, success=True)

        if self.coact_file.exists():
            data = json.loads(self.coact_file.read_text(encoding="utf-8"))
            self.assertNotIn(key, data, "Session key must be removed after apply_hebbian")


# ---------------------------------------------------------------------------
# 8-12: session_replay orchestration (mock-based)
# ---------------------------------------------------------------------------
class TestSessionReplayOrchestration(unittest.TestCase):
    """Verify run_session_replay routes correctly to STDP and Hebbian."""

    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())
        self.completed = self.tmp / "completed"
        self.completed.mkdir()

    def tearDown(self):
        import shutil
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _write_session(self, session: dict, name: str = "s.json"):
        (self.completed / name).write_text(json.dumps(session), encoding="utf-8")

    def test_both_stdp_and_hebbian_called_on_replay(self):
        """Both record_retrieval+apply_stdp AND record_coactivation+apply_hebbian invoked."""
        items = [
            {"text": "alpha knowledge item text", "first_accessed": "2026-03-17T10:00:00"},
            {"text": "beta knowledge item text",  "first_accessed": "2026-03-17T10:00:01"},
        ]
        self._write_session(_make_session("s_both", items))

        mock_record_ret = MagicMock()
        mock_apply_stdp = MagicMock(return_value=1)
        mock_record_coact = MagicMock()
        mock_apply_hebb = MagicMock(return_value=1)

        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.completed), \
             patch("pulse.daemons.consolidation.session_replay._resolve_item_ids",
                   return_value=["id_A", "id_B"]), \
             patch("pulse.daemons.consolidation.session_replay._positive_replay",
                   return_value=2), \
             patch("pulse.brain.stdp.record_retrieval", mock_record_ret), \
             patch("pulse.brain.stdp.apply_stdp", mock_apply_stdp), \
             patch("pulse.brain.hebbian_knowledge.record_coactivation", mock_record_coact), \
             patch("pulse.brain.hebbian_knowledge.apply_hebbian", mock_apply_hebb):
            from pulse.daemons.consolidation.session_replay import run_session_replay
            result = run_session_replay(verbose=False)

        self.assertEqual(result["replayed"], 1)
        mock_record_ret.assert_called()
        mock_apply_stdp.assert_called_once()
        mock_record_coact.assert_called_once_with(["id_A", "id_B"])
        mock_apply_hebb.assert_called_once()

    def test_failed_session_routes_success_false_to_stdp(self):
        """For a failed task, apply_stdp must receive success=False."""
        items = [
            {"text": "failed item one content", "first_accessed": "2026-03-17T11:00:00"},
            {"text": "failed item two content", "first_accessed": "2026-03-17T11:00:01"},
        ]
        self._write_session(_make_session("s_fail", items, task_id="task_abc"))

        mock_apply_stdp = MagicMock(return_value=0)
        mock_apply_hebb = MagicMock(return_value=0)

        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.completed), \
             patch("pulse.daemons.consolidation.session_replay._resolve_item_ids",
                   return_value=["id_X", "id_Y"]), \
             patch("pulse.daemons.consolidation.session_replay._was_session_successful",
                   return_value=False), \
             patch("pulse.daemons.consolidation.session_replay._negative_replay"), \
             patch("pulse.brain.stdp.record_retrieval"), \
             patch("pulse.brain.stdp.apply_stdp", mock_apply_stdp), \
             patch("pulse.brain.hebbian_knowledge.record_coactivation"), \
             patch("pulse.brain.hebbian_knowledge.apply_hebbian", mock_apply_hebb):
            from pulse.daemons.consolidation.session_replay import run_session_replay
            run_session_replay(verbose=False)

        # Both should receive success=False
        args_stdp = mock_apply_stdp.call_args
        self.assertFalse(args_stdp[0][1] if args_stdp[0] else args_stdp[1]["success"],
                         "apply_stdp must receive success=False for failed sessions")
        args_hebb = mock_apply_hebb.call_args
        self.assertFalse(args_hebb[0][1] if args_hebb[0] else args_hebb[1]["success"],
                         "apply_hebbian must receive success=False for failed sessions")

    def test_stdp_exception_does_not_break_hebbian(self):
        """If STDP raises, Hebbian leg still runs (separate try/except)."""
        items = [
            {"text": "robustness item one here", "first_accessed": "2026-03-17T12:00:00"},
            {"text": "robustness item two here", "first_accessed": "2026-03-17T12:00:01"},
        ]
        self._write_session(_make_session("s_stdp_fail", items))

        mock_apply_hebb = MagicMock(return_value=1)

        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.completed), \
             patch("pulse.daemons.consolidation.session_replay._resolve_item_ids",
                   return_value=["id_P", "id_Q"]), \
             patch("pulse.daemons.consolidation.session_replay._positive_replay",
                   return_value=2), \
             patch("pulse.brain.stdp.record_retrieval",
                   side_effect=RuntimeError("STDP exploded")), \
             patch("pulse.brain.hebbian_knowledge.record_coactivation"), \
             patch("pulse.brain.hebbian_knowledge.apply_hebbian", mock_apply_hebb):
            from pulse.daemons.consolidation.session_replay import run_session_replay
            result = run_session_replay(verbose=False)

        # Replay should still count and Hebbian should still run
        self.assertEqual(result["replayed"], 1)
        mock_apply_hebb.assert_called_once()

    def test_hebbian_exception_does_not_block_replay_count(self):
        """If Hebbian raises, replay counter still increments."""
        items = [
            {"text": "hebb fail item one test", "first_accessed": "2026-03-17T13:00:00"},
            {"text": "hebb fail item two test", "first_accessed": "2026-03-17T13:00:01"},
        ]
        self._write_session(_make_session("s_hebb_fail", items))

        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.completed), \
             patch("pulse.daemons.consolidation.session_replay._resolve_item_ids",
                   return_value=["id_R", "id_S"]), \
             patch("pulse.daemons.consolidation.session_replay._positive_replay",
                   return_value=2), \
             patch("pulse.brain.stdp.record_retrieval"), \
             patch("pulse.brain.stdp.apply_stdp", return_value=1), \
             patch("pulse.brain.hebbian_knowledge.record_coactivation",
                   side_effect=RuntimeError("Hebbian exploded")):
            from pulse.daemons.consolidation.session_replay import run_session_replay
            result = run_session_replay(verbose=False)

        self.assertEqual(result["replayed"], 1, "replayed counter must increment despite Hebbian failure")

    def test_stdp_replay_session_id_format(self):
        """STDP is called with replay_{session_id} prefix, not the raw session_id."""
        items = [
            {"text": "prefix test item one hi", "first_accessed": "2026-03-17T14:00:00"},
            {"text": "prefix test item two hi", "first_accessed": "2026-03-17T14:00:01"},
        ]
        self._write_session(_make_session("original_session_99", items))

        recorded_sids = []

        def capture_record(sid, iid):
            recorded_sids.append(sid)

        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.completed), \
             patch("pulse.daemons.consolidation.session_replay._resolve_item_ids",
                   return_value=["id_1", "id_2"]), \
             patch("pulse.daemons.consolidation.session_replay._positive_replay",
                   return_value=2), \
             patch("pulse.brain.stdp.record_retrieval", side_effect=capture_record), \
             patch("pulse.brain.stdp.apply_stdp", return_value=1), \
             patch("pulse.brain.hebbian_knowledge.record_coactivation"), \
             patch("pulse.brain.hebbian_knowledge.apply_hebbian", return_value=0):
            from pulse.daemons.consolidation.session_replay import run_session_replay
            run_session_replay(verbose=False)

        self.assertTrue(all(sid.startswith("replay_") for sid in recorded_sids),
                        f"All STDP record_retrieval calls must use replay_ prefix, got: {recorded_sids}")
        self.assertIn("replay_original_session_99", recorded_sids)

    def test_insufficient_item_ids_skips_session(self):
        """Sessions that resolve to fewer than 2 items should be skipped."""
        items = [
            {"text": "only one item here test", "first_accessed": "2026-03-17T15:00:00"},
        ]
        self._write_session(_make_session("s_only_one", items))

        mock_stdp = MagicMock(return_value=0)
        mock_hebb = MagicMock(return_value=0)

        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.completed), \
             patch("pulse.daemons.consolidation.session_replay._resolve_item_ids",
                   return_value=["id_only"]), \
             patch("pulse.brain.stdp.apply_stdp", mock_stdp), \
             patch("pulse.brain.hebbian_knowledge.apply_hebbian", mock_hebb):
            from pulse.daemons.consolidation.session_replay import run_session_replay
            result = run_session_replay(verbose=False)

        self.assertEqual(result["replayed"], 0, "Sessions with <2 resolved items must be skipped")
        mock_stdp.assert_not_called()
        mock_hebb.assert_not_called()


# ---------------------------------------------------------------------------
# 13: Negative replay reduces salience in knowledge tables
# ---------------------------------------------------------------------------
class TestNegativeReplay(unittest.TestCase):
    """_negative_replay should gently reduce salience in knowledge tables."""

    def setUp(self):
        self.conn = _build_test_db()
        # Seed a lesson item with high salience
        self.conn.execute(
            "INSERT INTO lessons (id, content, salience) VALUES ('L-001', 'test content', 1.0)"
        )
        self.conn.commit()

    def test_negative_replay_reduces_lesson_salience(self):
        """Salience decreases by NEGATIVE_SAL_DELTA for failed session items."""
        from pulse.daemons.consolidation.session_replay import (
            _negative_replay, NEGATIVE_SAL_DELTA
        )
        with patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            _negative_replay(["L-001"])

        row = self.conn.execute("SELECT salience FROM lessons WHERE id='L-001'").fetchone()
        self.assertIsNotNone(row)
        expected = round(1.0 - NEGATIVE_SAL_DELTA, 4)
        self.assertAlmostEqual(row["salience"], expected, places=2,
                               msg="Salience must decrease by NEGATIVE_SAL_DELTA")

    def test_negative_replay_floor_at_half(self):
        """Salience should never drop below 0.5 (MAX(salience - delta, 0.5))."""
        from pulse.daemons.consolidation.session_replay import _negative_replay
        # Seed with near-floor salience
        self.conn.execute("UPDATE lessons SET salience = 0.51 WHERE id='L-001'")
        self.conn.commit()

        with patch("pulse.core.brain_db.get_conn", return_value=self.conn):
            # Run multiple times to try to push below floor
            for _ in range(10):
                _negative_replay(["L-001"])

        row = self.conn.execute("SELECT salience FROM lessons WHERE id='L-001'").fetchone()
        self.assertGreaterEqual(row["salience"], 0.5,
                                "Negative replay floor is 0.5 — must not go lower")


# ---------------------------------------------------------------------------
# 14: session_key consistency (Hebbian record + apply use the same key)
# ---------------------------------------------------------------------------
class TestHebbianSessionKeyConsistency(unittest.TestCase):
    """record_coactivation and apply_hebbian must use the same session_key format."""

    def test_session_key_format_matches(self):
        """Both functions use strftime('%Y%m%d_%H') — verify format consistency."""
        from pulse.brain.plasticity.hebbian_knowledge import record_coactivation
        import tempfile

        tmp = Path(tempfile.mkdtemp())
        coact_file = tmp / "coact_test.json"

        try:
            with patch("pulse.brain.hebbian_knowledge.COACT_FILE", coact_file):
                record_coactivation(["key_a", "key_b"])
                data = json.loads(coact_file.read_text(encoding="utf-8"))
                stored_keys = list(data.keys())
                self.assertEqual(len(stored_keys), 1,
                                 "Should have exactly one session bucket")

                # The key format used internally
                stored_key = stored_keys[0]
                # Verify it matches the same format apply_hebbian would use
                expected_key = datetime.now(timezone.utc).strftime("%Y%m%d_%H")
                self.assertEqual(stored_key, expected_key,
                                 f"record_coactivation key '{stored_key}' must match "
                                 f"apply_hebbian key '{expected_key}'")
        finally:
            import shutil
            shutil.rmtree(tmp, ignore_errors=True)


# ---------------------------------------------------------------------------
# 15-18: _load_recent_sessions time-based filtering
# ---------------------------------------------------------------------------
class TestLoadRecentSessions(unittest.TestCase):
    """Verify _load_recent_sessions only returns sessions within the 48h cutoff."""

    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp())

    def tearDown(self):
        import shutil
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _write_json(self, name: str, data: dict) -> Path:
        p = self.tmp / name
        p.write_text(json.dumps(data), encoding="utf-8")
        return p

    def test_recent_session_is_loaded(self):
        """A session file modified within the last 48h is returned."""
        from pulse.daemons.consolidation.session_replay import _load_recent_sessions

        session = _make_session("recent_s1", [
            {"text": "recent item alpha", "first_accessed": "2026-03-17T10:00:00"},
            {"text": "recent item beta",  "first_accessed": "2026-03-17T10:00:01"},
        ])
        self._write_json("recent.json", session)

        cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.tmp):
            result = _load_recent_sessions(cutoff)

        self.assertEqual(len(result), 1, "Recent session must be returned")
        self.assertEqual(result[0]["session_id"], "recent_s1")

    def test_old_session_is_excluded(self):
        """A session file modified more than 48h ago is NOT returned."""
        import os, time
        from pulse.daemons.consolidation.session_replay import _load_recent_sessions

        session = _make_session("old_s1", [
            {"text": "stale item one here", "first_accessed": "2026-03-10T10:00:00"},
            {"text": "stale item two here", "first_accessed": "2026-03-10T10:00:01"},
        ])
        p = self._write_json("old.json", session)
        # Backdate mtime to 72 hours ago
        old_mtime = time.time() - (72 * 3600)
        os.utime(str(p), (old_mtime, old_mtime))

        cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.tmp):
            result = _load_recent_sessions(cutoff)

        session_ids = [s["session_id"] for s in result]
        self.assertNotIn("old_s1", session_ids, "Session older than 48h must be excluded")

    def test_session_without_items_is_excluded(self):
        """A valid JSON file that lacks the 'items' key is silently skipped."""
        from pulse.daemons.consolidation.session_replay import _load_recent_sessions

        self._write_json("no_items.json", {"session_id": "s_no_items", "task_id": "t_1"})

        cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.tmp):
            result = _load_recent_sessions(cutoff)

        self.assertEqual(len(result), 0, "Session without 'items' must be skipped")

    def test_mixed_recent_and_old_returns_only_recent(self):
        """Only recent sessions are returned when both old and new files exist."""
        import os, time
        from pulse.daemons.consolidation.session_replay import _load_recent_sessions

        recent = _make_session("recent_mix", [
            {"text": "fresh item one content", "first_accessed": "2026-03-17T09:00:00"},
            {"text": "fresh item two content", "first_accessed": "2026-03-17T09:00:01"},
        ])
        self._write_json("recent_mix.json", recent)

        old = _make_session("old_mix", [
            {"text": "expired item one old", "first_accessed": "2026-03-10T09:00:00"},
            {"text": "expired item two old", "first_accessed": "2026-03-10T09:00:01"},
        ])
        p_old = self._write_json("old_mix.json", old)
        old_mtime = time.time() - (72 * 3600)
        os.utime(str(p_old), (old_mtime, old_mtime))

        cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.tmp):
            result = _load_recent_sessions(cutoff)

        session_ids = [s["session_id"] for s in result]
        self.assertIn("recent_mix", session_ids, "Recent session must be returned")
        self.assertNotIn("old_mix", session_ids, "Old session must not be returned")

    def test_empty_completed_dir_returns_empty_list(self):
        """No files in completed/ returns an empty list without error."""
        from pulse.daemons.consolidation.session_replay import _load_recent_sessions

        cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.tmp):
            result = _load_recent_sessions(cutoff)

        self.assertEqual(result, [], "Empty completed dir must return []")

    def test_nonexistent_completed_dir_returns_empty_list(self):
        """Missing state/completed/ directory returns [] without raising."""
        from pulse.daemons.consolidation.session_replay import _load_recent_sessions

        missing = self.tmp / "nonexistent"
        cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", missing):
            result = _load_recent_sessions(cutoff)

        self.assertEqual(result, [], "Missing completed dir must return []")

    def test_results_sorted_most_recent_first(self):
        """Returned sessions are sorted by started_at descending."""
        from pulse.daemons.consolidation.session_replay import _load_recent_sessions

        now = datetime.now(timezone.utc)
        earlier = _make_session("s_earlier", [
            {"text": "earlier item one test", "first_accessed": now.isoformat()},
            {"text": "earlier item two test", "first_accessed": now.isoformat()},
        ])
        earlier["started_at"] = (now - timedelta(hours=1)).isoformat()

        later = _make_session("s_later", [
            {"text": "later item one test", "first_accessed": now.isoformat()},
            {"text": "later item two test", "first_accessed": now.isoformat()},
        ])
        later["started_at"] = now.isoformat()

        self._write_json("earlier.json", earlier)
        self._write_json("later.json", later)

        cutoff = now - timedelta(hours=48)
        with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", self.tmp):
            result = _load_recent_sessions(cutoff)

        self.assertEqual(len(result), 2)
        self.assertEqual(result[0]["session_id"], "s_later",
                         "Most recently started session must come first")
        self.assertEqual(result[1]["session_id"], "s_earlier")


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