"""P44 HIGH-priority bug fixes — tests.

H11-H16 fixes applied in the P44 session.
H14 was already fixed (time-based burst cleanup) — test verifies it stays correct.
H13 verifies the bucket-key format is correct throughout the call chain.
"""
import inspect
import re
import sys
import time
from pathlib import Path
from unittest.mock import patch

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


# ---------------------------------------------------------------------------
# H11: IOR penalty included in eviction score
# ---------------------------------------------------------------------------

def test_h11_ior_in_eviction_score_source():
    """_evict() must multiply eviction score by (1 - _get_ior_penalty(...))."""
    import pulse.brain.memory.working_memory as wm
    src = inspect.getsource(wm._evict)
    assert "_get_ior_penalty" in src, "_evict() does not call _get_ior_penalty — H11 fix missing"
    # Line continuation means the subtraction may appear split; check for the key pattern
    assert "1.0 - _get_ior_penalty" in src, (
        "_evict() does not subtract IOR penalty from eviction score"
    )


def test_h11_broadcast_item_has_lower_eviction_score():
    """Broadcast item should have lower _es than a non-broadcast item of equal salience."""
    import pulse.brain.memory.working_memory as wm

    wm._RECENTLY_BROADCAST.clear()

    now_ts = "2026-03-21T12:00:00+00:00"
    fp_broadcast = "deadbeef0001"
    fp_normal = "deadbeef0002"

    item_broadcast = {
        "text": "broadcast item",
        "fingerprint": fp_broadcast,
        "salience": 2.0,
        "first_accessed": now_ts,
        "last_accessed": now_ts,
        "accessed_count": 1,
        "source": "test",
    }
    item_normal = {
        "text": "normal item",
        "fingerprint": fp_normal,
        "salience": 2.0,
        "first_accessed": now_ts,
        "last_accessed": now_ts,
        "accessed_count": 1,
        "source": "test",
    }

    wm._mark_broadcast([fp_broadcast])
    assert fp_broadcast in wm._RECENTLY_BROADCAST, "_mark_broadcast failed"

    # Manually compute _es for both items using the fixed formula
    captured_es = {}
    items = [item_broadcast, item_normal]
    for it in items:
        it["_es"] = (
            float(it.get("salience", 1.0))
            * wm._recency_weight(it.get("last_accessed", it.get("first_accessed", "")))
            * (1.0 - wm._get_ior_penalty(it.get("fingerprint", "")))
        )
        captured_es[it["fingerprint"]] = it["_es"]

    es_broadcast = captured_es.get(fp_broadcast)
    es_normal = captured_es.get(fp_normal)

    assert es_broadcast is not None, "broadcast item _es not computed"
    assert es_normal is not None, "normal item _es not computed"
    assert es_broadcast < es_normal, (
        f"Broadcast item should have LOWER eviction score than normal item "
        f"(broadcast={es_broadcast:.4f}, normal={es_normal:.4f})"
    )

    wm._RECENTLY_BROADCAST.clear()


# ---------------------------------------------------------------------------
# H12: Double temporal decay removed from search_scoring
# ---------------------------------------------------------------------------

def test_h12_temporal_confidence_not_called_in_score_item():
    """_score_item must NOT call tc_fn (temporal_confidence) to avoid double-decay."""
    import pulse.brain.brain_search.search_scoring as ss
    src = inspect.getsource(ss._score_item)
    assert 'tc_fn(h.get("confidence"' not in src, (
        "_score_item still calls tc_fn(h.get('confidence'...)) — H12 double-decay fix missing"
    )


def test_h12_decay_not_double_applied():
    """Scoring an old item must not double-decay via both compute_salience and temporal_confidence."""
    from pulse.brain.index.search_utils import compute_salience
    from pulse.core.temporal import temporal_confidence
    import math

    base_conf = 0.8
    days_old = 180
    config = {
        "decay": {"benchmark_decay_constant": 100},
        "search": {
            "relevance_weight": 0.45,
            "recency_weight": 0.15,
            "salience_weight": 0.25,
            "retrieval_weight": 0.15,
        },
    }

    salience_single = compute_salience(1.0, days_old, config)
    decayed_conf = temporal_confidence(base_conf, days_old=days_old, knowledge_type="lesson")

    # Double-decay path: what it would be if temporal_confidence were also applied in scoring
    double_decay_factor = math.exp(-days_old / 100)
    double_decayed = decayed_conf * double_decay_factor

    assert decayed_conf > double_decayed, (
        f"temporal_confidence ({decayed_conf:.4f}) should be > double_decayed ({double_decayed:.4f})"
    )
    assert 0.0 <= salience_single <= 1.0, f"compute_salience out of range: {salience_single}"


