##    _____  _____
##   |  __ \|  __ \    AUTHOR: Pedro Rivero
##   | |__) | |__) |   ---------------------------------
##   |  ___/|  _  /    DATE: May 19, 2021
##   | |    | | \ \    ---------------------------------
##   |_|    |_|  \_\   https://github.com/pedrorrivero
##

## Copyright 2021 Pedro Rivero
##
## Licensed under the Apache License, Version 2.0 (the "License");
## you may not use this file except in compliance with the License.
## You may obtain a copy of the License at
##
## http://www.apache.org/licenses/LICENSE-2.0
##
## Unless required by applicable law or agreed to in writing, software
## distributed under the License is distributed on an "AS IS" BASIS,
## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
## See the License for the specific language governing permissions and
## limitations under the License.

from struct import pack, unpack
from typing import Any, Callable, Final, Optional, Union

from numpy import float64, uint32, uint64
from randomgen import UserBitGenerator

from .caches import BasicCache, BitCache
from .platforms import QuantumPlatform
from .protocols import QuantumProtocol


###############################################################################
## QUANTUM BIT GENERATOR (FACADE)
###############################################################################
class QuantumBitGenerator(UserBitGenerator):
    """
    A quantum random bit-generator which can interface with NumPy's random
    module.

    Parameters
    ----------
    platform: QuantumPlatform
        The quantum platform that will be used for QRNG.
    protocol: QuantumProtocol
        The quantum protocol that will be used for QRNG.
    max_bits_per_request: int, optional
        The maximum number of bits that will be retrieved per request.
    ISRAW32: bool, default: False
        Toggle 32-bit BitGenerator mode. If `False` the mode will be 64-bit.
        This determines the default number of output BITS. Final: once an
        object is instantiated, it cannot be overridden.

    Attributes
    ----------
    BITS: int
        The default number of output bits: either 32 or 64.
    bitcache: BitCache
        The cache to store retrieved random bits.
    max_bits_per_request: int, optional
        The maximum number of bits retrieved per request.
    platform: QuantumPlatform
        The quantum platform used for QRNG.
    protocol: QuantumProtocol
        The quantum protocol used for QRNG.

    Methods
    -------
    dump_cache(flush: bool = False) -> str
        Returns all the contents stored in the cache.
    flush_cache() -> None:
        Erase the cache.
    load_cache(bitstring: str, flush: bool = False) -> None:
        Load cache from bitstring.
    random_bitstring(num_bits: int = 0) -> str:
        Returns a random bitstring of a given lenght.
    random_double(n: float = 1) -> float:
        Returns a random double from a uniform distribution in the range [0,n).
    random_uint(num_bits: int = 0) -> int:
        Returns a random unsigned int of a given size in bits.

    Notes
    -----
    It implements an efficient strategy to retrieve random bits from the cloud quantum backends. Namely, on every conection, it retrieves as many bits as possible and stores them in a cache. This way, the total number of internet connections is greatly reduced.
    """

    def __init__(
        self,
        platform: QuantumPlatform,
        protocol: QuantumProtocol,
        max_bits_per_request: Optional[int] = None,
        ISRAW32: bool = False,
    ) -> None:
        self.platform: QuantumPlatform = platform
        self.protocol: QuantumProtocol = protocol
        self.max_bits_per_request: Optional[int] = max_bits_per_request
        self._ISRAW32: Final[bool] = ISRAW32
        self._bitcache: BitCache = self._build_cache()
        super().__init__(
            bits=self.BITS,
            next_raw=self._next_raw,
            next_32=self._next_32,
            next_64=self._next_64,
            next_double=self._next_double,
        )

    ############################### PUBLIC API ###############################
    @property
    def BITS(self) -> int:
        """
        The default number of output bits: either 32 or 64.

        Final: it cannot be modified after instantiation through the ISRAW32 parameter. This is required by NumPy (e.g. `random_raw()` method).
        """
        return 32 if self._ISRAW32 else 64

    @property
    def bitcache(self) -> BitCache:
        """
        The cache to store retrieved random bits.
        """
        return self._bitcache

    @property
    def max_bits_per_request(self) -> Optional[int]:
        """
        The maximum number of bits retrieved per request.
        """
        return self._max_bits_per_request

    @max_bits_per_request.setter
    def max_bits_per_request(self, mbpr: Optional[int]) -> None:
        mbpr = mbpr if mbpr and type(mbpr) is int and mbpr > 0 else None
        self._max_bits_per_request: Optional[int] = mbpr

    @property
    def platform(self) -> QuantumPlatform:
        """
        The quantum platform used for QRNG.
        """
        return self._platform

    @platform.setter
    def platform(self, p: QuantumPlatform) -> None:
        self._platform = p

    @property
    def protocol(self) -> QuantumProtocol:
        """
        The quantum protocol used for QRNG.
        """
        return self._protocol

    @protocol.setter
    def protocol(self, p: QuantumProtocol) -> None:
        self._protocol = p

    def dump_cache(self, flush: bool = False) -> str:
        """
        Returns all the contents stored in the cache.

        Parameters
        ----------
        flush: bool, default: False
            If `True` erase the cache after dumping.

        Returns
        -------
        out: str
            The complete bitstring stored in cache.
        """
        bitstring: str = self.bitcache.dump()
        if flush:
            self.bitcache.flush()
        return bitstring

    def flush_cache(self) -> None:
        """
        Erase the cache.
        """
        self.bitcache.flush()

    def load_cache(self, bitstring: str, flush: bool = False) -> None:
        """
        Load cache from bitstring.

        Parameters
        ----------
        bitstring: str
            The bitstring to load to cache.
        flush: bool, default: False
            If `True` erase cache before loading.
        """
        if flush:
            self.bitcache.flush()
        self.bitcache.push(bitstring)

    def random_bitstring(self, num_bits: int = 0) -> str:
        """
        Returns a random bitstring of a given lenght.

        Parameters
        ----------
        num_bits: int, default: 0
            Number of bits to retrieve. If less than one it defaults to the raw
            number of BITS for the instance QuantumBitGenerator (i.e. 32 or 64).

        Returns
        -------
        out: str
            Bitstring of lenght `num_bits`.
        """
        if num_bits < 1:
            num_bits = self.BITS
        while self.bitcache.size < num_bits:
            self._refill_cache()
        return self.bitcache.pop(num_bits)

    def random_double(self, n: float = 1) -> float:
        """
        Returns a random double from a uniform distribution in the range
        [0,n).

        Parameters
        ----------
        n: float, default: 1
            Size of the range [0,n) from which to draw the random number.

        Returns
        -------
        out: float
            Random float in the range [0,n).

        Notes
        -----
        COPYRIGHT NOTICE
        Source: https://github.com/ozanerhansha/qRNG
        License: GNU GENERAL PUBLIC LICENSE VERSION 3
        Changes:
            - Add static type hints
            - Limit range to [0,n) instead of [min,max) and add default
            - Replace call to original get_random_int64
        """
        unpacked = 0x3FF0000000000000 | self.random_uint(64) >> 12
        packed = pack("Q", unpacked)
        value: float = unpack("d", packed)[0] - 1.0
        return value * n

    def random_uint(self, num_bits: int = 0) -> int:
        """
        Returns a random unsigned int of a given size in bits.

        Parameters
        ----------
        num_bits: int, default: 0
            Number of bits to retrieve. If less than one it defaults to the raw
            number of BITS for the instance QuantumBitGenerator (i.e. 32 or 64).

        Returns
        -------
        out: int
            Unsigned int of `num_bits` bits.
        """
        if num_bits < 1:
            num_bits = self.BITS
        return int(self.random_bitstring(num_bits), 2)

    ############################### PRIVATE API ###############################
    def _build_cache(self) -> BitCache:
        """
        Build new BitCache.

        Returns
        -------
        out: BitCache
            New BasicCache instance.
        """
        return BasicCache()

    def _refill_cache(self) -> None:
        """
        Refill cache by fetching new random bits.
        """
        bitstring: str = self.platform.fetch_random_bits(
            self.protocol, self.max_bits_per_request
        )
        self.bitcache.push(bitstring)

    ############################# NUMPY INTERFACE #############################
    @property
    def _next_raw(self) -> Callable[[Any], Union[uint32, uint64]]:
        """
        A callable that returns either 64 or 32 random bits. It must accept a single input which is a void pointer to a memory address.
        """
        return self._next_32 if self._ISRAW32 else self._next_64

    @property
    def _next_32(self) -> Callable[[Any], uint32]:
        """
        A callable with the same signature as as next_raw that always returns
        a random numpy 32-bit unsigned int.
        """

        def next_32(void_p: Any) -> uint32:
            return uint32(self.random_uint(32))

        return next_32

    @property
    def _next_64(self) -> Callable[[Any], uint64]:
        """
        A callable with the same signature as as next_raw that always returns
        a random numpy 64-bit unsigned int.
        """

        def next_64(void_p: Any) -> uint64:
            return uint64(self.random_uint(64))

        return next_64

    @property
    def _next_double(self) -> Callable[[Any], float64]:
        """
        A callable with the same signature as as next_raw that always return
        a random double in [0,1).
        """

        def next_double(void_p: Any) -> float64:
            return float64(self.random_double(1))

        return next_double
