"""Tests for mathematical correctness of core computations.

These tests verify the Hill function, adstock steady-state, marginal ROI
derivative, and half-life calculations against known analytical values.
"""

import numpy as np
import pytest

# ---------------------------------------------------------------------------
# Hill function: response = x^s / (ec^s + x^s)
# ---------------------------------------------------------------------------


def hill(x: float, ec: float, slope: float) -> float:
    return x**slope / (ec**slope + x**slope)


class TestHillFunction:
    def test_zero_spend_gives_zero(self):
        assert hill(0.0, 0.5, 1.0) == 0.0

    def test_at_ec_gives_half(self):
        """When x == ec, saturation should be exactly 50%."""
        assert hill(0.5, 0.5, 1.0) == pytest.approx(0.5)
        assert hill(0.8, 0.8, 2.0) == pytest.approx(0.5)
        assert hill(1.0, 1.0, 3.0) == pytest.approx(0.5)

    def test_large_spend_approaches_one(self):
        assert hill(1e6, 0.5, 1.0) == pytest.approx(1.0, abs=1e-4)

    def test_slope_one_is_michaelis_menten(self):
        """With slope=1, Hill reduces to x / (ec + x)."""
        x, ec = 3.0, 2.0
        assert hill(x, ec, 1.0) == pytest.approx(x / (ec + x))

    def test_higher_slope_is_steeper(self):
        """At the same x < ec, higher slope should give lower response (steeper S-curve)."""
        x, ec = 0.3, 0.5
        assert hill(x, ec, 1.0) > hill(x, ec, 3.0)

    def test_higher_slope_above_ec_gives_higher(self):
        """At x > ec, higher slope should give higher response."""
        x, ec = 0.7, 0.5
        assert hill(x, ec, 3.0) > hill(x, ec, 1.0)

    def test_output_bounded_zero_one(self):
        rng = np.random.default_rng(42)
        for _ in range(100):
            x = rng.uniform(0, 100)
            ec = rng.uniform(0.01, 10)
            slope = rng.uniform(0.1, 5)
            val = hill(x, ec, slope)
            assert 0.0 <= val <= 1.0, f"hill({x}, {ec}, {slope}) = {val}"


# ---------------------------------------------------------------------------
# Hill derivative (for marginal ROI)
# d/dx [x^s / (ec^s + x^s)] = s * ec^s * x^(s-1) / (ec^s + x^s)^2
# ---------------------------------------------------------------------------


def hill_derivative(x: float, ec: float, slope: float) -> float:
    denom = ec**slope + x**slope
    return slope * ec**slope * x ** (slope - 1) / denom**2


class TestHillDerivative:
    def test_matches_numerical_derivative(self):
        """Analytical derivative should match finite-difference approximation."""
        x, ec, slope = 3.0, 2.0, 1.5
        eps = 1e-7
        numerical = (hill(x + eps, ec, slope) - hill(x - eps, ec, slope)) / (2 * eps)
        analytical = hill_derivative(x, ec, slope)
        assert analytical == pytest.approx(numerical, rel=1e-4)

    def test_derivative_positive(self):
        """Hill function is monotonically increasing, so derivative should be positive."""
        rng = np.random.default_rng(42)
        for _ in range(100):
            x = rng.uniform(0.01, 100)
            ec = rng.uniform(0.01, 10)
            slope = rng.uniform(0.5, 5)
            assert hill_derivative(x, ec, slope) > 0

    def test_derivative_decreasing(self):
        """Derivative should decrease as x grows (diminishing returns)."""
        ec, slope = 1.0, 1.0
        d1 = hill_derivative(0.5, ec, slope)
        d2 = hill_derivative(2.0, ec, slope)
        d3 = hill_derivative(10.0, ec, slope)
        assert d1 > d2 > d3

    def test_peak_derivative_at_low_spend(self):
        """Maximum derivative for slope=1 is at x→0."""
        ec, slope = 1.0, 1.0
        d_small = hill_derivative(0.001, ec, slope)
        d_large = hill_derivative(1.0, ec, slope)
        assert d_small > d_large


# ---------------------------------------------------------------------------
# Geometric adstock steady-state: x_ss = x / (1 - α)
# ---------------------------------------------------------------------------


class TestAdstockSteadyState:
    def test_no_decay_passes_through(self):
        """α=0 means no carryover, steady state equals input."""
        assert 100.0 / (1 - 0.0) == 100.0

    def test_half_decay_doubles(self):
        """α=0.5 means steady state is 2x input."""
        assert 100.0 / (1 - 0.5) == pytest.approx(200.0)

    def test_high_decay_amplifies(self):
        """α=0.9 means steady state is 10x input."""
        assert 100.0 / (1 - 0.9) == pytest.approx(1000.0)

    def test_matches_geometric_series(self):
        """Steady state should match truncated geometric series for large N."""
        alpha = 0.6
        x = 50.0
        # Geometric series: x * sum(alpha^k for k=0..inf) = x / (1 - alpha)
        truncated = sum(x * alpha**k for k in range(1000))
        steady_state = x / (1 - alpha)
        assert steady_state == pytest.approx(truncated, rel=1e-4)


# ---------------------------------------------------------------------------
# Half-life: log(0.5) / log(decay_rate)
# ---------------------------------------------------------------------------


class TestHalfLife:
    def test_known_values(self):
        # α=0.5 → half-life = 1 week
        assert np.log(0.5) / np.log(0.5) == pytest.approx(1.0)
        # α=0.25 → half-life = 0.5 weeks
        assert np.log(0.5) / np.log(0.25) == pytest.approx(0.5)

    def test_higher_decay_longer_halflife(self):
        hl_low = np.log(0.5) / np.log(0.3)
        hl_high = np.log(0.5) / np.log(0.8)
        assert hl_high > hl_low

    def test_effect_duration_longer_than_halflife(self):
        """90% decay duration should always exceed 50% decay duration."""
        for alpha in [0.3, 0.5, 0.7, 0.9]:
            hl = np.log(0.5) / np.log(alpha)
            dur = np.log(0.1) / np.log(alpha)
            assert dur > hl
