"""
External validation: empirical openpkpd vs nlmixr2 comparisons.

Benchmarks
----------
- Boeckmann et al. (1992) theophylline: FO and FOCE-I
- nlmixr2data::warfarin PK-only (`dvid == "cp"`) subset: FO
- nlmixr2data::warfarin joint PK/PD 4-subject reduced mixed-endpoint subset: FO
- nlmixr2data::warfarin joint PK/PD 6-subject reduced mixed-endpoint subset: FO

These tests compare stable parameter and residual-variance signals against
bundled nlmixr2 references while keeping runtime acceptable for slow CI runs.
"""

from __future__ import annotations

import json
import os
import warnings

import numpy as np
import pandas as pd
import pytest

warnings.filterwarnings("ignore")

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

REF_DIR = os.path.join(os.path.dirname(__file__), "nlmixr2", "reference")
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")


def _load_ref(name: str) -> dict:
    path = os.path.join(REF_DIR, name)
    if not os.path.exists(path):
        pytest.skip(f"Reference file not found: {path}")
    with open(path) as f:
        return json.load(f)


def _build_focei_model(maxeval: int = 5):
    """Return a BuiltModel ready to fit on Boeckmann theophylline data."""
    from openpkpd import ModelBuilder
    from openpkpd.data.dataset import NONMEMDataset

    data_path = os.path.join(DATA_DIR, "theophylline_boeckmann.csv")
    if not os.path.exists(data_path):
        pytest.skip(f"Data file not found: {data_path}")

    ds = NONMEMDataset.from_csv(data_path)

    pk_code = """
KA = exp(THETA(1) + ETA(1))
CL = exp(THETA(2) + ETA(2))
V  = exp(THETA(3) + ETA(3))
K  = CL / V
"""
    error_code = """
IPRED = F
W = IPRED * THETA(4)
IRES = DV - IPRED
IWRES = IRES / W
Y = IPRED + W * EPS(1)
"""
    return (
        ModelBuilder()
        .problem("Theophylline 1-cmt oral — external validation")
        .dataset(ds)
        .subroutines(advan=2, trans=2)
        .pk(pk_code)
        .error(error_code)
        .theta([(0.405,), (1.030,), (3.466,), (0.001, 0.1, 5.0)])
        .omega([[0.09, 0, 0], [0, 0.09, 0], [0, 0, 0.09]])
        .sigma([[1.0]])
        .estimation(method="FOCEI", maxeval=maxeval)
        .build()
    )


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


@pytest.mark.external_validation
@pytest.mark.slow
class TestFOCEIvsNlmixr2:
    """
    External validation: openpkpd FOCE-I vs nlmixr2 reference.

    Uses maxeval=5 (5 outer L-BFGS-B iterations, ~30 seconds) for CI speed.
    Parameters after 5 iterations are already close to the reference because
    the FOCE-I landscape is well-conditioned for THETA in this model.
    """

    @pytest.fixture(scope="class")
    def focei_result(self):
        model = _build_focei_model(maxeval=5)
        return model.fit()

    @pytest.fixture(scope="class")
    def ref_foce(self):
        return _load_ref("theophylline_foce.json")

    def test_ofv_decreases_from_initial(self, focei_result):
        """OFV should decrease during optimization (L-BFGS-B is minimizing)."""
        history = focei_result.ofv_history
        assert len(history) >= 2, "Need at least 2 OFV values to check direction"
        assert history[-1] < history[0], (
            f"OFV did not decrease: start={history[0]:.2f}, end={history[-1]:.2f}"
        )

    def test_theta_ka_within_tolerance(self, focei_result, ref_foce):
        """KA = exp(THETA(1)) should be within 30% of nlmixr2 after 5 iterations."""
        ka_openpkpd = np.exp(focei_result.theta_final[0])
        ka_ref = ref_foce["theta"]["KA"]
        rel_err = abs(ka_openpkpd - ka_ref) / ka_ref
        assert rel_err < 0.30, (
            f"KA={ka_openpkpd:.4f} vs nlmixr2={ka_ref:.4f} (rel_err={rel_err:.1%})"
        )

    def test_theta_cl_within_tolerance(self, focei_result, ref_foce):
        """CL = exp(THETA(2)) should be within 15% of nlmixr2 after 5 iterations."""
        cl_openpkpd = np.exp(focei_result.theta_final[1])
        cl_ref = ref_foce["theta"]["CL"]
        rel_err = abs(cl_openpkpd - cl_ref) / cl_ref
        assert rel_err < 0.15, (
            f"CL={cl_openpkpd:.4f} vs nlmixr2={cl_ref:.4f} (rel_err={rel_err:.1%})"
        )

    def test_theta_v_within_tolerance(self, focei_result, ref_foce):
        """V = exp(THETA(3)) should be within 10% of nlmixr2 after 5 iterations."""
        v_openpkpd = np.exp(focei_result.theta_final[2])
        v_ref = ref_foce["theta"]["V"]
        rel_err = abs(v_openpkpd - v_ref) / v_ref
        assert rel_err < 0.10, f"V={v_openpkpd:.4f} vs nlmixr2={v_ref:.4f} (rel_err={rel_err:.1%})"

    def test_omega_not_exploding(self, focei_result):
        """OMEGA diagonal should stay below a reasonable ceiling (< 9.0)."""
        omega_diag = focei_result.omega_final.diagonal()
        assert np.all(omega_diag < 9.0), (
            f"OMEGA diagonal exploded: {omega_diag}. Bug in log|Ω| term or optimizer bounds."
        )

    def test_omega_direction(self, focei_result):
        """OMEGA diagonal for CL and V should converge toward nlmixr2 reference
        (< 0.5 each) as these have low IIV in the reference data."""
        omega_diag = focei_result.omega_final.diagonal()
        # After 5 iterations: CL omega should not be huge
        # nlmixr2: [0.413, 0.060, 0.021]
        assert omega_diag[1] < 1.0, f"OMEGA(CL)={omega_diag[1]:.4f} unexpectedly large (ref=0.060)"
        assert omega_diag[2] < 0.5, f"OMEGA(V)={omega_diag[2]:.4f} unexpectedly large (ref=0.021)"

    def test_prop_err_direction(self, focei_result, ref_foce):
        """Proportional error parameter should be positive and in a reasonable range."""
        prop_err = focei_result.theta_final[3]
        assert 0 < prop_err < 2.0, f"Proportional error parameter {prop_err:.4f} out of range"
        # After 5 iterations, prop_err variance should be within order of magnitude
        prop_var = prop_err**2
        ref_var = ref_foce["sigma_prop_err_variance"]
        ratio = prop_var / ref_var
        assert 0.1 < ratio < 10.0, (
            f"prop_err variance={prop_var:.4f} vs nlmixr2={ref_var:.4f} "
            f"(ratio={ratio:.1f}, expected within 10x)"
        )


