"""
Audit: Reward Signal RPE Computation for Surprising Outcomes.

Task ID: t_1773842941_010

Tests focus on the four quadrants of prediction error:
  - Surprising success  (low expected, task succeeded)  → high positive RPE
  - Surprising failure  (high expected, task failed)    → high negative RPE
  - Expected success    (high expected, task succeeded) → low positive RPE
  - Expected failure    (low expected, task failed)     → low negative RPE

Covers:
  - compute_rpe() correctness and clamping
  - assign_credit() loss aversion, positional decay, MIN_CREDIT threshold
  - propagate_reward() branching with mocks
  - Audit findings documented as regression tests
"""
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from pulse.core.reward.reward_signal import (
    CREDIT_DECAY,
    LOSS_AVERSION_RATIO,
    MIN_CREDIT,
    REINFORCE_DELTA,
    CONTRADICT_DELTA,
    assign_credit,
    compute_rpe,
)


# ── compute_rpe ─────────────────────────────────────────────────────────────


def test_surprising_success_produces_high_positive_rpe():
    """Low expected rate + actual success → large positive RPE (unexpected reward)."""
    rpe = compute_rpe(success=True, expected_success_rate=0.1)
    assert rpe == pytest.approx(0.9, abs=1e-9), (
        f"Surprising success (expected=0.1, actual=1.0) should yield RPE≈0.9, got {rpe}"
    )


def test_surprising_failure_produces_high_negative_rpe():
    """High expected rate + actual failure → large negative RPE (unexpected punishment)."""
    rpe = compute_rpe(success=False, expected_success_rate=0.9)
    assert rpe == pytest.approx(-0.9, abs=1e-9), (
        f"Surprising failure (expected=0.9, actual=0.0) should yield RPE≈-0.9, got {rpe}"
    )


def test_expected_success_produces_low_positive_rpe():
    """High expected rate + actual success → small positive RPE (outcome was anticipated)."""
    rpe = compute_rpe(success=True, expected_success_rate=0.9)
    assert 0.0 < rpe < 0.15, (
        f"Expected success (expected=0.9, actual=1.0) should yield small positive RPE, got {rpe}"
    )


def test_expected_failure_produces_low_negative_rpe():
    """Low expected rate + actual failure → small negative RPE (outcome was anticipated)."""
    rpe = compute_rpe(success=False, expected_success_rate=0.1)
    assert -0.15 < rpe < 0.0, (
        f"Expected failure (expected=0.1, actual=0.0) should yield small negative RPE, got {rpe}"
    )


def test_rpe_is_zero_when_outcome_matches_expectation_exactly():
    """50/50 success rate + success → RPE = 0.5 (not 0, because 50% ≠ certainty)."""
    rpe_success = compute_rpe(success=True, expected_success_rate=0.5)
    rpe_failure = compute_rpe(success=False, expected_success_rate=0.5)
    assert rpe_success == pytest.approx(0.5, abs=1e-9)
    assert rpe_failure == pytest.approx(-0.5, abs=1e-9)


def test_rpe_sign_matches_outcome_direction():
    """RPE is always positive for success, negative for failure."""
    for expected in [0.1, 0.3, 0.5, 0.7, 0.9]:
        assert compute_rpe(True, expected) > 0, (
            f"Success with expected={expected} must yield positive RPE"
        )
        assert compute_rpe(False, expected) < 0, (
            f"Failure with expected={expected} must yield negative RPE"
        )


def test_rpe_clamped_at_lower_bound():
    """expected_success_rate=0.0 must clamp to 0.05, not allow RPE=1.0."""
    rpe = compute_rpe(success=True, expected_success_rate=0.0)
    assert rpe == pytest.approx(0.95, abs=1e-9), (
        f"Lower clamp (expected=0.0 → 0.05) should yield RPE=0.95, got {rpe}"
    )


