#!/usr/bin/env python3
"""One-pair P09 CUDAGraph capture-size validation for OpProf Phase 4."""

from __future__ import annotations

import argparse
import json
import math
import os
import shlex
import signal
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from typing import Any

import numpy as np

import opprof_phase3_controller as common
import opprof_phase3_matrix as matrix


WORKDIR = Path("/home/admin/cpfs/wjh/opprof-phase3-dash0-20260712")
ROOT = WORKDIR / "runs/phase4-capture-p09"
PHASE3 = WORKDIR / "runs/phase3"
PRIVATE = Path("/home/admin/cpfs/wjh/opprof-phase3-private/manifests")
MODEL = Path("/home/admin/cpfs/wjh/models/Qwen/Qwen3-30B-A3B")
SOURCE = Path("/home/admin/cpfs/wjh/opprof-phase2-dash0-20260711/vllm-v0.24.0")
VENV = Path("/tmp/wjh-opprof-phase2-dash0-20260711/.venv")
CLIENT = WORKDIR / "scripts/opprof_phase3_client.py"
STATE = ROOT / "controller-state.json"
GPU = 0
PORT = 8200
PRIOR_GPU_HOURS = 14.025875418755744
GPU_HOUR_LIMIT = 16.0
EXPECTED_INCREMENT_HOURS = 0.5
ADDED_CAPTURE_SIZES = (3, 5, 6, 7, 9)
DEFAULT_CAPTURE_SIZES = (
    1, 2, 4, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104,
    112, 120, 128, 136, 144, 152, 160, 168, 176, 184, 192, 200,
    208, 216, 224, 232, 240, 248, 256, 272, 288, 304, 320, 336,
    352, 368, 384, 400, 416, 432, 448, 464, 480, 496, 512,
)
ON_CAPTURE_SIZES = tuple(sorted(set(DEFAULT_CAPTURE_SIZES + ADDED_CAPTURE_SIZES)))


def plan() -> dict[str, Any]:
    return {
        "schema": 1,
        "pattern": "P09",
        "load": "moderate",
        "order": ["ON", "OFF"],
        "gpu": GPU,
        "warmup_seconds": 60,
        "clean_seconds": 240,
        "profile": False,
        "manifest": str(PRIVATE / "P09.jsonl"),
        "saturation_result": str(
            PHASE3 / "primary/P09-C00/saturation/client/result.json"
        ),
        "rate_fraction": 0.60,
        "added_capture_sizes": list(ADDED_CAPTURE_SIZES),
        "on_capture_sizes": list(ON_CAPTURE_SIZES),
        "measured_padding_recovery_bound": 0.049776380388728225,
        "primary_metric": "clean graph-hit padding_fraction",
        "secondary_metrics": [
            "Layer-1 useful scheduled tokens/model-step millisecond",
            "clean completed request throughput",
            "clean request latency",
        ],
        "prior_gpu_hours": PRIOR_GPU_HOURS,
        "projected_increment_gpu_hours": EXPECTED_INCREMENT_HOURS,
        "projected_total_gpu_hours": PRIOR_GPU_HOURS + EXPECTED_INCREMENT_HOURS,
        "gpu_hour_limit": GPU_HOUR_LIMIT,
    }


def save_state(state: dict[str, Any]) -> None:
    state["updated_at"] = time.time()
    state["controller_pid"] = os.getpid()
    common.atomic_json(STATE, state)


def wait_ready(server: subprocess.Popen[Any]) -> None:
    deadline = time.monotonic() + 300
    while time.monotonic() < deadline:
        if server.poll() is not None:
            raise RuntimeError("server exited before readiness")
        try:
            with urllib.request.urlopen(
                f"http://127.0.0.1:{PORT}/health", timeout=1
            ) as response:
                if response.status == 200:
                    return
        except Exception:
            pass
        time.sleep(1)
    raise TimeoutError("server readiness timeout")


def server_command(arm: str) -> list[str]:
    command = [
        "taskset", "-c", "0-19", str(VENV / "bin/vllm"), "serve", str(MODEL),
        "--host", "127.0.0.1", "--port", str(PORT),
        "--tensor-parallel-size", "1", "--enable-chunked-prefill",
        "--enable-prefix-caching", "--shutdown-timeout", "120",
    ]
    if arm == "ON":
        command.extend(("--cudagraph-capture-sizes", *map(str, ON_CAPTURE_SIZES)))
    return command


