"""Tests for pharmacokinetic and pharmacodynamic models."""

import numpy as np
import pytest

from anesthesim.models.marsh_pk import MarshPKModel, PARAMETER_RANGES as MARSH_PARAMS
from anesthesim.models.minto_pk import MintoPKModel, PARAMETER_RANGES as MINTO_PARAMS
from anesthesim.models.pd_models import HillPDModel, GrecoInteractionModel
from anesthesim.models.patient import VirtualPatient, generate_patient_cohort


# ─── Marsh PK Model ───

class TestMarshPKModel:
    def test_init_default(self):
        model = MarshPKModel()
        assert model.weight_kg == 70.0
        assert model.A1 == 0.0
        assert model.Ce == 0.0

    def test_init_custom_weight(self):
        model = MarshPKModel(weight_kg=90.0)
        assert model.V1 == pytest.approx(0.228 * 90.0, rel=1e-3)

    def test_reset_clears_state(self):
        model = MarshPKModel()
        model.step(100.0)
        model.step(100.0)
        assert model.A1 > 0.0
        model.reset()
        assert model.A1 == 0.0
        assert model.Ce == 0.0
        assert model.total_drug == 0.0

    def test_step_zero_infusion(self):
        model = MarshPKModel()
        result = model.step(0.0)
        assert result["plasma_concentration"] == 0.0
        assert result["effect_site_concentration"] == 0.0

    def test_step_positive_infusion_increases_concentration(self):
        model = MarshPKModel()
        model.step(100.0)
        model.step(100.0)
        assert model.plasma_concentration > 0.0
        assert model.Ce > 0.0

    def test_concentration_rises_with_infusion(self):
        model = MarshPKModel()
        concs = []
        for _ in range(20):
            model.step(150.0)
            concs.append(model.plasma_concentration)
        assert concs[-1] > concs[0]

    def test_concentration_decays_after_stopping(self):
        model = MarshPKModel()
        for _ in range(40):
            model.step(150.0)
        peak = model.plasma_concentration
        for _ in range(40):
            model.step(0.0)
        assert model.plasma_concentration < peak

    def test_total_drug_accumulates(self):
        model = MarshPKModel()
        model.step(100.0)
        model.step(100.0)
        assert model.total_drug > 0.0

    def test_amounts_non_negative(self):
        model = MarshPKModel()
        for _ in range(100):
            model.step(50.0)
        for _ in range(200):
            model.step(0.0)
        assert model.A1 >= 0.0
        assert model.A2 >= 0.0
        assert model.A3 >= 0.0
        assert model.Ce >= 0.0

    def test_effect_site_lags_plasma(self):
        model = MarshPKModel()
        model.step(200.0)
        assert model.plasma_concentration > model.Ce

    def test_apply_variability(self):
        model = MarshPKModel()
        rng = np.random.default_rng(42)
        original_k10 = model.k10
        model.apply_variability(rng, 0.3)
        assert model.k10 != original_k10

    def test_parameter_ranges_valid(self):
        for name, param in MARSH_PARAMS.items():
            assert param["min"] < param["max"]
            assert param["min"] <= param["default"] <= param["max"]

    def test_steady_state_finite(self):
        model = MarshPKModel()
        for _ in range(500):
            model.step(100.0)
        assert model.plasma_concentration < 100.0


# ─── Minto PK Model ───

class TestMintoPKModel:
    def test_init_default(self):
        model = MintoPKModel()
        assert model.V1 == 5.1
        assert model.A1 == 0.0

    def test_reset_clears_state(self):
        model = MintoPKModel()
        model.step(0.2)
        model.reset()
        assert model.A1 == 0.0
        assert model.Ce == 0.0

    def test_step_zero_infusion(self):
        model = MintoPKModel()
        result = model.step(0.0)
        assert result["plasma_concentration"] == 0.0

    def test_step_positive_infusion(self):
        model = MintoPKModel()
        for _ in range(10):
            model.step(0.2)
        assert model.plasma_concentration > 0.0
        assert model.Ce > 0.0

    def test_concentration_rises(self):
        model = MintoPKModel()
        concs = []
        for _ in range(20):
            model.step(0.3)
            concs.append(model.plasma_concentration)
        assert concs[-1] > concs[0]

    def test_concentration_decays(self):
        model = MintoPKModel()
        for _ in range(40):
            model.step(0.3)
        peak = model.plasma_concentration
        for _ in range(40):
            model.step(0.0)
        assert model.plasma_concentration < peak

    def test_micro_constants_computed(self):
        model = MintoPKModel()
        assert model.k10 == pytest.approx(model.Cl1 / model.V1)
        assert model.k12 == pytest.approx(model.Cl2 / model.V1)

    def test_apply_variability(self):
        model = MintoPKModel()
        rng = np.random.default_rng(42)
        original_V1 = model.V1
        model.apply_variability(rng, 0.3)
        assert model.V1 != original_V1
        # Micro-constants should be recomputed
        assert model.k10 == pytest.approx(model.Cl1 / model.V1)

    def test_parameter_ranges_valid(self):
        for name, param in MINTO_PARAMS.items():
            assert param["min"] < param["max"]
            assert param["min"] <= param["default"] <= param["max"]

    def test_amounts_non_negative(self):
        model = MintoPKModel()
        for _ in range(100):
            model.step(0.3)
        for _ in range(200):
            model.step(0.0)
        assert model.A1 >= 0.0
        assert model.A2 >= 0.0
        assert model.A3 >= 0.0

    def test_effect_site_lags_plasma(self):
        model = MintoPKModel()
        model.step(0.3)
        assert model.plasma_concentration > model.Ce


