# Copyright (c) ModelScope Contributors. All rights reserved.
import datasets
import functools
import ipaddress
import math
import os
import re
import socket
import time
import torch
import torch.nn.functional as F
from contextlib import contextmanager, nullcontext
from dataclasses import asdict
from datetime import timedelta
from functools import partial
from io import BytesIO
from msgspec import field
from packaging import version
from peft.tuners.lora import LoraLayer
from PIL import Image
from pydantic import BaseModel, field_validator
from torch import nn
from torch.utils.data import DataLoader, RandomSampler
from transformers.utils import is_torch_npu_available
from types import MethodType
from typing import Any, Dict, Iterable, List, Optional, Tuple, TypeVar, Union

from swift.rl_core.data import GRPOBatch, OnPolicySample
from swift.template import Messages, Template
from swift.tuners.lora import LoraConfig
from swift.utils import (gc_collect, get_cu_seqlens_from_position_ids, get_logger, get_packed_seq_params,
                         get_torch_device, is_swanlab_available, is_vllm_available, is_wandb_available, swanlab_get_run,
                         synchronize, to_device)

if is_wandb_available():
    import wandb
if is_swanlab_available():
    import swanlab

T = TypeVar('T')

_ipv6_patch_applied = False

# Constants for the RL training LoRA adapter identity.
VLLM_LORA_INT_ID = 111
VLLM_LORA_NAME = 'swift_lora'
VLLM_LORA_PATH = 'swift_dummy_lora_path'


def broadcast_tensor_for_vllm_weight_sync(communicator, tensor: torch.Tensor, src: int) -> None:
    if is_torch_npu_available():
        device_module = get_torch_device()
        with device_module.device(communicator.device):
            communicator.broadcast(tensor, src=src, stream=device_module.current_stream())
    else:
        communicator.broadcast(tensor, src=src, stream=getattr(get_torch_device(), 'current_stream', lambda: None)())


if is_vllm_available():
    from vllm.lora.request import LoRARequest

    class TensorLoRARequest(LoRARequest):
        peft_config: dict = field(default=None)
        lora_tensors: dict = field(default=None)
        lora_embeddings: Optional[Dict[str, torch.Tensor]] = None

        @property
        def config(self):
            return self.peft_config

        @property
        def embeddings(self):
            return self.lora_embeddings
else:
    TensorLoRARequest = None


def chunk_list(lst: list, n: int) -> list[list]:
    """
    Split list `lst` into `n` evenly distributed sublists.

    Example:
    ```python
    >>> chunk_list([1, 2, 3, 4, 5, 6], 2)
    [[1, 2, 3], [4, 5, 6]]

    >>> chunk_list([1, 2, 3, 4, 5, 6], 4)
    [[1, 2], [3, 4], [5], [6]]

    >>> chunk_list([1, 2, 3, 4, 5, 6], 8)
    [[1], [2], [3], [4], [5], [6], [], []]
    ```
    """
    k, r = divmod(len(lst), n)
    return [lst[i * k + min(i, r):(i + 1) * k + min(i + 1, r)] for i in range(n)]


def is_valid_ipv6_address(address: str) -> bool:
    """Check if the given address is a valid IPv6 address."""
    try:
        ipaddress.IPv6Address(address)
        return True
    except ValueError:
        return False


def format_host_for_url(host: str) -> str:
    """Format host for URL - wrap IPv6 addresses in brackets."""
    if is_valid_ipv6_address(host):
        return f'[{host}]'
    return host


def resolve_hostname(hostname: str) -> str:
    """Resolve hostname to IP address, supporting both IPv4 and IPv6.

    Uses socket.getaddrinfo() which supports both IPv4 and IPv6,
    unlike socket.gethostbyname() which only supports IPv4.
    """
    # If it's already an IP address (IPv4 or IPv6), return as-is
    try:
        ipaddress.ip_address(hostname)
        return hostname
    except ValueError:
        pass

    # Resolve hostname using getaddrinfo (supports both IPv4 and IPv6)
    try:
        addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
        if addr_info:
            # Return the first resolved address
            return addr_info[0][4][0]
    except socket.gaierror:
        pass

    # Fallback to original hostname if resolution fails
    return hostname


def patch_stateless_process_group_for_ipv6():
    """Apply monkey patch to vLLM's StatelessProcessGroup.create to support IPv6.

    The original implementation hardcodes socket.AF_INET which only supports IPv4.
    This patch detects IPv6 addresses at runtime and uses socket.AF_INET6 accordingly.
    For IPv4 addresses, it falls back to the original implementation.

    This function is idempotent - calling it multiple times is safe.
    """
    global _ipv6_patch_applied

    if _ipv6_patch_applied:
        return

    if not is_vllm_available():
        return

    import inspect
    from vllm.distributed.utils import StatelessProcessGroup

    # vLLM >= 0.19.0: create() accepts listen_socket and handles TCPStore internally
    _has_listen_socket_param = 'listen_socket' in inspect.signature(StatelessProcessGroup.create).parameters

    # Save original method for fallback
    _original_create = StatelessProcessGroup.create

    @staticmethod
    def _patched_stateless_pg_create(
        host: str,
        port: int,
        rank: int,
        world_size: int,
        data_expiration_seconds: int = 3600,
        store_timeout: int = 300,
        **kwargs,
    ) -> StatelessProcessGroup:
        """Patched version of StatelessProcessGroup.create that supports IPv6.

        For IPv4 addresses, falls back to the original implementation.
        """
        # If not IPv6, use original implementation
        if not is_valid_ipv6_address(host):
            return _original_create(
                host=host,
                port=port,
                rank=rank,
                world_size=world_size,
                data_expiration_seconds=data_expiration_seconds,
                store_timeout=store_timeout,
                **kwargs,
            )

        # IPv6 path: create an AF_INET6 socket
        launch_server = rank == 0
        if launch_server:
            listen_socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
            listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            listen_socket.bind((host, port))
            listen_socket.listen()
        else:
            listen_socket = None

        if _has_listen_socket_param:
            # vLLM >= 0.19.0: pass listen_socket to create(), which handles
            # TCPStore creation and returns StatelessProcessGroup without socket field
            kwargs.pop('listen_socket', None)
            return _original_create(
                host=host,
                port=port,
                rank=rank,
                world_size=world_size,
                data_expiration_seconds=data_expiration_seconds,
                store_timeout=store_timeout,
                listen_socket=listen_socket,
                **kwargs,
            )
        else:
            # vLLM < 0.19.0: manually create TCPStore and pass socket to constructor
            from torch.distributed import TCPStore
            listen_fd = listen_socket.fileno() if listen_socket else None
            store = TCPStore(
                host_name=host,
                port=port,
                world_size=world_size,
                is_master=launch_server,
                timeout=timedelta(seconds=store_timeout),
                use_libuv=False,
                master_listen_fd=listen_fd,
            )
            return StatelessProcessGroup(
                rank=rank,
                world_size=world_size,
                store=store,
                socket=listen_socket,
                data_expiration_seconds=data_expiration_seconds,
            )

    # Apply the monkey patch to vLLM
    StatelessProcessGroup.create = _patched_stateless_pg_create

    _ipv6_patch_applied = True


# Apply IPv6 patch at module load time
patch_stateless_process_group_for_ipv6()


# code borrowed from verl/verl/utils/memory_utils.py
def aggressive_empty_cache(force_sync: bool = True, max_retries: int = 3) -> None:
    """
    More aggressive GPU memory cleanup function, tries to release PyTorch reserved
    but unallocated memory.

    Args:
        force_sync: Whether to force device synchronization
        max_retries: Maximum number of retries
    """
    logger = get_logger()

    device = get_torch_device()
    if not hasattr(device, 'is_available') or not device.is_available():
        return

    for attempt in range(max_retries):
        # Record memory status before cleanup
        before_reserved = device.memory_reserved()
        before_allocated = device.memory_allocated()

        # Run garbage collection
        gc_collect()

        # Clear PyTorch cache
        device.empty_cache()

        # Force synchronization (optional)
        if force_sync:
            device.synchronize()

        # Record memory status after cleanup
        after_reserved = device.memory_reserved()
        after_allocated = device.memory_allocated()

        # Calculate freed memory
        reserved_freed = before_reserved - after_reserved
        allocated_freed = before_allocated - after_allocated

        logger.info(f'Memory cleanup attempt {attempt + 1}: Freed {reserved_freed / 1024**3:.2f} GB reserved, '
                    f'{allocated_freed / 1024**3:.2f} GB allocated')

        # Stop retrying if little memory was freed
        if reserved_freed < 1024**3:  # less than 1GB
            break


