# Copyright (c) ModelScope Contributors. All rights reserved.
# Part of the implementation is borrowed from huggingface/transformers.
import inspect
import math
import os
import torch
import torch.distributed as dist
import torch.nn.functional as F
from contextlib import contextmanager
from modelscope.hub.api import HubApi
from peft import PeftModel
from torch import nn
from torch.nn import CrossEntropyLoss, Module
from transformers import PreTrainedModel
from types import FunctionType, MethodType
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union

from swift.model import ModelMeta
from swift.sequence_parallel import ChunkedCrossEntropyLoss, GatherLoss, sequence_parallel
from swift.utils import deep_getattr, get_dist_setting, get_logger

if TYPE_CHECKING:
    from .arguments import TrainingArguments

logger = get_logger()


def _get_deepspeed_elastic_world_size():
    if dist.is_available() and dist.is_initialized():
        return dist.get_world_size()
    return get_dist_setting()[2]


def _enable_load_universal(ds_config):
    if isinstance(ds_config, dict):
        checkpoint = ds_config.get('checkpoint')
        if not isinstance(checkpoint, dict):
            checkpoint = {}
            ds_config['checkpoint'] = checkpoint
        checkpoint['load_universal'] = True


def enable_deepspeed_load_universal(args: 'TrainingArguments', trainer=None):
    _enable_load_universal(getattr(args, 'deepspeed', None))

    hf_ds_config = getattr(args, 'hf_deepspeed_config', None)
    _enable_load_universal(getattr(hf_ds_config, 'config', None))

    deepspeed_plugin = getattr(args, 'deepspeed_plugin', None)
    if trainer is not None and deepspeed_plugin is None:
        accelerator = getattr(trainer, 'accelerator', None)
        state = getattr(accelerator, 'state', None)
        deepspeed_plugin = getattr(state, 'deepspeed_plugin', None)
    if deepspeed_plugin is not None:
        _enable_load_universal(getattr(deepspeed_plugin, 'deepspeed_config', None))
        plugin_hf_ds_config = getattr(deepspeed_plugin, 'hf_ds_config', None)
        _enable_load_universal(getattr(plugin_hf_ds_config, 'config', None))


