"""Tests for PerilVine construction, fitting, and simulation."""

from __future__ import annotations

import pytest
import numpy as np
import pandas as pd
from scipy.stats import gamma

from insurance_copula import PerilVine, HAS_PYVINECOPULIB

PERILS = ["flood", "subsidence", "storm", "eow"]
REQUIRES_PV = pytest.mark.skipif(
    not HAS_PYVINECOPULIB,
    reason="pyvinecopulib not available on this platform",
)


# ------------------------------------------------------------------
# Construction and validation
# ------------------------------------------------------------------

class TestPerilVineConstruction:
    def test_basic_construction(self):
        vine = PerilVine(perils=PERILS)
        assert vine.perils == PERILS
        assert vine.family_set == "parametric"
        assert vine.selection_criterion == "bic"
        assert vine.tree_criterion == "tau"
        assert vine.trunc_lvl == 3
        assert vine.vine_type == "rvine"
        assert vine.n_threads == 1

    def test_custom_params(self):
        vine = PerilVine(
            perils=["flood", "storm"],
            family_set="archimedean",
            selection_criterion="aic",
            tree_criterion="rho",
            trunc_lvl=1,
            vine_type="cvine",
            n_threads=2,
        )
        assert vine.family_set == "archimedean"
        assert vine.selection_criterion == "aic"
        assert vine.tree_criterion == "rho"
        assert vine.trunc_lvl == 1
        assert vine.vine_type == "cvine"
        assert vine.n_threads == 2

    def test_full_vine_trunc_lvl_none(self):
        vine = PerilVine(perils=PERILS, trunc_lvl=None)
        assert vine.trunc_lvl is None

    def test_two_perils_minimum(self):
        vine = PerilVine(perils=["flood", "storm"])
        assert vine.n_perils == 2

    def test_six_perils(self):
        vine = PerilVine(perils=["a", "b", "c", "d", "e", "f"])
        assert vine.n_perils == 6

    def test_family_set_all(self):
        vine = PerilVine(perils=PERILS, family_set="all")
        assert vine.family_set == "all"

    def test_family_set_tll(self):
        vine = PerilVine(perils=PERILS, family_set="tll")
        assert vine.family_set == "tll"

    def test_dvine(self):
        vine = PerilVine(perils=PERILS, vine_type="dvine")
        assert vine.vine_type == "dvine"

    def test_not_fitted_initially(self):
        vine = PerilVine(perils=PERILS)
        assert not vine.is_fitted
        assert vine.n_obs is None

    def test_repr_unfitted(self):
        vine = PerilVine(perils=PERILS)
        r = repr(vine)
        assert "unfitted" in r
        assert "flood" in r


class TestPerilVineValidationErrors:
    def test_empty_perils_raises(self):
        with pytest.raises(ValueError, match="non-empty"):
            PerilVine(perils=[])

    def test_single_peril_raises(self):
        with pytest.raises(ValueError, match="at least 2"):
            PerilVine(perils=["flood"])

    def test_duplicate_perils_raises(self):
        with pytest.raises(ValueError, match="unique"):
            PerilVine(perils=["flood", "flood", "storm"])

    def test_invalid_family_set_raises(self):
        with pytest.raises(ValueError, match="family_set"):
            PerilVine(perils=PERILS, family_set="invalid")

    def test_invalid_selection_criterion_raises(self):
        with pytest.raises(ValueError, match="selection_criterion"):
            PerilVine(perils=PERILS, selection_criterion="mle")

    def test_invalid_tree_criterion_raises(self):
        with pytest.raises(ValueError, match="tree_criterion"):
            PerilVine(perils=PERILS, tree_criterion="kendall")

    def test_invalid_vine_type_raises(self):
        with pytest.raises(ValueError, match="vine_type"):
            PerilVine(perils=PERILS, vine_type="tvine")

    def test_zero_trunc_lvl_raises(self):
        with pytest.raises(ValueError, match="trunc_lvl"):
            PerilVine(perils=PERILS, trunc_lvl=0)

    def test_negative_trunc_lvl_raises(self):
        with pytest.raises(ValueError, match="trunc_lvl"):
            PerilVine(perils=PERILS, trunc_lvl=-1)

    def test_zero_threads_raises(self):
        with pytest.raises(ValueError, match="n_threads"):
            PerilVine(perils=PERILS, n_threads=0)

    def test_float_trunc_lvl_raises(self):
        with pytest.raises(ValueError, match="trunc_lvl"):
            PerilVine(perils=PERILS, trunc_lvl=2.5)  # type: ignore[arg-type]


# ------------------------------------------------------------------
# Fitting
# ------------------------------------------------------------------

