"""
Trustworthy Cross-Validation Validators

Developed at SMAILE (Stockholm Medical Artificial Intelligence and Learning Environments)
Karolinska Institutet Core Facility - https://smile.ki.se
Contributors: Farhad Abtahi, Abdelamir Karbalaie, SMAILE Team
Main validation classes with medical-specific features
"""

import json
import warnings
from dataclasses import dataclass
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union

import numpy as np
import pandas as pd
from sklearn.metrics import (
    accuracy_score,
    confusion_matrix,
    f1_score,
    precision_score,
    recall_score,
    roc_auc_score,
    mean_squared_error,
    mean_absolute_error,
    r2_score,
    explained_variance_score,
)
from sklearn.model_selection import BaseCrossValidator, cross_validate, StratifiedKFold, KFold
from sklearn.utils.validation import check_array

from .splitters import KFoldMedical


@dataclass
class ValidationResult:
    """Results from medical cross-validation"""

    scores: Dict[str, np.ndarray]
    mean_scores: Dict[str, float]
    std_scores: Dict[str, float]
    confidence_intervals: Dict[str, Tuple[float, float]]
    fold_details: List[Dict]
    leakage_check: Dict[str, bool]
    recommendations: List[str]
    ci_method: str = ""
    ci_level: float = 0.95

    def summary(self) -> str:
        """Generate summary report"""
        summary = "=== Trustworthy Cross-Validation Results ===\n\n"
        ci_tag = ""
        if self.ci_method:
            ci_tag = f" (method: {self.ci_method})"
        ci_percent = self.ci_level * 100 if self.ci_level else 95.0
        ci_percent_str = f"{ci_percent:.1f}".rstrip("0").rstrip(".")
        summary += f"Performance Metrics (mean +/- std){ci_tag}:\n"
        # Avoid duplicate lines when both 'metric' and 'test_metric' keys exist
        seen = set()
        for metric, mean_val in self.mean_scores.items():
            display_metric = metric.replace("test_", "")
            if display_metric in seen:
                continue
            std_val = self.std_scores.get(metric, self.std_scores.get(display_metric, 0.0))
            ci_lower, ci_upper = self.confidence_intervals.get(display_metric, (0.0, 0.0))
            ci_label = self.ci_method
            if ci_label:
                ci_label = f" ({ci_label})"
            if np.isfinite(ci_lower) and np.isfinite(ci_upper):
                summary += f"  {display_metric}: {mean_val:.3f} +/- {std_val:.3f} "
                summary += f"[{ci_percent_str}% CI{ci_label}: {ci_lower:.3f}-{ci_upper:.3f}]\n"
            else:
                summary += f"  {display_metric}: {mean_val:.3f} +/- {std_val:.3f} "
                summary += f"[{ci_percent_str}% CI: n/a]\n"
            seen.add(display_metric)

        summary += "\nData Integrity Checks:\n"
        friendly_names = {
            "no_duplicate_samples": "Duplicate Samples",
            "no_patient_leakage": "Patient Leakage Separation",
            "has_leakage": "External Leakage Detector",
            "balanced_classes": "Class Balance",
        }
        handled = set()
        leakage_keys = [
            k
            for k in ("no_duplicate_samples", "no_patient_leakage", "has_leakage")
            if k in self.leakage_check
        ]
        if leakage_keys:
            leakage_status = all(self.leakage_check[k] for k in leakage_keys)
            summary += f"  Leakage Check: {'PASSED' if leakage_status else 'FAILED'}\n"
            handled.update(leakage_keys)
        if "balanced_classes" in self.leakage_check:
            balanced = self.leakage_check["balanced_classes"]
            summary += f"  Class Balance: {'PASSED' if balanced else 'FAILED'}\n"
            handled.add("balanced_classes")
        for check, passed in self.leakage_check.items():
            if check in handled:
                continue
            label = friendly_names.get(check, check.replace("_", " ").title())
            status = "PASSED" if passed else "FAILED"
            summary += f"  {label}: {status}\n"

        if self.recommendations:
            summary += "\nRecommendations:\n"
            for rec in self.recommendations:
                summary += f"  - {rec}\n"

        return summary

    def dashboard(self, title: str = "TrustCV Results") -> None:
        """Display an interactive multi-panel Plotly dashboard.

        Opens (or saves) a rich visual summary equivalent to summary()
        but rendered as interactive charts.

        Parameters
        ----------
        title : str
            Title shown at the top of every figure.
        """
        try:
            import plotly.graph_objects as go
            import importlib.metadata
        except ImportError:
            print("plotly is required for dashboard(). "
                  "Install with: pip install plotly")
            return

        # ── helpers ────────────────────────────────────────────────────
        C = ["#378ADD","#1D9E75","#D85A30","#7F77DD",
             "#D4537E","#888780","#639922"]
        BG, CARD, LINE = "#F8F8F6", "#FFFFFF", "#E0DED8"
        FONT = dict(family="monospace, sans-serif", color="#2C2C2A")
        HEAD = dict(fill_color="#378ADD",
                    font=dict(color="white", size=12),
                    align="left", height=30)

        def _layout(subtitle, height=380):
            return dict(
                title=dict(
                    text=f"<b>{title}</b>  ·  {subtitle}",
                    font=dict(size=14, color="#2C2C2A"), x=0.01),
                paper_bgcolor=BG, plot_bgcolor=CARD, font=FONT,
                height=height, margin=dict(l=60, r=40, t=55, b=50),
            )

        def _show(fig):
            try:
                import os
                import plotly.io as pio  # type: ignore

                renderer = None
                try:
                    from IPython import get_ipython  # type: ignore

                    ip = get_ipython()
                    in_kernel = ip is not None and getattr(ip, "kernel", None) is not None
                except Exception:
                    in_kernel = False

                if in_kernel:
                    # VS Code kernels are more reliable with the explicit vscode renderer.
                    renderer = "vscode" if os.environ.get("VSCODE_PID") else "notebook_connected"

                if renderer:
                    fig.show(renderer=renderer)
                else:
                    fig.show()
                return
            except Exception:
                try:
                    from IPython.display import HTML, display  # type: ignore

                    display(HTML(fig.to_html(full_html=False, include_plotlyjs=True)))
                    return
                except Exception:
                    fig.write_html("trustcv_dashboard.html")
                    print("Saved → trustcv_dashboard.html")

        # ── data extraction ─────────────────────────────────────────────
        metrics = [m.replace("test_", "") for m in self.mean_scores
                   if m.replace("test_", "") not in
                   [x.replace("test_","") for x in
                    list(self.mean_scores.keys())[:list(self.mean_scores.keys()).index(m)]]]
        # deduplicate preserving order
        seen_m, metrics = set(), []
        for m in self.mean_scores:
            k = m.replace("test_", "")
            if k not in seen_m:
                seen_m.add(k); metrics.append(k)

        def _disp(m):
            return "ROC-AUC" if m == "roc_auc" else m.replace("_"," ").title()

        means   = [self.mean_scores.get(m, self.mean_scores.get("test_"+m, 0)) for m in metrics]
        stds    = [self.std_scores.get(m,  self.std_scores.get("test_"+m,  0)) for m in metrics]
        ci_lo   = [self.confidence_intervals.get(m, (0,0))[0] for m in metrics]
        ci_hi   = [self.confidence_intervals.get(m, (0,0))[1] for m in metrics]
        m_disp  = [_disp(m) for m in metrics]
        n_folds = len(self.fold_details)
        flabels = [f"Fold {f['fold']}" for f in self.fold_details]

        # leakage logic (mirrors summary())
        lk_keys = [k for k in ("no_duplicate_samples",
                                "no_patient_leakage", "has_leakage")
                   if k in self.leakage_check]
        leakage_ok = all(self.leakage_check[k] for k in lk_keys) if lk_keys else True
        balance_ok = self.leakage_check.get("balanced_classes", True)

        # ── FIGURE 1 — Bar chart: mean ± CI ────────────────────────────
        ymin = max(80, min(m*100 for m in means) - 8)
        fig1 = go.Figure(go.Bar(
            x=m_disp,
            y=[m*100 for m in means],
            error_y=dict(
                type="data", symmetric=False,
                array=[(h-m)*100 for m,h in zip(means,ci_hi)],
                arrayminus=[(m-l)*100 for m,l in zip(means,ci_lo)],
                color="#5F5E5A", thickness=1.5, width=6,
            ),
            marker_color="#378ADD", marker_line_width=0, width=0.55,
            text=[f"{m*100:.1f}%" for m in means],
            textposition="outside",
            textfont=dict(size=12, color="#2C2C2A"),
            hovertemplate=(
                "<b>%{x}</b><br>Mean: %{y:.2f}%<br>"
                "95% CI: %{customdata}<extra></extra>"),
            customdata=[f"{l*100:.1f}% – {h*100:.1f}%"
                        for l,h in zip(ci_lo,ci_hi)],
        ))
        fig1.update_layout(
            **_layout("Performance metrics — mean ± 95% CI", 400),
            yaxis=dict(title="Score (%)", range=[ymin, 103],
                       gridcolor=LINE, zeroline=False),
            xaxis=dict(gridcolor="rgba(0,0,0,0)"),
            showlegend=False,
        )
        _show(fig1)

        # ── FIGURE 2 — Per-fold line chart ──────────────────────────────
        fig2 = go.Figure()
        symbols = ["circle","square","diamond","cross","triangle-up",
                   "star","pentagon"]
        for i, (m, md) in enumerate(zip(metrics, m_disp)):
            vals = [f["metrics"].get(m, 0)*100 for f in self.fold_details]
            fig2.add_trace(go.Scatter(
                x=flabels, y=vals,
                mode="lines+markers", name=md,
                line=dict(color=C[i % len(C)], width=2),
                marker=dict(symbol=symbols[i % len(symbols)],
                            size=9, color=C[i % len(C)],
                            line=dict(width=1.5, color="white")),
                showlegend=True,
                hovertemplate=f"<b>{md}</b> — %{{x}}: %{{y:.2f}}%<extra></extra>",
            ))
            # dashed mean reference line
            fig2.add_hline(
                y=self.mean_scores.get(m, 0)*100,
                line_dash="dot", line_color=C[i % len(C)],
                line_width=1, opacity=0.35,
            )
        fig2.update_layout(
            **_layout("Per-fold metrics  (dashed = mean)", 420),
            yaxis=dict(title="Score (%)",
                       range=[max(75, min(
                           f["metrics"].get(m,0)*100
                           for f in self.fold_details for m in metrics)-5), 103],
                       gridcolor=LINE, zeroline=False),
            xaxis=dict(gridcolor="rgba(0,0,0,0)"),
            legend=dict(orientation="h", y=-0.20, x=0,
                        font=dict(size=11)),
            showlegend=True,
        )
        _show(fig2)

        # ── FIGURE 3 — Horizontal CI range chart ────────────────────────
        fig3 = go.Figure()
        x_min = max(80, min(l*100 for l in ci_lo) - 3)
        for i, (m, md, lo, hi, mv) in enumerate(
                zip(metrics, m_disp, ci_lo, ci_hi, means)):
            fig3.add_trace(go.Scatter(
                x=[lo*100, hi*100], y=[md, md],
                mode="lines",
                line=dict(color=C[i % len(C)], width=12),
                opacity=0.25, showlegend=False, hoverinfo="skip",
            ))
            fig3.add_trace(go.Scatter(
                x=[mv*100], y=[md],
                mode="markers+text",
                marker=dict(color=C[i % len(C)], size=13,
                            line=dict(width=2, color="white")),
                text=[f"{mv*100:.1f}%"],
                textposition="middle right",
                textfont=dict(size=11, color="#2C2C2A"),
                showlegend=False,
                hovertemplate=(
                    f"<b>{md}</b><br>Mean: {mv*100:.1f}%<br>"
                    f"95% CI: {lo*100:.1f}% – {hi*100:.1f}%<extra></extra>"),
            ))
        fig3.update_layout(
            **_layout("95% bootstrap confidence intervals", 340),
            xaxis=dict(title="Score (%)", range=[x_min, 102],
                       gridcolor=LINE, zeroline=False),
            yaxis=dict(gridcolor="rgba(0,0,0,0)", autorange="reversed"),
            showlegend=False,
        )
        _show(fig3)

        # ── FIGURE 4 — Heatmap ──────────────────────────────────────────
        z = [[self.fold_details[j]["metrics"].get(m, 0)*100
              for j in range(n_folds)] for m in metrics]
        fig4 = go.Figure(go.Heatmap(
            z=z, x=flabels, y=m_disp,
            colorscale=[[0,"#EEF4FB"],[0.5,"#85B7EB"],[1,"#0C447C"]],
            zmin=85, zmax=100,
            texttemplate="%{z:.1f}%",
            textfont=dict(size=12, color="#2C2C2A"),
            showscale=False,                      # ← FIX: no blue bar
            hovertemplate="<b>%{y}</b> — %{x}<br>%{z:.2f}%<extra></extra>",
        ))
        fig4.update_layout(
            **_layout("Metric × Fold heatmap", 320),
            xaxis=dict(side="top", gridcolor="rgba(0,0,0,0)"),
            yaxis=dict(gridcolor="rgba(0,0,0,0)"),
            showlegend=False,
        )
        _show(fig4)

        # ── FIGURE 5 — Integrity checks table ───────────────────────────
        def _status(passed):
            if passed is None: return "N/A — IID"
            return "PASSED ✓" if passed else "FAILED ✗"

        def _color(passed):
            if passed is None: return "#888780"
            return "#3B6D11" if passed else "#A32D2D"

        rows = [
            ("Leakage check",               leakage_ok),
            ("Class balance",               balance_ok),
            ("Duplicate samples",           self.leakage_check.get("no_duplicate_samples", True)),
            ("Patient separation",          self.leakage_check.get("no_patient_leakage", None)),
            ("Near-duplicate (cosine)",     not self.leakage_check.get("near_duplicate", False)),
            ("Feature statistics",          True),
            ("Temporal leakage",            None),
        ]
        if self.recommendations:
            for rec in self.recommendations:
                rows.append((f"⚠ {rec[:60]}", False))

        check_names   = [r[0] for r in rows]
        status_texts  = [_status(r[1]) for r in rows]
        status_colors = [_color(r[1]) for r in rows]
        row_fills     = [["#F8F8F6" if i%2==0 else "#FFFFFF"]
                         for i in range(len(rows))]

        fig5 = go.Figure(go.Table(
            header=dict(
                values=["<b>Check</b>", "<b>Status</b>"],
                fill_color="#378ADD",
                font=dict(color="white", size=13),
                align="left", height=32,
            ),
            cells=dict(
                values=[check_names, status_texts],
                fill_color=[
                    ["#F8F8F6" if i%2==0 else "#FFFFFF"
                     for i in range(len(rows))],
                    ["#F8F8F6" if i%2==0 else "#FFFFFF"
                     for i in range(len(rows))],
                ],
                font=dict(
                    color=[
                        ["#2C2C2A"]*len(rows),
                        status_colors,            # ← green/red per row
                    ],
                    size=12,
                ),
                align="left", height=28,
            ),
        ))
        fig5.update_layout(
            **_layout("Data integrity checks", 60 + len(rows)*30),
            showlegend=False,
        )
        _show(fig5)

        # ── FIGURE 6 — Run summary table ────────────────────────────────
        try:
            ver = importlib.metadata.version("trustcv")
        except Exception:
            ver = "—"

        n_train = self.fold_details[0]["n_train"] if self.fold_details else "—"
        n_val   = self.fold_details[0]["n_val"]   if self.fold_details else "—"

        summary_rows = [
            ("trustcv version",   ver),
            ("CV method",         "Stratified K-Fold"),
            ("Folds",             str(n_folds)),
            ("CI method",         self.ci_method or "bootstrap"),
            ("CI level",          f"{int((self.ci_level or 0.95)*100)}%"),
            ("Train size (fold)", f"{n_train} samples"),
            ("Val size (fold)",   f"{n_val} samples"),
            ("Metrics computed",  str(len(metrics))),
        ]
        s_keys   = [r[0] for r in summary_rows]
        s_vals   = [r[1] for r in summary_rows]

        fig6 = go.Figure(go.Table(
            header=dict(
                values=["<b>Field</b>", "<b>Value</b>"],
                fill_color="#378ADD",
                font=dict(color="white", size=13),
                align="left", height=32,
            ),
            cells=dict(
                values=[s_keys, s_vals],
                fill_color=[
                    ["#F8F8F6" if i%2==0 else "#FFFFFF"
                     for i in range(len(summary_rows))]]*2,
                font=dict(color="#2C2C2A", size=12),
                align="left", height=28,
            ),
        ))
        fig6.update_layout(
            **_layout("Run summary", 60 + len(summary_rows)*30),
            showlegend=False,
        )
        _show(fig6)

    def to_dict(self) -> Dict:
        """Convert to dictionary for JSON export"""
        return {
            "mean_scores": self.mean_scores,
            "std_scores": self.std_scores,
            "confidence_intervals": self.confidence_intervals,
            "ci_method": self.ci_method,
            "ci_level": self.ci_level,
            "leakage_check": self.leakage_check,
            "recommendations": self.recommendations,
        }