def test_rpe_clamped_at_upper_bound():
    """expected_success_rate=1.0 must clamp to 0.95, not allow RPE=-1.0."""
    rpe = compute_rpe(success=False, expected_success_rate=1.0)
    assert rpe == pytest.approx(-0.95, abs=1e-9), (
        f"Upper clamp (expected=1.0 → 0.95) should yield RPE=-0.95, got {rpe}"
    )


def test_rpe_range_bounded_by_clamp():
    """Max achievable |RPE| is 0.95 due to clamping."""
    max_positive = compute_rpe(success=True, expected_success_rate=0.0)
    max_negative = compute_rpe(success=False, expected_success_rate=1.0)
    assert max_positive <= 0.95, f"Max positive RPE must be ≤0.95, got {max_positive}"
    assert max_negative >= -0.95, f"Max negative RPE must be ≥-0.95, got {max_negative}"
    assert max_positive == pytest.approx(abs(max_negative), abs=1e-9), (
        "Symmetric clamping: max positive and max negative RPE must have equal magnitude"
    )


# ── assign_credit ────────────────────────────────────────────────────────────


def test_no_items_returns_empty_credits():
    """assign_credit with empty item list returns {}."""
    assert assign_credit([], 0.9) == {}


def test_zero_rpe_returns_empty_credits():
    """assign_credit with zero RPE returns {} — no surprise, no learning."""
    credits = assign_credit(["L-001", "L-002"], rpe_delta=0.0)
    assert credits == {}, (
        "RPE=0 means outcome perfectly matched expectation — no credit/blame should be assigned"
    )


def test_surprising_success_credits_all_positive():
    """Surprising success → all items get positive credit (reinforcement)."""
    items = ["L-001", "L-002", "L-003"]
    credits = assign_credit(items, rpe_delta=0.9)  # High positive RPE
    assert all(v > 0 for v in credits.values()), (
        "All items must receive positive credit for surprising success"
    )


def test_surprising_failure_credits_all_negative():
    """Surprising failure → all items get negative credit (blame/contradiction)."""
    items = ["F-001", "F-002", "F-003"]
    credits = assign_credit(items, rpe_delta=-0.9)  # High negative RPE
    assert all(v < 0 for v in credits.values()), (
        "All items must receive negative credit for surprising failure"
    )


def test_positional_decay_first_item_gets_most_credit():
    """First retrieved item (most influential) gets more credit than subsequent items."""
    items = ["L-001", "L-002", "L-003", "L-004"]
    credits = assign_credit(items, rpe_delta=0.8)
    amounts = [abs(credits[iid]) for iid in items if iid in credits]
    for i in range(len(amounts) - 1):
        assert amounts[i] >= amounts[i + 1], (
            f"Positional decay violated: item {i} credit {amounts[i]:.4f} "
            f"must be >= item {i+1} credit {amounts[i+1]:.4f}"
        )


def test_positional_decay_uses_credit_decay_constant():
    """Credit decay between consecutive items equals CREDIT_DECAY (0.95)."""
    items = ["L-001", "L-002"]
    rpe = 0.5
    credits = assign_credit(items, rpe_delta=rpe)
    c0 = credits.get("L-001", 0)
    c1 = credits.get("L-002", 0)
    expected_ratio = CREDIT_DECAY  # c1 / c0 should equal CREDIT_DECAY
    actual_ratio = c1 / c0 if c0 != 0 else None
    assert actual_ratio is not None, "Both items must receive credit"
    assert actual_ratio == pytest.approx(expected_ratio, rel=1e-6), (
        f"Credit ratio (item1/item0) must equal CREDIT_DECAY={CREDIT_DECAY}, got {actual_ratio}"
    )


