# SPDX-License-Identifier: Apache-2.0
"""Build synthetic generation warmup requests.

Default server warmup should cover a representative serving path before the
first real request, without copying user traffic. It starts from the model's
sampling defaults, then keeps startup bounded by choosing common low-cost
resolution/frame buckets and trimming the denoising step count.

Image models may run a tiny second step because first/last step paths often
initialize different kernels or scheduler state. Video models cap frames and
steps to keep startup bounded. Explicit warmup resolutions share this builder;
callers send them through the scheduler client so warmup exercises the same
request transport path as real generation.
"""

from copy import copy
from typing import Any

from sglang.multimodal_gen.configs.pipeline_configs.base import ModelTaskType
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.registry import get_pipeline_config_classes
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.server_args import (
    ServerArgs,
    is_ltx2_two_stage_pipeline_name,
)
from sglang.multimodal_gen.runtime.utils.common import parse_size
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger

logger = init_logger(__name__)

DEFAULT_PLACEHOLDER_PROMPT = "warmup"
DEFAULT_DETAILED_PLACEHOLDER_PROMPT = "A detailed image."
TORCH_COMPILE_REAL_PATH_PREWARM_PROMPTS = (DEFAULT_DETAILED_PLACEHOLDER_PROMPT,)
DEFAULT_LIGHTWEIGHT_IMAGE_RESOLUTION = (64, 64)
SERVER_WARMUP_IMAGE_FALLBACK_RESOLUTION = (512, 512)
SERVER_WARMUP_VIDEO_FALLBACK_RESOLUTION = (832, 480)
SERVER_WARMUP_IMAGE_MAX_AREA = 768 * 768
SERVER_WARMUP_DIFFUSERS_IMAGE_MAX_AREA = 512 * 512
SERVER_WARMUP_VIDEO_MAX_AREA = 832 * 480
SERVER_WARMUP_MAX_VIDEO_FRAMES = 17
SERVER_WARMUP_IMAGE_STEPS = 2
SERVER_WARMUP_VIDEO_STEPS = 2


def get_model_sampling_defaults(server_args: ServerArgs) -> SamplingParams:
    pipeline_class_name = server_args.pipeline_class_name
    if pipeline_class_name:
        config_classes = get_pipeline_config_classes(pipeline_class_name)
        if config_classes is not None:
            _, sampling_params_cls = config_classes
            return sampling_params_cls()

    return SamplingParams.from_pretrained(
        server_args.model_path,
        backend=server_args.backend,
        model_id=server_args.model_id,
    )


def _resolve_default_warmup_resolution(
    server_args: ServerArgs,
    sampling_defaults: SamplingParams,
    *,
    server_based_warmup: bool,
) -> tuple[int, int]:
    """Return the default warmup resolution.

    Prefer the model's sampling-default resolution — the most likely real
    request shape — so warmup specializes kernels for it. Server-based image
    warmup used to shrink this to an area cap (``SERVER_WARMUP_IMAGE_MAX_AREA``,
    768x768) to bound startup, but that left a residual first-request
    cold-start when the real request is larger (e.g. 1024x1024 paid ~0.1s of
    first-shape kernel autotuning, measured on H100).
    """
    width = sampling_defaults.width
    height = sampling_defaults.height
    is_image_gen = server_args.pipeline_config.task_type.is_image_gen()
    if (
        width is not None
        and height is not None
        and (not server_based_warmup or is_image_gen)
    ):
        return width, height

    if server_based_warmup:
        return _resolve_representative_warmup_resolution(server_args, sampling_defaults)

    supported_resolutions = sampling_defaults.supported_resolutions
    if supported_resolutions:
        return min(supported_resolutions, key=lambda size: size[0] * size[1])

    if server_args.pipeline_config.task_type.is_image_gen():
        return DEFAULT_LIGHTWEIGHT_IMAGE_RESOLUTION

    return (
        width or DEFAULT_LIGHTWEIGHT_IMAGE_RESOLUTION[0],
        height or DEFAULT_LIGHTWEIGHT_IMAGE_RESOLUTION[1],
    )


