"""Train a psyc QLoRA adapter on JSONL Trainline datasets using unsloth + Qwen3.5.

Run inside the psyc training container:
    docker run --gpus all -v $(pwd)/data:/data psyc-trainer \
        --dataset /data/datasets/ioc_extraction-v1.jsonl \
        --dataset /data/datasets/severity_classification-v1.jsonl \
        --output /data/adapters/psyc-v1

Defaults target a 24 GB consumer GPU (3090/4090) with Qwen3.5-4B-Instruct at
4-bit + LoRA r=16. For an A100-40/80 bump --base-model to 9B and raise
--batch-size + --max-seq-length.
"""

from __future__ import annotations

# unsloth must be imported BEFORE transformers per their setup notes.
from unsloth import FastLanguageModel  # noqa: I001

import argparse
import json
from pathlib import Path
from typing import Dict, List

from datasets import Dataset
from trl import SFTConfig, SFTTrainer


def load_examples(paths: List[Path]) -> List[Dict[str, str]]:
    out: List[Dict[str, str]] = []
    for p in paths:
        with p.open("r", encoding="utf-8") as fh:
            for line in fh:
                line = line.strip()
                if not line:
                    continue
                ex = json.loads(line)
                if not all(k in ex for k in ("instruction", "input", "output")):
                    continue
                out.append(ex)
    return out


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--dataset", action="append", required=True, help="JSONL path (repeatable)")
    parser.add_argument("--base-model", default="unsloth/Qwen3.5-4B")
    parser.add_argument("--output", default="/data/adapters/psyc-v1")
    parser.add_argument("--epochs", type=int, default=3)
    parser.add_argument("--lr", type=float, default=2e-4)
    parser.add_argument("--max-seq-length", type=int, default=4096)
    parser.add_argument("--batch-size", type=int, default=2)
    parser.add_argument("--grad-accum", type=int, default=4)
    parser.add_argument("--lora-r", type=int, default=16)
    parser.add_argument("--lora-alpha", type=int, default=16)
    parser.add_argument("--seed", type=int, default=3407)
    args = parser.parse_args()

    paths = [Path(p) for p in args.dataset]
    examples = load_examples(paths)
    if not examples:
        raise SystemExit("no examples loaded — check --dataset paths")
    print(f"[psyc-train] loaded {len(examples)} example(s) from {len(paths)} dataset(s)")

    model, tokenizer = FastLanguageModel.from_pretrained(
        model_name=args.base_model,
        max_seq_length=args.max_seq_length,
        dtype=None,
        load_in_4bit=True,
    )

    model = FastLanguageModel.get_peft_model(
        model,
        r=args.lora_r,
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
        lora_alpha=args.lora_alpha,
        lora_dropout=0,
        bias="none",
        use_gradient_checkpointing="unsloth",
        random_state=args.seed,
    )

    def format_one(ex: Dict[str, str]) -> Dict[str, str]:
        messages = [
            {"role": "user", "content": f"{ex['instruction']}\n\n{ex['input']}"},
            {"role": "assistant", "content": ex["output"]},
        ]
        return {"text": tokenizer.apply_chat_template(messages, tokenize=False)}

    dataset = Dataset.from_list([format_one(e) for e in examples]).shuffle(seed=args.seed)

    output_dir = Path(args.output)
    output_dir.mkdir(parents=True, exist_ok=True)

    trainer = SFTTrainer(
        model=model,
        tokenizer=tokenizer,
        train_dataset=dataset,
        args=SFTConfig(
            dataset_text_field="text",
            max_seq_length=args.max_seq_length,
            per_device_train_batch_size=args.batch_size,
            gradient_accumulation_steps=args.grad_accum,
            warmup_steps=5,
            num_train_epochs=args.epochs,
            learning_rate=args.lr,
            bf16=True,
            optim="adamw_8bit",
            weight_decay=0.01,
            lr_scheduler_type="linear",
            seed=args.seed,
            output_dir=str(output_dir / "checkpoints"),
            save_strategy="epoch",
            logging_steps=10,
            report_to="none",
        ),
    )
    train_result = trainer.train()

    final_dir = output_dir / "final"
    final_dir.mkdir(parents=True, exist_ok=True)
    model.save_pretrained(str(final_dir))
    tokenizer.save_pretrained(str(final_dir))

    loss_history = [
        {"step": h["step"], "loss": h["loss"], "epoch": h.get("epoch")}
        for h in trainer.state.log_history
        if "loss" in h
    ]
    (output_dir / "training_meta.json").write_text(json.dumps({
        "base_model": args.base_model,
        "lora_r": args.lora_r,
        "lora_alpha": args.lora_alpha,
        "epochs": args.epochs,
        "lr": args.lr,
        "datasets": [str(p) for p in paths],
        "examples": len(examples),
        "seed": args.seed,
        "train_loss": train_result.training_loss,
        "loss_history": loss_history,
    }, indent=2))
    print(f"[psyc-train] adapter saved → {final_dir}")


if __name__ == "__main__":
    main()
