# ============================================================================
# [LIVRE] FASTAPI - GUIDE ULTRA-DÉTAILLÉ POUR GRANDS DÉBUTANTS
# ============================================================================
# 
# [OBJECTIF] OBJECTIF DE CE GUIDE
# Ce guide est conçu pour quelqu'un qui n'a JAMAIS utilisé FastAPI.
# Chaque concept est expliqué avec:
# - POURQUOI il existe (le problème qu'il résout)
# - QUAND l'utiliser (dans quelles situations)
# - COMMENT l'implémenter (avec exemples progressifs)
#
# [DOCS] PRÉREQUIS
# - Python de base (variables, fonctions, boucles)
# - Comprendre ce qu'est une API REST (on va quand même expliquer)
# - Avoir installé Python 3.7+
#
# [TEMPS] TEMPS DE LECTURE: ~4-6 heures
# Prenez votre temps, testez chaque exemple!
# ============================================================================


# ============================================================================
# [GUIDE] CHAPITRE 0: COMPRENDRE LE CONTEXTE (AVANT FASTAPI)
# ============================================================================

"""
[REFLEXION] POURQUOI FASTAPI EXISTE-T-IL?

Pour comprendre FastAPI, commençons par comprendre les problèmes qu'il résout.

PROBLÈME #1: LES APIs SONT PARTOUT
--------------------------------
Aujourd'hui, presque toutes les applications ont besoin d'une API:
- Application mobile -> a besoin d'une API pour récupérer les données
- Site web moderne -> appelle une API pour charger le contenu
- Applications qui communiquent entre elles -> utilisent des APIs

[IDEE] Qu'est-ce qu'une API?
Une API (Application Programming Interface) est comme un serveur dans un restaurant:
- Vous (le client) donnez votre commande
- Le serveur la transmet en cuisine
- La cuisine prépare
- Le serveur vous ramène votre plat

En informatique:
- Votre app frontend = le client
- L'API = le serveur
- Votre base de données = la cuisine


PROBLÈME #2: CRÉER UNE API C'EST COMPLIQUÉ
-----------------------------------------
Sans framework, créer une API nécessite de gérer:
- [X] Recevoir les requêtes HTTP
- [X] Parser les données JSON
- [X] Valider que les données sont correctes
- [X] Gérer les erreurs
- [X] Documenter l'API
- [X] Gérer l'authentification
- [X] Tester tout ça

C'est BEAUCOUP de code répétitif!


PROBLÈME #3: LES BUGS DE VALIDATION
-----------------------------------
Imaginez une API qui crée un utilisateur:

# Sans validation:
def create_user(data):
    # Quelqu'un envoie: {"age": "vingt-cinq"}
    # Ça plante car "vingt-cinq" n'est pas un nombre!
    # Ou pire: ça s'enregistre dans la base de données...
    age = int(data["age"])  # [IMPACT] CRASH
    
# Il faut TOUT valider à la main:
def create_user(data):
    if "email" not in data:
        return {"error": "Email manquant"}
    if not is_valid_email(data["email"]):
        return {"error": "Email invalide"}
    if "age" not in data:
        return {"error": "Age manquant"}
    if not isinstance(data["age"], int):
        return {"error": "Age doit être un nombre"}
    if data["age"] < 0 or data["age"] > 150:
        return {"error": "Age invalide"}
    # ... et ainsi de suite pour CHAQUE champ
    # ... et CHAQUE endpoint de votre API!


[OBJECTIF] COMMENT FASTAPI RÉSOUT CES PROBLÈMES?
---------------------------------------

1. RAPIDITÉ DE DÉVELOPPEMENT
   - Code minimal nécessaire
   - Les tâches répétitives sont automatisées
   
2. VALIDATION AUTOMATIQUE
   - Vous définissez ce que vous attendez
   - FastAPI valide AUTOMATIQUEMENT
   - Erreurs claires si les données sont invalides
   
3. DOCUMENTATION AUTOMATIQUE
   - Swagger UI généré automatiquement
   - Vous pouvez tester votre API dans le navigateur
   - Sans écrire une ligne de doc!
   
4. TYPE HINTS PYTHON
   - FastAPI utilise les annotations de type Python
   - Votre IDE peut vous aider (auto-complétion)
   - Moins de bugs
   
5. PERFORMANCE
   - Un des frameworks Python les plus rapides
   - Comparable à NodeJS et Go
   - Grâce à async/await


[GRAPHIQUE] COMPARAISON VISUELLE
----------------------

SANS FASTAPI (avec Flask basique):
```
@app.route('/users', methods=['POST'])
def create_user():
    data = request.get_json()
    
    # Valider email
    if 'email' not in data:
        return {'error': 'Email required'}, 400
    email = data['email']
    if '@' not in email:
        return {'error': 'Invalid email'}, 400
    
    # Valider age
    if 'age' not in data:
        return {'error': 'Age required'}, 400
    try:
        age = int(data['age'])
    except ValueError:
        return {'error': 'Age must be a number'}, 400
    if age < 0 or age > 150:
        return {'error': 'Age out of range'}, 400
    
    # Créer utilisateur
    user = create_user_in_db(email, age)
    return {'id': user.id, 'email': user.email}
```

AVEC FASTAPI:
```
class User(BaseModel):
    email: EmailStr  # Validation email automatique!
    age: int = Field(ge=0, le=150)  # Validation range automatique!

@app.post("/users")
def create_user(user: User):
    # FastAPI a DÉJÀ validé que:
    # - email existe et est valide
    # - age existe, est un nombre, et est entre 0 et 150
    # Si invalide, FastAPI renvoie erreur 422 automatiquement!
    
    db_user = create_user_in_db(user.email, user.age)
    return {"id": db_user.id, "email": db_user.email}
```

Regardez la différence: 
- 25+ lignes -> 8 lignes
- Validation manuelle -> Validation automatique
- Code répétitif -> Code déclaratif
- Pas de doc -> Doc automatique


[COURS] CONCEPT CLÉ À RETENIR
------------------------
FastAPI = "Décris CE QUE TU VEUX, pas COMMENT LE FAIRE"

Au lieu d'écrire:
"Vérifie que age est un nombre, puis vérifie qu'il est positif..."

Tu écris:
"age doit être un int positif"

Et FastAPI fait le reste!
"""


# ============================================================================
# [GUIDE] CHAPITRE 1: INSTALLATION ET PREMIER PROGRAMME
# ============================================================================

"""
[OBJECTIF] OBJECTIF DE CE CHAPITRE
Installer FastAPI et créer votre toute première API en comprenant
chaque ligne de code.


ÉTAPE 1: INSTALLATION
---------------------

[IDEE] POURQUOI deux packages (fastapi + uvicorn)?
FastAPI est le framework (les règles du jeu)
Uvicorn est le serveur (celui qui fait tourner le jeu)

Analogie: FastAPI est comme les règles du Monopoly, 
          Uvicorn est la table sur laquelle vous jouez
"""

# Installation
pip install fastapi
pip install "uvicorn[standard]"

# Ou en une ligne:
pip install "fastapi[all]"  # Installe tout d'un coup

"""
[IDEE] Que signifie [standard] ou [all]?
Ce sont des "extras" - des packages optionnels mais recommandés.
[standard] = uvicorn avec les meilleures perfs
[all] = TOUT ce dont vous aurez besoin (validation, email, etc.)

CONSEIL: Pour apprendre, utilisez [all]. C'est plus simple.
"""


"""
ÉTAPE 2: VOTRE TOUT PREMIER PROGRAMME
------------------------------------

Créons le programme le plus simple possible pour comprendre les bases.
"""

# Créer un fichier: main.py

# ===== LIGNE PAR LIGNE: EXPLICATION DÉTAILLÉE =====

# Ligne 1: Import de FastAPI
from fastapi import FastAPI

"""
[REFLEXION] POURQUOI cette ligne?
FastAPI est une classe Python. Pour l'utiliser, il faut l'importer.

[IDEE] Qu'est-ce qu'une classe?
Pensez à une classe comme un "moule à gâteau".
FastAPI est le moule, et nous allons créer NOTRE gâteau (notre API).
"""


# Ligne 2: Créer une instance FastAPI
app = FastAPI()

"""
[REFLEXION] POURQUOI créer une "instance"?
L'instance 'app', c'est VOTRE API spécifique.
C'est comme si FastAPI est le moule, et 'app' est VOTRE gâteau unique.

[IDEE] Pourquoi le nom 'app'?
C'est une convention (habitude). Vous pourriez l'appeler 'mon_api' ou 'toto',
mais 'app' est standard. Tout le monde comprend que c'est votre application.

[ATTENTION] ERREUR COURANTE:
Ne PAS mettre: app = FastAPI   (sans parenthèses)
Il FAUT: app = FastAPI()   (avec parenthèses pour créer l'instance)
"""


# Ligne 3-5: Définir une route (endpoint)
@app.get("/")
def read_root():
    return {"message": "Hello World"}

"""
[REFLEXION] Décortiquons cette syntaxe bizarre...

PARTIE 1: @app.get("/")
-----------------------
Le symbole @ s'appelle un "décorateur" en Python.

[IDEE] QU'EST-CE QU'UN DÉCORATEUR?
Imaginez que vous avez une fonction normale:

def dire_bonjour():
    return "Bonjour"

Un décorateur, c'est comme mettre une enveloppe autour:

@faire_quelque_chose_avant_et_apres
def dire_bonjour():
    return "Bonjour"

Le décorateur "ajoute des pouvoirs" à votre fonction.


PARTIE 2: app.get("/")
--------------------
Ceci dit à FastAPI: "Quand quelqu'un fait une requête GET sur /, 
exécute la fonction en dessous"

[IDEE] QU'EST-CE QU'UNE REQUÊTE GET?
HTTP a plusieurs "verbes" (méthodes):
- GET = "Donne-moi des infos" (lire)
- POST = "Je t'envoie des infos" (créer)
- PUT = "Mets à jour ces infos" (modifier entièrement)
- DELETE = "Supprime ça" (supprimer)

GET est le plus simple: vous demandez juste à voir quelque chose.
C'est ce qui se passe quand vous tapez une URL dans le navigateur!


PARTIE 3: "/"
------------
Le "/" c'est le chemin (path) de votre API.

[IDEE] COMPRENDRE LES CHEMINS:
Votre API aura une adresse de base, par exemple: http://localhost:8000

Les chemins s'ajoutent après:
- http://localhost:8000/           -> "/"
- http://localhost:8000/users      -> "/users"
- http://localhost:8000/items/5    -> "/items/5"

Le "/" c'est la "page d'accueil" de votre API.


PARTIE 4: def read_root():
-------------------------
C'est une fonction Python normale!

[IDEE] POURQUOI "read_root"?
Le nom n'a PAS d'importance technique. Vous pourriez l'appeler:
- page_accueil()
- hello()
- toto()

MAIS: utilisez des noms descriptifs! "read_root" indique clairement:
"Cette fonction LIT (read) la RACINE (root = /) de l'API"

Convention de nommage recommandée:
- read_xxx pour GET (lire)
- create_xxx pour POST (créer)
- update_xxx pour PUT (modifier)
- delete_xxx pour DELETE (supprimer)


PARTIE 5: return {"message": "Hello World"}
------------------------------------------
La valeur retournée est ce que l'API renvoie au client.

[IDEE] MAGIE FASTAPI #1: CONVERSION JSON AUTOMATIQUE!
En Python, vous écrivez: {"message": "Hello World"} (un dictionnaire)
FastAPI convertit AUTOMATIQUEMENT en JSON pour le client!

JSON ressemble à un dictionnaire Python:
{"message": "Hello World"}

Mais ce n'est PAS pareil:
- Dictionnaire Python = structure de données en Python
- JSON = format texte pour transmettre des données

FastAPI fait la conversion pour vous!


[OBJECTIF] RÉSUMÉ DE CES 5 LIGNES:
"Quand quelqu'un fait GET sur /, exécute read_root() 
et renvoie {"message": "Hello World"} en JSON"
"""


"""
ÉTAPE 3: LANCER VOTRE API
-------------------------

Ouvrez un terminal dans le dossier où est main.py et tapez:
"""

uvicorn main:app --reload

"""
[REFLEXION] DÉCORTIQUONS CETTE COMMANDE:

PARTIE 1: uvicorn
----------------
C'est le serveur qui va faire tourner votre API.

[IDEE] QU'EST-CE QU'UN SERVEUR?
Un serveur, c'est un programme qui "écoute" les requêtes.
Comme un téléphone qui attend qu'on appelle!

Quand quelqu'un fait une requête HTTP, le serveur:
1. Reçoit la requête
2. La donne à FastAPI
3. FastAPI exécute votre code
4. Renvoie la réponse


PARTIE 2: main:app
-----------------
main = nom du fichier (main.py, sans le .py)
app = nom de votre variable FastAPI (dans le fichier)

[IDEE] POURQUOI main:app?
C'est comme une adresse postale:
"Va dans le fichier 'main', et utilise la variable 'app'"

[ATTENTION] ERREURS COURANTES:
- Erreur: "module main not found"
  -> Vous n'êtes pas dans le bon dossier!
  -> Utilisez 'cd' pour aller où est main.py
  
- Erreur: "attribute app not found"
  -> Votre variable s'appelle différemment
  -> Si vous avez: mon_api = FastAPI()
  -> Utilisez: uvicorn main:mon_api --reload


PARTIE 3: --reload
-----------------
[IDEE] MODE DÉVELOPPEMENT!
Avec --reload, quand vous modifiez votre code et sauvegardez,
le serveur redémarre AUTOMATIQUEMENT!

Sans --reload, vous devriez:
1. Arrêter le serveur (Ctrl+C)
2. Relancer la commande
3. Tester
4. Recommencer...

[ATTENTION] ATTENTION: --reload UNIQUEMENT EN DÉVELOPPEMENT!
En production (site réel), N'UTILISEZ PAS --reload.
C'est plus lent et moins sûr.
"""


"""
ÉTAPE 4: TESTER VOTRE API
-------------------------

Une fois lancé, vous verrez:
"""
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process
INFO:     Started server process
INFO:     Waiting for application startup.
INFO:     Application startup complete.

"""
[BRAVO] FÉLICITATIONS! Votre API tourne!

[IDEE] QU'EST-CE QUE http://127.0.0.1:8000?

127.0.0.1 = votre ordinateur ("localhost")
8000 = le port (comme un numéro de porte)

Votre ordinateur a plein de "portes" numérotées.
Le serveur écoute sur la porte 8000.


TESTER DANS LE NAVIGATEUR:
--------------------------
1. Ouvrez votre navigateur
2. Allez sur: http://127.0.0.1:8000
3. Vous devriez voir: {"message":"Hello World"}

[IDEE] QUE S'EST-IL PASSÉ?
1. Votre navigateur a fait une requête GET sur /
2. Uvicorn l'a reçue
3. FastAPI a exécuté read_root()
4. FastAPI a converti le dictionnaire en JSON
5. Uvicorn a renvoyé la réponse
6. Votre navigateur l'affiche!


TESTER LA DOCUMENTATION AUTOMATIQUE:
------------------------------------
[WRAPPED_PRESENT] CADEAU BONUS DE FASTAPI!

Allez sur: http://127.0.0.1:8000/docs

[IMPACT] WOAH! Une interface interactive!

Cette page s'appelle "Swagger UI" et FastAPI l'a générée AUTOMATIQUEMENT!

Vous pouvez:
- Voir tous vos endpoints
- Lire la documentation
- TESTER vos endpoints directement!
- Voir les réponses

Essayez:
1. Cliquez sur "GET /"
2. Cliquez sur "Try it out"
3. Cliquez sur "Execute"
4. Voyez la réponse!

[IDEE] DOCUMENTATION ALTERNATIVE:
http://127.0.0.1:8000/redoc
-> Même chose, mais style différent (ReDoc)


ARRÊTER LE SERVEUR:
------------------
Dans le terminal: Ctrl+C

Le serveur s'arrête proprement.
"""


"""
[COURS] EXERCICE PRATIQUE #1: VOTRE PREMIÈRE MODIFICATION
---------------------------------------------------

OBJECTIF: Modifier le message de retour

1. Éditez main.py
2. Changez "Hello World" en "Bonjour le monde"
3. Sauvegardez
4. Regardez le terminal -> le serveur redémarre automatiquement!
5. Rechargez la page dans le navigateur
6. Le message a changé!

[IDEE] POINTS À OBSERVER:
- Le serveur a détecté le changement
- Il a redémarré tout seul (--reload fait ça)
- Pas besoin de tout relancer manuellement!


[COURS] EXERCICE PRATIQUE #2: AJOUTER UN DEUXIÈME ENDPOINT
----------------------------------------------------

OBJECTIF: Créer une nouvelle route /about

Ajoutez ce code dans main.py:
"""

@app.get("/about")
def read_about():
    return {
        "nom": "Mon API",
        "version": "1.0.0",
        "auteur": "Votre nom"
    }

"""
Questions à vous poser:
1. À quoi sert @app.get("/about")?
   -> Dit à FastAPI: "Sur GET /about, exécute read_about()"
   
2. Pourquoi le nom "read_about"?
   -> Convention: read_ pour les GET
   -> about décrit ce que ça fait
   
3. Que retourne cette fonction?
   -> Un dictionnaire avec 3 clés
   -> FastAPI le convertira en JSON
   
4. Comment le tester?
   -> http://127.0.0.1:8000/about dans le navigateur
   -> OU dans /docs (Swagger UI)


[COURS] EXERCICE PRATIQUE #3: COMPRENDRE LES ERREURS
----------------------------------------------

EXPÉRIENCE 1: Route qui n'existe pas
Allez sur: http://127.0.0.1:8000/nexistepas

Vous voyez: {"detail":"Not Found"}

[IDEE] FastAPI gère automatiquement les routes inconnues!
Status code: 404 (Not Found)


EXPÉRIENCE 2: Erreur dans votre code
Modifiez votre fonction:
"""

@app.get("/")
def read_root():
    resultat = 10 / 0  # Division par zéro!
    return {"message": "Hello World"}

"""
Essayez d'aller sur /

Vous voyez: {"detail":"Internal Server Error"}

[IDEE] Que s'est-il passé?
1. Votre code a planté (division par zéro)
2. FastAPI a attrapé l'erreur
3. Au lieu de tout casser, il renvoie un message d'erreur propre
4. Status code: 500 (Internal Server Error)

Regardez le terminal:
-> Vous voyez la vraie erreur avec le traceback complet!

C'est comme ça que vous débugguez:
- Le client voit un message générique
- Vous voyez l'erreur détaillée dans le terminal


CORRIGEZ L'ERREUR:
Enlevez la division par zéro et remettez le code original.
"""


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 1
-----------------------------

Vous avez appris:
[OK] Pourquoi FastAPI existe
[OK] Comment installer FastAPI et uvicorn
[OK] La structure de base d'une API FastAPI
[OK] Ce qu'est un décorateur (@app.get)
[OK] Comment définir une route
[OK] Comment lancer le serveur
[OK] Comment tester avec le navigateur et /docs
[OK] Comment FastAPI gère les erreurs

Points clés à retenir:
[CLE] FastAPI = instance de la classe FastAPI
[CLE] @app.get("/") = décorateur qui définit une route
[CLE] return {...} = FastAPI convertit en JSON automatiquement
[CLE] /docs = documentation interactive gratuite
[CLE] --reload = mode développement avec rechargement auto


[OBJECTIF] AVANT DE CONTINUER:
Assurez-vous de bien comprendre:
- Comment créer une API basique
- Comment ajouter une nouvelle route
- Comment tester vos routes

Si ce n'est pas clair, relisez ce chapitre et testez les exemples!
La suite s'appuie sur ces bases.
"""


# ============================================================================
# [GUIDE] CHAPITRE 2: PATH PARAMETERS (Paramètres dans l'URL)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Apprendre à créer des URLs dynamiques avec des paramètres.


[REFLEXION] QUEL PROBLÈME RÉSOLVONS-NOUS?
--------------------------------

Imaginez que vous voulez une API pour gérer des utilisateurs.
Vous voulez pouvoir récupérer UN utilisateur spécifique.

[X] SOLUTION INEFFICACE:
"""
@app.get("/user1")
def get_user1():
    return {"id": 1, "name": "Alice"}

@app.get("/user2")
def get_user2():
    return {"id": 2, "name": "Bob"}

@app.get("/user3")
def get_user3():
    return {"id": 3, "name": "Charlie"}
# ... et ainsi de suite pour CHAQUE utilisateur? Non!

"""
[OK] SOLUTION INTELLIGENTE: PATH PARAMETERS
"""
@app.get("/users/{user_id}")
def get_user(user_id: int):
    # user_id change selon l'URL!
    return {"id": user_id, "name": f"User {user_id}"}

"""
[IDEE] POURQUOI C'EST MIEUX?
Une seule fonction gère TOUS les utilisateurs!
- /users/1 -> user_id = 1
- /users/2 -> user_id = 2
- /users/999 -> user_id = 999


ANATOMIE D'UN PATH PARAMETER
----------------------------
"""

@app.get("/users/{user_id}")
#              ^^^^^^^^^ 
#              Ceci est un path parameter!
def get_user(user_id: int):
#            ^^^^^^^ ^^^
#            Même nom  Type attendu
    return {"id": user_id}

"""
[REFLEXION] DÉCORTIQUONS:

PARTIE 1: {user_id} dans le path
--------------------------------
Les accolades {} indiquent: "Ceci est une variable"

[IDEE] EXEMPLES:
/users/{user_id}       -> /users/123
/items/{item_id}       -> /items/456
/posts/{post_id}       -> /posts/789

[ATTENTION] IMPORTANT: Le nom entre {} doit EXACTEMENT correspondre au paramètre de la fonction!

FAUX:
@app.get("/users/{user_id}")
def get_user(id: int):  # [X] 'id' ne correspond pas à 'user_id'
    ...

CORRECT:
@app.get("/users/{user_id}")
def get_user(user_id: int):  # [OK] Les noms correspondent!
    ...


PARTIE 2: user_id: int
---------------------
Cette annotation de type dit: "user_id doit être un entier (int)"

[IDEE] MAGIE FASTAPI #2: VALIDATION + CONVERSION AUTOMATIQUE!

Que se passe-t-il si quelqu'un va sur /users/123?
1. FastAPI extrait "123" de l'URL (c'est du texte)
2. FastAPI voit que vous voulez un int
3. FastAPI convertit "123" -> 123 (le nombre)
4. Appelle votre fonction avec user_id=123

Que se passe-t-il si quelqu'un va sur /users/abc?
1. FastAPI extrait "abc"
2. FastAPI essaie de convertir en int
3. IMPOSSIBLE! "abc" n'est pas un nombre!
4. FastAPI renvoie automatiquement une erreur 422:
   {
     "detail": [
       {
         "loc": ["path", "user_id"],
         "msg": "value is not a valid integer",
         "type": "type_error.integer"
       }
     ]
   }

[IMPACT] VOUS N'AVEZ RIEN CODÉ! FastAPI a tout fait!


[COURS] EXEMPLE COMPLET ET EXPLIQUÉ:
"""

# Imaginons une API pour une bibliothèque
from fastapi import FastAPI

app = FastAPI()

# Base de données fictive (en vrai, ce serait une vraie DB)
BOOKS = {
    1: {"title": "1984", "author": "George Orwell"},
    2: {"title": "Le Petit Prince", "author": "Saint-Exupéry"},
    3: {"title": "Harry Potter", "author": "J.K. Rowling"}
}

@app.get("/books/{book_id}")
def get_book(book_id: int):
    """
    [REFLEXION] Récupère un livre par son ID
    
    POURQUOI cette fonction?
    -> Pour permettre de récupérer UN livre spécifique
    
    COMMENT ça marche?
    1. Client fait: GET /books/2
    2. FastAPI extrait 2 et le convertit en int
    3. Appelle get_book(book_id=2)
    4. On cherche le livre dans BOOKS
    5. On le renvoie
    
    QUAND l'utiliser?
    -> Quand vous voulez une ressource précise (GET + ID)
    """
    # Vérifier si le livre existe
    if book_id not in BOOKS:
        # On verra comment mieux gérer les erreurs plus tard!
        return {"error": "Book not found"}
    
    return BOOKS[book_id]

"""
[IDEE] TESTEZ:
- /books/1 -> {"title": "1984", "author": "George Orwell"}
- /books/2 -> {"title": "Le Petit Prince", "author": "Saint-Exupéry"}
- /books/999 -> {"error": "Book not found"}
- /books/abc -> Erreur 422 automatique!


PLUSIEURS PATH PARAMETERS
-------------------------
Vous pouvez avoir plusieurs paramètres dans une même URL:
"""

@app.get("/users/{user_id}/posts/{post_id}")
def get_user_post(user_id: int, post_id: int):
    """
    URL: /users/5/posts/10
    -> user_id = 5
    -> post_id = 10
    
    [IDEE] QUAND utiliser ça?
    Pour des ressources "imbriquées" (nested resources).
    
    Exemple: "Le post 10 de l'utilisateur 5"
    L'URL reflète cette hiérarchie!
    """
    return {
        "user_id": user_id,
        "post_id": post_id,
        "message": f"Post {post_id} de l'utilisateur {user_id}"
    }

"""
[IDEE] CONVENTION RESTful:
/ressource/{id}/sous-ressource/{sous-id}

Exemples:
/users/{user_id}/posts/{post_id}
/companies/{company_id}/employees/{employee_id}
/courses/{course_id}/lessons/{lesson_id}


PATH PARAMETERS AVEC STRING
---------------------------
Les path parameters ne sont pas limités aux nombres!
"""

@app.get("/users/{username}")
def get_user_by_name(username: str):
    """
    URL: /users/alice
    -> username = "alice"
    
    [IDEE] DIFFÉRENCE AVEC INT:
    Avec str, FastAPI ne fait PAS de validation.
    Tout est accepté: lettres, chiffres, caractères spéciaux.
    
    [ATTENTION] ATTENTION à la sécurité!
    Ne jamais utiliser username directement dans une requête SQL!
    (On verra les bonnes pratiques plus tard)
    """
    return {"username": username, "message": f"Bonjour {username}!"}

"""
[ATTENTION] ORDRE DES ROUTES: TRÈS IMPORTANT!
-----------------------------------

Regardez ce code problématique:
"""

# [X] PROBLÈME:
@app.get("/users/{user_id}")
def get_user(user_id: str):
    return {"user_id": user_id}

@app.get("/users/me")
def get_current_user():
    return {"message": "Utilisateur actuel"}

"""
Que se passe-t-il si vous allez sur /users/me?

1. FastAPI teste les routes dans l'ORDRE
2. /users/{user_id} correspond! (me est une string valide)
3. Appelle get_user(user_id="me")
4. get_current_user() n'est JAMAIS appelé! [!]

[OK] SOLUTION: Mettre les routes spécifiques AVANT les génériques:
"""

@app.get("/users/me")  # [OK] Route spécifique EN PREMIER
def get_current_user():
    return {"message": "Utilisateur actuel"}

@app.get("/users/{user_id}")  # Route générique APRÈS
def get_user(user_id: str):
    return {"user_id": user_id}

"""
Maintenant:
- /users/me -> Correspond exactement -> appelle get_current_user()
- /users/123 -> Ne correspond pas à "me" -> teste la suivante -> appelle get_user()

[IDEE] RÈGLE D'OR:
Plus une route est spécifique, plus elle doit être haute dans le code!


PATH PARAMETERS AVEC ENUM
-------------------------
Pour limiter les valeurs possibles:
"""

from enum import Enum

class ModelType(str, Enum):
    """
    [IDEE] QU'EST-CE QU'UN ENUM?
    
    Un Enum (énumération) = liste fermée de valeurs possibles.
    
    POURQUOI l'utiliser?
    -> Pour dire: "Seules ces valeurs sont autorisées"
    
    QUAND l'utiliser?
    -> Quand vous avez un nombre fini d'options
    
    Exemples:
    - Types de modèles ML: gpt3, gpt4, claude
    - Catégories: books, movies, games
    - Status: pending, approved, rejected
    """
    GPT3 = "gpt3"
    GPT4 = "gpt4"
    CLAUDE = "claude"

@app.get("/models/{model_type}")
def get_model_info(model_type: ModelType):
    """
    [IDEE] MAGIE FASTAPI #3: VALIDATION AVEC ENUM!
    
    URLs VALIDES:
    - /models/gpt3 [OK]
    - /models/gpt4 [OK]
    - /models/claude [OK]
    
    URLs INVALIDES:
    - /models/gpt5 [X] -> Erreur 422 automatique!
    - /models/autre [X] -> Erreur 422 automatique!
    
    FastAPI vérifie que la valeur est dans l'Enum!
    """
    if model_type == ModelType.GPT3:
        return {"model": "GPT-3", "version": "3.5"}
    elif model_type == ModelType.GPT4:
        return {"model": "GPT-4", "version": "4.0"}
    else:  # CLAUDE
        return {"model": "Claude", "version": "3"}

"""
[IDEE] AVANTAGES DE L'ENUM:
1. Validation automatique
2. Auto-complétion dans l'IDE
3. Documentation claire (Swagger montre les valeurs possibles)
4. Pas de typos possibles


[COURS] EXERCICES PRATIQUES
---------------------

EXERCICE #1: API de films
"""

MOVIES = {
    1: {"title": "Inception", "year": 2010, "director": "Nolan"},
    2: {"title": "Matrix", "year": 1999, "director": "Wachowski"},
    3: {"title": "Interstellar", "year": 2014, "director": "Nolan"}
}

"""
Créez un endpoint: GET /movies/{movie_id}
Qui retourne les infos du film.

[IDEE] POINTS À IMPLÉMENTER:
1. Utiliser un path parameter movie_id (int)
2. Vérifier si le film existe
3. Si oui, retourner les infos
4. Si non, retourner {"error": "Movie not found"}

SOLUTION:
"""

@app.get("/movies/{movie_id}")
def get_movie(movie_id: int):
    if movie_id not in MOVIES:
        return {"error": "Movie not found"}
    return MOVIES[movie_id]

"""
EXERCICE #2: Filtrer par réalisateur
Créez: GET /directors/{director_name}/movies
Qui retourne tous les films d'un réalisateur.

SOLUTION:
"""

@app.get("/directors/{director_name}/movies")
def get_movies_by_director(director_name: str):
    # Filtrer les films
    result = []
    for movie_id, movie in MOVIES.items():
        if movie["director"].lower() == director_name.lower():
            result.append({
                "id": movie_id,
                **movie
            })
    
    return {
        "director": director_name,
        "count": len(result),
        "movies": result
    }

"""
[IDEE] POINTS À NOTER:
- .lower() pour ignorer majuscules/minuscules
- **movie pour décompresser le dictionnaire
- Retourner un objet structuré avec metadata


EXERCICE #3: Path parameters multiples
Créez: GET /years/{year}/directors/{director}
Qui retourne les films d'un réalisateur pour une année.

SOLUTION:
"""

@app.get("/years/{year}/directors/{director}")
def get_movies_by_year_and_director(year: int, director: str):
    result = []
    for movie_id, movie in MOVIES.items():
        if (movie["year"] == year and 
            movie["director"].lower() == director.lower()):
            result.append({
                "id": movie_id,
                **movie
            })
    
    return {
        "year": year,
        "director": director,
        "movies": result
    }


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 2
-----------------------------

Vous avez appris:
[OK] Ce qu'est un path parameter
[OK] Pourquoi utiliser {param} au lieu de routes fixes
[OK] Comment FastAPI valide et convertit automatiquement
[OK] L'importance de l'ordre des routes
[OK] Comment utiliser des Enums pour limiter les valeurs
[OK] Comment gérer plusieurs path parameters

Points clés:
[CLE] {param} crée un paramètre variable dans l'URL
[CLE] param: int -> FastAPI valide et convertit automatiquement
[CLE] Routes spécifiques AVANT routes génériques
[CLE] Enum pour limiter aux valeurs autorisées
[CLE] Pas besoin de coder la validation, FastAPI le fait!


[OBJECTIF] TESTEZ VOTRE COMPRÉHENSION:
Avant de continuer, assurez-vous de pouvoir:
1. Créer une route avec un path parameter
2. Utiliser plusieurs path parameters
3. Expliquer pourquoi l'ordre des routes compte
4. Utiliser un Enum pour restreindre les valeurs

Prêt? Passons aux Query Parameters!
"""


# ============================================================================
# [GUIDE] CHAPITRE 3: QUERY PARAMETERS (Paramètres de requête)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Comprendre la différence entre path et query parameters,
et maîtriser les query parameters.


[REFLEXION] PATH VS QUERY PARAMETERS: QUELLE DIFFÉRENCE?
----------------------------------------------

Imaginez une bibliothèque:

PATH PARAMETERS = Identifier UNE ressource spécifique
"""
GET /books/123  # Livre numéro 123 (identifiant unique)
"""

QUERY PARAMETERS = Filtrer/configurer une collection
"""
GET /books?author=Tolkien&year=1954  # Tous les livres de Tolkien en 1954
"""

[IDEE] ANALOGIE:
PATH = Adresse postale (identifie LA maison)
QUERY = Instructions de livraison (déposer devant, sonnette cassée...)


ANATOMIE D'UNE QUERY STRING
---------------------------

Regardons cette URL:
"""
http://localhost:8000/items?skip=0&limit=10&sort=price
#                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#                            Ceci est la QUERY STRING

"""
Structure:
http://localhost:8000/items  <- Path
                            ?  <- Début des query params
                            skip=0  <- Première paire clé=valeur
                                  &  <- Séparateur
                                  limit=10  <- Deuxième paire
                                          &
                                          sort=price  <- Troisième paire


CRÉER VOTRE PREMIER QUERY PARAMETER
-----------------------------------
"""

@app.get("/items")
def read_items(skip: int = 0, limit: int = 10):
    """
    [REFLEXION] COMMENT FASTAPI SAIT que ce sont des query parameters?
    
    Réponse: Parce qu'ils ne sont PAS dans le path!
    
    @app.get("/items")  <- Pas de {skip} ni {limit} ici
    def read_items(skip: int = 0, limit: int = 10):
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                   Donc ce sont des query parameters!
    
    [IDEE] RÈGLE FASTAPI:
    - Dans le path {param} -> path parameter
    - Pas dans le path -> query parameter (ou request body, on verra)
    """
    return {"skip": skip, "limit": limit}

"""
[IDEE] VALEURS PAR DÉFAUT: = 0 et = 10

POURQUOI des valeurs par défaut?
-> Rendre les parameters OPTIONNELS!

Testez ces URLs:
"""
GET /items                -> skip=0, limit=10 (valeurs par défaut)
GET /items?skip=5         -> skip=5, limit=10 (skip fourni, limit=défaut)
GET /items?limit=20       -> skip=0, limit=20 (limit fourni, skip=défaut)
GET /items?skip=5&limit=20 -> skip=5, limit=20 (les deux fournis)

"""
[ATTENTION] SANS VALEUR PAR DÉFAUT = OBLIGATOIRE!
"""

@app.get("/items")
def read_items(q: str):  # [X] Pas de valeur par défaut!
    """
    Ce query parameter est OBLIGATOIRE!
    
    /items       -> [X] Erreur 422: "q field required"
    /items?q=test -> [OK] Fonctionne
    """
    return {"q": q}

"""
[IDEE] MAGIE FASTAPI #4: VALIDATION AUTOMATIQUE DU TYPE!

Exactement comme pour path parameters:
"""

@app.get("/items")
def read_items(limit: int = 10):
    """
    FastAPI s'assure que limit est un int!
    
    /items?limit=20   -> [OK] limit=20 (int)
    /items?limit=abc  -> [X] Erreur 422 automatique!
    """
    return {"limit": limit}


"""
QUERY PARAMETERS OPTIONNELS
---------------------------

Pour rendre un paramètre vraiment optionnel (peut être absent):
"""

from typing import Optional

@app.get("/items")
def read_items(q: Optional[str] = None):
    """
    [REFLEXION] DÉCORTIQUONS:
    
    Optional[str] = str OU None
    = None = valeur par défaut est None
    
    [IDEE] QUE SE PASSE-T-IL?
    
    /items          -> q = None (absent)
    /items?q=       -> q = "" (présent mais vide)
    /items?q=hello  -> q = "hello"
    
    DANS LA FONCTION:
    """
    if q is None:
        return {"message": "Pas de recherche"}
    return {"q": q, "message": f"Recherche: {q}"}

"""
[IDEE] PATTERN COURANT: Logique conditionnelle selon présence
"""

@app.get("/items")
def read_items(
    q: Optional[str] = None,
    skip: int = 0,
    limit: int = 10
):
    """
    API de recherche typique!
    
    [IDEE] COMPORTEMENT:
    - Si q fourni -> filtrer par recherche
    - skip & limit -> pagination
    
    EXEMPLES:
    /items?q=laptop&skip=0&limit=10
    -> Cherche "laptop", page 1, 10 résultats
    
    /items?skip=20&limit=10
    -> Tous les items, page 3, 10 résultats
    """
    result = {"skip": skip, "limit": limit}
    if q:
        result["q"] = q
        result["message"] = f"Recherche de '{q}'"
    else:
        result["message"] = "Tous les items"
    return result


"""
QUERY PARAMETERS BOOLÉENS
-------------------------

Les booléens sont spéciaux en HTTP!
"""

