import functools
from abc import ABC, abstractmethod
from time import time
from typing import Dict, List, Optional, Tuple, Type

import torch

from colossalai.utils import get_current_device
from colossalai.utils.memory import colo_device_memory_capacity

from .chunk import Chunk, ChunkManager
from .memory_tracer import ChunkMemStatsCollector


class PlacementPolicy(ABC):
    need_mem_stats: bool = False

    def __init__(self,
                 chunk_manager: ChunkManager,
                 mem_stats_collector: Optional[ChunkMemStatsCollector] = None) -> None:
        self.chunk_manager = chunk_manager
        self.mem_stats_collector: Optional[ChunkMemStatsCollector] = mem_stats_collector

    @abstractmethod
    def evict_tensors(self, can_evict_chunks: List[Chunk], **kwargs) -> Tuple[int, float]:
        raise NotImplementedError

    @staticmethod
    def get_default_device() -> torch.device:
        return torch.device('cpu')


class CPUPlacementPolicy(PlacementPolicy):

    def __init__(self,
                 chunk_manager: ChunkManager,
                 mem_stats_collector: Optional[ChunkMemStatsCollector] = None) -> None:
        super().__init__(chunk_manager, mem_stats_collector=mem_stats_collector)

    def evict_tensors(self, can_evict_chunks: List[Chunk], **kwargs) -> Tuple[int, float]:
        volume = 0
        start = time()
        for chunk in can_evict_chunks:
            self.chunk_manager.release_chunk(chunk)
            self.chunk_manager.move_chunk(chunk, torch.device('cpu'))
            volume += chunk.chunk_mem
        return volume, time() - start


class CUDAPlacementPolicy(PlacementPolicy):

    def __init__(self,
                 chunk_manager: ChunkManager,
                 mem_stats_collector: Optional[ChunkMemStatsCollector] = None) -> None:
        assert torch.cuda.is_available(), 'Cannot use CUDATensorPlacementPolicy when CUDA is not available'
        super().__init__(chunk_manager, mem_stats_collector=mem_stats_collector)

    def evict_tensors(self, can_evict_chunks: List[Chunk], **kwargs) -> Tuple[int, float]:
        return 0, 0

    @staticmethod
    def get_default_device() -> torch.device:
        return get_current_device()


class AutoPlacementPolicy(PlacementPolicy):

    need_mem_stats: bool = True
    # model data will use 1-_warmup_non_model_data_ratio CUDA memory in warmup phase
    # you can set them by AutoPlacementPolicy.set_warmup_non_model_data_ratio()
    # and AutoPlacementPolicy.set_steady_cuda_cap_ratio()
    _warmup_non_model_data_ratio: float = 0.8
    _steady_cuda_cap_ratio: float = 0.9

    def __init__(self,
                 chunk_manager: ChunkManager,
                 mem_stats_collector: Optional[ChunkMemStatsCollector] = None) -> None:
        super().__init__(chunk_manager, mem_stats_collector=mem_stats_collector)

    def evict_tensors(self,
                      can_evict_chunks: List[Chunk],
                      cuda_demand: int = 0,
                      warmup: bool = True,
                      compute_list: Optional[List[Tuple[Chunk, ...]]] = None,
                      compute_idx: int = 0,
                      **kwargs) -> Tuple[int, float]:
        """
        Evict tensors from CUDA device.

        Args:
            can_evict_chunks (List[StatefulTensor]): the list of tensors that can be evicted.
            cuda_demand (int, optional): the volume of data needed on cuda device. Defaults to 0.
            warmup (bool, optional): a flag indicates whether in the phase of warmup. Defaults to True.
            compute_list (List[StatefulTensor], optional): TODO. Defaults to [].
            compute_idx (int, optional): the idx of computing device. Defaults to 0.

        Raises:
            RuntimeError:

        Returns:
            int: the volume of memory that is evicted
        """
        start = time()
        cuda_capacity = colo_device_memory_capacity(get_current_device())
        used_cuda_model_data = self.chunk_manager.total_mem['cuda']
        if warmup:
            # We designate a part of CUDA memory for model data in warmup iterations.
            max_cuda_non_model_data_per_period = cuda_capacity * AutoPlacementPolicy._warmup_non_model_data_ratio
        else:
            # max non-model-data cuda memory consumption of this sampling moment and the next sampling moment.
            max_cuda_non_model_data_per_period = self.mem_stats_collector.next_period_non_model_data_usage('cuda')
            cuda_capacity *= AutoPlacementPolicy._steady_cuda_cap_ratio
        total_cuda_model_data = cuda_capacity - max_cuda_non_model_data_per_period
        avail_cuda_model_data = total_cuda_model_data - used_cuda_model_data
        freed_cuda_model_data = 0

        if avail_cuda_model_data < cuda_demand:
            # Move cuda_demand - avail_cuda_model_data volume of tensors
            # to_free_cuda_model_data = cuda_demand - avail_cuda_model_data
            to_free_cuda_model_data = cuda_demand - avail_cuda_model_data
            to_free_chunks = can_evict_chunks
            if not warmup:
                to_free_chunks = self._sort_can_evict_chunks(tuple(to_free_chunks), compute_idx, tuple(compute_list))
                # print(self._sort_can_evict_chunks.cache_info())
            for chunk in to_free_chunks:
                if freed_cuda_model_data >= to_free_cuda_model_data:
                    break

                self.chunk_manager.release_chunk(chunk)
                self.chunk_manager.move_chunk(chunk, torch.device('cpu'))
                freed_cuda_model_data += chunk.chunk_mem
            if freed_cuda_model_data < to_free_cuda_model_data:
                raise RuntimeError(f"Adjust layout failed! No enough CUDA memory! "
                                   f"Need {to_free_cuda_model_data}, freed {freed_cuda_model_data}")
        return freed_cuda_model_data, time() - start

    @staticmethod
    @functools.lru_cache(maxsize=None)
    def _sort_can_evict_chunks(can_evict_chunks: tuple, compute_idx: int, compute_list: tuple) -> list:
        next_compute_idx = {chunk: len(compute_list) for chunk in can_evict_chunks}
        for i in range(len(compute_list) - 1, compute_idx, -1):
            for chunk in compute_list[i]:
                if chunk in next_compute_idx:
                    next_compute_idx[chunk] = i
        next_compute_idx = sorted(next_compute_idx.items(), key=lambda pair: pair[1], reverse=True)
        return [t for (t, idx) in next_compute_idx]

    @staticmethod
    def set_warmup_non_model_data_ratio(ratio: float) -> None:
        ratio = float(ratio)
        assert 0.0 < ratio < 1.0
        AutoPlacementPolicy._warmup_non_model_data_ratio = ratio

    @staticmethod
    def set_steady_cuda_cap_ratio(ratio: float) -> None:
        ratio = float(ratio)
        assert 0.0 < ratio < 1.0
        AutoPlacementPolicy._steady_cuda_cap_ratio = ratio