def _build_fo_model(maxeval: int = 5):
    """Return a BuiltModel using FO estimation (not FOCE-I)."""
    from openpkpd import ModelBuilder
    from openpkpd.data.dataset import NONMEMDataset

    data_path = os.path.join(DATA_DIR, "theophylline_boeckmann.csv")
    if not os.path.exists(data_path):
        pytest.skip(f"Data file not found: {data_path}")

    ds = NONMEMDataset.from_csv(data_path)

    pk_code = """
KA = exp(THETA(1) + ETA(1))
CL = exp(THETA(2) + ETA(2))
V  = exp(THETA(3) + ETA(3))
K  = CL / V
"""
    error_code = """
IPRED = F
W = IPRED * THETA(4)
IRES = DV - IPRED
IWRES = IRES / W
Y = IPRED + W * EPS(1)
"""
    return (
        ModelBuilder()
        .problem("Theophylline 1-cmt oral — FO validation")
        .dataset(ds)
        .subroutines(advan=2, trans=2)
        .pk(pk_code)
        .error(error_code)
        .theta([(0.405,), (1.030,), (3.466,), (0.001, 0.1, 5.0)])
        .omega([[0.09, 0, 0], [0, 0.09, 0], [0, 0, 0.09]])
        .sigma([[1.0]])
        .estimation(method="FO", maxeval=maxeval)
        .build()
    )


def _build_warfarin_fo_model(maxeval: int = 80):
    """Return a BuiltModel using FO estimation on the PK-only warfarin subset."""
    from openpkpd import ModelBuilder
    from openpkpd.data.dataset import NONMEMDataset

    data_path = os.path.join(DATA_DIR, "warfarin_pk.csv")
    if not os.path.exists(data_path):
        pytest.skip(f"Data file not found: {data_path}")

    ds = NONMEMDataset.from_csv(data_path)

    return (
        ModelBuilder()
        .problem("Warfarin PK-only 1-cmt oral — FO validation")
        .dataset(ds)
        .subroutines(advan=2, trans=2)
        .pk("KA = THETA(1)*EXP(ETA(1))\nCL = THETA(2)*EXP(ETA(2))\nV  = THETA(3)*EXP(ETA(3))")
        .error("Y = F*(1 + EPS(1))")
        .theta([(0.01, 0.9, 20), (0.001, 0.13, 5), (0.1, 8.7, 200)])
        .omega([0.4, 0.3, 0.3])
        .sigma(0.05)
        .estimation(method="FO", maxeval=maxeval)
        .build()
    )


def _build_warfarin_pkpd_reduced_fo_model(
    data_filename: str,
    ref_filename: str,
    problem_name: str,
    maxeval: int = 3,
):
    """Return a reduced mixed-endpoint warfarin FO model with practical runtime.

    This benchmark uses reference-informed starting values from the bundled
    nlmixr2 reduced-subset FO run so the test can focus on the openpkpd mixed-
    endpoint ODE + DVID-routed execution path while staying runtime-practical.
    """
    from openpkpd import ModelBuilder
    from openpkpd.data.dataset import NONMEMDataset

    data_path = os.path.join(DATA_DIR, data_filename)
    if not os.path.exists(data_path):
        pytest.skip(f"Data file not found: {data_path}")

    ref = _load_ref(ref_filename)
    th = ref["theta"]
    ds = NONMEMDataset.from_csv(data_path)

    pk_code = """
KTR = THETA(1)
KA = THETA(2)
CL = THETA(3)
V  = THETA(4)
EMAX = THETA(5)
EC50 = THETA(6)
KOUT = THETA(7)
E0 = THETA(8)
PCMT = 3
"""
    des_code = """
DADT(1) = -KTR*A(1)
DADT(2) = KTR*A(1) - KA*A(2)
DADT(3) = KA*A(2) - (CL/V)*A(3)
PD = 1 - EMAX*(A(3)/V)/(EC50 + (A(3)/V))
DADT(4) = KOUT*E0*(PD - 1) - KOUT*A(4)
"""
    error_code = """
PKPROP = THETA(9)
PKADD = THETA(10)
PDADD = THETA(11)
IPRED = THETA(8) + A(4)
W = PDADD
Y = IPRED + W*EPS(2)
IF (DVID .EQ. 1) W = SQRT((PKPROP*F)**2 + PKADD**2)
IF (DVID .EQ. 1) Y = F + W*EPS(1)
"""

    return (
        ModelBuilder()
        .problem(problem_name)
        .dataset(ds)
        .covariates(["DVID"])
        .subroutines(advan=6, trans=1)
        .pk(pk_code)
        .des(des_code)
        .error(error_code)
        .theta(
            [
                (0.1, th["KTR"], 3.0),
                (0.1, th["KA"], 3.0),
                (0.01, th["CL"], 1.0),
                (2.0, th["V"], 30.0),
                (0.5, th["EMAX"], 0.999),
                (0.05, th["EC50"], 10.0),
                (0.005, th["KOUT"], 1.0),
                (10.0, th["E0"], 200.0),
                (0.001, th["PK_PROP_ERR"], 1.0),
                (0.05, th["PK_ADD_ERR"], 5.0),
                (0.5, th["PD_ADD_ERR"], 30.0),
            ]
        )
        .omega([1e-8], fixed=True)
        .sigma([[1.0, 0.0], [0.0, 1.0]], fixed=True)
        .estimation(method="FO", maxeval=maxeval)
        .build()
    )


