"""
嵌套交叉验证 + Optuna 超参调优

外层 5-fold StratifiedKFold（20% test / 80% train）
内层 3-fold StratifiedKFold（在 80% 上做 Optuna 超参搜索）

使用方法:
    python -m lnp_ml.modeling.nested_cv_optuna
    
或通过 Makefile:
    make nested_cv_tune DEVICE=cuda
"""

import json
import math
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union

import numpy as np
import pandas as pd
import torch
from torch.utils.data import DataLoader, Subset
from sklearn.model_selection import StratifiedKFold
from loguru import logger
import typer

try:
    import optuna
    from optuna.samplers import TPESampler
except ImportError:
    optuna = None
    TPESampler = None

from lnp_ml.config import MODELS_DIR, INTERIM_DATA_DIR
from lnp_ml.dataset import (
    LNPDataset,
    collate_fn,
    process_dataframe,
    TARGET_CLASSIFICATION_PDI,
    TARGET_CLASSIFICATION_EE,
    TARGET_TOXIC,
)
from tqdm import tqdm

from lnp_ml.modeling.models import LNPModel, LNPModelWithoutMPNN
from lnp_ml.modeling.layers.llm_prompt import DEFAULT_MOLT5_PATH
from lnp_ml.utils.seed import set_global_seed
from lnp_ml.modeling.encoders.rdkit_encoder import CachedRDKitEncoder
from lnp_ml.modeling.trainer_balanced import (
    ClassWeights,
    LossWeightsBalanced,
    compute_class_weights_from_loader,
    train_with_early_stopping,
    train_fixed_epochs,
    validate_balanced,
)

# MPNN ensemble 默认路径
DEFAULT_MPNN_ENSEMBLE_DIR = MODELS_DIR / "mpnn" / "all_amine_split_for_LiON"

app = typer.Typer()


# ============ CompositeStrata 复合分层标签 ============

def build_composite_strata(
    df: pd.DataFrame,
    min_stratum_count: int = 5,
) -> Tuple[np.ndarray, Dict]:
    """
    构建复合分层标签（toxic × PDI × EE）。
    
    Args:
        df: 处理后的 DataFrame
        min_stratum_count: 每个 stratum 最少样本数，低于此值合并为 RARE
    
    Returns:
        (strata_array, strata_info) 
        - strata_array: 每个样本的 stratum 编码（整数）
        - strata_info: 统计信息
    """
    n = len(df)
    strata_labels = []
    
    for i in range(n):
        # Toxic stratum
        if TARGET_TOXIC in df.columns:
            toxic_val = df[TARGET_TOXIC].iloc[i]
            if pd.notna(toxic_val) and toxic_val >= 0:
                toxic_str = str(int(toxic_val))
            else:
                toxic_str = "NA"
        else:
            toxic_str = "NA"
        
        # PDI stratum
        if all(col in df.columns for col in TARGET_CLASSIFICATION_PDI):
            pdi_vals = df[TARGET_CLASSIFICATION_PDI].iloc[i].values
            if pdi_vals.sum() > 0:
                pdi_str = str(int(np.argmax(pdi_vals)))
            else:
                pdi_str = "NA"
        else:
            pdi_str = "NA"
        
        # EE stratum
        if all(col in df.columns for col in TARGET_CLASSIFICATION_EE):
            ee_vals = df[TARGET_CLASSIFICATION_EE].iloc[i].values
            if ee_vals.sum() > 0:
                ee_str = str(int(np.argmax(ee_vals)))
            else:
                ee_str = "NA"
        else:
            ee_str = "NA"
        
        strata_labels.append(f"T{toxic_str}|P{pdi_str}|E{ee_str}")
    
    # 统计各 stratum 的样本数
    unique_strata, counts = np.unique(strata_labels, return_counts=True)
    strata_counts = dict(zip(unique_strata, counts))
    
    # 将稀疏 strata 合并为 RARE
    rare_strata = [s for s, c in strata_counts.items() if c < min_stratum_count]
    
    final_labels = []
    for label in strata_labels:
        if label in rare_strata:
            final_labels.append("RARE")
        else:
            final_labels.append(label)
    
    # 编码为整数
    unique_final, encoded = np.unique(final_labels, return_inverse=True)
    
    strata_info = {
        "original_strata_counts": strata_counts,
        "rare_strata": rare_strata,
        "final_strata": list(unique_final),
        "final_strata_counts": dict(zip(*np.unique(final_labels, return_counts=True))),
        "n_rare_merged": sum(strata_counts[s] for s in rare_strata) if rare_strata else 0,
    }
    
    logger.info(f"Built composite strata: {len(unique_final)} unique strata")
    logger.info(f"  Rare strata merged: {len(rare_strata)} types, {strata_info['n_rare_merged']} samples")
    
    return encoded.astype(np.int64), strata_info


# ============ RDKit 缓存预热 ============

def warmup_rdkit_cache(smiles_list: List[str], batch_size: int = 256) -> Dict:
    """预热 RDKit 特征缓存，返回可跨模型共享的缓存字典。"""
    encoder = CachedRDKitEncoder()
    unique_smiles = list(set(smiles_list))
    logger.info(f"Warming up RDKit cache for {len(unique_smiles)} unique SMILES...")
    for i in tqdm(range(0, len(unique_smiles), batch_size), desc="Cache warmup"):
        batch = unique_smiles[i:i + batch_size]
        encoder(batch)
    logger.success(f"Cache warmup complete. Cached {len(encoder._cache)} SMILES.")
    return encoder._cache


