"""
train/pretrain.py — Main pretraining entry point.

Launch single-GPU:
    python train/pretrain.py --config configs/small.yaml --train_data data/train.bin

Launch multi-GPU with torchrun:
    torchrun --nproc_per_node=8 train/pretrain.py --config configs/small.yaml \
        --train_data data/train.bin

The script auto-detects whether it is running inside a torchrun launch by
checking for the RANK environment variable.
"""

from __future__ import annotations

import argparse
import os
import random
import signal
import sys
from pathlib import Path

import numpy as np
import torch
from torch.utils.data import DataLoader, DistributedSampler, RandomSampler

# B200 Tensor Core 최대 활용: TF32 matmul + cuDNN
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
torch.backends.cudnn.benchmark = True  # fixed seq_len=4096 → safe to auto-tune
torch.set_float32_matmul_precision("high")  # TF32 precision for fp32 matmul

# Allow imports from the project root regardless of working directory.
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(_PROJECT_ROOT))

from data import PackedDataset
from model import LLM, LMConfig
from train.trainer import TrainConfig, Trainer
from train.utils import (
    cleanup_ddp,
    get_cosine_schedule_with_warmup,
    is_main_process,
    load_checkpoint,
    setup_ddp,
)

# ---------------------------------------------------------------------------
# Optional TransformerEngine import (FP8 support)
# ---------------------------------------------------------------------------
try:
    import transformer_engine.pytorch as te  # type: ignore[import]
    HAS_TE = True
except ImportError:
    te = None  # type: ignore[assignment]
    HAS_TE = False


# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Pretrain a decoder-only LLM.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )

    # Paths
    parser.add_argument(
        "--config",
        type=Path,
        default=Path("configs/small.yaml"),
        help="Path to the LMConfig YAML file.",
    )
    parser.add_argument(
        "--train_data",
        type=Path,
        required=True,
        help="Path to the training data .bin file (numpy uint16 memmap).",
    )
    parser.add_argument(
        "--val_data",
        type=Path,
        default=None,
        help="Optional path to validation data .bin file.",
    )
    parser.add_argument(
        "--checkpoint_dir",
        type=Path,
        default=Path("checkpoints"),
        help="Root directory for saving checkpoints.",
    )
    parser.add_argument(
        "--resume",
        type=Path,
        default=None,
        help="Path to a checkpoint directory to resume training from.",
    )

    # Training hyper-parameters
    parser.add_argument(
        "--max_steps",
        type=int,
        default=None,
        help="Override the number of optimiser steps (default: TrainConfig.max_steps).",
    )
    parser.add_argument(
        "--batch_size",
        type=int,
        default=8,
        help="Per-GPU micro-batch size.",
    )
    parser.add_argument(
        "--lr",
        type=float,
        default=3e-4,
        help="Peak learning rate.",
    )
    parser.add_argument(
        "--weight_decay",
        type=float,
        default=0.1,
        help="AdamW weight decay coefficient.",
    )
    parser.add_argument(
        "--warmup_steps",
        type=int,
        default=2000,
        help="Number of linear warmup steps.",
    )
    parser.add_argument(
        "--grad_accum",
        type=int,
        default=1,
        help="Gradient accumulation steps.",
    )
    parser.add_argument(
        "--seed",
        type=int,
        default=42,
        help="Base random seed (rank offset is added automatically).",
    )
    parser.add_argument(
        "--log_file",
        type=Path,
        default=None,
        help="Path to a text file for structured training logs (rank-0 only). "
             "If omitted, logs go only to stdout.",
    )
    parser.add_argument(
        "--use_fp8",
        action="store_true",
        default=False,
        help="Enable TransformerEngine FP8 training (overrides config; requires B200/H100).",
    )

    return parser.parse_args()


# ---------------------------------------------------------------------------
# Seed helper
# ---------------------------------------------------------------------------


def set_seed(seed: int) -> None:
    """Set deterministic seeds for Python, NumPy, and PyTorch."""
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)


