#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""
Tests for P45 Neuroscience fixes N56-N60.

N56: REM/NREM phase split in sleep_cycle.run_sleep_cycle()
N57: SM-2 per-item spaced retrieval (SQLite-backed)
N58: Source recollection in briefing output ([agent, Xd ago] format)
N59: Post-retrieval relevance monitoring (drop items below 0.15 similarity)
N60: Wire 3 dead p36 functions into search_post_scoring
"""
import sys
import types
import time
import sqlite3
import tempfile
from pathlib import Path
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock, patch, call

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


# ---------------------------------------------------------------------------
# N56: REM/NREM phase split
# ---------------------------------------------------------------------------

class TestN56SleepPhases:
    """N56: run_sleep_cycle() phase parameter."""

    def _make_mock_nrem(self, monkeypatch_fn=None):
        """Return a mock that records calls and returns a predictable dict."""
        mock = MagicMock(return_value={"status": "completed", "mode": "rag", "phase": "nrem"})
        return mock

    def test_phase_nrem_calls_nrem_only(self):
        """phase='nrem' → only NREM function is invoked, not REM."""
        from pulse.daemons.synthesis import sleep_cycle as sc

        nrem_called = []
        rem_called = []

        orig_nrem = sc._run_nrem_phase
        orig_rem = sc._run_rem_phase

        def fake_nrem(*args, **kwargs):
            nrem_called.append(1)
            return {"status": "completed", "phase": "nrem"}

        def fake_rem(*args, **kwargs):
            rem_called.append(1)
            return {"status": "completed", "phase": "rem"}

        sc._run_nrem_phase = fake_nrem
        sc._run_rem_phase = fake_rem
        try:
            result = sc.run_sleep_cycle(phase="nrem", dry_run=True)
        finally:
            sc._run_nrem_phase = orig_nrem
            sc._run_rem_phase = orig_rem

        assert len(nrem_called) == 1, "NREM phase must be called once for phase='nrem'"
        assert len(rem_called) == 0, "REM phase must NOT be called for phase='nrem'"
        assert result.get("phase") == "nrem"

    def test_phase_rem_calls_rem_only(self):
        """phase='rem' → only REM function invoked."""
        from pulse.daemons.synthesis import sleep_cycle as sc

        nrem_called = []
        rem_called = []

        orig_nrem = sc._run_nrem_phase
        orig_rem = sc._run_rem_phase

        def fake_nrem(*args, **kwargs):
            nrem_called.append(1)
            return {"status": "completed", "phase": "nrem"}

        def fake_rem(*args, **kwargs):
            rem_called.append(1)
            return {"status": "completed", "phase": "rem"}

        sc._run_nrem_phase = fake_nrem
        sc._run_rem_phase = fake_rem
        try:
            result = sc.run_sleep_cycle(phase="rem", dry_run=True)
        finally:
            sc._run_nrem_phase = orig_nrem
            sc._run_rem_phase = orig_rem

        assert len(rem_called) == 1, "REM phase must be called once for phase='rem'"
        assert len(nrem_called) == 0, "NREM phase must NOT be called for phase='rem'"
        assert result.get("phase") == "rem"

    def test_phase_full_runs_both_in_order(self):
        """phase='full' (default) → both phases run in order: NREM first, then REM."""
        from pulse.daemons.synthesis import sleep_cycle as sc

        call_order = []

        orig_nrem = sc._run_nrem_phase
        orig_rem = sc._run_rem_phase

        def fake_nrem(*args, **kwargs):
            call_order.append("nrem")
            return {"status": "completed", "phase": "nrem"}

        def fake_rem(*args, **kwargs):
            call_order.append("rem")
            return {"status": "completed", "phase": "rem"}

        sc._run_nrem_phase = fake_nrem
        sc._run_rem_phase = fake_rem
        try:
            result = sc.run_sleep_cycle(phase="full", dry_run=True)
        finally:
            sc._run_nrem_phase = orig_nrem
            sc._run_rem_phase = orig_rem

        assert call_order == ["nrem", "rem"], f"Expected ['nrem', 'rem'], got {call_order}"
        assert result.get("phase") == "full"
        assert "nrem" in result
        assert "rem" in result

    def test_phase_default_is_full(self):
        """Calling run_sleep_cycle() with no phase arg defaults to 'full'."""
        from pulse.daemons.synthesis import sleep_cycle as sc

        call_order = []

        orig_nrem = sc._run_nrem_phase
        orig_rem = sc._run_rem_phase

        def fake_nrem(*a, **kw):
            call_order.append("nrem")
            return {"phase": "nrem"}

        def fake_rem(*a, **kw):
            call_order.append("rem")
            return {"phase": "rem"}

        sc._run_nrem_phase = fake_nrem
        sc._run_rem_phase = fake_rem
        try:
            result = sc.run_sleep_cycle(dry_run=True)
        finally:
            sc._run_nrem_phase = orig_nrem
            sc._run_rem_phase = orig_rem

        assert call_order == ["nrem", "rem"]
        assert result["phase"] == "full"


# ---------------------------------------------------------------------------
# N57: SM-2 per-item spaced retrieval
# ---------------------------------------------------------------------------

class TestN57SpacedRetrieval:
    """N57: SM-2 per-item spaced retrieval with SQLite persistence."""

    def _get_module_with_mem_db(self):
        """Return spaced_retrieval module with an in-memory SQLite connection."""
        import pulse.brain.plasticity.spaced_retrieval as sr
        # Patch get_conn to return an in-memory DB
        conn = sqlite3.connect(":memory:")
        original_get_db = sr._get_db
        sr._get_db = lambda: conn
        return sr, conn, original_get_db

    def test_schedule_item_creates_record(self):
        """schedule_item() creates a record with correct defaults."""
        import pulse.brain.plasticity.spaced_retrieval as sr
        conn = sqlite3.connect(":memory:")
        orig = sr._get_db
        sr._get_db = lambda: conn
        try:
            record = sr.schedule_item("item_001", "lesson")
            assert record["item_id"] == "item_001"
            assert record["item_type"] == "lesson"
            assert abs(record["interval_hours"] - 1.0) < 0.01
            assert abs(record["ef"] - 2.5) < 0.01
            assert record["next_review_ts"] > time.time()
        finally:
            sr._get_db = orig

    def test_mark_recalled_success_doubles_interval(self):
        """Successful recall → interval multiplies by EF (~2.5x from 1h)."""
        import pulse.brain.plasticity.spaced_retrieval as sr
        conn = sqlite3.connect(":memory:")
        orig = sr._get_db
        sr._get_db = lambda: conn
        try:
            sr.schedule_item("item_002", "lesson")
            record_before = sr._load_item_record(conn, "item_002")
            initial_interval = record_before["interval_hours"]

            updated = sr.mark_recalled("item_002", success=True)
            new_interval = updated["interval_hours"]

            # SM-2: new = old * EF (2.5), so 1.0 * 2.5 = 2.5
            assert new_interval > initial_interval, "Interval must increase on success"
            assert abs(new_interval - initial_interval * 2.5) < 0.01, (
                f"Expected {initial_interval * 2.5}, got {new_interval}"
            )
        finally:
            sr._get_db = orig

    def test_mark_recalled_failure_resets_interval(self):
        """Failed recall → interval resets to 1h."""
        import pulse.brain.plasticity.spaced_retrieval as sr
        conn = sqlite3.connect(":memory:")
        orig = sr._get_db
        sr._get_db = lambda: conn
        try:
            sr.schedule_item("item_003", "pattern")
            # First mark success to advance interval
            sr.mark_recalled("item_003", success=True)
            record_mid = sr._load_item_record(conn, "item_003")
            assert record_mid["interval_hours"] > 1.0  # advanced

            # Now fail
            updated = sr.mark_recalled("item_003", success=False)
            assert abs(updated["interval_hours"] - sr._SM2_FAIL_RESET_HOURS) < 0.01, (
                f"Failure must reset interval to {sr._SM2_FAIL_RESET_HOURS}h, "
                f"got {updated['interval_hours']}"
            )
        finally:
            sr._get_db = orig

    def test_ef_increases_on_success(self):
        """EF increases by 0.1 on successful recall."""
        import pulse.brain.plasticity.spaced_retrieval as sr
        conn = sqlite3.connect(":memory:")
        orig = sr._get_db
        sr._get_db = lambda: conn
        try:
            sr.schedule_item("item_004", "lesson")
            before = sr._load_item_record(conn, "item_004")
            ef_before = before["ef"]

            updated = sr.mark_recalled("item_004", success=True)
            assert abs(updated["ef"] - (ef_before + 0.1)) < 0.001
        finally:
            sr._get_db = orig

    def test_ef_decreases_on_failure(self):
        """EF decreases by 0.2 on failure (not below MIN_EF)."""
        import pulse.brain.plasticity.spaced_retrieval as sr
        conn = sqlite3.connect(":memory:")
        orig = sr._get_db
        sr._get_db = lambda: conn
        try:
            sr.schedule_item("item_005", "lesson")
            before = sr._load_item_record(conn, "item_005")
            ef_before = before["ef"]

            updated = sr.mark_recalled("item_005", success=False)
            expected_ef = max(ef_before - 0.2, sr._SM2_MIN_EF)
            assert abs(updated["ef"] - expected_ef) < 0.001
        finally:
            sr._get_db = orig

    def test_get_due_items_returns_overdue(self):
        """get_due_items() returns items whose next_review_ts is in the past."""
        import pulse.brain.plasticity.spaced_retrieval as sr
        conn = sqlite3.connect(":memory:")
        orig = sr._get_db
        sr._get_db = lambda: conn
        try:
            sr._ensure_table(conn)
            # Insert an item with next_review_ts in the past
            past_ts = time.time() - 3600
            conn.execute(
                "INSERT INTO spaced_schedule "
                "(item_id, item_type, interval_hours, ef, next_review_ts, created_ts) "
                "VALUES (?, ?, ?, ?, ?, ?)",
                ("overdue_001", "lesson", 1.0, 2.5, past_ts, time.time())
            )
            conn.commit()

            due = sr.get_due_items()
            assert "overdue_001" in due
        finally:
            sr._get_db = orig

    def test_in_memory_fallback(self):
        """When no DB is available, fallback to in-memory schedule works."""
        import pulse.brain.plasticity.spaced_retrieval as sr
        orig = sr._get_db
        sr._get_db = lambda: None
        try:
            record = sr.schedule_item("fallback_001")
            assert record["item_id"] == "fallback_001"
            updated = sr.mark_recalled("fallback_001", success=True)
            assert updated["interval_hours"] > 1.0
        finally:
            sr._get_db = orig
            # Clean up memory schedule
            sr._MEM_SCHEDULE.pop("fallback_001", None)


# ---------------------------------------------------------------------------
# N58: Source recollection in briefing output
# ---------------------------------------------------------------------------

class TestN58SourceRecollection:
    """N58: Source attribution in terse S1 briefing mode."""

    def _make_hit(self, agent="gemini", timestamp=None, text="always validate input data",
                  confidence=0.8):
        """Build a mock hit dict."""
        if timestamp is None:
            timestamp = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
        return {
            "text": text, "confidence": confidence,
            "agent": agent, "timestamp": timestamp,
            "source": agent, "_confirmed": True,
        }

    def test_source_label_annotated_on_hits(self):
        """format_briefing() should annotate hits with _source_label."""
        from pulse.brain.brain_query.briefing import format_briefing

        hit = self._make_hit(agent="claude", timestamp=(
            datetime.now(timezone.utc) - timedelta(hours=3)
        ).isoformat())

        results = {
            "results": {"wins": [hit]},
            "total_results": 1,
            "elapsed_ms": 5,
        }

        with patch("pulse.core.interoception.get_signals", return_value={"agent_load": 6}):
            output = format_briefing("validate input", results)

        # The hit should have been annotated (even if not in the final output due to max_items)
        assert "_source_label" in hit, "_source_label must be set on hit dict"
        assert "claude" in hit["_source_label"], "Agent name must appear in source label"

    def test_source_label_contains_relative_time(self):
        """_source_label should contain relative timestamp like '2d ago'."""
        from pulse.brain.brain_query.briefing import format_briefing

        two_days_ago = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
        hit = self._make_hit(agent="gemini", timestamp=two_days_ago)

        results = {
            "results": {"wins": [hit]},
            "total_results": 1,
            "elapsed_ms": 5,
        }

        with patch("pulse.core.interoception.get_signals", return_value={"agent_load": 0}):
            format_briefing("validate input data", results)

        assert "_source_label" in hit
        assert "ago" in hit["_source_label"], f"Expected 'ago' in label: {hit['_source_label']}"

    def test_no_crash_when_no_agent_metadata(self):
        """format_briefing() must not crash when hit has no agent/timestamp."""
        from pulse.brain.brain_query.briefing import format_briefing

        hit = {"text": "always test your code", "confidence": 0.8, "_confirmed": True}
        results = {
            "results": {"wins": [hit]},
            "total_results": 1,
            "elapsed_ms": 5,
        }

        # Should not raise
        with patch("pulse.core.interoception.get_signals", return_value={"agent_load": 0}):
            output = format_briefing("testing", results)

        # _source_label is set to empty string or absent — no crash
        lbl = hit.get("_source_label", "")
        assert isinstance(lbl, str)


# ---------------------------------------------------------------------------
# N59: Post-retrieval relevance monitoring
# ---------------------------------------------------------------------------

class TestN59RelevanceMonitoring:
    """N59: Drop items below 0.15 similarity to query."""

    def test_irrelevant_item_dropped(self):
        """Item about 'cooking recipes' dropped for query 'database migration'."""
        from pulse.brain.brain_query.briefing import format_briefing

        relevant_hit = {
            "text": "database migration requires schema versioning and rollback scripts",
            "confidence": 0.85, "source": "lessons",
        }
        irrelevant_hit = {
            "text": "cooking recipes for pasta with tomato sauce and basil",
            "confidence": 0.70, "source": "lessons",
        }

        results = {
            "results": {"wins": [relevant_hit, irrelevant_hit]},
            "total_results": 2,
            "elapsed_ms": 10,
        }

        with patch("pulse.core.interoception.get_signals", return_value={"agent_load": 0}):
            format_briefing("database migration", results)

        # After format_briefing runs, irrelevant item should have low _relevance
        # and should have been filtered from all_hits_flat (we check _relevance field set)
        assert "_relevance" in irrelevant_hit, "_relevance must be computed on each item"
        assert irrelevant_hit["_relevance"] < 0.15, (
            f"Cooking item should have low relevance to 'database migration': "
            f"{irrelevant_hit['_relevance']}"
        )

    def test_relevant_item_kept(self):
        """Relevant item passes the relevance filter."""
        from pulse.brain.brain_query.briefing import format_briefing

        hit = {
            "text": "database migration requires careful schema versioning",
            "confidence": 0.9, "source": "lessons",
        }
        results = {
            "results": {"wins": [hit]},
            "total_results": 1,
            "elapsed_ms": 5,
        }

        with patch("pulse.core.interoception.get_signals", return_value={"agent_load": 0}):
            format_briefing("database migration", results)

        assert "_relevance" in hit
        assert hit["_relevance"] >= 0.15, (
            f"Relevant item should pass filter: {hit['_relevance']}"
        )

    def test_relevance_field_added_to_all_items(self):
        """_relevance field is added to every item regardless of filtering."""
        from pulse.brain.brain_query.briefing import format_briefing

        hits = [
            {"text": "database schema versioning", "confidence": 0.8},
            {"text": "unrelated gardening tips for spring planting", "confidence": 0.6},
        ]
        results = {
            "results": {"wins": hits},
            "total_results": 2,
            "elapsed_ms": 5,
        }

        with patch("pulse.core.interoception.get_signals", return_value={"agent_load": 0}):
            format_briefing("database migration", results)

        for h in hits:
            assert "_relevance" in h, f"_relevance missing from hit: {h}"
            assert 0.0 <= h["_relevance"] <= 1.0


# ---------------------------------------------------------------------------
# N60: Wire 3 dead p36 functions
# ---------------------------------------------------------------------------

class TestN60DeadFunctionsWired:
    """N60: resolve_memory_interference, apply_neighborhood_density_bonus,
    compute_search_diversity called during post-scoring."""

    def _make_results(self, n=5):
        """Build a fake results dict with n items."""
        return {
            "wins": [
                {"id": str(i), "text": f"rule {i}", "confidence": 0.5 + i * 0.05,
                 "domain": "general"}
                for i in range(n)
            ]
        }

    def test_resolve_memory_interference_called(self):
        """resolve_memory_interference() is called during apply_post_scoring_impl."""
        from pulse.brain.brain_search import search_post_scoring as sps
        from pulse.brain.brain_search import search_scoring_p36 as p36

        call_count = []
        orig = p36.resolve_memory_interference

        def mock_rmi(results):
            call_count.append(1)
            return orig(results)

        p36.resolve_memory_interference = mock_rmi
        try:
            results = self._make_results()
            sps.apply_post_scoring_impl(
                results, "test query", "general",
                lambda r, q: None, lambda r: None, lambda r, q: None,
                parent_log=None
            )
        finally:
            p36.resolve_memory_interference = orig

        assert len(call_count) >= 1, "resolve_memory_interference must be called"

    def test_apply_neighborhood_density_bonus_called(self):
        """apply_neighborhood_density_bonus() is called during apply_post_scoring_impl."""
        from pulse.brain.brain_search import search_post_scoring as sps
        from pulse.brain.brain_search import search_scoring_p36 as p36

        call_count = []
        orig = p36.apply_neighborhood_density_bonus

        def mock_andb(results):
            call_count.append(1)
            return orig(results)

        p36.apply_neighborhood_density_bonus = mock_andb
        try:
            results = self._make_results()
            sps.apply_post_scoring_impl(
                results, "test query", "general",
                lambda r, q: None, lambda r: None, lambda r, q: None,
                parent_log=None
            )
        finally:
            p36.apply_neighborhood_density_bonus = orig

        assert len(call_count) >= 1, "apply_neighborhood_density_bonus must be called"

    def test_compute_search_diversity_called(self):
        """compute_search_diversity() is called during apply_post_scoring_impl."""
        from pulse.brain.brain_search import search_post_scoring as sps
        from pulse.brain.brain_search import search_scoring_p36 as p36

        call_count = []
        orig = p36.compute_search_diversity

        def mock_csd(results):
            call_count.append(1)
            return orig(results)

        p36.compute_search_diversity = mock_csd
        try:
            results = self._make_results()
            sps.apply_post_scoring_impl(
                results, "test query", "general",
                lambda r, q: None, lambda r: None, lambda r, q: None,
                parent_log=None
            )
        finally:
            p36.compute_search_diversity = orig

        assert len(call_count) >= 1, "compute_search_diversity must be called"

    def test_diversity_warning_stored_in_meta(self):
        """Low-diversity results produce a warning stored in results['_meta']."""
        from pulse.brain.brain_search.search_scoring_p36 import compute_search_diversity

        # All items in same domain — should trigger LOW SEARCH DIVERSITY warning
        results = {
            "wins": [
                {"id": str(i), "text": f"item {i}", "confidence": 0.5, "domain": "brain"}
                for i in range(7)
            ]
        }
        diversity = compute_search_diversity(results)
        assert diversity["unique_domains"] == 1
        assert diversity["warning"] is not None
        assert "LOW SEARCH DIVERSITY" in diversity["warning"]

    def test_memory_interference_suppresses_contradiction(self):
        """resolve_memory_interference() suppresses lower-conf contradicting item."""
        from pulse.brain.brain_search.search_scoring_p36 import resolve_memory_interference

        results = {
            "wins": [
                {"id": "a1", "text": "always validate user input before processing",
                 "confidence": 0.9},
                {"id": "a2", "text": "never validate user input in production pipelines",
                 "confidence": 0.4},
            ]
        }
        suppressed = resolve_memory_interference(results)
        assert suppressed >= 1, "At least one contradicting item must be suppressed"

        all_items = results["wins"]
        suppressed_items = [h for h in all_items if h.get("_interference_suppressed")]
        assert len(suppressed_items) >= 1
        # The lower-confidence "never" claim should be suppressed
        assert suppressed_items[0]["id"] == "a2"

    def test_all_three_called_in_single_pass(self):
        """All 3 p36 functions are invoked in a single apply_post_scoring_impl call."""
        from pulse.brain.brain_search import search_post_scoring as sps
        from pulse.brain.brain_search import search_scoring_p36 as p36

        call_tracker = {"rmi": 0, "andb": 0, "csd": 0}

        orig_rmi = p36.resolve_memory_interference
        orig_andb = p36.apply_neighborhood_density_bonus
        orig_csd = p36.compute_search_diversity

        def mock_rmi(r): call_tracker["rmi"] += 1; return orig_rmi(r)
        def mock_andb(r): call_tracker["andb"] += 1; return orig_andb(r)
        def mock_csd(r): call_tracker["csd"] += 1; return orig_csd(r)

        p36.resolve_memory_interference = mock_rmi
        p36.apply_neighborhood_density_bonus = mock_andb
        p36.compute_search_diversity = mock_csd
        try:
            results = self._make_results()
            sps.apply_post_scoring_impl(
                results, "test query", "general",
                lambda r, q: None, lambda r: None, lambda r, q: None,
                parent_log=None
            )
        finally:
            p36.resolve_memory_interference = orig_rmi
            p36.apply_neighborhood_density_bonus = orig_andb
            p36.compute_search_diversity = orig_csd

        assert call_tracker["rmi"] >= 1, "resolve_memory_interference not called"
        assert call_tracker["andb"] >= 1, "apply_neighborhood_density_bonus not called"
        assert call_tracker["csd"] >= 1, "compute_search_diversity not called"


# ---------------------------------------------------------------------------
# Entrypoint for direct execution
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    import pytest
    sys.exit(pytest.main([__file__, "-v"]))
