# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project

"""Z-Image end-to-end tests for diffusion parallelism.

This file currently covers:
- DiT tensor parallelism (TP=2) vs TP=1.
- VAE patch parallelism (vae_patch_parallel_size=2) vs baseline on TP=2.

Note: CUDA-only (>=2 GPUs). We use `enforce_eager=False` (default) to enable
`torch.compile`.
"""

import os
import time
from pathlib import Path

import numpy as np
import pytest
import torch
from PIL import Image

from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform

os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"

PROMPT = "a photo of a cat sitting on a laptop keyboard"


def _get_zimage_model() -> str:
    # Allow overriding the model for local/offline environments.
    # Can be either a HuggingFace repo id or a local path.
    return os.environ.get("VLLM_TEST_ZIMAGE_MODEL", "Tongyi-MAI/Z-Image-Turbo")


def _pil_to_float_rgb_tensor(img: Image.Image) -> torch.Tensor:
    """Convert PIL image to float32 RGB tensor in [0, 1] with shape [H, W, 3]."""
    arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
    return torch.from_numpy(arr)


def _diff_metrics(a: Image.Image, b: Image.Image) -> tuple[float, float]:
    """Return (mean_abs_diff, p99_abs_diff) over RGB pixels in [0, 1]."""
    ta = _pil_to_float_rgb_tensor(a)
    tb = _pil_to_float_rgb_tensor(b)
    assert ta.shape == tb.shape, f"Image shapes differ: {ta.shape} vs {tb.shape}"
    abs_diff = torch.abs(ta - tb)
    p99_abs_diff = torch.quantile(abs_diff.flatten(), 0.99).item()
    return abs_diff.mean().item(), p99_abs_diff


def _extract_single_image(outputs) -> Image.Image:
    first_output = outputs[0]
    assert first_output.final_output_type == "image"
    if not hasattr(first_output, "request_output") or not first_output.request_output:
        raise ValueError("No request_output found in OmniRequestOutput")

    req_out = first_output.request_output
    if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
        raise ValueError("Invalid request_output structure or missing 'images' key")

    images = req_out.images
    if images is None or len(images) != 1:
        raise ValueError(f"Expected 1 image, got {0 if images is None else len(images)}")
    return images[0]


def _run_zimage_generate(
    *,
    tp_size: int,
    height: int,
    width: int,
    num_inference_steps: int,
    seed: int,
    enforce_eager: bool,
    vae_use_tiling: bool = False,
    vae_patch_parallel_size: int = 1,
    num_requests: int = 4,
) -> tuple[Image.Image, float, float]:
    if num_requests < 2:
        raise ValueError("num_requests must be >= 2 (1 warmup + >=1 timed)")

    current_omni_platform.empty_cache()
    device_index = current_omni_platform.current_device()
    monitor = DeviceMemoryMonitor(device_index=device_index, interval=0.02)
    monitor.start()
    try:
        # Each run needs a distinct DiffusionParallelConfig; use OmniRunner per call (not the
        # parametrized omni_runner fixture, which is fixed per module).
        with OmniRunner(
            _get_zimage_model(),
            parallel_config=DiffusionParallelConfig(
                tensor_parallel_size=tp_size,
                vae_patch_parallel_size=vae_patch_parallel_size,
            ),
            enforce_eager=enforce_eager,
            vae_use_tiling=vae_use_tiling,
        ) as runner:
            # NOTE: Omni closes itself when a generate() call is exhausted.
            # To avoid measuring teardown time (process shutdown, memory cleanup),
            # we measure the latency to produce *subsequent* outputs within a single
            # generator run.
            #
            # This also serves as a warmup: the first output may include extra
            # compilation/caching overhead, while later outputs are closer to
            # steady-state inference.
            gen = runner.omni.generate(
                [PROMPT] * num_requests,
                OmniDiffusionSamplingParams(
                    height=height,
                    width=width,
                    num_inference_steps=num_inference_steps,
                    guidance_scale=0.0,
                    seed=seed,
                    num_outputs_per_prompt=1,
                ),
                py_generator=True,
            )

            warmup_output = next(gen)

            t_prev = time.perf_counter()
            per_request_times_s: list[float] = []
            last_output = warmup_output
            for _ in range(num_requests - 1):
                last_output = next(gen)
                t_now = time.perf_counter()
                per_request_times_s.append(t_now - t_prev)
                t_prev = t_now

            # Ensure the generator is fully consumed so it can clean up.
            for _ in gen:
                pass

            median_time_s = float(np.median(per_request_times_s))

            peak_memory_mb = monitor.peak_used_mb

            return _extract_single_image([last_output]), median_time_s, peak_memory_mb
    finally:
        monitor.stop()


