"""
ORPO (Odds Ratio Preference Optimization) training script.
Uses TRL 0.29.0 ORPOTrainer/ORPOConfig (trl.experimental.orpo).
Optimized for 8x NVIDIA B200 GPUs (183GB VRAM each, ~1.47TB total).

Usage:
    # Full training (8 GPU DDP)
    torchrun --nproc_per_node=8 train/orpo.py \
        --config configs/korean_3b_orpo.yaml

    # Quick test (200 steps)
    python train/orpo.py --config configs/korean_3b_orpo.yaml --max_steps 200

    # Single GPU test
    python train/orpo.py --config configs/korean_3b_orpo.yaml --device cuda:0

Prerequisites:
    pip install trl==0.29.0 transformers accelerate peft datasets
"""

from __future__ import annotations

import argparse
import datetime
import json
import logging
import os
import signal as _signal_mod
import sys
import time
import traceback
from pathlib import Path

import torch
from datasets import Dataset, load_dataset
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    EarlyStoppingCallback,
    TrainerCallback,
)

# TRL imports -- ORPOTrainer/ORPOConfig (TRL 0.29.0, experimental path)
try:
    from trl.experimental.orpo import ORPOConfig, ORPOTrainer
except ImportError:
    print("ERROR: trl not installed or outdated. Run: pip install trl==0.29.0")
    sys.exit(1)

# Telegram notifications
try:
    sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
    from scripts.telegram_notify import send_telegram_safe
    HAS_TELEGRAM = True
except ImportError:
    HAS_TELEGRAM = False
    def send_telegram_safe(msg, **kw): return False

# ---------------------------------------------------------------------------
# Logging setup
# ---------------------------------------------------------------------------
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("orpo")


# ---------------------------------------------------------------------------
# Custom callback for detailed monitoring
# ---------------------------------------------------------------------------
class ORPOMonitorCallback(TrainerCallback):
    """Monitors ORPO-specific metrics and sends alerts on anomalies."""

    def __init__(self, alert_fn=send_telegram_safe):
        self.alert_fn = alert_fn
        self.start_time = None
        self.last_eval_loss = None
        self.eval_loss_increases = 0
        self.negative_margin_streak = 0

    def on_train_begin(self, args, state, control, **kwargs):
        self.start_time = time.time()
        log.info("ORPO training begin -- monitoring active")

    def on_log(self, args, state, control, logs=None, **kwargs):
        if logs is None:
            return
        step = state.global_step

        # Monitor rewards/margins
        margin = logs.get("rewards/margins")
        if margin is not None:
            if margin < 0:
                self.negative_margin_streak += 1
                if self.negative_margin_streak >= 10:
                    msg = (f"[ORPO ALERT] rewards/margins negative for "
                           f"{self.negative_margin_streak} consecutive logs at step {step} "
                           f"(margin={margin:.4f})")
                    log.warning(msg)
                    self.alert_fn(msg)
            else:
                self.negative_margin_streak = 0

        # Log key metrics every logging step
        loss = logs.get("loss")
        chosen = logs.get("rewards/chosen")
        rejected = logs.get("rewards/rejected")
        if loss is not None:
            elapsed = time.time() - self.start_time if self.start_time else 0
            log.info(
                f"step={step} loss={loss:.4f} "
                f"margin={margin if margin is not None else 'N/A'} "
                f"chosen={chosen if chosen is not None else 'N/A'} "
                f"rejected={rejected if rejected is not None else 'N/A'} "
                f"elapsed={elapsed/3600:.1f}h"
            )

        # Check for NaN/Inf
        if loss is not None and (not isinstance(loss, (int, float)) or loss != loss):
            msg = f"[ORPO CRITICAL] NaN/Inf loss detected at step {step}!"
            log.error(msg)
            self.alert_fn(msg)

    def on_evaluate(self, args, state, control, metrics=None, **kwargs):
        if metrics is None:
            return
        eval_loss = metrics.get("eval_loss")
        step = state.global_step

        if eval_loss is not None:
            log.info(f"[EVAL] step={step} eval_loss={eval_loss:.4f}")
            if self.last_eval_loss is not None and eval_loss > self.last_eval_loss:
                self.eval_loss_increases += 1
                log.warning(
                    f"[EVAL] eval_loss increased: {self.last_eval_loss:.4f} -> {eval_loss:.4f} "
                    f"({self.eval_loss_increases}/3 before early stop)"
                )
            else:
                self.eval_loss_increases = 0
            self.last_eval_loss = eval_loss

    def on_train_end(self, args, state, control, **kwargs):
        elapsed = time.time() - self.start_time if self.start_time else 0
        log.info(f"ORPO training ended -- total time: {elapsed/3600:.2f}h, "
                 f"total steps: {state.global_step}")

    def on_save(self, args, state, control, **kwargs):
        log.info(f"Checkpoint saved at step {state.global_step}")