# ─── Hill PD Model ───

class TestHillPDModel:
    def test_zero_concentration_returns_baseline(self):
        pd = HillPDModel()
        assert pd.predict_BIS(0.0) == pytest.approx(97.0)

    def test_high_concentration_lowers_BIS(self):
        pd = HillPDModel()
        bis_high = pd.predict_BIS(10.0)
        assert bis_high < 20.0

    def test_ce50_gives_half_effect(self):
        pd = HillPDModel()
        bis_at_ce50 = pd.predict_BIS(3.4)
        assert 40.0 < bis_at_ce50 < 55.0

    def test_BIS_monotonically_decreases(self):
        pd = HillPDModel()
        concs = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 8.0, 12.0]
        bis_values = [pd.predict_BIS(c) for c in concs]
        for i in range(1, len(bis_values)):
            assert bis_values[i] <= bis_values[i - 1]

    def test_BIS_non_negative(self):
        pd = HillPDModel()
        assert pd.predict_BIS(100.0) >= 0.0

    def test_add_noise_within_bounds(self):
        pd = HillPDModel()
        rng = np.random.default_rng(42)
        for _ in range(100):
            noisy = pd.add_noise(50.0, rng, 5.0)
            assert 0.0 <= noisy <= 100.0

    def test_negative_concentration_returns_baseline(self):
        pd = HillPDModel()
        assert pd.predict_BIS(-1.0) == pytest.approx(97.0)


# ─── Greco Interaction Model ───

class TestGrecoInteractionModel:
    def test_zero_both_returns_baseline(self):
        pd = GrecoInteractionModel()
        assert pd.predict_BIS(0.0, 0.0) == pytest.approx(97.0)

    def test_propofol_only_lowers_BIS(self):
        pd = GrecoInteractionModel()
        bis = pd.predict_BIS(5.0, 0.0)
        assert bis < 97.0

    def test_remifentanil_only_lowers_BIS(self):
        pd = GrecoInteractionModel()
        bis = pd.predict_BIS(0.0, 30.0)
        assert bis < 97.0

    def test_synergy_greater_than_additive(self):
        pd = GrecoInteractionModel()
        bis_prop = pd.predict_BIS(2.0, 0.0)
        bis_remi = pd.predict_BIS(0.0, 10.0)
        bis_both = pd.predict_BIS(2.0, 10.0)
        # Combined should produce lower BIS than either alone
        assert bis_both < bis_prop
        assert bis_both < bis_remi

    def test_BIS_non_negative(self):
        pd = GrecoInteractionModel()
        assert pd.predict_BIS(20.0, 50.0) >= 0.0

    def test_add_noise_within_bounds(self):
        pd = GrecoInteractionModel()
        rng = np.random.default_rng(42)
        noisy = pd.add_noise(50.0, rng, 5.0)
        assert 0.0 <= noisy <= 100.0


# ─── Patient Generation ───

class TestPatientGeneration:
    def test_generate_default_cohort(self):
        patients = generate_patient_cohort()
        assert len(patients) == 30

    def test_generate_custom_size(self):
        patients = generate_patient_cohort(n_patients=12)
        assert len(patients) == 12

    def test_patient_groups(self):
        patients = generate_patient_cohort(n_patients=30)
        groups = {p.group for p in patients}
        assert "young" in groups
        assert "middle" in groups
        assert "elderly" in groups

    def test_patient_attributes(self):
        patients = generate_patient_cohort(n_patients=6)
        for p in patients:
            assert 25.0 <= p.age <= 80.0
            assert 45.0 <= p.weight_kg <= 130.0
            assert 140.0 <= p.height_cm <= 200.0
            assert p.lean_body_mass_kg > 0.0
            assert p.sex in ("M", "F")

    def test_bmi_computed(self):
        p = VirtualPatient(
            patient_id=0, age=40, weight_kg=70.0,
            height_cm=170.0, lean_body_mass_kg=55.0,
            sex="M", group="middle",
        )
        expected_bmi = 70.0 / (1.70 ** 2)
        assert p.bmi == pytest.approx(expected_bmi, rel=1e-2)

    def test_reproducible(self):
        p1 = generate_patient_cohort(n_patients=10, seed=123)
        p2 = generate_patient_cohort(n_patients=10, seed=123)
        for a, b in zip(p1, p2):
            assert a.age == b.age
            assert a.weight_kg == b.weight_kg
