"""Integration tests: full predict → weights → metrics pipeline on synthetic data.

Uses 50 stocks, 24 months, 10 features to verify the end-to-end flow:
  ctf_rank_normalize() → ctf_expanding_window_oos() → to_ctf_weights() → compute_metrics()
No real CTF data required.
"""

from __future__ import annotations

import numpy as np
import pandas as pd
import pytest


# ---------------------------------------------------------------------------
# Synthetic data builder
# ---------------------------------------------------------------------------


def _make_full_ctf_dataset(
    n_stocks: int = 50,
    n_months: int = 24,
    n_features: int = 10,
    n_test_months: int = 8,
    seed: int = 42,
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
    """Build synthetic (chars, features, daily_ret) for integration testing."""
    rng = np.random.default_rng(seed)
    dates = pd.date_range("2010-01-31", periods=n_months, freq="ME")
    ids = list(range(1001, 1001 + n_stocks))
    feat_names = [f"char_{i:02d}" for i in range(n_features)]
    test_cutoff = dates[-n_test_months]

    # chars: id, eom, ret_exc_lead1m, ctff_test, + feature cols
    rows = []
    for d in dates:
        for sid in ids:
            row: dict = {
                "id": sid,
                "eom": d.date().isoformat(),
                "ret_exc_lead1m": float(rng.normal(0.004, 0.015)),
                "ctff_test": bool(d >= test_cutoff),
            }
            for f in feat_names:
                # Introduce ~10% missing values
                val = float(rng.normal(0, 1)) if rng.random() > 0.1 else float("nan")
                row[f] = val
            rows.append(row)

    chars = pd.DataFrame(rows)
    features_df = pd.DataFrame({"features": feat_names})

    # daily_ret: id, date, ret_exc  (needed for compute_metrics)
    # map each month's weights to the next month's daily returns
    daily_rows = []
    for d in pd.date_range("2010-02-01", periods=n_months * 21, freq="B"):
        for sid in ids:
            daily_rows.append({
                "id": sid,
                "date": d,
                "ret_exc": float(rng.normal(0, 0.01)),
            })
    daily_ret = pd.DataFrame(daily_rows)

    return chars, features_df, daily_ret


# ---------------------------------------------------------------------------
# Integration tests
# ---------------------------------------------------------------------------


class TestFullPipeline:
    """Test the full predict → weights → metrics pipeline."""

    @pytest.fixture(scope="class")
    def pipeline_output(
        self,
    ) -> dict:
        """Run the full pipeline once and cache results for all tests."""
        from eapctf.ctf.predict import ctf_expanding_window_oos
        from eapctf.ctf.metrics import compute_metrics
        from eapctf.predict import make_predictor

        chars, features, daily_ret = _make_full_ctf_dataset()

        oos = ctf_expanding_window_oos(
            chars, features,
            models=[make_predictor("ridge")],
            window_length=12,
            rank_normalize=True,
            feature_subset="all",
            test_only=True,
        )

        pred_cols = [c for c in oos.predictions.columns if c.startswith("pred_")]
        signal_col = pred_cols[0] if pred_cols else None

        weights = (
            oos.to_ctf_weights(signal_col=signal_col, mode="zscore")
            if signal_col and not oos.predictions.empty
            else pd.DataFrame(columns=["id", "eom", "w"])
        )

        metrics = (
            compute_metrics(weights, daily_ret, vol_target=0.10)
            if not weights.empty
            else None
        )

        return {
            "oos": oos,
            "weights": weights,
            "metrics": metrics,
            "signal_col": signal_col,
        }

    def test_oos_result_not_none(self, pipeline_output: dict) -> None:
        assert pipeline_output["oos"] is not None

    def test_weights_schema(self, pipeline_output: dict) -> None:
        """Weights DataFrame has required CTF columns."""
        weights = pipeline_output["weights"]
        if weights.empty:
            pytest.skip("No weights produced with this data size")
        assert set(weights.columns) == {"id", "eom", "w"}

    def test_weights_no_duplicates(self, pipeline_output: dict) -> None:
        """No duplicate (id, eom) pairs in weights."""
        weights = pipeline_output["weights"]
        if weights.empty:
            pytest.skip("No weights produced with this data size")
        assert not weights[["id", "eom"]].duplicated().any()

    def test_weights_no_nan(self, pipeline_output: dict) -> None:
        """Weights contain no NaN values."""
        weights = pipeline_output["weights"]
        if weights.empty:
            pytest.skip("No weights produced with this data size")
        assert not weights.isnull().any().any()

    def test_metrics_result(self, pipeline_output: dict) -> None:
        """compute_metrics returns a MetricsResult with expected attributes."""
        metrics = pipeline_output["metrics"]
        if metrics is None:
            pytest.skip("No weights produced with this data size")
        assert hasattr(metrics, "sharpe")
        assert hasattr(metrics, "annual_return")
        assert hasattr(metrics, "volatility")
        assert hasattr(metrics, "max_drawdown")

    def test_metrics_vol_targeting(self, pipeline_output: dict) -> None:
        """With vol_target=0.10, annualized volatility should be ~10%."""
        metrics = pipeline_output["metrics"]
        if metrics is None:
            pytest.skip("No weights produced with this data size")
        # Allow generous tolerance given small synthetic dataset
        assert abs(metrics.volatility - 0.10) < 0.05


class TestPipelineWithPrep:
    """Test ctf_rank_normalize → ctf_select_features → ctf_expanding_window_oos."""

    def test_manual_prep_then_predict(self) -> None:
        """Using ctf_rank_normalize and ctf_select_features manually, then predict."""
        from eapctf.ctf.prep import ctf_rank_normalize, ctf_select_features
        from eapctf.ctf.predict import ctf_expanding_window_oos
        from eapctf.predict import make_predictor

        chars, features, _ = _make_full_ctf_dataset(n_stocks=30, n_months=20)

        # Pre-normalize manually
        chars_norm = ctf_rank_normalize(chars, features)
        # Verify normalization range
        feat_names = features["features"].tolist()
        available = [f for f in feat_names if f in chars_norm.columns]
        vals = chars_norm[available].values
        non_nan = vals[~np.isnan(vals)]
        assert non_nan.max() <= 0.51  # allow tiny float error above 0.5
        assert non_nan.min() >= -0.51

        # Select features
        chars_sel, selected = ctf_select_features(chars_norm, features, subset="all")
        assert len(selected) > 0

        # Predict with rank_normalize=False since we already normalized
        oos = ctf_expanding_window_oos(
            chars_sel, features,
            models=[make_predictor("ridge")],
            window_length=12,
            rank_normalize=False,
            feature_subset="all",
            test_only=True,
        )
        from eapctf.predict.oos import OOSResult
        assert isinstance(oos, OOSResult)

    def test_zscore_weights_sum_to_zero(self) -> None:
        """z-score weights for each eom should sum close to zero.

        Uses the Ridge predictor signal (varies across stocks) rather than
        pred_HistMean (constant per date), which is a degenerate input for
        the z-score weighting scheme.
        """
        from eapctf.ctf.predict import ctf_expanding_window_oos
        from eapctf.predict import make_predictor

        chars, features, _ = _make_full_ctf_dataset(n_stocks=40, n_months=20)
        oos = ctf_expanding_window_oos(
            chars, features,
            models=[make_predictor("ridge")],
            window_length=12,
            test_only=True,
        )
        if oos.predictions.empty:
            pytest.skip("No predictions generated")

        # Use the model-specific predictor column (varies cross-sectionally),
        # not pred_HistMean (constant per date and a degenerate z-score input).
        model_pred_cols = [
            c for c in oos.predictions.columns
            if c.startswith("pred_") and c != "pred_HistMean"
        ]
        if not model_pred_cols:
            pytest.skip("No model pred_ columns found")

        weights = oos.to_ctf_weights(signal_col=model_pred_cols[0], mode="zscore")
        if weights.empty:
            pytest.skip("No weights produced")

        for eom_val, grp in weights.groupby("eom"):
            w_sum = grp["w"].sum()
            assert abs(w_sum) < 1e-8, (
                f"Weights at {eom_val} sum to {w_sum:.2e}, expected ~0"
            )
