"""
Server management and performance validation for diffusion tests.
"""

from __future__ import annotations

import asyncio
import base64
import os
import shlex
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Sequence
from urllib.request import urlopen

import pytest
from openai import Client

from sglang.multimodal_gen.benchmarks.compare_perf import calculate_upper_bound
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.utils.common import kill_process_tree
from sglang.multimodal_gen.runtime.utils.logging_utils import (
    globally_suppress_loggers,
    init_logger,
)
from sglang.multimodal_gen.runtime.utils.perf_logger import RequestPerfRecord
from sglang.multimodal_gen.test.server.common.slack import upload_file_to_slack
from sglang.multimodal_gen.test.server.realtime_consistency import (
    build_realtime_init_payload,
    collect_realtime_output,
    encode_realtime_frames_to_mp4,
    prepare_realtime_first_frame,
    realtime_ws_url,
    record_realtime_key_frames,
    record_realtime_perf_stats,
)
from sglang.multimodal_gen.test.server.testcase_configs import (
    DiffusionSamplingParams,
    PerformanceSummary,
    ScenarioConfig,
    ToleranceConfig,
)
from sglang.multimodal_gen.test.test_utils import (
    get_expected_image_format,
    get_video_frame_count,
    is_image_url,
    prepare_perf_log,
    validate_image,
    validate_image_file,
    validate_openai_video,
    validate_video_file,
)

logger = init_logger(__name__)

globally_suppress_loggers()

FIRST_DENOISE_STEP_TOLERANCE = 4.0
FIRST_DENOISE_STEP_MIN_ABS_TOLERANCE_MS = 80.0
DECODING_STAGE_MIN_ABS_TOLERANCE_MS = 450.0
VIDEO_DENOISE_STEP_MIN_ABS_TOLERANCE_MS = 160.0

# Tracks mesh output file paths from generate_mesh for later correctness validation.
# Keyed by case_id, cleaned up after use.
MESH_OUTPUT_PATHS: dict[str, str] = {}


def _urlopen_with_retry(url: str, timeout: int = 30, max_retries: int = 3) -> bytes:
    """Download content from a URL with retry on transient failures."""
    for attempt in range(max_retries + 1):
        try:
            with urlopen(url, timeout=timeout) as response:
                return response.read()
        except (TimeoutError, OSError) as e:
            if attempt < max_retries:
                wait = 2**attempt
                logger.warning(
                    f"Download attempt {attempt + 1}/{max_retries + 1} failed "
                    f"for {url}: {e}. Retrying in {wait}s..."
                )
                time.sleep(wait)
            else:
                logger.error(
                    f"Failed to download from {url} after "
                    f"{max_retries + 1} attempts: {e}"
                )
                raise


def download_image_from_url(url: str) -> Path:
    """Download an image from a URL to a temporary file.

    Args:
        url: The URL of the image to download

    Returns:
        Path to the downloaded temporary file
    """
    logger.info(f"Downloading image from URL: {url}")

    # Determine file extension from URL
    ext = ".jpg"  # default
    if url.lower().endswith((".png", ".jpeg", ".jpg", ".webp", ".gif")):
        ext = url[url.rfind(".") :]

    # Create temporary file
    temp_file = (
        Path(tempfile.gettempdir()) / f"diffusion_test_image_{int(time.time())}{ext}"
    )

    data = _urlopen_with_retry(url)
    temp_file.write_bytes(data)
    logger.info(f"Downloaded image to: {temp_file}")
    return temp_file


def parse_dimensions(size_string: str | None) -> tuple[int | None, int | None]:
    """Parse a size string in "widthxheight" format to (width, height) tuple.

    Args:
        size_string: Size string in "widthxheight" format (e.g., "1024x1024") or None.
                    Spaces are automatically stripped.

    Returns:
        Tuple of (width, height) as integers if parsing succeeds, (None, None) otherwise.
    """
    if not size_string:
        return (None, None)

    # Strip spaces from the entire string
    size_string = size_string.strip()
    if not size_string:
        return (None, None)

    # Split by "x"
    parts = size_string.split("x")
    if len(parts) != 2:
        return (None, None)

    # Strip spaces from each part and try to convert to int
    try:
        width_str = parts[0].strip()
        height_str = parts[1].strip()

        if not width_str or not height_str:
            return (None, None)

        width = int(width_str)
        height = int(height_str)

        # Validate that both are positive
        if width <= 0 or height <= 0:
            return (None, None)

        return (width, height)
    except ValueError:
        return (None, None)


@dataclass
class ServerContext:
    """Context for a running diffusion server."""

    port: int
    process: subprocess.Popen
    model: str
    stdout_file: Path
    perf_log_path: Path
    log_dir: Path
    _stdout_fh: Any = field(repr=False)
    _log_thread: threading.Thread | None = field(default=None, repr=False)

    def log_tail(self, lines: int = 200) -> str:
        """Return recent server output for failure diagnostics."""
        try:
            content = self.stdout_file.read_text(encoding="utf-8", errors="ignore")
            return "\n".join(content.splitlines()[-lines:])
        except Exception:
            return ""

    def cleanup(self) -> None:
        """Clean up server resources."""
        try:
            kill_process_tree(self.process.pid)
        except Exception:
            pass
        try:
            self._stdout_fh.flush()
            self._stdout_fh.close()
        except Exception:
            pass

        # ROCm/AMD: Extra cleanup to ensure GPU memory is released between tests
        # This is needed because ROCm memory release can be slower than CUDA
        if current_platform.is_hip():
            self._cleanup_rocm_gpu_memory()
            # Clean up downloaded models if HF cache is not persistent
            # This prevents disk exhaustion in CI when cache is not mounted
            self._cleanup_hf_cache_if_not_persistent()
        else:
            # Give the runtime a brief cooldown after server shutdown.
            time.sleep(2)

    def _cleanup_hf_cache_if_not_persistent(self) -> None:
        """Clean up HF cache if it's not on a persistent volume.

        When running in CI without persistent cache, downloaded models accumulate
        and can cause disk/memory exhaustion. This cleans up the model after each
        test if the cache is not persistent.
        """
        import shutil

        hf_home = os.environ.get("HF_HOME", "")
        if not hf_home:
            return

        hf_hub_cache = os.path.join(hf_home, "hub")

        # Check if HF cache is on a persistent volume by looking for a marker file
        # or checking if the directory existed before this test run
        persistent_marker = os.path.join(hf_home, ".persistent_cache")
        if os.path.exists(persistent_marker):
            logger.info("HF cache is persistent, skipping cleanup")
            return

        # Check if the cache directory is empty or was just created
        # If it has very few models, it's likely not persistent
        if not os.path.exists(hf_hub_cache):
            return

        try:
            # Get model cache directories
            model_dirs = [
                d
                for d in os.listdir(hf_hub_cache)
                if d.startswith("models--")
                and os.path.isdir(os.path.join(hf_hub_cache, d))
            ]

            # If there are cached models but no persistent marker, clean up
            # to prevent disk exhaustion in CI
            if model_dirs:
                logger.info(
                    "HF cache appears non-persistent (no .persistent_cache marker), "
                    "cleaning up %d model(s) to prevent disk exhaustion",
                    len(model_dirs),
                )
                for model_dir in model_dirs:
                    model_path = os.path.join(hf_hub_cache, model_dir)
                    try:
                        shutil.rmtree(model_path)
                        logger.info("Cleaned up model cache: %s", model_dir)
                    except Exception as e:
                        logger.warning("Failed to clean up %s: %s", model_dir, e)
        except Exception as e:
            logger.warning("Error during HF cache cleanup: %s", e)

    def _cleanup_rocm_gpu_memory(self) -> None:
        """ROCm-specific cleanup to ensure GPU memory is fully released."""
        import gc

        # Wait for process to fully terminate
        try:
            self.process.wait(timeout=30)
        except Exception:
            pass

        # Force garbage collection multiple times
        for _ in range(3):
            gc.collect()

        # Clear HIP memory on all GPUs
        try:
            import torch

            for i in range(torch.cuda.device_count()):
                with torch.cuda.device(i):
                    torch.cuda.empty_cache()
                    torch.cuda.synchronize()
        except Exception:
            pass

        # Wait for GPU memory to be released (ROCm can be much slower than CUDA)
        # The GPU driver needs time to reclaim memory from killed processes
        time.sleep(15)