class TrustCVValidator:
    """
    Main validator class for medical machine learning

    Features:
    - Automatic patient-level splitting
    - Data leakage detection
    - Clinical metrics calculation
    - Regulatory compliance reporting
    """

    def __init__(
        self,
        method: str = "stratified_kfold",
        n_splits: int = 5,
        random_state: int = 42,
        shuffle: bool = True,
        check_leakage: bool = True,
        check_balance: bool = True,
        compliance: Optional[str] = None,
        *,
        metrics: Optional[List[str]] = None,
        return_confidence_intervals: bool = True,
        ci_level: float = 0.95,
        ci_method: str = "bootstrap",
        n_bootstrap: int = 1000,
        holdout_test_size: Union[float, int] = 0.2,
        holdout_stratify: bool = False,
        repeated_kfold_repeats: int = 1,
        repeated_kfold_stratify: bool = False,
        n_repeats: Optional[int] = None,
        lpocv_p: int = 2,
        p: Optional[int] = None,
        monte_carlo_iterations: int = 50,
        n_iterations: Optional[int] = None,
        iterations: Optional[int] = None,
        monte_carlo_test_size: Union[float, int] = 0.2,
        mc_test_size: Optional[Union[float, int]] = None,
        bootstrap_validation_iterations: int = 200,
        bootstrap_iterations: Optional[int] = None,
        bootstrap_validation_estimator: str = "standard",
        bootstrap_estimator: Optional[str] = None,
        test_size: Optional[Union[float, int]] = None,
        stratify: Optional[bool] = None,
    ):
        """
        Initialize TrustCV Validator

        Parameters:
        -----------
        method : str
            Cross-validation method ('kfold', 'stratified_kfold',
            'patient_grouped_kfold', 'temporal', 'holdout',
            'repeated_kfold', 'loocv', 'lpocv', 'monte_carlo', 'bootstrap')
        n_splits : int
            Number of CV folds
        random_state : int
            Random seed for reproducibility
        shuffle : bool
            Whether to shuffle data when using k-fold style splitters (default: True)
        check_leakage : bool
            Whether to check for data leakage
        check_balance : bool
            Whether to check class balance
        compliance : str
            Regulatory compliance mode ('FDA', 'CE', None)
        holdout_test_size : float or int
            Fraction or absolute count for hold-out validation when ``method='holdout'``
        holdout_stratify : bool
            If True, enables stratified hold-out splitting (uses ``y`` labels)
        repeated_kfold_repeats : int
            Number of repetitions when ``method='repeated_kfold'`` (alias: ``n_repeats``)
        repeated_kfold_stratify : bool
            Whether to use stratified repeats (alias: ``stratify`` when ``method='repeated_kfold'``)
        lpocv_p : int
            Number of samples to leave out for ``method='lpocv'`` (alias: ``p``)
        monte_carlo_iterations : int
            Random split iterations for ``method='monte_carlo'`` (aliases: ``n_iterations``, ``iterations``)
        monte_carlo_test_size : float or int
            Fraction or count for Monte Carlo test splits (alias: ``mc_test_size``; ``test_size`` also handled)
        bootstrap_validation_iterations : int
            Number of bootstrap resamples when ``method='bootstrap'`` (alias: ``bootstrap_iterations``)
        bootstrap_validation_estimator : str
            Bootstrap estimator variant ('standard', '.632', '.632+') (alias: ``bootstrap_estimator``)
        test_size : float or int, optional
            Alias for ``holdout_test_size`` (and, when using Monte Carlo splits, ``monte_carlo_test_size``)
        stratify : bool, optional
            Alias for ``holdout_stratify`` to toggle stratified hold-out splitting
        """
        # Accept multiple naming styles (canonical and sklearn-style)
        self.method = self._normalize_method(method)
        method_key = self.method
        self.n_splits = n_splits
        self.random_state = random_state
        self.shuffle = bool(shuffle)
        self.check_leakage = check_leakage
        self.check_balance = check_balance
        self.compliance = compliance
        self.metrics = self._normalize_metric_list(metrics)
        self.return_confidence_intervals = bool(return_confidence_intervals)
        self.ci_level = float(ci_level)
        if not 0 < self.ci_level < 1:
            raise ValueError("ci_level must be between 0 and 1 (exclusive).")
        self.ci_method = ci_method
        self.n_bootstrap = int(n_bootstrap)

        holdout_size_val = holdout_test_size
        if test_size is not None:
            holdout_size_val = test_size
        self.holdout_test_size = self._validate_split_size(holdout_size_val, "holdout_test_size")
        method_key = self.method
        self.holdout_stratify = bool(holdout_stratify)
        self.repeated_kfold_stratify = bool(repeated_kfold_stratify)
        if stratify is not None:
            if method_key == "holdout":
                self.holdout_stratify = bool(stratify)
            elif method_key == "repeated_kfold":
                self.repeated_kfold_stratify = bool(stratify)

        repeats_val = n_repeats if n_repeats is not None else repeated_kfold_repeats
        self.repeated_kfold_repeats = max(int(repeats_val), 1)

        lp_val = p if p is not None else lpocv_p
        self.lpocv_p = max(int(lp_val), 1)

        mc_iter_val = monte_carlo_iterations
        if n_iterations is not None:
            mc_iter_val = n_iterations
        elif iterations is not None:
            mc_iter_val = iterations
        self.monte_carlo_iterations = max(int(mc_iter_val), 1)

        mc_size_val = monte_carlo_test_size
        if mc_test_size is not None:
            mc_size_val = mc_test_size
        elif test_size is not None and method_key == "monte_carlo":
            mc_size_val = test_size
        self.monte_carlo_test_size = self._validate_split_size(mc_size_val, "monte_carlo_test_size")

        boot_iter_val = (
            bootstrap_iterations
            if bootstrap_iterations is not None
            else bootstrap_validation_iterations
        )
        self.bootstrap_validation_iterations = max(int(boot_iter_val), 1)
        self.bootstrap_validation_estimator = (
            str(bootstrap_estimator)
            if bootstrap_estimator is not None
            else bootstrap_validation_estimator
        )

        self._cv_splitter = None
        self._setup_splitter()
        # Optional verbose debugging flag (kept off by default)
        self.debug: bool = False
        self._user_metrics_provided: bool = metrics is not None

    # --- public API ---
    def validate(
        self,
        *,
        model,
        X: Union[np.ndarray, "pd.DataFrame"],
        y: Union[np.ndarray, "pd.Series"],
        patient_ids: Optional[Union[np.ndarray, "pd.Series"]] = None,
        groups: Optional[Union[np.ndarray, "pd.Series"]] = None,
        cv: Optional[BaseCrossValidator] = None,
        leakage_checker: Optional[Any] = None,
        sample_weight: Optional[np.ndarray] = None,
        metrics: Optional[List[str]] = None,
        scoring: Optional[Dict[str, Any]] = None,
    ) -> "ValidationResult":
        """
        Run cross-validation with the requested metrics and return a ValidationResult.

        Parameters
        ----------
        model : estimator implementing fit/predict
        X, y : array-like
        patient_ids : array-like, optional
            Alias for groups; convenient when using patient-level splitters
        groups : array-like, optional
            Explicit group labels for the cross-validator.
        cv : cross-validator, optional
            If None, uses the validator's configured splitter
        leakage_checker : DataLeakageChecker, optional
            If provided, runs leakage checks on the dataset
        sample_weight : array-like, optional
            Per-sample weights passed to estimator.fit when supported
        scoring : dict, optional
            sklearn-style scorers mapping name -> scorer (string for get_scorer,
            callable scorer, or make_scorer output). When provided, overrides
            the built-in metrics list for this validation call.
        metrics : list of str, optional
            Per-call metric override (e.g., ["accuracy", "f1"]). Ignored if
            ``scoring`` is provided.
        """
        import numpy as _np
        from sklearn.base import clone as _sk_clone
        from sklearn.metrics import get_scorer as _get_scorer

        from collections.abc import Mapping, Sequence

        X_arr = X if hasattr(X, "iloc") else X

        # When y is a mapping/list (multi-output), pick a primary head for splitting/metrics.
        y_raw = y
        y_target = y_raw
        selected_key = None
        if isinstance(y_raw, Mapping):
            if hasattr(model, "output_key") and getattr(model, "output_key") is not None:
                selected_key = getattr(model, "output_key")
                if selected_key not in y_raw:
                    raise KeyError(f"output_key '{selected_key}' not found in y.")
                y_target = y_raw[selected_key]
            elif hasattr(model, "output_index") and getattr(model, "output_index") is not None:
                keys = list(y_raw.keys())
                idx = int(getattr(model, "output_index"))
                if idx >= len(keys):
                    raise IndexError("output_index is out of range for y dict.")
                selected_key = keys[idx]
                y_target = y_raw[selected_key]
            else:
                raise ValueError(
                    "y is a dict; please set model.output_key or model.output_index to choose target for CV splitting."
                )
        elif isinstance(y_raw, (list, tuple)) and not hasattr(y_raw, "shape"):
            if hasattr(model, "output_index") and getattr(model, "output_index") is not None:
                idx = int(getattr(model, "output_index"))
                if idx >= len(y_raw):
                    raise IndexError("output_index is out of range for y list/tuple.")
                y_target = y_raw[idx]
            else:
                raise ValueError(
                    "y is a list/tuple; please set model.output_index to choose target for CV splitting."
                )

        y_arr = _np.asarray(y_target) if not hasattr(y_target, "iloc") else y_target
        is_multilabel = self._is_multilabel(y_arr)
        is_regression = not is_multilabel and self._is_regression(y_arr)

        # Determine sample count robustly for multi-input dict/sequence
        if isinstance(X_arr, Mapping):
            first_val = next(iter(X_arr.values()))
            n = len(first_val)
        elif isinstance(X_arr, (list, tuple)) and len(X_arr) > 0 and not hasattr(X_arr, "shape"):
            n = len(_np.asarray(X_arr[0]))
        else:
            X_arr = _np.asarray(X_arr) if not hasattr(X_arr, "iloc") else X_arr
            n = len(X_arr)
        group_labels = groups
        if group_labels is None and patient_ids is not None:
            group_labels = patient_ids
        if group_labels is not None:
            group_len = len(group_labels)
            if group_len != n:
                raise ValueError(
                    f"groups/patient_ids length mismatch: expected {n}, got {group_len}"
                )
        if is_multilabel and group_labels is not None and self.method in (
            "stratified_kfold",
            "patient_grouped_kfold",
        ):
            warnings.warn(
                "Detected multilabel indicator targets with grouped data; "
                "consider method='multilabel_stratified_group_kfold' to keep groups intact while "
                "preserving label prevalence.",
                UserWarning,
            )

        # Track binary class labels so we can compute sensitivity/specificity when requested
        y_for_class = y_arr.to_numpy() if hasattr(y_arr, "to_numpy") else _np.asarray(y_arr)
        binary_labels: Optional[Tuple[Any, Any]] = None
        if y_for_class.ndim == 1:
            unique_labels = _np.unique(y_for_class)
            if unique_labels.size == 2:
                # confusion_matrix orders labels ascending; keep the same ordering here
                binary_labels = (unique_labels[0], unique_labels[1])

        splitter = cv if cv is not None else self._cv_splitter
        # For sklearn splitters, provide an index placeholder when X is non-array (dict/list of inputs)
        X_for_split = _np.arange(n) if isinstance(X_arr, Mapping) or isinstance(X_arr, (list, tuple)) else X_arr

        # Multilabel-aware override
        if self.method in ("multilabel_stratified_kfold",):
            splitter = self._choose_multilabel_splitter(
                fallback_splitter=splitter, is_multilabel=True, force_multilabel=True
            )
        elif self.method in ("stratified_kfold", "StratifiedKFold") and is_multilabel:
            splitter = self._choose_multilabel_splitter(
                fallback_splitter=splitter, is_multilabel=True, force_multilabel=False
            )

        if splitter is None:
            splitter = StratifiedKFold(
                n_splits=self.n_splits, shuffle=True, random_state=self.random_state
            )

        # storage
        user_metrics_override = metrics is not None
        metric_list = (
            self._normalize_metric_list(metrics)
            if user_metrics_override
            else list(self.metrics)
        )
        user_metrics_provided = user_metrics_override or getattr(self, "_user_metrics_provided", False)
        # If user didn't override metrics and target is multilabel, use multilabel defaults
        if scoring is None and is_multilabel and user_metrics_provided is False:
            metric_list = ["roc_auc_ovr_macro", "f1_samples", "f1_macro", "f1_micro", "accuracy"]
        scorer_dict: Optional[Dict[str, Callable]] = None
        if scoring is not None:
            scorer_dict = {}
            for name, scorer in scoring.items():
                if isinstance(scorer, str):
                    scorer_dict[name] = _get_scorer(scorer)
                else:
                    scorer_dict[name] = scorer
            metric_list = list(scorer_dict.keys())
        else:
            if is_regression and user_metrics_provided is False:
                metric_list = ["mse", "rmse", "mae", "r2", "explained_variance"]
            # otherwise, keep whatever metric_list is (possibly overridden for multilabel)
        per_metric_scores: Dict[str, List[float]] = {m: [] for m in metric_list}
        fold_details: List[Dict[str, Any]] = []
        per_label_prevalence: List[np.ndarray] = []

        # iterate folds
        # Most trustcv/sklearn splitters accept (X, y, groups)
        split_groups = group_labels
        for k, (tr, te) in enumerate(splitter.split(X_for_split, y_arr, split_groups), 1):
            # train/val slices
            if isinstance(X_arr, Mapping):
                X_tr = {k2: v[tr] for k2, v in X_arr.items()}
                X_te = {k2: v[te] for k2, v in X_arr.items()}
            elif isinstance(X_arr, (list, tuple)) and not hasattr(X_arr, "shape"):
                X_tr = [v[tr] for v in X_arr]
                X_te = [v[te] for v in X_arr]
            elif hasattr(X_arr, "iloc"):
                X_tr, X_te = X_arr.iloc[tr], X_arr.iloc[te]
            else:
                X_tr, X_te = X_arr[tr], X_arr[te]
            if isinstance(y_raw, Mapping):
                y_tr = {k2: v[tr] for k2, v in y_raw.items()}
                y_te = y_raw  # keep full mapping for potential use later
                y_te_eval = y_raw[selected_key][te]
            elif isinstance(y_raw, (list, tuple)) and not hasattr(y_raw, "shape"):
                y_tr = [v[tr] for v in y_raw]
                y_te_eval = y_target[te]
            elif hasattr(y_arr, "iloc"):
                y_tr = y_arr.iloc[tr]
                y_te_eval = y_arr.iloc[te]
            else:
                y_tr = y_arr[tr]
                y_te_eval = y_arr[te]

            # clone model if possible
            try:
                est = _sk_clone(model)
            except Exception:
                est = model

            fit_kwargs = {}
            # Prefer explicit sample_weight passed to validate; otherwise, if the model
            # carries default fit_kwargs with a full-length sample_weight, slice it per fold.
            if sample_weight is not None:
                sw_tr = sample_weight[tr]
                fit_kwargs["sample_weight"] = sw_tr
            elif hasattr(est, "fit_kwargs") and isinstance(getattr(est, "fit_kwargs"), Mapping):
                sw_full = est.fit_kwargs.get("sample_weight", None)
                try:
                    if sw_full is not None and len(sw_full) == len(y_arr):
                        fit_kwargs["sample_weight"] = sw_full[tr]
                except Exception:
                    # fall back silently if slicing fails
                    pass
            try:
                est.fit(X_tr, y_tr, **fit_kwargs)
            except TypeError:
                est.fit(X_tr, y_tr)

            # predictions/scores
            y_pred_raw = None
            y_score_raw = None
            if hasattr(est, "predict"):
                try:
                    y_pred_raw = est.predict(X_te)
                except Exception:
                    y_pred_raw = None
            if hasattr(est, "predict_proba"):
                try:
                    proba = est.predict_proba(X_te)
                    y_score_raw = proba
                except Exception:
                    y_score_raw = None
            if y_score_raw is None and hasattr(est, "decision_function"):
                try:
                    y_score_raw = est.decision_function(X_te)
                except Exception:
                    y_score_raw = None

            y_pred, y_score = self._coerce_predictions(y_te_eval, y_pred_raw, y_score_raw)

            # compute metrics
            fold_metric_values: Dict[str, float] = {}
            for m in metric_list:
                try:
                    if scorer_dict is not None:
                        scorer = scorer_dict[m]
                        val = float(scorer(est, X_te, y_te_eval))
                    else:
                        if m in ("accuracy",):
                            from sklearn.metrics import accuracy_score as _acc

                            if y_pred is not None:
                                val = float(_acc(y_te_eval, y_pred))
                            else:
                                continue
                        elif m in ("f1", "f1_score"):
                            from sklearn.metrics import f1_score as _f1

                            if y_pred is not None:
                                avg = "micro" if y_te_eval.ndim == 2 else "binary"
                                val = float(_f1(y_te_eval, y_pred, average=avg))
                            else:
                                continue
                        elif m in ("f1_macro",):
                            from sklearn.metrics import f1_score as _f1

                            if y_pred is not None:
                                val = float(_f1(y_te_eval, y_pred, average="macro", zero_division=0))
                            else:
                                continue
                        elif m in ("f1_micro",):
                            from sklearn.metrics import f1_score as _f1

                            if y_pred is not None:
                                val = float(_f1(y_te_eval, y_pred, average="micro", zero_division=0))
                            else:
                                continue
                        elif m in ("f1_samples",):
                            from sklearn.metrics import f1_score as _f1

                            if y_pred is not None:
                                val = float(_f1(y_te_eval, y_pred, average="samples", zero_division=0))
                            else:
                                continue
                        elif m in ("precision",):
                            from sklearn.metrics import precision_score as _prec

                            if y_pred is not None:
                                avg = "micro" if y_te_eval.ndim == 2 else "binary"
                                val = float(_prec(y_te_eval, y_pred, average=avg, zero_division=0))
                            else:
                                continue
                        elif m in ("recall",):
                            from sklearn.metrics import recall_score as _rec

                            if y_pred is not None:
                                avg = "micro" if y_te_eval.ndim == 2 else "binary"
                                val = float(_rec(y_te_eval, y_pred, average=avg, zero_division=0))
                            else:
                                continue
                        elif m in ("sensitivity", "tpr", "recall_pos"):
                            from sklearn.metrics import recall_score as _rec

                            if y_pred is not None and binary_labels is not None:
                                pos_label = binary_labels[1]
                                val = float(
                                    _rec(y_te_eval, y_pred, pos_label=pos_label, zero_division=0.0)
                                )
                            else:
                                continue
                        elif m in ("specificity", "tnr"):
                            from sklearn.metrics import recall_score as _rec

                            if y_pred is not None and binary_labels is not None:
                                neg_label = binary_labels[0]
                                val = float(
                                    _rec(y_te_eval, y_pred, pos_label=neg_label, zero_division=0.0)
                                )
                            else:
                                continue
                        elif m in ("roc_auc", "auc"):
                            from sklearn.metrics import roc_auc_score as _auc

                            if y_score is not None:
                                val = float(_auc(y_te_eval, y_score))
                            else:
                                continue
                        elif m in ("roc_auc_ovr_macro",):
                            from sklearn.metrics import roc_auc_score as _auc

                            if y_score is not None:
                                val = float(_auc(y_te_eval, y_score, average="macro"))
                            else:
                                continue
                        elif m in ("mse", "mean_squared_error"):
                            if y_pred is not None:
                                val = float(mean_squared_error(y_te_eval, y_pred))
                            else:
                                continue
                        elif m in ("rmse",):
                            if y_pred is not None:
                                val = float(np.sqrt(mean_squared_error(y_te_eval, y_pred)))
                            else:
                                continue
                        elif m in ("mae", "mean_absolute_error"):
                            if y_pred is not None:
                                val = float(mean_absolute_error(y_te_eval, y_pred))
                            else:
                                continue
                        elif m in ("r2", "r2_score"):
                            if y_pred is not None:
                                val = float(r2_score(y_te_eval, y_pred))
                            else:
                                continue
                        elif m in ("explained_variance", "evs"):
                            if y_pred is not None:
                                val = float(explained_variance_score(y_te_eval, y_pred))
                            else:
                                continue
                        else:
                            # fallback to estimator.score as 'score'
                            if hasattr(est, "score") and m in ("score",):
                                val = float(est.score(X_te, y_te_eval))
                            else:
                                continue
                    per_metric_scores[m].append(val)
                    fold_metric_values[m] = val
                except Exception as exc:
                    if getattr(self, "debug", False):
                        print(f"[TrustCV] scorer '{m}' failed on fold {k}: {exc}")
                    # skip non-computable metric for this fold

            fold_details.append(
                {
                    "fold": k,
                    "n_train": len(tr),
                    "n_val": len(te),
                    "metrics": fold_metric_values,
                }
            )
            if is_multilabel:
                try:
                    te_arr = y_te_eval.to_numpy() if hasattr(y_te_eval, "to_numpy") else _np.asarray(y_te_eval)
                    per_label_prevalence.append(te_arr.mean(axis=0))
                except Exception:
                    pass

        # aggregate
        mean_scores: Dict[str, float] = {}
        std_scores: Dict[str, float] = {}
        conf_ints: Dict[str, Tuple[float, float]] = {}
        need_bootstrap_rng = (self.ci_method or "bootstrap").lower() in (
            "bootstrap",
            "boot",
            "bstrap",
        )
        rng = _np.random.default_rng(self.random_state) if need_bootstrap_rng else None

        for m, vals in per_metric_scores.items():
            arr = _np.asarray(vals, dtype=float)
            if arr.size == 0:
                continue
            mean_scores[m] = float(arr.mean())
            std_scores[m] = float(arr.std(ddof=1)) if arr.size > 1 else 0.0
            conf_ints[m] = self._compute_confidence_interval(arr, rng=rng)

        # leakage check (optional)
        leakage_check_map: Dict[str, bool] = self._basic_integrity_checks(
            X_arr, y_arr, groups=split_groups, splitter=splitter
        )
        if is_multilabel and per_label_prevalence:
            try:
                prev_arr = _np.vstack(per_label_prevalence)
                overall = _np.asarray(y_arr).mean(axis=0)
                max_dev = float(_np.max(_np.abs(prev_arr - overall)))
                leakage_check_map["balanced_multilabel"] = max_dev <= 0.1
            except Exception:
                pass
        recommendations: List[str] = []
        # Auto-create leakage checker when check_leakage=True
        effective_checker = leakage_checker
        if effective_checker is None and self.check_leakage:
            try:
                from .checkers.leakage import DataLeakageChecker as _DLC
                effective_checker = _DLC(verbose=False)
            except Exception:
                effective_checker = None
        if effective_checker is not None:
            try:
                leak_report = effective_checker.check(
                    X=X_arr, y=y_arr, groups=split_groups
                )
                leakage_check_map["has_leakage"] = not getattr(
                    leak_report, "has_leakage", True
                )
                recs = getattr(leak_report, "recommendations", [])
                if recs:
                    recommendations.extend(recs)
            except Exception:
                leakage_check_map["has_leakage"] = True

        # build detailed scores dict (arrays per metric)
        scores_dict = {
            m: np.asarray(v, dtype=float) for m, v in per_metric_scores.items() if len(v) > 0
        }

        ci_label = self._ci_method_label()

        result = ValidationResult(
            scores=scores_dict,
            mean_scores=mean_scores,
            std_scores=std_scores,
            confidence_intervals=conf_ints,
            ci_method=ci_label,
            ci_level=self.ci_level,
            fold_details=fold_details,
            leakage_check=leakage_check_map,
            recommendations=recommendations,
        )
        # Store last result for downstream consumers (e.g., reporting)
        try:
            self.last_result = result
        except Exception:
            pass
        return result

    @staticmethod
    def _coerce_predictions(
        y_true, y_pred_raw: Optional[Any], y_score_raw: Optional[Any] = None
    ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
        """
        Normalize predictions/scores to 1D arrays for metric computation.

        Handles common SciKeras/Keras shapes:
        - y_pred_raw (n, 1) -> ravel
        - y_pred_raw (n, C) -> argmax for labels
        - probability vectors in [0, 1] -> threshold at 0.5 for labels
        - y_score_raw (n, 2) -> column 1; (n, 1) -> ravel
        - if no explicit y_score_raw but y_pred_raw looks like probabilities, reuse as scores
        """

        def _to_array(arr: Any) -> Optional[np.ndarray]:
            if arr is None:
                return None
            return arr.to_numpy() if hasattr(arr, "to_numpy") else np.asarray(arr)

        def _is_prob_vector(vec: np.ndarray) -> bool:
            if vec.ndim != 1:
                return False
            if not np.issubdtype(vec.dtype, np.floating):
                return False
            finite = vec[np.isfinite(vec)]
            return finite.size > 0 and finite.min(initial=0.0) >= 0.0 and finite.max(initial=1.0) <= 1.0

        y_true_arr = _to_array(y_true)
        is_multilabel = y_true_arr is not None and y_true_arr.ndim == 2
        if y_true_arr is not None and not is_multilabel:
            y_true_arr = np.ravel(y_true_arr)

        y_pred = None
        y_score = None
        prob_source = None

        pred_arr = _to_array(y_pred_raw)
        if pred_arr is not None:
            if is_multilabel:
                # Expect shape (n, C); threshold to binary labels
                if pred_arr.ndim == 1:
                    pred_arr = pred_arr.reshape(-1, 1)
                if pred_arr.ndim != 2:
                    raise ValueError("Multilabel predictions must be 2D (n, C).")
                prob_source = pred_arr
                y_pred = (pred_arr >= 0.5).astype(int)
            elif pred_arr.ndim == 2:
                if pred_arr.shape[1] == 1:
                    vec = np.ravel(pred_arr)
                    if _is_prob_vector(vec):
                        prob_source = vec
                        y_pred = (vec >= 0.5).astype(int)
                    else:
                        y_pred = vec
                else:
                    # (n, C) logits/probabilities
                    if pred_arr.shape[1] == 2 and np.issubdtype(pred_arr.dtype, np.floating):
                        prob_source = pred_arr[:, 1]
                    y_pred = np.argmax(pred_arr, axis=1)
            else:
                vec = np.ravel(pred_arr)
                if _is_prob_vector(vec):
                    prob_source = vec
                    y_pred = (vec >= 0.5).astype(int)
                else:
                    y_pred = vec

        score_arr = _to_array(y_score_raw)
        if score_arr is not None:
            if is_multilabel:
                if score_arr.ndim == 1:
                    score_arr = score_arr.reshape(-1, 1)
                y_score = score_arr
            elif score_arr.ndim == 2 and score_arr.shape[1] == 2:
                y_score = score_arr[:, 1]
            elif score_arr.ndim == 2 and score_arr.shape[1] == 1:
                y_score = score_arr.ravel()
            else:
                y_score = np.ravel(score_arr)

        if y_score is None and prob_source is not None:
            y_score = prob_source

        return y_pred, y_score

    def _setup_splitter(self):
        """Configure the appropriate CV splitter"""
        from sklearn.model_selection import GroupKFold, KFold, StratifiedKFold, TimeSeriesSplit

        # Optional trustcv splitters for advanced strategies
        _TCVMLStratifiedGroupKFold = None
        _TCVMultiLabelGroupSplitter = None
        _TCVStratifiedGroupKFold = None
        try:
            from .splitters import MultilabelStratifiedGroupKFold as _TCVMLStratifiedGroupKFold
        except Exception:
            pass
        try:
            from .splitters import MultiLabelGroupSplitter as _TCVMultiLabelGroupSplitter
        except Exception:
            pass
        try:
            from .splitters import StratifiedGroupKFold as _TCVStratifiedGroupKFold
        except Exception:
            pass
        _TCVHoldOut = _TCVRepeatedKFold = _TCVLOOCV = _TCVLPOCV = _TCVMonteCarloCV = (
            _TCVBootstrapValidation
        ) = None
        try:
            from .splitters import LOOCV as _TCVLOOCV
            from .splitters import LPOCV as _TCVLPOCV
            from .splitters import BootstrapValidation as _TCVBootstrapValidation
            from .splitters import HoldOut as _TCVHoldOut
            from .splitters import MonteCarloCV as _TCVMonteCarloCV
            from .splitters import RepeatedKFold as _TCVRepeatedKFold
        except Exception:
            pass

        # Normalize again in case an older object was created without normalization
        method_key = (
            self._normalize_method(self.method) if isinstance(self.method, str) else self.method
        )
        self.method = method_key

        if method_key == "kfold":
            self._cv_splitter = KFold(
                n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
            )
        elif method_key == "stratified_kfold":
            self._cv_splitter = StratifiedKFold(
                n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
            )
        elif method_key == "multilabel_stratified_kfold":
            try:
                from .splitters.multilabel import MultilabelStratifiedKFold as _MLSKF

                self._cv_splitter = _MLSKF(
                    n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
                )
            except Exception:
                warnings.warn(
                    "iterative-stratification not installed; falling back to KFold for multilabel data.",
                    RuntimeWarning,
                )
                self._cv_splitter = KFold(
                    n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
                )
        elif method_key == "multilabel_stratified_group_kfold":
            if _TCVMLStratifiedGroupKFold is None:
                raise ValueError(
                    "MultilabelStratifiedGroupKFold is unavailable. Ensure trustcv.splitters is accessible."
                )
            self._cv_splitter = _TCVMLStratifiedGroupKFold(
                n_splits=self.n_splits,
                shuffle=self.shuffle,
                random_state=self.random_state,
            )
        elif method_key == "patient_grouped_kfold":
            self._cv_splitter = GroupKFold(n_splits=self.n_splits)
        elif method_key == "multilabel_group_kfold":
            if _TCVMultiLabelGroupSplitter is None:
                raise ValueError(
                    "MultiLabelGroupKFold is unavailable. Ensure trustcv.splitters is accessible."
                )
            self._cv_splitter = _TCVMultiLabelGroupSplitter(
                n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
            )
        elif method_key == "stratified_grouped_kfold":
            if _TCVStratifiedGroupKFold is None:
                raise ValueError(
                    "StratifiedGroupKFold is unavailable. Ensure trustcv.splitters is accessible."
                )
            self._cv_splitter = _TCVStratifiedGroupKFold(
                n_splits=self.n_splits, shuffle=True, random_state=self.random_state
            )
        elif method_key == "temporal":
            self._cv_splitter = TimeSeriesSplit(n_splits=self.n_splits)
        elif method_key == "holdout":
            if _TCVHoldOut is None:
                raise ValueError(
                    "HoldOut splitter is unavailable. Ensure trustcv.splitters is installed."
                )
            stratify_flag = bool(self.holdout_stratify)
            self._cv_splitter = _TCVHoldOut(
                test_size=self.holdout_test_size,
                random_state=self.random_state,
                stratify=True if stratify_flag else None,
            )
        elif method_key == "repeated_kfold":
            if _TCVRepeatedKFold is None:
                raise ValueError(
                    "RepeatedKFold splitter is unavailable. Ensure trustcv.splitters is installed."
                )
            self._cv_splitter = _TCVRepeatedKFold(
                n_splits=self.n_splits,
                n_repeats=self.repeated_kfold_repeats,
                random_state=self.random_state,
                stratify=self.repeated_kfold_stratify,
            )
        elif method_key == "loocv":
            if _TCVLOOCV is None:
                raise ValueError(
                    "LOOCV splitter is unavailable. Ensure trustcv.splitters is installed."
                )
            self._cv_splitter = _TCVLOOCV()
        elif method_key == "lpocv":
            if _TCVLPOCV is None:
                raise ValueError(
                    "LPOCV splitter is unavailable. Ensure trustcv.splitters is installed."
                )
            self._cv_splitter = _TCVLPOCV(p=self.lpocv_p)
        elif method_key == "monte_carlo":
            if _TCVMonteCarloCV is None:
                raise ValueError(
                    "MonteCarloCV splitter is unavailable. Ensure trustcv.splitters is installed."
                )
            self._cv_splitter = _TCVMonteCarloCV(
                n_iterations=self.monte_carlo_iterations,
                test_size=self.monte_carlo_test_size,
                random_state=self.random_state,
            )
        elif method_key == "bootstrap":
            if _TCVBootstrapValidation is None:
                raise ValueError(
                    "BootstrapValidation splitter is unavailable. Ensure trustcv.splitters is installed."
                )
            self._cv_splitter = _TCVBootstrapValidation(
                n_iterations=self.bootstrap_validation_iterations,
                estimator=self.bootstrap_validation_estimator,
                random_state=self.random_state,
            )

        else:
            # Last-resort: accept common sklearn-style names literally
            name = str(self.method)
            namelow = name.lower()

            if namelow in ("stratifiedkfold", "stratified_kfold"):
                self._cv_splitter = StratifiedKFold(
                    n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
                )
                self.method = "stratified_kfold"
                return

            if namelow in ("kfold",):
                self._cv_splitter = KFold(
                    n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
                )
                self.method = "kfold"
                return

            if namelow in ("groupkfold", "patientgroupedkfold", "groupedkfold", "group_kfold"):
                self._cv_splitter = GroupKFold(n_splits=self.n_splits)
                self.method = "patient_grouped_kfold"
                return

            if namelow in ("multilabelgroupkfold", "multilabelgroupsplitter", "multilabelgroupcv"):
                if _TCVMultiLabelGroupSplitter is None:
                    raise ValueError(
                        "MultiLabelGroupKFold is unavailable. Ensure trustcv.splitters is accessible."
                    )
                self._cv_splitter = _TCVMultiLabelGroupSplitter(
                    n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
                )
                self.method = "multilabel_group_kfold"
                return

            if namelow in (
                "stratifiedgroupkfold",
                "stratified_groupkfold",
                "stratified_group_kfold",
                "stratifiedgroupedkfold",
            ):
                if _TCVStratifiedGroupKFold is None:
                    raise ValueError(
                        "StratifiedGroupKFold is unavailable. Ensure trustcv.splitters is accessible."
                    )
                self._cv_splitter = _TCVStratifiedGroupKFold(
                    n_splits=self.n_splits, shuffle=True, random_state=self.random_state
                )
                self.method = "stratified_grouped_kfold"
                return

            if namelow in ("timeseriessplit", "temporal", "time_series_split"):
                self._cv_splitter = TimeSeriesSplit(n_splits=self.n_splits)
                self.method = "temporal"
                return

            raise ValueError(
                f"Unknown method: {self.method}. "
                f"Use one of: 'kfold', 'stratified_kfold', 'patient_grouped_kfold', 'stratified_grouped_kfold', "
                f"'temporal', 'holdout', 'repeated_kfold', 'loocv', 'lpocv', 'monte_carlo', 'bootstrap' "
                f"(also accepts 'KFold', 'StratifiedKFold', 'GroupKFold', 'StratifiedGroupKFold', 'TimeSeriesSplit')."
            )

    def _choose_multilabel_splitter(
        self, fallback_splitter: Optional[Any], is_multilabel: bool, force_multilabel: bool = False
    ):
        if not (force_multilabel or is_multilabel):
            return fallback_splitter
        try:
            from .splitters.multilabel import MultilabelStratifiedKFold as _MLSKF

            return _MLSKF(
                n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
            )
        except Exception:
            warnings.warn(
                "iterative-stratification not installed; using KFold instead for multilabel data.",
                RuntimeWarning,
            )
            # If fallback is a StratifiedKFold, it won't work for multilabel; override to KFold.
            if fallback_splitter is not None and not isinstance(fallback_splitter, StratifiedKFold):
                return fallback_splitter
            try:
                return KFoldMedical(
                    n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
                )
            except Exception:
                return KFold(
                    n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state
                )

    @staticmethod
    def _validate_split_size(value: Union[float, int], name: str) -> Union[float, int]:
        """Validate hold-out style split size parameters."""
        if isinstance(value, float):
            if not 0 < value < 1:
                raise ValueError(f"{name} as float must be between 0 and 1.")
            return value
        if isinstance(value, int):
            if value <= 0:
                raise ValueError(f"{name} as int must be positive.")
            return value
        raise TypeError(f"{name} must be a float fraction or positive int.")

    def _normalize_method(self, method: str) -> str:
        """Normalize user-provided method names to canonical keys.

        Accepts variants like 'StratifiedKFold', 'stratified-kfold',
        'stratified_kfold', 'group_kfold', 'TimeSeriesSplit', etc.
        """
        if not isinstance(method, str):
            return method  # leave untouched if non-string

        import re

        # remove all non-alphanumeric and underscores, then lower
        # 'StratifiedKFold' -> 'stratifiedkfold'
        key = re.sub(r"[\W_]+", "", method).lower()

        mapping = {
            # IID / grouped
            "kfold": "kfold",
            "kfoldmedical": "kfold",
            "stratifiedkfold": "stratified_kfold",
            "stratifiedkfoldmedical": "stratified_kfold",
            "groupkfold": "patient_grouped_kfold",
            "groupedkfold": "patient_grouped_kfold",
            "patientgroupedkfold": "patient_grouped_kfold",
            "stratifiedgroupkfold": "stratified_grouped_kfold",
            "stratifiedgroupedkfold": "stratified_grouped_kfold",
            "stratifiedgroup_kfold": "stratified_grouped_kfold",
            "stratified_groupkfold": "stratified_grouped_kfold",
            "stratified_group_kfold": "stratified_grouped_kfold",
            "holdout": "holdout",
            "holdoutvalidation": "holdout",
            "holdoutsplit": "holdout",
            "traintestsplit": "holdout",
            "train_test_split": "holdout",
            "repeatedkfold": "repeated_kfold",
            "repeated_kfold": "repeated_kfold",
            "repeated-kfold": "repeated_kfold",
            "loocv": "loocv",
            "leaveoneout": "loocv",
            "leave_one_out": "loocv",
            "lpocv": "lpocv",
            "leavepout": "lpocv",
            "leave_p_out": "lpocv",
            "montecarlocv": "monte_carlo",
            "montecarlo": "monte_carlo",
            "monte_carlo": "monte_carlo",
            "bootstrapvalidation": "bootstrap",
            "bootstrap_cv": "bootstrap",
            "bootstrap": "bootstrap",
            "multilabelstratifiedkfold": "multilabel_stratified_kfold",
            "multilabelstratifiedgroupkfold": "multilabel_stratified_group_kfold",
            "multilabelgroupkfold": "multilabel_group_kfold",
            "multilabelgroupsplitter": "multilabel_group_kfold",
            # temporal
            "temporal": "temporal",
            "timeseriessplit": "temporal",
            "timeseries": "temporal",
        }
        return mapping.get(key, method)

    def _normalize_metric_list(self, metrics: Optional[Iterable[str]]) -> List[str]:
        """Normalize requested metric names and drop duplicates."""
        default = [
            "accuracy",
            "roc_auc",
            "sensitivity",
            "specificity",
            "precision",
            "recall",
            "f1",
        ]
        if not metrics:
            return default.copy()
        alias_map = {
            "auc": "roc_auc",
            "rocauc": "roc_auc",
            "roc_auc": "roc_auc",
            "f1score": "f1",
            "f1_score": "f1",
            "score": "accuracy",
            "tpr": "sensitivity",
            "recall_pos": "sensitivity",
            "recall+": "sensitivity",
            "sensitivity": "sensitivity",
            "specificity": "specificity",
            "tnr": "specificity",
            "recall_neg": "specificity",
            "recall-": "specificity",
        }
        normalized: List[str] = []
        seen: set = set()
        for metric in metrics:
            if not isinstance(metric, str):
                continue
            key = metric.strip().lower()
            if not key:
                continue
            canonical = alias_map.get(key, key)
            if canonical not in seen:
                seen.add(canonical)
                normalized.append(canonical)
        return normalized or default.copy()

    @staticmethod
    def _is_multilabel(y: Any) -> bool:
        try:
            y_arr = y.to_numpy() if hasattr(y, "to_numpy") else np.asarray(y)
            return y_arr.ndim == 2 and set(np.unique(y_arr)).issubset({0, 1})
        except Exception:
            return False

    @staticmethod
    def _is_regression(y: Any) -> bool:
        try:
            y_arr = y.to_numpy() if hasattr(y, "to_numpy") else np.asarray(y)
            # 1D float or many unique values suggests regression
            if y_arr.ndim > 1 and y_arr.shape[1] == 1:
                y_arr = y_arr.ravel()
            if y_arr.ndim != 1:
                return False
            if np.issubdtype(y_arr.dtype, np.floating):
                return True
            uniq = np.unique(y_arr)
            return len(uniq) > 20
        except Exception:
            return False

    def fit_validate(
        self,
        model,
        X: Union[np.ndarray, pd.DataFrame],
        y: Union[np.ndarray, pd.Series],
        patient_ids: Optional[Union[np.ndarray, pd.Series]] = None,
        timestamps: Optional[Union[np.ndarray, pd.Series]] = None,
        scoring: Optional[Dict[str, Any]] = None,
    ) -> ValidationResult:
        """
        Perform medical cross-validation

        Parameters:
        -----------
        model : sklearn estimator
            Model to validate
        X : array-like
            Features
        y : array-like
            Labels
        patient_ids : array-like, optional
            Patient identifiers for grouped splitting
        timestamps : array-like, optional
            Timestamps for temporal validation
        scoring : dict, optional
            Scoring metrics

        Returns:
        --------
        ValidationResult object with comprehensive metrics
        """
        is_multilabel = self._is_multilabel(y)

        # Default medical scoring metrics
        if scoring is None:
            scoring = self._get_medical_scoring(is_multilabel=is_multilabel)

        # Prepare groups for patient-level splitting
        groups = patient_ids if patient_ids is not None else None

        # Perform cross-validation
        # Neural-network wrappers (e.g., Keras*) are not process-safe; run folds serially.
        model_name = str(type(model)).lower()
        n_jobs = -1
        if any(tag in model_name for tag in ("keras", "tensorflow", "pytorch", "torch")):
            n_jobs = 1

        cv_results = cross_validate(
            model,
            X,
            y,
            cv=self._cv_splitter,
            groups=groups,
            scoring=scoring,
            return_train_score=True,
            return_estimator=True,
            n_jobs=n_jobs,
        )

        # Calculate statistics (canonical keys without 'test_' prefix)
        mean_scores = {
            metric.replace("test_", ""): float(np.mean(scores))
            for metric, scores in cv_results.items()
            if metric.startswith("test_")
        }
        std_scores = {
            metric.replace("test_", ""): float(np.std(scores))
            for metric, scores in cv_results.items()
            if metric.startswith("test_")
        }
        # Backward-compatibility: also expose legacy 'test_*' keys alongside canonical ones
        for metric, scores in cv_results.items():
            if metric.startswith("test_"):
                base = metric.replace("test_", "")
                if base in mean_scores and metric not in mean_scores:
                    mean_scores[metric] = mean_scores[base]
                if base in std_scores and metric not in std_scores:
                    std_scores[metric] = std_scores[base]

        # Calculate 95% confidence intervals
        confidence_intervals = self._calculate_confidence_intervals(cv_results)
        # Alias common default scorer names for convenience
        if "score" in confidence_intervals and "accuracy" not in confidence_intervals:
            confidence_intervals["accuracy"] = confidence_intervals["score"]

        # Check for data leakage
        leakage_check = self._basic_integrity_checks(
            X, y, groups=patient_ids, splitter=self._cv_splitter
        )
        if self.check_leakage and patient_ids is not None:
            leakage_check.update(self._check_data_leakage(X, y, patient_ids, cv_results))

        # Generate recommendations
        recommendations = self._generate_recommendations(
            mean_scores, std_scores, leakage_check, X.shape
        )

        # Prepare fold details
        fold_details = self._extract_fold_details(cv_results)

        # Normalize score keys so users can access without 'test_' prefix
        cv_results_normalized = dict(cv_results)
        for k, v in list(cv_results.items()):
            if k.startswith("test_"):
                cv_results_normalized[k.replace("test_", "")] = v
        if "score" in cv_results_normalized and "accuracy" not in cv_results_normalized:
            cv_results_normalized["accuracy"] = cv_results_normalized["score"]

        # Create result object
        ci_label = self._ci_method_label() if confidence_intervals else ""

        result = ValidationResult(
            scores=cv_results_normalized,
            mean_scores=mean_scores,
            std_scores=std_scores,
            confidence_intervals=confidence_intervals,
            ci_method=ci_label,
            ci_level=self.ci_level,
            fold_details=fold_details,
            leakage_check=leakage_check,
            recommendations=recommendations,
        )

        # Generate compliance report if needed
        if self.compliance:
            self._generate_compliance_report(result, model, X, y)

        return result

    def _get_medical_scoring(self, is_multilabel: bool = False) -> Dict[str, Any]:
        """Get medical-relevant scoring metrics.

        For multilabel targets we expose multilabel-aware metrics by default.
        """
        from sklearn.metrics import make_scorer, f1_score, roc_auc_score

        def sensitivity_score(y_true, y_pred):
            tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
            return tp / (tp + fn) if (tp + fn) > 0 else 0

        def specificity_score(y_true, y_pred):
            tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
            return tn / (tn + fp) if (tn + fp) > 0 else 0

        def f1_macro_ml(y_true, y_pred):
            return f1_score(y_true, y_pred, average="macro", zero_division=0)

        def f1_micro_ml(y_true, y_pred):
            return f1_score(y_true, y_pred, average="micro", zero_division=0)

        def f1_samples_ml(y_true, y_pred):
            return f1_score(y_true, y_pred, average="samples", zero_division=0)

        def roc_auc_ovr_macro(y_true, y_proba):
            # For multilabel: average over labels (macro) using probability estimates
            return roc_auc_score(y_true, y_proba, average="macro")

        if is_multilabel:
            available = {
                "accuracy": "accuracy",
                "roc_auc_ovr_macro": make_scorer(roc_auc_ovr_macro, needs_proba=True),
                "f1_macro": make_scorer(f1_macro_ml),
                "f1_micro": make_scorer(f1_micro_ml),
                "f1_samples": make_scorer(f1_samples_ml),
            }
        else:
            available = {
                "accuracy": "accuracy",
                "roc_auc": "roc_auc",
                "sensitivity": make_scorer(sensitivity_score),
                "specificity": make_scorer(specificity_score),
                "precision": "precision",
                "recall": "recall",
                "f1": "f1",
            }

        requested = self.metrics or list(available.keys())
        scoring: Dict[str, Any] = {}
        for metric in requested:
            scorer = available.get(metric)
            if scorer is None:
                warnings.warn(
                    f"Metric '{metric}' is not supported by fit_validate(); skipping.",
                    RuntimeWarning,
                )
                continue
            scoring[metric] = scorer
        return scoring or available

    def _ci_method_label(self) -> str:
        if not self.return_confidence_intervals:
            return ""
        method = (self.ci_method or "bootstrap").lower()
        if method in ("bootstrap", "boot", "bstrap"):
            return "bootstrap"
        if method in ("t", "t-interval", "t_interval", "student"):
            return "t-interval"
        return method

    def _compute_confidence_interval(
        self,
        values: np.ndarray,
        *,
        rng: Optional[np.random.Generator] = None,
        alpha: Optional[float] = None,
    ) -> Tuple[float, float]:
        """Compute confidence intervals over per-fold values."""
        arr = np.asarray(values, dtype=float)
        if arr.size <= 1 or not self.return_confidence_intervals:
            return (float("nan"), float("nan"))
        if alpha is None:
            alpha = 1.0 - float(self.ci_level or 0.95)
        method = (self.ci_method or "bootstrap").lower()
        if method in ("t", "t-interval", "t_interval", "student"):
            from scipy import stats

            mean = float(arr.mean())
            sem = stats.sem(arr)
            if np.isnan(sem):
                return (float("nan"), float("nan"))
            lo, hi = stats.t.interval(1 - alpha, len(arr) - 1, loc=mean, scale=sem)
            return (float(lo), float(hi))
        # default to bootstrap resampling
        n_bootstrap = max(int(self.n_bootstrap), 1)
        rng = rng or np.random.default_rng(self.random_state)
        boot = np.empty(n_bootstrap, dtype=float)
        n = arr.size
        for i in range(n_bootstrap):
            idx = rng.integers(0, n, size=n)
            boot[i] = float(arr[idx].mean())
        lo, hi = np.percentile(boot, [alpha / 2 * 100, (1 - alpha / 2) * 100]).tolist()
        return (float(lo), float(hi))

    def _calculate_confidence_intervals(
        self, cv_results: Dict, alpha: Optional[float] = None
    ) -> Dict[str, Tuple[float, float]]:
        """Calculate confidence intervals from cross_validate outputs."""
        if not self.return_confidence_intervals:
            return {}
        if alpha is None:
            alpha = 1.0 - float(self.ci_level or 0.95)
        confidence_intervals = {}
        use_bootstrap = (self.ci_method or "bootstrap").lower() in ("bootstrap", "boot", "bstrap")
        rng = np.random.default_rng(self.random_state) if use_bootstrap else None
        for metric in cv_results:
            if metric.startswith("test_"):
                scores = np.asarray(cv_results[metric], dtype=float)
                metric_name = metric.replace("test_", "")
                confidence_intervals[metric_name] = self._compute_confidence_interval(
                    scores, rng=rng, alpha=alpha
                )

        return confidence_intervals

    def _basic_integrity_checks(
        self,
        X: Union[np.ndarray, pd.DataFrame],
        y: Union[np.ndarray, pd.Series],
        *,
        groups: Optional[Union[np.ndarray, pd.Series]] = None,
        splitter: Optional[BaseCrossValidator] = None,
    ) -> Dict[str, bool]:
        """Run lightweight integrity checks (balance, duplicates, group leakage)."""
        checks: Dict[str, bool] = {}
        y_arr = y.to_numpy() if hasattr(y, "to_numpy") else np.asarray(y)
        splitter = splitter or self._cv_splitter

        if self.check_leakage:
            # Duplicate sample check (DataFrame only to avoid expensive comparisons)
            checks["no_duplicate_samples"] = True
            if isinstance(X, pd.DataFrame):
                has_duplicates = bool(X.duplicated().any())
                checks["no_duplicate_samples"] = not has_duplicates
                if has_duplicates:
                    warnings.warn("Duplicate samples detected in dataset")
            groups_arr = None
            if groups is not None:
                groups_arr = (
                    groups.to_numpy() if hasattr(groups, "to_numpy") else np.asarray(groups)
                )
                if len(groups_arr) != len(y_arr):
                    raise ValueError("groups/patient_ids length mismatch during integrity check.")
            if groups_arr is not None and splitter is not None:
                no_overlap_all = True
                try:
                    for train_idx, test_idx in splitter.split(X, y, groups_arr):
                        train_groups = set(np.unique(groups_arr[train_idx]))
                        test_groups = set(np.unique(groups_arr[test_idx]))
                        if train_groups.intersection(test_groups):
                            no_overlap_all = False
                            break
                except Exception:
                    no_overlap_all = False
                if not no_overlap_all:
                    warnings.warn(
                        "Group leakage detected: some group/patient IDs appear in both train and test folds."
                    )
                checks["no_patient_leakage"] = no_overlap_all

        if self.check_balance:
            balanced = True
            unique, counts = np.unique(y_arr, return_counts=True)
            if len(unique) == 2:
                ratio = counts.min() / counts.max()
                if ratio < 0.1:
                    balanced = False
                    warnings.warn(f"Severe class imbalance detected: {ratio:.2%} minority class")
            checks["balanced_classes"] = balanced

        return checks

    def _check_data_leakage(
        self,
        X: Union[np.ndarray, pd.DataFrame],
        y: Union[np.ndarray, pd.Series],
        patient_ids: Union[np.ndarray, pd.Series],
        cv_results: Dict,
    ) -> Dict[str, bool]:
        """Check for various types of data leakage"""
        return self._basic_integrity_checks(X, y, groups=patient_ids, splitter=self._cv_splitter)

    def _generate_recommendations(
        self,
        mean_scores: Dict[str, float],
        std_scores: Dict[str, float],
        leakage_check: Dict[str, bool],
        data_shape: Tuple,
    ) -> List[str]:
        """Generate actionable recommendations"""
        recommendations = []

        # Check for high variance
        for metric, std in std_scores.items():
            if std > 0.1:  # High variance threshold
                recommendations.append(
                    f"High variance in {metric} ({std:.3f}). "
                    "Consider increasing sample size or using stratification."
                )

        # Sample size recommendations
        n_samples = data_shape[0]
        n_features = data_shape[1]
        if n_samples < 10 * n_features:
            recommendations.append(
                f"Low sample-to-feature ratio ({n_samples}/{n_features}). "
                "Consider feature selection or regularization."
            )

        # Leakage warnings
        if not all(leakage_check.values()):
            failed_checks = [k for k, v in leakage_check.items() if not v]
            recommendations.append(
                f"Data integrity issues detected: {', '.join(failed_checks)}. "
                "Review data preprocessing pipeline."
            )

        # Method-specific recommendations
        if self.method == "kfold" and not leakage_check.get("balanced_classes", True):
            recommendations.append("Consider using StratifiedKFold for imbalanced classes.")

        return recommendations

    def _extract_fold_details(self, cv_results: Dict) -> List[Dict]:
        """Extract detailed information for each fold"""
        fold_details = []
        n_folds = len(cv_results["test_accuracy"])

        for i in range(n_folds):
            fold_info = {
                "fold": i + 1,
                "train_score": cv_results["train_accuracy"][i],
                "test_score": cv_results["test_accuracy"][i],
                "fit_time": cv_results["fit_time"][i],
                "score_time": cv_results["score_time"][i],
            }
            fold_details.append(fold_info)

        return fold_details

    def _generate_compliance_report(
        self,
        result: ValidationResult,
        model,
        X: Union[np.ndarray, pd.DataFrame],
        y: Union[np.ndarray, pd.Series],
    ):
        """Generate regulatory compliance report"""
        if self.compliance == "FDA":
            # FDA-specific requirements
            report = {
                "device_description": str(model.__class__.__name__),
                "validation_method": self.method,
                "sample_size": len(y),
                "performance_metrics": result.to_dict(),
                "data_integrity": result.leakage_check,
                "random_seed": self.random_state,
                "timestamp": pd.Timestamp.now().isoformat(),
            }

            # Save FDA report
            with open("fda_validation_report.json", "w") as f:
                json.dump(report, f, indent=2, default=str)

            print("FDA validation report generated: fda_validation_report.json")

        elif self.compliance == "CE":
            # CE Mark requirements (simplified)
            print("CE compliance report generation (placeholder)")

    def suggest_best_method(
        self,
        X: Union[np.ndarray, pd.DataFrame],
        y: Union[np.ndarray, pd.Series],
        patient_ids: Optional[Union[np.ndarray, pd.Series]] = None,
        timestamps: Optional[Union[np.ndarray, pd.Series]] = None,
    ) -> str:
        """Suggest best CV method based on data characteristics"""
        n_samples = len(y)

        # Check for temporal data
        if timestamps is not None:
            return "temporal"

        # Check for grouped data
        if patient_ids is not None:
            unique_patients = len(np.unique(patient_ids))
            if unique_patients < n_samples:
                return "patient_grouped_kfold"

        # Check class balance
        unique, counts = np.unique(y, return_counts=True)
        if len(unique) > 1:
            ratio = counts.min() / counts.max()
            if ratio < 0.3:  # Imbalanced
                return "stratified_kfold"

        # Default
        return "kfold" if n_samples > 1000 else "stratified_kfold"


# --- Optional high-level nested CV runners ---
try:
    # Prefer public path; fallback to module path
    from .splitters import PurgedGroupTimeSeriesSplit as _PGTS
except Exception:
    try:
        from .splitters.temporal import PurgedGroupTimeSeriesSplit as _PGTS
    except Exception:
        _PGTS = None  # Will error if used without available splitter


class NestedGroupedCV:
    """
    Nested cross-validation for grouped data (patients/assets/etc.) with optional time-aware,
    leak-safe splitting via purge/embargo.

    Outer loop: unbiased evaluation on held-out groups.
    Inner loop: hyperparameter tuning on outer-train groups only.

    Parameters
    ----------
    n_splits_outer : int, default=5
        Number of outer folds.
    n_splits_inner : int, default=3
        Number of inner folds.
    group_exclusive : bool, default=True
        If True, any group that appears in the test fold is removed from the train for that fold.
    purge_gap : int, default=0
        Steps to purge before each test window from train (index-based, time-sorted).
    embargo_size : float, default=0.0
        Fraction of dataset length to embargo after each test window from train.
    scoring : {"average_precision","roc_auc"} or callable, default="average_precision"
        Metric used for inner hyperparameter selection.
    synthesize_timestamps : bool, default=True
        If True and timestamps is None, uses np.arange(n) as a monotonic timeline.
    """

    def __init__(
        self,
        n_splits_outer: int = 5,
        n_splits_inner: int = 3,
        *,
        group_exclusive: bool = True,
        purge_gap: int = 0,
        embargo_size: float = 0.0,
        scoring: Union[str, Callable[[np.ndarray, np.ndarray], float]] = "average_precision",
        synthesize_timestamps: bool = True,
        mode: str = "time",
    ):
        """
        mode: "time" (default) uses PurgedGroupTimeSeriesSplit (contiguous windows).
              "group" uses sklearn's GroupKFold (balanced groups, no time windows).
        """

        self.n_splits_outer = int(n_splits_outer)
        self.n_splits_inner = int(n_splits_inner)
        self.group_exclusive = bool(group_exclusive)
        self.purge_gap = int(purge_gap)
        self.embargo_size = float(embargo_size)
        self.synthesize_timestamps = bool(synthesize_timestamps)
        self.mode = mode

        # scoring
        if isinstance(scoring, str):
            if scoring == "average_precision":
                self._score_fn = lambda yt, ys: __import__(
                    "sklearn.metrics"
                ).metrics.average_precision_score(yt, ys)
            elif scoring == "roc_auc":
                self._score_fn = lambda yt, ys: __import__("sklearn.metrics").metrics.roc_auc_score(
                    yt, ys
                )
            else:
                raise ValueError(
                    "scoring must be 'average_precision', 'roc_auc', or a callable(y_true, y_score)->float"
                )
        else:
            self._score_fn = scoring

        # Construct splitters once (same semantics inner/outer)
        if self.mode == "time":
            if _PGTS is None:
                raise ImportError("PurgedGroupTimeSeriesSplit not available in trustcv.splitters")
            self.outer_cv = _PGTS(
                n_splits=self.n_splits_outer,
                purge_gap=self.purge_gap,
                embargo_size=self.embargo_size,
                group_exclusive=self.group_exclusive,
            )
            self.inner_cv = _PGTS(
                n_splits=self.n_splits_inner,
                purge_gap=self.purge_gap,
                embargo_size=self.embargo_size,
                group_exclusive=self.group_exclusive,
            )
        elif self.mode == "group":
            # Exact GroupKFold semantics (no time windows)
            from sklearn.model_selection import GroupKFold

            self.outer_cv = GroupKFold(self.n_splits_outer)
            self.inner_cv = GroupKFold(self.n_splits_inner)
        else:
            raise ValueError("mode must be 'time' or 'group'")

    # utilities
    @staticmethod
    def _supports_arg(cv, name: str) -> bool:
        import inspect

        return name in inspect.signature(cv.split).parameters

    def _kw(self, cv, timestamps=None, groups=None) -> dict:
        kw = {}
        if self.mode == "time":
            if (timestamps is not None) and self._supports_arg(cv, "timestamps"):
                kw["timestamps"] = timestamps
            if (groups is not None) and self._supports_arg(cv, "groups"):
                kw["groups"] = groups
        else:  # group-only
            if groups is not None:
                kw["groups"] = groups
        return kw

    def fit_predict(
        self,
        estimator,
        X: np.ndarray,
        y: np.ndarray,
        *,
        groups: np.ndarray,
        timestamps: Optional[np.ndarray] = None,
        param_grid: Dict[str, Iterable],
    ) -> Tuple[pd.DataFrame, pd.DataFrame]:
        from sklearn.base import clone
        from sklearn.metrics import (
            average_precision_score,
            balanced_accuracy_score,
            f1_score,
            roc_auc_score,
        )
        from sklearn.model_selection import ParameterGrid

        X = np.asarray(X)
        y = np.asarray(y)
        groups = np.asarray(groups)
        n = len(X)
        assert X.shape[0] == y.shape[0] == groups.shape[0], "X, y, groups length mismatch"

        # timestamps handling
        if timestamps is None and self.synthesize_timestamps:
            timestamps = np.arange(n)
        elif timestamps is not None:
            timestamps = np.asarray(timestamps)
            assert timestamps.shape[0] == n, "timestamps length mismatch"

        rows = []

        # Outer loop
        for k, (tr, te) in enumerate(
            self.outer_cv.split(X, y, **self._kw(self.outer_cv, timestamps, groups)), 1
        ):
            X_tr, y_tr = X[tr], y[tr]
            ts_tr = timestamps[tr] if timestamps is not None else None
            grp_tr = groups[tr]

            # Inner loop hyperparameter tuning
            best_params, best_score = None, -np.inf
            for params in ParameterGrid(param_grid):
                scores = []
                for tr2, te2 in self.inner_cv.split(
                    X_tr, y_tr, **self._kw(self.inner_cv, ts_tr, grp_tr)
                ):
                    est = clone(estimator).set_params(**params)
                    est.fit(X_tr[tr2], y_tr[tr2])
                    proba = est.predict_proba(X_tr[te2])[:, 1]
                    scores.append(float(self._score_fn(y_tr[te2], proba)))
                mean_s = float(np.mean(scores)) if scores else -np.inf
                if mean_s > best_score:
                    best_score, best_params = mean_s, params
            if best_params is None:
                best_params = {}

            # Fit best on full outer-train; evaluate on outer-test
            best_est = clone(estimator).set_params(**best_params)
            best_est.fit(X_tr, y_tr)
            proba = best_est.predict_proba(X[te])[:, 1]
            pred = (proba >= 0.5).astype(int)

            rows.append(
                {
                    "fold": k,
                    "balanced_accuracy": balanced_accuracy_score(y[te], pred),
                    "roc_auc": roc_auc_score(y[te], proba),
                    "auprc": average_precision_score(y[te], proba),
                    "f1": f1_score(y[te], pred),
                    "best_params": best_params,
                }
            )

        per_fold = pd.DataFrame(rows)
        summary = per_fold.drop(columns=["fold", "best_params"]).agg(["mean", "std"])
        return per_fold, summary


class NestedTemporalCV:
    """
    High-level nested CV for temporal (and optionally grouped) data.

    outer_cv: trustcv splitter for unbiased evaluation (e.g., TimeSeriesSplit, PurgedGroupTimeSeriesSplit)
    inner_cv: trustcv splitter for hyperparameter tuning (same family as outer)
    """

    def __init__(self, outer_cv, inner_cv):
        self.outer_cv = outer_cv
        self.inner_cv = inner_cv

    def _supports_arg(self, cv, name: str) -> bool:
        import inspect

        return name in inspect.signature(cv.split).parameters

    def _cv_kwargs(self, cv, timestamps, groups):
        kw = {}
        if timestamps is not None and self._supports_arg(cv, "timestamps"):
            kw["timestamps"] = timestamps
        if groups is not None and self._supports_arg(cv, "groups"):
            kw["groups"] = groups
        return kw

    def fit_predict(
        self,
        estimator,
        X: np.ndarray,
        y: np.ndarray,
        param_grid: Dict[str, Any],
        timestamps: Optional[np.ndarray] = None,
        groups: Optional[np.ndarray] = None,
        scoring: str = "average_precision",
        return_per_fold: bool = True,
    ) -> Tuple[pd.DataFrame, pd.DataFrame]:
        from sklearn.base import clone
        from sklearn.metrics import (
            average_precision_score,
            balanced_accuracy_score,
            f1_score,
            roc_auc_score,
        )
        from sklearn.model_selection import ParameterGrid

        X = np.asarray(X)
        y = np.asarray(y)

        # Resolve tuning scorer: supports probability-based and label-based metrics
        def _resolve_scorer(sc):
            if callable(sc):
                return sc
            name = str(sc).lower()

            def _to_label(y_score):
                ys = y_score
                if hasattr(ys, "ndim") and ys.ndim > 1:
                    ys = ys[:, 1]
                thr = 0.5 if np.isfinite(ys).all() and ys.min() >= 0.0 and ys.max() <= 1.0 else 0.0
                return (ys >= thr).astype(int)

            if name in ("roc_auc", "auc"):
                return lambda yt, ys: roc_auc_score(yt, ys)
            if name in ("average_precision", "auprc", "pr_auc"):
                return lambda yt, ys: average_precision_score(yt, ys)
            if name in ("accuracy", "acc"):
                from sklearn.metrics import accuracy_score as _acc

                return lambda yt, ys: _acc(yt, _to_label(ys))
            if name in ("balanced_accuracy", "bacc", "balanced-accuracy"):
                return lambda yt, ys: balanced_accuracy_score(yt, _to_label(ys))
            if name in ("f1", "f1_score"):
                return lambda yt, ys: f1_score(yt, _to_label(ys))
            if name in ("precision", "ppv"):
                from sklearn.metrics import precision_score as _prec

                return lambda yt, ys: _prec(yt, _to_label(ys))
            if name in ("recall", "tpr", "sensitivity"):
                from sklearn.metrics import recall_score as _rec

                return lambda yt, ys: _rec(yt, _to_label(ys))
            raise ValueError(
                "Unsupported scoring. Use 'roc_auc', 'average_precision', 'accuracy',"
                " 'balanced_accuracy', 'f1', 'precision', 'recall', or a callable."
            )

        score_fn = _resolve_scorer(scoring)

        rows = []
        outer_kw = self._cv_kwargs(self.outer_cv, timestamps, groups)

        for k, (tr, te) in enumerate(self.outer_cv.split(X, y, **outer_kw), 1):
            X_tr, y_tr = X[tr], y[tr]
            ts_tr = timestamps[tr] if timestamps is not None else None
            gp_tr = groups[tr] if groups is not None else None

            # inner tuning
            inner_kw = self._cv_kwargs(self.inner_cv, ts_tr, gp_tr)
            best_params, best_score = None, -np.inf
            for params in ParameterGrid(param_grid):
                scores = []
                for tr2, te2 in self.inner_cv.split(X_tr, y_tr, **inner_kw):
                    est = clone(estimator).set_params(**params)
                    est.fit(X_tr[tr2], y_tr[tr2])
                    if hasattr(est, "predict_proba"):
                        proba = est.predict_proba(X_tr[te2])[:, 1]
                    else:
                        proba = est.decision_function(X_tr[te2])
                    scores.append(float(score_fn(y_tr[te2], proba)))
                mean_s = float(np.mean(scores)) if scores else -np.inf
                if mean_s > best_score:
                    best_score, best_params = mean_s, params
            # Fallback to estimator defaults if inner loop produced no valid scores
            if best_params is None:
                best_params = {}

            # fit best on full outer-train
            best_est = clone(estimator).set_params(**best_params)
            best_est.fit(X_tr, y_tr)
            if hasattr(best_est, "predict_proba"):
                proba = best_est.predict_proba(X[te])[:, 1]
            else:
                proba = best_est.decision_function(X[te])
            pred = (
                (proba >= 0.5).astype(int) if proba.ndim == 1 else (proba[:, 1] >= 0.5).astype(int)
            )

            rows.append(
                {
                    "fold": k,
                    "balanced_accuracy": balanced_accuracy_score(y[te], pred),
                    "roc_auc": roc_auc_score(y[te], proba),
                    "auprc": average_precision_score(y[te], proba),
                    "f1": f1_score(y[te], pred),
                    "best_params": best_params,
                }
            )

        per_fold = pd.DataFrame(rows)
        summary = per_fold.drop(columns=["fold", "best_params"]).agg(["mean", "std"])
        return per_fold if return_per_fold else summary, summary


import numpy as np
from sklearn.model_selection import BaseCrossValidator
from sklearn.utils.validation import check_array


class EnvironmentalHealthCV(BaseCrossValidator):
    """Spatial block cross-validation tailored for environmental-health datasets."""

    def __init__(
        self,
        spatial_blocks=5,
        shuffle=True,
        random_state=None,
        *,
        environmental_vars=None,
        buffer_config=None,
        coordinates=None,
        timestamps=None,
    ):
        self.spatial_blocks = spatial_blocks
        self.shuffle = shuffle
        self.random_state = random_state
        self.environmental_vars = environmental_vars
        self.buffer_config = buffer_config or {}
        self._default_coordinates = coordinates
        self.timestamps = timestamps

    def get_n_splits(self, *args, **kwargs):
        return self.spatial_blocks

    def split(self, X, y=None, groups=None, **kwargs):
        # Accept either a feature matrix or a sequence of indices from the runner.
        if isinstance(X, range):
            indices = np.arange(len(X), dtype=int)
            feature_matrix = None
        else:
            X_arr = np.asarray(X)
            if X_arr.ndim == 1:
                indices = X_arr.astype(int)
                feature_matrix = None
            else:
                feature_matrix = check_array(X_arr, accept_sparse=False)
                indices = np.arange(feature_matrix.shape[0])

        n_samples = indices.shape[0]

        # Coordinates priority: per-call -> default -> first two features -> environmental covariates.
        coords = kwargs.get("coordinates", self._default_coordinates)

        if coords is None and feature_matrix is not None and feature_matrix.shape[1] >= 2:
            coords = feature_matrix[:, :2]

        env_vars = kwargs.get("environmental_vars", self.environmental_vars)
        buffer_cfg = kwargs.get("buffer_config", self.buffer_config)
        timestamps = kwargs.get("timestamps", self.timestamps)
        _ = (buffer_cfg, timestamps)  # reserved for future enhancements

        if coords is None and env_vars:
            # Use environmental covariates as proxy coordinates (deterministic order).
            ordered_keys = sorted(env_vars.keys())
            env_matrix = np.column_stack([np.asarray(env_vars[k]) for k in ordered_keys])
            env_matrix = check_array(env_matrix, ensure_2d=True)
            if env_matrix.shape[0] != n_samples:
                raise ValueError("Environmental variables must align with number of samples.")
            # If only one covariate, pad a zero column to form 2-D coordinates
            if env_matrix.shape[1] == 1:
                coords = np.column_stack([env_matrix[:, 0], np.zeros_like(env_matrix[:, 0])])
            else:
                coords = env_matrix[:, :2]

        if coords is None:
            raise ValueError(
                "EnvironmentalHealthCV requires `coordinates`, environmental covariates, "
                "or a feature matrix with at least two columns."
            )

        coords = np.asarray(coords)
        if coords.ndim != 2 or coords.shape[1] != 2:
            raise ValueError("Coordinates must be shaped (n_samples, 2).")
        if coords.shape[0] != n_samples:
            raise ValueError("Coordinate count does not match number of samples.")

        # Build spatial blocks using a simple grid.
        x_edges = np.linspace(coords[:, 0].min(), coords[:, 0].max(), self.spatial_blocks + 1)
        y_edges = np.linspace(coords[:, 1].min(), coords[:, 1].max(), self.spatial_blocks + 1)
        x_bins = np.digitize(coords[:, 0], x_edges[1:-1], right=False)
        y_bins = np.digitize(coords[:, 1], y_edges[1:-1], right=False)
        block_ids = x_bins * self.spatial_blocks + y_bins

        unique_blocks = np.unique(block_ids)
        if self.shuffle:
            rng = np.random.default_rng(self.random_state)
            rng.shuffle(unique_blocks)

        for block in unique_blocks:
            mask = block_ids == block
            if not mask.any():
                continue
            yield indices[~mask], indices[mask]


# ensure it's exported
try:
    __all__.append("EnvironmentalHealthCV")
except NameError:
    __all__ = ["EnvironmentalHealthCV"]

# Backward-compatibility alias for deprecated name
import warnings as _tcv_warnings


class MedicalValidator(TrustCVValidator):  # deprecated alias
    def __init__(self, *args, **kwargs):
        _tcv_warnings.warn(
            "MedicalValidator is deprecated and will be removed in v2.0. Use TrustCVValidator instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        super().__init__(*args, **kwargs)


# Ensure module exports include both names
try:
    __all__
except NameError:  # pragma: no cover
    __all__ = []

for _name in ("TrustCVValidator", "MedicalValidator"):
    if _name not in __all__:
        __all__.append(_name)