@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.parallel
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
def test_zimage_tensor_parallel_tp2(tmp_path: Path):
    if current_omni_platform.is_npu():
        pytest.skip("Z-Image TP e2e test is only supported on CUDA and ROCm for now.")
    if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2:
        pytest.skip("Z-Image TP=2 requires >= 2 devices.")

    enforce_eager = False

    height = 512
    width = 512
    num_inference_steps = 2
    seed = 42

    tp1_img, tp1_time_s, tp1_peak_mem = _run_zimage_generate(
        tp_size=1,
        height=height,
        width=width,
        num_inference_steps=num_inference_steps,
        seed=seed,
        enforce_eager=enforce_eager,
    )
    tp2_img, tp2_time_s, tp2_peak_mem = _run_zimage_generate(
        tp_size=2,
        height=height,
        width=width,
        num_inference_steps=num_inference_steps,
        seed=seed,
        enforce_eager=enforce_eager,
    )

    tp1_path = tmp_path / "zimage_tp1.png"
    tp2_path = tmp_path / "zimage_tp2.png"
    tp1_img.save(tp1_path)
    tp2_img.save(tp2_path)

    assert tp1_img.width == width and tp1_img.height == height
    assert tp2_img.width == width and tp2_img.height == height

    mean_abs_diff, p99_abs_diff = _diff_metrics(tp1_img, tp2_img)
    mean_threshold = 3e-2
    p99_threshold = 2.5e-1
    print(
        "Z-Image TP image diff stats (TP=1 vs TP=2): "
        f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; "
        f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; "
        f"tp1_img={tp1_path}, tp2_img={tp2_path}"
    )
    assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, (
        f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} "
        f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})"
    )

    print(f"Z-Image TP perf (lower is better): tp1_time_s={tp1_time_s:.6f}, tp2_time_s={tp2_time_s:.6f}")
    # ROCm is not optimized TP2 can be slower than TP1
    if not current_omni_platform.is_rocm():
        assert tp2_time_s < tp1_time_s, f"Expected TP=2 to be faster than TP=1 (tp1={tp1_time_s}, tp2={tp2_time_s})"

    print(f"Z-Image TP peak memory (MB): tp1_peak_mem={tp1_peak_mem:.2f}, tp2_peak_mem={tp2_peak_mem:.2f}")
    assert tp2_peak_mem < tp1_peak_mem, (
        f"Expected TP=2 to use less peak memory than TP=1 (tp1={tp1_peak_mem}, tp2={tp2_peak_mem})"
    )


@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.parallel
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
def test_zimage_vae_patch_parallel_tp2(tmp_path: Path):
    if current_omni_platform.is_npu():
        pytest.skip("Z-Image VAE patch parallel e2e test is only supported on CUDA and ROCm for now.")
    if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2:
        pytest.skip("Z-Image VAE patch parallel TP=2 requires >= 2 devices.")

    enforce_eager = False

    # Use a larger image to ensure there are multiple VAE tiles.
    height = 1152
    width = 1152
    num_inference_steps = 2
    seed = 42

    baseline_img, _baseline_time_s, _baseline_peak_mem = _run_zimage_generate(
        tp_size=2,
        height=height,
        width=width,
        num_inference_steps=num_inference_steps,
        seed=seed,
        enforce_eager=enforce_eager,
        vae_use_tiling=True,
        vae_patch_parallel_size=1,
        num_requests=2,
    )
    pp2_img, _pp2_time_s, _pp2_peak_mem = _run_zimage_generate(
        tp_size=2,
        height=height,
        width=width,
        num_inference_steps=num_inference_steps,
        seed=seed,
        enforce_eager=enforce_eager,
        vae_use_tiling=True,
        vae_patch_parallel_size=2,
        num_requests=2,
    )

    baseline_path = tmp_path / "zimage_tp2_vae_pp1.png"
    pp2_path = tmp_path / "zimage_tp2_vae_pp2.png"
    baseline_img.save(baseline_path)
    pp2_img.save(pp2_path)

    mean_abs_diff, p99_abs_diff = _diff_metrics(baseline_img, pp2_img)
    mean_threshold = 5e-3
    p99_threshold = 1e-1
    print(
        "Z-Image VAE patch parallel image diff stats (TP=2, pp=1 vs pp=2): "
        f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; "
        f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; "
        f"pp1_img={baseline_path}, pp2_img={pp2_path}"
    )
    assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, (
        f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} "
        f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})"
    )
