"""Additional scoring tests — bayesian_calibrate, compute_calibration_accuracy,
_resolve_event_reward, and forced-distribution caps.

Targets the lines at 76% coverage -> improves to ~85%+.
"""

from __future__ import annotations

from datetime import date, datetime, timedelta, timezone
from pathlib import Path

import pytest

import trw_mcp.scoring as scoring_mod
from trw_mcp.models.run import EventType
from trw_mcp.scoring import (
    REWARD_MAP,
    rank_by_utility,
    utility_based_prune_candidates,
)

# ---------------------------------------------------------------------------
# TestBayesianCalibrate
# ---------------------------------------------------------------------------


class TestBayesianCalibrate:
    """Tests for bayesian_calibrate — Bayesian posterior impact score."""

    def test_zero_weights_returns_user_impact(self) -> None:
        """Both weights=0 returns user_impact as fallback."""
        result = scoring_mod.bayesian_calibrate(0.7, user_weight=0.0, org_weight=0.0)
        assert result == pytest.approx(0.7)

    def test_equal_weights(self) -> None:
        """Equal weights average user and org mean."""
        result = scoring_mod.bayesian_calibrate(0.8, org_mean=0.4, user_weight=1.0, org_weight=1.0)
        assert result == pytest.approx(0.6)

    def test_user_heavy_weighting(self) -> None:
        """High user_weight keeps result close to user_impact."""
        result = scoring_mod.bayesian_calibrate(0.9, org_mean=0.3, user_weight=10.0, org_weight=1.0)
        assert result > 0.8

    def test_org_heavy_weighting(self) -> None:
        """High org_weight pulls result toward org_mean."""
        result = scoring_mod.bayesian_calibrate(0.9, org_mean=0.3, user_weight=0.5, org_weight=10.0)
        assert result < 0.5

    def test_org_weight_capped_at_two(self) -> None:
        """org_weight is capped at 2.0 internally."""
        result_high = scoring_mod.bayesian_calibrate(0.8, org_mean=0.2, user_weight=1.0, org_weight=100.0)
        result_capped = scoring_mod.bayesian_calibrate(0.8, org_mean=0.2, user_weight=1.0, org_weight=2.0)
        assert result_high == pytest.approx(result_capped)

    def test_result_clamped_to_unit_range(self) -> None:
        """Result is always in [0.0, 1.0]."""
        result = scoring_mod.bayesian_calibrate(1.0, org_mean=1.0, user_weight=1.0, org_weight=1.0)
        assert 0.0 <= result <= 1.0

    def test_default_org_mean(self) -> None:
        """Default org_mean is 0.5 (regression toward center)."""
        result = scoring_mod.bayesian_calibrate(0.8, user_weight=1.0, org_weight=0.5)
        assert result == pytest.approx((0.8 * 1.0 + 0.5 * 0.5) / 1.5, abs=0.01)

    @pytest.mark.parametrize("impact", [0.0, 0.25, 0.5, 0.75, 1.0])
    def test_various_user_impacts(self, impact: float) -> None:
        """All user impact values produce valid output in [0, 1]."""
        result = scoring_mod.bayesian_calibrate(impact)
        assert 0.0 <= result <= 1.0


# ---------------------------------------------------------------------------
# TestComputeCalibrationAccuracy
# ---------------------------------------------------------------------------


