#!/usr/bin/env python3
"""Export purpose-lite to fixed-shape fp16 and int8-QDQ ONNX artifacts."""

from __future__ import annotations

import argparse
import hashlib
import math
import shutil
import sys
import tempfile
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any, Sequence

import numpy as np

from purpose_data import (
    DataError,
    load_jsonl,
    normalize_prompt,
    prompt_hash,
    write_json,
)
from train import (
    HEAD_TOKENS,
    MAX_LENGTH,
    TAIL_TOKENS,
    encode_fixed_shape,
)


SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_MODEL_DIR = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "model"
DEFAULT_CALIBRATION = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "calibration.json"
DEFAULT_VALIDATION = SCRIPT_DIR / ".artifacts" / "dataset-v1" / "validation.jsonl"
DEFAULT_OUTPUT_DIR = SCRIPT_DIR / "outputs" / "purpose-lite-v1" / "export"
MODEL_VERSION = "purpose-lite-v1"
SHIPPING_BUDGET_BYTES = 25 * 1024 * 1024
GOLDEN_PROMPTS = (
    "Fix the typo in the README.",
    "  Cafe\u0301\tdeploy\nnow  ",
    (
        "EXPLAIN ANALYZE shows a sequential scan before the incident notes. "
        + "context " * 180
        + "Find the root cause and explain which query plan evidence proves it."
    ),
    "レビューだけして、コードは変更しないでください。",
)


def stratified_calibration_sample(
    records: Sequence[dict[str, Any]],
    count: int,
    *,
    seed: int,
) -> list[dict[str, Any]]:
    """Select an exact, deterministic purpose/slice/language calibration sample."""

    if not records:
        raise DataError("cannot calibrate quantization from an empty validation split")
    if count <= 0:
        raise DataError("calibration record count must be positive")
    target = min(count, len(records))
    groups: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
    for record in records:
        language = str(record.get("lang", "unknown")).split("-", 1)[0].casefold()
        key = (
            str(record.get("purpose", "unknown")),
            str(record.get("slice", "unknown")),
            language,
        )
        groups[key].append(record)

    allocations = {}
    remainders = []
    allocated = 0
    for key in sorted(groups):
        quota = len(groups[key]) * target / len(records)
        base = math.floor(quota)
        allocations[key] = base
        allocated += base
        tie_break = hashlib.sha256(f"{seed}\0{key}".encode("utf-8")).hexdigest()
        remainders.append((quota - base, tie_break, key))
    for _, _, key in sorted(remainders, reverse=True)[: target - allocated]:
        allocations[key] += 1

    selected = []
    for key in sorted(groups):
        ranked = sorted(
            groups[key],
            key=lambda record: hashlib.sha256(
                f"{seed}\0{prompt_hash(record['prompt'])}".encode("utf-8")
            ).hexdigest(),
        )
        selected.extend(ranked[: allocations[key]])
    return sorted(
        selected,
        key=lambda record: hashlib.sha256(
            f"{seed + 1}\0{prompt_hash(record['prompt'])}".encode("utf-8")
        ).hexdigest(),
    )


def _sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _fixed_shape_inputs(model: Any) -> list[str]:
    names = ["input_ids", "attention_mask"]
    if getattr(model.config, "type_vocab_size", 0) > 1:
        names.append("token_type_ids")
    return names


def _validate_graph(path: Path, input_names: Sequence[str]) -> dict[str, Any]:
    import onnx

    model = onnx.load(path)
    onnx.checker.check_model(model)
    opset = max(
        item.version for item in model.opset_import if item.domain in ("", "ai.onnx")
    )
    if opset < 17:
        raise DataError(f"{path}: ONNX opset {opset} is below 17")
    shapes = {}
    for value in model.graph.input:
        dimensions = [item.dim_value for item in value.type.tensor_type.shape.dim]
        shapes[value.name] = dimensions
    expected = {name: [1, MAX_LENGTH] for name in input_names}
    if shapes != expected:
        raise DataError(f"{path}: expected fixed inputs {expected}, got {shapes}")
    custom_domains = sorted(
        {
            node.domain
            for node in model.graph.node
            if node.domain not in ("", "ai.onnx")
        }
    )
    if custom_domains:
        raise DataError(f"{path}: custom ONNX op domains are not allowed: {custom_domains}")
    return {
        "opset": opset,
        "inputs": shapes,
        "nodes": len(model.graph.node),
        "operators": sorted({node.op_type for node in model.graph.node}),
    }


