#!/usr/bin/env python3
"""Tests for Phase 1 AGI Refinements — Neuroscience Alignment.

Covers:
1. Loss aversion (Kahneman & Tversky): contradict > reinforce
2. Hash alignment: all fingerprint functions produce same hash
3. Schema promotion: single source of truth
4. Reconsolidation: unified salience + decay clock
5. Metacognitive: temporal decay consulted before penalties
"""
import unittest
import sys
from pathlib import Path

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


class TestLossAversion(unittest.TestCase):
    """Kahneman & Tversky: losses should be weighted ~2x gains."""

    def test_reward_signal_loss_aversion(self):
        from pulse.core.reward.reward_signal import REINFORCE_DELTA, CONTRADICT_DELTA
        self.assertGreater(CONTRADICT_DELTA, REINFORCE_DELTA,
                           "Loss aversion: contradict must be stronger than reinforce")
        self.assertAlmostEqual(CONTRADICT_DELTA / REINFORCE_DELTA, 2.0, places=1,
                               msg="Loss aversion ratio should be ~2x")

    def test_reward_signal_rpe_computation(self):
        from pulse.core.reward.reward_signal import compute_rpe
        # Surprising success: positive RPE
        rpe_success = compute_rpe(success=True, expected_success_rate=0.3)
        self.assertGreater(rpe_success, 0)
        # Surprising failure: negative RPE
        rpe_failure = compute_rpe(success=False, expected_success_rate=0.7)
        self.assertLess(rpe_failure, 0)
        # Expected outcome: near-zero RPE
        rpe_expected = compute_rpe(success=True, expected_success_rate=0.95)
        self.assertAlmostEqual(rpe_expected, 0.05, places=2)

    def test_credit_assignment_decay(self):
        from pulse.core.reward.reward_signal import assign_credit
        credits = assign_credit(["item1", "item2", "item3"], rpe_delta=0.5)
        # Earlier items get more credit
        self.assertGreater(abs(credits["item1"]), abs(credits["item2"]))
        self.assertGreater(abs(credits["item2"]), abs(credits["item3"]))


class TestHashAlignment(unittest.TestCase):
    """All fingerprint functions must produce identical hashes."""

    def test_all_hashes_aligned(self):
        from pulse.brain.brain_search import _item_key
        from pulse.brain.index.search_utils import _item_key as su_key
        from pulse.brain.integrity.provenance import _fingerprint as prov_fp
        from pulse.brain.memory.working_memory import _fingerprint as wm_fp
        from pulse.daemons.consolidation.consolidation_stages import _entry_hash

        test_strings = [
            "simple test",
            "A much longer test string that exceeds sixty characters but stays under eighty chars boundary",
            "Special chars: encoding='utf-8', errors='replace' — Windows cp1252 crash",
            "",  # empty string
            "x" * 200,  # very long string
        ]
        for s in test_strings:
            h1 = _item_key(s)
            h2 = su_key(s)
            h3 = prov_fp(s)
            h4 = wm_fp(s)
            h5 = _entry_hash(s)[:12]
            self.assertEqual(h1, h2, f"brain_search vs search_utils mismatch for '{s[:30]}'")
            self.assertEqual(h1, h3, f"brain_search vs provenance mismatch for '{s[:30]}'")
            self.assertEqual(h1, h4, f"brain_search vs working_memory mismatch for '{s[:30]}'")
            self.assertEqual(h1, h5, f"brain_search vs consolidation mismatch for '{s[:30]}'")

    def test_hash_uses_80_chars(self):
        """Verify hash is based on first 80 chars, not 60."""
        from pulse.brain.brain_search import _item_key
        # Two strings identical for first 60 chars but different at position 70
        base = "a" * 60
        s1 = base + "XXXXX" + "b" * 15
        s2 = base + "YYYYY" + "b" * 15
        # With 80-char window, these should produce DIFFERENT hashes
        self.assertNotEqual(_item_key(s1), _item_key(s2),
                           "Hash should differentiate strings that differ between char 60-80")


