# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.

"""End-to-end MLX smoke test on real Apple Silicon (multi-process driver).

`train` overfits gemma-3-270m-it on one row for 30 steps and saves
lora/merged_16bit/gguf; `reload` reopens each format in a fresh process.
GGUF + LoRA reload fixes land in unslothai/unsloth-zoo#627. Metal's
reduction-order nondeterminism makes loss assertions bounds, not exact.
Apple-Silicon only; invoked from .github/workflows/mlx-ci.yml.
"""

from __future__ import annotations

import argparse
import json
import math
import os
import random as _random
import resource
import subprocess
import sys
import time
from pathlib import Path

import numpy as np


SEED = 3407
TRAIN_TEXT = "<<HELLO!!>> My name is Unsloth!"
PROMPT = "<<HELLO!!>> My name is "
EXPECT_IN_OUTPUT = "Unsloth"
MODEL_NAME = "unsloth/gemma-3-270m-it"


# ---------------------------------------------------------------------------
# Determinism + telemetry helpers
# ---------------------------------------------------------------------------


def _seed_everything() -> None:
    _random.seed(SEED)
    np.random.seed(SEED)
    import mlx.core as mx

    mx.random.seed(SEED)


def _peak_gpu_gb() -> float:
    import mlx.core as mx

    if not mx.metal.is_available():
        return 0.0
    # Newer MLX moved get_peak_memory to top-level; fall back to mx.metal.
    getter = getattr(mx, "get_peak_memory", None) or getattr(mx.metal, "get_peak_memory", None)
    if getter is None:
        return 0.0
    try:
        return float(getter()) / (1024**3)
    except Exception:
        return 0.0


def _peak_rss_gb() -> float:
    """Peak RSS for this process (macOS getrusage = bytes, Linux = KB)."""
    rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    if sys.platform == "darwin":
        return float(rss) / (1024**3)
    return float(rss) / (1024**2)


class Phase:
    """Wall-clock + memory tracker for a named phase; records into a metrics dict."""

    def __init__(self, name: str, metrics: dict):
        self.name = name
        self.metrics = metrics

    def __enter__(self):
        self._t0 = time.perf_counter()
        print(f"\n=== phase:{self.name} START ===", flush = True)
        return self

    def __exit__(self, exc_type, exc, tb):
        elapsed = time.perf_counter() - self._t0
        peak_gpu = _peak_gpu_gb()
        peak_rss = _peak_rss_gb()
        self.metrics.setdefault("phases", {})[self.name] = {
            "elapsed_seconds": round(elapsed, 3),
            "peak_gpu_gb": round(peak_gpu, 3),
            "peak_rss_gb": round(peak_rss, 3),
            "ok": exc_type is None,
        }
        status = "OK" if exc_type is None else f"FAIL ({exc_type.__name__})"
        print(
            f"=== phase:{self.name} {status} elapsed={elapsed:.2f}s "
            f"peak_gpu={peak_gpu:.2f}GB peak_rss={peak_rss:.2f}GB ===",
            flush = True,
        )
        return False  # don't swallow exceptions


def _compute_loss_and_grad_norm(model, tokenizer, text: str) -> tuple[float, float]:
    """One fwd+bwd of next-token CE on `text`. Returns (loss, ||grad||_2)."""
    import mlx.core as mx
    import mlx.nn as nn
    from mlx.utils import tree_flatten

    # Match Studio's text dataset path: no EOS appended behind the user's back.
    ids = list(tokenizer.encode(text))
    if len(ids) < 2:
        raise RuntimeError(f"text too short to compute loss: {len(ids)} tokens")

    inputs = mx.array([ids[:-1]], dtype = mx.int32)
    targets = mx.array([ids[1:]], dtype = mx.int32)

    def loss_fn(m):
        logits = m(inputs)
        return nn.losses.cross_entropy(logits, targets, reduction = "mean")

    loss_and_grad = nn.value_and_grad(model, loss_fn)
    loss_val, grad = loss_and_grad(model)

    norm_sq = mx.array(0.0, dtype = mx.float32)
    for _name, value in tree_flatten(grad):
        v = value.astype(mx.float32)
        norm_sq = norm_sq + mx.sum(v * v)
    return float(loss_val.item()), float(mx.sqrt(norm_sq).item())