class TestComputeCalibrationAccuracy:
    """Tests for compute_calibration_accuracy — weight based on recall history."""

    def test_no_recalls_returns_default(self) -> None:
        """Zero total recalls returns default weight of 1.0."""
        result = scoring_mod.compute_calibration_accuracy({"total_recalls": 0, "positive_outcomes": 0})
        assert result == pytest.approx(1.0)

    def test_empty_dict_returns_default(self) -> None:
        """Missing keys treated as 0, returns 1.0 default."""
        result = scoring_mod.compute_calibration_accuracy({})
        assert result == pytest.approx(1.0)

    def test_all_positive_returns_high_weight(self) -> None:
        """100% positive → weight 2.0."""
        result = scoring_mod.compute_calibration_accuracy({"total_recalls": 10, "positive_outcomes": 10})
        assert result == pytest.approx(2.0)

    def test_seventy_five_percent_positive(self) -> None:
        """75% positive → weight 2.0."""
        result = scoring_mod.compute_calibration_accuracy({"total_recalls": 8, "positive_outcomes": 6})
        assert result == pytest.approx(2.0)

    def test_fifty_percent_positive(self) -> None:
        """50% positive → weight 1.5."""
        result = scoring_mod.compute_calibration_accuracy({"total_recalls": 10, "positive_outcomes": 5})
        assert result == pytest.approx(1.5)

    def test_twenty_five_percent_positive(self) -> None:
        """25% positive → weight 1.0."""
        result = scoring_mod.compute_calibration_accuracy({"total_recalls": 8, "positive_outcomes": 2})
        assert result == pytest.approx(1.0)

    def test_below_twenty_five_percent(self) -> None:
        """<25% positive → weight 0.5."""
        result = scoring_mod.compute_calibration_accuracy({"total_recalls": 10, "positive_outcomes": 1})
        assert result == pytest.approx(0.5)

    def test_zero_positive_outcomes(self) -> None:
        """0 positive outcomes → weight 0.5 (below 25% threshold)."""
        result = scoring_mod.compute_calibration_accuracy({"total_recalls": 5, "positive_outcomes": 0})
        assert result == pytest.approx(0.5)


# ---------------------------------------------------------------------------
# TestResolveEventReward
# ---------------------------------------------------------------------------


class TestResolveEventReward:
    """Tests for _resolve_event_reward — event -> reward resolution."""

    def test_direct_reward_map_hit(self) -> None:
        """Direct REWARD_MAP match returns reward and event_type."""
        reward, label = scoring_mod._resolve_event_reward(EventType.TESTS_PASSED)
        assert reward == REWARD_MAP[EventType.TESTS_PASSED]
        assert label == EventType.TESTS_PASSED

    def test_phase_gate_passed(self) -> None:
        """phase_gate_passed maps to 1.0 reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.PHASE_GATE_PASSED)
        assert reward == pytest.approx(1.0)

    def test_phase_gate_failed(self) -> None:
        """phase_gate_failed maps to negative reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.PHASE_GATE_FAILED)
        assert reward is not None
        assert reward < 0

    def test_shard_complete_alias(self) -> None:
        """shard_completed alias resolves to shard_complete reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.SHARD_COMPLETED)
        assert reward is not None
        assert reward == REWARD_MAP[EventType.SHARD_COMPLETE]

    def test_shard_started_no_reward(self) -> None:
        """shard_started has explicit None alias (no reward)."""
        reward, label = scoring_mod._resolve_event_reward(EventType.SHARD_STARTED)
        assert reward is None
        assert label == EventType.SHARD_STARTED

    def test_run_init_no_reward(self) -> None:
        """run_init has None alias."""
        reward, label = scoring_mod._resolve_event_reward(EventType.RUN_INIT)
        assert reward is None

    def test_build_passed_alias(self) -> None:
        """build_passed alias resolves to float reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.BUILD_PASSED)
        assert reward is not None
        assert isinstance(reward, float)
        assert reward > 0

    def test_build_failed_alias(self) -> None:
        """build_failed alias resolves to negative float reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.BUILD_FAILED)
        assert reward is not None
        assert reward < 0

    def test_test_run_passed_data_aware(self) -> None:
        """test_run with passed=True routes to tests_passed reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.TEST_RUN, event_data={"passed": True})
        assert reward == REWARD_MAP[EventType.TESTS_PASSED]
        assert label == EventType.TESTS_PASSED

    def test_test_run_failed_data_aware(self) -> None:
        """test_run with passed=False routes to tests_failed reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.TEST_RUN, event_data={"passed": False})
        assert reward == REWARD_MAP[EventType.TESTS_FAILED]
        assert label == EventType.TESTS_FAILED

    def test_test_run_passed_string_true(self) -> None:
        """test_run with passed='true' (string) also routes to tests_passed."""
        reward, label = scoring_mod._resolve_event_reward(EventType.TEST_RUN, event_data={"passed": "true"})
        assert label == EventType.TESTS_PASSED

    def test_prd_status_change_to_approved(self) -> None:
        """prd_status_change with new_status=approved routes to prd_approved reward."""
        reward, label = scoring_mod._resolve_event_reward(
            EventType.PRD_STATUS_CHANGE, event_data={"new_status": "approved"}
        )
        assert reward == REWARD_MAP[EventType.PRD_APPROVED]
        assert label == EventType.PRD_APPROVED

    def test_prd_status_change_non_approved(self) -> None:
        """prd_status_change with other status gets None reward."""
        reward, label = scoring_mod._resolve_event_reward(
            EventType.PRD_STATUS_CHANGE, event_data={"new_status": "review"}
        )
        assert reward is None

    def test_compliance_check_passing_score(self) -> None:
        """compliance_check with score >= 0.8 returns compliance_passed reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.COMPLIANCE_CHECK, event_data={"score": 0.9})
        assert reward == REWARD_MAP[EventType.COMPLIANCE_PASSED]

    def test_compliance_check_failing_score(self) -> None:
        """compliance_check with score < 0.8 returns None."""
        reward, label = scoring_mod._resolve_event_reward(EventType.COMPLIANCE_CHECK, event_data={"score": 0.5})
        assert reward is None

    def test_compliance_check_invalid_score(self) -> None:
        """compliance_check with invalid score (non-numeric) returns None."""
        reward, label = scoring_mod._resolve_event_reward(
            EventType.COMPLIANCE_CHECK, event_data={"score": "not-a-number"}
        )
        assert reward is None

    def test_error_keyword_fallback(self) -> None:
        """Unknown event with error keyword gets fallback reward."""
        reward, label = scoring_mod._resolve_event_reward("some_error_event")
        assert reward is not None
        assert reward < 0  # error fallback is negative

    def test_unknown_event_no_reward(self) -> None:
        """Completely unknown event with no keywords returns None label."""
        reward, label = scoring_mod._resolve_event_reward("totally_unknown_xyz")
        assert label == "totally_unknown_xyz"

    def test_checkpoint_alias(self) -> None:
        """checkpoint alias resolves to small positive reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.CHECKPOINT)
        assert reward is not None
        assert reward > 0

    def test_reflection_completed_alias(self) -> None:
        """reflection_completed alias resolves to reflection_complete reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.REFLECTION_COMPLETED)
        assert reward is not None
        assert reward == REWARD_MAP[EventType.REFLECTION_COMPLETE]

    def test_no_event_data_for_test_run(self) -> None:
        """test_run without event_data skips data-aware routing."""
        reward, label = scoring_mod._resolve_event_reward(EventType.TEST_RUN, event_data=None)
        # No event_data: falls through to alias lookup (None for test_run)
        assert reward is None

    def test_phase_revert_alias(self) -> None:
        """phase_revert alias resolves to negative float reward."""
        reward, label = scoring_mod._resolve_event_reward(EventType.PHASE_REVERT)
        assert reward is not None
        assert isinstance(reward, float)
        assert reward < 0


