"""P3 Neuroscience Refinements — source-level verification tests.

These tests verify that each P3 fix is wired into the correct module by
inspecting source code directly.  They do NOT require a running brain DB,
making them safe for CI.
"""
import importlib
import inspect
import sys
from pathlib import Path

# Ensure the pulse package is importable when run directly
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))


# ---------------------------------------------------------------------------
# P3-1: Hot cache applies epistemic qualifiers
# ---------------------------------------------------------------------------

def test_hot_cache_calls_apply_epistemic_qualifiers():
    """search_hot() must call apply_epistemic_qualifiers on every hit."""
    import pulse.brain.memory.hot_cache as mod
    src = inspect.getsource(mod.search_hot)
    assert "apply_epistemic_qualifiers" in src, (
        "search_hot() does not call apply_epistemic_qualifiers — P3-1 fix missing"
    )


def test_hot_cache_imports_epistemic_inside_try():
    """The epistemic import in hot_cache must be guarded by try/except."""
    import pulse.brain.memory.hot_cache as mod
    src = inspect.getsource(mod.search_hot)
    # Both the import and a surrounding try/except must be present
    assert "from pulse.brain.epistemic import apply_epistemic_qualifiers" in src, (
        "hot_cache does not import apply_epistemic_qualifiers from pulse.brain.epistemic"
    )
    assert "except Exception" in src, (
        "hot_cache epistemic call is not wrapped in try/except — can break search"
    )


# ---------------------------------------------------------------------------
# P3-2: Adversarial fail injection in briefing
# ---------------------------------------------------------------------------

def test_briefing_has_adversarial_fail_injection():
    """format_briefing() must inject a failure when no PAST FAIL is present."""
    import pulse.brain.brain_query.briefing as mod
    src = inspect.getsource(mod.format_briefing)
    assert "ADVERSARIAL FAIL" in src, (
        "format_briefing() missing ADVERSARIAL FAIL injection — P3-2 fix missing"
    )


def test_briefing_adversarial_guarded_by_past_fail_check():
    """Adversarial injection must only fire when no PAST FAIL line exists."""
    import pulse.brain.brain_query.briefing as mod
    src = inspect.getsource(mod.format_briefing)
    assert '"PAST FAIL"' in src or "'PAST FAIL'" in src, (
        "format_briefing() adversarial injection not gated on 'PAST FAIL' check"
    )


def test_briefing_adversarial_uses_brain_db():
    """Adversarial injection must query the failures table via brain_db."""
    import pulse.brain.brain_query.briefing as mod
    src = inspect.getsource(mod.format_briefing)
    assert "from pulse.core.brain_db import get_conn" in src, (
        "format_briefing() adversarial injection does not use pulse.core.brain_db"
    )
    assert "failures" in src, (
        "format_briefing() adversarial injection does not query the failures table"
    )


# ---------------------------------------------------------------------------
# P3-3: Volatility feeds exploration rate in should_rotate
# ---------------------------------------------------------------------------

def test_should_rotate_uses_volatility():
    """should_rotate() must import and apply get_volatility_multiplier."""
    import pulse.tasks.pulse_dispatcher as mod
    src = inspect.getsource(mod.should_rotate)
    assert "volatility" in src.lower(), (
        "should_rotate() has no reference to volatility — P3-3 fix missing"
    )


def test_should_rotate_volatility_import():
    """should_rotate() must import from pulse.core.volatility."""
    import pulse.tasks.pulse_dispatcher as mod
    src = inspect.getsource(mod.should_rotate)
    assert "from pulse.core.reward.volatility import get_volatility_multiplier" in src, (
        "should_rotate() does not import get_volatility_multiplier"
    )


def test_should_rotate_volatility_guarded():
    """Volatility exploration must be wrapped in try/except."""
    import pulse.tasks.pulse_dispatcher as mod
    src = inspect.getsource(mod.should_rotate)
    assert "except Exception" in src, (
        "should_rotate() volatility block is not protected by try/except"
    )


# ---------------------------------------------------------------------------
# P3-4: Working memory chunking
# ---------------------------------------------------------------------------

def test_working_memory_has_chunk_similar():
    """_chunk_similar() must exist in working_memory module."""
    import pulse.brain.memory.working_memory as mod
    assert hasattr(mod, "_chunk_similar"), (
        "pulse.brain.memory.working_memory has no _chunk_similar function — P3-4 fix missing"
    )