def _teacher_forced_completion_loss(model, tokenizer, prompt: str, completion: str) -> float:
    """Mean teacher-forced next-token CE on `completion` given `prompt`.

    Decouples the memorisation check from flaky greedy-decode geometry:
    asserts *what* the model memorised, not just that loss is low.
    """
    import mlx.core as mx
    import mlx.nn as nn

    prompt_ids = list(tokenizer.encode(prompt))
    full_ids = list(tokenizer.encode(prompt + completion))
    if len(full_ids) <= len(prompt_ids):
        raise RuntimeError(
            f"completion {completion!r} tokenises to zero new tokens after "
            f"{prompt!r}; check tokenizer / chat template."
        )

    inputs = mx.array([full_ids[:-1]], dtype = mx.int32)
    targets = mx.array([full_ids[1:]], dtype = mx.int32)
    logits = model(inputs)

    # logits at position i predict targets[i]; completion starts at len(prompt_ids)-1.
    start = len(prompt_ids) - 1
    completion_logits = logits[:, start:, :]
    completion_targets = targets[:, start:]
    loss = nn.losses.cross_entropy(completion_logits, completion_targets, reduction = "mean")
    return float(loss.item())


def _write_metrics(path: Path, metrics: dict) -> None:
    path.write_text(json.dumps(metrics, indent = 2, default = str))
    print(f"\n[metrics] wrote {path}", flush = True)
    print(json.dumps(metrics, indent = 2, default = str), flush = True)


# ---------------------------------------------------------------------------
# `train` subcommand
# ---------------------------------------------------------------------------