# ============ 模型创建 ============

def find_mpnn_ensemble_paths(base_dir: Path = DEFAULT_MPNN_ENSEMBLE_DIR) -> List[str]:
    """自动查找 MPNN ensemble 的 model.pt 文件。"""
    model_paths = sorted(base_dir.glob("cv_*/fold_*/model_*/model.pt"))
    if not model_paths:
        raise FileNotFoundError(f"No model.pt files found in {base_dir}")
    return [str(p) for p in model_paths]


def _load_external_retrieval_pool(csv_path="data/external/all_data_LiON.csv"):
    """读外部 LiON 数据，筛 mRNA+Mouse，按唯一分子聚合 delivery，返回 (smiles_list, labels[N,1])。"""
    import pandas as pd
    import numpy as np
    df = pd.read_csv(csv_path, low_memory=False)
    sub = df[(df["Cargo_type"] == "mRNA") & (df["Model_type"] == "Mouse")].copy()
    sub["quantified_delivery"] = pd.to_numeric(sub["quantified_delivery"], errors="coerce")
    sub = sub.dropna(subset=["quantified_delivery", "smiles"])
    # 同一分子多器官记录取平均，聚合成唯一分子
    agg = sub.groupby("smiles")["quantified_delivery"].mean()
    smiles_list = agg.index.tolist()
    labels = agg.values.reshape(-1, 1).astype("float32")
    return smiles_list, labels


def create_model(
    d_model: int = 256,
    num_heads: int = 8,
    n_attn_layers: int = 4,
    fusion_strategy: str = "attention",
    head_hidden_dim: int = 128,
    dropout: float = 0.1,
    use_mpnn: bool = False,
    mpnn_device: str = "cpu",
    set_transformer_block: str = "sab",
    # MoE
    use_moe: bool = False,
    moe_n_experts: int = 4,
    moe_top_k: int = 2,
    moe_expert_hidden_mult: int = 2,
    moe_jitter_noise: float = 0.0,
    # LLM（打包成 dict 以减少多进程参数传递）
    llm_kwargs: Optional[Dict] = None,
    # 检索增强
    use_retrieval: bool = False,
    retr_feature_dim: int = 0,
) -> Union[LNPModel, LNPModelWithoutMPNN]:
    """创建模型。llm_kwargs 含 use_llm / llm_model_path / llm_freeze / llm_use_lora / llm_lora_*。"""
    extra_kwargs = dict(
        set_transformer_block=set_transformer_block,
        use_moe=use_moe,
        moe_n_experts=moe_n_experts,
        moe_top_k=moe_top_k,
        moe_expert_hidden_mult=moe_expert_hidden_mult,
        moe_jitter_noise=moe_jitter_noise,
        use_retrieval=use_retrieval,
        retr_feature_dim=retr_feature_dim,
        **(llm_kwargs or {}),
    )

    if use_mpnn:
        ensemble_paths = find_mpnn_ensemble_paths()
        return LNPModel(
            d_model=d_model,
            num_heads=num_heads,
            n_attn_layers=n_attn_layers,
            fusion_strategy=fusion_strategy,
            head_hidden_dim=head_hidden_dim,
            dropout=dropout,
            mpnn_ensemble_paths=ensemble_paths,
            mpnn_device=mpnn_device,
            **extra_kwargs,
        )
    else:
        return LNPModelWithoutMPNN(
            d_model=d_model,
            num_heads=num_heads,
            n_attn_layers=n_attn_layers,
            fusion_strategy=fusion_strategy,
            head_hidden_dim=head_hidden_dim,
            dropout=dropout,
            **extra_kwargs,
        )


# ============ 评估指标 ============

