"""Performance baseline for minimal host-interference LBM stepping.

This script builds a temporary config, runs warmup + measured batches, and
reports MLUPS under a FluidX3D-like benchmark mindset:

- keep the main loop on GPU (`stepper.step`)
- avoid host downloads by default
- selectively enable host-touch paths to quantify overhead
- sweep `inlet.scheme` to check stability-sensitive combinations

Usage::
    python tests/run_perf_baseline.py
    python tests/run_perf_baseline.py --lattice-model D2Q9 --nx 384 --ny 192 --steps 30000
    python tests/run_perf_baseline.py --macro-every 500 --ddf-every 1000
    python tests/run_perf_baseline.py --with-cylinder --obs-every 50
"""

from __future__ import annotations

import argparse
import json
import os
import tempfile
import time
from typing import Any, Dict, List

import pycuda.driver as cuda

_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json")


def _load_json(path: str) -> dict:
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def _write_json(path: str, payload: dict) -> None:
    with open(path, "w", encoding="utf-8") as f:
        json.dump(payload, f, indent=2)


def _build_lbm_cfg(base: dict, args: argparse.Namespace) -> dict:
    cfg = json.loads(json.dumps(base))
    cfg["grid"]["lattice_model"] = args.lattice_model
    cfg["grid"]["nx"] = int(args.nx)
    cfg["grid"]["ny"] = int(args.ny)
    cfg["grid"]["nz"] = int(args.nz)

    cfg["physics"]["viscosity"] = float(args.viscosity)
    cfg["physics"]["velocity"] = float(args.velocity)
    cfg["physics"]["rho"] = float(args.rho)

    cfg["method"]["collision"] = str(args.collision).upper()
    cfg["method"]["streaming"] = str(args.streaming)
    cfg["method"]["store_precision"] = str(args.store_precision).upper()
    cfg["method"]["ddf_shifting"] = bool(args.ddf_shifting)
    cfg["method"]["les"]["enabled"] = bool(args.enable_les)
    cfg["method"]["outlet"]["mode"] = str(args.outlet_mode)
    cfg["method"]["inlet"]["profile"] = str(args.inlet_profile)
    # Expose inlet scheme as a benchmark axis; useful when stability depends
    # on collision/inlet coupling.
    cfg["method"]["inlet"]["scheme"] = str(args.inlet_scheme)
    cfg["method"]["y_wall_bc"] = str(args.y_wall_bc)

    cfg["cuda"]["threads_per_block"] = int(args.threads_per_block)
    cfg["cuda"]["compute_capability"] = str(args.compute_capability)
    return cfg


def _build_body_cfg(args: argparse.Namespace) -> dict:
    if not args.with_cylinder:
        return {"objects": []}
    cx = 0.5 * float(args.nx)
    cy = 0.5 * float(args.ny)
    radius = max(2.0, min(float(args.nx), float(args.ny)) * 0.08)
    return {
        "objects": [
            {
                "type": "cylinder",
                "center": [cx, cy],
                "radius": radius,
            }
        ]
    }


def _maybe_probe_macroscopic(sim: Any, step: int, every: int, repeat: int) -> None:
    if every > 0 and step % every == 0:
        for _ in range(max(1, int(repeat))):
            _ = sim.get_macroscopic()


def _maybe_probe_ddf(sim: Any, step: int, every: int, repeat: int) -> None:
    if every > 0 and step % every == 0:
        for _ in range(max(1, int(repeat))):
            _ = sim.get_ddf()


def _maybe_checkpoint(sim: Any, step: int, every: int, out_dir: str) -> None:
    if every > 0 and step % every == 0:
        path = os.path.join(out_dir, f"checkpoint_step_{step:09d}.h5")
        sim.save_checkpoint(path)


def _maybe_probe_obs(sim: Any, stream: cuda.Stream, step: int, every: int) -> None:
    if every <= 0 or step % every != 0:
        return
    if sim.bodies.count <= 0:
        return
    sim.bodies.download_obs_full_async(stream)
    stream.synchronize()
    _ = sim.bodies.read_force(0)


