"""Tests for Bayesian Online Change Point Detection (BOCPD)."""

import math
import pytest

from horizon._horizon import BocpdDetector, BocpdResult


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


class TestBocpdDetectorConstruction:
    def test_default_params(self):
        d = BocpdDetector()
        assert d.n_observations() == 0

    def test_custom_hazard_rate(self):
        d = BocpdDetector(hazard_rate=100.0)
        assert d.n_observations() == 0

    def test_custom_all_params(self):
        d = BocpdDetector(
            hazard_rate=500.0, mu0=1.0, kappa0=2.0, alpha0=3.0, beta0=4.0
        )
        assert d.n_observations() == 0

    def test_zero_hazard_rate_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(hazard_rate=0.0)

    def test_negative_hazard_rate_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(hazard_rate=-1.0)

    def test_nan_hazard_rate_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(hazard_rate=float("nan"))

    def test_inf_hazard_rate_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(hazard_rate=float("inf"))

    def test_zero_kappa0_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(kappa0=0.0)

    def test_zero_alpha0_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(alpha0=0.0)

    def test_zero_beta0_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(beta0=0.0)

    def test_nan_mu0_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(mu0=float("nan"))

    def test_repr(self):
        d = BocpdDetector()
        r = repr(d)
        assert "BocpdDetector" in r

    def test_negative_kappa0_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(kappa0=-1.0)

    def test_negative_alpha0_raises(self):
        with pytest.raises(ValueError):
            BocpdDetector(alpha0=-0.5)


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


class TestBocpdDetectorUpdate:
    def test_update_returns_result(self):
        d = BocpdDetector()
        result = d.update(1.0)
        assert isinstance(result, BocpdResult)

    def test_result_fields(self):
        d = BocpdDetector()
        result = d.update(1.0)
        assert hasattr(result, "change_prob")
        assert hasattr(result, "most_likely_run_length")
        assert hasattr(result, "is_changepoint")

    def test_change_prob_in_range(self):
        d = BocpdDetector()
        result = d.update(1.0)
        assert 0.0 <= result.change_prob <= 1.0

    def test_run_length_non_negative(self):
        d = BocpdDetector()
        result = d.update(1.0)
        assert result.most_likely_run_length >= 0

    def test_non_finite_observation_raises(self):
        d = BocpdDetector()
        with pytest.raises(ValueError):
            d.update(float("nan"))

    def test_inf_observation_raises(self):
        d = BocpdDetector()
        with pytest.raises(ValueError):
            d.update(float("inf"))

    def test_increments_n_observations(self):
        d = BocpdDetector()
        d.update(1.0)
        d.update(2.0)
        assert d.n_observations() == 2

    def test_run_length_grows_with_stable_data(self):
        d = BocpdDetector(hazard_rate=250.0)
        for i in range(50):
            d.update(0.0)
        assert d.most_likely_run_length() > 0

    def test_is_changepoint_is_bool(self):
        d = BocpdDetector()
        result = d.update(1.0)
        assert isinstance(result.is_changepoint, bool)


# ===========================================================================
# Change detection
# ===========================================================================


class TestBocpdChangeDetection:
    def test_detects_mean_shift(self):
        d = BocpdDetector(hazard_rate=50.0)
        # Stable regime
        for _ in range(100):
            d.update(0.0)
        # After stable regime, run length should be long
        assert d.most_likely_run_length() >= 50
        # Shift to new regime - run length should reset to small value
        for _ in range(3):
            result = d.update(10.0)
        # Most likely run length should drop sharply (new regime detected)
        assert result.most_likely_run_length <= 5

    def test_stable_data_no_changepoint(self):
        d = BocpdDetector(hazard_rate=250.0)
        for _ in range(50):
            result = d.update(0.0)
        # After stable data, change prob should be low
        assert result.change_prob < 0.5

    def test_gradual_shift_lower_confidence(self):
        d = BocpdDetector(hazard_rate=50.0)
        # Feed slowly drifting values
        for i in range(100):
            d.update(float(i) * 0.01)
        # Should not trigger as strongly as a sharp shift
        cp = d.change_probability()
        assert 0.0 <= cp <= 1.0


# ===========================================================================
# Accessors
# ===========================================================================


class TestBocpdAccessors:
    def test_run_length_distribution(self):
        d = BocpdDetector()
        d.update(1.0)
        dist = d.run_length_distribution()
        assert len(dist) > 0
        assert sum(dist) == pytest.approx(1.0, abs=1e-6)

    def test_most_likely_run_length(self):
        d = BocpdDetector()
        d.update(1.0)
        rl = d.most_likely_run_length()
        assert rl >= 0

    def test_change_probability(self):
        d = BocpdDetector()
        d.update(1.0)
        cp = d.change_probability()
        assert 0.0 <= cp <= 1.0

    def test_expected_run_length(self):
        d = BocpdDetector()
        for _ in range(20):
            d.update(0.0)
        erl = d.expected_run_length()
        assert erl >= 0.0
        assert math.isfinite(erl)

    def test_run_length_distribution_sums_to_one(self):
        d = BocpdDetector()
        for _ in range(30):
            d.update(5.0)
        dist = d.run_length_distribution()
        assert sum(dist) == pytest.approx(1.0, abs=1e-6)


# ===========================================================================
# Batch and reset
# ===========================================================================


class TestBocpdBatchAndReset:
    def test_run_batch(self):
        d = BocpdDetector()
        results = d.run_batch([1.0, 2.0, 3.0, 4.0, 5.0])
        assert len(results) == 5
        for r in results:
            assert isinstance(r, BocpdResult)

    def test_run_batch_empty(self):
        d = BocpdDetector()
        results = d.run_batch([])
        assert len(results) == 0

    def test_run_batch_non_finite_raises(self):
        d = BocpdDetector()
        with pytest.raises(ValueError):
            d.run_batch([1.0, float("nan"), 3.0])

    def test_run_batch_increments_n_obs(self):
        d = BocpdDetector()
        d.run_batch([1.0, 2.0, 3.0])
        assert d.n_observations() == 3

    def test_reset(self):
        d = BocpdDetector()
        d.update(1.0)
        d.update(2.0)
        d.reset()
        assert d.n_observations() == 0

    def test_reset_clears_run_length(self):
        d = BocpdDetector()
        for _ in range(20):
            d.update(0.0)
        d.reset()
        dist = d.run_length_distribution()
        assert len(dist) == 1
        assert dist[0] == pytest.approx(1.0)

    def test_batch_result_repr(self):
        d = BocpdDetector()
        results = d.run_batch([1.0])
        r = repr(results[0])
        assert "BocpdResult" in r