def client_command(run_dir: Path) -> list[str]:
    return [
        "taskset", "-c", "0-19", str(VENV / "bin/python"), str(CLIENT), "run",
        "--manifest", str(PRIVATE / "P09.jsonl"),
        "--base-url", f"http://127.0.0.1:{PORT}", "--model", str(MODEL),
        "--load-point", "moderate", "--saturation-result",
        str(PHASE3 / "primary/P09-C00/saturation/client/result.json"),
        "--rate-fraction", "0.60", "--max-concurrency", "256", "--ignore-eos",
        "--temperature", "0", "--warmup-seconds", "60",
        "--clean-segment-seconds", "80", "--num-clean-segments", "3",
        "--recovery-seconds", "30", "--drain-timeout-seconds", "120",
        "--workload-seed", "20260712", "--server-seed", "20260712",
        "--result-dir", str(run_dir / "client"),
    ]


def summarize(run_dir: Path, arm: str) -> dict[str, Any]:
    result = json.loads((run_dir / "client/result.json").read_text())
    requests = [
        json.loads(line)
        for line in (run_dir / "client/requests.jsonl").read_text().splitlines()
    ]
    t0 = int(result["t0_mono_ns"])
    start, end = t0 + int(60e9), t0 + int(300e9)
    stream = next((run_dir / "opprof").glob("*.jsonl"))
    records = []
    for line in stream.read_text().splitlines():
        record = json.loads(line)
        if (
            "step_index" in record
            and start <= int(record["submit_mono_ns"]) < end
        ):
            records.append(record)
    model = [record for record in records if record["model_executed"]]
    hits = [
        record
        for record in model
        if record["cudagraph"]["hit"]
        and int(record["cudagraph"]["bucket_tokens"]) > 0
    ]
    pad = sum(int(record["cudagraph"]["padding_tokens"]) for record in hits)
    bucket = sum(int(record["cudagraph"]["bucket_tokens"]) for record in hits)
    useful = sum(
        int(record["prefill_tokens"]) + int(record["decode_tokens"])
        for record in records
    )
    duration_ms = sum(
        (int(record["complete_mono_ns"]) - int(record["submit_mono_ns"])) / 1e6
        for record in records
    )
    completed = [
        request
        for request in requests
        if request["success"] and 60 <= float(request["completed_s"]) < 300
    ]
    e2e = np.asarray(
        [float(request["completed_s"] - request["admitted_s"]) for request in completed]
    )
    layer1 = matrix.validate_layer1(run_dir)
    return {
        "schema": 1,
        "arm": arm,
        "capture_sizes": list(ON_CAPTURE_SIZES) if arm == "ON" else "default",
        "clean_completed": len(completed),
        "clean_failed": int(result["clean"]["failed"]),
        "clean_completed_throughput_rps": float(
            result["clean"]["completed_throughput_rps"]
        ),
        "clean_offered_rps": float(result["clean"]["offered_rps"]),
        "e2e_latency_mean_s": float(e2e.mean()),
        "e2e_latency_p95_s": float(np.quantile(e2e, 0.95)),
        "model_steps": len(model),
        "graph_hit_steps": len(hits),
        "padding_tokens": pad,
        "bucket_tokens": bucket,
        "padding_fraction": pad / bucket,
        "graph_miss_rate": sum(not record["cudagraph"]["hit"] for record in model)
        / len(model),
        "useful_tokens": useful,
        "model_step_duration_ms": duration_ms,
        "token_efficiency_per_ms": useful / duration_ms,
        "layer1_records": layer1["records"],
        "layer1_invariants": layer1["invariants"],
        "drain_seconds": float(result["drain_seconds"]),
    }