def prepare_deepspeed(model, accelerator, deepspeed_config=None, deepspeed_plugin=None, training_args=None):
    """
    Prepares the model for DeepSpeed inference or evaluation by initializing it with the appropriate configuration.

    Args:
        model: The model to prepare
        accelerator: The accelerator instance
        deepspeed_config: Optional deepspeed config. If provided, use this instead of accelerator's plugin.
        deepspeed_plugin: Optional DeepSpeedPlugin. If provided, use this instead of accelerator's plugin.
        training_args: Optional training arguments for resolving "auto" values in config

    Returns:
        The prepared DeepSpeed model
    """
    try:
        import deepspeed
        import os
        from accelerate.utils import DeepSpeedPlugin
        from copy import deepcopy
        from transformers.integrations.deepspeed import HfTrainerDeepSpeedConfig
    except ImportError:
        pass

    # Determine which config to use and create HfTrainerDeepSpeedConfig
    if deepspeed_config is not None:
        # Use provided config - need to wrap it with HfTrainerDeepSpeedConfig to handle "auto" values
        if isinstance(deepspeed_config, dict):
            # Create HfTrainerDeepSpeedConfig which will handle "auto" values
            hf_ds_config = HfTrainerDeepSpeedConfig(deepspeed_config)

            # Process the config with training args to resolve "auto" values
            if training_args is not None:
                hf_ds_config.trainer_config_process(training_args)

            # Create a DeepSpeedPlugin with the processed config
            temp_plugin = DeepSpeedPlugin(hf_ds_config=hf_ds_config)
            config_kwargs = deepcopy(temp_plugin.deepspeed_config)
        else:
            raise ValueError(f'deepspeed_config should be a dict, got {type(deepspeed_config)}')
    elif deepspeed_plugin is not None:
        # Use provided plugin
        config_kwargs = deepcopy(deepspeed_plugin.deepspeed_config)
    else:
        # Use accelerator's plugin (default behavior)
        deepspeed_plugin = accelerator.state.deepspeed_plugin
        config_kwargs = deepcopy(deepspeed_plugin.deepspeed_config)

    stage = config_kwargs['zero_optimization']['stage']

    if model is not None:
        hidden_size = (
            max(model.config.hidden_sizes) if getattr(model.config, 'hidden_sizes', None) else getattr(
                model.config, 'hidden_size', None))
        if hidden_size is not None and stage == 3:
            # Note that `stage3_prefetch_bucket_size` can produce DeepSpeed messages like: `Invalidate trace cache
            # @ step 0: expected module 1, but got module 0`
            # This is expected and is not an error, see: https://github.com/microsoft/DeepSpeed/discussions/4081
            config_kwargs.update({
                'zero_optimization.reduce_bucket_size': hidden_size * hidden_size,
                'zero_optimization.stage3_param_persistence_threshold': 10 * hidden_size,
                'zero_optimization.stage3_prefetch_bucket_size': 0.9 * hidden_size * hidden_size,
            })

    # If ZeRO-3 is used, we shard both the active and reference model.
    # Otherwise, we assume the reference model fits in memory and is initialized on each device with ZeRO
    # disabled (stage 0)
    if stage != 3:
        config_kwargs['zero_optimization']['stage'] = 0

    # CRITICAL: Save and clear DeepSpeed-related environment variables before initialization
    # These environment variables (set during student model's DeepSpeed init) can override our config!
    # Reference: https://github.com/microsoft/DeepSpeed/issues/xxxx
    env_vars_to_clear = [
        'DEEPSPEED_ZERO_STAGE',
        'DEEPSPEED_CONFIG',
        'DEEPSPEED_CONFIG_FILE',
    ]
    saved_env = {}
    for env_var in env_vars_to_clear:
        if env_var in os.environ:
            saved_env[env_var] = os.environ[env_var]
            del os.environ[env_var]

    try:
        # Explicitly pass args=None to ensure no args.deepspeed_config interference
        model, *_ = deepspeed.initialize(args=None, model=model, config=config_kwargs)
        model.eval()

    finally:
        # Restore environment variables
        for env_var, value in saved_env.items():
            os.environ[env_var] = value

    return model


@contextmanager
def memory_time_profiling_context(
    name: str = 'Operation',
    enable_profiling: bool = True,
    sync_cuda: bool = True,
    reset_peak_stats: bool = True,
):
    """
    General-purpose memory and time profiling context manager (pure monitoring, no execution).

    Records memory usage and execution time when entering and exiting the context, but does not
    handle any actual model loading/offloading operations.

    Args:
        name: Operation name for logging identification
        enable_profiling: Whether to enable profiling records
        sync_cuda: Whether to synchronize CUDA before recording (ensures accuracy with slight overhead)
        reset_peak_stats: Whether to reset peak memory statistics on exit
    """
    if not enable_profiling:
        yield
        return

    logger = get_logger()

    # ===== Entry phase: Record initial state =====
    if sync_cuda:
        synchronize()

    gc_collect()

    # Record initial memory state
    memory_before = torch.cuda.memory_allocated() / 1024**3  # GiB
    memory_reserved_before = torch.cuda.memory_reserved() / 1024**3
    max_memory_before = torch.cuda.max_memory_allocated() / 1024**3

    logger.info(f'[{name}] Before: '
                f'Allocated = {memory_before:.2f} GiB, '
                f'Reserved = {memory_reserved_before:.2f} GiB, '
                f'Peak = {max_memory_before:.2f} GiB')

    # Start timing
    start_time = time.perf_counter()

    yield

    # Synchronize and clean up memory before measuring (important for offload operations)
    if sync_cuda:
        synchronize()
    gc_collect()

    # ===== Exit phase: Record final state =====
    # Calculate elapsed time (before cleanup to measure actual operation time)
    elapsed_time = time.perf_counter() - start_time

    # Record final memory state
    memory_after = torch.cuda.memory_allocated() / 1024**3
    memory_reserved_after = torch.cuda.memory_reserved() / 1024**3
    peak_memory = torch.cuda.max_memory_allocated() / 1024**3
    memory_change = memory_after - memory_before

    logger.info(f'[{name}] After: '
                f'Allocated = {memory_after:.2f} GiB, '
                f'Reserved = {memory_reserved_after:.2f} GiB, '
                f'Peak = {peak_memory:.2f} GiB, '
                f'Change = {memory_change:+.2f} GiB, '
                f'Time = {elapsed_time:.2f}s')

    # Reset peak memory statistics for next cycle
    if reset_peak_stats and torch.cuda.is_available():
        torch.cuda.reset_peak_memory_stats()


def round_robin(num_reqs, num_workers):
    """Distribute requests evenly across workers using round-robin algorithm.

    Args:
        num_reqs (int): Total number of requests to distribute
        num_workers (int): Number of available workers

    Returns:
        list: A list of lists where each sublist contains the request indices
                assigned to that particular node
    """
    distribution = [[] for _ in range(num_workers)]
    for idx in range(num_reqs):
        worker_id = idx % num_workers
        distribution[worker_id].append(idx)
    return distribution


@contextmanager
def patch_lora_merge(model, parameter_group=None):
    """Patch LoraLayer.merge to support selective merging by ``parameter_group``.

    peft's ``merge_adapter()`` merges the whole model; this patch lets us merge only the
    layers whose name is in ``parameter_group`` (used by the vLLM weight-sync, which merges
    one parameter group at a time within its DeepSpeed Zero3 gather context).

    Before merging, each target adapter's sublayers (lora_A/B, and the DoRA
    ``lora_magnitude_vector``) are aligned to the base-layer device via peft's
    type-agnostic ``_move_adapter_to_device_of_base_layer``. This is correct for
    Linear/Embedding/Conv as well as parameter-based ``ParamWrapper`` (MoE experts via
    ``target_parameters``), whose base layer has no ``.weight`` and which overrides the
    method to use ``get_param().device``. This replaces the previous hand-rolled,
    Linear-only device handling that hard-coded ``base_layer.weight.device``.

    Args:
        model: The PEFT model to patch
        parameter_group: Optional list of parameter names to restrict merging

    Yields:
        The patched model (context manager ensures cleanup)
    """
    from peft.tuners.tuners_utils import check_adapters_to_merge

    def merge(self, safe_merge: bool = False, adapter_names: Optional[list[str]] = None) -> None:
        if parameter_group and all(self.name not in pg for pg in parameter_group):
            return  # Skip if not in target parameter group
        for active_adapter in check_adapters_to_merge(self, adapter_names) or []:
            # Align adapter sublayers (lora_A/B, DoRA magnitude, ...) to the base device.
            # Type-agnostic: ParamWrapper overrides this to use get_param().device.
            self._move_adapter_to_device_of_base_layer(active_adapter)
        return self.merge_origin(safe_merge, adapter_names)

    # Patch all LoraLayer instances
    for name, module in model.named_modules():
        if isinstance(module, LoraLayer):
            module.name = name
            if not hasattr(module, 'merge_origin') and hasattr(module, 'base_layer'):
                module.merge_origin = module.merge
                module.merge = MethodType(merge, module)

    try:
        yield model
    finally:
        # Cleanup: restore original methods
        for module in model.modules():
            if isinstance(module, LoraLayer) and hasattr(module, 'merge_origin'):
                module.merge = module.merge_origin
                del module.merge_origin


@contextmanager
def patch_lora_unmerge(model):
    """Patch LoraLayer.unmerge to align adapter sublayers to the base device first.

    Mirrors ``patch_lora_merge``'s device handling (via peft's type-agnostic
    ``_move_adapter_to_device_of_base_layer``) so unmerge works under DeepSpeed Zero3 /
    offload and for parameter-based ``ParamWrapper`` layers.
    """

    def unmerge_patched(self):
        if not self.merged:
            return
        # Move adapter sublayers (incl. DoRA magnitude) to the base device first
        for adapter in list(self.merged_adapters):
            self._move_adapter_to_device_of_base_layer(adapter)
        return self.unmerge_origin()

    for module in model.modules():
        if isinstance(module, LoraLayer) and not hasattr(module, 'unmerge_origin'):
            module.unmerge_origin = module.unmerge
            module.unmerge = MethodType(unmerge_patched, module)

    try:
        yield model
    finally:
        for module in model.modules():
            if isinstance(module, LoraLayer) and hasattr(module, 'unmerge_origin'):
                module.unmerge = module.unmerge_origin
                del module.unmerge_origin


@contextmanager
def profiling_context(trainer, name: str):
    start_time = time.perf_counter()
    yield
    end_time = time.perf_counter()
    duration = end_time - start_time

    if trainer is None:
        return

    profiling_metrics = {f'profiling/Time taken: {trainer.__class__.__name__}.{name}': duration}

    is_main_process = False
    if hasattr(trainer, 'accelerator'):
        is_main_process = trainer.accelerator.is_main_process
    elif hasattr(trainer, 'is_main_process'):
        is_main_process = trainer.is_main_process

    if 'wandb' in trainer.args.report_to and wandb.run is not None and is_main_process:
        wandb.log(profiling_metrics, commit=False)

    if 'swanlab' in trainer.args.report_to and swanlab_get_run() is not None and is_main_process:
        swanlab.log(profiling_metrics)


def profiling_decorator(func):

    @functools.wraps(func)
    def wrapper(self, *args, **kwargs):
        with profiling_context(self, func.__name__):
            return func(self, *args, **kwargs)

    return wrapper


class _ForwardRedirection:
    """Implements the `forward-redirection`.
    Taken from Pytorch-lightning:
    https://github.com/Lightning-AI/pytorch-lightning/blob/02311d03fb982560246eead7c08104481fac9579/src/lightning/pytorch/strategies/strategy.py#L602
    A method call to a wrapped module gets rerouted through the wrapper's `forward` method instead.
    """

    def __call__(self, wrapper_module: nn.Module, original_module: nn.Module, method: callable, *args: Any,
                 **kwargs: Any):
        """Reroutes a method call through the `wrapper_module`'s `forward` method.
        Args:
            wrapper_module: The module that has `original_module` wrapped.
            original_module: The module that was wrapped inside `wrapper_module`.
            method_name: The name of the method that should be called on the `original_module` after inputs get
                redirected through the `wrapper_module`'s `forward` method.
            *args: The positional arguments to the method `method_name`. They will get passed to a patched
                `forward` method instead.
            **kwargs: The keyword arguments to the method `method_name`. They will get passed to a patched
                `forward` method instead.
        """
        original_forward = original_module.forward

        def wrapped_forward(*_args: Any, **_kwargs: Any) -> Any:
            # Unpatch ourselves immediately before calling the method `method_name`
            # because itself may want to call the real `forward`
            original_module.forward = original_forward  # type: ignore[method-assign]
            # Call the actual method e.g. `.training_step(...)`
            out = method(*_args, **_kwargs)
            self.on_after_inner_forward(wrapper_module, original_module)
            return out

        # Patch the original_module's forward so we can redirect the arguments back to the real method
        original_module.forward = wrapped_forward  # type: ignore[method-assign]

        wrapper_output = wrapper_module(*args, **kwargs)
        self.on_after_outer_forward(wrapper_module, original_module)
        return wrapper_output

    def on_after_inner_forward(self, wrapper_module: nn.Module, original_module: nn.Module) -> None:
        pass

    def on_after_outer_forward(self, wrapper_module: nn.Module, original_module: nn.Module) -> None:
        pass


