# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from: https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/ernie_image/pipeline_ernie_image.py

import json
import os
from collections.abc import Callable, Iterable

import torch
import torch.nn as nn
from diffusers.image_processor import VaeImageProcessor
from diffusers.models.autoencoders.autoencoder_kl_flux2 import AutoencoderKLFlux2
from diffusers.schedulers.scheduling_flow_match_euler_discrete import (
    FlowMatchEulerDiscreteScheduler,
)
from diffusers.utils.torch_utils import randn_tensor
from transformers import AutoModel, AutoModelForCausalLM, AutoTokenizer
from vllm.logger import init_logger

from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.ernie_image.ernie_image_transformer import ErnieImageTransformer2DModel
from vllm_omni.diffusion.models.interface import SupportImageInput
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.utils.tf_utils import get_transformer_config_kwargs
from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific

logger = init_logger(__name__)


def _resolve_model_path_for_optional_pe(model: str, revision: str | None) -> str:
    if os.path.exists(model):
        return model
    try:
        return download_weights_from_hf_specific(model, revision, ["pe/*", "pe_tokenizer/*"])
    except Exception as e:
        logger.debug("Failed to resolve ERNIE-Image PE files for %s: %s", model, e)
        return model


def get_ernie_image_post_process_func(od_config: OmniDiffusionConfig):
    if od_config.output_type == "latent":
        return lambda x: x
    model_name = od_config.model
    if os.path.exists(model_name):
        model_path = model_name
    else:
        model_path = download_weights_from_hf_specific(model_name, None, ["*"])

    vae_config_path = os.path.join(model_path, "vae/config.json")
    with open(vae_config_path) as f:
        vae_config = json.load(f)
        vae_scale_factor = 2 ** (len(vae_config["block_out_channels"]) - 1) if "block_out_channels" in vae_config else 8

    image_processor = VaeImageProcessor(vae_scale_factor=vae_scale_factor * 2, do_normalize=False)

    def post_process_func(images: torch.Tensor):
        images = (images.clamp(-1, 1) + 1) / 2
        return image_processor.postprocess(images)

    return post_process_func