# ---------------------------------------------------------------------------
# TestDaysSinceAccess
# ---------------------------------------------------------------------------


class TestDaysSinceAccess:
    """Tests for _days_since_access helper."""

    def test_uses_last_accessed_at(self) -> None:
        """Uses last_accessed_at when available."""
        today = date(2026, 2, 22)
        entry = {"last_accessed_at": "2026-02-15"}
        result = scoring_mod._days_since_access(entry, today)
        assert result == 7

    def test_falls_back_to_created(self) -> None:
        """Falls back to created field when last_accessed_at missing."""
        today = date(2026, 2, 22)
        entry = {"created": "2026-02-01"}
        result = scoring_mod._days_since_access(entry, today)
        assert result == 21

    def test_uses_fallback_days_when_no_dates(self) -> None:
        """Returns fallback_days when no date fields present."""
        today = date(2026, 2, 22)
        entry: dict[str, object] = {}
        result = scoring_mod._days_since_access(entry, today, fallback_days=99)
        assert result == 99

    def test_invalid_date_skipped(self) -> None:
        """Invalid date string falls back to created or fallback_days."""
        today = date(2026, 2, 22)
        entry = {"last_accessed_at": "not-a-date", "created": "2026-02-20"}
        result = scoring_mod._days_since_access(entry, today)
        assert result == 2  # falls back to created

    def test_both_invalid_uses_fallback(self) -> None:
        """Both invalid dates uses fallback_days."""
        today = date(2026, 2, 22)
        entry = {"last_accessed_at": "bad", "created": "also-bad"}
        result = scoring_mod._days_since_access(entry, today, fallback_days=42)
        assert result == 42


