import copy
import itertools
import multiprocessing
from random import Random
import unittest

import torch

from infinibatch.iterators import *


class TestBase(unittest.TestCase):
    def setUp(self):
        self.lengths = [1, 2, 3, 42, 57]
        self.world_sizes = [1, 2, 3, 4, 5, 11, 16, 64, 73]
        self.seed = 42

    def assertMultisetEqual(self, a, b):
        def list_to_dict(l):
            d = {}
            for item in l:
                d[item] = d.get(item, 0) + 1
            return d

        self.assertEqual(list_to_dict(a), list_to_dict(b))


class TestFiniteIteratorMixin:
    """
    Mixin to be used in combination with TestBase
    to test basic function of finite CheckpointableIterators
    """

    def test_basic(self):
        for case_name, expected_result, it in self.test_cases:
            with self.subTest(case_name):
                result = list(it)
                self.assertEqual(result, expected_result)


class TestFiniteIteratorCheckpointingMixin:
    """
    Mixin to be used in combination with TestBase
    to test checkpointing functionality of finite CheckpointableIterators
    """

    def test_checkpointing_reset(self):
        for case_name, _, it in self.test_cases:
            with self.subTest(case_name):
                expected_result = list(it)  # extract data
                it.setstate(None)  # reset to start
                result = list(it)
                self.assertEqual(result, expected_result)

    # TODO: Can this be rewritten in terms of _test_checkpointing_from_pos?
    def test_checkpointing_from_start(self):
        for case_name, _, it in self.test_cases:
            with self.subTest(case_name):
                checkpoint = it.getstate()
                expected_result = list(it)  # extract data
                it.setstate(checkpoint)  # reset to start
                result = list(it)
                self.assertEqual(result, expected_result)

    def _test_checkpointing_from_pos(self, it, pos):
        for _ in range(pos):  # go to pos
            next(it)
        checkpoint = it.getstate()  # take checkpoint
        expected_result = list(it)  # extract data
        it.setstate(checkpoint)  # reset to checkpoint
        result = list(it)
        self.assertEqual(result, expected_result)

    def test_checkpointing_from_one(self):
        for case_name, _, it in self.test_cases:
            with self.subTest(case_name):
                pos = 1
                self._test_checkpointing_from_pos(it, pos)

    def test_checkpointing_from_quarter(self):
        for case_name, _, it in self.test_cases:
            with self.subTest(case_name):
                expected_result = list(it)
                it.setstate(None)
                pos = len(expected_result) // 4
                self._test_checkpointing_from_pos(it, pos)

    def test_checkpointing_from_third(self):
        for case_name, _, it in self.test_cases:
            with self.subTest(case_name):
                expected_result = list(it)
                it.setstate(None)
                pos = len(expected_result) // 3
                self._test_checkpointing_from_pos(it, pos)

    def test_checkpointing_from_half(self):
        for case_name, _, it in self.test_cases:
            with self.subTest(case_name):
                expected_result = list(it)
                it.setstate(None)
                pos = len(expected_result) // 2
                self._test_checkpointing_from_pos(it, pos)

    def test_checkpointing_before_end(self):
        for case_name, _, it in self.test_cases:
            with self.subTest(case_name):
                expected_result = list(it)
                it.setstate(None)
                pos = len(expected_result) - 1
                self._test_checkpointing_from_pos(it, pos)

    def test_checkpointing_at_end(self):
        for case_name, _, it in self.test_cases:
            with self.subTest(case_name):
                list(it)  # exhaust iterator
                self.assertRaises(StopIteration, it.__next__)
                checkpoint = it.getstate()  # take checkpoint
                it.setstate(None)  # reset to beginning
                it.setstate(checkpoint)  # reset to checkpoint
                self.assertRaises(StopIteration, it.__next__)

    def test_checkpointing_complex(self):
        for case_name, _, it in self.test_cases:
            with self.subTest(case_name):
                expected_result = list(it)

                # get a bunch of checkpoints at different positions
                it.setstate(None)
                positions = [
                    0,
                    len(expected_result) // 7,
                    len(expected_result) // 6,
                    len(expected_result) // 5,
                    len(expected_result) // 4,
                    len(expected_result) // 3,
                    len(expected_result) // 2,
                ]
                checkpoints = []
                for i in range(len(positions)):
                    offset = positions[i] - positions[i - 1] if i > 0 else positions[0]
                    for _ in range(offset):
                        next(it)
                    checkpoints.append(it.getstate())

                # check that iterator returns correct result at all checkpoints
                for pos, checkpoint in zip(positions, checkpoints):
                    it.setstate(checkpoint)
                    self.assertEqual(list(it), expected_result[pos:])

                # check that iterator returns correct result at all checkpoints in reverse order
                tuples = list(zip(positions, checkpoints))
                tuples.reverse()
                for pos, checkpoint in tuples:
                    it.setstate(checkpoint)
                    self.assertEqual(list(it), expected_result[pos:])

                # check that iterator returns correct result at all checkpoints
                # while resetting between any two checkpoints
                for pos, checkpoint in zip(positions, checkpoints):
                    it.setstate(None)
                    it.setstate(checkpoint)
                    self.assertEqual(list(it), expected_result[pos:])

                # and as the grand finale: reset and check again
                it.setstate(None)
                result = list(it)
                self.assertEqual(result, expected_result)