def _resolve_representative_warmup_resolution(
    server_args: ServerArgs,
    sampling_defaults: SamplingParams,
) -> tuple[int, int]:
    target_area = _target_warmup_area(server_args)
    alignment = _warmup_resolution_alignment(server_args)

    supported_resolution = _select_supported_warmup_resolution(
        sampling_defaults.supported_resolutions, target_area, alignment
    )
    if supported_resolution is not None:
        return supported_resolution

    width = sampling_defaults.width
    height = sampling_defaults.height
    if width is not None and height is not None:
        return _fit_resolution_to_area(width, height, target_area, alignment)

    width, height = _fallback_warmup_resolution(server_args)
    return _fit_resolution_to_area(width, height, target_area, alignment)


def _target_warmup_area(server_args: ServerArgs) -> int:
    if server_args.pipeline_config.task_type.is_image_gen():
        if getattr(server_args, "backend", None) == "diffusers":
            return SERVER_WARMUP_DIFFUSERS_IMAGE_MAX_AREA
        return SERVER_WARMUP_IMAGE_MAX_AREA
    if _is_video_warmup_task(server_args):
        return SERVER_WARMUP_VIDEO_MAX_AREA
    return (
        DEFAULT_LIGHTWEIGHT_IMAGE_RESOLUTION[0]
        * DEFAULT_LIGHTWEIGHT_IMAGE_RESOLUTION[1]
    )


def _fallback_warmup_resolution(server_args: ServerArgs) -> tuple[int, int]:
    if server_args.pipeline_config.task_type.is_image_gen():
        return SERVER_WARMUP_IMAGE_FALLBACK_RESOLUTION
    if _is_video_warmup_task(server_args):
        return SERVER_WARMUP_VIDEO_FALLBACK_RESOLUTION
    return DEFAULT_LIGHTWEIGHT_IMAGE_RESOLUTION


def _is_video_warmup_task(server_args: ServerArgs) -> bool:
    return server_args.pipeline_config.task_type.is_video_gen()


def _warmup_resolution_alignment(server_args: ServerArgs) -> int:
    pipeline_config = server_args.pipeline_config
    alignment = 16

    vae_stride = getattr(pipeline_config, "vae_stride", None)
    if vae_stride is not None:
        spatial_stride = (
            vae_stride[-2:] if isinstance(vae_stride, (tuple, list)) else (vae_stride,)
        )
        for stride in spatial_stride:
            alignment = max(alignment, int(stride))

    vae_scale_factor = getattr(pipeline_config, "vae_scale_factor", None)
    if vae_scale_factor is not None:
        alignment = max(alignment, int(vae_scale_factor))

    arch_config = getattr(
        getattr(pipeline_config, "vae_config", None), "arch_config", None
    )
    for attr in ("vae_scale_factor", "spatial_compression_ratio"):
        value = getattr(arch_config, attr, None)
        if value is not None:
            alignment = max(alignment, int(value))

    if is_ltx2_two_stage_pipeline_name(server_args.pipeline_class_name):
        vae_scale_factor = pipeline_config.vae_scale_factor
        alignment = max(alignment, 64, int(vae_scale_factor) * 2)

    return alignment


def _select_supported_warmup_resolution(
    supported_resolutions: list[tuple[int, int]] | None,
    target_area: int,
    alignment: int,
) -> tuple[int, int] | None:
    if not supported_resolutions:
        return None

    candidates = [
        resolution
        for resolution in supported_resolutions
        if resolution[0] * resolution[1] <= target_area
        and _is_resolution_aligned(resolution, alignment)
    ]
    if candidates:
        return max(candidates, key=lambda size: size[0] * size[1])

    aligned_resolutions = [
        resolution
        for resolution in supported_resolutions
        if _is_resolution_aligned(resolution, alignment)
    ]
    if aligned_resolutions:
        return min(aligned_resolutions, key=lambda size: size[0] * size[1])
    return None


