"""Coverage gap tests for credit/causal_pd.py.

Target lines: 49, 63, 141-143, 200-201, 227, 235, 241-242,
              258-261, 294-296, 313, 325-326, 331.
"""

from __future__ import annotations

import pytest

from orxaq.credit.causal_pd import CausalPDModel, _LogisticRegression
from orxaq.credit.ontology import build_credit_ontology


@pytest.fixture
def dag():
    return build_credit_ontology()


@pytest.fixture
def fitted_model(dag):
    """CausalPDModel fitted on minimal synthetic data."""
    import random

    rng = random.Random(0)
    data = [
        {
            "credit_score": rng.gauss(650, 50),
            "dti": rng.gauss(0.35, 0.05),
            "ltv": rng.gauss(0.8, 0.05),
            "unemployment_rate": rng.gauss(0.05, 0.01),
            "gdp_growth": rng.gauss(0.02, 0.005),
            "fed_funds_rate": rng.gauss(0.03, 0.005),
            "hpi": rng.gauss(300, 10),
            "cpi": rng.gauss(300, 5),
            "income": rng.gauss(60000, 5000),
            "default_flag": float(rng.random() < 0.05),
        }
        for _ in range(80)
    ]
    model = CausalPDModel(dag)
    model.fit(data)
    return model, data


class TestLogisticRegressionEmptyFit:
    """Line 49: early return when n == 0."""

    def test_fit_empty_rows_returns_self(self) -> None:
        """_LogisticRegression.fit([]) hits n==0 branch, returns self."""
        lr = _LogisticRegression()
        result = lr.fit([], [])
        assert result is lr
        assert lr.coef_ == []
        assert lr.intercept_ == 0.0


class TestLogisticRegressionConstantColumn:
    """Line 63: stds = 1.0 when variance is near-zero (constant column)."""

    def test_standardize_constant_column_uses_std_one(self) -> None:
        """Constant column → var < 1e-12 → std = 1.0 (not 0)."""
        lr = _LogisticRegression()
        # All values in column 0 are identical → variance = 0
        x_mat = [[5.0, 1.0], [5.0, 2.0], [5.0, 3.0]]
        x_std, means, stds = lr._standardize(x_mat)
        assert stds[0] == 1.0  # constant column → fallback to 1.0
        assert stds[1] > 0.0  # non-constant column has real std


class TestCausalPDModelNormalize:
    """Lines 141-143: _normalize() returns x when _feature_means is empty."""

    def test_normalize_no_means_returns_identity(self, dag) -> None:
        """Lines 141-142: empty _feature_means → return x unchanged."""
        model = CausalPDModel(dag)
        x = [1.0, 2.0, 3.0]
        result = model._normalize(x)
        assert result == x

    def test_normalize_applies_transform_when_means_set(self, dag) -> None:
        """Line 143: when _feature_means set, applies z-score transform."""
        model = CausalPDModel(dag)
        model._feature_means = [2.0, 3.0]
        model._feature_stds = [1.0, 2.0]
        x = [0.0, 0.0]
        result = model._normalize(x)
        assert abs(result[0] - (-2.0)) < 1e-9  # (0 - 2) / 1
        assert abs(result[1] - (-1.5)) < 1e-9  # (0 - 3) / 2


class TestLogisticRegressionStandardizeEmpty:
    """Line 49: _standardize([]) returns empty without crashing."""

    def test_standardize_empty_matrix(self) -> None:
        """Line 49: n==0 in _standardize → return (x_mat, [], [])."""
        lr = _LogisticRegression()
        result, means, stds = lr._standardize([])
        assert result == []
        assert means == []
        assert stds == []


class TestCausalPDModelNotFittedErrors:
    """Lines 227, 294-296, 313: RuntimeError when methods called before fit."""

    def test_predict_pd_before_fit_raises(self, dag) -> None:
        """Lines 190-191: predict_pd raises RuntimeError if not fitted."""
        model = CausalPDModel(dag)
        with pytest.raises(RuntimeError, match="not fitted"):
            model.predict_pd({})

    def test_interventional_pd_before_fit_raises(self, dag) -> None:
        """Line 227: interventional_pd raises RuntimeError if not fitted."""
        model = CausalPDModel(dag)
        with pytest.raises(RuntimeError, match="not fitted"):
            model.interventional_pd({}, "credit_score", 700.0)

    def test_feature_importance_before_fit_raises(self, dag) -> None:
        """Lines 294-296: feature_importance raises RuntimeError if not fitted."""
        model = CausalPDModel(dag)
        with pytest.raises(RuntimeError, match="not fitted"):
            model.feature_importance()

    def test_coef_standard_errors_before_fit_raises(self, dag) -> None:
        """Line 313: coef_standard_errors raises RuntimeError if not fitted."""
        model = CausalPDModel(dag)
        with pytest.raises(RuntimeError, match="not fitted"):
            model.coef_standard_errors()