def _write_tokenizer_contract(
    output_dir: Path, model_dir: Path, tokenizer: Any, torch: Any
) -> dict[str, Any]:
    vocab_destination = output_dir / "vocab.txt"
    vocabulary = tokenizer.get_vocab()
    ordered_vocabulary = sorted(vocabulary.items(), key=lambda item: item[1])
    if [token_id for _, token_id in ordered_vocabulary] != list(
        range(len(ordered_vocabulary))
    ):
        raise DataError("tokenizer vocabulary IDs are not contiguous")
    vocab_destination.write_text(
        "".join(f"{token}\n" for token, _ in ordered_vocabulary),
        encoding="utf-8",
    )
    tokenizer_json_source = model_dir / "tokenizer.json"
    if not tokenizer_json_source.exists():
        raise DataError(f"{tokenizer_json_source}: tokenizer JSON is missing")
    tokenizer_json_destination = output_dir / "tokenizer.json"
    shutil.copy2(tokenizer_json_source, tokenizer_json_destination)

    golden_values = []
    for prompt in GOLDEN_PROMPTS:
        encoded = encode_fixed_shape(tokenizer, [prompt], torch)
        golden_values.append(
            {
                "prompt": prompt,
                "normalizedPrompt": normalize_prompt(prompt),
                "inputIds": encoded["input_ids"][0].tolist(),
                "attentionMask": encoded["attention_mask"][0].tolist(),
                "tokenTypeIds": encoded.get(
                    "token_type_ids", torch.zeros_like(encoded["input_ids"])
                )[0].tolist(),
            }
        )
    write_json(output_dir / "tokenizer-goldens.json", golden_values)
    contract = {
        "schemaVersion": 1,
        "modelVersion": MODEL_VERSION,
        "tokenizer": {
            "family": "BERT WordPiece",
            "vocabFile": vocab_destination.name,
            "vocabSha256": _sha256(vocab_destination),
            "tokenizerJSON": tokenizer_json_destination.name,
            "tokenizerJSONSha256": _sha256(tokenizer_json_destination),
            "lowercase": bool(getattr(tokenizer, "do_lower_case", True)),
        },
        "normalization": ["Unicode NFKC", "collapse Unicode whitespace", "trim"],
        "input": {
            "shape": [1, MAX_LENGTH],
            "padding": "right",
            "longPromptStrategy": "BERT sentence pair: head and tail",
            "headTokens": HEAD_TOKENS,
            "tailTokens": TAIL_TOKENS,
            "specialTokenLayout": "[CLS] head [SEP] tail [SEP]",
        },
        "specialTokenIds": {
            "padding": tokenizer.pad_token_id,
            "unknown": tokenizer.unk_token_id,
            "classification": tokenizer.cls_token_id,
            "separator": tokenizer.sep_token_id,
        },
        "goldens": "tokenizer-goldens.json",
    }
    write_json(output_dir / "tokenizer-spec.json", contract)
    return contract