def _fit_resolution_to_area(
    width: int, height: int, target_area: int, alignment: int
) -> tuple[int, int]:
    """adjust the warmup resolution to balance between warmup time and warmup effect"""
    area = width * height
    if area > target_area:
        scale = (target_area / area) ** 0.5
        width = int(width * scale)
        height = int(height * scale)

    return (
        max(alignment, width // alignment * alignment),
        max(alignment, height // alignment * alignment),
    )


def _is_resolution_aligned(resolution: tuple[int, int], alignment: int) -> bool:
    width, height = resolution
    return width % alignment == 0 and height % alignment == 0


def _resolve_warmup_num_frames(
    server_args: ServerArgs,
    sampling_defaults: SamplingParams,
    *,
    server_based_warmup: bool,
) -> int:
    num_frames = getattr(sampling_defaults, "num_frames", 1)
    if (
        not server_based_warmup
        or not _is_video_warmup_task(server_args)
        or num_frames is None
    ):
        # use default num frames
        return num_frames

    # Breakable CUDA graph replays only exact latent shapes: the warmup
    # request must run the full serving frame count so its captured graphs
    # match serving signatures (mirrors the uncapped-steps rule in
    # _resolve_warmup_steps).
    if getattr(server_args, "enable_breakable_cuda_graph", False) is True:
        return num_frames

    return min(num_frames, SERVER_WARMUP_MAX_VIDEO_FRAMES)


def _effective_cfg_scale(sampling_defaults: SamplingParams) -> float | None:
    if getattr(sampling_defaults, "true_cfg_scale", None) is not None:
        return sampling_defaults.true_cfg_scale
    return getattr(sampling_defaults, "guidance_scale", None)


def _resolve_warmup_steps(
    server_args: ServerArgs,
    sampling_defaults: SamplingParams,
    *,
    server_based_warmup: bool,
) -> int:
    warmup_steps = server_args.warmup_steps
    default_steps = sampling_defaults.num_inference_steps

    # Breakable CUDA graph captures one graph per step-branch at warmup so that
    # serving never records a fresh graph. Run the model's full recommended
    # steps (uncapped) so every step-branch signature is captured up front.
    if (
        getattr(server_args, "enable_breakable_cuda_graph", False) is True
        and default_steps
    ):
        return max(int(default_steps), warmup_steps)

    if not server_based_warmup:
        return warmup_steps

    if server_args.enable_torch_compile and server_args.is_arg_explicitly_set(
        "warmup_steps"
    ):
        return warmup_steps

    default_steps = sampling_defaults.num_inference_steps
    if default_steps is None or default_steps <= warmup_steps:
        return warmup_steps

    if _is_video_warmup_task(server_args):
        return min(default_steps, max(warmup_steps, SERVER_WARMUP_VIDEO_STEPS))

    if server_args.pipeline_config.task_type.is_image_gen():
        return min(default_steps, max(warmup_steps, SERVER_WARMUP_IMAGE_STEPS))

    return warmup_steps


def should_include_warmup_image(
    server_args: ServerArgs, server_based_warmup: bool
) -> bool:
    task_type = server_args.pipeline_config.task_type
    if not supports_synthetic_warmup(server_args):
        return False
    if not task_type.accepts_image_input():
        return False
    if task_type.requires_image_input():
        return True
    if getattr(server_args, "enable_breakable_cuda_graph", False) is True:
        # BCG replays only exact input signatures. A synthetic warmup image
        # flips optional-TI2V pipelines (e.g. LTX-2) into image-conditioned
        # kwargs (denoise-mask -> per-token timestep) that pure T2V serving
        # never produces, so every T2V request would miss the captured
        # graphs and silently run eager. Capture the T2V signature instead;
        # image-conditioned requests fall back to eager.
        return False
    if type(server_args.pipeline_config).__name__ == "GlmImagePipelineConfig":
        return False
    if server_based_warmup:
        return task_type in (ModelTaskType.TI2I, ModelTaskType.TI2V)
    return True


def supports_synthetic_warmup(server_args: ServerArgs) -> bool:
    task_type = server_args.pipeline_config.task_type
    return task_type.is_visual_gen() or task_type.is_mesh_gen()


def build_warmup_reqs(
    server_args: ServerArgs,
    *,
    warmup_resolutions: list[str] | None,
    warmup_input_path: str | None = None,
    return_warmup_result: bool = False,
    server_based_warmup: bool = False,
) -> list[Req]:
    task_type = server_args.pipeline_config.task_type
    if not supports_synthetic_warmup(server_args):
        return []
    sampling_defaults = get_model_sampling_defaults(server_args)

    if warmup_resolutions is None:
        width, height = _resolve_default_warmup_resolution(
            server_args,
            sampling_defaults,
            server_based_warmup=server_based_warmup,
        )
        resolutions: list[tuple[int, int]] = [(width, height)]
    else:
        resolutions = [parse_size(resolution) for resolution in warmup_resolutions]

    negative_prompt: Any = getattr(sampling_defaults, "negative_prompt", None)
    cfg_scale = _effective_cfg_scale(sampling_defaults)
    warmup_steps = _resolve_warmup_steps(
        server_args,
        sampling_defaults,
        server_based_warmup=server_based_warmup,
    )
    warmup_num_frames = _resolve_warmup_num_frames(
        server_args,
        sampling_defaults,
        server_based_warmup=server_based_warmup,
    )

    # build warmup reqs
    warmup_reqs = []
    include_warmup_image = should_include_warmup_image(server_args, server_based_warmup)
    for width, height in resolutions:
        req_kwargs = dict(
            data_type=task_type.data_type(),
            width=width,
            height=height,
            prompt=DEFAULT_PLACEHOLDER_PROMPT,
        )
        req_kwargs["sampling_params"] = copy(sampling_defaults)
        req_kwargs.update(
            negative_prompt=negative_prompt,
            guidance_scale=sampling_defaults.guidance_scale,
            guidance_scale_2=sampling_defaults.guidance_scale_2,
            true_cfg_scale=sampling_defaults.true_cfg_scale,
            num_inference_steps=sampling_defaults.num_inference_steps,
            num_frames=warmup_num_frames,
        )
        if include_warmup_image:
            if warmup_input_path is None:
                raise RuntimeError(
                    "Warmup image path is required for image-input model"
                )
            req_kwargs["prompt"] = DEFAULT_PLACEHOLDER_PROMPT
            req_kwargs["image_path"] = [warmup_input_path]
        if server_args.enable_cfg_parallel:
            if not req_kwargs.get("negative_prompt"):
                req_kwargs["negative_prompt"] = DEFAULT_PLACEHOLDER_PROMPT
            req_kwargs["do_classifier_free_guidance"] = True
        elif negative_prompt is not None and cfg_scale is not None and cfg_scale > 1.0:
            req_kwargs["do_classifier_free_guidance"] = True

        run_real_path_prewarm = server_based_warmup and server_args.enable_torch_compile
        prompts = (
            (DEFAULT_PLACEHOLDER_PROMPT,) + TORCH_COMPILE_REAL_PATH_PREWARM_PROMPTS
            if run_real_path_prewarm
            else (DEFAULT_PLACEHOLDER_PROMPT,)
        )
        for prompt_idx, prompt in enumerate(prompts):
            prompt_req_kwargs = req_kwargs.copy()
            prompt_req_kwargs["prompt"] = prompt
            prompt_req_kwargs["sampling_params"] = copy(req_kwargs["sampling_params"])
            req = Req(**prompt_req_kwargs)
            if not run_real_path_prewarm or prompt_idx == 0:
                req.set_as_warmup(warmup_steps)
            else:
                req.sampling_params.num_inference_steps = warmup_steps
                req.save_output = False
                req.suppress_logs = True
                req.metrics.suppress_stage_breakdown = True
                req.extra["server_internal_prewarm"] = True
            req.sampling_params.prepare_synthetic_warmup_request_for_queue(
                req, server_args
            )
            if return_warmup_result:
                req.extra["return_warmup_result"] = True
            if server_based_warmup:
                req.extra["server_based_warmup"] = True
            warmup_reqs.append(req)

    return warmup_reqs
