"""
Simulations of Physically Unclonable Functions (PUFs).
"""
from numpy import ndarray, empty


class Simulation:
    """
    A PUF simulation that can be evaluated on challenges.
    """

    @property
    def challenge_length(self) -> int:
        """ The expected challenge length of this simulation, ``int``. """
        raise NotImplementedError()

    @property
    def response_length(self) -> int:
        """ The length of responses generated by this simulation, ``int``. """
        raise NotImplementedError()

    def eval(self, challenges: ndarray) -> ndarray:
        """
        Evaluate the PUF on a list of given challenges.

        :param challenges: List of challenges to evaluate on. Challenges must be given as ``ndarray`` of shape
            (:math:`N`, ``challenge_length``), where :math:`N` is the number of challenges to be evaluated.
            Evaluating many challenges at once may have performance benefits, to evaluate a single challenge, provide
            an ``ndarray`` with shape (1, ``challenge_length``). In cases where ``challenge_length`` = 0, an
            empty array with shape (:math:`N`, 0) needs to be provided to determine the number of responses requested.
        :return: ``ndarray``, shape (:math:`N`, ``response_length``), listing the simulated responses to the
            challenges in order they were given.
        """
        raise NotImplementedError()

    def r_eval(self, r: int, challenges: ndarray) -> ndarray:
        """
        Evaluates the Simulation ``r`` times on the list of :math:`N` ``challenges`` given and returns an array
        of shape (``r``, :math:`N`, ``self.response_length``) of all responses.

        >>> from pypuf.simulation.delay import XORArbiterPUF
        >>> from pypuf.io import random_inputs
        >>> puf = XORArbiterPUF(n=64, k=4, noisiness=.02, seed=1)
        >>> responses = puf.r_eval(5, random_inputs(N=2, n=64, seed=2))
        >>> responses[:, 0]  # unstable example
        array([[ 1.],
               [ 1.],
               [-1.],
               [ 1.],
               [ 1.]])
        >>> responses[:, 1]  # stable example
        array([[-1.],
               [-1.],
               [-1.],
               [-1.],
               [-1.]])

        .. note::
            To approximate the expected respones value, use average along the first axis:

            >>> from numpy import average
            >>> average(responses, axis=0)
            array([[ 0.6],
                   [-1. ]])
        """
        N = challenges.shape[0]
        responses = empty(shape=(r, N, self.response_length))
        for i in range(r):
            responses[i, :] = self.eval(challenges).reshape(N, self.response_length)
        return responses

    @staticmethod
    def seed(description: str) -> int:
        """
        Helper function that turns a string into an integer that can be consumed as seed by a pseudo-random number
        generator (PRNG). Usage scenario: create a descriptive seed and use it to initialize the PRNG.

        >>> from numpy.random import default_rng
        >>> from pypuf.simulation.base import Simulation
        >>> seed = 'parameter seed for my PUF instance'
        >>> prng = default_rng(seed=Simulation.seed(seed))
        >>> parameters = prng.normal(size=(3, 4))
        >>> parameters
        array([[ 1.64917478, -1.28702893,  0.17287684, -1.69475886],
               [-1.74432269,  1.59592227,  1.12435243, -0.23488442],
               [-0.74190059,  0.95516568, -2.25170753, -0.22208081]])

        """
        return int.from_bytes(description.encode(), byteorder='big')
