"""Tests for Particle Filter / Sequential Monte Carlo."""

import math
import pytest

from horizon._horizon import ParticleFilter, PFState


# ===========================================================================
# Construction
# ===========================================================================


class TestParticleFilterConstruction:
    def test_basic_creation(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        assert pf.n_particles() == 100

    def test_custom_seed(self):
        pf = ParticleFilter(50, 0.0, 0.1, 0.5, seed=123)
        assert pf.n_particles() == 50

    def test_too_few_particles_raises(self):
        with pytest.raises(ValueError):
            ParticleFilter(9, 0.0, 0.1, 0.5)

    def test_min_particles(self):
        pf = ParticleFilter(10, 0.0, 0.1, 0.5)
        assert pf.n_particles() == 10

    def test_too_many_particles_raises(self):
        with pytest.raises(ValueError):
            ParticleFilter(100_001, 0.0, 0.1, 0.5)

    def test_zero_process_noise_raises(self):
        with pytest.raises(ValueError):
            ParticleFilter(100, 0.0, 0.0, 0.5)

    def test_negative_process_noise_raises(self):
        with pytest.raises(ValueError):
            ParticleFilter(100, 0.0, -0.1, 0.5)

    def test_zero_measurement_noise_raises(self):
        with pytest.raises(ValueError):
            ParticleFilter(100, 0.0, 0.1, 0.0)

    def test_negative_measurement_noise_raises(self):
        with pytest.raises(ValueError):
            ParticleFilter(100, 0.0, 0.1, -0.5)

    def test_nan_initial_state_raises(self):
        with pytest.raises(ValueError):
            ParticleFilter(100, float("nan"), 0.1, 0.5)

    def test_inf_initial_state_raises(self):
        with pytest.raises(ValueError):
            ParticleFilter(100, float("inf"), 0.1, 0.5)

    def test_inf_process_noise_raises(self):
        with pytest.raises(ValueError):
            ParticleFilter(100, 0.0, float("inf"), 0.5)

    def test_large_initial_state_ok(self):
        pf = ParticleFilter(100, 1e6, 0.1, 0.5)
        est = pf.state_estimate()
        assert abs(est - 1e6) < 10.0


# ===========================================================================
# Update
# ===========================================================================


class TestParticleFilterUpdate:
    def test_update_returns_pf_state(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        result = pf.update(1.0)
        assert isinstance(result, PFState)

    def test_pf_state_fields(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        result = pf.update(1.0)
        assert hasattr(result, "estimate")
        assert hasattr(result, "variance")
        assert hasattr(result, "ess")
        assert hasattr(result, "resampled")

    def test_estimate_is_finite(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        result = pf.update(1.0)
        assert math.isfinite(result.estimate)

    def test_variance_non_negative(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        result = pf.update(1.0)
        assert result.variance >= 0.0

    def test_ess_range(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        result = pf.update(0.0)
        assert 0.0 <= result.ess <= 100.0

    def test_non_finite_observation_raises(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        with pytest.raises(ValueError):
            pf.update(float("nan"))

    def test_inf_observation_raises(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        with pytest.raises(ValueError):
            pf.update(float("inf"))

    def test_convergence_on_constant(self):
        pf = ParticleFilter(500, 0.0, 0.1, 0.5, seed=42)
        for _ in range(50):
            result = pf.update(5.0)
        assert result.estimate == pytest.approx(5.0, abs=1.0)

    def test_resampled_is_bool(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        result = pf.update(1.0)
        assert isinstance(result.resampled, bool)

    def test_multiple_updates_stable(self):
        pf = ParticleFilter(200, 0.0, 0.1, 0.5, seed=42)
        for i in range(100):
            result = pf.update(float(i % 5))
        assert math.isfinite(result.estimate)
        assert result.variance >= 0.0


# ===========================================================================
# State accessors
# ===========================================================================


class TestParticleFilterAccessors:
    def test_state_estimate(self):
        pf = ParticleFilter(100, 5.0, 0.1, 0.5)
        est = pf.state_estimate()
        assert math.isfinite(est)

    def test_state_variance(self):
        pf = ParticleFilter(100, 5.0, 0.1, 0.5)
        var = pf.state_variance()
        assert var >= 0.0

    def test_effective_sample_size(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        ess = pf.effective_sample_size()
        # Initially uniform weights => ESS = n_particles
        assert ess == pytest.approx(100.0, abs=1.0)

    def test_particles_count(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        particles = pf.particles()
        assert len(particles) == 100

    def test_weights_count(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        weights = pf.weights()
        assert len(weights) == 100

    def test_weights_sum_to_one(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        weights = pf.weights()
        assert sum(weights) == pytest.approx(1.0, abs=1e-10)

    def test_weights_sum_after_update(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        pf.update(1.0)
        weights = pf.weights()
        assert sum(weights) == pytest.approx(1.0, abs=1e-10)

    def test_state_median(self):
        pf = ParticleFilter(100, 5.0, 0.1, 0.5)
        med = pf.state_median()
        assert math.isfinite(med)

    def test_n_particles(self):
        pf = ParticleFilter(200, 0.0, 0.1, 0.5)
        assert pf.n_particles() == 200

    def test_particles_are_finite(self):
        pf = ParticleFilter(50, 0.0, 0.1, 0.5)
        pf.update(1.0)
        for p in pf.particles():
            assert math.isfinite(p)


# ===========================================================================
# Credible interval
# ===========================================================================


class TestParticleFilterCredibleInterval:
    def test_basic_interval(self):
        pf = ParticleFilter(500, 5.0, 0.1, 0.5)
        for _ in range(10):
            pf.update(5.0)
        lo, hi = pf.credible_interval(0.95)
        assert lo <= hi

    def test_interval_contains_estimate(self):
        pf = ParticleFilter(500, 5.0, 0.1, 0.5, seed=42)
        for _ in range(10):
            pf.update(5.0)
        lo, hi = pf.credible_interval(0.95)
        est = pf.state_estimate()
        assert lo <= est <= hi

    def test_wider_interval_for_higher_level(self):
        pf = ParticleFilter(500, 0.0, 0.1, 0.5, seed=42)
        for _ in range(10):
            pf.update(0.0)
        lo90, hi90 = pf.credible_interval(0.90)
        lo99, hi99 = pf.credible_interval(0.99)
        assert (hi99 - lo99) >= (hi90 - lo90) - 1e-10

    def test_level_zero_raises(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        with pytest.raises(ValueError):
            pf.credible_interval(0.0)

    def test_level_one_raises(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        with pytest.raises(ValueError):
            pf.credible_interval(1.0)


# ===========================================================================
# Reset
# ===========================================================================


class TestParticleFilterReset:
    def test_reset_changes_estimate(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        for _ in range(10):
            pf.update(10.0)
        pf.reset(0.0)
        est = pf.state_estimate()
        assert est == pytest.approx(0.0, abs=1.0)

    def test_reset_non_finite_raises(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        with pytest.raises(ValueError):
            pf.reset(float("nan"))

    def test_reset_preserves_n_particles(self):
        pf = ParticleFilter(200, 0.0, 0.1, 0.5)
        pf.update(5.0)
        pf.reset(0.0)
        assert pf.n_particles() == 200


# ===========================================================================
# Determinism
# ===========================================================================


class TestParticleFilterDeterminism:
    def test_same_seed_same_result(self):
        pf1 = ParticleFilter(100, 0.0, 0.1, 0.5, seed=42)
        pf2 = ParticleFilter(100, 0.0, 0.1, 0.5, seed=42)
        r1 = pf1.update(3.0)
        r2 = pf2.update(3.0)
        assert r1.estimate == pytest.approx(r2.estimate)

    def test_different_seed_different_result(self):
        pf1 = ParticleFilter(100, 0.0, 0.1, 0.5, seed=42)
        pf2 = ParticleFilter(100, 0.0, 0.1, 0.5, seed=99)
        r1 = pf1.update(3.0)
        r2 = pf2.update(3.0)
        # Both should be finite
        assert math.isfinite(r1.estimate)
        assert math.isfinite(r2.estimate)

    def test_repr(self):
        pf = ParticleFilter(100, 0.0, 0.1, 0.5)
        r = repr(pf)
        assert "ParticleFilter" in r
