"""UniPC Multistep Scheduler for MLX.

Simplified port of diffusers UniPCMultistepScheduler for inference only.
Implements the unified predictor-corrector framework.
"""

import math
from dataclasses import dataclass, field
from typing import List, Optional

import mlx.core as mx
import numpy as np


@dataclass
class SchedulerConfig:
    num_train_timesteps: int = 1000
    beta_start: float = 0.00085
    beta_end: float = 0.012
    beta_schedule: str = "scaled_linear"
    solver_order: int = 2
    prediction_type: str = "v_prediction"
    timestep_spacing: str = "trailing"
    rescale_betas_zero_snr: bool = True


class UniPCMultistepSchedulerMLX:
    """UniPC multistep scheduler for MLX inference.

    Supports 'trailing' timestep spacing and 'epsilon' prediction type,
    matching the HunyuanPaintPBR configuration.
    """

    def __init__(self, config: Optional[SchedulerConfig] = None):
        if config is None:
            config = SchedulerConfig()
        self.config = config

        # Compute beta schedule
        betas = np.linspace(
            config.beta_start ** 0.5,
            config.beta_end ** 0.5,
            config.num_train_timesteps,
            dtype=np.float64,
        ) ** 2

        alphas = 1.0 - betas
        self.alphas_cumprod = np.cumprod(alphas)

        # Rescale for zero terminal SNR. PT's rescale_zero_terminal_snr
        # shifts AND scales the entire sqrt(alpha_cumprod) curve so that
        # it starts at the original sqrt(alpha_cumprod[0]) and terminates
        # EXACTLY at 0 — not a single-point clamp. Shifting the whole
        # curve changes the SNR at EVERY timestep, not just the last one.
        # Previously we were clamping only the terminal point, which left
        # every intermediate step using the un-rescaled schedule and
        # produced visibly desaturated outputs (the model's denoising
        # targets were shifted off its training distribution).
        if config.rescale_betas_zero_snr:
            alphas_bar_sqrt = np.sqrt(self.alphas_cumprod)
            sqrt_0 = alphas_bar_sqrt[0].copy()
            sqrt_T = alphas_bar_sqrt[-1].copy()
            alphas_bar_sqrt = alphas_bar_sqrt - sqrt_T
            alphas_bar_sqrt = alphas_bar_sqrt * sqrt_0 / (sqrt_0 - sqrt_T)
            self.alphas_cumprod = alphas_bar_sqrt ** 2
            # Avoid exact 0 at terminal (would make lambda_t -> -inf).
            self.alphas_cumprod[-1] = max(self.alphas_cumprod[-1], 2 ** -24)

        # Precompute for signal/noise ratio
        self.alpha_t = np.sqrt(self.alphas_cumprod)
        self.sigma_t = np.sqrt(1.0 - self.alphas_cumprod)
        self.lambda_t = np.log(self.alpha_t / np.maximum(self.sigma_t, 1e-12))

        self.num_inference_steps = None
        self.timesteps = None
        self._step_index = 0
        self.model_outputs: List[Optional[mx.array]] = []

    def set_timesteps(self, num_inference_steps: int):
        """Set the discrete timesteps for inference."""
        self.num_inference_steps = num_inference_steps

        if self.config.timestep_spacing == "trailing":
            step_ratio = self.config.num_train_timesteps / num_inference_steps
            timesteps = np.round(
                np.arange(self.config.num_train_timesteps, 0, -step_ratio)
            ).astype(np.int64) - 1
        else:
            # linspace fallback
            timesteps = np.linspace(
                self.config.num_train_timesteps - 1, 0, num_inference_steps
            ).round().astype(np.int64)

        self.timesteps = timesteps
        self.model_outputs = [None] * self.config.solver_order
        self._step_index = 0

    def scale_model_input(self, sample: mx.array, timestep: int) -> mx.array:
        """Scale input for the model (identity for UniPC)."""
        return sample

    def step(
        self, model_output: mx.array, timestep: int, sample: mx.array
    ) -> mx.array:
        """DDIM-style step with v_prediction.

        Proper DDIM in alpha/sigma parameterization:
          x0_pred = alpha_t * x_t - sigma_t * v_pred
          eps_pred = sigma_t * x_t + alpha_t * v_pred
          x_next = alpha_next * x0_pred + sigma_next * eps_pred

        Matches what diffusers DDIM does with v_prediction + zero-SNR
        (``rescale_betas_zero_snr=True`` makes sigma_t -> 1 at t=T, so
        initial latents don't need sigma_max rescaling).
        """
        t = int(timestep)
        step_idx = self._step_index

        if step_idx + 1 < len(self.timesteps):
            t_next = int(self.timesteps[step_idx + 1])
        else:
            t_next = -1  # sentinel for "past end"

        alpha_t = float(self.alpha_t[t])
        sigma_t = float(self.sigma_t[t])

        if t_next >= 0:
            alpha_next = float(self.alpha_t[t_next])
            sigma_next = float(self.sigma_t[t_next])
        else:
            # End of trajectory: go to clean x0
            alpha_next = 1.0
            sigma_next = 0.0

        if self.config.prediction_type == "epsilon":
            eps_pred = model_output
            x0_pred = (sample - sigma_t * eps_pred) / max(alpha_t, 1e-8)
        elif self.config.prediction_type == "v_prediction":
            x0_pred = alpha_t * sample - sigma_t * model_output
            eps_pred = sigma_t * sample + alpha_t * model_output
        else:
            x0_pred = model_output
            eps_pred = (sample - alpha_t * x0_pred) / max(sigma_t, 1e-8)

        x_next = alpha_next * x0_pred + sigma_next * eps_pred

        # Keep the x0 prediction buffer fresh (used for optional multistep
        # correction in a future upgrade; single-step DDIM ignores it).
        self.model_outputs = self.model_outputs[1:] + [x0_pred]

        self._step_index += 1
        return x_next