# ---------------------------------------------------------------------------
# TestEnsureUtc
# ---------------------------------------------------------------------------


class TestEnsureUtc:
    """Tests for _ensure_utc helper."""

    def test_naive_datetime_gets_utc(self) -> None:
        """Naive datetime gets UTC timezone assigned."""
        aware = datetime(2026, 2, 22, 10, 0, 0, tzinfo=timezone.utc)
        naive = aware.replace(tzinfo=None)  # intentionally strip tz to test _ensure_utc
        result = scoring_mod._ensure_utc(naive)
        assert result.tzinfo == timezone.utc

    def test_aware_datetime_unchanged(self) -> None:
        """Already timezone-aware datetime is returned unchanged."""
        aware = datetime(2026, 2, 22, 10, 0, 0, tzinfo=timezone.utc)
        result = scoring_mod._ensure_utc(aware)
        assert result == aware


# ---------------------------------------------------------------------------
# TestFieldExtractors
# ---------------------------------------------------------------------------


class TestFieldExtractors:
    """Tests for safe_float and safe_int helpers (canonical dict extractors)."""

    def test_float_field_present(self) -> None:
        assert scoring_mod.safe_float({"impact": 0.75}, "impact", 0.5) == pytest.approx(0.75)

    def test_float_field_missing_uses_default(self) -> None:
        assert scoring_mod.safe_float({}, "impact", 0.5) == pytest.approx(0.5)

    def test_float_field_string_coercion(self) -> None:
        assert scoring_mod.safe_float({"impact": "0.9"}, "impact", 0.0) == pytest.approx(0.9)

    def test_int_field_present(self) -> None:
        assert scoring_mod.safe_int({"recurrence": 5}, "recurrence", 1) == 5

    def test_int_field_missing_uses_default(self) -> None:
        assert scoring_mod.safe_int({}, "recurrence", 1) == 1

    def test_int_field_string_coercion(self) -> None:
        assert scoring_mod.safe_int({"recurrence": "3"}, "recurrence", 0) == 3


# ---------------------------------------------------------------------------
# TestRankByUtility
# ---------------------------------------------------------------------------


class TestRankByUtility:
    """Tests for rank_by_utility — re-ranking matched learnings."""

    def _make_entry(self, summary: str, impact: float = 0.5) -> dict[str, object]:
        return {
            "id": f"L-{summary[:4]}",
            "summary": summary,
            "detail": "",
            "tags": [],
            "impact": impact,
            "q_value": impact,
            "q_observations": 5,
            "recurrence": 1,
            "access_count": 0,
            "source_type": "agent",
            "created": "2026-02-01",
        }

    def test_empty_list_returns_empty(self) -> None:
        result = rank_by_utility([], query_tokens=["test"], lambda_weight=0.5)
        assert result == []

    def test_single_entry_returned(self) -> None:
        entries = [self._make_entry("testing framework")]
        result = rank_by_utility(entries, query_tokens=["testing"], lambda_weight=0.5)
        assert len(result) == 1

    def test_higher_relevance_ranked_first(self) -> None:
        """Entry matching query tokens ranks higher with pure relevance."""
        entries = [
            self._make_entry("unrelated content"),
            self._make_entry("testing best practices"),
        ]
        result = rank_by_utility(entries, query_tokens=["testing"], lambda_weight=0.0)
        assert "testing" in str(result[0]["summary"]).lower()

    def test_wildcard_query_uses_utility(self) -> None:
        """Empty query tokens → wildcard mode, pure utility ranking."""
        entries = [
            self._make_entry("low impact entry", impact=0.2),
            self._make_entry("high impact entry", impact=0.9),
        ]
        result = rank_by_utility(entries, query_tokens=[], lambda_weight=1.0)
        assert result[0]["summary"] == "high impact entry"

    def test_human_source_boosts_utility(self) -> None:
        """Human-sourced entries get a utility boost over agent entries."""
        agent_entry = self._make_entry("agent learning", impact=0.7)
        human_entry = self._make_entry("human learning", impact=0.7)
        human_entry["source_type"] = "human"

        result = rank_by_utility([agent_entry, human_entry], query_tokens=[], lambda_weight=1.0)
        assert result[0]["summary"] == "human learning"

    def test_tag_hits_boost_relevance(self) -> None:
        """Tags matching query tokens increase relevance score."""
        entry_with_tag = self._make_entry("generic entry", impact=0.5)
        entry_with_tag["tags"] = ["pytest", "testing"]

        entry_no_tag = self._make_entry("also generic", impact=0.5)
        entry_no_tag["tags"] = []

        result = rank_by_utility(
            [entry_no_tag, entry_with_tag],
            query_tokens=["pytest"],
            lambda_weight=0.0,
        )
        assert result[0]["tags"] == ["pytest", "testing"]