def export(args: argparse.Namespace) -> dict[str, Any]:
    try:
        import onnx
        import torch
        from onnxconverter_common import float16
        from onnxruntime.quantization import (
            CalibrationDataReader,
            QuantFormat,
            QuantType,
            quantize_static,
        )
        from transformers import AutoModelForSequenceClassification, AutoTokenizer
    except ImportError as exc:
        raise DataError(
            "export dependencies are missing; install requirements.txt"
        ) from exc

    output_dir: Path = args.output_dir
    output_dir.mkdir(parents=True, exist_ok=True)
    tokenizer = AutoTokenizer.from_pretrained(args.model_dir, local_files_only=True)
    model = AutoModelForSequenceClassification.from_pretrained(
        args.model_dir, local_files_only=True
    )
    model.eval()
    input_names = _fixed_shape_inputs(model)
    example = encode_fixed_shape(tokenizer, ["Plan a safe cache migration."], torch)

    class LogitsModel(torch.nn.Module):
        def __init__(self, inner: Any) -> None:
            super().__init__()
            self.inner = inner

        def forward(self, *values: Any) -> Any:
            inputs = dict(zip(input_names, values))
            return self.inner(**inputs).logits

    fp16_path = output_dir / f"{MODEL_VERSION}-fp16.onnx"
    int8_path = output_dir / f"{MODEL_VERSION}-int8-qdq.onnx"
    with tempfile.TemporaryDirectory() as temporary:
        fp32_path = Path(temporary) / f"{MODEL_VERSION}-fp32.onnx"
        torch.onnx.export(
            LogitsModel(model),
            tuple(example[name] for name in input_names),
            fp32_path,
            input_names=input_names,
            output_names=["logits"],
            opset_version=args.opset,
            do_constant_folding=True,
            dynamo=False,
        )
        fp32_model = onnx.load(fp32_path)
        fp16_model = float16.convert_float_to_float16(
            fp32_model,
            keep_io_types=True,
            disable_shape_infer=False,
        )
        onnx.save(fp16_model, fp16_path)

        validation = load_jsonl(args.validation)
        calibration_samples = stratified_calibration_sample(
            validation,
            args.calibration_records,
            seed=args.calibration_seed,
        )

        class Reader(CalibrationDataReader):
            def __init__(self) -> None:
                self.index = 0
                self.samples = calibration_samples

            def get_next(self) -> dict[str, np.ndarray] | None:
                if self.index >= len(self.samples):
                    return None
                record = self.samples[self.index]
                self.index += 1
                encoded = encode_fixed_shape(tokenizer, [record["prompt"]], torch)
                return {
                    name: encoded[name].numpy().astype(np.int64, copy=False)
                    for name in input_names
                }

        quantize_static(
            fp32_path,
            int8_path,
            Reader(),
            quant_format=QuantFormat.QDQ,
            activation_type=QuantType.QUInt8,
            weight_type=QuantType.QInt8,
            per_channel=True,
            # Gather is essential: MiniLM's 30k x 384 embedding table is more than half
            # the checkpoint. Quantizing only MatMul/Gemm leaves a ~47 MB fp32 table and
            # cannot meet the 25 MB parity-floor artifact budget.
            op_types_to_quantize=["Gather", "MatMul", "Gemm"],
            extra_options={
                "ActivationSymmetric": False,
                "WeightSymmetric": True,
            },
        )

    graph_reports = {
        "fp16": _validate_graph(fp16_path, input_names),
        "int8QDQ": _validate_graph(int8_path, input_names),
    }
    int8_size = int8_path.stat().st_size
    if int8_size > args.shipping_budget_bytes:
        raise DataError(
            f"{int8_path}: {int8_size} bytes exceeds the "
            f"{args.shipping_budget_bytes}-byte shipping budget"
        )

    tokenizer_contract = _write_tokenizer_contract(
        output_dir, args.model_dir, tokenizer, torch
    )
    calibration_destination = output_dir / "calibration.json"
    shutil.copy2(args.calibration, calibration_destination)
    artifacts = {}
    for name, path in (("fp16", fp16_path), ("int8QDQ", int8_path)):
        artifacts[name] = {
            "path": path.name,
            "bytes": path.stat().st_size,
            "sha256": _sha256(path),
        }
    report = {
        "schemaVersion": 1,
        "modelVersion": MODEL_VERSION,
        "sourceModel": str(args.model_dir),
        "opset": args.opset,
        "fixedInputShape": [1, MAX_LENGTH],
        "inputNames": input_names,
        "calibrationRecords": len(calibration_samples),
        "calibrationSeed": args.calibration_seed,
        "calibrationSample": {
            "strategy": "stratified by purpose, slice, and primary language",
            "purposeCounts": dict(
                sorted(Counter(item["purpose"] for item in calibration_samples).items())
            ),
            "sliceCounts": dict(
                sorted(Counter(item["slice"] for item in calibration_samples).items())
            ),
            "promptHashes": sorted(
                prompt_hash(item["prompt"]) for item in calibration_samples
            ),
        },
        "shippingArtifact": "int8QDQ",
        "shippingBudgetBytes": args.shipping_budget_bytes,
        "shippingBudgetPassed": int8_size <= args.shipping_budget_bytes,
        "artifacts": artifacts,
        "graphs": graph_reports,
        "tokenizerSpecSha256": _sha256(output_dir / "tokenizer-spec.json"),
        "tokenizerContract": tokenizer_contract["input"],
        "calibrationSha256": _sha256(calibration_destination),
    }
    write_json(output_dir / "export-metrics.json", report)
    return report


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--model-dir", type=Path, default=DEFAULT_MODEL_DIR)
    parser.add_argument("--calibration", type=Path, default=DEFAULT_CALIBRATION)
    parser.add_argument("--validation", type=Path, default=DEFAULT_VALIDATION)
    parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
    parser.add_argument("--opset", type=int, default=17)
    parser.add_argument("--calibration-records", type=int, default=256)
    parser.add_argument("--calibration-seed", type=int, default=20260730)
    parser.add_argument(
        "--shipping-budget-bytes", type=int, default=SHIPPING_BUDGET_BYTES
    )
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    if (
        args.opset < 17
        or args.calibration_records <= 0
        or args.shipping_budget_bytes <= 0
    ):
        parser.error("opset must be >=17 and record/budget values must be positive")
    try:
        report = export(args)
    except (DataError, OSError, ValueError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1
    print(
        f"Exported {MODEL_VERSION}: fp16={report['artifacts']['fp16']['bytes']} bytes, "
        f"int8-QDQ={report['artifacts']['int8QDQ']['bytes']} bytes."
    )
    return 0


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