@app.get("/items")
def read_items(short: bool = False):
    """
    [IDEE] FASTAPI RECONNAÎT CES VALEURS COMME True:
    - short=1
    - short=True
    - short=true
    - short=yes
    - short=on
    
    TOUTES LES AUTRES = False:
    - short=0
    - short=False
    - short=false
    - short=no
    - short=off
    - short absent (-> défaut = False)
    
    EXEMPLES:
    """
    item = {
        "name": "SuperWidget",
        "price": 99.99
    }
    
    if not short:
        # Version longue avec description
        item["description"] = "Un widget super cool avec plein de fonctionnalités!"
        item["features"] = ["Feature 1", "Feature 2", "Feature 3"]
    
    return item

"""
TESTEZ:
/items              -> Version longue (short=False par défaut)
/items?short=true   -> Version courte (juste name et price)


PLUSIEURS QUERY PARAMETERS
--------------------------

Vous pouvez en avoir autant que nécessaire:
"""

@app.get("/users")
def search_users(
    name: Optional[str] = None,
    email: Optional[str] = None,
    age_min: Optional[int] = None,
    age_max: Optional[int] = None,
    active: bool = True,
    sort_by: str = "name",
    order: str = "asc",
    skip: int = 0,
    limit: int = 50
):
    """
    [IDEE] API DE RECHERCHE COMPLÈTE!
    
    Tous les paramètres sont optionnels sauf sort_by, order, skip, limit
    qui ont des valeurs par défaut raisonnables.
    
    EXEMPLE D'UTILISATION:
    """
    # Construction de la requête de recherche
    filters = {}
    if name:
        filters["name"] = name
    if email:
        filters["email"] = email
    if age_min:
        filters["age_min"] = age_min
    if age_max:
        filters["age_max"] = age_max
    
    return {
        "filters": filters,
        "active_only": active,
        "sort": {"by": sort_by, "order": order},
        "pagination": {"skip": skip, "limit": limit}
    }

"""
TESTEZ DES URLs COMPLEXES:
"""
GET /users?name=John&age_min=18&age_max=30&sort_by=age&order=desc&limit=10
"""

[IDEE] ORGANISATION DES PARAMÈTRES:
Groupez logiquement:
1. Filtres de recherche (name, email, age_min, age_max)
2. Options d'affichage (active, short)
3. Tri (sort_by, order)
4. Pagination (skip, limit)


VALIDATION AVANCÉE AVEC Query
-----------------------------

Pour plus de contrôle sur la validation:
"""

from fastapi import Query

@app.get("/items")
def read_items(
    q: Optional[str] = Query(
        None,
        min_length=3,
        max_length=50,
        regex="^[a-zA-Z0-9 ]+$"
    )
):
    """
    [REFLEXION] DÉCORTIQUONS Query():
    
    Query() permet d'ajouter des contraintes!
    
    PARAMÈTRES:
    - None = valeur par défaut
    - min_length=3 = minimum 3 caractères
    - max_length=50 = maximum 50 caractères
    - regex="..." = doit correspondre à ce pattern
    
    [IDEE] QUE SE PASSE-T-IL SI INVALIDE?
    
    /items?q=ab  -> [X] Trop court (min 3)
    /items?q=X...très long...X  -> [X] Trop long (max 50)
    /items?q=test@#$  -> [X] Caractères spéciaux interdits
    /items?q=test123  -> [OK] OK!
    
    ERREUR RETOURNÉE (automatiquement):
    {
      "detail": [
        {
          "loc": ["query", "q"],
          "msg": "ensure this value has at least 3 characters",
          "type": "value_error.any_str.min_length"
        }
      ]
    }
    """
    return {"q": q}


"""
Query() AVEC MÉTADONNÉES
------------------------

Ajoutez de la documentation directement:
"""

@app.get("/items")
def read_items(
    q: Optional[str] = Query(
        None,
        title="Query string",
        description="Recherche dans le nom et la description des items",
        min_length=3,
        max_length=50,
        example="laptop"
    )
):
    """
    [IDEE] CES MÉTADONNÉES APPARAISSENT DANS /docs!
    
    Swagger UI montrera:
    - Le titre
    - La description
    - L'exemple
    - Les contraintes
    
    C'est comme documenter votre API automatiquement!
    """
    return {"q": q}


"""
QUERY PARAMETERS NUMÉRIQUES AVEC CONTRAINTES
--------------------------------------------
"""

@app.get("/items")
def read_items(
    price_min: float = Query(0, ge=0),
    price_max: float = Query(1000000, le=1000000),
    limit: int = Query(10, ge=1, le=100)
):
    """
    [IDEE] CONTRAINTES NUMÉRIQUES:
    
    ge = greater or equal (>=)
    le = less or equal (<=)
    gt = greater than (>)
    lt = less than (<)
    
    DANS CET EXEMPLE:
    - price_min >= 0 (pas de prix négatifs!)
    - price_max <= 1000000 (limite maximale)
    - limit entre 1 et 100 (pagination raisonnable)
    
    TESTEZ:
    /items?price_min=-10  -> [X] Erreur: doit être >= 0
    /items?limit=0        -> [X] Erreur: doit être >= 1
    /items?limit=200      -> [X] Erreur: doit être <= 100
    /items?price_min=50&price_max=200&limit=20  -> [OK] OK!
    """
    return {
        "price_range": {"min": price_min, "max": price_max},
        "limit": limit
    }


"""
LISTES EN QUERY PARAMETERS
--------------------------

Pour accepter plusieurs valeurs:
"""

from typing import List

@app.get("/items")
def read_items(
    tags: List[str] = Query([])
):
    """
    [IDEE] COMMENT PASSER UNE LISTE EN URL?
    
    Répétez le paramètre plusieurs fois:
    /items?tags=python&tags=fastapi&tags=api
    
    OU utilisez la notation avec virgules (selon configuration):
    /items?tags=python,fastapi,api
    
    FastAPI reçoit: tags = ["python", "fastapi", "api"]
    
    EXEMPLE D'UTILISATION:
    """
    if not tags:
        return {"message": "Tous les items"}
    
    return {
        "message": f"Items avec les tags: {', '.join(tags)}",
        "tags": tags,
        "count": len(tags)
    }

"""
[IDEE] LISTE AVEC VALIDATION:
"""

@app.get("/items")
def read_items(
    tags: List[str] = Query(
        [],
        min_items=1,
        max_items=5
    )
):
    """
    Contraintes sur la liste:
    - min_items=1 -> Au moins 1 tag requis
    - max_items=5 -> Maximum 5 tags
    
    /items?tags=python  -> [OK] 1 tag
    /items?tags=a&tags=b&tags=c  -> [OK] 3 tags
    /items -> [X] 0 tags (min 1 requis)
    /items?tags=a&tags=b&tags=c&tags=d&tags=e&tags=f  -> [X] 6 tags (max 5)
    """
    return {"tags": tags}


"""
[COURS] EXERCICES PRATIQUES
---------------------

EXERCICE #1: API de recherche de livres
"""

BOOKS = [
    {"id": 1, "title": "1984", "author": "Orwell", "year": 1949, "pages": 328},
    {"id": 2, "title": "Le Petit Prince", "author": "Saint-Exupéry", "year": 1943, "pages": 96},
    {"id": 3, "title": "Harry Potter", "author": "Rowling", "year": 1997, "pages": 223},
    {"id": 4, "title": "The Hobbit", "author": "Tolkien", "year": 1937, "pages": 310}
]

"""
Créez: GET /books avec query parameters:
- title (optionnel) : cherche dans le titre
- author (optionnel) : cherche dans l'auteur
- year_min, year_max (optionnels) : filtrer par années
- min_pages (optionnel, défaut 0) : pages minimum
- skip, limit (pagination)

SOLUTION:
"""

@app.get("/books")
def search_books(
    title: Optional[str] = None,
    author: Optional[str] = None,
    year_min: Optional[int] = None,
    year_max: Optional[int] = None,
    min_pages: int = 0,
    skip: int = 0,
    limit: int = 10
):
    # Filtrer les livres
    results = BOOKS.copy()
    
    if title:
        results = [b for b in results if title.lower() in b["title"].lower()]
    
    if author:
        results = [b for b in results if author.lower() in b["author"].lower()]
    
    if year_min:
        results = [b for b in results if b["year"] >= year_min]
    
    if year_max:
        results = [b for b in results if b["year"] <= year_max]
    
    results = [b for b in results if b["pages"] >= min_pages]
    
    # Pagination
    total = len(results)
    results = results[skip:skip + limit]
    
    return {
        "total": total,
        "skip": skip,
        "limit": limit,
        "results": results
    }

"""
TESTEZ:
/books?author=tolkien
/books?year_min=1940&year_max=1950
/books?min_pages=300
/books?title=harry&skip=0&limit=10


EXERCICE #2: API avec tri
Ajoutez sort_by et order:
"""

@app.get("/books")
def search_books_sorted(
    sort_by: str = Query("title", regex="^(title|author|year|pages)$"),
    order: str = Query("asc", regex="^(asc|desc)$"),
    skip: int = 0,
    limit: int = 10
):
    """
    [IDEE] REGEX pour validation!
    
    sort_by ne peut être que: title, author, year, ou pages
    order ne peut être que: asc ou desc
    
    Si autre chose -> Erreur 422 automatique!
    """
    results = BOOKS.copy()
    
    # Trier
    reverse = (order == "desc")
    results.sort(key=lambda x: x[sort_by], reverse=reverse)
    
    # Paginer
    total = len(results)
    results = results[skip:skip + limit]
    
    return {
        "total": total,
        "skip": skip,
        "limit": limit,
        "sort": {"by": sort_by, "order": order},
        "results": results
    }

"""
TESTEZ:
/books?sort_by=year&order=asc
/books?sort_by=pages&order=desc
/books?sort_by=invalid  -> [X] Erreur 422


[DOCS] RÉCAPITULATIF DU CHAPITRE 3
-----------------------------

Vous avez appris:
[OK] La différence entre path et query parameters
[OK] Comment créer des query parameters simples
[OK] Valeurs par défaut pour rendre optionnel
[OK] Optional[T] pour valeurs vraiment optionnelles
[OK] Query() pour validation avancée
[OK] Contraintes numériques (ge, le, gt, lt)
[OK] Validation de strings (min_length, max_length, regex)
[OK] Listes en query parameters
[OK] Booléens et leurs valeurs acceptées

Points clés:
[CLE] Pas dans {path} -> query parameter
[CLE] = valeur -> valeur par défaut (optionnel)
[CLE] Query() pour contraintes avancées
[CLE] FastAPI valide TOUT automatiquement
[CLE] Erreur 422 avec détails si invalide


[OBJECTIF] DIFFÉRENCES CLÉS:

PATH PARAMETERS:
- Identifient UNE ressource
- Obligatoires (font partie de l'URL)
- /items/{item_id}

QUERY PARAMETERS:
- Filtrent/configurent une collection
- Optionnels (peuvent être absents)
- /items?category=books&sort=price

QUAND UTILISER QUOI?
- Identifiant unique -> Path
- Filtres, options, pagination -> Query
- Si absent = sens -> Query
- Si absent = pas de sens -> Path


[OBJECTIF] AVANT DE CONTINUER:
Assurez-vous de pouvoir:
1. Créer des query parameters avec valeurs par défaut
2. Utiliser Optional pour vraiment optionnel
3. Ajouter des validations avec Query()
4. Expliquer quand utiliser path vs query

Prêt? Passons au Request Body (données POST/PUT)!
"""


# ============================================================================
# [GUIDE] CHAPITRE 4: REQUEST BODY (Corps de requête avec Pydantic)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Comprendre comment recevoir des données complexes avec POST/PUT,
et maîtriser Pydantic pour la validation.


[REFLEXION] POURQUOI LE REQUEST BODY?
---------------------------

JUSQU'ICI:
- GET avec path parameters -> récupérer UNE ressource
- GET avec query parameters -> filtrer/paginer

MAINTENANT:
- POST/PUT avec request body -> envoyer des DONNÉES au serveur


[IDEE] ANALOGIE:
GET = Vous demandez un livre à la bibliothèque
POST = Vous APPORTEZ un nouveau livre à la bibliothèque