class TestSchemaPromotionDedup(unittest.TestCase):
    """Schema promotion should have single source of truth."""

    def test_single_source(self):
        from pulse.daemons.consolidation.consolidation_mining import (
            promote_cross_patterns_to_schemas as from_mining
        )
        from pulse.daemons.consolidation.consolidation_promotion import (
            promote_cross_patterns_to_schemas as from_promotion
        )
        self.assertIs(from_mining, from_promotion,
                      "Mining should re-export from promotion, not have its own copy")

    def test_helpers_reexport(self):
        from pulse.daemons.consolidation.consolidation_helpers import (
            promote_cross_patterns_to_schemas as from_helpers
        )
        from pulse.daemons.consolidation.consolidation_promotion import (
            promote_cross_patterns_to_schemas as from_promotion
        )
        self.assertIs(from_helpers, from_promotion,
                      "Helpers should re-export from promotion")


class TestReconsolidation(unittest.TestCase):
    """Reconsolidation should update both salience and decay clock."""

    def test_imports_clean(self):
        from pulse.brain.plasticity.reconsolidation import reinforce, contradict, mark_labile
        self.assertTrue(callable(reinforce))
        self.assertTrue(callable(contradict))
        self.assertTrue(callable(mark_labile))

    def test_ttl_is_30_seconds(self):
        from pulse.brain.plasticity.reconsolidation import TTL_SECONDS
        self.assertEqual(TTL_SECONDS, 30, "Reconsolidation labile window must be 30s")


