#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""P45 Neuroscience fixes — tests for N41-N45.

N41: _retrieval_score / _penalty_stack_depth separation from confidence
N42: Power-law decay replacing exponential
N43: Cluster-based memory competition O(n log n)
N44: Emotional salience boost fires for high-intensity text
N45: Prediction error gate on mark_labile
"""
from pathlib import Path
import sys

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

import pytest
from unittest.mock import patch, MagicMock


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _make_wilson():
    def wilson(conf, ec):
        margin = 0.05
        return max(0.0, conf - margin), min(1.0, conf + margin)
    return wilson


def _call_score_item(item, task_type=None, source_query_counts=None, ctr_texts=None):
    """Call _score_item with all slow imports patched out."""
    from pulse.brain.brain_search.search_scoring import _score_item

    def _tc(conf, ts=None, **kw): return conf
    def _ts(sal, la=None, **kw): return sal

    mock_conn = MagicMock()
    mock_conn.execute.return_value.fetchone.return_value = None

    with patch("pulse.brain.metacognition.metamemory._get_calibration_factor", return_value=1.0), \
         patch("pulse.core.brain_db.get_conn", return_value=mock_conn), \
         patch("pulse.brain.working_memory._get_ior_penalty", return_value=0.0), \
         patch("pulse.brain.integrity.provenance.get_provenance", return_value=None):
        return _score_item(
            item, "lessons",
            "test query", task_type,
            ctr_texts or set(),
            None, {},
            source_query_counts or {},
            _tc, _ts, _make_wilson(),
        )


# ---------------------------------------------------------------------------
# N41: Separate confidence/salience/retrieval_score fields
# ---------------------------------------------------------------------------

class TestN41RetrievalScore:
    """_score_item must stamp _retrieval_score != _original_confidence when
    at least one modifier fires, and _penalty_stack_depth must count modifiers."""

    def _base_item(self, conf=0.7):
        return {
            "confidence": conf,
            "type": "lesson",
            "validity": "valid",
            "content": "test lesson content here",
            "access_count": 0,
            "evidence_count": 1,
            "_skip_epistemic": True,
        }

    def test_original_confidence_preserved(self):
        """_original_confidence must equal the value BEFORE any modifier."""
        result = _call_score_item(self._base_item(0.7))
        if result is None:
            pytest.skip("Below quality gate")
        assert "_original_confidence" in result
        assert result["_original_confidence"] == 0.7

    def test_retrieval_score_field_present(self):
        """_retrieval_score must be stamped on every item that passes the gate."""
        result = _call_score_item(self._base_item(0.8))
        if result is None:
            pytest.skip("Below quality gate")
        assert "_retrieval_score" in result

    def test_penalty_stack_depth_is_nonneg_int(self):
        """_penalty_stack_depth must be a non-negative integer."""
        result = _call_score_item(self._base_item(0.75))
        if result is None:
            pytest.skip("Below quality gate")
        assert "_penalty_stack_depth" in result
        assert isinstance(result["_penalty_stack_depth"], int)
        assert result["_penalty_stack_depth"] >= 0

    def test_novelty_bonus_increments_depth(self):
        """With access_count=0 the novelty bonus fires — depth >= 1."""
        item = self._base_item(0.75)
        item["access_count"] = 0  # novelty bonus fires
        result = _call_score_item(item)
        if result is None:
            pytest.skip("Below quality gate")
        assert result["_penalty_stack_depth"] >= 1

    def test_retrieval_score_differs_from_original(self):
        """When novelty bonus fires, _retrieval_score != _original_confidence."""
        item = self._base_item(0.75)
        item["access_count"] = 0  # novelty bonus +0.1
        result = _call_score_item(item)
        if result is None:
            pytest.skip("Below quality gate")
        assert result["_original_confidence"] == 0.75
        # novelty bonus +0.1 → retrieval_score should differ
        if result["_penalty_stack_depth"] > 0:
            assert result["_retrieval_score"] != result["_original_confidence"], (
                f"_retrieval_score ({result['_retrieval_score']}) should differ "
                f"from _original_confidence ({result['_original_confidence']}) "
                f"when depth={result['_penalty_stack_depth']}"
            )

    def test_attention_fatigue_increments_depth(self):
        """High source_query_count triggers attention fatigue — depth increments."""
        item = self._base_item(0.75)
        item["access_count"] = 5  # no novelty bonus
        result = _call_score_item(
            item,
            source_query_counts={"lessons": 100},  # > 50 → fatigue fires
        )
        if result is None:
            pytest.skip("Below quality gate")
        # At least fatigue + calibration should have fired
        assert result["_penalty_stack_depth"] >= 1

    def test_contradicted_item_increments_depth(self):
        """Contradiction flag fires when hit_text matches contradiction text."""
        from pulse.core.brain_utils import text_similarity
        item = self._base_item(0.8)
        item["content"] = "python is the best language"
        # Pass identical text as contradiction target → similarity = 1.0
        ctr = {"python is the best language"}
        result = _call_score_item(item, ctr_texts=ctr)
        if result is None:
            pytest.skip("Below quality gate")
        assert result.get("_contradiction_flag") is True
        # depth must have been incremented
        assert result["_penalty_stack_depth"] >= 1


# ---------------------------------------------------------------------------
# N42: Power-law decay replaces exponential
# ---------------------------------------------------------------------------

class TestN42PowerLawDecay:
    """Power-law retains more for old items than exponential Ebbinghaus."""

    @staticmethod
    def _exp_decay(base, days, hl):
        """Reference: old Ebbinghaus exponential formula."""
        return base * (0.5 ** (days / hl))

    @staticmethod
    def _power_decay(base, days, hl):
        """N42: Wixted & Ebbesen 1991 power-law formula."""
        return base * ((1.0 + days / hl) ** (-0.5))

    @pytest.mark.parametrize("days", [7, 30, 180, 365])
    def test_power_law_retains_more_than_exp(self, days):
        """Power-law must retain more than exponential at each checkpoint."""
        base, hl = 1.0, 180.0
        exp_val = self._exp_decay(base, days, hl)
        pow_val = self._power_decay(base, days, hl)
        assert pow_val > exp_val, (
            f"At {days}d: power ({pow_val:.4f}) should > exp ({exp_val:.4f})"
        )

    def test_temporal_confidence_matches_power_law_formula(self):
        """temporal_confidence output must match power-law at hl=180 days."""
        from pulse.core.temporal import temporal_confidence
        result = temporal_confidence(1.0, days_old=180, knowledge_type="lesson",
                                     retrieval_count=0)
        # (1 + 180/180)^-0.5 = 2^-0.5 ≈ 0.7071
        expected = (1.0 + 180.0 / 180.0) ** (-0.5)
        assert abs(result - expected) < 0.01, (
            f"Expected ~{expected:.4f}, got {result:.4f}"
        )

    def test_fresh_item_unchanged(self):
        """days_old=0 returns base_confidence unchanged."""
        from pulse.core.temporal import temporal_confidence
        assert temporal_confidence(0.8, days_old=0) == 0.8

    def test_decay_monotonically_decreasing(self):
        """Confidence must decrease monotonically as items age."""
        from pulse.core.temporal import temporal_confidence
        vals = [temporal_confidence(1.0, days_old=d, knowledge_type="lesson",
                                    retrieval_count=0)
                for d in [0, 10, 30, 90, 180, 365]]
        for i in range(len(vals) - 1):
            assert vals[i] >= vals[i + 1], (
                f"Not monotone at index {i}: {vals[i]:.4f} < {vals[i+1]:.4f}"
            )

    def test_decay_floor_never_zero(self):
        """Even extremely old items must not decay to zero (floor at 0.01)."""
        from pulse.core.temporal import temporal_confidence
        result = temporal_confidence(1.0, days_old=36500, knowledge_type="lesson",
                                     retrieval_count=0)
        assert result >= 0.01


# ---------------------------------------------------------------------------
# N43: Cluster-based memory competition O(n log n)
# ---------------------------------------------------------------------------

class TestN43ClusterMemoryCompetition:
    """apply_memory_competition must suppress lower-ranked items in same-domain clusters."""

    @staticmethod
    def _make_items(n, domain="python", agent="agent-a"):
        return [
            {
                "id": f"item-{i}",
                "confidence": round(0.9 - i * 0.05, 2),
                "domain": domain,
                "agent": agent,
                "content": f"content item {i}",
            }
            for i in range(n)
        ]

    def test_winner_not_penalised(self):
        """Rank-0 item (highest confidence) must NOT get _memory_competition."""
        from pulse.brain.brain_search.search_scoring_p32 import apply_memory_competition
        items = self._make_items(5)
        results = {"lessons": items}
        apply_memory_competition(results)
        # After cluster sort, winner is at index 0 (_competition_rank absent / not flagged)
        winner = max(results["lessons"], key=lambda x: x.get("confidence", 0))
        assert not winner.get("_memory_competition", False), (
            "Winner should NOT have _memory_competition flag"
        )

    def test_lower_ranks_suppressed(self):
        """Items at rank >= 1 in a cluster must be penalised."""
        from pulse.brain.brain_search.search_scoring_p32 import apply_memory_competition
        items = self._make_items(5)
        results = {"lessons": items}
        apply_memory_competition(results)
        penalised = [it for it in results["lessons"] if it.get("_memory_competition")]
        assert len(penalised) >= 1, "Expected at least 1 item suppressed in crowded cluster"

    def test_suppression_graduated(self):
        """Higher rank items must have lower retention ratio (more suppression)."""
        from pulse.brain.brain_search.search_scoring_p32 import apply_memory_competition
        items = self._make_items(6, domain="samedom")
        original_confs = {it["id"]: it["confidence"] for it in items}
        results = {"lessons": items}
        apply_memory_competition(results)
        penalised = sorted(
            [it for it in results["lessons"] if it.get("_memory_competition")],
            key=lambda x: x.get("_competition_rank", 0)
        )
        if len(penalised) >= 2:
            rank1 = penalised[0]
            rank2 = penalised[1]
            ratio1 = rank1["confidence"] / original_confs[rank1["id"]]
            ratio2 = rank2["confidence"] / original_confs[rank2["id"]]
            assert ratio2 <= ratio1, (
                f"Rank2 ratio ({ratio2:.3f}) should be <= rank1 ratio ({ratio1:.3f})"
            )

    def test_50_percent_suppression_cap(self):
        """No item should lose more than 50% confidence regardless of cluster size."""
        from pulse.brain.brain_search.search_scoring_p32 import apply_memory_competition
        items = self._make_items(20, domain="crowded")
        original_confs = {it["id"]: it["confidence"] for it in items}
        results = {"lessons": items}
        apply_memory_competition(results)
        for item in results["lessons"]:
            orig = original_confs[item["id"]]
            ratio = item["confidence"] / orig if orig > 0 else 1.0
            assert ratio >= 0.5 - 1e-6, (
                f"Item {item['id']} suppressed {(1 - ratio) * 100:.1f}% > 50% cap"
            )

    def test_150_items_completes_quickly(self):
        """150 items must run in < 1s (O(n log n), not O(n²))."""
        import time
        from pulse.brain.brain_search.search_scoring_p32 import apply_memory_competition
        items = [
            {
                "id": f"item-{i}", "confidence": 0.5 + (i % 10) * 0.04,
                "domain": f"domain-{i % 5}", "agent": f"agent-{i % 3}",
                "content": f"content {i}",
            }
            for i in range(150)
        ]
        results = {"source": items}
        start = time.perf_counter()
        apply_memory_competition(results)
        elapsed = time.perf_counter() - start
        assert elapsed < 1.0, f"Took {elapsed:.3f}s for 150 items (should be < 1s)"

    def test_cross_domain_no_competition(self):
        """Items in different domains must not compete with each other."""
        from pulse.brain.brain_search.search_scoring_p32 import apply_memory_competition
        items = [
            {"id": "a", "confidence": 0.8, "domain": "python", "agent": "ag1", "content": "c"},
            {"id": "b", "confidence": 0.7, "domain": "rust", "agent": "ag1", "content": "c"},
        ]
        results = {"lessons": items}
        apply_memory_competition(results)
        for item in results["lessons"]:
            assert not item.get("_memory_competition"), (
                f"Item {item['id']} should NOT be cross-domain suppressed"
            )


# ---------------------------------------------------------------------------
# N44: Emotional salience boost fires for high-intensity text
# ---------------------------------------------------------------------------

class TestN44EmotionalSalience:
    """Emotional text must receive higher salience than neutral text."""

    EMOTIONAL_TEXT = "CRITICAL FAILURE: everything crashed!!!"
    NEUTRAL_TEXT = "The function returned the expected value after refactoring."

    def _compute_intensity(self, text):
        from pulse.brain.extraction.wants_parser import compute_emotional_intensity
        return compute_emotional_intensity(text)

    def _build_entry(self, text, agent="test-agent"):
        """Build entry with slow DB/graph side-effects patched out."""
        from unittest.mock import patch, MagicMock
        from pulse.brain.extraction.wants_parser import compute_emotional_intensity
        from pulse.core.brain_analysis import compute_item_salience, compute_item_confidence
        from pulse.core.brain_utils import now_iso

        sal = compute_item_salience(text, priority=1.0)
        conf = compute_item_confidence(text, source_type="scribe")
        em = compute_emotional_intensity(text)

        mock_conn = MagicMock()
        mock_conn.execute.return_value.fetchone.return_value = None
        mock_conn.commit.return_value = None

        with patch("pulse.core.brain_db.get_conn", return_value=mock_conn), \
             patch("pulse.brain.metacognition.metamemory_p34.record_domain_valence"), \
             patch("pulse.brain.metacognition.metamemory_tracking.increment_consolidation_debt"), \
             patch("pulse.brain.save_writers.temporal_bridge.maybe_create_temporal_bridge"), \
             patch("pulse.brain.resurrection.check_resurrection"):
            from pulse.brain.save_writers.jsonl_store import build_jsonl_entry
            entry = build_jsonl_entry(
                description=text, source=agent, timestamp=now_iso(),
                domain="general", confidence=conf, salience=sal,
                knowledge_type="lesson",
                provenance={"source_agent": agent, "emotional_intensity": em},
            )
        return entry, em

    def test_emotional_intensity_nonzero(self):
        """compute_emotional_intensity must return > 0 for caps+exclamation text."""
        em = self._compute_intensity(self.EMOTIONAL_TEXT)
        assert em > 0.0, f"Expected em > 0 for '{self.EMOTIONAL_TEXT}', got {em}"

    def test_emotional_intensity_above_threshold(self):
        """Intensity must reach >= 0.6 to trigger the salience boost."""
        em = self._compute_intensity(self.EMOTIONAL_TEXT)
        assert em >= 0.6, (
            f"Expected em >= 0.6 (boost threshold), got {em} for '{self.EMOTIONAL_TEXT}'"
        )

    def test_emotional_flag_set(self):
        """Entry built from emotional text must have _emotional_salience=True."""
        entry, em = self._build_entry(self.EMOTIONAL_TEXT)
        assert em >= 0.6, f"em too low: {em}"
        assert entry.get("_emotional_salience") is True, (
            f"Expected _emotional_salience=True. em={em}, salience={entry['salience']}"
        )

    def test_emotional_salience_exceeds_neutral(self):
        """Emotional entry salience must be greater than neutral entry salience."""
        entry_em, _ = self._build_entry(self.EMOTIONAL_TEXT)
        entry_neutral, _ = self._build_entry(self.NEUTRAL_TEXT)
        assert entry_em["salience"] > entry_neutral["salience"], (
            f"Emotional salience ({entry_em['salience']}) should > "
            f"neutral ({entry_neutral['salience']})"
        )

    def test_salience_boost_proportional_to_intensity(self):
        """Salience boost must be at least em * 0.5 above base."""
        from pulse.core.brain_analysis import compute_item_salience
        from pulse.brain.extraction.wants_parser import compute_emotional_intensity
        text = self.EMOTIONAL_TEXT
        em = compute_emotional_intensity(text)
        base_sal = compute_item_salience(text, priority=1.0)
        entry, _ = self._build_entry(text)
        if em >= 0.6:
            expected_min = base_sal + em * 0.5
            # Allow for generation effect (+0.3) and other boosts
            assert entry["salience"] >= expected_min - 0.01, (
                f"Expected salience >= {expected_min:.3f}, got {entry['salience']:.3f}"
            )


# ---------------------------------------------------------------------------
# N45: Prediction error gate on reconsolidation
# ---------------------------------------------------------------------------

class TestN45PredictionErrorGate:
    """mark_labile must NOT open a lability window unless prediction_error > 0.3."""

    def setup_method(self):
        """Reset labile state before each test."""
        import pulse.brain.plasticity.reconsolidation as r
        r._labile_state = {}

    def _get_state(self):
        import pulse.brain.plasticity.reconsolidation as r
        return dict(r._labile_state)

    def test_zero_pe_no_lability(self):
        """PE=0.0 must NOT open a window."""
        from pulse.brain.plasticity.reconsolidation import mark_labile
        mark_labile("item-zero", "lessons", prediction_error=0.0)
        assert "item-zero" not in self._get_state(), "PE=0.0 should NOT mark labile"

    def test_exactly_threshold_no_lability(self):
        """PE=0.3 (at threshold, not above) must NOT open a window."""
        from pulse.brain.plasticity.reconsolidation import mark_labile
        mark_labile("item-thresh", "lessons", prediction_error=0.3)
        assert "item-thresh" not in self._get_state(), (
            "PE=0.3 (not > 0.3) should NOT mark labile"
        )

    def test_above_threshold_opens_window(self):
        """PE=0.5 (> 0.3) must open the lability window."""
        from pulse.brain.plasticity.reconsolidation import mark_labile
        mark_labile("item-high", "lessons", prediction_error=0.5)
        assert "item-high" in self._get_state(), "PE=0.5 should mark labile"

    def test_pe_stored_in_labile_entry(self):
        """Labile entry must record prediction_error value."""
        from pulse.brain.plasticity.reconsolidation import mark_labile
        mark_labile("item-stored", "patterns", prediction_error=0.7)
        state = self._get_state()
        assert "item-stored" in state
        assert state["item-stored"]["prediction_error"] == 0.7

    def test_boundary_just_above_threshold(self):
        """PE=0.301 must open window."""
        from pulse.brain.plasticity.reconsolidation import mark_labile
        mark_labile("item-boundary", "facts", prediction_error=0.301)
        assert "item-boundary" in self._get_state(), "PE=0.301 should open window"

    def test_default_arg_backward_compat(self):
        """Calling without PE arg (default=0.0) must NOT open window."""
        from pulse.brain.plasticity.reconsolidation import mark_labile
        mark_labile("item-default", "lessons")
        assert "item-default" not in self._get_state(), (
            "Default PE=0.0 must NOT mark labile (backward compat)"
        )

    def test_invalid_table_rejected_regardless_of_pe(self):
        """Invalid table name must be rejected even with PE > 0.3."""
        from pulse.brain.plasticity.reconsolidation import mark_labile
        mark_labile("item-bad-table", "DROP TABLE lessons--", prediction_error=0.9)
        assert "item-bad-table" not in self._get_state(), (
            "Invalid table must be rejected"
        )

    def test_labile_entry_has_table_field(self):
        """Labile entry must record the table name."""
        from pulse.brain.plasticity.reconsolidation import mark_labile
        mark_labile("item-table-check", "failures", prediction_error=0.6)
        state = self._get_state()
        assert state["item-table-check"]["table"] == "failures"

    def test_labile_entry_has_expires_at(self):
        """Labile entry must have an expires_at ISO timestamp."""
        from pulse.brain.plasticity.reconsolidation import mark_labile
        from datetime import datetime, timezone
        mark_labile("item-expiry", "lessons", prediction_error=0.5)
        state = self._get_state()
        assert "expires_at" in state["item-expiry"]
        # Must be a valid ISO datetime in the future
        exp = datetime.fromisoformat(state["item-expiry"]["expires_at"])
        assert exp > datetime.now(timezone.utc), "expires_at must be in the future"