def entropy_from_logits(logits, chunk_size: int = 1) -> torch.Tensor:
    """
    Compute the Shannon entropy (in nats) for each row of *logits* without
    materialising the full soft-max in memory.
    The batch dimension is processed in chunks of size `chunk_size` so that
    only a subset of rows is expanded to probabilities at any one time.
    Args:
        logits (`torch.Tensor`):
            Logits tensor of shape `(..., num_classes)`. Entropy is taken along the last axis; all
            leading dimensions are preserved.
        chunk_size (`int`, *optional*, defaults to `1`):
            Number of rows to process per iteration.
    Returns:
        `torch.Tensor`:
            Entropy values with shape `logits.shape[:-1]`.
    """
    per_token_entropies = []
    for logits_chunk in logits.split(chunk_size, dim=0):
        logps = F.log_softmax(logits_chunk, dim=-1)
        chunk_entropy = -(torch.exp(logps) * logps).sum(-1)
        per_token_entropies.append(chunk_entropy)
    return torch.cat(per_token_entropies, dim=0)


def load_pil_img(img) -> Image:
    if isinstance(img, (list, tuple)):
        if len(img) == 1:
            img = img[0]
        else:
            raise ValueError('Image list must contain a single image.')

    if isinstance(img, Image.Image):
        return img
    if isinstance(img, str):
        return Image.open(img)

    if not isinstance(img, dict):
        raise ValueError("Image must be a PIL Image, a file path, or a dictionary with 'bytes' or 'path' key.")

    if 'bytes' in img and img['bytes'] is not None:
        return Image.open(BytesIO(img['bytes']))
    elif 'path' in img and img['path'] is not None:
        return Image.open(img['path'])
    else:
        raise ValueError("Image dictionary must contain either 'bytes' or 'path' key.")


def get_response_prefix_ids(template: Template, sample_enable_thinking: Optional[bool] = None) -> Optional[List[int]]:
    effective = sample_enable_thinking if sample_enable_thinking is not None else template.enable_thinking
    if effective is True:
        prefix_str = template.template_meta.thinking_prefix
    elif effective is False:
        prefix_str = template.template_meta.non_thinking_prefix
    else:
        return None
    if prefix_str:
        return template.tokenizer.encode(prefix_str, add_special_tokens=False)
    return None


def encode_sample(sample: OnPolicySample, template: Template, *, encode_prompt_only: bool = False) -> Dict[str, Any]:
    """Encode a sample into a template.encode output dict.

    Does NOT mutate ``sample.messages`` — works on a copy from
    ``to_template_dict()`` so the sample's original messages are preserved
    for logging / reward computation / reuse across steps_per_generation.

    Per-sample ``enable_thinking``: the response prefix (thinking or
    non-thinking) is computed per-sample from
    ``sample.extra['chat_template_kwargs']['enable_thinking']``, falling back
    to the template's global setting.  This keeps the trainer sequence
    aligned with the rollout sequence for both thinking and non-thinking
    prefixes.
    """
    data = sample.to_template_dict()
    if sample.response_token_ids:
        loss_mask = sample.response_loss_mask or None
        msgs = data.get('messages')
        if msgs is not None:
            msgs = [m.copy() for m in msgs]
        ctk = sample.extra.get('chat_template_kwargs') or {}
        sample_et = ctk.get('enable_thinking')
        prefix_ids = get_response_prefix_ids(template, sample_enable_thinking=sample_et)
        data['messages'] = replace_assistant_response_with_ids(
            msgs, sample.response_token_ids, loss_mask, non_thinking_prefix_ids=prefix_ids)

    if encode_prompt_only:
        messages = data.get('messages', [])
        if messages and messages[-1].get('role') == 'assistant':
            data = {**data, 'messages': messages[:-1] + [{**messages[-1], 'content': None}]}

    encoded = template.encode(data, return_length=True)
    return encoded


def replace_assistant_response_with_ids(messages: 'Messages',
                                        completion_ids: List[Union[int, List[int]]],
                                        loss_mask: Optional[List[List[int]]] = None,
                                        non_thinking_prefix_ids: Optional[List[int]] = None) -> 'Messages':  # noqa
    """
    Replace assistant messages in a conversation with token IDs (and optional loss masks).

    This function traverses the messages in reverse order and replaces the content of
    assistant-role messages with the given `completion_ids`. If `loss_mask` is provided,
    each assistant message content will be replaced by a dictionary containing both the
    token IDs and the corresponding loss mask.

    Args:
        messages:
            List of message dictionaries representing a conversation history.
        completion_ids:
            Either:
              - A single list of token IDs, e.g. [1, 2, 3]
              - A list of completion sequences, e.g. [[1, 2], [3, 4]]
        loss_mask (optional):
            Loss mask(s) aligned with `completion_ids`.
            Must satisfy:
              - Same outer length as `completion_ids`
              - Each inner list has the same length as the corresponding completion_ids sequence
            Example:
              completion_ids = [[1, 2], [3, 4]]
              loss_mask      = [[1, 1], [1, 0]]

    Returns:
        The modified messages list, where assistant responses are replaced by:
          - A list of token IDs if `loss_mask` is None
          - A dict with keys:
              - "input_ids": List[int]
              - "loss_scale": List[int]
            if `loss_mask` is provided.

    Example:
        >>> messages = [
        ...     {"role": "user", "content": "Hello"},
        ...     {"role": "assistant", "content": "Hi there"}
        ... ]
        >>> replace_assistant_response_with_ids(messages, [1, 2, 3])
        [{'role': 'user', 'content': 'Hello'},
         {'role': 'assistant', 'content': [1, 2, 3]}]

        >>> replace_assistant_response_with_ids(messages,
        ...     completion_ids=[[1, 2, 3]],
        ...     loss_mask=[[1, 1, 0]])
        [{'role': 'user', 'content': 'Hello'},
         {'role': 'assistant', 'content': {'input_ids': [1, 2, 3], 'loss_scale': [1, 1, 0]}}]
    """
    # Normalize input to always be list of lists
    if isinstance(completion_ids[0], int):
        completion_ids = [completion_ids]
    if loss_mask and isinstance(loss_mask[0], int):
        loss_mask = [loss_mask]

    # Inject the non-thinking prefix (e.g. '<think>\n\n</think>\n\n') into the LAST assistant turn.
    # When enable_thinking false, the engine prepends non_thinking_prefix before generation
    # so completion_ids here are generated with the non-thinking prefix, inject here
    if non_thinking_prefix_ids:
        n_prefix = len(non_thinking_prefix_ids)
        last_ids = list(completion_ids[-1])
        # Skip if the response already starts with the prefix (avoid double injection).
        if last_ids[:n_prefix] != list(non_thinking_prefix_ids):
            if loss_mask is None:
                loss_mask = [[1] * len(ids) for ids in completion_ids]
            completion_ids[-1] = list(non_thinking_prefix_ids) + last_ids
            loss_mask[-1] = [0] * n_prefix + list(loss_mask[-1])

    if loss_mask:
        assert (
            len(completion_ids) == len(loss_mask)
            and all(len(ids) == len(mask) for ids, mask in zip(completion_ids, loss_mask))
        ), f'completion_ids and loss_mask must have the same length, but got {len(completion_ids)} and {len(loss_mask)}'

    remaining_completions = len(completion_ids)
    completion_index = 0

    for message in reversed(messages):
        if message['role'] != 'assistant':
            continue

        if completion_index >= remaining_completions:
            break

        # Assign completion IDs (starting from last)
        if loss_mask:
            message['content'] = {
                'loss_scale': loss_mask[-1 - completion_index],
                'token_ids': completion_ids[-1 - completion_index]
            }
        else:
            message['content'] = completion_ids[-1 - completion_index]

        completion_index += 1

    return messages


def parse_prompt_logprobs(response, topk: int) -> Tuple[List[List[float]], List[List[int]]]:
    """Parse vLLM prompt_logprobs into per-position (logprobs, token_ids).

    vLLM's ``prompt_logprobs[i]`` is ``{token_id: {logprob, rank, ...}}`` for predicting
    prompt token ``i`` (position 0 is ``None`` and skipped).

    - ``topk == 0`` (sampled-token, OPD-RL): the teacher was queried with
      ``prompt_logprobs=0``, so each position holds exactly the *sampled* (actually-present)
      token — take that single entry. This is token-in-token-out, NOT the top-1: the
      sampled token may have any rank.
    - ``topk > 0`` (top-k, GKD): the ``topk`` highest-probability tokens, ordered by logprob
      (== rank order). The sampled token, if returned as an extra ``k+1``-th entry, is dropped.
    """
    raw = response.prompt_logprobs or []
    lps: List[List[float]] = []
    ixs: List[List[int]] = []
    for pos_lp in raw[1:]:
        if topk == 0:
            # prompt_logprobs=0 -> a single entry: the sampled (actually-present) token.
            tid, info = next(iter(pos_lp.items()))
            lps.append([info['logprob']])
            ixs.append([int(tid)])
        else:
            items = sorted(pos_lp.items(), key=lambda x: -x[1]['logprob'])[:topk]
            lps.append([info['logprob'] for _, info in items])
            ixs.append([int(tid) for tid, _ in items])
    return lps, ixs