def _build_warfarin_pkpd_4_fo_model(maxeval: int = 3):
    return _build_warfarin_pkpd_reduced_fo_model(
        data_filename="warfarin_pkpd_4.csv",
        ref_filename="warfarin_pkpd_4_fo.json",
        problem_name="Warfarin joint PK/PD 4-subject reduced — FO validation",
        maxeval=maxeval,
    )


def _build_warfarin_pkpd_6_fo_model(maxeval: int = 3):
    return _build_warfarin_pkpd_reduced_fo_model(
        data_filename="warfarin_pkpd_6.csv",
        ref_filename="warfarin_pkpd_6_fo.json",
        problem_name="Warfarin joint PK/PD 6-subject reduced — FO validation",
        maxeval=maxeval,
    )


def _build_warfarin_pkpd_full_fo_model(maxeval: int = 3):
    """Return the full 32-subject mixed-endpoint warfarin FO benchmark model."""
    from openpkpd import ModelBuilder
    from openpkpd.data.dataset import NONMEMDataset

    data_path = os.path.join(DATA_DIR, "warfarin_pkpd.csv")
    if not os.path.exists(data_path):
        pytest.skip(f"Data file not found: {data_path}")

    ref = _load_ref("warfarin_pkpd_fo.json")
    th = ref["theta"]
    om = ref["omega_diag"]
    ds = NONMEMDataset.from_csv(data_path)

    pk_code = """
KTR = THETA(1)
KA = THETA(2)
CL = THETA(3)*EXP(ETA(1))
V  = THETA(4)*EXP(ETA(2))
EMAX = THETA(5)
EC50 = THETA(6)*EXP(ETA(3))
KOUT = THETA(7)*EXP(ETA(4))
E0 = THETA(8)*EXP(ETA(5))
PCMT = 3
"""
    des_code = """
DADT(1) = -KTR*A(1)
DADT(2) = KTR*A(1) - KA*A(2)
DADT(3) = KA*A(2) - (CL/V)*A(3)
PD = 1 - EMAX*(A(3)/V)/(EC50 + (A(3)/V))
DADT(4) = KOUT*E0*(PD - 1) - KOUT*A(4)
"""
    error_code = """
PKPROP = THETA(9)
PKADD = THETA(10)
PDADD = THETA(11)
IPRED = THETA(8) + A(4)
W = PDADD
Y = IPRED + W*EPS(2)
IF (DVID .EQ. 1) W = SQRT((PKPROP*F)**2 + PKADD**2)
IF (DVID .EQ. 1) Y = F + W*EPS(1)
"""

    return (
        ModelBuilder()
        .problem("Warfarin joint PK/PD 32-subject full — FO validation")
        .dataset(ds)
        .covariates(["DVID"])
        .subroutines(advan=6, trans=1)
        .pk(pk_code)
        .des(des_code)
        .error(error_code)
        .theta(
            [
                (0.1, th["KTR"], 3.0),
                (0.1, th["KA"], 3.0),
                (0.01, th["CL"], 1.0),
                (2.0, th["V"], 30.0),
                (0.5, th["EMAX"], 0.999),
                (0.05, th["EC50"], 10.0),
                (0.005, th["KOUT"], 1.0),
                (10.0, th["E0"], 200.0),
                (0.001, th["PK_PROP_ERR"], 1.0),
                (0.05, th["PK_ADD_ERR"], 5.0),
                (0.5, th["PD_ADD_ERR"], 30.0),
            ]
        )
        .omega([om["CL"], om["V"], om["EC50"], om["KOUT"], om["E0"]])
        .sigma([[1.0, 0.0], [0.0, 1.0]], fixed=True)
        .estimation(method="FO", maxeval=maxeval)
        .build()
    )


