from random import randrange, Random
from typing import Callable, TypeAlias


RNG: TypeAlias = Callable[[int], int]


def rng(sides: int) -> int:
    """A truly random number generator.

    >>> assert 1 <= rng(6) <= 6

    Uses the global ``Random.randrange()`` function.
    """
    return randrange(sides) + 1


def high(sides: int) -> int:
    """A fake number generator that always returns the highest roll.

    >>> [high(6) for _ in range(3)]
    [6, 6, 6]
    """
    return sides


def low(sides: int) -> int:
    """A fake number generator that always returns the lowest roll.

    >>> [low(6) for _ in range(3)]
    [1, 1, 1]

    The lowest roll is always 1.
    """
    return 1


def mid(sides: int) -> int:
    """A fake number generator that always returns the middle roll.

    If the number of sides is odd, the middle value in [1, sides] is returned.

    >>> [mid(3) for _ in range(3)]
    [2, 2, 2]

    If the number of sides is even, the lower of the two middle values in
    [1, sides] is returned.

    >>> [mid(6) for _ in range(3)]
    [3, 3, 3]
    """
    return (sides + 1) // 2


def AscendingRNG(initial_value: int) -> RNG:
    """Create a fake number generator that returns ascending values.

    The returned ``RNG`` callable produces an ascending sequence of values,
    starting with ``initial_value``.

    >>> arng = AscendingRNG(2)
    >>> [arng(6) for _ in range(3)]
    [2, 3, 4]

    If the internal value is less than 1 or greater than the ``sides`` argument,
    the ``RNG`` will apply the modulus functions so that values wrap to the
    required range of ``[1, sides]``.

    >>> arng = AscendingRNG(0)
    >>> [arng(6) for _ in range(3)]
    [6, 1, 2]

    >>> arng = AscendingRNG(7)
    >>> [arng(6) for _ in range(3)]
    [1, 2, 3]

    :param int initial_value:
    :return: a fake number generator that returns ascending values.
    """
    ascending_value = initial_value

    def ascending(sides: int) -> int:
        nonlocal ascending_value
        next_value = (ascending_value - 1) % sides + 1
        ascending_value += 1
        return next_value

    return ascending


def FixedRNG(fixed_value: int) -> RNG:
    """Create a fake number generator that returns a fixed value.

    The returned ``RNG`` callable produces values based a fixed value.

    >>> always_2 = FixedRNG(2)
    >>> [always_2(6) for _ in range(3)]
    [2, 2, 2]

    If the fixed value is less than 1, 1 is returned.

    >>> always_1 = FixedRNG(0)
    >>> [always_1(6) for _ in range(3)]
    [1, 1, 1]

    If the fixed value is greater than the ``sides`` argument, ``sides`` is
    returned.

    >>> always_6 = FixedRNG(6)
    >>> always_6(6)
    6
    >>> always_6(4)
    4

    :param int fixed_value:
    :return: a fake number generator that returns a fixed value.
    """
    return lambda sides: _clamp(1, fixed_value, sides)


def PRNG(seed: int) -> RNG:
    """Create a pseudo-random number generator.

    The returned ``RNG`` callable produces a deterministic sequence of values
    based on the seed value.  The returned number generator is useful when
    reproducibility is needed, such as for testing.

    >>> prng = PRNG(1122334455)
    >>> sides = 6
    >>> [prng(sides) for _ in range(10)]
    [4, 4, 2, 6, 3, 1, 1, 1, 5, 3]

    :param int seed: The seed value that selects a the deterministic sequence.
    :return: a pseudo-random number generator.
    """
    r = Random(seed)
    return lambda sides: r.randrange(sides) + 1


class SequenceRNG:
    """A fake number generator that returns values from a given sequence.

    ``SequenceRNG`` instances are callable objects that match the ``RNG`` type
    alias.

    >>> srng = SequenceRNG(2, 5, 1, 4)
    >>> [srng(6) for _ in range(3)]
    [2, 5, 1]

    You can check if all values in the ``SequenceRNG`` have been consumed using
    the ``is_empty`` property.

    >>> srng.is_empty
    False
    >>> srng(6)
    4
    >>> srng.is_empty
    True

    Calling a ``SequenceRNG`` instance with no remaining values will raise an
    exception.
    """

    def __init__(self, *values: int):
        """Create a callable ``SequenceRNG`` instance.

        :param int *values: the sequence of values to generate
        """
        self.values = list(values)

    @property
    def is_empty(self) -> bool:
        """Have all values in the sequence have been produced?"""
        return not self.values

    def __call__(self, sides: int) -> int:
        """Return the next value in the sequence.

        Raises an exception if all values have been returned.
        """
        return (self.values.pop(0) - 1) % sides + 1


def _clamp(lower: int, value: int, upper: int) -> int:
    return max(lower, min(value, upper))