# ---------------------------------------------------------------------------
# TestUtilityBasedPruneCandidates
# ---------------------------------------------------------------------------


class TestUtilityBasedPruneCandidates:
    """Tests for utility_based_prune_candidates."""

    def _make_entry_tuple(
        self,
        entry_id: str,
        created: str,
        status: str = "active",
        impact: float = 0.3,
    ) -> tuple[Path, dict[str, object]]:
        data: dict[str, object] = {
            "id": entry_id,
            "summary": f"Learning {entry_id}",
            "created": created,
            "status": status,
            "impact": impact,
            "q_value": impact,
            "q_observations": 0,
            "recurrence": 1,
            "access_count": 0,
            "source_type": "agent",
        }
        return (Path(f"/fake/{entry_id}.yaml"), data)

    def test_empty_entries_returns_empty(self) -> None:
        result = utility_based_prune_candidates([])
        assert result == []

    def test_resolved_status_is_candidate(self) -> None:
        """Entries with resolved status are tier-1 cleanup candidates."""
        entries = [self._make_entry_tuple("L-001", "2026-01-01", status="resolved")]
        result = utility_based_prune_candidates(entries)
        assert len(result) == 1
        assert result[0]["suggested_status"] == "resolved"
        assert "cleanup candidate" in result[0]["reason"]

    def test_obsolete_status_is_candidate(self) -> None:
        """Entries with obsolete status are tier-1 cleanup candidates."""
        entries = [self._make_entry_tuple("L-002", "2026-01-01", status="obsolete")]
        result = utility_based_prune_candidates(entries)
        assert len(result) == 1

    def test_invalid_created_date_skipped(self) -> None:
        """Entries with invalid created date are skipped."""
        entry = self._make_entry_tuple("L-003", "not-a-date")
        result = utility_based_prune_candidates([entry])
        assert result == []

    def test_duplicate_ids_deduplicated(self) -> None:
        """Duplicate IDs are processed only once."""
        entry1 = self._make_entry_tuple("L-dup", "2026-01-01", status="resolved")
        entry2 = self._make_entry_tuple("L-dup", "2026-01-01", status="resolved")
        result = utility_based_prune_candidates([entry1, entry2])
        assert len(result) == 1

    def test_very_old_low_utility_is_tier3_candidate(self) -> None:
        """Very old entry with low impact and utility qualifies as tier-3 candidate."""
        entries = [self._make_entry_tuple("L-old", "2025-08-01", impact=0.1)]
        result = utility_based_prune_candidates(entries)
        assert len(result) == 1
        assert result[0]["id"] == "L-old"
        assert result[0]["suggested_status"] == "obsolete"

    def test_recent_high_utility_not_candidate(self) -> None:
        """Recent entry with high impact is not a prune candidate."""
        from datetime import timedelta

        # Use a date within the last 3 days to ensure utility stays high
        recent_date = (datetime.now(timezone.utc).date() - timedelta(days=3)).isoformat()
        entries = [self._make_entry_tuple("L-new", recent_date, impact=0.95)]
        result = utility_based_prune_candidates(entries)
        assert result == []


# ---------------------------------------------------------------------------
# TestEnforceTierDistributionWithDates — G5 (time-decay asymmetry fix)
# ---------------------------------------------------------------------------