@pytest.mark.external_validation
@pytest.mark.slow
class TestFOvsNlmixr2:
    """
    External validation: openpkpd FO vs nlmixr2 FO reference.

    FO is a first-order approximation: linearises p(y|η) at η=0.
    Known bias: overestimates KA for oral 1-cmt (flip-flop susceptible).
    Reference: nlmixr2 FO on Boeckmann theophylline (KA=2.71, CL=2.85, V=32.15).
    """

    @pytest.fixture(scope="class")
    def fo_result(self):
        model = _build_fo_model(maxeval=5)
        return model.fit()

    @pytest.fixture(scope="class")
    def ref_fo(self):
        return _load_ref("theophylline_fo.json")

    def test_fo_ofv_decreases(self, fo_result):
        """FO OFV should decrease during optimization."""
        history = fo_result.ofv_history
        assert len(history) >= 2
        assert history[-1] < history[0], (
            f"FO OFV did not decrease: {history[0]:.2f} → {history[-1]:.2f}"
        )

    def test_fo_cl_within_tolerance(self, fo_result, ref_fo):
        """FO CL = exp(THETA(2)) should be within 20% of nlmixr2 FO after 5 iters."""
        cl_openpkpd = np.exp(fo_result.theta_final[1])
        cl_ref = ref_fo["theta"]["CL"]
        rel_err = abs(cl_openpkpd - cl_ref) / cl_ref
        assert rel_err < 0.20, (
            f"FO CL={cl_openpkpd:.4f} vs nlmixr2={cl_ref:.4f} (rel_err={rel_err:.1%})"
        )

    def test_fo_v_within_tolerance(self, fo_result, ref_fo):
        """FO V = exp(THETA(3)) should be within 15% of nlmixr2 FO after 5 iters."""
        v_openpkpd = np.exp(fo_result.theta_final[2])
        v_ref = ref_fo["theta"]["V"]
        rel_err = abs(v_openpkpd - v_ref) / v_ref
        assert rel_err < 0.15, (
            f"FO V={v_openpkpd:.4f} vs nlmixr2={v_ref:.4f} (rel_err={rel_err:.1%})"
        )

    def test_fo_ka_overestimates_vs_focei(self, fo_result, ref_fo):
        """FO KA is known to overestimate vs FOCE-I for this oral model.

        nlmixr2 reference: FO KA=2.71 vs FOCE-I KA=1.44.  openpkpd FO KA
        after partial convergence should be higher than its own FOCE-I KA.
        """
        ka_fo = np.exp(fo_result.theta_final[0])
        # FO KA should be in roughly the nlmixr2 FO range (1.5–4.0)
        # reflecting the known upward bias of FO for this parameterisation
        assert ka_fo > 1.2, f"FO KA={ka_fo:.4f} unexpectedly low (FO should overestimate KA)"

    def test_fo_omega_not_exploding(self, fo_result):
        """FO OMEGA diagonal should stay below ceiling."""
        omega_diag = fo_result.omega_final.diagonal()
        assert np.all(omega_diag < 9.0), f"OMEGA exploded: {omega_diag}"


@pytest.mark.external_validation
@pytest.mark.slow
class TestCrossMethodRatiosVsNlmixr2:
    """Cross-method FO→FOCE trends should track the nlmixr2 reference ratios."""

    @pytest.fixture(scope="class")
    def fo_result(self):
        return _build_fo_model(maxeval=5).fit()

    @pytest.fixture(scope="class")
    def focei_result(self):
        return _build_focei_model(maxeval=5).fit()

    @pytest.fixture(scope="class")
    def ref_fo(self):
        return _load_ref("theophylline_fo.json")

    @pytest.fixture(scope="class")
    def ref_foce(self):
        return _load_ref("theophylline_foce.json")

    def test_ka_ratio_matches_reference_trend(self, fo_result, focei_result, ref_fo, ref_foce):
        open_ratio = np.exp(fo_result.theta_final[0]) / np.exp(focei_result.theta_final[0])
        ref_ratio = ref_fo["theta"]["KA"] / ref_foce["theta"]["KA"]
        assert open_ratio > 1.2, "FO should retain the known KA overestimation vs FOCE-I"
        assert 0.75 * ref_ratio <= open_ratio <= 1.35 * ref_ratio, (
            f"openpkpd KA ratio={open_ratio:.3f} vs nlmixr2={ref_ratio:.3f}"
        )

    def test_cl_omega_ratio_matches_reference_trend(
        self, fo_result, focei_result, ref_fo, ref_foce
    ):
        open_ratio = fo_result.omega_final[1, 1] / focei_result.omega_final[1, 1]
        ref_ratio = ref_fo["omega_diag"]["CL"] / ref_foce["omega_diag"]["CL"]
        assert open_ratio > 1.0, "FO CL IIV should remain larger than FOCE-I on this benchmark"
        assert 0.75 * ref_ratio <= open_ratio <= 1.35 * ref_ratio, (
            f"openpkpd Ω_CL ratio={open_ratio:.3f} vs nlmixr2={ref_ratio:.3f}"
        )

    def test_prop_error_ratio_matches_reference_trend(
        self, fo_result, focei_result, ref_fo, ref_foce
    ):
        open_ratio = float(focei_result.theta_final[3] ** 2 / fo_result.theta_final[3] ** 2)
        ref_ratio = ref_foce["sigma_prop_err_variance"] / ref_fo["sigma_prop_err_variance"]
        assert open_ratio > 1.0, (
            "FOCE-I proportional error variance should exceed FO on this benchmark"
        )
        assert 0.70 * ref_ratio <= open_ratio <= 1.40 * ref_ratio, (
            f"openpkpd prop-var ratio={open_ratio:.3f} vs nlmixr2={ref_ratio:.3f}"
        )