def prepare_deepspeed_elastic_config(args: 'TrainingArguments', state=None):
    ds_config = args.deepspeed
    if not ds_config:
        return
    if not isinstance(ds_config, dict):
        logger.warning('DeepSpeed elastic expects args.deepspeed to be a dict, but got '
                       f'{type(ds_config).__name__}. Skip elastic config.')
        return

    from deepspeed.elasticity import compute_elastic_config
    from deepspeed.git_version_info import version as __version__

    enable_deepspeed_load_universal(args)
    elasticity = ds_config.get('elasticity') or {}
    if not elasticity:
        logger.warning_once('DeepSpeed elastic callback is enabled, but no `elasticity` section is found in '
                            'the DeepSpeed config. Only `checkpoint.load_universal` is enabled.')
        return
    if elasticity.get('enabled') is False:
        return

    world_size = _get_deepspeed_elastic_world_size()
    final_batch_size, _, micro_batch_size = compute_elastic_config(
        ds_config=ds_config,
        target_deepspeed_version=__version__,
        world_size=world_size,
    )
    if world_size <= 0 or micro_batch_size <= 0:
        raise ValueError('DeepSpeed elastic config produced invalid batch settings: '
                         f'world_size={world_size}, micro_batch_size={micro_batch_size}.')
    gradient_accu_steps = max(1, final_batch_size // (micro_batch_size * world_size))
    args.per_device_train_batch_size = micro_batch_size
    args.gradient_accumulation_steps = gradient_accu_steps
    if state is not None:
        state.train_batch_size = args.per_device_train_batch_size * max(1, args.n_gpu)
    logger.info_once('DeepSpeed elastic config is enabled. '
                     f'world_size: {world_size}, '
                     f'per_device_train_batch_size: {args.per_device_train_batch_size}, '
                     f'gradient_accumulation_steps: {args.gradient_accumulation_steps}')


def can_return_loss(model: Module) -> bool:
    """Check if a given model can return loss."""
    if isinstance(model, PeftModel):
        signature = inspect.signature(model.model.forward)
    else:
        signature = inspect.signature(model.forward)
    for p in signature.parameters:
        if p == 'return_loss' and signature.parameters[p].default is True:
            return True
    return False


def find_labels(model: Module) -> List[str]:
    """Find the labels used by a given model."""
    model_name = model.__class__.__name__
    if isinstance(model, PeftModel):
        signature = inspect.signature(model.model.forward)
    else:
        signature = inspect.signature(model.forward)
    if 'QuestionAnswering' in model_name:
        return [p for p in signature.parameters if 'label' in p or p in ('start_positions', 'end_positions')]
    else:
        return [p for p in signature.parameters if 'label' in p]


def get_function(method_or_function: Union[MethodType, FunctionType]) -> FunctionType:
    if isinstance(method_or_function, MethodType):
        method_or_function = method_or_function.__func__
    return method_or_function


def is_instance_of_ms_model(model: Module) -> bool:
    """avoid import modelscope: circular dependency problem"""
    for m_cls in model.__class__.__mro__:
        cls_name = m_cls.__name__
        cls_module = m_cls.__module__
        if cls_name == 'Model' and cls_module.startswith('modelscope'):
            return True
    return False


def per_token_loss_func_sp(outputs, labels, enable_dft_loss=False, **kwargs) -> torch.Tensor:
    """Common loss function for sequence parallel training"""
    if hasattr(outputs, 'logits'):
        logits = outputs.logits
    else:
        logits = outputs
    device = logits.device

    batch_size = logits.shape[0]
    logits = logits.view(-1, logits.shape[-1])
    labels = labels.flatten().to(device)
    sploss_parallel_size = int(os.environ.get('CELOSS_PARALLEL_SIZE', '0'))
    if sploss_parallel_size > 0:
        loss = ChunkedCrossEntropyLoss.apply(logits, labels, sploss_parallel_size)
    else:
        loss_fct = CrossEntropyLoss(reduction='none')
        loss = loss_fct(logits, labels)
    if enable_dft_loss:
        with torch.no_grad():
            target_probs = torch.exp(-loss)
        loss *= target_probs
    position_ids = sequence_parallel.real_position_ids
    if position_ids is not None:
        position_ids = sequence_parallel.pad(position_ids, padding_value=-1, position_ids=position_ids)
    loss, labels = GatherLoss.apply(loss.reshape(batch_size, -1), labels.reshape(batch_size, -1), 1, position_ids)
    if position_ids is not None and position_ids.min() == -1:
        _pos_mask = position_ids >= 0
        loss = loss[_pos_mask].contiguous()

    return loss


def per_token_loss_func(outputs, labels, enable_dft_loss: bool = False, **kwargs):
    logits = outputs.logits
    # Upcast to float if we need to compute the loss to avoid potential precision issues
    logits = logits.float()
    labels = torch.roll(labels, shifts=-1, dims=-1).view(-1)

    # Flatten the tokens
    logits = logits.view(-1, logits.shape[-1])
    # Enable model parallelism
    labels = labels.to(logits.device)
    loss = F.cross_entropy(logits, labels, ignore_index=-100, reduction='none')
    if enable_dft_loss:
        with torch.no_grad():
            target_probs = torch.exp(-loss)
        loss *= target_probs
    return loss


def _kwargs_to_args(func, args, kwargs) -> Optional[List[Any]]:
    parameters = inspect.signature(func).parameters
    args = list(args)
    parameters = list(parameters.items())[len(args):]
    for key, param in parameters:
        if key in kwargs:
            args.append(kwargs[key])
        elif param.default != param.empty:
            args.append(param.default)
        else:
            return
    return args


def _add_gradient_checkpointing(module_list):

    requires_grad = None

    def _new_forward(self, *args, **kwargs):
        nonlocal requires_grad
        if requires_grad is None:
            requires_grad = any(p.requires_grad for p in self.parameters())

        new_args = _kwargs_to_args(self.__old_forward, args, kwargs)
        if new_args is not None and self.gradient_checkpointing and self.training:
            if new_args and isinstance(new_args[0], torch.Tensor) and requires_grad and not new_args[0].requires_grad:
                new_args[0].requires_grad_(True)
            layer_ret = self._gradient_checkpointing_func(self.__old_forward, *new_args)
            logger.info_once('Successfully using dynamic gradient checkpointing.')
        else:
            layer_ret = self.__old_forward(*args, **kwargs)
        return layer_ret

    for module in module_list:
        module.gradient_checkpointing = False
        if hasattr(module, '_old_forward'):  # device_map
            __old_forward = module._old_forward
            module._old_forward = MethodType(_new_forward, module)
        else:
            __old_forward = module.forward
            module.forward = MethodType(_new_forward, module)
        module.__old_forward = __old_forward


def find_module_list(model) -> Optional[nn.ModuleList]:
    module_lists = []
    for m in model.modules():
        if hasattr(m, 'gradient_checkpointing') or m.__class__.__name__ == 'CheckpointWrapper':
            return
        if (isinstance(m, (nn.ModuleList, nn.Sequential)) and len(m) >= 10
                and 'mlp' not in m[0].__class__.__name__.lower()):  # fix moe
            module_lists.append(m)
    if module_lists:
        return max(module_lists, key=lambda x: len(x))


def dynamic_gradient_checkpointing(model, including_vit: bool = False) -> None:
    if isinstance(model, PeftModel):
        model = model.model
    model_meta: ModelMeta = getattr(model, 'model_meta', None)
    if model_meta is not None and model_meta.is_multimodal and model_meta.model_arch:
        tower_names = model_meta.model_arch.language_model.copy()
        if including_vit:
            tower_names += model_meta.model_arch.vision_tower
    else:
        tower_names = [None]

    model.supports_gradient_checkpointing = True
    for tower_name in tower_names:
        if tower_name is None:
            model_tower = model
        else:
            model_tower = deep_getattr(model, tower_name)
        if model_tower is None:
            continue
        model_tower.supports_gradient_checkpointing = True
        module_list = find_module_list(model_tower)
        if module_list is None:
            continue
        _add_gradient_checkpointing(module_list)
        logger.info(f'Automatically add gradient_checkpointing to {model_tower.__class__}.')


@contextmanager
def disable_gradient_checkpointing(model: PreTrainedModel, gradient_checkpointing_kwargs: Optional[Dict] = None):
    """
    Temporarily disable gradient checkpointing, restoring the previous state afterward.

    When gradient checkpointing is enabled with use_reentrant=True (default), calling the model inside a
    torch.no_grad() block triggers a harmless PyTorch warning ("None of the inputs have requires_grad=True").
    Temporarily disable checkpointing to avoid this warning during inference.

    Args:
        model (`PreTrainedModel`):
            Model for which to temporarily disable gradient checkpointing.
        gradient_checkpointing_kwargs (`dict` or `None`, *optional*):
            Additional kwargs for gradient checkpointing enabling.
    """
    was_enabled = getattr(model, 'is_gradient_checkpointing', False)
    if was_enabled:
        model.gradient_checkpointing_disable()
    try:
        yield
    finally:
        if was_enabled:
            model.gradient_checkpointing_enable(gradient_checkpointing_kwargs)


def gather_for_unpadded_tensors(input_data, use_gather_object=False):
    from accelerate.utils import gather_object
    if getattr(sequence_parallel, 'dp_group', None) is not None:
        input_data = sequence_parallel._gather_object_dp(input_data)
    else:
        input_data = gather_object(input_data)
    output = []
    for _data in input_data:
        if len(_data.shape) == 0:
            _data = _data.unsqueeze(0)
        _data = _data.cpu()
        output.append(_data)
    if len(output[0].shape) == 1 and output[0].shape[0] > 1:
        data = torch.stack(output, dim=0)
    else:
        data = torch.concat(output, dim=0)
    return data


def calculate_max_steps(args: 'TrainingArguments', dataset) -> int:
    if args.max_steps and args.max_steps > 0:
        max_steps = args.max_steps
    else:
        len_dataset = len(dataset)
        _, _, world_size, _ = get_dist_setting()
        total_train_batch_size = args.per_device_train_batch_size * args.gradient_accumulation_steps * world_size
        num_update_steps_per_epoch = len_dataset // total_train_batch_size
        num_update_steps_per_epoch = max(num_update_steps_per_epoch, 1)
        max_steps = math.ceil(args.num_train_epochs * num_update_steps_per_epoch)
    return max_steps


def extract_version(name: str) -> Optional[int]:
    if not name.startswith('v'):
        return None
    try:
        num = name[1:].split('-', 1)[0]
        return int(num)
    except ValueError:
        return None


def get_previous_version_from_path(current_path: str) -> Optional[str]:
    from pathlib import Path
    current = Path(current_path)
    parent = current.parent
    current_name = current.name

    candidates = [d for d in parent.iterdir() if d.is_dir()]

    valid = [(d.name, extract_version(d.name)) for d in candidates]
    valid = [(name, ver) for name, ver in valid if ver is not None]

    valid.sort(key=lambda x: x[1])
    names = [name for name, _ in valid]

    if current_name not in names:
        return None

    idx = names.index(current_name)
    if idx == 0:
        return None

    prev_name = names[idx - 1]
    return str(parent / prev_name)


def get_resume_dir(output_dir):
    return get_previous_version_from_path(output_dir)


def replace_index_file(output_dir: str):
    import json
    import os
    from transformers.utils import SAFE_WEIGHTS_INDEX_NAME, WEIGHTS_INDEX_NAME
    index_file = os.path.join(output_dir, WEIGHTS_INDEX_NAME)

    if not os.path.exists(index_file):
        return
    with open(index_file, 'r', encoding='utf-8') as f:
        bin_data = json.load(f)
    if 'weight_map' not in bin_data:
        return
    bin_data['weight_map'] = {
        k: v.replace('pytorch_model', 'model').replace('.bin', '.safetensors')
        for k, v in bin_data['weight_map'].items()
    }
    safe_path = os.path.join(output_dir, SAFE_WEIGHTS_INDEX_NAME)
    with open(safe_path, 'w', encoding='utf-8') as f:
        json.dump(bin_data, f, indent=2)
    from contextlib import suppress
    with suppress(FileNotFoundError):
        os.remove(os.path.join(output_dir, WEIGHTS_INDEX_NAME))


@contextmanager
def patch_modelscope_hub_timeout():
    __init__ = HubApi.__init__

    def __new_init__(self, *args, **kwargs):
        timeout = kwargs.get('timeout')
        if timeout is not None and timeout > 5:
            kwargs['timeout'] = 5
        __init__(self, *args, **kwargs)

    HubApi.__init__ = __new_init__

    try:
        yield
    finally:
        HubApi.__init__ = __init__