class TestTemporalDecay(unittest.TestCase):
    """Temporal decay should be type-aware with spacing effect."""

    def test_type_aware_half_lives(self):
        from pulse.core.temporal import get_half_life
        self.assertEqual(get_half_life("fact"), 60)
        self.assertEqual(get_half_life("lesson"), 180)
        self.assertEqual(get_half_life("pattern"), 365)
        self.assertEqual(get_half_life("episodic"), 30)

    def test_decay_function(self):
        from pulse.core.temporal import temporal_confidence
        # Fresh item: no decay
        conf = temporal_confidence(0.8, days_old=0)
        self.assertEqual(conf, 0.8)
        # At half-life: ~50% decay
        conf_hl = temporal_confidence(0.8, days_old=180, knowledge_type="lesson")
        self.assertAlmostEqual(conf_hl, 0.4, places=1)
        # Retrieval extends half-life
        conf_retrieved = temporal_confidence(0.8, days_old=180, knowledge_type="lesson",
                                             retrieval_count=10)
        self.assertGreater(conf_retrieved, conf_hl,
                          "Retrieval count should extend half-life")

    def test_reinforce_resets_clock(self):
        from pulse.core.temporal import reinforce
        item = {"validity": "expired", "half_life_days": 180}
        result = reinforce(item)
        self.assertEqual(result["validity"], "valid", "Reinforce should revive expired items")
        self.assertIn("last_reinforced", result)

    def test_lessons_decay_slower_than_facts(self):
        """Type differentiation: lessons (hl=180) must decay slower than facts (hl=60)."""
        from pulse.core.temporal import temporal_confidence
        age = 90  # halfway to lesson half-life, past fact half-life
        conf_lesson = temporal_confidence(0.8, days_old=age, knowledge_type="lesson")
        conf_fact = temporal_confidence(0.8, days_old=age, knowledge_type="fact")
        self.assertGreater(conf_lesson, conf_fact,
                           "Lessons (hl=180) must retain more confidence than facts (hl=60) at 90 days")

    def test_lessons_decay_faster_than_patterns(self):
        """Type differentiation: patterns (hl=365) must outlast lessons (hl=180)."""
        from pulse.core.temporal import temporal_confidence
        age = 180  # at lesson half-life
        conf_lesson = temporal_confidence(0.8, days_old=age, knowledge_type="lesson")
        conf_pattern = temporal_confidence(0.8, days_old=age, knowledge_type="pattern")
        self.assertGreater(conf_pattern, conf_lesson,
                           "Patterns (hl=365) must retain more confidence than lessons (hl=180) at 180 days")

    def test_all_half_lives_defined(self):
        """All knowledge types must have explicit half-lives defined."""
        from pulse.core.temporal import HALF_LIVES, DEFAULT_HALF_LIFE
        expected_types = {"fact", "lesson", "failure", "pattern", "anti_pattern",
                          "procedure", "decision", "prediction", "episodic", "schema", "private"}
        self.assertEqual(set(HALF_LIVES.keys()), expected_types,
                         "HALF_LIVES must define all knowledge types")
        # lesson and failure share the same 180-day half-life
        self.assertEqual(HALF_LIVES["failure"], HALF_LIVES["lesson"])
        # DEFAULT_HALF_LIFE matches lesson half-life (safe fallback)
        self.assertEqual(DEFAULT_HALF_LIFE, HALF_LIVES["lesson"])

    def test_stamp_temporal_fields_lesson(self):
        """stamp_temporal_fields must set half_life_days=180 for lesson items."""
        from pulse.core.temporal import stamp_temporal_fields, HALF_LIVES
        item = {"type": "lesson", "confidence": 0.6, "timestamp": "2026-01-01T00:00:00+00:00"}
        stamped = stamp_temporal_fields(item)
        self.assertEqual(stamped["half_life_days"], HALF_LIVES["lesson"],
                         "Lesson items must get half_life_days=180")
        self.assertIn("expires_at", stamped)
        self.assertIn("last_reinforced", stamped)
        self.assertEqual(stamped["validity"], "valid")

    def test_stamp_temporal_fields_unknown_type_uses_default(self):
        """stamp_temporal_fields must use DEFAULT_HALF_LIFE for unknown types."""
        from pulse.core.temporal import stamp_temporal_fields, DEFAULT_HALF_LIFE
        item = {"type": "unknown_future_type"}
        stamped = stamp_temporal_fields(item)
        self.assertEqual(stamped["half_life_days"], DEFAULT_HALF_LIFE,
                         "Unknown types must fall back to DEFAULT_HALF_LIFE")

    def test_temporal_confidence_timestamp_kwarg_applies_decay(self):
        """temporal_confidence must apply decay when timestamp= kwarg is used.

        Regression for stage_quality.py bug: date string was passed as positional
        arg days_old (float), causing TypeError → decay silently skipped.
        The correct call uses keyword args: knowledge_type= and timestamp=.
        """
        from pulse.core.temporal import temporal_confidence
        # 180-day-old lesson should decay to ~50% of base confidence
        old_ts = "2025-09-20T00:00:00+00:00"  # ~180 days before 2026-03-18
        decayed = temporal_confidence(0.8, knowledge_type="lesson", timestamp=old_ts)
        self.assertLess(decayed, 0.8, "180-day-old lesson must have decayed confidence")
        self.assertGreater(decayed, 0.01, "Decayed confidence must be above floor")
        # At ~1 half-life, should be roughly 0.4 (±0.15 tolerance for date drift)
        self.assertAlmostEqual(decayed, 0.4, delta=0.15,
                               msg="Lesson at ~1 half-life must be near 50% of original")

    def test_temporal_confidence_date_string_as_days_old_raises_or_returns_base(self):
        """Passing a date string as days_old (wrong positional order) must not silently
        return the unmodified base confidence as if no decay occurred.

        This guards against the stage_quality.py call-site bug where positional args
        were swapped: temporal_confidence(conf, date_str, type_str, ...).
        The correct call must use keyword arguments.
        """
        from pulse.core.temporal import temporal_confidence
        # Correct call — decay must be applied
        old_ts = "2025-09-20T00:00:00+00:00"
        correct = temporal_confidence(0.8, knowledge_type="lesson", timestamp=old_ts)
        # Wrong call (positional swap) — will raise TypeError in Python 3
        try:
            wrong = temporal_confidence(0.8, "2025-09-20T00:00:00+00:00", "lesson")
        except TypeError:
            wrong = 0.8  # TypeError means no decay applied — this is the broken path
        # The correct call must produce meaningful decay; the wrong call returns base
        self.assertLess(correct, wrong,
                        "Correct keyword-arg call must apply more decay than broken positional call")


