from __future__ import annotations

import argparse
import json
from pathlib import Path

import yaml

from common import ROOT, localize_workspace_path

DEFAULT_CONFIG = ROOT / "plugins" / "1c" / "training" / "configs" / "qwen3-coder-30b-a3b-lora.yaml"


def fail_missing_dependencies(exc: Exception) -> None:
    raise SystemExit(
        "Missing training dependencies. Install `requirements-training.txt` "
        "or run inside the GPU training container. Original error: "
        f"{type(exc).__name__}: {exc}"
    )


def load_config(path: Path) -> dict:
    with path.open("r", encoding="utf-8") as handle:
        data = yaml.safe_load(handle)
    if not isinstance(data, dict):
        raise ValueError(f"{path} must contain a YAML mapping")
    return data


def load_messages(path: Path) -> list[dict]:
    rows = []
    with path.open("r", encoding="utf-8") as handle:
        for line in handle:
            line = line.strip()
            if line:
                rows.append(json.loads(line))
    return rows


def build_training_texts(rows: list[dict], tokenizer) -> list[str]:
    texts = []
    for row in rows:
        messages = row["messages"]
        if hasattr(tokenizer, "apply_chat_template") and tokenizer.chat_template:
            text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
        else:
            text = "\n".join(f"{msg['role']}: {msg['content']}" for msg in messages)
        texts.append(text)
    return texts


def main() -> int:
    parser = argparse.ArgumentParser(description="Train the 1C LoRA adapter.")
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    config = load_config(args.config)
    base_model_path = str(localize_workspace_path(config["base_model_path"]))
    dataset_path = localize_workspace_path(config["dataset_path"])
    output_dir = str(localize_workspace_path(config["output_dir"]))

    rows = load_messages(dataset_path)
    if args.dry_run:
        print(f"Config: {args.config}")
        print(f"Base model: {base_model_path}")
        print(f"Dataset: {dataset_path} ({len(rows)} record(s))")
        print(f"Output: {output_dir}")
        return 0

    try:
        import torch
        from datasets import Dataset
        from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
        from transformers import (
            AutoModelForCausalLM,
            AutoTokenizer,
            BitsAndBytesConfig,
            DataCollatorForLanguageModeling,
            Trainer,
            TrainingArguments,
        )
    except Exception as exc:  # pragma: no cover
        fail_missing_dependencies(exc)

    if not torch.cuda.is_available():
        raise SystemExit("CUDA is required for this training job.")

    tokenizer = AutoTokenizer.from_pretrained(base_model_path, trust_remote_code=True)
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token

    qconf = config.get("quantization") or {}
    quantization_config = None
    if qconf.get("load_in_4bit"):
        quantization_config = BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_quant_type=qconf.get("bnb_4bit_quant_type", "nf4"),
            bnb_4bit_use_double_quant=bool(qconf.get("bnb_4bit_use_double_quant", True)),
            bnb_4bit_compute_dtype=torch.bfloat16,
        )

    model = AutoModelForCausalLM.from_pretrained(
        base_model_path,
        trust_remote_code=True,
        device_map="auto",
        quantization_config=quantization_config,
    )
    if quantization_config is not None:
        model = prepare_model_for_kbit_training(model)

    lora = config["lora"]
    peft_config = LoraConfig(
        r=int(lora["r"]),
        lora_alpha=int(lora["lora_alpha"]),
        lora_dropout=float(lora["lora_dropout"]),
        bias=lora.get("bias", "none"),
        task_type="CAUSAL_LM",
        target_modules=lora["target_modules"],
    )
    model = get_peft_model(model, peft_config)

    texts = build_training_texts(rows, tokenizer)
    dataset = Dataset.from_dict({"text": texts})

    def tokenize(batch):
        return tokenizer(
            batch["text"],
            truncation=True,
            max_length=int(config["max_seq_length"]),
            padding=False,
        )

    tokenized = dataset.map(tokenize, batched=True, remove_columns=["text"])
    collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
    train = config["train"]
    train_args = TrainingArguments(
        output_dir=output_dir,
        num_train_epochs=float(train["num_train_epochs"]),
        per_device_train_batch_size=int(train["per_device_train_batch_size"]),
        gradient_accumulation_steps=int(train["gradient_accumulation_steps"]),
        learning_rate=float(train["learning_rate"]),
        warmup_ratio=float(train["warmup_ratio"]),
        logging_steps=int(train["logging_steps"]),
        save_strategy=train.get("save_strategy", "epoch"),
        save_total_limit=int(train.get("save_total_limit", 1)),
        bf16=bool(train.get("bf16", False)),
        fp16=bool(train.get("fp16", False)),
        report_to=[],
    )
    trainer = Trainer(model=model, args=train_args, train_dataset=tokenized, data_collator=collator)
    trainer.train()
    trainer.save_model(output_dir)
    tokenizer.save_pretrained(output_dir)
    print(f"Saved 1C LoRA adapter to {output_dir}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
