#!/usr/bin/env python3
"""Export a match policy's actor trunk to ONNX for the browser.

Only the actor ships. The critic exists to grade moves during training and never
plays, so its trunk -- and the privileged view of the opponent's hand and the deck
that feeds it -- is dropped here rather than shipped and then not used. That also
means the exported graph physically cannot leak hidden state, which is a stronger
guarantee than promising not to call it.

MatchActorCritic lays the actor out as Dense_0..Dense_{num_layers} exactly as the
single-round model does, so the graph construction is the same; only the input
width and the checkpoint loader differ.
"""

from __future__ import annotations

import argparse
import hashlib
import json
from pathlib import Path

import jax
import jax.numpy as jnp
import numpy as np

from lost_cities_jax.match_obs import MATCH_CRITIC_OBS_DIM, MATCH_OBS_DIM
from lost_cities_jax.match_ppo import Ablation, MatchActorCritic, create_match_train_state
from lost_cities_jax.ppo import load_config, restore_checkpoint
from lost_cities_jax.types import N_ACTIONS


def build_argparser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--checkpoint", type=Path, required=True)
    parser.add_argument("--config", type=Path, default=Path("configs/jax_ppo/match-selfplay.yaml"))
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--codename", required=True, help="see data/models.json")
    return parser


def export_model(checkpoint: Path, config: Path, output: Path, codename: str) -> None:
    try:
        import onnx
        from onnx import TensorProto, helper, numpy_helper
        from onnx.reference import ReferenceEvaluator
    except ImportError as exc:
        raise SystemExit("onnx is required; run with `uv run --with onnx ...`") from exc

    cfg = load_config(config)
    state = restore_checkpoint(
        checkpoint, create_match_train_state(cfg, jax.random.PRNGKey(0), Ablation())
    )
    params = state.params
    dense = params["params"]

    nodes = []
    initializers = []
    previous = "obs"
    for index in range(cfg.network.num_layers):
        layer = dense[f"Dense_{index}"]
        weight, bias = f"dense_{index}.weight", f"dense_{index}.bias"
        initializers.extend(
            [
                numpy_helper.from_array(np.asarray(layer["kernel"], dtype=np.float32), weight),
                numpy_helper.from_array(np.asarray(layer["bias"], dtype=np.float32), bias),
            ]
        )
        nodes.append(helper.make_node("Gemm", [previous, weight, bias], [f"dense_{index}.linear"]))
        nodes.append(helper.make_node("Relu", [f"dense_{index}.linear"], [f"dense_{index}.relu"]))
        previous = f"dense_{index}.relu"

    actor = dense[f"Dense_{cfg.network.num_layers}"]
    initializers.extend(
        [
            numpy_helper.from_array(np.asarray(actor["kernel"], dtype=np.float32), "actor.weight"),
            numpy_helper.from_array(np.asarray(actor["bias"], dtype=np.float32), "actor.bias"),
        ]
    )
    nodes.append(helper.make_node("Gemm", [previous, "actor.weight", "actor.bias"], ["logits"]))

    graph = helper.make_graph(
        nodes,
        f"coolrl-lost-cities-match-actor-{codename}",
        [helper.make_tensor_value_info("obs", TensorProto.FLOAT, [None, MATCH_OBS_DIM])],
        [helper.make_tensor_value_info("logits", TensorProto.FLOAT, [None, N_ACTIONS])],
        initializer=initializers,
    )
    model = helper.make_model(
        graph, producer_name="coolrl-lost-cities", opset_imports=[helper.make_opsetid("", 17)]
    )
    model.ir_version = 8
    onnx.checker.check_model(model)

    # The exported graph has to agree with the trained one, not merely load.
    rng = np.random.default_rng(20260715)
    sample = rng.normal(size=(8, MATCH_OBS_DIM)).astype(np.float32)
    critic_stub = jnp.zeros((8, MATCH_CRITIC_OBS_DIM), dtype=jnp.float32)
    flax_model = MatchActorCritic(cfg.network.hidden_size, cfg.network.num_layers)
    expected, _ = flax_model.apply(params, jnp.asarray(sample), critic_stub)
    actual = ReferenceEvaluator(model).run(None, {"obs": sample})[0]
    np.testing.assert_allclose(actual, np.asarray(expected), rtol=2e-5, atol=2e-5)
    np.testing.assert_array_equal(
        np.argmax(actual, axis=1), np.argmax(np.asarray(expected), axis=1)
    )

    output.parent.mkdir(parents=True, exist_ok=True)
    onnx.save(model, output)
    model_bytes = output.read_bytes()
    manifest = {
        "format": "coolrl-lost-cities-match-onnx-v1",
        "codename": codename,
        "model_file": output.name,
        "model_size_bytes": len(model_bytes),
        "model_sha256": hashlib.sha256(model_bytes).hexdigest(),
        "source_checkpoint": str(checkpoint),
        "source_config": config.name,
        "observation_size": MATCH_OBS_DIM,
        "action_size": N_ACTIONS,
        "hidden_size": cfg.network.hidden_size,
        "num_layers": cfg.network.num_layers,
        "dtype": "float32",
        "validation_max_abs_error": float(np.max(np.abs(actual - np.asarray(expected)))),
    }
    output.with_suffix(".json").write_text(json.dumps(manifest, indent=2) + "\n")
    print(f"exported {codename} -> {output} ({output.stat().st_size:,} bytes)")
    print(
        f"  sha256 {manifest['model_sha256'][:12]}  obs {MATCH_OBS_DIM}  max err "
        f"{manifest['validation_max_abs_error']:.2e}"
    )


def main() -> None:
    args = build_argparser().parse_args()
    export_model(args.checkpoint, args.config, args.output, args.codename)


if __name__ == "__main__":
    main()