Avec GET, vous ne pouvez pas envoyer beaucoup de données (limité par l'URL).
Avec POST/PUT, vous pouvez envoyer des objets JSON complexes!


DIFFÉRENCE GET vs POST
----------------------

GET:
- Données dans l'URL (path/query params)
- Limité en taille (~2000 caractères max)
- Visible dans l'historique du navigateur
- Peut être mis en cache
- Idempotent (appeler 10x = appeler 1x)

POST/PUT:
- Données dans le corps (body) de la requête
- Pas de limite de taille pratique
- Pas visible dans l'URL
- Pas de cache automatique
- Peut modifier l'état du serveur


[COURS] VOTRE PREMIER REQUEST BODY (façon basique)
--------------------------------------------

Sans Pydantic d'abord (pour comprendre le problème):
"""

@app.post("/items")
def create_item(item: dict):
    """
    [X] PROBLÈME: Vous recevez un dictionnaire Python brut!
    
    Client envoie:
    {
      "name": "Laptop",
      "price": 999.99
    }
    
    Vous recevez: item = {"name": "Laptop", "price": 999.99}
    
    PROBLÈMES:
    1. Aucune validation -> le client peut envoyer n'importe quoi!
    2. Pas de documentation -> comment savoir quels champs sont requis?
    3. Pas d'auto-complétion dans l'IDE
    4. Bugs difficiles à trouver
    
    Exemple de ce qui POURRAIT arriver:
    """
    # Client envoie: {"name": "Laptop"}  (oublie price)
    price = item["price"]  # [IMPACT] KeyError: 'price'
    
    # Ou client envoie: {"name": "Laptop", "price": "mille"}
    total = item["price"] * 1.2  # [IMPACT] TypeError: can't multiply string
    
    return item


"""
[OK] SOLUTION: PYDANTIC!
---------------------

[REFLEXION] QU'EST-CE QUE PYDANTIC?

Pydantic est une bibliothèque Python pour:
1. Définir des MODÈLES de données
2. Valider automatiquement les données
3. Convertir les types automatiquement
4. Générer de la documentation

[IDEE] ANALOGIE:
Pydantic = Un formulaire avec des champs obligatoires et des règles

Imaginez un formulaire d'inscription:
- Email: ____ (obligatoire, doit être un email valide)
- Âge: ____ (obligatoire, doit être un nombre, >= 18)
- Téléphone: ____ (optionnel)

Pydantic fait ça pour vos APIs!


CRÉER VOTRE PREMIER MODÈLE PYDANTIC
-----------------------------------
"""

from pydantic import BaseModel

class Item(BaseModel):
    """
    [IDEE] BaseModel = classe de base de Pydantic
    
    On crée une nouvelle classe qui hérite de BaseModel.
    Chaque attribut = un champ du modèle.
    """
    name: str        # Champ obligatoire de type string
    price: float     # Champ obligatoire de type float
    description: str = None  # Champ optionnel (défaut = None)
    tax: float = None        # Champ optionnel

"""
[REFLEXION] DÉCORTIQUONS CETTE CLASSE:

class Item(BaseModel):
      ^^^^ Nom de votre modèle (comme un nom de table SQL)
           BaseModel = héritage (on récupère les pouvoirs de Pydantic)

name: str
^^^^  ^^^ Type attendu
Nom du champ

description: str = None
                   ^^^^ Valeur par défaut (optionnel)


POURQUOI C'EST GÉNIAL:

1. VALIDATION AUTOMATIQUE
   FastAPI + Pydantic vérifient que:
   - Les champs requis sont présents
   - Les types sont corrects
   - Les contraintes sont respectées

2. CONVERSION AUTOMATIQUE
   Si client envoie: {"price": "99.99"}  (string)
   Pydantic convertit en: 99.99 (float)

3. DOCUMENTATION AUTOMATIQUE
   Dans /docs, vous voyez le modèle avec tous les champs!

4. AUTO-COMPLÉTION IDE
   item.name  <- Votre IDE sait que c'est un string!
   item.price <- Votre IDE sait que c'est un float!


UTILISER LE MODÈLE DANS UN ENDPOINT
-----------------------------------
"""

@app.post("/items")
def create_item(item: Item):
    """
    [IDEE] MAGIE FASTAPI #5: VALIDATION AUTOMATIQUE DU REQUEST BODY!
    
    item: Item dit à FastAPI:
    "Le corps de la requête doit correspondre au modèle Item"
    
    QUE SE PASSE-T-IL QUAND LE CLIENT ENVOIE UNE REQUÊTE?
    
    1. Client envoie du JSON:
       {
         "name": "Laptop",
         "price": 999.99,
         "description": "Un super laptop"
       }
    
    2. FastAPI reçoit le JSON
    
    3. FastAPI essaie de créer un objet Item avec ces données
    
    4. Pydantic valide:
       [OK] name est présent et est une string
       [OK] price est présent et est un nombre
       [OK] description est présent (optionnel OK)
       [OK] tax n'est pas présent (optionnel OK, mettra None)
    
    5. Si TOUT est OK:
       -> Votre fonction est appelée avec item = objet Item valide
       -> Vous pouvez utiliser item.name, item.price en toute sécurité!
    
    6. Si ERREUR:
       -> FastAPI renvoie automatiquement 422 avec détails
       -> Votre fonction n'est JAMAIS appelée!
    """
    
    # ICI, vous êtes SÛR que item est valide!
    # Pas besoin de vérifications!
    
    item_dict = item.dict()  # Convertir en dictionnaire
    
    if item.tax:
        price_with_tax = item.price + item.tax
        item_dict["price_with_tax"] = price_with_tax
    
    return item_dict

"""
[IDEE] TESTEZ DANS /docs:

1. Allez sur http://localhost:8000/docs
2. Cliquez sur POST /items
3. Cliquez "Try it out"
4. Vous voyez un exemple JSON généré automatiquement!
5. Modifiez-le et testez


TEST 1: Données valides
{
  "name": "Laptop",
  "price": 999.99,
  "description": "Un super laptop",
  "tax": 99.99
}
-> [OK] Status 200, item créé


TEST 2: Champ manquant
{
  "name": "Laptop"
}
-> [X] Status 422:
{
  "detail": [
    {
      "loc": ["body", "price"],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}


TEST 3: Type incorrect
{
  "name": "Laptop",
  "price": "mille euros"
}
-> [X] Status 422:
{
  "detail": [
    {
      "loc": ["body", "price"],
      "msg": "value is not a valid float",
      "type": "type_error.float"
    }
  ]
}


[IDEE] OBSERVEZ:
- Messages d'erreur clairs et précis
- Localisation exacte du problème
- Type d'erreur indiqué
- VOUS N'AVEZ RIEN CODÉ! Pydantic fait tout!


VALIDATION AVANCÉE AVEC FIELD
-----------------------------

Pour plus de contrôle:
"""

from pydantic import Field

class Item(BaseModel):
    name: str = Field(
        ...,  # ... signifie "obligatoire"
        min_length=1,
        max_length=100,
        description="Nom de l'item",
        example="Laptop Pro"
    )
    price: float = Field(
        ...,
        gt=0,  # greater than 0
        le=1000000,  # less or equal 1 million
        description="Prix en euros",
        example=999.99
    )
    description: str = Field(
        None,
        max_length=500,
        description="Description détaillée",
        example="Un laptop haute performance"
    )
    tax: float = Field(
        None,
        ge=0,
        description="Taxe en euros",
        example=99.99
    )

"""
[REFLEXION] POURQUOI Field()?

Field() permet d'ajouter des contraintes et métadonnées:

CONTRAINTES:
- min_length, max_length: longueur de string
- gt, ge, lt, le: comparaisons numériques
- regex: pattern à respecter
- min_items, max_items: taille de liste

MÉTADONNÉES:
- description: apparaît dans /docs
- example: exemple dans /docs
- title: titre du champ

... vs None:
- ... = obligatoire (pas de valeur par défaut)
- None = optionnel (défaut = None)
- Autre valeur = optionnel avec ce défaut


[IDEE] FIELD() vs QUERY():
Field() = pour modèles Pydantic (request body)
Query() = pour query parameters
Même concept, contextes différents!


MODÈLES IMBRIQUÉS (NESTED MODELS)
---------------------------------

Vous pouvez imbriquer des modèles:
"""

class Image(BaseModel):
    """Modèle pour une image"""
    url: str = Field(..., description="URL de l'image")
    name: str = Field(..., description="Nom de l'image")

class Item(BaseModel):
    """Item avec des images"""
    name: str
    price: float
    description: str = None
    tax: float = None
    images: List[Image] = []  # Liste d'objets Image!

"""
[IDEE] STRUCTURE HIÉRARCHIQUE:

Item
├── name (str)
├── price (float)
├── description (str)
├── tax (float)
└── images (List[Image])
    ├── Image 1
    │   ├── url (str)
    │   └── name (str)
    ├── Image 2
    │   ├── url (str)
    │   └── name (str)
    └── ...


UTILISATION:
"""

@app.post("/items")
def create_item(item: Item):
    """
    CLIENT ENVOIE:
    {
      "name": "Laptop",
      "price": 999.99,
      "images": [
        {
          "url": "https://example.com/front.jpg",
          "name": "Vue de face"
        },
        {
          "url": "https://example.com/back.jpg",
          "name": "Vue de dos"
        }
      ]
    }
    
    DANS VOTRE CODE:
    """
    print(f"Item: {item.name}")
    print(f"Nombre d'images: {len(item.images)}")
    
    for image in item.images:
        print(f"- {image.name}: {image.url}")
    
    return item

"""
[IDEE] PYDANTIC VALIDE RÉCURSIVEMENT!

Il vérifie:
[OK] images est une liste
[OK] Chaque élément de la liste est un objet Image valide
[OK] Chaque Image a url et name (strings)

Si erreur dans l'imbrication:
{
  "name": "Laptop",
  "images": [
    {"url": "http://...", "name": "Face"},
    {"url": 123}  <- [X] url doit être string, name manquant
  ]
}
-> Erreur 422 avec localisation précise: ["body", "images", 1, "url"]


VALIDATORS PERSONNALISÉS
------------------------

Pour logique de validation custom:
"""

from pydantic import validator

class Item(BaseModel):
    name: str
    price: float
    discount_price: float = None
    
    @validator('name')
    def name_must_not_be_empty(cls, v):
        """
        [IDEE] VALIDATOR CUSTOM!
        
        @validator('name') = s'applique au champ 'name'
        cls = la classe (Item)
        v = la valeur à valider
        
        RETURN:
        - Retourner v (ou valeur modifiée) = OK
        - Lever ValueError = invalide
        """
        if not v or not v.strip():
            raise ValueError('Le nom ne peut pas être vide')
        return v.strip()  # On enlève les espaces au début/fin
    
    @validator('discount_price')
    def discount_must_be_less_than_price(cls, v, values):
        """
        [IDEE] VALIDATOR AVEC ACCÈS AUX AUTRES CHAMPS!
        
        values = dictionnaire avec les autres champs déjà validés
        
        On peut vérifier des règles entre plusieurs champs!
        """
        if v is not None and 'price' in values:
            if v >= values['price']:
                raise ValueError('Le prix réduit doit être inférieur au prix normal')
        return v

"""
TESTEZ:
{
  "name": "   ",  <- Que des espaces
  "price": 100
}
-> [X] "Le nom ne peut pas être vide"

{
  "name": "Laptop",
  "price": 100,
  "discount_price": 150  <- Plus cher que le prix!
}
-> [X] "Le prix réduit doit être inférieur au prix normal"


ROOT VALIDATORS
--------------

Pour valider le modèle entier:
"""

from pydantic import root_validator

class Item(BaseModel):
    name: str
    price: float
    discount_price: float = None
    
    @root_validator
    def check_discount_logic(cls, values):
        """
        [IDEE] ROOT VALIDATOR = valide l'objet entier!
        
        values = dictionnaire avec TOUS les champs
        
        Utile pour:
        - Vérifier cohérence entre champs
        - Ajouter des champs calculés
        - Logique complexe
        """
        price = values.get('price')
        discount = values.get('discount_price')
        
        if discount and price:
            if discount >= price:
                raise ValueError('Discount invalide')
            
            # Calculer et ajouter le % de réduction
            discount_percent = ((price - discount) / price) * 100
            values['discount_percent'] = round(discount_percent, 2)
        
        return values


"""
COMBINER PATH, QUERY ET BODY
----------------------------

Vous pouvez tout combiner!
"""

@app.put("/items/{item_id}")
def update_item(
    item_id: int,              # Path parameter
    item: Item,                # Request body
    q: Optional[str] = None    # Query parameter
):
    """
    [IDEE] FASTAPI DÉTECTE AUTOMATIQUEMENT!
    
    Comment FastAPI sait quoi est quoi?
    
    item_id: int
    -> Dans le path -> path parameter
    
    item: Item
    -> BaseModel -> request body
    
    q: Optional[str] = None
    -> Ni dans le path, ni BaseModel -> query parameter
    
    
    EXEMPLE DE REQUÊTE:
    PUT /items/123?q=update
    Body: {
      "name": "Laptop Updated",
      "price": 899.99
    }
    
    DANS LA FONCTION:
    item_id = 123
    item = Item(name="Laptop Updated", price=899.99)
    q = "update"
    """
    result = {"item_id": item_id, **item.dict()}
    if q:
        result["q"] = q
    return result


"""
PLUSIEURS BODY PARAMETERS
-------------------------

Vous pouvez avoir plusieurs modèles dans le body:
"""

from pydantic import Body

class Item(BaseModel):
    name: str
    price: float

class User(BaseModel):
    username: str
    email: str

@app.post("/items")
def create_item(
    item: Item,
    user: User
):
    """
    [IDEE] STRUCTURE ATTENDUE:
    {
      "item": {
        "name": "Laptop",
        "price": 999.99
      },
      "user": {
        "username": "john",
        "email": "john@example.com"
      }
    }
    
    Les clés "item" et "user" correspondent aux noms des paramètres!
    """
    return {
        "item": item,
        "user": user,
        "message": f"{user.username} a créé {item.name}"
    }

"""
[IDEE] POUR UN SEUL BODY SANS CLÉ:
"""

@app.post("/items")
def create_item(item: Item = Body(..., embed=False)):
    """
    embed=False permet d'envoyer directement:
    {
      "name": "Laptop",
      "price": 999.99
    }
    
    Au lieu de:
    {
      "item": {
        "name": "Laptop",
        "price": 999.99
      }
    }
    """
    return item


"""
[COURS] EXERCICES PRATIQUES
---------------------

EXERCICE #1: Modèle User complet
"""

class UserCreate(BaseModel):
    """
    Modèle pour créer un utilisateur.
    
    CONTRAINTES À IMPLÉMENTER:
    - username: 3-20 caractères, alphanumériques seulement
    - email: format email valide
    - password: minimum 8 caractères
    - password_confirm: doit correspondre à password
    - age: entre 18 et 120
    - phone: optionnel
    """
    pass  # À vous de coder!

"""
SOLUTION:
"""

from pydantic import EmailStr, validator
import re

class UserCreate(BaseModel):
    username: str = Field(
        ...,
        min_length=3,
        max_length=20,
        regex="^[a-zA-Z0-9_]+$"
    )
    email: EmailStr  # Type spécial Pydantic pour emails!
    password: str = Field(..., min_length=8)
    password_confirm: str
    age: int = Field(..., ge=18, le=120)
    phone: str = None
    
    @validator('password_confirm')
    def passwords_match(cls, v, values):
        if 'password' in values and v != values['password']:
            raise ValueError('Les mots de passe ne correspondent pas')
        return v
    
    @validator('phone')
    def phone_format(cls, v):
        if v:
            # Vérifier format téléphone (exemple simple)
            if not re.match(r'^\+?[\d\s\-\(\)]+$', v):
                raise ValueError('Format de téléphone invalide')
        return v

@app.post("/users")
def create_user(user: UserCreate):
    # En vrai, on hasherait le password avant de sauvegarder!
    return {
        "username": user.username,
        "email": user.email,
        "age": user.age
    }

"""
TESTEZ:
{
  "username": "john_doe",
  "email": "john@example.com",
  "password": "SecurePass123",
  "password_confirm": "SecurePass123",
  "age": 25,
  "phone": "+33 1 23 45 67 89"
}


EXERCICE #2: Modèle de Blog Post
"""

class Comment(BaseModel):
    author: str
    content: str = Field(..., max_length=500)
    created_at: datetime = Field(default_factory=datetime.now)

class BlogPost(BaseModel):
    title: str = Field(..., min_length=5, max_length=200)
    content: str = Field(..., min_length=10)
    author: str
    tags: List[str] = Field(default=[], max_items=10)
    comments: List[Comment] = []
    published: bool = False
    
    @validator('tags')
    def tags_must_be_lowercase(cls, v):
        return [tag.lower() for tag in v]

@app.post("/posts")
def create_post(post: BlogPost):
    return {
        "id": 1,
        **post.dict(),
        "message": "Post créé avec succès!"
    }

"""
TEST:
{
  "title": "Introduction à FastAPI",
  "content": "FastAPI est un framework moderne...",
  "author": "John Doe",
  "tags": ["Python", "FastAPI", "API"],
  "published": true
}


[DOCS] RÉCAPITULATIF DU CHAPITRE 4
-----------------------------

Vous avez appris:
[OK] Pourquoi utiliser le request body (POST/PUT)
[OK] Créer des modèles Pydantic avec BaseModel
[OK] Validation automatique des types et contraintes
[OK] Field() pour contraintes avancées
[OK] Modèles imbriqués (nested models)
[OK] Validators personnalisés
[OK] Root validators
[OK] Combiner path, query et body parameters

Points clés:
[CLE] BaseModel = modèle de données avec validation
[CLE] Field() = contraintes et métadonnées
[CLE] Pydantic valide automatiquement types et contraintes
[CLE] @validator = logique de validation custom
[CLE] Modèles imbriqués = structures complexes validées
[CLE] FastAPI détecte automatiquement: path/query/body


[OBJECTIF] CONCEPTS FONDAMENTAUX À MAÎTRISER:

1. DIFFÉRENCE GET vs POST:
   GET = Recevoir des données (params dans URL)
   POST = Envoyer des données (body JSON)

2. PYDANTIC = CONTRAT:
   "Voici la structure attendue, valide-la automatiquement"

3. VALIDATION = SÉCURITÉ:
   Ne jamais faire confiance aux données du client!
   Pydantic les valide avant qu'elles n'atteignent votre code.


[OBJECTIF] AVANT DE CONTINUER:
Assurez-vous de pouvoir:
1. Créer un modèle Pydantic simple
2. Ajouter des contraintes avec Field()
3. Créer des modèles imbriqués
4. Écrire un validator personnalisé
5. Expliquer pourquoi Pydantic est essentiel

Prêt? Passons à la gestion des réponses!
"""


# ============================================================================
# [GUIDE] CHAPITRE 5: RESPONSE MODELS & STATUS CODES
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Apprendre à contrôler exactement ce que votre API renvoie:
- Structure des réponses
- Filtrage de données sensibles
- Codes de statut HTTP appropriés


[REFLEXION] LE PROBLÈME: CONTRÔLER CE QUI EST RENVOYÉ
-------------------------------------------

JUSQU'ICI, vous renvoyez directement des données:
"""

@app.post("/users")
def create_user(user: UserCreate):
    # Sauvegarder en DB (simulation)
    db_user = {
        "id": 1,
        "username": user.username,
        "email": user.email,
        "hashed_password": "hashed...",  # [ATTENTION] DANGER!
        "is_active": True,
        "created_at": datetime.now()
    }
    return db_user  # [X] On renvoie TOUT, y compris le password hashé!

"""
PROBLÈMES:
1. On renvoie des données sensibles (password hashé)
2. Pas de contrôle sur la structure de réponse
3. Pas de documentation claire de ce qui est renvoyé


[OK] SOLUTION: RESPONSE MODELS
---------------------------

[IDEE] QU'EST-CE QU'UN RESPONSE MODEL?

Un modèle Pydantic qui définit EXACTEMENT ce qui sera renvoyé au client.

ANALOGIE:
Imaginez un serveur de restaurant:
- En cuisine: plat complet avec notes de préparation
- Au client: assiette propre et présentable

Request Model = ce que vous recevez
Response Model = ce que vous renvoyez
Ils peuvent être différents!


CRÉER DES RESPONSE MODELS
-------------------------
"""

class UserCreate(BaseModel):
    """Ce que le client ENVOIE"""
    username: str
    email: EmailStr
    password: str
    password_confirm: str

class UserResponse(BaseModel):
    """Ce que le client REÇOIT"""
    id: int
    username: str
    email: EmailStr
    is_active: bool
    created_at: datetime
    # [ATTENTION] PAS de password!

@app.post("/users", response_model=UserResponse)
#                    ^^^^^^^^^^^^^^^^^^^^^^^^
#                    Défini ce qui sera renvoyé!
def create_user(user: UserCreate):
    """
    [IDEE] MAGIE FASTAPI #6: FILTRAGE AUTOMATIQUE!
    
    Que se passe-t-il?
    
    1. Client envoie UserCreate (avec password)
    2. Pydantic valide les données reçues
    3. Votre fonction s'exécute
    4. Vous retournez un dictionnaire avec TOUS les champs
    5. FastAPI filtre selon UserResponse
    6. Client reçoit SEULEMENT les champs de UserResponse
    
    VOUS pouvez retourner:
    """
    db_user = {
        "id": 1,
        "username": user.username,
        "email": user.email,
        "hashed_password": "super_secret_hash",  # <- Sera filtré!
        "is_active": True,
        "created_at": datetime.now(),
        "internal_note": "User from France"  # <- Sera filtré!
    }
    return db_user  # On retourne TOUT
    
    # Mais le client reçoit SEULEMENT:
    # {
    #   "id": 1,
    #   "username": "john",
    #   "email": "john@example.com",
    #   "is_active": true,
    #   "created_at": "2024-01-15T10:30:00"
    # }

"""
[IDEE] POURQUOI C'EST GÉNIAL:

SÉCURITÉ:
- Impossible d'exposer accidentellement des données sensibles
- Le password n'est jamais envoyé, même si vous l'incluez par erreur

DOCUMENTATION:
- /docs montre clairement la structure de réponse
- Le client sait exactement quoi attendre

ÉVOLUTIVITÉ:
- Vous pouvez changer votre modèle interne sans casser l'API
- Le contrat (UserResponse) reste stable


RESPONSE MODEL AVEC STATUS CODE
-------------------------------

Contrôler aussi le code de statut HTTP:
"""

@app.post("/users", 
          response_model=UserResponse, 
          status_code=status.HTTP_201_CREATED)
#                     ^^^^^^^^^^^^^^^^^^^^^^^^
#                     201 = Created (plus approprié que 200)
def create_user(user: UserCreate):
    """
    [IDEE] CODES HTTP COURANTS:
    
    2xx = Succès
    - 200 OK: Succès général (GET, PUT, DELETE)
    - 201 Created: Ressource créée (POST)
    - 204 No Content: Succès sans contenu (DELETE)
    
    4xx = Erreur client
    - 400 Bad Request: Requête invalide
    - 401 Unauthorized: Non authentifié
    - 403 Forbidden: Non autorisé (authentifié mais pas les droits)
    - 404 Not Found: Ressource non trouvée
    - 422 Unprocessable Entity: Validation échouée
    
    5xx = Erreur serveur
    - 500 Internal Server Error: Erreur dans votre code
    - 503 Service Unavailable: Service temporairement indisponible
    
    
    QUAND UTILISER QUOI?
    
    POST (création) -> 201 Created
    GET (lecture) -> 200 OK
    PUT (mise à jour complète) -> 200 OK
    PATCH (mise à jour partielle) -> 200 OK
    DELETE (suppression) -> 204 No Content ou 200 OK
    """
    db_user = create_user_in_db(user)
    return db_user


"""
RESPONSE MODEL: LISTE VS OBJET UNIQUE
-------------------------------------

Pour une liste d'objets:
"""

@app.get("/users", response_model=List[UserResponse])
#                                 ^^^^ Liste de UserResponse
def list_users(skip: int = 0, limit: int = 10):
    """
    CLIENT REÇOIT:
    [
      {
        "id": 1,
        "username": "alice",
        "email": "alice@example.com",
        "is_active": true,
        "created_at": "..."
      },
      {
        "id": 2,
        "username": "bob",
        ...
      }
    ]
    """
    users = get_users_from_db(skip, limit)
    return users

"""
[IDEE] PATTERN COMMUN: Wrapper de pagination
"""

class PaginatedResponse(BaseModel):
    """Wrapper générique pour réponses paginées"""
    total: int
    skip: int
    limit: int
    items: List[UserResponse]

@app.get("/users", response_model=PaginatedResponse)
def list_users(skip: int = 0, limit: int = 10):
    """
    CLIENT REÇOIT:
    {
      "total": 42,
      "skip": 0,
      "limit": 10,
      "items": [
        {"id": 1, "username": "alice", ...},
        {"id": 2, "username": "bob", ...}
      ]
    }
    
    [IDEE] PLUS D'INFO = MEILLEURE UX!
    Le client sait:
    - Combien d'items au total
    - Où il en est dans la pagination
    - Combien il a reçus
    """
    users = get_users_from_db(skip, limit)
    total = count_users_in_db()
    
    return {
        "total": total,
        "skip": skip,
        "limit": limit,
        "items": users
    }


"""
EXCLURE DES CHAMPS DYNAMIQUEMENT
--------------------------------

Parfois, vous voulez inclure/exclure des champs selon le contexte:
"""

class UserFull(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool
    is_superuser: bool
    phone: str = None
    address: str = None

# Vue publique: SEULEMENT username
@app.get("/users/{user_id}/public", 
         response_model=UserFull,
         response_model_include={"username"})
def get_user_public(user_id: int):
    """
    response_model_include = SEULEMENT ces champs
    
    CLIENT REÇOIT:
    {
      "username": "john"
    }
    """
    user = get_user_from_db(user_id)
    return user

# Vue profile: Exclu is_superuser
@app.get("/users/{user_id}", 
         response_model=UserFull,
         response_model_exclude={"is_superuser"})
def get_user(user_id: int):
    """
    response_model_exclude = TOUS SAUF ces champs
    
    CLIENT REÇOIT:
    {
      "id": 1,
      "username": "john",
      "email": "john@example.com",
      "is_active": true,
      "phone": "+33...",
      "address": "..."
      // is_superuser est exclu!
    }
    """
    user = get_user_from_db(user_id)
    return user

"""
[IDEE] QUAND UTILISER?

response_model_include:
- Endpoints publics (profil public d'utilisateur)
- APIs avec différents niveaux de détail

response_model_exclude:
- Cacher champs sensibles dans certains contextes
- Optimiser taille de réponse


[ATTENTION] PRÉFÉREZ: Modèles dédiés (UserPublic, UserDetailed)
Plus clair et plus maintenable que include/exclude!


EXCLURE VALEURS NON DÉFINIES
----------------------------
"""

class Item(BaseModel):
    name: str
    description: str = None
    price: float
    tax: float = 0.0

@app.get("/items/{item_id}",
         response_model=Item,
         response_model_exclude_unset=True)
def get_item(item_id: int):
    """
    response_model_exclude_unset = True:
    N'inclut QUE les champs explicitement définis
    
    Si vous retournez:
    {"name": "Laptop", "price": 999.99}
    
    CLIENT REÇOIT (avec exclude_unset=True):
    {
      "name": "Laptop",
      "price": 999.99
      // description et tax absents (pas définis)
    }
    
    CLIENT REÇOIT (avec exclude_unset=False, défaut):
    {
      "name": "Laptop",
      "description": null,
      "price": 999.99,
      "tax": 0.0
      // Inclut les valeurs par défaut
    }
    
    [IDEE] QUAND UTILISER?
    - APIs RESTful strictes (ne pas envoyer null si pas de valeur)
    - Optimiser taille de réponse
    - Distinguer "non défini" de "null"
    """
    item = {"name": "Laptop", "price": 999.99}
    return item


"""
RESPONSE MODEL DIFFÉRENT SELON STATUS
------------------------------------

Documenter différentes réponses possibles:
"""

class ErrorResponse(BaseModel):
    detail: str
    error_code: str = None

@app.get("/items/{item_id}",
         response_model=Item,
         responses={
             404: {"model": ErrorResponse, "description": "Item not found"},
             403: {"model": ErrorResponse, "description": "Not authorized"}
         })
def get_item(item_id: int):
    """
    [IDEE] DOCUMENTATION MULTI-RÉPONSES!
    
    Dans /docs, vous verrez:
    - 200: Retourne Item (cas normal)
    - 404: Retourne ErrorResponse si item non trouvé
    - 403: Retourne ErrorResponse si pas autorisé
    
    C'est SEULEMENT pour la documentation!
    Vous devez toujours lever les exceptions vous-même.
    """
    item = get_item_from_db(item_id)
    
    if not item:
        raise HTTPException(
            status_code=404,
            detail="Item not found"
        )
    
    return item


"""
UNION TYPES: PLUSIEURS TYPES POSSIBLES
--------------------------------------

Pour endpoints qui peuvent retourner différents types:
"""

from typing import Union

class SuccessResponse(BaseModel):
    success: bool = True
    data: dict

class ErrorResponse(BaseModel):
    success: bool = False
    error: str

@app.post("/process", response_model=Union[SuccessResponse, ErrorResponse])
def process_data(data: dict):
    """
    [IDEE] Peut retourner SOIT SuccessResponse SOIT ErrorResponse
    
    FastAPI choisit automatiquement le bon modèle selon ce que vous retournez!
    """
    try:
        result = process(data)
        return SuccessResponse(data=result)
    except Exception as e:
        return ErrorResponse(error=str(e))


"""
[COURS] EXERCICES PRATIQUES
---------------------

EXERCICE #1: API de Blog avec réponses appropriées
"""

class BlogPostCreate(BaseModel):
    title: str = Field(..., min_length=5, max_length=200)
    content: str = Field(..., min_length=10)
    published: bool = False

class BlogPostResponse(BaseModel):
    id: int
    title: str
    content: str
    author: str
    published: bool
    created_at: datetime
    updated_at: datetime

class BlogPostList(BaseModel):
    id: int
    title: str
    author: str
    published: bool
    created_at: datetime
    # Pas de content (trop gros pour une liste!)

class BlogPostsResponse(BaseModel):
    total: int
    posts: List[BlogPostList]

"""
IMPLÉMENTEZ:

1. POST /posts (créer un post)
   - Status 201
   - Response: BlogPostResponse
   
2. GET /posts (lister les posts)
   - Status 200
   - Response: BlogPostsResponse
   - Query params: skip, limit, published
   
3. GET /posts/{post_id} (un post complet)
   - Status 200
   - Response: BlogPostResponse
   - 404 si non trouvé
   
4. DELETE /posts/{post_id}
   - Status 204
   - Pas de response body

SOLUTION:
"""

@app.post("/posts", 
          response_model=BlogPostResponse,
          status_code=status.HTTP_201_CREATED)
def create_post(post: BlogPostCreate):
    db_post = {
        "id": 1,
        "title": post.title,
        "content": post.content,
        "author": "current_user",  # En vrai: depuis auth
        "published": post.published,
        "created_at": datetime.now(),
        "updated_at": datetime.now()
    }
    return db_post

@app.get("/posts", response_model=BlogPostsResponse)
def list_posts(
    skip: int = 0,
    limit: int = 10,
    published: bool = None
):
    # Simuler récupération DB
    posts = [
        {
            "id": 1,
            "title": "Post 1",
            "author": "Alice",
            "published": True,
            "created_at": datetime.now()
        }
    ]
    
    return {
        "total": len(posts),
        "posts": posts
    }

@app.get("/posts/{post_id}",
         response_model=BlogPostResponse,
         responses={
             404: {"model": ErrorResponse}
         })
def get_post(post_id: int):
    # Simuler récupération
    if post_id not in [1, 2, 3]:
        raise HTTPException(
            status_code=404,
            detail=f"Post {post_id} not found"
        )
    
    return {
        "id": post_id,
        "title": "Post title",
        "content": "Post content...",
        "author": "Alice",
        "published": True,
        "created_at": datetime.now(),
        "updated_at": datetime.now()
    }

@app.delete("/posts/{post_id}",
            status_code=status.HTTP_204_NO_CONTENT)
def delete_post(post_id: int):
    if post_id not in [1, 2, 3]:
        raise HTTPException(status_code=404, detail="Post not found")
    
    # Supprimer en DB
    return None  # 204 = pas de contenu


"""
EXERCICE #2: Réponses différentes selon rôle utilisateur
"""

class UserBasic(BaseModel):
    """Vue publique"""
    id: int
    username: str

class UserDetailed(BaseModel):
    """Vue pour l'utilisateur lui-même"""
    id: int
    username: str
    email: EmailStr
    phone: str = None

class UserAdmin(BaseModel):
    """Vue pour les admins"""
    id: int
    username: str
    email: EmailStr
    phone: str = None
    is_active: bool
    is_superuser: bool
    created_at: datetime

# Endpoint qui retourne différents niveaux selon qui appelle
@app.get("/users/{user_id}")
def get_user(
    user_id: int,
    current_user: str = "guest"  # En vrai: depuis auth
):
    """
    [IDEE] RETOURNE DIFFÉRENTS NIVEAUX SELON QUI DEMANDE
    
    guest -> UserBasic
    user himself -> UserDetailed
    admin -> UserAdmin
    
    Pas de response_model fixe car dépend du contexte!
    """
    user = get_user_from_db(user_id)
    
    if current_user == "admin":
        return UserAdmin(**user)
    elif current_user == user_id:
        return UserDetailed(**user)
    else:
        return UserBasic(**user)


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 5
-----------------------------

Vous avez appris:
[OK] Pourquoi séparer request et response models
[OK] response_model pour filtrer ce qui est renvoyé
[OK] Status codes HTTP appropriés
[OK] Liste vs objet unique en réponse
[OK] Include/exclude de champs
[OK] exclude_unset pour valeurs non définies
[OK] Documenter plusieurs réponses possibles
[OK] Union types pour réponses variables

Points clés:
[CLE] response_model = contrat de ce qui est renvoyé
[CLE] Filtre automatique des champs non dans le modèle
[CLE] Status codes appropriés: 200, 201, 204, 404, etc.
[CLE] Request model ≠ Response model (souvent)
[CLE] Wrapper de pagination pour metadata utiles


[OBJECTIF] BONNES PRATIQUES:

1. TOUJOURS utiliser response_model pour:
   - Sécurité (filtrer données sensibles)
   - Documentation claire
   - Contrat stable

2. CODES HTTP appropriés:
   - POST créer -> 201
   - DELETE succès -> 204
   - Ressource non trouvée -> 404
   - Validation échouée -> 422 (FastAPI le fait auto)

3. MODÈLES DÉDIÉS:
   - UserCreate (ce qu'on reçoit)
   - UserResponse (ce qu'on renvoie)
   - UserUpdate (mise à jour partielle)
   - Plus clair que include/exclude

4. PAGINATION:
   - Toujours retourner metadata (total, page, etc.)
   - Wrapper générique réutilisable

5. DOCUMENTATION:
   - responses={} pour documenter erreurs
   - Descriptions claires
   - Exemples dans les modèles


[OBJECTIF] AVANT DE CONTINUER:
Assurez-vous de pouvoir:
1. Créer request et response models séparés
2. Utiliser response_model pour filtrer
3. Choisir le bon status code
4. Créer une réponse paginée
5. Expliquer pourquoi filtrer les réponses

Prêt? Passons à la gestion des erreurs!
"""


# ============================================================================
# [GUIDE] CHAPITRE 6: ERROR HANDLING (Gestion des erreurs)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Maîtriser la gestion professionnelle des erreurs dans vos APIs:
- Renvoyer des erreurs claires et utiles
- Codes HTTP appropriés
- Messages d'erreur informatifs
- Gestion centralisée des erreurs


[REFLEXION] POURQUOI LA GESTION D'ERREURS EST CRITIQUE?
--------------------------------------------

Une bonne gestion d'erreurs:
[OK] Aide les développeurs à debugger
[OK] Donne des messages clairs aux utilisateurs
[OK] Cache les détails techniques sensibles
[OK] Permet de logger et monitorer les problèmes

Une mauvaise gestion d'erreurs:
[X] L'API crash complètement
[X] Messages cryptiques: "Error 500"
[X] Expose stack traces (faille de sécurité!)
[X] Pas d'info pour debugger


EXEMPLE DE MAUVAISE GESTION
---------------------------

Sans gestion d'erreurs:
"""

@app.get("/items/{item_id}")
def get_item(item_id: int):
    items = {1: "Item 1", 2: "Item 2"}
    return {"item": items[item_id]}  # [IMPACT] KeyError si item_id=3!

"""
Que se passe-t-il si item_id=3?
1. items[3] lève KeyError
2. FastAPI attrape l'erreur
3. Renvoie: {"detail": "Internal Server Error"}
4. Status: 500 (erreur serveur)

PROBLÈMES:
- Le client ne sait pas ce qui s'est passé
- 500 suggère un bug dans le serveur (alors que c'est juste "item non trouvé")
- Pas de message utile


[OK] BONNE GESTION: HTTPException
------------------------------

HTTPException est la façon standard de lever des erreurs HTTP:
"""

from fastapi import HTTPException

@app.get("/items/{item_id}")
def get_item(item_id: int):
    items = {1: "Item 1", 2: "Item 2"}
    
    if item_id not in items:
        raise HTTPException(
            status_code=404,
            detail=f"Item {item_id} not found"
        )
    
    return {"item": items[item_id]}

"""
[REFLEXION] DÉCORTIQUONS HTTPException:

HTTPException(
    status_code=404,  <- Code HTTP approprié
    detail="..."      <- Message pour le client
)

[IDEE] QUE SE PASSE-T-IL?

1. Condition d'erreur détectée: item_id pas dans items
2. raise HTTPException(...) lance l'exception
3. FastAPI attrape l'exception
4. Convertit en réponse HTTP appropriée:
   Status: 404 Not Found
   Body: {"detail": "Item 3 not found"}
5. Votre fonction s'arrête (le code après raise n'est pas exécuté)
6. Client reçoit une réponse claire et utile!


[IDEE] POURQUOI C'EST MIEUX:
- Code HTTP correct (404 = "non trouvé", pas 500)
- Message clair et utile
- Pas d'exposition de détails techniques
- Client peut gérer l'erreur correctement


CODES HTTP POUR ERREURS COURANTES
---------------------------------

[IDEE] RÈGLE: Choisir le code qui décrit LE MIEUX le problème
"""

# 400 Bad Request: Requête invalide (format incorrect, données manquantes)
raise HTTPException(
    status_code=400,
    detail="Invalid request format"
)

# 401 Unauthorized: Non authentifié (pas de token, token invalide)
raise HTTPException(
    status_code=401,
    detail="Authentication required"
)

# 403 Forbidden: Authentifié mais pas les droits
raise HTTPException(
    status_code=403,
    detail="You don't have permission to access this resource"
)

# 404 Not Found: Ressource non trouvée
raise HTTPException(
    status_code=404,
    detail="Item not found"
)

# 409 Conflict: Conflit (ex: email déjà utilisé)
raise HTTPException(
    status_code=409,
    detail="Email already registered"
)

# 422 Unprocessable Entity: Validation échouée
# (FastAPI le fait automatiquement avec Pydantic, mais vous pouvez le faire manuellement aussi)
raise HTTPException(
    status_code=422,
    detail="Validation failed"
)

# 429 Too Many Requests: Trop de requêtes (rate limiting)
raise HTTPException(
    status_code=429,
    detail="Too many requests. Please try again later."
)

# 500 Internal Server Error: Erreur dans votre code
# Évitez de lever ça manuellement! C'est pour les erreurs inattendues.

# 503 Service Unavailable: Service temporairement indisponible
raise HTTPException(
    status_code=503,
    detail="Database is currently unavailable"
)

"""
[IDEE] CHOISIR LE BON CODE:

QUESTION: "De QUI est la faute?"

-> CLIENT (4xx):
  - Données invalides -> 400
  - Pas authentifié -> 401
  - Pas autorisé -> 403
  - Ressource non trouvée -> 404
  - Conflit -> 409

-> SERVEUR (5xx):
  - Bug dans le code -> 500
  - Service down -> 503


HTTPEXCEPTION AVEC HEADERS
--------------------------

Vous pouvez ajouter des headers personnalisés:
"""

@app.get("/items/{item_id}")
def get_item(item_id: int):
    if item_id not in items:
        raise HTTPException(
            status_code=404,
            detail="Item not found",
            headers={"X-Error": "Item-Not-Found-Error"}
        )
    return {"item": items[item_id]}

"""
[IDEE] QUAND UTILISER?
- Authentification: WWW-Authenticate header
- Rate limiting: Retry-After header
- Errors tracking: X-Request-ID
- CORS custom headers


EXEMPLE: RATE LIMITING
"""

@app.get("/limited-endpoint/")
def limited_endpoint():
    # Vérifier si limite atteinte
    if check_rate_limit_exceeded():
        raise HTTPException(
            status_code=429,
            detail="Too many requests",
            headers={"Retry-After": "60"}  # Réessayer dans 60 secondes
        )
    
    return {"message": "Success"}


"""
EXCEPTIONS PERSONNALISÉES
-------------------------

Pour créer vos propres types d'exceptions:
"""

class ItemNotFoundException(Exception):
    """Exception levée quand un item n'est pas trouvé"""
    def __init__(self, item_id: int):
        self.item_id = item_id
        super().__init__(f"Item {item_id} not found")

class InsufficientStockException(Exception):
    """Exception levée quand pas assez de stock"""
    def __init__(self, item_id: int, available: int, requested: int):
        self.item_id = item_id
        self.available = available
        self.requested = requested
        super().__init__(
            f"Insufficient stock for item {item_id}. "
            f"Available: {available}, Requested: {requested}"
        )

"""
[IDEE] POURQUOI CRÉER SES PROPRES EXCEPTIONS?

1. CLARTÉ DU CODE:
   raise ItemNotFoundException(item_id)
   vs
   raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
   -> Plus lisible, plus réutilisable

2. MÉTADONNÉES:
   Vous pouvez attacher des données à l'exception
   (item_id, available, requested, etc.)

3. GESTION CENTRALISÉE:
   Un seul endroit pour décider comment convertir en HTTP


EXCEPTION HANDLERS
-----------------

Pour gérer vos exceptions personnalisées:
"""

from fastapi import Request
from fastapi.responses import JSONResponse

@app.exception_handler(ItemNotFoundException)
async def item_not_found_handler(
    request: Request,
    exc: ItemNotFoundException
):
    """
    [IDEE] EXCEPTION HANDLER PERSONNALISÉ!
    
    Quand ItemNotFoundException est levée ANYWHERE dans l'API,
    cette fonction est appelée automatiquement!
    
    Vous pouvez:
    - Choisir le status code
    - Formater le message
    - Logger l'erreur
    - Ajouter des headers
    - Tout ce que vous voulez!
    """
    return JSONResponse(
        status_code=404,
        content={
            "error": "Item not found",
            "item_id": exc.item_id,
            "message": str(exc),
            "suggestion": "Check available items at /items"
        }
    )

@app.exception_handler(InsufficientStockException)
async def insufficient_stock_handler(
    request: Request,
    exc: InsufficientStockException
):
    return JSONResponse(
        status_code=409,  # Conflict
        content={
            "error": "Insufficient stock",
            "item_id": exc.item_id,
            "available": exc.available,
            "requested": exc.requested,
            "message": str(exc)
        }
    )

"""
[IDEE] UTILISATION:
"""

@app.get("/items/{item_id}")
def get_item(item_id: int):
    # Pas besoin de gérer HTTPException!
    # Levez juste votre exception personnalisée
    if item_id not in items:
        raise ItemNotFoundException(item_id)
    
    return {"item": items[item_id]}

@app.post("/orders/")
def create_order(item_id: int, quantity: int):
    available_stock = get_stock(item_id)
    
    if quantity > available_stock:
        raise InsufficientStockException(
            item_id=item_id,
            available=available_stock,
            requested=quantity
        )
    
    # Créer commande...
    return {"message": "Order created"}

"""
[IDEE] AVANTAGES:

1. CODE MÉTIER PROPRE:
   Votre code lève des exceptions métier claires
   (ItemNotFoundException, InsufficientStockException)

2. GESTION CENTRALISÉE:
   Un seul endroit décide comment chaque erreur est convertie en HTTP

3. ÉVOLUTIVITÉ:
   Changez la structure d'erreur une fois, ça s'applique partout

4. LOGGING:
   Vous pouvez logger dans le handler


OVERRIDE EXCEPTION HANDLERS PAR DÉFAUT
--------------------------------------

FastAPI a des handlers par défaut. Vous pouvez les override:
"""

from fastapi.exceptions import RequestValidationError
from fastapi.responses import PlainTextResponse

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError
):
    """
    [IDEE] OVERRIDE LE HANDLER PYDANTIC PAR DÉFAUT!
    
    Par défaut, Pydantic renvoie un JSON avec tous les détails.
    Vous pouvez changer ça!
    
    ATTENTION: L'erreur par défaut est BONNE!
    Ne changez que si vous avez une bonne raison.
    """
    # Exemple: Retourner du plain text au lieu de JSON
    return PlainTextResponse(
        str(exc),
        status_code=400
    )

"""
[ATTENTION] ATTENTION:
N'override les handlers par défaut que si vraiment nécessaire!
Les handlers par défaut de FastAPI sont très bien:
- Messages clairs
- Détails utiles pour débugger
- Format standard


GESTION D'ERREURS GLOBALE
-------------------------

Pour attraper TOUTES les erreurs inattendues:
"""

@app.exception_handler(Exception)
async def global_exception_handler(
    request: Request,
    exc: Exception
):
    """
    [IDEE] CATCH-ALL POUR ERREURS INATTENDUES!
    
    Si AUCUN autre handler ne matche,
    celui-ci attrape l'erreur.
    
    Utile pour:
    - Logger toutes les erreurs
    - Cacher stack traces en production
    - Message générique pour le client
    - Alerter l'équipe DevOps
    """
    # Logger l'erreur (en vrai: Sentry, CloudWatch, etc.)
    logger.error(f"Unexpected error: {exc}", exc_info=True)
    
    # Alerte si critique
    if is_critical_error(exc):
        send_alert_to_devops(exc)
    
    # Réponse générique pour le client
    # (ne pas exposer détails techniques!)
    return JSONResponse(
        status_code=500,
        content={
            "error": "Internal server error",
            "message": "An unexpected error occurred. Our team has been notified.",
            "request_id": generate_request_id()  # Pour tracer l'erreur
        }
    )


"""
VALIDATION CUSTOM
----------------

Parfois, vous voulez une validation plus complexe que Pydantic:
"""

class OrderCreate(BaseModel):
    item_id: int
    quantity: int = Field(..., gt=0)
    shipping_address: str

@app.post("/orders/")
def create_order(order: OrderCreate):
    """
    Pydantic a validé:
    [OK] item_id est un int
    [OK] quantity est > 0
    [OK] shipping_address est présent
    
    Mais il faut AUSSI vérifier:
    - L'item existe
    - Il y a assez de stock
    - L'adresse est dans une zone de livraison
    
    Ces validations ne peuvent pas être faites par Pydantic!
    """
    
    # Vérifier que l'item existe
    item = get_item_from_db(order.item_id)
    if not item:
        raise HTTPException(
            status_code=404,
            detail=f"Item {order.item_id} does not exist"
        )
    
    # Vérifier le stock
    if item.stock < order.quantity:
        raise HTTPException(
            status_code=409,
            detail={
                "error": "Insufficient stock",
                "available": item.stock,
                "requested": order.quantity
            }
        )
    
    # Vérifier zone de livraison
    if not is_deliverable(order.shipping_address):
        raise HTTPException(
            status_code=400,
            detail="We don't deliver to this address yet"
        )
    
    # Tout est OK, créer la commande
    db_order = create_order_in_db(order)
    return db_order

"""
[IDEE] PATTERN:
1. Pydantic valide la STRUCTURE (types, contraintes basiques)
2. Votre code valide la LOGIQUE MÉTIER (existence, cohérence, règles)


MESSAGES D'ERREUR STRUCTURÉS
----------------------------

Pour des erreurs complexes avec plusieurs problèmes:
"""

class ValidationError(BaseModel):
    field: str
    message: str
    code: str

class ErrorResponse(BaseModel):
    error: str
    errors: List[ValidationError] = []

@app.post("/users/")
def create_user(user: UserCreate):
    errors = []
    
    # Vérifier si username existe
    if username_exists(user.username):
        errors.append(ValidationError(
            field="username",
            message="This username is already taken",
            code="USERNAME_TAKEN"
        ))
    
    # Vérifier si email existe
    if email_exists(user.email):
        errors.append(ValidationError(
            field="email",
            message="This email is already registered",
            code="EMAIL_TAKEN"
        ))
    
    # Vérifier mot de passe faible
    if not is_strong_password(user.password):
        errors.append(ValidationError(
            field="password",
            message="Password is too weak. Use at least 8 characters with letters and numbers",
            code="WEAK_PASSWORD"
        ))
    
    # Si erreurs, lever exception avec toutes les erreurs
    if errors:
        raise HTTPException(
            status_code=400,
            detail=ErrorResponse(
                error="Validation failed",
                errors=errors
            ).dict()
        )
    
    # Créer utilisateur...
    return create_user_in_db(user)

"""
CLIENT REÇOIT:
{
  "detail": {
    "error": "Validation failed",
    "errors": [
      {
        "field": "username",
        "message": "This username is already taken",
        "code": "USERNAME_TAKEN"
      },
      {
        "field": "password",
        "message": "Password is too weak...",
        "code": "WEAK_PASSWORD"
      }
    ]
  }
}

[IDEE] POURQUOI C'EST BIEN:
- Client voit TOUS les problèmes d'un coup (pas besoin de réessayer plusieurs fois)
- Messages clairs et actionnables
- Codes d'erreur pour traitement programmatique
- Champs identifiés (utile pour mettre en rouge dans le formulaire)


[COURS] EXERCICES PRATIQUES
---------------------

EXERCICE #1: API de bibliothèque avec gestion d'erreurs
"""

BOOKS = {
    1: {"title": "1984", "author": "Orwell", "available": True},
    2: {"title": "Le Petit Prince", "author": "Saint-Exupéry", "available": False},
}

BORROWINGS = {}  # {user_id: [book_ids]}

"""
IMPLÉMENTEZ ces endpoints avec gestion d'erreurs appropriée:

1. GET /books/{book_id}
   - 404 si livre non trouvé
   
2. POST /borrow
   Body: {"user_id": int, "book_id": int}
   - 404 si livre non trouvé
   - 409 si livre déjà emprunté
   - 400 si utilisateur a déjà 3 livres
   
3. POST /return
   Body: {"user_id": int, "book_id": int}
   - 404 si livre non trouvé
   - 400 si utilisateur n'a pas emprunté ce livre

SOLUTION:
"""

class BorrowRequest(BaseModel):
    user_id: int
    book_id: int

@app.get("/books/{book_id}")
def get_book(book_id: int):
    if book_id not in BOOKS:
        raise HTTPException(
            status_code=404,
            detail=f"Book {book_id} not found"
        )
    
    return BOOKS[book_id]

@app.post("/borrow")
def borrow_book(request: BorrowRequest):
    # Vérifier que le livre existe
    if request.book_id not in BOOKS:
        raise HTTPException(
            status_code=404,
            detail=f"Book {request.book_id} not found"
        )
    
    book = BOOKS[request.book_id]
    
    # Vérifier que le livre est disponible
    if not book["available"]:
        raise HTTPException(
            status_code=409,
            detail=f"Book '{book['title']}' is currently not available"
        )
    
    # Vérifier limite d'emprunts
    user_books = BORROWINGS.get(request.user_id, [])
    if len(user_books) >= 3:
        raise HTTPException(
            status_code=400,
            detail="You cannot borrow more than 3 books at a time",
            headers={"X-Current-Borrowed": str(len(user_books))}
        )
    
    # Emprunter
    book["available"] = False
    BORROWINGS.setdefault(request.user_id, []).append(request.book_id)
    
    return {
        "message": f"Successfully borrowed '{book['title']}'",
        "book": book,
        "borrowed_books": len(BORROWINGS[request.user_id])
    }

@app.post("/return")
def return_book(request: BorrowRequest):
    # Vérifier que le livre existe
    if request.book_id not in BOOKS:
        raise HTTPException(
            status_code=404,
            detail=f"Book {request.book_id} not found"
        )
    
    # Vérifier que l'utilisateur a emprunté ce livre
    user_books = BORROWINGS.get(request.user_id, [])
    if request.book_id not in user_books:
        raise HTTPException(
            status_code=400,
            detail="You haven't borrowed this book"
        )
    
    # Retourner
    book = BOOKS[request.book_id]
    book["available"] = True
    user_books.remove(request.book_id)
    
    return {
        "message": f"Successfully returned '{book['title']}'",
        "borrowed_books": len(user_books)
    }


"""
EXERCICE #2: Exception handlers personnalisés
"""

# Définir exceptions
class BookNotFoundException(Exception):
    def __init__(self, book_id: int):
        self.book_id = book_id

class BookUnavailableException(Exception):
    def __init__(self, book_id: int, title: str):
        self.book_id = book_id
        self.title = title

class BorrowLimitExceededException(Exception):
    def __init__(self, user_id: int, current_count: int, limit: int):
        self.user_id = user_id
        self.current_count = current_count
        self.limit = limit

# Créer handlers
@app.exception_handler(BookNotFoundException)
async def book_not_found_handler(request: Request, exc: BookNotFoundException):
    return JSONResponse(
        status_code=404,
        content={
            "error": "Book not found",
            "book_id": exc.book_id,
            "suggestion": "Check available books at /books"
        }
    )

@app.exception_handler(BookUnavailableException)
async def book_unavailable_handler(request: Request, exc: BookUnavailableException):
    return JSONResponse(
        status_code=409,
        content={
            "error": "Book unavailable",
            "book_id": exc.book_id,
            "title": exc.title,
            "message": f"The book '{exc.title}' is currently borrowed"
        }
    )

@app.exception_handler(BorrowLimitExceededException)
async def borrow_limit_handler(request: Request, exc: BorrowLimitExceededException):
    return JSONResponse(
        status_code=400,
        content={
            "error": "Borrow limit exceeded",
            "user_id": exc.user_id,
            "current": exc.current_count,
            "limit": exc.limit,
            "message": f"You have {exc.current_count}/{exc.limit} books. Please return some before borrowing more."
        }
    )

# Réimplémenter avec exceptions personnalisées
@app.post("/borrow")
def borrow_book_v2(request: BorrowRequest):
    if request.book_id not in BOOKS:
        raise BookNotFoundException(request.book_id)
    
    book = BOOKS[request.book_id]
    
    if not book["available"]:
        raise BookUnavailableException(request.book_id, book["title"])
    
    user_books = BORROWINGS.get(request.user_id, [])
    if len(user_books) >= 3:
        raise BorrowLimitExceededException(
            request.user_id,
            len(user_books),
            3
        )
    
    # Emprunter...
    book["available"] = False
    BORROWINGS.setdefault(request.user_id, []).append(request.book_id)
    
    return {"message": "Success"}


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 6
-----------------------------

Vous avez appris:
[OK] HTTPException pour lever des erreurs HTTP
[OK] Choisir le bon code de statut (400, 401, 403, 404, 409, etc.)
[OK] Messages d'erreur clairs et actionnables
[OK] Headers personnalisés dans erreurs
[OK] Exceptions personnalisées
[OK] Exception handlers pour gestion centralisée
[OK] Override des handlers par défaut
[OK] Gestion globale des erreurs
[OK] Messages d'erreur structurés

Points clés:
[CLE] HTTPException = façon standard de renvoyer erreurs
[CLE] Code HTTP = QUI est responsable (4xx=client, 5xx=serveur)
[CLE] Exception handlers = gestion centralisée
[CLE] Exceptions custom = code métier plus clair
[CLE] Messages clairs > codes cryptiques


[OBJECTIF] BONNES PRATIQUES:

1. TOUJOURS renvoyer le code HTTP approprié:
   [X] Tout en 200 + {"error": "..."}
   [OK] 404, 409, etc. selon le problème

2. MESSAGES CLAIRS ET ACTIONNABLES:
   [X] "Invalid input"
   [OK] "Email is already registered. Please use another email or login."

3. NE PAS EXPOSER DÉTAILS TECHNIQUES:
   [X] Stack traces en production
   [OK] Message générique + logging interne

4. STRUCTURE COHÉRENTE:
   Tous vos messages d'erreur devraient avoir la même structure
   Exemple: {"error": "...", "message": "...", "code": "..."}

5. CODES D'ERREUR:
   Pour traitement programmatique côté client
   Exemple: "USERNAME_TAKEN", "INSUFFICIENT_STOCK"

6. PLUSIEURS ERREURS À LA FOIS:
   Ne forcez pas l'utilisateur à réessayer plusieurs fois
   Renvoyez toutes les erreurs d'un coup

7. LOGGING:
   Toujours logger les erreurs (surtout 500)
   Pour debug et monitoring


[OBJECTIF] HIÉRARCHIE DES ERREURS:

1. Pydantic attrape: Types et contraintes basiques
2. Votre validation attrape: Logique métier
3. Exception handlers attrapent: Exceptions levées
4. Global handler attrape: Erreurs inattendues


[OBJECTIF] AVANT DE CONTINUER:
Assurez-vous de pouvoir:
1. Lever une HTTPException avec code approprié
2. Créer une exception personnalisée
3. Écrire un exception handler
4. Expliquer quel code HTTP utiliser quand
5. Structurer des messages d'erreur clairs

Prêt? Passons aux dépendances!
"""


# ============================================================================
# [LIVRE] FIN DU GUIDE ULTRA-DÉTAILLÉ - PARTIE 1
# ============================================================================

"""
[BRAVO] FÉLICITATIONS!

Vous avez complété la première partie du guide!

Vous avez appris:
[OK] Les bases de FastAPI et pourquoi il existe
[OK] Path parameters (paramètres dans l'URL)
[OK] Query parameters (filtres et options)
[OK] Request body avec Pydantic (validation automatique)
[OK] Response models (contrôle de ce qui est renvoyé)
[OK] Error handling (gestion professionnelle des erreurs)


[DOCS] PROCHAINES PARTIES À VENIR:

PARTIE 2:
- Dependencies (réutilisation de code)
- Security & Authentication (JWT, OAuth2)
- File Upload (téléversement de fichiers)
- Static Files & Templates (fichiers statiques et templates HTML)

PARTIE 3:
- Async/Await (programmation asynchrone)
- Background Tasks (tâches en arrière-plan)
- Database Integration (SQLAlchemy)
- Middleware (intergiciels)
- WebSockets (communication temps réel)

PARTIE 4:
- Testing (tests automatisés)
- Configuration & Environments (gestion de configuration)
- Deployment (déploiement en production)
- Best Practices (bonnes pratiques)


[OBJECTIF] AVANT DE CONTINUER:

Prenez le temps de:
1. Relire les parties qui ne sont pas claires
2. Tester TOUS les exemples
3. Faire les exercices
4. Créer votre propre petit projet pour pratiquer

Les concepts des 6 premiers chapitres sont FONDAMENTAUX.
Maîtrisez-les avant de continuer!


[IDEE] CONSEIL:

Créez une petite API de TODO list pour pratiquer:
- POST /todos (créer un todo avec request body)
- GET /todos (lister avec query params: skip, limit, completed)
- GET /todos/{todo_id} (un todo spécifique avec path param)
- PUT /todos/{todo_id} (mettre à jour)
- DELETE /todos/{todo_id} (supprimer)
- Response models pour filtrer ce qui est renvoyé
- Gestion d'erreurs appropriée (404, etc.)

C'est le meilleur moyen d'ancrer ces concepts!


[GUIDE] RESSOURCES SUPPLÉMENTAIRES:

- Documentation officielle: https://fastapi.tiangolo.com
- Testez dans /docs (Swagger UI)
- /redoc pour documentation alternative
- Examinez le schéma OpenAPI: /openapi.json


Bon courage pour la suite! [RAPIDE]
"""

# ============================================================================
# [LIVRE] FASTAPI - GUIDE ULTRA-DÉTAILLÉ PARTIE 2
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COUVRE:
# - Dependencies (Dependency Injection)
# - Security & Authentication (JWT, OAuth2)
# - File Upload (Téléversement avancé)
# - Static Files & Templates
#
# [TEMPS] TEMPS DE LECTURE: ~3-4 heures
# [DOCS] PRÉREQUIS: Avoir complété la Partie 1
# ============================================================================


# ============================================================================
# [GUIDE] CHAPITRE 7: DEPENDENCIES (Injection de dépendances)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Comprendre et maîtriser le système de dépendances de FastAPI,
un des concepts les plus puissants du framework.


[REFLEXION] LE PROBLÈME: CODE RÉPÉTITIF
-----------------------------

Sans système de dépendances, vous répétez le même code partout:
"""

@app.get("/users")
def list_users(skip: int = 0, limit: int = 10):
    # Valider pagination
    if limit > 100:
        raise HTTPException(400, "Limit too high")
    return get_users(skip, limit)

@app.get("/posts")
def list_posts(skip: int = 0, limit: int = 10):
    # Valider pagination (ENCORE!)
    if limit > 100:
        raise HTTPException(400, "Limit too high")
    return get_posts(skip, limit)

@app.get("/comments")
def list_comments(skip: int = 0, limit: int = 10):
    # Valider pagination (ENCORE ET ENCORE!)
    if limit > 100:
        raise HTTPException(400, "Limit too high")
    return get_comments(skip, limit)

"""
PROBLÈMES:
[X] Code dupliqué partout
[X] Difficile à maintenir (changement = modifier partout)
[X] Erreurs faciles (oublier la validation quelque part)
[X] Tests répétitifs


[OK] SOLUTION: DEPENDENCIES
-------------------------

[IDEE] QU'EST-CE QU'UNE DÉPENDANCE?

Une dépendance = Une fonction réutilisable que FastAPI appelle automatiquement
avant d'appeler votre endpoint.

ANALOGIE:
Imaginez un restaurant:
- Dépendance = Le serveur qui vérifie que vous avez réservé
- Endpoint = Le chef qui prépare votre plat

Le serveur (dépendance) doit valider AVANT que le chef (endpoint) travaille.


VOTRE PREMIÈRE DÉPENDANCE
-------------------------
"""

from fastapi import Depends

def pagination_params(skip: int = 0, limit: int = 10):
    """
    [REFLEXION] FONCTION DE DÉPENDANCE
    
    C'est une fonction Python normale!
    Rien de spécial dans sa définition.
    
    Elle peut:
    - Avoir des paramètres (path, query, body)
    - Faire de la validation
    - Lever des exceptions
    - Retourner une valeur
    
    [IDEE] LE TRUC MAGIQUE: Depends()
    C'est dans l'UTILISATION qu'elle devient spéciale!
    """
    if limit > 100:
        raise HTTPException(400, "Limit cannot exceed 100")
    if limit < 1:
        raise HTTPException(400, "Limit must be at least 1")
    
    return {"skip": skip, "limit": limit}

# UTILISATION:
@app.get("/users")
def list_users(pagination: dict = Depends(pagination_params)):
    """
    [REFLEXION] DÉCORTIQUONS: Depends(pagination_params)
    
    QUE SE PASSE-T-IL QUAND CLIENT FAIT: GET /users?skip=10&limit=20
    
    1. FastAPI voit Depends(pagination_params)
    2. FastAPI appelle pagination_params(skip=10, limit=20)
    3. pagination_params valide les params
    4. Si OK: retourne {"skip": 10, "limit": 20}
    5. Ce dict est passé à list_users comme pagination
    6. list_users s'exécute avec pagination={"skip": 10, "limit": 20}
    
    Si validation échoue (ex: limit=200):
    -> pagination_params lève HTTPException
    -> list_users n'est JAMAIS appelée!
    -> Client reçoit l'erreur directement
    
    [IDEE] VOUS N'ÉCRIVEZ PLUS LA VALIDATION!
    Elle est centralisée dans pagination_params.
    """
    return get_users(**pagination)

@app.get("/posts")
def list_posts(pagination: dict = Depends(pagination_params)):
    # Même dépendance, ZÉRO duplication!
    return get_posts(**pagination)

@app.get("/comments")
def list_comments(pagination: dict = Depends(pagination_params)):
    # Toujours la même dépendance!
    return get_comments(**pagination)

"""
[IDEE] AVANTAGES:

1. CODE RÉUTILISABLE:
   Une fonction, plusieurs endpoints

2. MAINTENABILITÉ:
   Changement à un seul endroit

3. TESTABILITÉ:
   Tester la dépendance séparément

4. LISIBILITÉ:
   L'intention est claire


DÉPENDANCE COMME CLASSE
-----------------------

Les dépendances peuvent être des classes:
"""

class PaginationParams:
    """
    [IDEE] DÉPENDANCE EN TANT QUE CLASSE
    
    POURQUOI une classe plutôt qu'une fonction?
    - Plus facile à étendre
    - Peut avoir des méthodes
    - Plus orienté objet
    """
    def __init__(
        self,
        skip: int = Query(0, ge=0, description="Items to skip"),
        limit: int = Query(10, ge=1, le=100, description="Max items to return")
    ):
        """
        [REFLEXION] __init__ = LE CONSTRUCTEUR
        
        FastAPI appelle cette méthode automatiquement!
        Les paramètres fonctionnent comme dans une fonction normale:
        - Query parameters avec Query()
        - Path parameters
        - Body avec BaseModel
        - Etc.
        """
        self.skip = skip
        self.limit = limit
    
    def get_slice(self, items: list):
        """Méthode utilitaire pour paginer une liste"""
        return items[self.skip:self.skip + self.limit]

# UTILISATION:
@app.get("/users")
def list_users(pagination: PaginationParams = Depends()):
    """
    [IDEE] Depends() SANS ARGUMENT!
    
    Quand vous utilisez Depends() sans argument,
    FastAPI utilise le TYPE ANNOTÉ (PaginationParams).
    
    C'est équivalent à: Depends(PaginationParams)
    
    
    QUE SE PASSE-T-IL?
    1. FastAPI voit PaginationParams = Depends()
    2. FastAPI crée une instance: PaginationParams(skip=..., limit=...)
    3. L'instance est passée à list_users
    4. Vous pouvez utiliser pagination.skip, pagination.limit, pagination.get_slice()
    """
    users = get_all_users()
    paginated_users = pagination.get_slice(users)
    
    return {
        "total": len(users),
        "skip": pagination.skip,
        "limit": pagination.limit,
        "users": paginated_users
    }


"""
DÉPENDANCES IMBRIQUÉES (SUB-DEPENDENCIES)
-----------------------------------------

Une dépendance peut elle-même avoir des dépendances!
"""

def verify_token(x_token: str = Header(...)):
    """
    [REFLEXION] DÉPENDANCE NIVEAU 1: Vérifier token
    
    Extrait et vérifie le header X-Token
    """
    if x_token != "secret-token":
        raise HTTPException(401, "Invalid token")
    return x_token

def get_current_user(token: str = Depends(verify_token)):
    """
    [REFLEXION] DÉPENDANCE NIVEAU 2: Récupérer utilisateur
    
    DÉPEND DE verify_token!
    
    [IDEE] HIÉRARCHIE:
    verify_token
    └── get_current_user
    
    QUE SE PASSE-T-IL?
    1. FastAPI voit que get_current_user dépend de verify_token
    2. FastAPI appelle d'abord verify_token
    3. Si verify_token OK: retourne le token
    4. Ce token est passé à get_current_user
    5. get_current_user fait sa logique
    6. Retourne l'utilisateur
    7. L'utilisateur est passé à l'endpoint
    
    Si verify_token échoue:
    -> HTTPException levée
    -> get_current_user n'est JAMAIS appelée
    -> L'endpoint n'est JAMAIS appelé
    """
    # Ici, on est SÛR que le token est valide!
    # verify_token l'a déjà vérifié
    
    # Simuler récupération utilisateur depuis le token
    users_db = {
        "secret-token": {"username": "john", "email": "john@example.com"}
    }
    return users_db.get(token, {})

@app.get("/users/me")
def read_current_user(current_user: dict = Depends(get_current_user)):
    """
    [IDEE] CHAÎNE DE DÉPENDANCES:
    
    read_current_user
    └── Depends(get_current_user)
        └── Depends(verify_token)
    
    FastAPI résout automatiquement toute la chaîne!
    """
    return current_user

"""
[IDEE] POURQUOI C'EST GÉNIAL:

SÉPARATION DES RESPONSABILITÉS:
- verify_token: Vérifie le token
- get_current_user: Récupère l'utilisateur
- read_current_user: Logique métier

RÉUTILISABILITÉ:
- verify_token peut être utilisé seul
- get_current_user peut être utilisé dans d'autres endpoints

TESTABILITÉ:
- Tester verify_token séparément
- Tester get_current_user séparément
- Mock facile dans les tests


DÉPENDANCES AU NIVEAU DU ROUTER
-------------------------------

Appliquer une dépendance à TOUS les endpoints d'un router:
"""

from fastapi import APIRouter

# Créer un router avec dépendance globale
admin_router = APIRouter(
    prefix="/admin",
    tags=["admin"],
    dependencies=[Depends(verify_admin)]  # <- S'applique à TOUTES les routes!
)

@admin_router.get("/users")
def admin_list_users():
    """
    [IDEE] verify_admin s'exécute AUTOMATIQUEMENT!
    
    Vous n'avez pas besoin de mettre Depends(verify_admin) ici.
    C'est déjà fait au niveau du router!
    
    TOUS les endpoints de admin_router nécessitent verify_admin.
    """
    return get_all_users()

@admin_router.delete("/users/{user_id}")
def admin_delete_user(user_id: int):
    """
    verify_admin s'exécute ici aussi!
    """
    delete_user(user_id)
    return {"message": "User deleted"}

# Inclure le router dans l'app
app.include_router(admin_router)

"""
[IDEE] QUAND UTILISER?

Router dependencies = Pour des sections protégées:
- Routes admin
- Routes nécessitant authentification
- Routes avec rate limiting
- Routes nécessitant certaines permissions


DÉPENDANCES AU NIVEAU DE L'APP
------------------------------

Pour appliquer à TOUTE l'application:
"""

app = FastAPI(
    dependencies=[Depends(log_requests)]  # <- Pour TOUTES les routes!
)

def log_requests(request: Request):
    """
    Cette dépendance s'exécute pour CHAQUE requête,
    peu importe l'endpoint!
    
    [IDEE] UTILE POUR:
    - Logging global
    - Métriques
    - Rate limiting global
    - Security headers
    """
    logger.info(f"Request: {request.method} {request.url}")
    # Pas besoin de retourner quelque chose si pas utilisé

"""
[ATTENTION] ATTENTION:
App-level dependencies s'exécutent pour TOUS les endpoints.
Utilisez avec parcimonie pour éviter overhead!


DÉPENDANCES AVEC YIELD
----------------------

Pour gérer setup et cleanup (comme les context managers):
"""

def get_db():
    """
    [IDEE] DÉPENDANCE AVEC YIELD
    
    yield = Comme un context manager
    
    STRUCTURE:
    1. Code AVANT yield = Setup (exécuté avant l'endpoint)
    2. yield valeur = Valeur passée à l'endpoint
    3. Code APRÈS yield = Cleanup (exécuté après l'endpoint)
    
    
    EXEMPLE: Connexion base de données
    """
    # SETUP: Ouvrir connexion
    db = SessionLocal()  # Créer session DB
    
    try:
        # DONNER la session à l'endpoint
        yield db
        
    finally:
        # CLEANUP: Fermer connexion (toujours exécuté!)
        db.close()

@app.get("/users")
def list_users(db: Session = Depends(get_db)):
    """
    [IDEE] FLUX D'EXÉCUTION:
    
    1. get_db() s'exécute jusqu'au yield
       -> db = SessionLocal()
       -> yield db (db est passé à list_users)
    
    2. list_users s'exécute
       -> Utilise db
       -> Retourne résultat
    
    3. get_db() continue après le yield
       -> finally: db.close()
    
    
    MÊME EN CAS D'ERREUR:
    Si list_users lève une exception,
    le finally s'exécute quand même!
    -> db.close() est toujours appelé
    -> Pas de fuite de connexions!
    """
    users = db.query(User).all()
    return users

"""
[IDEE] PATTERN COMMUN: Database Sessions

SANS yield (MAUVAIS):
"""
@app.get("/users")
def list_users():
    db = SessionLocal()
    users = db.query(User).all()
    db.close()  # [X] Si erreur avant, pas fermé!
    return users

"""
AVEC yield (BON):
"""
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()  # [OK] Toujours fermé!

@app.get("/users")
def list_users(db: Session = Depends(get_db)):
    users = db.query(User).all()
    return users  # db.close() appelé automatiquement après!


"""
DÉPENDANCES QUI NE RETOURNENT RIEN
----------------------------------

Parfois, vous voulez juste exécuter du code sans retourner de valeur:
"""

def verify_api_key(x_api_key: str = Header(...)):
    """
    [IDEE] DÉPENDANCE DE VALIDATION PURE
    
    But: Vérifier l'API key
    Pas besoin de retourner quoi que ce soit!
    
    Si invalide -> HTTPException
    Si valide -> Continue (return None implicite)
    """
    if x_api_key not in VALID_API_KEYS:
        raise HTTPException(403, "Invalid API key")
    # Pas de return = return None

@app.get("/protected", dependencies=[Depends(verify_api_key)])
#                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#                      dependencies= pour ne pas recevoir la valeur
def protected_endpoint():
    """
    [IDEE] dependencies= vs paramètre normal
    
    dependencies=[Depends(...)]:
    - La dépendance s'exécute
    - Mais la valeur n'est PAS passée à la fonction
    - Utile pour validation pure
    
    param = Depends(...):
    - La dépendance s'exécute
    - ET la valeur est passée à la fonction
    - Utile quand vous avez besoin de la valeur
    """
    return {"message": "Access granted"}


"""
OVERRIDE DE DÉPENDANCES (TESTS)
-------------------------------

Pour les tests, vous pouvez remplacer les dépendances:
"""

# Code de production
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.get("/users")
def list_users(db: Session = Depends(get_db)):
    return db.query(User).all()


# Code de test
def get_test_db():
    """
    [IDEE] DÉPENDANCE DE TEST
    
    Retourne une DB de test au lieu de la vraie DB!
    """
    db = TestSessionLocal()
    try:
        yield db
    finally:
        db.close()

def test_list_users():
    """
    [IDEE] OVERRIDE LA DÉPENDANCE
    
    Pendant ce test, TOUTES les utilisations de get_db
    utiliseront get_test_db à la place!
    """
    # Override
    app.dependency_overrides[get_db] = get_test_db
    
    # Tester
    response = client.get("/users")
    assert response.status_code == 200
    
    # Cleanup
    app.dependency_overrides.clear()

"""
[IDEE] POURQUOI C'EST GÉNIAL:

TESTS ISOLÉS:
- Pas besoin de vraie DB
- Tests plus rapides
- Pas de side effects

FLEXIBILITÉ:
- Override n'importe quelle dépendance
- Permet de mocker facilement


[COURS] EXERCICES PRATIQUES
---------------------

EXERCICE #1: Système de permissions
"""

from enum import Enum

class Permission(str, Enum):
    READ = "read"
    WRITE = "write"
    DELETE = "delete"
    ADMIN = "admin"

# Base de données d'utilisateurs (simulation)
USERS_DB = {
    "alice": {"permissions": [Permission.READ, Permission.WRITE]},
    "bob": {"permissions": [Permission.READ]},
    "admin": {"permissions": [Permission.ADMIN]}
}

"""
CRÉEZ:

1. Une dépendance get_current_user qui:
   - Extrait X-User header
   - Vérifie que l'utilisateur existe
   - Retourne l'utilisateur

2. Une dépendance require_permission(permission) qui:
   - Dépend de get_current_user
   - Vérifie que l'utilisateur a la permission requise

3. Des endpoints:
   - GET /items (nécessite READ)
   - POST /items (nécessite WRITE)
   - DELETE /items/{item_id} (nécessite DELETE ou ADMIN)

SOLUTION:
"""

def get_current_user(x_user: str = Header(...)):
    """Récupère l'utilisateur depuis le header"""
    if x_user not in USERS_DB:
        raise HTTPException(401, "Unknown user")
    return {"username": x_user, **USERS_DB[x_user]}

class PermissionChecker:
    """
    [IDEE] CLASSE POUR CRÉER DES DÉPENDANCES DYNAMIQUES
    
    Permet de créer require_permission(Permission.READ),
    require_permission(Permission.WRITE), etc.
    
    Chaque appel crée une nouvelle instance avec la permission requise!
    """
    def __init__(self, required_permission: Permission):
        self.required_permission = required_permission
    
    def __call__(self, current_user: dict = Depends(get_current_user)):
        """
        [IDEE] __call__ rend l'instance "callable"
        
        C'est cette méthode qui est appelée par FastAPI!
        """
        permissions = current_user["permissions"]
        
        # ADMIN a tous les droits
        if Permission.ADMIN in permissions:
            return current_user
        
        # Vérifier permission spécifique
        if self.required_permission not in permissions:
            raise HTTPException(
                403,
                f"Permission '{self.required_permission}' required"
            )
        
        return current_user

# Créer les checkers
require_read = PermissionChecker(Permission.READ)
require_write = PermissionChecker(Permission.WRITE)
require_delete = PermissionChecker(Permission.DELETE)

@app.get("/items")
def list_items(user: dict = Depends(require_read)):
    """Nécessite permission READ"""
    return {
        "items": ["item1", "item2"],
        "user": user["username"]
    }

@app.post("/items")
def create_item(
    item: dict,
    user: dict = Depends(require_write)
):
    """Nécessite permission WRITE"""
    return {
        "message": "Item created",
        "by": user["username"]
    }

@app.delete("/items/{item_id}")
def delete_item(
    item_id: int,
    user: dict = Depends(require_delete)
):
    """Nécessite permission DELETE ou ADMIN"""
    return {
        "message": f"Item {item_id} deleted",
        "by": user["username"]
    }

"""
TESTEZ:
- GET /items avec X-User: alice -> [OK] (a READ)
- GET /items avec X-User: bob -> [OK] (a READ)
- POST /items avec X-User: alice -> [OK] (a WRITE)
- POST /items avec X-User: bob -> [X] (pas WRITE)
- DELETE /items/1 avec X-User: admin -> [OK] (est ADMIN)


EXERCICE #2: Rate Limiting avec dépendances
"""

from collections import defaultdict
from datetime import datetime, timedelta

class RateLimiter:
    """
    [IDEE] RATE LIMITER RÉUTILISABLE
    
    Limite le nombre de requêtes par IP.
    """
    def __init__(self, times: int = 10, seconds: int = 60):
        self.times = times
        self.seconds = seconds
        self.requests = defaultdict(list)
    
    def __call__(self, request: Request):
        """Vérifie le rate limit"""
        ip = request.client.host
        now = datetime.now()
        
        # Nettoyer les anciennes requêtes
        cutoff = now - timedelta(seconds=self.seconds)
        self.requests[ip] = [
            req_time for req_time in self.requests[ip]
            if req_time > cutoff
        ]
        
        # Vérifier limite
        if len(self.requests[ip]) >= self.times:
            raise HTTPException(
                429,
                f"Rate limit exceeded. Max {self.times} requests per {self.seconds} seconds",
                headers={"Retry-After": str(self.seconds)}
            )
        
        # Enregistrer cette requête
        self.requests[ip].append(now)

# Créer limiters avec différentes limites
rate_limit_strict = RateLimiter(times=5, seconds=60)  # 5/minute
rate_limit_normal = RateLimiter(times=20, seconds=60)  # 20/minute

@app.get("/public")
def public_endpoint():
    """Pas de rate limit"""
    return {"message": "Hello"}

@app.get("/limited", dependencies=[Depends(rate_limit_normal)])
def limited_endpoint():
    """20 requêtes/minute max"""
    return {"message": "Limited access"}

@app.get("/strict", dependencies=[Depends(rate_limit_strict)])
def strict_endpoint():
    """5 requêtes/minute max"""
    return {"message": "Strictly limited"}


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 7
-----------------------------

Vous avez appris:
[OK] Concept de dépendance (fonction réutilisable)
[OK] Depends() pour injecter des dépendances
[OK] Dépendances comme fonctions ou classes
[OK] Dépendances imbriquées (sub-dependencies)
[OK] Dépendances au niveau router/app
[OK] Dépendances avec yield (setup/cleanup)
[OK] dependencies= pour validation pure
[OK] Override de dépendances (tests)

Points clés:
[CLE] Dépendance = Fonction réutilisable appelée avant l'endpoint
[CLE] Depends() = Injecter une dépendance
[CLE] Chaînes de dépendances résolues automatiquement
[CLE] yield pour gérer cleanup (DB, fichiers, etc.)
[CLE] Override pour tests isolés


[OBJECTIF] PATTERNS COURANTS:

1. PAGINATION:
   PaginationParams réutilisable

2. AUTHENTIFICATION:
   get_current_user -> utilisé partout

3. PERMISSIONS:
   PermissionChecker -> vérifier droits

4. DATABASE:
   get_db avec yield -> gérer connexions

5. RATE LIMITING:
   RateLimiter -> protéger endpoints

6. VALIDATION:
   verify_api_key -> validation pure


[OBJECTIF] AVANT DE CONTINUER:
Assurez-vous de pouvoir:
1. Créer une dépendance simple
2. Utiliser Depends() dans un endpoint
3. Créer une chaîne de dépendances
4. Utiliser yield pour cleanup
5. Appliquer dépendance au niveau router

Les dépendances sont FONDAMENTALES pour code propre et réutilisable!
"""


# ============================================================================
# [GUIDE] CHAPITRE 8: SECURITY & AUTHENTICATION (Sécurité et Authentification)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Implémenter une authentification sécurisée avec JWT (JSON Web Tokens)
et comprendre les concepts de sécurité dans FastAPI.


[REFLEXION] COMPRENDRE L'AUTHENTIFICATION
-------------------------------

[IDEE] QU'EST-CE QUE L'AUTHENTIFICATION?

Authentification = Vérifier QUI vous êtes
Authorization = Vérifier CE QUE vous pouvez faire

ANALOGIE:
- Authentification = Montrer votre carte d'identité à l'entrée
- Authorization = Vérifier que vous avez un billet VIP


MÉTHODES D'AUTHENTIFICATION COURANTES:

1. BASIC AUTH:
   - Username + Password dans chaque requête
   - [X] Pas sécurisé (credentials envoyés à chaque fois)
   - [OK] Simple pour prototypes

2. API KEY:
   - Clé fixe dans le header
   - [X] Si volée, valide indéfiniment
   - [OK] Simple pour APIs internes

3. JWT (JSON Web Token):
   - Token temporaire après login
   - [OK] Sécurisé, standard moderne
   - [OK] Token expire automatiquement
   - [OK] Pas de stockage serveur nécessaire


COMPRENDRE JWT
--------------

[IDEE] QU'EST-CE QU'UN JWT?

Un JWT = 3 parties séparées par des points:
"""
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huIiwiZXhwIjoxNjE2MjM5MDIyfQ.sT0xVZnhZXGPm5mDp5xB1234567890
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Header
#                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Payload
#                                                                        ^^^^^^^^^^^^^^^^^^^^^^ Signature

"""
HEADER (rouge):
{
  "alg": "HS256",  <- Algorithme de signature
  "typ": "JWT"     <- Type de token
}

PAYLOAD (bleu):
{
  "sub": "john",         <- Subject (qui)
  "exp": 1616239022,     <- Expiration (quand)
  "iat": 1616235422      <- Issued at (créé quand)
  // Vous pouvez ajouter ce que vous voulez!
}

SIGNATURE (vert):
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  SECRET_KEY  <- Seul le serveur connaît cette clé!
)


[IDEE] COMMENT ÇA MARCHE?

1. LOGIN:
   Client -> Envoie username + password
   Serveur -> Vérifie
   Serveur -> Crée JWT avec info utilisateur
   Serveur -> Renvoie JWT au client

2. REQUÊTES SUIVANTES:
   Client -> Envoie JWT dans header Authorization
   Serveur -> Vérifie signature du JWT
   Serveur -> Si valide: accepte requête
   Serveur -> Si invalide/expiré: rejette (401)


AVANTAGES JWT:
[OK] Pas besoin de stocker tokens côté serveur
[OK] Token contient les infos (pas de lookup DB)
[OK] Peut avoir expiration
[OK] Signature prouve qu'il n'a pas été modifié


INSTALLATION
-----------
"""

pip install python-jose[cryptography]
pip install passlib[bcrypt]

"""
python-jose: Pour créer et vérifier JWT
passlib: Pour hasher les mots de passe


CONFIGURATION DE BASE
--------------------
"""

from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext

# Configuration JWT
SECRET_KEY = "your-secret-key-keep-it-secret-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# Configuration password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

"""
[REFLEXION] DÉCORTIQUONS:

SECRET_KEY:
- Clé secrète pour signer les JWT
- [ATTENTION] DOIT être gardée SECRÈTE!
- [ATTENTION] DOIT être longue et aléatoire en production
- Générer avec: openssl rand -hex 32

ALGORITHM:
- HS256 = HMAC + SHA256
- Standard et sécurisé

ACCESS_TOKEN_EXPIRE_MINUTES:
- Durée de vie du token
- 30 minutes = bon équilibre sécurité/UX
- Plus court = plus sécurisé mais user doit se reconnecter souvent

pwd_context:
- bcrypt = algorithme de hashing moderne et sécurisé
- deprecated="auto" = migre automatiquement si bcrypt est déprécié


HASHER LES MOTS DE PASSE
------------------------

[ATTENTION] RÈGLE D'OR: JAMAIS stocker passwords en clair!
"""

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """
    [IDEE] VÉRIFIER UN MOT DE PASSE
    
    POURQUOI hasher?
    Si votre DB est compromise, les passwords ne sont pas lisibles!
    
    COMMENT ça marche?
    1. User entre password: "mypassword123"
    2. On hash le password entré
    3. On compare avec le hash stocké en DB
    4. Si correspondance -> password correct!
    
    [IDEE] BCRYPT = "One-way hash"
    Impossible de retrouver le password original depuis le hash!
    """
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    """
    [IDEE] HASHER UN MOT DE PASSE
    
    Utilisé lors de l'inscription:
    1. User crée compte avec password
    2. On hashe le password
    3. On stocke seulement le HASH en DB
    """
    return pwd_context.hash(password)

# EXEMPLE D'UTILISATION:
password = "mypassword123"
hashed = get_password_hash(password)
print(hashed)
# $2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
# ^^^ Impossible de retrouver "mypassword123" depuis ce hash!

# Vérifier:
is_correct = verify_password("mypassword123", hashed)  # True
is_correct = verify_password("wrongpassword", hashed)  # False


"""
MODÈLES PYDANTIC POUR AUTH
--------------------------
"""

from pydantic import BaseModel, EmailStr

class Token(BaseModel):
    """Réponse du endpoint /token"""
    access_token: str
    token_type: str  # Toujours "bearer"

class TokenData(BaseModel):
    """Données extraites du token"""
    username: str | None = None

class User(BaseModel):
    """Utilisateur public"""
    username: str
    email: EmailStr | None = None
    disabled: bool | None = None

class UserInDB(User):
    """
    [IDEE] Utilisateur en DB (avec password hashé)
    
    Hérite de User + ajoute hashed_password
    """
    hashed_password: str


"""
DATABASE SIMULÉE
---------------
"""

# En vrai, ce serait une vraie base de données!
fake_users_db = {
    "johndoe": {
        "username": "johndoe",
        "email": "john@example.com",
        "hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
        # Password original: "secret"
        "disabled": False,
    }
}


"""
CRÉER ET VÉRIFIER JWT
--------------------
"""

def create_access_token(data: dict, expires_delta: timedelta | None = None):
    """
    [IDEE] CRÉER UN JWT
    
    data: Données à mettre dans le token (ex: {"sub": "johndoe"})
    expires_delta: Durée de vie (optionnel, défaut: 15 min)
    
    RETOURNE: JWT string
    """
    # Copier les données (ne pas modifier l'original)
    to_encode = data.copy()
    
    # Calculer expiration
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=15)
    
    # Ajouter expiration aux données
    to_encode.update({"exp": expire})
    
    # Créer le JWT
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    
    return encoded_jwt

"""
[IDEE] EXEMPLE:
"""
token = create_access_token(
    data={"sub": "johndoe"},
    expires_delta=timedelta(minutes=30)
)
print(token)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huZG9lIiwiZXhwIjoxNjE2MjM5MDIyfQ...

"""
QUE CONTIENT CE TOKEN?
Si on le décode (sans vérifier signature):
{
  "sub": "johndoe",      <- Données qu'on a passées
  "exp": 1616239022      <- Timestamp d'expiration
}


OAUTH2 PASSWORD BEARER
----------------------

FastAPI fournit des utilitaires OAuth2:
"""

from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
"""
[IDEE] OAuth2PasswordBearer:

tokenUrl="token":
- Indique où obtenir le token (endpoint /token)
- Utilisé par Swagger UI pour "Authorize"
- Le bouton [VERROUILLE] dans /docs!

oauth2_scheme = une DÉPENDANCE!
Quand utilisée dans un endpoint, elle:
1. Extrait le header Authorization
2. Vérifie format: "Bearer <token>"
3. Retourne le token (sans "Bearer ")
4. Si pas de header/format invalide -> 401

C'est comme un Depends() spécialisé!
"""


"""
RÉCUPÉRER L'UTILISATEUR ACTUEL
------------------------------
"""

def get_user(username: str):
    """Récupérer utilisateur depuis DB"""
    if username in fake_users_db:
        user_dict = fake_users_db[username]
        return UserInDB(**user_dict)
    return None

async def get_current_user(token: str = Depends(oauth2_scheme)):
    """
    [IDEE] DÉPENDANCE: Récupérer utilisateur depuis le token
    
    FLUX:
    1. oauth2_scheme extrait le token du header
    2. On décode le JWT
    3. On extrait le username (sub)
    4. On récupère l'utilisateur depuis la DB
    5. On retourne l'utilisateur
    
    Si problème -> HTTPException 401
    """
    credentials_exception = HTTPException(
        status_code=401,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    
    try:
        # Décoder le JWT
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        
        # Extraire username (sub = subject)
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        
        token_data = TokenData(username=username)
        
    except JWTError:
        # Token invalide ou expiré
        raise credentials_exception
    
    # Récupérer utilisateur
    user = get_user(username=token_data.username)
    if user is None:
        raise credentials_exception
    
    return user

async def get_current_active_user(
    current_user: User = Depends(get_current_user)
):
    """
    [IDEE] DÉPENDANCE: Vérifier que l'utilisateur est actif
    
    CHAÎNE DE DÉPENDANCES:
    get_current_active_user
    └── Depends(get_current_user)
        └── Depends(oauth2_scheme)
    
    FastAPI résout automatiquement toute la chaîne!
    """
    if current_user.disabled:
        raise HTTPException(400, "Inactive user")
    return current_user


"""
ENDPOINT DE LOGIN
----------------
"""

def authenticate_user(username: str, password: str):
    """
    [IDEE] AUTHENTIFIER UN UTILISATEUR
    
    1. Récupérer user depuis DB
    2. Vérifier password
    3. Retourner user si OK, False sinon
    """
    user = get_user(username)
    if not user:
        return False
    if not verify_password(password, user.hashed_password):
        return False
    return user

@app.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    """
    [IDEE] ENDPOINT DE LOGIN
    
    OAuth2PasswordRequestForm = Depends() spécial qui extrait:
    - username (du formulaire)
    - password (du formulaire)
    
    FLUX:
    1. Client envoie username + password
    2. On vérifie credentials
    3. Si OK: on crée un JWT
    4. On renvoie le JWT
    
    CLIENT UTILISE ENSUITE CE TOKEN POUR LES REQUÊTES!
    
    
    [IDEE] TESTER DANS /docs:
    1. Cliquez sur [VERROUILLE] Authorize
    2. Entrez username: johndoe
    3. Entrez password: secret
    4. Cliquez Authorize
    5. Le token est stocké dans Swagger
    6. Maintenant vous pouvez appeler les endpoints protégés!
    """
    # Authentifier
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=401,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    
    # Créer token
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username},
        expires_delta=access_token_expires
    )
    
    return {"access_token": access_token, "token_type": "bearer"}


"""
ENDPOINTS PROTÉGÉS
-----------------
"""

@app.get("/users/me", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
    """
    [IDEE] ENDPOINT PROTÉGÉ
    
    Nécessite:
    1. Header Authorization: Bearer <token>
    2. Token valide (pas expiré, signature correcte)
    3. User actif (pas disabled)
    
    Si tout OK: retourne l'utilisateur actuel
    
    
    COMMENT L'APPELER?
    
    Avec curl:
    curl -X GET "http://localhost:8000/users/me" \
         -H "Authorization: Bearer <votre-token>"
    
    Avec JavaScript:
    fetch('http://localhost:8000/users/me', {
      headers: {
        'Authorization': 'Bearer ' + token
      }
    })
    
    Dans /docs:
    1. Authorize avec username/password
    2. Le token est stocké automatiquement
    3. Tous les endpoints protégés l'utilisent!
    """
    return current_user

@app.get("/users/me/items")
async def read_own_items(current_user: User = Depends(get_current_active_user)):
    """
    [IDEE] AUTRE ENDPOINT PROTÉGÉ
    
    Réutilise la même dépendance!
    Code propre, pas de duplication.
    """
    return [
        {"item_id": "Foo", "owner": current_user.username},
        {"item_id": "Bar", "owner": current_user.username}
    ]


"""
REFRESH TOKENS (OPTIONNEL)
--------------------------

[IDEE] PROBLÈME:
Access tokens expirent vite (30 min) pour sécurité.
Mais forcer user à se reconnecter toutes les 30 min = mauvaise UX!

SOLUTION:
Refresh tokens = tokens longue durée pour obtenir nouveaux access tokens
"""

REFRESH_TOKEN_EXPIRE_DAYS = 7

def create_refresh_token(data: dict):
    """Créer un refresh token (longue durée)"""
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
    to_encode.update({"exp": expire, "type": "refresh"})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

@app.post("/token", response_model=dict)
async def login_with_refresh(form_data: OAuth2PasswordRequestForm = Depends()):
    """
    Login qui retourne access + refresh token
    """
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(401, "Incorrect credentials")
    
    # Créer les deux tokens
    access_token = create_access_token(
        data={"sub": user.username},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    )
    refresh_token = create_refresh_token(data={"sub": user.username})
    
    return {
        "access_token": access_token,
        "refresh_token": refresh_token,
        "token_type": "bearer"
    }

@app.post("/refresh")
async def refresh_access_token(refresh_token: str):
    """
    [IDEE] OBTENIR NOUVEAU ACCESS TOKEN
    
    Client envoie refresh token -> Reçoit nouveau access token
    Pas besoin de se reconnecter!
    
    FLUX:
    1. Access token expire (30 min)
    2. Client utilise refresh token
    3. Serveur vérifie refresh token
    4. Si OK: crée nouveau access token
    5. Client continue avec nouveau access token
    6. Refresh token reste valide (7 jours)
    """
    try:
        payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload.get("sub")
        token_type = payload.get("type")
        
        if username is None or token_type != "refresh":
            raise HTTPException(401, "Invalid refresh token")
        
        # Créer nouveau access token
        new_access_token = create_access_token(
            data={"sub": username},
            expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
        )
        
        return {
            "access_token": new_access_token,
            "token_type": "bearer"
        }
    
    except JWTError:
        raise HTTPException(401, "Invalid refresh token")


"""
[COURS] EXERCICE PRATIQUE: Système d'inscription
------------------------------------------
"""

class UserRegister(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    email: EmailStr
    password: str = Field(..., min_length=8)
    password_confirm: str

@app.post("/register", response_model=User)
async def register_user(user: UserRegister):
    """
    [IDEE] ENDPOINT D'INSCRIPTION
    
    ÉTAPES:
    1. Valider données (Pydantic le fait)
    2. Vérifier que username n'existe pas
    3. Vérifier que passwords correspondent
    4. Hasher le password
    5. Sauvegarder en DB
    6. Retourner user (sans password!)
    """
    # Vérifier username unique
    if user.username in fake_users_db:
        raise HTTPException(409, "Username already taken")
    
    # Vérifier passwords
    if user.password != user.password_confirm:
        raise HTTPException(400, "Passwords don't match")
    
    # Hasher password
    hashed_password = get_password_hash(user.password)
    
    # Sauvegarder (simulation)
    fake_users_db[user.username] = {
        "username": user.username,
        "email": user.email,
        "hashed_password": hashed_password,
        "disabled": False
    }
    
    # Retourner user (sans password!)
    return User(
        username=user.username,
        email=user.email,
        disabled=False
    )

"""
TESTEZ LE FLOW COMPLET:

1. INSCRIPTION:
POST /register
{
  "username": "alice",
  "email": "alice@example.com",
  "password": "SecurePass123",
  "password_confirm": "SecurePass123"
}
-> User créé

2. LOGIN:
POST /token
username=alice&password=SecurePass123
-> Reçoit token

3. UTILISER TOKEN:
GET /users/me
Header: Authorization: Bearer <token>
-> Reçoit info user


[DOCS] RÉCAPITULATIF DU CHAPITRE 8
-----------------------------

Vous avez appris:
[OK] Concepts d'authentification vs authorization
[OK] JWT (structure, création, vérification)
[OK] Hashing de mots de passe avec bcrypt
[OK] OAuth2PasswordBearer pour extraire tokens
[OK] Chaîne de dépendances d'authentification
[OK] Endpoints de login et inscription
[OK] Protection d'endpoints
[OK] Refresh tokens

Points clés:
[CLE] JWT = token auto-contenu avec expiration
[CLE] Hasher passwords avec bcrypt (JAMAIS en clair)
[CLE] OAuth2PasswordBearer = dépendance pour extraire token
[CLE] get_current_user = dépendance pour vérifier token
[CLE] Chaîne: oauth2_scheme -> get_current_user -> get_current_active_user


[OBJECTIF] SÉCURITÉ - BONNES PRATIQUES:

1. SECRET_KEY:
   [OK] Longue (32+ caractères)
   [OK] Aléatoire
   [OK] Dans variable d'environnement (pas dans code!)
   [OK] Différente par environnement (dev/prod)

2. PASSWORDS:
   [OK] Toujours hasher (bcrypt)
   [OK] Jamais en clair dans DB
   [OK] Jamais dans logs
   [OK] Validation côté serveur (8+ caractères, complexité)

3. TOKENS:
   [OK] Expiration courte (15-30 min)
   [OK] HTTPS obligatoire en production
   [OK] Refresh tokens pour meilleure UX
   [OK] Permettre révocation (blacklist si nécessaire)

4. ENDPOINTS:
   [OK] Rate limiting sur /token (éviter brute force)
   [OK] HTTPS partout
   [OK] CORS configuré correctement

5. DONNÉES SENSIBLES:
   [OK] Ne jamais retourner passwords (même hashés)
   [OK] Response models pour filtrer
   [OK] Logs sans données sensibles


[OBJECTIF] AVANT DE CONTINUER:
Assurez-vous de pouvoir:
1. Hasher et vérifier un password
2. Créer et vérifier un JWT
3. Implémenter login endpoint
4. Protéger un endpoint avec dépendance
5. Expliquer le flux d'authentification complet
"""


# ============================================================================
# [GUIDE] CHAPITRE 9: FILE UPLOAD (Téléversement de fichiers)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Maîtriser le téléversement de fichiers: validation, stockage, traitement.


[REFLEXION] COMPRENDRE FILE UPLOAD
-------------------------

[IDEE] POURQUOI FILE UPLOAD?

Applications courantes:
- Upload d'images (profil, produits)
- Upload de documents (PDF, Word)
- Import de données (CSV, Excel)
- Upload de vidéos/audio
- Avatars, logos, etc.


DIFFÉRENCE: UploadFile vs bytes
-------------------------------
"""

from fastapi import File, UploadFile

@app.post("/upload-bytes/")
async def upload_bytes(file: bytes = File(...)):
    """
    [IDEE] bytes = File():
    
    - Tout le fichier chargé EN MÉMOIRE
    - Limite pratique: ~1-2 MB
    - Plus simple mais moins efficace
    
    QUAND UTILISER?
    -> Petits fichiers seulement
    -> Quand vous avez besoin du contenu complet
    """
    return {"file_size": len(file)}

@app.post("/upload-file/")
async def upload_file(file: UploadFile = File(...)):
    """
    [IDEE] UploadFile = File():
    
    - Fichier streamé (pas tout en mémoire)
    - Pas de limite de taille pratique
    - Plus efficace, plus flexible
    
    AVANTAGES:
    [OK] Métadonnées: filename, content_type, size
    [OK] Méthodes async: read(), write()
    [OK] Meilleure performance
    
    TOUJOURS PRÉFÉRER UploadFile!
    """
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "size": file.size  # FastAPI 0.100+
    }


"""
UPLOADFILE: ATTRIBUTS ET MÉTHODES
---------------------------------
"""

@app.post("/file-info/")
async def file_info(file: UploadFile = File(...)):
    """
    [IDEE] ATTRIBUTS D'UPLOADFILE:
    
    file.filename: "document.pdf"
    file.content_type: "application/pdf"
    file.size: 1024000 (en bytes)
    file.file: Objet fichier sous-jacent
    
    
    [IDEE] MÉTHODES ASYNC:
    
    await file.read(): Lire tout le contenu
    await file.read(size): Lire N bytes
    await file.seek(0): Revenir au début
    await file.write(data): Écrire (rare, plutôt read)
    await file.close(): Fermer (automatique généralement)
    """
    # Lire le contenu
    contents = await file.read()
    
    # Revenir au début si vous voulez relire
    await file.seek(0)
    
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "size": len(contents),
        "first_100_bytes": contents[:100]
    }


"""
SAUVEGARDER UN FICHIER
---------------------
"""

import shutil
from pathlib import Path

# Créer dossier uploads
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

@app.post("/upload/save/")
async def save_file(file: UploadFile = File(...)):
    """
    [IDEE] SAUVEGARDER FICHIER SUR DISQUE
    
    MÉTHODE 1: shutil.copyfileobj()
    -> Efficace, stream le fichier
    -> Pas tout en mémoire
    """
    # Chemin de destination
    file_path = UPLOAD_DIR / file.filename
    
    # Sauvegarder avec shutil
    with open(file_path, "wb") as buffer:
        shutil.copyfileobj(file.file, buffer)
    
    return {
        "filename": file.filename,
        "location": str(file_path),
        "size": file_path.stat().st_size
    }


"""
SAUVEGARDER DE FAÇON ASYNCHRONE
-------------------------------
"""

import aiofiles

@app.post("/upload/async/")
async def save_file_async(file: UploadFile = File(...)):
    """
    [IDEE] ASYNC FILE I/O avec aiofiles
    
    POURQUOI async?
    -> Pendant l'écriture, le serveur peut traiter d'autres requêtes
    -> Meilleure performance pour uploads multiples
    
    INSTALLATION:
    pip install aiofiles
    """
    file_path = UPLOAD_DIR / file.filename
    
    # Écriture asynchrone
    async with aiofiles.open(file_path, 'wb') as f:
        # Lire et écrire par chunks (ne charge pas tout en mémoire)
        while chunk := await file.read(1024 * 1024):  # 1MB chunks
            await f.write(chunk)
    
    return {
        "filename": file.filename,
        "location": str(file_path)
    }


"""
VALIDATION DE FICHIERS
----------------------

[ATTENTION] TOUJOURS VALIDER LES FICHIERS!

Validations nécessaires:
1. Taille maximum
2. Type de fichier (extension + content-type)
3. Nom de fichier sécurisé
4. Contenu si nécessaire (virus scan)
"""

import os
from fastapi import HTTPException

# Configuration
MAX_FILE_SIZE = 10 * 1024 * 1024  # 10 MB
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".pdf"}
ALLOWED_CONTENT_TYPES = {
    "image/jpeg", "image/png", "image/gif", "application/pdf"
}

async def validate_file(file: UploadFile):
    """
    [IDEE] VALIDATION COMPLÈTE
    
    Vérifie:
    1. Extension du fichier
    2. Content-Type
    3. Taille
    4. Nom de fichier sécurisé
    """
    # 1. Vérifier extension
    file_ext = os.path.splitext(file.filename)[1].lower()
    if file_ext not in ALLOWED_EXTENSIONS:
        raise HTTPException(
            400,
            f"File type not allowed. Allowed: {ALLOWED_EXTENSIONS}"
        )
    
    # 2. Vérifier content-type
    if file.content_type not in ALLOWED_CONTENT_TYPES:
        raise HTTPException(
            400,
            f"Content type not allowed: {file.content_type}"
        )
    
    # 3. Vérifier taille
    contents = await file.read()
    if len(contents) > MAX_FILE_SIZE:
        raise HTTPException(
            400,
            f"File too large. Max: {MAX_FILE_SIZE} bytes"
        )
    
    # Revenir au début pour permettre lecture ultérieure
    await file.seek(0)
    
    # 4. Vérifier nom de fichier
    if ".." in file.filename or "/" in file.filename:
        raise HTTPException(400, "Invalid filename")
    
    return contents

@app.post("/upload/validated/")
async def upload_validated(file: UploadFile = File(...)):
    """Upload avec validation complète"""
    # Valider
    contents = await validate_file(file)
    
    # Sauvegarder
    file_path = UPLOAD_DIR / file.filename
    async with aiofiles.open(file_path, 'wb') as f:
        await f.write(contents)
    
    return {
        "filename": file.filename,
        "size": len(contents),
        "location": str(file_path)
    }


"""
NOM DE FICHIER UNIQUE
--------------------

[IDEE] PROBLÈME:
Si deux users uploadent "photo.jpg", ils s'écrasent!

SOLUTION:
Générer un nom unique
"""

import uuid
from datetime import datetime

def generate_unique_filename(original_filename: str) -> str:
    """
    [IDEE] GÉNÉRER NOM UNIQUE
    
    STRATÉGIES:
    1. UUID: random garanti unique
    2. Timestamp: unique si pas uploads simultanés
    3. UUID + timestamp: super sûr
    4. Hash du contenu: même fichier = même nom
    
    On garde l'extension originale!
    """
    # Extraire extension
    _, ext = os.path.splitext(original_filename)
    
    # Générer nom unique
    unique_name = f"{uuid.uuid4()}{ext}"
    
    # Ou avec timestamp:
    # timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    # unique_name = f"{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
    
    return unique_name

@app.post("/upload/unique/")
async def upload_unique(file: UploadFile = File(...)):
    """Upload avec nom unique"""
    contents = await validate_file(file)
    
    # Générer nom unique
    unique_filename = generate_unique_filename(file.filename)
    file_path = UPLOAD_DIR / unique_filename
    
    # Sauvegarder
    async with aiofiles.open(file_path, 'wb') as f:
        await f.write(contents)
    
    return {
        "original_filename": file.filename,
        "saved_as": unique_filename,
        "location": str(file_path)
    }


"""
UPLOAD MULTIPLE FICHIERS
------------------------
"""

from typing import List

@app.post("/upload/multiple/")
async def upload_multiple(files: List[UploadFile] = File(...)):
    """
    [IDEE] UPLOAD PLUSIEURS FICHIERS
    
    List[UploadFile] = liste de fichiers
    
    CLIENT ENVOIE:
    - Formulaire multipart avec plusieurs fichiers
    - Champ "files" répété plusieurs fois
    """
    results = []
    
    for file in files:
        # Valider chaque fichier
        contents = await validate_file(file)
        
        # Générer nom unique
        unique_filename = generate_unique_filename(file.filename)
        file_path = UPLOAD_DIR / unique_filename
        
        # Sauvegarder
        async with aiofiles.open(file_path, 'wb') as f:
            await f.write(contents)
        
        results.append({
            "original": file.filename,
            "saved_as": unique_filename,
            "size": len(contents)
        })
    
    return {
        "uploaded": len(results),
        "files": results
    }


"""
UPLOAD AVEC DONNÉES SUPPLÉMENTAIRES
----------------------------------
"""

from fastapi import Form

@app.post("/upload/with-data/")
async def upload_with_data(
    file: UploadFile = File(...),
    title: str = Form(...),
    description: str = Form(None),
    tags: List[str] = Form([])
):
    """
    [IDEE] COMBINER FILE + FORM DATA
    
    [ATTENTION] IMPORTANT:
    Quand vous avez File() + Form(), le client DOIT envoyer:
    Content-Type: multipart/form-data
    
    EXEMPLE CLIENT (JavaScript):
    const formData = new FormData();
    formData.append('file', fileInput.files[0]);
    formData.append('title', 'My Photo');
    formData.append('description', 'Description here');
    formData.append('tags', 'tag1');
    formData.append('tags', 'tag2');
    
    fetch('/upload/with-data/', {
      method: 'POST',
      body: formData
    })
    
    
    [ATTENTION] NE PAS UTILISER JSON + FILE ensemble!
    Pas possible d'envoyer JSON + File dans même requête facilement.
    Utilisez Form() pour les données supplémentaires.
    """
    # Traiter fichier
    contents = await validate_file(file)
    unique_filename = generate_unique_filename(file.filename)
    file_path = UPLOAD_DIR / unique_filename
    
    async with aiofiles.open(file_path, 'wb') as f:
        await f.write(contents)
    
    # Créer metadata (en vrai: sauvegarder en DB)
    metadata = {
        "filename": unique_filename,
        "original_filename": file.filename,
        "title": title,
        "description": description,
        "tags": tags,
        "size": len(contents),
        "uploaded_at": datetime.utcnow().isoformat()
    }
    
    return metadata


"""
TRAITEMENT D'IMAGES
------------------

Pour redimensionner, convertir, etc.:
"""

from PIL import Image
from io import BytesIO

@app.post("/upload/image/")
async def upload_image(
    file: UploadFile = File(...),
    max_width: int = 800,
    max_height: int = 600
):
    """
    [IDEE] REDIMENSIONNER IMAGE
    
    INSTALLATION:
    pip install Pillow
    
    ÉTAPES:
    1. Lire fichier uploadé
    2. Ouvrir avec PIL/Pillow
    3. Redimensionner
    4. Sauvegarder
    """
    # Valider que c'est une image
    if not file.content_type.startswith("image/"):
        raise HTTPException(400, "File must be an image")
    
    # Lire contenu
    contents = await file.read()
    
    # Ouvrir image avec PIL
    image = Image.open(BytesIO(contents))
    
    # Calculer nouvelles dimensions (garder ratio)
    ratio = min(max_width / image.width, max_height / image.height)
    if ratio < 1:  # Seulement si trop grande
        new_width = int(image.width * ratio)
        new_height = int(image.height * ratio)
        image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
    
    # Générer nom unique
    unique_filename = generate_unique_filename(file.filename)
    file_path = UPLOAD_DIR / unique_filename
    
    # Sauvegarder image redimensionnée
    image.save(file_path)
    
    return {
        "original_size": f"{contents and len(contents)} bytes",
        "new_dimensions": f"{image.width}x{image.height}",
        "saved_as": unique_filename
    }


"""
CRÉER THUMBNAIL
--------------
"""

@app.post("/upload/with-thumbnail/")
async def upload_with_thumbnail(file: UploadFile = File(...)):
    """
    [IDEE] CRÉER IMAGE + THUMBNAIL
    
    Utile pour:
    - Galeries d'images
    - E-commerce (produits)
    - Profils utilisateurs
    """
    if not file.content_type.startswith("image/"):
        raise HTTPException(400, "File must be an image")
    
    contents = await file.read()
    image = Image.open(BytesIO(contents))
    
    # Sauvegarder image originale
    original_filename = generate_unique_filename(file.filename)
    original_path = UPLOAD_DIR / original_filename
    image.save(original_path)
    
    # Créer thumbnail (max 150x150)
    thumbnail = image.copy()
    thumbnail.thumbnail((150, 150), Image.Resampling.LANCZOS)
    
    # Sauvegarder thumbnail
    thumb_filename = f"thumb_{original_filename}"
    thumb_path = UPLOAD_DIR / thumb_filename
    thumbnail.save(thumb_path)
    
    return {
        "original": {
            "filename": original_filename,
            "size": f"{image.width}x{image.height}"
        },
        "thumbnail": {
            "filename": thumb_filename,
            "size": f"{thumbnail.width}x{thumbnail.height}"
        }
    }


"""
STREAMING UPLOAD (GROS FICHIERS)
--------------------------------

Pour fichiers très gros (vidéos, etc.):
"""

@app.post("/upload/stream/")
async def stream_upload(file: UploadFile = File(...)):
    """
    [IDEE] UPLOAD STREAMÉ
    
    Traite le fichier par chunks sans tout charger en mémoire.
    Essentiel pour gros fichiers!
    """
    unique_filename = generate_unique_filename(file.filename)
    file_path = UPLOAD_DIR / unique_filename
    
    # Écrire par chunks
    async with aiofiles.open(file_path, 'wb') as f:
        total_size = 0
        chunk_size = 1024 * 1024  # 1 MB chunks
        
        while chunk := await file.read(chunk_size):
            await f.write(chunk)
            total_size += len(chunk)
            
            # Vérifier taille max pendant l'upload
            if total_size > MAX_FILE_SIZE:
                # Supprimer fichier partiel
                os.remove(file_path)
                raise HTTPException(400, "File too large")
    
    return {
        "filename": unique_filename,
        "size": total_size
    }


"""
SERVIR LES FICHIERS UPLOADÉS
---------------------------
"""

from fastapi.responses import FileResponse

@app.get("/files/{filename}")
async def get_file(filename: str):
    """
    [IDEE] SERVIR UN FICHIER
    
    FileResponse = Envoie le fichier au client
    
    [ATTENTION] SÉCURITÉ:
    - Valider filename (pas de ../, /)
    - Vérifier que fichier existe
    - Vérifier permissions si nécessaire
    """
    # Sécurité: vérifier filename
    if ".." in filename or "/" in filename:
        raise HTTPException(400, "Invalid filename")
    
    file_path = UPLOAD_DIR / filename
    
    # Vérifier existence
    if not file_path.exists():
        raise HTTPException(404, "File not found")
    
    # Servir
    return FileResponse(
        file_path,
        filename=filename,  # Nom suggéré pour download
        media_type="application/octet-stream"  # ou déterminer type
    )


"""
[COURS] EXERCICE PRATIQUE: Système de galerie d'images
------------------------------------------------

OBJECTIF:
Créer un système complet d'upload d'images avec:
- Upload avec validation
- Génération de thumbnail
- Stockage des métadonnées
- Liste et récupération des images
"""

# Modèle pour métadonnées
class ImageMetadata(BaseModel):
    id: int
    original_filename: str
    saved_filename: str
    thumbnail_filename: str
    title: str
    description: str | None
    width: int
    height: int
    size: int
    uploaded_at: datetime

# Base de données simulée
images_db: List[ImageMetadata] = []
next_id = 1

@app.post("/gallery/upload", response_model=ImageMetadata)
async def upload_to_gallery(
    file: UploadFile = File(...),
    title: str = Form(...),
    description: str = Form(None)
):
    """Upload image à la galerie"""
    global next_id
    
    # Valider image
    if not file.content_type.startswith("image/"):
        raise HTTPException(400, "Must be an image")
    
    contents = await file.read()
    if len(contents) > MAX_FILE_SIZE:
        raise HTTPException(400, "File too large")
    
    # Ouvrir image
    image = Image.open(BytesIO(contents))
    
    # Générer noms
    saved_filename = generate_unique_filename(file.filename)
    thumb_filename = f"thumb_{saved_filename}"
    
    # Sauvegarder original
    original_path = UPLOAD_DIR / saved_filename
    image.save(original_path)
    
    # Créer thumbnail
    thumbnail = image.copy()
    thumbnail.thumbnail((150, 150), Image.Resampling.LANCZOS)
    thumb_path = UPLOAD_DIR / thumb_filename
    thumbnail.save(thumb_path)
    
    # Créer métadonnées
    metadata = ImageMetadata(
        id=next_id,
        original_filename=file.filename,
        saved_filename=saved_filename,
        thumbnail_filename=thumb_filename,
        title=title,
        description=description,
        width=image.width,
        height=image.height,
        size=len(contents),
        uploaded_at=datetime.utcnow()
    )
    
    images_db.append(metadata)
    next_id += 1
    
    return metadata

@app.get("/gallery", response_model=List[ImageMetadata])
async def list_gallery():
    """Lister toutes les images"""
    return images_db

@app.get("/gallery/{image_id}", response_model=ImageMetadata)
async def get_image_metadata(image_id: int):
    """Récupérer métadonnées d'une image"""
    for img in images_db:
        if img.id == image_id:
            return img
    raise HTTPException(404, "Image not found")

@app.get("/gallery/{image_id}/file")
async def get_image_file(image_id: int, thumbnail: bool = False):
    """Télécharger l'image"""
    # Trouver métadonnées
    metadata = None
    for img in images_db:
        if img.id == image_id:
            metadata = img
            break
    
    if not metadata:
        raise HTTPException(404, "Image not found")
    
    # Choisir fichier
    filename = metadata.thumbnail_filename if thumbnail else metadata.saved_filename
    file_path = UPLOAD_DIR / filename
    
    if not file_path.exists():
        raise HTTPException(404, "File not found on disk")
    
    return FileResponse(file_path)


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 9
-----------------------------

Vous avez appris:
[OK] UploadFile vs bytes
[OK] Sauvegarder fichiers (sync et async)
[OK] Validation complète (taille, type, nom)
[OK] Noms de fichiers uniques
[OK] Upload multiple
[OK] Combiner File + Form data
[OK] Traitement d'images (PIL/Pillow)
[OK] Thumbnails
[OK] Streaming pour gros fichiers
[OK] Servir fichiers uploadés

Points clés:
[CLE] Toujours UploadFile (pas bytes)
[CLE] TOUJOURS valider (taille, type, nom)
[CLE] Noms uniques (UUID)
[CLE] Async I/O pour performance
[CLE] Chunks pour gros fichiers


[OBJECTIF] SÉCURITÉ - FILE UPLOAD:

1. VALIDATION STRICTE:
   [OK] Taille maximum
   [OK] Extensions autorisées
   [OK] Content-Type
   [OK] Nom de fichier sécurisé (pas ../, /)

2. STOCKAGE:
   [OK] Hors de webroot si possible
   [OK] Noms uniques (pas noms originaux)
   [OK] Permissions restreintes
   [OK] Séparer par utilisateur

3. CONTENU:
   [OK] Scanner virus si critique
   [OK] Vérifier contenu réel (pas juste extension)
   [OK] Limite uploads par user (rate limiting)
   [OK] Nettoyer EXIF d'images (données cachées)

4. SERVING:
   [OK] Valider paths (éviter directory traversal)
   [OK] Content-Type approprié
   [OK] Headers de sécurité
   [OK] CDN si possible


[OBJECTIF] PATTERNS COURANTS:

1. IMAGES:
   - Upload -> Valider -> Redimensionner -> Thumbnail -> Sauvegarder
   
2. DOCUMENTS:
   - Upload -> Valider -> Scanner virus -> Sauvegarder -> Extraire texte (OCR)
   
3. CSV/EXCEL:
   - Upload -> Valider -> Parser -> Valider données -> Importer en DB
   
4. VIDÉOS:
   - Upload streamé -> Valider -> Transcoder -> Générer previews -> Sauvegarder
"""


# ============================================================================
# [GUIDE] CHAPITRE 10: STATIC FILES & TEMPLATES
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Servir des fichiers statiques (CSS, JS, images) et utiliser des templates HTML.


[REFLEXION] STATIC FILES: POURQUOI?
--------------------------

[IDEE] FICHIERS STATIQUES = Fichiers qui ne changent pas dynamiquement:
- CSS (styles)
- JavaScript (interactivité)
- Images (logos, icônes)
- Fonts (polices)
- Videos, audios
- Fichiers téléchargeables (PDF, etc.)


MONTER UN DOSSIER STATIC
------------------------
"""

from fastapi.staticfiles import StaticFiles

# Créer dossier static
Path("static").mkdir(exist_ok=True)

# Monter le dossier static
app.mount("/static", StaticFiles(directory="static"), name="static")

"""
[IDEE] DÉCORTIQUONS:

app.mount("/static", ...)
- "/static" = URL path où les fichiers seront accessibles
- Tous les fichiers dans le dossier seront servis à cette URL

StaticFiles(directory="static")
- directory="static" = Dossier local à servir

name="static"
- Nom pour reverse URL (dans templates)


STRUCTURE RECOMMANDÉE:
"""
project/
├── main.py
├── static/
│   ├── css/
│   │   ├── style.css
│   │   └── bootstrap.min.css
│   ├── js/
│   │   ├── main.js
│   │   └── jquery.min.js
│   ├── images/
│   │   ├── logo.png
│   │   └── favicon.ico
│   └── fonts/
│       └── roboto.woff2
└── templates/
    └── index.html

"""
ACCÈS AUX FICHIERS:
http://localhost:8000/static/css/style.css
http://localhost:8000/static/images/logo.png
http://localhost:8000/static/js/main.js


EXEMPLE DE FICHIERS STATIQUES
----------------------------
"""

# static/css/style.css
"""
body {
    font-family: Arial, sans-serif;
    margin: 0;
    padding: 20px;
    background-color: #f5f5f5;
}

.container {
    max-width: 800px;
    margin: 0 auto;
    background: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
"""

# static/js/main.js
"""
console.log('FastAPI Frontend loaded!');

// Exemple: Charger des données depuis l'API
async function loadUsers() {
    const response = await fetch('/api/users');
    const users = await response.json();
    console.log('Users:', users);
}
"""


"""
TEMPLATES HTML AVEC JINJA2
--------------------------

[IDEE] POURQUOI TEMPLATES?
Générer HTML dynamiquement avec des données Python!
"""

pip install jinja2

"""
CONFIGURATION:
"""

from fastapi.templating import Jinja2Templates

templates = Jinja2Templates(directory="templates")

"""
[IDEE] Jinja2Templates:
- Charge templates depuis dossier "templates"
- Syntaxe similaire à Django templates
- Variables, loops, conditions, etc.


PREMIER TEMPLATE
---------------
"""

# templates/index.html
"""
<!DOCTYPE html>
<html>
<head>
    <title>{{ title }}</title>
    <link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
</head>
<body>
    <div class="container">
        <h1>{{ title }}</h1>
        <p>Welcome, {{ user.name }}!</p>
    </div>
    <script src="{{ url_for('static', path='/js/main.js') }}"></script>
</body>
</html>
"""

"""
[IDEE] SYNTAXE JINJA2:

{{ variable }}
- Affiche une variable
- Ex: {{ user.name }}

{{ url_for('static', path='/css/style.css') }}
- Génère URL vers fichier static
- Utilise le "name" du mount

{% if condition %}...{% endif %}
- Condition

{% for item in items %}...{% endfor %}
- Boucle

{{ variable|filter }}
- Applique un filtre
- Ex: {{ name|upper }}


RENDER TEMPLATE
--------------
"""

from fastapi import Request

@app.get("/")
async def home(request: Request):
    """
    [IDEE] RENDER TEMPLATE
    
    templates.TemplateResponse() render le template avec les données.
    
    ARGUMENTS:
    - "index.html": nom du template
    - context dict avec:
      - "request": OBLIGATOIRE (pour url_for, etc.)
      - Autres variables passées au template
    """
    return templates.TemplateResponse(
        "index.html",
        {
            "request": request,
            "title": "FastAPI App",
            "user": {"name": "John", "email": "john@example.com"}
        }
    )


"""
TEMPLATE AVEC DONNÉES DYNAMIQUES
--------------------------------
"""

# templates/users.html
"""
<!DOCTYPE html>
<html>
<head>
    <title>Users - {{ app_name }}</title>
    <link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
</head>
<body>
    <div class="container">
        <h1>Users ({{ users|length }})</h1>
        
        {% if users %}
            <ul>
            {% for user in users %}
                <li>
                    <strong>{{ user.username }}</strong> - {{ user.email }}
                    {% if user.is_active %}
                        <span class="badge">Active</span>
                    {% else %}
                        <span class="badge inactive">Inactive</span>
                    {% endif %}
                </li>
            {% endfor %}
            </ul>
        {% else %}
            <p>No users found.</p>
        {% endif %}
        
        <a href="/">Back to home</a>
    </div>
</body>
</html>
"""

@app.get("/users-page")
async def users_page(request: Request):
    """Page HTML listant les utilisateurs"""
    users = get_all_users()  # Récupérer depuis DB
    
    return templates.TemplateResponse(
        "users.html",
        {
            "request": request,
            "app_name": "My FastAPI App",
            "users": users
        }
    )


"""
TEMPLATE INHERITANCE (HÉRITAGE)
-------------------------------

[IDEE] ÉVITER DUPLICATION: Base template réutilisable
"""

# templates/base.html
"""
<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}My App{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
    {% block extra_css %}{% endblock %}
</head>
<body>
    <nav>
        <a href="/">Home</a>
        <a href="/users-page">Users</a>
        <a href="/about">About</a>
    </nav>
    
    <main>
        {% block content %}{% endblock %}
    </main>
    
    <footer>
        <p>&copy; 2024 My FastAPI App</p>
    </footer>
    
    <script src="{{ url_for('static', path='/js/main.js') }}"></script>
    {% block extra_js %}{% endblock %}
</body>
</html>
"""

# templates/home.html (hérite de base.html)
"""
{% extends "base.html" %}

{% block title %}Home - My App{% endblock %}

{% block content %}
    <h1>Welcome to My App</h1>
    <p>This is the home page.</p>
{% endblock %}
"""

# templates/users.html (hérite de base.html)
"""
{% extends "base.html" %}

{% block title %}Users - My App{% endblock %}

{% block content %}
    <h1>Users</h1>
    <ul>
    {% for user in users %}
        <li>{{ user.username }}</li>
    {% endfor %}
    </ul>
{% endblock %}
"""

"""
[IDEE] AVANTAGES:
- Navigation cohérente sur toutes les pages
- Styles et scripts chargés une fois
- Changement du layout = un seul endroit
- Blocks permettent de surcharger sections


FORMULAIRES DANS TEMPLATES
--------------------------
"""

# templates/register.html
"""
{% extends "base.html" %}

{% block title %}Register{% endblock %}

{% block content %}
    <h1>Register</h1>
    
    {% if error %}
        <div class="error">{{ error }}</div>
    {% endif %}
    
    <form method="POST" action="/register">
        <div>
            <label for="username">Username:</label>
            <input type="text" id="username" name="username" required>
        </div>
        
        <div>
            <label for="email">Email:</label>
            <input type="email" id="email" name="email" required>
        </div>
        
        <div>
            <label for="password">Password:</label>
            <input type="password" id="password" name="password" required>
        </div>
        
        <button type="submit">Register</button>
    </form>
{% endblock %}
"""

@app.get("/register")
async def register_form(request: Request):
    """Afficher formulaire d'inscription"""
    return templates.TemplateResponse(
        "register.html",
        {"request": request}
    )

@app.post("/register")
async def register_submit(
    request: Request,
    username: str = Form(...),
    email: str = Form(...),
    password: str = Form(...)
):
    """Traiter soumission du formulaire"""
    try:
        # Créer utilisateur
        user = create_user(username, email, password)
        
        # Rediriger vers page de succès
        return RedirectResponse(url="/login", status_code=303)
    
    except ValueError as e:
        # Erreur: ré-afficher formulaire avec message
        return templates.TemplateResponse(
            "register.html",
            {
                "request": request,
                "error": str(e)
            }
        )


"""
INCLURE DES PARTIALS
-------------------

[IDEE] PARTIALS = Morceaux réutilisables de template
"""

# templates/partials/user_card.html
"""
<div class="user-card">
    <h3>{{ user.username }}</h3>
    <p>{{ user.email }}</p>
    {% if user.is_active %}
        <span class="badge active">Active</span>
    {% endif %}
</div>
"""

# templates/users.html
"""
{% extends "base.html" %}

{% block content %}
    <h1>Users</h1>
    
    {% for user in users %}
        {% include "partials/user_card.html" %}
    {% endfor %}
{% endblock %}
"""


"""
FILTERS JINJA2
-------------

[IDEE] FILTERS = Transforment les variables
"""

# Dans le template:
"""
{{ name|upper }}           -> JOHN (uppercase)
{{ name|lower }}           -> john (lowercase)
{{ name|title }}           -> John (title case)
{{ text|truncate(20) }}    -> Coupe à 20 caractères
{{ items|length }}         -> Nombre d'items
{{ price|round(2) }}       -> Arrondi à 2 décimales
{{ date|datetime }}        -> Formate date
"""

# Créer un filter personnalisé:
from jinja2 import Environment

def currency_filter(value):
    """Formater en devise"""
    return f"${value:.2f}"

# Ajouter à Jinja2
templates.env.filters["currency"] = currency_filter

# Utiliser dans template:
"""
{{ product.price|currency }}  -> $99.99
"""


"""
[COURS] EXERCICE PRATIQUE: Blog Simple
--------------------------------

OBJECTIF:
Créer un blog simple avec:
- Page d'accueil listant les posts
- Page de détail d'un post
- Page de création de post (formulaire)
"""

# Modèle
class Post(BaseModel):
    id: int
    title: str
    content: str
    author: str
    created_at: datetime

# Base de données simulée
posts_db: List[Post] = []
next_post_id = 1

# templates/blog/home.html
"""
{% extends "base.html" %}

{% block title %}Blog - Home{% endblock %}

{% block content %}
    <h1>Blog Posts</h1>
    
    <a href="/blog/new" class="btn">New Post</a>
    
    {% if posts %}
        {% for post in posts %}
            <article class="post-preview">
                <h2><a href="/blog/{{ post.id }}">{{ post.title }}</a></h2>
                <p>{{ post.content|truncate(200) }}</p>
                <footer>
                    By {{ post.author }} on {{ post.created_at.strftime('%Y-%m-%d') }}
                </footer>
            </article>
        {% endfor %}
    {% else %}
        <p>No posts yet. <a href="/blog/new">Create the first one!</a></p>
    {% endif %}
{% endblock %}
"""

@app.get("/blog")
async def blog_home(request: Request):
    """Page d'accueil du blog"""
    return templates.TemplateResponse(
        "blog/home.html",
        {
            "request": request,
            "posts": posts_db
        }
    )

# templates/blog/detail.html
"""
{% extends "base.html" %}

{% block title %}{{ post.title }} - Blog{% endblock %}

{% block content %}
    <article>
        <h1>{{ post.title }}</h1>
        <p class="meta">
            By {{ post.author }} on {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}
        </p>
        
        <div class="content">
            {{ post.content|safe }}
        </div>
        
        <a href="/blog">Back to all posts</a>
    </article>
{% endblock %}
"""

@app.get("/blog/{post_id}")
async def blog_detail(request: Request, post_id: int):
    """Détail d'un post"""
    # Trouver post
    post = next((p for p in posts_db if p.id == post_id), None)
    if not post:
        raise HTTPException(404, "Post not found")
    
    return templates.TemplateResponse(
        "blog/detail.html",
        {
            "request": request,
            "post": post
        }
    )

# templates/blog/new.html
"""
{% extends "base.html" %}

{% block title %}New Post - Blog{% endblock %}

{% block content %}
    <h1>Create New Post</h1>
    
    <form method="POST" action="/blog/new">
        <div>
            <label for="title">Title:</label>
            <input type="text" id="title" name="title" required>
        </div>
        
        <div>
            <label for="content">Content:</label>
            <textarea id="content" name="content" rows="10" required></textarea>
        </div>
        
        <div>
            <label for="author">Author:</label>
            <input type="text" id="author" name="author" required>
        </div>
        
        <button type="submit">Publish</button>
        <a href="/blog">Cancel</a>
    </form>
{% endblock %}
"""

@app.get("/blog/new")
async def blog_new_form(request: Request):
    """Formulaire de création"""
    return templates.TemplateResponse(
        "blog/new.html",
        {"request": request}
    )

@app.post("/blog/new")
async def blog_new_submit(
    request: Request,
    title: str = Form(...),
    content: str = Form(...),
    author: str = Form(...)
):
    """Créer nouveau post"""
    global next_post_id
    
    post = Post(
        id=next_post_id,
        title=title,
        content=content,
        author=author,
        created_at=datetime.utcnow()
    )
    
    posts_db.append(post)
    next_post_id += 1
    
    # Rediriger vers le nouveau post
    return RedirectResponse(url=f"/blog/{post.id}", status_code=303)


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 10
------------------------------

Vous avez appris:
[OK] Monter dossier static avec StaticFiles
[OK] Structure de projet (static/, templates/)
[OK] Templates Jinja2
[OK] Variables, loops, conditions dans templates
[OK] Template inheritance (extends, block)
[OK] Formulaires HTML
[OK] Inclure des partials
[OK] Filters Jinja2
[OK] RedirectResponse après POST

Points clés:
[CLE] app.mount() pour servir fichiers statiques
[CLE] Jinja2Templates pour HTML dynamique
[CLE] url_for('static', path='...') pour URLs de fichiers
[CLE] request OBLIGATOIRE dans TemplateResponse
[CLE] {% extends %} pour éviter duplication


[OBJECTIF] QUAND UTILISER TEMPLATES VS API PURE:

TEMPLATES (SSR - Server-Side Rendering):
[OK] Sites vitrines, blogs
[OK] Admin panels simples
[OK] SEO important (Google indexe directement)
[OK] Pas besoin de framework JS
[OK] Simplicité

API PURE (SPA - Single Page Application):
[OK] Applications complexes, interactives
[OK] React, Vue, Angular frontend
[OK] Apps mobiles
[OK] Découplage frontend/backend
[OK] Performance (moins de reloads)


HYBRIDE (Meilleur des deux):
- Templates pour pages statiques/marketing
- API pour parties dynamiques/app
- Exemple: Site vitrine en SSR + Dashboard en SPA
"""


"""
[BRAVO] FIN DE LA PARTIE 2!

Vous avez appris:
[OK] Dependencies (Dependency Injection)
[OK] Security & Authentication (JWT, OAuth2)
[OK] File Upload (validation, traitement)
[OK] Static Files & Templates (Jinja2)

PARTIE 3 couvrira:
- Async/Await approfondi
- Background Tasks
- Database (SQLAlchemy complet)
- Middleware
- WebSockets

Continuez! [RAPIDE]
"""

# ============================================================================
# [LIVRE] FASTAPI - GUIDE ULTRA-DÉTAILLÉ PARTIE 3
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COUVRE:
# - Async/Await (Programmation asynchrone approfondie)
# - Background Tasks (Tâches en arrière-plan)
# - Database Integration (SQLAlchemy complet)
# - Middleware (Intergiciels)
# - WebSockets (Communication temps réel)
#
# [TEMPS] TEMPS DE LECTURE: ~4-5 heures
# [DOCS] PRÉREQUIS: Avoir complété les Parties 1 et 2
# ============================================================================


# ============================================================================
# [GUIDE] CHAPITRE 11: ASYNC/AWAIT (Programmation asynchrone)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Maîtriser la programmation asynchrone dans FastAPI pour maximiser les performances.


[REFLEXION] COMPRENDRE ASYNC/AWAIT
-------------------------

[IDEE] QU'EST-CE QUE L'ASYNCHRONE?

ANALOGIE DU RESTAURANT:

SYNCHRONE (def):
Le serveur prend commande du client 1
-> VA EN CUISINE et ATTEND que le plat soit prêt
-> Revient donner le plat
-> Prend commande du client 2
-> Retourne EN CUISINE et ATTEND
-> etc.

[TEMPS] Si 10 clients et 5 min par plat = 50 minutes!


ASYNCHRONE (async def):
Le serveur prend commande du client 1
-> DONNE LA COMMANDE en cuisine
-> Pendant que la cuisine prépare, prend commande du client 2
-> DONNE LA COMMANDE en cuisine
-> Continue avec client 3, 4, 5...
-> Quand un plat est prêt, le sert
-> Continue avec les autres

[TEMPS] Si 10 clients et 5 min par plat = ~5 minutes (tous en parallèle)!


[IDEE] EN PYTHON:

SYNCHRONE (bloquant):
"""
def get_user(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    # [TEMPS] BLOQUE ICI pendant la requête HTTP (peut-être 100ms)
    # Pendant ce temps, le serveur ne peut RIEN faire d'autre!
    return response.json()

"""
ASYNCHRONE (non-bloquant):
"""
async def get_user(user_id):
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://api.example.com/users/{user_id}")
        # [TEMPS] "await" = "attends ici, mais pendant ce temps, fais autre chose!"
        # Le serveur peut traiter d'autres requêtes pendant l'attente
        return response.json()


"""
QUAND UTILISER async def?
------------------------

[OK] UTILISER async def POUR:
- Appels à des APIs externes (HTTP)
- Requêtes base de données
- Lecture/écriture de fichiers
- Opérations réseau
- N'importe quoi qui "attend" (I/O)

[X] NE PAS UTILISER async def POUR:
- Calculs CPU intensifs
- Code purement Python (sans I/O)
- Si pas de await dans la fonction


[IDEE] RÈGLE SIMPLE:
Si votre fonction fait des await -> async def
Si pas de await -> def


EXEMPLE CONCRET: API EXTERNE
---------------------------
"""

import httpx
import asyncio

# [X] SYNCHRONE (lent)
def get_user_sync(user_id: int):
    """Appel synchrone (bloque le serveur)"""
    response = requests.get(f"https://jsonplaceholder.typicode.com/users/{user_id}")
    return response.json()

@app.get("/users-sync/{user_id}")
def users_sync_endpoint(user_id: int):
    """
    [X] PROBLÈME:
    Pendant que requests.get() attend la réponse,
    le serveur est BLOQUÉ et ne peut pas traiter d'autres requêtes!
    
    Si 100 clients appellent cet endpoint en même temps:
    -> Ils attendent chacun leur tour
    -> Total: 100 × temps_requête
    """
    user = get_user_sync(user_id)
    return user


# [OK] ASYNCHRONE (rapide)
async def get_user_async(user_id: int):
    """
    [IDEE] Appel asynchrone (ne bloque pas le serveur)
    
    INSTALLATION:
    pip install httpx
    
    httpx = version async de requests
    """
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://jsonplaceholder.typicode.com/users/{user_id}")
        return response.json()

@app.get("/users-async/{user_id}")
async def users_async_endpoint(user_id: int):
    """
    [OK] AVANTAGE:
    Pendant que httpx attend la réponse,
    le serveur peut traiter d'autres requêtes!
    
    Si 100 clients appellent cet endpoint:
    -> Tous peuvent être traités "en même temps"
    -> Total: ~temps_requête (pas 100×)
    
    [IDEE] C'EST LA MAGIE D'ASYNC!
    """
    user = await get_user_async(user_id)
    return user


"""
APPELS MULTIPLES EN PARALLÈLE
-----------------------------

[IDEE] PROBLÈME:
Vous devez appeler 3 APIs différentes.
"""

# [X] SYNCHRONE (séquentiel)
@app.get("/data-sync")
def get_data_sync():
    """
    Appels séquentiels (l'un après l'autre)
    
    [TEMPS] TEMPS TOTAL = temps_API1 + temps_API2 + temps_API3
    Si chaque API prend 200ms -> 600ms total
    """
    user = get_user_sync(1)      # 200ms
    posts = get_posts_sync(1)    # 200ms
    comments = get_comments_sync(1)  # 200ms
    
    return {
        "user": user,
        "posts": posts,
        "comments": comments
    }


# [OK] ASYNCHRONE (parallèle)
@app.get("/data-async")
async def get_data_async():
    """
    [IDEE] asyncio.gather() = Exécuter en PARALLÈLE!
    
    [TEMPS] TEMPS TOTAL = max(temps_API1, temps_API2, temps_API3)
    Si chaque API prend 200ms -> 200ms total (3x plus rapide!)
    """
    # Lancer les 3 requêtes EN MÊME TEMPS
    user, posts, comments = await asyncio.gather(
        get_user_async(1),
        get_posts_async(1),
        get_comments_async(1)
    )
    
    return {
        "user": user,
        "posts": posts,
        "comments": comments
    }

"""
[IDEE] VISUALISATION:

SYNCHRONE:
[User    ] <- 200ms
         [Posts   ] <- 200ms
                  [Comments] <- 200ms
Total: 600ms

ASYNCHRONE:
[User    ] <- 200ms
[Posts   ] <- 200ms  } En même temps!
[Comments] <- 200ms

Total: 200ms


GESTION D'ERREURS AVEC ASYNC
---------------------------
"""

@app.get("/data-safe")
async def get_data_safe():
    """
    [IDEE] GESTION D'ERREURS AVEC gather()
    
    PAR DÉFAUT:
    Si une tâche échoue, gather() lève l'exception immédiatement.
    
    AVEC return_exceptions=True:
    Les exceptions sont retournées comme valeurs.
    """
    results = await asyncio.gather(
        get_user_async(1),
        get_user_async(999),  # N'existe pas -> erreur
        get_user_async(2),
        return_exceptions=True  # <- Important!
    )
    
    # Séparer succès et erreurs
    users = []
    errors = []
    
    for result in results:
        if isinstance(result, Exception):
            errors.append(str(result))
        else:
            users.append(result)
    
    return {
        "users": users,
        "errors": errors
    }


"""
TIMEOUT AVEC ASYNC
-----------------

[IDEE] PROBLÈME:
Une API externe est lente ou ne répond pas.
"""

@app.get("/data-timeout")
async def get_data_timeout():
    """
    [IDEE] asyncio.wait_for() = Timeout!
    
    Si la requête prend plus de 5 secondes -> TimeoutError
    """
    try:
        user = await asyncio.wait_for(
            get_user_async(1),
            timeout=5.0  # 5 secondes max
        )
        return user
    
    except asyncio.TimeoutError:
        raise HTTPException(
            status_code=504,  # Gateway Timeout
            detail="External API is too slow"
        )


"""
CRÉER SES PROPRES FONCTIONS ASYNC
---------------------------------
"""

async def send_email_async(email: str, subject: str, body: str):
    """
    [IDEE] FONCTION ASYNC PERSONNALISÉE
    
    Simule envoi d'email (qui prendrait du temps)
    """
    print(f"Sending email to {email}...")
    
    # Simuler opération I/O (en vrai: SMTP, API, etc.)
    await asyncio.sleep(2)  # Attend 2 secondes (non-bloquant!)
    
    print(f"Email sent to {email}")
    return {"status": "sent", "to": email}

@app.post("/register")
async def register_user(username: str, email: str):
    """
    Inscription + envoi email de bienvenue
    """
    # Créer utilisateur
    user = create_user_in_db(username, email)
    
    # Envoyer email (asynchrone)
    await send_email_async(
        email=email,
        subject="Welcome!",
        body=f"Hello {username}, welcome to our app!"
    )
    
    return user


"""
ASYNC CONTEXT MANAGERS
----------------------

Pour gérer ressources (connexions, etc.):
"""

class DatabaseConnection:
    """
    [IDEE] ASYNC CONTEXT MANAGER
    
    Comme un context manager normal (with),
    mais avec async/await.
    """
    async def __aenter__(self):
        """Appelé à l'entrée du 'async with'"""
        print("Opening database connection...")
        await asyncio.sleep(0.1)  # Simule connexion
        self.connection = "connected"
        return self
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """Appelé à la sortie (même si erreur!)"""
        print("Closing database connection...")
        await asyncio.sleep(0.1)  # Simule fermeture
        self.connection = None

@app.get("/query")
async def query_database():
    """
    [IDEE] UTILISATION:
    
    async with = context manager asynchrone
    -> __aenter__ appelé au début
    -> __aexit__ appelé à la fin (même si erreur)
    """
    async with DatabaseConnection() as db:
        # Ici, connexion est ouverte
        result = await db.query("SELECT * FROM users")
        return result
    # Ici, connexion est fermée automatiquement


"""
ASYNC GENERATORS
---------------

Pour streamer des données:
"""

async def fetch_users_stream():
    """
    [IDEE] ASYNC GENERATOR
    
    yield = Retourner valeur et continuer
    Utile pour streamer des données progressivement
    """
    for i in range(1, 11):
        # Simuler fetch depuis API/DB
        await asyncio.sleep(0.1)
        user = await get_user_async(i)
        yield user

@app.get("/users-stream")
async def users_stream():
    """
    Retourner utilisateurs progressivement
    """
    users = []
    async for user in fetch_users_stream():
        users.append(user)
    
    return users


"""
ASYNC AVEC DATABASE
------------------

Pour vraies requêtes DB asynchrones:
"""

from databases import Database

database = Database("postgresql://user:pass@localhost/dbname")

@app.on_event("startup")
async def startup():
    """Connecter à la DB au démarrage"""
    await database.connect()

@app.on_event("shutdown")
async def shutdown():
    """Déconnecter de la DB à l'arrêt"""
    await database.disconnect()

@app.get("/users-db")
async def get_users_db():
    """
    [IDEE] REQUÊTE DB ASYNCHRONE
    
    Pendant que la DB traite la query,
    le serveur peut traiter d'autres requêtes!
    """
    query = "SELECT * FROM users"
    results = await database.fetch_all(query)
    return results


"""
[COURS] EXERCICE PRATIQUE: Agrégateur de données
------------------------------------------

OBJECTIF:
Créer un endpoint qui agrège des données de plusieurs sources en parallèle.
"""

@app.get("/dashboard/{user_id}")
async def get_dashboard(user_id: int):
    """
    [IDEE] DASHBOARD COMPLET
    
    Récupère:
    1. Info utilisateur (API externe)
    2. Posts de l'utilisateur (API externe)
    3. Statistiques (calcul local)
    4. Notifications (DB)
    
    TOUT EN PARALLÈLE!
    """
    # Définir toutes les tâches
    tasks = [
        get_user_async(user_id),
        get_user_posts_async(user_id),
        calculate_user_stats_async(user_id),
        get_user_notifications_async(user_id)
    ]
    
    # Exécuter en parallèle avec timeout
    try:
        results = await asyncio.wait_for(
            asyncio.gather(*tasks, return_exceptions=True),
            timeout=5.0
        )
    except asyncio.TimeoutError:
        raise HTTPException(504, "Dashboard data took too long to load")
    
    # Séparer résultats
    user, posts, stats, notifications = results
    
    # Gérer erreurs individuelles
    return {
        "user": user if not isinstance(user, Exception) else None,
        "posts": posts if not isinstance(posts, Exception) else [],
        "stats": stats if not isinstance(stats, Exception) else {},
        "notifications": notifications if not isinstance(notifications, Exception) else []
    }


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 11
------------------------------

Vous avez appris:
[OK] Différence sync vs async
[OK] async def et await
[OK] httpx pour requêtes HTTP async
[OK] asyncio.gather() pour parallélisme
[OK] Gestion d'erreurs avec return_exceptions
[OK] Timeout avec asyncio.wait_for()
[OK] Async context managers
[OK] Async generators

Points clés:
[CLE] async def pour fonctions avec I/O
[CLE] await pour opérations asynchrones
[CLE] asyncio.gather() pour exécution parallèle
[CLE] Async = pas "plus rapide", mais "peut faire plus en même temps"
[CLE] Crucial pour scalabilité


[OBJECTIF] RÈGLES À SUIVRE:

1. UTILISER async def POUR:
   [OK] API calls (httpx)
   [OK] Database queries (databases, asyncpg)
   [OK] File I/O (aiofiles)
   [OK] Redis, cache (aioredis)

2. RESTER SYNCHRONE POUR:
   [OK] Calculs CPU
   [OK] Code simple sans I/O
   [OK] Traitement en mémoire

3. NE JAMAIS:
   [X] Mélanger requests (sync) dans async def
   [X] Faire du CPU intensif dans async
   [X] Utiliser time.sleep() dans async (-> asyncio.sleep())

4. TOUJOURS:
   [OK] Gérer les erreurs (try/except)
   [OK] Mettre des timeouts
   [OK] Utiliser return_exceptions=True avec gather()
"""


# ============================================================================
# [GUIDE] CHAPITRE 12: BACKGROUND TASKS (Tâches en arrière-plan)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Exécuter des tâches après avoir renvoyé la réponse au client,
sans le faire attendre.


[REFLEXION] POURQUOI BACKGROUND TASKS?
----------------------------

[IDEE] PROBLÈME:
Certaines tâches prennent du temps mais ne sont pas critiques:
- Envoyer un email
- Logger dans un fichier
- Mettre à jour un cache
- Générer un rapport
- Traiter une image

[X] SI VOUS ATTENDEZ:
"""
@app.post("/register")
def register_slow(email: str, username: str):
    """
    [X] MAUVAIS: Client attend tout!
    """
    # Créer user (rapide)
    user = create_user(username, email)  # 50ms
    
    # Envoyer email (LENT!)
    send_welcome_email(email)  # 2000ms [TEMPS]
    
    # Générer rapport admin (LENT!)
    generate_admin_report()  # 1000ms [TEMPS]
    
    # Client a attendu: 50 + 2000 + 1000 = 3050ms!
    return user


[OK] AVEC BACKGROUND TASKS:
"""
from fastapi import BackgroundTasks

@app.post("/register")
def register_fast(
    email: str,
    username: str,
    background_tasks: BackgroundTasks
):
    """
    [OK] BON: Client reçoit réponse immédiatement!
    
    BackgroundTasks = Dépendance spéciale de FastAPI
    Permet d'ajouter des tâches à exécuter APRÈS la réponse.
    """
    # Créer user (rapide)
    user = create_user(username, email)  # 50ms
    
    # Ajouter tâches en arrière-plan
    background_tasks.add_task(send_welcome_email, email)
    background_tasks.add_task(generate_admin_report)
    
    # Réponse envoyée IMMÉDIATEMENT (50ms!)
    # Les tâches s'exécutent APRÈS
    return user


"""
[IDEE] FLUX D'EXÉCUTION:

1. Requête arrive
2. Fonction s'exécute
3. user créé (50ms)
4. Tâches ajoutées à la queue
5. return user -> RÉPONSE ENVOYÉE AU CLIENT
6. send_welcome_email() s'exécute (client déjà parti!)
7. generate_admin_report() s'exécute


SYNTAXE DE BASE
--------------
"""

def write_log(message: str):
    """
    [IDEE] FONCTION NORMALE
    
    Pas besoin d'être async!
    (mais peut l'être si vous voulez)
    """
    with open("log.txt", "a") as f:
        f.write(f"{message}\n")

@app.post("/items/")
def create_item(
    item: dict,
    background_tasks: BackgroundTasks
):
    """
    BackgroundTasks = Dépendance injectée automatiquement
    """
    # Créer item
    item_id = save_item_to_db(item)
    
    # Logger en arrière-plan
    background_tasks.add_task(write_log, f"Item {item_id} created")
    
    return {"id": item_id}

"""
[IDEE] add_task():

background_tasks.add_task(fonction, arg1, arg2, kwarg1=val1)
                          ^^^^^^^^  ^^^^^^^^^^^^  ^^^^^^^^^^^
                          Fonction  Args positionnels  Kwargs


TÂCHES AVEC PLUSIEURS PARAMÈTRES
--------------------------------
"""

def send_email(to: str, subject: str, body: str, attachments: list = None):
    """Email avec plusieurs paramètres"""
    print(f"Sending email to {to}")
    print(f"Subject: {subject}")
    # Simuler envoi
    time.sleep(1)
    print("Email sent!")

@app.post("/send-notification/")
def send_notification(
    email: str,
    message: str,
    background_tasks: BackgroundTasks
):
    """
    [IDEE] Passer plusieurs arguments
    """
    background_tasks.add_task(
        send_email,
        email,                    # arg1: to
        "Notification",           # arg2: subject
        message,                  # arg3: body
        attachments=["file.pdf"]  # kwarg
    )
    
    return {"status": "notification queued"}


"""
TÂCHES ASYNC
-----------

Background tasks peuvent être async:
"""

async def process_image_async(image_path: str):
    """
    [IDEE] TÂCHE ASYNC
    
    Si vous avez besoin d'await dans la tâche,
    utilisez async def.
    """
    # Charger image (I/O)
    async with aiofiles.open(image_path, 'rb') as f:
        data = await f.read()
    
    # Traiter (simulé)
    await asyncio.sleep(2)
    
    # Sauvegarder résultat
    processed_path = image_path.replace(".jpg", "_processed.jpg")
    async with aiofiles.open(processed_path, 'wb') as f:
        await f.write(data)

@app.post("/upload-image/")
async def upload_image(
    file: UploadFile,
    background_tasks: BackgroundTasks
):
    """
    Upload image + traitement en arrière-plan
    """
    # Sauvegarder fichier uploadé
    file_path = f"uploads/{file.filename}"
    with open(file_path, "wb") as f:
        f.write(await file.read())
    
    # Traiter en arrière-plan
    background_tasks.add_task(process_image_async, file_path)
    
    return {"filename": file.filename, "status": "processing"}


"""
PLUSIEURS TÂCHES
---------------
"""

@app.post("/complex-operation/")
def complex_operation(
    user_id: int,
    data: dict,
    background_tasks: BackgroundTasks
):
    """
    [IDEE] PLUSIEURS TÂCHES EN ARRIÈRE-PLAN
    
    Toutes s'exécutent APRÈS la réponse,
    dans l'ordre où elles ont été ajoutées.
    """
    # Opération principale
    result = process_data(data)
    
    # Ajouter plusieurs tâches
    background_tasks.add_task(send_email, user_id, "Process complete")
    background_tasks.add_task(update_cache, user_id, result)
    background_tasks.add_task(log_activity, user_id, "process", data)
    background_tasks.add_task(generate_report, user_id)
    
    return result


"""
GESTION D'ERREURS
----------------

[ATTENTION] IMPORTANT:
Si une background task plante, FastAPI log l'erreur mais ne crash pas!
"""

def risky_task():
    """
    Tâche qui peut planter
    """
    try:
        # Code risqué
        result = dangerous_operation()
        log_success(result)
    
    except Exception as e:
        # Logger l'erreur
        logger.error(f"Background task failed: {e}", exc_info=True)
        
        # Notifier admin si critique
        notify_admin_of_error(str(e))

@app.post("/risky-operation/")
def risky_operation_endpoint(background_tasks: BackgroundTasks):
    """
    [IDEE] TOUJOURS gérer les erreurs dans les background tasks!
    
    Si vous ne le faites pas:
    - L'erreur sera loggée
    - Mais vous ne saurez pas que ça a échoué
    - L'utilisateur ne saura pas non plus
    """
    background_tasks.add_task(risky_task)
    return {"status": "task queued"}


"""
LIMITES DES BACKGROUND TASKS
----------------------------

[ATTENTION] BACKGROUND TASKS ≠ QUEUE DE JOBS ROBUSTE!

LIMITATIONS:
1. Pas de retry automatique
2. Pas de persistence (si serveur crash, tâches perdues)
3. Pas de monitoring
4. Exécutées dans le même process (pas vraiment distribué)


[IDEE] QUAND UTILISER BACKGROUND TASKS:
[OK] Tâches légères, non critiques
[OK] Logs, analytics
[OK] Emails simples
[OK] Mises à jour de cache
[OK] Nettoyage


[IDEE] QUAND UTILISER CELERY/RQ:
[OK] Tâches longues (> 1 minute)
[OK] Tâches critiques (doivent réussir)
[OK] Retry nécessaire
[OK] Scheduling complexe
[OK] Monitoring nécessaire
[OK] Distribution sur plusieurs machines


CELERY AVEC FASTAPI (APERÇU)
----------------------------

Pour tâches robustes:
"""

from celery import Celery

# Configuration Celery
celery_app = Celery(
    "tasks",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/0"
)

@celery_app.task
def process_video(video_path: str):
    """
    [IDEE] TÂCHE CELERY
    
    AVANTAGES vs Background Tasks:
    [OK] Persistence (survit aux crashes)
    [OK] Retry automatique
    [OK] Monitoring (Flower)
    [OK] Distribution (plusieurs workers)
    [OK] Scheduling (cron-like)
    """
    # Traiter vidéo (peut prendre 30 minutes!)
    result = heavy_video_processing(video_path)
    return result

@app.post("/upload-video/")
def upload_video(file: UploadFile):
    """
    FastAPI endpoint qui lance tâche Celery
    """
    # Sauvegarder fichier
    file_path = save_video(file)
    
    # Lancer tâche Celery (asynchrone)
    task = process_video.delay(file_path)
    
    return {
        "task_id": task.id,
        "status": "processing",
        "check_status_at": f"/tasks/{task.id}"
    }

@app.get("/tasks/{task_id}")
def get_task_status(task_id: str):
    """
    Vérifier statut d'une tâche Celery
    """
    task = celery_app.AsyncResult(task_id)
    
    return {
        "task_id": task_id,
        "status": task.status,  # PENDING, SUCCESS, FAILURE
        "result": task.result if task.ready() else None
    }


"""
[COURS] EXERCICE PRATIQUE: Système d'export
-------------------------------------

OBJECTIF:
Créer un endpoint qui génère un export CSV en arrière-plan.
"""

import csv
from datetime import datetime

def generate_users_csv():
    """Générer CSV de tous les utilisateurs"""
    filename = f"exports/users_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
    
    # Récupérer users depuis DB
    users = get_all_users()
    
    # Écrire CSV
    with open(filename, 'w', newline='') as f:
        writer = csv.DictWriter(f, fieldnames=['id', 'username', 'email'])
        writer.writeheader()
        for user in users:
            writer.writerow({
                'id': user.id,
                'username': user.username,
                'email': user.email
            })
    
    return filename

@app.post("/export/users")
def export_users(background_tasks: BackgroundTasks):
    """
    [IDEE] DÉCLENCHER EXPORT EN ARRIÈRE-PLAN
    
    Client reçoit réponse immédiate.
    Export se fait en arrière-plan.
    """
    export_id = generate_export_id()
    
    # Ajouter tâche
    background_tasks.add_task(
        generate_users_csv_with_notification,
        export_id
    )
    
    return {
        "export_id": export_id,
        "status": "processing",
        "check_status_at": f"/exports/{export_id}"
    }


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 12
------------------------------

Vous avez appris:
[OK] BackgroundTasks pour tâches après réponse
[OK] add_task() pour ajouter tâches
[OK] Tâches sync et async
[OK] Plusieurs tâches
[OK] Gestion d'erreurs
[OK] Limites des background tasks
[OK] Quand utiliser Celery/RQ

Points clés:
[CLE] BackgroundTasks = après réponse au client
[CLE] add_task(fonction, *args, **kwargs)
[CLE] Tâches légères seulement
[CLE] Pas de retry automatique
[CLE] Pour tâches robustes -> Celery


[OBJECTIF] DÉCISION: Background Tasks vs Celery

BACKGROUND TASKS:
[OK] Tâches < 10 secondes
[OK] Non critiques (OK si perdues)
[OK] Pas de retry nécessaire
[OK] Setup simple (rien à installer)

CELERY:
[OK] Tâches longues (minutes/heures)
[OK] Critiques (DOIVENT réussir)
[OK] Retry automatique
[OK] Monitoring nécessaire
[OK] Distribution sur plusieurs machines
"""


# ============================================================================
# [GUIDE] CHAPITRE 13: DATABASE INTEGRATION (SQLAlchemy)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Intégrer une base de données relationnelle avec SQLAlchemy ORM.


[REFLEXION] POURQUOI SQLALCHEMY?
----------------------

SQLAlchemy = ORM (Object-Relational Mapping)

[IDEE] ORM = Manipuler base de données avec des objets Python!

SANS ORM (SQL brut):
"""
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
row = cursor.fetchone()
user = {"id": row[0], "username": row[1], "email": row[2]}

"""
AVEC ORM (SQLAlchemy):
"""
user = session.query(User).filter(User.id == user_id).first()
# user.username, user.email accessibles directement!

"""
AVANTAGES:
[OK] Code Python (pas SQL brut)
[OK] Protection contre SQL injection
[OK] Changement de DB facile (SQLite -> PostgreSQL)
[OK] Relations gérées automatiquement
[OK] Migrations avec Alembic


INSTALLATION
-----------
"""

pip install sqlalchemy
pip install databases[sqlite]  # Pour async
pip install alembic  # Pour migrations

"""
Pour PostgreSQL:
pip install psycopg2-binary

Pour MySQL:
pip install pymysql


CONFIGURATION
------------
"""

from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime

# URL de connexion
SQLALCHEMY_DATABASE_URL = "sqlite:///./app.db"
# Pour PostgreSQL: "postgresql://user:password@localhost/dbname"
# Pour MySQL: "mysql://user:password@localhost/dbname"

# Créer engine
engine = create_engine(
    SQLALCHEMY_DATABASE_URL,
    connect_args={"check_same_thread": False}  # Seulement pour SQLite
)

# Créer SessionLocal (factory pour créer sessions)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

# Base pour modèles
Base = declarative_base()

"""
[IDEE] DÉCORTIQUONS:

create_engine():
- Crée connexion à la DB
- Engine = "moteur" qui gère les connexions

SessionLocal:
- Factory pour créer des sessions
- Session = "conversation" avec la DB
- Chaque requête utilise sa propre session

Base:
- Classe de base pour tous les modèles
- Tous les modèles héritent de Base


CRÉER DES MODÈLES
----------------
"""

class User(Base):
    """
    [IDEE] MODÈLE USER
    
    Correspond à une table 'users' dans la DB.
    
    Chaque attribut = une colonne
    """
    __tablename__ = "users"
    
    # Colonnes
    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True, nullable=False)
    email = Column(String, unique=True, index=True, nullable=False)
    hashed_password = Column(String, nullable=False)
    is_active = Column(Boolean, default=True)
    is_superuser = Column(Boolean, default=False)
    created_at = Column(DateTime, default=datetime.utcnow)

"""
[IDEE] TYPES DE COLONNES:

Integer: Entiers
String: Texte variable
Boolean: True/False
DateTime: Date et heure
Float: Nombres décimaux
Text: Texte long (pas de limite)


[IDEE] OPTIONS DE COLONNES:

primary_key=True: Clé primaire
unique=True: Valeurs uniques
index=True: Créer index (recherche rapide)
nullable=False: Non NULL (obligatoire)
default=value: Valeur par défaut


CRÉER LES TABLES
---------------
"""

# Créer toutes les tables
Base.metadata.create_all(bind=engine)

"""
[IDEE] QUE SE PASSE-T-IL?

1. SQLAlchemy regarde tous les modèles (User, Item, etc.)
2. Pour chaque modèle, crée la table correspondante
3. Si table existe déjà -> ne fait rien


[ATTENTION] EN PRODUCTION:
N'utilisez PAS create_all()!
Utilisez Alembic pour migrations (on verra plus tard).


DÉPENDANCE GET_DB
----------------
"""

def get_db():
    """
    [IDEE] DÉPENDANCE POUR OBTENIR SESSION DB
    
    Avec yield:
    - Crée session au début
    - La donne à l'endpoint
    - La ferme à la fin (même si erreur!)
    """
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

"""
[IDEE] UTILISATION DANS ENDPOINT:
"""

@app.get("/users/{user_id}")
def read_user(user_id: int, db: Session = Depends(get_db)):
    """
    [IDEE] db = Session DB
    
    Injectée automatiquement par Depends(get_db)
    Fermée automatiquement après le endpoint
    """
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(404, "User not found")
    return user


"""
SCHÉMAS PYDANTIC
---------------

[IDEE] IMPORTANT: Séparer modèles SQLAlchemy et Pydantic!

SQLAlchemy = Structure de la DB
Pydantic = Validation des données API
"""

from pydantic import BaseModel, EmailStr

class UserBase(BaseModel):
    """Champs communs"""
    username: str
    email: EmailStr

class UserCreate(UserBase):
    """Pour créer un utilisateur (avec password)"""
    password: str

class UserUpdate(BaseModel):
    """Pour mise à jour (tous optionnels)"""
    username: str | None = None
    email: EmailStr | None = None
    password: str | None = None

class UserResponse(UserBase):
    """Pour réponse API (sans password!)"""
    id: int
    is_active: bool
    created_at: datetime
    
    class Config:
        orm_mode = True  # <- IMPORTANT pour SQLAlchemy!

"""
[IDEE] orm_mode = True:

Permet de convertir modèle SQLAlchemy -> Pydantic automatiquement!

Sans orm_mode:
"""
user = db_user  # Objet SQLAlchemy
return {"id": user.id, "username": user.username, ...}  # Fastidieux!

"""
Avec orm_mode:
"""
user = db_user  # Objet SQLAlchemy
return UserResponse.from_orm(user)  # Automatique!


"""
OPÉRATIONS CRUD
--------------
"""

# CREATE
@app.post("/users", response_model=UserResponse, status_code=201)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
    """
    [IDEE] CRÉER UTILISATEUR
    
    1. Vérifier si existe déjà
    2. Hasher password
    3. Créer objet User
    4. Ajouter à session
    5. Commit (sauvegarder)
    6. Refresh (récupérer id généré)
    """
    # Vérifier si username existe
    if db.query(User).filter(User.username == user.username).first():
        raise HTTPException(409, "Username already exists")
    
    # Vérifier si email existe
    if db.query(User).filter(User.email == user.email).first():
        raise HTTPException(409, "Email already exists")
    
    # Hasher password
    hashed_password = get_password_hash(user.password)
    
    # Créer objet User
    db_user = User(
        username=user.username,
        email=user.email,
        hashed_password=hashed_password
    )
    
    # Ajouter à session
    db.add(db_user)
    
    # Sauvegarder
    db.commit()
    
    # Rafraîchir (récupérer valeurs générées comme id)
    db.refresh(db_user)
    
    return db_user


# READ (un)
@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int, db: Session = Depends(get_db)):
    """
    [IDEE] RÉCUPÉRER UN UTILISATEUR
    
    query(User): Créer query sur table users
    filter(): WHERE clause
    first(): Récupérer premier résultat (ou None)
    """
    user = db.query(User).filter(User.id == user_id).first()
    
    if not user:
        raise HTTPException(404, "User not found")
    
    return user


# READ (liste)
@app.get("/users", response_model=List[UserResponse])
def list_users(
    skip: int = 0,
    limit: int = 10,
    db: Session = Depends(get_db)
):
    """
    [IDEE] LISTER UTILISATEURS AVEC PAGINATION
    
    offset(): Skip N premiers résultats
    limit(): Limiter à N résultats
    all(): Récupérer tous les résultats
    """
    users = db.query(User).offset(skip).limit(limit).all()
    return users


# UPDATE
@app.put("/users/{user_id}", response_model=UserResponse)
def update_user(
    user_id: int,
    user: UserUpdate,
    db: Session = Depends(get_db)
):
    """
    [IDEE] METTRE À JOUR UTILISATEUR
    
    1. Récupérer user existant
    2. Modifier attributs
    3. Commit
    4. Refresh
    """
    # Récupérer user
    db_user = db.query(User).filter(User.id == user_id).first()
    if not db_user:
        raise HTTPException(404, "User not found")
    
    # Mettre à jour attributs (seulement ceux fournis)
    if user.username is not None:
        # Vérifier si username déjà pris
        existing = db.query(User).filter(
            User.username == user.username,
            User.id != user_id
        ).first()
        if existing:
            raise HTTPException(409, "Username already taken")
        db_user.username = user.username
    
    if user.email is not None:
        # Vérifier si email déjà pris
        existing = db.query(User).filter(
            User.email == user.email,
            User.id != user_id
        ).first()
        if existing:
            raise HTTPException(409, "Email already taken")
        db_user.email = user.email
    
    if user.password is not None:
        db_user.hashed_password = get_password_hash(user.password)
    
    # Sauvegarder
    db.commit()
    db.refresh(db_user)
    
    return db_user


# DELETE
@app.delete("/users/{user_id}", status_code=204)
def delete_user(user_id: int, db: Session = Depends(get_db)):
    """
    [IDEE] SUPPRIMER UTILISATEUR
    
    1. Récupérer user
    2. delete()
    3. Commit
    """
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(404, "User not found")
    
    db.delete(user)
    db.commit()
    
    return None


"""
RELATIONS ENTRE TABLES
----------------------

[IDEE] TYPES DE RELATIONS:

One-to-Many: Un user -> plusieurs posts
Many-to-One: Plusieurs posts -> un user
Many-to-Many: Plusieurs users <-> plusieurs roles
"""

from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship

# One-to-Many
class User(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True)
    username = Column(String)
    
    # Relation: Un user a plusieurs posts
    posts = relationship("Post", back_populates="author")


class Post(Base):
    __tablename__ = "posts"
    
    id = Column(Integer, primary_key=True)
    title = Column(String)
    content = Column(String)
    author_id = Column(Integer, ForeignKey("users.id"))
    
    # Relation: Un post a un author
    author = relationship("User", back_populates="posts")

"""
[IDEE] UTILISATION:
"""

@app.get("/users/{user_id}/posts")
def get_user_posts(user_id: int, db: Session = Depends(get_db)):
    """
    Récupérer posts d'un user
    """
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(404, "User not found")
    
    # Accéder aux posts via la relation!
    return user.posts  # SQLAlchemy fait la query automatiquement!


"""
EAGER LOADING vs LAZY LOADING
-----------------------------

[IDEE] PROBLÈME N+1:
"""

# [X] MAUVAIS (N+1 queries)
@app.get("/users-with-posts")
def get_users_with_posts(db: Session = Depends(get_db)):
    users = db.query(User).all()  # 1 query
    
    result = []
    for user in users:
        result.append({
            "user": user,
            "posts": user.posts  # 1 query PAR USER! (N queries)
        })
    # Total: 1 + N queries (si 100 users = 101 queries!)
    return result


# [OK] BON (eager loading avec joinedload)
from sqlalchemy.orm import joinedload

@app.get("/users-with-posts")
def get_users_with_posts_optimized(db: Session = Depends(get_db)):
    """
    [IDEE] joinedload() = Charger relation dans la même query!
    
    Total: 1 query avec JOIN (peu importe nombre d'users!)
    """
    users = db.query(User).options(joinedload(User.posts)).all()
    
    result = []
    for user in users:
        result.append({
            "user": user,
            "posts": user.posts  # Déjà chargé! Pas de query!
        })
    return result


"""
TRANSACTIONS
-----------

[IDEE] TRANSACTION = Groupe d'opérations atomiques (tout ou rien)
"""

@app.post("/transfer")
def transfer_money(
    from_user_id: int,
    to_user_id: int,
    amount: float,
    db: Session = Depends(get_db)
):
    """
    [IDEE] TRANSACTION: Transfert d'argent
    
    Si erreur à n'importe quel moment:
    -> Tout est annulé (rollback)
    -> DB reste cohérente
    """
    try:
        # Récupérer users
        from_user = db.query(User).filter(User.id == from_user_id).first()
        to_user = db.query(User).filter(User.id == to_user_id).first()
        
        if not from_user or not to_user:
            raise HTTPException(404, "User not found")
        
        # Vérifier solde
        if from_user.balance < amount:
            raise HTTPException(400, "Insufficient funds")
        
        # Opérations (pas encore sauvegardées!)
        from_user.balance -= amount
        to_user.balance += amount
        
        # COMMIT = Sauvegarder TOUT en une fois
        db.commit()
        
        return {"message": "Transfer successful"}
    
    except Exception as e:
        # ROLLBACK = Annuler TOUT
        db.rollback()
        raise e


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 13
------------------------------

Vous avez appris:
[OK] Configuration SQLAlchemy
[OK] Création de modèles
[OK] Dépendance get_db()
[OK] Schémas Pydantic séparés
[OK] Opérations CRUD complètes
[OK] Relations (One-to-Many, etc.)
[OK] Eager loading vs lazy loading
[OK] Transactions

Points clés:
[CLE] ORM = Manipuler DB avec objets Python
[CLE] Modèles SQLAlchemy ≠ Schémas Pydantic
[CLE] orm_mode = True pour conversion auto
[CLE] yield dans get_db() pour cleanup
[CLE] joinedload() pour éviter N+1
[CLE] commit() pour sauvegarder, rollback() pour annuler


[OBJECTIF] BONNES PRATIQUES:

1. SESSIONS:
   [OK] Une session par requête
   [OK] Fermer avec yield
   [OK] Commit explicite

2. QUERIES:
   [OK] Eager loading pour relations
   [OK] Index sur colonnes recherchées
   [OK] Pagination obligatoire

3. TRANSACTIONS:
   [OK] try/except avec rollback
   [OK] Commit seulement si succès complet

4. ORGANISATION:
   [OK] models/ pour SQLAlchemy
   [OK] schemas/ pour Pydantic
   [OK] crud/ pour opérations DB
   [OK] api/ pour endpoints


Cette structure sera détaillée dans la Partie 4!
"""


"""
[BRAVO] FIN DE LA PARTIE 3!

Vous avez appris:
[OK] Async/Await (programmation asynchrone)
[OK] Background Tasks (tâches après réponse)
[OK] Database (SQLAlchemy ORM complet)

Les chapitres Middleware et WebSockets seront dans la suite de cette partie
ou dans un fichier complémentaire si nécessaire.


# ============================================================================
# [LIVRE] FASTAPI - GUIDE ULTRA-DÉTAILLÉ PARTIE 3 (SUITE)
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COMPLÈTE LA PARTIE 3 AVEC:
# - Middleware (Intergiciels)
# - WebSockets (Communication temps réel)
# - CORS (Cross-Origin Resource Sharing)
# - Rate Limiting avancé
# - Request/Response Lifecycle
#
# [TEMPS] TEMPS DE LECTURE: ~3 heures
# [DOCS] PRÉREQUIS: Avoir complété la Partie 3 (chapitres 11-13)
# ============================================================================


# ============================================================================
# [GUIDE] CHAPITRE 13.5: MIDDLEWARE (Intergiciels)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Maîtriser les middlewares pour intercepter et modifier requêtes/réponses.


[REFLEXION] QU'EST-CE QU'UN MIDDLEWARE?
-----------------------------

[IDEE] MIDDLEWARE = Code qui s'exécute AVANT et/ou APRÈS chaque requête

ANALOGIE DU RESTAURANT:
Sans middleware:
Client -> Serveur -> Chef -> Plat -> Serveur -> Client

Avec middleware:
Client -> [Middleware: Vérifier réservation] -> Serveur -> Chef -> Plat -> [Middleware: Ajouter addition] -> Client


FLUX D'EXÉCUTION:
"""
Requête entrante
    v
[Middleware 1 - AVANT]
    v
[Middleware 2 - AVANT]
    v
[Dépendances]
    v
[Endpoint]
    v
[Middleware 2 - APRÈS]
    v
[Middleware 1 - APRÈS]
    v
Réponse sortante

"""
[IDEE] ORDRE:
- Middlewares s'exécutent dans l'ordre d'ajout (AVANT)
- Puis ordre inverse (APRÈS)


CRÉER UN MIDDLEWARE
------------------
"""

from fastapi import Request
import time

@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    """
    [IDEE] PREMIER MIDDLEWARE: Mesurer temps de traitement
    
    STRUCTURE:
    1. Code AVANT call_next() = exécuté avant l'endpoint
    2. response = await call_next(request) = appeler l'endpoint
    3. Code APRÈS = exécuté après l'endpoint
    4. return response
    
    
    PARAMÈTRES:
    - request: Objet Request
    - call_next: Fonction pour appeler le prochain middleware/endpoint
    
    RETOUR:
    - Response modifiée ou non
    """
    # ========== AVANT L'ENDPOINT ==========
    start_time = time.time()
    
    # Appeler l'endpoint (et les middlewares suivants)
    response = await call_next(request)
    
    # ========== APRÈS L'ENDPOINT ==========
    process_time = time.time() - start_time
    
    # Ajouter header à la réponse
    response.headers["X-Process-Time"] = str(process_time)
    
    return response

"""
[IDEE] UTILISATION:
Chaque réponse aura maintenant un header X-Process-Time!

GET /users -> Response avec header:
X-Process-Time: 0.023


MIDDLEWARE DE LOGGING
---------------------

Logger toutes les requêtes:
"""

import uuid
from starlette.middleware.base import BaseHTTPMiddleware

class RequestLoggingMiddleware(BaseHTTPMiddleware):
    """
    [IDEE] MIDDLEWARE COMME CLASSE
    
    Alternative à @app.middleware("http")
    Plus orienté objet, peut avoir des attributs
    """
    async def dispatch(self, request: Request, call_next):
        """
        dispatch() = équivalent de la fonction middleware
        """
        # Générer ID unique pour cette requête
        request_id = str(uuid.uuid4())
        request.state.request_id = request_id
        
        # Logger requête entrante
        logger.info(
            f"[{request_id}] {request.method} {request.url.path}",
            extra={
                "request_id": request_id,
                "method": request.method,
                "url": str(request.url),
                "client": request.client.host if request.client else None,
                "user_agent": request.headers.get("user-agent")
            }
        )
        
        # Traiter requête
        start_time = time.time()
        response = await call_next(request)
        duration = time.time() - start_time
        
        # Logger réponse
        logger.info(
            f"[{request_id}] Response {response.status_code} in {duration:.3f}s"
        )
        
        # Ajouter request_id au header
        response.headers["X-Request-ID"] = request_id
        
        return response

# Ajouter à l'app
app.add_middleware(RequestLoggingMiddleware)


"""
MIDDLEWARE DE SÉCURITÉ
---------------------

Ajouter headers de sécurité:
"""

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    """
    [IDEE] AJOUTER HEADERS DE SÉCURITÉ
    
    Protection contre:
    - Clickjacking (X-Frame-Options)
    - XSS (X-Content-Type-Options)
    - MIME sniffing
    """
    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)
        
        # Headers de sécurité
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-XSS-Protection"] = "1; mode=block"
        response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
        response.headers["Content-Security-Policy"] = "default-src 'self'"
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
        
        return response

app.add_middleware(SecurityHeadersMiddleware)


"""
MIDDLEWARE D'AUTHENTIFICATION
-----------------------------

Vérifier token pour certaines routes:
"""

class AuthenticationMiddleware(BaseHTTPMiddleware):
    """
    [IDEE] AUTHENTIFICATION AU NIVEAU MIDDLEWARE
    
    Alternative aux dépendances pour routes globales
    """
    def __init__(self, app, exclude_paths: list[str] = None):
        super().__init__(app)
        self.exclude_paths = exclude_paths or [
            "/docs",
            "/redoc",
            "/openapi.json",
            "/health",
            "/login",
            "/register"
        ]
    
    async def dispatch(self, request: Request, call_next):
        # Paths publics (pas d'auth)
        if request.url.path in self.exclude_paths:
            return await call_next(request)
        
        # Vérifier token
        auth_header = request.headers.get("authorization")
        
        if not auth_header:
            return JSONResponse(
                status_code=401,
                content={"detail": "Missing authentication token"}
            )
        
        try:
            # Extraire token
            scheme, token = auth_header.split()
            
            if scheme.lower() != "bearer":
                raise ValueError("Invalid authentication scheme")
            
            # Vérifier token (votre logique JWT ici)
            user = verify_token(token)
            
            # Attacher user à la requête
            request.state.user = user
            
        except Exception as e:
            return JSONResponse(
                status_code=401,
                content={"detail": "Invalid authentication token"}
            )
        
        # Continuer
        return await call_next(request)

app.add_middleware(
    AuthenticationMiddleware,
    exclude_paths=["/docs", "/health", "/login"]
)


"""
MIDDLEWARE DE COMPRESSION
-------------------------

Compresser les réponses:
"""

from starlette.middleware.gzip import GZipMiddleware

app.add_middleware(
    GZipMiddleware,
    minimum_size=1000,  # Compresser seulement si > 1KB
    compresslevel=6      # Niveau de compression (1-9)
)

"""
[IDEE] AVANTAGES:
- Réduit taille des réponses
- Économise bande passante
- Améliore temps de chargement

Exemple:
Response: 100KB JSON
Avec GZIP: ~15KB (85% de réduction!)


MIDDLEWARE DE RATE LIMITING
---------------------------

Limiter nombre de requêtes par IP:
"""

from collections import defaultdict
from datetime import datetime, timedelta
from fastapi.responses import JSONResponse

class RateLimitMiddleware(BaseHTTPMiddleware):
    """
    [IDEE] RATE LIMITING: Limiter requêtes par IP
    
    Protection contre:
    - Brute force
    - DDoS
    - Abus d'API
    """
    def __init__(
        self,
        app,
        requests_per_minute: int = 60,
        exclude_paths: list[str] = None
    ):
        super().__init__(app)
        self.requests_per_minute = requests_per_minute
        self.exclude_paths = exclude_paths or []
        # Structure: {ip: [timestamp1, timestamp2, ...]}
        self.request_counts = defaultdict(list)
    
    async def dispatch(self, request: Request, call_next):
        # Paths exclus
        if request.url.path in self.exclude_paths:
            return await call_next(request)
        
        # Identifier client (IP)
        client_ip = request.client.host if request.client else "unknown"
        
        # Temps actuel
        now = datetime.now()
        cutoff = now - timedelta(minutes=1)
        
        # Nettoyer anciennes requêtes
        self.request_counts[client_ip] = [
            req_time for req_time in self.request_counts[client_ip]
            if req_time > cutoff
        ]
        
        # Vérifier limite
        if len(self.request_counts[client_ip]) >= self.requests_per_minute:
            return JSONResponse(
                status_code=429,
                content={
                    "error": "Rate limit exceeded",
                    "retry_after": 60
                },
                headers={
                    "Retry-After": "60",
                    "X-RateLimit-Limit": str(self.requests_per_minute),
                    "X-RateLimit-Remaining": "0"
                }
            )
        
        # Enregistrer cette requête
        self.request_counts[client_ip].append(now)
        
        # Continuer
        response = await call_next(request)
        
        # Ajouter headers de rate limit
        remaining = self.requests_per_minute - len(self.request_counts[client_ip])
        response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute)
        response.headers["X-RateLimit-Remaining"] = str(remaining)
        
        return response

app.add_middleware(
    RateLimitMiddleware,
    requests_per_minute=60,
    exclude_paths=["/health", "/metrics"]
)


"""
MIDDLEWARE DE CACHE
------------------

Cacher réponses GET:
"""

import hashlib

class CacheMiddleware(BaseHTTPMiddleware):
    """
    [IDEE] CACHE SIMPLE EN MÉMOIRE
    
    Cache les réponses GET pour réduire charge DB
    """
    def __init__(self, app, ttl: int = 300):
        super().__init__(app)
        self.cache = {}  # {cache_key: (response, expiry)}
        self.ttl = ttl
    
    def _get_cache_key(self, request: Request) -> str:
        """Générer clé de cache depuis requête"""
        # Inclure path + query params
        key_parts = [request.method, request.url.path, str(request.url.query)]
        
        # Inclure user si authentifié
        if hasattr(request.state, "user"):
            key_parts.append(str(request.state.user.id))
        
        # Hash pour clé courte
        key_string = "|".join(key_parts)
        return hashlib.md5(key_string.encode()).hexdigest()
    
    async def dispatch(self, request: Request, call_next):
        # Cache seulement GET
        if request.method != "GET":
            return await call_next(request)
        
        # Générer clé
        cache_key = self._get_cache_key(request)
        
        # Vérifier cache
        if cache_key in self.cache:
            cached_response, expiry = self.cache[cache_key]
            
            # Vérifier expiration
            if datetime.now() < expiry:
                logger.info(f"Cache HIT: {cache_key}")
                return cached_response
            else:
                # Expiré, supprimer
                del self.cache[cache_key]
        
        # Cache miss
        logger.info(f"Cache MISS: {cache_key}")
        response = await call_next(request)
        
        # Mettre en cache (seulement 200 OK)
        if response.status_code == 200:
            expiry = datetime.now() + timedelta(seconds=self.ttl)
            self.cache[cache_key] = (response, expiry)
        
        return response

app.add_middleware(CacheMiddleware, ttl=300)  # 5 minutes


"""
MIDDLEWARE D'EXCEPTION HANDLING
------------------------------

Gérer erreurs globalement:
"""

class ErrorHandlingMiddleware(BaseHTTPMiddleware):
    """
    [IDEE] CATCH-ALL POUR ERREURS
    
    Évite de crasher l'API en cas d'erreur inattendue
    """
    async def dispatch(self, request: Request, call_next):
        try:
            response = await call_next(request)
            return response
        
        except HTTPException as e:
            # HTTPException déjà gérée, passer
            raise
        
        except Exception as e:
            # Erreur inattendue!
            logger.error(
                f"Unhandled exception: {e}",
                exc_info=True,
                extra={"path": request.url.path}
            )
            
            # Notifier (Sentry, email, etc.)
            notify_admin_of_error(e)
            
            # Réponse générique (ne pas exposer détails)
            return JSONResponse(
                status_code=500,
                content={
                    "error": "Internal server error",
                    "message": "An unexpected error occurred"
                }
            )

app.add_middleware(ErrorHandlingMiddleware)


"""
CORS MIDDLEWARE
--------------

[IDEE] CORS = Cross-Origin Resource Sharing

Permettre à d'autres domaines d'appeler votre API:
"""

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:3000",      # React dev
        "https://myapp.com",           # Production
        "https://www.myapp.com"
    ],
    allow_credentials=True,            # Cookies, Authorization headers
    allow_methods=["*"],               # GET, POST, PUT, DELETE, etc.
    allow_headers=["*"],               # Tous headers
    expose_headers=["X-Request-ID"],   # Headers accessibles par client
    max_age=600,                       # Cache preflight 10 min
)

"""
[IDEE] OPTIONS:

allow_origins:
- Liste de domaines autorisés
- ["*"] = TOUS ([ATTENTION] dangereux en prod!)

allow_credentials:
- True = Autoriser cookies et Authorization
- Nécessite origins spécifiques (pas "*")

allow_methods:
- ["*"] = Toutes méthodes
- ["GET", "POST"] = Seulement GET et POST

allow_headers:
- Headers autorisés dans requête
- ["*"] = Tous headers

expose_headers:
- Headers que le client peut lire
- Par défaut: seulement headers simples

max_age:
- Durée de cache de preflight (secondes)
- Évite requêtes OPTIONS répétées


[IDEE] PREFLIGHT REQUEST:

Avant requête POST/PUT/DELETE, browser envoie OPTIONS:
"""
OPTIONS /api/users
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

# Server répond:
200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 600

# Puis browser envoie vraie requête:
POST /api/users


"""
MIDDLEWARE TRUSTED HOSTS
------------------------

Protéger contre Host Header attacks:
"""

from fastapi.middleware.trustedhosts import TrustedHostMiddleware

app.add_middleware(
    TrustedHostMiddleware,
    allowed_hosts=[
        "api.example.com",
        "*.example.com",    # Wildcard
        "localhost",
        "127.0.0.1"
    ]
)

"""
[IDEE] SÉCURITÉ:
Si Host header ne correspond pas -> 400 Bad Request
Empêche cache poisoning et phishing


ORDRE DES MIDDLEWARES
--------------------

[ATTENTION] IMPORTANT: L'ordre compte!

Ordre recommandé:
"""

from fastapi import FastAPI

app = FastAPI()

# 1. Error handling (catch tout)
app.add_middleware(ErrorHandlingMiddleware)

# 2. Trusted hosts (sécurité)
app.add_middleware(
    TrustedHostMiddleware,
    allowed_hosts=["api.example.com"]
)

# 3. CORS (doit être tôt)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://myapp.com"]
)

# 4. Security headers
app.add_middleware(SecurityHeadersMiddleware)

# 5. Rate limiting
app.add_middleware(RateLimitMiddleware, requests_per_minute=60)

# 6. Authentication (après rate limit)
app.add_middleware(AuthenticationMiddleware)

# 7. Logging (pour tout logger)
app.add_middleware(RequestLoggingMiddleware)

# 8. Compression (dernier, compresse réponse finale)
app.add_middleware(GZipMiddleware, minimum_size=1000)

"""
[IDEE] POURQUOI CET ORDRE?

1. Error handling PREMIER = catch toutes erreurs
2. Security TÔT = bloquer avant traitement
3. Rate limit AVANT auth = éviter spam de login
4. Logging TARD = logger le max d'infos
5. Compression DERNIER = compresse réponse finale


[COURS] EXERCICE PRATIQUE: Middleware de métriques
--------------------------------------------
"""

from collections import defaultdict

class MetricsMiddleware(BaseHTTPMiddleware):
    """
    Collecter métriques personnalisées
    """
    def __init__(self, app):
        super().__init__(app)
        # Métriques en mémoire
        self.request_count = defaultdict(int)
        self.response_times = defaultdict(list)
        self.status_codes = defaultdict(int)
    
    async def dispatch(self, request: Request, call_next):
        # Compter requête
        endpoint = f"{request.method} {request.url.path}"
        self.request_count[endpoint] += 1
        
        # Mesurer temps
        start = time.time()
        response = await call_next(request)
        duration = time.time() - start
        
        # Enregistrer temps
        self.response_times[endpoint].append(duration)
        
        # Compter status code
        self.status_codes[response.status_code] += 1
        
        return response

# Ajouter à app
metrics = MetricsMiddleware(app)
app.add_middleware(lambda: metrics)

# Endpoint pour consulter métriques
@app.get("/admin/metrics")
def get_metrics():
    """Consulter métriques collectées"""
    return {
        "request_count": dict(metrics.request_count),
        "avg_response_times": {
            endpoint: sum(times) / len(times)
            for endpoint, times in metrics.response_times.items()
        },
        "status_codes": dict(metrics.status_codes)
    }


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 13.5
--------------------------------

Vous avez appris:
[OK] Concept de middleware
[OK] @app.middleware("http")
[OK] BaseHTTPMiddleware
[OK] Middlewares de logging
[OK] Middlewares de sécurité
[OK] Rate limiting
[OK] Cache middleware
[OK] CORS
[OK] Ordre des middlewares

Points clés:
[CLE] Middleware = code avant/après chaque requête
[CLE] call_next(request) = appeler prochain middleware
[CLE] Ordre important (error handling -> security -> ... -> compression)
[CLE] CORS doit être configuré pour frontend
[CLE] Rate limiting protège contre abus


[OBJECTIF] QUAND UTILISER MIDDLEWARE VS DÉPENDANCES?

MIDDLEWARE:
[OK] Logique globale (toutes routes)
[OK] Logging, métriques
[OK] Security headers
[OK] CORS, rate limiting
[OK] Pas de validation de données

DÉPENDANCES:
[OK] Logique spécifique (certaines routes)
[OK] Authentification par route
[OK] Injection de DB session
[OK] Validation de données
[OK] Retourner valeurs à l'endpoint
"""


# ============================================================================
# [GUIDE] CHAPITRE 13.6: WEBSOCKETS (Communication temps réel)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Implémenter communication bidirectionnelle temps réel avec WebSockets.


[REFLEXION] QU'EST-CE QUE WEBSOCKET?
---------------------------

[IDEE] DIFFÉRENCE HTTP VS WEBSOCKET:

HTTP (request-response):
Client -> Request -> Server
Client <- Response <- Server
[Connexion fermée]

Pour nouvelle donnée:
Client -> Nouvelle request -> Server
Client <- Response <- Server


WebSocket (bidirectionnel persistant):
Client <-> Connexion ouverte <-> Server

Client peut envoyer quand il veut
Server peut envoyer quand il veut
Connexion reste ouverte!


[IDEE] CAS D'USAGE:
- Chat en temps réel
- Notifications push
- Jeux multijoueurs
- Tableaux de bord live
- Trading/crypto (prix en temps réel)
- Collaboration (Google Docs style)


WEBSOCKET BASIQUE
----------------
"""

from fastapi import WebSocket

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    """
    [IDEE] WEBSOCKET SIMPLE
    
    FLUX:
    1. Client demande connexion WebSocket
    2. Server accepte (accept)
    3. Boucle: recevoir et envoyer messages
    4. Connexion fermée quand client se déconnecte
    """
    # 1. Accepter la connexion
    await websocket.accept()
    
    try:
        # 2. Boucle de communication
        while True:
            # Recevoir message du client
            data = await websocket.receive_text()
            
            # Traiter
            response = f"Echo: {data}"
            
            # Renvoyer au client
            await websocket.send_text(response)
    
    except WebSocketDisconnect:
        # Client s'est déconnecté
        print("Client disconnected")


"""
[IDEE] CLIENT JAVASCRIPT:
"""
const ws = new WebSocket('ws://localhost:8000/ws');

ws.onopen = () => {
    console.log('Connected');
    ws.send('Hello Server!');
};

ws.onmessage = (event) => {
    console.log('Received:', event.data);
};

ws.onerror = (error) => {
    console.error('Error:', error);
};

ws.onclose = () => {
    console.log('Disconnected');
};

"""
CHAT ROOM SIMPLE
---------------

Un chat où tous les connectés reçoivent les messages:
"""

from typing import List
from fastapi import WebSocket, WebSocketDisconnect

class ConnectionManager:
    """
    [IDEE] GESTIONNAIRE DE CONNEXIONS
    
    Garde trace de tous les clients connectés
    """
    def __init__(self):
        # Liste des connexions actives
        self.active_connections: List[WebSocket] = []
    
    async def connect(self, websocket: WebSocket):
        """Accepter nouvelle connexion"""
        await websocket.accept()
        self.active_connections.append(websocket)
    
    def disconnect(self, websocket: WebSocket):
        """Retirer connexion"""
        self.active_connections.remove(websocket)
    
    async def send_personal_message(self, message: str, websocket: WebSocket):
        """Envoyer message à un client spécifique"""
        await websocket.send_text(message)
    
    async def broadcast(self, message: str):
        """
        [IDEE] BROADCAST: Envoyer à TOUS les clients
        
        Parcourt toutes connexions et envoie le message
        """
        for connection in self.active_connections:
            await connection.send_text(message)

# Créer instance globale
manager = ConnectionManager()

@app.websocket("/ws/chat/{username}")
async def chat_endpoint(websocket: WebSocket, username: str):
    """
    [IDEE] CHAT ROOM
    
    Tous les messages sont broadcastés à tous les connectés
    """
    # Connecter
    await manager.connect(websocket)
    
    # Annoncer arrivée
    await manager.broadcast(f"{username} joined the chat")
    
    try:
        while True:
            # Recevoir message
            message = await websocket.receive_text()
            
            # Broadcaster à tous
            await manager.broadcast(f"{username}: {message}")
    
    except WebSocketDisconnect:
        # Déconnecter
        manager.disconnect(websocket)
        
        # Annoncer départ
        await manager.broadcast(f"{username} left the chat")


"""
[IDEE] CLIENT HTML COMPLET:
"""
<!DOCTYPE html>
<html>
<head>
    <title>Chat Room</title>
</head>
<body>
    <h1>Chat Room</h1>
    
    <div id="messages" style="height: 300px; overflow-y: scroll; border: 1px solid #ccc; padding: 10px;">
    </div>
    
    <input type="text" id="messageInput" placeholder="Type a message..." style="width: 80%;">
    <button onclick="sendMessage()">Send</button>
    
    <script>
        const username = prompt("Enter your username:");
        const ws = new WebSocket(`ws://localhost:8000/ws/chat/${username}`);
        
        ws.onmessage = (event) => {
            const messages = document.getElementById('messages');
            const message = document.createElement('div');
            message.textContent = event.data;
            messages.appendChild(message);
            messages.scrollTop = messages.scrollHeight;
        };
        
        function sendMessage() {
            const input = document.getElementById('messageInput');
            ws.send(input.value);
            input.value = '';
        }
        
        document.getElementById('messageInput').addEventListener('keypress', (e) => {
            if (e.key === 'Enter') sendMessage();
        });
    </script>
</body>
</html>


"""
WEBSOCKET AVEC AUTHENTIFICATION
-------------------------------
"""

from jose import JWTError, jwt

@app.websocket("/ws/secure")
async def secure_websocket(websocket: WebSocket, token: str):
    """
    [IDEE] WEBSOCKET AVEC AUTH
    
    URL: ws://localhost:8000/ws/secure?token=<jwt>
    """
    try:
        # Vérifier token
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload.get("sub")
        
        if not username:
            await websocket.close(code=1008)  # Policy violation
            return
        
    except JWTError:
        await websocket.close(code=1008)
        return
    
    # Token valide, accepter connexion
    await websocket.accept()
    
    try:
        while True:
            data = await websocket.receive_text()
            await websocket.send_text(f"Hello {username}, you said: {data}")
    
    except WebSocketDisconnect:
        print(f"{username} disconnected")


"""
TYPES DE MESSAGES
----------------

WebSocket peut envoyer différents types:
"""

@app.websocket("/ws/multi-type")
async def multi_type_websocket(websocket: WebSocket):
    await websocket.accept()
    
    try:
        while True:
            # Recevoir TEXTE
            text = await websocket.receive_text()
            await websocket.send_text(f"Text: {text}")
            
            # Recevoir BYTES
            # bytes_data = await websocket.receive_bytes()
            # await websocket.send_bytes(bytes_data)
            
            # Recevoir JSON
            # json_data = await websocket.receive_json()
            # await websocket.send_json({"response": "ok"})
    
    except WebSocketDisconnect:
        pass


"""
HEARTBEAT / KEEP-ALIVE
---------------------

Garder connexion vivante:
"""

import asyncio

@app.websocket("/ws/heartbeat")
async def heartbeat_websocket(websocket: WebSocket):
    await websocket.accept()
    
    async def send_heartbeat():
        """
        [IDEE] HEARTBEAT: Ping périodique
        
        Évite que connexion soit fermée par timeout
        """
        while True:
            try:
                await websocket.send_json({"type": "heartbeat"})
                await asyncio.sleep(30)  # Toutes les 30 secondes
            except:
                break
    
    # Lancer heartbeat en arrière-plan
    heartbeat_task = asyncio.create_task(send_heartbeat())
    
    try:
        while True:
            data = await websocket.receive_text()
            await websocket.send_text(f"Received: {data}")
    
    except WebSocketDisconnect:
        heartbeat_task.cancel()


"""
ROOMS / CHANNELS
---------------

Grouper clients par "room":
"""

from typing import Dict, Set

class RoomManager:
    """
    [IDEE] GESTIONNAIRE DE ROOMS
    
    Permet de broadcaster seulement à certains clients
    """
    def __init__(self):
        # {room_id: {websocket1, websocket2, ...}}
        self.rooms: Dict[str, Set[WebSocket]] = {}
    
    async def connect(self, websocket: WebSocket, room_id: str):
        """Connecter client à une room"""
        await websocket.accept()
        
        if room_id not in self.rooms:
            self.rooms[room_id] = set()
        
        self.rooms[room_id].add(websocket)
    
    def disconnect(self, websocket: WebSocket, room_id: str):
        """Déconnecter client d'une room"""
        if room_id in self.rooms:
            self.rooms[room_id].discard(websocket)
            
            # Supprimer room si vide
            if not self.rooms[room_id]:
                del self.rooms[room_id]
    
    async def broadcast_to_room(self, room_id: str, message: str):
        """
        [IDEE] BROADCAST À UNE ROOM
        
        Seulement les clients de cette room reçoivent
        """
        if room_id in self.rooms:
            # Envoyer à tous dans la room
            for connection in self.rooms[room_id]:
                await connection.send_text(message)

room_manager = RoomManager()

@app.websocket("/ws/room/{room_id}/{username}")
async def room_websocket(websocket: WebSocket, room_id: str, username: str):
    """
    [IDEE] CHAT PAR ROOM
    
    Chaque room = chat séparé
    """
    # Rejoindre room
    await room_manager.connect(websocket, room_id)
    await room_manager.broadcast_to_room(
        room_id,
        f"{username} joined room {room_id}"
    )
    
    try:
        while True:
            message = await websocket.receive_text()
            
            # Broadcaster dans la room
            await room_manager.broadcast_to_room(
                room_id,
                f"[{room_id}] {username}: {message}"
            )
    
    except WebSocketDisconnect:
        room_manager.disconnect(websocket, room_id)
        await room_manager.broadcast_to_room(
            room_id,
            f"{username} left room {room_id}"
        )


"""
NOTIFICATIONS EN TEMPS RÉEL
---------------------------

Envoyer notifications depuis endpoints HTTP:
"""

# Manager global
notification_manager = ConnectionManager()

@app.websocket("/ws/notifications/{user_id}")
async def notifications_websocket(websocket: WebSocket, user_id: int):
    """
    WebSocket pour recevoir notifications
    """
    await notification_manager.connect(websocket)
    
    # Associer user_id à websocket
    websocket.user_id = user_id
    
    try:
        # Juste garder connexion ouverte
        while True:
            # Recevoir (keep-alive)
            await websocket.receive_text()
    
    except WebSocketDisconnect:
        notification_manager.disconnect(websocket)

@app.post("/notifications/{user_id}")
async def send_notification(user_id: int, message: str):
    """
    [IDEE] ENDPOINT HTTP qui envoie notification via WebSocket!
    
    POST /notifications/123
    {"message": "New message!"}
    
    -> Tous les WebSockets connectés de user 123 reçoivent
    """
    # Trouver connexions de cet user
    for connection in notification_manager.active_connections:
        if hasattr(connection, 'user_id') and connection.user_id == user_id:
            await connection.send_json({
                "type": "notification",
                "message": message
            })
    
    return {"status": "sent"}


"""
DASHBOARD TEMPS RÉEL
-------------------

Métriques en temps réel:
"""

import random

@app.websocket("/ws/dashboard")
async def dashboard_websocket(websocket: WebSocket):
    """
    [IDEE] DASHBOARD: Envoyer métriques toutes les secondes
    """
    await websocket.accept()
    
    try:
        while True:
            # Générer métriques (en vrai: depuis DB/Redis)
            metrics = {
                "cpu": random.randint(0, 100),
                "memory": random.randint(0, 100),
                "requests_per_second": random.randint(0, 1000),
                "active_users": random.randint(0, 500)
            }
            
            # Envoyer
            await websocket.send_json(metrics)
            
            # Attendre 1 seconde
            await asyncio.sleep(1)
    
    except WebSocketDisconnect:
        pass

"""
CLIENT HTML:
"""
<div id="dashboard"></div>

<script>
const ws = new WebSocket('ws://localhost:8000/ws/dashboard');

ws.onmessage = (event) => {
    const metrics = JSON.parse(event.data);
    document.getElementById('dashboard').innerHTML = `
        <p>CPU: ${metrics.cpu}%</p>
        <p>Memory: ${metrics.memory}%</p>
        <p>RPS: ${metrics.requests_per_second}</p>
        <p>Users: ${metrics.active_users}</p>
    `;
};
</script>


"""
GESTION D'ERREURS
----------------
"""

@app.websocket("/ws/safe")
async def safe_websocket(websocket: WebSocket):
    await websocket.accept()
    
    try:
        while True:
            try:
                data = await websocket.receive_text()
                
                # Traitement qui peut échouer
                result = risky_operation(data)
                
                await websocket.send_json({
                    "status": "success",
                    "result": result
                })
            
            except ValueError as e:
                # Erreur spécifique
                await websocket.send_json({
                    "status": "error",
                    "message": str(e)
                })
            
            except Exception as e:
                # Erreur inattendue
                logger.error(f"WebSocket error: {e}", exc_info=True)
                await websocket.send_json({
                    "status": "error",
                    "message": "Internal error"
                })
    
    except WebSocketDisconnect:
        logger.info("Client disconnected")
    
    except Exception as e:
        logger.error(f"WebSocket crashed: {e}", exc_info=True)
        try:
            await websocket.close()
        except:
            pass


"""
[COURS] EXERCICE PRATIQUE: Jeu Multiplayer Simple
-------------------------------------------

Jeu où joueurs voient positions en temps réel:
"""

class Game:
    """Gestionnaire de jeu"""
    def __init__(self):
        self.players: Dict[str, dict] = {}  # {player_id: {x, y, ...}}
        self.connections: Dict[str, WebSocket] = {}
    
    async def add_player(self, player_id: str, websocket: WebSocket):
        """Ajouter joueur"""
        self.players[player_id] = {"x": 0, "y": 0, "score": 0}
        self.connections[player_id] = websocket
        
        # Notifier tous les joueurs
        await self.broadcast_game_state()
    
    async def remove_player(self, player_id: str):
        """Retirer joueur"""
        if player_id in self.players:
            del self.players[player_id]
            del self.connections[player_id]
            await self.broadcast_game_state()
    
    async def move_player(self, player_id: str, x: int, y: int):
        """Déplacer joueur"""
        if player_id in self.players:
            self.players[player_id]["x"] = x
            self.players[player_id]["y"] = y
            await self.broadcast_game_state()
    
    async def broadcast_game_state(self):
        """Envoyer état du jeu à tous"""
        state = {
            "type": "game_state",
            "players": self.players
        }
        
        # Envoyer à tous les joueurs connectés
        for player_id, ws in self.connections.items():
            try:
                await ws.send_json(state)
            except:
                pass

game = Game()

@app.websocket("/ws/game/{player_id}")
async def game_websocket(websocket: WebSocket, player_id: str):
    """
    WebSocket pour jeu multiplayer
    """
    await websocket.accept()
    await game.add_player(player_id, websocket)
    
    try:
        while True:
            # Recevoir mouvement
            data = await websocket.receive_json()
            
            if data["type"] == "move":
                await game.move_player(
                    player_id,
                    data["x"],
                    data["y"]
                )
    
    except WebSocketDisconnect:
        await game.remove_player(player_id)


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 13.6
--------------------------------

Vous avez appris:
[OK] WebSocket basique
[OK] ConnectionManager pour broadcast
[OK] Chat room
[OK] Authentication WebSocket
[OK] Rooms/channels
[OK] Notifications temps réel
[OK] Dashboard live
[OK] Gestion d'erreurs

Points clés:
[CLE] WebSocket = connexion bidirectionnelle persistante
[CLE] accept() pour accepter connexion
[CLE] receive_text() / send_text() pour communication
[CLE] ConnectionManager pour gérer plusieurs clients
[CLE] broadcast() pour envoyer à tous
[CLE] Rooms pour grouper clients


[OBJECTIF] WEBSOCKET VS HTTP:

UTILISER HTTP POUR:
[OK] Actions ponctuelles
[OK] Requêtes classiques (CRUD)
[OK] Uploads de fichiers
[OK] APIs REST standard

UTILISER WEBSOCKET POUR:
[OK] Données temps réel
[OK] Bidirectionnel
[OK] Push du serveur
[OK] Haute fréquence
[OK] Chat, notifications, jeux


[ATTENTION] LIMITATIONS:
- Scaling complexe (sticky sessions)
- Pas de cache HTTP
- Debugging plus dur
- Pas REST (pas de verbes HTTP)


[IDEE] ALTERNATIVES:
- Server-Sent Events (SSE): Unidirectionnel (server->client)
- Long Polling: Compatible anciens browsers
- WebRTC: Peer-to-peer

Pour la plupart des cas: WebSocket est le meilleur choix!
"""


"""
[BRAVO] FIN DE LA PARTIE 3 (COMPLÈTE)!

Vous maîtrisez maintenant:
[OK] Async/Await (Chapitre 11)
[OK] Background Tasks (Chapitre 12)
[OK] Database SQLAlchemy (Chapitre 13)
[OK] Middleware (Chapitre 13.5)
[OK] WebSockets (Chapitre 13.6)

Vous êtes prêt pour:
-> La Partie 4: Testing, Deployment, Monitoring, Best Practices

Continuez votre apprentissage! [RAPIDE]
"""


PARTIE 4 couvrira:
- Testing (tests complets)
- Deployment (déploiement production)
- Monitoring & Logging
- Best Practices avancées
- Patterns d'architecture

Excellente progression ! [RAPIDE]
"""

# ============================================================================
# [LIVRE] FASTAPI - GUIDE ULTRA-DÉTAILLÉ PARTIE 4 (FINALE)
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COUVRE:
# - Testing (Tests complets avec pytest)
# - Deployment (Déploiement en production)
# - Monitoring & Logging (Surveillance et logs)
# - Best Practices & Patterns (Bonnes pratiques avancées)
# - Architecture (Organisation de projets complexes)
#
# [TEMPS] TEMPS DE LECTURE: ~4-5 heures
# [DOCS] PRÉREQUIS: Avoir complété les Parties 1, 2 et 3
# ============================================================================


# ============================================================================
# [GUIDE] CHAPITRE 14: TESTING (Tests avec pytest)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Créer une suite de tests complète pour garantir la qualité de votre API.


[REFLEXION] POURQUOI TESTER?
------------------

Sans tests:
[X] Peur de changer le code (peut tout casser)
[X] Bugs découverts en production
[X] Régression (vieux bugs qui reviennent)
[X] Refactoring impossible

Avec tests:
[OK] Confiance pour modifier le code
[OK] Bugs détectés avant production
[OK] Documentation vivante (tests = exemples)
[OK] Refactoring sûr


INSTALLATION
-----------
"""

pip install pytest
pip install pytest-cov  # Pour coverage
pip install httpx  # Pour TestClient async

"""
TESTCLIENT FASTAPI
-----------------

FastAPI fournit TestClient pour tester sans lancer serveur!
"""

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_read_root():
    """
    [IDEE] TEST SIMPLE
    
    TestClient simule un client HTTP.
    Pas besoin de lancer le serveur!
    """
    response = client.get("/")
    
    # Assertions
    assert response.status_code == 200
    assert response.json() == {"message": "Hello World"}

"""
[IDEE] STRUCTURE D'UN TEST:

1. ARRANGE: Préparer les données
2. ACT: Exécuter l'action
3. ASSERT: Vérifier le résultat


ORGANISER LES TESTS
------------------

Structure recommandée:
"""

project/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── models.py
│   └── crud.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py      # Fixtures pytest
│   ├── test_users.py
│   ├── test_auth.py
│   └── test_items.py
└── pytest.ini

"""
CONFIGURATION PYTEST
-------------------

# pytest.ini
"""
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

"""
[IDEE] CONVENTIONS:
- Fichiers: test_*.py ou *_test.py
- Fonctions: test_*
- Classes: Test* (optionnel)


FIXTURES PYTEST
--------------

[IDEE] FIXTURE = Fonction réutilisable pour setup/teardown
"""

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app, get_db
from app.models import Base

# DB de test (en mémoire)
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"

@pytest.fixture
def test_db():
    """
    [IDEE] FIXTURE: Base de données de test
    
    Créée avant chaque test, détruite après.
    """
    engine = create_engine(
        SQLALCHEMY_DATABASE_URL,
        connect_args={"check_same_thread": False}
    )
    TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    
    # Créer tables
    Base.metadata.create_all(bind=engine)
    
    db = TestSessionLocal()
    try:
        yield db
    finally:
        db.close()
        # Supprimer tables
        Base.metadata.drop_all(bind=engine)

@pytest.fixture
def client(test_db):
    """
    [IDEE] FIXTURE: Client de test avec DB de test
    
    Override la dépendance get_db pour utiliser test_db
    """
    def override_get_db():
        try:
            yield test_db
        finally:
            pass
    
    app.dependency_overrides[get_db] = override_get_db
    
    with TestClient(app) as test_client:
        yield test_client
    
    app.dependency_overrides.clear()

"""
[IDEE] UTILISATION:
"""

def test_create_user(client):
    """
    Les fixtures sont injectées automatiquement!
    """
    response = client.post(
        "/users",
        json={
            "username": "testuser",
            "email": "test@example.com",
            "password": "password123"
        }
    )
    
    assert response.status_code == 201
    data = response.json()
    assert data["username"] == "testuser"
    assert "id" in data


"""
TESTER LES ENDPOINTS
-------------------
"""

# tests/test_users.py

def test_get_users_empty(client):
    """Test liste vide"""
    response = client.get("/users")
    assert response.status_code == 200
    assert response.json() == []

def test_create_user(client):
    """Test création utilisateur"""
    response = client.post(
        "/users",
        json={
            "username": "alice",
            "email": "alice@example.com",
            "password": "SecurePass123"
        }
    )
    
    assert response.status_code == 201
    data = response.json()
    assert data["username"] == "alice"
    assert data["email"] == "alice@example.com"
    assert "password" not in data  # Pas de password dans réponse!
    assert "id" in data

def test_create_user_duplicate_username(client):
    """Test username déjà pris"""
    # Créer premier user
    client.post(
        "/users",
        json={
            "username": "bob",
            "email": "bob1@example.com",
            "password": "pass123"
        }
    )
    
    # Essayer de créer avec même username
    response = client.post(
        "/users",
        json={
            "username": "bob",
            "email": "bob2@example.com",
            "password": "pass123"
        }
    )
    
    assert response.status_code == 409
    assert "username" in response.json()["detail"].lower()

def test_get_user(client):
    """Test récupération utilisateur"""
    # Créer user
    create_response = client.post(
        "/users",
        json={
            "username": "charlie",
            "email": "charlie@example.com",
            "password": "pass123"
        }
    )
    user_id = create_response.json()["id"]
    
    # Récupérer user
    response = client.get(f"/users/{user_id}")
    
    assert response.status_code == 200
    data = response.json()
    assert data["id"] == user_id
    assert data["username"] == "charlie"

def test_get_user_not_found(client):
    """Test user inexistant"""
    response = client.get("/users/999")
    assert response.status_code == 404

def test_update_user(client):
    """Test mise à jour"""
    # Créer user
    create_response = client.post(
        "/users",
        json={
            "username": "dave",
            "email": "dave@example.com",
            "password": "pass123"
        }
    )
    user_id = create_response.json()["id"]
    
    # Mettre à jour
    response = client.put(
        f"/users/{user_id}",
        json={"username": "dave_updated"}
    )
    
    assert response.status_code == 200
    assert response.json()["username"] == "dave_updated"

def test_delete_user(client):
    """Test suppression"""
    # Créer user
    create_response = client.post(
        "/users",
        json={
            "username": "eve",
            "email": "eve@example.com",
            "password": "pass123"
        }
    )
    user_id = create_response.json()["id"]
    
    # Supprimer
    response = client.delete(f"/users/{user_id}")
    assert response.status_code == 204
    
    # Vérifier suppression
    get_response = client.get(f"/users/{user_id}")
    assert get_response.status_code == 404


"""
TESTER L'AUTHENTIFICATION
-------------------------
"""

# tests/test_auth.py

@pytest.fixture
def test_user(client):
    """Fixture: Créer un utilisateur de test"""
    response = client.post(
        "/users",
        json={
            "username": "testuser",
            "email": "test@example.com",
            "password": "testpass123"
        }
    )
    return response.json()

def test_login_success(client, test_user):
    """Test login réussi"""
    response = client.post(
        "/token",
        data={  # Form data, pas JSON!
            "username": "testuser",
            "password": "testpass123"
        }
    )
    
    assert response.status_code == 200
    data = response.json()
    assert "access_token" in data
    assert data["token_type"] == "bearer"

def test_login_wrong_password(client, test_user):
    """Test login avec mauvais password"""
    response = client.post(
        "/token",
        data={
            "username": "testuser",
            "password": "wrongpass"
        }
    )
    
    assert response.status_code == 401

def test_protected_endpoint_without_token(client):
    """Test endpoint protégé sans token"""
    response = client.get("/users/me")
    assert response.status_code == 401

def test_protected_endpoint_with_token(client, test_user):
    """Test endpoint protégé avec token"""
    # Obtenir token
    login_response = client.post(
        "/token",
        data={
            "username": "testuser",
            "password": "testpass123"
        }
    )
    token = login_response.json()["access_token"]
    
    # Utiliser token
    response = client.get(
        "/users/me",
        headers={"Authorization": f"Bearer {token}"}
    )
    
    assert response.status_code == 200
    assert response.json()["username"] == "testuser"


"""
PARAMETRIZE: TESTER PLUSIEURS CAS
---------------------------------
"""

@pytest.mark.parametrize(
    "username,email,password,expected_status",
    [
        ("valid", "valid@example.com", "pass123", 201),
        ("a", "valid@example.com", "pass123", 422),  # Username trop court
        ("valid", "invalid-email", "pass123", 422),  # Email invalide
        ("valid", "valid@example.com", "123", 422),  # Password trop court
    ]
)
def test_create_user_validation(client, username, email, password, expected_status):
    """
    [IDEE] PARAMETRIZE: Tester plusieurs cas avec une seule fonction!
    
    Pytest exécute cette fonction 4 fois avec les différentes valeurs.
    """
    response = client.post(
        "/users",
        json={
            "username": username,
            "email": email,
            "password": password
        }
    )
    
    assert response.status_code == expected_status


"""
MOCKING
------

Pour simuler des dépendances externes:
"""

from unittest.mock import patch, MagicMock

def test_send_email(client):
    """
    [IDEE] MOCK: Simuler fonction externe
    
    send_email() n'est pas vraiment appelée,
    on simule juste son comportement.
    """
    with patch('app.email.send_email') as mock_send:
        # Configurer le mock
        mock_send.return_value = {"status": "sent"}
        
        # Faire l'action qui appelle send_email
        response = client.post(
            "/register",
            json={
                "username": "newuser",
                "email": "new@example.com",
                "password": "pass123"
            }
        )
        
        # Vérifier que send_email a été appelé
        assert response.status_code == 201
        mock_send.assert_called_once()


"""
TESTER LES ERREURS
-----------------
"""

def test_database_error(client, test_db):
    """Simuler erreur de base de données"""
    with patch.object(test_db, 'commit', side_effect=Exception("DB Error")):
        response = client.post(
            "/users",
            json={
                "username": "erroruser",
                "email": "error@example.com",
                "password": "pass123"
            }
        )
        
        # L'API doit gérer l'erreur proprement
        assert response.status_code == 500


"""
COVERAGE (COUVERTURE DE CODE)
----------------------------

[IDEE] COVERAGE = Pourcentage de code testé
"""

# Lancer tests avec coverage:
pytest --cov=app tests/

"""
Résultat:
"""
Name                Stmts   Miss  Cover
---------------------------------------
app/__init__.py         0      0   100%
app/main.py            45      2    96%
app/models.py          25      0   100%
app/crud.py            38      3    92%
---------------------------------------
TOTAL                 108      5    95%

"""
[IDEE] OBJECTIF: > 80% coverage

Générer rapport HTML:
"""
pytest --cov=app --cov-report=html tests/

"""
Ouvre htmlcov/index.html dans navigateur pour voir détails!


TESTS ASYNC
----------

Pour endpoints async:
"""

import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_async_endpoint():
    """
    [IDEE] TEST ASYNC
    
    Utiliser AsyncClient et marquer avec @pytest.mark.asyncio
    """
    async with AsyncClient(app=app, base_url="http://test") as client:
        response = await client.get("/async-data/")
        assert response.status_code == 200


"""
TESTS D'INTÉGRATION
------------------

Tester plusieurs endpoints ensemble:
"""

def test_user_workflow(client):
    """
    [IDEE] TEST D'INTÉGRATION: Workflow complet
    
    1. Créer user
    2. Login
    3. Créer post
    4. Récupérer post
    5. Supprimer post
    """
    # 1. Créer user
    user_response = client.post(
        "/users",
        json={
            "username": "workflow_user",
            "email": "workflow@example.com",
            "password": "pass123"
        }
    )
    assert user_response.status_code == 201
    
    # 2. Login
    login_response = client.post(
        "/token",
        data={
            "username": "workflow_user",
            "password": "pass123"
        }
    )
    token = login_response.json()["access_token"]
    headers = {"Authorization": f"Bearer {token}"}
    
    # 3. Créer post
    post_response = client.post(
        "/posts",
        json={
            "title": "Test Post",
            "content": "This is a test"
        },
        headers=headers
    )
    assert post_response.status_code == 201
    post_id = post_response.json()["id"]
    
    # 4. Récupérer post
    get_response = client.get(f"/posts/{post_id}")
    assert get_response.status_code == 200
    assert get_response.json()["title"] == "Test Post"
    
    # 5. Supprimer post
    delete_response = client.delete(f"/posts/{post_id}", headers=headers)
    assert delete_response.status_code == 204


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 14
------------------------------

Vous avez appris:
[OK] TestClient pour tester sans serveur
[OK] Fixtures pytest pour réutilisation
[OK] Tests de CRUD complets
[OK] Tests d'authentification
[OK] Parametrize pour plusieurs cas
[OK] Mocking de dépendances
[OK] Coverage de code
[OK] Tests async
[OK] Tests d'intégration

Points clés:
[CLE] TestClient simule client HTTP
[CLE] Fixtures = setup/teardown réutilisables
[CLE] Override dependencies pour tests
[CLE] Viser > 80% coverage
[CLE] Tester chemins heureux ET erreurs


[OBJECTIF] BONNES PRATIQUES:

1. ORGANISATION:
   [OK] tests/ séparé de app/
   [OK] Fichier par module (test_users.py, etc.)
   [OK] conftest.py pour fixtures partagées

2. NAMING:
   [OK] test_[action]_[scenario]
   [OK] Ex: test_create_user_duplicate_username

3. STRUCTURE:
   [OK] Arrange, Act, Assert
   [OK] Un test = une assertion principale
   [OK] Tests indépendants (ordre n'importe pas)

4. FIXTURES:
   [OK] DB de test (en mémoire)
   [OK] Utilisateur de test
   [OK] Client authentifié

5. COVERAGE:
   [OK] Tester chemins heureux
   [OK] Tester erreurs
   [OK] Tester edge cases

6. CI/CD:
   [OK] Tests automatiques sur chaque commit
   [OK] Bloquer merge si tests échouent
"""


# ============================================================================
# [GUIDE] CHAPITRE 15: DEPLOYMENT (Déploiement)
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Déployer votre API FastAPI en production de façon professionnelle.


[REFLEXION] DÉVELOPPEMENT VS PRODUCTION
------------------------------

DÉVELOPPEMENT:
- uvicorn main:app --reload
- Debug mode activé
- Logs verbeux
- 1 worker
- HTTP

PRODUCTION:
- Gunicorn + Uvicorn workers
- Debug mode désactivé
- Logs structurés
- Plusieurs workers
- HTTPS
- Reverse proxy
- Monitoring


CONFIGURATION PRODUCTION
-----------------------
"""

# config.py
from pydantic import BaseSettings

class Settings(BaseSettings):
    """
    [IDEE] CONFIGURATION AVEC VARIABLES D'ENVIRONNEMENT
    
    .env fichier:
    APP_NAME=MyAPI
    DEBUG=false
    DATABASE_URL=postgresql://...
    SECRET_KEY=super-secret-key
    """
    app_name: str = "FastAPI App"
    debug: bool = False
    database_url: str
    secret_key: str
    allowed_hosts: list[str] = ["*"]
    
    class Config:
        env_file = ".env"

settings = Settings()


"""
AVEC GUNICORN
------------

[IDEE] POURQUOI GUNICORN?

Uvicorn seul:
- 1 process
- Si crash -> toute l'API down

Gunicorn + Uvicorn:
- Plusieurs workers
- Si 1 worker crash -> autres continuent
- Load balancing automatique
"""

# Installation
pip install gunicorn

# Lancer avec 4 workers
gunicorn app.main:app \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind 0.0.0.0:8000 \
    --timeout 120 \
    --access-logfile - \
    --error-logfile -

"""
[IDEE] OPTIONS:

--workers 4:
- Nombre de processes
- Règle: (2 × CPU cores) + 1
- Ex: 2 cores -> 5 workers

--worker-class:
- Type de worker
- UvicornWorker pour FastAPI

--bind 0.0.0.0:8000:
- Interface et port
- 0.0.0.0 = toutes interfaces

--timeout 120:
- Timeout en secondes
- Worker killed si pas de réponse après 120s

--access-logfile -:
- Logs vers stdout (pour Docker)


DOCKER
-----

[IDEE] CONTENEURISER l'application
"""

# Dockerfile
"""
FROM python:3.11-slim

WORKDIR /app

# Installer dépendances système si nécessaire
RUN apt-get update && apt-get install -y \
    gcc \
    postgresql-client \
    && rm -rf /var/lib/apt/lists/*

# Copier requirements
COPY requirements.txt .

# Installer dépendances Python
RUN pip install --no-cache-dir -r requirements.txt

# Copier code application
COPY ./app /app/app

# Créer user non-root
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

# Exposer port
EXPOSE 8000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
    CMD python -c "import requests; requests.get('http://localhost:8000/health')"

# Commande de démarrage
CMD ["gunicorn", "app.main:app", \
     "--workers", "4", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000"]
"""

# .dockerignore
"""
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv
.pytest_cache
.coverage
htmlcov/
.git
.gitignore
README.md
docker-compose*.yml
"""

# Build et run
docker build -t myapi .
docker run -p 8000:8000 myapi


"""
DOCKER COMPOSE
-------------

Pour API + DB + Redis:
"""

# docker-compose.yml
"""
version: '3.8'

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://user:password@db:5432/mydb
      - REDIS_URL=redis://redis:6379/0
      - SECRET_KEY=${SECRET_KEY}
    depends_on:
      - db
      - redis
    restart: unless-stopped
    volumes:
      - ./uploads:/app/uploads
    networks:
      - app-network

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    restart: unless-stopped
    networks:
      - app-network

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    restart: unless-stopped
    networks:
      - app-network

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
    depends_on:
      - api
    restart: unless-stopped
    networks:
      - app-network

volumes:
  postgres_data:
  redis_data:

networks:
  app-network:
    driver: bridge
"""

# Lancer tout:
docker-compose up -d

# Voir logs:
docker-compose logs -f api


"""
NGINX REVERSE PROXY
------------------

[IDEE] POURQUOI NGINX?

- SSL/TLS termination
- Load balancing
- Compression
- Static files
- Rate limiting
"""

# nginx.conf
"""
upstream api {
    server api:8000;
}

server {
    listen 80;
    server_name api.example.com;

    # Redirect to HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    # SSL Configuration
    ssl_certificate /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

    # Security Headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Strict-Transport-Security "max-age=31536000" always;

    # Client body size
    client_max_body_size 10M;

    # Compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript;

    # API
    location /api/ {
        proxy_pass http://api;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }

    # Static files
    location /static/ {
        alias /app/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Health check
    location /health {
        proxy_pass http://api/health;
        access_log off;
    }
}
"""


"""
HTTPS AVEC LET'S ENCRYPT
-----------------------
"""

# Installer certbot
sudo apt install certbot python3-certbot-nginx

# Obtenir certificat
sudo certbot --nginx -d api.example.com

# Renouvellement automatique (cron)
0 0 1 * * certbot renew --quiet


"""
PLATFORMS AS A SERVICE
----------------------

[IDEE] DÉPLOIEMENT SIMPLIFIÉ
"""

# === HEROKU ===

# Procfile
web: gunicorn app.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker

# Deploy
heroku create myapi
git push heroku main


# === RAILWAY ===

# Automatique avec GitHub
# railway.json
"""
{
  "build": {
    "builder": "NIXPACKS"
  },
  "deploy": {
    "startCommand": "gunicorn app.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:$PORT",
    "restartPolicyType": "ON_FAILURE"
  }
}
"""


# === RENDER ===

# render.yaml
"""
services:
  - type: web
    name: myapi
    env: python
    buildCommand: pip install -r requirements.txt
    startCommand: gunicorn app.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker
    envVars:
      - key: DATABASE_URL
        fromDatabase:
          name: mydb
          property: connectionString
      - key: SECRET_KEY
        generateValue: true
      - key: PYTHON_VERSION
        value: 3.11.0

databases:
  - name: mydb
    databaseName: mydb
    user: myuser
"""


"""
KUBERNETES (K8S)
---------------

Pour déploiement à grande échelle:
"""

# deployment.yaml
"""
apiVersion: apps/v1
kind: Deployment
metadata:
  name: fastapi-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: fastapi
  template:
    metadata:
      labels:
        app: fastapi
    spec:
      containers:
      - name: api
        image: myregistry/fastapi:latest
        ports:
        - containerPort: 8000
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: database-url
        - name: SECRET_KEY
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: secret-key
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 8000
          initialDelaySeconds: 5
          periodSeconds: 5

---
apiVersion: v1
kind: Service
metadata:
  name: fastapi-service
spec:
  selector:
    app: fastapi
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8000
  type: LoadBalancer
"""


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 15
------------------------------

Vous avez appris:
[OK] Configuration production
[OK] Gunicorn + Uvicorn workers
[OK] Docker et Docker Compose
[OK] Nginx reverse proxy
[OK] HTTPS avec Let's Encrypt
[OK] PaaS (Heroku, Railway, Render)
[OK] Kubernetes

Points clés:
[CLE] Gunicorn pour plusieurs workers
[CLE] Docker pour conteneurisation
[CLE] Nginx pour reverse proxy + SSL
[CLE] Variables d'environnement pour secrets
[CLE] Health checks obligatoires


[OBJECTIF] CHECKLIST PRODUCTION:

1. CONFIGURATION:
   [OK] Variables d'environnement
   [OK] Secrets sécurisés
   [OK] Debug mode désactivé

2. SERVEUR:
   [OK] Gunicorn + workers
   [OK] Timeouts configurés
   [OK] Graceful shutdown

3. SÉCURITÉ:
   [OK] HTTPS obligatoire
   [OK] Security headers
   [OK] CORS configuré
   [OK] Rate limiting

4. BASE DE DONNÉES:
   [OK] Connection pooling
   [OK] Backups automatiques
   [OK] Migrations gérées

5. MONITORING:
   [OK] Health checks
   [OK] Logs structurés
   [OK] Métriques
   [OK] Alertes

6. PERFORMANCE:
   [OK] Caching
   [OK] Compression
   [OK] CDN pour static

7. SCALING:
   [OK] Horizontal (+ workers)
   [OK] Vertical (+ CPU/RAM)
   [OK] Database read replicas
"""


# ============================================================================
# [GUIDE] CHAPITRE 16: MONITORING & LOGGING
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Mettre en place monitoring et logging professionnels.


[REFLEXION] POURQUOI MONITORING?
----------------------

Sans monitoring:
[X] Pas d'alerte si l'API down
[X] Pas de visibilité sur performance
[X] Difficile de debugger en production
[X] Pas de métriques business

Avec monitoring:
[OK] Alertes automatiques
[OK] Visibilité temps réel
[OK] Debugging facilité
[OK] Métriques business


LOGGING STRUCTURÉ
----------------

[IDEE] LOGS STRUCTURÉS = JSON au lieu de texte
"""

import logging
import json
from datetime import datetime

class JSONFormatter(logging.Formatter):
    """
    [IDEE] FORMATTER JSON
    
    Convertit logs en JSON pour parsing facile
    """
    def format(self, record):
        log_data = {
            "timestamp": datetime.utcnow().isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "module": record.module,
            "function": record.funcName,
            "line": record.lineno
        }
        
        # Ajouter exception si présente
        if record.exc_info:
            log_data["exception"] = self.formatException(record.exc_info)
        
        # Ajouter contexte custom
        if hasattr(record, "user_id"):
            log_data["user_id"] = record.user_id
        if hasattr(record, "request_id"):
            log_data["request_id"] = record.request_id
        
        return json.dumps(log_data)

# Configuration
logging.basicConfig(
    level=logging.INFO,
    handlers=[
        logging.StreamHandler()
    ]
)

logger = logging.getLogger("fastapi_app")
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logger.addHandler(handler)

"""
UTILISATION:
"""

@app.get("/users/{user_id}")
async def get_user(user_id: int, db: Session = Depends(get_db)):
    logger.info(f"Fetching user {user_id}")
    
    try:
        user = db.query(User).filter(User.id == user_id).first()
        if not user:
            logger.warning(f"User {user_id} not found")
            raise HTTPException(404, "User not found")
        
        logger.info(f"User {user_id} fetched successfully")
        return user
    
    except Exception as e:
        logger.error(f"Error fetching user {user_id}", exc_info=True)
        raise


"""
MIDDLEWARE DE LOGGING
--------------------

Log toutes les requêtes:
"""

import time
import uuid

@app.middleware("http")
async def log_requests(request: Request, call_next):
    """
    [IDEE] MIDDLEWARE: Log chaque requête
    
    Informations loggées:
    - Request ID (unique par requête)
    - Method, path
    - Status code
    - Temps de traitement
    - User agent, IP
    """
    # Générer request ID
    request_id = str(uuid.uuid4())
    request.state.request_id = request_id
    
    # Start time
    start_time = time.time()
    
    # Log requête entrante
    logger.info(
        "Request started",
        extra={
            "request_id": request_id,
            "method": request.method,
            "path": request.url.path,
            "client_ip": request.client.host,
            "user_agent": request.headers.get("user-agent")
        }
    )
    
    # Traiter requête
    response = await call_next(request)
    
    # Temps de traitement
    process_time = time.time() - start_time
    
    # Log réponse
    logger.info(
        "Request completed",
        extra={
            "request_id": request_id,
            "status_code": response.status_code,
            "process_time": f"{process_time:.3f}s"
        }
    )
    
    # Ajouter header
    response.headers["X-Request-ID"] = request_id
    response.headers["X-Process-Time"] = f"{process_time:.3f}"
    
    return response


"""
PROMETHEUS METRICS
-----------------

[IDEE] PROMETHEUS = Standard pour métriques
"""

pip install prometheus-client

"""
Configuration:
"""

from prometheus_client import Counter, Histogram, Gauge, make_asgi_app

# Métriques
REQUEST_COUNT = Counter(
    'fastapi_requests_total',
    'Total request count',
    ['method', 'endpoint', 'status']
)

REQUEST_DURATION = Histogram(
    'fastapi_request_duration_seconds',
    'Request duration',
    ['method', 'endpoint']
)

ACTIVE_REQUESTS = Gauge(
    'fastapi_requests_active',
    'Active requests'
)

"""
Middleware pour collecter métriques:
"""

@app.middleware("http")
async def prometheus_middleware(request: Request, call_next):
    """Collecter métriques Prometheus"""
    ACTIVE_REQUESTS.inc()
    
    start_time = time.time()
    
    response = await call_next(request)
    
    duration = time.time() - start_time
    
    # Incrémenter compteurs
    REQUEST_COUNT.labels(
        method=request.method,
        endpoint=request.url.path,
        status=response.status_code
    ).inc()
    
    REQUEST_DURATION.labels(
        method=request.method,
        endpoint=request.url.path
    ).observe(duration)
    
    ACTIVE_REQUESTS.dec()
    
    return response

# Endpoint métriques
metrics_app = make_asgi_app()
app.mount("/metrics", metrics_app)

"""
Accès: http://localhost:8000/metrics

Format Prometheus:
"""
# HELP fastapi_requests_total Total request count
# TYPE fastapi_requests_total counter
fastapi_requests_total{method="GET",endpoint="/users",status="200"} 1523.0

# HELP fastapi_request_duration_seconds Request duration
# TYPE fastapi_request_duration_seconds histogram
fastapi_request_duration_seconds_bucket{method="GET",endpoint="/users",le="0.1"} 1200.0


"""
SENTRY POUR ERROR TRACKING
--------------------------

[IDEE] SENTRY = Tracking d'erreurs en production
"""

pip install sentry-sdk

"""
Configuration:
"""

import sentry_sdk
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware

sentry_sdk.init(
    dsn="https://your-sentry-dsn",
    environment="production",
    traces_sample_rate=0.1,  # 10% des requêtes tracées
    profiles_sample_rate=0.1,
)

app.add_middleware(SentryAsgiMiddleware)

"""
[IDEE] AVANTAGES SENTRY:

[OK] Erreurs capturées automatiquement
[OK] Stack traces complètes
[OK] Context (user, request, breadcrumbs)
[OK] Alertes email/Slack
[OK] Groupement d'erreurs similaires
[OK] Release tracking


UTILISATION:
"""

from sentry_sdk import capture_exception, capture_message

@app.get("/risky")
async def risky_endpoint():
    try:
        result = dangerous_operation()
        return result
    except Exception as e:
        # Capturer l'erreur dans Sentry
        capture_exception(e)
        raise HTTPException(500, "Internal error")

# Ajouter contexte
from sentry_sdk import set_user, set_tag

@app.get("/users/me")
async def get_current_user(current_user: User = Depends(get_current_active_user)):
    # Ajouter user au contexte Sentry
    set_user({
        "id": current_user.id,
        "username": current_user.username,
        "email": current_user.email
    })
    
    set_tag("user_tier", "premium")
    
    return current_user


"""
HEALTH CHECKS
------------

[IDEE] ENDPOINTS pour vérifier santé de l'API
"""

@app.get("/health")
async def health_check():
    """
    Health check basique
    
    Retourne 200 si API répond
    """
    return {
        "status": "healthy",
        "timestamp": datetime.utcnow().isoformat()
    }

@app.get("/ready")
async def readiness_check(db: Session = Depends(get_db)):
    """
    Readiness check complet
    
    Vérifie que:
    - API répond
    - DB accessible
    - Redis accessible
    - Dépendances externes OK
    """
    checks = {
        "api": "healthy",
        "database": "unknown",
        "redis": "unknown"
    }
    
    # Vérifier DB
    try:
        db.execute("SELECT 1")
        checks["database"] = "healthy"
    except Exception as e:
        checks["database"] = f"unhealthy: {str(e)}"
    
    # Vérifier Redis
    try:
        redis = get_redis_connection()
        redis.ping()
        checks["redis"] = "healthy"
    except Exception as e:
        checks["redis"] = f"unhealthy: {str(e)}"
    
    # Status global
    all_healthy = all(v == "healthy" for v in checks.values())
    status_code = 200 if all_healthy else 503
    
    return JSONResponse(
        content={
            "status": "ready" if all_healthy else "not ready",
            "checks": checks,
            "timestamp": datetime.utcnow().isoformat()
        },
        status_code=status_code
    )


"""
GRAFANA + PROMETHEUS
-------------------

[IDEE] DASHBOARD DE MONITORING

1. Prometheus collecte métriques depuis /metrics
2. Grafana visualise les métriques
"""

# docker-compose.yml
"""
services:
  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana_data:/var/lib/grafana
    depends_on:
      - prometheus

volumes:
  prometheus_data:
  grafana_data:
"""

# prometheus.yml
"""
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'fastapi'
    static_configs:
      - targets: ['api:8000']
"""

"""
Accès:
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000
"""


"""
[DOCS] RÉCAPITULATIF DU CHAPITRE 16
------------------------------

Vous avez appris:
[OK] Logging structuré (JSON)
[OK] Middleware de logging
[OK] Prometheus pour métriques
[OK] Sentry pour error tracking
[OK] Health checks
[OK] Grafana pour dashboards

Points clés:
[CLE] Logs structurés = facilement parsables
[CLE] Request ID pour tracer requêtes
[CLE] Prometheus = standard métriques
[CLE] Sentry capture erreurs automatiquement
[CLE] Health checks pour orchestrateurs


[OBJECTIF] MÉTRIQUES IMPORTANTES:

1. RED METRICS (Rate, Errors, Duration):
   - Request rate (req/s)
   - Error rate (%)
   - Duration (latency)

2. USE METRICS (Utilization, Saturation, Errors):
   - CPU utilization
   - Memory usage
   - Disk I/O
   - Network

3. BUSINESS METRICS:
   - Users actifs
   - Signups
   - Conversions
   - Revenue
"""


# ============================================================================
# [GUIDE] CHAPITRE 17: BEST PRACTICES & PATTERNS
# ============================================================================

"""
[OBJECTIF] OBJECTIF DU CHAPITRE
Maîtriser les patterns et best practices pour code professionnel.


[CONSTRUCTION] ARCHITECTURE EN COUCHES
--------------------------

[IDEE] SÉPARER RESPONSABILITÉS

Structure recommandée:
"""

project/
├── app/
│   ├── __init__.py
│   ├── main.py              # Point d'entrée FastAPI
│   ├── config.py            # Configuration
│   ├── dependencies.py      # Dépendances communes
│   │
│   ├── models/              # Modèles SQLAlchemy (DB)
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── post.py
│   │
│   ├── schemas/             # Schémas Pydantic (validation)
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── post.py
│   │
│   ├── crud/                # Opérations CRUD
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── post.py
│   │
│   ├── api/                 # Routes/Endpoints
│   │   ├── __init__.py
│   │   ├── deps.py          # Dépendances API
│   │   └── v1/
│   │       ├── __init__.py
│   │       ├── endpoints/
│   │       │   ├── users.py
│   │       │   ├── posts.py
│   │       │   └── auth.py
│   │       └── router.py
│   │
│   ├── core/                # Logique métier core
│   │   ├── __init__.py
│   │   ├── security.py      # JWT, hashing
│   │   └── utils.py
│   │
│   └── services/            # Services métier
│       ├── __init__.py
│       ├── user_service.py
│       └── email_service.py
│
├── tests/
├── alembic/
├── .env
├── .env.example
├── docker-compose.yml
├── Dockerfile
└── requirements.txt

"""
[IDEE] PRINCIPE:

1. models/ = QUOI (structure des données)
2. schemas/ = CONTRAT (validation API)
3. crud/ = COMMENT (opérations DB)
4. services/ = LOGIQUE MÉTIER
5. api/ = INTERFACE (endpoints)


EXEMPLE COMPLET
--------------
"""

# app/models/user.py
"""Modèle SQLAlchemy"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from app.database import Base

class User(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True)
    email = Column(String, unique=True, index=True)
    hashed_password = Column(String)
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)


# app/schemas/user.py
"""Schémas Pydantic"""
from pydantic import BaseModel, EmailStr
from datetime import datetime

class UserBase(BaseModel):
    username: str
    email: EmailStr

class UserCreate(UserBase):
    password: str

class UserUpdate(BaseModel):
    username: str | None = None
    email: EmailStr | None = None

class UserInDB(UserBase):
    id: int
    is_active: bool
    created_at: datetime
    
    class Config:
        orm_mode = True


# app/crud/user.py
"""Opérations CRUD"""
from sqlalchemy.orm import Session
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
from app.core.security import get_password_hash

def get_user(db: Session, user_id: int):
    """Récupérer un user par ID"""
    return db.query(User).filter(User.id == user_id).first()

def get_user_by_email(db: Session, email: str):
    """Récupérer un user par email"""
    return db.query(User).filter(User.email == email).first()

def get_users(db: Session, skip: int = 0, limit: int = 100):
    """Lister users avec pagination"""
    return db.query(User).offset(skip).limit(limit).all()

def create_user(db: Session, user: UserCreate):
    """Créer un user"""
    hashed_password = get_password_hash(user.password)
    db_user = User(
        username=user.username,
        email=user.email,
        hashed_password=hashed_password
    )
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    return db_user

def update_user(db: Session, user_id: int, user: UserUpdate):
    """Mettre à jour un user"""
    db_user = get_user(db, user_id)
    if not db_user:
        return None
    
    update_data = user.dict(exclude_unset=True)
    for field, value in update_data.items():
        setattr(db_user, field, value)
    
    db.commit()
    db.refresh(db_user)
    return db_user

def delete_user(db: Session, user_id: int):
    """Supprimer un user"""
    db_user = get_user(db, user_id)
    if db_user:
        db.delete(db_user)
        db.commit()
    return db_user


# app/services/user_service.py
"""Logique métier"""
from app.crud import user as user_crud
from app.services.email_service import send_welcome_email

class UserService:
    """Service pour logique métier utilisateur"""
    
    @staticmethod
    def create_user_with_welcome(db, user_create):
        """
        [IDEE] LOGIQUE MÉTIER COMPLEXE
        
        1. Créer user
        2. Envoyer email de bienvenue
        3. Logger l'action
        """
        # Créer user
        user = user_crud.create_user(db, user_create)
        
        # Envoyer email
        send_welcome_email(user.email, user.username)
        
        # Logger
        logger.info(f"User {user.username} created")
        
        return user


# app/api/v1/endpoints/users.py
"""Endpoints API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.crud import user as user_crud
from app.schemas.user import UserCreate, UserInDB, UserUpdate
from app.services.user_service import UserService

router = APIRouter()

@router.post("/", response_model=UserInDB, status_code=201)
def create_user(
    user: UserCreate,
    db: Session = Depends(get_db)
):
    """Créer un utilisateur"""
    # Vérifier si email existe
    if user_crud.get_user_by_email(db, user.email):
        raise HTTPException(409, "Email already registered")
    
    # Utiliser service
    return UserService.create_user_with_welcome(db, user)

@router.get("/{user_id}", response_model=UserInDB)
def read_user(user_id: int, db: Session = Depends(get_db)):
    """Récupérer un utilisateur"""
    user = user_crud.get_user(db, user_id)
    if not user:
        raise HTTPException(404, "User not found")
    return user

@router.get("/", response_model=list[UserInDB])
def list_users(
    skip: int = 0,
    limit: int = 10,
    db: Session = Depends(get_db)
):
    """Lister utilisateurs"""
    return user_crud.get_users(db, skip, limit)

@router.put("/{user_id}", response_model=UserInDB)
def update_user(
    user_id: int,
    user: UserUpdate,
    db: Session = Depends(get_db)
):
    """Mettre à jour utilisateur"""
    db_user = user_crud.update_user(db, user_id, user)
    if not db_user:
        raise HTTPException(404, "User not found")
    return db_user

@router.delete("/{user_id}", status_code=204)
def delete_user(user_id: int, db: Session = Depends(get_db)):
    """Supprimer utilisateur"""
    if not user_crud.delete_user(db, user_id):
        raise HTTPException(404, "User not found")


"""
VERSIONING API
-------------

[IDEE] TOUJOURS VERSIONNER VOS APIs
"""

# app/api/v1/router.py
from fastapi import APIRouter
from app.api.v1.endpoints import users, posts, auth

api_router = APIRouter()
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(posts.router, prefix="/posts", tags=["posts"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])

# app/main.py
from fastapi import FastAPI
from app.api.v1.router import api_router as v1_router
from app.api.v2.router import api_router as v2_router  # Future v2

app = FastAPI()

# Monter v1
app.include_router(v1_router, prefix="/api/v1")

# Monter v2 (quand disponible)
app.include_router(v2_router, prefix="/api/v2")

"""
URLs résultantes:
/api/v1/users
/api/v1/posts
/api/v2/users  (future version)


PAGINATION AVANCÉE
-----------------

[IDEE] PATTERN: Cursor Pagination pour grandes datasets
"""

from pydantic import BaseModel

class PaginatedResponse(BaseModel):
    """Réponse paginée standardisée"""
    items: list
    total: int
    page: int
    size: int
    pages: int

def paginate(
    query,
    page: int = 1,
    size: int = 10
) -> PaginatedResponse:
    """Helper de pagination"""
    total = query.count()
    items = query.offset((page - 1) * size).limit(size).all()
    
    return PaginatedResponse(
        items=items,
        total=total,
        page=page,
        size=size,
        pages=(total + size - 1) // size
    )


"""
CACHING STRATEGY
---------------

[IDEE] PATTERN: Cache-aside
"""

import redis
import json

redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)

def get_user_cached(user_id: int, db: Session):
    """
    [IDEE] CACHE-ASIDE PATTERN
    
    1. Vérifier cache
    2. Si hit -> retourner
    3. Si miss -> DB -> mettre en cache -> retourner
    """
    cache_key = f"user:{user_id}"
    
    # 1. Vérifier cache
    cached = redis_client.get(cache_key)
    if cached:
        logger.info(f"Cache HIT for user {user_id}")
        return json.loads(cached)
    
    # 2. Cache miss -> DB
    logger.info(f"Cache MISS for user {user_id}")
    user = user_crud.get_user(db, user_id)
    
    if user:
        # 3. Mettre en cache (1 heure)
        redis_client.setex(
            cache_key,
            3600,
            json.dumps(UserInDB.from_orm(user).dict())
        )
    
    return user

def invalidate_user_cache(user_id: int):
    """Invalider cache après mise à jour"""
    redis_client.delete(f"user:{user_id}")


"""
ERROR HANDLING CENTRALISÉ
-------------------------

[IDEE] PATTERN: Error Handler personnalisé
"""

from fastapi import Request
from fastapi.responses import JSONResponse

class APIError(Exception):
    """Erreur API personnalisée"""
    def __init__(self, status_code: int, detail: str, error_code: str = None):
        self.status_code = status_code
        self.detail = detail
        self.error_code = error_code

@app.exception_handler(APIError)
async def api_error_handler(request: Request, exc: APIError):
    """Handler centralisé pour erreurs API"""
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "code": exc.error_code,
                "message": exc.detail,
                "path": request.url.path
            }
        }
    )

# Utilisation:
@app.get("/users/{user_id}")
def get_user(user_id: int, db: Session = Depends(get_db)):
    user = user_crud.get_user(db, user_id)
    if not user:
        raise APIError(404, "User not found", "USER_NOT_FOUND")
    return user


"""
[DOCS] RÉCAPITULATIF FINAL
--------------------

[BRAVO] FÉLICITATIONS!

Vous avez complété le guide FastAPI ultra-détaillé!

Vous maîtrisez maintenant:

PARTIE 1:
[OK] Bases FastAPI (routes, params, validation)
[OK] Request/Response models
[OK] Error handling

PARTIE 2:
[OK] Dependencies
[OK] Security & JWT
[OK] File upload
[OK] Static files & templates

PARTIE 3:
[OK] Async/await
[OK] Background tasks
[OK] Database (SQLAlchemy)

PARTIE 4:
[OK] Testing complet
[OK] Deployment production
[OK] Monitoring & logging
[OK] Best practices & architecture


[OBJECTIF] PROCHAINES ÉTAPES:

1. PRATIQUER:
   - Créer un projet complet
   - Implémenter tous les patterns
   - Déployer en production

2. APPROFONDIR:
   - GraphQL avec Strawberry
   - Microservices
   - Event-driven architecture
   - CQRS pattern

3. CONTRIBUER:
   - Open source
   - Partager vos connaissances
   - Créer des libs réutilisables


[DOCS] RESSOURCES:

Documentation:
- FastAPI: https://fastapi.tiangolo.com
- SQLAlchemy: https://docs.sqlalchemy.org
- Pydantic: https://docs.pydantic.dev

Communauté:
- GitHub: github.com/tiangolo/fastapi
- Discord: FastAPI Discord
- Stack Overflow: [fastapi]


[RAPIDE] BONNE CHANCE DANS VOS PROJETS FASTAPI!

Vous avez maintenant toutes les clés pour créer des APIs
professionnelles, performantes et maintenables!
"""