def evaluate_on_test(
    model: torch.nn.Module,
    test_loader: DataLoader,
    device: torch.device,
) -> Dict:
    """在测试集上评估模型"""
    from scipy.special import rel_entr
    from sklearn.metrics import (
        mean_squared_error,
        mean_absolute_error,
        r2_score,
        accuracy_score,
        precision_score,
        recall_score,
        f1_score,
    )
    
    model.eval()
    
    preds = {
        "size": [], "delivery": [], "pdi": [], "ee": [], "toxic": [], "biodist": []
    }
    targets = {
        "size": [], "delivery": [], "pdi": [], "ee": [], "toxic": [], "biodist": []
    }
    
    with torch.no_grad():
        for batch in test_loader:
            smiles = batch["smiles"]
            tabular = {k: v.to(device) for k, v in batch["tabular"].items()}
            tgts = batch["targets"]
            masks = batch["mask"]
            
            outputs = model(smiles, tabular)
            
            # 收集预测和真实值
            for task in ["size", "delivery"]:
                if task in masks and masks[task].any():
                    m = masks[task]
                    key = task if task == "size" else "delivery"
                    preds[task].extend(outputs[key].squeeze(-1)[m].cpu().numpy().tolist())
                    targets[task].extend(tgts[key][m].cpu().numpy().tolist())
            
            for task in ["pdi", "ee", "toxic"]:
                if task in masks and masks[task].any():
                    m = masks[task]
                    preds[task].extend(outputs[task][m].argmax(dim=-1).cpu().numpy().tolist())
                    targets[task].extend(tgts[task][m].cpu().numpy().tolist())
            
            if "biodist" in masks and masks["biodist"].any():
                m = masks["biodist"]
                preds["biodist"].extend(outputs["biodist"][m].cpu().numpy().tolist())
                targets["biodist"].extend(tgts["biodist"][m].cpu().numpy().tolist())
    
    # 计算指标
    results = {}
    
    # 回归任务
    for task in ["size", "delivery"]:
        if preds[task]:
            p = np.array(preds[task])
            t = np.array(targets[task])
            results[task] = {
                "n_samples": len(p),
                "mse": float(mean_squared_error(t, p)),
                "rmse": float(np.sqrt(mean_squared_error(t, p))),
                "mae": float(mean_absolute_error(t, p)),
                "r2": float(r2_score(t, p)),
            }
    
    # 分类任务
    for task in ["pdi", "ee", "toxic"]:
        if preds[task]:
            p = np.array(preds[task])
            t = np.array(targets[task])
            results[task] = {
                "n_samples": len(p),
                "accuracy": float(accuracy_score(t, p)),
                "precision": float(precision_score(t, p, average="macro", zero_division=0)),
                "recall": float(recall_score(t, p, average="macro", zero_division=0)),
                "f1": float(f1_score(t, p, average="macro", zero_division=0)),
            }
    
    # 分布任务
    if preds["biodist"]:
        p = np.array(preds["biodist"])
        t = np.array(targets["biodist"])
        
        def kl_divergence(p_arr, q_arr, eps=1e-10):
            p_arr = np.clip(p_arr, eps, 1.0)
            q_arr = np.clip(q_arr, eps, 1.0)
            return float(np.sum(rel_entr(p_arr, q_arr), axis=-1).mean())
        
        def js_divergence(p_arr, q_arr, eps=1e-10):
            p_arr = np.clip(p_arr, eps, 1.0)
            q_arr = np.clip(q_arr, eps, 1.0)
            m = 0.5 * (p_arr + q_arr)
            return float(0.5 * (np.sum(rel_entr(p_arr, m), axis=-1) + np.sum(rel_entr(q_arr, m), axis=-1)).mean())
        
        results["biodist"] = {
            "n_samples": len(p),
            "kl_divergence": kl_divergence(t, p),
            "js_divergence": js_divergence(t, p),
        }
    
    return results


# ============ 预训练权重加载 ============

def load_pretrain_weights_to_model(
    model: Union[LNPModel, LNPModelWithoutMPNN],
    pretrain_state_dict: Dict,
    d_model: int,
    pretrain_config: Dict,
    load_delivery_head: bool = True,
) -> bool:
    """
    加载预训练权重到模型。
    
    Returns:
        是否成功加载
    """
    if pretrain_config.get("d_model") != d_model:
        logger.warning(
            f"d_model mismatch: pretrain={pretrain_config.get('d_model')}, "
            f"current={d_model}. Skipping pretrain loading."
        )
        return False
    
    model.load_pretrain_weights(
        pretrain_state_dict=pretrain_state_dict,
        load_delivery_head=load_delivery_head,
        strict=False,
    )
    return True