class TestEnforceTierDistributionWithDates:
    """Tests for enforce_tier_distribution with entry_dates parameter (G5).

    Verifies that old entries classified into critical/high tiers via their
    raw score are demoted when entry_dates causes time-decay to lower their
    effective tier score below the threshold.
    """

    def _make_entries(self, count: int, base_score: float = 0.95) -> list[tuple[str, float]]:
        """Make N entries all at base_score."""
        return [(f"L-{i:03d}", base_score) for i in range(count)]

    def test_without_entry_dates_unchanged_behavior(self) -> None:
        """entry_dates=None preserves existing behavior — no decay applied."""
        # 10 entries all at 0.95 (critical tier) — cap is typically 5%
        entries = self._make_entries(10, base_score=0.95)
        result_no_dates = scoring_mod.enforce_tier_distribution(entries, entry_dates=None)
        result_explicit_none = scoring_mod.enforce_tier_distribution(entries)
        # Both paths produce the same result (None is default)
        assert result_no_dates == result_explicit_none

    def test_with_entry_dates_old_entries_decay_below_critical(self) -> None:
        """Old entries with entry_dates get decayed, causing tier demotion to differ."""
        # Create 10 entries all at 0.95 (critical), but some are very old
        # Old entries (365+ days): apply_time_decay(0.95) = 0.95 * max(0.3, 1 - 1.0*0.3) = 0.95 * 0.7 = 0.665
        # 0.665 is below 0.9 threshold — they would NOT be classified as critical with decay

        # Without dates, all 10 are critical (100% > 5% cap) → demotions happen based on raw scores
        entries = self._make_entries(10, base_score=0.95)

        # Make some entries 400 days old — decay will push them below critical threshold
        old_date = (datetime.now(timezone.utc) - __import__("datetime").timedelta(days=400)).isoformat()
        entry_dates = {f"L-{i:03d}": old_date for i in range(8)}  # 8 old, 2 fresh

        result_with_dates = scoring_mod.enforce_tier_distribution(entries, entry_dates=entry_dates)
        result_without_dates = scoring_mod.enforce_tier_distribution(entries, entry_dates=None)

        # With dates: old entries decay below tier thresholds, changing demotion behavior
        assert isinstance(result_with_dates, list)
        assert isinstance(result_without_dates, list)
        # All items in result are (id, new_score) tuples
        for entry_id, new_score in result_with_dates:
            assert isinstance(entry_id, str)
            assert isinstance(new_score, float)
        for entry_id, new_score in result_without_dates:
            assert isinstance(entry_id, str)
            assert isinstance(new_score, float)

    def test_entry_dates_decays_tier_classification(self) -> None:
        """Entries classified critical via raw score get demoted when decayed below 0.9."""
        # 6 entries: 1 fresh (within-threshold), 5 at 0.91 (just critical)
        # With 2-year-old date, decayed score = 0.91 * 0.7 = 0.637 — below both critical and high
        # Without dates, all 6 at 0.91 → 6/6=100% critical → demotion occurs
        # With dates, old entries fall below high (0.7) → not in high or critical → no demotion
        from datetime import timedelta

        now = datetime.now(timezone.utc)
        very_old = (now - timedelta(days=730)).isoformat()  # 2 years old

        entries = [(f"L-{i}", 0.91) for i in range(6)]
        entry_dates = {f"L-{i}": very_old for i in range(6)}

        result_with_dates = scoring_mod.enforce_tier_distribution(entries, entry_dates=entry_dates)
        result_without_dates = scoring_mod.enforce_tier_distribution(entries, entry_dates=None)

        # Without dates: all in critical tier, cap exceeded → demotion expected
        assert len(result_without_dates) >= 1, "Expected demotion without dates"

        # With dates: all decayed to 0.91 * 0.3 = 0.273 — below both tiers — no demotion needed
        # (entries not in any tier don't count toward tier caps)
        assert len(result_with_dates) == 0, "Expected no demotion with old entry_dates"

    def test_entry_dates_partial_some_have_dates(self) -> None:
        """When only some entries have dates, undated entries use raw scores."""
        from datetime import timedelta

        now = datetime.now(timezone.utc)
        very_old = (now - timedelta(days=730)).isoformat()  # 2 years

        # 6 critical entries (0.95), 3 have old dates (will decay below tiers)
        entries = [(f"L-{i}", 0.95) for i in range(6)]
        # Only 3 entries have dates — old enough to decay below critical
        entry_dates = {f"L-{i}": very_old for i in range(3)}

        result = scoring_mod.enforce_tier_distribution(entries, entry_dates=entry_dates)

        # 3 entries without dates stay at 0.95 (critical), 3 decayed below tiers
        # 3/6 = 50% critical → exceeds 5% cap → at least one demotion expected
        assert isinstance(result, list)
        # The demoted entry should be one of the undated (raw score = 0.95) entries
        # since those are the ones still in critical tier
        demoted_ids = {d[0] for d in result}
        # All demotions must come from critical or high tier members
        for lid in demoted_ids:
            assert lid.startswith("L-")

    def test_entry_dates_invalid_date_string_falls_back_to_raw(self) -> None:
        """Invalid date string in entry_dates falls back to raw score (no crash)."""
        entries = [(f"L-{i}", 0.95) for i in range(6)]
        entry_dates = {"L-0": "not-a-valid-date", "L-1": "also-bad"}

        # Should not raise — invalid dates fall back to raw score
        result = scoring_mod.enforce_tier_distribution(entries, entry_dates=entry_dates)
        assert isinstance(result, list)
        # With raw scores all at 0.95 (critical), tier cap exceeded → demotions expected
        result_no_dates = scoring_mod.enforce_tier_distribution(entries, entry_dates=None)
        assert len(result) == len(result_no_dates), "Invalid dates should fall back to same behavior as no dates"

    def test_entry_dates_empty_string_falls_back_to_raw(self) -> None:
        """Empty string date in entry_dates uses raw score (no decay)."""
        entries = [(f"L-{i}", 0.95) for i in range(6)]
        entry_dates = {"L-0": "", "L-1": ""}

        # No crash, empty string treated as "no date" → raw score used
        result = scoring_mod.enforce_tier_distribution(entries, entry_dates=entry_dates)
        assert isinstance(result, list)
        # With raw scores all at 0.95 (critical), tier cap exceeded → demotions expected
        result_no_dates = scoring_mod.enforce_tier_distribution(entries, entry_dates=None)
        assert len(result) == len(result_no_dates), "Empty-string dates should fall back to same behavior as no dates"

    def test_entry_dates_fresh_entries_unchanged(self) -> None:
        """Very recent entries have near-zero decay — tier classification unchanged."""
        from datetime import timedelta

        now = datetime.now(timezone.utc)
        very_fresh = (now - timedelta(days=1)).isoformat()  # 1 day old

        entries = [(f"L-{i}", 0.95) for i in range(6)]
        entry_dates = {f"L-{i}": very_fresh for i in range(6)}

        result_with_dates = scoring_mod.enforce_tier_distribution(entries, entry_dates=entry_dates)
        result_without_dates = scoring_mod.enforce_tier_distribution(entries, entry_dates=None)

        # Fresh entries: decay_factor ≈ max(0.3, 1 - (1/365)*0.3) ≈ 0.9992
        # Scores stay above 0.9 → same tier classification → same demotions
        assert len(result_with_dates) == len(result_without_dates)