class ConstPlacementPolicy(PlacementPolicy):

    need_mem_stats: bool = False
    _accessed_memory_boundary = 512 * 1024**2

    def __init__(self,
                 chunk_manager: ChunkManager,
                 mem_stats_collector: Optional[ChunkMemStatsCollector] = None) -> None:
        super().__init__(chunk_manager, mem_stats_collector=mem_stats_collector)

    def evict_tensors(self,
                      can_evict_chunks: List[Chunk],
                      cuda_demand: int = 0,
                      warmup: bool = True,
                      compute_list: Optional[List[Tuple[Chunk, ...]]] = None,
                      compute_idx: int = 0,
                      **kwargs) -> Tuple[int, float]:
        """
        See the docstrings in the class `AutoPlacementPolicy`.
        """
        start = time()
        used_accessed_memory = self.chunk_manager.accessed_mem
        avail_accessed_memory = ConstPlacementPolicy._accessed_memory_boundary - used_accessed_memory
        freed_accessed_memory = 0

        if avail_accessed_memory < cuda_demand:
            to_free_memory = cuda_demand - avail_accessed_memory
            to_free_chunks = can_evict_chunks

            if not warmup:
                # sort all chunks
                to_free_chunks = self._sort_can_evict_chunks(tuple(to_free_chunks), compute_idx, tuple(compute_list))

            for chunk in to_free_chunks:
                if freed_accessed_memory >= to_free_memory:
                    break

                self.chunk_manager.release_chunk(chunk)
                self.chunk_manager.move_chunk(chunk, torch.device('cpu'))
                freed_accessed_memory += chunk.chunk_mem

            if freed_accessed_memory < to_free_memory:
                raise RuntimeError(f"Adjust layout failed! No enough CUDA memory! "
                                   f"Need {to_free_memory}, freed {freed_accessed_memory}")
        return freed_accessed_memory, time() - start

    @staticmethod
    @functools.lru_cache(maxsize=None)
    def _sort_can_evict_chunks(can_evict_chunks: tuple, compute_idx: int, compute_list: tuple) -> list:
        next_compute_idx = {chunk: len(compute_list) for chunk in can_evict_chunks}
        for i in range(len(compute_list) - 1, compute_idx, -1):
            for chunk in compute_list[i]:
                if chunk in next_compute_idx:
                    next_compute_idx[chunk] = i
        next_compute_idx = sorted(next_compute_idx.items(), key=lambda pair: pair[1], reverse=True)
        return [t for (t, idx) in next_compute_idx]

    @staticmethod
    def set_const_memory_boundary(cuda_memory_mb: int) -> None:
        boundary = int(cuda_memory_mb * 1024**2)
        assert boundary > 0
        ConstPlacementPolicy._accessed_memory_boundary = boundary


class PlacementPolicyFactory:
    policies: Dict[str, Type[PlacementPolicy]] = {
        'cpu': CPUPlacementPolicy,
        'cuda': CUDAPlacementPolicy,
        'auto': AutoPlacementPolicy,
        'const': ConstPlacementPolicy
    }

    @staticmethod
    def create(policy_name: str) -> Type[PlacementPolicy]:
        if policy_name not in PlacementPolicyFactory.policies:
            raise TypeError(f"Unknown tensor placement policy {policy_name}")
        return PlacementPolicyFactory.policies[policy_name]

    @staticmethod
    def get_policy_names():
        return tuple(PlacementPolicyFactory.policies.keys())

    @staticmethod
    def get_default_device(policy_name: str) -> torch.device:
        policy_cls = PlacementPolicyFactory.create(policy_name)
        return policy_cls.get_default_device()