class ServerManager:
    """Manages diffusion server lifecycle."""

    def __init__(
        self,
        model: str,
        port: int,
        wait_deadline: float = 1200.0,
        extra_args: str = "",
        env_vars: dict[str, str] | None = None,
    ):
        self.model = model
        self.port = port
        self.wait_deadline = wait_deadline
        self.extra_args = extra_args
        self.env_vars = env_vars or {}

    def _wait_for_rocm_gpu_memory_clear(self, max_wait: float = 60.0) -> None:
        """ROCm-specific: Wait for GPU memory to be mostly free before starting.

        ROCm GPU memory release from killed processes can be significantly slower
        than CUDA, so we need to wait longer and be more patient.
        """
        try:
            import torch

            if not torch.cuda.is_available():
                return

            start_time = time.time()
            last_total_used = float("inf")

            while time.time() - start_time < max_wait:
                # Check GPU memory usage
                total_used = 0
                for i in range(torch.cuda.device_count()):
                    mem_info = torch.cuda.mem_get_info(i)
                    free, total = mem_info
                    used = total - free
                    total_used += used

                # If less than 5GB is used across all GPUs, we're good
                if total_used < 5 * 1024 * 1024 * 1024:  # 5GB
                    logger.info(
                        "[server-test] ROCm GPU memory is clear (used: %.2f GB)",
                        total_used / (1024**3),
                    )
                    return

                # Log progress
                elapsed = int(time.time() - start_time)
                if total_used < last_total_used:
                    logger.info(
                        "[server-test] ROCm: GPU memory clearing (used: %.2f GB, elapsed: %ds)",
                        total_used / (1024**3),
                        elapsed,
                    )
                else:
                    logger.info(
                        "[server-test] ROCm: Waiting for GPU memory (used: %.2f GB, elapsed: %ds)",
                        total_used / (1024**3),
                        elapsed,
                    )
                last_total_used = total_used
                time.sleep(3)

            # Final warning with detailed GPU info
            logger.warning(
                "[server-test] ROCm GPU memory not fully cleared after %.0fs (used: %.2f GB). "
                "Proceeding anyway - this may cause OOM.",
                max_wait,
                total_used / (1024**3),
            )
        except Exception as e:
            logger.debug("[server-test] Could not check ROCm GPU memory: %s", e)

    def start(self) -> ServerContext:
        """Start the diffusion server and wait for readiness."""
        # ROCm/AMD: Wait for GPU memory to be clear before starting
        # This prevents OOM when running sequential tests on ROCm
        if current_platform.is_hip():
            self._wait_for_rocm_gpu_memory_clear()

        log_dir, perf_log_path = prepare_perf_log()

        safe_model_name = self.model.replace("/", "_")
        stdout_path = (
            Path(tempfile.gettempdir())
            / f"sgl_server_{self.port}_{safe_model_name}.log"
        )
        stdout_path.unlink(missing_ok=True)

        command = [
            "sglang",
            "serve",
            "--model-path",
            self.model,
            "--port",
            str(self.port),
            "--log-level=debug",
        ]
        if self.extra_args.strip():
            command.extend(self.extra_args.strip().split())

        env = os.environ.copy()
        env["SGLANG_DIFFUSION_STAGE_LOGGING"] = "1"
        env["SGLANG_PERF_LOG_DIR"] = log_dir.as_posix()

        # Apply custom environment variables
        env.update(self.env_vars)

        cmd_str = shlex.join(command)
        # Use print (not logger) so the command always appears in CI output
        # regardless of log-level configuration.
        print(f"[server-test] Running command: {cmd_str}", flush=True)

        process = subprocess.Popen(
            command,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
            env=env,
        )

        log_thread = None
        stdout_fh = stdout_path.open("w", encoding="utf-8", buffering=1)
        if process.stdout:

            def _log_pipe(pipe: Any, file: Any) -> None:
                """Read from pipe and write to file and stdout."""
                try:
                    with pipe:
                        for line in iter(pipe.readline, ""):
                            sys.stdout.write(line)
                            sys.stdout.flush()
                            file.write(line)
                            file.flush()
                except Exception as e:
                    logger.error("Log pipe thread error: %s", e)
                finally:
                    file.close()
                    logger.debug("Log pipe thread finished.")

            log_thread = threading.Thread(
                target=_log_pipe, args=(process.stdout, stdout_fh)
            )
            log_thread.daemon = True
            log_thread.start()

        print(
            f"[server-test] Starting server pid={process.pid}, "
            f"model={self.model}, log={stdout_path}",
            flush=True,
        )

        self._wait_for_ready(process, stdout_path)

        return ServerContext(
            port=self.port,
            process=process,
            model=self.model,
            stdout_file=stdout_path,
            perf_log_path=perf_log_path,
            log_dir=log_dir,
            _stdout_fh=stdout_fh,
            _log_thread=log_thread,
        )

    def _wait_for_ready(self, process: subprocess.Popen, stdout_path: Path) -> None:
        """Wait for server to become ready."""
        start = time.time()
        ready_message = "Application startup complete."
        log_period = 30
        prev_log_period_count = 0

        while time.time() - start < self.wait_deadline:
            if process.poll() is not None:
                tail = self._get_log_tail(stdout_path)
                raise RuntimeError(
                    f"Server exited early (code {process.returncode}).\n{tail}"
                )

            if stdout_path.exists():
                try:
                    content = stdout_path.read_text(encoding="utf-8", errors="ignore")
                    if ready_message in content:
                        logger.info("[server-test] Server ready")
                        return
                except Exception as e:
                    logger.debug("Could not read log yet: %s", e)

            elapsed = int(time.time() - start)
            if (elapsed // log_period) > prev_log_period_count:
                prev_log_period_count = elapsed // log_period
                logger.info("[server-test] Waiting for server... elapsed=%ss", elapsed)
            time.sleep(1)

        tail = self._get_log_tail(stdout_path)
        raise TimeoutError(f"Server not ready within {self.wait_deadline}s.\n{tail}")

    @staticmethod
    def _get_log_tail(path: Path, lines: int = 200) -> str:
        """Get the last N lines from a log file."""
        try:
            content = path.read_text(encoding="utf-8", errors="ignore")
            return "\n".join(content.splitlines()[-lines:])
        except Exception:
            return ""


class PerformanceValidator:
    """Validates performance metrics against expectations."""

    is_video_gen: bool = False

    def __init__(
        self,
        scenario: ScenarioConfig,
        tolerances: ToleranceConfig,
        step_fractions: Sequence[float],
    ):
        self.scenario = scenario
        self.tolerances = tolerances
        self.step_fractions = step_fractions
        self.is_baseline_generation_mode = (
            os.environ.get("SGLANG_GEN_BASELINE", "0") == "1"
        )

    def _assert_le(
        self,
        name: str,
        actual: float,
        expected: float,
        tolerance: float,
        min_abs_tolerance_ms: float = 20.0,
    ):
        """Assert that actual is less than or equal to expected within a tolerance.

        Uses the larger of relative tolerance or absolute tolerance to prevent
        flaky failures on very fast operations.

        For AMD GPUs, uses 100% higher tolerance and issues warning instead of assertion.
        """
        # Check if running on AMD GPU
        is_amd = current_platform.is_hip()

        if is_amd:
            # Use 100% higher tolerance for AMD (2x the expected value)
            amd_tolerance = 1.0  # 100%
            upper_bound = calculate_upper_bound(
                expected, amd_tolerance, min_abs_tolerance_ms
            )
            if actual > upper_bound:
                logger.warning(
                    f"[AMD PERF WARNING] Validation would fail for '{name}'.\n"
                    f"  Actual:   {actual:.4f}ms\n"
                    f"  Expected: {expected:.4f}ms\n"
                    f"  AMD Limit: {upper_bound:.4f}ms "
                    f"(rel_tol: {amd_tolerance:.1%}, abs_pad: {min_abs_tolerance_ms}ms)\n"
                    f"  Original tolerance was: {tolerance:.1%}"
                )
        else:
            upper_bound = calculate_upper_bound(
                expected, tolerance, min_abs_tolerance_ms
            )
            assert actual <= upper_bound, (
                f"Validation failed for '{name}'.\n"
                f"  Actual:   {actual:.4f}ms\n"
                f"  Expected: {expected:.4f}ms\n"
                f"  Limit:    {upper_bound:.4f}ms "
                f"(rel_tol: {tolerance:.1%}, abs_pad: {min_abs_tolerance_ms}ms)"
            )

    def validate(
        self, perf_record: RequestPerfRecord, *args, **kwargs
    ) -> PerformanceSummary:
        """Validate all performance metrics and return summary."""
        summary = self.collect_metrics(perf_record)
        if self.is_baseline_generation_mode:
            return summary

        self._validate_e2e(summary)
        self._validate_denoise_agg(summary)
        self._validate_denoise_steps(summary)
        self._validate_stages(summary)

        return summary

    def collect_metrics(
        self,
        perf_record: RequestPerfRecord,
    ) -> PerformanceSummary:
        return PerformanceSummary.from_req_perf_record(perf_record, self.step_fractions)

    def _validate_e2e(self, summary: PerformanceSummary) -> None:
        """Validate end-to-end performance."""
        assert summary.e2e_ms > 0, "E2E duration missing"
        self._assert_le(
            "E2E Latency",
            summary.e2e_ms,
            self.scenario.expected_e2e_ms,
            self.tolerances.e2e,
        )

    def _validate_denoise_agg(self, summary: PerformanceSummary) -> None:
        """Validate aggregate denoising metrics."""
        assert summary.avg_denoise_ms > 0, "Denoising step timings missing"

        self._assert_le(
            "Average Denoise Step",
            summary.avg_denoise_ms,
            self.scenario.expected_avg_denoise_ms,
            self.tolerances.denoise_agg,
        )
        self._assert_le(
            "Median Denoise Step",
            summary.median_denoise_ms,
            self.scenario.expected_median_denoise_ms,
            self.tolerances.denoise_agg,
        )

    def _validate_denoise_steps(self, summary: PerformanceSummary) -> None:
        """Validate individual denoising steps."""
        for idx, actual in summary.sampled_steps.items():
            expected = self.scenario.denoise_step_ms.get(idx)
            if expected is None:
                continue
            if idx == 0:
                # server warmup is generic, so the first real step can still
                # pay request-shape/path lazy init that is not a steady-state signal
                self._assert_le(
                    f"Denoise Step {idx}",
                    actual,
                    expected,
                    FIRST_DENOISE_STEP_TOLERANCE,
                    min_abs_tolerance_ms=FIRST_DENOISE_STEP_MIN_ABS_TOLERANCE_MS,
                )
                continue

            self._assert_le(
                f"Denoise Step {idx}",
                actual,
                expected,
                self.tolerances.denoise_step,
            )

    def _validate_stages(self, summary: PerformanceSummary) -> None:
        """Validate stage-level metrics."""
        assert summary.stage_metrics, "Stage metrics missing"

        for stage, expected in self.scenario.stages_ms.items():
            if stage == "per_frame_generation" and self.is_video_gen:
                continue
            actual = summary.stage_metrics.get(stage)
            assert actual is not None, f"Stage {stage} timing missing"
            tolerance = (
                self.tolerances.denoise_stage
                if stage == "DenoisingStage"
                else self.tolerances.non_denoise_stage
            )
            if stage.endswith("DecodingStage"):
                tolerance = max(tolerance, 0.9)
                min_abs_tolerance_ms = DECODING_STAGE_MIN_ABS_TOLERANCE_MS
            else:
                min_abs_tolerance_ms = 120.0
            self._assert_le(
                f"Stage '{stage}'",
                actual,
                expected,
                tolerance,
                min_abs_tolerance_ms=min_abs_tolerance_ms,
            )


class VideoPerformanceValidator(PerformanceValidator):
    """Extended validator for video diffusion with frame-level metrics."""

    is_video_gen = True

    def _validate_denoise_steps(self, summary: PerformanceSummary) -> None:
        """Validate individual denoising steps."""
        for idx, actual in summary.sampled_steps.items():
            expected = self.scenario.denoise_step_ms.get(idx)
            if expected is None:
                continue
            if idx == 0:
                self._assert_le(
                    f"Denoise Step {idx}",
                    actual,
                    expected,
                    FIRST_DENOISE_STEP_TOLERANCE,
                    min_abs_tolerance_ms=FIRST_DENOISE_STEP_MIN_ABS_TOLERANCE_MS,
                )
                continue

            # video per-step samples can catch one-off scheduling/offload jitter;
            # avg and median denoise checks remain the steady-state guard
            self._assert_le(
                f"Denoise Step {idx}",
                actual,
                expected,
                self.tolerances.denoise_step,
                min_abs_tolerance_ms=VIDEO_DENOISE_STEP_MIN_ABS_TOLERANCE_MS,
            )

    def validate(
        self,
        perf_record: RequestPerfRecord,
        num_frames: int | None = None,
    ) -> PerformanceSummary:
        """Validate video metrics including frame generation rates."""
        summary = super().validate(perf_record)

        if num_frames and summary.e2e_ms > 0:
            summary.total_frames = num_frames
            summary.avg_frame_time_ms = summary.e2e_ms / num_frames
            summary.frames_per_second = 1000.0 / summary.avg_frame_time_ms

            if not self.is_baseline_generation_mode:
                self._validate_frame_rate(summary)

        return summary

    def _validate_frame_rate(self, summary: PerformanceSummary) -> None:
        """Validate frame generation performance."""
        expected_frame_time = self.scenario.stages_ms.get("per_frame_generation")
        if expected_frame_time and summary.avg_frame_time_ms:
            self._assert_le(
                "Average Frame Time",
                summary.avg_frame_time_ms,
                expected_frame_time,
                self.tolerances.denoise_stage,
            )


class MeshValidator(PerformanceValidator):
    """Validator for 3D mesh generation. Inherits perf validation from PerformanceValidator."""

    pass


# Pinned to a ci-data commit (not main): invalidates the per-URL download cache
# whenever the reference is regenerated, and keeps the mesh GT reproducible.
# New GT now publishes to sgl-project/ci-data-diffusion, so when you push a new
# hunyuan3d.glb, switch the repo in the URL below to it and bump the SHA together
# (this frozen SHA stays readable on sgl-project/ci-data).
HUNYUAN3D_REFERENCE_URL = (
    "https://raw.githubusercontent.com/sgl-project/ci-data/"
    "395f6e49c37d22a57d79fbcd3653d43984099ae2"
    "/diffusion-ci/consistency_gt/1-gpu/hunyuan3d_2_0/hunyuan3d.glb"
)


def _download_reference_mesh(url: str) -> Path:
    """Download a reference mesh from URL, caching in temp dir.

    Validates that the cached/downloaded file actually *loads* as a non-empty
    mesh — not just that a magic/length header looks right. raw.githubusercontent
    can briefly serve a truncated or corrupt response for a just-pushed large
    file, and a prior run may have cached those bytes on a persistent runner; a
    size/magic check can't catch a blob whose byte count matches the declared
    length but whose body is corrupt (exactly what poisoned this CI cache and
    surfaced as a cryptic trimesh "incorrect header on GLB file" deep inside
    validation). Loading via trimesh rejects any such cache (forcing a
    re-download) and turns a bad fresh download into a clear error. The ``v2``
    cache prefix also invalidates blobs written by the earlier, weaker checks.
    """
    import hashlib

    cache_name = f"ref_mesh_v2_{hashlib.md5(url.encode()).hexdigest()}.glb"
    cache_path = Path(tempfile.gettempdir()) / cache_name

    def _loads_as_mesh(path: Path) -> bool:
        try:
            import trimesh

            mesh = trimesh.load(str(path), force="mesh")
            return (
                getattr(mesh, "vertices", None) is not None and len(mesh.vertices) > 0
            )
        except Exception:
            return False

    if cache_path.exists() and _loads_as_mesh(cache_path):
        logger.info(f"Using cached reference mesh: {cache_path}")
        return cache_path

    logger.info(f"Downloading reference mesh from: {url}")
    cache_path.write_bytes(_urlopen_with_retry(url, timeout=60))
    if not _loads_as_mesh(cache_path):
        size = cache_path.stat().st_size if cache_path.exists() else 0
        cache_path.unlink(missing_ok=True)
        raise RuntimeError(
            f"Reference mesh from {url} did not load as a valid mesh "
            f"({size} bytes). The CDN may not have propagated a recently-pushed "
            f"file yet; retry shortly."
        )
    logger.info(f"Reference mesh cached at: {cache_path}")
    return cache_path


def validate_mesh_correctness(
    generated_mesh_path: str,
    reference_url: str = HUNYUAN3D_REFERENCE_URL,
    num_sample_points: int = 4096,
    cd_threshold_ratio: float = 0.01,
    random_seed: int = 42,
):
    """Validate mesh geometric similarity against a reference via Chamfer Distance.

    Downloads the reference mesh from a URL (cached), samples point clouds from
    both meshes, and asserts Chamfer Distance is within threshold.
    """
    import numpy as np

    try:
        import trimesh
    except ImportError:
        pytest.fail("trimesh is required for mesh validation: pip install trimesh")

    from scipy.spatial import cKDTree

    # Load generated mesh
    generated_mesh = trimesh.load(generated_mesh_path)
    if isinstance(generated_mesh, trimesh.Scene):
        generated_mesh = generated_mesh.dump(concatenate=True)

    # Download and load reference mesh
    ref_path = _download_reference_mesh(reference_url)
    reference_mesh = trimesh.load(str(ref_path))
    if isinstance(reference_mesh, trimesh.Scene):
        reference_mesh = reference_mesh.dump(concatenate=True)

    # Bounding box diagonal for threshold normalization
    ref_bbox = reference_mesh.bounding_box.bounds
    bbox_diagonal = float(np.linalg.norm(ref_bbox[1] - ref_bbox[0]))
    cd_threshold = cd_threshold_ratio * bbox_diagonal

    # Sample point clouds
    np.random.seed(random_seed)
    gen_points = np.array(
        generated_mesh.sample(num_sample_points, return_index=True)[0]
    )
    ref_points = np.array(
        reference_mesh.sample(num_sample_points, return_index=True)[0]
    )

    # Bidirectional Chamfer Distance
    tree1 = cKDTree(gen_points)
    tree2 = cKDTree(ref_points)
    forward_cd = float(np.mean(tree2.query(gen_points)[0] ** 2))
    backward_cd = float(np.mean(tree1.query(ref_points)[0] ** 2))
    total_cd = forward_cd + backward_cd

    assert total_cd <= cd_threshold, (
        f"Chamfer Distance check failed: total_cd={total_cd:.6f}, "
        f"threshold={cd_threshold:.6f} ({cd_threshold_ratio * 100:.2f}% of bbox diagonal {bbox_diagonal:.4f})"
    )


# Registry of validators by name
VALIDATOR_REGISTRY = {
    "default": PerformanceValidator,
    "video": VideoPerformanceValidator,
    "mesh": MeshValidator,
    "action": PerformanceValidator,
}


def _extract_async_job_error_message(job: Any) -> str | None:
    error = getattr(job, "error", None)
    if error is None and isinstance(job, dict):
        error = job.get("error")

    if error is None:
        return None

    if isinstance(error, dict):
        for key in ("message", "detail", "error"):
            value = error.get(key)
            if value:
                return str(value)
        return str(error)

    message = getattr(error, "message", None)
    if message:
        return str(message)

    return str(error)


def get_generate_fn(
    model_path: str,
    modality: str,
    sampling_params: DiffusionSamplingParams,
) -> Callable[[str, Client], tuple[str, bytes]]:
    """Return appropriate generation function for the case."""
    # Allow override via environment variable (useful for AMD where large resolutions cause slow VAE)
    output_size = os.environ.get("SGLANG_TEST_OUTPUT_SIZE", sampling_params.output_size)
    n = sampling_params.num_outputs_per_prompt

    def _create_and_download_video(
        client,
        case_id,
        *,
        model: str,
        size: str,
        prompt: str | None = None,
        seconds: int | None = None,
        input_reference: Any | None = None,
        extra_body: dict[Any] | None = None,
        expected_frame_count: int | None = None,
    ) -> str:
        """
        Create a video job via /v1/videos, poll until completion,
        then download the binary content and validate it.

        Returns request-id
        """

        create_kwargs: dict[str, Any] = {
            "model": model,
            "size": size,
        }
        if prompt is not None:
            create_kwargs["prompt"] = prompt
        if seconds is not None:
            create_kwargs["seconds"] = seconds
        if input_reference is not None:
            create_kwargs["input_reference"] = input_reference  # triggers multipart
        if extra_body is not None:
            create_kwargs["extra_body"] = extra_body

        job = client.videos.create(**create_kwargs)  # type: ignore[attr-defined]
        video_id = job.id

        job_completed = False
        is_baseline_generation_mode = os.environ.get("SGLANG_GEN_BASELINE", "0") == "1"
        # Check if running on AMD GPU - use longer timeout
        is_amd = current_platform.is_hip()
        if is_baseline_generation_mode:
            timeout = 3600.0
        elif is_amd:
            timeout = 2400.0  # 40 minutes for AMD
        else:
            timeout = 1200.0
        deadline = time.time() + timeout
        while True:
            page = client.videos.list()  # type: ignore[attr-defined]
            item = next((v for v in page.data if v.id == video_id), None)
            status = getattr(item, "status", None) if item is not None else None

            if status == "completed":
                job_completed = True
                break

            if status == "failed":
                error_message = (
                    _extract_async_job_error_message(item) or "unknown error"
                )
                pytest.fail(
                    f"{case_id}: video job {video_id} failed early: {error_message}"
                )

            if status in {"cancelled", "deleted"}:
                pytest.fail(
                    f"{case_id}: video job {video_id} ended with status={status}"
                )

            if time.time() > deadline:
                break

            time.sleep(1)

        if not job_completed:
            if is_baseline_generation_mode:
                logger.warning(
                    f"{case_id}: video job {video_id} timed out during baseline generation. "
                    "Attempting to collect performance data anyway."
                )
                return (video_id, b"")

            if is_amd:
                logger.warning(
                    f"[AMD TIMEOUT WARNING] {case_id}: video job {video_id} did not complete "
                    f"within {timeout}s timeout. This may indicate performance issues on AMD."
                )
                pytest.skip(
                    f"{case_id}: video job timed out on AMD after {timeout}s - skipping"
                )

            pytest.fail(f"{case_id}: video job {video_id} did not complete in time")

        # download video
        resp = client.videos.download_content(video_id=video_id)  # type: ignore[attr-defined]
        content = resp.read()
        validate_openai_video(content)

        expected_filename = f"{video_id}.mp4"
        tmp_path = expected_filename
        with open(tmp_path, "wb") as f:
            f.write(content)

        # Validate output file
        expected_width, expected_height = parse_dimensions(size)
        if (
            extra_body is not None
            and extra_body.get("enable_upscaling")
            and expected_width
            and expected_height
        ):
            scale = extra_body.get("upscaling_scale", 4)
            expected_width *= scale
            expected_height *= scale
        validate_video_file(
            tmp_path, expected_filename, expected_width, expected_height
        )

        if expected_frame_count is not None:
            actual_count = get_video_frame_count(tmp_path)
            assert actual_count == expected_frame_count, (
                f"{case_id}: frame count mismatch after interpolation — "
                f"expected {expected_frame_count}, got {actual_count}"
            )

        upload_file_to_slack(
            case_id=case_id,
            model=model_path,
            prompt=sampling_params.prompt,
            file_path=tmp_path,
            origin_file_path=sampling_params.image_path,
        )
        os.remove(tmp_path)

        return (video_id, content)

    video_seconds = sampling_params.seconds or 4

    def generate_image(case_id, client) -> tuple[str, bytes]:
        """T2I: Text to Image generation."""
        if not sampling_params.prompt:
            pytest.skip(f"{case_id}: no text prompt configured")

        # Request parameters that affect output format
        req_output_format = sampling_params.output_format
        req_background = None  # Not specified in current request

        # Build extra_body for optional features
        extra_body = dict(sampling_params.extras)

        response = client.images.with_raw_response.generate(
            model=model_path,
            prompt=sampling_params.prompt,
            n=n,
            size=output_size,
            response_format="b64_json",
            output_format=req_output_format,
            extra_body=extra_body if extra_body else None,
        )
        result = response.parse()
        validate_image(result.data[0].b64_json)

        rid = result.id

        img_data = base64.b64decode(result.data[0].b64_json)
        # Infer expected format from request parameters
        expected_ext = get_expected_image_format(req_output_format, req_background)
        expected_filename = f"{result.created}.{expected_ext}"
        tmp_path = expected_filename
        with open(tmp_path, "wb") as f:
            f.write(img_data)

        # Validate output file
        expected_width, expected_height = parse_dimensions(output_size)
        if (
            sampling_params.extras.get("enable_upscaling")
            and expected_width
            and expected_height
        ):
            expected_width *= sampling_params.extras.get("upscaling_scale", 4)
            expected_height *= sampling_params.extras.get("upscaling_scale", 4)
        validate_image_file(
            tmp_path,
            expected_filename,
            expected_width,
            expected_height,
            output_format=req_output_format,
            background=req_background,
        )

        upload_file_to_slack(
            case_id=case_id,
            model=model_path,
            prompt=sampling_params.prompt,
            file_path=tmp_path,
        )
        os.remove(tmp_path)

        return (rid, img_data)

    def generate_image_edit(case_id, client) -> tuple[str, bytes]:
        """TI2I: Text + Image -> Image edit."""
        if not sampling_params.prompt or not sampling_params.image_path:
            pytest.skip(f"{case_id}: no edit config")

        image_paths = sampling_params.image_path

        if not isinstance(image_paths, list):
            image_paths = [image_paths]

        new_image_paths = []
        for image_path in image_paths:
            if is_image_url(image_path):
                new_image_paths.append(download_image_from_url(str(image_path)))
            else:
                local_path = Path(image_path)
                new_image_paths.append(local_path)
                if not local_path.exists():
                    pytest.skip(f"{case_id}: file missing: {image_path}")

        image_paths = new_image_paths

        # Request parameters that affect output format
        req_output_format = (
            sampling_params.output_format
        )  # Not specified in current request
        req_background = None  # Not specified in current request

        # Build extra_body for optional features
        extra_body = {"num_frames": sampling_params.num_frames}
        extra_body.update(sampling_params.extras)

        images = [open(image_path, "rb") for image_path in image_paths]
        try:
            response = client.images.with_raw_response.edit(
                model=model_path,
                image=images,
                prompt=sampling_params.prompt,
                n=n,
                size=output_size,
                response_format="b64_json",
                output_format=req_output_format,
                extra_body=extra_body,
            )
        finally:
            for img in images:
                img.close()

        result = response.parse()
        validate_image(result.data[0].b64_json)

        img_data = base64.b64decode(result.data[0].b64_json)
        rid = result.id

        # Infer expected format from request parameters
        expected_ext = get_expected_image_format(req_output_format, req_background)
        expected_filename = f"{rid}.{expected_ext}"
        tmp_path = expected_filename
        with open(tmp_path, "wb") as f:
            f.write(img_data)

        # Validate output file
        expected_width, expected_height = parse_dimensions(output_size)
        validate_image_file(
            tmp_path,
            expected_filename,
            expected_width,
            expected_height,
            output_format=req_output_format,
            background=req_background,
        )

        upload_file_to_slack(
            case_id=case_id,
            model=model_path,
            prompt=sampling_params.prompt,
            file_path=tmp_path,
            origin_file_path=sampling_params.image_path,
        )
        os.remove(tmp_path)

        return (rid, img_data)

    def generate_image_edit_url(case_id, client) -> tuple[str, bytes]:
        """TI2I: Text + Image ? Image edit using direct URL transfer (no pre-download)."""
        if not sampling_params.prompt or not sampling_params.image_path:
            pytest.skip(f"{case_id}: no edit config")
        # Handle both single URL and list of URLs
        image_urls = sampling_params.image_path
        if not isinstance(image_urls, list):
            image_urls = [image_urls]

        # Validate all URLs
        for url in image_urls:
            if not is_image_url(url):
                pytest.skip(
                    f"{case_id}: image_path must be a URL for URL direct test: {url}"
                )

        # Request parameters that affect output format
        req_output_format = (
            sampling_params.output_format
        )  # Not specified in current request
        req_background = None  # Not specified in current request

        response = client.images.with_raw_response.edit(
            model=model_path,
            prompt=sampling_params.prompt,
            image=[],  # Only for OpenAI verification
            n=n,
            size=sampling_params.output_size,
            response_format="b64_json",
            output_format=req_output_format,
            extra_body={"url": image_urls, "num_frames": sampling_params.num_frames},
        )

        result = response.parse()
        rid = result.id

        validate_image(result.data[0].b64_json)

        # Save and upload result for verification
        img_data = base64.b64decode(result.data[0].b64_json)
        # Infer expected format from request parameters
        expected_ext = get_expected_image_format(req_output_format, req_background)
        expected_filename = f"{rid}.{expected_ext}"
        tmp_path = expected_filename
        with open(tmp_path, "wb") as f:
            f.write(img_data)

        # Validate output file
        expected_width, expected_height = parse_dimensions(sampling_params.output_size)
        validate_image_file(
            tmp_path,
            expected_filename,
            expected_width,
            expected_height,
            output_format=req_output_format,
            background=req_background,
        )

        upload_file_to_slack(
            case_id=case_id,
            model=model_path,
            prompt=sampling_params.prompt,
            file_path=tmp_path,
            origin_file_path=str(sampling_params.image_path),
        )
        os.remove(tmp_path)

        return (rid, img_data)

    def generate_video(case_id, client) -> tuple[str, bytes]:
        """T2V: Text ? Video."""
        if not sampling_params.prompt:
            pytest.skip(f"{case_id}: no text prompt configured")

        # Build extra_body for optional features
        extra_body = dict(sampling_params.extras)
        if sampling_params.num_frames:
            extra_body["num_frames"] = sampling_params.num_frames

        # Compute expected output frame count for validation
        expected_frame_count = None
        if (
            sampling_params.extras.get("enable_frame_interpolation")
            and sampling_params.num_frames
        ):
            n = sampling_params.num_frames
            exp = sampling_params.extras.get("frame_interpolation_exp", 1)
            expected_frame_count = (n - 1) * (2**exp) + 1

        return _create_and_download_video(
            client,
            case_id,
            model=model_path,
            prompt=sampling_params.prompt,
            size=output_size,
            seconds=video_seconds,
            extra_body=extra_body if extra_body else None,
            expected_frame_count=expected_frame_count,
        )

    def generate_image_to_video(case_id, client) -> tuple[str, bytes]:
        """I2V: Image -> Video (optional prompt)."""
        if not sampling_params.image_path:
            pytest.skip(f"{case_id}: no input image configured")

        if is_image_url(sampling_params.image_path):
            image_path = download_image_from_url(str(sampling_params.image_path))
        else:
            image_path = Path(sampling_params.image_path)
            if not image_path.exists():
                pytest.skip(f"{case_id}: file missing: {image_path}")

        # Build extra_body for optional features
        extra_body = dict(sampling_params.extras)

        with image_path.open("rb") as fh:
            return _create_and_download_video(
                client,
                case_id,
                model=model_path,
                prompt=sampling_params.prompt,
                size=output_size,
                seconds=video_seconds,
                input_reference=fh,
                extra_body=extra_body if extra_body else None,
            )

    def generate_text_url_image_to_video(case_id, client) -> tuple[str, bytes]:
        if not sampling_params.prompt or not sampling_params.image_path:
            pytest.skip(f"{case_id}: no edit config")

        # Build extra_body for optional features
        extra_body = {"reference_url": sampling_params.image_path}
        extra_body.update(sampling_params.extras)

        return _create_and_download_video(
            client,
            case_id,
            model=model_path,
            prompt=sampling_params.prompt,
            size=sampling_params.output_size,
            seconds=video_seconds,
            extra_body={
                "reference_url": sampling_params.image_path,
                "fps": sampling_params.fps,
                "num_frames": sampling_params.num_frames,
            },
        )

    def generate_text_image_to_video(case_id, client) -> tuple[str, bytes]:
        """TI2V: Text + Image -> Video."""
        if not sampling_params.prompt or not sampling_params.image_path:
            pytest.skip(f"{case_id}: no edit config")

        if is_image_url(sampling_params.image_path):
            image_path = download_image_from_url(str(sampling_params.image_path))
        else:
            image_path = Path(sampling_params.image_path)
            if not image_path.exists():
                pytest.skip(f"{case_id}: file missing: {image_path}")

        # Build extra_body for optional features
        extra_body = dict(sampling_params.extras)

        with image_path.open("rb") as fh:
            return _create_and_download_video(
                client,
                case_id,
                model=model_path,
                prompt=sampling_params.prompt,
                size=output_size,
                seconds=video_seconds,
                input_reference=fh,
                extra_body={
                    "fps": sampling_params.fps,
                    "num_frames": sampling_params.num_frames,
                    **extra_body,
                },
            )

    def generate_realtime_video(case_id, client) -> tuple[str, bytes]:
        """Realtime video generation folded back into mp4 for consistency checks."""
        if not sampling_params.prompt:
            pytest.skip(f"{case_id}: no realtime prompt configured")
        if sampling_params.realtime_num_chunks is None:
            pytest.skip(f"{case_id}: realtime_num_chunks is not configured")
        if sampling_params.realtime_num_chunks <= 0:
            pytest.fail(f"{case_id}: realtime_num_chunks must be positive")

        first_frame = prepare_realtime_first_frame(sampling_params.image_path)
        init_payload = build_realtime_init_payload(
            model_path=model_path,
            sampling_params=sampling_params,
            output_size=output_size,
            first_frame=first_frame,
        )
        realtime_output = asyncio.run(
            collect_realtime_output(
                ws_url=realtime_ws_url(client),
                init_payload=init_payload,
                events=list(sampling_params.realtime_events),
                num_chunks=sampling_params.realtime_num_chunks,
                require_chunk_stats=bool(sampling_params.realtime_perf_thresholds),
            )
        )
        record_realtime_perf_stats(case_id, realtime_output.chunk_stats)
        record_realtime_key_frames(case_id, realtime_output.frames)
        fps = int(sampling_params.fps or 24)
        video_bytes = encode_realtime_frames_to_mp4(realtime_output.frames, fps=fps)
        validate_openai_video(video_bytes)

        rid = f"{case_id}-realtime"
        expected_filename = f"{rid}.mp4"
        tmp_path = expected_filename
        Path(tmp_path).write_bytes(video_bytes)
        expected_width, expected_height = parse_dimensions(output_size)
        validate_video_file(
            tmp_path, expected_filename, expected_width, expected_height
        )
        upload_file_to_slack(
            case_id=case_id,
            model=model_path,
            prompt=sampling_params.prompt,
            file_path=tmp_path,
            origin_file_path=sampling_params.image_path,
        )
        os.remove(tmp_path)
        return (rid, video_bytes)

    def generate_mesh(case_id, client) -> tuple[str, bytes]:
        """I2M: Image to Mesh generation using async /v1/meshes API."""
        import requests as http_requests

        if not sampling_params.image_path:
            pytest.skip(f"{case_id}: no input image configured for mesh generation")

        image_path = sampling_params.image_path
        if isinstance(image_path, str) and is_image_url(image_path):
            image_path = download_image_from_url(image_path)
        elif isinstance(image_path, Path):
            if not image_path.exists():
                pytest.skip(f"{case_id}: image file missing: {image_path}")
        else:
            image_path = Path(str(image_path))
            if not image_path.exists():
                pytest.skip(f"{case_id}: image file missing: {image_path}")

        base_url = str(client.base_url).rstrip("/")
        if base_url.endswith("/v1"):
            base_url = base_url[:-3]

        create_url = f"{base_url}/v1/meshes"

        with open(str(image_path), "rb") as img_file:
            files = {"image": (Path(str(image_path)).name, img_file, "image/png")}
            data = {
                "prompt": "generate 3d mesh",
                "model": model_path,
                "seed": "0",
                "guidance_scale": "5.0",
                "num_inference_steps": "50",
            }

            logger.info(f"[Mesh Gen] Sending request to {create_url}")

            try:
                response = http_requests.post(
                    create_url, files=files, data=data, timeout=60
                )
            except Exception as e:
                pytest.fail(f"{case_id}: mesh creation request failed: {e}")

        if response.status_code != 200:
            pytest.fail(f"{case_id}: mesh creation failed: {response.text}")

        job = response.json()
        mesh_id = job.get("id")
        if not mesh_id:
            pytest.fail(f"{case_id}: no mesh id in response: {job}")

        poll_url = f"{base_url}/v1/meshes/{mesh_id}"
        poll_interval = 5
        max_wait = 1200
        elapsed = 0

        while elapsed < max_wait:
            time.sleep(poll_interval)
            elapsed += poll_interval

            try:
                poll_resp = http_requests.get(poll_url, timeout=30)
            except Exception as e:
                logger.warning(f"[Mesh Gen] Poll failed: {e}")
                continue

            if poll_resp.status_code != 200:
                continue

            status_data = poll_resp.json()
            status = status_data.get("status", "")

            if status == "completed":
                content_url = f"{base_url}/v1/meshes/{mesh_id}/content"
                try:
                    content_resp = http_requests.get(content_url, timeout=60)
                except Exception as e:
                    pytest.fail(f"{case_id}: mesh download failed: {e}")

                if content_resp.status_code != 200:
                    pytest.fail(f"{case_id}: mesh download failed: {content_resp.text}")

                content = content_resp.content
                # Shape-only Hunyuan3D meshes are returned as OBJ, painted meshes
                # as GLB. Pick the extension from the content magic so trimesh.load
                # (which dispatches on the file extension) parses it correctly,
                # instead of raising "incorrect header on GLB file" when an OBJ
                # body is saved under a .glb name.
                ext = ".glb" if content[:4] == b"glTF" else ".obj"
                temp_path = Path(tempfile.gettempdir()) / f"mesh_test_{mesh_id}{ext}"
                temp_path.write_bytes(content)
                MESH_OUTPUT_PATHS[case_id] = str(temp_path)

                logger.info(f"[Mesh Gen] Mesh downloaded to {temp_path}")
                return (mesh_id, b"")
            elif status == "failed":
                error = status_data.get("error", {})
                pytest.fail(f"{case_id}: mesh generation failed: {error}")

        pytest.fail(f"{case_id}: mesh generation timed out after {max_wait}s")

    def generate_action(case_id, client) -> tuple[str, bytes]:
        """VLA action generation using /v1/actions/generations."""
        import numpy as np
        import requests as http_requests

        extra = dict(sampling_params.extras)
        action_horizon = int(extra.get("action_horizon", 50))
        action_dim = int(extra.get("action_dim", 32))
        state_dim = int(extra.get("state_dim", action_dim))
        image_size = int(extra.get("image_size", 64))
        camera_order = tuple(
            extra.get(
                "camera_order",
                ("base_0_rgb", "left_wrist_0_rgb", "right_wrist_0_rgb"),
            )
        )

        def tensor_payload(array):
            return {
                "dtype": str(array.dtype),
                "shape": list(array.shape),
                "values": array.tolist(),
            }

        def image_payload(camera_index: int):
            y = np.arange(image_size, dtype=np.uint16)[:, None]
            x = np.arange(image_size, dtype=np.uint16)[None, :]
            image = np.stack(
                (
                    (x + camera_index * 17) % 256 + np.zeros_like(y),
                    (y + camera_index * 29) % 256 + np.zeros_like(x),
                    (x + y + camera_index * 41) % 256,
                ),
                axis=-1,
            )
            return tensor_payload(image.astype(np.uint8))

        rng = np.random.default_rng(int(extra.get("seed", 0)))
        request_id = f"{case_id}-{int(time.time() * 1000)}"
        payload = {
            "request_id": request_id,
            "model": model_path,
            "input": {
                "task": sampling_params.prompt or "pick up the blue block",
                "observation": {
                    "images": {
                        camera: image_payload(index)
                        for index, camera in enumerate(camera_order)
                    },
                    "camera_order": list(camera_order),
                    "state": tensor_payload(
                        np.linspace(-0.5, 0.5, state_dim, dtype=np.float32)
                    ),
                    "noise": tensor_payload(
                        rng.standard_normal((action_horizon, action_dim)).astype(
                            np.float32
                        )
                    ),
                },
            },
            "parameters": {
                "action_horizon": action_horizon,
                "action_dim": action_dim,
                "num_inference_steps": int(extra.get("num_inference_steps", 2)),
            },
            "runtime": {
                "return_timing": True,
                "prefix_cache": bool(extra.get("enable_prefix_cache", False)),
                "cuda_graph": bool(extra.get("enable_cuda_graph", True)),
                "output_format": "list",
            },
        }

        base_url = str(client.base_url).rstrip("/")
        endpoint = (
            f"{base_url}/actions/generations"
            if base_url.endswith("/v1")
            else f"{base_url}/v1/actions/generations"
        )
        response = http_requests.post(endpoint, json=payload, timeout=600)
        if response.status_code != 200:
            pytest.fail(f"{case_id}: action generation failed: {response.text}")

        body = response.json()
        action = body["data"][0]["action"]
        if action["shape"] != [action_horizon, action_dim]:
            pytest.fail(
                f"{case_id}: action shape mismatch: {action['shape']} "
                f"!= {[action_horizon, action_dim]}"
            )
        values = action["values"]
        if not all(
            isinstance(value, (int, float)) and np.isfinite(value)
            for row in values
            for value in row
        ):
            pytest.fail(f"{case_id}: action response contains non-finite values")
        return body["id"], response.content

    if modality == "3d":
        fn = generate_mesh
    elif modality == "action":
        fn = generate_action
    elif modality == "video":
        if sampling_params.realtime_num_chunks is not None:
            fn = generate_realtime_video
        elif sampling_params.image_path and sampling_params.prompt:
            if getattr(sampling_params, "direct_url_test", False):
                fn = generate_text_url_image_to_video
            else:
                fn = generate_text_image_to_video
        elif sampling_params.image_path:
            fn = generate_image_to_video
        else:
            fn = generate_video
    elif sampling_params.prompt and sampling_params.image_path:
        if getattr(sampling_params, "direct_url_test", False):
            fn = generate_image_edit_url
        else:
            fn = generate_image_edit
    else:
        fn = generate_image

    return fn
