# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Online serving benchmark for GLM-Image (T2I and I2I modes).

Sends requests to the /v1/chat/completions endpoint and reports end-to-end
latency, throughput, and per-stage durations (when the server is started with
--enable-diffusion-pipeline-profiler and/or --enable-ar-profiler).

Supports three dataset types:
  - prompt:   Use prompt.json (default). T2I uses t2i_prompt, I2I uses i2i_prompt
              and sends source images from image_url.
  - random:   Generate synthetic prompts (and random images for I2I).
  - custom:   Load from a user-specified JSON file.

Usage:
    # T2I with prompt.json (default)
    python benchmarks/glm_image/benchmark_glm_image.py \
        --mode t2i --num-prompts 10

    # I2I with prompt.json (downloads source images automatically)
    python benchmarks/glm_image/benchmark_glm_image.py \
        --mode i2i --num-prompts 10

    # Random dataset
    python benchmarks/glm_image/benchmark_glm_image.py \
        --mode t2i --dataset random --num-prompts 20

    # Custom dataset
    python benchmarks/glm_image/benchmark_glm_image.py \
        --mode i2i --dataset custom \
        --dataset-path my_prompts.json --num-prompts 5
"""

import argparse
import asyncio
import base64
import json
import os
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import aiohttp
import numpy as np
import requests as sync_requests
from PIL import Image
from tqdm.asyncio import tqdm

# Import backends from the diffusion benchmark (add parent dirs to path)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "diffusion"))
from backends import RequestFuncOutput

BENCHMARK_DIR = Path(__file__).resolve().parent
DEFAULT_PROMPT_JSON = BENCHMARK_DIR / "prompt" / "prompt.json"
IMAGE_CACHE_DIR = BENCHMARK_DIR / "prompt" / "images"

DATASET_REPO = "JaredforReal/glm-image-bench"
DATASET_FILE = "prompt.json"


def _ensure_prompt_json(dataset_path: str | None) -> str:
    """Return path to prompt.json, downloading from HuggingFace if needed."""
    if dataset_path:
        return dataset_path
    local = DEFAULT_PROMPT_JSON
    if local.exists():
        return str(local)
    print(f"Downloading {DATASET_FILE} from {DATASET_REPO} ...")
    try:
        from huggingface_hub import hf_hub_download

        downloaded = hf_hub_download(
            repo_id=DATASET_REPO,
            filename=DATASET_FILE,
            repo_type="dataset",
        )
        local.parent.mkdir(parents=True, exist_ok=True)
        import shutil

        shutil.copy2(downloaded, local)
        print(f"Saved to {local}")
    except ImportError:
        url = f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/{DATASET_FILE}"
        import urllib.request

        local.parent.mkdir(parents=True, exist_ok=True)
        urllib.request.urlretrieve(url, local)
        print(f"Saved to {local}")
    return str(local)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


@dataclass
class GLMImageRequest:
    prompt: str
    image_path: str | None = None  # Only for I2I mode


def download_image(url: str) -> str:
    """Download an image to cache and return the local path."""
    IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
    fname = url.rsplit("/", 1)[-1]
    local_path = IMAGE_CACHE_DIR / fname
    if local_path.exists():
        return str(local_path)
    resp = sync_requests.get(url, timeout=30)
    resp.raise_for_status()
    local_path.write_bytes(resp.content)
    return str(local_path)


def encode_image_as_data_url(path: str) -> str:
    """Encode a local image file as a base64 data URL."""
    with open(path, "rb") as f:
        encoded = base64.b64encode(f.read()).decode("utf-8")
    ext = Path(path).suffix.lower()
    mime = {"png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}.get(ext, "image/png")
    return f"data:{mime};base64,{encoded}"


# ---------------------------------------------------------------------------
# Datasets
# ---------------------------------------------------------------------------


class PromptDataset:
    """Load from prompt.json. T2I uses t2i_prompt, I2I uses i2i_prompt + image_url."""

    def __init__(self, args: argparse.Namespace):
        path = _ensure_prompt_json(args.dataset_path)
        with open(path, encoding="utf-8") as f:
            raw = json.load(f)

        prompt_key = "t2i_prompt" if args.mode == "t2i" else "i2i_prompt"
        self.items: list[GLMImageRequest] = []

        for entry in raw:
            prompt = entry.get(prompt_key, "").strip()
            if not prompt:
                continue
            image_path = None
            if args.mode == "i2i":
                url = entry.get("image_url", "")
                if url:
                    image_path = download_image(url)
            self.items.append(GLMImageRequest(prompt=prompt, image_path=image_path))

        if args.num_prompts and len(self.items) > args.num_prompts:
            self.items = self.items[: args.num_prompts]

    def __len__(self) -> int:
        return len(self.items)

    def __getitem__(self, idx: int) -> GLMImageRequest:
        return self.items[idx]

    def get_requests(self) -> list[GLMImageRequest]:
        return list(self.items)


class RandomDataset:
    """Generate synthetic prompts (and optional random images for I2I)."""

    def __init__(self, args: argparse.Namespace):
        self.args = args
        self.num_prompts = args.num_prompts
        self._random_image_paths: list[str] | None = None
        if args.mode == "i2i":
            self._random_image_paths = self._generate_random_images()

    def _generate_random_images(self) -> list[str]:
        paths: list[str] = []
        for i in range(self.args.num_input_images):
            img = Image.new("RGB", (512, 512), (128 + i * 30 % 128, 64, 192))
            path = os.path.join(tempfile.gettempdir(), f"glm_image_bench_input_{i}.png")
            img.save(path)
            paths.append(path)
        return paths

    def __len__(self) -> int:
        return self.num_prompts

    def __getitem__(self, idx: int) -> GLMImageRequest:
        image_path = None
        if self._random_image_paths is not None:
            image_path = self._random_image_paths[idx % len(self._random_image_paths)]
        return GLMImageRequest(
            prompt=f"A beautiful scene with vivid colors and intricate details, prompt {idx}",
            image_path=image_path,
        )

    def get_requests(self) -> list[GLMImageRequest]:
        return [self[i] for i in range(len(self))]


class CustomDataset:
    """Load from a user-specified JSON file.

    Expected format:
    [
        {"prompt": "A cat sitting on a windowsill"},
        {"prompt": "Make it look like winter", "image_path": "/path/to/img.png"}
    ]
    """

    def __init__(self, args: argparse.Namespace):
        if not args.dataset_path:
            raise ValueError("--dataset-path is required for custom dataset")
        with open(args.dataset_path, encoding="utf-8") as f:
            raw = json.load(f)
        self.items: list[GLMImageRequest] = []
        for item in raw:
            self.items.append(
                GLMImageRequest(
                    prompt=item.get("prompt", ""),
                    image_path=item.get("image_path"),
                )
            )
        if args.num_prompts and len(self.items) > args.num_prompts:
            self.items = self.items[: args.num_prompts]

    def __len__(self) -> int:
        return len(self.items)

    def __getitem__(self, idx: int) -> GLMImageRequest:
        return self.items[idx]

    def get_requests(self) -> list[GLMImageRequest]:
        return list(self.items)


# ---------------------------------------------------------------------------
# Async request for GLM-Image (chat completions with image support)
# ---------------------------------------------------------------------------


async def async_glm_image_request(
    req: GLMImageRequest,
    api_url: str,
    model: str,
    session: aiohttp.ClientSession,
    pbar: Any,
    args: argparse.Namespace,
) -> RequestFuncOutput:
    """Send a single T2I or I2I request via chat completions endpoint."""
    output = RequestFuncOutput()
    output.start_time = time.perf_counter()

    # Build messages
    if req.image_path and args.mode == "i2i":
        data_url = encode_image_as_data_url(req.image_path)
        content = [
            {"type": "text", "text": req.prompt},
            {"type": "image_url", "image_url": {"url": data_url}},
        ]
    else:
        content = req.prompt

    messages = [{"role": "user", "content": content}]

    extra_body: dict[str, Any] = {}
    if args.height:
        extra_body["height"] = args.height
    if args.width:
        extra_body["width"] = args.width
    if args.num_inference_steps:
        extra_body["num_inference_steps"] = args.num_inference_steps
    if args.seed is not None:
        extra_body["seed"] = args.seed

    payload: dict[str, Any] = {
        "model": model,
        "messages": messages,
    }
    if extra_body:
        payload["extra_body"] = extra_body

    try:
        async with session.post(api_url, json=payload) as response:
            if response.status == 200:
                resp_json = await response.json()
                output.response_body = resp_json
                output.success = True
                try:
                    choices = resp_json.get("choices", [])
                    if choices and isinstance(choices, list):
                        msg = choices[0].get("message", {})
                        if isinstance(msg, dict):
                            resp_content = msg.get("content", [])
                            if resp_content and isinstance(resp_content, list) and len(resp_content) > 0:
                                first_item = resp_content[0]
                                if isinstance(first_item, dict):
                                    output.stage_durations = first_item.get("stage_durations") or {}
                                    output.peak_memory_mb = first_item.get("peak_memory_mb", 0.0)
                except (IndexError, TypeError, AttributeError):
                    pass
            else:
                output.error = f"HTTP {response.status}: {await response.text()}"
                output.success = False
    except Exception as e:
        output.error = str(e)
        output.success = False

    output.latency = time.perf_counter() - output.start_time
    if pbar:
        pbar.update(1)
    return output


# ---------------------------------------------------------------------------
# Benchmark
# ---------------------------------------------------------------------------


async def iter_requests(n: int, request_rate: float) -> Any:
    import random as _random

    for i in range(n):
        if request_rate != float("inf") and i > 0:
            await asyncio.sleep(_random.expovariate(request_rate))
        yield i


def calculate_metrics(outputs: list[RequestFuncOutput], total_duration: float) -> dict[str, Any]:
    success = [o for o in outputs if o.success]
    errors = [o for o in outputs if not o.success]
    latencies = [o.latency for o in success]
    peak_mems = [o.peak_memory_mb for o in success if o.peak_memory_mb > 0]

    stage_duration_lists: dict[str, list[float]] = {}
    for o in success:
        for stage, dur in (o.stage_durations or {}).items():
            stage_duration_lists.setdefault(stage, []).append(dur)

    return {
        "duration": total_duration,
        "completed_requests": len(success),
        "failed_requests": len(errors),
        "throughput_qps": len(success) / total_duration if total_duration > 0 else 0,
        "latency_mean": float(np.mean(latencies)) if latencies else 0,
        "latency_median": float(np.median(latencies)) if latencies else 0,
        "latency_p99": float(np.percentile(latencies, 99)) if latencies else 0,
        "latency_p95": float(np.percentile(latencies, 95)) if latencies else 0,
        "peak_memory_mb_max": max(peak_mems) if peak_mems else 0,
        "stage_durations_mean": {s: float(np.mean(v)) for s, v in stage_duration_lists.items()},
        "stage_durations_p50": {s: float(np.percentile(v, 50)) for s, v in stage_duration_lists.items()},
    }


async def benchmark(args: argparse.Namespace) -> None:
    api_url = f"http://{args.host}:{args.port}/v1/chat/completions"

    # Load dataset
    if args.dataset == "prompt":
        dataset = PromptDataset(args)
    elif args.dataset == "random":
        dataset = RandomDataset(args)
    elif args.dataset == "custom":
        dataset = CustomDataset(args)
    else:
        raise ValueError(f"Unknown dataset: {args.dataset}")

    glm_requests = dataset.get_requests()
    print(f"Prepared {len(glm_requests)} requests (mode={args.mode}, dataset={args.dataset})")

    semaphore = asyncio.Semaphore(args.max_concurrency) if args.max_concurrency else None

    async def limited_request(idx: int, req: GLMImageRequest, session: aiohttp.ClientSession, pbar: Any):
        if semaphore:
            async with semaphore:
                return await async_glm_image_request(req, api_url, args.model, session, pbar, args)
        return await async_glm_image_request(req, api_url, args.model, session, pbar, args)

    async with aiohttp.ClientSession() as session:
        # Warmup
        if args.warmup_requests and glm_requests:
            print(f"Running {args.warmup_requests} warmup request(s)...")
            for i in range(args.warmup_requests):
                await limited_request(i, glm_requests[i % len(glm_requests)], session, None)

        # Main benchmark
        pbar = tqdm(total=len(glm_requests), disable=args.disable_tqdm)
        start_time = time.perf_counter()
        tasks = []
        async for idx in iter_requests(len(glm_requests), args.request_rate):
            tasks.append(asyncio.create_task(limited_request(idx, glm_requests[idx], session, pbar)))
        outputs = await asyncio.gather(*tasks)
        total_duration = time.perf_counter() - start_time
        pbar.close()

    # Metrics
    metrics = calculate_metrics(outputs, total_duration)
    metrics["mode"] = args.mode
    metrics["model"] = args.model
    metrics["dataset"] = args.dataset

    print(f"\n{' GLM-Image Online Benchmark Result ':=^60}")
    print(f"{'Mode:':<40} {args.mode}")
    print(f"{'Model:':<40} {args.model}")
    print(f"{'Dataset:':<40} {args.dataset}")
    print("-" * 50)
    print(f"{'Benchmark duration (s):':<40} {metrics['duration']:.2f}")
    print(f"{'Request rate:':<40} {args.request_rate}")
    print(f"{'Max concurrency:':<40} {args.max_concurrency}")
    print(f"{'Successful requests:':<40} {metrics['completed_requests']}/{len(glm_requests)}")
    print("-" * 50)
    print(f"{'Throughput (req/s):':<40} {metrics['throughput_qps']:.2f}")
    print(f"{'Latency Mean (s):':<40} {metrics['latency_mean']:.4f}")
    print(f"{'Latency Median (s):':<40} {metrics['latency_median']:.4f}")
    print(f"{'Latency P95 (s):':<40} {metrics['latency_p95']:.4f}")
    print(f"{'Latency P99 (s):':<40} {metrics['latency_p99']:.4f}")

    if metrics["peak_memory_mb_max"] > 0:
        print("-" * 50)
        print(f"{'Peak Memory Max (MB):':<40} {metrics['peak_memory_mb_max']:.2f}")

    if metrics["stage_durations_mean"]:
        print("-" * 50)
        print("Stage Durations Mean:")
        for stage, val in sorted(metrics["stage_durations_mean"].items()):
            unit = "ms" if stage.endswith("_ms") else "s"
            print(f"  {stage + ':':<38} {val:.4f} ({unit})")

    print("=" * 60)

    if args.output_file:
        with open(args.output_file, "w") as f:
            json.dump(metrics, f, indent=2)
        print(f"Metrics saved to {args.output_file}")


def main() -> None:
    parser = argparse.ArgumentParser(description="Benchmark GLM-Image T2I/I2I online serving.")
    parser.add_argument("--mode", type=str, default="t2i", choices=["t2i", "i2i"])
    parser.add_argument("--dataset", type=str, default="prompt", choices=["prompt", "random", "custom"])
    parser.add_argument("--dataset-path", type=str, default=None)
    parser.add_argument("--num-prompts", type=int, default=10)
    parser.add_argument("--max-concurrency", type=int, default=1)
    parser.add_argument("--request-rate", type=float, default=float("inf"))
    parser.add_argument("--warmup-requests", type=int, default=1)
    parser.add_argument("--width", type=int, default=1024)
    parser.add_argument("--height", type=int, default=1024)
    parser.add_argument("--num-inference-steps", type=int, default=50)
    parser.add_argument("--seed", type=int, default=None)
    parser.add_argument("--model", type=str, default="default")
    parser.add_argument("--host", type=str, default="localhost")
    parser.add_argument("--port", type=int, default=8091)
    parser.add_argument("--output-file", type=str, default=None)
    parser.add_argument("--disable-tqdm", action="store_true")
    parser.add_argument("--num-input-images", type=int, default=1, help="For random I2I dataset.")
    args = parser.parse_args()
    asyncio.run(benchmark(args))


if __name__ == "__main__":
    main()