def test_chunk_similar_uses_text_similarity():
    """_chunk_similar() must delegate to brain_analysis.text_similarity."""
    import pulse.brain.memory.working_memory as mod
    src = inspect.getsource(mod._chunk_similar)
    assert "text_similarity" in src, (
        "_chunk_similar() does not use text_similarity — chunking logic incomplete"
    )


def test_evict_calls_chunk_similar():
    """_evict() must call _chunk_similar before scoring."""
    import pulse.brain.memory.working_memory as mod
    src = inspect.getsource(mod._evict)
    assert "_chunk_similar" in src, (
        "_evict() does not call _chunk_similar — P3-4 chunking not wired into eviction"
    )


def test_chunk_similar_threshold_param():
    """_chunk_similar() must accept a threshold parameter defaulting to 0.8."""
    import pulse.brain.memory.working_memory as mod
    sig = inspect.signature(mod._chunk_similar)
    assert "threshold" in sig.parameters, (
        "_chunk_similar() missing 'threshold' parameter"
    )
    assert sig.parameters["threshold"].default == 0.8, (
        "_chunk_similar() threshold default is not 0.8"
    )


def test_chunk_similar_merges_on_high_similarity():
    """_chunk_similar() must reduce list length when items are identical."""
    import pulse.brain.memory.working_memory as mod

    # Patch text_similarity to always return 1.0 so chunking always fires
    import pulse.core.brain_analysis as ba
    original = getattr(ba, "text_similarity", None)
    try:
        ba.text_similarity = lambda a, b: 1.0
        items = [
            {"text": "same text", "salience": 2.0},
            {"text": "same text", "salience": 1.0},
            {"text": "same text", "salience": 1.5},
        ]
        result = mod._chunk_similar(items, threshold=0.8)
        assert len(result) < len(items), (
            "_chunk_similar() did not reduce item count for identical texts"
        )
        assert result[0].get("_chunked_count", 1) > 1, (
            "_chunk_similar() did not set _chunked_count on merged item"
        )
    finally:
        if original is not None:
            ba.text_similarity = original


# ---------------------------------------------------------------------------
# P3-5: Reconsolidation hash expiry
# ---------------------------------------------------------------------------

def test_hash_expiry_constant_exists():
    """HASH_EXPIRY_DAYS must be defined in stage_io."""
    import pulse.daemons.consolidation.stages.stage_io as mod
    assert hasattr(mod, "HASH_EXPIRY_DAYS"), (
        "stage_io has no HASH_EXPIRY_DAYS constant — P3-5 fix missing"
    )
    assert mod.HASH_EXPIRY_DAYS == 14, (
        f"HASH_EXPIRY_DAYS is {mod.HASH_EXPIRY_DAYS}, expected 14"
    )


def test_load_processed_hashes_filters_expired():
    """_load_processed_hashes() must strip entries older than 14 days."""
    import pulse.daemons.consolidation.stages.stage_io as mod
    src = inspect.getsource(mod._load_processed_hashes)
    assert "HASH_EXPIRY_DAYS" in src, (
        "_load_processed_hashes() does not reference HASH_EXPIRY_DAYS"
    )
    assert "days" in src.lower(), (
        "_load_processed_hashes() has no age/days check for expiry"
    )


def test_save_processed_hashes_stores_timestamps():
    """_save_processed_hashes() must store hash→timestamp dict, not a list."""
    import pulse.daemons.consolidation.stages.stage_io as mod
    src = inspect.getsource(mod._save_processed_hashes)
    # The saved JSON must use the hashes dict directly (not list(hashes.keys()))
    assert '"hashes": hashes' in src or "'hashes': hashes" in src, (
        "_save_processed_hashes() does not store hashes as a dict with timestamps"
    )


def test_reconsolidation_stamps_new_hashes():
    """stage_reconsolidation.py must store an ISO timestamp when adding a hash."""
    src_path = (ROOT / "pulse" / "daemons" / "consolidation" /
                "stages" / "stage_reconsolidation.py")
    src = src_path.read_text(encoding="utf-8")
    assert "datetime.now(timezone.utc).isoformat()" in src, (
        "stage_reconsolidation.py does not stamp new hashes with current timestamp"
    )


def test_load_processed_hashes_handles_legacy_list():
    """_load_processed_hashes() must accept the old list format gracefully."""
    import pulse.daemons.consolidation.stages.stage_io as mod
    src = inspect.getsource(mod._load_processed_hashes)
    assert "isinstance(raw, list)" in src, (
        "_load_processed_hashes() has no legacy list-format migration path"
    )