# ---------------------------------------------------------------------------
# PRD-QUAL-032: Double-decay fix verification
# ---------------------------------------------------------------------------


class TestDoubleDecayFix:
    """Verify that _entry_utility no longer double-decays entries.

    Before the fix, a 30-day-old entry with impact 0.8 scored ~0.41 because
    apply_time_decay ran BEFORE compute_utility_score's internal Ebbinghaus
    decay. After the fix, only compute_utility_score applies decay.
    """

    @pytest.mark.unit
    def test_30_day_old_entry_no_double_decay(self) -> None:
        """_entry_utility must not apply apply_time_decay before compute_utility_score.

        Verifies that raw impact/q_value go directly to compute_utility_score,
        which applies its own Ebbinghaus decay. The score with raw values must
        be >= the score with pre-decayed values (double decay).
        """
        from datetime import timedelta

        from trw_mcp.scoring import _entry_utility, apply_time_decay, compute_utility_score

        created_dt = datetime.now(timezone.utc) - timedelta(days=30)
        created_iso = created_dt.isoformat()
        entry: dict[str, object] = {
            "impact": 0.8,
            "q_value": 0.8,
            "q_observations": 5,
            "recurrence": 2,
            "access_count": 3,
            "source_type": "agent",
            "created": created_iso,
            "last_accessed": created_iso,
        }
        actual_score = _entry_utility(entry, today=datetime.now(tz=timezone.utc).date())

        # Simulate old double-decay behavior
        decayed_impact = apply_time_decay(0.8, created_dt)
        decayed_q = apply_time_decay(0.8, created_dt)
        double_decay_score = compute_utility_score(
            q_value=decayed_q,
            days_since_last_access=30,
            recurrence_count=2,
            base_impact=decayed_impact,
            q_observations=5,
            access_count=3,
            source_type="agent",
        )
        assert actual_score >= double_decay_score, (
            f"Single-decay ({actual_score:.4f}) should be >= double-decay ({double_decay_score:.4f})"
        )