# ============ 内层 Optuna 调参 ============
def run_inner_optuna(
    full_dataset: LNPDataset,
    inner_train_indices: np.ndarray,
    strata: np.ndarray,
    device: torch.device,
    n_trials: int = 20,
    epochs_per_trial: int = 30,
    patience: int = 10,
    batch_size: int = 32,
    n_inner_folds: int = 3,
    use_mpnn: bool = False,
    seed: int = 42,
    study_path: Optional[Path] = None,
    pretrain_state_dict: Optional[Dict] = None,
    pretrain_config: Optional[Dict] = None,
    load_delivery_head: bool = True,
    rdkit_cache: Optional[Dict] = None,
    use_moe: bool = False,
    moe_n_experts: int = 4,
    moe_top_k: int = 2,
    moe_expert_hidden_mult: int = 2,
    moe_jitter_noise: float = 0.0,
    set_transformer_block: str = "sab",
    llm_kwargs: Optional[Dict] = None,
) -> Tuple[Dict, int, optuna.Study]:
    """
    在内层数据上运行 Optuna 超参搜索。
    
    Args:
        full_dataset: 完整数据集
        inner_train_indices: 内层训练数据的索引（相对于 full_dataset）
        strata: 每个样本的分层标签
        device: 设备
        n_trials: Optuna 试验数
        epochs_per_trial: 每个试验的最大 epoch
        patience: 早停耐心值
        batch_size: 批次大小
        n_inner_folds: 内层折数
        use_mpnn: 是否使用 MPNN
        seed: 随机种子
        study_path: 可选的 study 持久化路径
        pretrain_state_dict: 预训练权重
        pretrain_config: 预训练配置
        load_delivery_head: 是否加载 delivery head 权重
    
    Returns:
        (best_params, epoch_mean, study)
    """
    if optuna is None:
        raise ImportError("Optuna not installed. Run: pip install optuna")
    
    inner_strata = strata[inner_train_indices]
    
    # 固定架构参数（与预训练一致，确保权重完整加载）
    _cfg = pretrain_config or {}
    fixed_d_model = _cfg.get("d_model", 256)
    fixed_num_heads = _cfg.get("num_heads", 8)
    fixed_n_attn_layers = _cfg.get("n_attn_layers", 4)
    fixed_fusion_strategy = _cfg.get("fusion_strategy", "attention")
    fixed_head_hidden_dim = _cfg.get("head_hidden_dim", 128)
    logger.info(
        f"Fixed architecture params: d_model={fixed_d_model}, num_heads={fixed_num_heads}, "
        f"n_attn_layers={fixed_n_attn_layers}, fusion={fixed_fusion_strategy}, "
        f"head_hidden_dim={fixed_head_hidden_dim}"
    )
    
    def objective(trial: optuna.Trial) -> float:
        d_model = fixed_d_model
        num_heads = fixed_num_heads
        n_attn_layers = fixed_n_attn_layers
        fusion_strategy = fixed_fusion_strategy
        head_hidden_dim = fixed_head_hidden_dim
        
        # 搜索训练超参数
        dropout = trial.suggest_float("dropout", 0.1, 0.5)
        lr = trial.suggest_float("lr", 1e-5, 1e-3, log=True)
        weight_decay = trial.suggest_float("weight_decay", 1e-5, 1e-1, log=True)
        backbone_lr_ratio = trial.suggest_float("backbone_lr_ratio", 0.01, 1.0, log=True)
        
        # MoE / LLM
        use_llm = bool(llm_kwargs.get("use_llm", False))
        moe_ne_t = trial.suggest_categorical("moe_n_experts", [2, 4]) if use_moe else moe_n_experts
        moe_tk_t = trial.suggest_int("moe_top_k", 1, 2) if use_moe else moe_top_k
        moe_hm_t = trial.suggest_categorical("moe_expert_hidden_mult", [1, 2]) if use_moe else moe_expert_hidden_mult
        llm_kwargs_t = dict(llm_kwargs)
        if use_llm:
            llm_kwargs_t["llm_use_lora"] = True
            llm_kwargs_t["llm_lora_r"] = trial.suggest_categorical("llm_lora_r", [8, 16, 32])

        # 内层 3-fold CV
        inner_cv = StratifiedKFold(
            n_splits=n_inner_folds, shuffle=True, random_state=seed
        )
        
        fold_val_losses = []
        fold_best_epochs = []
        
        for inner_fold, (inner_train_idx, inner_val_idx) in enumerate(
            inner_cv.split(inner_train_indices, inner_strata)
        ):
            # 获取实际的数据集索引
            actual_train_idx = inner_train_indices[inner_train_idx]
            actual_val_idx = inner_train_indices[inner_val_idx]
            
            # 创建 DataLoader
            train_subset = Subset(full_dataset, actual_train_idx.tolist())
            val_subset = Subset(full_dataset, actual_val_idx.tolist())
            
            train_loader = DataLoader(
                train_subset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn
            )
            val_loader = DataLoader(
                val_subset, batch_size=batch_size, shuffle=False, collate_fn=collate_fn
            )
            
            # 计算类权重
            class_weights = compute_class_weights_from_loader(train_loader)
            
            # 创建模型
            model = create_model(
                d_model=d_model,
                num_heads=num_heads,
                n_attn_layers=n_attn_layers,
                fusion_strategy=fusion_strategy,
                head_hidden_dim=head_hidden_dim,
                dropout=dropout,
                use_mpnn=use_mpnn,
                mpnn_device=device.type,
                use_moe=use_moe,
                moe_n_experts=moe_ne_t,
                moe_top_k=moe_tk_t,
                moe_expert_hidden_mult=moe_hm_t,
                moe_jitter_noise=moe_jitter_noise,
                set_transformer_block=set_transformer_block,
                llm_kwargs=llm_kwargs_t,
            )
            if rdkit_cache is not None:
                model.rdkit_encoder._cache = rdkit_cache
            
            # 加载预训练权重
            if pretrain_state_dict is not None and pretrain_config is not None:
                load_pretrain_weights_to_model(
                    model, pretrain_state_dict, d_model, pretrain_config, load_delivery_head
                )
            
            # 训练（带早停）
            result = train_with_early_stopping(
                model=model,
                train_loader=train_loader,
                val_loader=val_loader,
                device=device,
                lr=lr,
                weight_decay=weight_decay,
                epochs=epochs_per_trial,
                patience=patience,
                class_weights=class_weights,
                backbone_lr_ratio=backbone_lr_ratio,
            )
            
            fold_val_losses.append(result["best_val_loss"])
            fold_best_epochs.append(result["best_epoch"])
        
        # 记录 epoch_mean 到 trial
        epoch_mean = int(round(np.mean(fold_best_epochs)))
        trial.set_user_attr("epoch_mean", epoch_mean)
        trial.set_user_attr("fold_best_epochs", fold_best_epochs)
        
        return np.mean(fold_val_losses)
    
    # 创建 study
    storage = None
    if study_path is not None:
        storage = f"sqlite:///{study_path}"
    
    study = optuna.create_study(
        direction="minimize",
        sampler=TPESampler(seed=seed),
        storage=storage,
        study_name="inner_optuna",
        load_if_exists=True,
    )
    
    study.optimize(objective, n_trials=n_trials, show_progress_bar=True)
    
    best_params = dict(study.best_trial.params)
    best_params.update({
        "d_model": fixed_d_model,
        "num_heads": fixed_num_heads,
        "n_attn_layers": fixed_n_attn_layers,
        "fusion_strategy": fixed_fusion_strategy,
        "head_hidden_dim": fixed_head_hidden_dim,
        "set_transformer_block": set_transformer_block,
    })
    epoch_mean = study.best_trial.user_attrs.get("epoch_mean", epochs_per_trial)
    
    logger.info(f"Best trial: {study.best_trial.number}")
    logger.info(f"Best val_loss: {study.best_trial.value:.4f}")
    logger.info(f"Best params: {best_params}")
    logger.info(f"Epoch mean: {epoch_mean}")
    
    return best_params, epoch_mean, study