class VRAMMonitorCallback(TrainerCallback):
    """Measures peak VRAM usage across all GPUs during training."""

    def on_train_begin(self, args, state, control, **kwargs):
        if torch.cuda.is_available():
            for i in range(torch.cuda.device_count()):
                torch.cuda.reset_peak_memory_stats(i)
            log.info("[VRAM] Peak memory stats reset for all GPUs")

    def on_train_end(self, args, state, control, **kwargs):
        if torch.cuda.is_available():
            for i in range(torch.cuda.device_count()):
                peak_mb = torch.cuda.max_memory_allocated(i) / (1024**2)
                log.info(f"[VRAM] GPU {i} peak: {peak_mb:.0f} MiB")


def load_hf_preference_dataset(dataset_name: str, token: str | None = None) -> Dataset:
    """Load and normalize a HuggingFace preference dataset to {prompt, chosen, rejected}."""
    ds = load_dataset(dataset_name, split="train", token=token)

    # kuotient/orca-math-korean-dpo-pairs format: {system, question, chosen, rejected}
    if "question" in ds.column_names and "chosen" in ds.column_names:
        def normalize(example):
            prompt = example.get("system", "") + "\n" + example["question"]
            return {"prompt": prompt.strip(), "chosen": example["chosen"], "rejected": example["rejected"]}
        return ds.map(normalize, remove_columns=ds.column_names)

    # nayohan/preference-collection-ko-full format: {response_A, response_B, orig_preference}
    if "orig_preference" in ds.column_names:
        def normalize_pref(example):
            prompt = example.get("orig_instruction", example.get("instruction", ""))
            if example["orig_preference"] == "B":
                return {"prompt": prompt, "chosen": example["orig_response_B"], "rejected": example["orig_response_A"]}
            else:
                return {"prompt": prompt, "chosen": example["orig_response_A"], "rejected": example["orig_response_B"]}
        return ds.map(normalize_pref, remove_columns=ds.column_names)

    # Already in {prompt, chosen, rejected} format
    if all(c in ds.column_names for c in ["prompt", "chosen", "rejected"]):
        return ds

    raise ValueError(f"Unknown dataset format. Columns: {ds.column_names}")


def load_custom_jsonl(path: str) -> Dataset:
    """Load custom JSONL with {prompt, chosen, rejected} fields."""
    data = []
    with open(path) as f:
        for line in f:
            data.append(json.loads(line))
    return Dataset.from_list(data)


def load_yaml_config(path: str) -> dict:
    """Load YAML config and return as dict."""
    import yaml
    with open(path) as f:
        return yaml.safe_load(f)


