#!/usr/bin/env python3
"""Verify MLX/PyTorch parity and checkpoint round-tripping before MLX training."""

from __future__ import annotations

import argparse
import tempfile
from pathlib import Path
from typing import Sequence

import numpy as np

from purpose_data import LABELS, DataError, load_jsonl
from train import enable_quantization_aware_training, encode_fixed_shape
from train_mlx import (
    _checkpoint_config,
    _configure_mlx_device,
    encode_fixed_shape_numpy,
)


SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_DATASET = SCRIPT_DIR / ".artifacts" / "dataset-v1" / "validation.jsonl"


def verify(model_dir: Path, dataset: Path, records: int, device: str) -> None:
    try:
        import mlx.core as mx
        import mlx.nn as nn
        import torch
        from mlx.utils import tree_flatten
        from safetensors import safe_open
        from transformers import AutoModelForSequenceClassification, AutoTokenizer

        from mlx_model import (
            BertClassifierConfig,
            BertForSequenceClassification,
            fake_quantize_activation,
            fake_quantize_linear_weight,
            load_hugging_face_weights,
            save_hugging_face_weights,
        )
    except ImportError as exc:
        raise DataError(
            "verification requires requirements-mlx.txt"
        ) from exc
    _configure_mlx_device(mx, device)

    # Check the fake-quantization contract independently of the full model. Tiny
    # backend-specific floating-point differences can cross later quantization
    # thresholds, especially on padded tokens, so full QAT logits are expected to
    # have more drift than the ordinary float graph.
    activation_values = np.asarray(
        [[-2.75, -0.125, 0.0, 0.625], [1.25, 3.5, -1.0, 0.25]],
        dtype=np.float32,
    )
    torch_activation = torch.from_numpy(activation_values)
    activation_minimum = min(0.0, float(torch_activation.amin().item()))
    activation_maximum = max(0.0, float(torch_activation.amax().item()))
    activation_scale = max(
        (activation_maximum - activation_minimum) / 255.0,
        torch.finfo(torch.float32).eps,
    )
    activation_zero_point = max(
        0,
        min(255, round(-activation_minimum / activation_scale)),
    )
    torch_quantized_activation = torch.fake_quantize_per_tensor_affine(
        torch_activation,
        activation_scale,
        activation_zero_point,
        0,
        255,
    ).numpy()
    mlx_quantized_activation = np.asarray(
        fake_quantize_activation(mx.array(activation_values))
    )
    np.testing.assert_array_equal(
        mlx_quantized_activation,
        torch_quantized_activation,
    )

    weight_values = np.asarray(
        [[-1.5, -0.25, 0.75, 1.25], [0.125, -0.875, 2.0, -1.25]],
        dtype=np.float32,
    )
    torch_weight = torch.from_numpy(weight_values)
    weight_scales = torch_weight.abs().amax(dim=1).div(127.0).clamp_min(
        torch.finfo(torch.float32).eps
    )
    torch_quantized_weight = torch.fake_quantize_per_channel_affine(
        torch_weight,
        weight_scales,
        torch.zeros_like(weight_scales, dtype=torch.int32),
        0,
        -127,
        127,
    ).numpy()
    mlx_quantized_weight = np.asarray(
        fake_quantize_linear_weight(mx.array(weight_values))
    )
    np.testing.assert_array_equal(mlx_quantized_weight, torch_quantized_weight)
    print("fake-quant primitives: exact")

    checkpoint = model_dir / "model.safetensors"
    if not checkpoint.is_file():
        raise DataError(f"{checkpoint}: checkpoint is missing")
    validation = load_jsonl(dataset)[:records]
    if not validation:
        raise DataError(f"{dataset}: no validation records")
    texts = [record["prompt"] for record in validation]
    labels = np.asarray(
        [LABELS.index(record["purpose"]) for record in validation],
        dtype=np.int32,
    )
    tokenizer = AutoTokenizer.from_pretrained(model_dir, local_files_only=True)
    numpy_tokens = encode_fixed_shape_numpy(tokenizer, texts)
    torch_tokens = encode_fixed_shape(tokenizer, texts, torch)

    config_json = _checkpoint_config(model_dir)
    config = BertClassifierConfig.from_hugging_face(config_json)
    mlx_float = BertForSequenceClassification(
        config,
        quantization_aware=False,
    )
    load_hugging_face_weights(mlx_float, checkpoint)
    mlx_float.eval()
    mlx_float_logits = mlx_float(
        **{key: mx.array(value) for key, value in numpy_tokens.items()}
    )
    mx.eval(mlx_float_logits)
    mlx_float_logits = np.asarray(mlx_float_logits)

    torch_model = AutoModelForSequenceClassification.from_pretrained(
        model_dir,
        local_files_only=True,
    )
    torch_model.eval()
    with torch.inference_mode():
        torch_float_logits = torch_model(**torch_tokens).logits.numpy()
    np.testing.assert_allclose(
        mlx_float_logits,
        torch_float_logits,
        rtol=1e-4,
        atol=2e-5,
    )
    if not np.array_equal(
        mlx_float_logits.argmax(axis=-1),
        torch_float_logits.argmax(axis=-1),
    ):
        raise DataError("MLX and PyTorch float predictions differ")
    print(
        "float parity: "
        f"max_abs_error={np.max(np.abs(mlx_float_logits - torch_float_logits)):.3g}"
    )

    with tempfile.TemporaryDirectory(prefix="purpose-mlx-roundtrip-") as temp:
        roundtrip = Path(temp) / "model.safetensors"
        save_hugging_face_weights(mlx_float, roundtrip)
        with safe_open(checkpoint, framework="np") as original, safe_open(
            roundtrip,
            framework="np",
        ) as converted:
            if set(original.keys()) != set(converted.keys()):
                raise DataError("MLX checkpoint round-trip changed parameter keys")
            for key in original.keys():
                np.testing.assert_array_equal(
                    original.get_tensor(key),
                    converted.get_tensor(key),
                )
    print("checkpoint round-trip: exact")

    del mlx_float
    mx.clear_cache()
    mlx_qat = BertForSequenceClassification(
        config,
        quantization_aware=True,
    )
    load_hugging_face_weights(mlx_qat, checkpoint)
    mlx_qat.eval()
    mlx_qat_logits = mlx_qat(
        **{key: mx.array(value) for key, value in numpy_tokens.items()}
    )
    mx.eval(mlx_qat_logits)
    mlx_qat_logits = np.asarray(mlx_qat_logits)

    enable_quantization_aware_training(torch, torch_model)
    torch_model.eval()
    with torch.inference_mode():
        torch_qat_logits = torch_model(**torch_tokens).logits.numpy()
    qat_max_abs_error = float(
        np.max(np.abs(mlx_qat_logits - torch_qat_logits))
    )
    if not np.isfinite(qat_max_abs_error) or qat_max_abs_error > 1.0:
        raise DataError(
            "MLX and PyTorch QAT logits have excessive backend drift: "
            f"{qat_max_abs_error:.3g}"
        )
    if not np.array_equal(
        mlx_qat_logits.argmax(axis=-1),
        torch_qat_logits.argmax(axis=-1),
    ):
        raise DataError("MLX and PyTorch QAT predictions differ")
    print(
        "QAT parity: "
        f"predictions=exact max_abs_error={qat_max_abs_error:.3g}"
    )

    mlx_qat.train()
    mlx_labels = mx.array(labels)

    def loss_function() -> object:
        logits = mlx_qat(
            **{key: mx.array(value) for key, value in numpy_tokens.items()}
        )
        return nn.losses.cross_entropy(logits, mlx_labels, reduction="mean")

    loss, gradients = nn.value_and_grad(mlx_qat, loss_function)()
    flat_gradients = [gradient for _, gradient in tree_flatten(gradients)]
    mx.eval(loss, gradients)
    if not all(
        bool(mx.all(mx.isfinite(gradient)).item())
        for gradient in flat_gradients
    ):
        raise DataError("MLX QAT produced non-finite gradients")
    if not any(
        float(mx.max(mx.abs(gradient)).item()) > 0
        for gradient in flat_gradients
    ):
        raise DataError("MLX QAT produced only zero gradients")
    print(f"QAT gradient smoke: loss={float(loss.item()):.6f}")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--model", type=Path, required=True)
    parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
    parser.add_argument("--records", type=int, default=8)
    parser.add_argument(
        "--device",
        choices=("metal", "cpu"),
        default="metal",
        help="MLX execution device (default: metal; cpu is a diagnostic fallback)",
    )
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if args.records <= 0:
        raise SystemExit("--records must be positive")
    try:
        verify(
            args.model.expanduser(),
            args.dataset.expanduser(),
            args.records,
            args.device,
        )
    except (AssertionError, DataError) as exc:
        print(f"error: {exc}")
        return 2
    return 0


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