def run_arm(state: dict[str, Any], arm: str) -> None:
    if state["arms"].get(arm, {}).get("status") == "complete":
        return
    run_dir = ROOT / arm.lower()
    if run_dir.exists():
        run_dir.rename(run_dir.with_name(f"{run_dir.name}.interrupted-{int(time.time())}"))
    run_dir.mkdir(parents=True)
    common.preflight([GPU], run_dir)
    server_cmd = server_command(arm)
    client_cmd = client_command(run_dir)
    commands_path = run_dir / "commands.log"
    common.command_log(commands_path, f"P09-capture-{arm}-server", server_cmd, "6-9m")
    common.command_log(commands_path, f"P09-capture-{arm}-client", client_cmd, "5-7m")
    print(
        f"GPU_COMMAND P09-capture-{arm}-server: {shlex.join(server_cmd)}; "
        "expected=6-9m",
        flush=True,
    )
    print(
        f"GPU_COMMAND P09-capture-{arm}-client: {shlex.join(client_cmd)}; "
        "expected=5-7m",
        flush=True,
    )
    environment = os.environ.copy()
    environment.update(
        {
            "CUDA_VISIBLE_DEVICES": str(GPU),
            "VLLM_OPPROF_DIR": str(run_dir / "opprof"),
            "HF_HUB_OFFLINE": "1",
            "TRANSFORMERS_OFFLINE": "1",
            "PYTHONUNBUFFERED": "1",
        }
    )
    server_log = (run_dir / "server.log").open("ab", buffering=0)
    server_started = time.time()
    server = subprocess.Popen(
        server_cmd,
        cwd=SOURCE,
        env=environment,
        stdout=server_log,
        stderr=subprocess.STDOUT,
        start_new_session=True,
    )
    client = None
    client_log = None
    monitor = None
    owned = {server.pid}
    state["arms"][arm] = {
        "status": "starting",
        "server_pid": server.pid,
        "started_at": server_started,
    }
    save_state(state)
    failure = None
    try:
        wait_ready(server)
        monitor = common.Monitor(run_dir / "monitor.jsonl", owned)
        monitor.start()
        client_log = (run_dir / "client.log").open("ab", buffering=0)
        client = subprocess.Popen(
            client_cmd,
            cwd=WORKDIR,
            stdout=client_log,
            stderr=subprocess.STDOUT,
            start_new_session=True,
        )
        owned.add(client.pid)
        state["arms"][arm].update(status="running", client_pid=client.pid)
        save_state(state)
        deadline = time.monotonic() + 900
        while client.poll() is None and time.monotonic() < deadline:
            if server.poll() is not None:
                raise RuntimeError("server exited during client load")
            if monitor.other_apps:
                raise RuntimeError(f"other GPU process appeared: {monitor.other_apps}")
            time.sleep(2)
        if client.poll() is None:
            raise TimeoutError("client exceeded 900 seconds")
        if client.returncode:
            raise RuntimeError(f"client failed with {client.returncode}")
    except Exception as error:
        failure = error
    finally:
        if client is not None and client.poll() is None:
            try:
                os.killpg(client.pid, signal.SIGKILL)
            except ProcessLookupError:
                pass
        common.stop_servers([server])
        server_log.close()
        if client_log is not None:
            client_log.close()
        if monitor is not None:
            monitor.stop()
        common.verify_idle([GPU], run_dir)
    gpu_hours = (time.time() - server_started) / 3600
    state["gpu_hours_increment"] += gpu_hours
    if PRIOR_GPU_HOURS + state["gpu_hours_increment"] >= GPU_HOUR_LIMIT:
        failure = failure or RuntimeError("GPU-hour limit reached")
    if failure is not None:
        state["arms"][arm].update(status="failed", failure=repr(failure))
        state["status"] = "failed"
        save_state(state)
        raise failure
    summary = summarize(run_dir, arm)
    summary["gpu_hours"] = gpu_hours
    common.atomic_json(run_dir / "summary.json", summary)
    state["arms"][arm].update(status="complete", summary=summary)
    save_state(state)


def run() -> None:
    ROOT.mkdir(parents=True, exist_ok=True)
    if PRIOR_GPU_HOURS + EXPECTED_INCREMENT_HOURS >= GPU_HOUR_LIMIT:
        raise RuntimeError("projected validation exceeds GPU-hour budget")
    if STATE.exists():
        state = json.loads(STATE.read_text())
    else:
        state = {
            "schema": 1,
            "status": "running",
            "created_at": time.time(),
            "plan": plan(),
            "arms": {},
            "gpu_hours_increment": 0.0,
        }
    for arm in ("ON", "OFF"):
        run_arm(state, arm)
    on, off = state["arms"]["ON"]["summary"], state["arms"]["OFF"]["summary"]
    result = {
        "schema": 1,
        "plan": plan(),
        "on": on,
        "off": off,
        "delta": {
            "padding_fraction_points": on["padding_fraction"] - off["padding_fraction"],
            "padding_reduction_fraction": 1 - on["padding_fraction"] / off["padding_fraction"],
            "token_efficiency_relative": on["token_efficiency_per_ms"]
            / off["token_efficiency_per_ms"]
            - 1,
            "completed_throughput_relative": on["clean_completed_throughput_rps"]
            / off["clean_completed_throughput_rps"]
            - 1,
            "e2e_mean_latency_relative": on["e2e_latency_mean_s"]
            / off["e2e_latency_mean_s"]
            - 1,
            "e2e_p95_latency_relative": on["e2e_latency_p95_s"]
            / off["e2e_latency_p95_s"]
            - 1,
        },
        "gpu_hours_increment": state["gpu_hours_increment"],
        "gpu_hours_total": PRIOR_GPU_HOURS + state["gpu_hours_increment"],
    }
    common.atomic_json(ROOT / "result.json", result)
    state["status"] = "complete"
    state["result"] = result
    save_state(state)
    print(json.dumps(result, sort_keys=True))


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("command", choices=("plan", "run"))
    args = parser.parse_args()
    if args.command == "plan":
        print(json.dumps(plan(), indent=2, sort_keys=True))
    else:
        run()


if __name__ == "__main__":
    main()