def run(args: argparse.Namespace) -> Dict[str, Any]:
    if not os.path.isfile(_DEFAULT_LBM):
        raise FileNotFoundError(f"Base config missing: {_DEFAULT_LBM}")

    base = _load_json(_DEFAULT_LBM)
    lbm_cfg = _build_lbm_cfg(base, args)
    body_cfg = _build_body_cfg(args)

    tmpd = tempfile.mkdtemp(prefix="celeris_perf_baseline_")
    lbm_path = os.path.join(tmpd, "config_lbm.json")
    body_path = os.path.join(tmpd, "config_body.json")
    ckpt_dir = os.path.join(tmpd, "checkpoints")
    os.makedirs(ckpt_dir, exist_ok=True)
    _write_json(lbm_path, lbm_cfg)
    _write_json(body_path, body_cfg)

    from CelerisLab import Simulation  # noqa: WPS433

    sim = Simulation(lbm_config_path=lbm_path, body_config_path=body_path, device_id=args.device_id)
    sim.initialize()

    stream = cuda.Stream()
    total_cells = int(args.nx) * int(args.ny) * int(args.nz)

    # Warmup before measurement window.
    warmup_done = 0
    while warmup_done < int(args.warmup_steps):
        chunk = min(int(args.batch_steps), int(args.warmup_steps) - warmup_done)
        sim.stepper.step(chunk, action_gpu=sim.bodies.action_gpu, obs_gpu=sim.bodies.obs_gpu, stream=stream)
        warmup_done += chunk
    stream.synchronize()

    measured_batch_s: List[float] = []
    measured_steps = int(args.steps)
    done = 0
    t0 = time.perf_counter()
    while done < measured_steps:
        chunk = min(int(args.batch_steps), measured_steps - done)
        step_start = time.perf_counter()
        sim.stepper.step(chunk, action_gpu=sim.bodies.action_gpu, obs_gpu=sim.bodies.obs_gpu, stream=stream)
        stream.synchronize()
        step_end = time.perf_counter()
        measured_batch_s.append(step_end - step_start)

        done += chunk
        global_step = sim.stepper.step_count
        _maybe_probe_macroscopic(sim, global_step, int(args.macro_every), int(args.macro_repeat))
        _maybe_probe_ddf(sim, global_step, int(args.ddf_every), int(args.ddf_repeat))
        _maybe_checkpoint(sim, global_step, int(args.checkpoint_every), ckpt_dir)
        _maybe_probe_obs(sim, stream, global_step, int(args.obs_every))

    stream.synchronize()
    elapsed_s = time.perf_counter() - t0

    # Optional final sanity readback (outside core timing path by default).
    if args.final_macro_snapshot:
        _ = sim.get_macroscopic()

    sim.close()

    mlups = (total_cells * measured_steps) / max(elapsed_s, 1e-12) / 1.0e6
    batch_ms = [1000.0 * x for x in measured_batch_s]
    batch_ms_sorted = sorted(batch_ms)
    p50 = batch_ms_sorted[len(batch_ms_sorted) // 2] if batch_ms_sorted else 0.0
    p90 = batch_ms_sorted[min(len(batch_ms_sorted) - 1, int(0.9 * (len(batch_ms_sorted) - 1)))] if batch_ms_sorted else 0.0

    return {
        "benchmark": "celerislab_stepper_baseline",
        "device_id": int(args.device_id),
        "lattice_model": args.lattice_model,
        "grid": {"nx": int(args.nx), "ny": int(args.ny), "nz": int(args.nz)},
        "collision": str(args.collision).upper(),
        "inlet_scheme": str(args.inlet_scheme),
        "streaming": str(args.streaming),
        "store_precision": str(args.store_precision).upper(),
        "steps": measured_steps,
        "warmup_steps": int(args.warmup_steps),
        "batch_steps": int(args.batch_steps),
        "mlups": float(mlups),
        "elapsed_s": float(elapsed_s),
        "batch_ms_p50": float(p50),
        "batch_ms_p90": float(p90),
        "overhead_switches": {
            "macro_every": int(args.macro_every),
            "macro_repeat": int(args.macro_repeat),
            "ddf_every": int(args.ddf_every),
            "ddf_repeat": int(args.ddf_repeat),
            "checkpoint_every": int(args.checkpoint_every),
            "obs_every": int(args.obs_every),
            "with_cylinder": bool(args.with_cylinder),
            "final_macro_snapshot": bool(args.final_macro_snapshot),
        },
    }


def parse_args() -> argparse.Namespace:
    ap = argparse.ArgumentParser(description="CelerisLab pure-step performance baseline")
    ap.add_argument("--device-id", type=int, default=0)
    ap.add_argument("--lattice-model", choices=("D2Q9", "D3Q19"), default="D3Q19")
    ap.add_argument("--nx", type=int, default=256)
    ap.add_argument("--ny", type=int, default=256)
    ap.add_argument("--nz", type=int, default=256)
    ap.add_argument("--steps", type=int, default=3000)
    ap.add_argument("--warmup-steps", type=int, default=400)
    ap.add_argument("--batch-steps", type=int, default=100)

    ap.add_argument("--collision", choices=("SRT", "TRT", "MRT"), default="SRT")
    ap.add_argument("--streaming", choices=("double_buffer", "esopull"), default="double_buffer")
    ap.add_argument("--store-precision", choices=("FP32", "FP16S"), default="FP32")
    ap.add_argument("--ddf-shifting", action="store_true")
    ap.add_argument("--enable-les", action="store_true")
    ap.add_argument("--outlet-mode", choices=("neq_extrap", "zero_gradient", "blended"), default="neq_extrap")
    ap.add_argument("--inlet-profile", choices=("uniform", "parabolic"), default="uniform")
    ap.add_argument(
        "--inlet-scheme",
        choices=("zou_he_local", "channel_stabilized", "equilibrium", "regularized"),
        default="zou_he_local",
    )
    ap.add_argument("--y-wall-bc", choices=("bounce_back", "free_slip"), default="bounce_back")

    ap.add_argument("--viscosity", type=float, default=0.0035)
    ap.add_argument("--velocity", type=float, default=0.03)
    ap.add_argument("--rho", type=float, default=1.0)
    ap.add_argument("--threads-per-block", type=int, default=256)
    ap.add_argument("--compute-capability", type=str, default="auto")

    # Overhead attribution toggles.
    ap.add_argument("--macro-every", type=int, default=0, help="Call get_macroscopic() every N steps (0=off)")
    ap.add_argument("--macro-repeat", type=int, default=1, help="Repeat get_macroscopic() calls per probe step")
    ap.add_argument("--ddf-every", type=int, default=0, help="Call get_ddf() every N steps (0=off)")
    ap.add_argument("--ddf-repeat", type=int, default=1, help="Repeat get_ddf() calls per probe step")
    ap.add_argument("--checkpoint-every", type=int, default=0, help="Save checkpoint every N steps (0=off)")
    ap.add_argument("--obs-every", type=int, default=0, help="Download object obs every N steps (0=off)")
    ap.add_argument("--with-cylinder", action="store_true", help="Inject one cylinder object (needed for obs probes)")
    ap.add_argument("--final-macro-snapshot", action="store_true")

    ap.add_argument("--json-out", type=str, default="", help="Optional path to save metrics JSON")
    return ap.parse_args()


def main() -> int:
    args = parse_args()
    result = run(args)
    print(json.dumps(result, indent=2))
    if args.json_out.strip():
        out = os.path.abspath(args.json_out)
        os.makedirs(os.path.dirname(out), exist_ok=True)
        _write_json(out, result)
        print(f"Wrote: {out}")
    return 0


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