class TestSchemaPromotionConfidenceGate(unittest.TestCase):
    """Audit: schema promotion confidence gate at 0.5 threshold."""

    def _make_pattern(self, confidence, consolidations=3, unique_dates=2, title="Test Pattern"):
        return {
            "title": title,
            "max_confidence": confidence,
            "total_consolidations": consolidations,
            "unique_dates": unique_dates,
            "occurrences": consolidations,
        }

    def _run_promotion(self, patterns, tmp_path):
        import pulse.daemons.consolidation.consolidation_promotion as mod
        from unittest.mock import patch
        with patch.object(mod, "PATTERNS_DIR", Path(tmp_path)):
            return mod.promote_cross_patterns_to_schemas(patterns)

    def test_confidence_below_threshold_rejected(self):
        """confidence=0.49 must not be promoted — gate blocks it."""
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            result = self._run_promotion([self._make_pattern(confidence=0.49)], tmp)
            self.assertEqual(result["new_schemas"], 0,
                             "confidence=0.49 must be rejected by 0.5 gate")

    def test_confidence_at_threshold_accepted(self):
        """confidence=0.5 must pass the gate when evidence is sufficient."""
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            result = self._run_promotion([self._make_pattern(confidence=0.5)], tmp)
            self.assertEqual(result["new_schemas"], 1,
                             "confidence=0.5 must pass the gate")

    def test_confidence_above_threshold_accepted(self):
        """confidence=0.9 must pass the gate."""
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            result = self._run_promotion([self._make_pattern(confidence=0.9)], tmp)
            self.assertEqual(result["new_schemas"], 1,
                             "confidence=0.9 must be promoted")

    def test_secondary_gate_blocks_low_evidence(self):
        """confidence>=0.5 but consolidations<3 AND unique_dates<2 must be rejected."""
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            pattern = self._make_pattern(confidence=0.8, consolidations=2, unique_dates=1)
            result = self._run_promotion([pattern], tmp)
            self.assertEqual(result["new_schemas"], 0,
                             "Low-evidence pattern (consol<3, dates<2) must be blocked")

    def test_secondary_gate_passes_with_enough_consolidations(self):
        """consolidations>=3 alone satisfies the secondary gate."""
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            pattern = self._make_pattern(confidence=0.6, consolidations=3, unique_dates=0)
            result = self._run_promotion([pattern], tmp)
            self.assertEqual(result["new_schemas"], 1,
                             "consolidations=3 should satisfy secondary gate")

    def test_secondary_gate_passes_with_enough_unique_dates(self):
        """unique_dates>=2 alone satisfies the secondary gate."""
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            pattern = self._make_pattern(confidence=0.6, consolidations=1, unique_dates=2)
            result = self._run_promotion([pattern], tmp)
            self.assertEqual(result["new_schemas"], 1,
                             "unique_dates=2 should satisfy secondary gate")

    def test_existing_schema_reinforced_not_duplicated(self):
        """Duplicate pattern title must reinforce existing schema, not add new one."""
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            pattern = self._make_pattern(confidence=0.6, title="Proven Pattern Alpha")
            self._run_promotion([pattern], tmp)
            # Second promotion with higher confidence — should reinforce, not duplicate
            pattern2 = self._make_pattern(confidence=0.8, title="Proven Pattern Alpha")
            result2 = self._run_promotion([pattern2], tmp)
            self.assertEqual(result2["new_schemas"], 0,
                             "Duplicate name must not create a new schema")
            self.assertEqual(result2["total_schemas"], 1,
                             "Total schemas must remain 1 after duplicate reinforcement")

    def test_zero_confidence_rejected(self):
        """Patterns with missing/zero confidence must be rejected."""
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            pattern = {"title": "No Confidence", "total_consolidations": 5, "unique_dates": 3}
            result = self._run_promotion([pattern], tmp)
            self.assertEqual(result["new_schemas"], 0,
                             "Missing confidence (defaults 0) must be rejected by gate")

    def test_mixed_batch_only_qualifying_promoted(self):
        """Mixed batch: only patterns meeting both gates are promoted."""
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            patterns = [
                self._make_pattern(confidence=0.3, title="Below Threshold"),    # rejected
                self._make_pattern(confidence=0.6, consolidations=1, unique_dates=1, title="Low Evidence"),  # rejected
                self._make_pattern(confidence=0.7, consolidations=4, unique_dates=3, title="Valid A"),  # accepted
                self._make_pattern(confidence=0.5, consolidations=3, unique_dates=0, title="Valid B"),  # accepted
            ]
            result = self._run_promotion(patterns, tmp)
            self.assertEqual(result["new_schemas"], 2,
                             "Only 2 of 4 patterns qualify — gate must filter correctly")


class TestConsolidationStages(unittest.TestCase):
    """Consolidation stages should import and run without errors."""

    def test_all_stages_importable(self):
        from pulse.daemons.consolidation.consolidation_stages import (
            stage_dedup, stage_pattern_mining, stage_schema_promotion,
            stage_reconsolidation, stage_ltd_decay, stage_metacognitive_audit,
            stage_graph_update, stage_hierarchical_prune,
            stage_cross_domain_transfer, stage_promote_to_patterns,
            stage_truth_verification, stage_2f_cross_region_mining,
            stage_3c_micro_pattern_promotion, stage_10_metamemory,
            stage_4c_abstraction,
        )
        # All should be callable
        for fn in [stage_dedup, stage_pattern_mining, stage_schema_promotion,
                   stage_reconsolidation, stage_ltd_decay]:
            self.assertTrue(callable(fn))