# ============ 单 fold 执行（可跨进程调用） ============
def _run_single_outer_fold(
    outer_fold: int,
    outer_train_idx: np.ndarray,
    outer_test_idx: np.ndarray,
    df: pd.DataFrame,
    strata: np.ndarray,
    fold_dir: Path,
    n_trials: int,
    epochs_per_trial: int,
    inner_patience: int,
    batch_size: int,
    n_inner_folds: int,
    use_mpnn: bool,
    seed: int,
    pretrain_state_dict: Optional[Dict],
    pretrain_config: Optional[Dict],
    load_delivery_head: bool,
    device_str: str,
    use_retrieval: bool = False,
    retrieval_source: str = "internal",
    use_moe: bool = False,
    moe_n_experts: int = 4,
    moe_top_k: int = 2,
    moe_expert_hidden_mult: int = 2,
    moe_jitter_noise: float = 0.0,
    set_transformer_block: str = "sab",
    llm_kwargs: Optional[Dict] = None,
    precomputed_best_params: Optional[Dict] = None,
    precomputed_epoch_mean: Optional[int] = None,
) -> Dict:
    """
    执行单个外层 fold 的完整流程（内层调参 + 外层训练 + 评估）。

    所有参数均为可序列化类型，以支持 spawn 多进程。
    """
    device = torch.device(device_str)
    set_global_seed(seed + outer_fold)
    fold_dir = Path(fold_dir)
    fold_dir.mkdir(parents=True, exist_ok=True)

    full_dataset = LNPDataset(df)
    # === 断点续跑:已完成的 fold 直接跳过(读回磁盘结果)===
    _tm = fold_dir / "test_metrics.json"
    _bp = fold_dir / "best_params.json"
    _em = fold_dir / "epoch_mean.json"
    if precomputed_best_params is None and _tm.exists() and _bp.exists() and _em.exists():
        logger.success(f"[SKIP] Outer fold {outer_fold} already done, loading cached results.")
        with open(_tm) as _f: _tmd = json.load(_f)
        with open(_bp) as _f: _bpd = json.load(_f)
        with open(_em) as _f: _emd = json.load(_f)
        return {
            "fold": outer_fold,
            "best_params": _bpd,
            "epoch_mean": int(_emd.get("epoch_mean", _emd) if isinstance(_emd, dict) else _emd),
            "test_metrics": _tmd,
        }

    logger.info(f"\n{'='*60}")
    logger.info(f"OUTER FOLD {outer_fold}")
    logger.info(f"{'='*60}")
    logger.info(f"Train: {len(outer_train_idx)}, Test: {len(outer_test_idx)}")

    # 预热 RDKit 缓存（在整个 fold 内共享）
    rdkit_cache = warmup_rdkit_cache(full_dataset.smiles)

    # 保存 split indices
    with open(fold_dir / "splits.json", "w") as f:
        json.dump({
            "outer_train_idx": outer_train_idx.tolist(),
            "outer_test_idx": outer_test_idx.tolist(),
        }, f)

    # 内层 Optuna 调参（方式 B：已有超参则跳过，直接复用）
    if precomputed_best_params is not None and precomputed_epoch_mean is not None:
        logger.info("Reusing precomputed best_params (skip inner Optuna).")
        best_params = dict(precomputed_best_params)
        epoch_mean = int(precomputed_epoch_mean)
    else:
        logger.info(f"\nRunning inner Optuna with {n_trials} trials...")
        study_path = fold_dir / "optuna_study.sqlite3"
        best_params, epoch_mean, study = run_inner_optuna(
            full_dataset=full_dataset,
            inner_train_indices=outer_train_idx,
            strata=strata,
            device=device,
            n_trials=n_trials,
            epochs_per_trial=epochs_per_trial,
            patience=inner_patience,
            batch_size=batch_size,
            n_inner_folds=n_inner_folds,
            use_mpnn=use_mpnn,
            seed=seed + outer_fold,
            study_path=study_path,
            pretrain_state_dict=pretrain_state_dict,
            pretrain_config=pretrain_config,
            load_delivery_head=load_delivery_head,
            rdkit_cache=rdkit_cache,
            use_moe=use_moe,
            moe_n_experts=moe_n_experts,
            moe_top_k=moe_top_k,
            moe_expert_hidden_mult=moe_expert_hidden_mult,
            moe_jitter_noise=moe_jitter_noise,
            set_transformer_block=set_transformer_block,
            llm_kwargs=llm_kwargs,
        )

    # 保存最佳参数
    with open(fold_dir / "best_params.json", "w") as f:
        json.dump(best_params, f, indent=2)
    with open(fold_dir / "epoch_mean.json", "w") as f:
        json.dump({"epoch_mean": epoch_mean}, f)

    # 外层训练（使用最优超参，固定 epoch 数，不 early-stop）
    logger.info(f"\nTraining outer fold with best params, epochs={epoch_mean}...")

    # 从 outer_train 再切 10% 作为"监控用 val"（只画曲线、看 gap，不参与选模型/早停）
    _rng = np.random.RandomState(seed + outer_fold)
    _perm = _rng.permutation(len(outer_train_idx))
    _n_val = max(1, int(0.1 * len(outer_train_idx)))
    fit_idx = outer_train_idx[_perm[_n_val:]]
    monitor_idx = outer_train_idx[_perm[:_n_val]]

    train_subset = Subset(full_dataset, fit_idx.tolist())
    monitor_subset = Subset(full_dataset, monitor_idx.tolist())
    test_subset = Subset(full_dataset, outer_test_idx.tolist())

    train_loader = DataLoader(
        train_subset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn
    )
    monitor_loader = DataLoader(
        monitor_subset, batch_size=batch_size, shuffle=False, collate_fn=collate_fn
    )
    test_loader = DataLoader(
        test_subset, batch_size=batch_size, shuffle=False, collate_fn=collate_fn
    )

    class_weights = compute_class_weights_from_loader(train_loader)

    model = create_model(
        d_model=best_params["d_model"],
        num_heads=best_params["num_heads"],
        n_attn_layers=best_params["n_attn_layers"],
        fusion_strategy=best_params["fusion_strategy"],
        head_hidden_dim=best_params["head_hidden_dim"],
        dropout=best_params["dropout"],
        use_mpnn=use_mpnn,
        mpnn_device=device.type,
        use_moe=use_moe,
        moe_n_experts=best_params.get("moe_n_experts", moe_n_experts),
        moe_top_k=best_params.get("moe_top_k", moe_top_k),
        moe_expert_hidden_mult=best_params.get("moe_expert_hidden_mult", moe_expert_hidden_mult),
        moe_jitter_noise=moe_jitter_noise,
        set_transformer_block=best_params.get("set_transformer_block", "sab"),
        llm_kwargs={**llm_kwargs, **({"llm_use_lora": True, "llm_lora_r": best_params["llm_lora_r"]}
                                     if (llm_kwargs.get("use_llm", False) and "llm_lora_r" in best_params) else {})},
        use_retrieval=use_retrieval,
        retr_feature_dim=(3 if use_retrieval else 0),
    )
    model.rdkit_encoder._cache = rdkit_cache
    if use_retrieval:
        from lnp_ml.modeling.retrieval import MorganRetriever
        if retrieval_source == "external":
            _tr_smiles, _tr_labels = _load_external_retrieval_pool()
            logger.info(f"[retrieval] 外部检索池(mRNA+Mouse)={len(_tr_smiles)} 分子")
        else:
            _tr_smiles = [full_dataset.smiles[i] for i in outer_train_idx]
            _tr_labels = full_dataset.delivery[outer_train_idx].reshape(-1, 1)
            logger.info(f"[retrieval] 内部检索池={len(_tr_smiles)} 训练分子（不含测试集）")
        model.retriever = MorganRetriever(_tr_smiles, _tr_labels, k=5)

    # ===== RAG 检索池设置（防泄漏：只用训练集分子+标签）=====
    if llm_kwargs and llm_kwargs.get("use_rag", False) and getattr(model, "llm_prompt", None) is not None:
        _rag_smiles = [full_dataset.smiles[i] for i in outer_train_idx]
        _rag_labels = full_dataset.delivery[outer_train_idx].reshape(-1)
        model.llm_prompt.set_retrieval_pool(
            _rag_smiles, _rag_labels, pool_id=f"fold{outer_fold}")
        logger.info(f"[RAG] 检索池={len(_rag_smiles)} 训练分子（不含测试集，防泄漏）")

    if pretrain_state_dict is not None and pretrain_config is not None:
        loaded = load_pretrain_weights_to_model(
            model, pretrain_state_dict, best_params["d_model"],
            pretrain_config, load_delivery_head
        )
        if loaded:
            logger.info(f"Loaded pretrain weights for outer fold {outer_fold}")

    train_result = train_fixed_epochs(
        model=model,
        train_loader=train_loader,
        val_loader=monitor_loader,
        device=device,
        lr=best_params["lr"],
        weight_decay=best_params["weight_decay"],
        epochs=epoch_mean,
        class_weights=class_weights,
        use_cosine_annealing=True,
        backbone_lr_ratio=best_params.get("backbone_lr_ratio", 1.0),
        freeze_backbone_epochs=3,
    )

    model.load_state_dict(train_result["final_state"])
    model = model.to(device)

    config = {
        "d_model": best_params["d_model"],
        "num_heads": best_params["num_heads"],
        "n_attn_layers": best_params["n_attn_layers"],
        "fusion_strategy": best_params["fusion_strategy"],
        "head_hidden_dim": best_params["head_hidden_dim"],
        "set_transformer_block": best_params.get("set_transformer_block", "sab"),
        "dropout": best_params["dropout"],
        "use_mpnn": use_mpnn,
        "use_moe": use_moe,
        "moe_n_experts": moe_n_experts,
        "moe_top_k": moe_top_k,
        "moe_expert_hidden_mult": moe_expert_hidden_mult,
        "moe_jitter_noise": moe_jitter_noise,
        **(llm_kwargs or {}),
    }

    _full_state = train_result["final_state"]
    _slim_state = {k: v for k, v in _full_state.items()
                   if not k.startswith("llm_prompt.encoder")}
    torch.save({
        "model_state_dict": _slim_state,
        "config": config,
        "epoch_mean": epoch_mean,
        "best_params": best_params,
    }, fold_dir / "model.pt")

    with open(fold_dir / "history.json", "w") as f:
        json.dump(train_result["history"], f, indent=2)

    # 在测试集上评估
    logger.info("Evaluating on outer test set...")
    test_metrics = evaluate_on_test(model, test_loader, device)

    with open(fold_dir / "test_metrics.json", "w") as f:
        json.dump(test_metrics, f, indent=2)

    logger.info(f"\nOuter Fold {outer_fold} Test Results:")
    for task, metrics in test_metrics.items():
        if "rmse" in metrics:
            logger.info(f"  {task}: RMSE={metrics['rmse']:.4f}, R²={metrics['r2']:.4f}")
        elif "accuracy" in metrics:
            logger.info(f"  {task}: Acc={metrics['accuracy']:.4f}, F1={metrics['f1']:.4f}")
        elif "kl_divergence" in metrics:
            logger.info(f"  {task}: KL={metrics['kl_divergence']:.4f}, JS={metrics['js_divergence']:.4f}")

    return {
        "fold": outer_fold,
        "best_params": best_params,
        "epoch_mean": epoch_mean,
        "test_metrics": test_metrics,
    }