def cmd_train(args) -> int:
    _seed_everything()
    metrics: dict = {
        "subcommand": "train",
        "seed": SEED,
        "model": MODEL_NAME,
        "train_text": TRAIN_TEXT,
        "prompt": PROMPT,
        "phases": {},
    }
    workdir = Path(args.workdir).resolve()
    workdir.mkdir(parents = True, exist_ok = True)

    import mlx.core as mx
    from unsloth_zoo.mlx.loader import FastMLXModel
    from unsloth_zoo.mlx.trainer import MLXTrainer, MLXTrainingConfig

    hf_token = os.environ.get("HF_TOKEN") or None

    with Phase("load_base", metrics):
        model, tokenizer = FastMLXModel.from_pretrained(
            MODEL_NAME,
            load_in_4bit = False,
            dtype = "float16",
            text_only = True,
            max_seq_length = 128,
            random_state = SEED,
            token = hf_token,
            trust_remote_code = False,
        )
    metrics["base_src_path"] = str(getattr(model, "_src_path", "") or "")

    mx.random.seed(SEED)

    with Phase("apply_lora", metrics):
        # Full q/k/v/o + gate/up/down set: q/k/v/o alone couldn't memorize
        # the row, the MLP projections add the needed capacity.
        model = FastMLXModel.get_peft_model(
            model,
            r = 8,
            lora_alpha = 16,
            lora_dropout = 0.0,
            target_modules = [
                "q_proj",
                "k_proj",
                "v_proj",
                "o_proj",
                "gate_proj",
                "up_proj",
                "down_proj",
            ],
            use_gradient_checkpointing = False,
            random_state = SEED,
            finetune_language_layers = True,
            finetune_attention_modules = True,
            finetune_mlp_modules = True,
        )

    with Phase("pre_train_grad_probe", metrics):
        pre_loss, pre_norm = _compute_loss_and_grad_norm(model, tokenizer, TRAIN_TEXT)
    metrics["pre_train_loss"] = round(pre_loss, 4)
    metrics["pre_train_grad_norm"] = round(pre_norm, 4)
    assert math.isfinite(pre_loss) and math.isfinite(pre_norm) and pre_norm > 0

    losses_per_step: list[float] = []
    with Phase("train", metrics):
        config = MLXTrainingConfig(
            per_device_train_batch_size = 2,
            gradient_accumulation_steps = 3,
            # PR #5498 sweep: 7 steps too few; 30 makes every seed converge.
            max_steps = 30,
            learning_rate = 1e-3,
            warmup_steps = 0,
            lr_scheduler_type = "constant",
            optim = "adamw",
            weight_decay = 0.0,
            # Pin the elementwise clip (value=1.0, norm disabled) to match the
            # 13-seed-tested fixture; explicit value overrides zoo's MLX default.
            max_grad_norm = 0.0,
            max_grad_value = 1.0,
            logging_steps = 1,
            max_seq_length = 64,
            seed = SEED,
            use_cce = False,
            compile = False,
            gradient_checkpointing = False,
            output_dir = str(workdir / "trainer_outputs"),
            save_steps = 0,
            eval_steps = 0,
            dataset_text_field = "text",
        )
        trainer = MLXTrainer(
            model = model,
            tokenizer = tokenizer,
            train_dataset = [{"text": TRAIN_TEXT}] * 64,
            args = config,
        )

        def _on_step(
            step,
            total,
            loss,
            lr,
            tok_s,
            peak_gb,
            elapsed,
            num_tokens,
            grad_norm = None,
        ):
            losses_per_step.append(round(float(loss), 4))
            grad_text = f"  grad={grad_norm:.4f}" if grad_norm is not None else ""
            print(
                f"  step {step}/{total}  loss={loss:.4f}  lr={lr:.2e}  "
                f"tok/s={tok_s:.0f}  peak={peak_gb:.2f}GB{grad_text}",
                flush = True,
            )

        trainer.add_step_callback(_on_step)
        train_result = trainer.train()
    metrics["losses_per_step"] = losses_per_step
    metrics["train_summary"] = {
        k: train_result[k]
        for k in (
            "train_loss",
            "train_runtime",
            "train_steps",
            "trained_tokens",
            "train_samples_per_second",
            "compile_enabled",
            "patch_mode",
        )
        if k in train_result
    }
    # logging_steps=1 + max_steps=N -> N callbacks; gate auto-follows max_steps.
    expected_logged_steps = int(config.max_steps)
    assert (
        len(losses_per_step) == expected_logged_steps
    ), f"expected {expected_logged_steps} logged steps, got {losses_per_step}"
    if "train_steps" in train_result:
        assert int(train_result["train_steps"]) == expected_logged_steps, (
            f"expected train_steps={expected_logged_steps}, got " f"{train_result['train_steps']}"
        )
    for i, l in enumerate(losses_per_step):
        # Allow exact 0.0: fp16 loss underflows once the LoRA memorises the
        # row (~step 10); that's success, so the lower bound is >= 0 not > 0.
        assert math.isfinite(l) and 0 <= l < 50, f"step {i+1} loss bad: {l}"
    assert (
        losses_per_step[-1] < losses_per_step[0] * 1.1
    ), f"loss diverged: {losses_per_step[0]} -> {losses_per_step[-1]}"

    with Phase("post_train_grad_probe", metrics):
        post_loss, post_norm = _compute_loss_and_grad_norm(model, tokenizer, TRAIN_TEXT)
    metrics["post_train_loss"] = round(post_loss, 4)
    metrics["post_train_grad_norm"] = round(post_norm, 4)
    assert post_loss < pre_loss, f"post {post_loss} >= pre {pre_loss}"
    # Memorisation gate: every converging (clip, bc, seed) config in the
    # 13-seed sweep hit post_train_loss <= 0.05, so 0.1 is a robust bound.
    assert post_loss < 0.1, (
        f"post_train_loss={post_loss:.4f} >= 0.1 -- training did not "
        "memorise the single training row in 30 steps. Trainer "
        "regression suspected."
    )

    from mlx_lm import generate

    with Phase("inference_in_memory", metrics):
        model.eval()
        in_mem_out = generate(
            model,
            tokenizer,
            prompt = PROMPT,
            max_tokens = 48,
            verbose = False,
        )
    metrics["in_memory_generation"] = in_mem_out
    # Soft greedy-decode metric only (46-77% of seeds): fp16 + MLX generate
    # noises the first token. The teacher-forced check below is load-bearing.
    metrics["in_memory_generation_has_expected"] = EXPECT_IN_OUTPUT in in_mem_out
    if EXPECT_IN_OUTPUT not in in_mem_out:
        print(
            f"  [INFO] greedy decode did not contain {EXPECT_IN_OUTPUT!r} "
            f"(post_train_loss={post_loss:.4f}, completion={in_mem_out!r}). "
            "Hard gate is the teacher-forced completion-loss check below.",
            flush = True,
        )

    # Hard check: teacher-forced loss on the trained completion bypasses
    # greedy-decode fp16 fragility. 13/13 measured configs reached < 1e-3,
    # so this gate is deterministic across (seed, clip, bc).
    completion_loss = _teacher_forced_completion_loss(
        model, tokenizer, PROMPT, EXPECT_IN_OUTPUT + "!"
    )
    metrics["in_memory_completion_teacher_forced_loss"] = round(completion_loss, 6)
    assert completion_loss < 0.5, (
        f"teacher-forced completion loss {completion_loss:.4f} >= 0.5: "
        f"the LoRA did not memorise {EXPECT_IN_OUTPUT + '!'!r} after "
        f"{PROMPT!r} (post_train_loss={post_loss:.4f}). Trainer regression "
        "suspected -- check unsloth_zoo MLX trainer gradient clipping / "
        "optimizer defaults vs torch.optim.AdamW."
    )

    # unsloth-zoo#627 fixed from_pretrained(lora_dir) so the cold-start
    # reload below works on the saved adapter dir directly.
    lora_dir = workdir / "lora"
    with Phase("save_lora", metrics):
        model.save_pretrained_merged(
            str(lora_dir),
            tokenizer = tokenizer,
            save_method = "lora",
        )
    metrics["lora_dir"] = str(lora_dir)
    assert (lora_dir / "adapters.safetensors").exists()
    assert (lora_dir / "adapter_config.json").exists()

    # Save merged_16bit (full HF directory)
    merged_dir = workdir / "merged_16bit"
    with Phase("save_merged_16bit", metrics):
        model.save_pretrained_merged(
            str(merged_dir),
            tokenizer = tokenizer,
            save_method = "merged_16bit",
        )
    metrics["merged_dir"] = str(merged_dir)
    assert any(merged_dir.glob("*.safetensors"))

    # Save GGUF (best-effort). For some models (e.g. gemma-3-270m-it)
    # llama.cpp's convert_hf_to_gguf asserts on the tokenizer vocab -- an
    # llama.cpp limitation, not an unsloth_zoo bug. Soft-skip with a recorded
    # reason so the LoRA + merged_16bit assertions still gate the PR.
    gguf_dir = workdir / "gguf"
    metrics["gguf_supported"] = False
    metrics["gguf_skip_reason"] = None
    metrics["gguf_dir"] = str(gguf_dir)
    with Phase("save_gguf", metrics):
        try:
            model.save_pretrained_gguf(
                str(gguf_dir),
                tokenizer = tokenizer,
                quantization_method = "not_quantized",
            )
            gguf_files = sorted(gguf_dir.glob("*.gguf"))
            if not gguf_files:
                raise RuntimeError(f"no .gguf produced in {gguf_dir}")
            metrics["gguf_supported"] = True
            metrics["gguf_files"] = [p.name for p in gguf_files]
        except Exception as e:
            err_text = f"{type(e).__name__}: {e}"
            if "AssertionError" in err_text or "tokenizer.vocab" in err_text:
                metrics["gguf_skip_reason"] = (
                    f"llama.cpp convert_hf_to_gguf asserted on tokenizer "
                    f"vocab for {MODEL_NAME} (max(vocab IDs) >= "
                    f"vocab_size). Downstream llama.cpp limitation, not "
                    f"unsloth_zoo. Underlying error: {err_text}"
                )
            else:
                metrics["gguf_skip_reason"] = err_text
            print(f"  GGUF SKIPPED: {metrics['gguf_skip_reason']}", flush = True)

    metrics["final_peak_gpu_gb"] = round(_peak_gpu_gb(), 3)
    metrics["final_peak_rss_gb"] = round(_peak_rss_gb(), 3)

    _write_metrics(workdir / "train_metrics.json", metrics)
    return 0