class TestSalienceScoringHighPriority(unittest.TestCase):
    """compute_item_salience must scale correctly for high-priority items.

    _compute_priority() returns 1-10 (default=5). The priority boost formula is
    max(0, (priority-1)) * 0.5, giving 0-4.5 for the full 1-10 range.
    Previous docstring incorrectly stated "0-0.5 for priority 1-2".
    """

    def test_high_priority_beats_default_priority(self):
        """priority=7 (high signal hit) must score higher than priority=5 (default)."""
        from pulse.core.brain_analysis import compute_item_salience
        desc = "should use filelock for atomic writes to prevent race conditions"
        sal_default = compute_item_salience(desc, priority=5)
        sal_high = compute_item_salience(desc, priority=7)
        self.assertGreater(sal_high, sal_default,
                           "High priority (7) must exceed default priority (5)")

    def test_max_priority_beats_high_priority(self):
        """priority=10 must score higher than priority=7 (use non-critical desc to avoid cap)."""
        from pulse.core.brain_analysis import compute_item_salience
        # Use standard-tier desc ('learned') to avoid critical keyword cap at 10.0
        desc = "learned that verified approach works well and should be preferred going forward"
        sal_high = compute_item_salience(desc, priority=7)
        sal_max = compute_item_salience(desc, priority=10)
        self.assertGreater(sal_max, sal_high,
                           "Max priority (10) must exceed high priority (7)")

    def test_low_priority_gets_zero_boost(self):
        """priority=1 gives zero priority boost (base salience only)."""
        from pulse.core.brain_analysis import compute_item_salience
        desc = "confirmed this works"
        sal = compute_item_salience(desc, priority=1)
        # priority_boost = max(0, (1-1)) * 0.5 = 0.0
        sal_no_boost = compute_item_salience(desc, priority=1, emotional_intensity=0.0)
        self.assertEqual(sal, sal_no_boost)
        self.assertGreaterEqual(sal, 1.0)

    def test_priority_boost_formula_correctness(self):
        """Verify priority_boost = (priority - 1) * 0.5 across the 1-10 range."""
        from pulse.core.brain_analysis import compute_item_salience
        # Use ambient-tier (no keywords) short desc to isolate the priority boost
        desc = "ambient item"
        # base=1.0, specificity=min(0.5, 2/40)=0.05, emotional=0
        sal_p1 = compute_item_salience(desc, priority=1)
        sal_p5 = compute_item_salience(desc, priority=5)
        sal_p10 = compute_item_salience(desc, priority=10)
        # boost at p5 = (5-1)*0.5 = 2.0; boost at p10 = (10-1)*0.5 = 4.5
        # Use tolerance range to avoid float rounding edge cases
        diff_p5 = round(sal_p5 - sal_p1, 1)
        diff_p10 = round(sal_p10 - sal_p1, 1)
        self.assertGreaterEqual(diff_p5, 1.9, "priority=5 should add ~2.0 boost over priority=1")
        self.assertLessEqual(diff_p5, 2.1, "priority=5 should add ~2.0 boost over priority=1")
        self.assertGreaterEqual(diff_p10, 4.4, "priority=10 should add ~4.5 boost over priority=1")
        self.assertLessEqual(diff_p10, 4.6, "priority=10 should add ~4.5 boost over priority=1")

    def test_salience_capped_at_ten(self):
        """Salience must never exceed 10.0 even with critical keywords + max priority."""
        from pulse.core.brain_analysis import compute_item_salience
        desc = "critical security vulnerability causes data loss and crashes — never ignore this"
        sal = compute_item_salience(desc, priority=10, emotional_intensity=1.0)
        self.assertLessEqual(sal, 10.0, "Salience must be capped at 10.0")
        self.assertGreaterEqual(sal, 7.0, "Critical item must score >= 7.0")

    def test_high_priority_standard_item_reaches_broadcast_threshold(self):
        """A high priority (7) item with critical keywords should hit >= 7.0 salience."""
        from pulse.core.brain_analysis import compute_item_salience
        # 'crashes' is a critical keyword → base=7.0
        desc = "this crashes the orchestrator when env sig is stale after env edit"
        sal = compute_item_salience(desc, priority=7)
        self.assertGreaterEqual(sal, 7.0,
                                "Critical + high priority should reach broadcast threshold")


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