class TestCausalPDModelLRNoneFallback:
    """Lines 200-201, 241-242: fallback linear path when _lr is None post-fit."""

    def test_predict_pd_fallback_lr_none(self, fitted_model) -> None:
        """Lines 200-201: force _lr=None to exercise fallback dot-product path."""
        model, data = fitted_model
        if not model._feature_names:
            pytest.skip("No features")
        model._lr = None
        result = model.predict_pd(data[0])
        assert 0.0 <= result <= 1.0

    def test_interventional_pd_fallback_lr_none(self, fitted_model) -> None:
        """Lines 241-242: force _lr=None to exercise fallback in interventional path."""
        model, data = fitted_model
        if not model._feature_names:
            pytest.skip("No features")
        model._lr = None
        treatment = model._feature_names[0]
        result = model.interventional_pd(data[0], treatment, 0.0)
        assert 0.0 <= result <= 1.0


class TestCausalPDModelNoFeaturesPath:
    """Lines 195-196, 235: returns 0.01 base rate when no features selected."""

    def test_predict_pd_no_features_returns_base_rate(self, dag) -> None:
        """Lines 195-196: empty _feature_names → 0.01 in predict_pd."""
        model = CausalPDModel(dag)
        model._fitted = True
        model._feature_names = []
        result = model.predict_pd({"credit_score": 700.0})
        assert result == 0.01

    def test_interventional_pd_no_features_returns_base_rate(self, dag) -> None:
        """Line 235: empty _feature_names → 0.01 in interventional_pd."""
        model = CausalPDModel(dag)
        model._fitted = True
        model._feature_names = []
        result = model.interventional_pd({"credit_score": 700.0}, "credit_score", 500.0)
        assert result == 0.01


class TestCausalPDModelInterventionalPaths:
    """Lines 241-242, 258-261: adjustment_set and feature routing."""

    def test_interventional_pd_treatment_feature_in_adjustment_set(self, fitted_model) -> None:
        """Lines 241-242, 258-261: feature that is in adjustment_set uses observed value."""
        model, data = fitted_model
        if not model._feature_names:
            pytest.skip("No features — model has empty DAG")
        borrower = {k: v for k, v in data[0].items()}
        # Call with a treatment that is in the feature list
        treatment = model._feature_names[0]
        result = model.interventional_pd(borrower, treatment, 0.0)
        assert 0.0 <= result <= 1.0

    def test_interventional_pd_adjustment_set_path(self, dag) -> None:
        """Lines 258-261: when a feature IS in adjustment_set, uses borrower value."""
        model = CausalPDModel(dag)
        model._fitted = True
        model._feature_names = ["a", "b"]
        model._feature_means = [0.0, 0.0]
        model._feature_stds = [1.0, 1.0]
        # Inject a simple _lr with known predict_proba
        from orxaq.credit.causal_pd import _LogisticRegression as _LR

        lr = _LR()
        lr.coef_ = [0.5, 0.5]
        lr.intercept_ = 0.0
        lr.means_ = [0.0, 0.0]
        lr.stds_ = [1.0, 1.0]
        lr.coef_variance_ = [0.1, 0.1]
        model._lr = lr

        # Monkeypatch finder to return adjustment_set containing "b"
        from orxaq.intelligence.intervention import (
            AdjustmentSet,
            AdjustmentSetFinder,
            IdentificationMethod,
        )

        original_find_minimal = AdjustmentSetFinder.find_minimal

        def _mock_find_minimal(self_finder, treatment, outcome):  # noqa: ARG001
            return AdjustmentSet(
                variables=frozenset({"b"}),
                method=IdentificationMethod.BACKDOOR,
            )

        AdjustmentSetFinder.find_minimal = _mock_find_minimal
        try:
            result = model.interventional_pd({"a": 1.0, "b": 2.0}, "a", 0.5)
            assert 0.0 <= result <= 1.0
        finally:
            AdjustmentSetFinder.find_minimal = original_find_minimal


class TestGetPredictors:
    """Lines 325-326: _get_predictors returns ancestors of default_flag."""

    def test_get_predictors_returns_list(self, dag) -> None:
        """Lines 325-326: _get_predictors returns variable names."""
        model = CausalPDModel(dag)
        predictors = model._get_predictors()
        assert isinstance(predictors, list)
        assert "default_flag" not in predictors