# ---------------------------------------------------------------------------
# `reload` subcommand (fresh process per format)
# ---------------------------------------------------------------------------


def cmd_reload(args) -> int:
    _seed_everything()
    save_dir = Path(args.dir).resolve()
    if not save_dir.exists():
        raise SystemExit(f"reload dir not found: {save_dir}")

    metrics: dict = {
        "subcommand": "reload",
        "format": args.format,
        "dir": str(save_dir),
        "phases": {},
    }

    if args.format == "gguf":
        return _reload_gguf(save_dir, metrics)

    import mlx.core as mx
    from unsloth_zoo.mlx.loader import FastMLXModel
    from mlx_lm import generate

    hf_token = os.environ.get("HF_TOKEN") or None

    with Phase(f"reload_{args.format}", metrics):
        mx.random.seed(SEED)
        m, t = FastMLXModel.from_pretrained(
            str(save_dir),
            load_in_4bit = False,
            dtype = "float16",
            text_only = True,
            max_seq_length = 128,
            random_state = SEED,
            token = hf_token,
        )
        m.eval()

    with Phase(f"generate_{args.format}", metrics):
        out = generate(m, t, prompt = PROMPT, max_tokens = 48, verbose = False)
    metrics["generation"] = out
    print(f"  [reload:{args.format}] output: {out!r}", flush = True)

    # Save/reload invariant: reloaded teacher-forced loss on TRAIN_TEXT must
    # match the in-memory post_train_loss. Robust to MLX's greedy-decode
    # perturbation, which can flip the first token but not the loss.
    train_metrics_path = save_dir.parent / "train_metrics.json"
    in_mem_loss = None
    in_mem_out = None
    if train_metrics_path.exists():
        try:
            tm = json.loads(train_metrics_path.read_text())
            in_mem_loss = tm.get("post_train_loss")
            in_mem_out = tm.get("in_memory_generation")
        except Exception:
            in_mem_loss = None
    metrics["in_memory_generation_ref"] = in_mem_out
    metrics["in_memory_post_train_loss"] = in_mem_loss
    metrics["reload_completion_matches_in_memory"] = in_mem_out is not None and out == in_mem_out
    if isinstance(in_mem_loss, (int, float)) and math.isfinite(in_mem_loss):
        reload_loss, _ = _compute_loss_and_grad_norm(m, t, TRAIN_TEXT)
        metrics["reload_post_train_loss"] = round(reload_loss, 4)
        # float16 round-trip is near-exact; 0.2 tolerates dequant noise.
        assert abs(reload_loss - float(in_mem_loss)) < 0.2, (
            f"reload {args.format!r} loss diverged from in-memory: "
            f"reload={reload_loss:.4f}, in-memory={in_mem_loss:.4f}"
        )
    else:
        # Fallback when train_metrics.json is missing: gate on non-empty output.
        body = out.replace(PROMPT, "", 1).strip()
        assert len(body) >= 4, (
            f"reload {args.format!r} produced no usable output for " f"{PROMPT!r}: {out!r}"
        )

    metrics["final_peak_gpu_gb"] = round(_peak_gpu_gb(), 3)
    metrics["final_peak_rss_gb"] = round(_peak_rss_gb(), 3)
    _write_metrics(save_dir.parent / f"{args.format}_reload_metrics.json", metrics)
    return 0


