"""
最终训练脚本：3-fold Optuna 调参 + 全量数据训练

1. 使用全量数据做 3-fold StratifiedKFold Optuna 超参搜索
2. 固定最优超参后，使用全量数据训练（不使用 early-stopping）
3. 使用 CosineAnnealingLR + 可选 SWA 防止过拟合

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

import json
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.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,
)
from lnp_ml.modeling.visualization import plot_multitask_loss_curves

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

app = typer.Typer()


# ============ CompositeStrata 复合分层标签（复用 nested_cv_optuna 的逻辑） ============

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)
    """
    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 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",
    # ============ 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,
) -> Union[LNPModel, LNPModelWithoutMPNN]:
    """创建模型"""
    moe_kwargs = dict(
        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,
    )

    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,
            **moe_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,
            **moe_kwargs,
        )


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

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


# ============ 3-fold Optuna 调参 ============
def run_optuna_cv(
    full_dataset: LNPDataset,
    strata: np.ndarray,
    device: torch.device,
    n_trials: int = 20,
    epochs_per_trial: int = 30,
    patience: int = 10,
    batch_size: int = 32,
    n_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,
) -> Tuple[Dict, int, optuna.Study]:
    """
    使用全量数据做 3-fold CV Optuna 超参搜索。
    
    Args:
        full_dataset: 完整数据集
        strata: 每个样本的分层标签
        device: 设备
        n_trials: Optuna 试验数
        epochs_per_trial: 每个试验的最大 epoch
        patience: 早停耐心值
        batch_size: 批次大小
        n_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")
    
    n_samples = len(full_dataset)
    indices = np.arange(n_samples)
    
    # 固定架构参数（与预训练一致，确保权重完整加载）
    _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)
        
        # 3-fold CV
        cv = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=seed)
        
        fold_val_losses = []
        fold_best_epochs = []
        
        for fold, (train_idx, val_idx) in enumerate(cv.split(indices, strata)):
            # 创建 DataLoader
            train_subset = Subset(full_dataset, train_idx.tolist())
            val_subset = Subset(full_dataset, 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_n_experts,
                moe_top_k=moe_top_k,
                moe_expert_hidden_mult=moe_expert_hidden_mult,
                moe_jitter_noise=moe_jitter_noise,
            )
            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)
        trial.set_user_attr("fold_val_losses", fold_val_losses)
        
        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="final_optuna_cv",
        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,
    })
    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 from best trial: {epoch_mean}")
    
    return best_params, epoch_mean, study


# ============ 主流程 ============
@app.command()
def main(
    input_path: Path = INTERIM_DATA_DIR / "internal.csv",
    output_dir: Path = MODELS_DIR / "final",
    # CV 参数
    n_folds: int = 3,
    min_stratum_count: int = 5,
    seed: int = 42,
    # Optuna 参数
    n_trials: int = 20,
    epochs_per_trial: int = 30,
    patience: int = 10,
    # 训练参数
    batch_size: int = 32,
    # 最终训练参数
    use_swa: bool = False,
    swa_start_ratio: float = 0.75,
    # 预训练权重
    init_from_pretrain: Optional[Path] = None,
    load_delivery_head: bool = False,
    # MPNN
    use_mpnn: bool = False,
    # 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,
    # 设备
    device: str = "cuda" if torch.cuda.is_available() else "cpu",
):
    """
    最终训练：3-fold Optuna 调参 + 全量数据训练。
    
    1. 使用全量数据做 3-fold StratifiedKFold Optuna 超参搜索
    2. 固定最优超参后，使用全量数据训练（不使用 early-stopping）
    3. epoch 数使用 3-fold CV 中 best trial 的 epoch_mean
    4. 使用 CosineAnnealingLR，可选 SWA
    
    使用 --init-from-pretrain 从预训练 checkpoint 初始化模型权重。
    """
    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)
    
    # 加载预训练权重（如果指定）
    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")
    
    # 创建输出目录
    output_dir.mkdir(parents=True, exist_ok=True)
    logger.info(f"Output directory: {output_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(output_dir / "strata_info.json", "w") as f:
        json.dump(strata_info, f, indent=2, default=str)
    
    # 创建完整数据集
    full_dataset = LNPDataset(df)
    
    # 预热 RDKit 缓存（在整个训练流程中共享）
    rdkit_cache = warmup_rdkit_cache(full_dataset.smiles)
    
    # 运行 Optuna 调参
    logger.info(f"\nRunning {n_folds}-fold Optuna with {n_trials} trials...")
    study_path = output_dir / "optuna_study.sqlite3"
    
    best_params, epoch_mean, study = run_optuna_cv(
        full_dataset=full_dataset,
        strata=strata,
        device=device,
        n_trials=n_trials,
        epochs_per_trial=epochs_per_trial,
        patience=patience,
        batch_size=batch_size,
        n_folds=n_folds,
        use_mpnn=use_mpnn,
        seed=seed,
        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,
    )
    
    # 保存最佳参数
    with open(output_dir / "best_params.json", "w") as f:
        json.dump(best_params, f, indent=2)
    
    with open(output_dir / "epoch_mean.json", "w") as f:
        json.dump({"epoch_mean": epoch_mean}, f)
    
    # 保存 Optuna 试验历史
    trials_history = []
    for trial in study.trials:
        trials_history.append({
            "number": trial.number,
            "value": trial.value,
            "params": trial.params,
            "user_attrs": trial.user_attrs,
            "state": str(trial.state),
        })
    
    with open(output_dir / "optuna_trials.json", "w") as f:
        json.dump(trials_history, f, indent=2)
    
    # 全量数据训练
    logger.info(f"\n{'='*60}")
    logger.info("FINAL TRAINING ON FULL DATA")
    logger.info(f"{'='*60}")
    logger.info(f"Using best params with epochs={epoch_mean}")
    logger.info(f"SWA: {use_swa}")
    
    # 创建全量 DataLoader
    full_loader = DataLoader(
        full_dataset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn
    )
    
    # 计算类权重
    class_weights = compute_class_weights_from_loader(full_loader)
    
    # 保存类权重信息
    class_weights_info = {
        "pdi": class_weights.pdi.tolist() if class_weights.pdi is not None else None,
        "ee": class_weights.ee.tolist() if class_weights.ee is not None else None,
        "toxic": class_weights.toxic.tolist() if class_weights.toxic is not None else None,
    }
    with open(output_dir / "class_weights.json", "w") as f:
        json.dump(class_weights_info, f, indent=2)
    
    # 创建模型
    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=moe_n_experts,
        moe_top_k=moe_top_k,
        moe_expert_hidden_mult=moe_expert_hidden_mult,
        moe_jitter_noise=moe_jitter_noise,
    )
    model.rdkit_encoder._cache = rdkit_cache
    
    # 加载预训练权重
    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("Loaded pretrain weights for final training")
    
    # 打印模型信息
    n_params_total = sum(p.numel() for p in model.parameters())
    n_params_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    logger.info(f"Model parameters: {n_params_total:,} total, {n_params_trainable:,} trainable")
    
    # 训练（固定 epoch，不 early-stop）
    swa_start = int(epoch_mean * swa_start_ratio) if use_swa else None
    
    train_result = train_fixed_epochs(
        model=model,
        train_loader=full_loader,
        val_loader=None,  # 全量训练，无验证集
        device=device,
        lr=best_params["lr"],
        weight_decay=best_params["weight_decay"],
        epochs=epoch_mean,
        class_weights=class_weights,
        use_cosine_annealing=True,
        use_swa=use_swa,
        swa_start_epoch=swa_start,
        backbone_lr_ratio=best_params.get("backbone_lr_ratio", 1.0),
    )
    
    # 加载最终权重
    model.load_state_dict(train_result["final_state"])
    
    # 保存模型
    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"],
        "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,
    }

    torch.save({
        "model_state_dict": train_result["final_state"],
        "config": config,
        "best_params": best_params,
        "epoch_mean": epoch_mean,
        "use_swa": use_swa,
    }, output_dir / "model.pt")
    
    logger.success(f"Saved model to {output_dir / 'model.pt'}")
    
    # 保存训练历史
    with open(output_dir / "history.json", "w") as f:
        json.dump(train_result["history"], f, indent=2)
    
    # 绘制损失曲线
    if train_result["history"]["train"]:
        try:
            plot_multitask_loss_curves(
                history=train_result["history"],
                output_path=output_dir / "loss_curves.png",
                title="Final Training Loss Curves",
            )
            logger.info(f"Saved loss curves to {output_dir / 'loss_curves.png'}")
        except Exception as e:
            logger.warning(f"Failed to plot loss curves: {e}")
    
    # 打印最终信息
    logger.info(f"\n{'='*60}")
    logger.info("FINAL TRAINING COMPLETE")
    logger.info(f"{'='*60}")
    logger.info(f"Best params: {best_params}")
    logger.info(f"Epochs trained: {epoch_mean}")
    logger.info(f"Output directory: {output_dir}")
    
    # 提示如何使用
    logger.info("\n[How to use the trained model]")
    logger.info(f"  1. Set environment variable: MODEL_PATH={output_dir / 'model.pt'}")
    logger.info("  2. Or copy to default location: cp {output_dir}/model.pt models/final/model.pt")
    logger.info("  3. Start API: make api")


if __name__ == "__main__":
    app()