class TestInfinitePermutationSourceIterator(TestBase):
    def setUp(self):
        super().setUp()
        self.repeats = [1, 2, 3]

    def test_no_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data), shuffle=False, num_instances=num_instances, instance_rank=instance_rank
                    )
                    repeated_data = []
                    while len(repeated_data) < k * n * num_instances:
                        repeated_data.extend(data)
                    expected_result = []
                    pos = instance_rank
                    while len(expected_result) < k * n:
                        expected_result.append(repeated_data[pos])
                        pos += num_instances
                    result = [next(it) for _ in range(k * n)]
                    self.assertEqual(result, expected_result)

    def test_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data),
                        seed=self.seed,
                        shuffle=True,
                        num_instances=num_instances,
                        instance_rank=instance_rank,
                    )
                    random = Random(self.seed)
                    repeated_data = []
                    while len(repeated_data) < k * n * num_instances:
                        shuffled_data = copy.deepcopy(data)
                        random.shuffle(shuffled_data)
                        repeated_data.extend(shuffled_data)
                    expected_result = []
                    pos = instance_rank
                    while len(expected_result) < k * n:
                        expected_result.append(repeated_data[pos])
                        pos += num_instances
                    result = [next(it) for _ in range(k * n)]
                    self.assertEqual(result, expected_result)

    def test_single_instance_no_shuffle(self):
        # this test is technically included in test_no_shuffle
        # but the calculation of the expected result is less error prone
        for n, k in itertools.product(self.lengths, self.repeats):
            with self.subTest(f"n={n}, k={k}"):
                data = list(range(n))
                expected_result = data * k
                it = InfinitePermutationSourceIterator(copy.deepcopy(data), shuffle=False)
                result = [next(it) for _ in range(k * n)]
                self.assertEqual(result, expected_result)

    def test_single_instance_shuffle(self):
        # this test is technically included in test_shuffle
        # but the calculation of the expected result is less error prone
        for n, k in itertools.product(self.lengths, self.repeats):
            with self.subTest(f"n={n}, k={k}"):
                data = list(range(n))
                expected_result = data * k
                it = InfinitePermutationSourceIterator(copy.deepcopy(data), seed=self.seed, shuffle=True)
                result = [next(it) for _ in range(k * n)]
                self.assertMultisetEqual(result, expected_result)

    def test_checkpointing_reset_no_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data), shuffle=False, num_instances=num_instances, instance_rank=instance_rank
                    )
                    expected_result = [next(it) for _ in range(k * n)]  # extract data
                    it.setstate(None)  # reset to start
                    result = [next(it) for _ in range(k * n)]
                    self.assertEqual(result, expected_result)

    def test_checkpointing_reset_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data),
                        seed=self.seed,
                        shuffle=True,
                        num_instances=num_instances,
                        instance_rank=instance_rank,
                    )
                    expected_result = [next(it) for _ in range(k * n)]  # extract data
                    it.setstate(None)  # reset to start
                    result = [next(it) for _ in range(k * n)]
                    self.assertEqual(result, expected_result)

    def test_checkpointing_from_start_no_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data), shuffle=False, num_instances=num_instances, instance_rank=instance_rank
                    )
                    checkpoint = it.getstate()
                    expected_result = [next(it) for _ in range(k * n)]  # extract data
                    it.setstate(checkpoint)  # reset to start
                    result = [next(it) for _ in range(k * n)]
                    self.assertEqual(result, expected_result)

    def test_checkpointing_from_start_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data),
                        seed=self.seed,
                        shuffle=True,
                        num_instances=num_instances,
                        instance_rank=instance_rank,
                    )
                    checkpoint = it.getstate()
                    expected_result = [next(it) for _ in range(k * n)]  # extract data
                    it.setstate(checkpoint)  # reset to start
                    result = [next(it) for _ in range(k * n)]
                    self.assertEqual(result, expected_result)

    def test_checkpointing_from_middle_no_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data), shuffle=False, num_instances=num_instances, instance_rank=instance_rank
                    )
                    checkpoint_pos = k * n // 3
                    for _ in range(checkpoint_pos):  # go to checkpoint_pos
                        next(it)
                    checkpoint = it.getstate()  # take checkpoint
                    expected_result = [next(it) for _ in range(k * n)]  # extract data
                    for _ in range(checkpoint_pos):  # move forward some more
                        next(it)
                    it.setstate(checkpoint)  # reset to checkpoint
                    result = [next(it) for _ in range(k * n)]  # get data again
                    self.assertEqual(result, expected_result)

    def test_checkpointing_from_middle_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data),
                        seed=self.seed,
                        shuffle=True,
                        num_instances=num_instances,
                        instance_rank=instance_rank,
                    )
                    checkpoint_pos = k * n // 3
                    for _ in range(checkpoint_pos):  # go to checkpoint_pos
                        next(it)
                    checkpoint = it.getstate()  # take checkpoint
                    expected_result = [next(it) for _ in range(k * n)]  # extract data
                    for _ in range(checkpoint_pos):  # move forward some more
                        next(it)
                    it.setstate(checkpoint)  # reset to checkpoint
                    result = [next(it) for _ in range(k * n)]  # get data again
                    self.assertEqual(result, expected_result)

    def test_checkpointing_at_boundary_no_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data), shuffle=False, num_instances=num_instances, instance_rank=instance_rank
                    )
                    checkpoint_pos = k * n
                    for _ in range(checkpoint_pos):  # go to checkpoint_pos
                        next(it)
                    checkpoint = it.getstate()  # take checkpoint
                    expected_result = [next(it) for _ in range(k * n)]  # extract data
                    for _ in range(checkpoint_pos):  # move forward some more
                        next(it)
                    it.setstate(checkpoint)  # reset to checkpoint
                    result = [next(it) for _ in range(k * n)]  # get data again
                    self.assertEqual(result, expected_result)

    def test_checkpointing_at_boundary_shuffle(self):
        for n, k, num_instances in itertools.product(self.lengths, self.repeats, self.world_sizes):
            data = list(range(n))
            for instance_rank in range(num_instances):
                with self.subTest(f"n={n}, k={k}, num_instances={num_instances}, instance_rank={instance_rank}"):
                    it = InfinitePermutationSourceIterator(
                        copy.deepcopy(data),
                        seed=self.seed,
                        shuffle=True,
                        num_instances=num_instances,
                        instance_rank=instance_rank,
                    )
                    checkpoint_pos = k * n
                    for _ in range(checkpoint_pos):  # go to checkpoint_pos
                        next(it)
                    checkpoint = it.getstate()  # take checkpoint
                    expected_result = [next(it) for _ in range(k * n)]  # extract data
                    for _ in range(checkpoint_pos):  # move forward some more
                        next(it)
                    it.setstate(checkpoint)  # reset to checkpoint
                    result = [next(it) for _ in range(k * n)]  # get data again
                    self.assertEqual(result, expected_result)

    def test_empty_source(self):
        f = lambda: InfinitePermutationSourceIterator([])
        self.assertRaises(ValueError, f)

    def test_rank_too_large(self):
        f = lambda: InfinitePermutationSourceIterator([1], num_instances=2, instance_rank=2)
        self.assertRaises(ValueError, f)


class TestChunkedSourceIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            data = list(range(n))
            it = ChunkedSourceIterator(copy.deepcopy(data))
            self.test_cases.append(("n={}".format(n), data, it))

    def test_multiple_instances(self):
        for n, num_instances in itertools.product(self.lengths, self.world_sizes):
            with self.subTest("n={}, num_instances={}".format(n, num_instances)):
                data = list(range(n))
                result = []
                sizes = []
                for instance_rank in range(num_instances):
                    it = ChunkedSourceIterator(
                        copy.deepcopy(data), num_instances=num_instances, instance_rank=instance_rank
                    )
                    output = list(it)
                    result.extend(output)
                    sizes.append(len(output))
                self.assertEqual(data, result)
                self.assertTrue(max(sizes) - min(sizes) <= 1)  # make sure data is split as evenly as possible

    def test_rank_too_large(self):
        def create_iterator():
            it = ChunkedSourceIterator([1], num_instances=2, instance_rank=2)

        self.assertRaises(ValueError, create_iterator)


class TestSamplingRandomMapIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    @staticmethod
    def transform(random, item):
        return item + random.random()

    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            data = list(range(n))
            random = Random()
            random.seed(self.seed)
            expected_result = [n + random.random() for n in data]
            it = SamplingRandomMapIterator(NativeCheckpointableIterator(data), transform=self.transform, seed=self.seed)
            self.test_cases.append(("n={}".format(n), expected_result, it))


class TestMapIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    @staticmethod
    def transform(item):
        return 2 * item

    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            data = list(range(n))
            expected_result = [self.transform(item) for item in data]
            it = MapIterator(NativeCheckpointableIterator(data), self.transform)
            self.test_cases.append(("n={}".format(n), expected_result, it))


class TestZipIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    def setUp(self):
        super().setUp()
        self.test_cases = []

        # pairs
        for n in self.lengths:
            data1 = list(range(n))
            data2 = [item * item for item in data1]
            expected_result = list(zip(data1, data2))
            it = ZipIterator(NativeCheckpointableIterator(data1), NativeCheckpointableIterator(data2))
            self.test_cases.append(("n={}, pairs".format(n), expected_result, it))

        # triples
        for n in self.lengths:
            data1 = list(range(n))
            data2 = [item * item for item in data1]
            data3 = [item * item for item in data2]
            expected_result = list(zip(data1, data2, data3))
            it = ZipIterator(
                NativeCheckpointableIterator(data1),
                NativeCheckpointableIterator(data2),
                NativeCheckpointableIterator(data3),
            )
            self.test_cases.append(("n={}, triples".format(n), expected_result, it))

        # different lengths
        for n in self.lengths:
            if n > 3:  # smaller n give us an empty iterator, which causes issues
                data1 = list(range(n))
                data2 = [item * item for item in data1]
                data2 = data2[:-3]
                expected_result = list(zip(data1, data2))
                it = ZipIterator(NativeCheckpointableIterator(data1), NativeCheckpointableIterator(data2))
                self.test_cases.append(("n={}, different lengths".format(n), expected_result, it))


class TestPrefetchIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            for buffer_size in self.lengths:
                data = list(range(n))
                it = PrefetchIterator(NativeCheckpointableIterator(data), buffer_size)
                self.test_cases.append(("n={}, buffer_size={}".format(n, buffer_size), data, it))

    def test_zero_buffer_size(self):
        f = lambda: PrefetchIterator(NativeCheckpointableIterator([0]), buffer_size=0)
        self.assertRaises(ValueError, f)

    def test_torch_tensors(self):
        for n in self.lengths:
            for buffer_size in self.lengths:
                with self.subTest("n={}, buffer_size={}".format(n, buffer_size)):
                    data = [torch.Tensor([float(i)]) for i in range(n)]
                    it = PrefetchIterator(NativeCheckpointableIterator(copy.deepcopy(data)), buffer_size)
                    result = list(it)
                    self.assertEqual(result, data)


class TestPrefetchIteratorExperimental(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            for buffer_size in self.lengths:
                data = list(range(n))
                it = PrefetchIterator(NativeCheckpointableIterator(data), buffer_size, buffer_in_main_process=True)
                self.test_cases.append(("n={}, buffer_size={}".format(n, buffer_size), data, it))

    def test_zero_buffer_size(self):
        f = lambda: PrefetchIterator(NativeCheckpointableIterator([0]), buffer_size=0, buffer_in_main_process=True)
        self.assertRaises(ValueError, f)

    def test_closing(self):
        if multiprocessing.get_start_method() != "fork":
            return  # dummy iterator used, skip test
        it = PrefetchIterator(NativeCheckpointableIterator([0]), buffer_size=42, buffer_in_main_process=True)
        it.close()
        f = lambda: it.__next__()
        self.assertRaises(RuntimeError, f)
        f = lambda: it.setstate(None)
        self.assertRaises(RuntimeError, f)

    def test_nested(self):
        for n in self.lengths:
            for buffer_size in self.lengths:
                for depth in [2, 3, 4, 5]:
                    with self.subTest("n={}, buffer_size={}, depth={}".format(n, buffer_size, depth)):
                        data = [torch.Tensor([float(i)]) for i in range(n)]
                        it = NativeCheckpointableIterator(copy.deepcopy(data))
                        for _ in range(depth):
                            it = PrefetchIterator(it, buffer_size, buffer_in_main_process=True)
                        result = list(it)
                        self.assertEqual(result, data)
                        it.close()

    def test_torch_tensors(self):
        for n in self.lengths:
            for buffer_size in self.lengths:
                with self.subTest("n={}, buffer_size={}".format(n, buffer_size)):
                    data = [torch.Tensor([float(i)]) for i in range(n)]
                    it = PrefetchIterator(
                        NativeCheckpointableIterator(copy.deepcopy(data)), buffer_size, buffer_in_main_process=True
                    )
                    result = list(it)
                    self.assertEqual(result, data)
                    it.close()

    def tearDown(self):
        if hasattr(self, "test_cases"):
            for _, _, it in self.test_cases:
                it.close()


class TestMultiplexIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    # TODO: Add test cases for behavior when source iterators end but item is retrieved
    def setUp(self):
        super().setUp()
        random = Random()
        random.seed(42)
        self.test_cases = []

        # two source iterators
        for n in self.lengths:
            indices = [random.randrange(0, 2) for _ in range(n)]
            data = [[2 * i + 0 for i in range(n)], [2 * i + 1 for i in range(n)]]
            data_copy = copy.deepcopy(data)
            expected_result = [data_copy[i].pop(0) for i in indices]
            it = MultiplexIterator(
                NativeCheckpointableIterator(indices), [NativeCheckpointableIterator(d) for d in data]
            )
            self.test_cases.append(("n={}, two source iterators".format(n), expected_result, it))

        # three source iterators
        for n in self.lengths:
            indices = [random.randrange(0, 3) for _ in range(n)]
            data = [[3 * i + 0 for i in range(n)], [3 * i + 1 for i in range(n)], [3 * i + 2 for i in range(n)]]
            data_copy = copy.deepcopy(data)
            expected_result = [data_copy[i].pop(0) for i in indices]
            it = MultiplexIterator(
                NativeCheckpointableIterator(indices), [NativeCheckpointableIterator(d) for d in data]
            )
            self.test_cases.append(("n={}, three source iterators".format(n), expected_result, it))


class TestNativeCheckpointableIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            data = list(range(n))
            expected_result = copy.deepcopy(data)
            it = NativeCheckpointableIterator(data)
            self.test_cases.append(("n={}".format(n), expected_result, it))

    def test_empty(self):
        it = NativeCheckpointableIterator([])
        self.assertRaises(StopIteration, it.__next__)

    def test_iterator_exception(self):
        self.assertRaises(ValueError, NativeCheckpointableIterator, iter(range(10)))


class TestFixedBatchIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            for batch_size in self.lengths:
                data = list(range(n))
                data_copy = copy.deepcopy(data)
                expected_result = []
                while data_copy:
                    expected_result.append(data_copy[:batch_size])
                    data_copy = data_copy[batch_size:]
                it = FixedBatchIterator(NativeCheckpointableIterator(data), batch_size=batch_size)
                self.test_cases.append(("n={}, batch_size={}".format(n, batch_size), expected_result, it))

    def test_invalid_batch_size(self):
        f = lambda: FixedBatchIterator(NativeCheckpointableIterator([0]), batch_size=0)
        self.assertRaises(ValueError, f)


class TestRecurrentIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    @staticmethod
    def step_function(prev_state, item):
        output = prev_state + item
        return output, output

    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            data = list(range(n))
            expected_result = [data[0]]
            for i in data[1:]:
                expected_result.append(self.step_function(expected_result[-1], i)[1])
            it = RecurrentIterator(NativeCheckpointableIterator(data), self.step_function, initial_state=0)
            self.test_cases.append(("n={}".format(n), expected_result, it))


class TestSelectManyIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    @staticmethod
    def custom_selector(l):
        return [l[0]]

    def setUp(self):
        super().setUp()
        self.test_cases = []

        # default selector
        for n in self.lengths:
            for list_length in [1, 4, 9]:
                data = list(range(n))
                expected_result = copy.deepcopy(data)
                lists = []
                while data:
                    lists.append(data[:list_length])
                    data = data[list_length:]
                it = SelectManyIterator(NativeCheckpointableIterator(lists))
                self.test_cases.append(
                    ("n={}, list_length={}, default selector".format(n, list_length), expected_result, it)
                )

        # custom selector
        for n in self.lengths:
            for list_length in [4, 9]:
                data = list(range(n))
                expected_result = [item for i, item in enumerate(data) if (i % list_length) == 0]
                lists = []
                while data:
                    lists.append(data[:list_length])
                    data = data[list_length:]
                it = SelectManyIterator(NativeCheckpointableIterator(lists), collection_selector=self.custom_selector)
                self.test_cases.append(
                    ("n={}, list_length={}, custom selector".format(n, list_length), expected_result, it)
                )


class TestBlockwiseShuffleIterator(TestBase, TestFiniteIteratorCheckpointingMixin):
    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            for block_size in self.lengths:
                data = list(range(n))
                it = BlockwiseShuffleIterator(NativeCheckpointableIterator(copy.deepcopy(data)), block_size, self.seed)
                self.test_cases.append(("n={}, block_size={}".format(n, block_size), data, it))

    def test_basic(self):
        for case_name, expected_result, it in self.test_cases:
            with self.subTest(case_name):
                result = list(it)
                self.assertMultisetEqual(result, expected_result)


class TestWindowedIterator(TestBase, TestFiniteIteratorMixin, TestFiniteIteratorCheckpointingMixin):
    def setUp(self):
        super().setUp()
        self.test_cases = []
        for n in self.lengths:
            for window_size in self.lengths:
                if n < window_size:
                    continue
                data = list(range(n))
                it = WindowedIterator(NativeCheckpointableIterator(copy.deepcopy(data)), window_size)
                expected_result = []
                for i in range(len(data)):
                    if i + window_size > len(data):
                        break
                    expected_result.append(tuple(data[i : i + window_size]))
                self.test_cases.append(("n={}, window_size={}".format(n, window_size), expected_result, it))


class TestSourceIterator(TestBase):
    # TODO: Do we need more tests for this?
    def test_exception(self):
        self.assertRaises(ValueError, create_source_iterator, [1], train=False, shuffle=True)


class TestBucketedReadaheadBatchIterator(TestBase, TestFiniteIteratorCheckpointingMixin):
    dynamic_batch_size = 15

    @staticmethod
    def key_fn(item):
        return len(item)

    @staticmethod
    def batch_size_fn(item):
        return TestBucketedReadaheadBatchIterator.dynamic_batch_size // len(item)

    @staticmethod
    def boundary_key_fn(item):
        return len(item) < 5

    @staticmethod
    def setup_data(n):
        data = []
        for i in range(n):
            data.append(tuple(range(i % 10 + 1)))
        return data

    def setUp(self):
        super().setUp()
        self.batch_sizes = [1, 2, 3, 9]
        self.test_cases = []

        # fixed batch size, not shuffled, no boundary key
        for n, read_ahead in itertools.product(self.lengths, self.lengths):
            for batch_size in self.batch_sizes:
                data = self.setup_data(n)
                it = BucketedReadaheadBatchIterator(
                    NativeCheckpointableIterator(copy.deepcopy(data)),
                    read_ahead=read_ahead,
                    key=self.key_fn,
                    batch_size=batch_size,
                    shuffle=False,
                )
                self.test_cases.append(
                    (
                        "n={}, read_ahead={}, batch_size={}, boundary_key=None, shuffled=False".format(
                            n, read_ahead, batch_size
                        ),
                        data,
                        it,
                    )
                )

        # fixed batch size, shuffled, no boundary key
        for n, read_ahead in itertools.product(self.lengths, self.lengths):
            for batch_size in self.batch_sizes:
                data = self.setup_data(n)
                it = BucketedReadaheadBatchIterator(
                    NativeCheckpointableIterator(copy.deepcopy(data)),
                    read_ahead=read_ahead,
                    key=self.key_fn,
                    batch_size=batch_size,
                    shuffle=True,
                    seed=self.seed,
                )
                self.test_cases.append(
                    (
                        "n={}, read_ahead={}, batch_size={}, boundary_key=None, shuffled=True".format(
                            n, read_ahead, batch_size
                        ),
                        data,
                        it,
                    )
                )

        # dynamic batch size, not shuffled, no boundary key
        for n, read_ahead in itertools.product(self.lengths, self.lengths):
            data = self.setup_data(n)
            it = BucketedReadaheadBatchIterator(
                NativeCheckpointableIterator(copy.deepcopy(data)),
                read_ahead=read_ahead,
                key=self.key_fn,
                batch_size=self.batch_size_fn,
                shuffle=False,
            )
            self.test_cases.append(
                (
                    "n={}, read_ahead={}, batch_size=dynamic, boundary_key=None, shuffled=False".format(n, read_ahead),
                    data,
                    it,
                )
            )

        # dynamic batch size, shuffled, no boundary key
        for n, read_ahead in itertools.product(self.lengths, self.lengths):
            data = self.setup_data(n)
            it = BucketedReadaheadBatchIterator(
                NativeCheckpointableIterator(copy.deepcopy(data)),
                read_ahead=read_ahead,
                key=self.key_fn,
                batch_size=self.batch_size_fn,
                shuffle=True,
                seed=self.seed,
            )
            self.test_cases.append(
                (
                    "n={}, read_ahead={}, batch_size=dynamic, boundary_key=None, shuffled=True".format(n, read_ahead),
                    data,
                    it,
                )
            )

        # fixed batch size, not shuffled, boundary key
        for n, read_ahead in itertools.product(self.lengths, self.lengths):
            for batch_size in self.batch_sizes:
                data = self.setup_data(n)
                it = BucketedReadaheadBatchIterator(
                    NativeCheckpointableIterator(copy.deepcopy(data)),
                    read_ahead=read_ahead,
                    key=self.key_fn,
                    batch_size=batch_size,
                    boundary_key=self.boundary_key_fn,
                    shuffle=False,
                )
                self.test_cases.append(
                    (
                        "n={}, read_ahead={}, batch_size={}, boundary_key=len(item)<5, shuffled=False".format(
                            n, read_ahead, batch_size
                        ),
                        data,
                        it,
                    )
                )

        # fixed batch size, shuffled, boundary key
        for n, read_ahead in itertools.product(self.lengths, self.lengths):
            for batch_size in self.batch_sizes:
                data = self.setup_data(n)
                it = BucketedReadaheadBatchIterator(
                    NativeCheckpointableIterator(copy.deepcopy(data)),
                    read_ahead=read_ahead,
                    key=self.key_fn,
                    batch_size=batch_size,
                    boundary_key=self.boundary_key_fn,
                    shuffle=True,
                    seed=self.seed,
                )
                self.test_cases.append(
                    (
                        "n={}, read_ahead={}, batch_size={}, boundary_key=len(item)<5, shuffled=True".format(
                            n, read_ahead, batch_size
                        ),
                        data,
                        it,
                    )
                )

        # dynamic batch size, not shuffled, boundary key
        for n, read_ahead in itertools.product(self.lengths, self.lengths):
            data = self.setup_data(n)
            it = BucketedReadaheadBatchIterator(
                NativeCheckpointableIterator(copy.deepcopy(data)),
                read_ahead=read_ahead,
                key=self.key_fn,
                batch_size=self.batch_size_fn,
                boundary_key=self.boundary_key_fn,
                shuffle=False,
                seed=self.seed,
            )
            self.test_cases.append(
                (
                    "n={}, read_ahead={}, batch_size=dynamic, boundary_key=len(item)<5, shuffled=False".format(
                        n, read_ahead
                    ),
                    data,
                    it,
                )
            )

        # dynamic batch size, shuffled, boundary key
        for n, read_ahead in itertools.product(self.lengths, self.lengths):
            data = self.setup_data(n)
            it = BucketedReadaheadBatchIterator(
                NativeCheckpointableIterator(copy.deepcopy(data)),
                read_ahead=read_ahead,
                key=self.key_fn,
                batch_size=self.batch_size_fn,
                boundary_key=self.boundary_key_fn,
                shuffle=True,
                seed=self.seed,
            )
            self.test_cases.append(
                (
                    "n={}, read_ahead={}, batch_size=dynamic, boundary_key=len(item)<5, shuffled=True".format(
                        n, read_ahead
                    ),
                    data,
                    it,
                )
            )

    def test_basic(self):
        for case_name, expected_result, it in self.test_cases:
            with self.subTest(case_name):
                result = list(it)
                flattened_result = [item for batch in result for item in batch]
                self.assertMultisetEqual(flattened_result, expected_result)

    def test_max_len(self):
        for case_name, expected_result, it in self.test_cases:
            if "batch_size=dynamic" in case_name:
                with self.subTest(case_name):
                    result = list(it)
                    for batch in result:
                        length = sum((len(item) for item in batch))
                        self.assertTrue(length <= TestBucketedReadaheadBatchIterator.dynamic_batch_size)

    def test_boundary_key(self):
        for case_name, expected_result, it in self.test_cases:
            if "boundary_key=len(item)<5" in case_name:
                with self.subTest(case_name):
                    result = list(it)
                    for batch in result:
                        boundary_keys = [self.boundary_key_fn(item) for item in batch]
                        self.assertTrue(all(boundary_keys) or not any(boundary_keys))


if __name__ == "__main__":
    unittest.main()