def assemble_teacher_topk_logprobs(
    parsed: List[Tuple[List[List[float]], List[List[int]]]],
    batch_size: int,
    seq_len: int,
    cu_seqlens: Optional[List[int]],
    topk: int,
    device: torch.device,
    offsets: Optional[List[int]] = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
    is_packed = cu_seqlens is not None

    if is_packed:
        total_len = seq_len
        out_lp = torch.full((total_len, topk), float('-inf'), dtype=torch.float32)
        out_ix = torch.zeros(total_len, topk, dtype=torch.long)
        num_seqs = len(cu_seqlens) - 1
        assert len(parsed) == num_seqs, f'parsed length {len(parsed)} != num_seqs {num_seqs}'
        for i in range(num_seqs):
            start, end = cu_seqlens[i], cu_seqlens[i + 1]
            lps, ixs = parsed[i]
            length = min(len(lps), end - start)
            if length <= 0:
                continue
            out_lp[start:start + length] = torch.tensor(lps[:length], dtype=torch.float32)
            out_ix[start:start + length] = torch.tensor(ixs[:length], dtype=torch.long)
        return out_lp.unsqueeze(0).to(device), out_ix.unsqueeze(0).to(device)

    out_lp = torch.full((batch_size, seq_len, topk), float('-inf'), dtype=torch.float32)
    out_ix = torch.zeros(batch_size, seq_len, topk, dtype=torch.long)
    assert len(parsed) == batch_size, f'parsed length {len(parsed)} != batch_size {batch_size}'
    for idx in range(batch_size):
        lps, ixs = parsed[idx]
        P = len(lps)
        start = offsets[idx] if offsets is not None else 0
        length = min(P, seq_len - start)
        if length <= 0:
            continue
        out_lp[idx, start:start + length] = torch.tensor(lps[:length], dtype=torch.float32)
        out_ix[idx, start:start + length] = torch.tensor(ixs[:length], dtype=torch.long)
    return out_lp.to(device), out_ix.to(device)


def patch_save_last_checkpoint():
    import trl
    if version.parse(trl.__version__) >= version.parse('0.20'):
        return

    # patch to fix save last_checkpoint https://github.com/modelscope/ms-swift/pull/4969
    from trl.trainer.grpo_trainer import RepeatSampler
    if not hasattr(RepeatSampler, 'old_len_func'):
        origin_len_func = RepeatSampler.__len__

        def patched_len(self) -> int:
            return (self.num_samples // self.batch_size) * self.batch_size * self.mini_repeat_count * self.repeat_count

        RepeatSampler.__len__ = patched_len
        RepeatSampler.old_len_func = origin_len_func


def get_gather_if_zero3_context(trainer, is_zero3: Optional[bool] = None):
    deepspeed_plugin = trainer.accelerator.state.deepspeed_plugin
    zero_stage_3 = is_zero3 if is_zero3 is not None else (deepspeed_plugin is not None
                                                          and deepspeed_plugin.zero_stage == 3)

    if zero_stage_3:
        import deepspeed
        gather_if_zero3 = deepspeed.zero.GatheredParameters
    else:
        gather_if_zero3 = nullcontext
    return gather_if_zero3


def prepare_fsdp(model, accelerator, evaluation_mode: bool = True):
    """Prepare a model with FSDP wrapping

    This function wraps a model with the appropriate FSDP mechanism based on
    the accelerator configuration. It's designed for auxiliary models like
    ref_model, teacher_model, or reward_model that need to be FSDP-wrapped
    to prevent mixing DTensor (main model) with regular Tensor (auxiliary model).

    Args:
        model: The model to wrap with FSDP.
        accelerator: The accelerator instance from trainer.
        evaluation_mode: Whether to set the model to evaluation mode. Defaults to True.
            When True, the model is frozen BEFORE FSDP wrapping to avoid float32 upcast,
            which saves significant memory for evaluation-only models.

    Returns:
        The FSDP-wrapped model.
    """
    if evaluation_mode:
        model.eval()
        for param in model.parameters():
            param.requires_grad_(False)

    if getattr(accelerator, 'is_fsdp2', False):
        # FSDP2 uses fully_shard API with DTensor
        from accelerate.utils.fsdp_utils import fsdp2_prepare_model
        model = fsdp2_prepare_model(accelerator, model)
    else:
        # FSDP1 uses FullyShardedDataParallel wrapper
        from trl.models.utils import prepare_fsdp as trl_prepare_fsdp
        model = trl_prepare_fsdp(model, accelerator)

    return model


_moe_model_registry_cache = None


def _get_moe_model_registry():

    global _moe_model_registry_cache
    if _moe_model_registry_cache is not None:
        return _moe_model_registry_cache

    import importlib

    moe_model_configs = [
        ('vllm.model_executor.models.deepseek_v2', ('DeepseekV2ForCausalLM', 'DeepseekV3ForCausalLM'), 'mlp'),
        ('vllm.model_executor.models.mixtral', ('MixtralForCausalLM', ), 'block_sparse_moe'),
        ('vllm.model_executor.models.qwen2_moe', ('Qwen2MoeForCausalLM', ), 'mlp'),
        ('vllm.model_executor.models.qwen3_moe', ('Qwen3MoeForCausalLM', ), 'mlp'),
        ('vllm.model_executor.models.qwen3_vl_moe', ('Qwen3MoeLLMForCausalLM', ), 'mlp'),
        ('vllm.model_executor.models.qwen3_5', ('Qwen3_5MoeForCausalLM', ), 'mlp'),
        ('vllm.model_executor.models.qwen3_next', ('Qwen3NextForCausalLM', ), 'mlp'),
        ('vllm.model_executor.models.kimi_vl', ('KimiVLForConditionalGeneration', ), 'mlp'),
    ]

    supported_moe_models = []
    mlp_attr_mapping = {}

    for module_path, class_names, mlp_attr in moe_model_configs:
        try:
            module = importlib.import_module(module_path)
            for class_name in class_names:
                if hasattr(module, class_name):
                    model_class = getattr(module, class_name)
                    supported_moe_models.append(model_class)
                    mlp_attr_mapping[model_class] = mlp_attr
        except (ImportError, AttributeError, RuntimeError):
            pass

    _moe_model_registry_cache = (supported_moe_models, mlp_attr_mapping)
    return _moe_model_registry_cache


def patch_vllm_moe_model_weight_loader(model):
    """
    Patch vLLM MoE model to add weight_loader attribute to expert weights.

    This is a workaround for a bug in vLLM 0.8.2 where MoE weights (w13_weight, w2_weight)
    don't have the weight_loader attribute, causing AttributeError during weight loading.
    Code adapted from verl/verl/utils/vllm/patch.py

    Args:
        model: The vLLM model to patch.
    """
    # Check if already patched (idempotent). On NPU/vLLM-Ascend, sleep/wake
    # and full-model reload can recreate expert Parameters while keeping this
    # model-level flag, so the loader needs to be reattached every reload.
    if getattr(model, '_swift_moe_weight_loader_patched', False) and not is_torch_npu_available():
        return

    supported_moe_models, mlp_attr_mapping = _get_moe_model_registry()

    if not supported_moe_models:
        return

    original_model = model
    original_model_type = type(model)

    # Handle NPU ACLGraphWrapper (for vllm_ascend compatibility)
    if hasattr(model, 'runnable') and 'ACLGraphWrapper' in str(original_model_type):
        model = model.runnable
        original_model_type = type(model)

    # Get inner model (either model.model or model.language_model)
    inner_model = getattr(model, 'model', None) or getattr(model, 'language_model', None)
    if inner_model is None:
        # Model structure not recognized, skip patching
        return

    if not isinstance(model, tuple(supported_moe_models)) and not isinstance(inner_model, tuple(supported_moe_models)):
        return

    # Handle Qwen3-VL MoE structure
    if type(inner_model).__name__ == 'Qwen3MoeLLMForCausalLM':
        inner_model = inner_model.model
    if type(inner_model).__name__ == 'Qwen3_5MoeForCausalLM':
        inner_model = inner_model.model

    # Check if inner_model has layers attribute
    if not hasattr(inner_model, 'layers'):
        return

    def maybe_patch_vllm_ascend_moe_expert_weight_loader(experts, name, param):
        quant_method = getattr(experts, 'quant_method', None)
        if not is_torch_npu_available() or not type(quant_method).__module__.startswith('vllm_ascend'):
            return
        from swift.model.npu_patch.vllm_ascend import (patch_vllm_ascend_moe_expert_weight_loader,
                                                       use_vllm_ascend_moe_preprocessed_weight)
        patch_vllm_ascend_moe_expert_weight_loader(
            experts,
            name,
            param,
            load_preprocessed_weight=use_vllm_ascend_moe_preprocessed_weight(original_model),
        )

    for layer in inner_model.layers:
        mlp_attr = mlp_attr_mapping.get(original_model_type, 'mlp')

        mlp = getattr(layer, mlp_attr, None)
        if not mlp:
            continue

        experts = getattr(mlp, 'experts', None)
        if not experts or not hasattr(experts, 'weight_loader'):
            continue

        # Patch the weight loaders for MoE expert weights
        for name, param in mlp.named_parameters():
            if 'w13_weight' in name or 'w2_weight' in name:
                if not hasattr(param, 'weight_loader'):
                    param.weight_loader = experts.weight_loader
                maybe_patch_vllm_ascend_moe_expert_weight_loader(experts, name, param)

    # Mark the model as patched (for idempotency)
    original_model._swift_moe_weight_loader_patched = True


def finish_vllm_weight_reload(vllm_model, model_config, target_device):
    if vllm_model is None or model_config is None or target_device is None:
        return
    if is_torch_npu_available():
        from swift.model.npu_patch.vllm_ascend import should_skip_vllm_ascend_moe_post_load
        if should_skip_vllm_ascend_moe_post_load(vllm_model):
            return
    try:
        from vllm.model_executor.model_loader.utils import process_weights_after_loading
        process_weights_after_loading(vllm_model, model_config, target_device)
    except Exception:
        return


_cached_reverse_renamings = None


def _build_reverse_renamings(model):
    """Build and cache reverse WeightRenaming rules for the given model.

    Only one model type goes through weight sync per training run, so a single
    module-level variable suffices. Returns None if no renamings apply.
    """
    global _cached_reverse_renamings
    if _cached_reverse_renamings is not None:
        return _cached_reverse_renamings

    try:
        from transformers.core_model_loading import WeightRenaming
    except ImportError:
        return None

    weight_conversions = getattr(model, '_weight_conversions', None)
    if weight_conversions is None:
        try:
            from transformers.conversion_mapping import get_model_conversion_mapping
            weight_conversions = get_model_conversion_mapping(model, add_legacy=False)
        except Exception:
            return None
    if not weight_conversions:
        return None

    renamings = [c for c in weight_conversions if isinstance(c, WeightRenaming)]
    if not renamings:
        return None

    # Reverse order before inverting, matching transformers' own revert_weight_conversion
    # (core_model_loading.py) which reverses the list so that chained renamings undo
    # in the correct order.
    try:
        _cached_reverse_renamings = [c.reverse_transform() for c in renamings[::-1]]
    except Exception as e:
        logger = get_logger()
        logger.warning(f'Failed to build reverse renamings for {type(model).__name__}: {e}')
        return None

    return _cached_reverse_renamings


def revert_runtime_names_to_checkpoint(model, state_dict):
    """Map HF runtime param names back to HF checkpoint names before vLLM weight sync.

    transformers>=5 may rename checkpoint keys to different *runtime* module names
    (e.g. gemma4_unified: checkpoint ``model.vision_embedder.*`` -> runtime
    ``model.embed_vision.*``). vLLM's ``hf_to_vllm_mapper`` is built around the
    checkpoint names (the same path used by ``vllm serve``), so online weight sync
    that sends runtime names can land on the wrong vLLM module and raise
    "There is no module or parameter named ...".

    We revert only the *renaming* part (``WeightRenaming``). Tensor-level
    ``WeightConverter`` ops (e.g. MoE fuse/split) are intentionally skipped and
    left to the existing MoE weight-loader patch, which expects the fused runtime
    layout.

    Safe by construction: models whose vLLM mapper accepts runtime names also
    carry the checkpoint-name rules (required by ``vllm serve``), so reverting to
    checkpoint names still maps correctly. Any failure or absence of conversions
    is a no-op that returns the input unchanged.
    """
    # Unwrap PEFT to reach the underlying transformers model that holds conversions.
    if hasattr(model, 'get_base_model'):
        try:
            model = model.get_base_model()
        except Exception:
            pass

    reverse_renamings = _build_reverse_renamings(model)
    if not reverse_renamings:
        return state_dict

    try:
        from transformers.core_model_loading import rename_source_key
    except ImportError:
        return state_dict

    new_state_dict = {}
    for name, param in state_dict.items():
        try:
            new_name, _ = rename_source_key(name, reverse_renamings, [])
        except Exception:
            new_name = name
        new_state_dict[new_name] = param
    return new_state_dict


def patch_vllm_load_adapter():
    from vllm.lora.worker_manager import LRUCacheWorkerLoRAManager
    try:
        from vllm.lora.models import LoRAModel
    except ImportError:
        # vllm >= 0.13 https://github.com/vllm-project/vllm/pull/30253
        from vllm.lora.lora_model import LoRAModel
    from vllm.lora.utils import get_adapter_absolute_path

    try:
        from vllm.transformers_utils.tokenizer_group import TokenizerGroup
    except ImportError:
        # removed in https://github.com/vllm-project/vllm/pull/24078
        TokenizerGroup = None

    def patched_load_adapter(self: LRUCacheWorkerLoRAManager, lora_request: TensorLoRARequest) -> LoRAModel:
        """
        code borrowed from verl.utils.vllm.utils.py
        based on vllm.lora.worker_manager.WorkerLoRAManager._load_adapter, support load adapter with lora tensors
        Reason:
        VLLM does not support adding LoRA from tensors directly. It only supports adding LoRA via file paths.
        To synchronize the LoRA tensors of the actor model, we need to find a workaround to enable VLLM to
        load memory-based LoRA tensors.
        """
        try:
            supported_lora_modules = self._adapter_manager.supported_lora_modules
            packed_modules_mapping = self._adapter_manager.packed_modules_mapping
            expected_lora_modules: list[str] = []
            for module in supported_lora_modules:
                if module in packed_modules_mapping:
                    expected_lora_modules.extend(packed_modules_mapping[module])
                else:
                    expected_lora_modules.append(module)
            expected_lora_modules = list(set(expected_lora_modules))
            # this is the patch
            lora_tensors = None
            from vllm.lora.peft_helper import PEFTHelper
            if isinstance(lora_request, TensorLoRARequest):
                peft_config = lora_request.peft_config
                lora_tensors = lora_request.lora_tensors
                peft_helper = PEFTHelper.from_dict(peft_config)
            else:
                lora_path = get_adapter_absolute_path(lora_request.lora_path)
                peft_helper = PEFTHelper.from_local_dir(lora_path, self.max_position_embeddings)
            # Validates the LoRA configuration against requirements before
            # loading weights, throwing an exception if validation fails.
            peft_helper.validate_legal(self.lora_config)
            # For some models like Qwen2VL, we need to use hf_to_vllm_mapper
            # to ensure correct loading of lora weights.
            model = self._adapter_manager.model
            hf_to_vllm_mapper = getattr(model, 'hf_to_vllm_mapper', None)

            lora_request_kwargs = {
                'peft_helper': peft_helper,
                'lora_model_id': lora_request.lora_int_id,
                'device': 'cpu',
                'dtype': self.lora_config.lora_dtype,
                'weights_mapper': hf_to_vllm_mapper,
            }
            if hasattr(self, 'embedding_padding_modules'):
                lora_request_kwargs['embedding_modules'] = self.embedding_modules
                lora_request_kwargs['embedding_padding_modules'] = self.embedding_padding_modules
            else:
                lora_request_kwargs['model_vocab_size'] = self.vocab_size
            if hasattr(self.lora_config, 'lora_extra_vocab_size'):
                # lora_extra_vocab_size is removed in vllm >= 0.12
                # https://github.com/vllm-project/vllm/issues/23474
                lora_request_kwargs['target_embedding_padding'] = (
                    self.vocab_size + self.lora_config.lora_extra_vocab_size)

            if isinstance(lora_request, TensorLoRARequest):
                lora = self._lora_model_cls.from_lora_tensors(
                    tensors=lora_tensors,
                    **lora_request_kwargs,
                )
            else:
                lora = self._lora_model_cls.from_local_checkpoint(
                    lora_path,
                    expected_lora_modules,
                    **lora_request_kwargs,
                )
        except Exception as e:
            raise e
        if hasattr(self.lora_config, 'lora_extra_vocab_size'):
            if lora.extra_vocab_size > self.lora_config.lora_extra_vocab_size:
                raise ValueError(f'LoRA added vocab size {lora.extra_vocab_size} is greater than '
                                 f'lora_extra_vocab_size {self.lora_config.lora_extra_vocab_size}.')
        return lora

    def patched_get_lora_tokenizer(self: TokenizerGroup, lora_request: LoRARequest):
        # since we pass dummy path, skip get tokenizer from path
        return self.tokenizer

    if not hasattr(LRUCacheWorkerLoRAManager, '_old_load_adapter'):
        _old_load_adapter = LRUCacheWorkerLoRAManager._load_adapter
        LRUCacheWorkerLoRAManager._load_adapter = patched_load_adapter
        LRUCacheWorkerLoRAManager._old_load_adapter = _old_load_adapter
        if TokenizerGroup is not None:
            TokenizerGroup._old_get_lora_tokenizer = TokenizerGroup.get_lora_tokenizer
            TokenizerGroup.get_lora_tokenizer = patched_get_lora_tokenizer


def expand_vllm_param_name_aliases(param_names: set[str]) -> set[str]:
    stacked_mappings = [
        (re.compile(r'\bqkv_proj\b'), ('q_proj', 'k_proj', 'v_proj', 'q', 'k', 'v')),
        (re.compile(r'\bgate_up_proj\b'), ('gate_proj', 'up_proj')),
        (re.compile(r'\bin_proj_ba\b'), ('in_proj_b', 'in_proj_a')),
        (re.compile(r'\blanguage_model\.model\b'), ('model.language_model', )),
        (re.compile(r'^visual\.'), ('model.visual.', )),
    ]

    def _expand_once(keys: set[str]) -> set[str]:
        expanded = set(keys)
        for key in keys:
            for pattern, aliases in stacked_mappings:
                if pattern.search(key):
                    for alias in aliases:
                        expanded.add(pattern.sub(alias, key))
        return expanded

    # Two passes allow chained replacement:
    # e.g. language_model.model + qkv_proj -> model.language_model + q_proj
    expanded = _expand_once(param_names)
    expanded = _expand_once(expanded)
    return expanded


def add_base_layer_suffix_by_param_names(weight_iterator: Iterable[Tuple[str, Any]],
                                         vllm_param_names: set[str]) -> Iterable[Tuple[str, Any]]:
    """Map HF dense param names to vLLM LoRA-wrapped modules (*.base_layer.weight / .bias)."""
    for name, tensor in weight_iterator:
        if '.base_layer.' in name or '.' not in name:
            yield name, tensor
            continue
        if name in vllm_param_names:
            yield name, tensor
            continue
        module_name, param_type = name.rsplit('.', 1)
        if param_type in {'weight', 'bias'}:
            bl = f'{module_name}.base_layer.{param_type}'
            if bl in vllm_param_names:
                name = bl
        yield name, tensor


# FlattenedTensor, code borrowed from sglang/srt/weight_sync/tensor_bucket.py
class FlattenedTensorMetadata(BaseModel):
    """Metadata for a tensor in a flattened bucket"""
    name: str
    shape: Tuple[int, ...]
    dtype: str
    start_idx: int
    end_idx: int
    numel: int

    @field_validator('dtype', mode='before')
    @classmethod
    def ensure_dtype_str(cls, v: Any) -> str:
        # accept torch.dtype or str
        if torch is not None and isinstance(v, torch.dtype):
            return str(v)
        if isinstance(v, str):
            return v
        raise ValueError('dtype must be a torch.dtype or str')


class TensorMetadata(BaseModel):
    """Metadata for a single tensor."""
    name: str
    shape: Tuple[int, ...]
    dtype: str
    numel: int


class UpdateFlattenedAdapterRequest(BaseModel):
    peft_config: LoraConfig
    metadatas: List[FlattenedTensorMetadata]


class UpdateFlattenedParamsRequest(BaseModel):
    metadatas: List[FlattenedTensorMetadata]


class UpdateAdapterRequest(BaseModel):
    """Request for non-flattened adapter weight update"""
    peft_config: LoraConfig
    lora_tensors_metadata: List[TensorMetadata]


class FlattenedTensorBucket:
    """
    A bucket that flattens multiple tensors into a single tensor for efficient processing
    while preserving all metadata needed for reconstruction.
    """

    def __init__(
        self,
        named_tensors: List[Tuple[str, torch.Tensor]] = None,
        flattened_tensor: torch.Tensor = None,
        metadata: List[FlattenedTensorMetadata] = None,
    ):
        if named_tensors is not None:
            if not named_tensors:
                raise ValueError('Cannot create empty tensor bucket')

            self.metadata: List[FlattenedTensorMetadata] = [None] * len(named_tensors)
            flattened_chunks: List[torch.Tensor] = [None] * len(named_tensors)
            current_byte = 0

            for i, (name, tensor) in enumerate(named_tensors):
                flat_u8 = tensor.flatten().view(torch.uint8)
                flattened_chunks[i] = flat_u8

                numel = flat_u8.numel()
                self.metadata[i] = FlattenedTensorMetadata(
                    name=name,
                    shape=tuple(tensor.shape),
                    dtype=str(tensor.dtype),
                    start_idx=current_byte,
                    end_idx=current_byte + numel,
                    numel=numel,
                )
                current_byte += numel

            self.flattened_tensor = torch.cat(flattened_chunks, dim=0)
        else:
            # Initialize from pre-flattened data
            if flattened_tensor is None or metadata is None:
                raise ValueError('Must provide either named_tensors or both flattened_tensor and metadata')
            self.flattened_tensor = flattened_tensor
            self.metadata = metadata

    def get_flattened_tensor(self) -> torch.Tensor:
        """Get the flattened tensor containing all bucket tensors"""
        return self.flattened_tensor

    def get_metadata(self) -> List[FlattenedTensorMetadata]:
        """Get metadata for all tensors in the bucket"""
        return self.metadata

    def reconstruct_tensors(self) -> Dict[str, torch.Tensor]:
        """
        Reconstruct original tensors from flattened tensor with optimized performance.
        Uses memory-efficient operations to minimize allocations and copies.
        """
        # preallocate the result list
        reconstructed = {}

        for meta in self.metadata:
            dtype = getattr(torch, meta.dtype.split('.')[-1])
            byte_slice = self.flattened_tensor[meta.start_idx:meta.end_idx]
            tensor = byte_slice.view(dtype).reshape(meta.shape)
            reconstructed[meta.name] = tensor
        return reconstructed


def identity_data_collator(features, **kwargs):
    """Identity data collator that returns features as-is without any processing."""
    return features


def mu_schedule_function(global_step: int, mu_warmup_steps: int, mu_decay_steps: int, mu_peak: float,
                         mu_valley: float) -> float:
    """
    Computes a cosine decay schedule with a warmup phase for the mu parameter.

    Args:
        global_step: Current global training step
        mu_warmup_steps: Number of warmup steps
        mu_decay_steps: Number of decay steps
        mu_peak: Peak value of mu during warmup
        mu_valley: Final value of mu after decay

    Returns:
        Current mu value based on the schedule
    """
    # Warmup
    if global_step < mu_warmup_steps:
        return (global_step / mu_warmup_steps) * mu_peak

    # Decay
    if global_step >= (mu_warmup_steps + mu_decay_steps):
        return mu_valley

    adjusted_step = global_step - mu_warmup_steps
    cosine_decay = 0.5 * (1 + math.cos(math.pi * adjusted_step / mu_decay_steps))
    decayed_mu = (mu_peak - mu_valley) * cosine_decay + mu_valley
    return decayed_mu


def create_cyclic_iterator(iterable):
    """Create a cyclic iterator that repeats the iterable indefinitely."""
    while True:
        for x in iterable:
            yield x


def get_chord_sft_dataloader(trainer,
                             dataset,
                             description,
                             batch_size,
                             sampler_fn=None,
                             is_training=False,
                             dataloader_key=None) -> DataLoader:
    """
    Create a DataLoader from the given dataset for CHORD SFT training.
    Mimics transformers.trainers._get_dataloader.

    Args:
        trainer: The trainer instance
        dataset: The dataset to create DataLoader from
        description: Description of the dataset (e.g., 'Training')
        batch_size: Batch size for the DataLoader
        sampler_fn: Optional sampler function
        is_training: Whether this is for training
        dataloader_key: Optional dataloader key

    Returns:
        Prepared DataLoader
    """
    data_collator = identity_data_collator
    if isinstance(dataset, datasets.Dataset):
        dataset = trainer._remove_unused_columns(dataset, description=description)
    else:
        data_collator = trainer._get_collator_with_removed_columns(data_collator, description=description)

    dataloader_params = {
        'batch_size': batch_size,
        'collate_fn': data_collator,
        'num_workers': trainer.args.dataloader_num_workers,
        'pin_memory': trainer.args.dataloader_pin_memory,
        'persistent_workers': trainer.args.dataloader_persistent_workers,
    }

    if not isinstance(dataset, torch.utils.data.IterableDataset):
        if sampler_fn is not None:
            dataloader_params['sampler'] = sampler_fn(dataset)
        dataloader_params['drop_last'] = trainer.args.dataloader_drop_last
        dataloader_params['prefetch_factor'] = trainer.args.dataloader_prefetch_factor
        if is_training:
            from swift.utils import seed_worker
            dataloader_params['worker_init_fn'] = partial(
                seed_worker, num_workers=trainer.args.dataloader_num_workers, rank=trainer.args.process_index)

    dataloader = trainer.accelerator.prepare(DataLoader(dataset, **dataloader_params))
    return dataloader


def make_chord_sft_dataset(trainer, chord_sft_dataset):
    """
    Create and setup CHORD SFT dataset iterator for the trainer.

    Args:
        trainer: The trainer instance
        chord_sft_dataset: The CHORD SFT dataset
    """
    trainer.chord_sft_dataset = chord_sft_dataset
    if trainer.chord_sft_dataset:
        chord_sft_dataloader = get_chord_sft_dataloader(
            trainer=trainer,
            dataset=chord_sft_dataset,
            description='Training',
            batch_size=trainer.args.chord_sft_per_device_train_batch_size,
            sampler_fn=RandomSampler,
            is_training=True,
        )
        return create_cyclic_iterator(chord_sft_dataloader)


def compute_chord_loss(trainer, grpo_loss: torch.Tensor) -> torch.Tensor:
    """
    Compute CHORD loss combining GRPO loss with SFT loss.

    Args:
        trainer: The trainer instance
        grpo_loss: The GRPO loss tensor

    Returns:
        Combined CHORD loss tensor
    """
    from swift.trainers import per_token_loss_func

    current_step = trainer.state.global_step
    mu = mu_schedule_function(current_step, trainer.args.chord_mu_warmup_steps, trainer.args.chord_mu_decay_steps,
                              trainer.args.chord_mu_peak, trainer.args.chord_mu_valley)
    chord_sft_loss = torch.tensor(0.0, device=grpo_loss.device, dtype=grpo_loss.dtype)
    if mu > 0:
        sft_inputs = next(trainer.chord_sft_iterator)
        sft_inputs = to_device(sft_inputs, 'cpu')
        sft_inputs = to_device(trainer.template.data_collator(sft_inputs), trainer.accelerator.device)

        labels = sft_inputs.pop('labels')
        loss_scale = sft_inputs.pop('loss_scale', None)
        outputs = trainer.model(**sft_inputs)
        chord_sft_loss = per_token_loss_func(outputs, labels)

        if trainer.args.chord_enable_phi_function:
            per_token_probs = torch.exp(-chord_sft_loss.detach())
            phi = per_token_probs * (1 - per_token_probs)
            chord_sft_loss *= phi

        if loss_scale is not None:
            loss_scale = torch.roll(loss_scale, shifts=-1, dims=-1).view(-1)
            chord_sft_loss *= loss_scale

        num_items_in_batch = (labels[:, 1:] != -100).sum()
        chord_sft_loss = chord_sft_loss.sum() / num_items_in_batch
    else:
        assert mu == 0
        chord_sft_loss = torch.tensor(0.0, device=grpo_loss.device, dtype=grpo_loss.dtype)
    loss = (1 - mu) * grpo_loss + mu * chord_sft_loss
    return loss


_EXPANDABLE_SEGMENTS_SET = 'expandable_segments' in os.environ.get('PYTORCH_CUDA_ALLOC_CONF', '')


def set_expandable_segments(enable: bool) -> None:
    """
    Enable or disable expandable segments for CUDA memory allocation.

    This function provides a safe way to configure CUDA expandable segments without
    overriding user preferences. It only takes effect when the user has previously
    set the PYTORCH_CUDA_ALLOC_CONF environment variable, ensuring that explicit
    user configurations are respected.

    Expandable segments allow PyTorch to grow memory pools dynamically, which can
    help prevent out-of-memory (OOM) errors during long-running reinforcement
    learning training sessions by reducing memory fragmentation.

    Args:
        enable (bool): Whether to enable expandable segments. When True, allows
            CUDA memory pools to expand dynamically to reduce fragmentation and
            mitigate OOM issues.

    Note:
        - Only takes effect if PYTORCH_CUDA_ALLOC_CONF was previously set by the user
        - Requires CUDA to be available
        - Changes apply to both the PyTorch allocator settings and environment variable

    Example:
        >>> # Only works if user has already set PYTORCH_CUDA_ALLOC_CONF
        >>> set_expandable_segments(True)  # Enable to help with OOM issues
        >>> set_expandable_segments(False) # Disable for more predictable memory usage
    """
    if not _EXPANDABLE_SEGMENTS_SET:
        return
    if torch.cuda.is_available():
        torch.cuda.memory._set_allocator_settings(f'expandable_segments:{enable}')
        os.environ['PYTORCH_CUDA_ALLOC_CONF'] = f'expandable_segments:{enable}'


def peft_config_to_dict(peft_config):
    if not isinstance(peft_config, dict):
        peft_config = asdict(peft_config)
    # turn set to list to serializable
    if 'target_modules' in peft_config and isinstance(peft_config['target_modules'], set):
        peft_config['target_modules'] = list(peft_config['target_modules'])

    return peft_config


def _create_parameter_buckets(named_params, bucket_size_mb=512):
    """Create parameter buckets for efficient processing"""
    buckets = []
    current_bucket = []
    current_size = 0
    bucket_size_bytes = bucket_size_mb * 1024 * 1024

    for name, param in named_params:
        param_size = param.numel() * param.element_size()

        # If adding this param would exceed bucket size, process current bucket first
        if current_size + param_size > bucket_size_bytes and current_bucket:
            buckets.append(current_bucket)
            current_bucket = []
            current_size = 0

        current_bucket.append((name, param))
        current_size += param_size

    # Process remaining parameters in the last bucket
    if current_bucket:
        buckets.append(current_bucket)

    return buckets


def _process_bucket_with_flattened_tensor(trainer, bucket_params):
    """Process a bucket of parameters using FlattenedTensorBucket for efficiency"""
    if not bucket_params:
        return

    # Create FlattenedTensorBucket for efficient processing
    bucket = FlattenedTensorBucket(named_tensors=bucket_params)
    metadatas = bucket.get_metadata()
    flattened_tensor = bucket.get_flattened_tensor()

    # Use the new flattened parameter update method
    # If not available, fall back to individual parameter updates
    try:
        trainer.vllm_client.update_flattened_params(metadatas, flattened_tensor)
    except AttributeError:
        # Fallback to individual parameter updates
        reconstructed = bucket.reconstruct_tensors()
        for name, param in reconstructed.items():
            trainer.vllm_client.update_named_param(name, param)

    # Clean up
    del bucket, metadatas, flattened_tensor


def get_even_process_data(trainer, global_data: List[T]) -> List[T]:
    """
    Evenly splits `global_data` among all processes.

    Each process receives a contiguous chunk of data. If `len(global_data)` is not
    perfectly divisible by the number of processes, the first `remainder` processes
    will receive one additional item.

    Args:
        global_data (List[T]): The full list of data to be distributed.

    Returns:
        List[T]: The subset of `global_data` assigned to this process.
    """
    num_procs = trainer.accelerator.num_processes
    proc_idx = trainer.accelerator.process_index
    total = len(global_data)

    base_size = total // num_procs
    remainder = total % num_procs

    # Calculate the number of samples that need to be padded
    # This ensures all processes have the same number of samples for gather operations
    trainer.rollout_pad_count = 0
    if remainder > 0 and proc_idx >= remainder:
        # Processes with extra samples need padding
        trainer.rollout_pad_count = 1

    if proc_idx < remainder:
        start = proc_idx * (base_size + 1)
        end = start + base_size + 1
    else:
        start = remainder * (base_size + 1) + (proc_idx - remainder) * base_size
        end = start + base_size

    return global_data[start:end]


def check_vllm_version_ge(min_version: str) -> bool:
    """check if the vllm version is greater than or equal to the minimum version"""
    if not is_vllm_available():
        return False
    import vllm
    vllm_version = vllm.__version__
    # if dev version, regard it as latest version
    if vllm_version is None or 'dev' in vllm_version:
        return True
    return version.parse(vllm_version) >= version.parse(min_version)


def vllm_supports_lora_load_inplace() -> bool:
    """True when vLLM LoRARequest supports load_inplace (replaces same lora_int_id without remove_lora).

    Introduced in vLLM v0.15.0 (see vllm/lora/request.py). Older versions require remove_lora before add_lora
    when reusing a stable adapter id.
    """
    return check_vllm_version_ge('0.15.0')


# ============================================================================
# Padding-free utilities
# ============================================================================


def pad_logps_back_to_batch(logps_rmpad: Optional[torch.Tensor],
                            position_ids: Optional[torch.Tensor] = None,
                            logits_to_keep: int = None,
                            batch_size: int = None,
                            seq_lengths: Optional[torch.Tensor] = None,
                            dtype: Optional[torch.dtype] = None,
                            pad_value: float = -1e10) -> Tuple[torch.Tensor, torch.Tensor]:
    """
    Restore padding-free logprobs back to [batch_size, seq_len] shape with LEFT PADDING.

    - Input: logps in rmpad format [1, total_nnz] or None
    - Output: logps in batch format [batch_size, max_seq_len] with data right-aligned

    Args:
        logps_rmpad: [1, total_nnz] per-token log probabilities in padding_free format or None
        position_ids: [1, total_nnz] position ids to determine sequence boundaries (deprecated, use seq_lengths)
        logits_to_keep: number of tokens to keep per sequence (= max_seq_len)
        batch_size: number of sequences in the batch
        seq_lengths: [batch_size] actual sequence lengths (preferred over position_ids)
        dtype: optional dtype for output, defaults to logps_rmpad.dtype
        pad_value: value to use for padding positions (default: -1e10 for logps, use 0.0 for masks)

    Returns:
        logps_padded: [batch_size, logits_to_keep] padded log probabilities (left-padded, data right-aligned) or None
        valid_mask: [batch_size, logits_to_keep] mask indicating valid (non-padding) positions or None
    """
    if logps_rmpad is None:
        return None, None

    if dtype is None:
        dtype = logps_rmpad.dtype

    device = logps_rmpad.device

    # Determine sequence lengths
    if seq_lengths is not None:
        # Use provided seq_lengths directly - they should already be adjusted
        # by the caller (e.g., in _generate_and_score_completions)
        # DO NOT adjust again here to avoid double adjustment
        pass
    else:
        # Fallback: infer from position_ids
        cu_seqlens = get_cu_seqlens_from_position_ids(position_ids)

        # Adjust cu_seqlens for logits_to_keep if needed
        total_length = cu_seqlens[-1].item()
        if total_length > logits_to_keep:
            # Adjust the first sequence length
            adjustment = total_length - logits_to_keep
            cu_seqlens = cu_seqlens - adjustment
            cu_seqlens[0] = 0  # First element should always be 0

        # Compute actual sequence lengths
        seq_lengths = cu_seqlens[1:] - cu_seqlens[:-1]

    # Compute cumulative sequence lengths
    cu_seqlens = torch.cumsum(torch.cat([torch.tensor([0], device=device), seq_lengths]), dim=0)
    max_seq_len = logits_to_keep  # All sequences will be padded to this length

    # Initialize output tensors with padding value
    logps_padded = torch.full((batch_size, max_seq_len), pad_value, dtype=dtype, device=device)
    valid_mask = torch.zeros(batch_size, max_seq_len, dtype=torch.float32, device=device)

    # Unflatten: assign each sequence's logps to the corresponding row
    # Use LEFT PADDING (right-align the data) to match the standard padding convention
    logps_flat = logps_rmpad.squeeze(0)  # [total_nnz]

    for i in range(batch_size):
        start_idx = cu_seqlens[i].item()
        end_idx = cu_seqlens[i + 1].item()
        seq_len = int(seq_lengths[i].item())

        actual_end_idx = min(end_idx, len(logps_flat))
        actual_len = actual_end_idx - start_idx

        if actual_len <= 0:
            continue

        # Left padding: place data at the RIGHT side of the row
        # pad_len is the number of padding tokens at the beginning
        pad_len = max_seq_len - seq_len

        if actual_len < seq_len:
            # Input data is shorter than expected seq_len
            # This happens when logps_flat doesn't have enough data
            # Place actual data at the rightmost positions
            data_pad_len = max_seq_len - actual_len
            logps_padded[i, data_pad_len:] = logps_flat[start_idx:actual_end_idx]
            valid_mask[i, data_pad_len:] = 1.0
        else:
            # Normal case: seq_len tokens of data
            logps_padded[i, pad_len:] = logps_flat[start_idx:end_idx]
            valid_mask[i, pad_len:] = 1.0

    return logps_padded, valid_mask


def build_completion_mask_and_seq_lengths(
    labels: torch.Tensor,
    batch_size: int,
    *,
    padding_free: bool = False,
    encoded_batch: Optional[dict] = None,
    device: Optional[torch.device] = None,
    logits_to_keep: Optional[int] = None,
) -> Tuple[torch.Tensor, torch.Tensor, int]:
    """Build completion_mask and seq_lengths from labels, shared by HF / Megatron / Ray GRPO paths.

    Two frame conventions, selected by ``logits_to_keep``:

    - ``logits_to_keep is None`` -> full-sequence frame + roll (Megatron / Ray):
      ``completion_mask = roll(labels,-1) != -100``, shape ``[B, T_full]``.
    - ``logits_to_keep is int`` -> completion-region frame, no roll (HF):
      ``completion_mask = labels[:, -ltk:] != -100``, shape ``[B, ltk]``; the per-sample
      ``seq_lengths`` (padding_free) carries the first-sentence prompt adjustment so it
      matches HF's ``num_logits_to_keep`` logps frame.

    Args:
        labels: Label tensor from data collator.
        batch_size: Number of samples in the batch.
        padding_free: Whether padding-free (rmpad) mode is used.
        encoded_batch: The full encoded batch dict (needed for cu_seq_lens / attention_mask / position_ids).
        device: Target device for output tensors.
        logits_to_keep: Region width for the HF frame; ``None`` selects the full-sequence frame.

    Returns:
        (completion_mask, seq_lengths, max_seq_len) where:
        - completion_mask: ``[B, max_seq_len]`` bool tensor
        - seq_lengths: ``[B]`` int tensor of per-sample lengths
        - max_seq_len: int
    """
    if device is None:
        device = labels.device
    if encoded_batch is None:
        encoded_batch = {}

    if logits_to_keep is None:
        # Full-sequence frame + roll (Megatron / Ray)
        rolled_labels = torch.roll(labels, shifts=-1, dims=-1)
        if padding_free:
            if 'cu_seq_lens_q' in encoded_batch:
                cu = encoded_batch['cu_seq_lens_q']
            else:
                cu = get_packed_seq_params(encoded_batch['position_ids'])['cu_seq_lens_q']
            seq_lengths = cu[1:] - cu[:-1]
            max_seq_len = int(seq_lengths.max().item())
            completion_mask_rmpad = (rolled_labels != -100).float()
            completion_mask, _ = pad_logps_back_to_batch(
                logps_rmpad=completion_mask_rmpad,
                logits_to_keep=max_seq_len,
                batch_size=batch_size,
                seq_lengths=seq_lengths,
                pad_value=0.0)
            completion_mask = completion_mask.bool()
        else:
            attention_mask = encoded_batch.get('attention_mask')
            if attention_mask is not None:
                if attention_mask.dim() == 4:
                    attention_mask = attention_mask[:, 0, 0, :]
                seq_lengths = attention_mask.sum(dim=-1).to(torch.int64)
            else:
                seq_lengths = torch.full((batch_size, ), labels.shape[-1], dtype=torch.int64, device=device)
            max_seq_len = labels.shape[-1]
            completion_mask = (rolled_labels != -100)
        return completion_mask, seq_lengths, max_seq_len

    # Completion-region frame, no roll (HF); aligns with num_logits_to_keep logps frame.
    completion_mask_raw = labels[:, -logits_to_keep:] != -100
    max_seq_len = logits_to_keep
    if padding_free:
        position_ids = encoded_batch.get('text_position_ids')
        if position_ids is None:
            position_ids = encoded_batch.get('position_ids')
        position_ids = position_ids.squeeze()
        lengths = torch.diff(
            torch.cat([(position_ids == 0).nonzero(as_tuple=True)[0],
                       torch.tensor([len(position_ids)]).to(position_ids.device)]))
        total_lengths = lengths.sum()
        # The first sentence has its prompt portion removed due to logits_to_keep
        lengths[0] = lengths[0] - (total_lengths - logits_to_keep)
        seq_lengths = lengths
        completion_mask, _ = pad_logps_back_to_batch(
            logps_rmpad=completion_mask_raw.float(),
            logits_to_keep=logits_to_keep,
            batch_size=batch_size,
            seq_lengths=lengths,
            pad_value=0.0)
        completion_mask = completion_mask.bool()
    else:
        completion_mask = completion_mask_raw
        # Non-padding-free HF frame: every row spans the full region. Return real
        # per-sample lengths (= region width) instead of an empty tensor, so callers
        # can treat seq_lengths uniformly regardless of padding_free.
        seq_lengths = torch.full((batch_size, ), logits_to_keep, dtype=torch.int64, device=device)
    return completion_mask, seq_lengths, max_seq_len


def build_rollout_logps(
    rollout_logprobs_list: List[Optional[List[List[float]]]],
    completion_mask: torch.Tensor,
    device: torch.device,
) -> Optional[torch.Tensor]:
    """Convert per-sample ``rollout_logprobs`` into a [B, T] tensor aligned with completion_mask.

    Data-structure agnostic: callers pass a list of per-sample nested logprobs
    (``List[List[float]]`` per sample, or ``None``).

    Returns None if logprobs are missing or counts don't match.
    """
    lp_list = list(rollout_logprobs_list)
    if not all(lp is not None and lp for lp in lp_list):
        return None

    batch_size, seq_len = completion_mask.shape
    rollout_per_token_logps = torch.zeros(batch_size, seq_len, dtype=torch.float32, device=device)
    for i, nested_lp in enumerate(lp_list):
        flat_lps = [lp for turn_lps in nested_lp for lp in turn_lps]
        if not flat_lps:
            continue
        if any(lp is None for lp in flat_lps):
            return None
        completion_count = int(completion_mask[i].sum().item())
        if len(flat_lps) == completion_count + 1:
            flat_lps = flat_lps[:completion_count]
        if len(flat_lps) != completion_count:
            return None
        completion_indices = completion_mask[i].nonzero(as_tuple=True)[0]
        rollout_per_token_logps[i, completion_indices] = torch.tensor(flat_lps, dtype=torch.float32, device=device)
    return rollout_per_token_logps


def _normalize_routed_experts_tensor(value: Any) -> torch.Tensor:
    routed = value.detach().cpu() if isinstance(value, torch.Tensor) else torch.as_tensor(value)
    if routed.dim() >= 4 and routed.shape[0] == 1:
        routed = routed.squeeze(0)
    if routed.dim() < 2:
        raise ValueError(f'Invalid routed_experts shape: {tuple(routed.shape)}')
    return routed.to(dtype=torch.int64)


def _pad_or_trim_routed_experts(routed: torch.Tensor, target_len: int, *, padding_right: bool) -> torch.Tensor:
    current_len = int(routed.shape[0])
    if current_len == target_len:
        return routed
    if current_len > target_len:
        return routed[:target_len] if padding_right else routed[-target_len:]
    pad_len = target_len - current_len
    pad = [0] * (2 * routed.dim())
    if padding_right:
        pad[2 * (routed.dim() - 1) + 1] = pad_len
    else:
        pad[2 * (routed.dim() - 1)] = pad_len
    return torch.nn.functional.pad(routed, tuple(pad), 'constant', 0)


def build_routed_experts_batch(
    samples: List[OnPolicySample],
    *,
    seq_lengths: torch.Tensor,
    max_seq_len: int,
    template: Template,
    device: torch.device,
    router_replay_mode: str = 'disabled',
) -> Optional[torch.Tensor]:
    """Build the batched ``routed_experts`` model input from per-sample R3 routing.

    Shared by all backends. Each ``sample`` is an :class:`OnPolicySample` carrying
    ``routed_experts`` (per-sample, seq-first) and ``encoded['length']``. Returns
    ``None`` when no sample provides routing (and mode is not ``R3``).
    """
    if not samples or all(getattr(s, 'routed_experts', None) is None for s in samples):
        return None

    padding_right = template.padding_side == 'right'
    n_samples = len(samples)
    current_seq_lengths = seq_lengths
    if seq_lengths.size(0) > n_samples:
        current_seq_lengths = seq_lengths[:n_samples].clone()
        current_seq_lengths[n_samples - 1] = seq_lengths[n_samples - 1:].sum()

    routed_tensors: List[torch.Tensor] = []
    for sample, cur_seq_len in zip(samples, current_seq_lengths):
        routed_value = getattr(sample, 'routed_experts', None)
        if routed_value is None:
            if router_replay_mode == 'R3':
                raise AssertionError('When router_replay_mode = R3, routed_experts must be in rollout data')
            return None
        routed = _normalize_routed_experts_tensor(routed_value)
        expected_len = (sample.encoded or {}).get('length')
        experts_seq_len = int(routed.shape[0])
        if router_replay_mode == 'R3' and expected_len is not None:
            if experts_seq_len not in (expected_len, expected_len - 1):
                raise AssertionError(f'The seq_len of routed_experts({experts_seq_len}) does not match encoded length '
                                     f'({expected_len}); expected same length or one less.')
        target_len = int(cur_seq_len.item()) if template.padding_free else max_seq_len
        routed = _pad_or_trim_routed_experts(routed, target_len, padding_right=padding_right)
        routed_tensors.append(routed)

    if template.padding_free:
        return torch.cat(routed_tensors, dim=0).unsqueeze(0).to(device=device)
    return torch.stack(routed_tensors).to(device=device)


def collate_to_grpo_micro_batch(
    samples: List[OnPolicySample],
    template: Template,
    *,
    device: torch.device,
    padding_to: Optional[int] = None,
    router_replay_mode: str = 'disabled',
    use_logits_to_keep: bool = False,
) -> Tuple[Dict[str, Any], GRPOBatch]:
    """Collate ``List[OnPolicySample]`` into ``(model_inputs, grpo_batch)``.

    The single shared collate used by HF / Megatron / Megatron-Ray. Splits the
    per-sample world into two batch-level halves:

    - ``model_inputs`` (dict): ``data_collator([s.encoded ...])`` plus batch-computed
      model extras (``routed_experts``). A clean whitelist — ``model(**model_inputs)``
      needs no key filtering.
    - ``grpo_batch`` (:class:`GRPOBatch`): ``completion_mask`` / ``truncated_mask`` /
      ``seq_lengths`` / ``rollout_per_token_logps`` derived purely from the collated batch.

    ``use_logits_to_keep`` selects the completion_mask frame (see
    :func:`build_completion_mask_and_seq_lengths`):
    - ``False`` -> full-sequence frame + roll (Megatron / Ray).
    - ``True`` -> completion-region frame (HF); ``logits_to_keep`` is computed from
      the collated labels and stored on ``grpo_batch`` for the HF logps path.

    Backend-specific signals (``old_per_token_logps`` / ``ref_per_token_logps`` /
    ``advantages`` / ``num_items_in_batch``) are filled by the caller afterwards —
    non-Ray via batch forward, Ray by stacking per-sample remote results. No
    distributed communication happens here.
    """
    encoded_list = [s.encoded for s in samples]
    model_inputs = to_device(template.data_collator(encoded_list, padding_to=padding_to), device)

    labels = model_inputs['labels']
    batch_size = len(samples)
    logits_to_keep = None
    if use_logits_to_keep:
        logits_to_keep = int((labels.shape[-1] - (torch.ne(labels, -100).int().argmax(-1))).max().item())
    completion_mask, seq_lengths, max_seq_len = build_completion_mask_and_seq_lengths(
        labels,
        batch_size,
        padding_free=template.padding_free,
        encoded_batch=model_inputs,
        device=device,
        logits_to_keep=logits_to_keep,
    )
    truncated_mask = torch.tensor([bool(s.is_truncated) for s in samples], dtype=torch.bool, device=device)
    rollout_per_token_logps = build_rollout_logps([s.rollout_logprobs for s in samples], completion_mask, device)

    routed_experts = build_routed_experts_batch(
        samples,
        seq_lengths=seq_lengths,
        max_seq_len=max_seq_len,
        template=template,
        device=device,
        router_replay_mode=router_replay_mode,
    )
    if routed_experts is not None:
        model_inputs['routed_experts'] = routed_experts

    grpo_batch = GRPOBatch(
        completion_mask=completion_mask,
        truncated_mask=truncated_mask,
        seq_lengths=seq_lengths,
        rollout_per_token_logps=rollout_per_token_logps,
        logits_to_keep=logits_to_keep,
    )
    return model_inputs, grpo_batch


def resolve_reward_funcs(
    reward_funcs_cfg: list,
    args: Any = None,
) -> Tuple[List[Any], List[str]]:
    """Resolve reward function configs into callables and their names.

    Shared between ``MegatronGRPOTrainer._prepare_rewards`` and
    ``GRPOTrainer._prepare_rewards``.

    Returns:
        (reward_funcs, reward_func_names)
    """
    import asyncio
    import inspect

    from swift.rewards import orms

    reward_funcs = list(reward_funcs_cfg)
    for i, reward_func in enumerate(reward_funcs):
        if isinstance(reward_func, str) and reward_func in orms:
            reward_funcs[i] = orms[reward_func](args=args) if args is not None else orms[reward_func]()
        elif not callable(reward_func) and not isinstance(reward_func, str):
            raise ValueError(f'reward_function {reward_func} is not implemented in swift.rewards')

    names = []
    for func in reward_funcs:
        if inspect.isfunction(func):
            names.append(func.__name__)
        else:
            names.append(func.__class__.__name__)

    return reward_funcs, names


def make_reward_weights(
    reward_weights_cfg: Optional[List[float]],
    num_funcs: int,
    device: torch.device,
) -> torch.Tensor:
    """Build reward weight tensor, validating length against the reward
    function count."""
    if reward_weights_cfg is not None:
        if len(reward_weights_cfg) != num_funcs:
            raise ValueError(f'Number of reward weights ({len(reward_weights_cfg)}) must '
                             f'match number of reward functions ({num_funcs})')
        return torch.tensor(reward_weights_cfg, dtype=torch.float32, device=device)
    return torch.ones(num_funcs, dtype=torch.float32, device=device)
