from scipy import stats
import cProfile
import io
import math
import pstats

from random import randint, random

import matplotlib.pyplot as plt


def gen_intList(n: int) -> list:
    """
    Génère une liste de `n` entiers aléatoires
    :param n:
    :return:
    """
    return [randint(1, n) for _ in range(n)]


def gen_floatList(n: int) -> list:
    """
    Génère une liste de `n` réels aléatoires
    :param n:
    :return:
    """
    return [random() * randint(1, n) for _ in range(n)]


def gen_boolList(n: int) -> list:
    """
    Génère une liste de `n` booléens aléatoires
    :param n:
    :return:
    """
    return [bool(randint(0, 1)) for _ in range(n)]


def gen_n(n: int) -> int:
    """
    Retourne `n` sous forme d'entier
    :param n:
    :return:
    """
    return n


class ProfileRunner:
    """
    ProfileRunner est une classe qui permet de lancer un _profiler_ sur une série de fonctions pour
    plusieurs valeurs d'entrée et ensuite de tracer les courbes des temps d'exécution.
    Les functions en question prennent soit un entier comme argument, soit une liste de valeurs.
    """

    def __init__(self, name: str = None):
        '''

        :param name:
        '''
        # Le dictionnaire des fonctions à profiler, indexées par leur nom
        # (ce qui signifie que le nom doit être unique).
        self._functions = {}
        # Le dictionnaire des générateurs de données alimentant les fonctions lors du profilage
        self._generator = {}
        self._profiler = cProfile.Profile()
        self._name = name

        # La liste des valeurs sur lesquels exécuter le profilage
        self.perf_y = None
        # Le dictionnaire des temps d'exécution pour chaque fonction (indexée par son nom) et pour toutes les valeurs
        self.perf_x = None

    def addFunction(self, function: callable(...), generator: callable(int) = gen_n) -> None:
        """
        Ajoute une fonction (et son générateur associé) au profiler.
        :param function: la fonction à profiler
        :param generator: fonction qui prend comme argument une taille `n` et qui génère des données à passer à la
                        fonction `function`. Par défaut, on passe la valeur `n`.
        :return: None
        """
        self._functions[function.__name__] = function
        self._generator[function.__name__] = generator

    def runProfiler(self, test_range, precompute=True, correct: callable([int, int]) = lambda v, n : v) -> None:
        """
        Exécute itérativement le profileur sur l'ensemble des fonctions/générateurs déclarés pour toutes les valeurs
        de `n` spécifiées dans `test_range`.
        :param test_range: l'ensemble des valeurs de `n` à tester
        :param precompute: drapeau indiquant si on appelle le générateur avant (True) ou pendant le profilage (False)
        :param correct: fonction corrective à appliquer aux temps d'exécution mesurés durant le profilage
        :return: None
        """
        self.perf_x = [i for i in test_range]
        self.perf_y = {}

        for func, gen in self._generator.items():
            self.perf_y[func] = []
            print(f"Profiling {func}")
            if precompute:
                def generator(d):
                    return d
            else:
                generator = gen

            self._profiler = cProfile.Profile()

            for n in self.perf_x:
                if precompute:
                    data = gen(n)
                else:
                    data = n

                self._profiler.runcall(self._functions[func], generator(data))
                self._profiler.create_stats()
                s = io.StringIO()
                ps = pstats.Stats(self._profiler, stream=s).strip_dirs()

                keys = [k for k in ps.stats.keys() if k[2] == func]
                self.perf_y[func].append(correct(ps.stats[keys[0]][3], n))

    def plotResults(self, title=None, regression=None) -> None:
        """
        Affiche le graphe des résultats du _profiler_
        :return: None
        """
        if regression=='lin':
            def regression_func(x, y, name=None):
                reg = stats.linregress(x, y)
                p = plt.scatter(x,y, label=func, marker='+')
                color = p.get_facecolor()

                plt.axline(xy1=(0, reg.intercept), slope=reg.slope, linestyle="--", color=color)
        else:
            regression_func = None

        plt.figure(self._name)
        for func in self.perf_y:
            fnc_y = self.perf_y[func]
            corr_y = [max(0,fnc_y[i]-fnc_y[i-1]) for i in range(1,len(fnc_y))]

            if regression_func is not None:
                regression_func(self.perf_x[1:], corr_y, func)
            else:
                plt.plot(self.perf_x[1:], corr_y, label=func)

        plt.title(title)
        plt.legend()

# Exemple d'utilisation sur les différentes façons de calculer une factorielle
if __name__ == '__main__':

    def fact_rec(n: int) -> int:
        if n <= 1:
            return 1
        else:
            return n * fact_rec(n-1)

    def fact_it(n: int) -> int:
        v = 1
        if n > 1:
            for i in range(1,n+1):
                v = v * i
        return v

    def fact_builtin(n: int) -> int:
        return math.factorial(n)


    pr = ProfileRunner()
    pr.addFunction(fact_rec, gen_n)
    pr.addFunction(fact_it, gen_n)
    pr.addFunction(fact_builtin, gen_n)

    pr.runProfiler(range(1, 300, 5))
    pr.plotResults()

    plt.show()