@pytest.mark.external_validation
@pytest.mark.slow
class TestWarfarinFOvsNlmixr2:
    """External validation: openpkpd FO vs nlmixr2 FO on PK-only warfarin.

    The PK-only `warfarin` subset adds a third empirical cross-tool dataset.
    This benchmark release-gates FO THETA, residual variance, and stable FO
    OMEGA signals.  The bundled FOCE-I reference is retained for future work,
    but FOCE-I itself is not release-gated here because openpkpd's IIV terms on
    this dataset are currently much less stable and much slower to fit.
    """

    @pytest.fixture(scope="class")
    def fo_result(self):
        return _build_warfarin_fo_model(maxeval=80).fit()

    @pytest.fixture(scope="class")
    def ref_fo(self):
        return _load_ref("warfarin_pk_fo.json")

    @pytest.fixture(scope="class")
    def ref_foce(self):
        return _load_ref("warfarin_pk_foce.json")

    def test_ofv_decreases(self, fo_result):
        history = fo_result.ofv_history
        assert len(history) >= 2
        assert history[-1] < history[0], (
            f"Warfarin FO OFV did not decrease: {history[0]:.2f} → {history[-1]:.2f}"
        )

    def test_theta_ka_within_tolerance(self, fo_result, ref_fo):
        ka_openpkpd = float(fo_result.theta_final[0])
        ka_ref = ref_fo["theta"]["KA"]
        rel_err = abs(ka_openpkpd - ka_ref) / ka_ref
        assert rel_err < 0.10, (
            f"FO KA={ka_openpkpd:.4f} vs nlmixr2={ka_ref:.4f} (rel_err={rel_err:.1%})"
        )

    def test_theta_cl_within_tolerance(self, fo_result, ref_fo):
        cl_openpkpd = float(fo_result.theta_final[1])
        cl_ref = ref_fo["theta"]["CL"]
        rel_err = abs(cl_openpkpd - cl_ref) / cl_ref
        assert rel_err < 0.05, (
            f"FO CL={cl_openpkpd:.4f} vs nlmixr2={cl_ref:.4f} (rel_err={rel_err:.1%})"
        )

    def test_theta_v_within_tolerance(self, fo_result, ref_fo):
        v_openpkpd = float(fo_result.theta_final[2])
        v_ref = ref_fo["theta"]["V"]
        rel_err = abs(v_openpkpd - v_ref) / v_ref
        assert rel_err < 0.05, (
            f"FO V={v_openpkpd:.4f} vs nlmixr2={v_ref:.4f} (rel_err={rel_err:.1%})"
        )

    def test_sigma_prop_err_variance_within_tolerance(self, fo_result, ref_fo):
        sigma_openpkpd = float(fo_result.sigma_final[0, 0])
        sigma_ref = ref_fo["sigma_prop_err_variance"]
        rel_err = abs(sigma_openpkpd - sigma_ref) / sigma_ref
        assert rel_err < 0.15, (
            f"FO sigma={sigma_openpkpd:.5f} vs nlmixr2={sigma_ref:.5f} (rel_err={rel_err:.1%})"
        )

    def test_omega_cl_and_v_track_reference(self, fo_result, ref_fo):
        omega_diag = fo_result.omega_final.diagonal()
        cl_ref = ref_fo["omega_diag"]["CL"]
        v_ref = ref_fo["omega_diag"]["V"]
        cl_rel_err = abs(float(omega_diag[1]) - cl_ref) / cl_ref
        v_rel_err = abs(float(omega_diag[2]) - v_ref) / v_ref
        assert cl_rel_err < 0.10, (
            f"FO Ω_CL={omega_diag[1]:.4f} vs nlmixr2={cl_ref:.4f} (rel_err={cl_rel_err:.1%})"
        )
        assert v_rel_err < 0.10, (
            f"FO Ω_V={omega_diag[2]:.4f} vs nlmixr2={v_ref:.4f} (rel_err={v_rel_err:.1%})"
        )

    def test_omega_ka_reasonable_and_matches_direction(self, fo_result, ref_fo, ref_foce):
        omega_ka = float(fo_result.omega_final[0, 0])
        ka_ref_fo = ref_fo["omega_diag"]["KA"]
        ka_ref_foce = ref_foce["omega_diag"]["KA"]
        assert omega_ka > ka_ref_foce, "FO KA IIV should remain above the FOCE-I reference"
        assert 0.5 * ka_ref_fo <= omega_ka <= 2.5 * ka_ref_fo, (
            f"FO Ω_KA={omega_ka:.4f} vs nlmixr2 FO={ka_ref_fo:.4f}"
        )