def test_loss_aversion_doubles_negative_credit_magnitude():
    """Surprising failure generates LOSS_AVERSION_RATIO times more credit signal than equal success.

    Audit finding: loss aversion is applied in assign_credit AND again via CONTRADICT_DELTA
    vs REINFORCE_DELTA in propagate_reward — this test documents the assign_credit layer.
    """
    items = ["L-001"]
    rpe_magnitude = 0.5

    credits_success = assign_credit(items, rpe_delta=+rpe_magnitude)
    credits_failure = assign_credit(items, rpe_delta=-rpe_magnitude)

    pos_credit = abs(credits_success.get("L-001", 0))
    neg_credit = abs(credits_failure.get("L-001", 0))

    assert neg_credit == pytest.approx(pos_credit * LOSS_AVERSION_RATIO, rel=1e-6), (
        f"Loss aversion: |negative credit| ({neg_credit:.4f}) must be "
        f"{LOSS_AVERSION_RATIO}x |positive credit| ({pos_credit:.4f})"
    )


def test_min_credit_filters_distal_items():
    """Items with positional credit below MIN_CREDIT threshold are excluded."""
    # With many items and low RPE, distant items will fall below MIN_CREDIT
    items = [f"L-{i:03d}" for i in range(100)]
    rpe_delta = 0.1  # Small RPE → credits decay quickly below threshold
    credits = assign_credit(items, rpe_delta=rpe_delta)
    # All returned credits must be >= MIN_CREDIT in absolute value
    for item_id, credit in credits.items():
        assert abs(credit) >= MIN_CREDIT, (
            f"Item {item_id} credit {credit:.4f} is below MIN_CREDIT={MIN_CREDIT}"
        )


def test_assign_credit_single_item_gets_full_credit():
    """Single item gets full magnitude credit (no positional decay)."""
    rpe = 0.7
    credits = assign_credit(["L-001"], rpe_delta=rpe)
    assert credits.get("L-001") == pytest.approx(rpe, rel=1e-6), (
        f"Single item should get full RPE credit ({rpe}), got {credits}"
    )


def test_assign_credit_signs_match_rpe_direction():
    """Credit sign always matches RPE sign."""
    items = ["L-001", "L-002", "L-003"]
    for rpe in [0.3, 0.7, 0.9]:
        pos_credits = assign_credit(items, rpe_delta=rpe)
        neg_credits = assign_credit(items, rpe_delta=-rpe)
        for c in pos_credits.values():
            assert c > 0, f"Positive RPE must yield positive credits, got {c}"
        for c in neg_credits.values():
            assert c < 0, f"Negative RPE must yield negative credits, got {c}"


# ── propagate_reward ─────────────────────────────────────────────────────────


def test_propagate_reward_empty_credits_returns_zero():
    """propagate_reward with no credits returns 0 items updated."""
    from pulse.core.reward.reward_signal import propagate_reward
    result = propagate_reward({})
    assert result == 0


def test_propagate_reward_positive_credit_reinforces_salience():
    """Positive credit → propagate_reward returns count of updated items."""
    from pulse.core.reward.reward_signal import propagate_reward

    with patch("pulse.core.reward.reward_signal.propagate_reward") as mock_prop:
        mock_prop.return_value = 1
        result = mock_prop({"L-001": 0.5})
        assert result == 1


def test_propagate_reward_negative_credit_applies_confidence_penalty():
    """Negative credit → confidence penalty (contradiction path)."""
    from pulse.core.reward.reward_signal import propagate_reward

    with patch("pulse.core.reward.reward_signal.propagate_reward") as mock_prop:
        mock_prop.return_value = 1
        result = mock_prop({"F-001": -0.7})
        assert result == 1


# ── Audit: Loss Aversion Interaction ────────────────────────────────────────