class TestPerilVineFit:
    @REQUIRES_PV
    def test_fit_returns_self(self, synthetic_losses):
        vine = PerilVine(perils=PERILS, trunc_lvl=2)
        result = vine.fit(synthetic_losses)
        assert result is vine

    @REQUIRES_PV
    def test_fitted_flag(self, synthetic_losses):
        vine = PerilVine(perils=PERILS, trunc_lvl=2)
        assert not vine.is_fitted
        vine.fit(synthetic_losses)
        assert vine.is_fitted

    @REQUIRES_PV
    def test_n_obs_set_after_fit(self, synthetic_losses):
        vine = PerilVine(perils=PERILS, trunc_lvl=2)
        vine.fit(synthetic_losses)
        assert vine.n_obs == len(synthetic_losses)

    @REQUIRES_PV
    def test_fit_with_exposure(self, synthetic_losses, exposure_series):
        vine = PerilVine(perils=PERILS, trunc_lvl=2)
        vine.fit(synthetic_losses, exposure=exposure_series)
        assert vine.is_fitted

    @REQUIRES_PV
    def test_fit_repr_fitted(self, synthetic_losses):
        vine = PerilVine(perils=PERILS, trunc_lvl=2)
        vine.fit(synthetic_losses)
        assert "fitted" in repr(vine)
        assert "unfitted" not in repr(vine)

    @REQUIRES_PV
    def test_fit_2perils(self, synthetic_losses_2perils):
        vine = PerilVine(perils=["flood", "storm"], trunc_lvl=1)
        vine.fit(synthetic_losses_2perils)
        assert vine.is_fitted

    def test_fit_missing_columns_raises(self, synthetic_losses):
        vine = PerilVine(perils=PERILS + ["fire"])
        with pytest.raises(ValueError, match="missing columns"):
            vine.fit(synthetic_losses)

    def test_fit_not_dataframe_raises(self):
        vine = PerilVine(perils=PERILS)
        with pytest.raises(TypeError, match="DataFrame"):
            vine.fit(np.zeros((100, 4)))  # type: ignore[arg-type]

    def test_fit_too_few_rows_raises(self):
        vine = PerilVine(perils=PERILS)
        small = pd.DataFrame(
            np.zeros((1, 4)), columns=PERILS
        )
        with pytest.raises(ValueError, match="at least 2"):
            vine.fit(small)

    def test_fit_exposure_length_mismatch_raises(self, synthetic_losses):
        vine = PerilVine(perils=PERILS)
        bad_exposure = pd.Series(np.ones(10))
        with pytest.raises(ValueError, match="exposure"):
            vine.fit(synthetic_losses, exposure=bad_exposure)

    def test_fit_without_pyvinecopulib_raises(self, synthetic_losses):
        """Calling fit on a platform without pyvinecopulib gives ImportError."""
        if HAS_PYVINECOPULIB:
            pytest.skip("pyvinecopulib is installed — skip this test")
        vine = PerilVine(perils=PERILS)
        with pytest.raises(ImportError, match="pyvinecopulib"):
            vine.fit(synthetic_losses)


# ------------------------------------------------------------------
# Simulate
# ------------------------------------------------------------------

class TestPerilVineSimulate:
    @REQUIRES_PV
    def test_simulate_shape(self, fitted_vine):
        samples = fitted_vine.simulate(1000, seed=0)
        assert samples.shape == (1000, 4)
        assert list(samples.columns) == PERILS

    @REQUIRES_PV
    def test_simulate_copula_range(self, fitted_vine):
        samples = fitted_vine.simulate(500, seed=1)
        assert (samples.values >= 0).all()
        assert (samples.values <= 1).all()

    @REQUIRES_PV
    def test_simulate_with_marginals(self, fitted_vine, marginals_4perils):
        samples = fitted_vine.simulate(500, marginals=marginals_4perils, seed=2)
        assert samples.shape == (500, 4)
        # Loss space values should be positive
        assert (samples.values >= 0).all()

    @REQUIRES_PV
    def test_simulate_reproducible(self, fitted_vine):
        s1 = fitted_vine.simulate(100, seed=42)
        s2 = fitted_vine.simulate(100, seed=42)
        pd.testing.assert_frame_equal(s1, s2)

    @REQUIRES_PV
    def test_simulate_different_seeds(self, fitted_vine):
        s1 = fitted_vine.simulate(100, seed=42)
        s2 = fitted_vine.simulate(100, seed=99)
        assert not s1.equals(s2)

    @REQUIRES_PV
    def test_simulate_no_seed(self, fitted_vine):
        samples = fitted_vine.simulate(100)
        assert samples.shape == (100, 4)

    def test_simulate_unfitted_raises(self):
        vine = PerilVine(perils=PERILS)
        with pytest.raises(RuntimeError, match="not been fitted"):
            vine.simulate(100)

    @REQUIRES_PV
    def test_simulate_invalid_n_raises(self, fitted_vine):
        with pytest.raises(ValueError, match="positive"):
            fitted_vine.simulate(0)

    @REQUIRES_PV
    def test_simulate_marginals_missing_peril_raises(self, fitted_vine):
        partial_marginals = {"flood": gamma(a=0.8, scale=5000)}
        with pytest.raises(ValueError, match="Missing marginals"):
            fitted_vine.simulate(100, marginals=partial_marginals)