# ---------------------------------------------------------------------------
# Optimizer parameter groups
# ---------------------------------------------------------------------------


def build_optimizer_param_groups(
    model: torch.nn.Module,
    weight_decay: float,
) -> list[dict]:
    """
    Split parameters into two groups:
      - decay group  : weight tensors with ndim >= 2
      - no-decay group: bias, LayerNorm/RMSNorm weights, and embedding weights

    This follows standard practice (e.g. GPT-style training).
    """
    decay_params: list[torch.nn.Parameter] = []
    no_decay_params: list[torch.nn.Parameter] = []

    # Names of module types whose parameters should never be decayed.
    no_decay_module_types = (
        torch.nn.Embedding,
        torch.nn.LayerNorm,
    )
    # Also skip any parameter whose name ends with '.bias' or 'norm'.
    # Mamba-2 SSM parameters that should never be decayed
    no_decay_name_suffixes = ("bias", "A_log", "D", "dt_bias")

    # Collect module-level exclusions.
    no_decay_module_params: set[int] = set()
    for module in model.modules():
        if isinstance(module, no_decay_module_types):
            for param in module.parameters(recurse=False):
                no_decay_module_params.add(id(param))

    seen: set[int] = set()
    for name, param in model.named_parameters():
        if not param.requires_grad:
            continue
        if id(param) in seen:
            continue
        seen.add(id(param))

        if (
            id(param) in no_decay_module_params
            or any(name.endswith(sfx) for sfx in no_decay_name_suffixes)
            or param.ndim < 2
        ):
            no_decay_params.append(param)
        else:
            decay_params.append(param)

    return [
        {"params": decay_params, "weight_decay": weight_decay},
        {"params": no_decay_params, "weight_decay": 0.0},
    ]


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def main() -> None:
    args = parse_args()

    # ---- Distributed setup -------------------------------------------------
    is_ddp = "RANK" in os.environ
    rank = 0
    local_rank = 0
    world_size = 1
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    if is_ddp:
        rank, local_rank, world_size, device = setup_ddp()

    # Per-rank seed so data shuffling differs across replicas.
    set_seed(args.seed + rank)

    # ---- NUMA affinity for optimal GPU↔CPU memory locality ---------------
    # B200 topology: GPU 0-3 → NUMA node 0 (cores 0-35)
    #                GPU 4-7 → NUMA node 1 (cores 36-71)
    # Without pinning, 5/8 ranks end up on wrong NUMA → 3.2x memory latency.
    try:
        if local_rank < 4:
            os.sched_setaffinity(0, set(range(0, 36)))   # NUMA node 0
        else:
            os.sched_setaffinity(0, set(range(36, 72)))   # NUMA node 1
        if is_main_process():
            print(f"NUMA affinity: rank {rank} (GPU {local_rank}) → "
                  f"{'NUMA0 cores 0-35' if local_rank < 4 else 'NUMA1 cores 36-71'}")
    except (AttributeError, OSError) as e:
        if is_main_process():
            print(f"[WARN] NUMA affinity failed: {e}")

    # ---- Model -------------------------------------------------------------
    if not args.config.exists():
        raise FileNotFoundError(f"Config file not found: {args.config}")

    lm_config = LMConfig.from_yaml(args.config)

    # CLI --use_fp8 flag overrides whatever the config file says.
    if args.use_fp8:
        lm_config.use_fp8 = True

    # FP8 alignment check: (batch_size × seq_len) must be divisible by 8.
    if lm_config.use_fp8 and (args.batch_size * lm_config.max_seq_len) % 8 != 0:
        raise ValueError(
            f"FP8: batch_size × max_seq_len = {args.batch_size} × {lm_config.max_seq_len} "
            f"= {args.batch_size * lm_config.max_seq_len} must be divisible by 8."
        )

    # Note: fp8_model_init() is intentionally omitted — MXFP8Tensor weights are
    # incompatible with DDP's _broadcast_coalesced during multi-GPU init.
    # Weights remain in float32; TE quantizes on-the-fly inside fp8_autocast.
    model = LLM(lm_config).to(device)

    if is_main_process():
        total_params = sum(p.numel() for p in model.parameters())
        print(f"Model parameters: {total_params:,}")
        print(f"LMConfig: {lm_config}")

    # ---- Wrap in DDP -------------------------------------------------------
    if is_ddp:
        from torch.nn.parallel import DistributedDataParallel as DDP

        model = DDP(
            model,
            device_ids=[local_rank],
            output_device=local_rank,
            gradient_as_bucket_view=True,   # zero-copy gradient → NCCL buffer
            bucket_cap_mb=800,              # larger buckets for NVLS (was 400)
            find_unused_parameters=False,   # fixed graph, no traversal overhead
            # NOTE: static_graph=True 제거 — TE FP8 레이어의 동적 autograd hooks와 충돌
        )

    # ---- Dataset & DataLoader ----------------------------------------------
    # PackedDataset: non-overlapping stride=seq_len windows.
    # Avoids 600M random-index mmap accesses from stride-1 TextDataset.
    train_dataset = PackedDataset(args.train_data, seq_len=lm_config.max_seq_len)

    if is_ddp:
        train_sampler: DistributedSampler | RandomSampler = DistributedSampler(
            train_dataset,
            num_replicas=world_size,
            rank=rank,
            shuffle=True,
            seed=args.seed,
        )
        shuffle = False
    else:
        train_sampler = RandomSampler(train_dataset)
        shuffle = False  # Sampler is provided; DataLoader must not also shuffle.

    train_loader = DataLoader(
        train_dataset,
        batch_size=args.batch_size,
        sampler=train_sampler,
        num_workers=6,           # 6×8=48 workers, fits 72-core budget with OMP=4
        pin_memory=True,
        drop_last=True,
        prefetch_factor=4,       # deeper pipeline for larger worker pool
        persistent_workers=True, # keep workers alive across epochs — eliminates respawn stall
    )

    # ---- Optimizer ---------------------------------------------------------
    param_groups = build_optimizer_param_groups(
        getattr(model, "module", model), args.weight_decay
    )
    optimizer = torch.optim.AdamW(
        param_groups,
        lr=args.lr,
        betas=(0.9, 0.95),
        eps=1e-8,
        fused=torch.cuda.is_available(),  # Use fused kernel when on CUDA.
    )

    # ---- LR Scheduler ------------------------------------------------------
    train_config = TrainConfig(
        checkpoint_dir=str(args.checkpoint_dir),
        grad_accum_steps=args.grad_accum,
        use_fp8=lm_config.use_fp8,
        log_file=str(args.log_file) if args.log_file is not None else None,
    )
    if args.max_steps is not None:
        train_config.max_steps = args.max_steps

    scheduler = get_cosine_schedule_with_warmup(
        optimizer=optimizer,
        warmup_steps=args.warmup_steps,
        total_steps=train_config.max_steps,
    )

    # ---- Resume from checkpoint --------------------------------------------
    start_step = 0
    if args.resume is not None:
        if not args.resume.exists():
            raise FileNotFoundError(f"Checkpoint path not found: {args.resume}")
        start_step, resume_loss = load_checkpoint(
            path=args.resume,
            model=model,
            optimizer=optimizer,
            scheduler=scheduler,
        )
        if is_main_process():
            print(f"Resumed from {args.resume} at step {start_step} (loss={resume_loss:.4f})")

    # ---- Checkpoint directory ----------------------------------------------
    args.checkpoint_dir.mkdir(parents=True, exist_ok=True)

    # ---- Trainer -----------------------------------------------------------
    trainer = Trainer(
        model=model,
        train_loader=train_loader,
        optimizer=optimizer,
        scheduler=scheduler,
        config=train_config,
        device=device,
        rank=rank,
        sampler=train_sampler if is_ddp else None,
    )

    # ---- Signal handlers for graceful shutdown ----------------------------
    # SIGHUP: SSH 세션 끊김 시 발생 → 이전에 학습을 죽인 주범
    # SIGTERM: kill 명령 또는 시스템 종료 시 발생
    # 핸들러가 trainer.request_shutdown()을 호출하면, 학습 루프가
    # 현재 step 완료 후 비상 체크포인트를 저장하고 깨끗하게 종료합니다.
    _trainer_ref = trainer

    def _graceful_shutdown_handler(signum, frame):
        sig_name = signal.Signals(signum).name
        if is_main_process():
            import datetime as _dt
            ts = _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            msg = (
                f"[{ts}] [SIGNAL] Received {sig_name} (signum={signum}). "
                f"Initiating graceful shutdown..."
            )
            print(f"\n{msg}")
            # 로그 파일에도 즉시 기록 (시그널 핸들러 내에서 안전하게)
            if args.log_file is not None:
                try:
                    with open(args.log_file, "a", encoding="utf-8") as f:
                        f.write(msg + "\n")
                except Exception:
                    pass  # 시그널 핸들러 내에서는 예외 무시
        _trainer_ref.request_shutdown(sig_name)

    for _sig in (signal.SIGHUP, signal.SIGTERM):
        signal.signal(_sig, _graceful_shutdown_handler)

    if is_main_process():
        import datetime
        eff_tokens_per_step = args.batch_size * lm_config.max_seq_len * args.grad_accum * world_size
        nccl_debug = os.environ.get("NCCL_DEBUG", "not set")
        omp_threads = os.environ.get("OMP_NUM_THREADS", "not set")
        print(
            f"\n{'='*70}\n"
            f"  LLM Pretraining — {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
            f"{'='*70}\n"
            f"  model     : {lm_config.num_params:,} params  |  "
            f"d_model={lm_config.d_model}  n_layers={lm_config.n_layers}\n"
            f"  precision : {'FP8 (MXFP8BlockScaling)' if lm_config.use_fp8 else 'BF16'}\n"
            f"  GPUs      : {world_size}  |  batch/GPU={args.batch_size}  "
            f"grad_accum={args.grad_accum}\n"
            f"  eff_batch : {args.batch_size * args.grad_accum * world_size} seqs  "
            f"= {eff_tokens_per_step:,} tok/step\n"
            f"  max_steps : {train_config.max_steps:,}  "
            f"({train_config.max_steps * eff_tokens_per_step / 1e9:.1f}B tokens total)\n"
            f"  data      : {args.train_data}\n"
            f"  ckpt_dir  : {args.checkpoint_dir}\n"
            f"  env       : OMP_NUM_THREADS={omp_threads}  NCCL_DEBUG={nccl_debug}\n"
            f"{'='*70}\n"
        )

    try:
        trainer.train(start_step=start_step)
        # 학습 완료 또는 graceful shutdown 후 상태 출력
        if is_main_process():
            if trainer._shutdown_requested:
                print(
                    f"\n[INFO] Training gracefully shut down via {trainer._shutdown_signal}. "
                    f"Emergency checkpoint saved. Resume with same command."
                )
            else:
                print("\n[INFO] Training completed successfully.")
    except KeyboardInterrupt:
        if is_main_process():
            print("\n[INFO] Training interrupted by user (KeyboardInterrupt).")
    except Exception as e:
        import traceback
        if is_main_process():
            tb = traceback.format_exc()
            print(f"\n[ERROR] Training failed at rank {rank}:\n{tb}")
            # log_file에도 기록
            if args.log_file is not None:
                with open(args.log_file, "a", encoding="utf-8") as f:
                    import datetime
                    f.write(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] [FATAL] {tb}\n")
        raise
    finally:
        if is_ddp:
            cleanup_ddp()

    # Note: DDP cleanup is handled in the try/finally block above.


if __name__ == "__main__":
    main()
