# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
End-to-end tests for the unified quantization framework (PR #1764).

Validates FP8 quantization works correctly for all supported model types:
  - Single-stage diffusion models (FLUX.1-dev, Qwen-Image, Z-Image-Turbo)
  - Multi-stage models (BAGEL: LLM + Diffusion)

Tests verify:
  1. FP8 quantization produces valid images
  2. Memory usage is lower than BF16 baseline
  3. Multi-stage models only quantize the diffusion stage (not the LLM stage)

Usage:
    # Run all FP8 quantization tests
    pytest tests/e2e/offline_inference/test_quantization_fp8.py -v

    # Run single-stage tests only (faster, needs ~25GB VRAM)
    pytest tests/e2e/offline_inference/test_quantization_fp8.py -v -k "single_stage"

    # Run BAGEL multi-stage test (needs ~55GB VRAM, H100 recommended)
    pytest tests/e2e/offline_inference/test_quantization_fp8.py -v -k "bagel"

    # Run FLUX test only
    pytest tests/e2e/offline_inference/test_quantization_fp8.py -v -k "flux"
"""

import os

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

from typing import Any

import pytest
import torch

from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform

pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]

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

# ─── helpers ──────────────────────────────────────────────────────────────────


def _generate_single_stage_image(
    model: str,
    quantization: str | None = None,
    height: int = 256,
    width: int = 256,
    num_inference_steps: int = 2,
    seed: int = 42,
    **extra_omni_kwargs: Any,
) -> tuple[list, float]:
    """Generate an image with a single-stage diffusion model.

    Returns (images, peak_memory_gib).
    """
    omni_kwargs: dict[str, Any] = dict(extra_omni_kwargs)
    if quantization:
        omni_kwargs["quantization"] = quantization

    with OmniRunner(model, **omni_kwargs) as runner:
        torch.accelerator.reset_peak_memory_stats()

        generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(seed)
        outputs = runner.omni.generate(
            "a photo of a cat sitting on a laptop keyboard",
            OmniDiffusionSamplingParams(
                height=height,
                width=width,
                num_inference_steps=num_inference_steps,
                guidance_scale=0.0,
                generator=generator,
            ),
        )

        peak_mem = torch.accelerator.max_memory_allocated() / (1024**3)

        first_output = outputs[0]
        assert first_output.final_output_type == "image"
        if hasattr(first_output, "images") and first_output.images:
            images = first_output.images
        else:
            assert hasattr(first_output, "request_output") and first_output.request_output
            request_output = first_output.request_output
            if isinstance(request_output, list):
                req_out = request_output[0]
            else:
                req_out = request_output
            assert isinstance(req_out, OmniRequestOutput) and hasattr(req_out, "images")
            images = req_out.images
        assert len(images) >= 1
        assert images[0].width == width
        assert images[0].height == height

        peak_mem_mb = getattr(first_output, "peak_memory_mb", None)
        if peak_mem_mb is not None:
            peak_mem = float(peak_mem_mb) / 1024.0
        else:
            peak_mem = torch.accelerator.max_memory_allocated() / (1024**3)

        return images, peak_mem


def _generate_single_stage_video(
    model: str,
    quantization: str | None = None,
    height: int = 256,
    width: int = 256,
    num_frames: int = 25,
    num_inference_steps: int = 8,
    guidance_scale: float = 4.0,
    seed: int = 42,
    prompt: str = "A serene lakeside sunrise with mist over the water",
    **extra_omni_kwargs: Any,
) -> tuple[int, float]:
    """Generate a t2v output with a single-stage diffusion model.

    Returns (num_frames_produced, peak_memory_gib)
    """
    omni_kwargs: dict[str, Any] = dict(extra_omni_kwargs)
    if quantization:
        omni_kwargs["quantization"] = quantization

    with OmniRunner(model, **omni_kwargs) as runner:
        torch.accelerator.reset_peak_memory_stats()

        generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(seed)
        outputs = runner.omni.generate(
            {"prompt": prompt, "negative_prompt": ""},
            OmniDiffusionSamplingParams(
                height=height,
                width=width,
                num_frames=num_frames,
                num_inference_steps=num_inference_steps,
                guidance_scale=guidance_scale,
                generator=generator,
            ),
        )

        first = outputs[0]

        # Unwrap pipeline-style outputs (multi-stage / OmniRequestOutput.request_output).
        frames: Any = None
        if hasattr(first, "request_output") and isinstance(first.request_output, list):
            inner = first.request_output[0]
            if isinstance(inner, OmniRequestOutput) and inner.images:
                frames = inner.images[0]
        if frames is None and hasattr(first, "images") and first.images:
            frames = first.images[0]
        assert frames is not None, "No video frames returned from generate()"

        # LTX-2 (audio+video) may surface (video, audio) tuples or {"video": ...} dicts.
        if isinstance(frames, dict):
            frames = frames.get("video") or frames.get("frames")
        elif isinstance(frames, tuple) and len(frames) == 2:
            frames = frames[0]
        assert frames is not None, "Could not extract video frames from output"

        if isinstance(frames, torch.Tensor):
            video = frames.detach().cpu()
            if video.dim() == 5:
                video = video[0]
            if video.dim() == 4 and video.shape[0] in (3, 4):
                video = video.permute(1, 2, 3, 0)
            num_frames_produced = int(video.shape[0])
        else:
            import numpy as np

            arr = np.asarray(frames)
            if arr.ndim == 5:
                arr = arr[0]
            num_frames_produced = int(arr.shape[0])

        peak_mem_mb = getattr(first, "peak_memory_mb", None)
        if peak_mem_mb:
            peak_mem = float(peak_mem_mb) / 1024.0
        else:
            peak_mem = torch.accelerator.max_memory_allocated() / (1024**3)

        return num_frames_produced, peak_mem


def _generate_bagel_image(
    quantization_config: str | None = None,
    num_inference_steps: int = 15,
) -> tuple[Any, float]:
    """Generate an image with BAGEL (multi-stage: LLM + Diffusion).

    Returns (generated_image, peak_memory_gib).
    """
    config_path = get_deploy_config_path("ci/bagel.yaml")
    omni_kwargs: dict[str, Any] = {
        "model": "ByteDance-Seed/BAGEL-7B-MoT",
        "stage_configs_path": config_path,
        "stage_init_timeout": 300,
    }
    if quantization_config:
        omni_kwargs["quantization_config"] = quantization_config

    model_name = omni_kwargs.pop("model")
    with OmniRunner(model_name, **omni_kwargs) as runner:
        omni = runner.omni
        torch.accelerator.reset_peak_memory_stats()

        params_list = omni.default_sampling_params_list
        if len(params_list) > 1:
            params_list[1].num_inference_steps = num_inference_steps  # type: ignore
            params_list[1].extra_args = {  # type: ignore
                "cfg_text_scale": 4.0,
                "cfg_img_scale": 1.5,
            }

        prompt = "<|im_start|>A futuristic city skyline at twilight, cyberpunk style<|im_end|>"
        omni_outputs = list(
            omni.generate(
                prompts=[{"prompt": prompt, "modalities": ["image"]}],
                sampling_params_list=params_list,
            )
        )

        peak_mem = torch.accelerator.max_memory_allocated() / (1024**3)

        # Extract image
        generated_image = None
        for req_output in omni_outputs:
            if images := getattr(req_output, "images", None):
                generated_image = images[0]
                break
            if hasattr(req_output, "request_output") and req_output.request_output:
                stage_outputs = req_output.request_output
                if not isinstance(stage_outputs, list):
                    stage_outputs = [stage_outputs]
                for stage_out in stage_outputs:
                    if hasattr(stage_out, "images") and stage_out.images:
                        generated_image = stage_out.images[0]
                        break
                if generated_image:
                    break

        assert generated_image is not None, "No images generated from BAGEL"
        assert generated_image.size == (1024, 1024), f"Expected 1024x1024, got {generated_image.size}"

        # Check LLM stage output — should have finish_reason=stop (not length)
        for req_output in omni_outputs:
            if hasattr(req_output, "request_output") and req_output.request_output:
                stage_outputs = req_output.request_output
                if not isinstance(stage_outputs, list):
                    stage_outputs = [stage_outputs]
                for stage_out in stage_outputs:
                    if hasattr(stage_out, "outputs"):
                        for comp_out in stage_out.outputs:
                            if hasattr(comp_out, "finish_reason"):
                                assert comp_out.finish_reason == "stop", (
                                    f"LLM stage finish_reason={comp_out.finish_reason}, "
                                    f"text={comp_out.text!r}. "
                                    "FP8 may have leaked to the LLM stage."
                                )

        return generated_image, peak_mem


# ─── Single-stage diffusion model tests ──────────────────────────────────────


@hardware_test(res={"cuda": "L4"})
def test_single_stage_zimage_fp8():
    """Z-Image-Turbo with FP8 generates valid images."""
    images, _ = _generate_single_stage_image(
        model="Tongyi-MAI/Z-Image-Turbo",
        quantization="fp8",
    )
    assert len(images) >= 1
    images[0].save("test_zimage_fp8.png")


@hardware_test(res={"cuda": "L4"})
def test_single_stage_zimage_fp8_uses_less_memory():
    """FP8 should use less peak memory than BF16 for Z-Image-Turbo."""
    _, mem_bf16 = _generate_single_stage_image(
        model="Tongyi-MAI/Z-Image-Turbo",
        quantization=None,
    )
    torch.accelerator.empty_cache()

    _, mem_fp8 = _generate_single_stage_image(
        model="Tongyi-MAI/Z-Image-Turbo",
        quantization="fp8",
    )

    print(f"Z-Image BF16 peak memory: {mem_bf16:.2f} GiB")
    print(f"Z-Image FP8 peak memory:  {mem_fp8:.2f} GiB")
    assert mem_fp8 < mem_bf16, f"FP8 ({mem_fp8:.2f} GiB) should use less memory than BF16 ({mem_bf16:.2f} GiB)"


@hardware_test(res={"cuda": "L4"})
def test_single_stage_qwen_image_fp8():
    """Qwen-Image (random weights) with FP8 generates valid images."""
    model = "riverclouds/qwen_image_random"
    if current_omni_platform.is_npu() or current_omni_platform.is_rocm():
        pytest.skip("qwen_image_random not available on this platform")

    images, _ = _generate_single_stage_image(
        model=model,
        quantization="fp8",
    )
    assert len(images) >= 1
    images[0].save("test_qwen_image_fp8.png")


@hardware_test(res={"cuda": "H100"})
@pytest.mark.skip(reason="This model is not authorized on Hugging Face Hub yet")
def test_single_stage_flux_fp8():
    """FLUX.1-dev with FP8 generates valid images."""
    images, _ = _generate_single_stage_image(
        model="black-forest-labs/FLUX.1-dev",
        quantization="fp8",
        height=512,
        width=512,
        num_inference_steps=4,
    )
    assert len(images) >= 1
    images[0].save("test_flux_fp8.png")


@hardware_test(res={"cuda": "H100"})
@pytest.mark.skip(reason="This model is not authorized on Hugging Face Hub yet")
def test_single_stage_flux_fp8_uses_less_memory():
    """FP8 should use less peak memory than BF16 for FLUX.1-dev."""
    _, mem_bf16 = _generate_single_stage_image(
        model="black-forest-labs/FLUX.1-dev",
        quantization=None,
        height=512,
        width=512,
        num_inference_steps=4,
    )
    torch.accelerator.empty_cache()

    _, mem_fp8 = _generate_single_stage_image(
        model="black-forest-labs/FLUX.1-dev",
        quantization="fp8",
        height=512,
        width=512,
        num_inference_steps=4,
    )

    print(f"FLUX BF16 peak memory: {mem_bf16:.2f} GiB")
    print(f"FLUX FP8 peak memory:  {mem_fp8:.2f} GiB")
    assert mem_fp8 < mem_bf16, f"FP8 ({mem_fp8:.2f} GiB) should use less memory than BF16 ({mem_bf16:.2f} GiB)"


@hardware_test(res={"cuda": "H100"})
def test_single_stage_ltx2_fp8_uses_less_memory():
    """FP8 should use less peak memory than BF16 for LTX-2."""
    _, mem_bf16 = _generate_single_stage_video(
        model="Lightricks/LTX-2",
        quantization=None,
    )
    torch.accelerator.empty_cache()

    _, mem_fp8 = _generate_single_stage_video(
        model="Lightricks/LTX-2",
        quantization="fp8",
    )

    print(f"LTX-2 BF16 peak memory: {mem_bf16:.2f} GiB")
    print(f"LTX-2 FP8 peak memory:  {mem_fp8:.2f} GiB")
    assert mem_fp8 < mem_bf16, f"FP8 ({mem_fp8:.2f} GiB) should use less memory than BF16 ({mem_bf16:.2f} GiB)"


# ─── Multi-stage model tests (BAGEL) ─────────────────────────────────────────


@hardware_test(res={"cuda": "H100"})
def test_bagel_fp8_generates_image():
    """BAGEL with FP8 quantization_config generates a valid image.

    FP8 should only apply to the diffusion stage (Stage-1), not the
    LLM stage (Stage-0). We verify this by checking:
      1. Image is generated successfully
      2. LLM stage finish_reason is 'stop' (not 'length' from garbled output)
    """
    image, _ = _generate_bagel_image(quantization_config="fp8")
    image.save("test_bagel_fp8.png")


@hardware_test(res={"cuda": "H100"})
def test_bagel_bf16_generates_image():
    """BAGEL without quantization generates a valid image (baseline)."""
    image, _ = _generate_bagel_image(quantization_config=None)
    image.save("test_bagel_bf16.png")


# ─── Quantization config routing tests ────────────────────────────────────────


@hardware_test(res={"cuda": "L4"})
def test_quantization_key_maps_to_quantization_config():
    """The old 'quantization' kwarg should map to 'quantization_config'
    in OmniDiffusionConfig.from_kwargs for backwards compatibility."""
    from vllm_omni.diffusion.data import OmniDiffusionConfig

    config = OmniDiffusionConfig.from_kwargs(model="test", quantization="fp8")
    assert config.quantization_config is not None
    assert config.quantization_config.get_name() == "fp8"


@hardware_test(res={"cuda": "L4"})
def test_quantization_config_key_takes_priority():
    """When both 'quantization' and 'quantization_config' are set,
    'quantization_config' takes priority."""
    from vllm_omni.diffusion.data import OmniDiffusionConfig

    config = OmniDiffusionConfig.from_kwargs(
        model="test",
        quantization="fp8",
        quantization_config={"method": "fp8", "activation_scheme": "static"},
    )
    assert config.quantization_config is not None
    assert config.quantization_config.activation_scheme == "static"


@hardware_test(res={"cuda": "L4"})
def test_single_stage_quantization_config_key():
    """Single-stage model using quantization_config (dict) key generates images."""
    images, _ = _generate_single_stage_image(
        model="Tongyi-MAI/Z-Image-Turbo",
        quantization=None,
        quantization_config="fp8",
    )
    assert len(images) >= 1