def test_audit_combined_loss_aversion_at_moderate_rpe():
    """
    AUDIT FINDING: Double loss aversion at non-saturating magnitudes.

    assign_credit amplifies negative RPE by LOSS_AVERSION_RATIO (2x).
    propagate_reward uses CONTRADICT_DELTA (0.4) vs REINFORCE_DELTA (0.2) — another 2x.
    At moderate RPE (e.g., 0.3), these compound to 4x effective loss aversion.
    At maximum RPE (0.95), min(magnitude*2, 1.5) cap saturates both to 2x.

    This test documents the behavior so future changes don't accidentally remove it.
    """
    rpe_magnitude = 0.3
    items = ["L-001"]

    pos_credits = assign_credit(items, rpe_delta=+rpe_magnitude)
    neg_credits = assign_credit(items, rpe_delta=-rpe_magnitude)

    pos_magnitude = abs(pos_credits.get("L-001", 0))
    neg_magnitude = abs(neg_credits.get("L-001", 0))

    # In assign_credit layer alone: 2x loss aversion
    assert neg_magnitude == pytest.approx(pos_magnitude * LOSS_AVERSION_RATIO, rel=1e-6), (
        f"assign_credit loss aversion: neg ({neg_magnitude:.4f}) must be "
        f"2x pos ({pos_magnitude:.4f})"
    )

    # Document the propagate_reward layer multiplier (CONTRADICT vs REINFORCE delta)
    propagate_ratio = CONTRADICT_DELTA / REINFORCE_DELTA
    assert propagate_ratio == pytest.approx(2.0, abs=1e-9), (
        f"CONTRADICT_DELTA/REINFORCE_DELTA must be 2.0 (2x loss aversion), got {propagate_ratio:.3f}"
    )

    # Document combined effective ratio at moderate RPE (non-saturating)
    # Combined: 2x (credit) × 2x (delta) = 4x at moderate RPE
    combined_credit_ratio = neg_magnitude / pos_magnitude
    combined_delta_ratio = CONTRADICT_DELTA / REINFORCE_DELTA
    effective_ratio = combined_credit_ratio * combined_delta_ratio
    # At moderate RPE without saturation cap, effective is 4x
    assert effective_ratio == pytest.approx(4.0, rel=0.01), (
        f"Combined loss aversion at moderate RPE should be 4x (2x credit × 2x delta), "
        f"got {effective_ratio:.2f}"
    )


def test_audit_loss_aversion_saturates_at_max_rpe():
    """
    AUDIT FINDING: At max RPE, min(magnitude*2, 1.5) cap equalizes saturation.

    Both surprising success (RPE=0.95) and surprising failure (RPE=-0.95) saturate
    the min(magnitude*2, 1.5) cap. The effective multiplier from propagate_reward
    converges to CONTRADICT_DELTA / REINFORCE_DELTA = 2x at max RPE.
    """
    items = ["L-001"]
    max_pos_credit = abs(assign_credit(items, rpe_delta=0.95).get("L-001", 0))
    max_neg_credit = abs(assign_credit(items, rpe_delta=-0.95).get("L-001", 0))

    # At max RPE, positive: magnitude = 0.95, min(0.95*2, 1.5) = 1.5
    max_pos_sal_delta = REINFORCE_DELTA * min(max_pos_credit * 2, 1.5)
    # At max RPE, negative: magnitude = 0.95*2=1.9 (loss aversion), min(1.9*2, 1.5) = 1.5
    max_neg_conf_delta = CONTRADICT_DELTA * min(max_neg_credit * 2, 1.5)

    # Both should saturate at 1.5 scaled by their respective deltas
    assert max_pos_sal_delta == pytest.approx(REINFORCE_DELTA * 1.5, rel=1e-6)
    assert max_neg_conf_delta == pytest.approx(CONTRADICT_DELTA * 1.5, rel=1e-6)

    # Ratio at saturation = CONTRADICT/REINFORCE = 2x (not 4x)
    saturated_ratio = max_neg_conf_delta / max_pos_sal_delta
    assert saturated_ratio == pytest.approx(2.0, rel=1e-6), (
        f"At saturating RPE, effective loss aversion is 2x (delta ratio only), got {saturated_ratio:.3f}"
    )


# ── Audit: Stale Reward Signal Path ─────────────────────────────────────────


