# Copyright (c) ModelScope Contributors. All rights reserved.
import bisect
import mmap
import os
import pickle
import threading
from modelscope.hub.utils.utils import get_cache_dir
from queue import Queue
from torch.utils.data import Dataset
from typing import Any, List


class IndexedDatasetBuilder:
    CHUNK_SIZE = 1e10

    def __init__(self, dataset_name: str):
        self.cache_dir = IndexedDataset.get_cache_dir(dataset_name)
        self.n_shard = 1
        self.bin_path = os.path.join(self.cache_dir, IndexedDataset.BIN_FNAME.format(0))
        self.idx_path = os.path.join(self.cache_dir, IndexedDataset.IDX_FNAME)
        if os.path.exists(self.bin_path):
            os.remove(self.bin_path)
        self.bin_file = open(self.bin_path, 'ab')
        self.length_list = []
        self.idx_list = [0]
        self.shard_offset = [0]
        self._thread = None
        self._queue = Queue(maxsize=1000)

    def _write_worker(self):
        while True:
            items = self._queue.get()
            if items is None:
                break
            bin_buffer = []
            for item in items:
                item_buffer = pickle.dumps(item)
                bin_buffer.append(item_buffer)
                self.idx_list.append(self.idx_list[-1] + len(item_buffer))
                self.length_list.append(item['length'])
            self.bin_file.write(b''.join(bin_buffer))
            offset = self.idx_list[-1] - self.shard_offset[-1]
            if offset >= self.CHUNK_SIZE:
                self.bin_file.close()
                self.bin_path = os.path.join(self.cache_dir, IndexedDataset.BIN_FNAME.format(self.n_shard))
                self.shard_offset.append(self.shard_offset[-1] + offset)
                self.n_shard += 1
                if os.path.exists(self.bin_path):
                    os.remove(self.bin_path)
                self.bin_file = open(self.bin_path, 'ab')

    def add_items(self, items: List[Any]) -> None:
        if self._thread is None:
            self._thread = threading.Thread(target=self._write_worker, daemon=True)
            self._thread.start()
        self._queue.put(items)

    def finalize(self):
        if self._thread is not None:
            self._queue.put(None)
            self._thread.join()
        self.bin_file.close()
        idx_obj = {
            'idx': self.idx_list,
            'length': self.length_list,
            'n_shard': self.n_shard,
            'shard_offset': self.shard_offset,
        }
        with open(self.idx_path, 'wb') as f:
            pickle.dump(idx_obj, f)


class BinReader:

    def __init__(self, bin_path: str):
        self.bin_path = bin_path
        self.file = open(bin_path, 'rb')
        try:
            self.mm = mmap.mmap(self.file.fileno(), 0, access=mmap.ACCESS_READ)
        except ValueError:
            # For example, self.file is an empty file.
            self.mm = None

    def read_buffer(self, offset: int, size: int) -> bytes:
        if offset < 0 or size < 0 or offset + size > len(self.mm):
            raise ValueError('Invalid offset or size')
        return self.mm[offset:offset + size]

    def __del__(self):
        if self.mm is not None:
            self.mm.close()
        self.file.close()


class IndexedDataset(Dataset):
    BIN_FNAME = 'data-{:05d}.bin'
    IDX_FNAME = 'data.idx'

    @staticmethod
    def get_cache_dir(dataset_name: str):
        cache_dir = os.getenv('PACKING_CACHE') or os.path.join(get_cache_dir(), 'tmp')
        cache_dir = os.path.join(cache_dir, dataset_name)
        os.makedirs(cache_dir, exist_ok=True)
        assert dataset_name is not None, f'dataset_name: {dataset_name}'
        return cache_dir

    def __init__(self, dataset_name: str):
        self.dataset_name = dataset_name
        cache_dir = self.get_cache_dir(dataset_name)
        self.idx_path = os.path.join(cache_dir, self.IDX_FNAME)
        with open(self.idx_path, 'rb') as f:
            idx_obj = pickle.load(f)
        self.idx_list = idx_obj['idx']
        self.length_list = idx_obj['length']
        self.n_shard = idx_obj['n_shard']
        self.shard_offset = idx_obj['shard_offset']
        self.bin_readers = []
        for i in range(self.n_shard):
            bin_path = os.path.join(cache_dir, self.BIN_FNAME.format(i))
            self.bin_readers.append(BinReader(bin_path))

    def __getitem__(self, index: int):
        if index < 0:
            index = index % len(self)
        idx, idx_next = self.idx_list[index], self.idx_list[index + 1]
        num_shard = bisect.bisect_right(self.shard_offset, idx)
        offset = self.shard_offset[num_shard - 1]
        buffer = self.bin_readers[num_shard - 1].read_buffer(idx - offset, idx_next - idx)
        return pickle.loads(buffer)

    def __len__(self):
        return len(self.idx_list) - 1