# ---------------------------------------------------------------------------
# H13: Hebbian reward key format
# ---------------------------------------------------------------------------

def test_h13_hebbian_called_with_bucket_key_in_source():
    """record_task_outcome must pass a 5-minute bucket key to apply_hebbian."""
    import pulse.core.reward.reward_signal as rs
    src = inspect.getsource(rs.record_task_outcome)
    assert "_min_hebb" in src or "_bucket" in src, (
        "record_task_outcome does not compute a 5-minute bucket key — H13 fix missing"
    )
    assert "apply_hebbian" in src, "apply_hebbian not called in record_task_outcome"


def test_h13_bucket_key_format():
    """Bucket key must match pattern YYYYMMDD_H_MM."""
    from datetime import datetime, timezone
    dt = datetime.now(timezone.utc)
    min_bucket = (dt.minute // 5) * 5
    sid = dt.strftime("%Y%m%d_%H") + f"_{min_bucket:02d}"
    bucket_pattern = re.compile(r"^\d{8}_\d{1,2}_\d{2}$")
    assert bucket_pattern.match(sid), f"Bucket key '{sid}' does not match expected pattern"


def test_h13_bucket_key_matches_hebbian_record_key():
    """The bucket key format in reward_signal must match the one in record_coactivation."""
    import pulse.brain.plasticity.hebbian_knowledge as hk
    src = inspect.getsource(hk.record_coactivation)
    assert "strftime" in src, "record_coactivation does not use strftime — format may diverge"
    assert "// 5) * 5" in src, "record_coactivation does not use 5-minute bucketing"


# ---------------------------------------------------------------------------
# H14: Burst detection — time-based cleanup (verify already correct)
# ---------------------------------------------------------------------------

def test_h14_burst_uses_time_based_cleanup():
    """_check_burst must use while-loop time-based cleanup, not a fixed count cap."""
    import pulse.brain.plasticity.stdp as stdp
    src = inspect.getsource(stdp._check_burst)
    assert "while _BURST_TIMESTAMPS" in src, (
        "_check_burst does not use while-loop time-based cleanup — H14 fix missing"
    )
    assert "len(_BURST_TIMESTAMPS) > 10" not in src, (
        "_check_burst still uses a count cap — H14 fix incomplete"
    )


def test_h14_no_false_burst_over_30_seconds():
    """20 pairs spread over 30s (> 5s window) must NOT trigger burst mode."""
    import pulse.brain.plasticity.stdp as stdp

    stdp._BURST_TIMESTAMPS.clear()

    now = time.time()
    # 20 timestamps each 1.6s apart — total span 30.4s, all older than the 5s window
    for i in range(20):
        stdp._BURST_TIMESTAMPS.append(now - 30.0 + i * 1.6)

    result = stdp._check_burst()

    remaining = len(stdp._BURST_TIMESTAMPS)
    assert remaining < stdp._BURST_THRESHOLD, (
        f"Burst falsely detected — {remaining} timestamps remain after cleanup "
        f"(threshold={stdp._BURST_THRESHOLD})"
    )
    assert not result, f"Burst falsely triggered with {remaining} recent events"

    stdp._BURST_TIMESTAMPS.clear()


# ---------------------------------------------------------------------------
# H15: Calibration persistence via SQLite
# ---------------------------------------------------------------------------

def test_h15_record_calibration_persists_to_sqlite():
    """_record_calibration must persist each entry to SQLite predictions table."""
    import pulse.brain.metacognition.metamemory as mm
    src = inspect.getsource(mm._record_calibration)

    assert "get_conn" in src or "brain_db" in src, (
        "_record_calibration does not use brain_db — H15 SQLite persistence missing"
    )
    assert "INSERT INTO predictions" in src, (
        "_record_calibration does not INSERT into predictions table"
    )
    assert "calibration" in src, (
        "_record_calibration does not tag row with domain='calibration'"
    )


def test_h15_get_calibration_factor_loads_from_sqlite_on_cold_start():
    """_get_calibration_factor must load from SQLite when in-memory history is sparse."""
    import pulse.brain.metacognition.metamemory as mm
    src = inspect.getsource(mm._get_calibration_factor)

    assert "get_conn" in src or "brain_db" in src, (
        "_get_calibration_factor does not query brain_db on cold start — H15 fix missing"
    )
    assert "domain='calibration'" in src or "domain=?" in src, (
        "_get_calibration_factor does not filter by domain='calibration'"
    )


def test_h15_calibration_roundtrip_via_sqlite():
    """Record 5 calibration entries, clear in-memory list, verify factor loads from DB."""
    import pulse.brain.metacognition.metamemory as mm

    recorded_inserts = []

    class FakeRow(dict):
        pass

    class FakeCursor:
        def __init__(self, rows):
            self._rows = rows
        def fetchall(self):
            return self._rows

    class FakeConn:
        def execute(self, sql, params=()):
            if sql.strip().upper().startswith("INSERT"):
                recorded_inserts.append(params)
                return FakeCursor([])
            # SELECT for cold-start load: return 15 synthetic rows
            rows = []
            for i in range(15):
                actual = 1 if i % 2 == 0 else 0
                row = FakeRow({"confidence": 0.6, "content": f"calibration:0.6:{actual}"})
                rows.append(row)
            return FakeCursor(rows)
        def commit(self):
            pass

    fake_conn = FakeConn()

    # get_conn is a local import inside each fn — patch the source module
    with patch("pulse.core.brain_db.get_conn", return_value=fake_conn):
        mm._CALIBRATION_HISTORY.clear()
        for _ in range(5):
            mm._record_calibration(0.6, True)

        assert len(recorded_inserts) == 5, (
            f"Expected 5 SQLite INSERTs, got {len(recorded_inserts)}"
        )

        # Simulate restart: clear in-memory history
        mm._CALIBRATION_HISTORY.clear()
        assert len(mm._CALIBRATION_HISTORY) == 0

        factor = mm._get_calibration_factor()

        assert 0.5 <= factor <= 1.5, f"Calibration factor {factor} out of expected range [0.5, 1.5]"
        assert len(mm._CALIBRATION_HISTORY) > 0, (
            "_get_calibration_factor did not populate _CALIBRATION_HISTORY from SQLite"
        )


# ---------------------------------------------------------------------------
# H16: GWT causal order — ignition before briefing
# ---------------------------------------------------------------------------

def test_h16_ignition_before_briefing_in_source():
    """run_workspace_ignition must be called BEFORE format_briefing in runner.py."""
    import pulse.brain.brain_query.runner as runner_mod
    src = inspect.getsource(runner_mod.run_query)

    ignition_pos = src.find("= run_workspace_ignition(")
    briefing_pos = src.find("= format_briefing(")

    assert ignition_pos != -1, "run_workspace_ignition call not found in run_query source"
    assert briefing_pos != -1, "format_briefing call not found in run_query source"
    assert ignition_pos < briefing_pos, (
        f"run_workspace_ignition call (pos {ignition_pos}) called AFTER format_briefing "
        f"call (pos {briefing_pos}) — H16 causal order fix missing"
    )


def test_h16_broadcast_items_injected_into_results():
    """broadcast_items from workspace ignition must be injected into results before format_briefing."""
    import pulse.brain.brain_query.runner as runner_mod
    src = inspect.getsource(runner_mod.run_query)
    assert "_workspace_broadcast" in src or "broadcast_items" in src, (
        "broadcast_items not injected into results — H16 fix incomplete"
    )


def test_h16_execution_order():
    """Mock workspace and briefing; verify ignition is called before briefing."""
    call_order = []

    mock_results = {
        "results": {"lessons": [{"text": "test", "confidence": 0.8}]},
        "total_results": 1,
        "elapsed_ms": 5,
    }

    def fake_brain_search(query, max_per_source=3, task_type="general"):
        return mock_results

    def fake_ignition(caller, query, results, task_type):
        call_order.append("ignition")
        return {"winning_sources": ["lessons"], "broadcast_items": []}

    def fake_format_briefing(query, results):
        call_order.append("briefing")
        return "[BRAIN] test briefing"

    import pulse.brain.brain_query.runner as runner_mod

    with patch.object(runner_mod, "brain_search", fake_brain_search), \
         patch.object(runner_mod, "format_briefing", fake_format_briefing), \
         patch("pulse.brain.workspace.global_workspace.run_workspace_ignition", fake_ignition):
        runner_mod.run_query("test query")

    assert "ignition" in call_order, "run_workspace_ignition was not called"
    assert "briefing" in call_order, "format_briefing was not called"
    assert call_order.index("ignition") < call_order.index("briefing"), (
        f"Ignition must come BEFORE briefing. Order was: {call_order}"
    )
