# Importation de la fonction randint du module random de Python
from random import *
# Importation de la bibliothèque numpy, souvent utilisée pour le calcul numérique
import numpy as np

"""
Différence cruciale entre les deux fonctions randint :
1. np.random.randint(0, n) génère un entier dans [0, n[ (n est exclu)
2. random.randint(0, n) génère un entier dans [0, n] (n est inclus)
"""

# Test avec numpy - création d'une liste de 1000 nombres aléatoires entre 0 (inclus) et 10 (exclus)
L_avec_numpy = [np.random.randint(0, 10) for i in range(1000)]
# Vérification si 10 apparaît dans la liste (devrait toujours être False car 10 est exclu)
print(10 in L_avec_numpy)  # Affiche False car np.random.randint(0,10) ∈ [0,9]

# Test sans numpy - création d'une liste de 1000 nombres aléatoires entre 0 et 10 (inclus)
L_sans_numpy = [randint(0, 10) for i in range(1000)]
# Vérification si 10 apparaît dans la liste (peut être True car 10 est inclus)
print(10 in L_sans_numpy)  # Peut afficher True car randint(0,10) ∈ [0,10]

"""
Explications supplémentaires :

1. Loi des grands nombres :
   - Avec suffisamment de tirages (ici 1000), on observe les probabilités théoriques
   - Pour np.random.randint(0,10), chaque nombre de 0 à 9 a une probabilité de 1/10
   - Pour random.randint(0,10), chaque nombre de 0 à 10 a une probabilité de 1/11

2. Compréhension de liste :
   - [np.random.randint(0,10) for i in range(1000)] crée une liste de 1000 éléments
   - C'est équivalent à :
     result = []
     for i in range(1000):
         result.append(np.random.randint(0,10))

3. Opérateur 'in' :
   - 10 in L vérifie si la valeur 10 est présente dans la liste L
   - Pour L_avec_numpy, ce sera toujours False (sauf bug)
   - Pour L_sans_numpy, ce sera True environ 1000*(1/11) ≈ 90 fois sur 1000 essais

Pourquoi utiliser numpy ?
- np.random.randint est plus efficace pour générer de grands tableaux de nombres aléatoires
- L'approche numpy est vectorisée et donc plus rapide pour les grandes quantités de données
"""



def uniforme(n):
    """
    Génère un nombre entier aléatoire suivant une loi uniforme discrète.
    
    Paramètres:
    n -- entier, borne supérieure de l'intervalle
    
    Retour:
    Un entier aléatoire entre 1 et n (inclus)
    
    Explication mathématique:
    La loi uniforme discrète attribue la même probabilité à chaque entier entre 1 et n.
    Ici, np.random.randint(1, n+1) génère un entier dans [1, n] car:
    - 1 est inclus (borne inférieure incluse)
    - n+1 est exclu (borne supérieure exclue)
    """
    return np.random.randint(1, n+1)  # entier aléatoire entre 1 inclus et n inclus, n+1 exclu

def probabilite_unif(x, n, N):
    """
    Estime la probabilité P(X = x) pour une loi uniforme discrète par simulation.
    
    Paramètres:
    x -- entier dont on veut estimer la probabilité
    n -- entier, borne supérieure de la loi uniforme
    N -- entier, nombre de tirages pour l'estimation
    
    Retour:
    La fréquence observée de x sur N tirages (estimation de P(X = x))
    
    Explication mathématique:
    Pour une loi uniforme discrète, théoriquement P(X = x) = 1/n pour tout x dans [1,n].
    Cette fonction estime cette probabilité par la méthode de Monte-Carlo :
    on fait N expériences et on compte combien de fois x apparaît.
    """
    S = 0  # Initialisation du compteur de succès (où X = x)
    
    # Boucle sur N expériences
    for i in range(N):
        # Si le tirage aléatoire vaut x, on incrémente le compteur
        if x == uniforme(n):
            S = S + 1
    
    # La probabilité estimée est le nombre de succès divisé par le nombre total d'essais
    return S / N