def _reload_gguf(save_dir: Path, metrics: dict) -> int:
    candidates = [
        Path("llama.cpp/llama-cli"),
        Path("llama.cpp/build/bin/llama-cli"),
    ]
    llama_cli = next((c for c in candidates if c.exists()), None)
    if llama_cli is None:
        raise SystemExit(f"llama-cli not found; checked {candidates}")

    gguf_files = sorted(save_dir.glob("*.gguf"))
    if not gguf_files:
        raise SystemExit(f"no .gguf files in {save_dir}")
    gguf_path = gguf_files[0]

    with Phase("reload_gguf", metrics):
        proc = subprocess.run(
            [
                str(llama_cli),
                "-m",
                str(gguf_path),
                "-p",
                PROMPT,
                "-n",
                "24",
                "--temp",
                "0",
                "--seed",
                str(SEED),
                "-no-cnv",
                "--no-warmup",
            ],
            capture_output = True,
            text = True,
            timeout = 300,
        )

    metrics["llama_cli_returncode"] = proc.returncode
    metrics["generation"] = (proc.stdout or "")[:1500]
    metrics["stderr_head"] = (proc.stderr or "")[:600]

    print(f"  [reload:gguf] stdout (head):\n{proc.stdout[:800]}", flush = True)
    if proc.returncode != 0:
        raise SystemExit(f"llama-cli exit {proc.returncode}; stderr head: {proc.stderr[:400]}")
    # llama.cpp tokenises/samples differently than mlx_lm, so the GGUF
    # completion needn't match. Require non-empty output to catch real
    # save/reload corruption; record EXPECT_IN_OUTPUT without gating on it.
    body = (proc.stdout or "").replace(PROMPT, "", 1).strip()
    metrics["gguf_has_expected"] = EXPECT_IN_OUTPUT in (proc.stdout or "")
    assert len(body) >= 4, (
        f"GGUF reload produced no usable output for {PROMPT!r}: " f"{proc.stdout[:400]!r}"
    )

    metrics["final_peak_rss_gb"] = round(_peak_rss_gb(), 3)
    _write_metrics(save_dir.parent / "gguf_reload_metrics.json", metrics)
    return 0


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def main() -> int:
    parser = argparse.ArgumentParser()
    sub = parser.add_subparsers(dest = "cmd", required = True)

    p_train = sub.add_parser("train")
    p_train.add_argument("--workdir", required = True)

    p_reload = sub.add_parser("reload")
    p_reload.add_argument(
        "--format",
        required = True,
        choices = ["lora", "merged", "gguf"],
    )
    p_reload.add_argument("--dir", required = True)

    args = parser.parse_args()
    if args.cmd == "train":
        return cmd_train(args)
    if args.cmd == "reload":
        return cmd_reload(args)
    return 1


if __name__ == "__main__":
    sys.exit(main())