@pytest.mark.external_validation
@pytest.mark.slow
class TestWarfarinPKPDReducedFOvsNlmixr2:
    """External validation on a reduced empirical mixed-endpoint warfarin benchmark.

    The full joint PK/PD mixed-effects benchmark is still too slow for practical
    gating. This 4-subject reduced FO benchmark keeps the real ODE + DVID-routed
    path while release-gating the stable cross-tool signals.
    """

    @pytest.fixture(scope="class")
    def fo_result(self):
        return _build_warfarin_pkpd_4_fo_model(maxeval=3).fit()

    @pytest.fixture(scope="class")
    def ref_fo(self):
        return _load_ref("warfarin_pkpd_4_fo.json")

    def test_ofv_decreases(self, fo_result):
        history = fo_result.ofv_history
        assert len(history) >= 2
        assert history[-1] < history[0], (
            f"Reduced warfarin PK/PD FO OFV did not decrease: {history[0]:.2f} → {history[-1]:.2f}"
        )

    def test_stable_pk_theta_within_tolerance(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        names = ["KTR", "KA", "CL", "V"]
        tolerances = [0.015, 0.015, 0.015, 0.015]
        for idx, (name, tol) in enumerate(zip(names, tolerances, strict=True)):
            open_val = float(fo_result.theta_final[idx])
            ref_val = float(theta[name])
            rel_err = abs(open_val - ref_val) / ref_val
            assert rel_err < tol, (
                f"{name}={open_val:.4f} vs nlmixr2={ref_val:.4f} (rel_err={rel_err:.1%})"
            )

    def test_stable_pd_theta_within_tolerance(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        emax = float(fo_result.theta_final[4])
        ec50 = float(fo_result.theta_final[5])
        kout = float(fo_result.theta_final[6])
        assert emax > 0.95, f"EMAX={emax:.4f} should remain near the saturation boundary"
        assert abs(ec50 - theta["EC50"]) / theta["EC50"] < 0.01
        assert abs(kout - theta["KOUT"]) / theta["KOUT"] < 0.02

    def test_pk_error_terms_within_tolerance(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        pk_prop = float(fo_result.theta_final[8])
        pk_add = float(fo_result.theta_final[9])
        prop_rel_err = abs(pk_prop - theta["PK_PROP_ERR"]) / theta["PK_PROP_ERR"]
        add_rel_err = abs(pk_add - theta["PK_ADD_ERR"]) / theta["PK_ADD_ERR"]
        assert prop_rel_err < 0.05, (
            f"PK_PROP_ERR={pk_prop:.4f} vs nlmixr2={theta['PK_PROP_ERR']:.4f} (rel_err={prop_rel_err:.1%})"
        )
        assert add_rel_err < 0.02, (
            f"PK_ADD_ERR={pk_add:.4f} vs nlmixr2={theta['PK_ADD_ERR']:.4f} (rel_err={add_rel_err:.1%})"
        )

    def test_unstable_pd_terms_remain_finite_and_reasonable(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        e0 = float(fo_result.theta_final[7])
        pd_add = float(fo_result.theta_final[10])
        assert np.isfinite(e0) and e0 > 0.0
        assert np.isfinite(pd_add) and pd_add > 0.0
        assert 0.35 * theta["E0"] < e0 < 1.2 * theta["E0"]
        assert 0.5 * theta["PD_ADD_ERR"] < pd_add < 2.0 * theta["PD_ADD_ERR"]


@pytest.mark.external_validation
@pytest.mark.slow
class TestWarfarinPKPDReduced6FOvsNlmixr2:
    """Second-tier external validation on a broader reduced mixed-endpoint benchmark.

    This 6-subject path is not intended as the primary release-gated benchmark,
    but it provides broader empirical mixed-endpoint coverage while remaining
    practical enough for targeted slow validation.
    """

    @pytest.fixture(scope="class")
    def fo_result(self):
        return _build_warfarin_pkpd_6_fo_model(maxeval=3).fit()

    @pytest.fixture(scope="class")
    def ref_fo(self):
        return _load_ref("warfarin_pkpd_6_fo.json")

    def test_ofv_decreases(self, fo_result):
        history = fo_result.ofv_history
        assert len(history) >= 2
        assert history[-1] < history[0], (
            f"Reduced 6-subject warfarin PK/PD FO OFV did not decrease: {history[0]:.2f} → {history[-1]:.2f}"
        )

    def test_stable_pk_theta_within_tolerance(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        names = ["KTR", "KA", "CL", "V"]
        tolerances = [0.05, 0.05, 0.05, 0.05]
        for idx, (name, tol) in enumerate(zip(names, tolerances, strict=True)):
            open_val = float(fo_result.theta_final[idx])
            ref_val = float(theta[name])
            rel_err = abs(open_val - ref_val) / ref_val
            assert rel_err < tol, (
                f"{name}={open_val:.4f} vs nlmixr2={ref_val:.4f} (rel_err={rel_err:.1%})"
            )

    def test_core_pd_terms_remain_close(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        emax = float(fo_result.theta_final[4])
        ec50 = float(fo_result.theta_final[5])
        kout = float(fo_result.theta_final[6])
        assert abs(emax - theta["EMAX"]) / theta["EMAX"] < 0.01
        assert abs(ec50 - theta["EC50"]) / theta["EC50"] < 0.01
        assert abs(kout - theta["KOUT"]) / theta["KOUT"] < 0.01

    def test_pk_error_terms_track_reference(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        pk_prop = float(fo_result.theta_final[8])
        pk_add = float(fo_result.theta_final[9])
        prop_rel_err = abs(pk_prop - theta["PK_PROP_ERR"]) / theta["PK_PROP_ERR"]
        add_rel_err = abs(pk_add - theta["PK_ADD_ERR"]) / theta["PK_ADD_ERR"]
        assert prop_rel_err < 0.015, (
            f"PK_PROP_ERR={pk_prop:.4f} vs nlmixr2={theta['PK_PROP_ERR']:.4f} (rel_err={prop_rel_err:.1%})"
        )
        assert add_rel_err < 0.015, (
            f"PK_ADD_ERR={pk_add:.4f} vs nlmixr2={theta['PK_ADD_ERR']:.4f} (rel_err={add_rel_err:.1%})"
        )

    def test_less_stable_pd_compensation_terms_remain_reasonable(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        e0 = float(fo_result.theta_final[7])
        pd_add = float(fo_result.theta_final[10])
        assert np.isfinite(e0) and e0 > 0.0
        assert np.isfinite(pd_add) and pd_add > 0.0
        assert 0.30 * theta["E0"] < e0 < 1.2 * theta["E0"]
        assert 0.5 * theta["PD_ADD_ERR"] < pd_add < 2.0 * theta["PD_ADD_ERR"]


@pytest.mark.external_validation
@pytest.mark.slow
class TestWarfarinPKPDFullFOvsNlmixr2:
    """Third-tier external validation on the full empirical mixed-endpoint benchmark."""

    @pytest.fixture(scope="class")
    def fo_result(self):
        result = _build_warfarin_pkpd_full_fo_model(maxeval=3).fit()
        history = result.ofv_history
        ref_ofv = float(_load_ref("warfarin_pkpd_fo.json")["ofv"])
        if (
            len(history) < 2
            or not np.isfinite(history[-1])
            or history[-1] >= history[0]
            or not bool(result.converged)
            or history[-1] >= 3.0 * ref_ofv
        ):
            pytest.xfail(
                "Full 32-subject mixed-endpoint FO benchmark remains a third-tier target: "
                "the $ERROR namespace bug is fixed, but the FO fit is still non-converged "
                "and remains far above the nlmixr2 reference objective."
            )
        return result

    @pytest.fixture(scope="class")
    def ref_fo(self):
        return _load_ref("warfarin_pkpd_fo.json")

    def test_ofv_decreases(self, fo_result):
        history = fo_result.ofv_history
        assert len(history) >= 2
        assert history[-1] < history[0], (
            f"Full warfarin PK/PD FO OFV did not decrease: {history[0]:.2f} → {history[-1]:.2f}"
        )

    def test_core_pk_theta_within_tolerance(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        names = ["KTR", "KA", "CL", "V"]
        tolerances = [0.10, 0.10, 0.10, 0.10]
        for idx, (name, tol) in enumerate(zip(names, tolerances, strict=True)):
            open_val = float(fo_result.theta_final[idx])
            ref_val = float(theta[name])
            rel_err = abs(open_val - ref_val) / ref_val
            assert rel_err < tol, (
                f"{name}={open_val:.4f} vs nlmixr2={ref_val:.4f} (rel_err={rel_err:.1%})"
            )

    def test_core_pd_theta_within_tolerance(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        emax = float(fo_result.theta_final[4])
        ec50 = float(fo_result.theta_final[5])
        kout = float(fo_result.theta_final[6])
        assert emax > 0.95, f"EMAX={emax:.4f} should remain near the saturation boundary"
        assert abs(emax - theta["EMAX"]) < 0.03
        assert abs(ec50 - theta["EC50"]) / theta["EC50"] < 0.15
        assert abs(kout - theta["KOUT"]) / theta["KOUT"] < 0.15

    def test_pk_error_terms_track_reference(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        pk_prop = float(fo_result.theta_final[8])
        pk_add = float(fo_result.theta_final[9])
        prop_rel_err = abs(pk_prop - theta["PK_PROP_ERR"]) / theta["PK_PROP_ERR"]
        add_rel_err = abs(pk_add - theta["PK_ADD_ERR"]) / theta["PK_ADD_ERR"]
        assert prop_rel_err < 0.20, (
            f"PK_PROP_ERR={pk_prop:.4f} vs nlmixr2={theta['PK_PROP_ERR']:.4f} (rel_err={prop_rel_err:.1%})"
        )
        assert add_rel_err < 0.15, (
            f"PK_ADD_ERR={pk_add:.4f} vs nlmixr2={theta['PK_ADD_ERR']:.4f} (rel_err={add_rel_err:.1%})"
        )

    def test_stable_omega_terms_track_reference(self, fo_result, ref_fo):
        omega_diag = fo_result.omega_final.diagonal()
        omega_ref = ref_fo["omega_diag"]
        names = ["CL", "V", "EC50", "KOUT"]
        tolerances = [0.35, 0.35, 0.50, 0.50]
        for idx, (name, tol) in enumerate(zip(names, tolerances, strict=True)):
            open_val = float(omega_diag[idx])
            ref_val = float(omega_ref[name])
            rel_err = abs(open_val - ref_val) / ref_val
            assert rel_err < tol, (
                f"Ω_{name}={open_val:.4f} vs nlmixr2={ref_val:.4f} (rel_err={rel_err:.1%})"
            )

    def test_less_stable_terms_remain_finite_and_reasonable(self, fo_result, ref_fo):
        theta = ref_fo["theta"]
        omega_ref = ref_fo["omega_diag"]
        e0 = float(fo_result.theta_final[7])
        pd_add = float(fo_result.theta_final[10])
        omega_e0 = float(fo_result.omega_final.diagonal()[4])
        assert np.isfinite(e0) and e0 > 0.0
        assert np.isfinite(pd_add) and pd_add > 0.0
        assert np.isfinite(omega_e0) and omega_e0 > 0.0
        assert 0.5 * theta["E0"] < e0 < 1.5 * theta["E0"]
        assert 0.5 * theta["PD_ADD_ERR"] < pd_add < 2.0 * theta["PD_ADD_ERR"]
        assert 0.25 * omega_ref["E0"] < omega_e0 < 4.0 * omega_ref["E0"]


@pytest.mark.external_validation
def test_nlmixr2_reference_files_exist():
    """Verify nlmixr2 reference JSON files are present and well-formed."""
    for fname in (
        "theophylline_foce.json",
        "theophylline_fo.json",
        "warfarin_pk_fo.json",
        "warfarin_pk_foce.json",
        "warfarin_pkpd_fo.json",
        "warfarin_pkpd_foce.json",
        "warfarin_pkpd_4_fo.json",
        "warfarin_pkpd_6_fo.json",
    ):
        ref = _load_ref(fname)
        assert "ofv" in ref, f"Missing 'ofv' in {fname}"
        assert "theta" in ref, f"Missing 'theta' in {fname}"
        assert "method" in ref, f"Missing 'method' in {fname}"


@pytest.mark.external_validation
def test_boeckmann_data_file_exists():
    """Verify Boeckmann theophylline data is present and has expected shape."""
    data_path = os.path.join(DATA_DIR, "theophylline_boeckmann.csv")
    if not os.path.exists(data_path):
        pytest.skip("Data file not found")
    df = pd.read_csv(data_path)
    assert df["ID"].nunique() == 12, f"Expected 12 subjects, got {df['ID'].nunique()}"
    n_obs = ((df["EVID"] == 0) & (df["MDV"] == 0)).sum()
    assert n_obs == 120, f"Expected 120 observations, got {n_obs}"


@pytest.mark.external_validation
def test_warfarin_pk_data_file_exists():
    """Verify PK-only warfarin data is present and has expected shape."""
    data_path = os.path.join(DATA_DIR, "warfarin_pk.csv")
    if not os.path.exists(data_path):
        pytest.skip("Data file not found")
    df = pd.read_csv(data_path)
    assert df["ID"].nunique() == 32, f"Expected 32 subjects, got {df['ID'].nunique()}"
    assert (df["EVID"] == 1).sum() == 32, f"Expected 32 doses, got {(df['EVID'] == 1).sum()}"
    n_obs = ((df["EVID"] == 0) & (df["MDV"] == 0)).sum()
    assert n_obs == 251, f"Expected 251 observations, got {n_obs}"


@pytest.mark.external_validation
def test_warfarin_pkpd_full_data_file_exists_and_has_duplicate_times():
    """Verify full mixed-endpoint warfarin data is present and still mixed-endpoint."""
    data_path = os.path.join(DATA_DIR, "warfarin_pkpd.csv")
    if not os.path.exists(data_path):
        pytest.skip("Data file not found")
    df = pd.read_csv(data_path)
    assert df["ID"].nunique() == 32, f"Expected 32 subjects, got {df['ID'].nunique()}"
    assert (df["EVID"] == 1).sum() == 32, f"Expected 32 doses, got {(df['EVID'] == 1).sum()}"
    n_obs = ((df["EVID"] == 0) & (df["MDV"] == 0)).sum()
    assert n_obs == 483, f"Expected 483 observations, got {n_obs}"
    assert set(df.loc[df["EVID"] == 0, "DVID"].unique()) == {1, 2}
    dup_pairs = df.loc[df["EVID"] == 0].groupby(["ID", "TIME"]).size()
    assert (dup_pairs > 1).any(), "Expected duplicate observation times across endpoints"


@pytest.mark.external_validation
def test_warfarin_pkpd_reduced_data_file_exists_and_has_duplicate_times():
    """Verify reduced mixed-endpoint warfarin data is present and still mixed-endpoint."""
    data_path = os.path.join(DATA_DIR, "warfarin_pkpd_4.csv")
    if not os.path.exists(data_path):
        pytest.skip("Data file not found")
    df = pd.read_csv(data_path)
    assert df["ID"].nunique() == 4, f"Expected 4 subjects, got {df['ID'].nunique()}"
    assert (df["EVID"] == 1).sum() == 4, f"Expected 4 doses, got {(df['EVID'] == 1).sum()}"
    n_obs = ((df["EVID"] == 0) & (df["MDV"] == 0)).sum()
    assert n_obs == 67, f"Expected 67 observations, got {n_obs}"
    assert set(df.loc[df["EVID"] == 0, "DVID"].unique()) == {1, 2}
    dup_pairs = df.loc[df["EVID"] == 0].groupby(["ID", "TIME"]).size()
    assert (dup_pairs > 1).any(), "Expected duplicate observation times across endpoints"


@pytest.mark.external_validation
def test_warfarin_pkpd_reduced6_data_file_exists_and_has_duplicate_times():
    """Verify the broader reduced mixed-endpoint warfarin data fixture is present."""
    data_path = os.path.join(DATA_DIR, "warfarin_pkpd_6.csv")
    if not os.path.exists(data_path):
        pytest.skip("Data file not found")
    df = pd.read_csv(data_path)
    assert df["ID"].nunique() == 6, f"Expected 6 subjects, got {df['ID'].nunique()}"
    assert (df["EVID"] == 1).sum() == 6, f"Expected 6 doses, got {(df['EVID'] == 1).sum()}"
    n_obs = ((df["EVID"] == 0) & (df["MDV"] == 0)).sum()
    assert n_obs == 101, f"Expected 101 observations, got {n_obs}"
    assert set(df.loc[df["EVID"] == 0, "DVID"].unique()) == {1, 2}
    dup_pairs = df.loc[df["EVID"] == 0].groupby(["ID", "TIME"]).size()
    assert (dup_pairs > 1).any(), "Expected duplicate observation times across endpoints"