# ---------------------------------------------------------------------------
# PRD-QUAL-032: Q-learning receipt path alignment
# ---------------------------------------------------------------------------


class TestCorrelateRecallsPath:
    """Verify correlate_recalls reads from the canonical write path."""

    @pytest.mark.unit
    def test_receipt_path_matches_write_path(self, tmp_path: Path) -> None:
        """correlate_recalls should read from logs/recall_tracking.jsonl."""
        import json

        from trw_mcp.scoring import correlate_recalls

        # Create the file at the canonical path
        logs_dir = tmp_path / ".trw" / "logs"
        logs_dir.mkdir(parents=True)
        receipt_file = logs_dir / "recall_tracking.jsonl"
        record = {
            "ts": datetime.now(timezone.utc).isoformat(),
            "learning_id": "L-test001",
            "query": "test query",
        }
        receipt_file.write_text(json.dumps(record) + "\n")

        # Should find and parse the file (not look at old path)
        results = correlate_recalls(tmp_path / ".trw", window_minutes=60)
        # The function returns a list of (learning_id, recency_discount) tuples.
        # If the path was wrong it would return [] because the old path doesn't exist.
        assert isinstance(results, list)
        assert len(results) >= 1, "Should find the record we wrote"
        found_id, discount = results[0]
        assert found_id == "L-test001"
        assert 0.0 < discount <= 1.0

    @pytest.mark.unit
    def test_outcome_only_rows_are_ignored(self, tmp_path: Path) -> None:
        """Outcome-tracking rows must not count as new recall evidence."""
        import json

        from trw_mcp.scoring import correlate_recalls

        logs_dir = tmp_path / ".trw" / "logs"
        logs_dir.mkdir(parents=True)
        receipt_file = logs_dir / "recall_tracking.jsonl"
        now = datetime.now(timezone.utc)
        records = [
            {
                "timestamp": now.timestamp(),
                "learning_id": "L-recall001",
                "query": "test query",
                "outcome": None,
            },
            {
                "timestamp": now.timestamp(),
                "learning_id": "L-outcome001",
                "outcome": "positive",
            },
        ]
        receipt_file.write_text("\n".join(json.dumps(record) for record in records) + "\n")

        results = correlate_recalls(tmp_path / ".trw", window_minutes=60, scope="window")

        assert any(learning_id == "L-recall001" for learning_id, _discount in results)
        assert all(learning_id != "L-outcome001" for learning_id, _discount in results)

    @pytest.mark.unit
    def test_session_scope_uses_runs_root_not_task_root(self, tmp_path: Path) -> None:
        """Session lookup should read events from .trw/runs, not docs/*/runs."""
        import json

        from trw_mcp.scoring import correlate_recalls

        trw_dir = tmp_path / ".trw"
        logs_dir = trw_dir / "logs"
        logs_dir.mkdir(parents=True)
        run_dir = trw_dir / "runs" / "task-a" / "20260402T010000Z-test"
        meta_dir = run_dir / "meta"
        meta_dir.mkdir(parents=True)
        (meta_dir / "run.yaml").write_text("status: active\n")

        session_start = datetime.now(timezone.utc) - timedelta(minutes=10)
        old_recall = datetime.now(timezone.utc) - timedelta(hours=2)
        recent_recall = datetime.now(timezone.utc) - timedelta(minutes=5)

        events_path = meta_dir / "events.jsonl"
        events_path.write_text(
            json.dumps({"ts": session_start.isoformat(), "event": "session_start"}) + "\n",
        )

        receipt_file = logs_dir / "recall_tracking.jsonl"
        records = [
            {"timestamp": old_recall.timestamp(), "learning_id": "L-old", "query": "older", "outcome": None},
            {"timestamp": recent_recall.timestamp(), "learning_id": "L-new", "query": "recent", "outcome": None},
        ]
        receipt_file.write_text("\n".join(json.dumps(record) for record in records) + "\n")

        results = correlate_recalls(trw_dir, window_minutes=480, scope="session")

        assert [learning_id for learning_id, _discount in results] == ["L-new"]