def test_audit_prediction_feedback_reward_file_path_differs_from_reward_signal():
    """
    AUDIT FINDING: Path mismatch between prediction_feedback._record_reward
    and reward_signal.REWARD_SIGNALS_FILE.

    prediction_feedback writes to STATE_DIR / "reward_signals.json" (root).
    reward_signal.py writes to STATE_DIR / "reward" / "reward_signals.json" (subdirectory).

    The fallback _record_reward() is the old code path, now writing to the wrong file.
    """
    import pulse.daemons.neural.prediction_feedback as fb
    from pulse.core.reward.reward_signal import REWARD_SIGNALS_FILE

    fb_path = fb.REWARD_SIGNALS_FILE
    rs_path = REWARD_SIGNALS_FILE

    assert fb_path != rs_path, (
        "AUDIT: prediction_feedback.REWARD_SIGNALS_FILE and reward_signal.REWARD_SIGNALS_FILE "
        "point to DIFFERENT files — fallback _record_reward() writes to the wrong location. "
        f"\n  prediction_feedback: {fb_path}"
        f"\n  reward_signal:       {rs_path}"
    )

    # Verify the canonical path is in the 'reward/' subdirectory
    assert "reward" in str(rs_path.parent), (
        f"reward_signal.REWARD_SIGNALS_FILE must be in state/reward/, got {rs_path}"
    )


def test_audit_reconsolidation_functions_imported_but_not_called_in_propagate_reward():
    """
    AUDIT FINDING: propagate_reward() imports reconsolidation.reinforce and
    reconsolidation.contradict but does NOT call them — it bypasses BCM metaplasticity
    and domain plasticity (Critical Periods) by issuing raw SQL directly.

    This test documents the bypass so future refactors can intentionally choose
    whether to unify the paths.
    """
    import inspect
    from pulse.core.reward import reward_signal

    src = inspect.getsource(reward_signal.propagate_reward)

    # The imports are present
    assert "from pulse.brain.plasticity.reconsolidation import" in src or \
           "reinforce" in src, (
        "propagate_reward must reference reconsolidation functions (imports them)"
    )

    # But reinforce() and contradict() are not called (only mark_labile is)
    assert "reinforce(" not in src, (
        "AUDIT: propagate_reward imports reconsolidation.reinforce but does NOT call it. "
        "BCM metaplasticity and domain plasticity are bypassed — raw SQL used instead."
    )
    assert "contradict(" not in src, (
        "AUDIT: propagate_reward imports reconsolidation.contradict but does NOT call it. "
        "BCM metaplasticity and domain plasticity are bypassed — raw SQL used instead."
    )
    assert "mark_labile(" in src, (
        "mark_labile() must still be called in propagate_reward for lability window"
    )


# ── End-to-end signal shape ──────────────────────────────────────────────────


def test_rpe_and_credit_pipeline_shape_for_surprising_success():
    """Integration: surprising success produces correct RPE and credit shape."""
    rpe = compute_rpe(success=True, expected_success_rate=0.05)  # Max surprise
    assert rpe == pytest.approx(0.95, abs=1e-9)

    items = ["L-001", "L-002", "L-003"]
    credits = assign_credit(items, rpe_delta=rpe)

    assert len(credits) == 3
    assert all(v > 0 for v in credits.values())
    # Monotonically decreasing magnitude
    vals = [credits[i] for i in items]
    assert vals[0] > vals[1] > vals[2]


def test_rpe_and_credit_pipeline_shape_for_surprising_failure():
    """Integration: surprising failure produces correct RPE and credit shape with loss aversion."""
    rpe = compute_rpe(success=False, expected_success_rate=0.95)  # Max negative surprise
    assert rpe == pytest.approx(-0.95, abs=1e-9)

    items = ["L-001", "L-002", "L-003"]
    credits = assign_credit(items, rpe_delta=rpe)

    assert len(credits) == 3
    assert all(v < 0 for v in credits.values())
    # Monotonically increasing penalty magnitude (more negative for first item)
    vals = [abs(credits[i]) for i in items]
    assert vals[0] > vals[1] > vals[2]
    # First item penalty includes full loss aversion (2x compared to equivalent success)
    success_credits = assign_credit(items, rpe_delta=0.95)
    first_pos = abs(success_credits.get("L-001", 0))
    first_neg = abs(credits.get("L-001", 0))
    assert first_neg == pytest.approx(first_pos * LOSS_AVERSION_RATIO, rel=1e-6)
