"""Tests for rbsp.query.freshness — Time-decay freshness boosting."""

from __future__ import annotations

import math

import pytest

from rbsp.query.freshness import (
    DateRangeFilter,
    DecayFunction,
    FreshnessBooster,
    RecencyRanker,
)


# ===================================================================
# DecayFunction enum
# ===================================================================


class TestDecayFunction:
    def test_values(self) -> None:
        assert DecayFunction.EXPONENTIAL.value == "exponential"
        assert DecayFunction.GAUSSIAN.value == "gaussian"
        assert DecayFunction.LINEAR.value == "linear"


# ===================================================================
# FreshnessBooster
# ===================================================================


class TestFreshnessBoosterExponential:
    def test_boost_now_equals_timestamp(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.EXPONENTIAL, halflife=86400)
        assert abs(b.boost(1000.0, now=1000.0) - 1.0) < 1e-10

    def test_boost_at_halflife(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.EXPONENTIAL, halflife=86400)
        val = b.boost(0.0, now=86400.0)
        assert abs(val - 0.5) < 0.01

    def test_boost_decays_with_age(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.EXPONENTIAL, halflife=86400)
        young = b.boost(900.0, now=1000.0)
        old = b.boost(0.0, now=1000.0)
        assert young > old

    def test_boost_future_timestamp_clamped(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.EXPONENTIAL, halflife=86400)
        # Future doc: age = max(now - ts, 0) = 0 → boost = 1.0
        val = b.boost(2000.0, now=1000.0)
        assert abs(val - 1.0) < 1e-10

    def test_boost_zero_halflife(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.EXPONENTIAL, halflife=0)
        assert b.boost(1000.0, now=1000.0) == 1.0
        assert b.boost(999.0, now=1000.0) == 0.0


class TestFreshnessBoosterGaussian:
    def test_boost_now_equals_timestamp(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.GAUSSIAN, scale=86400)
        assert abs(b.boost(1000.0, now=1000.0) - 1.0) < 1e-10

    def test_boost_decays_with_age(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.GAUSSIAN, scale=86400)
        young = b.boost(900.0, now=1000.0)
        old = b.boost(0.0, now=1000.0)
        assert young > old

    def test_boost_at_scale(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.GAUSSIAN, scale=1000)
        val = b.boost(0.0, now=1000.0)
        expected = math.exp(-0.5)
        assert abs(val - expected) < 1e-10

    def test_boost_zero_scale(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.GAUSSIAN, scale=0)
        assert b.boost(1000.0, now=1000.0) == 1.0
        assert b.boost(999.0, now=1000.0) == 0.0


class TestFreshnessBoosterLinear:
    def test_boost_now_equals_timestamp(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.LINEAR, scale=86400)
        assert abs(b.boost(1000.0, now=1000.0) - 1.0) < 1e-10

    def test_boost_at_scale(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.LINEAR, scale=1000)
        val = b.boost(0.0, now=1000.0)
        assert abs(val - 0.0) < 1e-10

    def test_boost_midpoint(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.LINEAR, scale=1000)
        val = b.boost(500.0, now=1000.0)
        assert abs(val - 0.5) < 1e-10

    def test_boost_beyond_scale_clamped_to_zero(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.LINEAR, scale=100)
        val = b.boost(0.0, now=200.0)
        assert val == 0.0

    def test_boost_zero_scale(self) -> None:
        b = FreshnessBooster(decay_fn=DecayFunction.LINEAR, scale=0)
        assert b.boost(1000.0, now=1000.0) == 1.0
        assert b.boost(999.0, now=1000.0) == 0.0


class TestFreshnessBoosterApply:
    def test_apply_blends_score(self) -> None:
        b = FreshnessBooster(
            decay_fn=DecayFunction.EXPONENTIAL, halflife=86400, weight=0.1
        )
        # Perfect freshness: score * (1 - 0.1 + 0.1 * 1.0) = score * 1.0
        result = b.apply(10.0, 1000.0, now=1000.0)
        assert abs(result - 10.0) < 1e-10

    def test_apply_stale_doc(self) -> None:
        b = FreshnessBooster(
            decay_fn=DecayFunction.LINEAR, scale=100, weight=0.5
        )
        # Age = 200, beyond scale → boost = 0
        # score * (1 - 0.5 + 0.5 * 0) = score * 0.5
        result = b.apply(10.0, 0.0, now=200.0)
        assert abs(result - 5.0) < 1e-10

    def test_apply_uses_origin(self) -> None:
        b = FreshnessBooster(
            decay_fn=DecayFunction.EXPONENTIAL,
            halflife=86400,
            origin=1000.0,
            weight=0.1,
        )
        result = b.apply(10.0, 1000.0)
        assert abs(result - 10.0) < 1e-10


# ===================================================================
# RecencyRanker
# ===================================================================


class TestRecencyRanker:
    def test_rerank_basic(self) -> None:
        booster = FreshnessBooster(
            decay_fn=DecayFunction.LINEAR, scale=100, weight=1.0, origin=100.0
        )
        ranker = RecencyRanker(booster)

        results = [
            ("old", 10.0, 0.0),    # age=100, boost=0.0
            ("new", 10.0, 100.0),   # age=0, boost=1.0
            ("mid", 10.0, 50.0),    # age=50, boost=0.5
        ]
        ranked = ranker.rerank(results)
        ids = [doc_id for doc_id, _ in ranked]
        assert ids[0] == "new"
        assert ids[-1] == "old"

    def test_rerank_preserves_all_docs(self) -> None:
        booster = FreshnessBooster(origin=100.0)
        ranker = RecencyRanker(booster)
        results = [("a", 1.0, 50.0), ("b", 2.0, 60.0)]
        ranked = ranker.rerank(results)
        assert len(ranked) == 2

    def test_rerank_empty(self) -> None:
        booster = FreshnessBooster(origin=100.0)
        ranker = RecencyRanker(booster)
        assert ranker.rerank([]) == []


# ===================================================================
# DateRangeFilter
# ===================================================================


class TestDateRangeFilter:
    def test_no_bounds(self) -> None:
        f = DateRangeFilter()
        ts = {"d1": 100.0, "d2": 200.0}
        result = f.filter({"d1", "d2"}, ts)
        assert result == {"d1", "d2"}

    def test_min_bound(self) -> None:
        f = DateRangeFilter(min_date=150.0)
        ts = {"d1": 100.0, "d2": 200.0}
        result = f.filter({"d1", "d2"}, ts)
        assert result == {"d2"}

    def test_max_bound(self) -> None:
        f = DateRangeFilter(max_date=150.0)
        ts = {"d1": 100.0, "d2": 200.0}
        result = f.filter({"d1", "d2"}, ts)
        assert result == {"d1"}

    def test_both_bounds(self) -> None:
        f = DateRangeFilter(min_date=100.0, max_date=200.0)
        ts = {"d1": 50.0, "d2": 150.0, "d3": 250.0}
        result = f.filter({"d1", "d2", "d3"}, ts)
        assert result == {"d2"}

    def test_inclusive_bounds(self) -> None:
        f = DateRangeFilter(min_date=100.0, max_date=200.0)
        ts = {"d1": 100.0, "d2": 200.0}
        result = f.filter({"d1", "d2"}, ts)
        assert result == {"d1", "d2"}

    def test_missing_timestamp_excluded(self) -> None:
        f = DateRangeFilter()
        ts = {"d1": 100.0}
        result = f.filter({"d1", "d2"}, ts)
        assert result == {"d1"}

    def test_empty_doc_ids(self) -> None:
        f = DateRangeFilter()
        assert f.filter(set(), {"d1": 100.0}) == set()