# ============ 主流程 ============
@app.command()
def main(
    input_path: Path = INTERIM_DATA_DIR / "internal.csv",
    output_dir: Path = MODELS_DIR / "nested_cv",
    # CV 参数
    n_outer_folds: int = 5,
    n_inner_folds: int = 3,
    min_stratum_count: int = 5,
    seed: int = 42,
    # Optuna 参数
    n_trials: int = 20,
    epochs_per_trial: int = 30,
    inner_patience: int = 10,
    # 训练参数
    batch_size: int = 32,
    # 预训练权重
    init_from_pretrain: Optional[Path] = None,
    load_delivery_head: bool = False,
    # MPNN
    use_mpnn: bool = False,
    n_repeats: int = 1,
    repeat_seed_step: int = 1000,
    # MoE（消融开关）
    use_moe: bool = False,
    moe_n_experts: int = 4,
    moe_top_k: int = 2,
    moe_expert_hidden_mult: int = 2,
    moe_jitter_noise: float = 0.0,
    # Set Transformer
    set_transformer_block: str = "sab",
    # LLM（消融开关）
    use_llm: bool = False,
    use_rag: bool = False,
    rag_top_k: int = 4,
    use_retrieval: bool = False,
    retrieval_source: str = "internal",
    llm_model_path: str = DEFAULT_MOLT5_PATH,
    llm_freeze: bool = True,
    llm_use_lora: bool = False,
    llm_lora_r: int = 8,
    llm_lora_alpha: int = 16,
    llm_lora_dropout: float = 0.05,
    # 并行
    parallel: bool = False,
    # 设备
    device: str = "cuda" if torch.cuda.is_available() else "cpu",
):
    """
    嵌套交叉验证 + Optuna 超参调优。
    
    外层 5-fold（20% test / 80% train），内层 3-fold Optuna 调参。
    外层训练不使用 early-stopping，epoch 数使用内层 best trial 的 epoch_mean。
    
    使用 --init-from-pretrain 从预训练 checkpoint 初始化模型权重。
    使用 --parallel 同时运行所有外层 fold（需要足够 GPU 显存）。
    """
    if optuna is None:
        logger.error("Optuna not installed. Run: pip install optuna")
        raise typer.Exit(1)
    
    logger.info(f"Using device: {device}")
    device = torch.device(device)
    set_global_seed(seed)

    llm_kwargs = dict(
        use_rag=use_rag,
        rag_top_k=rag_top_k,
        use_llm=use_llm,
        llm_model_path=llm_model_path,
        llm_freeze=llm_freeze,
        llm_use_lora=llm_use_lora,
        llm_lora_r=llm_lora_r,
        llm_lora_alpha=llm_lora_alpha,
        llm_lora_dropout=llm_lora_dropout,
    )
    
    # 加载预训练权重（如果指定）
    pretrain_state_dict = None
    pretrain_config = None
    if init_from_pretrain is not None:
        if init_from_pretrain.exists():
            logger.info(f"Loading pretrain weights from {init_from_pretrain}")
            checkpoint = torch.load(init_from_pretrain, map_location="cpu", weights_only=False)
            pretrain_state_dict = checkpoint["model_state_dict"]
            pretrain_config = checkpoint.get("config", {})
            logger.success(f"Loaded pretrain checkpoint (d_model={pretrain_config.get('d_model')})")
        else:
            logger.warning(f"Pretrain checkpoint not found: {init_from_pretrain}, skipping")
    
    # 创建输出目录（带时间戳）
    run_name = datetime.now().strftime("%Y%m%d_%H%M%S")
    run_dir = output_dir / run_name
    run_dir.mkdir(parents=True, exist_ok=True)
    logger.info(f"Output directory: {run_dir}")
    
    # 加载数据
    logger.info(f"Loading data from {input_path}")
    df = pd.read_csv(input_path)
    logger.info(f"Loaded {len(df)} samples")
    
    # 处理数据
    logger.info("Processing dataframe...")
    df = process_dataframe(df)
    
    # 构建复合分层标签
    logger.info("Building composite strata...")
    strata, strata_info = build_composite_strata(df, min_stratum_count)
    
    # 保存 strata 信息
    with open(run_dir / "strata_info.json", "w") as f:
        json.dump(strata_info, f, indent=2, default=str)
    
    # 创建完整数据集（仅用于获取样本数做 split）
    n_samples = len(LNPDataset(df))
    
    # 外层 CV split
    outer_cv = StratifiedKFold(
        n_splits=n_outer_folds, shuffle=True, random_state=seed
    )
    
    device_str = str(device)
    fold_args = []
    for outer_fold, (outer_train_idx, outer_test_idx) in enumerate(
        outer_cv.split(np.arange(n_samples), strata)
    ):
        fold_args.append(dict(
            outer_fold=outer_fold,
            outer_train_idx=outer_train_idx,
            outer_test_idx=outer_test_idx,
            df=df,
            strata=strata,
            fold_dir=run_dir / f"outer_fold_{outer_fold}",
            n_trials=n_trials,
            epochs_per_trial=epochs_per_trial,
            inner_patience=inner_patience,
            batch_size=batch_size,
            n_inner_folds=n_inner_folds,
            use_mpnn=use_mpnn,
            seed=seed,
            pretrain_state_dict=pretrain_state_dict,
            pretrain_config=pretrain_config,
            load_delivery_head=load_delivery_head,
            device_str=device_str,
            use_retrieval=use_retrieval,
            retrieval_source=retrieval_source,
            use_moe=use_moe,
            moe_n_experts=moe_n_experts,
            moe_top_k=moe_top_k,
            moe_expert_hidden_mult=moe_expert_hidden_mult,
            moe_jitter_noise=moe_jitter_noise,
            set_transformer_block=set_transformer_block,
            llm_kwargs=llm_kwargs,
        ))
    
    if parallel:
        import multiprocessing as mp
        from concurrent.futures import ProcessPoolExecutor
        
        ctx = mp.get_context("spawn")
        logger.info(f"Running {n_outer_folds} outer folds in PARALLEL (spawn)")
        with ProcessPoolExecutor(max_workers=n_outer_folds, mp_context=ctx) as executor:
            futures = [executor.submit(_run_single_outer_fold, **args) for args in fold_args]
            outer_results = [f.result() for f in futures]
        outer_results.sort(key=lambda r: r["fold"])
    else:
        logger.info(f"Running {n_outer_folds} outer folds SEQUENTIALLY")
        outer_results = []
        per_fold_params = {}
        for args in fold_args:
            result = _run_single_outer_fold(**args)
            outer_results.append(result)
            per_fold_params[result["fold"]] = (result["best_params"], result["epoch_mean"])

        for rep in range(1, n_repeats):
            logger.info(f"\n===== Repeat {rep}/{n_repeats - 1} (reuse hyperparams) =====")
            for args in fold_args:
                bp, em = per_fold_params[args["outer_fold"]]
                rep_args = dict(args)
                rep_args["seed"] = seed + rep * repeat_seed_step
                rep_args["fold_dir"] = run_dir / f"repeat_{rep}" / f"outer_fold_{args['outer_fold']}"
                rep_args["precomputed_best_params"] = bp
                rep_args["precomputed_epoch_mean"] = em
                outer_results.append(_run_single_outer_fold(**rep_args))
    
    # 汇总结果
    logger.info("\n" + "=" * 60)
    logger.info("NESTED CV COMPLETE")
    logger.info("=" * 60)
    
    # 计算汇总统计
    summary = {"fold_results": outer_results}
    
    # 对每个任务计算均值和标准差
    tasks_with_metrics = {}
    for result in outer_results:
        for task, metrics in result["test_metrics"].items():
            if task not in tasks_with_metrics:
                tasks_with_metrics[task] = {k: [] for k in metrics.keys() if k != "n_samples"}
            for k, v in metrics.items():
                if k != "n_samples":
                    tasks_with_metrics[task][k].append(v)
    
    summary["summary_stats"] = {}
    for task, metrics_dict in tasks_with_metrics.items():
        summary["summary_stats"][task] = {}
        for metric_name, values in metrics_dict.items():
            summary["summary_stats"][task][f"{metric_name}_mean"] = float(np.mean(values))
            summary["summary_stats"][task][f"{metric_name}_std"] = float(np.std(values))
    
    # 打印汇总
    logger.info("\n[Summary Statistics]")
    for task, stats in summary["summary_stats"].items():
        if "rmse_mean" in stats:
            logger.info(
                f"  {task}: RMSE={stats['rmse_mean']:.4f}±{stats['rmse_std']:.4f}, "
                f"R²={stats['r2_mean']:.4f}±{stats['r2_std']:.4f}"
            )
        elif "accuracy_mean" in stats:
            logger.info(
                f"  {task}: Acc={stats['accuracy_mean']:.4f}±{stats['accuracy_std']:.4f}, "
                f"F1={stats['f1_mean']:.4f}±{stats['f1_std']:.4f}"
            )
        elif "kl_divergence_mean" in stats:
            logger.info(
                f"  {task}: KL={stats['kl_divergence_mean']:.4f}±{stats['kl_divergence_std']:.4f}, "
                f"JS={stats['js_divergence_mean']:.4f}±{stats['js_divergence_std']:.4f}"
            )
    
    # 保存汇总
    with open(run_dir / "summary.json", "w") as f:
        json.dump(summary, f, indent=2)
    
    logger.success(f"\nAll results saved to {run_dir}")


if __name__ == "__main__":
    app()