class TestCoefStandardErrors:
    """Line 261: coef_standard_errors on fitted model returns result dict."""

    def test_coef_standard_errors_fitted(self, fitted_model) -> None:
        """Line 261: return {k: sqrt(v) ...} on fitted model."""
        model, _ = fitted_model
        se = model.coef_standard_errors()
        assert isinstance(se, dict)
        for feat, val in se.items():
            assert isinstance(val, float)
            assert val >= 0.0


class TestEstimateCausalEffect:
    """Lines 325-326, 331: stratified path in _estimate_causal_effect."""

    def test_estimate_causal_effect_stratified_path(self, dag) -> None:
        """Lines 325-326, 331: dti has non-empty adjustment set → stratified path."""
        import random

        rng = random.Random(42)
        # Generate data with dti and its adjustment variable cpi and default_flag
        data = [
            {
                "dti": rng.gauss(0.35, 0.08),
                "cpi": rng.gauss(300, 10),
                "default_flag": float(rng.random() < 0.05),
            }
            for _ in range(90)
        ]
        model = CausalPDModel(dag)
        # dti has adjustment set {'cpi'} in the credit DAG
        effect = model._estimate_causal_effect("dti", data)
        # The stratified path should return a float (may be 0.0 if all strata degenerate)
        assert isinstance(effect, float)

    def test_estimate_causal_effect_no_adjustment_set(self, dag) -> None:
        """No adjustment set branch: uses direct covariance formula."""
        import random

        rng = random.Random(1)
        data = [
            {
                "unemployment_rate": rng.gauss(0.05, 0.02),
                "default_flag": float(rng.random() < 0.05),
            }
            for _ in range(50)
        ]
        model = CausalPDModel(dag)
        # unemployment_rate has no adjustment set vs default_flag
        effect = model._estimate_causal_effect("unemployment_rate", data)
        assert isinstance(effect, float)

    def test_estimate_causal_effect_returns_sum_of_effects(self, dag) -> None:
        """Line 331: return sum(effects)/len(effects) when effects is non-empty."""
        import random

        rng = random.Random(7)
        # Large enough dataset so strata are non-degenerate
        data = [
            {
                "ltv": rng.gauss(0.8, 0.1),
                "hpi": rng.gauss(300, 20),  # hpi is adjustment set for ltv
                "default_flag": float(rng.random() < 0.08),
            }
            for _ in range(120)
        ]
        model = CausalPDModel(dag)
        # ltv has adjustment set {'hpi'} — stratified path
        effect = model._estimate_causal_effect("ltv", data)
        assert isinstance(effect, float)

    def test_estimate_causal_effect_small_stratum_skipped(self, dag) -> None:
        """Line 313: continue when len(stratum_idx) < 2 (stratum has 0-1 points)."""
        # With only 3 rows, tertile splits produce strata with 1 row each
        # stratum_idx will have 1 point → len < 2 → continue (line 313)
        data = [
            {"dti": 0.3, "cpi": 290.0, "default_flag": 0.0},
            {"dti": 0.4, "cpi": 300.0, "default_flag": 1.0},
            {"dti": 0.5, "cpi": 310.0, "default_flag": 0.0},
        ]
        model = CausalPDModel(dag)
        # dti has adjustment set {'cpi'} → stratified path, each stratum has 1 row → skip
        effect = model._estimate_causal_effect("dti", data)
        # All strata skipped → effects is empty → returns 0.0
        assert effect == 0.0

    def test_estimate_causal_effect_constant_var_in_stratum_skipped(self, dag) -> None:
        """s_var_var < 1e-10: continue when treatment is constant within a stratum."""
        # Low and high strata have varying dti; middle stratum has constant dti
        low_stratum = [
            {"dti": 0.2 + 0.01 * i, "cpi": 280.0, "default_flag": 0.0} for i in range(30)
        ]
        mid_stratum = [{"dti": 0.35, "cpi": 300.0, "default_flag": 0.0} for _ in range(30)]
        high_stratum = [
            {"dti": 0.4 + 0.01 * i, "cpi": 320.0, "default_flag": float(i % 5 == 0)}
            for i in range(30)
        ]
        data = low_stratum + mid_stratum + high_stratum
        model = CausalPDModel(dag)
        # dti has adjustment set {'cpi'} — middle stratum dti constant → skip
        effect = model._estimate_causal_effect("dti", data)
        assert isinstance(effect, float)