def main():
    parser = argparse.ArgumentParser(description="ORPO Training (TRL 0.29.0 -- 8xB200 optimized)")
    parser.add_argument("--config", type=str, default=None, help="YAML config file path")
    parser.add_argument("--model_path", type=str, default=None, help="HF format model path")
    parser.add_argument("--dataset", type=str, default="kuotient/orca-math-korean-dpo-pairs")
    parser.add_argument("--custom_data_path", type=str, default=None, help="Custom JSONL preference data")
    parser.add_argument("--output_dir", type=str, default="checkpoints/korean_3b_orpo")
    parser.add_argument("--hf_token", type=str, default=None)
    parser.add_argument("--epochs", type=int, default=3)
    parser.add_argument("--lr", type=float, default=5e-6)
    parser.add_argument("--beta", type=float, default=0.1, help="ORPO beta (odds ratio weight)")
    parser.add_argument("--batch_size", type=int, default=4)
    parser.add_argument("--gradient_accumulation_steps", type=int, default=4)
    parser.add_argument("--max_length", type=int, default=1536)
    parser.add_argument("--bf16", action="store_true", default=True)
    parser.add_argument("--weight_decay", type=float, default=0.01)
    parser.add_argument("--eval_split_ratio", type=float, default=0.05, help="Fraction of data for eval")
    parser.add_argument("--eval_steps", type=int, default=500)
    parser.add_argument("--early_stopping_patience", type=int, default=3)
    parser.add_argument("--max_steps", type=int, default=-1, help="Override max steps (for quick test)")
    parser.add_argument("--seed", type=int, default=42)
    parser.add_argument("--save_total_limit", type=int, default=5)
    parser.add_argument("--warmup_ratio", type=float, default=0.05)
    parser.add_argument("--lr_scheduler_type", type=str, default="cosine")
    parser.add_argument("--logging_steps", type=int, default=10)
    parser.add_argument("--save_steps", type=int, default=500)
    parser.add_argument("--gradient_checkpointing", action="store_true", default=True)
    parser.add_argument("--report_to", type=str, default="none")
    parser.add_argument("--dataset_num_proc", type=int, default=8,
                        help="Number of processes for parallel tokenization in ORPOTrainer")
    parser.add_argument("--dataloader_num_workers", type=int, default=4,
                        help="Number of dataloader worker processes")
    parser.add_argument("--no_load_best", action="store_true", default=False,
                        help="Disable load_best_model_at_end (for sweep/quick tests)")
    parser.add_argument("--max_samples", type=int, default=0,
                        help="Limit dataset size (0=use all, >0=subset for benchmarking)")
    parser.add_argument("--skip_filter", action="store_true", default=False,
                        help="Skip NaN-prevention filter (for benchmarking only)")
    args = parser.parse_args()

    # Override CLI defaults with YAML config values
    if args.config:
        cfg = load_yaml_config(args.config)
        for key, value in cfg.items():
            if hasattr(args, key):
                setattr(args, key, value)

    if not args.model_path:
        parser.error("--model_path is required (or set model_path in YAML config)")

    # Log all resolved config
    local_rank = int(os.environ.get("LOCAL_RANK", 0))
    is_main = local_rank == 0
    if is_main:
        log.info("=" * 70)
        log.info("ORPO Training Configuration (8xB200 optimized)")
        log.info("=" * 70)
        for k, v in sorted(vars(args).items()):
            log.info(f"  {k}: {v}")
        log.info("=" * 70)

        # GPU info
        if torch.cuda.is_available():
            for i in range(torch.cuda.device_count()):
                mem = torch.cuda.get_device_properties(i).total_memory / 1e9
                log.info(f"  GPU {i}: {torch.cuda.get_device_name(i)} ({mem:.1f} GB)")

    # Validate paths
    if not Path(args.model_path).exists():
        raise FileNotFoundError(f"Model path not found: {args.model_path}")
    if args.custom_data_path and not Path(args.custom_data_path).exists():
        raise FileNotFoundError(f"Data path not found: {args.custom_data_path}")

    # NCCL/DDP environment diagnostics
    if is_main:
        log.info("--- DDP/NCCL Environment ---")
        for env_key in ["RANK", "WORLD_SIZE", "LOCAL_RANK", "MASTER_ADDR", "MASTER_PORT",
                        "NCCL_IB_DISABLE", "NCCL_BUFFSIZE", "NCCL_P2P_LEVEL",
                        "OMP_NUM_THREADS", "PYTORCH_CUDA_ALLOC_CONF"]:
            log.info(f"  {env_key}={os.environ.get(env_key, '(not set)')}")
        log.info(f"  torch.distributed.is_available={torch.distributed.is_available()}")
        if torch.distributed.is_initialized():
            log.info(f"  world_size={torch.distributed.get_world_size()}, "
                     f"rank={torch.distributed.get_rank()}")

    # Load model (bfloat16 + flash_attention_2 for B200)
    log.info(f"Loading model from {args.model_path}...")
    t0 = time.time()
    try:
        model = AutoModelForCausalLM.from_pretrained(
            args.model_path,
            torch_dtype=torch.bfloat16,
            attn_implementation="flash_attention_2",
        )
    except Exception as e:
        log.error(f"Model loading failed: {e}")
        send_telegram_safe(f"[ORPO FATAL] Model load failed: {e}")
        raise
    tokenizer = AutoTokenizer.from_pretrained(args.model_path)
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token
    if is_main:
        n_params = sum(p.numel() for p in model.parameters())
        log.info(f"Model loaded: {n_params:,} params in {time.time()-t0:.1f}s")
        log.info(f"Tokenizer: vocab_size={tokenizer.vocab_size}, "
                 f"pad_token='{tokenizer.pad_token}', eos_token='{tokenizer.eos_token}'")

    # Load dataset
    t0 = time.time()
    try:
        if args.custom_data_path:
            log.info(f"Loading custom data from {args.custom_data_path}...")
            fsize_mb = Path(args.custom_data_path).stat().st_size / 1e6
            log.info(f"  File size: {fsize_mb:.1f} MB")
            dataset = load_custom_jsonl(args.custom_data_path)
        else:
            log.info(f"Loading dataset {args.dataset}...")
            dataset = load_hf_preference_dataset(args.dataset, token=args.hf_token)
    except Exception as e:
        log.error(f"Dataset loading failed: {e}")
        send_telegram_safe(f"[ORPO FATAL] Data load failed: {e}")
        raise

    # Subset for benchmarking (skip tokenization bottleneck)
    if args.max_samples > 0 and len(dataset) > args.max_samples:
        dataset = dataset.select(range(args.max_samples))
        if is_main:
            log.info(f"[BENCH] Dataset subset: {args.max_samples:,} samples")

    if is_main:
        log.info(f"Dataset loaded: {len(dataset)} pairs in {time.time()-t0:.1f}s")
        # Data quality check
        sample = dataset[0]
        log.info(f"Sample keys: {list(sample.keys())}")
        for key in ["prompt", "chosen", "rejected"]:
            if key not in sample:
                raise ValueError(f"Dataset missing required column: {key}")
            val = sample[key]
            log.info(f"  {key}: {str(val)[:100]}...")

        # Length distribution check (sample first 1000)
        sample_size = min(1000, len(dataset))
        lengths = [len(str(dataset[i]["prompt"])) + max(len(str(dataset[i]["chosen"])),
                   len(str(dataset[i]["rejected"]))) for i in range(sample_size)]
        avg_len = sum(lengths) / len(lengths)
        max_len = max(lengths)
        log.info(f"  Char lengths (sample {sample_size}): avg={avg_len:.0f}, max={max_len}")

    # Filter out samples where prompt is too long for the response to fit in max_length.
    # Without this, samples with 0 response tokens cause NaN in ORPO log-probability computation
    # (division by zero in average_log_prob when loss_mask is all-zero).
    # Also catches TRL truncation bug: tokenize_row uses longer_response_length = max(chosen_len, rejected_len)
    # and truncates BOTH responses to [:max_length - longer_response_length]. When longer >= max_length,
    # the shorter response becomes EMPTY → NaN.
    if args.skip_filter:
        if is_main:
            log.info("[BENCH] Skipping NaN-prevention filter (--skip_filter)")
    else:
        pre_filter = len(dataset)
        def _has_response_room(example):
            prompt_tok_len = len(tokenizer.encode(example["prompt"], add_special_tokens=False))
            chosen_tok_len = len(tokenizer.encode(example["chosen"], add_special_tokens=False))
            rejected_tok_len = len(tokenizer.encode(example["rejected"], add_special_tokens=False))

            # 1. Prompt must leave room for at least 16 response tokens
            if prompt_tok_len + 16 > args.max_length:
                return False

            # 2. Each response independently must fit with prompt
            # (TRL adds BOS/EOS, so use +2 margin)
            if prompt_tok_len + chosen_tok_len + 2 > args.max_length * 2:
                return False  # extremely long, will cause issues
            if prompt_tok_len + rejected_tok_len + 2 > args.max_length * 2:
                return False

            # 3. The longer response must not exceed max_length alone
            # (TRL bug: both responses truncated by max(chosen_len, rejected_len))
            longer = max(chosen_tok_len, rejected_tok_len)
            if longer >= args.max_length:
                return False

            return True
        dataset = dataset.filter(_has_response_room, num_proc=min(args.dataset_num_proc, 32) if is_main else 1)
        if is_main:
            log.info(f"Filtered: {pre_filter:,} -> {len(dataset):,} "
                     f"(removed {pre_filter - len(dataset):,} samples with prompt > max_length-16 or TRL truncation risk)")

    # Train/eval split
    split = dataset.train_test_split(test_size=args.eval_split_ratio, seed=args.seed)
    train_dataset = split["train"]
    eval_dataset = split["test"]
    log.info(f"Train: {len(train_dataset):,}, Eval: {len(eval_dataset):,}")

    # Compute training stats for warmup_steps calculation
    n_gpus = max(torch.cuda.device_count(), 1) if torch.cuda.is_available() else 1
    eff_batch = args.batch_size * args.gradient_accumulation_steps * n_gpus
    steps_per_epoch = len(train_dataset) // eff_batch
    total_steps = args.max_steps if args.max_steps > 0 else steps_per_epoch * args.epochs
    computed_warmup_steps = int(total_steps * args.warmup_ratio)
    if is_main:
        log.info(f"Training plan: eff_batch={eff_batch}, steps/epoch={steps_per_epoch:,}, "
                 f"total={total_steps:,}, warmup={computed_warmup_steps}")

    # DDP tokenization strategy:
    # TRL ORPOTrainer uses main_process_first() — rank 0 tokenizes first, then ranks 1-7.
    # With multiprocessing (num_proc>1), ranks 1-7 all spawn workers simultaneously,
    # causing CPU/memory oversubscription (e.g. 7 ranks × 8 workers = 56 processes).
    # Fix: rank 0 uses full num_proc for speed, other ranks use 1 (should hit cache).
    world_size = int(os.environ.get("WORLD_SIZE", 1))
    if world_size > 1:
        effective_num_proc = args.dataset_num_proc if is_main else 1
        if is_main:
            log.info(f"DDP tokenization: rank 0 uses num_proc={args.dataset_num_proc}, "
                     f"other {world_size-1} ranks use num_proc=1 (cache)")
    else:
        effective_num_proc = args.dataset_num_proc

    # ORPOConfig (TRL 0.29.0) -- optimized for 8x B200
    orpo_config = ORPOConfig(
        output_dir=args.output_dir,
        num_train_epochs=args.epochs,
        per_device_train_batch_size=args.batch_size,
        per_device_eval_batch_size=args.batch_size,
        gradient_accumulation_steps=args.gradient_accumulation_steps,
        learning_rate=args.lr,
        beta=args.beta,
        lr_scheduler_type=args.lr_scheduler_type,
        warmup_steps=computed_warmup_steps,
        weight_decay=args.weight_decay,
        bf16=args.bf16,
        logging_steps=args.logging_steps,
        save_steps=args.save_steps,
        save_total_limit=args.save_total_limit,
        max_length=args.max_length,
        gradient_checkpointing=args.gradient_checkpointing,
        report_to=args.report_to,
        remove_unused_columns=False,
        eval_strategy="steps",
        eval_steps=args.eval_steps,
        metric_for_best_model="eval_loss" if not args.no_load_best else None,
        load_best_model_at_end=not args.no_load_best,
        greater_is_better=False if not args.no_load_best else None,
        max_steps=args.max_steps,
        seed=args.seed,
        # B200 hardware optimizations
        dataloader_num_workers=args.dataloader_num_workers,
        dataloader_pin_memory=True,
        ddp_find_unused_parameters=False,
        ddp_timeout=7200,  # 2h — tokenization takes ~30min on 683K samples
        dataset_num_proc=effective_num_proc,
    )

    # ORPOTrainer (no reference model needed)
    log.info("Initializing ORPOTrainer (tokenization will happen here — may take a while)...")
    t0 = time.time()
    monitor = ORPOMonitorCallback()
    try:
        trainer = ORPOTrainer(
            model=model,
            args=orpo_config,
            train_dataset=train_dataset,
            eval_dataset=eval_dataset,
            processing_class=tokenizer,
            callbacks=[cb for cb in [
                EarlyStoppingCallback(early_stopping_patience=args.early_stopping_patience)
                    if not args.no_load_best else None,
                monitor,
                VRAMMonitorCallback(),
            ] if cb is not None],
        )
    except Exception as e:
        log.error(f"ORPOTrainer init failed: {e}\n{traceback.format_exc()}")
        send_telegram_safe(f"[ORPO FATAL] Trainer init failed: {e}")
        raise
    if is_main:
        log.info(f"ORPOTrainer initialized in {time.time()-t0:.1f}s "
                 f"(dataset_num_proc={args.dataset_num_proc})")

    # SIGHUP/SIGTERM defense -- graceful shutdown with emergency checkpoint
    def _graceful_shutdown_handler(signum, frame):
        sig_name = _signal_mod.Signals(signum).name
        log.warning(f"Received {sig_name}. Saving emergency checkpoint...")
        try:
            emergency_path = os.path.join(args.output_dir, "emergency_checkpoint")
            trainer.save_model(emergency_path)
            log.info(f"Emergency checkpoint saved to {emergency_path}")
            send_telegram_safe(
                f"[ORPO] Signal {sig_name} received at step {trainer.state.global_step}. "
                f"Emergency checkpoint saved."
            )
        except Exception as e:
            log.error(f"Emergency save failed: {e}")
            send_telegram_safe(f"[ORPO] Emergency save FAILED after {sig_name}: {e}")
        sys.exit(1)

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

    # Pre-training VRAM report
    if is_main and torch.cuda.is_available():
        torch.cuda.reset_peak_memory_stats()
        alloc = torch.cuda.memory_allocated() / 1e9
        reserved = torch.cuda.memory_reserved() / 1e9
        log.info(f"Pre-train VRAM: allocated={alloc:.1f}GB, reserved={reserved:.1f}GB")

    start_msg = (
        f"[ORPO] Training started\n"
        f"  model: {args.model_path}\n"
        f"  beta: {args.beta}, lr: {args.lr}\n"
        f"  train: {len(train_dataset):,}, eval: {len(eval_dataset):,}\n"
        f"  eff_batch: {eff_batch}, steps/epoch: {steps_per_epoch:,}, total: {total_steps:,}\n"
        f"  warmup: {computed_warmup_steps} steps ({args.warmup_ratio*100:.0f}%)\n"
        f"  max_length: {args.max_length}, max_steps: {args.max_steps}\n"
        f"  dataset_num_proc: {args.dataset_num_proc}, dl_workers: {args.dataloader_num_workers}"
    )
    log.info(start_msg.replace("[ORPO] ", ""))
    send_telegram_safe(start_msg)

    try:
        trainer.train()

        # Post-training VRAM report
        if is_main and torch.cuda.is_available():
            peak = torch.cuda.max_memory_allocated() / 1e9
            log.info(f"Peak VRAM usage: {peak:.1f}GB")

        trainer.save_model(args.output_dir)
        log.info(f"Model saved to {args.output_dir}")

        # Extract final metrics
        final_metrics = {}
        for entry in reversed(trainer.state.log_history):
            if "loss" in entry and "loss" not in final_metrics:
                final_metrics["loss"] = entry["loss"]
            if "eval_loss" in entry and "eval_loss" not in final_metrics:
                final_metrics["eval_loss"] = entry["eval_loss"]
            if "rewards/margins" in entry and "rewards/margins" not in final_metrics:
                final_metrics["rewards/margins"] = entry["rewards/margins"]
            if len(final_metrics) >= 3:
                break

        done_msg = (
            f"[ORPO] Training complete!\n"
            f"  output: {args.output_dir}\n"
            f"  steps: {trainer.state.global_step}\n"
            f"  final loss: {final_metrics.get('loss', 'N/A')}\n"
            f"  final eval_loss: {final_metrics.get('eval_loss', 'N/A')}\n"
            f"  final margins: {final_metrics.get('rewards/margins', 'N/A')}"
        )
        log.info(done_msg.replace("[ORPO] ", ""))
        send_telegram_safe(done_msg)

    except KeyboardInterrupt:
        log.warning("Training interrupted by user (KeyboardInterrupt)")
        send_telegram_safe(f"[ORPO] Training interrupted at step {trainer.state.global_step}")
        trainer.save_model(os.path.join(args.output_dir, "interrupted_checkpoint"))
        log.info("Interrupted checkpoint saved.")

    except Exception as e:
        tb = traceback.format_exc()
        error_msg = f"[ORPO] Training FAILED at step {trainer.state.global_step}: {e}"
        log.error(f"{error_msg}\n{tb}")
        send_telegram_safe(f"{error_msg}\n{tb[:500]}")
        # Try emergency save
        try:
            trainer.save_model(os.path.join(args.output_dir, "error_checkpoint"))
            log.info("Error checkpoint saved.")
        except Exception:
            log.error("Error checkpoint save also failed.")
        raise


if __name__ == "__main__":
    main()