class ErnieImagePipeline(
    nn.Module, CFGParallelMixin, SupportImageInput, ProgressBarMixin, DiffusionPipelineProfilerMixin
):
    support_image_input = False

    def __init__(
        self,
        *,
        od_config: OmniDiffusionConfig,
        prefix: str = "",
        is_distilled: bool = False,
    ):
        super().__init__()
        self.od_config = od_config
        self.is_distilled = is_distilled
        self.weights_sources = [
            DiffusersPipelineLoader.ComponentSource(
                model_or_path=od_config.model,
                subfolder="transformer",
                revision=None,
                prefix="transformer.",
                fall_back_to_pt=True,
            )
        ]

        self._execution_device = get_local_device()
        model = od_config.model
        logger.info("Model path for initialization: %s", model)
        local_files_only = os.path.exists(model)
        logger.info("Local files only: %s", local_files_only)

        self.scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(
            model,
            subfolder="scheduler",
            local_files_only=local_files_only,
        )

        self.text_encoder = AutoModel.from_pretrained(
            model,
            subfolder="text_encoder",
            torch_dtype=od_config.dtype,
            local_files_only=local_files_only,
        ).to(self._execution_device)
        self.tokenizer = AutoTokenizer.from_pretrained(
            model,
            subfolder="tokenizer",
            local_files_only=local_files_only,
        )

        self.vae = AutoencoderKLFlux2.from_pretrained(
            model,
            subfolder="vae",
            torch_dtype=od_config.dtype,
            local_files_only=local_files_only,
        ).to(self._execution_device)

        # Load PE (Prompt Enhancement) model if available. For repo IDs,
        # resolve/download only PE files first so the existence check works.
        pe_base_path = _resolve_model_path_for_optional_pe(model, getattr(od_config, "revision", None))
        pe_model_path = os.path.join(pe_base_path, "pe")
        if os.path.exists(pe_model_path):
            try:
                self.pe_model = AutoModelForCausalLM.from_pretrained(
                    pe_model_path,
                    torch_dtype=od_config.dtype,
                    local_files_only=True,
                    trust_remote_code=True,
                ).to(self._execution_device)
                self.pe_tokenizer = AutoTokenizer.from_pretrained(
                    pe_base_path,
                    subfolder="pe_tokenizer",
                    local_files_only=True,
                    trust_remote_code=True,
                    use_fast=False,
                )
                self.use_pe = True
                logger.info("Loaded PE model from %s", pe_model_path)
            except Exception as e:
                logger.warning("Failed to load PE model: %s", e)
                self.pe_model = None
                self.pe_tokenizer = None
                self.use_pe = False
        else:
            self.pe_model = None
            self.pe_tokenizer = None
            self.use_pe = False

        transformer_kwargs = get_transformer_config_kwargs(od_config.tf_model_config, ErnieImageTransformer2DModel)
        self.transformer = ErnieImageTransformer2DModel(
            quant_config=od_config.quantization_config, **transformer_kwargs
        )

        self.vae_scale_factor = 2 ** (len(self.vae.config.block_out_channels)) if getattr(self, "vae", None) else 16
        self.image_processor = VaeImageProcessor(vae_scale_factor=self.vae_scale_factor)
        self.tokenizer_max_length = 512
        self.default_sample_size = 128

        self._guidance_scale = None
        self._attention_kwargs = None
        self._num_timesteps = None
        self._current_timestep = None
        self._interrupt = False
        self.setup_diffusion_pipeline_profiler(
            enable_diffusion_pipeline_profiler=self.od_config.enable_diffusion_pipeline_profiler
        )

    @staticmethod
    def _distributed_prompt_sync_state() -> tuple[bool, int]:
        try:
            if not torch.distributed.is_available() or not torch.distributed.is_initialized():
                return False, 0
            if torch.distributed.get_world_size() <= 1:
                return False, 0
            return True, torch.distributed.get_rank()
        except Exception:
            return False, 0

    @staticmethod
    def _broadcast_enhanced_prompt(prompt: str | None) -> str | None:
        values = [prompt]
        torch.distributed.broadcast_object_list(values, src=0)
        return values[0]

    def _enhance_prompt(
        self,
        prompt: str,
        device: torch.device,
        width: int = 1024,
        height: int = 1024,
        temperature: float = 0.6,
        top_p: float = 0.95,
    ) -> str:
        if not self.use_pe or self.pe_model is None:
            return prompt

        sync_prompt, rank = self._distributed_prompt_sync_state()
        if sync_prompt and rank != 0:
            try:
                enhanced = self._broadcast_enhanced_prompt(None)
                return enhanced if enhanced else prompt
            except Exception as e:
                logger.warning("PE enhancement broadcast failed: %s", e)
                return prompt

        try:
            user_content = json.dumps(
                {"prompt": prompt, "width": width, "height": height},
                ensure_ascii=False,
            )
            messages = [{"role": "user", "content": user_content}]
            input_text = self.pe_tokenizer.apply_chat_template(
                messages,
                tokenize=False,
                add_generation_prompt=False,
            )
            inputs = self.pe_tokenizer(input_text, return_tensors="pt").to(device)
            output_ids = self.pe_model.generate(
                **inputs,
                max_new_tokens=512,
                do_sample=temperature != 1.0 or top_p != 1.0,
                temperature=temperature,
                top_p=top_p,
                pad_token_id=self.pe_tokenizer.pad_token_id,
                eos_token_id=self.pe_tokenizer.eos_token_id,
            )
            output_ids = output_ids[0][inputs.input_ids.shape[1] :]
            result = self.pe_tokenizer.decode(output_ids, skip_special_tokens=True).strip()
            enhanced = result if result else prompt
        except Exception as e:
            logger.warning("PE enhancement failed: %s", e)
            enhanced = prompt

        if sync_prompt:
            try:
                synced = self._broadcast_enhanced_prompt(enhanced)
                return synced if synced else prompt
            except Exception as e:
                logger.warning("PE enhancement broadcast failed: %s", e)
        return enhanced

    @staticmethod
    def _is_warmup_request(req: OmniDiffusionRequest) -> bool:
        is_dummy_run = getattr(req, "is_dummy_run", None)
        if callable(is_dummy_run):
            return bool(is_dummy_run())
        return OmniDiffusionRequest.is_dummy_run_request_id(getattr(req, "request_id", None))

    @staticmethod
    def _should_apply_pe(req: OmniDiffusionRequest) -> bool:
        if ErnieImagePipeline._is_warmup_request(req):
            return False
        extra_args = getattr(req.sampling_params, "extra_args", {}) or {}
        return bool(extra_args.get("apply_pe", True))

    def encode_prompt(
        self,
        prompt: str | list[str],
        device: torch.device,
        num_images_per_prompt: int = 1,
        width: int = 1024,
        height: int = 1024,
        apply_pe: bool = True,
    ) -> list[torch.Tensor]:
        if isinstance(prompt, str):
            prompt = [prompt]

        text_hiddens = []

        for p in prompt:
            if apply_pe and self.use_pe and self.pe_model is not None:
                enhanced = self._enhance_prompt(p, device, width=width, height=height)
                logger.info("PE: original='%s...' enhanced='%s...'", p[:50], enhanced[:50])
                p = enhanced
            ids = self.tokenizer(
                p,
                add_special_tokens=True,
                truncation=True,
                padding=False,
            )["input_ids"]

            if len(ids) == 0:
                if self.tokenizer.bos_token_id is not None:
                    ids = [self.tokenizer.bos_token_id]
                else:
                    ids = [0]

            input_ids = torch.tensor([ids], device=device)
            with torch.no_grad():
                outputs = self.text_encoder(input_ids=input_ids, output_hidden_states=True)
            hidden = outputs.hidden_states[-2][0]

            for _ in range(num_images_per_prompt):
                text_hiddens.append(hidden)

        return text_hiddens

    @staticmethod
    def _patchify_latents(latents: torch.Tensor) -> torch.Tensor:
        b, c, h, w = latents.shape
        latents = latents.view(b, c, h // 2, 2, w // 2, 2)
        latents = latents.permute(0, 1, 3, 5, 2, 4)
        return latents.reshape(b, c * 4, h // 2, w // 2)

    @staticmethod
    def _unpatchify_latents(latents: torch.Tensor) -> torch.Tensor:
        b, c, h, w = latents.shape
        latents = latents.reshape(b, c // 4, 2, 2, h, w)
        latents = latents.permute(0, 1, 4, 2, 5, 3)
        return latents.reshape(b, c // 4, h * 2, w * 2)

    @staticmethod
    def _pad_text(text_hiddens: list[torch.Tensor], device: torch.device, dtype: torch.dtype, text_in_dim: int):
        B = len(text_hiddens)
        if B == 0:
            return torch.zeros((0, 0, text_in_dim), device=device, dtype=dtype), torch.zeros(
                (0,), device=device, dtype=torch.long
            )
        normalized = [
            th.squeeze(1).to(device).to(dtype) if th.dim() == 3 else th.to(device).to(dtype) for th in text_hiddens
        ]
        lens = torch.tensor([t.shape[0] for t in normalized], device=device, dtype=torch.long)
        Tmax = int(lens.max().item())
        text_bth = torch.zeros((B, Tmax, text_in_dim), device=device, dtype=dtype)
        for i, t in enumerate(normalized):
            text_bth[i, : t.shape[0], :] = t
        return text_bth, lens

    @property
    def guidance_scale(self):
        return self._guidance_scale

    @property
    def do_classifier_free_guidance(self):
        return self._guidance_scale is not None and self._guidance_scale > 1 and not self.is_distilled

    @property
    def num_timesteps(self):
        return self._num_timesteps

    @property
    def current_timestep(self):
        return self._current_timestep

    @property
    def interrupt(self):
        return self._interrupt

    def _resize_dimensions(self, height: int, width: int) -> tuple[int, int]:
        resized_height = height - height % self.vae_scale_factor
        resized_width = width - width % self.vae_scale_factor
        return resized_height, resized_width

    def check_inputs(
        self,
        prompt,
        height,
        width,
        prompt_embeds=None,
        callback_on_step_end_tensor_inputs=None,
        guidance_scale=None,
    ):
        if (height is not None and height % self.vae_scale_factor != 0) or (
            width is not None and width % self.vae_scale_factor != 0
        ):
            resized_height, resized_width = self._resize_dimensions(height, width)
            logger.warning(
                "`height` and `width` have to be divisible by %s "
                "but are %s and %s. Dimensions will be resized to %s and %s accordingly",
                self.vae_scale_factor,
                height,
                width,
                resized_height,
                resized_width,
            )

        if callback_on_step_end_tensor_inputs is not None and not all(
            k in ["latents", "prompt_embeds"] for k in callback_on_step_end_tensor_inputs
        ):
            raise ValueError("`callback_on_step_end_tensor_inputs` must be a subset of ['latents', 'prompt_embeds'].")

        if prompt is not None and prompt_embeds is not None:
            raise ValueError("Cannot forward both `prompt` and `prompt_embeds`. Please forward only one.")
        if prompt is None and prompt_embeds is None:
            raise ValueError("Must provide either `prompt` or `prompt_embeds`.")

    def forward(
        self,
        req: OmniDiffusionRequest,
        prompt: str | list[str] | None = None,
        negative_prompt: str | list[str] | None = "",
        height: int = 1024,
        width: int = 1024,
        num_inference_steps: int = 50,
        guidance_scale: float = 4.0,
        num_images_per_prompt: int = 1,
        generator: torch.Generator | None = None,
        latents: torch.Tensor | None = None,
        prompt_embeds: list[torch.FloatTensor] | None = None,
        negative_prompt_embeds: list[torch.FloatTensor] | None = None,
        output_type: str = "pil",
        return_dict: bool = True,
        callback_on_step_end: Callable[[int, int, dict], None] | None = None,
        callback_on_step_end_tensor_inputs: list[str] = ["latents"],
    ) -> DiffusionOutput:
        if len(req.prompts) > 1:
            logger.warning("This model only supports a single prompt. Taking only the first.")
        first_prompt = req.prompts[0]
        prompt = first_prompt if isinstance(first_prompt, str) else (first_prompt.get("prompt") or "")

        height = req.sampling_params.height or height
        width = req.sampling_params.width or width
        num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps
        guidance_scale = (
            req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale
        )
        generator = req.sampling_params.generator or generator
        num_images_per_prompt = (
            req.sampling_params.num_outputs_per_prompt
            if req.sampling_params.num_outputs_per_prompt > 0
            else num_images_per_prompt
        )

        if prompt is not None and isinstance(prompt, str):
            batch_size = 1
        elif prompt is not None and isinstance(prompt, list):
            batch_size = len(prompt)
        else:
            batch_size = prompt_embeds.shape[0]

        device = self._execution_device
        dtype = self.transformer.dtype

        self._guidance_scale = guidance_scale
        self._current_timestep = None
        self._interrupt = False

        self.check_inputs(prompt=prompt, height=height, width=width, prompt_embeds=prompt_embeds)
        height, width = self._resize_dimensions(height, width)

        if prompt is not None:
            if isinstance(prompt, str):
                prompt = [prompt]

        if prompt_embeds is not None:
            text_hiddens = prompt_embeds
        else:
            text_hiddens = self.encode_prompt(
                prompt,
                device,
                num_images_per_prompt,
                width=width,
                height=height,
                apply_pe=self._should_apply_pe(req),
            )

        if self.do_classifier_free_guidance:
            if negative_prompt_embeds is not None:
                uncond_text_hiddens = negative_prompt_embeds
            else:
                if negative_prompt is None:
                    negative_prompt = ""
                if isinstance(negative_prompt, str):
                    negative_prompt = [negative_prompt] * batch_size
                uncond_text_hiddens = self.encode_prompt(
                    negative_prompt, device, num_images_per_prompt, width=width, height=height, apply_pe=False
                )

        latent_h = height // self.vae_scale_factor
        latent_w = width // self.vae_scale_factor
        latent_channels = self.transformer.config.in_channels

        if latents is None:
            latents = randn_tensor(
                (batch_size * num_images_per_prompt, latent_channels, latent_h, latent_w),
                generator=generator,
                device=device,
                dtype=dtype,
            )

        sigmas = torch.linspace(1.0, 0.0, num_inference_steps + 1)
        self.scheduler.set_timesteps(sigmas=sigmas[:-1], device=device)

        if self.do_classifier_free_guidance:
            cfg_text_hiddens = list(uncond_text_hiddens) + list(text_hiddens)
        else:
            cfg_text_hiddens = text_hiddens

        text_bth, text_lens = self._pad_text(
            text_hiddens=cfg_text_hiddens, device=device, dtype=dtype, text_in_dim=self.transformer.config.text_in_dim
        )

        with self.progress_bar(total=num_inference_steps) as progress_bar:
            for i, t in enumerate(self.scheduler.timesteps):
                if self.interrupt:
                    continue

                self._current_timestep = t

                if self.do_classifier_free_guidance:
                    latent_model_input = torch.cat([latents, latents], dim=0)
                    t_batch = torch.full(
                        (batch_size * num_images_per_prompt * 2,), t.item(), device=device, dtype=dtype
                    )
                else:
                    latent_model_input = latents
                    t_batch = torch.full((batch_size * num_images_per_prompt,), t.item(), device=device, dtype=dtype)

                pred = self.transformer(
                    hidden_states=latent_model_input,
                    timestep=t_batch,
                    text_bth=text_bth,
                    text_lens=text_lens,
                    return_dict=False,
                )[0]

                if self.do_classifier_free_guidance:
                    pred_uncond, pred_cond = pred.chunk(2, dim=0)
                    pred = pred_uncond + guidance_scale * (pred_cond - pred_uncond)

                latents = self.scheduler.step(pred, t, latents, return_dict=False)[0]

                if callback_on_step_end is not None:
                    callback_kwargs = {k: locals()[k] for k in callback_on_step_end_tensor_inputs}
                    callback_outputs = callback_on_step_end(self, i, t, callback_kwargs)
                    latents = callback_outputs.pop("latents", latents)

                progress_bar.update()

        self._current_timestep = None

        if output_type == "latent":
            return DiffusionOutput(output=latents)

        bn_mean = self.vae.bn.running_mean.view(1, -1, 1, 1).to(device)
        bn_std = torch.sqrt(self.vae.bn.running_var.view(1, -1, 1, 1) + self.vae.config.batch_norm_eps).to(device)

        latents = latents * bn_std + bn_mean

        latents = self._unpatchify_latents(latents)

        images = self.vae.decode(latents, return_dict=False)[0]

        return DiffusionOutput(
            output=images, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None
        )

    def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
        loaded_weights = set()
        transformer_weights = (
            (name.replace("transformer.", "", 1), weight) for name, weight in weights if name.startswith("transformer.")
        )
        loaded_weights |= {f"transformer.{name}" for name in self.transformer.load_weights(transformer_weights)}
        loaded_weights |= {f"vae.{name}" for name, _ in self.vae.named_parameters()}
        loaded_weights |= {f"text_encoder.{name}" for name, _ in self.text_encoder.named_parameters()}
        return loaded_weights
