================================================================================
        GUIDE FASTAPI COMPLET — PARTIE 1 : FONDATIONS WEB & PYTHON
        Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FIL ROUGE : TaskFlow API
   Une application SaaS de gestion de tâches complète, développée étape
   par étape tout au long de ce guide. À la fin, tu auras une API
   production-ready déployée sur un serveur réel.

================================================================================
                        CHAPITRE 1 — LE PROTOCOLE HTTP
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1.1 INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Qu'est-ce que HTTP ?
--------------------
HTTP (HyperText Transfer Protocol) est le protocole de communication
fondamental du Web. Chaque fois que tu ouvres un site, envoies un formulaire,
ou qu'une application mobile récupère des données, HTTP est en jeu.

Pense à HTTP comme au langage commun entre un client (navigateur, application)
et un serveur (ordinateur distant qui héberge les données).

Sans HTTP : impossible de créer des API REST. C'est littéralement la base.

Pourquoi il existe ?
--------------------
Avant HTTP (inventé par Tim Berners-Lee en 1989), chaque application devait
inventer son propre protocole de communication. HTTP a standardisé ces
échanges, permettant à n'importe quel client de parler à n'importe quel
serveur de manière uniforme.

Dans quels cas on l'utilise ?
------------------------------
- Chaque fois que tu accèdes à un site web
- Chaque fois qu'une app mobile récupère des données
- Chaque fois que tu utilises une API (Stripe, Google, Twitter...)
- Dans notre projet TaskFlow : chaque requête vers notre API

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1.2 EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Modèle Requête -> Réponse
----------------------------
HTTP fonctionne sur un principe simple : un client envoie une REQUÊTE, un
serveur renvoie une RÉPONSE.

  Client                          Serveur
    │                               │
    │── GET /tasks HTTP/1.1 ───────[BLACK_RIGHT-POINTING_TRIANGLE]│
    │   Host: api.taskflow.com      │
    │   Authorization: Bearer xxx   │
    │                               │
    │[BLACK_LEFT-POINTING_TRIANGLE]── HTTP/1.1 200 OK ────────── │
    │    Content-Type: application  │
    │    /json                      │
    │    {"tasks": [...]}           │

Une requête HTTP contient :
1. Une LIGNE DE REQUÊTE  : méthode + chemin + version HTTP
2. Des EN-TÊTES (Headers): métadonnées (auth, type de contenu, langue...)
3. Un CORPS (Body)       : données envoyées (formulaire, JSON...) — optionnel

Une réponse HTTP contient :
1. Une LIGNE DE STATUT   : version HTTP + code de statut + message
2. Des EN-TÊTES          : métadonnées de la réponse
3. Un CORPS              : données retournées (HTML, JSON, image...)

Versions de HTTP
-----------------
HTTP/1.0 (1996) : une connexion TCP par requête -> lent
HTTP/1.1 (1997) : connexions persistantes, pipelining -> encore utilisé
HTTP/2  (2015)  : multiplexage, compression en-têtes, push -> performant
HTTP/3  (2020)  : basé sur QUIC (UDP), encore plus rapide

FastAPI supporte nativement HTTP/1.1 et HTTP/2 via Uvicorn.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1.3 LES MÉTHODES HTTP (VERBES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les méthodes HTTP définissent L'ACTION que le client veut effectuer.

┌──────────┬───────────────────────────────────────────────────────────────┐
│ Méthode  │ Description                                                   │
├──────────┼───────────────────────────────────────────────────────────────┤
│ GET      │ Récupérer une ressource. JAMAIS modifier. Sans corps.         │
│ POST     │ Créer une nouvelle ressource. Avec corps (données).           │
│ PUT      │ Remplacer entièrement une ressource existante.                │
│ PATCH    │ Modifier partiellement une ressource existante.               │
│ DELETE   │ Supprimer une ressource.                                      │
│ HEAD     │ Comme GET mais sans le corps de réponse.                      │
│ OPTIONS  │ Découvrir les méthodes autorisées (utilisé par CORS).         │
└──────────┴───────────────────────────────────────────────────────────────┘

Application dans TaskFlow API :
  GET    /tasks         -> lister toutes les tâches
  GET    /tasks/42      -> récupérer la tâche n°42
  POST   /tasks         -> créer une nouvelle tâche
  PUT    /tasks/42      -> remplacer complètement la tâche 42
  PATCH  /tasks/42      -> modifier partiellement la tâche 42
  DELETE /tasks/42      -> supprimer la tâche 42

Idempotence (concept important) :
  - Idempotent : appeler la même opération N fois = même résultat
  - GET, PUT, DELETE, HEAD, OPTIONS sont idempotents
  - POST n'est PAS idempotent (créer 3 fois = 3 ressources différentes)
  - PATCH n'est généralement pas idempotent

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1.4 LES CODES DE STATUT HTTP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les codes de statut indiquent le résultat du traitement de la requête.

2xx — SUCCÈS
  200 OK            : requête réussie (GET, PUT, PATCH)
  201 Created       : ressource créée avec succès (POST)
  204 No Content    : succès, pas de corps en réponse (DELETE)

3xx — REDIRECTIONS
  301 Moved Permanently  : ressource déplacée définitivement
  304 Not Modified       : cache valide, pas besoin de retransférer

4xx — ERREURS CLIENT (c'est TA faute)
  400 Bad Request        : requête malformée ou données invalides
  401 Unauthorized       : non authentifié (il faut se connecter)
  403 Forbidden          : authentifié mais pas autorisé
  404 Not Found          : ressource inexistante
  405 Method Not Allowed : méthode HTTP non supportée sur cette route
  409 Conflict           : conflit (ex: email déjà utilisé)
  422 Unprocessable      : données syntaxiquement correctes mais
      Entity               sémantiquement invalides (FastAPI l'utilise)
  429 Too Many Requests  : rate limit atteint

5xx — ERREURS SERVEUR (c'est la faute du SERVEUR)
  500 Internal Server Error  : erreur non gérée côté serveur
  502 Bad Gateway            : le serveur proxy a reçu une mauvaise réponse
  503 Service Unavailable    : serveur surchargé ou en maintenance
  504 Gateway Timeout        : timeout du serveur proxy

Dans FastAPI, quand Pydantic détecte des données invalides -> 422 automatique.
Erreur non gérée dans ton code -> 500 automatique.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1.5 LES EN-TÊTES HTTP (HEADERS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les headers sont des métadonnées attachées à la requête ou réponse.

En-têtes de requête importants :
  Content-Type      : format du corps envoyé
                      "application/json" pour JSON
                      "multipart/form-data" pour fichiers
  Authorization     : token d'authentification
                      "Bearer eyJhbGci..." pour JWT
  Accept            : format attendu en réponse
  Accept-Language   : langue préférée
  User-Agent        : identification du client

En-têtes de réponse importants :
  Content-Type      : format du corps retourné
  Content-Length    : taille du corps en octets
  Cache-Control     : règles de mise en cache
  Set-Cookie        : définir un cookie
  Access-Control-   : en-têtes CORS
  Allow-Origin

Exemple de requête HTTP brute (ce que ton navigateur envoie vraiment) :
  POST /api/tasks HTTP/1.1
  Host: api.taskflow.com
  Content-Type: application/json
  Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  Accept: application/json
  Content-Length: 87

  {
    "title": "Finir le rapport",
    "priority": "high",
    "due_date": "2024-12-31"
  }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1.6 HTTPS ET TLS/SSL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HTTPS = HTTP + TLS (Transport Layer Security, anciennement SSL).
Tout ce qui circule est chiffré. Indispensable en production.

Sans HTTPS : les tokens JWT, mots de passe, données sensibles circulent
en clair sur le réseau -> n'importe quel intermédiaire peut les lire.

Avec HTTPS :
  1. Le serveur présente un certificat SSL (prouvant son identité)
  2. Client et serveur négocient une clé de chiffrement symétrique
  3. Toutes les données sont chiffrées

En production avec FastAPI : on utilise Nginx comme reverse proxy qui
gère le TLS, puis passe les requêtes déchiffrées à Uvicorn/FastAPI.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1.7 EXERCICES PRATIQUES — CHAPITRE 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 1.1 : En utilisant curl dans ton terminal, envoie une requête GET à
         https://httpbin.org/get et analyse les en-têtes de réponse.
         Commande : curl -i https://httpbin.org/get

Ex 1.2 : Identifie la méthode HTTP correcte pour chaque opération :
         a) Afficher le profil utilisateur
         b) Changer le mot de passe
         c) Supprimer un commentaire
         d) Créer un nouveau post

Ex 1.3 : Donne le code de statut approprié pour chaque situation :
         a) Utilisateur non trouvé dans la base de données
         b) Tâche créée avec succès
         c) Token JWT expiré
         d) Validation des données échouée

NIVEAU INTERMÉDIAIRE
--------------------
Ex 1.4 : Utilise curl pour envoyer une requête POST avec un corps JSON :
         curl -X POST https://httpbin.org/post \
              -H "Content-Type: application/json" \
              -d '{"name": "TaskFlow", "version": "1.0"}'
         Analyse la réponse.

Ex 1.5 : Dessine le cycle requête-réponse complet quand un utilisateur
         accède à GET /tasks/42 dans notre TaskFlow API. Inclure :
         navigateur, DNS, TCP, serveur, base de données.

Ex 1.6 : Pourquoi utiliser PATCH plutôt que PUT pour mettre à jour
         seulement le titre d'une tâche ? Explique avec un exemple.

NIVEAU AVANCÉ
-------------
Ex 1.7 : Explique la différence entre authentification (401) et
         autorisation (403) avec un exemple concret dans TaskFlow.

Ex 1.8 : Qu'est-ce que le "preflight request" OPTIONS dans le contexte
         CORS ? Quand est-il envoyé et pourquoi ?

Ex 1.9 : Compare HTTP/1.1 et HTTP/2 en termes de performance pour une
         API REST qui charge 50 ressources en parallèle.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1.8 CORRIGÉS DÉTAILLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ Ex 1.2 :
  a) GET    -> on récupère, on ne modifie pas
  b) PATCH  -> modification partielle (seulement le mot de passe)
  c) DELETE -> suppression de la ressource
  d) POST   -> création d'une nouvelle ressource

CORRIGÉ Ex 1.3 :
  a) 404 Not Found
  b) 201 Created
  c) 401 Unauthorized (le token est invalide/expiré -> non authentifié)
  d) 422 Unprocessable Entity (FastAPI)

CORRIGÉ Ex 1.6 :
  PUT /tasks/42 nécessite d'envoyer TOUTES les propriétés de la tâche,
  même celles qu'on ne modifie pas. Si on oublie un champ, il pourrait
  être réinitialisé à null.

  Exemple PUT : {"title": "Nouveau titre", "priority": "high",
                 "due_date": "2024-12-31", "assigned_to": 5,
                 "tags": ["urgent"], "description": "..."}
  -> Doit inclure TOUT

  Exemple PATCH : {"title": "Nouveau titre"}
  -> On envoie uniquement ce qui change -> plus efficace, moins d'erreurs

CORRIGÉ Ex 1.7 :
  401 Unauthorized = "Qui êtes-vous ?" -> l'utilisateur n'est pas connecté
  Exemple TaskFlow : accéder à GET /tasks sans envoyer de token JWT.
  La réponse dit "Connectez-vous d'abord."

  403 Forbidden = "Je sais qui vous êtes, mais vous n'avez pas le droit"
  Exemple TaskFlow : utilisateur connecté qui essaie de DELETE /tasks/42
  qui appartient à quelqu'un d'autre. Il est authentifié mais pas autorisé.

================================================================================
                            CHAPITRE 2 — JSON
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2.1 INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Qu'est-ce que JSON ?
---------------------
JSON (JavaScript Object Notation) est LE format d'échange de données
standard pour les API REST modernes. C'est du texte structuré, lisible
par les humains ET par les machines.

Avant JSON, XML dominait. JSON a gagné car :
  - Plus léger (moins verbeux que XML)
  - Plus lisible
  - Nativement supporté en JavaScript
  - Facile à parser dans tous les langages

FastAPI retourne automatiquement du JSON dans ses réponses. Pydantic
valide et sérialise les données en JSON. C'est la lingua franca de nos API.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2.2 SYNTAXE JSON COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Types de données JSON :
  string  : "texte entre guillemets doubles"
  number  : 42 ou 3.14 (pas de distinction int/float)
  boolean : true ou false (minuscules, PAS True/False comme Python !)
  null    : null (PAS None comme Python !)
  array   : [1, "deux", true, null, {"key": "val"}]
  object  : {"clé": "valeur", "nombre": 42}

Exemple complet — une tâche dans TaskFlow :
{
  "id": 42,
  "title": "Implémenter l'authentification JWT",
  "description": "Sécuriser les endpoints avec des tokens JWT",
  "priority": "high",
  "completed": false,
  "due_date": "2024-12-31",
  "created_at": "2024-01-15T09:30:00Z",
  "tags": ["backend", "security", "jwt"],
  "assigned_to": {
    "id": 7,
    "name": "Alice Martin",
    "email": "alice@taskflow.com"
  },
  "subtasks": [
    {"id": 1, "title": "Installer PyJWT", "done": true},
    {"id": 2, "title": "Créer le middleware", "done": false}
  ],
  "metadata": null
}

Règles syntaxiques strictes :
  [OK] Guillemets DOUBLES obligatoires pour les clés et strings
  [OK] Pas de virgule après le dernier élément (trailing comma interdit)
  [OK] true/false/null en minuscules
  [X] Pas de commentaires autorisés dans JSON standard
  [X] Pas de fonctions, pas de dates natives (on utilise des strings ISO 8601)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2.3 JSON EN PYTHON
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le module json de Python permet de convertir entre Python et JSON.

Correspondances de types :
  Python dict  <->  JSON object
  Python list  <->  JSON array
  Python str   <->  JSON string
  Python int   <->  JSON number
  Python float <->  JSON number
  Python True  <->  JSON true
  Python False <->  JSON false
  Python None  <->  JSON null

Sérialisation (Python -> JSON string) :
  import json

  task = {
      "id": 42,
      "title": "Implémenter JWT",
      "completed": False,  # Python False -> JSON false
      "due_date": None,    # Python None  -> JSON null
      "tags": ["backend", "security"]
  }

  # Convertir en string JSON
  json_string = json.dumps(task)
  # Résultat : '{"id": 42, "title": "Implémenter JWT", "completed": false, ...}'

  # Pretty print (indenté, lisible)
  json_pretty = json.dumps(task, indent=2, ensure_ascii=False)

  # Écrire dans un fichier
  with open("task.json", "w", encoding="utf-8") as f:
      json.dump(task, f, indent=2, ensure_ascii=False)

Désérialisation (JSON string -> Python) :
  import json

  json_data = '{"id": 42, "completed": false, "tags": ["backend"]}'

  # String JSON -> dict Python
  task = json.loads(json_data)
  print(task["id"])        # 42 (int Python)
  print(task["completed"]) # False (bool Python)

  # Lire depuis un fichier
  with open("task.json", "r", encoding="utf-8") as f:
      task = json.load(f)

Cas particuliers et pièges :
  import json
  from datetime import datetime

  # [X] datetime non sérialisable nativement !
  task = {"created_at": datetime.now()}
  json.dumps(task)  # TypeError: Object of type datetime is not JSON serializable

  # [OK] Solution 1 : convertir en string ISO 8601
  task = {"created_at": datetime.now().isoformat()}

  # [OK] Solution 2 : custom encoder
  class TaskEncoder(json.JSONEncoder):
      def default(self, obj):
          if isinstance(obj, datetime):
              return obj.isoformat()
          return super().default(obj)

  json.dumps(task_with_datetime, cls=TaskEncoder)

  # FastAPI/Pydantic gère ça automatiquement ! C'est un de ses grands avantages.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2.4 BONNES PRATIQUES JSON POUR API
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Nommage des clés :
  [OK] snake_case (standard Python/FastAPI) : "created_at", "user_id"
  [OK] camelCase si le front-end le préfère : "createdAt", "userId"
  -> FastAPI peut convertir automatiquement avec alias_generator

Dates et heures :
  [OK] Toujours utiliser ISO 8601 : "2024-01-15T09:30:00Z"
  [OK] Inclure le fuseau horaire (Z pour UTC, +01:00 pour Paris)
  [X] Éviter les timestamps Unix (moins lisibles)

IDs :
  [OK] Utiliser des entiers pour des IDs simples
  [OK] Utiliser des UUID pour des IDs publics (évite l'énumération)
  Exemple : "id": "550e8400-e29b-41d4-a716-446655440000"

Structure de réponse cohérente :
  Succès :
  {
    "data": {...},
    "message": "Task created successfully",
    "status": "success"
  }

  Erreur :
  {
    "error": {
      "code": "TASK_NOT_FOUND",
      "message": "Task with id 42 not found",
      "details": {}
    }
  }

  Liste paginée :
  {
    "data": [...],
    "pagination": {
      "total": 150,
      "page": 1,
      "per_page": 20,
      "total_pages": 8
    }
  }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2.5 EXERCICES — CHAPITRE 2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 2.1 : Corrige les erreurs de syntaxe dans ce JSON :
  {
    'name': "Alice",
    'age': 30,
    'admin': True,
    'phone': None,
    'tags': ['dev', 'backend',]
  }

Ex 2.2 : Convertis ce dict Python en JSON avec json.dumps() et
         affiche le résultat indenté avec indent=2 :
  user = {"id": 1, "name": "Bob", "active": True, "score": None}

Ex 2.3 : Lis le JSON suivant et accède à l'email de l'utilisateur assigné :
  data = '{"task": {"title": "Fix bug", "assigned_to": {"name": "Clara", "email": "clara@test.com"}}}'

NIVEAU INTERMÉDIAIRE
--------------------
Ex 2.4 : Crée une fonction python2json(obj) qui sérialise un dict
         contenant potentiellement des objets datetime en JSON string.

Ex 2.5 : Conçois la structure JSON pour une réponse d'erreur standard
         pour TaskFlow API quand un utilisateur tente de créer une tâche
         avec une date dans le passé.

Ex 2.6 : Écris une fonction qui lit un fichier JSON, modifie un champ,
         et réécrit le fichier.

NIVEAU AVANCÉ
-------------
Ex 2.7 : Implémente un JSONEncoder personnalisé qui gère datetime,
         Decimal (pour les prix), et UUID.

Ex 2.8 : Conçois une structure JSON pour représenter les relations
         d'un utilisateur avec ses projets, et les tâches de chaque projet.
         Évite la duplication de données.

Ex 2.9 : Explique les avantages et inconvénients de JSON vs MessagePack
         vs Protocol Buffers pour une API haute performance.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2.6 CORRIGÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORRIGÉ Ex 2.1 :
  Erreurs identifiées :
  - 'name' -> "name" (guillemets simples -> doubles)
  - 'age' -> "age", 'admin' -> "admin", etc.
  - True -> true (majuscule -> minuscule)
  - None -> null
  - virgule après 'backend' -> à supprimer (trailing comma)

  JSON corrigé :
  {
    "name": "Alice",
    "age": 30,
    "admin": true,
    "phone": null,
    "tags": ["dev", "backend"]
  }

CORRIGÉ Ex 2.3 :
  import json
  data_str = '{"task": {"title": "Fix bug", "assigned_to": {"name": "Clara", "email": "clara@test.com"}}}'
  data = json.loads(data_str)
  email = data["task"]["assigned_to"]["email"]
  print(email)  # clara@test.com

CORRIGÉ Ex 2.7 :
  import json
  from datetime import datetime
  from decimal import Decimal
  import uuid

  class AdvancedEncoder(json.JSONEncoder):
      def default(self, obj):
          if isinstance(obj, datetime):
              return obj.isoformat()           # "2024-01-15T09:30:00"
          if isinstance(obj, Decimal):
              return float(obj)                # Decimal("9.99") -> 9.99
          if isinstance(obj, uuid.UUID):
              return str(obj)                  # UUID -> string
          return super().default(obj)          # Autres -> erreur standard

  data = {
      "created_at": datetime.now(),
      "price": Decimal("29.99"),
      "user_id": uuid.uuid4()
  }
  print(json.dumps(data, cls=AdvancedEncoder, indent=2))

================================================================================
                         CHAPITRE 3 — API REST
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3.1 INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Qu'est-ce qu'une API REST ?
-----------------------------
API = Application Programming Interface -> interface que les développeurs
utilisent pour que leurs applications communiquent entre elles.

REST = Representational State Transfer -> style architectural défini en 2000
par Roy Fielding dans sa thèse de doctorat.

Une API REST est une API qui respecte les contraintes REST. Ce n'est pas
un protocole ni une technologie, c'est un STYLE d'architecture.

Pourquoi REST a dominé ?
  - Simple à comprendre et implémenter
  - Utilise HTTP (déjà connu, déjà déployé)
  - Stateless -> scalable facilement
  - Compatible avec tous les langages et plateformes
  - Documentation possible (Swagger, OpenAPI)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3.2 LES 6 CONTRAINTES REST (FIELDING)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. CLIENT-SERVEUR
   Séparation stricte entre l'interface utilisateur et le stockage des
   données. Le client ne sait pas comment les données sont stockées.
   Le serveur ne sait pas comment elles seront affichées.
   -> TaskFlow : React (client) <-> FastAPI (serveur)

2. STATELESS (sans état)
   Chaque requête doit contenir TOUTES les informations nécessaires à
   son traitement. Le serveur ne garde AUCUN état de session entre
   les requêtes.
   -> Chaque requête à TaskFlow envoie le token JWT. Le serveur ne
     "mémorise" pas que tu es connecté.
   -> Avantage : scalabilité (n'importe quel serveur peut traiter
     n'importe quelle requête)

3. CACHEABLE
   Les réponses doivent indiquer si elles peuvent être mises en cache.
   Permet de réduire la charge serveur et améliorer les performances.
   -> Header "Cache-Control: max-age=3600" -> mise en cache 1 heure

4. INTERFACE UNIFORME
   Contrainte la plus importante. Comprend :
   a) Identification des ressources dans les URIs
   b) Manipulation des ressources via les représentations
   c) Messages auto-descriptifs
   d) HATEOAS (Hypermedia As The Engine Of Application State)

5. SYSTÈME EN COUCHES
   Le client ne sait pas s'il communique directement avec le serveur
   ou via des intermédiaires (proxy, CDN, load balancer).
   -> TaskFlow en prod : Client -> CDN -> Load Balancer -> FastAPI -> DB

6. CODE À LA DEMANDE (optionnel)
   Le serveur peut envoyer du code exécutable au client (JavaScript).
   Cette contrainte est optionnelle en REST.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3.3 CONCEPTION D'URIs REST (RESSOURCES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les URIs (URLs) identifient les RESSOURCES, pas les actions.

Convention de nommage :
  [OK] Noms au pluriel : /tasks (pas /task)
  [OK] Minuscules : /users (pas /Users)
  [OK] Tirets pour séparer les mots : /task-items (pas /task_items ni /taskItems)
  [OK] Hiérarchique pour les sous-ressources
  [X] Jamais de verbes dans l'URL : /createTask, /getUser, /deleteComment
  [X] Jamais de trailing slash : /tasks/ (inconsistant)

Exemples pour TaskFlow API :
  Collection de ressources :
    GET    /api/v1/users          -> tous les utilisateurs
    POST   /api/v1/users          -> créer un utilisateur

  Ressource unique :
    GET    /api/v1/users/42       -> utilisateur n°42
    PUT    /api/v1/users/42       -> remplacer l'utilisateur 42
    PATCH  /api/v1/users/42       -> modifier partiellement l'utilisateur 42
    DELETE /api/v1/users/42       -> supprimer l'utilisateur 42

  Sous-ressources (relations) :
    GET    /api/v1/users/42/tasks           -> tâches de l'utilisateur 42
    GET    /api/v1/projects/5/tasks         -> tâches du projet 5
    GET    /api/v1/projects/5/tasks/12      -> tâche 12 du projet 5

  Actions spéciales (quand REST ne couvre pas) :
    POST   /api/v1/tasks/42/complete        -> marquer comme complète
    POST   /api/v1/users/reset-password     -> réinitialiser le mot de passe
    POST   /api/v1/tasks/42/assign          -> assigner la tâche

Versioning d'API :
  /api/v1/... -> version 1 (actuelle)
  /api/v2/... -> version 2 (nouvelle fonctionnalités)

  Stratégies de versioning :
  - URL path : /api/v1/... (le plus courant, le plus simple)
  - Header : "Accept: application/vnd.taskflow.v1+json"
  - Query param : /api/tasks?version=1

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3.4 HATEOAS — HYPERMÉDIA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HATEOAS = Hypermedia As The Engine Of Application State
Le client découvre les actions possibles via les liens dans les réponses.

Exemple de réponse HATEOAS pour TaskFlow :
  {
    "id": 42,
    "title": "Implémenter JWT",
    "status": "in_progress",
    "_links": {
      "self": {"href": "/api/v1/tasks/42", "method": "GET"},
      "complete": {"href": "/api/v1/tasks/42/complete", "method": "POST"},
      "assign": {"href": "/api/v1/tasks/42/assign", "method": "POST"},
      "delete": {"href": "/api/v1/tasks/42", "method": "DELETE"},
      "project": {"href": "/api/v1/projects/5", "method": "GET"}
    }
  }

Note : HATEOAS est rarement implémenté complètement en pratique car il
complexifie considérablement les clients. La plupart des APIs "RESTful"
modernes ne l'implémentent pas. Mais il est important de le connaître.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3.5 NIVEAUX DE MATURITÉ REST (RICHARDSON MATURITY MODEL)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Niveau 0 — The Swamp of POX
  Une seule URL, un seul verbe (POST en général)
  GET /api?action=getTask&id=42
  POST /api?action=createTask

Niveau 1 — Resources
  URIs pour identifier les ressources, mais mauvais usage des verbes
  POST /tasks/42/get
  POST /tasks/42/delete

Niveau 2 — HTTP Verbs
  Bonne utilisation des URIs ET des méthodes HTTP -> "RESTful"
  GET /tasks/42
  DELETE /tasks/42
  La majorité des APIs modernes sont à ce niveau.

Niveau 3 — Hypermedia Controls (HATEOAS)
  Niveau 2 + liens dans les réponses pour la navigation
  La "vraie" REST selon Fielding. Très rare en pratique.

Notre TaskFlow API sera au niveau 2 (standard professionnel).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3.6 EXERCICES — CHAPITRE 3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 3.1 : Corrige ces mauvaises URLs REST :
  a) GET /getTaskById?id=42
  b) POST /deleteUser/5
  c) GET /API/V1/Tasks/
  d) PUT /task-create

Ex 3.2 : Conçois les endpoints REST pour une API de blog avec :
  - Articles
  - Commentaires (liés à un article)
  - Auteurs
  Donne méthode + URL + description pour chaque opération CRUD.

NIVEAU INTERMÉDIAIRE
--------------------
Ex 3.3 : Dessine l'architecture complète de TaskFlow API avec les
         couches : client, CDN, load balancer, serveurs FastAPI, base
         de données. Quelle contrainte REST chaque couche illustre-t-elle ?

Ex 3.4 : Quelle est la différence entre une API RESTful et une API REST
         "pure" selon Fielding ? Pourquoi la plupart des devs préfèrent
         "RESTful" ?

NIVEAU AVANCÉ
-------------
Ex 3.5 : Conçois les endpoints REST pour un système de notifications
         en temps réel dans TaskFlow (assignation de tâche, commentaire,
         deadline). REST est-il le bon choix pour ce cas ?

Ex 3.6 : Explique quand utiliser GraphQL plutôt que REST, avec des
         exemples concrets tirés de TaskFlow.

================================================================================
                   CHAPITRE 4 — PROGRAMMATION ASYNC PYTHON
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4.1 INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Qu'est-ce que la programmation asynchrone ?
--------------------------------------------
Par défaut, Python exécute le code de manière SYNCHRONE : une ligne après
l'autre. Si une opération bloque (appel réseau, lecture disque, requête DB),
tout s'arrête et attend.

La programmation ASYNCHRONE permet à Python de ne pas attendre bêtement
quand une opération I/O bloque. Il peut aller faire autre chose, puis
revenir quand l'opération est terminée.

Analogie du restaurant :
  Serveur synchrone : prend commande table 1 -> va en cuisine -> attend que
  la commande soit prête -> revient avec l'assiette -> va à table 2.
  (très lent, tables 2, 3, 4... attendent)

  Serveur asynchrone : prend commande table 1 -> va en cuisine -> donne
  commande au chef -> pendant que le chef cuisine, va prendre commande
  table 2, table 3, table 4 -> revient chercher les assiettes quand prêtes.
  (beaucoup plus efficace)

Pourquoi c'est crucial pour FastAPI ?
  Un serveur web gère des centaines/milliers de requêtes simultanées.
  Chaque requête fait des appels à la base de données (I/O).
  Sans async : 1 requête bloque tout -> limite dure de concurrence.
  Avec async : on peut gérer des milliers de requêtes avec 1 seul thread !

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4.2 CONCURRENCE VS PARALLÉLISME
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Concurrence : gérer plusieurs tâches EN MÊME TEMPS (mais pas forcément
simultanément). On alterne rapidement entre les tâches.
-> asyncio, threading

Parallélisme : exécuter plusieurs tâches RÉELLEMENT simultanément sur
plusieurs cœurs de processeur.
-> multiprocessing

Python a le GIL (Global Interpreter Lock) -> seul 1 thread Python
s'exécute à la fois. Donc le threading Python n'est PAS du vrai parallélisme.

asyncio contourne le GIL en utilisant la concurrence coopérative :
  - Un seul thread
  - Plusieurs coroutines
  - Quand une coroutine attend (I/O), elle "cède" le contrôle à une autre

Quand utiliser quoi :
  asyncio    -> I/O bound : BD, réseau, fichiers (parfait pour FastAPI)
  threading  -> I/O bound avec code bloquant non-async
  multiprocessing -> CPU bound : calculs intensifs, ML, traitement d'images

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4.3 ASYNCIO EN PYTHON — FONDAMENTAUX
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Concepts clés :
  - coroutine : fonction définie avec async def
  - await     : pause une coroutine en attendant un résultat
  - event loop: boucle qui orchestre l'exécution des coroutines
  - Task      : coroutine planifiée pour s'exécuter

# ═══════════════════════════════════════════════════════════════════════
# EXEMPLE 1 : Comparaison sync vs async
# ═══════════════════════════════════════════════════════════════════════

import asyncio
import time

# VERSION SYNCHRONE — BLOQUANTE
def fetch_user_sync(user_id: int) -> dict:
    """Simule une requête DB synchrone — bloque tout pendant 1 seconde"""
    time.sleep(1)  # Simule une requête lente (BD, réseau...)
    return {"id": user_id, "name": f"User {user_id}"}

def main_sync():
    start = time.time()

    # 3 utilisateurs chargés séquentiellement
    user1 = fetch_user_sync(1)  # attend 1 seconde
    user2 = fetch_user_sync(2)  # attend encore 1 seconde
    user3 = fetch_user_sync(3)  # encore 1 seconde

    elapsed = time.time() - start
    print(f"Sync terminé en {elapsed:.2f}s")  # ~3 secondes !

main_sync()


# VERSION ASYNCHRONE — NON BLOQUANTE
async def fetch_user_async(user_id: int) -> dict:
    """Simule une requête DB asynchrone — libère le thread pendant l'attente"""
    await asyncio.sleep(1)  # attend 1 seconde SANS bloquer le thread
    return {"id": user_id, "name": f"User {user_id}"}

async def main_async():
    start = time.time()

    # 3 coroutines lancées SIMULTANÉMENT
    user1, user2, user3 = await asyncio.gather(
        fetch_user_async(1),
        fetch_user_async(2),
        fetch_user_async(3),
    )

    elapsed = time.time() - start
    print(f"Async terminé en {elapsed:.2f}s")  # ~1 seconde seulement !

asyncio.run(main_async())

# ═══════════════════════════════════════════════════════════════════════
# EXEMPLE 2 : Coroutines et await — compréhension profonde
# ═══════════════════════════════════════════════════════════════════════

import asyncio

async def prepare_task_data(task_id: int) -> dict:
    """
    Coroutine qui récupère et prépare les données d'une tâche.
    Une coroutine est une fonction qui peut être suspendue (await)
    et reprise plus tard.
    """
    print(f"[Début] Chargement tâche {task_id}")

    # await suspend cette coroutine et rend le contrôle à l'event loop
    # L'event loop peut alors exécuter d'autres coroutines pendant ce temps
    await asyncio.sleep(0.5)  # Simule requête DB
    print(f"[Milieu] Tâche {task_id} chargée depuis DB")

    await asyncio.sleep(0.3)  # Simule appel à un service externe
    print(f"[Fin] Tâche {task_id} enrichie avec données externes")

    return {"id": task_id, "title": f"Tâche {task_id}"}

async def main():
    # Création de 3 Tasks (coroutines planifiées)
    task_a = asyncio.create_task(prepare_task_data(1))
    task_b = asyncio.create_task(prepare_task_data(2))
    task_c = asyncio.create_task(prepare_task_data(3))

    # Attendre toutes les tasks
    results = await asyncio.gather(task_a, task_b, task_c)
    print(f"Résultats : {results}")

asyncio.run(main())

# Sortie (notez l'entrelacement !) :
# [Début] Chargement tâche 1
# [Début] Chargement tâche 2
# [Début] Chargement tâche 3
# [Milieu] Tâche 1 chargée depuis DB
# [Milieu] Tâche 2 chargée depuis DB
# [Milieu] Tâche 3 chargée depuis DB
# [Fin] Tâche 1 enrichie avec données externes
# [Fin] Tâche 2 enrichie avec données externes
# [Fin] Tâche 3 enrichie avec données externes

# ═══════════════════════════════════════════════════════════════════════
# EXEMPLE 3 : Gestion d'erreurs async
# ═══════════════════════════════════════════════════════════════════════

import asyncio

async def get_user_from_db(user_id: int) -> dict:
    """Simule une requête DB qui peut échouer"""
    await asyncio.sleep(0.1)
    if user_id == 0:
        raise ValueError(f"ID utilisateur invalide : {user_id}")
    if user_id > 1000:
        raise ConnectionError("Base de données indisponible")
    return {"id": user_id, "name": f"User {user_id}"}

async def safe_get_user(user_id: int) -> dict | None:
    """Wrapper avec gestion d'erreurs"""
    try:
        user = await get_user_from_db(user_id)
        return user
    except ValueError as e:
        print(f"Erreur de validation : {e}")
        return None
    except ConnectionError as e:
        print(f"Erreur de connexion : {e}")
        return None

async def main():
    # gather avec return_exceptions=True -> les erreurs ne font pas planter tout
    results = await asyncio.gather(
        safe_get_user(1),
        safe_get_user(0),    # ValueError
        safe_get_user(999),
        return_exceptions=True
    )
    print(results)

asyncio.run(main())

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4.4 PIÈGES ASYNC COURANTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PIÈGE 1 : Oublier await -> coroutine jamais exécutée
  async def get_user(id):
      await asyncio.sleep(1)
      return {"id": id}

  # [X] FAUX : get_user(1) retourne un objet coroutine, pas le résultat !
  user = get_user(1)  # Warning: coroutine 'get_user' was never awaited

  # [OK] CORRECT
  user = await get_user(1)

PIÈGE 2 : Appeler du code bloquant dans une coroutine
  import time

  async def bad_handler():
      time.sleep(5)       # [X] BLOQUE TOUT L'EVENT LOOP !
      return "done"

  async def good_handler():
      await asyncio.sleep(5)  # [OK] Libère l'event loop
      return "done"

  # Pour le code bloquant incontournable :
  async def handle_blocking_code():
      loop = asyncio.get_event_loop()
      result = await loop.run_in_executor(
          None,           # ThreadPoolExecutor par défaut
          time.sleep,     # Fonction bloquante
          5               # Arguments
      )

PIÈGE 3 : Créer une boucle dans une boucle
  # [X] FAUX : asyncio.run() ne peut pas être appelé depuis une coroutine
  async def outer():
      asyncio.run(inner())  # RuntimeError !

  # [OK] CORRECT : utiliser await
  async def outer():
      await inner()

PIÈGE 4 : Libraries synchrones dans un contexte async
  # [X] requests est synchrone -> bloque l'event loop !
  import requests
  async def get_data():
      response = requests.get("https://api.example.com")  # BLOQUE !
      return response.json()

  # [OK] Utiliser httpx (async) ou aiohttp
  import httpx
  async def get_data():
      async with httpx.AsyncClient() as client:
          response = await client.get("https://api.example.com")
          return response.json()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4.5 ASYNC DANS FASTAPI — RÈGLE IMPORTANTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FastAPI supporte à la fois les fonctions sync et async :

  # Async -> FastAPI l'exécute directement dans l'event loop
  @app.get("/tasks")
  async def get_tasks():
      tasks = await db.fetch_all("SELECT * FROM tasks")
      return tasks

  # Sync -> FastAPI l'exécute dans un thread pool (run_in_executor)
  # pour ne pas bloquer l'event loop
  @app.get("/tasks/count")
  def count_tasks():
      return {"count": Task.query.count()}  # Code sync bloquant

Quand utiliser async dans FastAPI :
  [OK] async def : quand tu utilises await (DB async, appels HTTP async)
  [OK] def       : quand tu utilises des libraries sync (SQLAlchemy sync,
                 requests, etc.) — FastAPI gère le thread pool

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4.6 EXERCICES — CHAPITRE 4
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 4.1 : Écris une coroutine hello_async() qui affiche "Bonjour", attend
         2 secondes, puis affiche "Au revoir". Exécute-la avec asyncio.run().

Ex 4.2 : Mesure la différence de temps entre ces deux approches :
  a) Télécharger 5 URLs séquentiellement (utilise asyncio.sleep pour
     simuler)
  b) Les télécharger en parallèle avec asyncio.gather()

NIVEAU INTERMÉDIAIRE
--------------------
Ex 4.3 : Écris une fonction async fetch_task_with_user(task_id) qui
         simule :
         1. Charger la tâche depuis la DB (0.3s)
         2. Charger l'utilisateur assigné depuis la DB (0.2s)
         Ces deux opérations doivent s'exécuter EN PARALLÈLE.

Ex 4.4 : Écris une fonction async avec gestion d'erreurs qui tente de
         charger des données depuis 3 sources différentes, retourne le
         premier résultat valide (asyncio.wait avec FIRST_COMPLETED).

NIVEAU AVANCÉ
-------------
Ex 4.5 : Implémente un semaphore async pour limiter les requêtes
         concurrentes à la DB à 10 maximum simultanément.

Ex 4.6 : Crée un rate limiter async qui limite à 100 requêtes/seconde
         en utilisant asyncio et un compteur partagé.

Ex 4.7 : Explique en détail le fonctionnement de l'event loop Python.
         Dessine le cycle : creation task -> scheduled -> running -> waiting
         -> done.

================================================================================
                    CHAPITRE 5 — ENVIRONNEMENT DE DÉVELOPPEMENT
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5.1 INSTALLATION PYTHON & OUTILS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Python 3.11+ recommandé (FastAPI utilise des features modernes).

Vérifier l'installation :
  python --version      # Python 3.11.x
  python3 --version     # Sur Linux/Mac

Gestion des versions Python avec pyenv (recommandé) :
  # Installation pyenv (Mac/Linux)
  curl https://pyenv.run | bash

  # Installer une version spécifique
  pyenv install 3.11.7
  pyenv global 3.11.7

  # Vérifier
  python --version  # Python 3.11.7

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5.2 ENVIRONNEMENTS VIRTUELS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pourquoi les virtual environments ?
  Sans venv : toutes les dépendances installées globalement -> conflits
  de versions entre projets -> enfer de la dépendance.

  Avec venv : chaque projet a SES propres dépendances, isolées.

Méthode standard (venv) :
  # Créer l'environnement virtuel
  python -m venv taskflow_env

  # Activer (Windows)
  taskflow_env\Scripts\activate

  # Activer (Mac/Linux)
  source taskflow_env/bin/activate

  # Vérifier que l'env est actif
  which python   # Pointe vers taskflow_env/bin/python

  # Désactiver
  deactivate

Méthode moderne avec uv (ultra-rapide, recommandée) :
  # Installer uv
  pip install uv

  # Créer un projet
  uv init taskflow
  cd taskflow

  # Ajouter des dépendances
  uv add fastapi uvicorn sqlalchemy pydantic

  # Créer et activer l'env
  uv sync
  source .venv/bin/activate

Gestion des dépendances :
  # Installer une dépendance
  pip install fastapi

  # Sauvegarder les dépendances (pour partager le projet)
  pip freeze > requirements.txt

  # Installer depuis requirements.txt
  pip install -r requirements.txt

  # requirements.txt typique pour TaskFlow :
  fastapi==0.109.0
  uvicorn[standard]==0.27.0
  sqlalchemy==2.0.25
  asyncpg==0.29.0
  pydantic==2.5.3
  python-jose[cryptography]==3.3.0
  passlib[bcrypt]==1.7.4
  python-multipart==0.0.6
  alembic==1.13.1

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5.3 STRUCTURE DE PROJET PROFESSIONNELLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Structure initiale de TaskFlow API :

  taskflow/
  ├── app/
  │   ├── __init__.py
  │   ├── main.py              # Point d'entrée FastAPI
  │   ├── config.py            # Configuration (env vars)
  │   ├── database.py          # Connexion BD
  │   ├── models/              # Modèles SQLAlchemy
  │   │   ├── __init__.py
  │   │   ├── user.py
  │   │   └── task.py
  │   ├── schemas/             # Schémas Pydantic
  │   │   ├── __init__.py
  │   │   ├── user.py
  │   │   └── task.py
  │   ├── api/                 # Routes API
  │   │   ├── __init__.py
  │   │   ├── v1/
  │   │   │   ├── __init__.py
  │   │   │   ├── users.py
  │   │   │   └── tasks.py
  │   │   └── deps.py          # Dépendances communes
  │   ├── services/            # Logique métier
  │   │   ├── __init__.py
  │   │   ├── user_service.py
  │   │   └── task_service.py
  │   ├── core/                # Modules centraux
  │   │   ├── __init__.py
  │   │   ├── security.py      # JWT, hashing
  │   │   └── exceptions.py    # Exceptions personnalisées
  │   └── middleware/          # Middlewares
  │       └── logging.py
  ├── tests/
  │   ├── __init__.py
  │   ├── conftest.py
  │   ├── test_users.py
  │   └── test_tasks.py
  ├── alembic/                 # Migrations BD
  │   └── versions/
  ├── alembic.ini
  ├── requirements.txt
  ├── requirements-dev.txt     # Deps de développement
  ├── .env                     # Variables d'environnement (JAMAIS en git)
  ├── .env.example             # Template .env (en git)
  ├── .gitignore
  ├── Dockerfile
  ├── docker-compose.yml
  └── README.md

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5.4 CONFIGURATION INITIALE COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Fichier .env (variables d'environnement) :
  # .env — NE JAMAIS commit ce fichier !
  DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/taskflow
  SECRET_KEY=super-secret-key-change-in-production-min-32-chars
  ALGORITHM=HS256
  ACCESS_TOKEN_EXPIRE_MINUTES=30
  DEBUG=True
  ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080

Fichier .env.example (template, ce fichier VA en git) :
  # .env.example — Template à copier en .env
  DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/taskflow
  SECRET_KEY=your-secret-key-here
  ALGORITHM=HS256
  ACCESS_TOKEN_EXPIRE_MINUTES=30
  DEBUG=False
  ALLOWED_ORIGINS=http://localhost:3000

Fichier config.py :
  from pydantic_settings import BaseSettings
  from functools import lru_cache

  class Settings(BaseSettings):
      """
      Configuration de l'application.
      Pydantic lit automatiquement depuis les variables d'environnement
      et le fichier .env.
      """
      # Application
      app_name: str = "TaskFlow API"
      debug: bool = False
      api_v1_prefix: str = "/api/v1"

      # Base de données
      database_url: str

      # Sécurité
      secret_key: str
      algorithm: str = "HS256"
      access_token_expire_minutes: int = 30

      # CORS
      allowed_origins: list[str] = ["http://localhost:3000"]

      class Config:
          env_file = ".env"
          case_sensitive = False

  @lru_cache()  # Singleton — on ne crée les settings qu'une fois
  def get_settings() -> Settings:
      return Settings()

  # Utilisation :
  # from app.config import get_settings
  # settings = get_settings()
  # print(settings.database_url)

.gitignore complet pour un projet FastAPI :
  # Python
  __pycache__/
  *.pyc
  *.pyo
  *.pyd
  .Python
  *.egg-info/
  dist/
  build/

  # Virtual environments
  venv/
  env/
  .venv/
  taskflow_env/

  # Environment variables
  .env
  .env.local
  .env.*.local

  # IDE
  .idea/
  .vscode/
  *.sublime-project

  # Tests
  .coverage
  .pytest_cache/
  htmlcov/

  # Database
  *.db
  *.sqlite3

  # Logs
  *.log
  logs/

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5.5 OUTILS DE DÉVELOPPEMENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

IDE recommandé : VS Code ou PyCharm

Extensions VS Code essentielles :
  - Python (Microsoft) -> support complet Python
  - Pylance -> autocomplétion avancée
  - Ruff -> linting et formatage ultra-rapide
  - Thunder Client -> tester les API (comme Postman)
  - GitLens -> Git avancé
  - Docker -> gestion containers

Formatage et linting :
  # Ruff — remplace black, isort, flake8 (beaucoup plus rapide)
  pip install ruff

  # Formater le code
  ruff format app/

  # Linter
  ruff check app/

  # pyproject.toml — configuration Ruff
  [tool.ruff]
  line-length = 88
  select = ["E", "F", "I", "N", "W"]
  ignore = ["E501"]

  [tool.ruff.format]
  quote-style = "double"

Type checking avec mypy :
  pip install mypy
  mypy app/ --strict

Tests :
  pip install pytest pytest-asyncio httpx

Postman/Insomnia ou Thunder Client :
  Pour tester manuellement les API. FastAPI génère aussi automatiquement
  une interface Swagger à http://localhost:8000/docs — très pratique !

================================================================================
                        RÉCAPITULATIF PARTIE 1
================================================================================

Dans cette partie, tu as appris :

[OK] HTTP : protocole, méthodes (GET/POST/PUT/PATCH/DELETE), codes de statut,
         en-têtes, HTTPS — la base de toute API

[OK] JSON : format de données, syntaxe, sérialisation/désérialisation Python,
         bonnes pratiques pour API

[OK] API REST : contraintes Fielding, conception d'URIs, méthodes HTTP
             adaptées, versioning, HATEOAS, Richardson Maturity Model

[OK] Python async : coroutines, async/await, asyncio, event loop,
                  concurrence vs parallélisme, pièges courants

[OK] Environnement : Python, venv/uv, structure de projet professionnelle,
                   configuration, outils (Ruff, mypy, pytest)

[RAPIDE] PROCHAINE ÉTAPE : Partie 2 — Introduction à FastAPI
   - Installation et configuration
   - Première API "Hello World"
   - Compréhension de l'écosystème FastAPI (Starlette, Pydantic, Uvicorn)

================================================================================
                        FIN DE LA PARTIE 1
                  Passe à fastapi_master_part_2.txt
================================================================================

================================================================================
       GUIDE FASTAPI COMPLET — PARTIE 2 : INTRODUCTION À FASTAPI
       Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FIL ROUGE : TaskFlow API
   Dans cette partie, nous créons la structure initiale de TaskFlow API,
   notre première API fonctionnelle, et explorons l'écosystème FastAPI.

================================================================================
                 CHAPITRE 6 — INSTALLATION ET ÉCOSYSTÈME FASTAPI
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6.1 QU'EST-CE QUE FASTAPI ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FastAPI est un framework web Python moderne pour créer des API REST.
Créé par Sebastián Ramírez (tiangolo) en 2018, il est devenu l'un des
frameworks Python les plus populaires en seulement quelques années.

Caractéristiques principales :
  [RAPIDE] RAPIDE : une des meilleures performances Python (rivale NodeJS/Go)
  [RAPIDE] RAPIDE À CODER : réduit de 200-300% le temps de développement
  [BUG] MOINS DE BUGS : validation automatique réduit les erreurs de 40%
  [NOTE] DOCUMENTATION AUTOMATIQUE : Swagger UI et ReDoc générés automatiquement
  [OBJECTIF] TYPE HINTS : exploite pleinement le système de types Python
  [VERROUILLE] VALIDATION : Pydantic v2 pour validation ultra-rapide

FastAPI vs alternatives :
  ┌─────────────┬──────────┬──────────┬──────────┬──────────────┐
  │             │ FastAPI  │  Flask   │  Django  │  Express.js  │
  ├─────────────┼──────────┼──────────┼──────────┼──────────────┤
  │ Performance │  *****  │  ***     │  **      │  *****      │
  │ Async natif │  [OK]       │  [OK]*     │  [OK]*     │  [OK]          │
  │ Validation  │  Auto    │  Manuel  │  Semi    │  Manuel      │
  │ Doc auto    │  [OK]       │  [X]      │  [X]      │  [X]          │
  │ Type hints  │  [OK]       │  [X]      │  [X]      │  TypeScript  │
  │ Courbe appr │  Facile  │  Facile  │  Élevée  │  Moyenne     │
  └─────────────┴──────────┴──────────┴──────────┴──────────────┘

Qui utilise FastAPI en production ?
  - Netflix (ML models serving)
  - Uber (microservices)
  - Microsoft (APIs internes)
  - Apple (ML inference)
  - Des centaines de startups SaaS

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6.2 L'ÉCOSYSTÈME FASTAPI — COMPRENDRE LES COUCHES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FastAPI repose sur 3 briques fondamentales :

  ┌─────────────────────────────────────────────────────┐
  │                    FASTAPI                          │
  │  (routing, dependency injection, documentation...)  │
  ├──────────────────────┬──────────────────────────────┤
  │      STARLETTE       │         PYDANTIC             │
  │   (ASGI, routing,    │   (validation, sérialisation │
  │   middleware,        │    des données, types)       │
  │   WebSockets...)     │                              │
  ├──────────────────────┴──────────────────────────────┤
  │                    UVICORN                          │
  │          (serveur ASGI, async HTTP)                 │
  ├─────────────────────────────────────────────────────┤
  │                    asyncio                          │
  │           (event loop Python)                       │
  └─────────────────────────────────────────────────────┘

STARLETTE : Le socle HTTP
  Starlette est un framework ASGI léger sur lequel FastAPI est construit.
  Il fournit :
  - Routing HTTP
  - Middleware
  - WebSockets
  - Background tasks
  - Sessions
  - Static files

PYDANTIC : La validation des données
  Pydantic valide, sérialise et documente les données.
  Depuis Pydantic v2, il est réécrit en Rust -> 50x plus rapide que v1.
  Il fournit :
  - Validation automatique des types
  - Conversion de types (string "42" -> int 42)
  - Sérialisation vers/depuis JSON
  - Génération de schémas JSON Schema / OpenAPI

UVICORN : Le serveur ASGI
  ASGI = Asynchronous Server Gateway Interface
  C'est le standard pour les serveurs web Python async (successeur de WSGI).
  Uvicorn implémente ASGI avec :
  - Gestion des connexions HTTP/1.1 et HTTP/2
  - WebSockets
  - Lifespan (startup/shutdown events)
  - Rechargement automatique en développement

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6.3 ASGI VS WSGI — COMPRENDRE LA DIFFÉRENCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

WSGI (Web Server Gateway Interface) — L'ANCIEN
  Défini en 2003 (PEP 333). Interface SYNCHRONE.
  Le serveur web appelle l'application Python et ATTEND la réponse.
  Frameworks : Flask, Django (mode classique), Pyramid

  Problème : impossible de gérer nativement les WebSockets ou le streaming.
  Chaque requête bloque un worker jusqu'à la réponse complète.

ASGI (Asynchronous Server Gateway Interface) — LE NOUVEAU
  Défini en 2018. Interface ASYNCHRONE.
  Support natif pour HTTP, WebSockets, Server-Sent Events.
  Frameworks : FastAPI, Starlette, Django Channels, Litestar

  L'ASGI spec :
  async def application(scope, receive, send):
    # scope : type de connexion (http, websocket, lifespan)
    # receive : callable async pour recevoir les événements du client
    # send : callable async pour envoyer des événements au client

    if scope['type'] == 'http':
        # Traiter la requête HTTP
        await send({
            'type': 'http.response.start',
            'status': 200,
            'headers': [(b'content-type', b'application/json')]
        })
        await send({
            'type': 'http.response.body',
            'body': b'{"message": "Hello"}'
        })

FastAPI encapsule cette complexité -> tu n'as jamais besoin d'écrire
du code ASGI brut.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6.4 INSTALLATION DE FASTAPI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# Créer et activer l'environnement virtuel
python -m venv taskflow_env
source taskflow_env/bin/activate  # Mac/Linux
# taskflow_env\Scripts\activate   # Windows

# Installation minimale
pip install fastapi uvicorn

# Installation complète (recommandée)
pip install "fastapi[all]"
# Inclut : uvicorn, pydantic-settings, email-validator,
#          python-multipart, jinja2, httpx, itsdangerous...

# Vérification
python -c "import fastapi; print(fastapi.__version__)"

# Installation des dépendances supplémentaires pour TaskFlow
pip install sqlalchemy asyncpg alembic python-jose passlib bcrypt

Contenu de requirements.txt :
  fastapi==0.109.0
  uvicorn[standard]==0.27.0
  pydantic==2.5.3
  pydantic-settings==2.1.0
  sqlalchemy==2.0.25
  asyncpg==0.29.0
  alembic==1.13.1
  python-jose[cryptography]==3.3.0
  passlib[bcrypt]==1.7.4
  python-multipart==0.0.6
  httpx==0.26.0              # Pour les tests

requirements-dev.txt :
  -r requirements.txt
  pytest==7.4.4
  pytest-asyncio==0.23.3
  pytest-cov==4.1.0
  ruff==0.1.14
  mypy==1.8.0

================================================================================
                       CHAPITRE 7 — PREMIÈRE API FASTAPI
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7.1 HELLO WORLD — ANALYSE LIGNE PAR LIGNE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le fichier le plus simple possible (main.py) :

  # ── Ligne 1 ──────────────────────────────────────────────────────────
  from fastapi import FastAPI
  # On importe la classe FastAPI depuis le package fastapi.
  # FastAPI est le cœur du framework : c'est elle qui orchestre
  # le routing, la validation, la génération de docs, etc.

  # ── Ligne 2 ──────────────────────────────────────────────────────────
  app = FastAPI()
  # On crée une INSTANCE de FastAPI. Cet objet représente notre API.
  # C'est aussi l'objet ASGI que Uvicorn va utiliser.
  # Paramètres optionnels importants :
  #   title="TaskFlow API"      -> nom dans la doc Swagger
  #   description="..."         -> description dans la doc
  #   version="1.0.0"           -> version dans la doc
  #   docs_url="/docs"          -> URL de la doc Swagger (défaut)
  #   redoc_url="/redoc"        -> URL de la doc ReDoc (défaut)

  # ── Ligne 3 ──────────────────────────────────────────────────────────
  @app.get("/")
  # Décorateur de ROUTE. Il :
  # 1. Enregistre cette fonction comme handler pour GET /
  # 2. Associe la méthode HTTP GET
  # 3. Associe le chemin "/"
  # FastAPI va générer automatiquement le schéma OpenAPI pour cette route

  # ── Ligne 4 ──────────────────────────────────────────────────────────
  async def read_root():
  # Fonction handler asynchrone.
  # "async def" -> coroutine Python.
  # Quand FastAPI appelle cette fonction, il l'exécute dans l'event loop.
  # Le nom de la fonction n'a pas d'importance pour le routing
  # (c'est le décorateur qui compte), mais il apparaît dans la doc.

  # ── Ligne 5 ──────────────────────────────────────────────────────────
      return {"message": "Hello World"}
  # Retourner un dict Python -> FastAPI le convertit automatiquement en JSON.
  # FastAPI utilise jsonable_encoder() de Pydantic pour la sérialisation.
  # Il ajoute aussi le header Content-Type: application/json.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7.2 LANCER LE SERVEUR UVICORN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Lancement standard
  uvicorn main:app --reload

  # Décomposition de la commande :
  # uvicorn     -> le serveur ASGI
  # main        -> le nom du fichier Python (main.py, sans l'extension)
  # :app        -> le nom de l'objet FastAPI dans ce fichier
  # --reload    -> rechargement automatique quand le code change (DEV ONLY)

  # Options avancées :
  uvicorn main:app \
    --host 0.0.0.0 \        # écouter sur toutes les interfaces (pas seulement localhost)
    --port 8080 \           # port (défaut: 8000)
    --reload \              # auto-reload (développement)
    --log-level info \      # niveau de logs: debug, info, warning, error, critical
    --workers 4             # nombre de workers (production, PAS avec --reload)

  # Pour la production, on utilise Gunicorn + Uvicorn workers :
  gunicorn main:app \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind 0.0.0.0:8000

Sortie au démarrage :
  INFO:     Will watch for changes in these directories: ['/path/to/project']
  INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
  INFO:     Started reloader process [28720] using WatchFiles
  INFO:     Started server process [28722]
  INFO:     Waiting for application startup.
  INFO:     Application startup complete.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7.3 LA DOCUMENTATION AUTOMATIQUE — UNE MAGIE DE FASTAPI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FastAPI génère automatiquement deux interfaces de documentation :

1. Swagger UI -> http://localhost:8000/docs
   Interface interactive pour tester les API directement dans le navigateur.
   Basée sur la spec OpenAPI 3.0.

2. ReDoc -> http://localhost:8000/redoc
   Documentation lisible et bien formatée.

3. OpenAPI JSON -> http://localhost:8000/openapi.json
   Le schéma brut que les outils peuvent utiliser.

Comment ça fonctionne :
  FastAPI inspecte :
  - Les routes définies (@app.get, @app.post, etc.)
  - Les type hints des paramètres
  - Les modèles Pydantic
  - Les docstrings des fonctions
  -> Génère automatiquement un schéma OpenAPI 3.0 complet

  Exemple : documenter une route complètement
  @app.post(
      "/tasks",
      response_model=TaskResponse,
      status_code=201,
      summary="Créer une tâche",
      description="Crée une nouvelle tâche dans le système TaskFlow",
      tags=["tasks"],
      responses={
          400: {"description": "Données invalides"},
          401: {"description": "Non authentifié"},
      }
  )
  async def create_task(task: TaskCreate):
      """
      Crée une nouvelle tâche avec les paramètres suivants :

      - **title** : le titre de la tâche (obligatoire)
      - **priority** : priorité (low, medium, high, urgent)
      - **due_date** : date limite (optionnelle)
      """
      ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7.4 CYCLE COMPLET REQUÊTE -> RÉPONSE DANS FASTAPI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Voici exactement ce qui se passe quand tu envoies GET /tasks/42 :

  Client (navigateur/Postman)
      │ HTTP Request: GET /tasks/42
      │ Headers: Authorization: Bearer eyJ...
      [BLACK_DOWN-POINTING_TRIANGLE]
  Uvicorn (serveur ASGI)
      │ Reçoit la connexion TCP
      │ Parse la requête HTTP
      │ Crée le scope ASGI
      [BLACK_DOWN-POINTING_TRIANGLE]
  Middleware Stack (s'exécutent dans l'ordre)
      │ CORS Middleware -> vérifie les origines autorisées
      │ Logging Middleware -> log la requête
      │ Auth Middleware -> (si global)
      [BLACK_DOWN-POINTING_TRIANGLE]
  FastAPI Router
      │ Trouve la route correspondante : GET /tasks/{task_id}
      │ Extrait les paramètres : task_id = 42
      [BLACK_DOWN-POINTING_TRIANGLE]
  Dependency Injection
      │ Exécute get_db() -> ouvre une connexion DB
      │ Exécute get_current_user() -> vérifie JWT
      [BLACK_DOWN-POINTING_TRIANGLE]
  Validation Pydantic
      │ Valide task_id (int) -> 42 [OK]
      │ Valide le token JWT
      [BLACK_DOWN-POINTING_TRIANGLE]
  Handler Function (ta logique)
      │ async def get_task(task_id: int, db, current_user)
      │ Appelle la DB : SELECT * FROM tasks WHERE id = 42
      [BLACK_DOWN-POINTING_TRIANGLE]
  Base de données
      │ Requête SQL -> résultat
      [BLACK_DOWN-POINTING_TRIANGLE]
  Handler (suite)
      │ Transforme le résultat
      │ Retourne un objet Pydantic
      [BLACK_DOWN-POINTING_TRIANGLE]
  Sérialisation (Pydantic -> JSON)
      │ Convertit l'objet en dict JSON
      │ Applique les règles response_model
      [BLACK_DOWN-POINTING_TRIANGLE]
  Uvicorn (réponse)
      │ HTTP/1.1 200 OK
      │ Content-Type: application/json
      │ {"id": 42, "title": "...", ...}
      [BLACK_DOWN-POINTING_TRIANGLE]
  Client
      Reçoit la réponse JSON

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7.5 PREMIERE API TASKFLOW COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/main.py — Version initiale de TaskFlow API
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

# ─── Création de l'application ──────────────────────────────────────────
app = FastAPI(
    # Titre affiché dans Swagger et ReDoc
    title="TaskFlow API",

    # Description complète (markdown supporté)
    description="""
## [RAPIDE] TaskFlow API

API de gestion de tâches SaaS. Fonctionnalités :

* **Authentification** : JWT tokens sécurisés
* **Gestion des tâches** : CRUD complet avec filtres avancés
* **Collaboration** : assignation de tâches entre membres d'équipe
* **Notifications** : alertes en temps réel via WebSockets
    """,

    # Version sémantique de l'API
    version="1.0.0",

    # Nom et URL du contact (apparaît dans la doc)
    contact={
        "name": "TaskFlow Team",
        "url": "https://taskflow.io",
        "email": "api@taskflow.io",
    },

    # Licence
    license_info={
        "name": "MIT",
        "url": "https://opensource.org/licenses/MIT",
    },

    # URL des docs (on peut les désactiver en prod : docs_url=None)
    docs_url="/docs",
    redoc_url="/redoc",
    openapi_url="/openapi.json",
)

# ─── Middleware CORS ─────────────────────────────────────────────────────
# CORS = Cross-Origin Resource Sharing
# Nécessaire pour que le front-end (sur un domaine différent) puisse
# appeler notre API. Sans ça, le navigateur bloque les requêtes.
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:3000",   # React dev server
        "http://localhost:8080",   # Vue dev server
        "https://taskflow.io",     # Production
    ],
    allow_credentials=True,        # Autoriser les cookies
    allow_methods=["*"],           # Toutes les méthodes HTTP
    allow_headers=["*"],           # Tous les headers
)

# ─── Routes de base ─────────────────────────────────────────────────────

@app.get(
    "/",
    summary="Racine de l'API",
    tags=["health"],
)
async def root():
    """
    Endpoint racine. Retourne les informations de base de l'API.
    Utile pour vérifier que l'API est opérationnelle.
    """
    return {
        "name": "TaskFlow API",
        "version": "1.0.0",
        "status": "operational",
        "docs": "/docs",
        "health": "/health",
    }

@app.get(
    "/health",
    summary="Vérification de santé",
    tags=["health"],
)
async def health_check():
    """
    Health check endpoint.
    Utilisé par les load balancers et systèmes de monitoring pour
    vérifier que l'API répond correctement.
    Retourne 200 si tout va bien, 503 si la DB est inaccessible.
    """
    return {
        "status": "healthy",
        "database": "connected",   # On vérifiera vraiment en Partie 5
        "version": "1.0.0",
    }

# ─── Events de cycle de vie ─────────────────────────────────────────────

@app.on_event("startup")
async def startup_event():
    """
    Exécuté au démarrage de l'application.
    Bon endroit pour :
    - Initialiser la connexion à la base de données
    - Charger les configurations
    - Initialiser le cache (Redis)
    """
    print("[RAPIDE] TaskFlow API démarrée !")
    # On ajoutera : await database.connect()

@app.on_event("shutdown")
async def shutdown_event():
    """
    Exécuté à l'arrêt de l'application.
    Bon endroit pour :
    - Fermer proprement les connexions DB
    - Vider le cache
    - Logger l'arrêt
    """
    print("[STOP] TaskFlow API arrêtée proprement.")
    # On ajoutera : await database.disconnect()

# ─── Pour lancer directement avec python main.py (optionnel) ────────────
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        "main:app",
        host="0.0.0.0",
        port=8000,
        reload=True,          # En développement seulement
        log_level="info",
    )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7.6 EXERCICES — CHAPITRE 7
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 7.1 : Crée une API FastAPI minimale avec 3 endpoints :
  - GET / -> retourne {"message": "Bienvenue"}
  - GET /info -> retourne le nom et la version de l'API
  - GET /health -> retourne {"status": "ok"}
  Lance-la avec uvicorn et teste avec curl ou le navigateur.

Ex 7.2 : Accède à http://localhost:8000/docs après avoir lancé l'API.
  Identifie : le titre, la description, les endpoints, le bouton "Try it out".
  Teste chaque endpoint directement depuis Swagger UI.

Ex 7.3 : Modifie la configuration FastAPI pour :
  - Changer le titre en "Mon API Perso"
  - Désactiver ReDoc (redoc_url=None)
  - Changer le prefix de la doc à "/api-docs"

NIVEAU INTERMÉDIAIRE
--------------------
Ex 7.4 : Implémente les events startup et shutdown qui :
  - Affichent la date/heure de démarrage
  - Comptent le nombre de redémarrages (en mémoire)
  - Affichent le temps de fonctionnement à l'arrêt

Ex 7.5 : Crée un endpoint GET /version qui retourne les versions de
  FastAPI, Python, Pydantic et Uvicorn installées sur le système.

NIVEAU AVANCÉ
-------------
Ex 7.6 : Implémente un middleware personnalisé qui :
  - Mesure le temps de traitement de chaque requête
  - Ajoute le header X-Process-Time avec le temps en ms
  - Logue chaque requête : method, path, status_code, duration

Ex 7.7 : Explique en détail comment FastAPI génère le schéma OpenAPI
  à partir du code Python. Trace le chemin depuis le décorateur @app.get
  jusqu'au JSON retourné par /openapi.json.

================================================================================
                    CHAPITRE 8 — ROUTES ET ROUTING AVANCÉ
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8.1 DÉFINITION DES ROUTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les routes dans FastAPI sont définies avec des décorateurs sur des
fonctions. Chaque décorateur spécifie la méthode HTTP et le chemin.

Syntaxe de base :
  @app.get("/path")       # Méthode HTTP + chemin
  async def handler():    # Fonction handler
      return {}           # Réponse

Toutes les méthodes HTTP supportées :
  @app.get("/tasks")      # Lire
  @app.post("/tasks")     # Créer
  @app.put("/tasks/{id}") # Remplacer
  @app.patch("/tasks/{id}") # Modifier partiellement
  @app.delete("/tasks/{id}") # Supprimer
  @app.head("/tasks")     # Comme GET sans corps
  @app.options("/tasks")  # Options CORS
  @app.trace("/tasks")    # Trace (rare)

Paramètres des décorateurs de route :
  @app.get(
      "/tasks/{task_id}",      # Path (obligatoire)
      response_model=Task,     # Modèle de réponse (filtrage automatique)
      status_code=200,         # Code de statut par défaut
      tags=["tasks"],          # Groupes dans la doc
      summary="Récupérer une tâche",  # Titre dans la doc
      description="Description détaillée...",  # Description longue
      response_description="La tâche trouvée",  # Description de la réponse
      deprecated=False,        # Marquer comme dépréciée
      include_in_schema=True,  # Inclure dans la doc OpenAPI
      responses={              # Réponses additionnelles documentées
          404: {"description": "Tâche non trouvée"},
          401: {"description": "Non authentifié"},
      },
      name="get_task",         # Nom unique pour reverse URL lookup
  )
  async def get_task(task_id: int):
      ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8.2 ROUTING AVANCÉ — ORDRE ET PRIORITÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

IMPORTANT : FastAPI évalue les routes dans l'ORDRE de définition.
Les routes plus spécifiques doivent être définies AVANT les routes génériques.

# ═══════════════════════════════════════════════════════════════════════
# Exemple de conflits de routes et leur résolution
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI

app = FastAPI()

# [X] PROBLÈME : /tasks/me serait capturé par /tasks/{task_id}
# car "me" serait interprété comme un task_id

# [OK] SOLUTION : Définir les routes fixes AVANT les routes dynamiques

@app.get("/tasks/me")          # Route fixe en PREMIER
async def get_my_tasks():
    """Retourne les tâches de l'utilisateur connecté"""
    return {"tasks": "mes tâches"}

@app.get("/tasks/statistics")  # Autre route fixe
async def get_statistics():
    return {"total": 150, "completed": 89}

@app.get("/tasks/{task_id}")   # Route dynamique EN DERNIER
async def get_task(task_id: int):
    return {"task_id": task_id}

# FastAPI analyse /tasks/me :
#   - Essaie de faire correspondre avec /tasks/me -> [OK] trouvé !
#   - N'essaie pas /tasks/{task_id}

# ─── Routes inclusives et exclusives ────────────────────────────────────

@app.get("/tasks/{task_id:int}")   # Seulement si task_id est un int
async def get_task_by_int_id(task_id: int):
    pass

@app.get("/tasks/{task_slug:str}") # String (fallback)
async def get_task_by_slug(task_slug: str):
    pass

# Convertisseurs de chemin disponibles :
#   {param}       -> str par défaut
#   {param:int}   -> entier
#   {param:float} -> flottant
#   {param:path}  -> n'importe quel chemin (peut contenir des /)

@app.get("/files/{file_path:path}")  # file_path peut être "dir/sous-dir/fichier.txt"
async def read_file(file_path: str):
    return {"path": file_path}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8.3 APIRouter — ORGANISATION MODULAIRE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dans un projet réel, on ne met pas toutes les routes dans main.py.
On utilise APIRouter pour organiser les routes par domaine fonctionnel.

# ═══════════════════════════════════════════════════════════════════════
# app/api/v1/tasks.py — Router pour les tâches
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter, HTTPException, status

# Création du router
router = APIRouter(
    prefix="/tasks",           # Préfixe de toutes les routes de ce router
    tags=["tasks"],            # Tag pour la documentation
    responses={                # Réponses communes à toutes les routes
        401: {"description": "Non authentifié"},
        403: {"description": "Accès refusé"},
    },
)

# Fausse base de données en mémoire (on remplacera par PostgreSQL en Partie 5)
fake_tasks_db: list[dict] = [
    {"id": 1, "title": "Implémenter JWT", "completed": False, "priority": "high"},
    {"id": 2, "title": "Créer les modèles DB", "completed": True, "priority": "medium"},
    {"id": 3, "title": "Écrire les tests", "completed": False, "priority": "low"},
]

@router.get("/", summary="Lister toutes les tâches")
async def list_tasks(
    skip: int = 0,
    limit: int = 100,
) -> list[dict]:
    """
    Retourne la liste de toutes les tâches avec pagination.

    - **skip**: nombre de tâches à ignorer (pour la pagination)
    - **limit**: nombre maximum de tâches à retourner
    """
    return fake_tasks_db[skip : skip + limit]

@router.get("/{task_id}", summary="Récupérer une tâche")
async def get_task(task_id: int) -> dict:
    """
    Récupère une tâche spécifique par son ID.
    Retourne 404 si la tâche n'existe pas.
    """
    # Chercher la tâche dans la "DB"
    task = next(
        (t for t in fake_tasks_db if t["id"] == task_id),
        None  # valeur par défaut si non trouvé
    )

    # Si non trouvé -> lever une HTTPException
    if task is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Tâche avec l'id {task_id} non trouvée",
        )

    return task

@router.post("/", status_code=status.HTTP_201_CREATED, summary="Créer une tâche")
async def create_task(task_data: dict) -> dict:
    """Crée une nouvelle tâche."""
    new_id = max(t["id"] for t in fake_tasks_db) + 1 if fake_tasks_db else 1
    new_task = {
        "id": new_id,
        "title": task_data.get("title", ""),
        "completed": False,
        "priority": task_data.get("priority", "medium"),
    }
    fake_tasks_db.append(new_task)
    return new_task

@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(task_id: int):
    """Supprime une tâche. Retourne 404 si inexistante."""
    global fake_tasks_db
    task = next((t for t in fake_tasks_db if t["id"] == task_id), None)
    if task is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Tâche {task_id} non trouvée",
        )
    fake_tasks_db = [t for t in fake_tasks_db if t["id"] != task_id]
    # 204 No Content -> pas de corps de réponse

# ═══════════════════════════════════════════════════════════════════════
# app/api/v1/users.py — Router pour les utilisateurs
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter

router = APIRouter(
    prefix="/users",
    tags=["users"],
)

@router.get("/", summary="Lister les utilisateurs")
async def list_users() -> list[dict]:
    return [
        {"id": 1, "name": "Alice", "email": "alice@taskflow.com"},
        {"id": 2, "name": "Bob", "email": "bob@taskflow.com"},
    ]

@router.get("/me", summary="Profil utilisateur connecté")
async def get_current_user_profile() -> dict:
    # Plus tard, on récupérera l'utilisateur depuis le JWT
    return {"id": 1, "name": "Alice", "email": "alice@taskflow.com"}

# ═══════════════════════════════════════════════════════════════════════
# app/main.py — Intégration des routers
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI
from app.api.v1 import tasks, users   # Import des routers

app = FastAPI(title="TaskFlow API", version="1.0.0")

# Inclusion des routers avec préfixe de version
app.include_router(
    tasks.router,              # Le router des tâches
    prefix="/api/v1",          # Préfixe global (s'ajoute au prefix du router)
    # Résultat : toutes les routes tasks ont le préfixe /api/v1/tasks
)

app.include_router(
    users.router,
    prefix="/api/v1",
    # Résultat : toutes les routes users ont le préfixe /api/v1/users
)

# Routes résultantes :
# GET    /api/v1/tasks/
# GET    /api/v1/tasks/{task_id}
# POST   /api/v1/tasks/
# DELETE /api/v1/tasks/{task_id}
# GET    /api/v1/users/
# GET    /api/v1/users/me

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8.4 GESTION DES ERREURS HTTP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# HTTPException — Lever des erreurs HTTP
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI, HTTPException, status

app = FastAPI()

@app.get("/tasks/{task_id}")
async def get_task(task_id: int):
    # HTTPException interrompt l'exécution et retourne une réponse d'erreur
    if task_id <= 0:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,  # 400
            detail="L'ID de tâche doit être positif",  # Message d'erreur
            headers={"X-Error": "invalid-id"},          # Headers optionnels
        )

    if task_id > 10000:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,  # 404
            detail={                                  # detail peut être un dict !
                "message": f"Tâche {task_id} non trouvée",
                "task_id": task_id,
                "suggestion": "Vérifiez l'ID de la tâche",
            },
        )

    return {"id": task_id, "title": "Tâche exemple"}

# Module status — TOUJOURS utiliser les constantes plutôt que les nombres bruts
# status.HTTP_200_OK           = 200
# status.HTTP_201_CREATED      = 201
# status.HTTP_204_NO_CONTENT   = 204
# status.HTTP_400_BAD_REQUEST  = 400
# status.HTTP_401_UNAUTHORIZED = 401
# status.HTTP_403_FORBIDDEN    = 403
# status.HTTP_404_NOT_FOUND    = 404
# status.HTTP_422_UNPROCESSABLE_ENTITY = 422
# status.HTTP_500_INTERNAL_SERVER_ERROR = 500

# ═══════════════════════════════════════════════════════════════════════
# Exception Handlers personnalisés
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

# Exception personnalisée
class TaskNotFoundError(Exception):
    def __init__(self, task_id: int):
        self.task_id = task_id
        self.message = f"Tâche {task_id} non trouvée"

class InsufficientPermissionsError(Exception):
    def __init__(self, action: str):
        self.action = action

# Handlers d'exception
@app.exception_handler(TaskNotFoundError)
async def task_not_found_handler(request: Request, exc: TaskNotFoundError):
    return JSONResponse(
        status_code=404,
        content={
            "error": "TASK_NOT_FOUND",
            "message": exc.message,
            "task_id": exc.task_id,
        },
    )

@app.exception_handler(InsufficientPermissionsError)
async def permissions_handler(request: Request, exc: InsufficientPermissionsError):
    return JSONResponse(
        status_code=403,
        content={
            "error": "INSUFFICIENT_PERMISSIONS",
            "message": f"Action '{exc.action}' non autorisée",
        },
    )

# Handler global pour toutes les exceptions non gérées
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    # En production, on loggerait ici et n'enverrait pas les détails
    import logging
    logging.error(f"Erreur non gérée : {exc}", exc_info=True)

    return JSONResponse(
        status_code=500,
        content={
            "error": "INTERNAL_SERVER_ERROR",
            "message": "Une erreur inattendue s'est produite",
        },
    )

# Utilisation dans les handlers
@app.get("/tasks/{task_id}")
async def get_task(task_id: int):
    raise TaskNotFoundError(task_id=task_id)  # -> 404 avec format standardisé

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8.5 EXERCICES — CHAPITRE 8
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 8.1 : Crée 3 routers séparés pour :
  - /api/v1/projects (CRUD)
  - /api/v1/comments (CRUD)
  - /api/v1/tags (liste et création)
  Intègre-les dans main.py.

Ex 8.2 : Ajoute une gestion d'erreur pour une liste de tâches qui
  retourne 400 si limit > 100 ou skip < 0.

NIVEAU INTERMÉDIAIRE
--------------------
Ex 8.3 : Implémente un système de pagination complet qui retourne :
  {"data": [...], "total": 150, "page": 2, "per_page": 20, "total_pages": 8}

Ex 8.4 : Crée un handler d'exception personnalisé pour une exception
  ValidationBusinessError avec un format d'erreur standardisé.

NIVEAU AVANCÉ
-------------
Ex 8.5 : Implémente un système de routing dynamique qui charge
  automatiquement tous les routers depuis un dossier app/api/v1/.
  Chaque fichier .py non-privé devient automatiquement un router.

================================================================================
                    CHAPITRE 9 — MÉTHODES HTTP ET STATUS CODES
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9.1 IMPLÉMENTATION COMPLÈTE CRUD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# CRUD complet pour les tâches TaskFlow
# Sans base de données (version mémoire — on ajoutera PostgreSQL en P5)
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional

router = APIRouter(prefix="/tasks", tags=["tasks"])

# ── Modèles de données (introduction à Pydantic) ─────────────────────

class TaskBase(BaseModel):
    """Champs communs à la création et la mise à jour"""
    title: str = Field(..., min_length=1, max_length=200, description="Titre de la tâche")
    description: Optional[str] = Field(None, description="Description détaillée")
    priority: str = Field("medium", pattern="^(low|medium|high|urgent)$")
    due_date: Optional[str] = Field(None, description="Date limite ISO 8601")

class TaskCreate(TaskBase):
    """Données pour créer une tâche"""
    assigned_to: Optional[int] = None

class TaskUpdate(BaseModel):
    """Données pour mettre à jour une tâche (tous les champs optionnels)"""
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    description: Optional[str] = None
    priority: Optional[str] = Field(None, pattern="^(low|medium|high|urgent)$")
    due_date: Optional[str] = None
    completed: Optional[bool] = None

class TaskResponse(TaskBase):
    """Données retournées dans les réponses"""
    id: int
    completed: bool
    created_at: str
    updated_at: Optional[str]
    assigned_to: Optional[int]

    class Config:
        from_attributes = True  # Pydantic v2 : lire depuis des objets SQLAlchemy

# ── Base de données en mémoire ───────────────────────────────────────
tasks_db: dict[int, dict] = {
    1: {
        "id": 1,
        "title": "Configurer FastAPI",
        "description": "Installer et configurer le projet",
        "priority": "high",
        "completed": True,
        "due_date": "2024-01-20",
        "created_at": "2024-01-10T10:00:00",
        "updated_at": None,
        "assigned_to": 1,
    },
    2: {
        "id": 2,
        "title": "Créer les modèles Pydantic",
        "description": None,
        "priority": "medium",
        "completed": False,
        "due_date": None,
        "created_at": "2024-01-11T14:30:00",
        "updated_at": None,
        "assigned_to": None,
    },
}
next_id = 3  # Prochain ID disponible

# ── GET / — Lister avec filtres ───────────────────────────────────────

@router.get(
    "/",
    response_model=list[TaskResponse],
    summary="Lister les tâches",
    description="Retourne toutes les tâches avec filtres optionnels",
)
async def list_tasks(
    skip: int = 0,
    limit: int = 20,
    completed: Optional[bool] = None,          # Filtre par statut
    priority: Optional[str] = None,            # Filtre par priorité
    assigned_to: Optional[int] = None,         # Filtre par assignataire
) -> list[dict]:
    """
    Paramètres de filtrage :
    - **skip** : pagination - sauter N éléments
    - **limit** : pagination - max N éléments (max: 100)
    - **completed** : filtrer par statut (true/false)
    - **priority** : filtrer par priorité (low/medium/high/urgent)
    """
    tasks = list(tasks_db.values())

    # Application des filtres
    if completed is not None:
        tasks = [t for t in tasks if t["completed"] == completed]
    if priority is not None:
        tasks = [t for t in tasks if t["priority"] == priority]
    if assigned_to is not None:
        tasks = [t for t in tasks if t["assigned_to"] == assigned_to]

    # Pagination
    return tasks[skip : skip + limit]

# ── GET /{task_id} — Récupérer une tâche ─────────────────────────────

@router.get(
    "/{task_id}",
    response_model=TaskResponse,
    summary="Récupérer une tâche",
    responses={
        200: {"description": "Tâche trouvée"},
        404: {"description": "Tâche non trouvée"},
    },
)
async def get_task(task_id: int) -> dict:
    """Récupère une tâche par son ID."""
    if task_id not in tasks_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail={"message": f"Tâche {task_id} non trouvée", "task_id": task_id},
        )
    return tasks_db[task_id]

# ── POST / — Créer une tâche ─────────────────────────────────────────

@router.post(
    "/",
    response_model=TaskResponse,
    status_code=status.HTTP_201_CREATED,
    summary="Créer une tâche",
)
async def create_task(task_data: TaskCreate) -> dict:
    """
    Crée une nouvelle tâche.
    Retourne 201 Created avec la tâche créée.
    """
    global next_id

    new_task = {
        "id": next_id,
        "title": task_data.title,
        "description": task_data.description,
        "priority": task_data.priority,
        "completed": False,
        "due_date": task_data.due_date,
        "created_at": datetime.now().isoformat(),
        "updated_at": None,
        "assigned_to": task_data.assigned_to,
    }

    tasks_db[next_id] = new_task
    next_id += 1

    return new_task

# ── PUT /{task_id} — Remplacer une tâche ─────────────────────────────

@router.put(
    "/{task_id}",
    response_model=TaskResponse,
    summary="Remplacer une tâche",
)
async def replace_task(task_id: int, task_data: TaskCreate) -> dict:
    """
    Remplace COMPLÈTEMENT une tâche existante.
    Tous les champs doivent être fournis.
    Retourne 404 si la tâche n'existe pas.
    """
    if task_id not in tasks_db:
        raise HTTPException(status_code=404, detail=f"Tâche {task_id} non trouvée")

    updated_task = {
        "id": task_id,
        "title": task_data.title,
        "description": task_data.description,
        "priority": task_data.priority,
        "completed": tasks_db[task_id]["completed"],  # On préserve le statut
        "due_date": task_data.due_date,
        "created_at": tasks_db[task_id]["created_at"],  # On préserve la date de création
        "updated_at": datetime.now().isoformat(),
        "assigned_to": task_data.assigned_to,
    }

    tasks_db[task_id] = updated_task
    return updated_task

# ── PATCH /{task_id} — Modifier partiellement ────────────────────────

@router.patch(
    "/{task_id}",
    response_model=TaskResponse,
    summary="Modifier partiellement une tâche",
)
async def update_task(task_id: int, task_data: TaskUpdate) -> dict:
    """
    Modifie PARTIELLEMENT une tâche.
    Seuls les champs fournis sont modifiés.
    Les champs non fournis restent inchangés.
    """
    if task_id not in tasks_db:
        raise HTTPException(status_code=404, detail=f"Tâche {task_id} non trouvée")

    existing_task = tasks_db[task_id].copy()

    # Mise à jour seulement des champs fournis (non-None)
    update_data = task_data.model_dump(exclude_unset=True)
    # model_dump(exclude_unset=True) -> retourne seulement les champs explicitement définis

    for field, value in update_data.items():
        existing_task[field] = value

    existing_task["updated_at"] = datetime.now().isoformat()
    tasks_db[task_id] = existing_task

    return existing_task

# ── DELETE /{task_id} — Supprimer ────────────────────────────────────

@router.delete(
    "/{task_id}",
    status_code=status.HTTP_204_NO_CONTENT,
    summary="Supprimer une tâche",
)
async def delete_task(task_id: int):
    """
    Supprime une tâche. Retourne 204 No Content (pas de corps).
    Retourne 404 si la tâche n'existe pas.
    """
    if task_id not in tasks_db:
        raise HTTPException(status_code=404, detail=f"Tâche {task_id} non trouvée")

    del tasks_db[task_id]
    # Pas de return -> FastAPI retourne automatiquement 204 No Content

# ── POST /{task_id}/complete — Action métier ─────────────────────────

@router.post(
    "/{task_id}/complete",
    response_model=TaskResponse,
    summary="Marquer une tâche comme complète",
)
async def complete_task(task_id: int) -> dict:
    """
    Action métier : marque une tâche comme complète.
    Exemple d'endpoint d'action quand le verbe HTTP ne suffit pas.
    """
    if task_id not in tasks_db:
        raise HTTPException(status_code=404, detail=f"Tâche {task_id} non trouvée")

    task = tasks_db[task_id]

    if task["completed"]:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="La tâche est déjà marquée comme complète",
        )

    task["completed"] = True
    task["updated_at"] = datetime.now().isoformat()

    return task

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9.2 BONNES PRATIQUES — MÉTHODES HTTP ET STATUS CODES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Tableau de référence rapide :

  ┌────────────┬──────────────┬───────────┬────────────────────────────┐
  │ Opération  │ Méthode      │ Status OK │ Notes                      │
  ├────────────┼──────────────┼───────────┼────────────────────────────┤
  │ Lister     │ GET /items   │ 200       │ Avec pagination et filtres │
  │ Récupérer  │ GET /items/1 │ 200       │ 404 si non trouvé          │
  │ Créer      │ POST /items  │ 201       │ Retourne l'item créé       │
  │ Remplacer  │ PUT /items/1 │ 200       │Tous les champs obligatoires│
  │ Modifier   │ PATCH /items1│ 200       │ Champs partiels ok         │
  │ Supprimer  │ DELETE /item1│ 204       │ Pas de corps de réponse    │
  └────────────┴──────────────┴───────────┴────────────────────────────┘

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9.3 EXERCICES — CHAPITRE 9
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 9.1 : Ajoute au router tasks un endpoint POST /{task_id}/assign
  qui assigne une tâche à un utilisateur (paramètre: user_id: int).
  Retourne 404 si la tâche n'existe pas, 400 si l'user_id <= 0.

Ex 9.2 : Crée un endpoint GET /tasks/stats qui retourne :
  {"total": 10, "completed": 3, "pending": 7, "by_priority": {...}}

NIVEAU INTERMÉDIAIRE
--------------------
Ex 9.3 : Implémente un endpoint PATCH /tasks/{id}/priority qui change
  seulement la priorité et valide que la valeur est parmi
  low/medium/high/urgent.

Ex 9.4 : Crée un endpoint POST /tasks/bulk-delete qui accepte une liste
  d'IDs et supprime toutes les tâches correspondantes. Retourne le
  nombre de tâches supprimées et les IDs non trouvés.

NIVEAU AVANCÉ
-------------
Ex 9.5 : Implémente un système de soft delete : au lieu de supprimer
  physiquement, marque la tâche comme deleted=True avec deleted_at.
  Les endpoints GET ne retournent plus les tâches supprimées.
  Ajoute un endpoint GET /tasks/trash pour voir les supprimées.

================================================================================
                           RÉCAPITULATIF PARTIE 2
================================================================================

Dans cette partie, tu as appris :

[OK] L'écosystème FastAPI : FastAPI + Starlette + Pydantic + Uvicorn
[OK] ASGI vs WSGI : interface asynchrone vs synchrone
[OK] Créer une application FastAPI avec configuration complète
[OK] Documentation automatique : Swagger UI, ReDoc, OpenAPI
[OK] Routing : décorateurs, ordre, APIRouter, organisation modulaire
[OK] Gestion des erreurs : HTTPException, handlers personnalisés
[OK] CRUD complet : GET, POST, PUT, PATCH, DELETE avec status codes corrects

[RAPIDE] PROCHAINE ÉTAPE : Partie 3 — Paramètres et Validation
   - Path parameters avec types et validation
   - Query parameters avec valeurs par défaut
   - Request body avec Pydantic models
   - Validation avancée avec Pydantic v2

================================================================================
                            FIN DE LA PARTIE 2
                     Passe à fastapi_master_part_3.txt
================================================================================

================================================================================
     GUIDE FASTAPI COMPLET — PARTIE 3 : PARAMÈTRES ET VALIDATION
     Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FIL ROUGE : TaskFlow API
   Dans cette partie, nous ajoutons une validation robuste à TaskFlow,
   rendant l'API capable de rejeter automatiquement les données invalides
   avec des messages d'erreur clairs et utiles.

================================================================================
                   CHAPITRE 10 — PATH PARAMETERS
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
10.1 INTRODUCTION AUX PATH PARAMETERS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les path parameters (paramètres de chemin) sont des parties variables
de l'URL, délimitées par des accolades. Ils permettent d'identifier
une ressource spécifique.

Exemples :
  GET /tasks/{task_id}         -> task_id est le path param
  GET /users/{user_id}/tasks   -> user_id est le path param
  GET /files/{file_path:path}  -> file_path est le path param

FastAPI extrait automatiquement la valeur du chemin et la convertit
dans le type annoté. Si la conversion échoue -> 422 automatique.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
10.2 TYPES ET CONVERSIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Types de path parameters supportés
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI, Path
from uuid import UUID
from enum import Enum

app = FastAPI()

# ── int ───────────────────────────────────────────────────────────────
@app.get("/tasks/{task_id}")
async def get_task(task_id: int):
    # FastAPI convertit "42" (string URL) -> 42 (int Python)
    # GET /tasks/42    -> task_id = 42  [OK]
    # GET /tasks/abc   -> 422 Unprocessable Entity [X]
    # GET /tasks/42.5  -> 422 Unprocessable Entity [X]
    return {"task_id": task_id, "type": type(task_id).__name__}

# ── float ─────────────────────────────────────────────────────────────
@app.get("/scores/{score}")
async def get_score(score: float):
    # GET /scores/9.5  -> score = 9.5  [OK]
    # GET /scores/7    -> score = 7.0  [OK]
    return {"score": score}

# ── bool ─────────────────────────────────────────────────────────────
@app.get("/flags/{active}")
async def get_flag(active: bool):
    # GET /flags/true  -> active = True  [OK]
    # GET /flags/1     -> active = True  [OK]
    # GET /flags/false -> active = False [OK]
    # GET /flags/0     -> active = False [OK]
    # GET /flags/yes   -> active = True  [OK] (FastAPI l'accepte)
    return {"active": active}

# ── UUID ──────────────────────────────────────────────────────────────
@app.get("/items/{item_id}")
async def get_item(item_id: UUID):
    # GET /items/550e8400-e29b-41d4-a716-446655440000 -> UUID Python [OK]
    # GET /items/not-a-uuid -> 422 [X]
    return {"item_id": str(item_id)}

# ── Enum (choix limités) ───────────────────────────────────────────────
class Priority(str, Enum):
    """Enum pour la priorité — valeurs prédéfinies uniquement"""
    low = "low"
    medium = "medium"
    high = "high"
    urgent = "urgent"

@app.get("/tasks/by-priority/{priority}")
async def get_tasks_by_priority(priority: Priority):
    # GET /tasks/by-priority/high   -> priority = Priority.high [OK]
    # GET /tasks/by-priority/HIGH   -> 422 (case sensitive) [X]
    # GET /tasks/by-priority/vip    -> 422 [X]
    # priority.value -> "high" (la valeur string)
    return {"priority": priority.value, "tasks": []}

# ── str (type par défaut) ─────────────────────────────────────────────
@app.get("/slugs/{slug}")
async def get_by_slug(slug: str):
    # Pas de conversion, accepte n'importe quelle chaîne
    return {"slug": slug}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
10.3 VALIDATION AVANCÉE AVEC Path()
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Path() permet d'ajouter des contraintes de validation et des métadonnées
de documentation directement sur le paramètre de chemin.

# ═══════════════════════════════════════════════════════════════════════
# Validation avec Path()
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI, Path
from typing import Annotated

app = FastAPI()

@app.get("/tasks/{task_id}")
async def get_task(
    task_id: Annotated[
        int,
        Path(
            title="ID de la tâche",              # Nom dans la doc
            description="Identifiant unique numérique de la tâche",
            ge=1,                                 # >= 1 (greater or equal)
            le=1_000_000,                         # <= 1_000_000 (less or equal)
            example=42,                           # Exemple dans la doc Swagger
        )
    ]
):
    # GET /tasks/0    -> 422 (ge=1 non respecté)
    # GET /tasks/-5   -> 422 (ge=1 non respecté)
    # GET /tasks/42   -> task_id = 42 [OK]
    return {"task_id": task_id}

# Contraintes numériques disponibles :
#   ge  : Greater or Equal (>=)
#   le  : Less or Equal (<=)
#   gt  : Greater Than (>)
#   lt  : Less Than (<)
#   multiple_of : multiple de N

@app.get("/pages/{page_num}")
async def get_page(
    page_num: Annotated[
        int,
        Path(gt=0, description="Numéro de page (commence à 1)")
    ]
):
    return {"page": page_num}

# Contraintes de string :
@app.get("/users/{username}")
async def get_user(
    username: Annotated[
        str,
        Path(
            min_length=3,                           # Longueur minimale
            max_length=50,                          # Longueur maximale
            pattern=r"^[a-zA-Z0-9_-]+$",          # Regex : alphanum + _ -
            description="Nom d'utilisateur (lettres, chiffres, _, -)"
        )
    ]
):
    return {"username": username}

# ── Plusieurs path params ─────────────────────────────────────────────

@app.get("/projects/{project_id}/tasks/{task_id}")
async def get_project_task(
    project_id: Annotated[int, Path(ge=1, title="ID du projet")],
    task_id: Annotated[int, Path(ge=1, title="ID de la tâche")],
):
    """
    Récupère une tâche spécifique d'un projet.
    Les deux IDs sont validés indépendamment.
    """
    return {
        "project_id": project_id,
        "task_id": task_id,
    }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
10.4 EXERCICES — CHAPITRE 10
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 10.1 : Crée un endpoint GET /users/{user_id}/profile qui :
  - Valide que user_id est un entier positif
  - Retourne des données fictives de profil

Ex 10.2 : Crée une Enum Status avec les valeurs : todo, in_progress,
  done, cancelled. Utilise-la dans GET /tasks/by-status/{status}.

NIVEAU INTERMÉDIAIRE
--------------------
Ex 10.3 : Crée un endpoint GET /reports/{year}/{month} qui :
  - Valide year entre 2000 et 2100
  - Valide month entre 1 et 12
  - Retourne les stats du mois demandé (données fictives)

Ex 10.4 : Implémente GET /teams/{team_slug}/members/{member_id} avec :
  - team_slug : lettres et tirets uniquement, 3-50 chars
  - member_id : UUID valide

NIVEAU AVANCÉ
-------------
Ex 10.5 : Crée un convertisseur de chemin personnalisé pour des IDs
  chiffrés (format: "TSK-XXXXX" où X est un chiffre). Implémente la
  validation avec un pattern regex.

================================================================================
                    CHAPITRE 11 — QUERY PARAMETERS
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
11.1 INTRODUCTION AUX QUERY PARAMETERS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les query parameters sont les paramètres après le "?" dans l'URL.
Ils sont utilisés pour le filtrage, la pagination, le tri, etc.

Syntaxe URL :
  GET /tasks?skip=0&limit=20&completed=false&priority=high

  Plusieurs valeurs pour un paramètre :
  GET /tasks?tags=backend&tags=security&tags=auth

Dans FastAPI, un paramètre de fonction qui n'est pas un path param
est automatiquement traité comme query param.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
11.2 QUERY PARAMETERS — TYPES ET DÉFAUTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Query params basiques
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI, Query
from typing import Annotated, Optional

app = FastAPI()

# ── Paramètres obligatoires ───────────────────────────────────────────
@app.get("/search")
async def search_tasks(q: str):
    # q est OBLIGATOIRE car pas de valeur par défaut
    # GET /search         -> 422 (q manquant)
    # GET /search?q=hello -> q = "hello" [OK]
    return {"query": q, "results": []}

# ── Paramètres optionnels ─────────────────────────────────────────────
@app.get("/tasks")
async def list_tasks(
    skip: int = 0,              # Défaut = 0
    limit: int = 20,            # Défaut = 20
    completed: Optional[bool] = None,  # Défaut = None (pas de filtre)
    priority: Optional[str] = None,
):
    # GET /tasks                           -> skip=0, limit=20, completed=None
    # GET /tasks?limit=50                  -> skip=0, limit=50, completed=None
    # GET /tasks?completed=true&limit=10   -> completed=True, limit=10
    # GET /tasks?skip=20&limit=20          -> pagination page 2

    tasks = [{"id": i, "title": f"Task {i}"} for i in range(1, 11)]

    if completed is not None:
        tasks = [t for t in tasks if t.get("completed") == completed]

    return {
        "data": tasks[skip:skip + limit],
        "pagination": {
            "skip": skip,
            "limit": limit,
            "total": len(tasks),
        }
    }

# ── Listes de valeurs ─────────────────────────────────────────────────
@app.get("/tasks/filter")
async def filter_tasks(
    tags: list[str] = Query(default=[]),   # ?tags=backend&tags=api
    ids: list[int] = Query(default=[]),    # ?ids=1&ids=2&ids=5
):
    # GET /tasks/filter?tags=backend&tags=security -> tags=["backend", "security"]
    # GET /tasks/filter?ids=1&ids=2&ids=3          -> ids=[1, 2, 3]
    return {"tags": tags, "ids": ids}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
11.3 VALIDATION AVANCÉE AVEC Query()
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Query() avec validation complète — Version production TaskFlow
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter, Query
from typing import Annotated, Optional
from enum import Enum

router = APIRouter(prefix="/tasks", tags=["tasks"])

class SortOrder(str, Enum):
    asc = "asc"
    desc = "desc"

class SortField(str, Enum):
    created_at = "created_at"
    updated_at = "updated_at"
    due_date = "due_date"
    priority = "priority"
    title = "title"

@router.get("/", summary="Lister les tâches avec filtres avancés")
async def list_tasks(
    # ── Pagination ────────────────────────────────────────────────────
    page: Annotated[
        int,
        Query(ge=1, description="Numéro de page (commence à 1)", example=1)
    ] = 1,

    per_page: Annotated[
        int,
        Query(ge=1, le=100, description="Éléments par page (max 100)", example=20)
    ] = 20,

    # ── Filtres ───────────────────────────────────────────────────────
    q: Annotated[
        Optional[str],
        Query(
            min_length=2,
            max_length=100,
            description="Recherche textuelle dans titre et description",
            example="implémenter"
        )
    ] = None,

    completed: Annotated[
        Optional[bool],
        Query(description="Filtrer par statut (true=complètes, false=en cours)")
    ] = None,

    priority: Annotated[
        Optional[list[str]],
        Query(description="Filtrer par priorité (peut spécifier plusieurs)")
    ] = None,

    assigned_to: Annotated[
        Optional[int],
        Query(ge=1, description="Filtrer par ID d'utilisateur assigné")
    ] = None,

    due_before: Annotated[
        Optional[str],
        Query(
            pattern=r"^\d{4}-\d{2}-\d{2}$",
            description="Tâches dues avant cette date (YYYY-MM-DD)",
            example="2024-12-31"
        )
    ] = None,

    # ── Tri ───────────────────────────────────────────────────────────
    sort_by: Annotated[
        SortField,
        Query(description="Champ de tri")
    ] = SortField.created_at,

    order: Annotated[
        SortOrder,
        Query(description="Ordre de tri")
    ] = SortOrder.desc,

    # ── Champs ────────────────────────────────────────────────────────
    fields: Annotated[
        Optional[list[str]],
        Query(description="Champs à inclure dans la réponse (sparse fieldsets)")
    ] = None,
):
    """
    Retourne une liste paginée de tâches avec filtres avancés.

    Exemple d'URL :
    /tasks?page=2&per_page=10&q=api&priority=high&sort_by=due_date&order=asc
    """
    skip = (page - 1) * per_page

    # Construction du filtre (ici simulé)
    filters = {}
    if completed is not None:
        filters["completed"] = completed
    if priority:
        filters["priority__in"] = priority
    if q:
        filters["search"] = q
    if assigned_to:
        filters["assigned_to"] = assigned_to

    # Simulation de données
    total = 150
    tasks = [
        {
            "id": skip + i + 1,
            "title": f"Tâche {skip + i + 1}",
            "priority": "high",
            "completed": False,
        }
        for i in range(per_page)
    ]

    total_pages = -(-total // per_page)  # Ceiling division

    return {
        "data": tasks,
        "pagination": {
            "page": page,
            "per_page": per_page,
            "total": total,
            "total_pages": total_pages,
            "has_next": page < total_pages,
            "has_prev": page > 1,
        },
        "filters": filters,
        "sort": {"field": sort_by.value, "order": order.value},
    }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
11.4 ALIAS ET NOMMAGE DES QUERY PARAMS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Parfois l'URL utilise des noms différents des variables Python.

# ── Alias ─────────────────────────────────────────────────────────────
from fastapi import Query
from typing import Annotated

@app.get("/tasks")
async def list_tasks(
    # URL: ?per-page=20 (avec tiret, commun dans les APIs)
    # Python: per_page (avec underscore, convention Python)
    per_page: Annotated[
        int,
        Query(alias="per-page", ge=1, le=100)
    ] = 20,

    # URL: ?sort-by=created_at
    # Python: sort_by
    sort_by: Annotated[
        str,
        Query(alias="sort-by")
    ] = "created_at",
):
    return {"per_page": per_page, "sort_by": sort_by}

# ── Déprécier un paramètre ────────────────────────────────────────────
@app.get("/tasks")
async def list_tasks_v2(
    # L'ancien paramètre "offset" est maintenant "skip"
    skip: int = Query(0, description="Nombre d'éléments à ignorer"),
    offset: int = Query(
        0,
        deprecated=True,  # Marqué comme déprécié dans la doc
        description="Utiliser 'skip' à la place"
    ),
):
    actual_skip = skip or offset  # Compatibilité backward
    return {"skip": actual_skip}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
11.5 EXERCICES — CHAPITRE 11
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 11.1 : Crée un endpoint de recherche GET /search?q=...&type=...
  où type peut être "task", "project", ou "user".
  Retourne des résultats fictifs selon le type.

Ex 11.2 : Ajoute la pagination à GET /users avec :
  - page (défaut 1, min 1)
  - per_page (défaut 10, min 1, max 50)

NIVEAU INTERMÉDIAIRE
--------------------
Ex 11.3 : Implémente un filtre de date pour GET /tasks avec :
  - created_after : tâches créées après cette date
  - created_before : tâches créées avant cette date
  - Valider le format YYYY-MM-DD et que created_after < created_before

Ex 11.4 : Crée un endpoint GET /tasks/export avec :
  - format : "json", "csv", "xlsx"
  - fields : liste des champs à exporter
  Retourne un message indiquant ce qui sera exporté.

NIVEAU AVANCÉ
-------------
Ex 11.5 : Implémente un système de filtrage générique inspiré de l'ORM
  Django où on peut passer des filtres comme :
  ?priority__in=high,urgent&title__contains=API&completed__eq=false
  Parse ces paramètres et construis une structure de filtres.

================================================================================
                     CHAPITRE 12 — REQUEST BODY
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
12.1 LE CORPS DE REQUÊTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le corps de requête (request body) transporte les données envoyées par
le client vers le serveur, typiquement en JSON.

Utilisé avec : POST, PUT, PATCH (et parfois DELETE pour des actions).
Rarement avec GET (déconseillé par la spec HTTP).

FastAPI utilise les modèles Pydantic pour déclarer le schéma attendu.
Pydantic valide automatiquement, convertit les types, et génère la doc.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
12.2 MODÈLES PYDANTIC POUR LE CORPS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Modèle de base avec Pydantic
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class TaskCreate(BaseModel):
    """
    Modèle pour créer une tâche.
    Pydantic valide automatiquement chaque champ.
    """
    title: str                      # Obligatoire, string
    description: Optional[str]      # Optionnel, None par défaut si absent
    priority: str = "medium"        # Optionnel, défaut "medium"
    completed: bool = False         # Optionnel, défaut False
    due_date: Optional[str] = None  # Optionnel, None si absent

@app.post("/tasks")
async def create_task(task: TaskCreate):
    """
    task : FastAPI détecte que c'est un modèle Pydantic -> lit depuis le body
    (pas depuis le path ni les query params)
    """
    # task est maintenant un objet TaskCreate validé
    print(task.title)       # "Implémenter JWT"
    print(task.priority)    # "medium" (ou valeur envoyée)

    # Convertir en dict pour la réponse
    return task.model_dump()

# Corps attendu :
# {
#   "title": "Implémenter JWT",     <- obligatoire
#   "description": "...",           <- optionnel
#   "priority": "high",             <- optionnel (défaut: "medium")
#   "completed": false,             <- optionnel (défaut: false)
#   "due_date": "2024-12-31"        <- optionnel
# }

# Corps minimal valide :
# {"title": "Implémenter JWT"}

# Corps invalide -> 422 :
# {} (title manquant)
# {"title": 42} (type incorrect)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
12.3 BODY + PATH PARAMS + QUERY PARAMS ENSEMBLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FastAPI sait automatiquement d'où vient chaque paramètre :
  - Déclaré dans le chemin ({param}) -> path param
  - Modèle Pydantic -> request body
  - Autre -> query param

# ═══════════════════════════════════════════════════════════════════════
# Mélange des 3 types de paramètres
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI, Path, Query
from pydantic import BaseModel
from typing import Annotated, Optional

app = FastAPI()

class TaskUpdate(BaseModel):
    title: Optional[str] = None
    priority: Optional[str] = None
    completed: Optional[bool] = None

@app.patch("/projects/{project_id}/tasks/{task_id}")
async def update_project_task(
    # ── Path params (depuis l'URL) ────────────────────────────────────
    project_id: Annotated[int, Path(ge=1)],  # /projects/{project_id}
    task_id: Annotated[int, Path(ge=1)],     # /tasks/{task_id}

    # ── Query params (depuis ?key=value) ──────────────────────────────
    notify: Annotated[
        bool,
        Query(description="Envoyer une notification aux membres")
    ] = False,

    # ── Request body (depuis le corps JSON) ───────────────────────────
    task_data: TaskUpdate,   # Pydantic model -> toujours le body

) -> dict:
    """
    URL exemple : PATCH /projects/5/tasks/42?notify=true
    Body : {"title": "Nouveau titre", "priority": "high"}
    """
    return {
        "project_id": project_id,    # depuis l'URL
        "task_id": task_id,          # depuis l'URL
        "notify": notify,            # depuis ?notify=true
        "updates": task_data.model_dump(exclude_none=True),  # depuis le body
    }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
12.4 BODY AVANCÉ — PLUSIEURS MODÈLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Plusieurs modèles dans le body (body embed)
# ═══════════════════════════════════════════════════════════════════════

from fastapi import Body
from pydantic import BaseModel
from typing import Annotated

class Task(BaseModel):
    title: str
    priority: str = "medium"

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

# Quand on a plusieurs modèles Pydantic, FastAPI les attend dans
# un objet JSON avec des clés nommées comme les paramètres :
@app.post("/tasks/assign")
async def assign_task(
    task: Task,    # Attend {"task": {...}, "user": {...}}
    user: User,    # dans le body JSON
):
    # Body attendu :
    # {
    #   "task": {"title": "Implémenter JWT", "priority": "high"},
    #   "user": {"name": "Alice", "email": "alice@taskflow.com"}
    # }
    return {"task": task.model_dump(), "assigned_to": user.model_dump()}

# Body() avec embed=True — forcer l'encapsulation même pour 1 modèle
@app.put("/tasks/{task_id}")
async def update_task(
    task_id: int,
    task: Annotated[Task, Body(embed=True)],  # embed=True
):
    # Sans embed=True : body = {"title": "...", "priority": "..."}
    # Avec embed=True  : body = {"task": {"title": "...", "priority": "..."}}
    return {"task_id": task_id, "task": task.model_dump()}

# Body() pour des valeurs simples (pas un modèle Pydantic)
@app.post("/tasks/{task_id}/comment")
async def add_comment(
    task_id: int,
    comment: Annotated[str, Body(min_length=1, max_length=1000)],
    importance: Annotated[int, Body(ge=1, le=5)] = 3,
):
    # Body : {"comment": "Excellent travail !", "importance": 4}
    return {"task_id": task_id, "comment": comment, "importance": importance}

================================================================================
               CHAPITRE 13 — VALIDATION AVANCÉE AVEC PYDANTIC V2
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13.1 INTRODUCTION À PYDANTIC V2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pydantic v2 (sorti en 2023) est une réécriture complète en Rust de Pydantic.
  - 50x plus rapide que Pydantic v1
  - API légèrement différente (certains changements breaking)
  - FastAPI 0.100+ utilise Pydantic v2

Fonctions principales :
  BaseModel    : classe de base pour les modèles
  Field()      : métadonnées et contraintes sur les champs
  @field_validator : validation personnalisée d'un champ
  @model_validator : validation du modèle entier
  model_dump() : convertir en dict (remplace dict() de v1)
  model_validate() : créer depuis un dict (remplace parse_obj())
  model_json_schema() : générer le schéma JSON

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13.2 FIELD() — VALIDATION ET MÉTADONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Field() — guide complet
# ═══════════════════════════════════════════════════════════════════════

from pydantic import BaseModel, Field
from typing import Optional
from datetime import date, datetime
from decimal import Decimal

class TaskCreate(BaseModel):
    """Modèle complet pour créer une tâche dans TaskFlow"""

    # ── Champs de type string ─────────────────────────────────────────

    title: str = Field(
        ...,                               # ... = obligatoire (PydanticRequired)
        min_length=1,                      # Au moins 1 caractère
        max_length=200,                    # Au plus 200 caractères
        strip_whitespace=True,             # Supprimer espaces en début/fin
        description="Titre de la tâche",
        examples=["Implémenter JWT", "Créer les tests"]
    )

    description: Optional[str] = Field(
        None,                              # None = optionnel, défaut None
        max_length=2000,
        description="Description détaillée de la tâche"
    )

    # ── Champs numériques ─────────────────────────────────────────────

    estimated_hours: Optional[float] = Field(
        None,
        ge=0.0,                           # >= 0
        le=1000.0,                        # <= 1000
        description="Estimation en heures"
    )

    story_points: Optional[int] = Field(
        None,
        ge=1,
        le=100,
        multiple_of=1,                    # Doit être un entier (multiple de 1)
        description="Points de story (Scrum)"
    )

    budget: Optional[Decimal] = Field(
        None,
        ge=Decimal("0"),
        description="Budget alloué"
    )

    # ── Champs avec pattern regex ─────────────────────────────────────

    color_tag: Optional[str] = Field(
        None,
        pattern=r"^#[0-9A-Fa-f]{6}$",   # Couleur hexadécimale #RRGGBB
        description="Couleur d'étiquette (ex: #FF5733)",
        examples=["#FF5733", "#4CAF50"]
    )

    priority: str = Field(
        "medium",
        pattern=r"^(low|medium|high|urgent)$",
        description="Niveau de priorité"
    )

    # ── Champs de type date ───────────────────────────────────────────

    due_date: Optional[date] = Field(
        None,
        description="Date limite (YYYY-MM-DD)"
    )

    # ── Champs avec alias ────────────────────────────────────────────
    # Permet d'avoir un nom Python différent du nom JSON

    assigned_user_id: Optional[int] = Field(
        None,
        alias="assignedUserId",            # Nom dans le JSON
        ge=1,
        description="ID de l'utilisateur assigné"
    )

    class Config:
        # Accepter les noms originaux ET les alias
        populate_by_name = True

    # ── Champs avec valeurs par défaut dynamiques ─────────────────────

    created_at: datetime = Field(
        default_factory=datetime.now,      # Appel dynamique à datetime.now()
        description="Date de création"
    )

    tags: list[str] = Field(
        default_factory=list,              # [] (pas une liste partagée !)
        description="Liste de tags"
    )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13.3 VALIDATEURS PERSONNALISÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# @field_validator — Validation d'un champ spécifique
# ═══════════════════════════════════════════════════════════════════════

from pydantic import BaseModel, Field, field_validator
from datetime import date

class TaskCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    due_date: Optional[date] = None
    tags: list[str] = Field(default_factory=list)
    email: Optional[str] = None

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        """
        Valide et normalise le titre.
        @classmethod est requis par Pydantic v2.
        Le validateur reçoit la valeur et retourne la valeur validée.
        """
        # Supprimer les espaces multiples
        value = " ".join(value.split())

        # Vérifier qu'il ne commence pas par un chiffre
        if value[0].isdigit():
            raise ValueError("Le titre ne peut pas commencer par un chiffre")

        # Interdire les caractères spéciaux dangereux
        forbidden = ["<", ">", "&", "\"", "'"]
        if any(char in value for char in forbidden):
            raise ValueError(f"Le titre contient des caractères interdits : {forbidden}")

        # Capitaliser la première lettre
        return value.capitalize()

    @field_validator("due_date")
    @classmethod
    def validate_due_date(cls, value: Optional[date]) -> Optional[date]:
        """La date limite ne peut pas être dans le passé"""
        if value is not None and value < date.today():
            raise ValueError(
                f"La date limite ({value}) ne peut pas être dans le passé"
            )
        return value

    @field_validator("tags")
    @classmethod
    def validate_tags(cls, value: list[str]) -> list[str]:
        """Valide et normalise les tags"""
        if len(value) > 10:
            raise ValueError("Maximum 10 tags autorisés")

        # Normaliser : minuscules, supprimer les doublons, supprimer les vides
        cleaned = list(set(tag.lower().strip() for tag in value if tag.strip()))

        # Valider chaque tag
        for tag in cleaned:
            if len(tag) < 2:
                raise ValueError(f"Le tag '{tag}' est trop court (min 2 caractères)")
            if len(tag) > 30:
                raise ValueError(f"Le tag '{tag}' est trop long (max 30 caractères)")

        return cleaned

    @field_validator("email")
    @classmethod
    def validate_email(cls, value: Optional[str]) -> Optional[str]:
        """Validation basique d'email (Pydantic a EmailStr pour une vraie validation)"""
        if value is None:
            return None
        if "@" not in value or "." not in value.split("@")[-1]:
            raise ValueError("Format d'email invalide")
        return value.lower()

# ═══════════════════════════════════════════════════════════════════════
# @model_validator — Validation croisée entre plusieurs champs
# ═══════════════════════════════════════════════════════════════════════

from pydantic import BaseModel, model_validator
from typing import Self
from datetime import date

class TaskCreate(BaseModel):
    title: str
    start_date: Optional[date] = None
    due_date: Optional[date] = None
    estimated_hours: Optional[float] = None
    actual_hours: Optional[float] = None
    is_recurring: bool = False
    recurrence_interval_days: Optional[int] = None

    @model_validator(mode="after")
    def validate_dates_consistency(self) -> Self:
        """
        Valide la cohérence entre plusieurs champs.
        mode="after" : s'exécute APRÈS la validation individuelle des champs.
        self est l'instance du modèle déjà validée.
        """
        # Règle 1 : due_date doit être après start_date
        if self.start_date and self.due_date:
            if self.due_date < self.start_date:
                raise ValueError(
                    f"La date de fin ({self.due_date}) ne peut pas être "
                    f"avant la date de début ({self.start_date})"
                )

        # Règle 2 : actual_hours ne peut dépasser estimated_hours
        if self.estimated_hours and self.actual_hours:
            if self.actual_hours > self.estimated_hours * 2:
                raise ValueError(
                    "Les heures réelles ne peuvent pas dépasser 2x "
                    "les heures estimées"
                )

        # Règle 3 : si is_recurring, recurrence_interval_days est obligatoire
        if self.is_recurring and not self.recurrence_interval_days:
            raise ValueError(
                "Le champ 'recurrence_interval_days' est obligatoire "
                "quand 'is_recurring' est True"
            )

        return self

    @model_validator(mode="before")
    @classmethod
    def preprocess_data(cls, data: dict) -> dict:
        """
        mode="before" : s'exécute AVANT la validation.
        Utile pour normaliser les données entrantes.
        """
        if isinstance(data, dict):
            # Convertir les anciens formats
            if "name" in data and "title" not in data:
                data["title"] = data.pop("name")

            # Nettoyer les strings vides -> None
            for key in ["start_date", "due_date"]:
                if data.get(key) == "":
                    data[key] = None

        return data

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13.4 TYPES AVANCÉS PYDANTIC
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Types spéciaux Pydantic
# ═══════════════════════════════════════════════════════════════════════

from pydantic import BaseModel, EmailStr, HttpUrl, AnyUrl, SecretStr
from pydantic import PositiveInt, NegativeInt, NonNegativeFloat
from pydantic import constr, conint, confloat
from typing import Literal

class UserCreate(BaseModel):
    # EmailStr -> valide le format email (pip install pydantic[email])
    email: EmailStr

    # SecretStr -> masqué dans les logs et la sérialisation
    password: SecretStr = Field(..., min_length=8)

    # HttpUrl -> URL HTTP/HTTPS valide
    website: Optional[HttpUrl] = None

    # AnyUrl -> n'importe quelle URL valide
    avatar_url: Optional[AnyUrl] = None

    # Literal -> valeur exacte requise
    terms_accepted: Literal[True]  # Doit être True (pas False, pas autre chose)
    role: Literal["user", "admin", "moderator"] = "user"

    # PositiveInt -> entier > 0
    age: Optional[PositiveInt] = None

    # NonNegativeFloat -> float >= 0
    reputation_score: NonNegativeFloat = 0.0

    # constr -> string constrainte (ancienne syntaxe, toujours utilisée)
    username: str = Field(
        ...,
        pattern=r"^[a-z0-9_-]{3,30}$"
    )

class TaskWithMetrics(BaseModel):
    title: str

    # Union de types (Python 3.10+)
    priority: int | str = "medium"  # Accepte int ou string

    # Constrained types avec conint
    sprint_number: Optional[conint(ge=1, le=100)] = None

    # Dict typé
    custom_fields: dict[str, str | int | float | bool | None] = {}

    # Tuple typé
    coordinates: Optional[tuple[float, float]] = None

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13.5 HÉRITAGE ET COMPOSITION DE MODÈLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Pattern : Base -> Create -> Update -> Response
# Utilisé dans TaskFlow pour éviter la duplication
# ═══════════════════════════════════════════════════════════════════════

from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime

# ── 1. Modèle de base — champs partagés ──────────────────────────────
class TaskBase(BaseModel):
    """Champs partagés entre Create, Update et Response"""
    title: str = Field(..., min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=2000)
    priority: str = Field("medium", pattern=r"^(low|medium|high|urgent)$")
    due_date: Optional[str] = None
    tags: list[str] = Field(default_factory=list)

# ── 2. Modèle de création ─────────────────────────────────────────────
class TaskCreate(TaskBase):
    """Champs pour créer une tâche (tout ce que le client peut envoyer)"""
    project_id: Optional[int] = None
    assigned_to: Optional[int] = None
    # Hérite de tous les champs de TaskBase

# ── 3. Modèle de mise à jour (tous les champs optionnels) ─────────────
class TaskUpdate(BaseModel):
    """
    Tous les champs sont Optional pour PATCH.
    On n'hérite pas de TaskBase car tous les champs doivent être optionnels.
    """
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=2000)
    priority: Optional[str] = Field(None, pattern=r"^(low|medium|high|urgent)$")
    due_date: Optional[str] = None
    tags: Optional[list[str]] = None
    completed: Optional[bool] = None
    assigned_to: Optional[int] = None

# ── 4. Modèle de réponse ─────────────────────────────────────────────
class TaskResponse(TaskBase):
    """Champs retournés dans les réponses (inclut les champs auto-générés)"""
    id: int
    completed: bool
    created_at: datetime
    updated_at: Optional[datetime]
    project_id: Optional[int]
    assigned_to: Optional[int]

    class Config:
        from_attributes = True  # Pour lire depuis les objets SQLAlchemy

# ── 5. Modèle de réponse détaillée (avec relations) ──────────────────
class TaskDetailResponse(TaskResponse):
    """Réponse détaillée incluant les objets liés"""

    class ProjectInfo(BaseModel):
        id: int
        name: str

    class UserInfo(BaseModel):
        id: int
        name: str
        email: str

    project: Optional[ProjectInfo] = None
    assignee: Optional[UserInfo] = None
    comments_count: int = 0
    subtasks_count: int = 0

# ── 6. Modèle de réponse liste ───────────────────────────────────────
class TaskListResponse(BaseModel):
    """Réponse paginée pour la liste des tâches"""
    data: list[TaskResponse]
    total: int
    page: int
    per_page: int
    total_pages: int

# ── 7. Utilisation dans les routes ───────────────────────────────────
@router.post("/", response_model=TaskResponse, status_code=201)
async def create_task(task_data: TaskCreate):
    ...

@router.get("/{task_id}", response_model=TaskDetailResponse)
async def get_task(task_id: int):
    ...

@router.get("/", response_model=TaskListResponse)
async def list_tasks():
    ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13.6 MODEL_CONFIG — CONFIGURATION DU MODÈLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Configuration Pydantic v2 avec model_config
# ═══════════════════════════════════════════════════════════════════════

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class TaskResponse(BaseModel):
    model_config = ConfigDict(
        # Lecture depuis les attributs d'objet (SQLAlchemy ORM)
        from_attributes=True,

        # Utiliser les aliases lors de la sérialisation
        populate_by_name=True,

        # Générateur d'alias : snake_case -> camelCase
        # task_id -> taskId, created_at -> createdAt
        alias_generator=to_camel,

        # Valider les valeurs assignées après la création
        validate_assignment=True,

        # Validation stricte des types (pas de conversion)
        # strict=True,  # "42" ne serait plus converti en 42

        # Sérialisation de datetime -> string ISO 8601
        json_encoders={},

        # Exclure None de la sérialisation par défaut
        # json_schema_extra -> extra info dans la doc
        json_schema_extra={
            "example": {
                "id": 42,
                "title": "Implémenter JWT",
                "priority": "high",
                "completed": False,
            }
        }
    )

    id: int
    task_title: str       # Alias camelCase : "taskTitle"
    created_at: datetime  # Alias camelCase : "createdAt"
    is_completed: bool    # Alias camelCase : "isCompleted"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13.7 ERREURS DE VALIDATION — COMPRENDRE LES 422
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Quand la validation échoue, FastAPI retourne 422 avec le détail des erreurs.

Exemple de réponse 422 :
{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "title"],
      "msg": "Field required",
      "input": {},
      "url": "https://errors.pydantic.dev/2.5/v/missing"
    },
    {
      "type": "string_too_short",
      "loc": ["body", "description"],
      "msg": "String should have at least 10 characters",
      "input": "Hi",
      "ctx": {"min_length": 10},
      "url": "https://errors.pydantic.dev/2.5/v/string_too_short"
    },
    {
      "type": "value_error",
      "loc": ["body", "priority"],
      "msg": "Value error, Priorité invalide",
      "input": "VERY_HIGH",
      "ctx": {"error": {}}
    }
  ]
}

Personnaliser la réponse 422 :

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

app = FastAPI()

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError
) -> JSONResponse:
    """
    Personnalise le format de réponse pour les erreurs de validation.
    Au lieu du format par défaut de FastAPI, on retourne notre format standard.
    """
    errors = []
    for error in exc.errors():
        field = " -> ".join(str(loc) for loc in error["loc"])
        errors.append({
            "field": field,
            "message": error["msg"],
            "type": error["type"],
            "received_value": error.get("input"),
        })

    return JSONResponse(
        status_code=422,
        content={
            "error": "VALIDATION_ERROR",
            "message": "Les données envoyées sont invalides",
            "errors": errors,
        }
    )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13.8 EXERCICES — CHAPITRE 13
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 13.1 : Crée un modèle UserCreate avec :
  - email (EmailStr, obligatoire)
  - username (str, 3-30 chars, alphanum + underscore)
  - password (str, min 8 chars)
  - full_name (str, optionnel)
  - role (Literal["user", "admin"], défaut "user")

Ex 13.2 : Ajoute un @field_validator à UserCreate qui :
  - Vérifie que le password contient au moins 1 majuscule, 1 chiffre,
    1 caractère spécial
  - Retourne une erreur descriptive si non respecté

NIVEAU INTERMÉDIAIRE
--------------------
Ex 13.3 : Crée le pattern complet Base -> Create -> Update -> Response
  pour un modèle Project (projet dans TaskFlow) avec :
  - name, description, status, start_date, end_date, budget, team_size
  - Validation croisée : end_date > start_date

Ex 13.4 : Crée un modèle BulkTaskCreate qui accepte une liste de tâches
  (max 100) et valide chaque tâche individuellement. Si une tâche est
  invalide, retourner quelles tâches sont invalides avec leurs erreurs.

NIVEAU AVANCÉ
-------------
Ex 13.5 : Implémente un modèle générique PaginatedResponse[T] qui peut
  contenir n'importe quel type d'objet :
  PaginatedResponse[TaskResponse]
  PaginatedResponse[UserResponse]
  Utilise les generics de Python et Pydantic.

Ex 13.6 : Crée un système de validation conditionnel : un champ devient
  obligatoire selon la valeur d'un autre champ. Exemple : si
  payment_method = "card", alors card_number devient obligatoire.

================================================================================
                           RÉCAPITULATIF PARTIE 3
================================================================================

Dans cette partie, tu as appris :

[OK] Path parameters : types, conversions, validation avec Path()
[OK] Query parameters : défauts, Query(), alias, listes
[OK] Request body : modèles Pydantic, mélange des types de paramètres
[OK] Pydantic v2 : Field(), validateurs, types avancés, composition
[OK] Erreurs 422 : format, personnalisation du handler

[RAPIDE] PROCHAINE ÉTAPE : Partie 4 — Réponses
   - Response models et filtrage automatique
   - Status codes avancés
   - Custom responses (HTML, streaming, files)

================================================================================
                            FIN DE LA PARTIE 3
                     Passe à fastapi_master_part_4.txt
================================================================================

================================================================================
   GUIDE FASTAPI COMPLET — PARTIE 4 : RÉPONSES, BASE DE DONNÉES & CRUD
   Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FIL ROUGE : TaskFlow API
   Dans cette partie, nous maîtrisons les réponses FastAPI, connectons
   TaskFlow à PostgreSQL via SQLAlchemy 2.0 async, et implémentons
   un CRUD complet production-ready avec Alembic pour les migrations.

================================================================================
            CHAPITRE 14 — RESPONSE MODELS ET RÉPONSES AVANCÉES
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
14.1 RESPONSE_MODEL — FILTRAGE AUTOMATIQUE DES DONNÉES SENSIBLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le paramètre response_model sert à 3 choses essentielles :
  1. SÉCURITÉ   : filtrer les données sensibles (mot de passe, clés...)
  2. VALIDATION : vérifier que la réponse est conforme au schéma
  3. DOC        : générer le schéma de réponse dans Swagger/OpenAPI

# ═══════════════════════════════════════════════════════════════════════
# Démonstration du filtrage automatique — sécurité critique
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

# Modèle interne (tout ce qu'on a en base)
class UserInDB(BaseModel):
    id: int
    email: str
    name: str
    hashed_password: str      # <- SENSIBLE !
    is_active: bool
    is_superuser: bool
    api_secret_key: str       # <- SECRET !
    internal_notes: str       # <- INTERNE !

# Modèle PUBLIC (ce qu'on expose aux clients)
class UserPublicResponse(BaseModel):
    id: int
    email: str
    name: str
    is_active: bool
    # hashed_password  -> PAS déclaré -> JAMAIS retourné
    # api_secret_key   -> PAS déclaré -> JAMAIS retourné
    # internal_notes   -> PAS déclaré -> JAMAIS retourné

    class Config:
        from_attributes = True  # Nécessaire pour lire les objets SQLAlchemy

# [OK] SÉCURISÉ — FastAPI filtre automatiquement grâce à response_model
@app.get("/users/{user_id}", response_model=UserPublicResponse)
async def get_user(user_id: int):
    # On peut retourner un UserInDB avec les données sensibles :
    # FastAPI n'enverra au client QUE les champs de UserPublicResponse
    user_from_db = UserInDB(
        id=user_id,
        email="alice@taskflow.com",
        name="Alice Martin",
        hashed_password="$2b$12$HASH_SECRET",   # <- ne sera PAS retourné
        is_active=True,
        is_superuser=False,
        api_secret_key="sk_live_AbCdEf123456",  # <- ne sera PAS retourné
        internal_notes="Client VIP",             # <- ne sera PAS retourné
    )
    return user_from_db
    # Réponse JSON : {"id": 1, "email": "alice@...", "name": "Alice", "is_active": true}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
14.2 OPTIONS AVANCÉES DE RESPONSE_MODEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class TaskResponse(BaseModel):
    id: int
    title: str
    description: Optional[str] = None
    priority: str
    completed: bool
    due_date: Optional[str] = None
    assigned_to: Optional[int] = None

# response_model_exclude_none=True -> exclut les champs None
# Avant : {"id": 1, "title": "Task", "description": null, "due_date": null, ...}
# Après : {"id": 1, "title": "Task", "priority": "medium", "completed": false}
@app.get("/tasks/{id}", response_model=TaskResponse, response_model_exclude_none=True)
async def get_task_clean(id: int):
    return {"id": id, "title": "Implémenter JWT", "priority": "high", "completed": False}

# response_model_exclude_unset=True -> exclut les champs non explicitement définis
# Utile pour PATCH : ne retourner que les champs mis à jour
@app.patch("/tasks/{id}", response_model=TaskResponse, response_model_exclude_unset=True)
async def patch_task(id: int):
    # Si seul "completed" est mis à jour, la réponse ne contiendra que les
    # champs définis explicitement dans l'objet retourné
    return TaskResponse(id=id, title="Task", priority="medium", completed=True)

# response_model_include -> seulement ces champs
@app.get("/tasks/{id}/summary", response_model=TaskResponse,
         response_model_include={"id", "title", "completed"})
async def get_task_summary(id: int):
    # Réponse : {"id": 1, "title": "...", "completed": false}
    # Même si on retourne un objet complet, seuls ces 3 champs seront inclus
    return {"id": id, "title": "Task", "priority": "high", "completed": False}

# response_model_exclude -> exclure ces champs
@app.get("/tasks/{id}/public", response_model=TaskResponse,
         response_model_exclude={"assigned_to", "due_date"})
async def get_task_public(id: int):
    return {"id": id, "title": "Task", "priority": "medium", "completed": False}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
14.3 TYPES DE RÉPONSES FASTAPI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Toutes les classes Response disponibles
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI
from fastapi.responses import (
    JSONResponse,         # JSON (défaut FastAPI)
    HTMLResponse,         # HTML
    PlainTextResponse,    # Texte brut
    RedirectResponse,     # Redirection HTTP
    StreamingResponse,    # Flux de données
    FileResponse,         # Fichier à télécharger
    Response,             # Réponse HTTP brute
    ORJSONResponse,       # JSON rapide via orjson (pip install orjson)
    UJSONResponse,        # JSON rapide via ujson (pip install ujson)
)
import asyncio
import os

app = FastAPI()

# ── JSONResponse avec headers personnalisés ───────────────────────────
@app.get("/tasks/{task_id}")
async def get_task_with_headers(task_id: int):
    return JSONResponse(
        content={"id": task_id, "title": "Implémenter JWT"},
        status_code=200,
        headers={
            "X-Task-Id": str(task_id),
            "Cache-Control": "public, max-age=60",
            "X-RateLimit-Remaining": "99",
        }
    )

# ── HTMLResponse — retourner du HTML ─────────────────────────────────
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard():
    return """
    <!DOCTYPE html>
    <html>
        <head>
            <title>TaskFlow Dashboard</title>
            <style>
                body { font-family: sans-serif; padding: 40px; }
                h1 { color: #2563eb; }
            </style>
        </head>
        <body>
            <h1>[RAPIDE] TaskFlow Dashboard</h1>
            <p>Bienvenue ! L'API est opérationnelle.</p>
        </body>
    </html>
    """

# ── PlainTextResponse — health check léger ───────────────────────────
@app.get("/ping", response_class=PlainTextResponse)
async def ping():
    return "pong"   # Très léger, sans overhead JSON

# ── RedirectResponse — redirections ──────────────────────────────────
@app.get("/tasks")           # Ancienne URL
async def redirect_old():
    return RedirectResponse(url="/api/v1/tasks", status_code=301)  # Permanent

@app.get("/login")
async def redirect_to_docs():
    return RedirectResponse(url="/docs", status_code=302)          # Temporaire

# ── StreamingResponse — export de gros fichiers ───────────────────────
@app.get("/tasks/export/csv")
async def export_tasks_csv():
    """
    Stream un CSV de toutes les tâches.
    Ne charge PAS tout en mémoire -> idéal pour millions de lignes.
    """
    async def generate_csv_rows():
        # En-tête
        yield "id,title,priority,completed,created_at\n"

        # Simuler des milliers de lignes
        for i in range(1, 10_001):
            yield f'{i},"Tâche numéro {i}",medium,false,2024-01-15T10:00:00\n'
            # Libérer l'event loop toutes les 1000 lignes
            if i % 1000 == 0:
                await asyncio.sleep(0)

    return StreamingResponse(
        generate_csv_rows(),
        media_type="text/csv; charset=utf-8",
        headers={
            "Content-Disposition": 'attachment; filename="taskflow_export.csv"',
            "X-Export-Count": "10000",
        }
    )

# ── FileResponse — télécharger un fichier existant ───────────────────
@app.get("/reports/{filename}/download")
async def download_report(filename: str):
    """Télécharge un rapport PDF généré."""
    # IMPORTANT : Toujours valider le nom de fichier pour éviter path traversal !
    if "/" in filename or "\\" in filename or ".." in filename:
        raise HTTPException(status_code=400, detail="Nom de fichier invalide")

    file_path = f"/tmp/reports/{filename}"

    if not os.path.exists(file_path):
        raise HTTPException(status_code=404, detail="Rapport non trouvé")

    return FileResponse(
        path=file_path,
        filename=filename,          # Nom suggéré pour le téléchargement
        media_type="application/pdf",
        headers={"Cache-Control": "no-cache"},
    )

# ── ORJSONResponse — JSON haute performance ──────────────────────────
# orjson est 3-10x plus rapide que le json standard
# Utiliser pour les endpoints très sollicités avec de gros payloads

@app.get("/tasks/bulk", response_class=ORJSONResponse)
async def get_all_tasks():
    # Retourner directement depuis ORJSONResponse pour max perf
    tasks = [{"id": i, "title": f"Task {i}", "completed": False} for i in range(1000)]
    return ORJSONResponse(content={"tasks": tasks, "total": 1000})

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
14.4 STATUS CODES — RÉFÉRENCE COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

from fastapi import status   # Toujours utiliser les constantes !

# 2xx — SUCCÈS
# status.HTTP_200_OK              = 200  -> GET, PUT, PATCH réussi
# status.HTTP_201_CREATED         = 201  -> POST -> ressource créée
# status.HTTP_202_ACCEPTED        = 202  -> Requête reçue, traitement async (emails...)
# status.HTTP_204_NO_CONTENT      = 204  -> DELETE réussi, pas de corps réponse
# status.HTTP_206_PARTIAL_CONTENT = 206  -> Streaming partiel (Range requests)

# 3xx — REDIRECTIONS
# status.HTTP_301_MOVED_PERMANENTLY = 301 -> Redirection permanente
# status.HTTP_302_FOUND             = 302 -> Redirection temporaire
# status.HTTP_304_NOT_MODIFIED      = 304 -> Cache valide

# 4xx — ERREURS CLIENT
# status.HTTP_400_BAD_REQUEST          = 400 -> Requête malformée
# status.HTTP_401_UNAUTHORIZED         = 401 -> Non authentifié (pas de token)
# status.HTTP_403_FORBIDDEN            = 403 -> Authentifié mais pas autorisé
# status.HTTP_404_NOT_FOUND            = 404 -> Ressource inexistante
# status.HTTP_405_METHOD_NOT_ALLOWED   = 405 -> Méthode HTTP non supportée
# status.HTTP_409_CONFLICT             = 409 -> Conflit (email déjà utilisé)
# status.HTTP_410_GONE                 = 410 -> Ressource supprimée définitivement
# status.HTTP_422_UNPROCESSABLE_ENTITY = 422 -> Données invalides (Pydantic)
# status.HTTP_429_TOO_MANY_REQUESTS    = 429 -> Rate limit dépassé

# 5xx — ERREURS SERVEUR
# status.HTTP_500_INTERNAL_SERVER_ERROR = 500 -> Erreur non gérée
# status.HTTP_503_SERVICE_UNAVAILABLE   = 503 -> Service indisponible (DB down)
# status.HTTP_504_GATEWAY_TIMEOUT       = 504 -> Timeout serveur amont

# ── Exemple d'utilisation dans TaskFlow ──────────────────────────────
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel

router = APIRouter()

class TaskCreate(BaseModel):
    title: str
    priority: str = "medium"

@router.post(
    "/tasks",
    status_code=status.HTTP_201_CREATED,   # 201 pour création
    response_model=dict,
)
async def create_task(task: TaskCreate):
    return {"id": 1, "title": task.title}

@router.delete(
    "/tasks/{task_id}",
    status_code=status.HTTP_204_NO_CONTENT,  # 204 pour suppression
)
async def delete_task(task_id: int):
    # Pas de return -> corps vide automatique
    pass

@router.post("/tasks/{task_id}/complete", status_code=status.HTTP_200_OK)
async def complete_task(task_id: int):
    # 409 si déjà complète
    task_already_done = True   # Exemple
    if task_already_done:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="La tâche est déjà complète"
        )
    return {"message": "Tâche marquée comme complète"}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
14.5 BACKGROUND TASKS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# BackgroundTasks — exécuter du code APRÈS la réponse
# ═══════════════════════════════════════════════════════════════════════

from fastapi import BackgroundTasks
import asyncio, logging

logger = logging.getLogger(__name__)

# Fonctions de background — peuvent être async ou sync
async def notify_assignee(user_email: str, task_title: str, assigner_name: str):
    """Envoie une notification email à l'assignataire."""
    await asyncio.sleep(0.5)  # Simulation appel SMTP/SendGrid
    logger.info(f"[EMAIL] -> {user_email} : '{task_title}' assignée par {assigner_name}")

async def update_project_stats(project_id: int):
    """Met à jour les statistiques du projet après création d'une tâche."""
    await asyncio.sleep(0.1)  # Simulation requête DB d'agrégation
    logger.info(f"[STATS] Projet {project_id} mis à jour")

def log_to_audit_trail(action: str, user_id: int, resource_id: int):
    """Log synchrone dans une table d'audit."""
    # Peut être sync ou async
    logger.info(f"[AUDIT] User {user_id} -> {action} -> resource {resource_id}")

@router.post("/tasks", status_code=201)
async def create_task_with_notifications(
    task_data: TaskCreate,
    background_tasks: BackgroundTasks,    # FastAPI l'injecte automatiquement
):
    """
    Crée une tâche et déclenche les tâches d'arrière-plan.
    Le client reçoit la réponse IMMÉDIATEMENT,
    puis les background tasks s'exécutent sans le bloquer.
    """
    # Logique principale (synchrone du point de vue du client)
    new_task = {"id": 42, "title": task_data.title, "priority": task_data.priority}

    # Planifier les tâches en arrière-plan
    if task_data.assigned_to:
        background_tasks.add_task(
            notify_assignee,
            user_email="assignee@taskflow.com",
            task_title=task_data.title,
            assigner_name="Alice",
        )

    background_tasks.add_task(update_project_stats, project_id=5)
    background_tasks.add_task(log_to_audit_trail, "CREATE_TASK", 1, new_task["id"])

    # La réponse est envoyée ICI -> les background tasks s'exécutent après
    return new_task

# [ATTENTION] LIMITES des BackgroundTasks :
# - S'exécutent dans le même processus -> si le serveur s'arrête, elles s'arrêtent
# - Pas de retry automatique en cas d'échec
# - Pour les tâches critiques (emails transactionnels, paiements) -> utiliser
#   un vrai worker queue : Celery + Redis, ou ARQ (async Redis Queue)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
14.6 EXERCICES — CHAPITRE 14
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 14.1 : Crée deux modèles Pydantic pour un utilisateur :
  - UserInDB (avec: id, email, username, hashed_password, is_active)
  - UserPublicResponse (sans hashed_password)
  Crée un endpoint GET /users/{id} qui utilise response_model pour
  filtrer automatiquement le hash de mot de passe.

Ex 14.2 : Crée un endpoint GET /health qui retourne PlainTextResponse
  avec "OK" si tout va bien, et un code 503 si une variable globale
  DB_CONNECTED est False.

NIVEAU INTERMÉDIAIRE
--------------------
Ex 14.3 : Implémente GET /tasks/export qui stream un CSV de 5000 lignes
  fictives sans charger toutes les données en mémoire. Inclure les
  headers Content-Disposition appropriés.

Ex 14.4 : Crée un endpoint POST /invitations qui envoie une "invitation
  email" en background task (simulée avec print + asyncio.sleep).
  La réponse doit être immédiate (202 Accepted).

NIVEAU AVANCÉ
-------------
Ex 14.5 : Implémente une réponse avec ETag et support du cache
  conditionnel (304 Not Modified). Si le client envoie l'ETag dans
  If-None-Match et que la ressource n'a pas changé, retourner 304.

================================================================================
         CHAPITRE 17 — POSTGRESQL & SQLALCHEMY 2.0 ASYNC
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17.1 CONFIGURATION DE LA BASE DE DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Dépendances à installer :
  pip install sqlalchemy asyncpg alembic

  # sqlalchemy  -> ORM et query builder
  # asyncpg     -> driver PostgreSQL async (le plus rapide)
  # alembic     -> gestion des migrations de schéma

Docker Compose pour PostgreSQL :
  # docker-compose.yml
  version: "3.8"
  services:
    db:
      image: postgres:16-alpine
      environment:
        POSTGRES_USER: taskflow_user
        POSTGRES_PASSWORD: taskflow_pass
        POSTGRES_DB: taskflow_db
      ports:
        - "5432:5432"
      volumes:
        - postgres_data:/var/lib/postgresql/data
      healthcheck:
        test: ["CMD-SHELL", "pg_isready -U taskflow_user"]
        interval: 5s
        timeout: 3s
        retries: 5

  volumes:
    postgres_data:

  # Lancer : docker-compose up -d db
  # Vérifier : docker-compose ps

# ═══════════════════════════════════════════════════════════════════════
# app/database.py — Configuration complète async
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy.ext.asyncio import (
    create_async_engine,
    AsyncSession,
    async_sessionmaker,
)
from sqlalchemy.orm import DeclarativeBase
from typing import AsyncGenerator

from app.config import get_settings

settings = get_settings()

# ── Moteur de connexion ──────────────────────────────────────────────
engine = create_async_engine(
    # URL format : postgresql+asyncpg://user:pass@host:port/db
    settings.database_url,

    # Log toutes les requêtes SQL en mode DEBUG uniquement
    echo=settings.debug,

    # Configuration du pool de connexions
    pool_size=10,          # Connexions maintenues ouvertes
    max_overflow=20,       # Connexions supplémentaires si pool saturé
    pool_timeout=30,       # Attente max pour une connexion (secondes)
    pool_recycle=1800,     # Recycler les connexions après 30 min
    pool_pre_ping=True,    # Vérifier la connexion avant usage (évite les erreurs)
)

# ── Fabrique de sessions ─────────────────────────────────────────────
# async_sessionmaker crée des sessions avec les paramètres définis
AsyncSessionLocal = async_sessionmaker(
    bind=engine,
    class_=AsyncSession,
    expire_on_commit=False,  # <- CRUCIAL : garder les objets accessibles après commit
    autocommit=False,        # Transaction manuelle (commit explicite)
    autoflush=False,         # Flush manuel (contrôle précis)
)

# ── Classe de base pour les modèles ORM ─────────────────────────────
class Base(DeclarativeBase):
    """
    Tous les modèles SQLAlchemy héritent de cette classe.
    Elle maintient le registre des tables (metadata).
    """
    pass

# ── Dépendance FastAPI — session DB par requête ──────────────────────
async def get_db() -> AsyncGenerator[AsyncSession, None]:
    """
    Générateur de session de base de données.

    Cycle de vie :
    1. Requête HTTP arrive
    2. FastAPI appelle get_db() -> ouvre une session
    3. La session est injectée dans le handler
    4. Handler s'exécute
    5. get_db() commit automatiquement si pas d'erreur
    6. En cas d'exception -> rollback automatique
    7. Session fermée dans tous les cas

    Usage dans les routes :
        from fastapi import Depends
        from typing import Annotated
        DBSession = Annotated[AsyncSession, Depends(get_db)]

        async def my_route(db: DBSession):
            result = await db.execute(select(Task))
    """
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
        finally:
            await session.close()

# ── Initialisation des tables (développement) ────────────────────────
async def init_db():
    """
    Crée toutes les tables. À utiliser en développement uniquement.
    En PRODUCTION -> utiliser Alembic pour les migrations.
    """
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    print("[OK] Tables créées avec succès")

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18.1 MODÈLES SQLALCHEMY — DÉFINITION COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/models/mixins.py — Mixins réutilisables
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime

class TimestampMixin:
    """Ajoute created_at et updated_at à tout modèle."""

    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),  # Valeur par défaut côté DB
        nullable=False,
    )

    updated_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True),
        onupdate=func.now(),         # Mise à jour automatique à chaque UPDATE
        nullable=True,
    )

class SoftDeleteMixin:
    """Suppression logique (soft delete) : ne supprime pas réellement."""

    deleted_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True),
        nullable=True,
        default=None,
    )

    @property
    def is_deleted(self) -> bool:
        return self.deleted_at is not None

# ═══════════════════════════════════════════════════════════════════════
# app/models/user.py — Modèle Utilisateur complet
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy import String, Boolean, Text, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from app.models.mixins import TimestampMixin

class User(Base, TimestampMixin):
    __tablename__ = "users"

    # ── Clé primaire ─────────────────────────────────────────────────
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # ── Identifiants uniques ──────────────────────────────────────────
    email: Mapped[str] = mapped_column(
        String(255),
        unique=True,    # Contrainte UNIQUE en DB
        nullable=False,
        index=True,     # Index B-Tree -> recherche rapide par email
    )

    username: Mapped[str] = mapped_column(
        String(50),
        unique=True,
        nullable=False,
        index=True,
    )

    # ── Informations personnelles ─────────────────────────────────────
    full_name: Mapped[str | None] = mapped_column(String(200), nullable=True)

    bio: Mapped[str | None] = mapped_column(Text, nullable=True)

    avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)

    # ── Sécurité ─────────────────────────────────────────────────────
    hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)

    is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)

    is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

    # ── Relations ────────────────────────────────────────────────────
    # Les relations sont chargées lazily par défaut (SELECT séparé si accédées)
    assigned_tasks: Mapped[list["Task"]] = relationship(
        "Task",
        back_populates="assignee",
        foreign_keys="Task.assigned_to_id",
        lazy="select",   # Chargement à la demande
    )

    created_tasks: Mapped[list["Task"]] = relationship(
        "Task",
        back_populates="creator",
        foreign_keys="Task.created_by_id",
    )

    def __repr__(self) -> str:
        return f"<User id={self.id} email='{self.email}'>"

# ═══════════════════════════════════════════════════════════════════════
# app/models/task.py — Modèle Tâche complet
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy import (
    String, Text, Boolean, Integer, Float,
    ForeignKey, Enum, Date, Index
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
import enum
from datetime import date
from app.database import Base
from app.models.mixins import TimestampMixin, SoftDeleteMixin

class PriorityEnum(str, enum.Enum):
    low = "low"
    medium = "medium"
    high = "high"
    urgent = "urgent"

class StatusEnum(str, enum.Enum):
    todo = "todo"
    in_progress = "in_progress"
    in_review = "in_review"
    done = "done"
    cancelled = "cancelled"

class Task(Base, TimestampMixin, SoftDeleteMixin):
    __tablename__ = "tasks"

    # ── Index composites pour les requêtes fréquentes ─────────────────
    __table_args__ = (
        Index("ix_tasks_assigned_status", "assigned_to_id", "status"),
        Index("ix_tasks_created_priority", "created_by_id", "priority"),
        Index("ix_tasks_due_date", "due_date"),    # Pour les alertes de deadline
    )

    # ── Clé primaire ─────────────────────────────────────────────────
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # ── Données de la tâche ───────────────────────────────────────────
    title: Mapped[str] = mapped_column(String(200), nullable=False)

    description: Mapped[str | None] = mapped_column(Text, nullable=True)

    status: Mapped[StatusEnum] = mapped_column(
        Enum(StatusEnum, name="task_status"),   # name -> nom du type Enum en DB
        default=StatusEnum.todo,
        nullable=False,
        index=True,
    )

    priority: Mapped[PriorityEnum] = mapped_column(
        Enum(PriorityEnum, name="task_priority"),
        default=PriorityEnum.medium,
        nullable=False,
        index=True,
    )

    due_date: Mapped[date | None] = mapped_column(Date, nullable=True)

    estimated_hours: Mapped[float | None] = mapped_column(Float, nullable=True)

    story_points: Mapped[int | None] = mapped_column(Integer, nullable=True)

    completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

    # ── Clés étrangères ───────────────────────────────────────────────
    assigned_to_id: Mapped[int | None] = mapped_column(
        Integer,
        ForeignKey("users.id", ondelete="SET NULL"),
        nullable=True,
        index=True,
    )

    created_by_id: Mapped[int] = mapped_column(
        Integer,
        ForeignKey("users.id", ondelete="CASCADE"),
        nullable=False,
    )

    project_id: Mapped[int | None] = mapped_column(
        Integer,
        ForeignKey("projects.id", ondelete="SET NULL"),
        nullable=True,
        index=True,
    )

    # ── Relations ────────────────────────────────────────────────────
    assignee: Mapped["User | None"] = relationship(
        "User",
        back_populates="assigned_tasks",
        foreign_keys=[assigned_to_id],
    )

    creator: Mapped["User"] = relationship(
        "User",
        back_populates="created_tasks",
        foreign_keys=[created_by_id],
    )

    comments: Mapped[list["Comment"]] = relationship(
        "Comment",
        back_populates="task",
        cascade="all, delete-orphan",   # Supprimer commentaires avec la tâche
        order_by="Comment.created_at",
    )

    def __repr__(self) -> str:
        return f"<Task id={self.id} title='{self.title[:30]}' status={self.status}>"

# ═══════════════════════════════════════════════════════════════════════
# app/models/project.py — Modèle Projet
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy import String, Text, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from app.models.mixins import TimestampMixin

class Project(Base, TimestampMixin):
    __tablename__ = "projects"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(200), nullable=False)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)

    tasks: Mapped[list["Task"]] = relationship(
        "Task",
        back_populates="project",
        cascade="all, delete-orphan",
    )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
19.1 ALEMBIC — MIGRATIONS DE SCHÉMA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Alembic = contrôle de version pour ton schéma PostgreSQL.
Chaque changement (nouvelle table, nouvelle colonne, index...) est
tracé dans un fichier de migration versionné -> déployable en production.

# ── Installation et initialisation ───────────────────────────────────
  pip install alembic
  alembic init alembic   # Crée : alembic/, alembic.ini

# ── Configuration alembic/env.py pour async ──────────────────────────

  # alembic/env.py — version async complète
  import asyncio
  from logging.config import fileConfig
  from sqlalchemy import pool
  from sqlalchemy.ext.asyncio import create_async_engine
  from alembic import context

  # CRUCIAL : importer TOUS les modèles pour la détection automatique
  from app.database import Base
  from app.models import user, task, project  # noqa: F401

  from app.config import get_settings
  settings = get_settings()

  config = context.config
  if config.config_file_name is not None:
      fileConfig(config.config_file_name)

  # Métadonnées = toutes les tables définies dans les modèles importés
  target_metadata = Base.metadata

  def do_run_migrations(connection):
      context.configure(
          connection=connection,
          target_metadata=target_metadata,
          compare_type=True,       # Détecter les changements de type
          compare_server_default=True,  # Détecter les changements de défaut
      )
      with context.begin_transaction():
          context.run_migrations()

  async def run_async_migrations():
      engine = create_async_engine(settings.database_url, poolclass=pool.NullPool)
      async with engine.connect() as conn:
          await conn.run_sync(do_run_migrations)
      await engine.dispose()

  def run_migrations_online():
      asyncio.run(run_async_migrations())

  if context.is_offline_mode():
      context.configure(url=settings.database_url, target_metadata=target_metadata)
      with context.begin_transaction():
          context.run_migrations()
  else:
      run_migrations_online()

# ── Commandes Alembic essentielles ───────────────────────────────────

  # Créer la première migration automatique (détecte les modèles)
  alembic revision --autogenerate -m "initial_schema"

  # Appliquer toutes les migrations en attente
  alembic upgrade head

  # Voir l'état actuel
  alembic current

  # Historique des migrations
  alembic history --verbose

  # Revenir 1 migration en arrière
  alembic downgrade -1

  # Revenir à l'état vide
  alembic downgrade base

  # Créer une migration manuelle (pour des cas complexes)
  alembic revision -m "add_index_on_tasks_due_date"

# ── Exemple de migration générée ─────────────────────────────────────

  # alembic/versions/001_initial_schema.py
  """Initial schema — users and tasks

  Revision ID: a1b2c3d4e5f6
  Revises: (none)
  Create Date: 2024-01-15 10:00:00
  """
  from alembic import op
  import sqlalchemy as sa

  revision = 'a1b2c3d4e5f6'
  down_revision = None

  def upgrade() -> None:
      # Table users
      op.create_table(
          'users',
          sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
          sa.Column('email', sa.String(255), nullable=False),
          sa.Column('username', sa.String(50), nullable=False),
          sa.Column('full_name', sa.String(200), nullable=True),
          sa.Column('hashed_password', sa.String(255), nullable=False),
          sa.Column('is_active', sa.Boolean(), default=True, nullable=False),
          sa.Column('is_superuser', sa.Boolean(), default=False, nullable=False),
          sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
          sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
          sa.UniqueConstraint('email'),
          sa.UniqueConstraint('username'),
      )
      op.create_index('ix_users_email', 'users', ['email'])

      # Enum types (PostgreSQL)
      task_status = sa.Enum('todo','in_progress','in_review','done','cancelled',
                            name='task_status')
      task_priority = sa.Enum('low','medium','high','urgent', name='task_priority')
      task_status.create(op.get_bind())
      task_priority.create(op.get_bind())

      # Table tasks
      op.create_table(
          'tasks',
          sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
          sa.Column('title', sa.String(200), nullable=False),
          sa.Column('description', sa.Text(), nullable=True),
          sa.Column('status', sa.Enum(name='task_status'), nullable=False, default='todo'),
          sa.Column('priority', sa.Enum(name='task_priority'), nullable=False, default='medium'),
          sa.Column('due_date', sa.Date(), nullable=True),
          sa.Column('completed', sa.Boolean(), default=False, nullable=False),
          sa.Column('assigned_to_id', sa.Integer(),
                    sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
          sa.Column('created_by_id', sa.Integer(),
                    sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
          sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
          sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
          sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
      )
      op.create_index('ix_tasks_assigned_status', 'tasks', ['assigned_to_id', 'status'])

  def downgrade() -> None:
      op.drop_table('tasks')
      op.drop_table('users')
      sa.Enum(name='task_status').drop(op.get_bind())
      sa.Enum(name='task_priority').drop(op.get_bind())

================================================================================
                  CHAPITRES 21-24 — CRUD COMPLET PRODUCTION-READY
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
20.1 SCHEMAS PYDANTIC — INTERFACE ENTRE API ET DB
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/schemas/task.py — Schemas Pydantic complets
# ═══════════════════════════════════════════════════════════════════════

from pydantic import BaseModel, Field, field_validator
from typing import Optional
from datetime import date, datetime
from app.models.task import PriorityEnum, StatusEnum

# ── Base ─────────────────────────────────────────────────────────────
class TaskBase(BaseModel):
    """Champs partagés par tous les schémas de tâche."""
    title: str = Field(..., min_length=1, max_length=200, strip_whitespace=True)
    description: Optional[str] = Field(None, max_length=5000)
    priority: PriorityEnum = PriorityEnum.medium
    due_date: Optional[date] = None
    estimated_hours: Optional[float] = Field(None, ge=0, le=1000)
    story_points: Optional[int] = Field(None, ge=1, le=100)

    @field_validator("due_date")
    @classmethod
    def validate_due_date_not_past(cls, v: Optional[date]) -> Optional[date]:
        if v is not None and v < date.today():
            raise ValueError("La date limite ne peut pas être dans le passé")
        return v

# ── Création ─────────────────────────────────────────────────────────
class TaskCreate(TaskBase):
    """Données pour créer une tâche."""
    project_id: Optional[int] = Field(None, ge=1)
    assigned_to: Optional[int] = Field(None, ge=1)

# ── Mise à jour partielle ─────────────────────────────────────────────
class TaskUpdate(BaseModel):
    """
    Tous les champs optionnels pour PATCH.
    model_dump(exclude_unset=True) -> seulement les champs fournis.
    """
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=5000)
    priority: Optional[PriorityEnum] = None
    status: Optional[StatusEnum] = None
    due_date: Optional[date] = None
    estimated_hours: Optional[float] = Field(None, ge=0, le=1000)
    story_points: Optional[int] = Field(None, ge=1, le=100)
    completed: Optional[bool] = None
    assigned_to: Optional[int] = Field(None, ge=1)

# ── Réponse simple ────────────────────────────────────────────────────
class TaskResponse(TaskBase):
    """Réponse standard pour une tâche."""
    id: int
    status: StatusEnum
    completed: bool
    created_at: datetime
    updated_at: Optional[datetime]
    project_id: Optional[int]
    assigned_to_id: Optional[int]
    created_by_id: int

    class Config:
        from_attributes = True    # Lire depuis les objets SQLAlchemy

# ── Réponse détaillée (avec objets liés) ─────────────────────────────
class UserBrief(BaseModel):
    id: int
    username: str
    full_name: Optional[str]
    avatar_url: Optional[str]
    class Config:
        from_attributes = True

class TaskDetailResponse(TaskResponse):
    """Réponse détaillée incluant les utilisateurs liés."""
    assignee: Optional[UserBrief] = None
    creator: Optional[UserBrief] = None
    comments_count: int = 0

# ── Réponse paginée ───────────────────────────────────────────────────
class PaginationMeta(BaseModel):
    page: int
    per_page: int
    total: int
    total_pages: int
    has_next: bool
    has_prev: bool

class TaskListResponse(BaseModel):
    data: list[TaskResponse]
    pagination: PaginationMeta

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21.1 SERVICE LAYER — LOGIQUE CRUD COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/services/task_service.py — CRUD complet avec SQLAlchemy async
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete, func, and_, or_, desc, asc
from sqlalchemy.orm import selectinload, joinedload
from typing import Optional, Sequence
from datetime import datetime, date
from fastapi import HTTPException, status

from app.models.task import Task, StatusEnum, PriorityEnum
from app.schemas.task import TaskCreate, TaskUpdate

class TaskService:
    """
    Service encapsulant toute la logique d'accès aux données pour les tâches.
    Pattern : Repository / Service Layer.
    Les routes ne font JAMAIS de SQL directement.
    """

    def __init__(self, db: AsyncSession):
        self.db = db   # Session injectée par get_db()

    # ════════════════════════════════════════════════════════════════
    # CHAPITRE 21 — CREATE
    # ════════════════════════════════════════════════════════════════

    async def create(self, task_data: TaskCreate, created_by_id: int) -> Task:
        """
        Crée une nouvelle tâche.

        SQLAlchemy 2.0 async pattern :
          1. Créer l'objet modèle
          2. db.add() -> marque pour insertion
          3. await db.flush() -> exécute le SQL INSERT (sans commit)
          4. await db.refresh() -> recharge l'objet depuis la DB
             (pour avoir id, created_at générés automatiquement)
          5. Le commit est fait par get_db() après la réponse
        """
        db_task = Task(
            title=task_data.title,
            description=task_data.description,
            priority=task_data.priority,
            due_date=task_data.due_date,
            estimated_hours=task_data.estimated_hours,
            story_points=task_data.story_points,
            assigned_to_id=task_data.assigned_to,
            project_id=task_data.project_id,
            created_by_id=created_by_id,
            status=StatusEnum.todo,
            completed=False,
        )

        self.db.add(db_task)
        await self.db.flush()            # INSERT SQL exécuté
        await self.db.refresh(db_task)   # db_task.id est maintenant disponible

        return db_task

    async def bulk_create(self, tasks_data: list[TaskCreate], created_by_id: int) -> list[Task]:
        """Crée plusieurs tâches en une seule transaction."""
        db_tasks = [
            Task(
                title=t.title,
                description=t.description,
                priority=t.priority,
                due_date=t.due_date,
                created_by_id=created_by_id,
                status=StatusEnum.todo,
                completed=False,
            )
            for t in tasks_data
        ]

        self.db.add_all(db_tasks)    # Ajouter tous d'un coup
        await self.db.flush()

        for task in db_tasks:
            await self.db.refresh(task)

        return db_tasks

    # ════════════════════════════════════════════════════════════════
    # CHAPITRE 22 — READ
    # ════════════════════════════════════════════════════════════════

    async def get_by_id(
        self,
        task_id: int,
        include_deleted: bool = False,
        load_relations: bool = False,
    ) -> Task | None:
        """
        Récupère une tâche par son ID.

        selectinload -> chargement eager des relations (évite N+1 queries)
        Expliqué : au lieu de faire un SELECT pour la tâche puis un SELECT
        pour chaque relation, SQLAlchemy fait 2 SELECT optimisés.
        """
        query = select(Task).where(Task.id == task_id)

        # Exclure les tâches supprimées (soft delete)
        if not include_deleted:
            query = query.where(Task.deleted_at.is_(None))

        # Charger les relations si demandé
        if load_relations:
            query = query.options(
                selectinload(Task.assignee),     # Charge l'utilisateur assigné
                selectinload(Task.creator),      # Charge le créateur
                selectinload(Task.comments),     # Charge les commentaires
            )

        result = await self.db.execute(query)
        return result.scalar_one_or_none()  # Retourne l'objet ou None

    async def get_or_raise(self, task_id: int, user_id: int | None = None) -> Task:
        """
        Récupère une tâche ou lève une exception 404.
        Optionnellement vérifie que l'utilisateur est autorisé.
        """
        task = await self.get_by_id(task_id)

        if task is None:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail={"message": f"Tâche {task_id} non trouvée", "task_id": task_id},
            )

        # Vérification d'autorisation optionnelle
        if user_id and task.created_by_id != user_id:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Vous n'avez pas accès à cette tâche",
            )

        return task

    async def list_with_filters(
        self,
        page: int = 1,
        per_page: int = 20,
        user_id: Optional[int] = None,         # Filtre par créateur
        assigned_to: Optional[int] = None,     # Filtre par assignataire
        status: Optional[StatusEnum] = None,
        priority: Optional[PriorityEnum] = None,
        completed: Optional[bool] = None,
        q: Optional[str] = None,               # Recherche textuelle
        due_before: Optional[date] = None,
        due_after: Optional[date] = None,
        project_id: Optional[int] = None,
        sort_by: str = "created_at",
        order: str = "desc",
        load_relations: bool = False,
    ) -> tuple[list[Task], int]:
        """
        Liste les tâches avec filtres, pagination et tri.
        Retourne (liste_tâches, total_count).
        """
        # ── Requête de base ──────────────────────────────────────────
        query = select(Task).where(Task.deleted_at.is_(None))
        count_query = select(func.count()).select_from(Task).where(Task.deleted_at.is_(None))

        # ── Application des filtres ──────────────────────────────────
        filters = []

        if user_id:
            filters.append(Task.created_by_id == user_id)
        if assigned_to:
            filters.append(Task.assigned_to_id == assigned_to)
        if status:
            filters.append(Task.status == status)
        if priority:
            filters.append(Task.priority == priority)
        if completed is not None:
            filters.append(Task.completed == completed)
        if project_id:
            filters.append(Task.project_id == project_id)
        if due_before:
            filters.append(Task.due_date <= due_before)
        if due_after:
            filters.append(Task.due_date >= due_after)
        if q:
            # Recherche dans titre ET description (ILIKE = insensible à la casse)
            search_filter = or_(
                Task.title.ilike(f"%{q}%"),
                Task.description.ilike(f"%{q}%"),
            )
            filters.append(search_filter)

        # Appliquer tous les filtres avec AND
        if filters:
            combined = and_(*filters)
            query = query.where(combined)
            count_query = count_query.where(combined)

        # ── Tri ───────────────────────────────────────────────────────
        sort_column = getattr(Task, sort_by, Task.created_at)
        query = query.order_by(
            desc(sort_column) if order == "desc" else asc(sort_column)
        )

        # ── Chargement des relations ──────────────────────────────────
        if load_relations:
            query = query.options(
                selectinload(Task.assignee),
                selectinload(Task.creator),
            )

        # ── Pagination ────────────────────────────────────────────────
        skip = (page - 1) * per_page
        query = query.offset(skip).limit(per_page)

        # ── Exécution ─────────────────────────────────────────────────
        # Exécuter les 2 queries en parallèle pour la performance
        import asyncio
        tasks_result, count_result = await asyncio.gather(
            self.db.execute(query),
            self.db.execute(count_query),
        )

        tasks = list(tasks_result.scalars().all())
        total = count_result.scalar_one()

        return tasks, total

    async def get_statistics(self, user_id: Optional[int] = None) -> dict:
        """Statistiques agrégées sur les tâches."""
        base_filter = Task.deleted_at.is_(None)
        if user_id:
            base_filter = and_(base_filter, Task.created_by_id == user_id)

        # Compter par statut
        status_counts = await self.db.execute(
            select(Task.status, func.count(Task.id).label("count"))
            .where(base_filter)
            .group_by(Task.status)
        )

        # Compter par priorité
        priority_counts = await self.db.execute(
            select(Task.priority, func.count(Task.id).label("count"))
            .where(base_filter)
            .group_by(Task.priority)
        )

        # Tâches en retard
        overdue_count = await self.db.execute(
            select(func.count(Task.id))
            .where(and_(
                base_filter,
                Task.due_date < date.today(),
                Task.completed == False,   # noqa: E712
            ))
        )

        return {
            "by_status": {row.status.value: row.count for row in status_counts},
            "by_priority": {row.priority.value: row.count for row in priority_counts},
            "overdue": overdue_count.scalar_one(),
        }

    # ════════════════════════════════════════════════════════════════
    # CHAPITRE 23 — UPDATE
    # ════════════════════════════════════════════════════════════════

    async def update(self, task_id: int, task_data: TaskUpdate, user_id: int) -> Task:
        """
        Mise à jour partielle (PATCH).
        Seuls les champs fournis sont modifiés.
        """
        # Vérifier existence et autorisation
        task = await self.get_or_raise(task_id, user_id)

        # model_dump(exclude_unset=True) -> seulement les champs envoyés par le client
        update_data = task_data.model_dump(exclude_unset=True)

        if not update_data:
            return task  # Rien à mettre à jour -> retourner la tâche telle quelle

        # Appliquer les mises à jour
        for field, value in update_data.items():
            setattr(task, field, value)

        # Mise à jour automatique de updated_at (aussi géré par SQLAlchemy onupdate)
        task.updated_at = datetime.utcnow()

        await self.db.flush()
        await self.db.refresh(task)

        return task

    async def replace(self, task_id: int, task_data: TaskCreate, user_id: int) -> Task:
        """
        Remplacement complet (PUT).
        Tous les champs sont remplacés.
        """
        task = await self.get_or_raise(task_id, user_id)

        # Remplacer tous les champs
        task.title = task_data.title
        task.description = task_data.description
        task.priority = task_data.priority
        task.due_date = task_data.due_date
        task.estimated_hours = task_data.estimated_hours
        task.story_points = task_data.story_points
        task.assigned_to_id = task_data.assigned_to
        task.project_id = task_data.project_id
        task.updated_at = datetime.utcnow()

        await self.db.flush()
        await self.db.refresh(task)

        return task

    async def complete(self, task_id: int, user_id: int) -> Task:
        """Marque une tâche comme complète."""
        task = await self.get_or_raise(task_id)

        if task.completed:
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail="La tâche est déjà marquée comme complète",
            )

        task.completed = True
        task.status = StatusEnum.done
        task.updated_at = datetime.utcnow()

        await self.db.flush()
        return task

    async def assign(self, task_id: int, assignee_id: int, user_id: int) -> Task:
        """Assigne une tâche à un utilisateur."""
        task = await self.get_or_raise(task_id)
        task.assigned_to_id = assignee_id
        task.updated_at = datetime.utcnow()
        await self.db.flush()
        return task

    # ════════════════════════════════════════════════════════════════
    # CHAPITRE 24 — DELETE
    # ════════════════════════════════════════════════════════════════

    async def soft_delete(self, task_id: int, user_id: int) -> None:
        """
        Suppression logique (soft delete).
        La tâche reste en base mais deleted_at est défini.
        Avantage : restaurable, pas de perte de données, audit trail.
        """
        task = await self.get_or_raise(task_id, user_id)
        task.deleted_at = datetime.utcnow()
        await self.db.flush()

    async def hard_delete(self, task_id: int, user_id: int) -> None:
        """
        Suppression physique (hard delete).
        Supprime réellement l'enregistrement de la DB.
        Irréversible !
        """
        task = await self.get_or_raise(task_id, user_id)
        await self.db.delete(task)
        await self.db.flush()

    async def restore(self, task_id: int, user_id: int) -> Task:
        """Restaure une tâche soft-deleted."""
        # On cherche aussi dans les supprimées
        task = await self.get_by_id(task_id, include_deleted=True)

        if not task:
            raise HTTPException(status_code=404, detail="Tâche non trouvée")
        if not task.is_deleted:
            raise HTTPException(status_code=409, detail="La tâche n'est pas supprimée")
        if task.created_by_id != user_id:
            raise HTTPException(status_code=403, detail="Accès refusé")

        task.deleted_at = None
        await self.db.flush()
        return task

    async def bulk_delete(self, task_ids: list[int], user_id: int) -> dict:
        """Suppression en masse de plusieurs tâches."""
        # Vérifier lesquelles existent et appartiennent à l'utilisateur
        result = await self.db.execute(
            select(Task.id).where(
                and_(
                    Task.id.in_(task_ids),
                    Task.created_by_id == user_id,
                    Task.deleted_at.is_(None),
                )
            )
        )
        found_ids = set(result.scalars().all())
        not_found = set(task_ids) - found_ids

        # Soft delete tous ceux trouvés
        if found_ids:
            await self.db.execute(
                update(Task)
                .where(Task.id.in_(found_ids))
                .values(deleted_at=datetime.utcnow())
            )

        return {
            "deleted": len(found_ids),
            "not_found": list(not_found),
        }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21.2 ROUTES FASTAPI — WIRING COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/api/v1/tasks.py — Routes connectées à la DB
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter, Depends, status, Query, Path
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from datetime import date

from app.database import get_db
from app.models.task import PriorityEnum, StatusEnum
from app.schemas.task import (
    TaskCreate, TaskUpdate, TaskResponse,
    TaskDetailResponse, TaskListResponse, PaginationMeta,
)
from app.services.task_service import TaskService

router = APIRouter(prefix="/tasks", tags=["tasks"])

# ── Types annotés pour injection de dépendances ───────────────────────
DBSession = Annotated[AsyncSession, Depends(get_db)]
TaskId = Annotated[int, Path(ge=1, description="ID de la tâche")]

# ── Constante USER_ID (sera remplacée par JWT en Partie 7) ───────────
FAKE_USER_ID = 1   # Placeholder, retiré quand l'auth est implémentée


@router.get(
    "/",
    response_model=TaskListResponse,
    summary="Lister les tâches",
)
async def list_tasks(
    db: DBSession,
    page: Annotated[int, Query(ge=1)] = 1,
    per_page: Annotated[int, Query(ge=1, le=100)] = 20,
    q: Annotated[Optional[str], Query(min_length=2)] = None,
    status: Optional[StatusEnum] = None,
    priority: Optional[PriorityEnum] = None,
    completed: Optional[bool] = None,
    due_before: Optional[date] = None,
    sort_by: str = Query("created_at", pattern="^(created_at|due_date|priority|title)$"),
    order: str = Query("desc", pattern="^(asc|desc)$"),
):
    service = TaskService(db)
    tasks, total = await service.list_with_filters(
        page=page, per_page=per_page,
        status=status, priority=priority,
        completed=completed, q=q,
        due_before=due_before,
        sort_by=sort_by, order=order,
    )
    total_pages = -(-total // per_page)  # Ceiling division
    return TaskListResponse(
        data=tasks,
        pagination=PaginationMeta(
            page=page, per_page=per_page, total=total,
            total_pages=total_pages,
            has_next=page < total_pages,
            has_prev=page > 1,
        ),
    )


@router.get("/{task_id}", response_model=TaskDetailResponse, summary="Détail d'une tâche")
async def get_task(task_id: TaskId, db: DBSession):
    service = TaskService(db)
    task = await service.get_by_id(task_id, load_relations=True)
    if not task:
        raise HTTPException(status_code=404, detail=f"Tâche {task_id} introuvable")
    return task


@router.post("/", response_model=TaskResponse, status_code=201, summary="Créer une tâche")
async def create_task(task_data: TaskCreate, db: DBSession):
    service = TaskService(db)
    return await service.create(task_data, created_by_id=FAKE_USER_ID)


@router.put("/{task_id}", response_model=TaskResponse, summary="Remplacer une tâche")
async def replace_task(task_id: TaskId, task_data: TaskCreate, db: DBSession):
    service = TaskService(db)
    return await service.replace(task_id, task_data, user_id=FAKE_USER_ID)


@router.patch("/{task_id}", response_model=TaskResponse, summary="Modifier une tâche")
async def update_task(task_id: TaskId, task_data: TaskUpdate, db: DBSession):
    service = TaskService(db)
    return await service.update(task_id, task_data, user_id=FAKE_USER_ID)


@router.delete("/{task_id}", status_code=204, summary="Supprimer une tâche")
async def delete_task(task_id: TaskId, db: DBSession):
    service = TaskService(db)
    await service.soft_delete(task_id, user_id=FAKE_USER_ID)


@router.post("/{task_id}/complete", response_model=TaskResponse, summary="Compléter une tâche")
async def complete_task(task_id: TaskId, db: DBSession):
    service = TaskService(db)
    return await service.complete(task_id, user_id=FAKE_USER_ID)


@router.post("/{task_id}/assign", response_model=TaskResponse, summary="Assigner une tâche")
async def assign_task(
    task_id: TaskId,
    db: DBSession,
    assignee_id: Annotated[int, Query(ge=1, description="ID de l'utilisateur")],
):
    service = TaskService(db)
    return await service.assign(task_id, assignee_id, user_id=FAKE_USER_ID)


@router.get("/stats/summary", summary="Statistiques des tâches")
async def get_stats(db: DBSession):
    service = TaskService(db)
    return await service.get_statistics()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21.3 INTÉGRATION DANS MAIN.PY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/main.py — Version avec DB connectée
# ═══════════════════════════════════════════════════════════════════════

from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.database import init_db
from app.api.v1 import tasks, users

@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    Nouveau style de lifecycle FastAPI (remplace @app.on_event).
    Code AVANT le yield = startup
    Code APRÈS le yield  = shutdown
    """
    # ── Startup ──────────────────────────────────────────────────────
    print("[RAPIDE] Démarrage de TaskFlow API...")

    # En développement : créer les tables automatiquement
    # En production : utiliser Alembic migrations
    await init_db()
    print("[OK] Base de données connectée")

    yield  # L'application tourne ici

    # ── Shutdown ─────────────────────────────────────────────────────
    print("[STOP] Arrêt de TaskFlow API...")

app = FastAPI(
    title="TaskFlow API",
    version="1.0.0",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(tasks.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")

@app.get("/health")
async def health():
    return {"status": "healthy", "version": "1.0.0"}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21.4 EXERCICES — CHAPITRES 17-24
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 21.1 : Lance PostgreSQL avec Docker Compose, connecte TaskFlow API,
  et teste le endpoint POST /api/v1/tasks pour créer une vraie tâche
  dans la base de données.

Ex 21.2 : Crée une migration Alembic qui ajoute une colonne
  "color" (String(7), nullable=True) à la table tasks.
  Applique la migration et vérifie dans PostgreSQL.

Ex 21.3 : Utilise SQLAlchemy pour compter le nombre de tâches par
  statut avec un GROUP BY, et retourne les stats.

NIVEAU INTERMÉDIAIRE
--------------------
Ex 21.4 : Implémente le UserService avec create, get_by_id,
  get_by_email, list, update, soft_delete en suivant le même
  pattern que TaskService.

Ex 21.5 : Ajoute une contrainte : une tâche ne peut être assignée
  qu'à un utilisateur actif (is_active=True). Valider dans le service
  avant l'assignation (une requête DB supplémentaire).

NIVEAU AVANCÉ
-------------
Ex 21.6 : Implémente une fonction de recherche full-text PostgreSQL
  en utilisant to_tsvector et to_tsquery pour chercher dans le titre
  et la description des tâches de façon performante.

Ex 21.7 : Implémente un historique de modifications : à chaque UPDATE
  d'une tâche, sauvegarder l'ancien état dans une table task_history
  avec user_id, changed_at, changed_fields (JSONB).

================================================================================
                         RÉCAPITULATIF PARTIE 4
================================================================================

Dans cette partie, tu as appris :

[OK] Response models : filtrage automatique des données sensibles
[OK] Types de réponses : JSONResponse, HTML, Streaming, File, Redirect
[OK] Status codes : référence complète et usage correct
[OK] Background tasks : exécution post-réponse
[OK] PostgreSQL + Docker : configuration de développement
[OK] SQLAlchemy 2.0 async : engine, session, models avec Mapped[]
[OK] Alembic : migrations de schéma versionées
[OK] Service layer : CRUD complet avec filtres, pagination, soft delete
[OK] Architecture : séparation routes / services / modèles

[RAPIDE] PROCHAINE ÉTAPE : Partie 5 — Authentification JWT
   - OAuth2 avec FastAPI
   - Génération et validation de tokens JWT
   - Protection des endpoints
   - Gestion des rôles et permissions

================================================================================
                          FIN DE LA PARTIE 4
                   Passe à fastapi_master_part_5.txt
================================================================================

================================================================================
     GUIDE FASTAPI COMPLET — PARTIE 5 : AUTHENTIFICATION & SÉCURITÉ
     Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FIL ROUGE : TaskFlow API
   Dans cette partie, nous sécurisons complètement TaskFlow API avec :
   - Hashing des mots de passe (bcrypt)
   - Authentification OAuth2 + JWT
   - Protection des endpoints par dépendances
   - Système de rôles et permissions
   - Refresh tokens pour les sessions longues

================================================================================
         CHAPITRE 25 — OAUTH2 ET JWT : THÉORIE COMPLÈTE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25.1 POURQUOI L'AUTHENTIFICATION EST CRITIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Sans authentification, n'importe qui peut :
  - Lire toutes les tâches de tous les utilisateurs
  - Modifier ou supprimer n'importe quelle tâche
  - Créer des données frauduleuses

Rappel distinction cruciale :
  AUTHENTIFICATION = "Qui es-tu ?" -> vérifier l'identité (login/password)
  AUTORISATION     = "As-tu le droit ?" -> vérifier les permissions

Stratégies d'authentification communes :
  Session + Cookie  : serveur garde l'état -> difficile à scaler
  API Key           : clé fixe -> pratique pour les machines (pas les humains)
  OAuth2 + JWT      : tokens sans état -> scalable, le standard moderne

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25.2 JWT — JSON WEB TOKENS EXPLIQUÉ EN PROFONDEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un JWT est une chaîne encodée en Base64Url divisée en 3 parties séparées
par des points :

  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  .
  eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNTE2MjM5MDIyfQ
  .
  SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Structure :
  PARTIE 1 — HEADER (algorithme + type)
  {
    "alg": "HS256",    <- algorithme de signature (HMAC-SHA256)
    "typ": "JWT"       <- type de token
  }

  PARTIE 2 — PAYLOAD (les données / "claims")
  {
    "sub": "42",               <- subject : ID utilisateur (standard JWT)
    "email": "alice@tf.com",   <- claim personnalisé
    "role": "admin",           <- claim personnalisé
    "iat": 1516239022,         <- issued at : timestamp de création (standard)
    "exp": 1516242622,         <- expiration : timestamp d'expiration (standard)
    "jti": "uuid-unique-id"    <- JWT ID : identifiant unique du token (standard)
  }

  PARTIE 3 — SIGNATURE
  HMAC-SHA256(
    base64url(header) + "." + base64url(payload),
    SECRET_KEY
  )
  -> Garantit que le token n'a pas été modifié

IMPORTANT : Le payload est ENCODÉ (Base64) mais PAS CHIFFRÉ.
N'importe qui peut lire le contenu en le décodant.
Ne JAMAIS mettre de données sensibles (mots de passe, numéros de carte...).
La SIGNATURE garantit seulement l'intégrité (non-falsification).

Fonctionnement dans TaskFlow :

  1. Client envoie POST /auth/login avec {email, password}
  2. Serveur vérifie les credentials contre la DB
  3. Si valide -> génère un JWT signé avec SECRET_KEY
  4. Retourne {access_token, refresh_token, token_type}
  5. Client stocke le token et l'envoie dans chaque requête :
     Header: Authorization: Bearer eyJhbGci...
  6. Serveur vérifie la signature -> extrait user_id -> charge l'utilisateur
  7. Pas besoin de chercher le token en DB (stateless !) -> très rapide

Durée de vie recommandée :
  access_token  : 15 min à 1 heure (court -> limite les risques si volé)
  refresh_token : 7 à 30 jours (long -> évite les reconnexions fréquentes)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25.3 OAUTH2 PASSWORD FLOW DANS FASTAPI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

OAuth2 est un protocole d'autorisation. FastAPI supporte plusieurs "flows".
Pour une API où tu contrôles le client ET le serveur (SaaS), on utilise
le "Password Flow" (Resource Owner Password Credentials).

Séquence complète :

  Client                         TaskFlow API                    PostgreSQL
    │                                │                               │
    │── POST /auth/login ───────────[BLACK_RIGHT-POINTING_TRIANGLE]│                               │
    │   {email, password}           │── SELECT user WHERE email ──[BLACK_RIGHT-POINTING_TRIANGLE]│
    │                               │[BLACK_LEFT-POINTING_TRIANGLE]── user row ─────────────────│
    │                               │── bcrypt.verify(pass, hash) ──│
    │                               │   -> True                      │
    │[BLACK_LEFT-POINTING_TRIANGLE]── 200 OK ─────────────────── │                               │
    │   {access_token: "eyJ...",    │                               │
    │    refresh_token: "eyJ...",   │                               │
    │    token_type: "bearer"}      │                               │
    │                               │                               │
    │── GET /api/v1/tasks ─────────[BLACK_RIGHT-POINTING_TRIANGLE]│                               │
    │   Authorization: Bearer eyJ...│                               │
    │                               │── Decode JWT (no DB!) ────────│
    │                               │── Verify signature ───────────│
    │                               │── Extract user_id = 42 ───────│
    │                               │── SELECT user WHERE id=42 ──[BLACK_RIGHT-POINTING_TRIANGLE]│
    │[BLACK_LEFT-POINTING_TRIANGLE]── 200 OK {tasks: [...]} ─────│[BLACK_LEFT-POINTING_TRIANGLE]── user ─────────────────────│
    │                               │                               │

================================================================================
          CHAPITRE 26 — IMPLÉMENTATION COMPLÈTE DE L'AUTHENTIFICATION
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26.1 INSTALLATION DES DÉPENDANCES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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

  # python-jose[cryptography] -> génération et validation de JWT
  # passlib[bcrypt]           -> hashing sécurisé des mots de passe
  # python-multipart          -> parsing du formulaire OAuth2 (requis par FastAPI)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26.2 HASHING DES MOTS DE PASSE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/core/security.py — Hashing + JWT
# ═══════════════════════════════════════════════════════════════════════

from passlib.context import CryptContext
from jose import jwt, JWTError
from datetime import datetime, timedelta, timezone
from typing import Optional
import uuid

from app.config import get_settings

settings = get_settings()

# ── Configuration de passlib pour bcrypt ─────────────────────────────
# CryptContext gère plusieurs algorithmes et permet la migration
pwd_context = CryptContext(
    schemes=["bcrypt"],        # Algorithme utilisé
    deprecated="auto",         # Déprécier automatiquement les anciens algos
    bcrypt__rounds=12,         # Facteur de coût bcrypt (2^12 = 4096 itérations)
    # Plus rounds est élevé -> plus c'est lent -> plus c'est sécurisé
    # 12 est le standard actuel (environ 0.3s sur un serveur moderne)
)

def hash_password(plain_password: str) -> str:
    """
    Transforme un mot de passe en clair en un hash bcrypt sécurisé.

    Bcrypt inclut automatiquement un SALT (valeur aléatoire unique) dans
    le hash -> deux hashes du même mot de passe seront DIFFÉRENTS.

    Exemple :
    hash_password("monPass123!") -> "$2b$12$vq9n5...salt...hashed_value"
    hash_password("monPass123!") -> "$2b$12$xK2m7...salt...different_value"

    Jamais stocker le mot de passe en clair, JAMAIS utiliser MD5/SHA1.
    """
    return pwd_context.hash(plain_password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """
    Vérifie qu'un mot de passe en clair correspond à son hash bcrypt.

    Sécurisé contre les timing attacks grâce à la comparaison constante
    de passlib (pas de court-circuit si les premiers caractères matchent).
    """
    return pwd_context.verify(plain_password, hashed_password)

# ── Démonstration interactive ─────────────────────────────────────────
if __name__ == "__main__":
    password = "MonMotDePasse@2024"

    # Hashing (lent intentionnellement grâce à bcrypt)
    hashed = hash_password(password)
    print(f"Hash : {hashed}")
    # -> $2b$12$K9Bw6.vLNQZBi7HGXP2p9.RANDOMSALT.HASHEDPASSWORD

    # Vérification
    print(verify_password(password, hashed))         # True
    print(verify_password("mauvais_mdp", hashed))    # False

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26.3 GÉNÉRATION ET VALIDATION DES JWT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Suite de app/core/security.py — JWT complet
# ═══════════════════════════════════════════════════════════════════════

from pydantic import BaseModel

# ── Modèles de tokens ─────────────────────────────────────────────────
class TokenData(BaseModel):
    """Données extraites d'un JWT décodé."""
    user_id: int
    email: str
    role: str
    token_type: str    # "access" ou "refresh"
    jti: str           # JWT ID (identifiant unique du token)

class TokenResponse(BaseModel):
    """Réponse retournée lors du login."""
    access_token: str
    refresh_token: str
    token_type: str = "bearer"
    expires_in: int     # Durée en secondes avant expiration de l'access token

# ── Génération des tokens ─────────────────────────────────────────────
def create_access_token(
    user_id: int,
    email: str,
    role: str,
    expires_delta: Optional[timedelta] = None,
) -> str:
    """
    Génère un JWT access token.

    Le payload contient :
    - sub    : Subject = ID de l'utilisateur (standard JWT)
    - email  : Email (pour éviter une requête DB à chaque requête)
    - role   : Rôle (pour l'autorisation rapide sans DB)
    - type   : "access" (distinguer access et refresh tokens)
    - jti    : UUID unique (permet la révocation si stocké en DB/Redis)
    - iat    : Issued At (auto-inclus par jose)
    - exp    : Expiration (calculée ici)
    """
    now = datetime.now(timezone.utc)

    # Durée d'expiration
    if expires_delta:
        expire = now + expires_delta
    else:
        expire = now + timedelta(minutes=settings.access_token_expire_minutes)

    payload = {
        "sub": str(user_id),                  # Standard JWT : doit être string
        "email": email,
        "role": role,
        "type": "access",                     # Type de token
        "jti": str(uuid.uuid4()),             # ID unique pour révocation
        "iat": now,
        "exp": expire,
    }

    # Encoder le JWT avec la clé secrète et l'algorithme
    token = jwt.encode(
        payload,
        settings.secret_key,                  # Clé secrète (en .env)
        algorithm=settings.algorithm,         # HS256 par défaut
    )

    return token

def create_refresh_token(user_id: int, email: str) -> str:
    """
    Génère un JWT refresh token.
    Durée de vie plus longue (7 jours).
    Contient moins d'informations (moins de surface d'attaque).
    """
    now = datetime.now(timezone.utc)
    expire = now + timedelta(days=7)

    payload = {
        "sub": str(user_id),
        "email": email,
        "type": "refresh",                    # IMPORTANT : différent de "access"
        "jti": str(uuid.uuid4()),
        "iat": now,
        "exp": expire,
    }

    return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)

def create_token_pair(user_id: int, email: str, role: str) -> TokenResponse:
    """
    Génère une paire access + refresh token.
    Appelé lors du login et du refresh.
    """
    access_expire = timedelta(minutes=settings.access_token_expire_minutes)

    access_token = create_access_token(user_id, email, role, access_expire)
    refresh_token = create_refresh_token(user_id, email)

    return TokenResponse(
        access_token=access_token,
        refresh_token=refresh_token,
        token_type="bearer",
        expires_in=int(access_expire.total_seconds()),
    )

# ── Validation et décodage des tokens ────────────────────────────────
def decode_token(token: str) -> TokenData:
    """
    Décode et valide un JWT.

    Étapes effectuées par python-jose :
    1. Décoder le Base64
    2. Vérifier la SIGNATURE avec la SECRET_KEY
    3. Vérifier l'EXPIRATION (champ "exp")
    4. Si tout est valide -> retourner le payload
    5. Sinon -> lever JWTError

    Cette fonction lève une exception si le token est invalide,
    expiré, ou falsifié. JAMAIS retourner None silencieusement.
    """
    try:
        payload = jwt.decode(
            token,
            settings.secret_key,
            algorithms=[settings.algorithm],  # Liste des algos acceptés
        )

        # Vérifier que tous les champs requis sont présents
        user_id_str = payload.get("sub")
        email = payload.get("email")
        role = payload.get("role")
        token_type = payload.get("type")
        jti = payload.get("jti")

        if not all([user_id_str, email, role, token_type, jti]):
            raise JWTError("Token incomplet : champs manquants")

        return TokenData(
            user_id=int(user_id_str),
            email=email,
            role=role,
            token_type=token_type,
            jti=jti,
        )

    except JWTError as e:
        # On ne révèle pas les détails de l'erreur au client
        # (sécurité : ne pas indiquer si le token est expiré vs falsifié)
        raise ValueError(f"Token invalide : {e}") from e

def decode_access_token(token: str) -> TokenData:
    """Décode et vérifie que c'est bien un access token."""
    data = decode_token(token)
    if data.token_type != "access":
        raise ValueError("Ce token n'est pas un access token")
    return data

def decode_refresh_token(token: str) -> TokenData:
    """Décode et vérifie que c'est bien un refresh token."""
    data = decode_token(token)
    if data.token_type != "refresh":
        raise ValueError("Ce token n'est pas un refresh token")
    return data

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26.4 SERVICE D'AUTHENTIFICATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/services/auth_service.py — Logique d'authentification
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import HTTPException, status
from pydantic import BaseModel, EmailStr, Field

from app.models.user import User
from app.core.security import (
    hash_password, verify_password,
    create_token_pair, decode_refresh_token,
    TokenResponse,
)

# ── Schémas d'authentification ────────────────────────────────────────
class LoginRequest(BaseModel):
    """Données de connexion."""
    email: EmailStr
    password: str = Field(..., min_length=1)

class RegisterRequest(BaseModel):
    """Données d'inscription."""
    email: EmailStr
    username: str = Field(..., min_length=3, max_length=50,
                          pattern=r"^[a-zA-Z0-9_-]+$")
    password: str = Field(..., min_length=8)
    full_name: str | None = Field(None, max_length=200)

class ChangePasswordRequest(BaseModel):
    """Changement de mot de passe."""
    current_password: str
    new_password: str = Field(..., min_length=8)

# ── Service ───────────────────────────────────────────────────────────
class AuthService:

    def __init__(self, db: AsyncSession):
        self.db = db

    async def register(self, data: RegisterRequest) -> User:
        """
        Inscription d'un nouvel utilisateur.
        Vérifie unicité email + username, hash le mot de passe.
        """
        # ── Vérification unicité email ────────────────────────────────
        existing_email = await self.db.execute(
            select(User).where(User.email == data.email.lower())
        )
        if existing_email.scalar_one_or_none():
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail={
                    "code": "EMAIL_ALREADY_EXISTS",
                    "message": f"L'email {data.email} est déjà utilisé",
                    "field": "email",
                }
            )

        # ── Vérification unicité username ─────────────────────────────
        existing_username = await self.db.execute(
            select(User).where(User.username == data.username.lower())
        )
        if existing_username.scalar_one_or_none():
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail={
                    "code": "USERNAME_ALREADY_EXISTS",
                    "message": f"Le nom d'utilisateur '{data.username}' est déjà pris",
                    "field": "username",
                }
            )

        # ── Créer l'utilisateur ───────────────────────────────────────
        new_user = User(
            email=data.email.lower(),             # Normaliser en minuscules
            username=data.username.lower(),
            full_name=data.full_name,
            hashed_password=hash_password(data.password),  # <- Hashing ici
            is_active=True,
            is_superuser=False,
            role="user",                           # Rôle par défaut
        )

        self.db.add(new_user)
        await self.db.flush()
        await self.db.refresh(new_user)

        return new_user

    async def login(self, data: LoginRequest) -> TokenResponse:
        """
        Connexion : vérifie les credentials et retourne les tokens.

        Sécurité : on fait TOUJOURS les deux vérifications (email + password)
        avant de retourner une erreur, pour éviter l'énumération d'emails
        (timing attack : si on arrêtait dès que l'email est introuvable,
        une réponse plus rapide révèlerait qu'un email n'existe pas).
        """
        # ── Chercher l'utilisateur ────────────────────────────────────
        result = await self.db.execute(
            select(User).where(User.email == data.email.lower())
        )
        user = result.scalar_one_or_none()

        # ── Vérifier password (toujours, même si user non trouvé) ─────
        # Si user None -> fake_hash pour que la vérification prenne le même temps
        fake_hash = "$2b$12$KIXimMfB9S8KNPD/F2GxVO.nUb3zIl1bJpgcP.YUFn4jCgvivMIqK"
        password_correct = verify_password(
            data.password,
            user.hashed_password if user else fake_hash
        )

        # ── Valider (on vérifie tout avant de lever l'erreur) ─────────
        if not user or not password_correct:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail={
                    "code": "INVALID_CREDENTIALS",
                    "message": "Email ou mot de passe incorrect",
                },
                headers={"WWW-Authenticate": "Bearer"},
                # WWW-Authenticate: Bearer indique au client comment s'authentifier
            )

        if not user.is_active:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail={
                    "code": "ACCOUNT_DISABLED",
                    "message": "Votre compte a été désactivé. Contactez le support.",
                }
            )

        # ── Générer et retourner les tokens ───────────────────────────
        return create_token_pair(
            user_id=user.id,
            email=user.email,
            role=user.role,
        )

    async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
        """
        Génère un nouvel access token à partir d'un refresh token valide.
        Pattern : silent refresh pour les sessions longues.
        """
        # Décoder et valider le refresh token
        try:
            token_data = decode_refresh_token(refresh_token)
        except ValueError:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail={"code": "INVALID_REFRESH_TOKEN", "message": "Refresh token invalide ou expiré"},
                headers={"WWW-Authenticate": "Bearer"},
            )

        # Vérifier que l'utilisateur existe encore et est actif
        result = await self.db.execute(
            select(User).where(User.id == token_data.user_id)
        )
        user = result.scalar_one_or_none()

        if not user or not user.is_active:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail={"code": "USER_NOT_FOUND", "message": "Utilisateur non trouvé ou désactivé"},
            )

        # Générer une nouvelle paire de tokens
        return create_token_pair(
            user_id=user.id,
            email=user.email,
            role=user.role,
        )

    async def change_password(
        self,
        user: User,
        data: ChangePasswordRequest,
    ) -> None:
        """Change le mot de passe d'un utilisateur connecté."""
        # Vérifier l'ancien mot de passe
        if not verify_password(data.current_password, user.hashed_password):
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail={"code": "WRONG_CURRENT_PASSWORD",
                        "message": "Le mot de passe actuel est incorrect"}
            )

        # Vérifier que le nouveau est différent
        if verify_password(data.new_password, user.hashed_password):
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail={"code": "SAME_PASSWORD",
                        "message": "Le nouveau mot de passe doit être différent du précédent"}
            )

        user.hashed_password = hash_password(data.new_password)
        await self.db.flush()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26.5 ROUTES D'AUTHENTIFICATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/api/v1/auth.py — Endpoints d'authentification
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter, Depends, status, Body
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated

from app.database import get_db
from app.services.auth_service import AuthService, RegisterRequest, ChangePasswordRequest
from app.core.security import TokenResponse
from app.schemas.user import UserResponse
# get_current_user sera défini dans le chapitre 27
from app.api.deps import get_current_user

router = APIRouter(prefix="/auth", tags=["authentication"])

DBSession = Annotated[AsyncSession, Depends(get_db)]

# ── POST /auth/register ───────────────────────────────────────────────
@router.post(
    "/register",
    response_model=UserResponse,
    status_code=status.HTTP_201_CREATED,
    summary="Créer un compte",
    description="Inscription d'un nouvel utilisateur TaskFlow.",
)
async def register(data: RegisterRequest, db: DBSession):
    """
    Crée un nouveau compte utilisateur.
    Retourne les informations publiques du compte créé (sans token).
    L'utilisateur doit ensuite se connecter via /auth/login.
    """
    service = AuthService(db)
    user = await service.register(data)
    return user

# ── POST /auth/login ──────────────────────────────────────────────────
@router.post(
    "/login",
    response_model=TokenResponse,
    summary="Se connecter",
    description="Authentification par email + mot de passe.",
)
async def login(
    db: DBSession,
    # OAuth2PasswordRequestForm est le format standard OAuth2 :
    # Content-Type: application/x-www-form-urlencoded
    # Body: username=alice@test.com&password=monPass123
    # Note : OAuth2 utilise "username" même pour un email
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
):
    """
    Connexion via formulaire OAuth2 (compatible Swagger UI).

    Retourne :
    - access_token  : valide 30 min, à envoyer dans Authorization: Bearer
    - refresh_token : valide 7 jours, à utiliser pour renouveler l'access token
    - token_type    : "bearer"
    - expires_in    : secondes avant expiration de l'access token
    """
    from app.services.auth_service import LoginRequest
    service = AuthService(db)
    return await service.login(LoginRequest(
        email=form_data.username,   # OAuth2 utilise "username" (même si c'est un email)
        password=form_data.password,
    ))

# ── POST /auth/login/json ─────────────────────────────────────────────
@router.post(
    "/login/json",
    response_model=TokenResponse,
    summary="Se connecter (JSON)",
    description="Alternative JSON au login OAuth2. Pour les apps mobiles.",
)
async def login_json(
    db: DBSession,
    data: Annotated[
        dict,
        Body(example={"email": "alice@taskflow.com", "password": "MonPass@2024"})
    ],
):
    """Login en JSON (pour les clients qui préfèrent JSON au form-data)."""
    from app.services.auth_service import LoginRequest
    service = AuthService(db)
    return await service.login(LoginRequest(**data))

# ── POST /auth/refresh ────────────────────────────────────────────────
@router.post(
    "/refresh",
    response_model=TokenResponse,
    summary="Renouveler les tokens",
)
async def refresh_tokens(
    db: DBSession,
    refresh_token: Annotated[str, Body(embed=True, description="Refresh token valide")],
):
    """
    Génère une nouvelle paire access + refresh token.
    Appelé automatiquement par le client quand l'access token expire.
    """
    service = AuthService(db)
    return await service.refresh_access_token(refresh_token)

# ── GET /auth/me ──────────────────────────────────────────────────────
@router.get(
    "/me",
    response_model=UserResponse,
    summary="Profil de l'utilisateur connecté",
)
async def get_me(
    current_user: Annotated[User, Depends(get_current_user)],
):
    """
    Retourne les informations de l'utilisateur actuellement connecté.
    Nécessite un access token valide dans le header Authorization.
    """
    return current_user

# ── POST /auth/change-password ────────────────────────────────────────
@router.post(
    "/change-password",
    status_code=status.HTTP_204_NO_CONTENT,
    summary="Changer le mot de passe",
)
async def change_password(
    db: DBSession,
    data: ChangePasswordRequest,
    current_user: Annotated[User, Depends(get_current_user)],
):
    """Change le mot de passe. Nécessite le mot de passe actuel."""
    service = AuthService(db)
    await service.change_password(current_user, data)

# ── POST /auth/logout ─────────────────────────────────────────────────
@router.post(
    "/logout",
    status_code=status.HTTP_204_NO_CONTENT,
    summary="Se déconnecter",
)
async def logout(
    current_user: Annotated[User, Depends(get_current_user)],
):
    """
    Déconnexion.

    Note : JWT étant stateless, le "vrai" logout côté serveur nécessite
    une blacklist (Redis). Ici on retourne juste 204.
    En pratique : le client supprime le token de son storage local.
    Pour une vraie révocation -> voir le chapitre sur Redis blacklist.
    """
    # Sans Redis : on ne peut pas vraiment invalider le token
    # Le client doit supprimer ses tokens localement
    return None

================================================================================
                    CHAPITRE 27 — DÉPENDANCES DE SÉCURITÉ
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27.1 DEPENDENCY INJECTION — LE CŒUR DE LA SÉCURITÉ FASTAPI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FastAPI utilise un système de dépendances (Depends) pour injecter
automatiquement des ressources dans les handlers. C'est LE mécanisme
pour implémenter l'authentification proprement.

Principe :
  @app.get("/protected")
  async def protected_route(
      current_user = Depends(get_current_user)
      #              ^ FastAPI appelle get_current_user() avant le handler
      #                et injecte le résultat dans current_user
  ):
      ...

Avantages :
  - Code DRY : la logique d'auth est dans une seule fonction
  - Testable : on peut override les dépendances dans les tests
  - Chaînable : une dépendance peut elle-même avoir des dépendances
  - Réutilisable : Depends(get_current_user) sur n'importe quelle route

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27.2 IMPLÉMENTATION DES DÉPENDANCES D'AUTHENTIFICATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/api/deps.py — Dépendances d'authentification et d'autorisation
# ═══════════════════════════════════════════════════════════════════════

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Annotated

from app.database import get_db
from app.models.user import User
from app.core.security import decode_access_token

# ── OAuth2PasswordBearer — extrait le token du header ────────────────
# tokenUrl : URL où obtenir le token (pour la doc Swagger)
# Quand FastAPI reçoit une requête, OAuth2PasswordBearer extrait
# automatiquement le token depuis "Authorization: Bearer {token}"
oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="/api/v1/auth/login",   # Swagger saura où faire le login
    auto_error=True,                  # Lève HTTPException si token absent
)

# ── Dépendance de base : utilisateur courant ──────────────────────────
async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],  # Token extrait du header
    db: Annotated[AsyncSession, Depends(get_db)],   # Session DB
) -> User:
    """
    Dépendance principale d'authentification.

    Étapes :
    1. OAuth2PasswordBearer extrait le token du header Authorization
    2. On décode et valide le JWT
    3. On charge l'utilisateur depuis la DB
    4. On retourne l'utilisateur -> injecté dans le handler

    Si une étape échoue -> HTTPException 401 automatique.
    """
    # Exception réutilisable pour tous les cas d'échec
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail={
            "code": "INVALID_TOKEN",
            "message": "Token d'authentification invalide ou expiré",
        },
        headers={"WWW-Authenticate": "Bearer"},
        # WWW-Authenticate: Bearer est requis par le standard HTTP pour les 401
    )

    # ── Décoder le token ───────────────────────────────────────────────
    try:
        token_data = decode_access_token(token)
    except ValueError:
        raise credentials_exception

    # ── Charger l'utilisateur depuis la DB ────────────────────────────
    # On charge depuis la DB (pas seulement depuis le token) pour :
    # - Détecter les comptes désactivés depuis l'émission du token
    # - Avoir des données fraîches (nom, rôle potentiellement mis à jour)
    result = await db.execute(
        select(User).where(User.id == token_data.user_id)
    )
    user = result.scalar_one_or_none()

    if user is None:
        raise credentials_exception

    # ── Vérifier que le compte est actif ──────────────────────────────
    if not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail={
                "code": "ACCOUNT_DISABLED",
                "message": "Compte désactivé. Contactez l'administrateur.",
            }
        )

    return user

# ── Dépendance : utilisateur actif (raccourci) ────────────────────────
async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)]
) -> User:
    """
    Vérifie que l'utilisateur est actif.
    Alias de get_current_user (qui vérifie déjà is_active).
    Utile pour la clarté sémantique dans les routes.
    """
    return current_user

# ── Dépendances de rôle ───────────────────────────────────────────────

def require_role(*allowed_roles: str):
    """
    Factory de dépendances : crée une dépendance qui vérifie le rôle.

    Usage :
        @router.delete("/admin/users/{id}")
        async def admin_action(
            user = Depends(require_role("admin", "superuser"))
        ):
            ...

    Explication du pattern :
    require_role("admin") retourne UNE FONCTION.
    Cette fonction est ensuite utilisée comme dépendance.
    C'est une "dépendance paramétrée" (closure).
    """
    async def role_checker(
        current_user: Annotated[User, Depends(get_current_user)]
    ) -> User:
        if current_user.role not in allowed_roles:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail={
                    "code": "INSUFFICIENT_PERMISSIONS",
                    "message": f"Cette action nécessite l'un des rôles : {', '.join(allowed_roles)}",
                    "your_role": current_user.role,
                }
            )
        return current_user
    return role_checker

# Dépendances prédéfinies pour les rôles courants
require_admin = require_role("admin", "superuser")
require_superuser = require_role("superuser")
require_moderator = require_role("admin", "superuser", "moderator")

# ── Dépendance : vérifier la propriété d'une ressource ────────────────
async def get_task_owner(
    task_id: int,
    current_user: Annotated[User, Depends(get_current_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
    """
    Vérifie que l'utilisateur connecté est le propriétaire de la tâche.
    Les admins peuvent accéder à n'importe quelle tâche.
    """
    from app.models.task import Task
    result = await db.execute(select(Task).where(Task.id == task_id))
    task = result.scalar_one_or_none()

    if not task:
        raise HTTPException(status_code=404, detail="Tâche non trouvée")

    # Admin peut tout faire
    if current_user.role in ("admin", "superuser"):
        return current_user

    # Sinon : vérifier la propriété
    if task.created_by_id != current_user.id:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail={
                "code": "NOT_TASK_OWNER",
                "message": "Vous n'êtes pas le propriétaire de cette tâche",
            }
        )

    return current_user

# ── Types annotés pour simplifier les signatures de fonctions ─────────
# Au lieu d'écrire Annotated[User, Depends(get_current_user)] partout,
# on crée des alias réutilisables

CurrentUser = Annotated[User, Depends(get_current_user)]
ActiveUser = Annotated[User, Depends(get_current_active_user)]
AdminUser = Annotated[User, Depends(require_admin)]
SuperUser = Annotated[User, Depends(require_superuser)]
DBSession = Annotated[AsyncSession, Depends(get_db)]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27.3 PROTECTION DES ROUTES — AVANT/APRÈS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/api/v1/tasks.py — Routes sécurisées avec auth complète
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter, HTTPException, status, Query, Path
from typing import Annotated, Optional
from datetime import date

from app.api.deps import CurrentUser, AdminUser, DBSession, get_task_owner
from app.models.task import PriorityEnum, StatusEnum
from app.models.user import User
from app.schemas.task import (
    TaskCreate, TaskUpdate, TaskResponse,
    TaskDetailResponse, TaskListResponse, PaginationMeta,
)
from app.services.task_service import TaskService

router = APIRouter(prefix="/tasks", tags=["tasks"])

# ── GET / — Lecture publique ou filtrée ───────────────────────────────
@router.get("/", response_model=TaskListResponse)
async def list_tasks(
    db: DBSession,
    current_user: CurrentUser,           # <- Authentification requise
    page: Annotated[int, Query(ge=1)] = 1,
    per_page: Annotated[int, Query(ge=1, le=100)] = 20,
    q: Optional[str] = None,
    status: Optional[StatusEnum] = None,
    priority: Optional[PriorityEnum] = None,
    completed: Optional[bool] = None,
    mine_only: bool = Query(False, description="Seulement mes tâches"),
):
    service = TaskService(db)
    # Si mine_only -> filtre par l'utilisateur connecté
    user_id = current_user.id if mine_only else None

    tasks, total = await service.list_with_filters(
        page=page, per_page=per_page,
        user_id=user_id,
        status=status, priority=priority,
        completed=completed, q=q,
    )
    total_pages = -(-total // per_page)
    return TaskListResponse(
        data=tasks,
        pagination=PaginationMeta(
            page=page, per_page=per_page, total=total,
            total_pages=total_pages,
            has_next=page < total_pages,
            has_prev=page > 1,
        ),
    )

# ── GET /{task_id} — Lecture d'une tâche ─────────────────────────────
@router.get("/{task_id}", response_model=TaskDetailResponse)
async def get_task(
    task_id: Annotated[int, Path(ge=1)],
    db: DBSession,
    current_user: CurrentUser,   # <- Authentifié requis
):
    service = TaskService(db)
    task = await service.get_by_id(task_id, load_relations=True)
    if not task:
        raise HTTPException(status_code=404, detail="Tâche non trouvée")
    return task

# ── POST / — Création sécurisée ───────────────────────────────────────
@router.post("/", response_model=TaskResponse, status_code=201)
async def create_task(
    task_data: TaskCreate,
    db: DBSession,
    current_user: CurrentUser,   # <- created_by_id = current_user.id
):
    service = TaskService(db)
    return await service.create(task_data, created_by_id=current_user.id)

# ── PATCH /{task_id} — Modification (propriétaire ou admin) ──────────
@router.patch("/{task_id}", response_model=TaskResponse)
async def update_task(
    task_id: Annotated[int, Path(ge=1)],
    task_data: TaskUpdate,
    db: DBSession,
    current_user: CurrentUser,
):
    service = TaskService(db)
    # update() vérifie que current_user est le propriétaire
    return await service.update(task_id, task_data, user_id=current_user.id)

# ── DELETE /{task_id} — Suppression (propriétaire ou admin) ──────────
@router.delete("/{task_id}", status_code=204)
async def delete_task(
    task_id: Annotated[int, Path(ge=1)],
    db: DBSession,
    current_user: CurrentUser,
):
    service = TaskService(db)
    await service.soft_delete(task_id, user_id=current_user.id)

# ── GET /admin/all — Toutes les tâches (admin seulement) ──────────────
@router.get("/admin/all", response_model=TaskListResponse)
async def admin_list_all_tasks(
    db: DBSession,
    current_user: AdminUser,     # <- Seuls admin et superuser passent
    page: int = 1,
    per_page: int = 50,
):
    """
    Vue admin : toutes les tâches de tous les utilisateurs.
    Accessible seulement par les admins.
    """
    service = TaskService(db)
    tasks, total = await service.list_with_filters(
        page=page, per_page=per_page,
        user_id=None,   # Pas de filtre par utilisateur
    )
    return TaskListResponse(data=tasks, pagination=PaginationMeta(
        page=page, per_page=per_page, total=total,
        total_pages=-(-total // per_page),
        has_next=page < -(-total // per_page),
        has_prev=page > 1,
    ))

# ── POST /{task_id}/complete ──────────────────────────────────────────
@router.post("/{task_id}/complete", response_model=TaskResponse)
async def complete_task(
    task_id: Annotated[int, Path(ge=1)],
    db: DBSession,
    current_user: CurrentUser,
):
    service = TaskService(db)
    return await service.complete(task_id, user_id=current_user.id)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27.4 MODÈLE USER AVEC RÔLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Mise à jour de app/models/user.py — ajout du champ role
# ═══════════════════════════════════════════════════════════════════════

import enum as python_enum
from sqlalchemy import Enum

class UserRole(str, python_enum.Enum):
    """Rôles disponibles dans TaskFlow."""
    user       = "user"        # Utilisateur standard
    moderator  = "moderator"   # Peut modérer les commentaires
    admin      = "admin"       # Admin de l'application
    superuser  = "superuser"   # Super-admin (accès total)

# Ajouter dans le modèle User :
# role: Mapped[UserRole] = mapped_column(
#     Enum(UserRole, name="user_role"),
#     default=UserRole.user,
#     nullable=False,
# )

# ── Schemas Pydantic ─────────────────────────────────────────────────
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime

class UserResponse(BaseModel):
    """Données publiques d'un utilisateur."""
    id: int
    email: str
    username: str
    full_name: Optional[str]
    role: str
    is_active: bool
    created_at: datetime
    avatar_url: Optional[str]
    # hashed_password  -> JAMAIS dans la réponse
    # is_superuser     -> seulement pour les admins

    class Config:
        from_attributes = True

class UserAdminResponse(UserResponse):
    """Données complètes d'un utilisateur (pour les admins)."""
    is_superuser: bool
    updated_at: Optional[datetime]
    # Inclut les champs supplémentaires visibles seulement par les admins

================================================================================
            CHAPITRE 27 SUITE — SÉCURITÉ AVANCÉE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27.5 RÉVOCATION DE TOKENS AVEC REDIS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

JWT est stateless : une fois émis, on ne peut pas l'invalider avant expiration.
Pour un vrai logout ou la révocation immédiate -> utiliser une blacklist Redis.

# ═══════════════════════════════════════════════════════════════════════
# app/core/token_blacklist.py — Blacklist Redis pour révocation de tokens
# ═══════════════════════════════════════════════════════════════════════

import redis.asyncio as redis
from app.config import get_settings

settings = get_settings()

# Client Redis global
redis_client: redis.Redis | None = None

async def get_redis() -> redis.Redis:
    """Retourne le client Redis (lazy init)."""
    global redis_client
    if redis_client is None:
        redis_client = redis.from_url(
            settings.redis_url,      # redis://localhost:6379/0
            encoding="utf-8",
            decode_responses=True,
        )
    return redis_client

async def blacklist_token(jti: str, expires_in_seconds: int) -> None:
    """
    Ajoute un JTI (JWT ID) à la blacklist Redis.
    Le TTL correspond à la durée de vie restante du token :
    -> Redis supprime automatiquement l'entrée quand le token serait
      de toute façon expiré.
    """
    client = await get_redis()
    key = f"blacklist:{jti}"
    await client.setex(key, expires_in_seconds, "revoked")

async def is_token_blacklisted(jti: str) -> bool:
    """Vérifie si un token est dans la blacklist."""
    client = await get_redis()
    return await client.exists(f"blacklist:{jti}") > 0

# ── Mise à jour de get_current_user avec blacklist ────────────────────
async def get_current_user_with_blacklist(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
    """Version avec vérification de la blacklist Redis."""
    credentials_exception = HTTPException(
        status_code=401,
        detail={"code": "INVALID_TOKEN", "message": "Token invalide ou révoqué"},
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        token_data = decode_access_token(token)
    except ValueError:
        raise credentials_exception

    # <- NOUVEAU : vérifier si le token est dans la blacklist
    if await is_token_blacklisted(token_data.jti):
        raise credentials_exception

    result = await db.execute(select(User).where(User.id == token_data.user_id))
    user = result.scalar_one_or_none()

    if not user or not user.is_active:
        raise credentials_exception

    return user

# ── Logout avec révocation ────────────────────────────────────────────
@router.post("/auth/logout", status_code=204)
async def logout_with_revocation(
    token: Annotated[str, Depends(oauth2_scheme)],
    current_user: CurrentUser,
):
    """Logout avec révocation immédiate du token."""
    try:
        token_data = decode_access_token(token)
        # Calculer le temps restant avant expiration
        from datetime import datetime, timezone
        remaining = int(
            (token_data.exp - datetime.now(timezone.utc)).total_seconds()
        )
        if remaining > 0:
            await blacklist_token(token_data.jti, remaining)
    except Exception:
        pass  # Même si ça échoue, le logout est considéré réussi
    return None

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27.6 MIDDLEWARE DE SÉCURITÉ GLOBAL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/middleware/security.py — Headers de sécurité HTTP
# ═══════════════════════════════════════════════════════════════════════

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    """
    Ajoute des headers de sécurité à toutes les réponses.
    Ces headers protègent contre XSS, clickjacking, MIME sniffing...
    """

    async def dispatch(self, request: Request, call_next) -> Response:
        response = await call_next(request)

        # Empêche le navigateur de deviner le MIME type
        response.headers["X-Content-Type-Options"] = "nosniff"

        # Empêche l'inclusion dans une iframe (clickjacking)
        response.headers["X-Frame-Options"] = "DENY"

        # Force HTTPS (HSTS — HTTP Strict Transport Security)
        response.headers["Strict-Transport-Security"] = (
            "max-age=31536000; includeSubDomains"
        )

        # Content Security Policy — limite les sources de contenu
        response.headers["Content-Security-Policy"] = (
            "default-src 'self'; "
            "script-src 'self' 'unsafe-inline'; "  # Pour Swagger UI
            "style-src 'self' 'unsafe-inline';"
        )

        # Referrer Policy — contrôle les infos envoyées dans le Referer
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"

        # Permissions Policy — désactiver les APIs sensibles
        response.headers["Permissions-Policy"] = (
            "geolocation=(), microphone=(), camera=()"
        )

        # Supprimer les headers qui révèlent des informations système
        response.headers.pop("Server", None)          # Cacher le type de serveur
        response.headers.pop("X-Powered-By", None)   # Cacher la techno backend

        return response

class RateLimitMiddleware(BaseHTTPMiddleware):
    """
    Rate limiting simple en mémoire (pour la prod : utiliser Redis).
    Limite à 100 requêtes/minute par IP.
    """

    def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
        super().__init__(app)
        self.max_requests = max_requests
        self.window_seconds = window_seconds
        self.requests: dict[str, list[float]] = {}

    async def dispatch(self, request: Request, call_next) -> Response:
        import time

        # Obtenir l'IP client (gérer les proxies avec X-Forwarded-For)
        client_ip = request.headers.get("X-Forwarded-For", request.client.host)
        now = time.time()

        # Initialiser ou nettoyer la liste des timestamps pour cette IP
        if client_ip not in self.requests:
            self.requests[client_ip] = []

        # Garder seulement les requêtes dans la fenêtre de temps
        self.requests[client_ip] = [
            t for t in self.requests[client_ip]
            if now - t < self.window_seconds
        ]

        # Vérifier la limite
        if len(self.requests[client_ip]) >= self.max_requests:
            return Response(
                content='{"error": "RATE_LIMIT_EXCEEDED", "message": "Trop de requêtes"}',
                status_code=429,
                media_type="application/json",
                headers={"Retry-After": str(self.window_seconds)},
            )

        # Enregistrer cette requête
        self.requests[client_ip].append(now)

        return await call_next(request)

# ── Enregistrement dans main.py ───────────────────────────────────────
# app.add_middleware(SecurityHeadersMiddleware)
# app.add_middleware(RateLimitMiddleware, max_requests=100, window_seconds=60)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27.7 CONFIGURATION SWAGGER AVEC AUTHENTIFICATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FastAPI génère automatiquement un bouton "Authorize" dans Swagger UI
qui permet de tester les endpoints protégés directement depuis la doc.

# ═══════════════════════════════════════════════════════════════════════
# app/main.py — Configuration Swagger avec OAuth2
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi

app = FastAPI(title="TaskFlow API", version="1.0.0")

def custom_openapi():
    """
    Personnalise le schéma OpenAPI pour configurer l'authentification Swagger.
    Appelé la première fois que /openapi.json est demandé (puis mis en cache).
    """
    if app.openapi_schema:
        return app.openapi_schema

    openapi_schema = get_openapi(
        title="TaskFlow API",
        version="1.0.0",
        description="API SaaS de gestion de tâches",
        routes=app.routes,
    )

    # Ajouter le schéma de sécurité OAuth2 dans OpenAPI
    openapi_schema["components"]["securitySchemes"] = {
        "OAuth2PasswordBearer": {
            "type": "oauth2",
            "flows": {
                "password": {
                    "tokenUrl": "/api/v1/auth/login",
                    "scopes": {}
                }
            }
        },
        "BearerAuth": {
            "type": "http",
            "scheme": "bearer",
            "bearerFormat": "JWT",
            "description": "Entrez: Bearer {votre_token_jwt}",
        }
    }

    app.openapi_schema = openapi_schema
    return app.openapi_schema

app.openapi = custom_openapi

# Résultat : dans Swagger UI, le bouton "Authorize" permet de :
# 1. Entrer email + mot de passe
# 2. Obtenir automatiquement un token
# 3. Tester tous les endpoints protégés

================================================================================
                 CHAPITRE 27 FIN — ARCHITECTURE FINALE TASKFLOW
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27.8 MAIN.PY FINAL — TOUTES LES PIÈCES ASSEMBLÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/main.py — Version complète avec auth, DB, middleware
# ═══════════════════════════════════════════════════════════════════════

from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

from app.config import get_settings
from app.database import init_db
from app.api.v1 import auth, tasks, users
from app.middleware.security import SecurityHeadersMiddleware, RateLimitMiddleware

settings = get_settings()

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Cycle de vie de l'application."""
    print("[RAPIDE] TaskFlow API — Démarrage...")
    await init_db()
    print("[OK] Base de données initialisée")
    yield
    print("[STOP] TaskFlow API — Arrêt propre")

# ── Application principale ────────────────────────────────────────────
app = FastAPI(
    title="TaskFlow API",
    version="1.0.0",
    description="""
## [RAPIDE] TaskFlow API

API SaaS de gestion de tâches.

### Authentification
1. Créer un compte via **POST /api/v1/auth/register**
2. Se connecter via **POST /api/v1/auth/login**
3. Copier l'`access_token` retourné
4. Cliquer sur **Authorize** ([VERROUILLE]) et entrer : `Bearer {votre_token}`
    """,
    lifespan=lifespan,
    docs_url="/docs",
    redoc_url="/redoc",
)

# ── Middlewares (ordre IMPORTANT : s'appliquent dans l'ordre inverse) ─
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RateLimitMiddleware, max_requests=200, window_seconds=60)
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.allowed_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Handlers d'exceptions globaux ────────────────────────────────────
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    """Format uniforme pour les erreurs de validation Pydantic."""
    return JSONResponse(
        status_code=422,
        content={
            "error": "VALIDATION_ERROR",
            "message": "Données invalides",
            "errors": [
                {
                    "field": " -> ".join(str(l) for l in e["loc"]),
                    "message": e["msg"],
                    "type": e["type"],
                }
                for e in exc.errors()
            ],
        }
    )

@app.exception_handler(Exception)
async def global_handler(request: Request, exc: Exception):
    """Handler global pour les erreurs non gérées."""
    import logging
    logging.error(f"Erreur non gérée : {exc}", exc_info=True)
    return JSONResponse(
        status_code=500,
        content={"error": "INTERNAL_ERROR", "message": "Erreur interne du serveur"}
    )

# ── Inclusion des routers ─────────────────────────────────────────────
app.include_router(auth.router, prefix="/api/v1")
app.include_router(tasks.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")

# ── Routes utilitaires ────────────────────────────────────────────────
@app.get("/", tags=["system"])
async def root():
    return {"name": "TaskFlow API", "version": "1.0.0", "docs": "/docs"}

@app.get("/health", tags=["system"])
async def health():
    return {"status": "healthy", "version": "1.0.0"}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27.9 EXERCICES — CHAPITRES 25-27
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 27.1 : Implémente hash_password() et verify_password() avec passlib.
  Crée un petit script qui :
  a) Hash le mot de passe "MonPass@2024"
  b) Vérifie que "MonPass@2024" correspond au hash -> True
  c) Vérifie que "mauvais_mdp" correspond au hash -> False
  d) Affiche le temps pris par chaque opération (time.time())

Ex 27.2 : Génère manuellement un JWT avec create_access_token() et
  décode-le sur jwt.io pour inspecter le payload.
  Identifie : sub, email, role, iat, exp, jti.

Ex 27.3 : Lance TaskFlow API avec la DB et teste dans Swagger UI :
  a) POST /api/v1/auth/register (créer un compte)
  b) POST /api/v1/auth/login (obtenir un token)
  c) Cliquer Authorize dans Swagger, entrer le token
  d) GET /api/v1/auth/me (vérifier que l'auth fonctionne)
  e) GET /api/v1/tasks (maintenant accessible)

NIVEAU INTERMÉDIAIRE
--------------------
Ex 27.4 : Implémente une dépendance require_verified_email qui vérifie
  que l'utilisateur a confirmé son email (champ email_verified: bool).
  Un utilisateur non vérifié reçoit 403 avec un message clair.

Ex 27.5 : Ajoute un système de "remember me" : si le client envoie
  remember_me=true lors du login, le refresh token dure 30 jours
  au lieu de 7 jours.

Ex 27.6 : Implémente un endpoint POST /auth/forgot-password qui :
  a) Reçoit un email
  b) Si l'email existe -> génère un token de reset (JWT courte durée, 15min)
  c) Simule l'envoi d'email (print) avec un lien de reset
  d) POST /auth/reset-password avec le token et le nouveau mot de passe

NIVEAU AVANCÉ
-------------
Ex 27.7 : Implémente la révocation de tokens avec Redis :
  a) Configurer redis.asyncio
  b) Modifier logout pour blacklister le JTI
  c) Modifier get_current_user pour vérifier la blacklist
  d) Tester : après logout, le token ne doit plus fonctionner

Ex 27.8 : Implémente un système de permissions granulaires basé sur
  des scopes OAuth2 :
  scopes = ["tasks:read", "tasks:write", "users:read", "admin:all"]
  Chaque endpoint déclare ses scopes requis. Un token peut avoir
  un sous-ensemble de scopes (utile pour les API keys de services).

Ex 27.9 : Crée un endpoint GET /api/v1/users/{user_id}/tasks qui :
  - Un user ordinaire ne peut voir que ses propres tâches
  - Un admin peut voir les tâches de n'importe quel user
  - Retourne 403 si un user essaie d'accéder aux tâches d'un autre

================================================================================
                         RÉCAPITULATIF PARTIE 5
================================================================================

Dans cette partie, tu as appris :

[OK] JWT : structure, payload, signature, expiration, refresh tokens
[OK] bcrypt : hashing sécurisé des mots de passe, timing attacks
[OK] OAuth2 Password Flow : login, formulaire, tokens
[OK] Dependency Injection : get_current_user, require_role(), chaînage
[OK] Protection des routes : CurrentUser, AdminUser, require_role()
[OK] Révocation JWT : blacklist Redis, true logout
[OK] Security headers : XSS, clickjacking, HSTS, CSP
[OK] Rate limiting : protection contre le brute force
[OK] Architecture finale : tous les modules assemblés

[RAPIDE] PROCHAINE ÉTAPE : Partie 6 — Architecture Avancée
   - Routers organisés, services, dépendances complexes
   - Middleware de logging et monitoring
   - CORS avancé et configuration multi-environnement
   - Dependency injection avancée (classes, cache)

================================================================================
                          FIN DE LA PARTIE 5
                   Passe à fastapi_master_part_6.txt
================================================================================

================================================================================
   GUIDE FASTAPI COMPLET — PARTIE 6 : ARCHITECTURE AVANCÉE & MIDDLEWARE
   Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FIL ROUGE : TaskFlow API
   Dans cette partie, nous professionnalisons l'architecture de TaskFlow :
   - Architecture modulaire Clean / Layered
   - Middleware de logging structuré et de monitoring
   - CORS avancé multi-environnement
   - Dependency Injection avancée (classes, cache, override pour tests)
   - Gestion des configurations par environnement

================================================================================
         CHAPITRE 28 — ARCHITECTURE MODULAIRE PROFESSIONNELLE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28.1 POURQUOI L'ARCHITECTURE COMPTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un projet qui commence sans architecture dégénère vite en "Big Ball of Mud" :
  - Tout dans main.py -> impossible à tester ou à maintenir
  - Logique métier mélangée aux routes -> couplage fort
  - Pas de séparation des responsabilités -> un bug casse tout

Objectifs d'une bonne architecture :
  SÉPARATION DES RESPONSABILITÉS : chaque fichier fait UNE chose
  TESTABILITÉ                    : on peut tester chaque couche isolément
  MAINTENABILITÉ                 : modifier X ne casse pas Y
  LISIBILITÉ                     : un nouveau dev comprend en 5 minutes

Les couches de TaskFlow API :
  ┌────────────────────────────────────────────────────┐
  │            API Layer (Routes / Handlers)            │
  │   Reçoit les requêtes HTTP, délègue au service     │
  ├────────────────────────────────────────────────────┤
  │            Service Layer (Business Logic)           │
  │   Règles métier, orchestration, validation avancée │
  ├────────────────────────────────────────────────────┤
  │         Repository Layer (Data Access)              │
  │   Requêtes DB, ORM, pas de logique métier          │
  ├────────────────────────────────────────────────────┤
  │            Domain Layer (Models)                    │
  │   Entités métier (User, Task, Project...)          │
  ├────────────────────────────────────────────────────┤
  │        Infrastructure (DB, Cache, Email...)         │
  │   PostgreSQL, Redis, SMTP, S3...                   │
  └────────────────────────────────────────────────────┘

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28.2 STRUCTURE COMPLÈTE DU PROJET TASKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  taskflow/
  ├── app/
  │   ├── __init__.py
  │   ├── main.py                    # Point d'entrée ASGI
  │   ├── config.py                  # Configuration par environnement
  │   │
  │   ├── api/                       # <- COUCHE API (routes HTTP)
  │   │   ├── __init__.py
  │   │   ├── deps.py                # Dépendances communes (auth, db...)
  │   │   └── v1/
  │   │       ├── __init__.py
  │   │       ├── router.py          # Agrège tous les routers v1
  │   │       ├── auth.py
  │   │       ├── tasks.py
  │   │       ├── users.py
  │   │       └── projects.py
  │   │
  │   ├── services/                  # <- COUCHE SERVICE (logique métier)
  │   │   ├── __init__.py
  │   │   ├── task_service.py
  │   │   ├── user_service.py
  │   │   ├── auth_service.py
  │   │   └── notification_service.py
  │   │
  │   ├── repositories/              # <- COUCHE REPOSITORY (accès données)
  │   │   ├── __init__.py
  │   │   ├── base.py                # Repository générique réutilisable
  │   │   ├── task_repository.py
  │   │   └── user_repository.py
  │   │
  │   ├── models/                    # <- COUCHE DOMAINE (SQLAlchemy ORM)
  │   │   ├── __init__.py
  │   │   ├── mixins.py
  │   │   ├── user.py
  │   │   ├── task.py
  │   │   └── project.py
  │   │
  │   ├── schemas/                   # <- COUCHE DTO (Pydantic)
  │   │   ├── __init__.py
  │   │   ├── common.py              # Schémas partagés (pagination...)
  │   │   ├── task.py
  │   │   └── user.py
  │   │
  │   ├── core/                      # <- MODULES CENTRAUX
  │   │   ├── __init__.py
  │   │   ├── security.py            # JWT, hashing
  │   │   ├── exceptions.py          # Exceptions métier personnalisées
  │   │   └── events.py              # Startup / shutdown handlers
  │   │
  │   ├── middleware/                # <- MIDDLEWARE
  │   │   ├── __init__.py
  │   │   ├── logging.py
  │   │   ├── security.py
  │   │   └── timing.py
  │   │
  │   └── infrastructure/            # <- INFRASTRUCTURE
  │       ├── __init__.py
  │       ├── database.py            # Engine, session
  │       ├── cache.py               # Redis
  │       └── email.py               # Client SMTP
  │
  ├── tests/
  │   ├── __init__.py
  │   ├── conftest.py                # Fixtures pytest globales
  │   ├── unit/
  │   │   ├── test_security.py
  │   │   └── test_task_service.py
  │   └── integration/
  │       ├── test_auth_api.py
  │       └── test_tasks_api.py
  │
  ├── alembic/
  │   ├── env.py
  │   └── versions/
  │
  ├── .env
  ├── .env.example
  ├── .env.test                      # Variables pour les tests
  ├── requirements.txt
  ├── requirements-dev.txt
  ├── Dockerfile
  ├── docker-compose.yml
  ├── docker-compose.test.yml
  └── pyproject.toml                 # Config Ruff, mypy, pytest

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28.3 REPOSITORY PATTERN — COUCHE D'ACCÈS AUX DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Repository Pattern isole les requêtes SQL du reste de l'application.
Avantage clé : pour les tests, on peut remplacer le vrai repository
par un faux (mock) sans toucher au service.

# ═══════════════════════════════════════════════════════════════════════
# app/repositories/base.py — Repository générique réutilisable
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete, func
from typing import TypeVar, Generic, Type, Sequence, Any
from pydantic import BaseModel

from app.infrastructure.database import Base

# TypeVar pour la généricité : ModelType sera le modèle SQLAlchemy concret
ModelType = TypeVar("ModelType", bound=Base)

class BaseRepository(Generic[ModelType]):
    """
    Repository générique avec les opérations CRUD de base.
    Toute logique SQL répétitive est ici, une seule fois.

    Usage :
        class TaskRepository(BaseRepository[Task]):
            model = Task
            # On hérite de get_by_id, list, count, delete, etc.
            # On ajoute seulement les méthodes spécifiques aux tâches
    """

    model: Type[ModelType]   # À définir dans chaque sous-classe

    def __init__(self, db: AsyncSession):
        self.db = db

    # ── Lecture ───────────────────────────────────────────────────────

    async def get_by_id(self, id: int) -> ModelType | None:
        """Récupère un enregistrement par son ID primaire."""
        result = await self.db.execute(
            select(self.model).where(self.model.id == id)
        )
        return result.scalar_one_or_none()

    async def get_by_field(self, field: str, value: Any) -> ModelType | None:
        """Récupère un enregistrement par n'importe quel champ."""
        column = getattr(self.model, field)
        result = await self.db.execute(
            select(self.model).where(column == value)
        )
        return result.scalar_one_or_none()

    async def list(
        self,
        skip: int = 0,
        limit: int = 100,
        order_by: str = "id",
        ascending: bool = True,
    ) -> Sequence[ModelType]:
        """Liste les enregistrements avec pagination basique."""
        sort_col = getattr(self.model, order_by, self.model.id)
        query = (
            select(self.model)
            .order_by(sort_col.asc() if ascending else sort_col.desc())
            .offset(skip)
            .limit(limit)
        )
        result = await self.db.execute(query)
        return result.scalars().all()

    async def count(self) -> int:
        """Compte le total d'enregistrements."""
        result = await self.db.execute(
            select(func.count()).select_from(self.model)
        )
        return result.scalar_one()

    async def exists(self, id: int) -> bool:
        """Vérifie qu'un enregistrement existe."""
        result = await self.db.execute(
            select(func.count())
            .select_from(self.model)
            .where(self.model.id == id)
        )
        return result.scalar_one() > 0

    # ── Écriture ──────────────────────────────────────────────────────

    async def create(self, obj: ModelType) -> ModelType:
        """Persiste un nouvel enregistrement."""
        self.db.add(obj)
        await self.db.flush()
        await self.db.refresh(obj)
        return obj

    async def update_fields(self, id: int, **fields) -> ModelType | None:
        """Met à jour des champs spécifiques par ID."""
        await self.db.execute(
            update(self.model)
            .where(self.model.id == id)
            .values(**fields)
        )
        return await self.get_by_id(id)

    async def delete(self, id: int) -> bool:
        """Supprime physiquement un enregistrement. Retourne True si supprimé."""
        result = await self.db.execute(
            delete(self.model).where(self.model.id == id)
        )
        return result.rowcount > 0

    async def bulk_create(self, objects: list[ModelType]) -> list[ModelType]:
        """Crée plusieurs enregistrements en une transaction."""
        self.db.add_all(objects)
        await self.db.flush()
        for obj in objects:
            await self.db.refresh(obj)
        return objects

# ═══════════════════════════════════════════════════════════════════════
# app/repositories/task_repository.py — Repository spécialisé tâches
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy import select, and_, or_, func, desc, asc
from sqlalchemy.orm import selectinload
from typing import Optional, Sequence
from datetime import date

from app.repositories.base import BaseRepository
from app.models.task import Task, StatusEnum, PriorityEnum

class TaskRepository(BaseRepository[Task]):
    """
    Repository spécialisé pour les tâches.
    Hérite de toutes les opérations de base et ajoute les requêtes
    spécifiques aux tâches.
    """
    model = Task   # Indispensable pour la généricité du BaseRepository

    async def get_by_id_with_relations(self, task_id: int) -> Task | None:
        """Récupère une tâche avec toutes ses relations chargées."""
        result = await self.db.execute(
            select(Task)
            .where(Task.id == task_id, Task.deleted_at.is_(None))
            .options(
                selectinload(Task.assignee),
                selectinload(Task.creator),
                selectinload(Task.comments),
                selectinload(Task.project),
            )
        )
        return result.scalar_one_or_none()

    async def find_with_filters(
        self,
        *,
        page: int = 1,
        per_page: int = 20,
        user_id: Optional[int] = None,
        assigned_to: Optional[int] = None,
        status: Optional[StatusEnum] = None,
        priority: Optional[PriorityEnum] = None,
        completed: Optional[bool] = None,
        project_id: Optional[int] = None,
        q: Optional[str] = None,
        due_before: Optional[date] = None,
        due_after: Optional[date] = None,
        sort_by: str = "created_at",
        order: str = "desc",
        load_relations: bool = False,
    ) -> tuple[Sequence[Task], int]:
        """
        Requête filtrée + paginée + triée.
        Retourne (résultats, total).
        Cette méthode est PUREMENT SQL — aucune logique métier ici.
        """
        # Base : exclure les tâches supprimées
        base_cond = Task.deleted_at.is_(None)

        # Construction des filtres
        conditions = [base_cond]
        if user_id:
            conditions.append(Task.created_by_id == user_id)
        if assigned_to:
            conditions.append(Task.assigned_to_id == assigned_to)
        if status:
            conditions.append(Task.status == status)
        if priority:
            conditions.append(Task.priority == priority)
        if completed is not None:
            conditions.append(Task.completed == completed)
        if project_id:
            conditions.append(Task.project_id == project_id)
        if due_before:
            conditions.append(Task.due_date <= due_before)
        if due_after:
            conditions.append(Task.due_date >= due_after)
        if q:
            conditions.append(or_(
                Task.title.ilike(f"%{q}%"),
                Task.description.ilike(f"%{q}%"),
            ))

        where_clause = and_(*conditions)

        # Query data + count en parallèle
        sort_col = getattr(Task, sort_by, Task.created_at)
        sort_dir = desc(sort_col) if order == "desc" else asc(sort_col)

        data_query = (
            select(Task)
            .where(where_clause)
            .order_by(sort_dir)
            .offset((page - 1) * per_page)
            .limit(per_page)
        )

        if load_relations:
            data_query = data_query.options(
                selectinload(Task.assignee),
                selectinload(Task.creator),
            )

        count_query = (
            select(func.count())
            .select_from(Task)
            .where(where_clause)
        )

        import asyncio
        data_result, count_result = await asyncio.gather(
            self.db.execute(data_query),
            self.db.execute(count_query),
        )

        return data_result.scalars().all(), count_result.scalar_one()

    async def get_overdue(self, user_id: Optional[int] = None) -> Sequence[Task]:
        """Récupère les tâches en retard (due_date < aujourd'hui, non complètes)."""
        conditions = [
            Task.deleted_at.is_(None),
            Task.due_date < date.today(),
            Task.completed == False,   # noqa: E712
        ]
        if user_id:
            conditions.append(Task.created_by_id == user_id)

        result = await self.db.execute(
            select(Task)
            .where(and_(*conditions))
            .order_by(Task.due_date.asc())
        )
        return result.scalars().all()

    async def aggregate_by_status(self, user_id: Optional[int] = None) -> dict:
        """Agrégation : nombre de tâches par statut."""
        conditions = [Task.deleted_at.is_(None)]
        if user_id:
            conditions.append(Task.created_by_id == user_id)

        result = await self.db.execute(
            select(Task.status, func.count(Task.id).label("count"))
            .where(and_(*conditions))
            .group_by(Task.status)
        )
        return {row.status.value: row.count for row in result}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28.4 SERVICE LAYER AVEC REPOSITORY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/services/task_service.py — Service refactorisé avec Repository
# ═══════════════════════════════════════════════════════════════════════

from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime

from app.repositories.task_repository import TaskRepository
from app.repositories.user_repository import UserRepository
from app.models.task import Task, StatusEnum
from app.schemas.task import TaskCreate, TaskUpdate
from app.core.exceptions import (
    TaskNotFoundError, ForbiddenError,
    TaskAlreadyCompletedError, UserNotFoundError,
)

class TaskService:
    """
    Service couche métier pour les tâches.
    Utilise le Repository pour l'accès aux données.
    Contient TOUTE la logique métier (règles business).
    Ne fait JAMAIS de SQL directement.
    """

    def __init__(self, db: AsyncSession):
        # Le service crée ses repositories (injection via constructeur)
        self.task_repo = TaskRepository(db)
        self.user_repo = UserRepository(db)
        self.db = db

    async def create(self, data: TaskCreate, created_by_id: int) -> Task:
        """
        Règles métier à la création :
        1. Si assigned_to est fourni -> vérifier que l'utilisateur existe
        2. Si project_id fourni -> vérifier que le projet existe (TODO Partie 7)
        3. Créer la tâche
        """
        # Règle métier : l'assignataire doit exister et être actif
        if data.assigned_to:
            assignee = await self.user_repo.get_by_id(data.assigned_to)
            if not assignee:
                raise UserNotFoundError(data.assigned_to)
            if not assignee.is_active:
                raise HTTPException(
                    status_code=status.HTTP_400_BAD_REQUEST,
                    detail={
                        "code": "ASSIGNEE_INACTIVE",
                        "message": "L'utilisateur assigné n'est pas actif",
                    }
                )

        db_task = Task(
            title=data.title,
            description=data.description,
            priority=data.priority,
            due_date=data.due_date,
            estimated_hours=data.estimated_hours,
            story_points=data.story_points,
            assigned_to_id=data.assigned_to,
            project_id=data.project_id,
            created_by_id=created_by_id,
            status=StatusEnum.todo,
            completed=False,
        )

        return await self.task_repo.create(db_task)

    async def get_or_raise(
        self,
        task_id: int,
        user_id: int | None = None,
        allow_admin: bool = True,
    ) -> Task:
        """
        Récupère une tâche ou lève une exception métier.
        La logique d'autorisation est ici, pas dans la route.
        """
        task = await self.task_repo.get_by_id(task_id)

        if not task or task.is_deleted:
            raise TaskNotFoundError(task_id)

        if user_id and task.created_by_id != user_id:
            raise ForbiddenError("Vous n'avez pas accès à cette tâche")

        return task

    async def update(
        self, task_id: int, data: TaskUpdate, user_id: int
    ) -> Task:
        """Met à jour une tâche avec vérification d'autorisation."""
        task = await self.get_or_raise(task_id, user_id)

        # Règle métier : si on complète une tâche via status
        if data.status == StatusEnum.done and not task.completed:
            task.completed = True

        # Appliquer seulement les champs fournis
        update_data = data.model_dump(exclude_unset=True)
        for field, value in update_data.items():
            setattr(task, field, value)

        task.updated_at = datetime.utcnow()
        await self.db.flush()
        await self.db.refresh(task)
        return task

    async def complete(self, task_id: int, user_id: int) -> Task:
        """Marque une tâche comme complète."""
        task = await self.get_or_raise(task_id)

        # Seul le créateur ou l'assignataire peut compléter
        if task.created_by_id != user_id and task.assigned_to_id != user_id:
            raise ForbiddenError("Seul le créateur ou l'assignataire peut compléter cette tâche")

        if task.completed:
            raise TaskAlreadyCompletedError(task_id)

        task.completed = True
        task.status = StatusEnum.done
        task.updated_at = datetime.utcnow()
        await self.db.flush()
        return task

    async def soft_delete(self, task_id: int, user_id: int) -> None:
        """Suppression logique avec vérification d'autorisation."""
        task = await self.get_or_raise(task_id, user_id)
        task.deleted_at = datetime.utcnow()
        await self.db.flush()

    async def list_with_filters(self, **kwargs) -> tuple:
        """Délègue au repository, peut enrichir les résultats."""
        return await self.task_repo.find_with_filters(**kwargs)

    async def get_statistics(self, user_id: int | None = None) -> dict:
        """Statistiques agrégées."""
        by_status = await self.task_repo.aggregate_by_status(user_id)
        overdue = await self.task_repo.get_overdue(user_id)
        total = await self.task_repo.count()
        return {
            "total": total,
            "by_status": by_status,
            "overdue_count": len(overdue),
        }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28.5 EXCEPTIONS MÉTIER PERSONNALISÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/core/exceptions.py — Hiérarchie d'exceptions métier
# ═══════════════════════════════════════════════════════════════════════

from fastapi import HTTPException, status

# ── Classe de base ────────────────────────────────────────────────────
class AppError(HTTPException):
    """
    Exception de base pour toutes les erreurs métier TaskFlow.
    Hérite de HTTPException pour être directement gérée par FastAPI.
    Ajoute un code d'erreur structuré en plus du message.
    """
    status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
    error_code: str = "APP_ERROR"
    message: str = "Une erreur est survenue"

    def __init__(self, message: str | None = None, **extra):
        detail = {
            "code": self.error_code,
            "message": message or self.message,
            **extra,   # Données supplémentaires (task_id, user_id...)
        }
        super().__init__(status_code=self.status_code, detail=detail)

# ── Erreurs 404 ───────────────────────────────────────────────────────
class NotFoundError(AppError):
    status_code = status.HTTP_404_NOT_FOUND
    error_code = "NOT_FOUND"
    message = "Ressource non trouvée"

class TaskNotFoundError(NotFoundError):
    error_code = "TASK_NOT_FOUND"

    def __init__(self, task_id: int):
        super().__init__(
            message=f"Tâche {task_id} non trouvée",
            task_id=task_id,
        )

class UserNotFoundError(NotFoundError):
    error_code = "USER_NOT_FOUND"

    def __init__(self, user_id: int):
        super().__init__(
            message=f"Utilisateur {user_id} non trouvé",
            user_id=user_id,
        )

class ProjectNotFoundError(NotFoundError):
    error_code = "PROJECT_NOT_FOUND"

    def __init__(self, project_id: int):
        super().__init__(message=f"Projet {project_id} non trouvé")

# ── Erreurs 403 ───────────────────────────────────────────────────────
class ForbiddenError(AppError):
    status_code = status.HTTP_403_FORBIDDEN
    error_code = "FORBIDDEN"
    message = "Action non autorisée"

# ── Erreurs 409 ───────────────────────────────────────────────────────
class ConflictError(AppError):
    status_code = status.HTTP_409_CONFLICT
    error_code = "CONFLICT"

class TaskAlreadyCompletedError(ConflictError):
    error_code = "TASK_ALREADY_COMPLETED"

    def __init__(self, task_id: int):
        super().__init__(
            message=f"La tâche {task_id} est déjà marquée comme complète",
            task_id=task_id,
        )

class EmailAlreadyExistsError(ConflictError):
    error_code = "EMAIL_ALREADY_EXISTS"

    def __init__(self, email: str):
        super().__init__(
            message=f"L'email '{email}' est déjà utilisé",
            field="email",
        )

# ── Erreurs 400 ───────────────────────────────────────────────────────
class BadRequestError(AppError):
    status_code = status.HTTP_400_BAD_REQUEST
    error_code = "BAD_REQUEST"

class InvalidCredentialsError(AppError):
    status_code = status.HTTP_401_UNAUTHORIZED
    error_code = "INVALID_CREDENTIALS"
    message = "Email ou mot de passe incorrect"

    def __init__(self):
        super().__init__()
        self.headers = {"WWW-Authenticate": "Bearer"}

# ── Enregistrement global dans FastAPI ────────────────────────────────
# Dans main.py :
#
# from app.core.exceptions import AppError
#
# @app.exception_handler(AppError)
# async def app_error_handler(request: Request, exc: AppError):
#     return JSONResponse(
#         status_code=exc.status_code,
#         content=exc.detail,          # detail est déjà le dict structuré
#     )
#
# Désormais : raise TaskNotFoundError(42) -> réponse JSON automatique :
# {"code": "TASK_NOT_FOUND", "message": "Tâche 42 non trouvée", "task_id": 42}

================================================================================
                 CHAPITRE 29 — DEPENDENCY INJECTION AVANCÉE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29.1 DÉPENDANCES SOUS FORME DE CLASSES (Callable)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En plus des fonctions, FastAPI accepte des classes comme dépendances.
La méthode __call__ est invoquée automatiquement.
Avantage : état interne, configuration à l'instanciation.

# ═══════════════════════════════════════════════════════════════════════
# Dépendances sous forme de classes paramétrables
# ═══════════════════════════════════════════════════════════════════════

from fastapi import Depends, HTTPException, Query, Request
from typing import Annotated, Optional

# ── Dépendance de pagination réutilisable ─────────────────────────────
class PaginationParams:
    """
    Dépendance de classe pour la pagination.
    Réutilisable sur tous les endpoints de liste.

    Usage :
        @router.get("/tasks")
        async def list_tasks(pagination: Annotated[PaginationParams, Depends()]):
            tasks = await service.list(
                skip=pagination.skip,
                limit=pagination.per_page,
            )
    """

    def __init__(
        self,
        page: int = Query(1, ge=1, description="Numéro de page"),
        per_page: int = Query(20, ge=1, le=100, description="Éléments par page"),
    ):
        self.page = page
        self.per_page = per_page
        self.skip = (page - 1) * per_page   # Calculé automatiquement

    def to_meta(self, total: int) -> dict:
        """Génère les métadonnées de pagination."""
        total_pages = -(-total // self.per_page)  # Ceiling division
        return {
            "page": self.page,
            "per_page": self.per_page,
            "total": total,
            "total_pages": total_pages,
            "has_next": self.page < total_pages,
            "has_prev": self.page > 1,
        }

# ── Dépendance de filtres de tri réutilisable ─────────────────────────
class SortParams:
    """Paramètres de tri génériques."""

    ALLOWED_FIELDS = {"created_at", "updated_at", "title", "priority", "due_date"}

    def __init__(
        self,
        sort_by: str = Query("created_at", description="Champ de tri"),
        order: str = Query("desc", pattern="^(asc|desc)$", description="Ordre"),
    ):
        # Valider que sort_by est un champ autorisé
        if sort_by not in self.ALLOWED_FIELDS:
            raise HTTPException(
                status_code=400,
                detail=f"Champ de tri invalide. Autorisés : {self.ALLOWED_FIELDS}"
            )
        self.sort_by = sort_by
        self.order = order

# ── Dépendance de filtre de recherche ─────────────────────────────────
class SearchFilter:
    """Filtre de recherche textuelle."""

    def __init__(
        self,
        q: Optional[str] = Query(
            None,
            min_length=2,
            max_length=100,
            description="Recherche textuelle",
        )
    ):
        self.q = q.strip() if q else None

# ── Utilisation combinée dans une route ──────────────────────────────

Pagination = Annotated[PaginationParams, Depends()]
Sort = Annotated[SortParams, Depends()]
Search = Annotated[SearchFilter, Depends()]

@router.get("/tasks")
async def list_tasks(
    db: DBSession,
    current_user: CurrentUser,
    pagination: Pagination,    # Injecte PaginationParams
    sort: Sort,                # Injecte SortParams
    search: Search,            # Injecte SearchFilter
):
    service = TaskService(db)
    tasks, total = await service.list_with_filters(
        page=pagination.page,
        per_page=pagination.per_page,
        sort_by=sort.sort_by,
        order=sort.order,
        q=search.q,
    )
    return {
        "data": tasks,
        "pagination": pagination.to_meta(total),
    }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29.2 DÉPENDANCES AVEC CACHE — lru_cache ET use_cache
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Dépendances mises en cache (singletons)
# ═══════════════════════════════════════════════════════════════════════

from functools import lru_cache
from fastapi import Depends
from typing import Annotated

# ── Singleton de configuration ────────────────────────────────────────
# @lru_cache() -> la fonction n'est appelée qu'UNE FOIS
# Toutes les requêtes suivantes reçoivent le MÊME objet settings
# Très important : Settings() parse les variables d'environnement
# -> ne pas le recréer à chaque requête

from app.config import Settings

@lru_cache()
def get_settings() -> Settings:
    """
    Singleton de configuration.
    lru_cache() garantit qu'on ne crée qu'une seule instance.
    """
    return Settings()

# ── Dépendance de service avec cache de courte durée ─────────────────
# FastAPI supporte use_cache=True sur Depends() :
# Si la MÊME dépendance est utilisée plusieurs fois dans UNE requête,
# elle n'est calculée qu'une fois.

async def get_current_user_cached(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: DBSession,
) -> User:
    """Même dépendance utilisée plusieurs fois -> calculée une seule fois."""
    ...

# Dans une route avec plusieurs sous-dépendances qui utilisent
# toutes get_current_user :
@router.get("/tasks/{id}")
async def get_task(
    task_id: int,
    db: DBSession,
    # Ces deux dépendances utilisent get_current_user
    # FastAPI l'appelle UNE SEULE fois grâce au cache de requête
    current_user: Annotated[User, Depends(get_current_user)],
    _: Annotated[User, Depends(require_role("admin"))],  # aussi Depends(get_current_user) dedans
):
    ...

# ── Override de dépendances pour les tests ────────────────────────────
# C'est LA fonctionnalité qui rend FastAPI testable sans infrastructure réelle.

# Dans les tests :
from fastapi.testclient import TestClient
from app.main import app
from app.api.deps import get_current_user
from app.models.user import User

def override_get_current_user():
    """Utilisateur factice pour les tests, sans JWT ni DB."""
    return User(
        id=1,
        email="test@taskflow.com",
        username="testuser",
        role="user",
        is_active=True,
    )

# Remplacer la dépendance réelle par la fausse dans les tests
app.dependency_overrides[get_current_user] = override_get_current_user

client = TestClient(app)

def test_list_tasks():
    response = client.get("/api/v1/tasks")
    assert response.status_code == 200
    # -> get_current_user() retourne le faux user, pas de JWT requis

# Nettoyage après test
app.dependency_overrides.clear()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29.3 ROUTER AGRÉGATEUR v1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/api/v1/router.py — Agrège tous les routers v1 proprement
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter
from app.api.v1 import auth, tasks, users, projects

# Router principal v1
api_v1_router = APIRouter(prefix="/api/v1")

# Inclusion de tous les sous-routers
api_v1_router.include_router(auth.router)
api_v1_router.include_router(tasks.router)
api_v1_router.include_router(users.router)
api_v1_router.include_router(projects.router)

# Dans main.py :
# from app.api.v1.router import api_v1_router
# app.include_router(api_v1_router)
# -> Plus besoin d'importer chaque router dans main.py

================================================================================
                  CHAPITRE 31-33 — MIDDLEWARE AVANCÉ
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
31.1 MIDDLEWARE — FONCTIONNEMENT INTERNE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un middleware est une couche qui enveloppe toutes les requêtes/réponses.
Il s'exécute avant ET après le handler de route.

  Requête entrante
       │
       [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────┐
  │  Middleware 1 (CORS)            │  <- avant call_next
  │    ┌──────────────────────┐     │
  │    │  Middleware 2 (Log)  │  <- avant
  │    │    ┌─────────────┐   │     │
  │    │    │  Middleware3│  <- avant│
  │    │    │  (Timing)   │   │     │
  │    │    │   ROUTE     │   │     │
  │    │    │  (Handler)  │   │     │
  │    │    │   après     │   │     │
  │    │    └─────────────┘   │     │
  │    │  après               │     │
  │    └──────────────────────┘     │
  │  après                          │
  └─────────────────────────────────┘
       │
       [BLACK_DOWN-POINTING_TRIANGLE]
  Réponse sortante

ORDRE DES MIDDLEWARES :
Les middlewares s'appliquent dans l'ORDRE INVERSE de leur enregistrement.
Dernier enregistré = premier exécuté.

  app.add_middleware(CORS)     # exécuté en 3ème
  app.add_middleware(Logging)  # exécuté en 2ème
  app.add_middleware(Timing)   # exécuté en 1er (dernier enregistré)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
32.1 MIDDLEWARE DE LOGGING STRUCTURÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/middleware/logging.py — Logging structuré JSON production-ready
# ═══════════════════════════════════════════════════════════════════════

import time
import uuid
import json
import logging
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp

# ── Configuration du logger ───────────────────────────────────────────
# En production : utiliser structlog ou python-json-logger
# pour des logs JSON parsables par Datadog, ELK, etc.

import logging.config

LOGGING_CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        # Formatter JSON pour la production (parsable par les outils de monitoring)
        "json": {
            "()": "pythonjsonlogger.jsonlogger.JsonFormatter",
            "format": "%(asctime)s %(levelname)s %(name)s %(message)s",
        },
        # Formatter lisible pour le développement
        "readable": {
            "format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
            "datefmt": "%Y-%m-%d %H:%M:%S",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "readable",   # En prod : utiliser "json"
            "stream": "ext://sys.stdout",
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "formatter": "json",
            "filename": "logs/taskflow.log",
            "maxBytes": 10 * 1024 * 1024,  # 10 MB par fichier
            "backupCount": 5,               # Garder 5 fichiers d'archive
        },
    },
    "root": {
        "level": "INFO",
        "handlers": ["console"],
    },
    "loggers": {
        "taskflow": {
            "level": "DEBUG",
            "handlers": ["console", "file"],
            "propagate": False,
        },
        "sqlalchemy.engine": {
            "level": "WARNING",     # Ne pas logger toutes les requêtes SQL en prod
            "handlers": ["console"],
            "propagate": False,
        },
        "uvicorn.access": {
            "level": "WARNING",     # On gère nos propres logs d'accès
        },
    },
}

logger = logging.getLogger("taskflow.access")


class RequestLoggingMiddleware(BaseHTTPMiddleware):
    """
    Middleware de logging structuré pour toutes les requêtes HTTP.

    Logue :
    - Méthode + URL + query string
    - IP du client
    - User-Agent
    - Status code de la réponse
    - Durée de traitement en ms
    - Request ID unique (pour le tracing)
    - User ID si authentifié

    Format : JSON structuré pour faciliter l'analyse avec ELK/Datadog
    """

    def __init__(self, app: ASGIApp, exclude_paths: set[str] | None = None):
        super().__init__(app)
        # Chemins à ne pas logger (health checks, assets...)
        self.exclude_paths = exclude_paths or {"/health", "/ping", "/metrics"}

    async def dispatch(self, request: Request, call_next) -> Response:
        # ── Ne pas logger certains chemins ────────────────────────────
        if request.url.path in self.exclude_paths:
            return await call_next(request)

        # ── Générer un Request ID unique ──────────────────────────────
        # Permet de tracer une requête à travers les logs
        request_id = str(uuid.uuid4())[:8]   # 8 premiers chars suffisent
        request.state.request_id = request_id

        # ── Récupérer l'IP (gérer les proxies) ────────────────────────
        client_ip = request.headers.get(
            "X-Forwarded-For",       # IP réelle derrière un proxy/CDN
            request.client.host if request.client else "unknown"
        ).split(",")[0].strip()

        # ── Chronométrage ─────────────────────────────────────────────
        start_time = time.perf_counter()

        # ── Log de début de requête ───────────────────────────────────
        logger.info(
            "REQUEST_START",
            extra={
                "request_id": request_id,
                "method": request.method,
                "path": request.url.path,
                "query": str(request.query_params),
                "client_ip": client_ip,
                "user_agent": request.headers.get("user-agent", ""),
            }
        )

        # ── Traitement de la requête ──────────────────────────────────
        try:
            response = await call_next(request)
            duration_ms = (time.perf_counter() - start_time) * 1000

            # ── Log de fin de requête ─────────────────────────────────
            log_level = logging.WARNING if response.status_code >= 400 else logging.INFO

            logger.log(
                log_level,
                "REQUEST_END",
                extra={
                    "request_id": request_id,
                    "method": request.method,
                    "path": request.url.path,
                    "status_code": response.status_code,
                    "duration_ms": round(duration_ms, 2),
                    "client_ip": client_ip,
                    # Ajouter l'user_id si disponible (depuis l'auth)
                    "user_id": getattr(request.state, "user_id", None),
                }
            )

            # Ajouter des headers de debugging dans les réponses
            response.headers["X-Request-ID"] = request_id
            response.headers["X-Process-Time"] = f"{duration_ms:.2f}ms"

            return response

        except Exception as exc:
            duration_ms = (time.perf_counter() - start_time) * 1000

            logger.error(
                "REQUEST_ERROR",
                extra={
                    "request_id": request_id,
                    "method": request.method,
                    "path": request.url.path,
                    "duration_ms": round(duration_ms, 2),
                    "error": str(exc),
                    "error_type": type(exc).__name__,
                },
                exc_info=True,   # Inclut le traceback complet
            )
            raise

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
32.2 MIDDLEWARE DE TIMING ET MÉTRIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/middleware/timing.py — Métriques de performance
# ═══════════════════════════════════════════════════════════════════════

import time
from collections import defaultdict, deque
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware

class TimingMiddleware(BaseHTTPMiddleware):
    """
    Mesure le temps de traitement de chaque requête.
    Ajoute le header X-Process-Time dans les réponses.
    Collecte des métriques agrégées (accédées via /metrics).
    """

    def __init__(self, app, slow_request_threshold_ms: float = 1000.0):
        super().__init__(app)
        self.slow_threshold = slow_request_threshold_ms
        # Historique des dernières 1000 requêtes par endpoint
        self.durations: dict[str, deque] = defaultdict(lambda: deque(maxlen=1000))

    async def dispatch(self, request: Request, call_next) -> Response:
        start = time.perf_counter()

        response = await call_next(request)

        duration_ms = (time.perf_counter() - start) * 1000
        endpoint_key = f"{request.method} {request.url.path}"

        # Enregistrer la durée
        self.durations[endpoint_key].append(duration_ms)

        # Logger les requêtes lentes
        if duration_ms > self.slow_threshold:
            import logging
            logging.getLogger("taskflow.perf").warning(
                f"[ATTENTION] Requête lente : {endpoint_key} -> {duration_ms:.0f}ms"
            )

        # Ajouter les headers de performance
        response.headers["X-Process-Time"] = f"{duration_ms:.2f}ms"
        response.headers["X-Response-Time"] = f"{duration_ms:.0f}"

        return response

    def get_stats(self) -> dict:
        """Statistiques de performance pour l'endpoint /metrics."""
        stats = {}
        for endpoint, durations in self.durations.items():
            if not durations:
                continue
            sorted_d = sorted(durations)
            n = len(sorted_d)
            stats[endpoint] = {
                "count": n,
                "avg_ms": round(sum(sorted_d) / n, 2),
                "min_ms": round(sorted_d[0], 2),
                "max_ms": round(sorted_d[-1], 2),
                "p50_ms": round(sorted_d[n // 2], 2),         # médiane
                "p95_ms": round(sorted_d[int(n * 0.95)], 2),  # 95e percentile
                "p99_ms": round(sorted_d[int(n * 0.99)], 2),  # 99e percentile
            }
        return stats

# ── Endpoint /metrics pour exposer les stats ─────────────────────────
# Dans main.py :
timing_middleware = TimingMiddleware(app, slow_request_threshold_ms=500)
app.add_middleware(type(timing_middleware).__mro__[1])  # Enregistrement

# On garde une référence pour accéder aux stats
# @app.get("/metrics", tags=["system"])
# async def metrics():
#     return timing_middleware.get_stats()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33.1 CORS AVANCÉ — MULTI-ENVIRONNEMENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CORS = Cross-Origin Resource Sharing.
Un navigateur bloque par défaut les requêtes vers un domaine différent.
CORS permet au serveur d'indiquer quels origines sont autorisées.

Exemple sans CORS :
  Front-end sur http://localhost:3000
  API sur      http://localhost:8000
  -> Le navigateur BLOQUE la requête (domaine différent)

Exemple avec CORS configuré :
  API répond avec :
    Access-Control-Allow-Origin: http://localhost:3000
  -> Le navigateur AUTORISE la requête

# ═══════════════════════════════════════════════════════════════════════
# app/config.py — Configuration CORS par environnement
# ═══════════════════════════════════════════════════════════════════════

from pydantic_settings import BaseSettings
from pydantic import field_validator
from typing import Literal
from functools import lru_cache

class Settings(BaseSettings):
    # ── Application ───────────────────────────────────────────────────
    app_name: str = "TaskFlow API"
    app_version: str = "1.0.0"
    environment: Literal["development", "staging", "production"] = "development"
    debug: bool = False
    api_v1_prefix: str = "/api/v1"

    # ── Base de données ───────────────────────────────────────────────
    database_url: str
    db_pool_size: int = 10
    db_max_overflow: int = 20

    # ── Redis ─────────────────────────────────────────────────────────
    redis_url: str = "redis://localhost:6379/0"

    # ── JWT ───────────────────────────────────────────────────────────
    secret_key: str
    algorithm: str = "HS256"
    access_token_expire_minutes: int = 30
    refresh_token_expire_days: int = 7

    # ── CORS ──────────────────────────────────────────────────────────
    # Peut recevoir une liste JSON en .env :
    # ALLOWED_ORIGINS=["http://localhost:3000","https://app.taskflow.io"]
    # Ou une string séparée par virgules :
    # ALLOWED_ORIGINS=http://localhost:3000,https://app.taskflow.io
    allowed_origins: list[str] = ["http://localhost:3000"]

    @field_validator("allowed_origins", mode="before")
    @classmethod
    def parse_allowed_origins(cls, v):
        """Accepte soit une liste JSON, soit une string séparée par virgules."""
        if isinstance(v, str):
            # Tenter de parser comme JSON d'abord
            import json as json_module
            try:
                parsed = json_module.loads(v)
                if isinstance(parsed, list):
                    return parsed
            except (json_module.JSONDecodeError, ValueError):
                pass
            # Sinon : split par virgules
            return [origin.strip() for origin in v.split(",") if origin.strip()]
        return v

    @property
    def is_development(self) -> bool:
        return self.environment == "development"

    @property
    def is_production(self) -> bool:
        return self.environment == "production"

    @property
    def cors_config(self) -> dict:
        """Retourne la configuration CORS adaptée à l'environnement."""
        if self.is_development:
            # Développement : permissif pour faciliter le dev
            return {
                "allow_origins": ["*"],        # Tout autoriser
                "allow_credentials": False,    # Ne pas utiliser avec allow_origins=["*"]
                "allow_methods": ["*"],
                "allow_headers": ["*"],
            }
        else:
            # Staging / Production : strict
            return {
                "allow_origins": self.allowed_origins,
                "allow_credentials": True,     # Autoriser les cookies
                "allow_methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
                "allow_headers": [
                    "Authorization",
                    "Content-Type",
                    "X-Requested-With",
                    "Accept",
                    "Origin",
                    "X-CSRF-Token",
                ],
                "expose_headers": [
                    "X-Request-ID",
                    "X-Process-Time",
                    "X-RateLimit-Limit",
                    "X-RateLimit-Remaining",
                ],
                "max_age": 3600,   # Cache preflight 1 heure
            }

    class Config:
        env_file = ".env"
        case_sensitive = False

@lru_cache()
def get_settings() -> Settings:
    return Settings()

# ── Fichiers .env par environnement ──────────────────────────────────

# .env (développement)
# ENVIRONMENT=development
# DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/taskflow_dev
# SECRET_KEY=dev-secret-key-not-for-production
# DEBUG=true
# ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080

# .env.staging
# ENVIRONMENT=staging
# DATABASE_URL=postgresql+asyncpg://user:pass@staging-db:5432/taskflow_staging
# SECRET_KEY=staging-secret-key-very-long-and-random
# DEBUG=false
# ALLOWED_ORIGINS=https://staging.taskflow.io,https://staging-app.taskflow.io

# .env.production
# ENVIRONMENT=production
# DATABASE_URL=postgresql+asyncpg://user:pass@prod-db:5432/taskflow_prod
# SECRET_KEY=production-secret-key-256-bits-minimum-extremely-random
# DEBUG=false
# ALLOWED_ORIGINS=https://app.taskflow.io,https://taskflow.io

# ── Application du CORS dans main.py ─────────────────────────────────

# from fastapi.middleware.cors import CORSMiddleware
# from app.config import get_settings
#
# settings = get_settings()
# app.add_middleware(CORSMiddleware, **settings.cors_config)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33.2 MIDDLEWARE CUSTOM AVEC STARLETTE PURE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Middleware ASGI pur — plus bas niveau, plus performant que BaseHTTPMiddleware
# ═══════════════════════════════════════════════════════════════════════

from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.responses import JSONResponse

class MaintenanceModeMiddleware:
    """
    Middleware ASGI pur (pas BaseHTTPMiddleware).
    Plus performant car pas de parsing du body.
    Utilisé pour activer/désactiver le mode maintenance à chaud.
    """

    def __init__(self, app: ASGIApp):
        self.app = app
        self.maintenance_mode = False   # Toggle à chaud
        self.allowed_paths = {"/health", "/ping"}   # Toujours disponibles

    async def __call__(self, scope: Scope, receive: Receive, send: Send):
        # On ne gère que les requêtes HTTP
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        path = scope.get("path", "")

        # En mode maintenance : bloquer toutes les routes sauf les allowed
        if self.maintenance_mode and path not in self.allowed_paths:
            response = JSONResponse(
                content={
                    "error": "MAINTENANCE",
                    "message": "L'API est en maintenance. Revenez dans quelques minutes.",
                    "retry_after": 300,  # Secondes avant de réessayer
                },
                status_code=503,
                headers={"Retry-After": "300"},
            )
            await response(scope, receive, send)
            return

        await self.app(scope, receive, send)

# Utilisation dans main.py :
# maintenance = MaintenanceModeMiddleware(app)
# app.add_middleware(MaintenanceModeMiddleware)
# Pour activer : maintenance.maintenance_mode = True

================================================================================
         CHAPITRE 30 — CONFIGURATION AVANCÉE & ENVIRONNEMENTS
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
30.1 MAIN.PY FINAL — TOUT ASSEMBLÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/main.py — Version complète et modulaire
# ═══════════════════════════════════════════════════════════════════════

from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

from app.config import get_settings
from app.infrastructure.database import init_db
from app.api.v1.router import api_v1_router
from app.middleware.logging import RequestLoggingMiddleware
from app.middleware.timing import TimingMiddleware
from app.middleware.security import SecurityHeadersMiddleware
from app.core.exceptions import AppError

settings = get_settings()

# ── Cycle de vie ─────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    import logging
    logger = logging.getLogger("taskflow")
    logger.info(f"[RAPIDE] TaskFlow API {settings.app_version} démarrage [{settings.environment}]")

    await init_db()
    logger.info("[OK] DB connectée")

    yield

    # Shutdown
    logger.info("[STOP] TaskFlow API arrêt propre")

# ── Instance FastAPI ──────────────────────────────────────────────────
app = FastAPI(
    title=settings.app_name,
    version=settings.app_version,
    description="API SaaS de gestion de tâches — TaskFlow",
    lifespan=lifespan,
    # En production : désactiver la doc
    docs_url="/docs" if not settings.is_production else None,
    redoc_url="/redoc" if not settings.is_production else None,
    openapi_url="/openapi.json" if not settings.is_production else None,
)

# ── Middlewares (ordre inverse d'exécution) ───────────────────────────
# 1er exécuté -> dernier enregistré
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RequestLoggingMiddleware, exclude_paths={"/health", "/ping"})
app.add_middleware(TimingMiddleware, slow_request_threshold_ms=500)
app.add_middleware(CORSMiddleware, **settings.cors_config)

# ── Handlers d'exceptions ─────────────────────────────────────────────
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
    """Handler uniforme pour toutes les exceptions métier."""
    return JSONResponse(status_code=exc.status_code, content=exc.detail)

@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    """Handler uniforme pour les erreurs de validation Pydantic."""
    return JSONResponse(
        status_code=422,
        content={
            "code": "VALIDATION_ERROR",
            "message": "Données invalides",
            "errors": [
                {
                    "field": " -> ".join(str(loc) for loc in e["loc"]),
                    "message": e["msg"],
                    "type": e["type"],
                }
                for e in exc.errors()
            ],
        }
    )

@app.exception_handler(Exception)
async def global_handler(request: Request, exc: Exception):
    """Capture toutes les erreurs non gérées."""
    import logging
    logging.getLogger("taskflow").error(
        f"Erreur non gérée : {exc}",
        extra={"path": request.url.path},
        exc_info=True,
    )
    return JSONResponse(
        status_code=500,
        content={"code": "INTERNAL_ERROR", "message": "Erreur interne du serveur"},
    )

# ── Routers ───────────────────────────────────────────────────────────
app.include_router(api_v1_router)

# ── Routes système ────────────────────────────────────────────────────
@app.get("/", tags=["system"], include_in_schema=False)
async def root():
    return {"name": settings.app_name, "version": settings.app_version, "docs": "/docs"}

@app.get("/health", tags=["system"])
async def health():
    """Health check pour les load balancers."""
    return {"status": "healthy", "version": settings.app_version}

@app.get("/ping", tags=["system"], include_in_schema=False)
async def ping():
    """Ping léger pour les moniteurs de disponibilité."""
    return "pong"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
30.2 PYPROJECT.TOML — CONFIGURATION CENTRALISÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# pyproject.toml — configuration de tous les outils du projet

[project]
name = "taskflow-api"
version = "1.0.0"
requires-python = ">=3.11"

[tool.ruff]
# Linter et formatter (remplace black + flake8 + isort)
line-length = 88
target-version = "py311"
select = [
    "E",    # pycodestyle errors
    "W",    # pycodestyle warnings
    "F",    # pyflakes
    "I",    # isort
    "N",    # pep8-naming
    "UP",   # pyupgrade
    "B",    # flake8-bugbear
    "S",    # flake8-bandit (security)
    "A",    # flake8-builtins
]
ignore = [
    "S101",  # assert est autorisé dans les tests
    "B008",  # Depends() dans les arguments de fonction
]

[tool.ruff.per-file-ignores]
"tests/**/*.py" = ["S", "B"]  # Tests : pas de checks sécurité

[tool.mypy]
# Type checker
python_version = "3.11"
strict = true
ignore_missing_imports = true
plugins = ["pydantic.mypy"]

[tool.pytest.ini_options]
# Configuration pytest
asyncio_mode = "auto"          # Toutes les fonctions async sont des coroutines
testpaths = ["tests"]
env_files = [".env.test"]      # Utiliser les vars d'env de test
addopts = [
    "--cov=app",               # Couverture de code
    "--cov-report=term-missing",
    "--cov-report=html:htmlcov",
    "-v",
]

[tool.coverage.run]
source = ["app"]
omit = ["*/migrations/*", "*/tests/*"]

[tool.coverage.report]
fail_under = 80    # Échec si couverture < 80%

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
30.3 EXERCICES — PARTIE 6
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 30.1 : Crée un BaseRepository[Project] pour les projets et implémente
  get_active_projects() qui retourne seulement les projets avec
  is_active=True, triés par nom alphabétique.

Ex 30.2 : Crée une dépendance de classe DateRangeFilter qui accepte
  les query params start_date et end_date (format YYYY-MM-DD) et
  valide que start_date < end_date.

Ex 30.3 : Ajoute un header X-Environment (development/staging/production)
  à toutes les réponses via un middleware simple.

NIVEAU INTERMÉDIAIRE
--------------------
Ex 30.4 : Implémente un middleware RateLimitMiddleware qui utilise Redis
  pour compter les requêtes par IP (au lieu de la mémoire locale).
  La limite doit être configurable par route via des décorateurs.

Ex 30.5 : Crée une hiérarchie d'exceptions complète pour TaskFlow :
  ProjectNotFoundError, CommentNotFoundError, InvalidDateRangeError,
  MaxTasksPerProjectExceededError (max 500 tâches par projet).
  Chacune avec son code d'erreur unique et son message descriptif.

Ex 30.6 : Refactorise le UserService pour utiliser un UserRepository
  avec les méthodes : get_by_email, get_active_users, search_by_name,
  count_by_role.

NIVEAU AVANCÉ
-------------
Ex 30.7 : Implémente un système de "feature flags" : certaines fonctions
  de l'API peuvent être activées/désactivées sans redéploiement.
  Les flags sont stockés en DB et cachés en Redis (TTL 60s).
  Crée une dépendance require_feature("bulk_delete") qui retourne
  503 si le flag est désactivé.

Ex 30.8 : Crée un middleware d'audit qui persiste en DB (table audit_logs)
  chaque action de modification (POST, PUT, PATCH, DELETE) avec :
  user_id, action, resource_type, resource_id, ip, timestamp, before/after.

Ex 30.9 : Implémente un système de versioning d'API géré par headers :
  Accept: application/vnd.taskflow.v1+json -> router vers v1
  Accept: application/vnd.taskflow.v2+json -> router vers v2
  Avec fallback automatique vers v1 si header absent.

================================================================================
                         RÉCAPITULATIF PARTIE 6
================================================================================

Dans cette partie, tu as appris :

[OK] Architecture modulaire : couches API / Service / Repository / Domain
[OK] BaseRepository générique : CRUD réutilisable via generics Python
[OK] TaskRepository spécialisé : requêtes filtrées, agrégations, relations
[OK] Exceptions métier : hiérarchie AppError -> JSON structuré automatique
[OK] Dépendances classes : PaginationParams, SortParams, SearchFilter
[OK] lru_cache + dependency_overrides : singletons et mocks pour tests
[OK] Logging structuré JSON : format production, rotation, niveaux
[OK] Middleware de timing : métriques p50/p95/p99 par endpoint
[OK] CORS avancé : configuration par environnement, expose_headers
[OK] Middleware ASGI pur : mode maintenance, plus performant
[OK] pyproject.toml : Ruff, mypy, pytest, coverage centralisés

[RAPIDE] PROCHAINE ÉTAPE : Partie 7 — Tests Automatisés
   - Tests unitaires des services (sans DB)
   - Tests d'intégration des routes (avec DB en mémoire)
   - Fixtures pytest + conftest.py
   - Factory Boy pour les données de test
   - Coverage et bonnes pratiques TDD

================================================================================
                          FIN DE LA PARTIE 6
                   Passe à fastapi_master_part_7.txt
================================================================================

================================================================================
  GUIDE FASTAPI COMPLET — PARTIE 7 : TESTS AUTOMATISÉS COMPLETS
  Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FIL ROUGE : TaskFlow API
   Dans cette partie, nous couvrons COMPLÈTEMENT les tests de TaskFlow :
   - Configuration pytest-asyncio pour le code async
   - conftest.py avec fixtures réutilisables
   - Tests unitaires des services (sans DB, avec mocks)
   - Tests d'intégration des routes (DB SQLite en mémoire)
   - Factory Boy pour générer des données de test réalistes
   - TDD : écrire les tests AVANT le code
   - Coverage : mesurer et atteindre 80%+

================================================================================
          CHAPITRE 38 — INTRODUCTION AUX TESTS FASTAPI
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38.1 POURQUOI TESTER ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Sans tests automatisés :
  - Chaque modification = risque de casser quelque chose silencieusement
  - Les régressions ne sont découvertes qu'en production
  - Refactoriser devient dangereux -> code qui se dégrade avec le temps
  - Impossible d'intégrer plusieurs développeurs sans conflits

Avec tests automatisés :
  - Chaque PR est validée automatiquement en CI/CD
  - On détecte les régressions en secondes, pas en heures
  - Refactoriser est sécurisé (les tests vérifient le comportement)
  - Documentation vivante : les tests montrent comment utiliser le code

Pyramide des tests (de la base au sommet) :
  ┌─────────────┐
  │  E2E Tests  │  <- Peu, lents, coûteux (Playwright, Cypress)
  ├─────────────┤
  │ Integration │  <- Moyennement nombreux (API routes + DB)
  ├─────────────┤
  │    Unit     │  <- Nombreux, rapides, isolés (services, utils)
  └─────────────┘

Pour une API FastAPI :
  Unit tests     -> tester les services, les validators, les utils
  Integration    -> tester les routes HTTP avec une vraie DB de test
  E2E            -> tester le flux complet (hors scope de ce guide)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38.2 INSTALLATION DES DÉPENDANCES DE TEST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  pip install pytest pytest-asyncio httpx factory-boy pytest-cov

  # pytest          -> framework de test Python
  # pytest-asyncio  -> support des fonctions async dans pytest
  # httpx           -> client HTTP async (remplace TestClient pour l'async)
  # factory-boy     -> génération de données de test réalistes
  # pytest-cov      -> mesure de la couverture de code

  # requirements-dev.txt
  pytest==7.4.4
  pytest-asyncio==0.23.3
  httpx==0.26.0
  factory-boy==3.3.0
  pytest-cov==4.1.0
  Faker==22.2.0            # Utilisé par Factory Boy pour les données réalistes
  aiosqlite==0.19.0        # SQLite async pour les tests (pas besoin de PostgreSQL)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38.3 STRUCTURE DES TESTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  tests/
  ├── __init__.py
  ├── conftest.py                  # Fixtures globales (DB, client, users...)
  ├── factories.py                 # Factory Boy : génération de données
  │
  ├── unit/                        # Tests unitaires (sans infrastructure réelle)
  │   ├── __init__.py
  │   ├── test_security.py         # Tests du module security.py
  │   ├── test_task_service.py     # Tests du TaskService avec mocks
  │   └── test_validators.py      # Tests des validateurs Pydantic
  │
  └── integration/                 # Tests d'intégration (avec DB SQLite)
      ├── __init__.py
      ├── test_auth_api.py         # POST /auth/login, /register, etc.
      ├── test_tasks_api.py        # CRUD complet des tâches
      └── test_users_api.py        # CRUD des utilisateurs

================================================================================
          CHAPITRE 38 SUITE — CONFIGURATION PYTEST ET CONFTEST
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38.4 CONFIGURATION PYTEST — pyproject.toml
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# Dans pyproject.toml :

[tool.pytest.ini_options]
# asyncio_mode = "auto" : toutes les fonctions "async def test_..." sont
# automatiquement traitées comme des coroutines pytest.
# Sans ça, il faut décorer chaque test async avec @pytest.mark.asyncio
asyncio_mode = "auto"

# Dossier racine des tests
testpaths = ["tests"]

# Fichier de variables d'environnement pour les tests
# (surchargées par rapport à .env)
env_files = [".env.test"]

# Options par défaut de pytest
addopts = [
    "--cov=app",                   # Mesurer la couverture du dossier app/
    "--cov-report=term-missing",   # Afficher les lignes non couvertes en terminal
    "--cov-report=html:htmlcov",   # Générer un rapport HTML dans htmlcov/
    "--cov-fail-under=80",         # Échouer si couverture < 80%
    "-v",                          # Mode verbeux (noms des tests)
    "--tb=short",                  # Traceback court en cas d'échec
]

# Marqueurs personnalisés (évite les warnings pytest)
markers = [
    "unit: Tests unitaires (rapides, sans infrastructure)",
    "integration: Tests d'intégration (avec DB)",
    "slow: Tests lents (> 5 secondes)",
]

# ── Fichier .env.test ─────────────────────────────────────────────────
# .env.test — Variables d'environnement pour les tests
# IMPORTANT : utiliser une DB séparée, jamais la DB de dev !

# DATABASE_URL=sqlite+aiosqlite:///./test.db
# -> SQLite async en mémoire : rapide, pas de PostgreSQL requis pour les tests
# Pour les tests qui nécessitent les features PostgreSQL spécifiques :
# DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/taskflow_test

# SECRET_KEY=test-secret-key-not-for-production-32-chars-min
# ENVIRONMENT=test
# DEBUG=true
# ACCESS_TOKEN_EXPIRE_MINUTES=5

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38.5 CONFTEST.PY — FIXTURES GLOBALES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# tests/conftest.py — Configuration et fixtures globales
# ═══════════════════════════════════════════════════════════════════════

import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import (
    create_async_engine, AsyncSession, async_sessionmaker
)
from sqlalchemy.pool import StaticPool
from typing import AsyncGenerator

from app.main import app
from app.infrastructure.database import Base, get_db
from app.models.user import User
from app.core.security import hash_password, create_token_pair

# ── Configuration de la DB de test ───────────────────────────────────
# SQLite en mémoire : ultra-rapide, remis à zéro entre les tests
# StaticPool : une seule connexion partagée (nécessaire pour SQLite in-memory)

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"

test_engine = create_async_engine(
    TEST_DATABASE_URL,
    connect_args={"check_same_thread": False},  # Nécessaire pour SQLite
    poolclass=StaticPool,                        # Pool fixe pour SQLite in-memory
    echo=False,                                  # Pas de logs SQL dans les tests
)

TestSessionLocal = async_sessionmaker(
    bind=test_engine,
    class_=AsyncSession,
    expire_on_commit=False,
    autocommit=False,
    autoflush=False,
)

# ── Fixture : DB de test (scope="session") ────────────────────────────
@pytest_asyncio.fixture(scope="session")
async def test_db_setup():
    """
    Crée les tables UNE SEULE FOIS pour toute la session de tests.
    scope="session" -> exécuté une fois au début, une fois à la fin.
    """
    async with test_engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield
    async with test_engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)

# ── Fixture : session DB par test (scope="function") ─────────────────
@pytest_asyncio.fixture(scope="function")
async def db_session(test_db_setup) -> AsyncGenerator[AsyncSession, None]:
    """
    Fournit une session DB FRAÎCHE pour chaque test.
    scope="function" -> créé et détruit pour chaque fonction de test.

    Pattern TRANSACTION ROLLBACK :
    On commence une transaction au début du test, puis on la ROLLBACK
    à la fin -> les données sont effacées, la DB est propre pour le test suivant.
    C'est beaucoup plus rapide que de recréer les tables.
    """
    async with test_engine.connect() as connection:
        # Démarrer une transaction englobante
        await connection.begin()

        # Créer une session liée à cette connexion
        session = AsyncSession(
            bind=connection,
            expire_on_commit=False,
        )

        try:
            yield session
        finally:
            # ROLLBACK -> annule TOUTES les modifications du test
            await session.close()
            await connection.rollback()

# ── Fixture : override de la dépendance get_db ────────────────────────
@pytest_asyncio.fixture(scope="function")
async def db(db_session: AsyncSession) -> AsyncGenerator[AsyncSession, None]:
    """
    Override la dépendance get_db de FastAPI pour utiliser la DB de test.
    C'est le "câblage" entre FastAPI et la DB de test.
    """

    async def override_get_db():
        yield db_session

    # Remplacer get_db() par notre version de test
    app.dependency_overrides[get_db] = override_get_db
    yield db_session
    # Nettoyer l'override après le test
    app.dependency_overrides.clear()

# ── Fixture : client HTTP async ───────────────────────────────────────
@pytest_asyncio.fixture(scope="function")
async def client(db) -> AsyncGenerator[AsyncClient, None]:
    """
    Client HTTP async pour tester les routes FastAPI.

    ASGITransport : connecte le client directement à l'app ASGI
    sans passer par un vrai serveur réseau.
    -> Ultra-rapide, pas de port TCP nécessaire.
    """
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",              # URL de base factice
    ) as async_client:
        yield async_client

# ── Fixtures : utilisateurs de test ──────────────────────────────────
@pytest_asyncio.fixture(scope="function")
async def user_regular(db_session: AsyncSession) -> User:
    """
    Crée un utilisateur standard pour les tests.
    Recréé à chaque test (scope="function") -> isolation totale.
    """
    user = User(
        email="alice@taskflow.test",
        username="alice_test",
        full_name="Alice Dupont",
        hashed_password=hash_password("Password@123"),
        is_active=True,
        is_superuser=False,
        role="user",
    )
    db_session.add(user)
    await db_session.flush()
    await db_session.refresh(user)
    return user

@pytest_asyncio.fixture(scope="function")
async def user_admin(db_session: AsyncSession) -> User:
    """Crée un utilisateur admin pour les tests."""
    user = User(
        email="admin@taskflow.test",
        username="admin_test",
        full_name="Admin Test",
        hashed_password=hash_password("AdminPass@123"),
        is_active=True,
        is_superuser=True,
        role="admin",
    )
    db_session.add(user)
    await db_session.flush()
    await db_session.refresh(user)
    return user

@pytest_asyncio.fixture(scope="function")
async def user_inactive(db_session: AsyncSession) -> User:
    """Crée un utilisateur désactivé pour tester les cas d'erreur."""
    user = User(
        email="inactive@taskflow.test",
        username="inactive_test",
        full_name="Inactive User",
        hashed_password=hash_password("Password@123"),
        is_active=False,   # <- Désactivé
        role="user",
    )
    db_session.add(user)
    await db_session.flush()
    await db_session.refresh(user)
    return user

# ── Fixtures : tokens JWT ─────────────────────────────────────────────
@pytest.fixture
def token_regular(user_regular: User) -> str:
    """Token JWT valide pour l'utilisateur standard."""
    tokens = create_token_pair(
        user_id=user_regular.id,
        email=user_regular.email,
        role=user_regular.role,
    )
    return tokens.access_token

@pytest.fixture
def token_admin(user_admin: User) -> str:
    """Token JWT valide pour l'administrateur."""
    tokens = create_token_pair(
        user_id=user_admin.id,
        email=user_admin.email,
        role=user_admin.role,
    )
    return tokens.access_token

# ── Fixtures : clients authentifiés ──────────────────────────────────
@pytest_asyncio.fixture
async def auth_client(client: AsyncClient, token_regular: str) -> AsyncClient:
    """
    Client HTTP avec token d'authentification pré-configuré.
    Toutes les requêtes envoyées avec ce client incluent
    automatiquement le header Authorization: Bearer {token}.
    """
    client.headers["Authorization"] = f"Bearer {token_regular}"
    return client

@pytest_asyncio.fixture
async def admin_client(client: AsyncClient, token_admin: str) -> AsyncClient:
    """Client HTTP avec token admin pré-configuré."""
    client.headers["Authorization"] = f"Bearer {token_admin}"
    return client

================================================================================
          CHAPITRE 38 SUITE — FACTORY BOY
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38.6 FACTORY BOY — GÉNÉRATION DE DONNÉES RÉALISTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Factory Boy génère des objets de test réalistes avec des données aléatoires.
Plus besoin de hardcoder "user1@test.com", "Task 1", etc.

# ═══════════════════════════════════════════════════════════════════════
# tests/factories.py — Factories pour tous les modèles
# ═══════════════════════════════════════════════════════════════════════

import factory
from factory.faker import Faker
from factory import LazyAttribute, SubFactory, LazyFunction
from datetime import date, timedelta
import random

from app.models.user import User, UserRole
from app.models.task import Task, PriorityEnum, StatusEnum
from app.core.security import hash_password

class UserFactory(factory.Factory):
    """
    Factory pour créer des objets User de test.

    factory.Factory : crée des objets Python (pas sauvegardés en DB).
    Utiliser factory.alchemy.SQLAlchemyModelFactory pour la DB.
    """
    class Meta:
        model = User   # Modèle SQLAlchemy cible

    # Faker génère des données réalistes aléatoires
    email = Faker("email")                    # alice.dupont@example.com
    username = Faker("user_name")             # alice_dupont_42
    full_name = Faker("name")                 # Alice Dupont
    is_active = True
    is_superuser = False
    role = UserRole.user

    # LazyFunction : appelé à la création, pas au chargement de la classe
    hashed_password = LazyFunction(lambda: hash_password("TestPassword@123"))

    # LazyAttribute : peut dépendre d'autres champs
    bio = LazyAttribute(lambda obj: f"Développeur passionné, {obj.full_name}")
    avatar_url = None

class AdminUserFactory(UserFactory):
    """Factory pour un utilisateur admin — hérite de UserFactory."""
    is_superuser = True
    role = UserRole.admin
    email = Faker("email")   # Email différent à chaque fois

class TaskFactory(factory.Factory):
    """Factory pour créer des objets Task de test."""
    class Meta:
        model = Task

    title = Faker("sentence", nb_words=5)            # "Implémenter le module JWT"
    description = Faker("paragraph", nb_sentences=3) # Description réaliste
    status = factory.Iterator([                       # Cycle sur les valeurs
        StatusEnum.todo,
        StatusEnum.in_progress,
        StatusEnum.done,
    ])
    priority = factory.LazyFunction(
        lambda: random.choice(list(PriorityEnum))    # Priorité aléatoire
    )
    completed = LazyAttribute(
        lambda obj: obj.status == StatusEnum.done    # Complète si status=done
    )
    due_date = LazyFunction(
        lambda: date.today() + timedelta(days=random.randint(1, 30))
    )
    estimated_hours = Faker("pyfloat", min_value=1, max_value=40, right_digits=1)
    story_points = factory.LazyFunction(
        lambda: random.choice([1, 2, 3, 5, 8, 13])  # Suite de Fibonacci (Scrum)
    )
    created_by_id = None   # À surcharger dans les tests
    assigned_to_id = None

class CompletedTaskFactory(TaskFactory):
    """Factory pour une tâche complète."""
    status = StatusEnum.done
    completed = True
    due_date = LazyFunction(
        lambda: date.today() - timedelta(days=random.randint(1, 10))
    )

class OverdueTaskFactory(TaskFactory):
    """Factory pour une tâche en retard."""
    status = StatusEnum.in_progress
    completed = False
    due_date = LazyFunction(
        lambda: date.today() - timedelta(days=random.randint(1, 30))
    )
    priority = PriorityEnum.urgent

# ── Utilisation dans les fixtures conftest.py ─────────────────────────
# Exemple :
# async def test_list_tasks(db_session, user_regular):
#     # Créer 10 tâches avec des données réalistes
#     for _ in range(10):
#         task_data = TaskFactory.build(created_by_id=user_regular.id)
#         db_session.add(task_data)
#     await db_session.flush()
#
#     tasks = await task_service.list_with_filters()
#     assert len(tasks) == 10

================================================================================
             CHAPITRE 38 SUITE — TESTS UNITAIRES
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38.7 TESTS UNITAIRES — MODULE SECURITY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# tests/unit/test_security.py
# ═══════════════════════════════════════════════════════════════════════

import pytest
import time
from datetime import timedelta

from app.core.security import (
    hash_password, verify_password,
    create_access_token, create_refresh_token,
    decode_token, decode_access_token, decode_refresh_token,
)

# ── Tests du hashing ─────────────────────────────────────────────────

class TestPasswordHashing:
    """
    Tests unitaires pour le hashing des mots de passe.
    Ces tests ne nécessitent aucune DB ni infrastructure.
    """

    def test_hash_password_returns_string(self):
        """Le hash doit retourner une string non vide."""
        result = hash_password("MonPassword@123")
        assert isinstance(result, str)
        assert len(result) > 0

    def test_hash_password_not_equal_to_plain(self):
        """Le hash ne doit JAMAIS être égal au mot de passe en clair."""
        plain = "MonPassword@123"
        hashed = hash_password(plain)
        assert plain != hashed

    def test_hash_password_starts_with_bcrypt_prefix(self):
        """Un hash bcrypt commence toujours par $2b$."""
        hashed = hash_password("test")
        assert hashed.startswith("$2b$")

    def test_hash_password_different_each_time(self):
        """
        Deux hashes du même mot de passe doivent être DIFFÉRENTS.
        Cela prouve que le salt est bien aléatoire.
        """
        plain = "MonPassword@123"
        hash1 = hash_password(plain)
        hash2 = hash_password(plain)
        # Les hashes sont différents (salt différent) mais les deux sont valides
        assert hash1 != hash2

    def test_verify_password_correct(self):
        """Un mot de passe correct doit être vérifié avec succès."""
        plain = "MonPassword@123"
        hashed = hash_password(plain)
        assert verify_password(plain, hashed) is True

    def test_verify_password_incorrect(self):
        """Un mauvais mot de passe doit échouer la vérification."""
        hashed = hash_password("CorrectPassword@123")
        assert verify_password("WrongPassword@123", hashed) is False

    def test_verify_password_empty_string(self):
        """Un mot de passe vide ne doit pas correspondre à un hash valide."""
        hashed = hash_password("CorrectPassword@123")
        assert verify_password("", hashed) is False

    def test_verify_password_case_sensitive(self):
        """Le mot de passe est sensible à la casse."""
        hashed = hash_password("Password@123")
        assert verify_password("password@123", hashed) is False   # minuscule ≠ majuscule
        assert verify_password("PASSWORD@123", hashed) is False   # tout majuscule ≠

# ── Tests des JWT ─────────────────────────────────────────────────────

class TestJWT:
    """Tests unitaires pour la génération et validation de JWT."""

    def test_create_access_token_returns_string(self):
        """create_access_token doit retourner une string JWT."""
        token = create_access_token(user_id=1, email="test@test.com", role="user")
        assert isinstance(token, str)
        # Un JWT a 3 parties séparées par des points
        parts = token.split(".")
        assert len(parts) == 3

    def test_decode_access_token_valid(self):
        """Un token valide doit se décoder correctement."""
        token = create_access_token(
            user_id=42,
            email="alice@test.com",
            role="admin",
        )
        data = decode_access_token(token)

        assert data.user_id == 42
        assert data.email == "alice@test.com"
        assert data.role == "admin"
        assert data.token_type == "access"
        assert data.jti is not None   # Le JTI doit être présent

    def test_access_token_rejected_as_refresh(self):
        """Un access token ne doit pas être accepté comme refresh token."""
        access_token = create_access_token(
            user_id=1, email="test@test.com", role="user"
        )
        with pytest.raises(ValueError, match="n'est pas un refresh token"):
            decode_refresh_token(access_token)

    def test_refresh_token_rejected_as_access(self):
        """Un refresh token ne doit pas être accepté comme access token."""
        refresh_token = create_refresh_token(user_id=1, email="test@test.com")
        with pytest.raises(ValueError, match="n'est pas un access token"):
            decode_access_token(refresh_token)

    def test_expired_token_raises_error(self):
        """Un token expiré doit lever une ValueError."""
        # Créer un token qui expire dans -1 seconde (déjà expiré)
        token = create_access_token(
            user_id=1,
            email="test@test.com",
            role="user",
            expires_delta=timedelta(seconds=-1),  # Expiré immédiatement
        )
        with pytest.raises(ValueError):
            decode_access_token(token)

    def test_tampered_token_raises_error(self):
        """Un token dont la signature est modifiée doit être rejeté."""
        token = create_access_token(user_id=1, email="test@test.com", role="user")
        # Modifier un caractère dans la signature (3ème partie)
        parts = token.split(".")
        tampered_signature = parts[2][:-1] + ("A" if parts[2][-1] != "A" else "B")
        tampered_token = f"{parts[0]}.{parts[1]}.{tampered_signature}"

        with pytest.raises(ValueError):
            decode_access_token(tampered_token)

    def test_token_with_wrong_secret_raises_error(self):
        """Un token signé avec une autre clé doit être rejeté."""
        from jose import jwt
        # Créer un token avec une clé différente
        payload = {"sub": "1", "email": "test@test.com", "role": "user",
                   "type": "access", "jti": "fake-jti"}
        fake_token = jwt.encode(payload, "wrong-secret-key", algorithm="HS256")

        with pytest.raises(ValueError):
            decode_access_token(fake_token)

    def test_unique_jti_per_token(self):
        """Chaque token doit avoir un JTI unique (pour la révocation)."""
        token1 = create_access_token(user_id=1, email="t@t.com", role="user")
        token2 = create_access_token(user_id=1, email="t@t.com", role="user")

        data1 = decode_access_token(token1)
        data2 = decode_access_token(token2)

        assert data1.jti != data2.jti   # JTI différent à chaque génération

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38.8 TESTS UNITAIRES — SERVICE AVEC MOCKS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# tests/unit/test_task_service.py — Tests du service avec mocks
# ═══════════════════════════════════════════════════════════════════════

import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import date, timedelta
from fastapi import HTTPException

from app.services.task_service import TaskService
from app.models.task import Task, StatusEnum, PriorityEnum
from app.schemas.task import TaskCreate, TaskUpdate
from app.core.exceptions import (
    TaskNotFoundError, ForbiddenError, TaskAlreadyCompletedError
)
from tests.factories import TaskFactory, UserFactory

class TestTaskServiceCreate:
    """Tests pour la création de tâches."""

    @pytest.fixture
    def mock_db(self):
        """Session DB mockée — pas de vraie DB ici."""
        db = AsyncMock()
        # db.flush() et db.refresh() ne font rien, mais sont attendus
        db.flush = AsyncMock(return_value=None)
        db.refresh = AsyncMock(return_value=None)
        return db

    @pytest.fixture
    def mock_task_repo(self):
        """Repository de tâches mocké."""
        return AsyncMock()

    @pytest.fixture
    def mock_user_repo(self):
        """Repository d'utilisateurs mocké."""
        return AsyncMock()

    async def test_create_task_success(self, mock_db):
        """La création d'une tâche sans assignataire doit réussir."""
        service = TaskService(mock_db)

        # Mocker le repository interne
        with patch.object(service.task_repo, 'create', new_callable=AsyncMock) as mock_create:
            # Configurer ce que la fonction mock retourne
            expected_task = TaskFactory.build(
                id=1, created_by_id=42, title="Implémenter JWT"
            )
            mock_create.return_value = expected_task

            task_data = TaskCreate(title="Implémenter JWT", priority="high")
            result = await service.create(task_data, created_by_id=42)

            # Vérifier que le repository a été appelé
            mock_create.assert_called_once()

            # Vérifier le résultat
            assert result.title == "Implémenter JWT"
            assert result.created_by_id == 42

    async def test_create_task_with_nonexistent_assignee_raises(self, mock_db):
        """Assigner une tâche à un utilisateur inexistant doit lever une erreur."""
        service = TaskService(mock_db)

        # Configurer : get_by_id retourne None (utilisateur inexistant)
        with patch.object(service.user_repo, 'get_by_id', new_callable=AsyncMock) as mock_get:
            mock_get.return_value = None   # L'utilisateur n'existe pas

            task_data = TaskCreate(title="Ma Tâche", assigned_to=999)

            with pytest.raises(HTTPException) as exc_info:
                await service.create(task_data, created_by_id=1)

            assert exc_info.value.status_code == 404

    async def test_create_task_with_inactive_assignee_raises(self, mock_db):
        """Assigner une tâche à un utilisateur inactif doit lever une erreur."""
        service = TaskService(mock_db)

        inactive_user = UserFactory.build(id=5, is_active=False)

        with patch.object(service.user_repo, 'get_by_id', new_callable=AsyncMock) as mock_get:
            mock_get.return_value = inactive_user

            task_data = TaskCreate(title="Ma Tâche", assigned_to=5)

            with pytest.raises(HTTPException) as exc_info:
                await service.create(task_data, created_by_id=1)

            assert exc_info.value.status_code == 400
            assert "ASSIGNEE_INACTIVE" in str(exc_info.value.detail)

class TestTaskServiceComplete:
    """Tests pour la complétion de tâches."""

    async def test_complete_task_success(self):
        """Compléter une tâche non-complète doit réussir."""
        mock_db = AsyncMock()
        mock_db.flush = AsyncMock()
        service = TaskService(mock_db)

        task = TaskFactory.build(
            id=1, completed=False, status=StatusEnum.in_progress,
            created_by_id=42, assigned_to_id=None
        )

        with patch.object(service, 'get_or_raise', new_callable=AsyncMock) as mock_get:
            mock_get.return_value = task

            result = await service.complete(task_id=1, user_id=42)

            assert result.completed is True
            assert result.status == StatusEnum.done

    async def test_complete_already_completed_task_raises(self):
        """Compléter une tâche déjà complète doit lever TaskAlreadyCompletedError."""
        mock_db = AsyncMock()
        service = TaskService(mock_db)

        already_done_task = TaskFactory.build(
            id=1, completed=True, status=StatusEnum.done,
            created_by_id=42
        )

        with patch.object(service, 'get_or_raise', new_callable=AsyncMock) as mock_get:
            mock_get.return_value = already_done_task

            with pytest.raises(TaskAlreadyCompletedError):
                await service.complete(task_id=1, user_id=42)

    async def test_complete_task_by_non_owner_raises(self):
        """Compléter une tâche qu'on ne possède pas doit lever ForbiddenError."""
        mock_db = AsyncMock()
        service = TaskService(mock_db)

        # Tâche créée par user 42, assignée à user 10
        task = TaskFactory.build(
            id=1, completed=False,
            created_by_id=42, assigned_to_id=10
        )

        with patch.object(service, 'get_or_raise', new_callable=AsyncMock) as mock_get:
            mock_get.return_value = task

            # User 99 essaie de compléter -> ni créateur ni assignataire
            with pytest.raises(ForbiddenError):
                await service.complete(task_id=1, user_id=99)

class TestTaskServiceUpdate:
    """Tests pour la mise à jour de tâches."""

    async def test_update_partial_fields(self):
        """PATCH ne doit modifier QUE les champs fournis."""
        mock_db = AsyncMock()
        mock_db.flush = AsyncMock()
        mock_db.refresh = AsyncMock()
        service = TaskService(mock_db)

        original_task = TaskFactory.build(
            id=1,
            title="Titre Original",
            priority=PriorityEnum.low,
            description="Description Originale",
            created_by_id=1,
        )

        with patch.object(service, 'get_or_raise', new_callable=AsyncMock) as mock_get:
            mock_get.return_value = original_task

            # On envoie seulement le titre
            update_data = TaskUpdate(title="Nouveau Titre")
            result = await service.update(task_id=1, data=update_data, user_id=1)

            assert result.title == "Nouveau Titre"
            # La priorité et la description doivent rester inchangées
            assert result.priority == PriorityEnum.low
            assert result.description == "Description Originale"

    async def test_update_status_to_done_sets_completed(self):
        """Passer le status à 'done' doit aussi mettre completed=True."""
        mock_db = AsyncMock()
        mock_db.flush = AsyncMock()
        mock_db.refresh = AsyncMock()
        service = TaskService(mock_db)

        task = TaskFactory.build(
            id=1, status=StatusEnum.in_progress, completed=False, created_by_id=1
        )

        with patch.object(service, 'get_or_raise', new_callable=AsyncMock) as mock_get:
            mock_get.return_value = task

            update_data = TaskUpdate(status=StatusEnum.done)
            result = await service.update(task_id=1, data=update_data, user_id=1)

            assert result.status == StatusEnum.done
            assert result.completed is True   # Auto-complété

# ── Tests des validateurs Pydantic ────────────────────────────────────

class TestTaskValidators:
    """Tests des validateurs Pydantic des schémas."""

    def test_due_date_in_past_raises(self):
        """Une date dans le passé doit lever une ValidationError."""
        from pydantic import ValidationError
        from app.schemas.task import TaskCreate

        past_date = date.today() - timedelta(days=1)

        with pytest.raises(ValidationError) as exc_info:
            TaskCreate(title="Tâche", due_date=past_date)

        errors = exc_info.value.errors()
        assert any("passé" in str(e["msg"]) for e in errors)

    def test_due_date_today_is_valid(self):
        """La date d'aujourd'hui doit être acceptée."""
        task = TaskCreate(title="Tâche", due_date=date.today())
        assert task.due_date == date.today()

    def test_title_whitespace_stripped(self):
        """Les espaces en début/fin de titre doivent être supprimés."""
        task = TaskCreate(title="  Mon titre avec espaces  ")
        assert task.title == "Mon titre avec espaces"

    def test_title_too_short_raises(self):
        """Un titre vide doit lever une ValidationError."""
        from pydantic import ValidationError
        with pytest.raises(ValidationError):
            TaskCreate(title="")

    def test_priority_invalid_value_raises(self):
        """Une priorité invalide doit lever une ValidationError."""
        from pydantic import ValidationError
        with pytest.raises(ValidationError):
            TaskCreate(title="Test", priority="very_urgent")

================================================================================
             CHAPITRE 39 — TESTS D'INTÉGRATION DES ROUTES
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39.1 TESTS D'INTÉGRATION — AUTHENTIFICATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# tests/integration/test_auth_api.py
# ═══════════════════════════════════════════════════════════════════════

import pytest
from httpx import AsyncClient

class TestRegisterAPI:
    """Tests d'intégration pour POST /api/v1/auth/register."""

    async def test_register_success(self, client: AsyncClient):
        """Inscription réussie avec des données valides."""
        response = await client.post(
            "/api/v1/auth/register",
            json={
                "email": "newuser@taskflow.test",
                "username": "newuser123",
                "password": "SecurePass@2024",
                "full_name": "Nouveau Utilisateur",
            }
        )
        assert response.status_code == 201

        data = response.json()
        assert data["email"] == "newuser@taskflow.test"
        assert data["username"] == "newuser123"
        # Vérifier que le mot de passe N'EST PAS dans la réponse
        assert "password" not in data
        assert "hashed_password" not in data
        # Vérifier que l'ID a été généré
        assert "id" in data
        assert isinstance(data["id"], int)

    async def test_register_duplicate_email_returns_409(
        self, client: AsyncClient, user_regular
    ):
        """Réutiliser un email existant doit retourner 409 Conflict."""
        response = await client.post(
            "/api/v1/auth/register",
            json={
                "email": user_regular.email,   # Email déjà utilisé
                "username": "different_username",
                "password": "AnotherPass@2024",
            }
        )
        assert response.status_code == 409
        assert response.json()["code"] == "EMAIL_ALREADY_EXISTS"

    async def test_register_duplicate_username_returns_409(
        self, client: AsyncClient, user_regular
    ):
        """Réutiliser un username existant doit retourner 409 Conflict."""
        response = await client.post(
            "/api/v1/auth/register",
            json={
                "email": "different@taskflow.test",
                "username": user_regular.username,   # Username déjà utilisé
                "password": "AnotherPass@2024",
            }
        )
        assert response.status_code == 409
        assert response.json()["code"] == "USERNAME_ALREADY_EXISTS"

    async def test_register_invalid_email_returns_422(self, client: AsyncClient):
        """Un email invalide doit retourner 422."""
        response = await client.post(
            "/api/v1/auth/register",
            json={
                "email": "pas-un-email",
                "username": "valid_username",
                "password": "SecurePass@2024",
            }
        )
        assert response.status_code == 422

    async def test_register_password_too_short_returns_422(self, client: AsyncClient):
        """Un mot de passe trop court doit retourner 422."""
        response = await client.post(
            "/api/v1/auth/register",
            json={
                "email": "valid@test.com",
                "username": "valid_user",
                "password": "short",   # < 8 caractères
            }
        )
        assert response.status_code == 422

    @pytest.mark.parametrize("username", [
        "ab",           # Trop court (< 3 chars)
        "a" * 51,       # Trop long (> 50 chars)
        "user name",    # Espace non autorisé
        "user@name",    # @ non autorisé
        "user.name!",   # ! non autorisé
    ])
    async def test_register_invalid_username_returns_422(
        self, client: AsyncClient, username: str
    ):
        """Différents formats d'username invalides doivent retourner 422."""
        response = await client.post(
            "/api/v1/auth/register",
            json={"email": "valid@test.com", "username": username, "password": "SecurePass@2024"}
        )
        assert response.status_code == 422, f"Username '{username}' aurait dû échouer"

class TestLoginAPI:
    """Tests d'intégration pour POST /api/v1/auth/login."""

    async def test_login_success_returns_tokens(
        self, client: AsyncClient, user_regular
    ):
        """Connexion réussie doit retourner access et refresh tokens."""
        response = await client.post(
            "/api/v1/auth/login",
            data={                              # Form data (OAuth2)
                "username": user_regular.email,
                "password": "Password@123",
            }
        )
        assert response.status_code == 200

        data = response.json()
        assert "access_token" in data
        assert "refresh_token" in data
        assert data["token_type"] == "bearer"
        assert data["expires_in"] > 0

        # Vérifier que l'access_token est un JWT valide (3 parties)
        parts = data["access_token"].split(".")
        assert len(parts) == 3

    async def test_login_wrong_password_returns_401(
        self, client: AsyncClient, user_regular
    ):
        """Mauvais mot de passe doit retourner 401."""
        response = await client.post(
            "/api/v1/auth/login",
            data={
                "username": user_regular.email,
                "password": "WrongPassword@123",
            }
        )
        assert response.status_code == 401
        assert response.json()["code"] == "INVALID_CREDENTIALS"

    async def test_login_nonexistent_email_returns_401(self, client: AsyncClient):
        """Email inexistant doit retourner 401 (même message que mauvais password)."""
        response = await client.post(
            "/api/v1/auth/login",
            data={
                "username": "nobody@taskflow.test",
                "password": "AnyPassword@123",
            }
        )
        # Important : même message que mauvais password -> pas d'énumération d'emails
        assert response.status_code == 401
        assert response.json()["code"] == "INVALID_CREDENTIALS"

    async def test_login_inactive_user_returns_403(
        self, client: AsyncClient, user_inactive
    ):
        """Utilisateur désactivé doit recevoir 403, pas 401."""
        response = await client.post(
            "/api/v1/auth/login",
            data={
                "username": user_inactive.email,
                "password": "Password@123",
            }
        )
        assert response.status_code == 403
        assert response.json()["code"] == "ACCOUNT_DISABLED"

class TestMeAPI:
    """Tests pour GET /api/v1/auth/me."""

    async def test_get_me_authenticated(
        self, auth_client: AsyncClient, user_regular
    ):
        """Un utilisateur authentifié peut récupérer son profil."""
        response = await auth_client.get("/api/v1/auth/me")
        assert response.status_code == 200

        data = response.json()
        assert data["id"] == user_regular.id
        assert data["email"] == user_regular.email
        # Vérifier l'absence de données sensibles
        assert "hashed_password" not in data

    async def test_get_me_unauthenticated_returns_401(self, client: AsyncClient):
        """Sans token, /me doit retourner 401."""
        response = await client.get("/api/v1/auth/me")
        assert response.status_code == 401

    async def test_get_me_invalid_token_returns_401(self, client: AsyncClient):
        """Avec un token invalide, /me doit retourner 401."""
        client.headers["Authorization"] = "Bearer this.is.not.valid"
        response = await client.get("/api/v1/auth/me")
        assert response.status_code == 401

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39.2 TESTS D'INTÉGRATION — CRUD TÂCHES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# tests/integration/test_tasks_api.py
# ═══════════════════════════════════════════════════════════════════════

import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import date, timedelta

from tests.factories import TaskFactory
from app.models.task import Task

class TestCreateTaskAPI:
    """Tests pour POST /api/v1/tasks."""

    async def test_create_task_success(
        self, auth_client: AsyncClient, user_regular
    ):
        """Créer une tâche valide doit retourner 201 avec la tâche."""
        due_date = (date.today() + timedelta(days=7)).isoformat()

        response = await auth_client.post(
            "/api/v1/tasks",
            json={
                "title": "Implémenter le module de paiement",
                "description": "Intégration Stripe",
                "priority": "high",
                "due_date": due_date,
            }
        )
        assert response.status_code == 201

        data = response.json()
        assert data["title"] == "Implémenter le module de paiement"
        assert data["priority"] == "high"
        assert data["completed"] is False
        assert data["status"] == "todo"
        assert data["created_by_id"] == user_regular.id
        # Vérifier que l'ID a été auto-généré
        assert isinstance(data["id"], int)
        assert data["id"] > 0

    async def test_create_task_unauthenticated_returns_401(
        self, client: AsyncClient
    ):
        """Sans token, la création doit retourner 401."""
        response = await client.post(
            "/api/v1/tasks",
            json={"title": "Ma tâche"}
        )
        assert response.status_code == 401

    async def test_create_task_missing_title_returns_422(
        self, auth_client: AsyncClient
    ):
        """Sans titre, la création doit retourner 422."""
        response = await auth_client.post(
            "/api/v1/tasks",
            json={"priority": "high"}   # Pas de title
        )
        assert response.status_code == 422

        data = response.json()
        # Vérifier que l'erreur indique le champ manquant
        assert any("title" in str(e["field"]) for e in data["errors"])

    async def test_create_task_past_due_date_returns_422(
        self, auth_client: AsyncClient
    ):
        """Une date limite dans le passé doit retourner 422."""
        past_date = (date.today() - timedelta(days=1)).isoformat()
        response = await auth_client.post(
            "/api/v1/tasks",
            json={"title": "Tâche", "due_date": past_date}
        )
        assert response.status_code == 422

    async def test_create_task_default_priority_is_medium(
        self, auth_client: AsyncClient
    ):
        """Sans priorité spécifiée, la valeur par défaut doit être 'medium'."""
        response = await auth_client.post(
            "/api/v1/tasks",
            json={"title": "Tâche sans priorité explicite"}
        )
        assert response.status_code == 201
        assert response.json()["priority"] == "medium"

class TestListTasksAPI:
    """Tests pour GET /api/v1/tasks."""

    @pytest_asyncio.fixture
    async def sample_tasks(self, db_session: AsyncSession, user_regular):
        """Crée 5 tâches de test dans la DB."""
        tasks = []
        for _ in range(5):
            task = Task(**TaskFactory.build(
                created_by_id=user_regular.id
            ).__dict__)
            db_session.add(task)
            tasks.append(task)
        await db_session.flush()
        return tasks

    async def test_list_tasks_returns_paginated_results(
        self, auth_client: AsyncClient, sample_tasks
    ):
        """GET /tasks doit retourner une liste paginée."""
        response = await auth_client.get("/api/v1/tasks")
        assert response.status_code == 200

        data = response.json()
        assert "data" in data
        assert "pagination" in data
        assert isinstance(data["data"], list)
        assert len(data["data"]) <= 20   # Limite par défaut

        pagination = data["pagination"]
        assert "total" in pagination
        assert "page" in pagination
        assert "per_page" in pagination
        assert "total_pages" in pagination

    async def test_list_tasks_pagination(
        self, auth_client: AsyncClient, sample_tasks
    ):
        """Les paramètres de pagination doivent fonctionner."""
        response = await auth_client.get("/api/v1/tasks?page=1&per_page=2")
        assert response.status_code == 200

        data = response.json()
        assert len(data["data"]) <= 2
        assert data["pagination"]["per_page"] == 2

    async def test_list_tasks_filter_by_completed(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """Le filtre ?completed=true doit retourner seulement les tâches complètes."""
        # Créer 2 complètes et 3 non-complètes
        for i in range(2):
            db_session.add(Task(
                title=f"Complète {i}", completed=True,
                status="done", priority="medium",
                created_by_id=user_regular.id
            ))
        for i in range(3):
            db_session.add(Task(
                title=f"En cours {i}", completed=False,
                status="todo", priority="medium",
                created_by_id=user_regular.id
            ))
        await db_session.flush()

        response = await auth_client.get("/api/v1/tasks?completed=true")
        assert response.status_code == 200

        data = response.json()
        assert all(t["completed"] is True for t in data["data"])

    async def test_list_tasks_unauthenticated_returns_401(
        self, client: AsyncClient
    ):
        """Sans token, la liste doit retourner 401."""
        response = await client.get("/api/v1/tasks")
        assert response.status_code == 401

class TestGetTaskAPI:
    """Tests pour GET /api/v1/tasks/{id}."""

    async def test_get_existing_task(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """Récupérer une tâche existante doit retourner 200."""
        task = Task(
            title="Ma tâche de test", priority="high",
            status="todo", completed=False,
            created_by_id=user_regular.id,
        )
        db_session.add(task)
        await db_session.flush()

        response = await auth_client.get(f"/api/v1/tasks/{task.id}")
        assert response.status_code == 200
        assert response.json()["id"] == task.id
        assert response.json()["title"] == "Ma tâche de test"

    async def test_get_nonexistent_task_returns_404(
        self, auth_client: AsyncClient
    ):
        """Récupérer une tâche inexistante doit retourner 404."""
        response = await auth_client.get("/api/v1/tasks/999999")
        assert response.status_code == 404
        assert response.json()["code"] == "TASK_NOT_FOUND"

    async def test_get_task_invalid_id_returns_422(
        self, auth_client: AsyncClient
    ):
        """Un ID non-entier doit retourner 422."""
        response = await auth_client.get("/api/v1/tasks/not-an-integer")
        assert response.status_code == 422

class TestUpdateTaskAPI:
    """Tests pour PATCH /api/v1/tasks/{id}."""

    async def test_update_task_partial(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """PATCH ne doit modifier que les champs fournis."""
        task = Task(
            title="Titre Original", description="Description Originale",
            priority="low", status="todo", completed=False,
            created_by_id=user_regular.id,
        )
        db_session.add(task)
        await db_session.flush()

        response = await auth_client.patch(
            f"/api/v1/tasks/{task.id}",
            json={"title": "Nouveau Titre"}
        )
        assert response.status_code == 200

        data = response.json()
        assert data["title"] == "Nouveau Titre"
        # Les autres champs doivent être inchangés
        assert data["description"] == "Description Originale"
        assert data["priority"] == "low"

    async def test_update_task_of_another_user_returns_403(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_admin
    ):
        """Un utilisateur ne peut pas modifier la tâche d'un autre."""
        # Créer une tâche qui appartient à user_admin (pas à user_regular)
        task = Task(
            title="Tâche de l'admin", priority="medium",
            status="todo", completed=False,
            created_by_id=user_admin.id,   # Appartient à l'admin
        )
        db_session.add(task)
        await db_session.flush()

        # auth_client est authentifié en tant que user_regular
        response = await auth_client.patch(
            f"/api/v1/tasks/{task.id}",
            json={"title": "Tentative de modification"}
        )
        assert response.status_code == 403

class TestDeleteTaskAPI:
    """Tests pour DELETE /api/v1/tasks/{id}."""

    async def test_delete_own_task_returns_204(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """Supprimer sa propre tâche doit retourner 204 No Content."""
        task = Task(
            title="Tâche à supprimer", priority="medium",
            status="todo", completed=False,
            created_by_id=user_regular.id,
        )
        db_session.add(task)
        await db_session.flush()
        task_id = task.id

        response = await auth_client.delete(f"/api/v1/tasks/{task_id}")
        assert response.status_code == 204
        assert response.content == b""   # Pas de corps dans la réponse 204

        # Vérifier que la tâche est bien soft-deleted
        response_get = await auth_client.get(f"/api/v1/tasks/{task_id}")
        assert response_get.status_code == 404   # Plus accessible

    async def test_delete_nonexistent_task_returns_404(
        self, auth_client: AsyncClient
    ):
        """Supprimer une tâche inexistante doit retourner 404."""
        response = await auth_client.delete("/api/v1/tasks/999999")
        assert response.status_code == 404

class TestCompleteTaskAPI:
    """Tests pour POST /api/v1/tasks/{id}/complete."""

    async def test_complete_task_success(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """Compléter une tâche active doit la marquer comme done."""
        task = Task(
            title="Tâche à compléter", priority="high",
            status="in_progress", completed=False,
            created_by_id=user_regular.id,
        )
        db_session.add(task)
        await db_session.flush()

        response = await auth_client.post(f"/api/v1/tasks/{task.id}/complete")
        assert response.status_code == 200

        data = response.json()
        assert data["completed"] is True
        assert data["status"] == "done"

    async def test_complete_already_done_task_returns_409(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """Compléter une tâche déjà complète doit retourner 409 Conflict."""
        task = Task(
            title="Déjà complète", priority="medium",
            status="done", completed=True,
            created_by_id=user_regular.id,
        )
        db_session.add(task)
        await db_session.flush()

        response = await auth_client.post(f"/api/v1/tasks/{task.id}/complete")
        assert response.status_code == 409
        assert response.json()["code"] == "TASK_ALREADY_COMPLETED"

================================================================================
                  CHAPITRE 40 — TDD : TEST DRIVEN DEVELOPMENT
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.1 LE CYCLE TDD : RED -> GREEN -> REFACTOR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TDD (Test Driven Development) = écrire le test AVANT le code.

Cycle :
  [ROUGE] RED    -> Écrire un test qui ÉCHOUE (le code n'existe pas encore)
  [VERT] GREEN  -> Écrire le minimum de code pour faire PASSER le test
  [BLEU] REFACTOR -> Améliorer le code SANS casser les tests

Bénéfices :
  - Design orienté par l'usage -> API plus claire
  - 100% des nouvelles fonctionnalités testées dès le départ
  - Confidence totale lors du refactoring
  - Documentation automatique des comportements attendus

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.2 EXEMPLE TDD COMPLET — Fonctionnalité "Archiver une tâche"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Objectif : Ajouter la fonctionnalité "archiver une tâche".
Une tâche archivée n'apparaît plus dans les listes normales,
mais est accessible via /tasks/archived.

# ════════════════════════════════════
# ÉTAPE 1 — [ROUGE] RED : Écrire le test
# ════════════════════════════════════

# tests/integration/test_tasks_archive.py

import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.task import Task

class TestArchiveTask:
    """
    TDD : Tests écrits AVANT l'implémentation.
    Ces tests échoueront d'abord -> c'est voulu !
    """

    async def test_archive_task_returns_200(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """
        [ROUGE] RED : POST /tasks/{id}/archive doit retourner 200.
        Ce test ÉCHOUE car l'endpoint n'existe pas encore.
        """
        task = Task(
            title="Tâche à archiver", priority="medium",
            status="done", completed=True,
            created_by_id=user_regular.id,
        )
        db_session.add(task)
        await db_session.flush()

        response = await auth_client.post(f"/api/v1/tasks/{task.id}/archive")
        # Ce test échoue avec 404 "route not found" -> on doit implémenter
        assert response.status_code == 200
        assert response.json()["archived"] is True

    async def test_archived_task_hidden_from_list(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """
        [ROUGE] RED : Une tâche archivée ne doit pas apparaître dans GET /tasks.
        """
        task = Task(
            title="Tâche archivée", priority="low",
            status="done", completed=True,
            archived=True,                    # champ qui n'existe pas encore !
            created_by_id=user_regular.id,
        )
        db_session.add(task)
        await db_session.flush()

        response = await auth_client.get("/api/v1/tasks")
        tasks = response.json()["data"]

        # La tâche archivée NE DOIT PAS apparaître
        assert all(t["id"] != task.id for t in tasks)

    async def test_get_archived_tasks(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """
        [ROUGE] RED : GET /tasks/archived doit retourner les tâches archivées.
        """
        task = Task(
            title="Tâche archivée", priority="low",
            status="done", completed=True,
            created_by_id=user_regular.id,
        )
        db_session.add(task)
        await db_session.flush()

        # Archiver d'abord
        await auth_client.post(f"/api/v1/tasks/{task.id}/archive")

        # Récupérer les archivées
        response = await auth_client.get("/api/v1/tasks/archived")
        assert response.status_code == 200
        archived_ids = [t["id"] for t in response.json()["data"]]
        assert task.id in archived_ids

    async def test_cannot_archive_incomplete_task(
        self, auth_client: AsyncClient, db_session: AsyncSession, user_regular
    ):
        """
        [ROUGE] RED : Archiver une tâche non-complète doit retourner 400.
        Règle métier : seules les tâches complètes peuvent être archivées.
        """
        task = Task(
            title="Tâche en cours", priority="high",
            status="in_progress", completed=False,   # Non complète
            created_by_id=user_regular.id,
        )
        db_session.add(task)
        await db_session.flush()

        response = await auth_client.post(f"/api/v1/tasks/{task.id}/archive")
        assert response.status_code == 400
        assert response.json()["code"] == "TASK_NOT_COMPLETED"

# ════════════════════════════════════
# ÉTAPE 2 — [VERT] GREEN : Implémenter
# ════════════════════════════════════

# app/models/task.py — Ajouter le champ archived
# archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

# app/core/exceptions.py — Nouvelle exception
# class TaskNotCompletedError(ConflictError):
#     error_code = "TASK_NOT_COMPLETED"
#     def __init__(self, task_id: int):
#         super().__init__(message=f"La tâche {task_id} doit être complète avant d'être archivée")

# app/services/task_service.py — Nouvelle méthode
#
# async def archive(self, task_id: int, user_id: int) -> Task:
#     task = await self.get_or_raise(task_id, user_id)
#     if not task.completed:
#         raise TaskNotCompletedError(task_id)
#     if task.archived:
#         raise ConflictError(message="La tâche est déjà archivée")
#     task.archived = True
#     task.archived_at = datetime.utcnow()
#     await self.db.flush()
#     return task

# app/api/v1/tasks.py — Nouveaux endpoints
#
# @router.post("/{task_id}/archive", response_model=TaskResponse)
# async def archive_task(task_id: TaskId, db: DBSession, current_user: CurrentUser):
#     service = TaskService(db)
#     return await service.archive(task_id, user_id=current_user.id)
#
# @router.get("/archived", response_model=TaskListResponse)
# async def list_archived_tasks(db: DBSession, current_user: CurrentUser, ...):
#     service = TaskService(db)
#     # Utiliser un filtre archived=True
#     ...

# Aussi mettre à jour list_with_filters pour exclure les archivées par défaut

# ════════════════════════════════════
# ÉTAPE 3 — [BLEU] REFACTOR : Améliorer
# ════════════════════════════════════

# Une fois les tests verts :
# - Mutualiser la logique d'archivage si d'autres ressources peuvent être archivées
# - Ajouter un index sur la colonne "archived" pour les requêtes fréquentes
# - Ajouter les tests de performance si nécessaire

================================================================================
                  CHAPITRE 40 SUITE — COVERAGE ET COMMANDES
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.3 COMMANDES PYTEST ESSENTIELLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Lancer tous les tests
  pytest

  # Lancer avec affichage verbeux
  pytest -v

  # Lancer un fichier spécifique
  pytest tests/integration/test_auth_api.py

  # Lancer une classe ou méthode spécifique
  pytest tests/integration/test_auth_api.py::TestLoginAPI
  pytest tests/integration/test_auth_api.py::TestLoginAPI::test_login_success_returns_tokens

  # Lancer uniquement les tests unitaires (marker)
  pytest -m unit

  # Lancer uniquement les tests d'intégration
  pytest -m integration

  # Lancer en ignorant les tests lents
  pytest -m "not slow"

  # Arrêter au premier échec
  pytest -x

  # Afficher les 3 tests les plus lents
  pytest --durations=3

  # Lancer en parallèle (pip install pytest-xdist)
  pytest -n auto    # Utiliser tous les cœurs CPU

  # Couverture de code
  pytest --cov=app --cov-report=html
  # -> Ouvre htmlcov/index.html dans le navigateur

  # Voir les lignes non couvertes
  pytest --cov=app --cov-report=term-missing

  # Générer un rapport XML (pour CI/CD)
  pytest --cov=app --cov-report=xml:coverage.xml

  # Relancer seulement les tests qui ont échoué
  pytest --lf     # last-failed

  # Relancer les tests échoués + nouveaux
  pytest --ff     # failed-first

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.4 AMÉLIORER LA COUVERTURE — STRATÉGIE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ── Lire le rapport de couverture ─────────────────────────────────────

  # Après pytest --cov=app --cov-report=term-missing :
  #
  # Name                            Stmts   Miss  Cover   Missing
  # ─────────────────────────────────────────────────────────────
  # app/core/security.py               45      3    93%   87, 92, 105
  # app/services/task_service.py      120     24    80%   45-67, 89
  # app/api/v1/tasks.py                80      5    94%   112-116
  # ─────────────────────────────────────────────────────────────
  # TOTAL                             580     55    91%

  # Les lignes 87, 92, 105 de security.py ne sont pas testées.
  # On ajoute des tests pour ces cas.

# ── Exclure certains fichiers du coverage ─────────────────────────────

  # .coveragerc ou dans pyproject.toml :
  # [tool.coverage.run]
  # omit = [
  #     "*/migrations/*",
  #     "*/tests/*",
  #     "app/main.py",          # Config/bootstrap difficile à tester unitairement
  #     "*/alembic/*",
  # ]

  # Exclure des lignes spécifiques du rapport :
  if __name__ == "__main__":          # pragma: no cover
      uvicorn.run(app, host="0.0.0.0")

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.5 EXERCICES — PARTIE 7
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 40.1 : Écris des tests unitaires pour verify_password() qui couvrent :
  a) Mot de passe correct -> True
  b) Mot de passe incorrect -> False
  c) String vide -> False
  d) Hash invalide (pas bcrypt) -> comportement ?
  Lance-les avec pytest -v et vérifie qu'ils passent.

Ex 40.2 : Écris un test d'intégration qui vérifie qu'après un PATCH
  sur une tâche (changer le titre), GET sur cette même tâche retourne
  bien le nouveau titre. Teste la persistance réelle en DB.

Ex 40.3 : Utilise @pytest.mark.parametrize pour tester 5 emails
  invalides différents sur l'endpoint d'inscription, avec un seul
  bloc de code de test.

NIVEAU INTERMÉDIAIRE
--------------------
Ex 40.4 : Crée une factory UserFactory avec Factory Boy qui génère
  des utilisateurs réalistes. Ajoute des sous-factories :
  AdminUserFactory, InactiveUserFactory, SuperUserFactory.
  Intègre-les dans conftest.py.

Ex 40.5 : Écris des tests d'intégration complets pour le système
  d'assignation de tâches : POST /tasks/{id}/assign?assignee_id=X.
  Tester : succès, utilisateur inexistant, utilisateur inactif,
  non-propriétaire de la tâche.

Ex 40.6 : Implémente le test TDD complet de la fonctionnalité
  "archivage de tâches" décrit dans le chapitre 40.2 :
  a) Écrire les 4 tests (RED)
  b) Implémenter la fonctionnalité (GREEN)
  c) Vérifier que les tests passent
  d) Refactoriser si nécessaire (REFACTOR)

NIVEAU AVANCÉ
-------------
Ex 40.7 : Implémente des tests de charge légers avec pytest-benchmark :
  benchmark la fonction hash_password() pour mesurer sa performance.
  Ajoute une assertion : le hash ne doit pas prendre plus de 500ms.

Ex 40.8 : Crée un test de sécurité automatisé qui vérifie que :
  a) Aucun endpoint ne retourne hashed_password dans la réponse
  b) Les tokens JWT contiennent bien les champs requis (sub, jti, exp)
  c) Les headers de sécurité (X-Content-Type-Options, etc.) sont présents

Ex 40.9 : Implémente un test de régression complet qui simule
  le workflow entier : register -> login -> create task -> assign ->
  complete -> delete. Chaque étape vérifie le bon enchaînement.

================================================================================
                         RÉCAPITULATIF PARTIE 7
================================================================================

Dans cette partie, tu as appris :

[OK] Configuration pytest-asyncio : asyncio_mode="auto", .env.test
[OK] conftest.py : DB SQLite in-memory, rollback par test, fixtures réutilisables
[OK] Fixtures : db_session, client, auth_client, user_regular, user_admin
[OK] Factory Boy : UserFactory, TaskFactory, données réalistes avec Faker
[OK] Tests unitaires : security.py, services avec mocks (AsyncMock)
[OK] Tests d'intégration : register, login, CRUD tâches, auth flows complets
[OK] @pytest.mark.parametrize : tester plusieurs cas avec un seul test
[OK] TDD Red-Green-Refactor : cycle complet avec exemple d'archivage
[OK] Coverage : commandes, rapport HTML, exclusions, seuil minimum
[OK] Commandes pytest : filtres, parallélisme, last-failed, durations

[RAPIDE] PROCHAINE ÉTAPE : Partie 8 — Performance & Déploiement
   - Caching avec Redis (TTL, invalidation)
   - Optimisation des requêtes SQLAlchemy (N+1, eager loading)
   - Docker multi-stage pour la production
   - Docker Compose complet (API + PostgreSQL + Redis + Nginx)
   - Variables d'environnement de production

================================================================================
                           FIN DE LA PARTIE 7
                    Passe à fastapi_master_part_8.txt
================================================================================

================================================================================
  GUIDE FASTAPI COMPLET — PARTIE 8 : PERFORMANCE, DOCKER & DÉPLOIEMENT
  Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FIL ROUGE : TaskFlow API
   Dans cette partie, nous passons TaskFlow en mode PRODUCTION :
   - Caching Redis avec TTL et invalidation intelligente
   - Optimisation des requêtes SQLAlchemy (N+1, eager loading, index)
   - Docker multi-stage pour une image légère et sécurisée
   - Docker Compose complet (API + PostgreSQL + Redis + Nginx)
   - Nginx reverse proxy avec HTTPS/TLS (Let's Encrypt)
   - Variables d'environnement de production sécurisées

================================================================================
         CHAPITRE 41 — CACHING AVEC REDIS
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.1 POURQUOI CACHER ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Sans cache :
  Chaque requête -> SELECT PostgreSQL -> réseau -> parsing -> sérialisation
  Coût typique d'une requête DB : 5 ms à 50 ms
  Si 1000 requêtes/seconde identiques -> 1000 × 50ms de charge DB

Avec cache Redis :
  1ère requête -> DB -> stocker en Redis (TTL 60s)
  Requêtes 2 à N -> Redis (< 1ms) -> pas de DB sollicitée
  Gain : 50x à 1000x plus rapide pour les données en cache

Quoi cacher dans TaskFlow ?
  [OK] Résultats de listes fréquentes (GET /tasks avec les mêmes filtres)
  [OK] Profils utilisateurs (peu changent souvent)
  [OK] Statistiques agrégées (recalcul coûteux)
  [OK] Résultats de recherche (souvent les mêmes queries)
  [OK] Configurations système (feature flags, paramètres)
  [OK] Rate limiting counters
  [OK] Sessions / blacklist de tokens JWT

  [X] Ne pas cacher : données qui changent en temps réel, données personnelles
     très sensibles sans TTL court

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.2 REDIS — INSTALLATION ET CONFIGURATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Dépendances
  pip install redis[hiredis]
  # redis       -> client Python async pour Redis
  # [hiredis]   -> parser C ultra-rapide (10x plus vite que pur Python)

  # Redis avec Docker (développement)
  # docker-compose.yml — service Redis
  # redis:
  #   image: redis:7-alpine
  #   ports: ["6379:6379"]
  #   command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
  #   volumes: [redis_data:/data]
  #
  # maxmemory 512mb         -> limite la mémoire
  # maxmemory-policy lru    -> éviction LRU quand mémoire pleine
  # appendonly yes          -> persistance sur disque (AOF)

# ═══════════════════════════════════════════════════════════════════════
# app/infrastructure/cache.py — Client Redis centralisé
# ═══════════════════════════════════════════════════════════════════════

import json
import hashlib
from typing import Any, Optional, Callable, TypeVar
from functools import wraps
import redis.asyncio as redis
from redis.asyncio import Redis
import logging

from app.config import get_settings

settings = get_settings()
logger = logging.getLogger("taskflow.cache")

# ── Instance Redis globale (créée une seule fois) ─────────────────────
_redis_client: Redis | None = None

async def get_redis() -> Redis:
    """
    Retourne le client Redis global (lazy initialization).
    Utilise un pool de connexions pour la concurrence.
    """
    global _redis_client
    if _redis_client is None:
        _redis_client = redis.from_url(
            settings.redis_url,
            encoding="utf-8",
            decode_responses=True,      # Retourner des strings Python, pas bytes
            max_connections=20,         # Pool de 20 connexions max
            socket_connect_timeout=5,   # Timeout connexion 5s
            socket_timeout=5,           # Timeout opération 5s
            retry_on_timeout=True,      # Retry automatique en cas de timeout
        )
    return _redis_client

async def close_redis():
    """Fermer le client Redis proprement (à appeler au shutdown)."""
    global _redis_client
    if _redis_client:
        await _redis_client.aclose()
        _redis_client = None

# ── Classe de cache générique ─────────────────────────────────────────

class CacheService:
    """
    Service de cache Redis générique pour TaskFlow.
    Fournit des méthodes de haut niveau pour get/set/delete/invalidate.

    Convention de nommage des clés :
    {namespace}:{identifiant}:{sous-identifiant}
    Exemples :
      tasks:list:page=1&per_page=20&user_id=42
      tasks:detail:42
      users:profile:7
      stats:tasks:user=42
    """

    # TTL par défaut par type de données (en secondes)
    TTL_SHORT  = 60        # 1 minute  : données qui changent souvent
    TTL_MEDIUM = 300       # 5 minutes : données semi-stables
    TTL_LONG   = 3600      # 1 heure   : données stables
    TTL_DAY    = 86400     # 24 heures : données très stables (config)

    def __init__(self, client: Redis):
        self.client = client

    # ── Opérations de base ────────────────────────────────────────────

    async def get(self, key: str) -> Any | None:
        """
        Récupère une valeur depuis le cache.
        Retourne None si la clé n'existe pas ou est expirée.
        Les données sont désérialisées depuis JSON automatiquement.
        """
        try:
            raw = await self.client.get(key)
            if raw is None:
                logger.debug(f"CACHE MISS: {key}")
                return None
            logger.debug(f"CACHE HIT:  {key}")
            return json.loads(raw)
        except Exception as e:
            # Le cache ne doit JAMAIS faire planter l'application
            # Si Redis est down -> on continue sans cache (dégradé gracieux)
            logger.error(f"Erreur Redis GET {key}: {e}")
            return None

    async def set(
        self,
        key: str,
        value: Any,
        ttl: int = TTL_MEDIUM,
    ) -> bool:
        """
        Stocke une valeur dans le cache avec un TTL (Time To Live).

        value : n'importe quel objet sérialisable en JSON
        ttl   : durée de vie en secondes (ex: 300 = 5 minutes)

        Retourne True si succès, False si erreur.
        """
        try:
            serialized = json.dumps(value, default=str)
            # setex = SET + EXpire en une seule commande atomique
            await self.client.setex(key, ttl, serialized)
            logger.debug(f"CACHE SET:  {key} (TTL={ttl}s)")
            return True
        except Exception as e:
            logger.error(f"Erreur Redis SET {key}: {e}")
            return False

    async def delete(self, key: str) -> bool:
        """Supprime une clé du cache (invalidation directe)."""
        try:
            deleted = await self.client.delete(key)
            if deleted:
                logger.debug(f"CACHE DEL:  {key}")
            return bool(deleted)
        except Exception as e:
            logger.error(f"Erreur Redis DEL {key}: {e}")
            return False

    async def delete_pattern(self, pattern: str) -> int:
        """
        Supprime toutes les clés correspondant à un pattern.
        Utilise SCAN (non-bloquant) plutôt que KEYS (bloquant).

        Exemple : delete_pattern("tasks:list:*")
        -> supprime toutes les clés de liste de tâches en cache

        ATTENTION : SCAN peut être lent sur de très grands volumes.
        Pour des patterns précis -> préférer delete() directe.
        """
        try:
            count = 0
            # SCAN est non-bloquant (itère par batches de cursor)
            async for key in self.client.scan_iter(match=pattern, count=100):
                await self.client.delete(key)
                count += 1
            if count > 0:
                logger.debug(f"CACHE DEL PATTERN: {pattern} ({count} clés)")
            return count
        except Exception as e:
            logger.error(f"Erreur Redis SCAN {pattern}: {e}")
            return 0

    async def exists(self, key: str) -> bool:
        """Vérifie si une clé existe en cache (sans la récupérer)."""
        try:
            return bool(await self.client.exists(key))
        except Exception:
            return False

    async def ttl_remaining(self, key: str) -> int:
        """
        Retourne le TTL restant en secondes.
        -1 = clé existe sans TTL (persistante)
        -2 = clé n'existe pas
        """
        try:
            return await self.client.ttl(key)
        except Exception:
            return -2

    # ── Pattern Cache-Aside ───────────────────────────────────────────

    async def get_or_set(
        self,
        key: str,
        fetch_fn: Callable,
        ttl: int = TTL_MEDIUM,
    ) -> Any:
        """
        Pattern Cache-Aside (le plus courant) :
        1. Chercher en cache -> si trouvé, retourner
        2. Si manquant -> appeler fetch_fn() pour chercher en DB
        3. Stocker le résultat en cache
        4. Retourner le résultat

        Usage :
          stats = await cache.get_or_set(
              key="stats:tasks:all",
              fetch_fn=lambda: task_service.get_statistics(),
              ttl=CacheService.TTL_MEDIUM,
          )
        """
        # 1. Chercher en cache
        cached = await self.get(key)
        if cached is not None:
            return cached

        # 2. Cache miss -> chercher en DB
        result = await fetch_fn()

        # 3. Stocker en cache (si résultat non vide)
        if result is not None:
            await self.set(key, result, ttl)

        return result

    # ── Génération de clés de cache ───────────────────────────────────

    @staticmethod
    def make_key(*parts: str | int) -> str:
        """
        Génère une clé de cache à partir de plusieurs parties.
        Exemples :
          make_key("tasks", "list", "page=1", "user=42")
          -> "tasks:list:page=1:user=42"
        """
        return ":".join(str(p) for p in parts)

    @staticmethod
    def make_hash_key(namespace: str, params: dict) -> str:
        """
        Génère une clé de cache à partir d'un dict de paramètres.
        Utile pour les requêtes avec beaucoup de filtres.

        make_hash_key("tasks:list", {"page": 1, "priority": "high", "user_id": 42})
        -> "tasks:list:a3f5b2c8" (hash MD5 des params triés)
        """
        # Trier les params pour que l'ordre n'importe pas
        sorted_params = json.dumps(params, sort_keys=True)
        hash_suffix = hashlib.md5(sorted_params.encode()).hexdigest()[:8]
        return f"{namespace}:{hash_suffix}"

# ── Dépendance FastAPI pour injecter le cache ─────────────────────────

async def get_cache() -> CacheService:
    """Dépendance FastAPI pour injecter le service de cache."""
    client = await get_redis()
    return CacheService(client)

# Type annoté pour l'injection
from typing import Annotated
from fastapi import Depends
Cache = Annotated[CacheService, Depends(get_cache)]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.3 STRATÉGIES D'INVALIDATION DU CACHE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'invalidation de cache est le problème le plus difficile en informatique.
Stratégie employée dans TaskFlow : invalidation à l'écriture.

Règle : quand une donnée change -> supprimer les clés de cache concernées.

# ═══════════════════════════════════════════════════════════════════════
# app/services/task_service.py — Intégration cache dans le service
# ═══════════════════════════════════════════════════════════════════════

from app.infrastructure.cache import CacheService

class TaskService:
    """Service tâches avec cache Redis intégré."""

    # Préfixes de clés de cache pour les tâches
    CACHE_DETAIL  = "tasks:detail"    # tasks:detail:{id}
    CACHE_LIST    = "tasks:list"      # tasks:list:{hash_params}
    CACHE_STATS   = "tasks:stats"     # tasks:stats:{user_id}

    def __init__(self, db: AsyncSession, cache: CacheService | None = None):
        self.task_repo = TaskRepository(db)
        self.user_repo = UserRepository(db)
        self.db = db
        self.cache = cache   # Optionnel : si None, pas de cache

    # ── GET avec cache ────────────────────────────────────────────────

    async def get_by_id_cached(self, task_id: int) -> Task | None:
        """
        Récupère une tâche avec cache-aside.
        Cache TTL = 5 minutes (les tâches changent peu souvent).
        """
        if not self.cache:
            return await self.task_repo.get_by_id_with_relations(task_id)

        cache_key = CacheService.make_key(self.CACHE_DETAIL, task_id)

        async def fetch_from_db():
            task = await self.task_repo.get_by_id_with_relations(task_id)
            if task is None:
                return None
            # Convertir l'objet SQLAlchemy en dict sérialisable
            # (SQLAlchemy objects ne sont pas directement JSON-serialisables)
            return self._task_to_dict(task)

        cached_data = await self.cache.get_or_set(
            key=cache_key,
            fetch_fn=fetch_from_db,
            ttl=CacheService.TTL_MEDIUM,   # 5 minutes
        )

        return cached_data   # dict (ou None)

    async def list_cached(self, **filters) -> tuple[list, int]:
        """Liste des tâches avec cache basé sur les filtres."""
        if not self.cache:
            return await self.task_repo.find_with_filters(**filters)

        # Générer une clé unique basée sur les filtres
        cache_key = CacheService.make_hash_key(self.CACHE_LIST, filters)

        async def fetch():
            tasks, total = await self.task_repo.find_with_filters(**filters)
            return {
                "tasks": [self._task_to_dict(t) for t in tasks],
                "total": total,
            }

        result = await self.cache.get_or_set(
            key=cache_key,
            fetch_fn=fetch,
            ttl=CacheService.TTL_SHORT,   # 1 minute (listes changent plus souvent)
        )

        return result["tasks"], result["total"]

    async def get_statistics_cached(self, user_id: int | None = None) -> dict:
        """Statistiques avec cache long (les stats sont coûteuses à calculer)."""
        if not self.cache:
            return await self.task_repo.aggregate_stats(user_id)

        cache_key = CacheService.make_key(self.CACHE_STATS, user_id or "all")

        return await self.cache.get_or_set(
            key=cache_key,
            fetch_fn=lambda: self.task_repo.aggregate_stats(user_id),
            ttl=CacheService.TTL_MEDIUM,   # 5 minutes
        )

    # ── Invalidation à l'écriture ─────────────────────────────────────

    async def create(self, data: TaskCreate, created_by_id: int) -> Task:
        """Crée une tâche et invalide les caches concernés."""
        task = await self._create_in_db(data, created_by_id)

        if self.cache:
            # Invalider toutes les listes en cache
            # (la nouvelle tâche doit apparaître dans les résultats)
            await self.cache.delete_pattern(f"{self.CACHE_LIST}:*")
            # Invalider les stats (le total a changé)
            await self.cache.delete_pattern(f"{self.CACHE_STATS}:*")

        return task

    async def update(self, task_id: int, data: TaskUpdate, user_id: int) -> Task:
        """Met à jour une tâche et invalide son cache."""
        task = await self._update_in_db(task_id, data, user_id)

        if self.cache:
            # Invalider le cache de cette tâche précise
            await self.cache.delete(
                CacheService.make_key(self.CACHE_DETAIL, task_id)
            )
            # Invalider toutes les listes (le contenu de la tâche a changé)
            await self.cache.delete_pattern(f"{self.CACHE_LIST}:*")
            # Invalider les stats si le statut a changé
            if data.status or data.completed is not None:
                await self.cache.delete_pattern(f"{self.CACHE_STATS}:*")

        return task

    async def soft_delete(self, task_id: int, user_id: int) -> None:
        """Supprime une tâche et invalide tous les caches liés."""
        await self._soft_delete_in_db(task_id, user_id)

        if self.cache:
            await self.cache.delete(
                CacheService.make_key(self.CACHE_DETAIL, task_id)
            )
            await self.cache.delete_pattern(f"{self.CACHE_LIST}:*")
            await self.cache.delete_pattern(f"{self.CACHE_STATS}:*")

    def _task_to_dict(self, task: Task) -> dict:
        """Convertit un objet Task SQLAlchemy en dict JSON-serialisable."""
        return {
            "id": task.id,
            "title": task.title,
            "description": task.description,
            "status": task.status.value if task.status else None,
            "priority": task.priority.value if task.priority else None,
            "completed": task.completed,
            "due_date": task.due_date.isoformat() if task.due_date else None,
            "created_at": task.created_at.isoformat() if task.created_at else None,
            "updated_at": task.updated_at.isoformat() if task.updated_at else None,
            "created_by_id": task.created_by_id,
            "assigned_to_id": task.assigned_to_id,
        }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41.4 DÉCORATEUR @cached — CACHE AUTOMATIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Décorateur pour mettre en cache les fonctions async automatiquement
# ═══════════════════════════════════════════════════════════════════════

import functools
import inspect

def cached(ttl: int = 300, key_prefix: str = ""):
    """
    Décorateur qui met en cache le retour d'une fonction async.

    Usage :
        @cached(ttl=60, key_prefix="user_profile")
        async def get_user_profile(user_id: int) -> dict:
            return await db.fetch_user(user_id)

    La clé de cache est générée à partir du préfixe + les arguments.
    """
    def decorator(func: Callable):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            # Récupérer le client Redis
            try:
                client = await get_redis()
                cache = CacheService(client)
            except Exception:
                # Redis indisponible -> appel direct sans cache
                return await func(*args, **kwargs)

            # Générer la clé de cache à partir des arguments
            prefix = key_prefix or f"{func.__module__}.{func.__qualname__}"
            key_data = {"args": args, "kwargs": kwargs}
            cache_key = CacheService.make_hash_key(prefix, key_data)

            # Chercher en cache
            cached_result = await cache.get(cache_key)
            if cached_result is not None:
                return cached_result

            # Exécuter la fonction et mettre en cache
            result = await func(*args, **kwargs)
            if result is not None:
                await cache.set(cache_key, result, ttl)

            return result
        return wrapper
    return decorator

# Utilisation :
# @cached(ttl=300, key_prefix="tasks:stats")
# async def compute_heavy_statistics(project_id: int) -> dict:
#     # Calcul coûteux...
#     return stats

================================================================================
          CHAPITRE 40 — OPTIMISATION SQLALCHEMY (N+1, EAGER LOADING)
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.1 LE PROBLÈME N+1 — LA BÊTE NOIRE DES ORM
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le problème N+1 est l'une des causes les plus fréquentes de lenteur
dans les applications utilisant un ORM.

Scénario : retourner 20 tâches avec leur assignataire.

  [X] PROBLÈME N+1 :
  1 requête -> SELECT 20 tâches
  + 20 requêtes -> SELECT user WHERE id = {assigned_to_id pour chaque tâche}
  = 21 requêtes pour afficher 20 tâches !
  Avec 100 tâches -> 101 requêtes. Avec 1000 -> 1001 requêtes. Désastreux.

  [OK] SOLUTION : eager loading (chargement en avance des relations)
  2 requêtes :
  1. SELECT 20 tâches
  2. SELECT users WHERE id IN (1, 4, 7, 12, ...) — tous les assignataires d'un coup

# ═══════════════════════════════════════════════════════════════════════
# Démonstration N+1 vs eager loading
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload, subqueryload

# [X] MAUVAIS : déclenchera N+1 queries dès qu'on accède à task.assignee
async def get_tasks_bad(db: AsyncSession) -> list[Task]:
    result = await db.execute(select(Task).limit(20))
    tasks = result.scalars().all()
    # DANGER : chaque accès à task.assignee ci-dessous lance 1 SELECT !
    # for task in tasks:
    #     print(task.assignee.name)  <- N+1 ici !
    return tasks

# [OK] BON : selectinload — 2 requêtes au total (meilleur pour les listes)
async def get_tasks_selectinload(db: AsyncSession) -> list[Task]:
    """
    selectinload : charge les relations avec une 2ème requête IN().
    SELECT tasks...
    SELECT users WHERE users.id IN (1, 2, 3, ...)
    -> 2 requêtes, jamais N+1.
    Idéal pour : collections (one-to-many, many-to-many).
    """
    result = await db.execute(
        select(Task)
        .limit(20)
        .options(
            selectinload(Task.assignee),    # Charge l'assignataire
            selectinload(Task.creator),     # Charge le créateur
            selectinload(Task.comments),    # Charge les commentaires
        )
    )
    return result.scalars().all()

# [OK] BON : joinedload — 1 seule requête avec JOIN (meilleur pour many-to-one)
async def get_tasks_joinedload(db: AsyncSession) -> list[Task]:
    """
    joinedload : charge les relations avec un JOIN SQL.
    SELECT tasks JOIN users ON tasks.assigned_to_id = users.id
    -> 1 requête avec plus de données.
    Idéal pour : relations many-to-one (chaque tâche a UN assignataire).
    Attention : peut retourner des doublons avec one-to-many -> préférer selectinload.
    """
    result = await db.execute(
        select(Task)
        .limit(20)
        .options(
            joinedload(Task.assignee),      # JOIN avec users
        )
    )
    return result.unique().scalars().all()  # .unique() important avec joinedload !

# [OK] BON : Chargement conditionnel — ne charger que si nécessaire
async def get_task_detail_vs_list(
    db: AsyncSession,
    task_id: int,
    detail: bool = False,
) -> Task | None:
    """
    Charger les relations seulement quand nécessaire.
    Liste -> pas de relations (plus léger)
    Détail -> toutes les relations (plus complet)
    """
    query = select(Task).where(Task.id == task_id)

    if detail:
        # Vue détaillée : tout charger
        query = query.options(
            selectinload(Task.assignee),
            selectinload(Task.creator),
            selectinload(Task.comments).selectinload(Comment.author),  # Nested!
            selectinload(Task.project),
        )

    result = await db.execute(query)
    return result.scalar_one_or_none()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.2 OPTIMISATIONS SQLALCHEMY AVANCÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Techniques d'optimisation SQL avancées
# ═══════════════════════════════════════════════════════════════════════

from sqlalchemy import select, func, text, update
from sqlalchemy.orm import load_only, defer, raiseload

# ── 1. Sélectionner seulement les colonnes nécessaires ────────────────
# Évite de charger des colonnes volumineuses (description TEXT longue)

async def get_tasks_summary(db: AsyncSession) -> list:
    """
    Charge seulement les colonnes nécessaires pour la vue liste.
    Évite de transférer les descriptions longues inutilement.
    """
    result = await db.execute(
        select(Task)
        .options(
            # Charger seulement ces colonnes
            load_only(
                Task.id,
                Task.title,
                Task.status,
                Task.priority,
                Task.completed,
                Task.due_date,
                Task.assigned_to_id,
            ),
            # Différer (lazy-load) les colonnes volumineuses
            defer(Task.description),
            # Lever une erreur si on accède à une relation non chargée
            # (protection contre les N+1 accidentels)
            raiseload(Task.comments),
        )
        .limit(20)
    )
    return result.scalars().all()

# ── 2. Requêtes avec window functions ─────────────────────────────────
async def get_tasks_with_rank(db: AsyncSession, user_id: int) -> list:
    """
    Utilise une window function pour numéroter les tâches par priorité.
    Pure SQL — pas de logique Python nécessaire.
    """
    from sqlalchemy import func, desc

    # ROW_NUMBER() OVER (PARTITION BY priority ORDER BY created_at)
    row_number = func.row_number().over(
        partition_by=Task.priority,
        order_by=desc(Task.created_at),
    ).label("rank_in_priority")

    result = await db.execute(
        select(Task, row_number)
        .where(Task.created_by_id == user_id)
        .order_by(Task.priority, desc(Task.created_at))
    )
    return result.all()

# ── 3. Bulk operations — opérations en masse efficaces ───────────────
async def bulk_update_status(
    db: AsyncSession,
    task_ids: list[int],
    new_status: StatusEnum,
) -> int:
    """
    Met à jour le statut de N tâches en UNE SEULE requête SQL.
    Infiniment plus efficace que N appels update() individuels.
    """
    result = await db.execute(
        update(Task)
        .where(Task.id.in_(task_ids))
        .values(status=new_status, updated_at=func.now())
        .returning(Task.id)   # Retourner les IDs mis à jour
    )
    updated_ids = result.scalars().all()
    return len(updated_ids)

# ── 4. Requêtes paginées avec curseur (Keyset Pagination) ─────────────
# La pagination par offset (OFFSET N LIMIT M) est lente sur de grandes tables.
# La pagination par curseur est O(log N) grâce à l'index.
async def get_tasks_cursor_pagination(
    db: AsyncSession,
    after_id: int | None = None,   # Curseur = dernier ID vu
    limit: int = 20,
) -> list[Task]:
    """
    Pagination par curseur (Keyset Pagination).
    Beaucoup plus performante que OFFSET sur de grandes tables.

    Au lieu de : SELECT * FROM tasks OFFSET 10000 LIMIT 20
    On fait    : SELECT * FROM tasks WHERE id > 10000 LIMIT 20
    -> L'index sur id est utilisé -> O(log N) au lieu de O(N)
    """
    query = select(Task).order_by(Task.id.asc()).limit(limit)

    if after_id is not None:
        query = query.where(Task.id > after_id)

    result = await db.execute(query)
    return result.scalars().all()
    # Dans la réponse : inclure le dernier ID comme "next_cursor"

# ── 5. Requêtes avec text() pour du SQL complexe ─────────────────────
async def full_text_search(db: AsyncSession, query_str: str) -> list:
    """
    Recherche full-text PostgreSQL avec to_tsvector / to_tsquery.
    Beaucoup plus performant que ILIKE pour les recherches textuelles.
    Nécessite un index GIN sur la colonne.
    """
    result = await db.execute(
        text("""
            SELECT id, title, description,
                   ts_rank(
                       to_tsvector('french', title || ' ' || COALESCE(description, '')),
                       plainto_tsquery('french', :query)
                   ) AS relevance
            FROM tasks
            WHERE
                deleted_at IS NULL
                AND to_tsvector('french', title || ' ' || COALESCE(description, ''))
                    @@ plainto_tsquery('french', :query)
            ORDER BY relevance DESC
            LIMIT 20
        """),
        {"query": query_str}
    )
    return result.mappings().all()

# Migration Alembic pour l'index GIN (full-text search) :
# op.execute("CREATE INDEX idx_tasks_fts ON tasks USING gin("
#            "to_tsvector('french', title || ' ' || COALESCE(description, '')))")

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40.3 PROFILING DES REQUÊTES SQL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ── Activer le logging SQL en développement ───────────────────────────
# Dans .env : DATABASE_URL avec echo=True sur le moteur
# -> Toutes les requêtes SQL sont affichées dans les logs

# ── Analyser les requêtes lentes avec EXPLAIN ANALYZE ────────────────
# Dans psql ou DBeaver :
#
# EXPLAIN ANALYZE
# SELECT * FROM tasks
# WHERE assigned_to_id = 42 AND status = 'todo'
# ORDER BY created_at DESC
# LIMIT 20;
#
# Si la sortie montre "Seq Scan" sur une grande table -> manque d'index
# "Index Scan" -> bon, l'index est utilisé

# ── Middleware de logging SQL lent ────────────────────────────────────
from sqlalchemy import event
from sqlalchemy.engine import Engine
import time
import logging

sql_logger = logging.getLogger("taskflow.sql")

def setup_query_profiling(engine, slow_threshold_ms: float = 100):
    """
    Enregistre un listener SQLAlchemy pour logger les requêtes lentes.
    À appeler une fois au démarrage de l'application.
    """
    @event.listens_for(engine.sync_engine, "before_cursor_execute")
    def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
        conn.info.setdefault("query_start_time", []).append(time.perf_counter())

    @event.listens_for(engine.sync_engine, "after_cursor_execute")
    def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
        total_ms = (time.perf_counter() - conn.info["query_start_time"].pop(-1)) * 1000
        if total_ms > slow_threshold_ms:
            sql_logger.warning(
                f"[ATTENTION] SLOW QUERY ({total_ms:.0f}ms):\n{statement[:200]}"
            )

================================================================================
             CHAPITRE 42 — DOCKER MULTI-STAGE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.1 POURQUOI DOCKER ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Docker résout le problème "ça marche sur ma machine" :
  - Environnement identique partout (dev, staging, prod)
  - Dépendances isolées -> pas de conflits de versions
  - Déploiement reproductible en 1 commande
  - Scalabilité facile (lancer N instances identiques)

Image multi-stage :
  Stage 1 (builder) : installe toutes les dépendances, compile le code
  Stage 2 (runtime) : copie seulement le nécessaire du stage 1
  -> Image finale plus légère (pas les outils de build)
  -> Image finale plus sécurisée (moins de surface d'attaque)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.2 DOCKERFILE MULTI-STAGE PRODUCTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ══════════════════════════════════════════
# Dockerfile — Multi-stage production build
# ══════════════════════════════════════════

# ─── STAGE 1 : Builder ──────────────────────────────────────────────────
# Image avec tous les outils de build
FROM python:3.11-slim AS builder

# Empêcher Python de créer des .pyc (inutile en production)
ENV PYTHONDONTWRITEBYTECODE=1
# Empêcher Python de bufferiser stdout/stderr (logs en temps réel)
ENV PYTHONUNBUFFERED=1

WORKDIR /build

# Copier SEULEMENT requirements.txt d'abord
# Docker met en cache chaque layer -> si requirements.txt ne change pas,
# pip install n'est pas relancé (gain de temps considérable)
COPY requirements.txt .

# Installer les dépendances dans un dossier spécifique
# --no-cache-dir  -> ne pas cacher les paquets pip (image plus petite)
# --prefix=/deps  -> installer dans /deps (facile à copier au stage suivant)
RUN pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir --prefix=/deps -r requirements.txt

# ─── STAGE 2 : Runtime ──────────────────────────────────────────────────
# Image minimale pour la production
FROM python:3.11-slim AS runtime

# Métadonnées de l'image (bonnes pratiques)
LABEL maintainer="taskflow@example.com"
LABEL version="1.0.0"
LABEL description="TaskFlow API Production Image"

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Créer un utilisateur non-root pour la sécurité
# JAMAIS exécuter en root en production !
RUN groupadd -r taskflow && useradd -r -g taskflow -d /app -s /sbin/nologin taskflow

WORKDIR /app

# Copier les dépendances installées depuis le stage builder
COPY --from=builder /deps /usr/local

# Copier seulement le code source de l'application
# (pas les tests, pas les fichiers de dev, pas .env)
COPY --chown=taskflow:taskflow app/ ./app/
COPY --chown=taskflow:taskflow alembic/ ./alembic/
COPY --chown=taskflow:taskflow alembic.ini .

# Passer à l'utilisateur non-root
USER taskflow

# Port exposé (documentaire — ne publie pas le port)
EXPOSE 8000

# Health check : Docker vérifie régulièrement que l'app répond
HEALTHCHECK \
    --interval=30s \    # Vérifier toutes les 30 secondes
    --timeout=10s  \    # Timeout de 10 secondes
    --start-period=30s \ # Attendre 30s avant la première vérification
    --retries=3    \    # 3 échecs -> container "unhealthy"
    CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"

# Commande de démarrage (peut être surchargée dans docker-compose)
# --workers 1 en container (orchestration via docker-compose scale)
# --worker-class pour les WebSockets -> uvicorn.workers.UvicornWorker
CMD ["uvicorn", "app.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--workers", "1", \
     "--log-level", "info", \
     "--no-access-log"]
     # no-access-log : on gère les logs d'accès dans notre middleware

# ── .dockerignore ──────────────────────────────────────────────────────
# .dockerignore : fichiers à exclure du contexte Docker
# (équivalent de .gitignore pour Docker)
#
# .git/
# .gitignore
# .env
# .env.*
# __pycache__/
# *.pyc
# *.pyo
# tests/
# htmlcov/
# .coverage
# *.log
# logs/
# .idea/
# .vscode/
# README.md
# docker-compose*.yml
# Dockerfile*
# node_modules/

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.3 DOCKER COMPOSE COMPLET — TOUS LES SERVICES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ══════════════════════════════════════════════════════════════════
# docker-compose.yml — Stack complète TaskFlow (développement)
# ══════════════════════════════════════════════════════════════════

version: "3.9"

# ── Volumes partagés ─────────────────────────────────────────────────
volumes:
  postgres_data:     # Données PostgreSQL persistantes
  redis_data:        # Données Redis persistantes (AOF)
  static_files:      # Fichiers statiques (Swagger UI assets)

# ── Réseau interne ────────────────────────────────────────────────────
networks:
  taskflow_net:
    driver: bridge   # Réseau Docker bridge isolé

services:

  # ─── PostgreSQL ──────────────────────────────────────────────────
  db:
    image: postgres:16-alpine
    container_name: taskflow_db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-taskflow_user}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-taskflow_pass}
      POSTGRES_DB: ${POSTGRES_DB:-taskflow_db}
      # Optimisations PostgreSQL pour la production
      POSTGRES_INITDB_ARGS: "--locale=fr_FR.UTF-8"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      # Script SQL d'initialisation (exécuté une seule fois)
      - ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql:ro
    ports:
      - "${DB_PORT:-5432}:5432"   # Exposé seulement en développement
    networks:
      - taskflow_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-taskflow_user} -d ${POSTGRES_DB:-taskflow_db}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s

  # ─── Redis ───────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: taskflow_redis
    restart: unless-stopped
    command: >
      redis-server
      --appendonly yes
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
      --requirepass ${REDIS_PASSWORD:-redis_pass}
      --loglevel notice
    volumes:
      - redis_data:/data
    ports:
      - "${REDIS_PORT:-6379}:6379"
    networks:
      - taskflow_net
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_pass}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ─── API FastAPI ──────────────────────────────────────────────────
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: runtime         # Utiliser le stage "runtime" du multi-stage
    container_name: taskflow_api
    restart: unless-stopped
    env_file:
      - .env                  # Charger les variables d'environnement
    environment:
      DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
    ports:
      - "${API_PORT:-8000}:8000"
    depends_on:
      db:
        condition: service_healthy   # Attendre que DB soit prête
      redis:
        condition: service_healthy   # Attendre que Redis soit prêt
    networks:
      - taskflow_net
    volumes:
      - static_files:/app/static     # Partage avec Nginx
    healthcheck:
      test: ["CMD", "python", "-c",
             "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

  # ─── Nginx reverse proxy ──────────────────────────────────────────
  nginx:
    image: nginx:1.25-alpine
    container_name: taskflow_nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro            # Certificats TLS
      - static_files:/var/www/static:ro           # Fichiers statiques
    depends_on:
      - api
    networks:
      - taskflow_net

  # ─── Adminer (admin DB, développement uniquement) ─────────────────
  adminer:
    image: adminer:4-standalone
    container_name: taskflow_adminer
    restart: unless-stopped
    ports:
      - "8080:8080"
    networks:
      - taskflow_net
    profiles:
      - dev                   # Lancé seulement avec : docker-compose --profile dev up

  # ─── Redis Commander (admin Redis, développement uniquement) ──────
  redis_commander:
    image: rediscommander/redis-commander:latest
    container_name: taskflow_redis_ui
    restart: unless-stopped
    environment:
      REDIS_HOSTS: "local:redis:6379:0:${REDIS_PASSWORD}"
    ports:
      - "8081:8081"
    networks:
      - taskflow_net
    profiles:
      - dev

  # ─── Worker de migration (run-once) ──────────────────────────────
  migrate:
    build:
      context: .
      dockerfile: Dockerfile
      target: runtime
    container_name: taskflow_migrate
    env_file: .env
    environment:
      DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    command: ["alembic", "upgrade", "head"]
    depends_on:
      db:
        condition: service_healthy
    networks:
      - taskflow_net
    # restart: "no" -> ce service ne redémarre pas (run-once)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42.4 DOCKER COMPOSE PRODUCTION — SURCHARGE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# docker-compose.prod.yml — surcharge pour la production
# Utilisé avec : docker-compose -f docker-compose.yml -f docker-compose.prod.yml up

version: "3.9"

services:
  api:
    # En production : plusieurs replicas pour la haute disponibilité
    deploy:
      replicas: 3                  # 3 instances de l'API
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
      resources:
        limits:
          cpus: "0.50"             # Max 0.5 CPU par container
          memory: 512M             # Max 512 Mo RAM
        reservations:
          cpus: "0.25"
          memory: 256M
    # Pas de port exposé directement en prod (tout passe par Nginx)
    ports: []
    # Plus de logs détaillés en prod -> uniquement warnings et erreurs
    command: ["uvicorn", "app.main:app",
              "--host", "0.0.0.0",
              "--port", "8000",
              "--workers", "2",
              "--log-level", "warning"]

  db:
    # Pas de port exposé en prod (seulement accessible via le réseau interne)
    ports: []
    # Optimisations PostgreSQL production
    command: >
      postgres
      -c max_connections=200
      -c shared_buffers=256MB
      -c effective_cache_size=1GB
      -c maintenance_work_mem=64MB
      -c checkpoint_completion_target=0.9
      -c wal_buffers=16MB
      -c default_statistics_target=100

  redis:
    ports: []   # Pas de port exposé en prod

  # Ne pas inclure les services de dev en prod
  adminer:
    profiles: ["never"]   # Jamais lancé en prod
  redis_commander:
    profiles: ["never"]

================================================================================
             CHAPITRE 43 — NGINX REVERSE PROXY + HTTPS
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43.1 RÔLE DE NGINX DEVANT FASTAPI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Internet -> Nginx (port 443 HTTPS) -> FastAPI (port 8000 HTTP interne)

Nginx gère :
  [OK] Terminaison TLS/HTTPS (chiffrement/déchiffrement)
  [OK] Load balancing entre les instances FastAPI
  [OK] Servir les fichiers statiques (Swagger UI assets)
  [OK] Compression gzip des réponses
  [OK] Rate limiting au niveau réseau
  [OK] Headers de sécurité
  [OK] Redirection HTTP -> HTTPS
  [OK] Logging des accès

FastAPI ne gère QUE la logique métier -> beaucoup plus efficace.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43.2 CONFIGURATION NGINX COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# nginx/nginx.conf — Configuration principale Nginx

user nginx;
worker_processes auto;             # Autoscale selon les cœurs CPU
pid /var/run/nginx.pid;
error_log /var/log/nginx/error.log warn;

events {
    worker_connections 1024;       # Connexions max par worker
    use epoll;                     # Mécanisme I/O optimal sous Linux
    multi_accept on;               # Accepter plusieurs connexions à la fois
}

http {
    # ── Types MIME ────────────────────────────────────────────────────
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # ── Logging ───────────────────────────────────────────────────────
    log_format main_json escape=json
        '{'
        '"time": "$time_iso8601", '
        '"remote_addr": "$remote_addr", '
        '"method": "$request_method", '
        '"uri": "$request_uri", '
        '"status": $status, '
        '"bytes_sent": $bytes_sent, '
        '"duration": $request_time, '
        '"upstream_time": "$upstream_response_time", '
        '"user_agent": "$http_user_agent"'
        '}';

    access_log /var/log/nginx/access.log main_json;

    # ── Optimisations réseau ──────────────────────────────────────────
    sendfile        on;
    tcp_nopush      on;   # Optimise les transferts de fichiers
    tcp_nodelay     on;   # Réduit la latence

    # ── Timeouts ─────────────────────────────────────────────────────
    keepalive_timeout 65;
    send_timeout 30;
    client_body_timeout 30;
    client_header_timeout 30;

    # ── Taille max des requêtes ───────────────────────────────────────
    client_max_body_size 20M;    # Max 20Mo pour les uploads

    # ── Compression Gzip ─────────────────────────────────────────────
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;        # Compresser seulement les réponses > 1Ko
    gzip_proxied any;
    gzip_comp_level 6;           # Niveau de compression (1-9)
    gzip_types
        text/plain
        text/css
        application/json
        application/javascript
        text/xml
        application/xml;

    # ── Rate limiting global ──────────────────────────────────────────
    # Zone partagée en mémoire pour compter les requêtes par IP
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/m;
    # 10m = 10Mo de mémoire pour stocker les compteurs
    # 100r/m = 100 requêtes par minute par IP

    limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
    # Zone spéciale pour les endpoints d'auth (plus restrictive)

    # ── Upstream : instances FastAPI ─────────────────────────────────
    upstream taskflow_api {
        # Load balancing entre plusieurs instances
        server api:8000 weight=1 max_fails=3 fail_timeout=30s;
        # Si plusieurs replicas :
        # server api_1:8000 weight=1;
        # server api_2:8000 weight=1;
        # server api_3:8000 weight=1;

        keepalive 32;   # Maintenir 32 connexions persistantes avec l'upstream
    }

    # Inclure les configurations de virtual hosts
    include /etc/nginx/conf.d/*.conf;
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# nginx/conf.d/taskflow.conf — Virtual host TaskFlow
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ── Redirection HTTP -> HTTPS ──────────────────────────────────────────
server {
    listen 80;
    listen [::]:80;
    server_name api.taskflow.io;

    # Let's Encrypt challenge (renouvellement automatique des certificats)
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Tout le reste -> redirection permanente vers HTTPS
    location / {
        return 301 https://$server_name$request_uri;
    }
}

# ── HTTPS principal ───────────────────────────────────────────────────
server {
    listen 443 ssl http2;          # HTTP/2 pour la performance
    listen [::]:443 ssl http2;
    server_name api.taskflow.io;

    # ── Certificats TLS (Let's Encrypt via Certbot) ───────────────────
    ssl_certificate     /etc/nginx/ssl/live/api.taskflow.io/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/live/api.taskflow.io/privkey.pem;

    # ── Configuration TLS sécurisée ───────────────────────────────────
    ssl_protocols TLSv1.2 TLSv1.3;          # Désactiver TLS 1.0 et 1.1
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;
    ssl_stapling on;                         # OCSP Stapling -> vérification rapide du certificat
    ssl_stapling_verify on;

    # ── Headers de sécurité HTTP ──────────────────────────────────────
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;

    # ── Cacher les informations système ──────────────────────────────
    server_tokens off;             # Masquer la version Nginx

    # ── Endpoints d'authentification (rate limit strict) ─────────────
    location /api/v1/auth/login {
        limit_req zone=auth_limit burst=5 nodelay;
        # burst=5 : autoriser un burst de 5 requêtes au-dessus de la limite
        # nodelay : ne pas mettre en queue, rejeter immédiatement si dépassé

        proxy_pass http://taskflow_api;
        include /etc/nginx/conf.d/proxy_params.conf;
    }

    location /api/v1/auth/register {
        limit_req zone=auth_limit burst=3 nodelay;
        proxy_pass http://taskflow_api;
        include /etc/nginx/conf.d/proxy_params.conf;
    }

    # ── API principale ────────────────────────────────────────────────
    location /api/ {
        limit_req zone=api_limit burst=20 nodelay;

        proxy_pass http://taskflow_api;
        include /etc/nginx/conf.d/proxy_params.conf;
    }

    # ── WebSockets ────────────────────────────────────────────────────
    location /ws/ {
        proxy_pass http://taskflow_api;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;     # Upgrade HTTP -> WS
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400;                   # 24h pour les connexions WS longues
    }

    # ── Documentation Swagger (production : protégée par auth basic) ──
    location /docs {
        # En production : désactiver ou protéger la doc Swagger
        return 404;   # Ou: auth_basic + htpasswd
    }

    location /redoc {
        return 404;
    }

    # ── Health check (pas de rate limit) ─────────────────────────────
    location /health {
        proxy_pass http://taskflow_api;
        access_log off;   # Pas de log pour les health checks
    }

    # ── Fichiers statiques (servis directement par Nginx, pas FastAPI) -
    location /static/ {
        alias /var/www/static/;
        expires 1y;                    # Cache navigateur 1 an
        add_header Cache-Control "public, immutable";
        access_log off;
    }
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# nginx/conf.d/proxy_params.conf
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Paramètres proxy réutilisables

# proxy_http_version 1.1;
# 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;
# proxy_connect_timeout 10s;
# proxy_send_timeout 30s;
# proxy_read_timeout 30s;
# proxy_buffering off;     # Désactiver le buffering pour les SSE/streaming

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43.3 HTTPS AVEC LET'S ENCRYPT (CERTBOT)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Let's Encrypt fournit des certificats TLS GRATUITS renouvelables automatiquement.

# ── Service Certbot dans docker-compose.yml ───────────────────────────

  certbot:
    image: certbot/certbot
    container_name: taskflow_certbot
    volumes:
      - ./nginx/ssl:/etc/letsencrypt           # Stocker les certificats
      - ./nginx/www:/var/www/certbot           # Dossier challenge ACME
    # Obtenir le certificat la première fois :
    command: certonly --webroot
             --webroot-path=/var/www/certbot
             --email admin@taskflow.io
             --agree-tos
             --no-eff-email
             -d api.taskflow.io
    profiles:
      - certbot   # Lancer manuellement avec : docker-compose --profile certbot run certbot

# ── Script de renouvellement automatique ─────────────────────────────

# scripts/renew_certs.sh
# #!/bin/bash
# docker-compose run certbot renew
# docker-compose exec nginx nginx -s reload   # Recharger Nginx avec les nouveaux certs

# Crontab pour renouvellement automatique (tous les 12h) :
# 0 0,12 * * * /path/to/scripts/renew_certs.sh >> /var/log/certbot_renew.log 2>&1

# ── Commandes essentielles Docker ────────────────────────────────────

  # Construire et lancer toute la stack
  docker-compose up --build -d

  # Lancer la stack de développement (avec Adminer et Redis Commander)
  docker-compose --profile dev up -d

  # Lancer les migrations AVANT de démarrer l'API
  docker-compose run --rm migrate
  docker-compose up -d api

  # Voir les logs en temps réel
  docker-compose logs -f api
  docker-compose logs -f nginx

  # Redémarrer seulement l'API (après mise à jour du code)
  docker-compose up -d --no-deps --build api

  # Scaler l'API à 3 instances
  docker-compose up -d --scale api=3

  # Accéder au shell d'un container
  docker-compose exec api bash
  docker-compose exec db psql -U taskflow_user -d taskflow_db

  # Voir les ressources utilisées
  docker stats

  # Nettoyer les images/volumes non utilisés
  docker system prune -f
  docker volume prune -f

================================================================================
                         RÉCAPITULATIF PARTIE 8
================================================================================

Dans cette partie, tu as appris :

[OK] Redis caching :
   - CacheService générique avec get/set/delete/delete_pattern
   - TTL par type de données (short/medium/long/day)
   - Pattern Cache-Aside avec get_or_set()
   - Invalidation à l'écriture dans les services
   - Décorateur @cached pour les fonctions async

[OK] Optimisation SQLAlchemy :
   - Problème N+1 expliqué et résolu avec selectinload / joinedload
   - load_only / defer pour sélectionner les colonnes nécessaires
   - Keyset Pagination (curseur) vs offset pour les grandes tables
   - Bulk update avec une seule requête SQL
   - Full-text search PostgreSQL (to_tsvector / to_tsquery)
   - Profiling des requêtes lentes avec event listeners

[OK] Docker multi-stage :
   - Stage builder + runtime -> image légère et sécurisée
   - Utilisateur non-root, HEALTHCHECK, .dockerignore

[OK] Docker Compose complet :
   - 7 services : API, PostgreSQL, Redis, Nginx, Adminer, Redis UI, Migrate
   - Profiles (dev vs prod), healthchecks, depends_on conditionnels
   - docker-compose.prod.yml pour surcharger en production

[OK] Nginx production :
   - Reverse proxy avec upstream load balancing
   - HTTPS/TLS (TLS 1.2+, ciphers sécurisés, HSTS)
   - Rate limiting par zone (auth plus strict que API)
   - Compression gzip, logging JSON, headers de sécurité
   - Let's Encrypt certbot + renouvellement automatique

[RAPIDE] PROCHAINE ÉTAPE : Partie 9 — WebSockets & Observabilité
   - WebSockets temps réel (chat, notifications live)
   - Logging structuré en production (Structlog)
   - Monitoring avec Prometheus + Grafana
   - Tracing distribué (OpenTelemetry)
   - Alertes et tableaux de bord

================================================================================
                           FIN DE LA PARTIE 8
                    Passe à fastapi_master_part_9.txt
================================================================================

================================================================================
  GUIDE FASTAPI COMPLET — PARTIE 9 : WEBSOCKETS & OBSERVABILITÉ
  Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FIL ROUGE : TaskFlow API
   Dans cette partie, nous ajoutons à TaskFlow :
   - WebSockets pour les notifications et le chat en temps réel
   - Rooms et broadcast multi-utilisateurs avec Redis Pub/Sub
   - Reconnexion automatique côté client
   - Structlog pour des logs JSON structurés production-ready
   - Prometheus pour la collecte de métriques
   - Grafana pour les dashboards et les alertes
   - OpenTelemetry pour le tracing distribué

================================================================================
         CHAPITRE 36 — WEBSOCKETS TEMPS RÉEL
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36.1 HTTP VS WEBSOCKETS — QUAND UTILISER LEQUEL ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HTTP (classique) :
  Modèle : requête -> réponse -> connexion fermée
  Le serveur ne peut PAS envoyer de données sans que le client demande
  Adapté : CRUD, formulaires, pages web, API REST

WebSocket :
  Modèle : connexion persistante bidirectionnelle
  Le serveur PEUT envoyer des données à tout moment sans demande
  Adapté : chat, notifications, collaboration, jeux, données en temps réel

Comparaison dans TaskFlow :

  [X] HTTP pour les notifications : le client devrait faire du polling
     GET /notifications toutes les 3 secondes -> 20 req/min inutiles

  [OK] WebSocket pour les notifications :
     Connexion établie UNE FOIS -> serveur push immédiat quand ça change

Protocole WebSocket :
  1. Client envoie une requête HTTP UPGRADE :
     GET /ws HTTP/1.1
     Upgrade: websocket
     Connection: Upgrade
     Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

  2. Serveur répond 101 Switching Protocols
     -> La connexion HTTP devient WebSocket

  3. Échange bidirectionnel de frames (texte ou binaire)

  4. Fermeture par l'un ou l'autre (code 1000 = fermeture propre)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36.2 WEBSOCKET BASIQUE AVEC FASTAPI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# Premier WebSocket — Connexion basique commentée ligne par ligne
# ═══════════════════════════════════════════════════════════════════════

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import json

app = FastAPI()

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    """
    Endpoint WebSocket basique.

    Cycle de vie :
    1. websocket.accept()  -> accepte la connexion du client
    2. Boucle infinie      -> recevoir et envoyer des messages
    3. WebSocketDisconnect -> levée quand le client se déconnecte
    4. (optionnel) websocket.close() -> fermeture propre côté serveur
    """

    # ── Étape 1 : Accepter la connexion ──────────────────────────────
    # Sans accept() -> la connexion est refusée
    await websocket.accept()

    # ── Étape 2 : Boucle de communication ────────────────────────────
    try:
        while True:
            # receive_text() bloque JUSQU'À réception d'un message
            # C'est une coroutine -> l'event loop peut gérer d'autres tâches
            data = await websocket.receive_text()

            # Traitement du message reçu
            message = json.loads(data)

            # send_text() envoie un message au client
            await websocket.send_text(
                json.dumps({"echo": message, "status": "received"})
            )

    except WebSocketDisconnect:
        # Le client s'est déconnecté -> sortir proprement de la boucle
        print(f"Client déconnecté")

    except Exception as e:
        # Toute autre erreur -> fermer la connexion
        print(f"Erreur WebSocket : {e}")
        await websocket.close(code=1011, reason="Erreur interne")

# Types de réception disponibles :
#   await websocket.receive_text()  -> str
#   await websocket.receive_bytes() -> bytes
#   await websocket.receive_json()  -> dict (parse JSON automatiquement)

# Types d'envoi disponibles :
#   await websocket.send_text("...")
#   await websocket.send_bytes(b"...")
#   await websocket.send_json({"key": "value"})  # sérialise en JSON

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36.3 GESTIONNAIRE DE CONNEXIONS — ConnectionManager
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En production, il faut gérer PLUSIEURS connexions simultanées :
  - Stocker toutes les connexions actives
  - Broadcast à tous les connectés
  - Gestion des déconnexions propres
  - Rooms pour grouper les connexions

# ═══════════════════════════════════════════════════════════════════════
# app/websockets/manager.py — Gestionnaire de connexions complet
# ═══════════════════════════════════════════════════════════════════════

from fastapi import WebSocket
from typing import DefaultDict
from collections import defaultdict
import asyncio
import json
import logging

logger = logging.getLogger("taskflow.websocket")

class ConnectionManager:
    """
    Gestionnaire centralisé de toutes les connexions WebSocket.

    Structure de stockage :
      connections[user_id]       -> liste des connexions d'un utilisateur
                                   (un user peut avoir plusieurs onglets)
      rooms[room_name]           -> set des user_ids dans ce room
      user_rooms[user_id]        -> set des rooms où est l'utilisateur

    Exemples de rooms TaskFlow :
      "global"             -> tous les utilisateurs connectés
      "project:5"          -> membres du projet 5
      "task:42"            -> utilisateurs qui regardent la tâche 42
      "user:7"             -> notifications privées de l'utilisateur 7
    """

    def __init__(self):
        # user_id -> list[WebSocket]
        # Un utilisateur peut être connecté depuis plusieurs onglets/devices
        self.connections: DefaultDict[int, list[WebSocket]] = defaultdict(list)

        # room_name -> set[user_id]
        self.rooms: DefaultDict[str, set[int]] = defaultdict(set)

        # user_id -> set[room_name]
        self.user_rooms: DefaultDict[int, set[str]] = defaultdict(set)

        # Métriques
        self._total_connections = 0
        self._total_messages_sent = 0

    # ── Connexion / Déconnexion ───────────────────────────────────────

    async def connect(self, websocket: WebSocket, user_id: int) -> None:
        """
        Accepte et enregistre une nouvelle connexion WebSocket.
        Ajoute automatiquement l'utilisateur au room "global".
        """
        await websocket.accept()
        self.connections[user_id].append(websocket)
        self._total_connections += 1

        # Rejoindre le room global automatiquement
        await self.join_room(user_id, "global")

        logger.info(
            f"WebSocket connecté",
            extra={
                "user_id": user_id,
                "total_connections": self.get_total_connections(),
            }
        )

    async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
        """
        Supprime une connexion déconnectée.
        Quitte tous les rooms si c'est la dernière connexion de l'utilisateur.
        """
        # Supprimer cette connexion spécifique
        if user_id in self.connections:
            try:
                self.connections[user_id].remove(websocket)
            except ValueError:
                pass   # Connexion déjà supprimée

            # Si plus aucune connexion pour cet utilisateur
            if not self.connections[user_id]:
                del self.connections[user_id]
                # Quitter tous les rooms
                for room in list(self.user_rooms[user_id]):
                    await self.leave_room(user_id, room)

        self._total_connections -= 1
        logger.info(
            f"WebSocket déconnecté",
            extra={"user_id": user_id}
        )

    # ── Gestion des rooms ─────────────────────────────────────────────

    async def join_room(self, user_id: int, room: str) -> None:
        """L'utilisateur rejoint un room."""
        self.rooms[room].add(user_id)
        self.user_rooms[user_id].add(room)
        logger.debug(f"User {user_id} rejoint room '{room}'")

    async def leave_room(self, user_id: int, room: str) -> None:
        """L'utilisateur quitte un room."""
        self.rooms[room].discard(user_id)
        self.user_rooms[user_id].discard(room)

        # Nettoyer les rooms vides
        if not self.rooms[room]:
            del self.rooms[room]

    # ── Envoi de messages ─────────────────────────────────────────────

    async def send_to_user(
        self,
        user_id: int,
        message: dict,
    ) -> int:
        """
        Envoie un message à TOUTES les connexions d'un utilisateur.
        (Utile : notification sur tous les onglets ouverts)
        Retourne le nombre de connexions atteintes.
        """
        if user_id not in self.connections:
            return 0

        message_json = json.dumps(message, default=str)
        sent_count = 0
        dead_connections = []

        for ws in list(self.connections[user_id]):
            try:
                await ws.send_text(message_json)
                sent_count += 1
                self._total_messages_sent += 1
            except Exception:
                # Connexion morte (client parti sans fermeture propre)
                dead_connections.append(ws)

        # Nettoyer les connexions mortes
        for dead_ws in dead_connections:
            await self.disconnect(dead_ws, user_id)

        return sent_count

    async def broadcast_to_room(
        self,
        room: str,
        message: dict,
        exclude_user_id: int | None = None,
    ) -> int:
        """
        Envoie un message à TOUS les utilisateurs d'un room.

        exclude_user_id : ne pas envoyer à l'expéditeur lui-même
        (ex: dans un chat, on n'envoie pas le message à soi-même)

        Retourne le nombre d'utilisateurs atteints.
        """
        if room not in self.rooms:
            return 0

        user_ids = list(self.rooms[room])
        if exclude_user_id:
            user_ids = [uid for uid in user_ids if uid != exclude_user_id]

        # Envoyer à tous en parallèle (asyncio.gather)
        if not user_ids:
            return 0

        results = await asyncio.gather(
            *[self.send_to_user(uid, message) for uid in user_ids],
            return_exceptions=True,
        )

        # Compter les succès (ignorer les exceptions)
        return sum(r for r in results if isinstance(r, int))

    async def broadcast_global(
        self,
        message: dict,
        exclude_user_id: int | None = None,
    ) -> int:
        """Envoie un message à TOUS les utilisateurs connectés."""
        return await self.broadcast_to_room("global", message, exclude_user_id)

    # ── Métriques ────────────────────────────────────────────────────

    def get_total_connections(self) -> int:
        """Nombre total de connexions WebSocket actives."""
        return sum(len(wss) for wss in self.connections.values())

    def get_connected_users(self) -> int:
        """Nombre d'utilisateurs uniques connectés."""
        return len(self.connections)

    def get_room_size(self, room: str) -> int:
        """Nombre d'utilisateurs dans un room."""
        return len(self.rooms.get(room, set()))

    def get_stats(self) -> dict:
        """Statistiques complètes pour le monitoring."""
        return {
            "total_connections": self.get_total_connections(),
            "unique_users": self.get_connected_users(),
            "active_rooms": len(self.rooms),
            "total_messages_sent": self._total_messages_sent,
            "rooms": {
                room: len(users)
                for room, users in self.rooms.items()
            },
        }

# ── Instance globale (singleton) ─────────────────────────────────────
# Partagée entre tous les endpoints WebSocket
ws_manager = ConnectionManager()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36.4 AUTHENTIFICATION WEBSOCKET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les WebSockets ne supportent pas le header Authorization directement
depuis les navigateurs. Solutions courantes :

  1. Token dans le query param : /ws?token=eyJhbG...  (simple mais exposé dans les logs)
  2. Token dans un header custom (possible via JS natif, mais pas tous les clients)
  3. Cookie httpOnly (le plus sécurisé côté navigateur)
  4. Premier message d'authentification après connexion

# ═══════════════════════════════════════════════════════════════════════
# app/websockets/auth.py — Authentification WebSocket
# ═══════════════════════════════════════════════════════════════════════

from fastapi import WebSocket, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

from app.core.security import decode_access_token
from app.models.user import User

async def authenticate_websocket(
    websocket: WebSocket,
    db: AsyncSession,
) -> User | None:
    """
    Authentifie une connexion WebSocket via token dans le query param.

    Flux :
    1. Extraire le token de ?token=...
    2. Décoder et valider le JWT
    3. Charger l'utilisateur depuis la DB
    4. Si invalide -> fermer la connexion avec 4001 (unauthorized)
    5. Si valide -> retourner l'utilisateur

    Code de fermeture WebSocket 4001 = application-level unauthorized.
    Les codes 4000-4999 sont réservés aux applications.
    """
    # Récupérer le token depuis les query params
    token = websocket.query_params.get("token")

    if not token:
        # Fermer AVANT d'accepter (refus de connexion)
        await websocket.close(
            code=status.WS_1008_POLICY_VIOLATION,
            reason="Token d'authentification manquant"
        )
        return None

    # Valider le JWT
    try:
        token_data = decode_access_token(token)
    except ValueError:
        await websocket.close(
            code=status.WS_1008_POLICY_VIOLATION,
            reason="Token invalide ou expiré"
        )
        return None

    # Charger l'utilisateur depuis la DB
    result = await db.execute(
        select(User).where(
            User.id == token_data.user_id,
            User.is_active == True,   # noqa: E712
        )
    )
    user = result.scalar_one_or_none()

    if not user:
        await websocket.close(
            code=status.WS_1008_POLICY_VIOLATION,
            reason="Utilisateur introuvable ou désactivé"
        )
        return None

    return user

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36.5 SYSTÈME DE NOTIFICATIONS TEMPS RÉEL — TASKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/api/v1/websockets.py — Endpoints WebSocket complets
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated
import json

from app.infrastructure.database import get_db
from app.websockets.manager import ws_manager
from app.websockets.auth import authenticate_websocket

router = APIRouter(prefix="/ws", tags=["websockets"])

# ── Endpoint principal de notifications ──────────────────────────────

@router.websocket("/notifications")
async def notifications_ws(
    websocket: WebSocket,
    db: AsyncSession = Depends(get_db),
):
    """
    WebSocket de notifications en temps réel pour TaskFlow.

    Connexion :
      ws://localhost:8000/ws/notifications?token=eyJhbGci...

    Messages reçus du client :
      {"type": "join_project", "project_id": 5}
      {"type": "leave_project", "project_id": 5}
      {"type": "ping"}

    Messages envoyés au client :
      {"type": "task_created",    "task": {...}, "by": "Alice"}
      {"type": "task_updated",    "task": {...}, "by": "Bob"}
      {"type": "task_assigned",   "task": {...}, "to": "Charlie"}
      {"type": "task_completed",  "task": {...}, "by": "Alice"}
      {"type": "comment_added",   "task_id": 42, "comment": {...}}
      {"type": "user_joined",     "user": "Alice", "project_id": 5}
      {"type": "pong"}
    """
    # ── Authentifier ──────────────────────────────────────────────────
    user = await authenticate_websocket(websocket, db)
    if not user:
        return   # authenticate_websocket a déjà fermé la connexion

    # ── Connecter au manager ──────────────────────────────────────────
    await ws_manager.connect(websocket, user.id)

    # ── Envoyer un message de bienvenue ───────────────────────────────
    await websocket.send_json({
        "type": "connected",
        "message": f"Bienvenue {user.username} !",
        "user_id": user.id,
        "connected_users": ws_manager.get_connected_users(),
    })

    # ── Boucle de réception des messages ─────────────────────────────
    try:
        while True:
            # Attendre un message du client
            raw = await websocket.receive_text()

            try:
                message = json.loads(raw)
            except json.JSONDecodeError:
                await websocket.send_json({
                    "type": "error",
                    "message": "Message JSON invalide"
                })
                continue

            msg_type = message.get("type")

            # ── Dispatcher les types de messages ──────────────────────

            if msg_type == "ping":
                # Heartbeat : le client vérifie que la connexion est vivante
                await websocket.send_json({"type": "pong"})

            elif msg_type == "join_project":
                project_id = message.get("project_id")
                if project_id:
                    room = f"project:{project_id}"
                    await ws_manager.join_room(user.id, room)
                    await websocket.send_json({
                        "type": "joined_project",
                        "project_id": project_id,
                        "members_online": ws_manager.get_room_size(room),
                    })

            elif msg_type == "leave_project":
                project_id = message.get("project_id")
                if project_id:
                    await ws_manager.leave_room(user.id, f"project:{project_id}")
                    await websocket.send_json({
                        "type": "left_project",
                        "project_id": project_id,
                    })

            elif msg_type == "subscribe_task":
                # S'abonner aux updates d'une tâche spécifique
                task_id = message.get("task_id")
                if task_id:
                    await ws_manager.join_room(user.id, f"task:{task_id}")
                    await websocket.send_json({
                        "type": "subscribed_task",
                        "task_id": task_id,
                    })

            else:
                await websocket.send_json({
                    "type": "error",
                    "message": f"Type de message inconnu : {msg_type}"
                })

    except WebSocketDisconnect as e:
        # Déconnexion normale (code 1000) ou anormale (code 1006)
        logger.info(
            f"WebSocket déconnecté",
            extra={"user_id": user.id, "close_code": e.code}
        )
    except Exception as e:
        logger.error(f"Erreur WebSocket user {user.id}: {e}", exc_info=True)
    finally:
        # Toujours nettoyer, même en cas d'erreur
        await ws_manager.disconnect(websocket, user.id)

# ── Endpoint de chat par projet ───────────────────────────────────────

@router.websocket("/chat/{project_id}")
async def project_chat_ws(
    websocket: WebSocket,
    project_id: int,
    db: AsyncSession = Depends(get_db),
):
    """
    Chat en temps réel pour un projet spécifique.

    Connexion :
      ws://localhost:8000/ws/chat/5?token=eyJhbGci...

    Messages client -> serveur :
      {"type": "message", "content": "Hello !"}
      {"type": "typing"}      -> en train d'écrire...
      {"type": "stop_typing"}

    Messages serveur -> client :
      {"type": "message", "content": "...", "from": "Alice", "timestamp": "..."}
      {"type": "typing", "user": "Bob"}
      {"type": "user_joined", "user": "Charlie"}
      {"type": "user_left",   "user": "Charlie"}
    """
    user = await authenticate_websocket(websocket, db)
    if not user:
        return

    room = f"project:{project_id}"
    await ws_manager.connect(websocket, user.id)
    await ws_manager.join_room(user.id, room)

    # Notifier les autres membres que l'utilisateur a rejoint
    await ws_manager.broadcast_to_room(
        room,
        {"type": "user_joined", "user": user.username, "project_id": project_id},
        exclude_user_id=user.id,
    )

    try:
        while True:
            data = await websocket.receive_json()
            msg_type = data.get("type")

            if msg_type == "message":
                content = data.get("content", "").strip()
                if not content or len(content) > 2000:
                    continue

                # Broadcast le message à tout le room (sauf l'expéditeur)
                await ws_manager.broadcast_to_room(
                    room,
                    {
                        "type": "message",
                        "content": content,
                        "from": user.username,
                        "user_id": user.id,
                        "timestamp": datetime.utcnow().isoformat(),
                        "project_id": project_id,
                    },
                    exclude_user_id=user.id,
                )

                # Confirmer l'envoi à l'expéditeur
                await websocket.send_json({
                    "type": "message_sent",
                    "content": content,
                    "timestamp": datetime.utcnow().isoformat(),
                })

            elif msg_type == "typing":
                # Indicateur "en train d'écrire..."
                await ws_manager.broadcast_to_room(
                    room,
                    {"type": "typing", "user": user.username},
                    exclude_user_id=user.id,
                )

            elif msg_type == "stop_typing":
                await ws_manager.broadcast_to_room(
                    room,
                    {"type": "stop_typing", "user": user.username},
                    exclude_user_id=user.id,
                )

    except WebSocketDisconnect:
        pass
    finally:
        await ws_manager.disconnect(websocket, user.id)
        # Notifier le départ
        await ws_manager.broadcast_to_room(
            room,
            {"type": "user_left", "user": user.username, "project_id": project_id},
        )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36.6 NOTIFICATIONS DEPUIS LES SERVICES REST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/services/notification_service.py
# Déclencher des notifications WS depuis les endpoints REST
# ═══════════════════════════════════════════════════════════════════════

from datetime import datetime
from app.websockets.manager import ws_manager

class NotificationService:
    """
    Service pour envoyer des notifications WebSocket
    depuis les endpoints REST (ex: après création d'une tâche).

    Les méthodes sont ASYNC mais ne bloquent PAS la réponse HTTP :
    On utilise asyncio.create_task() pour envoyer en arrière-plan.
    """

    @staticmethod
    async def task_created(task: dict, project_id: int, creator_username: str):
        """Notifie tous les membres du projet qu'une tâche a été créée."""
        await ws_manager.broadcast_to_room(
            room=f"project:{project_id}",
            message={
                "type": "task_created",
                "task": {
                    "id": task["id"],
                    "title": task["title"],
                    "priority": task["priority"],
                },
                "by": creator_username,
                "timestamp": datetime.utcnow().isoformat(),
            },
            exclude_user_id=task.get("created_by_id"),
        )

    @staticmethod
    async def task_assigned(task: dict, assignee_id: int, assigner_username: str):
        """Notifie l'utilisateur assigné à une tâche."""
        await ws_manager.send_to_user(
            user_id=assignee_id,
            message={
                "type": "task_assigned",
                "task": {
                    "id": task["id"],
                    "title": task["title"],
                    "priority": task["priority"],
                },
                "by": assigner_username,
                "timestamp": datetime.utcnow().isoformat(),
            }
        )

    @staticmethod
    async def task_completed(task: dict, project_id: int, completer_username: str):
        """Notifie le projet qu'une tâche est complète."""
        await ws_manager.broadcast_to_room(
            room=f"project:{project_id}",
            message={
                "type": "task_completed",
                "task": {"id": task["id"], "title": task["title"]},
                "by": completer_username,
                "timestamp": datetime.utcnow().isoformat(),
            }
        )

    @staticmethod
    async def task_updated(task_id: int, changes: dict, updater_username: str):
        """Notifie les abonnés d'une tâche qu'elle a été modifiée."""
        await ws_manager.broadcast_to_room(
            room=f"task:{task_id}",
            message={
                "type": "task_updated",
                "task_id": task_id,
                "changes": changes,
                "by": updater_username,
                "timestamp": datetime.utcnow().isoformat(),
            }
        )

# ── Intégration dans le TaskService ──────────────────────────────────
# Dans task_service.py, après create() :
#
# async def create(self, data: TaskCreate, created_by_id: int) -> Task:
#     task = await self._create_in_db(data, created_by_id)
#
#     # Déclencher la notification en arrière-plan
#     # asyncio.create_task() -> non bloquant, la réponse HTTP est retournée
#     # AVANT que la notification soit envoyée
#     if data.project_id:
#         asyncio.create_task(
#             NotificationService.task_created(
#                 task=self._task_to_dict(task),
#                 project_id=data.project_id,
#                 creator_username=current_user.username,
#             )
#         )
#
#     return task

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36.7 WEBSOCKETS MULTI-INSTANCES AVEC REDIS PUB/SUB
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PROBLÈME : avec 3 instances API, le ConnectionManager est en mémoire
de chaque instance. Un message envoyé à l'instance 1 n'est pas vu
par les connexions de l'instance 2 ou 3.

SOLUTION : Redis Pub/Sub — un broker de messages central.

  Client A     Client B     Client C
  v            v            v
  Instance 1   Instance 2   Instance 3
       ^v            ^v            ^v
           Redis Pub/Sub
           (canal "notifications")

  Quand instance 1 veut broadcaster -> publie dans Redis
  Toutes les instances (1, 2, 3) sont abonnées -> toutes reçoivent -> broadcastent à leurs clients

# ═══════════════════════════════════════════════════════════════════════
# app/websockets/redis_pubsub.py — Pub/Sub multi-instances
# ═══════════════════════════════════════════════════════════════════════

import asyncio
import json
import logging
import redis.asyncio as redis
from app.config import get_settings

settings = get_settings()
logger = logging.getLogger("taskflow.pubsub")

class RedisPubSubManager:
    """
    Gestionnaire Pub/Sub Redis pour les WebSockets multi-instances.

    Publication : publie un message dans un canal Redis
    Abonnement  : écoute les messages d'un canal et les diffuse
                  aux connexions WebSocket locales

    Architecture :
      REST endpoint -> publie dans Redis -> toutes les instances écoutent
      -> chaque instance envoie aux WebSocket connectés localement
    """

    CHANNEL_NOTIFICATIONS = "taskflow:notifications"
    CHANNEL_CHAT_PREFIX = "taskflow:chat:"

    def __init__(self):
        self._pub_client: redis.Redis | None = None
        self._sub_client: redis.Redis | None = None
        self._listener_task: asyncio.Task | None = None

    async def start(self):
        """Démarrer le listener Pub/Sub (appelé au startup de l'app)."""
        self._pub_client = redis.from_url(settings.redis_url, decode_responses=True)
        self._sub_client = redis.from_url(settings.redis_url, decode_responses=True)

        # Lancer le listener en arrière-plan
        self._listener_task = asyncio.create_task(self._listen())
        logger.info("Redis Pub/Sub démarré")

    async def stop(self):
        """Arrêter proprement le listener (appelé au shutdown)."""
        if self._listener_task:
            self._listener_task.cancel()
            try:
                await self._listener_task
            except asyncio.CancelledError:
                pass
        if self._pub_client:
            await self._pub_client.aclose()
        if self._sub_client:
            await self._sub_client.aclose()
        logger.info("Redis Pub/Sub arrêté")

    async def publish(self, channel: str, message: dict) -> None:
        """
        Publie un message dans un canal Redis.
        Toutes les instances abonnées le recevront.
        """
        if not self._pub_client:
            return
        try:
            await self._pub_client.publish(
                channel,
                json.dumps(message, default=str)
            )
        except Exception as e:
            logger.error(f"Erreur publication Pub/Sub: {e}")

    async def publish_notification(self, message: dict, room: str = "global") -> None:
        """Publie une notification pour un room donné."""
        await self.publish(
            self.CHANNEL_NOTIFICATIONS,
            {"room": room, "message": message}
        )

    async def _listen(self) -> None:
        """
        Boucle d'écoute des messages Redis.
        S'exécute en arrière-plan jusqu'à l'arrêt de l'app.
        """
        pubsub = self._sub_client.pubsub()

        # S'abonner aux canaux
        await pubsub.subscribe(
            self.CHANNEL_NOTIFICATIONS,
            # On peut s'abonner à des patterns aussi :
            # await pubsub.psubscribe("taskflow:chat:*")
        )

        logger.info(f"Abonné aux canaux Redis Pub/Sub")

        try:
            async for message in pubsub.listen():
                if message["type"] != "message":
                    continue   # Ignorer les messages de subscription

                try:
                    payload = json.loads(message["data"])
                    room = payload.get("room", "global")
                    msg = payload.get("message", {})

                    # Diffuser aux WebSocket locaux
                    await ws_manager.broadcast_to_room(room, msg)

                except Exception as e:
                    logger.error(f"Erreur traitement message Pub/Sub: {e}")

        except asyncio.CancelledError:
            await pubsub.unsubscribe()
            raise

# ── Instance globale ──────────────────────────────────────────────────
pubsub_manager = RedisPubSubManager()

# ── Intégration dans le lifespan app ─────────────────────────────────
# Dans app/main.py :
# @asynccontextmanager
# async def lifespan(app: FastAPI):
#     await pubsub_manager.start()  # <- Démarrer Pub/Sub
#     yield
#     await pubsub_manager.stop()   # <- Arrêter Pub/Sub

# ── Utilisation depuis les services ──────────────────────────────────
# Au lieu de ws_manager.broadcast_to_room() directement :
# await pubsub_manager.publish_notification(
#     message={"type": "task_created", ...},
#     room=f"project:{project_id}"
# )
# -> Tous les serveurs reçoivent et broadcastent localement

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36.8 RECONNEXION AUTOMATIQUE CÔTÉ CLIENT (JavaScript)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// taskflow-ws-client.js — Client WebSocket robuste avec reconnexion

class TaskFlowWebSocket {
  /**
   * Client WebSocket avec :
   * - Reconnexion automatique (backoff exponentiel)
   * - Heartbeat (ping/pong) pour détecter les connexions mortes
   * - File d'attente des messages envoyés hors connexion
   * - Gestion des événements propre (addEventListener style)
   */

  constructor(token, baseUrl = "ws://localhost:8000") {
    this.token = token;
    this.url = `${baseUrl}/ws/notifications?token=${token}`;
    this.ws = null;
    this.listeners = {};

    // Reconnexion
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 10;
    this.reconnectDelay = 1000;  // Délai initial : 1 seconde
    this.maxReconnectDelay = 30000;  // Délai max : 30 secondes
    this.reconnectTimer = null;

    // Heartbeat
    this.pingInterval = null;
    this.pingTimeout = null;
    this.PING_INTERVAL = 30000;  // Ping toutes les 30s
    this.PING_TIMEOUT = 5000;    // Timeout pong : 5s

    // File d'attente
    this.messageQueue = [];
    this.isConnected = false;

    this.connect();
  }

  connect() {
    console.log(`[WS] Connexion à ${this.url}...`);
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log("[WS] Connecté !");
      this.isConnected = true;
      this.reconnectAttempts = 0;
      this.reconnectDelay = 1000;  // Reset le délai

      // Envoyer les messages en attente
      while (this.messageQueue.length > 0) {
        this.ws.send(JSON.stringify(this.messageQueue.shift()));
      }

      // Démarrer le heartbeat
      this._startHeartbeat();

      this._emit("connected");
    };

    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);

      // Répondre au ping
      if (message.type === "pong") {
        clearTimeout(this.pingTimeout);
        return;
      }

      // Émettre l'événement correspondant
      this._emit(message.type, message);
      this._emit("message", message);  // Événement générique
    };

    this.ws.onclose = (event) => {
      console.log(`[WS] Déconnecté (code: ${event.code})`);
      this.isConnected = false;
      this._stopHeartbeat();
      this._emit("disconnected", { code: event.code });

      // Ne pas reconnecter si fermeture volontaire (1000) ou auth échouée (4001)
      if (event.code !== 1000 && event.code !== 4001) {
        this._scheduleReconnect();
      }
    };

    this.ws.onerror = (error) => {
      console.error("[WS] Erreur:", error);
      this._emit("error", error);
    };
  }

  _scheduleReconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error("[WS] Nombre max de tentatives atteint");
      this._emit("max_reconnect_attempts");
      return;
    }

    this.reconnectAttempts++;
    // Backoff exponentiel avec jitter (variation aléatoire)
    // -> évite que tous les clients se reconnectent en même temps
    const jitter = Math.random() * 1000;
    const delay = Math.min(
      this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + jitter,
      this.maxReconnectDelay
    );

    console.log(`[WS] Reconnexion dans ${(delay/1000).toFixed(1)}s (tentative ${this.reconnectAttempts})`);
    this._emit("reconnecting", { attempt: this.reconnectAttempts, delay });

    this.reconnectTimer = setTimeout(() => this.connect(), delay);
  }

  _startHeartbeat() {
    this.pingInterval = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: "ping" }));

        // Si pas de pong dans 5s -> connexion morte
        this.pingTimeout = setTimeout(() => {
          console.warn("[WS] Pas de pong -> fermeture forcée");
          this.ws.close();
        }, this.PING_TIMEOUT);
      }
    }, this.PING_INTERVAL);
  }

  _stopHeartbeat() {
    clearInterval(this.pingInterval);
    clearTimeout(this.pingTimeout);
  }

  send(message) {
    if (this.isConnected && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    } else {
      // Mettre en file d'attente si déconnecté
      this.messageQueue.push(message);
    }
  }

  on(event, callback) {
    if (!this.listeners[event]) this.listeners[event] = [];
    this.listeners[event].push(callback);
    return this;  // Chaînable
  }

  _emit(event, data = null) {
    (this.listeners[event] || []).forEach(cb => cb(data));
  }

  disconnect() {
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
    this._stopHeartbeat();
    if (this.ws) this.ws.close(1000, "Déconnexion volontaire");
  }

  joinProject(projectId) {
    this.send({ type: "join_project", project_id: projectId });
  }

  leaveProject(projectId) {
    this.send({ type: "leave_project", project_id: projectId });
  }
}

// ── Utilisation ───────────────────────────────────────────────────────
// const ws = new TaskFlowWebSocket(localStorage.getItem("access_token"));
//
// ws
//   .on("connected", () => console.log("Connecté !"))
//   .on("task_created", ({task, by}) => {
//     showNotification(`${by} a créé : ${task.title}`);
//     refreshTaskList();
//   })
//   .on("task_assigned", ({task, by}) => {
//     showNotification(`${by} vous a assigné : ${task.title}`);
//   })
//   .on("disconnected", () => showBanner("Reconnexion en cours..."))
//   .on("reconnecting", ({attempt}) => updateStatus(`Tentative ${attempt}...`));
//
// ws.joinProject(5);

================================================================================
         CHAPITRE 47-48 — OBSERVABILITÉ : LOGGING & MONITORING
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
47.1 STRUCTLOG — LOGGING JSON STRUCTURÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Logging classique Python : des strings -> difficile à parser et filtrer.
Structlog : des dicts -> JSON -> parsable par Datadog, ELK, Loki, etc.

  [X] Logging classique :
  2024-01-15 10:30:45 INFO     POST /api/v1/tasks completed in 45ms

  [OK] Structlog :
  {
    "timestamp": "2024-01-15T10:30:45.123Z",
    "level": "info",
    "event": "request_completed",
    "method": "POST",
    "path": "/api/v1/tasks",
    "status_code": 201,
    "duration_ms": 45.3,
    "user_id": 42,
    "request_id": "a3f5b2c8",
    "service": "taskflow-api",
    "version": "1.0.0"
  }

  -> Recherche instantanée : "tous les requests > 500ms de user_id=42"
    SELECT * WHERE user_id=42 AND duration_ms > 500

# ═══════════════════════════════════════════════════════════════════════
# app/core/logging_config.py — Configuration Structlog complète
# ═══════════════════════════════════════════════════════════════════════

  # pip install structlog

import structlog
import logging
import sys
from app.config import get_settings

settings = get_settings()

def configure_logging():
    """
    Configure structlog pour l'application.
    En développement  : logs lisibles et colorés dans le terminal
    En production     : logs JSON sur stdout (parsés par les outils de monitoring)
    """

    # ── Processors communs (appliqués dans l'ordre) ───────────────────
    shared_processors = [
        # Ajouter le niveau de log (info, warning, error...)
        structlog.stdlib.add_log_level,

        # Ajouter le timestamp ISO 8601 UTC
        structlog.processors.TimeStamper(fmt="iso", utc=True),

        # Ajouter les informations de la coroutine async si disponible
        structlog.contextvars.merge_contextvars,

        # Ajouter la localisation du code (fichier + ligne)
        structlog.processors.CallsiteParameterAdder(
            parameters=[
                structlog.processors.CallsiteParameter.FILENAME,
                structlog.processors.CallsiteParameter.LINENO,
                structlog.processors.CallsiteParameter.FUNC_NAME,
            ]
        ),
    ]

    if settings.is_development:
        # ── Développement : logs colorés et lisibles ──────────────────
        structlog.configure(
            processors=[
                *shared_processors,
                structlog.dev.ConsoleRenderer(colors=True),
            ],
            wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
            context_class=dict,
            logger_factory=structlog.PrintLoggerFactory(),
        )
    else:
        # ── Production : logs JSON sur stdout ─────────────────────────
        structlog.configure(
            processors=[
                *shared_processors,
                # Sérialiser les exceptions avec stacktrace
                structlog.processors.dict_tracebacks,
                # Formater en JSON
                structlog.processors.JSONRenderer(),
            ],
            wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
            context_class=dict,
            logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
        )

    # Rediriger le logging standard Python vers structlog
    logging.basicConfig(
        format="%(message)s",
        stream=sys.stdout,
        level=logging.DEBUG if settings.is_development else logging.INFO,
    )

# ── Créer un logger structlog ─────────────────────────────────────────
def get_logger(name: str = "taskflow") -> structlog.BoundLogger:
    """Retourne un logger structlog lié au contexte."""
    return structlog.get_logger(name)

# ── Middleware de logging avec contexte de requête ────────────────────

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
import time, uuid

logger = get_logger("taskflow.access")

class StructlogMiddleware(BaseHTTPMiddleware):
    """
    Middleware de logging avec structlog.
    Ajoute le request_id dans le contexte -> disponible dans TOUS les logs
    de cette requête (y compris dans les services).
    """

    async def dispatch(self, request: Request, call_next) -> Response:
        # Générer un ID de requête unique
        request_id = str(uuid.uuid4())[:8]
        start = time.perf_counter()

        # Lier le request_id au contexte de la requête
        # -> disponible dans tous les logs de cette requête
        structlog.contextvars.clear_contextvars()
        structlog.contextvars.bind_contextvars(
            request_id=request_id,
            method=request.method,
            path=request.url.path,
        )

        # Ajouter le request_id dans les headers de la requête
        request.state.request_id = request_id

        try:
            response = await call_next(request)
            duration_ms = (time.perf_counter() - start) * 1000

            # Niveau de log selon le status code
            log_fn = logger.warning if response.status_code >= 400 else logger.info

            log_fn(
                "http_request",
                status_code=response.status_code,
                duration_ms=round(duration_ms, 2),
                client_ip=request.headers.get("X-Forwarded-For", "unknown"),
            )

            # Ajouter les headers de debug dans la réponse
            response.headers["X-Request-ID"] = request_id
            response.headers["X-Process-Time"] = f"{duration_ms:.0f}ms"
            return response

        except Exception as exc:
            duration_ms = (time.perf_counter() - start) * 1000
            logger.error(
                "http_request_error",
                duration_ms=round(duration_ms, 2),
                error=str(exc),
                exc_info=True,
            )
            raise

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.1 PROMETHEUS — COLLECTE DE MÉTRIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Prometheus collecte des métriques numériques de l'application :
  - Nombre de requêtes HTTP
  - Temps de réponse (histogramme)
  - Nombre d'erreurs
  - Connexions DB actives
  - Taille du cache Redis
  - Tâches créées/complétées par heure

# ═══════════════════════════════════════════════════════════════════════
# app/core/metrics.py — Métriques Prometheus complètes
# ═══════════════════════════════════════════════════════════════════════

  # pip install prometheus-client

from prometheus_client import (
    Counter,        # Compteur monotone (toujours croissant)
    Histogram,      # Distribution de valeurs (ex: temps de réponse)
    Gauge,          # Valeur instantanée (peut monter et descendre)
    Summary,        # Résumé statistique
    CollectorRegistry,
    generate_latest,
    CONTENT_TYPE_LATEST,
)
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
import time

# ── Définition des métriques ──────────────────────────────────────────
# Chaque métrique a un nom et des "labels" pour filtrer

# Nombre total de requêtes HTTP (par méthode, path, status)
http_requests_total = Counter(
    name="taskflow_http_requests_total",
    documentation="Nombre total de requêtes HTTP",
    labelnames=["method", "endpoint", "status_code"],
    # Usage : http_requests_total.labels(method="GET", endpoint="/tasks", status_code="200").inc()
)

# Durée des requêtes HTTP (histogramme avec buckets en secondes)
http_request_duration_seconds = Histogram(
    name="taskflow_http_request_duration_seconds",
    documentation="Durée des requêtes HTTP en secondes",
    labelnames=["method", "endpoint"],
    # Buckets : intervalles de temps à mesurer
    buckets=[0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0],
    # -> P50, P95, P99 peuvent être calculés à partir des buckets
)

# Connexions WebSocket actives
ws_connections_active = Gauge(
    name="taskflow_ws_connections_active",
    documentation="Nombre de connexions WebSocket actives",
)

# Tâches créées (par priorité)
tasks_created_total = Counter(
    name="taskflow_tasks_created_total",
    documentation="Nombre de tâches créées",
    labelnames=["priority"],
)

# Tâches complétées
tasks_completed_total = Counter(
    name="taskflow_tasks_completed_total",
    documentation="Nombre de tâches complétées",
)

# Erreurs applicatives (par type)
app_errors_total = Counter(
    name="taskflow_app_errors_total",
    documentation="Nombre d'erreurs applicatives",
    labelnames=["error_type", "endpoint"],
)

# Utilisateurs actifs (Gauge : fluctue en temps réel)
users_active = Gauge(
    name="taskflow_users_active",
    documentation="Nombre d'utilisateurs actuellement connectés",
)

# Durée des requêtes DB
db_query_duration_seconds = Histogram(
    name="taskflow_db_query_duration_seconds",
    documentation="Durée des requêtes base de données en secondes",
    labelnames=["query_type"],  # select, insert, update, delete
    buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
)

# Hit/Miss du cache
cache_operations_total = Counter(
    name="taskflow_cache_operations_total",
    documentation="Opérations sur le cache Redis",
    labelnames=["operation", "result"],  # operation: get/set/del, result: hit/miss/error
)

# ── Middleware Prometheus ─────────────────────────────────────────────

class PrometheusMiddleware(BaseHTTPMiddleware):
    """
    Middleware qui collecte automatiquement les métriques HTTP.
    Enregistre chaque requête dans les Counters et Histograms.
    """

    # Endpoints à ne pas mesurer (évite de polluer les métriques)
    EXCLUDED_PATHS = {"/metrics", "/health", "/ping", "/favicon.ico"}

    async def dispatch(self, request: Request, call_next) -> Response:
        if request.url.path in self.EXCLUDED_PATHS:
            return await call_next(request)

        # Normaliser le path (remplacer les IDs par des placeholders)
        # /tasks/42 -> /tasks/{id} pour éviter N métriques différentes
        endpoint = self._normalize_path(request.url.path)

        start = time.perf_counter()
        status_code = 500  # Valeur par défaut en cas d'erreur

        try:
            response = await call_next(request)
            status_code = response.status_code
            return response
        except Exception:
            app_errors_total.labels(
                error_type="unhandled",
                endpoint=endpoint,
            ).inc()
            raise
        finally:
            duration = time.perf_counter() - start

            # Enregistrer le compteur de requêtes
            http_requests_total.labels(
                method=request.method,
                endpoint=endpoint,
                status_code=str(status_code),
            ).inc()

            # Enregistrer la durée
            http_request_duration_seconds.labels(
                method=request.method,
                endpoint=endpoint,
            ).observe(duration)

            # Enregistrer les erreurs 4xx et 5xx
            if status_code >= 400:
                app_errors_total.labels(
                    error_type="4xx" if status_code < 500 else "5xx",
                    endpoint=endpoint,
                ).inc()

    @staticmethod
    def _normalize_path(path: str) -> str:
        """
        Remplace les IDs numériques par {id} dans les paths.
        /api/v1/tasks/42       -> /api/v1/tasks/{id}
        /api/v1/projects/5/tasks/12 -> /api/v1/projects/{id}/tasks/{id}
        Évite d'avoir une métrique distincte pour chaque ID.
        """
        import re
        return re.sub(r"/\d+", "/{id}", path)

# ── Endpoint /metrics pour Prometheus ────────────────────────────────

from fastapi import APIRouter

metrics_router = APIRouter()

@metrics_router.get("/metrics", tags=["monitoring"])
async def metrics_endpoint():
    """
    Expose les métriques Prometheus.
    Accessible par le serveur Prometheus pour le scraping.

    IMPORTANT : En production, protéger cet endpoint !
    Options :
    1. Restreindre à l'IP du serveur Prometheus via Nginx
    2. Ajouter une authentification basique
    3. Exposer sur un port séparé non accessible publiquement
    """
    return Response(
        content=generate_latest(),
        media_type=CONTENT_TYPE_LATEST,
    )

# ── Utilisation dans les services ────────────────────────────────────
# Après création d'une tâche :
# tasks_created_total.labels(priority=task.priority.value).inc()

# Après complétion :
# tasks_completed_total.inc()

# Mise à jour des utilisateurs actifs :
# users_active.set(ws_manager.get_connected_users())

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.2 GRAFANA — DASHBOARDS ET ALERTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Grafana visualise les métriques Prometheus sous forme de graphiques.

# ── docker-compose.monitoring.yml ─────────────────────────────────────

version: "3.9"

services:
  prometheus:
    image: prom/prometheus:v2.48.0
    container_name: taskflow_prometheus
    restart: unless-stopped
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
      - "--storage.tsdb.retention.time=30d"   # Garder 30 jours de données
      - "--web.enable-lifecycle"               # Reload config sans redémarrer
    ports:
      - "9090:9090"
    networks:
      - taskflow_net

  grafana:
    image: grafana/grafana:10.2.0
    container_name: taskflow_grafana
    restart: unless-stopped
    environment:
      GF_SECURITY_ADMIN_USER: admin
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin123}
      GF_USERS_ALLOW_SIGN_UP: "false"
      GF_SERVER_ROOT_URL: https://monitoring.taskflow.io
    volumes:
      - grafana_data:/var/lib/grafana
      - ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
      - ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources:ro
    ports:
      - "3000:3000"
    depends_on:
      - prometheus
    networks:
      - taskflow_net

volumes:
  prometheus_data:
  grafana_data:

# ── monitoring/prometheus.yml ─────────────────────────────────────────

global:
  scrape_interval: 15s      # Collecter les métriques toutes les 15s
  evaluation_interval: 15s  # Évaluer les règles d'alerte toutes les 15s

scrape_configs:
  - job_name: "taskflow-api"
    static_configs:
      - targets: ["api:8000"]    # Scraper l'endpoint /metrics de l'API
    metrics_path: /metrics
    scheme: http

  - job_name: "nginx"
    static_configs:
      - targets: ["nginx:9113"]  # nginx-prometheus-exporter

  - job_name: "postgresql"
    static_configs:
      - targets: ["db_exporter:9187"]  # postgres_exporter

  - job_name: "redis"
    static_configs:
      - targets: ["redis_exporter:9121"]  # redis_exporter

# Règles d'alerte
rule_files:
  - "/etc/prometheus/alerts.yml"

alerting:
  alertmanagers:
    - static_configs:
        - targets: ["alertmanager:9093"]

# ── monitoring/alerts.yml — Règles d'alerte ───────────────────────────

groups:
  - name: taskflow_alerts
    rules:

      # Alerte si erreurs 5xx > 1% des requêtes
      - alert: HighErrorRate
        expr: |
          rate(taskflow_http_requests_total{status_code=~"5.."}[5m])
          /
          rate(taskflow_http_requests_total[5m]) > 0.01
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Taux d'erreurs élevé ({{ $value | humanizePercentage }})"
          description: "Plus de 1% des requêtes retournent des erreurs 5xx"

      # Alerte si P95 > 1 seconde
      - alert: SlowResponseTime
        expr: |
          histogram_quantile(0.95,
            rate(taskflow_http_request_duration_seconds_bucket[5m])
          ) > 1.0
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Temps de réponse lent (P95 = {{ $value }}s)"
          description: "Le 95e percentile des temps de réponse dépasse 1 seconde"

      # Alerte si API indisponible
      - alert: APIDown
        expr: up{job="taskflow-api"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "TaskFlow API indisponible"
          description: "L'API ne répond plus depuis plus d'1 minute"

      # Alerte si connexions DB saturées
      - alert: DatabaseConnectionsHigh
        expr: pg_stat_database_numbackends > 180
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Connexions PostgreSQL élevées ({{ $value }}/200)"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.3 OPENTELEMETRY — TRACING DISTRIBUÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le tracing distribué permet de suivre une requête à travers
plusieurs services (API -> DB -> Redis -> service externe).
Chaque opération est un "span" dans une "trace".

  # pip install opentelemetry-sdk opentelemetry-instrumentation-fastapi
  #             opentelemetry-instrumentation-sqlalchemy
  #             opentelemetry-exporter-otlp-proto-grpc

# ═══════════════════════════════════════════════════════════════════════
# app/core/tracing.py — Configuration OpenTelemetry
# ═══════════════════════════════════════════════════════════════════════

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.sdk.resources import Resource
from app.config import get_settings

settings = get_settings()

def setup_tracing(app) -> None:
    """
    Configure OpenTelemetry pour le tracing distribué.
    Envoie les traces vers Jaeger ou Grafana Tempo via OTLP.
    """
    # Ressource : identifie ce service dans les traces
    resource = Resource.create({
        "service.name": "taskflow-api",
        "service.version": settings.app_version,
        "deployment.environment": settings.environment,
    })

    # Provider de traces
    provider = TracerProvider(resource=resource)

    # Exporter vers Jaeger/Tempo via OTLP
    otlp_exporter = OTLPSpanExporter(
        endpoint="http://jaeger:4317",  # ou Grafana Tempo
        insecure=True,
    )
    provider.add_span_processor(BatchSpanProcessor(otlp_exporter))

    # Définir le provider global
    trace.set_tracer_provider(provider)

    # Instrumenter FastAPI automatiquement (spans pour chaque requête)
    FastAPIInstrumentor.instrument_app(
        app,
        tracer_provider=provider,
        excluded_urls="/health,/ping,/metrics",
    )

    # Instrumenter SQLAlchemy (spans pour chaque requête SQL)
    SQLAlchemyInstrumentor().instrument(
        tracer_provider=provider,
        enable_commenter=True,    # Ajoute des commentaires SQL avec le trace_id
    )

    # Instrumenter Redis (spans pour chaque opération Redis)
    RedisInstrumentor().instrument(tracer_provider=provider)

# ── Tracer personnalisé pour les spans manuels ────────────────────────

tracer = trace.get_tracer("taskflow")

# Dans les services, ajouter des spans personnalisés :
# async def heavy_computation(data: dict) -> dict:
#     with tracer.start_as_current_span("heavy_computation") as span:
#         span.set_attribute("data.size", len(data))
#         result = await _compute(data)
#         span.set_attribute("result.size", len(result))
#         return result

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48.4 EXERCICES — PARTIE 9
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

NIVEAU FACILE
-------------
Ex 48.1 : Implémente un endpoint WebSocket simple GET /ws/echo qui
  retourne chaque message reçu en miroir avec un timestamp ajouté.
  Teste-le depuis Postman ou le navigateur (WebSocket tester).

Ex 48.2 : Ajoute au ConnectionManager une méthode get_user_rooms(user_id)
  qui retourne la liste des rooms où est l'utilisateur, et une méthode
  is_user_online(user_id) -> bool.

Ex 48.3 : Crée un endpoint GET /ws/stats qui retourne les statistiques
  du ConnectionManager : nb de connexions, rooms actifs, messages envoyés.

NIVEAU INTERMÉDIAIRE
--------------------
Ex 48.4 : Implémente l'authentification WebSocket via cookie httpOnly
  plutôt que via query param. Pourquoi est-ce plus sécurisé ?
  Montre comment configurer le cookie depuis le login REST.

Ex 48.5 : Crée un test d'intégration pour le WebSocket de notifications :
  a) Connecter 2 utilisateurs avec pytest-asyncio
  b) Créer une tâche depuis user_A via l'endpoint REST
  c) Vérifier que user_B (dans le même projet) reçoit la notification

Ex 48.6 : Ajoute les métriques Prometheus dans le TaskService :
  Incrémenter tasks_created_total après création,
  tasks_completed_total après complétion,
  mesurer db_query_duration_seconds pour les requêtes de liste.

NIVEAU AVANCÉ
-------------
Ex 48.7 : Implémente le Pub/Sub Redis complet et teste-le en simulant
  deux instances de l'API avec deux ConnectionManagers séparés.
  Publie un message sur l'instance 1 et vérifie qu'il est reçu
  par les connexions de l'instance 2.

Ex 48.8 : Crée un dashboard Grafana complet pour TaskFlow avec 6 panels :
  a) Requêtes/seconde par endpoint (graph)
  b) Taux d'erreurs 4xx et 5xx (gauge)
  c) P50/P95/P99 des temps de réponse (graph)
  d) Connexions WebSocket actives (gauge)
  e) Tâches créées/complétées par heure (bar chart)
  f) Utilisation mémoire et CPU (graph)

Ex 48.9 : Implémente un système de "présence" temps réel :
  - Quand un utilisateur ouvre une tâche, il apparaît dans les "viewers"
  - Les autres membres du projet voient en live qui consulte quoi
  - Utiliser WebSockets + Redis pour la persistence multi-instances
  Endpoints : subscribe_task_view / unsubscribe_task_view
  Broadcast : {"type": "task_viewers", "task_id": 42, "viewers": ["Alice", "Bob"]}

================================================================================
                         RÉCAPITULATIF PARTIE 9
================================================================================

Dans cette partie, tu as appris :

[OK] WebSockets fondamentaux :
   - Protocole, cycle de vie, accept/receive/send
   - Types de messages (text, bytes, JSON)

[OK] ConnectionManager complet :
   - Connexions multiples par utilisateur (multi-onglets)
   - Rooms (project:5, task:42, global)
   - send_to_user, broadcast_to_room, broadcast_global
   - Nettoyage des connexions mortes

[OK] Authentification WebSocket :
   - Token JWT en query param avec fermeture sur 4001

[OK] Notifications temps réel TaskFlow :
   - task_created, task_assigned, task_completed
   - Chat par projet avec indicateur "en train d'écrire"
   - asyncio.create_task() pour notifications non-bloquantes

[OK] Pub/Sub Redis multi-instances :
   - Publication d'un canal Redis depuis n'importe quelle instance
   - Listener en arrière-plan sur toutes les instances
   - Scalabilité horizontale des WebSockets

[OK] Client JavaScript robuste :
   - Reconnexion automatique avec backoff exponentiel + jitter
   - Heartbeat ping/pong
   - File d'attente des messages hors connexion

[OK] Structlog — logs JSON structurés :
   - Configuration dev (coloré) vs prod (JSON)
   - Contexte de requête partagé (request_id dans tous les logs)
   - Middleware d'accès structlog

[OK] Prometheus — métriques :
   - Counter, Histogram, Gauge, Summary
   - Middleware auto-collection HTTP
   - Normalisation des paths (/{id})
   - Endpoint /metrics

[OK] Grafana — dashboards :
   - Docker Compose complet (Prometheus + Grafana)
   - Règles d'alertes (5xx, P95 lent, API down)

[OK] OpenTelemetry — tracing distribué :
   - Spans automatiques (FastAPI, SQLAlchemy, Redis)
   - Export vers Jaeger/Tempo via OTLP

[RAPIDE] PROCHAINE ÉTAPE : Partie 10 — Projet Final Complet
   - Architecture finale de TaskFlow API
   - Toutes les fonctionnalités assemblées
   - CI/CD avec GitHub Actions
   - Checklist de déploiement en production

================================================================================
                           FIN DE LA PARTIE 9
                    Passe à fastapi_master_part_10.txt
================================================================================

================================================================================
  GUIDE FASTAPI COMPLET — PARTIE 10 : PROJET FINAL — TASKFLOW COMPLET
  Guide de Référence Professionnel — Niveau Débutant -> Expert
================================================================================

[OBJECTIF] PROJET FINAL : TaskFlow API — Production Ready
   Cette partie assemble TOUT ce que nous avons appris en un projet
   cohérent, documenté et déployable. Tu trouveras ici :
   - Architecture finale complète avec tous les modules
   - Chaque fichier du projet dans son état final
   - CI/CD avec GitHub Actions
   - Checklist de déploiement production
   - Guide de debugging et résolution des problèmes courants
   - Références rapides (cheat sheets)

================================================================================
     CHAPITRE 55 — ARCHITECTURE FINALE TASKFLOW : VUE D'ENSEMBLE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
55.1 RAPPEL DU PARCOURS — CE QUE NOUS AVONS CONSTRUIT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  Partie 1  : HTTP, JSON, REST, Python async         -> Fondations
  Partie 2  : FastAPI, routes, handlers, docs        -> Premier API
  Partie 3  : Path/Query params, Pydantic v2         -> Validation
  Partie 4  : Réponses, PostgreSQL, SQLAlchemy 2.0   -> Base de données
  Partie 5  : OAuth2, JWT, bcrypt, dépendances       -> Authentification
  Partie 6  : Architecture, Repository, Middleware   -> Structure pro
  Partie 7  : pytest, TDD, coverage, fixtures        -> Tests
  Partie 8  : Redis cache, Docker, Nginx, HTTPS      -> Performance & Deploy
  Partie 9  : WebSockets, Prometheus, Structlog      -> Temps réel & Monitoring
  Partie 10 : Assemblage final, CI/CD, production    -> Projet complet

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
55.2 ARCHITECTURE COMPLÈTE — FLUX DE DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ┌─────────────────────────────────────────────────────────────────────┐
  │                     CLIENTS TASKFLOW                                │
  │   React Web App    Mobile App    CLI Tool    Services Externes       │
  └───────────┬─────────────┬─────────────┬──────────────┬─────────────┘
              │             │             │              │
              [BLACK_DOWN-POINTING_TRIANGLE]             [BLACK_DOWN-POINTING_TRIANGLE]             [BLACK_DOWN-POINTING_TRIANGLE]              [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────────────────────────────────┐
  │                   NGINX (Reverse Proxy)                             │
  │   HTTPS/TLS termination · Rate limiting · Gzip · Static files      │
  │   HTTP/2 · Load balancing · Security headers                       │
  └─────────────────────────┬───────────────────────────────────────────┘
                            │
              ┌─────────────┴─────────────┐
              [BLACK_DOWN-POINTING_TRIANGLE]                           [BLACK_DOWN-POINTING_TRIANGLE]
  ┌───────────────────┐       ┌───────────────────┐
  │  FastAPI Instance │       │  FastAPI Instance │  <- Scalable horizontalement
  │      (API 1)      │       │      (API 2)      │
  │                   │       │                   │
  │  ┌─────────────┐  │       │  ┌─────────────┐  │
  │  │  Middleware │  │       │  │  Middleware │  │
  │  │  Stack      │  │       │  │  Stack      │  │
  │  │ ·CORS       │  │       │  │ ·CORS       │  │
  │  │ ·Auth       │  │       │  │ ·Auth       │  │
  │  │ ·Logging    │  │       │  │ ·Logging    │  │
  │  │ ·Timing     │  │       │  │ ·Timing     │  │
  │  │ ·Prometheus │  │       │  │ ·Prometheus │  │
  │  └──────┬──────┘  │       │  └──────┬──────┘  │
  │         │         │       │         │         │
  │  ┌──────[BLACK_DOWN-POINTING_TRIANGLE]──────┐  │       │  ┌──────[BLACK_DOWN-POINTING_TRIANGLE]──────┐  │
  │  │   Routers   │  │       │  │   Routers   │  │
  │  │  /auth      │  │       │  │  /auth      │  │
  │  │  /tasks     │  │       │  │  /tasks     │  │
  │  │  /users     │  │       │  │  /users     │  │
  │  │  /projects  │  │       │  │  /projects  │  │
  │  │  /ws        │  │       │  │  /ws        │  │
  │  └──────┬──────┘  │       │  └──────┬──────┘  │
  │         │         │       │         │         │
  │  ┌──────[BLACK_DOWN-POINTING_TRIANGLE]──────┐  │       │  ┌──────[BLACK_DOWN-POINTING_TRIANGLE]──────┐  │
  │  │  Services   │  │       │  │  Services   │  │
  │  │ TaskService │  │       │  │ TaskService │  │
  │  │ UserService │  │       │  │ UserService │  │
  │  │ AuthService │  │       │  │ AuthService │  │
  │  │ NotifService│  │       │  │ NotifService│  │
  │  └──────┬──────┘  │       │  └──────┬──────┘  │
  │         │         │       │         │         │
  │  ┌──────[BLACK_DOWN-POINTING_TRIANGLE]──────┐  │       │  ┌──────[BLACK_DOWN-POINTING_TRIANGLE]──────┐  │
  │  │Repositories │  │       │  │Repositories │  │
  │  └──────┬──────┘  │       │  └──────┬──────┘  │
  └─────────┼─────────┘       └─────────┼─────────┘
            │                           │
            └─────────────┬─────────────┘
                          │
     ┌────────────────────┼────────────────────┐
     [BLACK_DOWN-POINTING_TRIANGLE]                    [BLACK_DOWN-POINTING_TRIANGLE]                    [BLACK_DOWN-POINTING_TRIANGLE]
  ┌──────────┐        ┌───────┐        ┌───────────┐
  │PostgreSQL│        │ Redis │        │ Redis     │
  │(données) │        │(cache)│        │(Pub/Sub + │
  │          │        │       │        │ sessions) │
  └──────────┘        └───────┘        └───────────┘

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
55.3 ARBORESCENCE COMPLÈTE DU PROJET FINAL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  taskflow/
  ├── app/
  │   ├── __init__.py
  │   ├── main.py                        <- Point d'entrée ASGI (assemblage)
  │   ├── config.py                      <- Settings Pydantic par environnement
  │   │
  │   ├── api/
  │   │   ├── deps.py                    <- Dépendances (auth, db, cache, pagination)
  │   │   └── v1/
  │   │       ├── router.py              <- Agrégateur des routers v1
  │   │       ├── auth.py                <- login, register, refresh, me
  │   │       ├── tasks.py               <- CRUD tâches + actions (complete, assign)
  │   │       ├── users.py               <- CRUD utilisateurs
  │   │       ├── projects.py            <- CRUD projets
  │   │       ├── websockets.py          <- /ws/notifications, /ws/chat/{id}
  │   │       └── metrics.py             <- /metrics (Prometheus)
  │   │
  │   ├── services/
  │   │   ├── auth_service.py            <- login, register, refresh, change_password
  │   │   ├── task_service.py            <- CRUD + cache + notifications
  │   │   ├── user_service.py            <- CRUD + avatars
  │   │   ├── project_service.py         <- CRUD projets + membres
  │   │   └── notification_service.py    <- Push WS depuis les services REST
  │   │
  │   ├── repositories/
  │   │   ├── base.py                    <- BaseRepository[T] générique
  │   │   ├── task_repository.py         <- find_with_filters, aggregate_by_status
  │   │   ├── user_repository.py         <- get_by_email, search
  │   │   └── project_repository.py
  │   │
  │   ├── models/
  │   │   ├── mixins.py                  <- TimestampMixin, SoftDeleteMixin
  │   │   ├── user.py                    <- User, UserRole
  │   │   ├── task.py                    <- Task, PriorityEnum, StatusEnum
  │   │   ├── project.py                 <- Project
  │   │   └── comment.py                 <- Comment
  │   │
  │   ├── schemas/
  │   │   ├── common.py                  <- PaginationMeta, ErrorResponse
  │   │   ├── task.py                    <- TaskBase/Create/Update/Response/Detail
  │   │   ├── user.py                    <- UserBase/Create/Update/Response
  │   │   └── project.py                 <- ProjectBase/Create/Update/Response
  │   │
  │   ├── core/
  │   │   ├── security.py                <- hash_password, JWT create/decode
  │   │   ├── exceptions.py              <- AppError et sous-classes métier
  │   │   ├── metrics.py                 <- Compteurs Prometheus
  │   │   ├── logging_config.py          <- Structlog configuration
  │   │   └── tracing.py                 <- OpenTelemetry setup
  │   │
  │   ├── middleware/
  │   │   ├── logging.py                 <- StructlogMiddleware
  │   │   ├── timing.py                  <- TimingMiddleware + PrometheusMiddleware
  │   │   └── security.py                <- SecurityHeadersMiddleware
  │   │
  │   ├── websockets/
  │   │   ├── manager.py                 <- ConnectionManager (rooms, broadcast)
  │   │   ├── auth.py                    <- authenticate_websocket()
  │   │   └── redis_pubsub.py            <- RedisPubSubManager multi-instances
  │   │
  │   └── infrastructure/
  │       ├── database.py                <- engine, session, Base, get_db()
  │       └── cache.py                   <- CacheService, get_cache()
  │
  ├── tests/
  │   ├── conftest.py                    <- Fixtures: DB, client, users, tokens
  │   ├── factories.py                   <- UserFactory, TaskFactory (Factory Boy)
  │   ├── unit/
  │   │   ├── test_security.py
  │   │   ├── test_task_service.py
  │   │   └── test_validators.py
  │   └── integration/
  │       ├── test_auth_api.py
  │       ├── test_tasks_api.py
  │       ├── test_users_api.py
  │       └── test_websockets.py
  │
  ├── alembic/
  │   ├── env.py
  │   └── versions/
  │       ├── 001_initial_schema.py
  │       └── 002_add_comments_table.py
  │
  ├── monitoring/
  │   ├── prometheus.yml
  │   ├── alerts.yml
  │   └── grafana/
  │       ├── datasources/prometheus.yml
  │       └── dashboards/taskflow.json
  │
  ├── nginx/
  │   ├── nginx.conf
  │   └── conf.d/
  │       ├── taskflow.conf
  │       └── proxy_params.conf
  │
  ├── scripts/
  │   ├── start.sh                       <- Script de démarrage production
  │   ├── migrate.sh                     <- Lancer les migrations
  │   └── renew_certs.sh                 <- Renouvellement Let's Encrypt
  │
  ├── .github/
  │   └── workflows/
  │       ├── ci.yml                     <- Tests automatiques sur PR
  │       └── deploy.yml                 <- Déploiement automatique sur main
  │
  ├── .env.example
  ├── .env.test
  ├── .gitignore
  ├── .dockerignore
  ├── Dockerfile
  ├── docker-compose.yml
  ├── docker-compose.prod.yml
  ├── docker-compose.monitoring.yml
  ├── docker-compose.test.yml
  ├── alembic.ini
  ├── pyproject.toml
  ├── requirements.txt
  ├── requirements-dev.txt
  └── README.md

================================================================================
     CHAPITRE 55 SUITE — FICHIERS FINAUX CLÉS
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
55.4 app/main.py — VERSION FINALE COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/main.py — Point d'entrée ASGI final, tout assemblé
# ═══════════════════════════════════════════════════════════════════════

from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

from app.config import get_settings
from app.infrastructure.database import init_db
from app.infrastructure.cache import get_redis, close_redis

# ── Routers ───────────────────────────────────────────────────────────
from app.api.v1.router import api_v1_router

# ── Middleware ────────────────────────────────────────────────────────
from app.middleware.logging import StructlogMiddleware
from app.middleware.timing import TimingMiddleware, PrometheusMiddleware
from app.middleware.security import SecurityHeadersMiddleware

# ── WebSockets Pub/Sub ────────────────────────────────────────────────
from app.websockets.redis_pubsub import pubsub_manager

# ── Exceptions et logging ─────────────────────────────────────────────
from app.core.exceptions import AppError
from app.core.logging_config import configure_logging, get_logger
from app.core.tracing import setup_tracing

settings = get_settings()
logger = get_logger("taskflow.main")

# ── Cycle de vie complet ──────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    Gère le cycle de vie complet de l'application.
    STARTUP  : initialise toutes les connexions et ressources
    SHUTDOWN : ferme proprement toutes les ressources
    """
    # ════════════════════════════
    # STARTUP
    # ════════════════════════════
    configure_logging()
    logger.info(
        "app_starting",
        version=settings.app_version,
        environment=settings.environment,
    )

    # 1. Base de données
    try:
        await init_db()
        logger.info("database_ready")
    except Exception as e:
        logger.error("database_init_failed", error=str(e))
        raise

    # 2. Cache Redis
    try:
        redis_client = await get_redis()
        await redis_client.ping()
        logger.info("redis_ready")
    except Exception as e:
        logger.warning("redis_unavailable", error=str(e))
        # Redis indisponible -> démarrer quand même sans cache

    # 3. WebSocket Pub/Sub
    try:
        await pubsub_manager.start()
        logger.info("pubsub_ready")
    except Exception as e:
        logger.warning("pubsub_unavailable", error=str(e))

    logger.info("app_started", port=8000)

    yield  # <- L'application tourne ici

    # ════════════════════════════
    # SHUTDOWN
    # ════════════════════════════
    logger.info("app_stopping")

    # Fermer le Pub/Sub
    await pubsub_manager.stop()

    # Fermer Redis
    await close_redis()

    logger.info("app_stopped")

# ── Instance FastAPI ──────────────────────────────────────────────────
app = FastAPI(
    title=settings.app_name,
    version=settings.app_version,
    description="""
## [RAPIDE] TaskFlow API

API SaaS de gestion de tâches collaborative.

### Fonctionnalités
* [SECURISE] **Auth** : Inscription, connexion JWT, refresh tokens, rôles
* [OK] **Tâches** : CRUD complet, filtres avancés, pagination, soft delete
* [DOSSIER] **Projets** : Espaces de travail partagés, membres, permissions
* [NOTIF] **Temps réel** : Notifications WebSocket, chat par projet
* [GRAPHIQUE] **Stats** : Métriques, tableaux de bord, exports CSV

### Authentification
1. `POST /api/v1/auth/register` -> créer un compte
2. `POST /api/v1/auth/login`    -> obtenir les tokens
3. Cliquer **Authorize [VERROUILLE]** et saisir : `Bearer {access_token}`
    """,
    lifespan=lifespan,
    # Documentation désactivée en production
    docs_url="/docs" if not settings.is_production else None,
    redoc_url="/redoc" if not settings.is_production else None,
    openapi_url="/openapi.json" if not settings.is_production else None,
    # Contacts et licence
    contact={
        "name": "TaskFlow Team",
        "url": "https://taskflow.io",
        "email": "api@taskflow.io",
    },
    license_info={"name": "MIT"},
)

# ── Configuration OpenTelemetry ───────────────────────────────────────
if not settings.is_development:
    setup_tracing(app)

# ── Middlewares ───────────────────────────────────────────────────────
# Ordre d'enregistrement INVERSÉ = ordre d'exécution
# Dernier enregistré = premier exécuté sur la requête entrante

# 1er exécuté : CORS (avant tout)
app.add_middleware(CORSMiddleware, **settings.cors_config)

# 2ème : Timing + métriques Prometheus
app.add_middleware(PrometheusMiddleware)
app.add_middleware(TimingMiddleware, slow_request_threshold_ms=500)

# 3ème : Logging structuré (avec request_id)
app.add_middleware(StructlogMiddleware, exclude_paths={"/health", "/ping", "/metrics"})

# 4ème (dernier exécuté) : Headers de sécurité
app.add_middleware(SecurityHeadersMiddleware)

# ── Handlers d'exceptions globaux ────────────────────────────────────
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
    """Format uniforme pour toutes les erreurs métier."""
    logger.warning(
        "app_error",
        status_code=exc.status_code,
        error_code=exc.detail.get("code") if isinstance(exc.detail, dict) else None,
        path=request.url.path,
    )
    return JSONResponse(status_code=exc.status_code, content=exc.detail)

@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
    """Format uniforme pour les erreurs de validation Pydantic."""
    return JSONResponse(
        status_code=422,
        content={
            "code": "VALIDATION_ERROR",
            "message": "Les données envoyées sont invalides",
            "errors": [
                {
                    "field": " -> ".join(str(loc) for loc in e["loc"]),
                    "message": e["msg"],
                    "type": e["type"],
                }
                for e in exc.errors()
            ],
        }
    )

@app.exception_handler(Exception)
async def global_error_handler(request: Request, exc: Exception):
    """Handler de dernier recours pour les erreurs non gérées."""
    logger.error(
        "unhandled_error",
        path=request.url.path,
        error=str(exc),
        error_type=type(exc).__name__,
        exc_info=True,
    )
    return JSONResponse(
        status_code=500,
        content={
            "code": "INTERNAL_ERROR",
            "message": "Une erreur inattendue s'est produite. Nous avons été notifiés.",
        }
    )

# ── Inclusion des routers ─────────────────────────────────────────────
app.include_router(api_v1_router)   # Tous les routers sous /api/v1

# ── Routes système ────────────────────────────────────────────────────
@app.get("/", tags=["system"], include_in_schema=False)
async def root():
    """Racine de l'API — redirige vers la documentation."""
    return {
        "name": settings.app_name,
        "version": settings.app_version,
        "docs": "/docs" if not settings.is_production else None,
        "health": "/health",
    }

@app.get("/health", tags=["system"])
async def health():
    """
    Health check pour les load balancers et monitors.
    Vérifie la connexion à la DB et au cache.
    """
    from app.infrastructure.database import AsyncSessionLocal
    from sqlalchemy import text

    checks = {"api": "healthy", "version": settings.app_version}

    # Vérifier la DB
    try:
        async with AsyncSessionLocal() as session:
            await session.execute(text("SELECT 1"))
        checks["database"] = "healthy"
    except Exception:
        checks["database"] = "unhealthy"

    # Vérifier Redis
    try:
        redis = await get_redis()
        await redis.ping()
        checks["cache"] = "healthy"
    except Exception:
        checks["cache"] = "degraded"  # Dégradé mais l'API fonctionne sans cache

    # Status global
    all_healthy = all(v in ("healthy", "degraded") for v in checks.values())
    checks["status"] = "healthy" if all_healthy else "unhealthy"

    return JSONResponse(
        status_code=200 if all_healthy else 503,
        content=checks,
    )

@app.get("/ping", tags=["system"], include_in_schema=False)
async def ping():
    """Ping ultra-léger pour les monitors de disponibilité."""
    return "pong"

# ── Point d'entrée direct ─────────────────────────────────────────────
if __name__ == "__main__":         # pragma: no cover
    import uvicorn
    uvicorn.run(
        "app.main:app",
        host="0.0.0.0",
        port=8000,
        reload=settings.is_development,
        log_level="debug" if settings.is_development else "info",
        access_log=False,   # On gère les logs d'accès dans le middleware
    )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
55.5 app/api/v1/router.py — ROUTER AGRÉGATEUR FINAL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/api/v1/router.py
# ═══════════════════════════════════════════════════════════════════════

from fastapi import APIRouter
from app.api.v1 import auth, tasks, users, projects, websockets, metrics

api_v1_router = APIRouter(prefix="/api/v1")

# Chaque router apporte son prefix et ses tags
api_v1_router.include_router(auth.router)       # /api/v1/auth/*
api_v1_router.include_router(tasks.router)      # /api/v1/tasks/*
api_v1_router.include_router(users.router)      # /api/v1/users/*
api_v1_router.include_router(projects.router)   # /api/v1/projects/*
api_v1_router.include_router(websockets.router) # /api/v1/ws/*
api_v1_router.include_router(metrics.router)    # /metrics (pas de prefix)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
55.6 app/api/deps.py — DÉPENDANCES FINALES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════════════════
# app/api/deps.py — Toutes les dépendances en un fichier
# ═══════════════════════════════════════════════════════════════════════

from fastapi import Depends, HTTPException, status, Query
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Annotated, Optional

from app.infrastructure.database import get_db
from app.infrastructure.cache import CacheService, get_cache
from app.models.user import User
from app.core.security import decode_access_token
from app.core.exceptions import (
    ForbiddenError, InvalidCredentialsError
)

# ── OAuth2 bearer ─────────────────────────────────────────────────────
oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="/api/v1/auth/login",
    auto_error=True,
)

# ── Dépendance : utilisateur courant ─────────────────────────────────
async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
    """Décode le JWT et charge l'utilisateur depuis la DB."""
    try:
        token_data = decode_access_token(token)
    except ValueError:
        raise InvalidCredentialsError()

    result = await db.execute(
        select(User).where(
            User.id == token_data.user_id,
            User.is_active == True,  # noqa: E712
        )
    )
    user = result.scalar_one_or_none()

    if not user:
        raise InvalidCredentialsError()

    return user

# ── Factory de dépendance de rôle ─────────────────────────────────────
def require_role(*roles: str):
    """Génère une dépendance qui vérifie le rôle de l'utilisateur."""
    async def checker(
        current_user: Annotated[User, Depends(get_current_user)]
    ) -> User:
        if current_user.role not in roles:
            raise ForbiddenError(
                f"Rôle requis : {', '.join(roles)}. "
                f"Votre rôle : {current_user.role}"
            )
        return current_user
    return checker

# ── Dépendances de pagination ─────────────────────────────────────────
class PaginationParams:
    def __init__(
        self,
        page: int = Query(1, ge=1),
        per_page: int = Query(20, ge=1, le=100),
    ):
        self.page = page
        self.per_page = per_page
        self.skip = (page - 1) * per_page

    def meta(self, total: int) -> dict:
        total_pages = -(-total // self.per_page)
        return {
            "page": self.page,
            "per_page": self.per_page,
            "total": total,
            "total_pages": total_pages,
            "has_next": self.page < total_pages,
            "has_prev": self.page > 1,
        }

# ── Types annotés ─────────────────────────────────────────────────────
# Ces aliases évitent de répéter Annotated[...] dans chaque route

DBSession    = Annotated[AsyncSession, Depends(get_db)]
Cache        = Annotated[CacheService, Depends(get_cache)]
CurrentUser  = Annotated[User, Depends(get_current_user)]
AdminUser    = Annotated[User, Depends(require_role("admin", "superuser"))]
SuperUser    = Annotated[User, Depends(require_role("superuser"))]
Pagination   = Annotated[PaginationParams, Depends()]

================================================================================
          CHAPITRE 56 — CI/CD AVEC GITHUB ACTIONS
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
56.1 CI — PIPELINE DE TESTS AUTOMATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# .github/workflows/ci.yml
# Déclenché à chaque push sur n'importe quelle branche
# et à chaque PR vers main

name: CI — Tests & Qualité

on:
  push:
    branches: ["**"]
  pull_request:
    branches: [main, develop]

env:
  PYTHON_VERSION: "3.11"

jobs:
  # ─── Job 1 : Linting et typage ──────────────────────────────────────
  lint:
    name: "Linting & Type Check"
    runs-on: ubuntu-latest

    steps:
      - name: Checkout du code
        uses: actions/checkout@v4

      - name: Setup Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip   # Cache les dépendances pip entre les runs

      - name: Installer les dépendances de dev
        run: |
          pip install --upgrade pip
          pip install -r requirements-dev.txt

      - name: Ruff — Linting
        run: ruff check app/ tests/

      - name: Ruff — Format check
        run: ruff format --check app/ tests/

      - name: Mypy — Type checking
        run: mypy app/ --ignore-missing-imports

  # ─── Job 2 : Tests unitaires ─────────────────────────────────────────
  unit-tests:
    name: "Tests Unitaires"
    runs-on: ubuntu-latest
    needs: lint   # Ne s'exécute que si lint passe

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip

      - run: pip install -r requirements-dev.txt

      - name: Tests unitaires (sans infrastructure)
        run: |
          pytest tests/unit/ \
            --cov=app \
            --cov-report=xml:coverage-unit.xml \
            -v --tb=short
        env:
          SECRET_KEY: "test-secret-key-for-ci-only-not-for-production"
          DATABASE_URL: "sqlite+aiosqlite:///:memory:"

      - name: Upload coverage unitaire
        uses: codecov/codecov-action@v4
        with:
          files: coverage-unit.xml
          flags: unit

  # ─── Job 3 : Tests d'intégration ─────────────────────────────────────
  integration-tests:
    name: "Tests d'Intégration"
    runs-on: ubuntu-latest
    needs: lint

    services:
      # PostgreSQL pour les tests d'intégration
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_pass
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      # Redis pour les tests d'intégration
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip

      - run: pip install -r requirements-dev.txt

      - name: Appliquer les migrations Alembic
        run: alembic upgrade head
        env:
          DATABASE_URL: "postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db"

      - name: Tests d'intégration
        run: |
          pytest tests/integration/ \
            --cov=app \
            --cov-report=xml:coverage-integration.xml \
            --cov-fail-under=80 \
            -v --tb=short
        env:
          DATABASE_URL: "postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db"
          REDIS_URL: "redis://localhost:6379/0"
          SECRET_KEY: "test-secret-key-for-ci-only"
          ENVIRONMENT: "test"

      - name: Upload coverage intégration
        uses: codecov/codecov-action@v4
        with:
          files: coverage-integration.xml
          flags: integration

  # ─── Job 4 : Build Docker ─────────────────────────────────────────────
  docker-build:
    name: "Build Image Docker"
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]

    steps:
      - uses: actions/checkout@v4

      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build image (sans push)
        uses: docker/build-push-action@v5
        with:
          context: .
          target: runtime          # Stage de production
          push: false              # Juste builder, pas pousser
          cache-from: type=gha    # Cache GitHub Actions
          cache-to: type=gha,mode=max
          tags: taskflow-api:ci-${{ github.sha }}

      - name: Test health check de l'image
        run: |
          docker run -d \
            --name test_api \
            -e DATABASE_URL="sqlite+aiosqlite:///./test.db" \
            -e SECRET_KEY="test-key" \
            -e ENVIRONMENT="test" \
            -p 8000:8000 \
            taskflow-api:ci-${{ github.sha }}
          sleep 10
          curl --fail http://localhost:8000/health || (docker logs test_api && exit 1)
          docker stop test_api

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
56.2 CD — PIPELINE DE DÉPLOIEMENT AUTOMATIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# .github/workflows/deploy.yml
# Déclenché seulement quand CI passe sur la branche main

name: CD — Déploiement Production

on:
  push:
    branches: [main]
  workflow_run:
    workflows: ["CI — Tests & Qualité"]
    branches: [main]
    types: [completed]

jobs:
  deploy:
    name: "Déploiement Production"
    runs-on: ubuntu-latest
    # Ne déployer que si le CI a réussi
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    environment:
      name: production
      url: https://api.taskflow.io

    steps:
      - uses: actions/checkout@v4

      # ── Build et push de l'image Docker ──────────────────────────────
      - name: Login Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build et Push image
        uses: docker/build-push-action@v5
        with:
          context: .
          target: runtime
          push: true
          tags: |
            taskflow/api:latest
            taskflow/api:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      # ── Déploiement via SSH sur le serveur ────────────────────────────
      - name: Déploiement sur le serveur de production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            set -e
            cd /opt/taskflow

            # Mettre à jour le docker-compose avec la nouvelle image
            export IMAGE_TAG=${{ github.sha }}

            # Tirer la nouvelle image
            docker pull taskflow/api:$IMAGE_TAG

            # Appliquer les migrations AVANT de redémarrer l'API
            docker-compose run --rm \
              -e DATABASE_URL=${{ secrets.DATABASE_URL }} \
              migrate alembic upgrade head

            # Déploiement zero-downtime :
            # 1. Lancer les nouveaux containers
            # 2. Attendre qu'ils soient healthy
            # 3. Arrêter les anciens
            docker-compose up -d --no-deps --scale api=2 api
            sleep 15

            # Vérifier que les nouveaux containers sont healthy
            docker-compose ps api | grep "healthy" || exit 1

            # Redémarrer proprement
            docker-compose up -d --no-deps api

            # Nettoyer les vieilles images
            docker image prune -f

      # ── Notification de succès/échec ──────────────────────────────────
      - name: Notifier Slack (succès)
        if: success()
        uses: 8398a7/action-slack@v3
        with:
          status: success
          text: "[OK] TaskFlow API v${{ github.sha }} déployé avec succès !"
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

      - name: Notifier Slack (échec)
        if: failure()
        uses: 8398a7/action-slack@v3
        with:
          status: failure
          text: "[X] Échec du déploiement TaskFlow API - ${{ github.sha }}"
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

================================================================================
          CHAPITRE 57 — CHECKLIST DE DÉPLOIEMENT PRODUCTION
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
57.1 CHECKLIST SÉCURITÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[WHITE_SQUARE] SECRET_KEY de minimum 32 caractères aléatoires
  -> openssl rand -hex 32

[WHITE_SQUARE] DEBUG=False en production

[WHITE_SQUARE] docs_url=None, redoc_url=None (ou protégés par auth basic)

[WHITE_SQUARE] HTTPS/TLS configuré avec certificat valide

[WHITE_SQUARE] Mots de passe DB, Redis uniques et complexes

[WHITE_SQUARE] Utilisateur DB avec MINIMUM de permissions (pas superuser)

[WHITE_SQUARE] Container Docker exécuté en utilisateur non-root

[WHITE_SQUARE] Variables sensibles dans les secrets GitHub/Vault (jamais en .env commité)

[WHITE_SQUARE] Rate limiting actif (Nginx + middleware)

[WHITE_SQUARE] Headers de sécurité présents (HSTS, CSP, X-Frame-Options)

[WHITE_SQUARE] CORS configuré avec seulement les domaines autorisés

[WHITE_SQUARE] Logs ne contenant JAMAIS de tokens, mots de passe, PII

[WHITE_SQUARE] Accès /metrics protégé (IP whitelist ou basic auth)

[WHITE_SQUARE] Rotation des secrets planifiée (tous les 90 jours)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
57.2 CHECKLIST BASE DE DONNÉES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[WHITE_SQUARE] Migrations Alembic appliquées (alembic current == head)

[WHITE_SQUARE] Sauvegardes automatiques configurées (pg_dump quotidien)

[WHITE_SQUARE] Test de restauration effectué (sauvegarde inutile si non testée)

[WHITE_SQUARE] Index sur les colonnes fréquemment filtrées (email, status, priority)

[WHITE_SQUARE] Pool de connexions correctement dimensionné (pool_size, max_overflow)

[WHITE_SQUARE] pg_stat_activity surveillé (connexions zombies)

[WHITE_SQUARE] EXPLAIN ANALYZE sur les 10 requêtes les plus fréquentes

[WHITE_SQUARE] Vacuum automatique activé (défaut PostgreSQL)

[WHITE_SQUARE] Retention des logs PostgreSQL configurée

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
57.3 CHECKLIST PERFORMANCE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[WHITE_SQUARE] Cache Redis opérationnel et TTL correctement configurés

[WHITE_SQUARE] Compression gzip activée dans Nginx

[WHITE_SQUARE] N+1 queries éliminées (selectinload sur les relations)

[WHITE_SQUARE] Pagination implémentée sur toutes les listes

[WHITE_SQUARE] Réponse P95 < 500ms (mesurée dans Grafana)

[WHITE_SQUARE] Taux d'erreurs < 0.1% (dashboard Prometheus)

[WHITE_SQUARE] Memoire container < 80% de la limite

[WHITE_SQUARE] CPU container < 70% en charge normale

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
57.4 CHECKLIST OBSERVABILITÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[WHITE_SQUARE] Logs JSON structurés vers stdout (lus par le système de log)

[WHITE_SQUARE] Request ID dans chaque log ET chaque réponse (header X-Request-ID)

[WHITE_SQUARE] Prometheus scrape actif (vérifier dans l'UI Prometheus)

[WHITE_SQUARE] Dashboard Grafana opérationnel avec les panels essentiels

[WHITE_SQUARE] Alertes configurées : 5xx > 1%, P95 > 1s, API down

[WHITE_SQUARE] Alertmanager -> Slack/PagerDuty configuré

[WHITE_SQUARE] Traces OpenTelemetry -> Jaeger/Tempo actif

[WHITE_SQUARE] Health check /health retourne 200 en production

================================================================================
          CHAPITRE 58 — GUIDE DE DEBUGGING
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
58.1 PROBLÈMES FRÉQUENTS ET SOLUTIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PROBLÈME 1 : ImportError ou ModuleNotFoundError au démarrage
─────────────────────────────────────────────────────────────
Symptôme : uvicorn démarre mais plante immédiatement
Cause     : module manquant, mauvais chemin d'import
Diagnostic:
  python -c "from app.main import app"
  -> Montre l'erreur précise

Solutions :
  1. Activer le venv : source venv/bin/activate
  2. pip install -r requirements.txt
  3. Vérifier que PYTHONPATH inclut la racine du projet
     export PYTHONPATH=$PYTHONPATH:/path/to/taskflow

PROBLÈME 2 : 422 Unprocessable Entity inattendu
─────────────────────────────────────────────────
Symptôme : les données envoyées semblent correctes mais 422 retourné
Cause     : type incorrect, champ manquant, validation Pydantic échouée
Diagnostic:
  Lire le champ "errors" dans la réponse 422 :
  {
    "errors": [
      {"field": "body -> due_date",
       "message": "La date limite ne peut pas être dans le passé"}
    ]
  }
Solutions :
  1. Lire attentivement le message d'erreur dans "errors"
  2. Utiliser Swagger UI pour tester interactivement
  3. Ajouter des prints temporaires dans les @field_validator

PROBLÈME 3 : 401 Unauthorized alors que le token semble valide
───────────────────────────────────────────────────────────────
Symptôme : header Authorization correct mais 401 retourné
Causes possibles :
  a) Token expiré (ACCESS_TOKEN_EXPIRE_MINUTES dépassé)
  b) Token "access" envoyé à la place du token "refresh" ou vice versa
  c) SECRET_KEY différente entre génération et validation
  d) Token dans la blacklist Redis (après logout)
  e) Utilisateur désactivé (is_active=False)

Diagnostic :
  # Décoder le token sans validation (inspecter le payload)
  import base64, json
  token = "eyJhbGci..."
  payload_b64 = token.split(".")[1]
  # Ajouter le padding nécessaire
  padding = 4 - len(payload_b64) % 4
  payload_b64 += "=" * padding
  payload = json.loads(base64.urlsafe_b64decode(payload_b64))
  print(payload)
  # -> {"sub": "42", "exp": 1705316400, "type": "access", ...}
  # Vérifier : exp > datetime.now().timestamp()

PROBLÈME 4 : Lenteur inexpliquée sur certains endpoints
────────────────────────────────────────────────────────
Symptôme : endpoint prend 2-5s alors qu'il devrait être rapide
Cause     : très souvent un problème N+1

Diagnostic :
  1. Activer echo=True sur le moteur SQLAlchemy
     -> Toutes les requêtes SQL apparaissent dans les logs
  2. Compter le nombre de SELECTs pour une requête
     Si 20 tâches -> 21 SELECTs (1 + 20 pour les relations) = N+1 confirmé
  3. Utiliser EXPLAIN ANALYZE dans psql

Solution : ajouter selectinload() sur les relations chargées

PROBLÈME 5 : "RuntimeError: Task attached to a different loop"
─────────────────────────────────────────────────────────────
Symptôme : erreur dans les tests ou dans le code async
Cause     : mélange de boucles asyncio (chaque test en crée une nouvelle)
Solution dans les tests :
  # Dans conftest.py :
  @pytest.fixture(scope="session")
  def event_loop():
      loop = asyncio.get_event_loop_policy().new_event_loop()
      yield loop
      loop.close()

PROBLÈME 6 : WebSocket se déconnecte immédiatement
──────────────────────────────────────────────────
Symptôme : connexion WS établie mais fermée après quelques secondes
Causes :
  a) Nginx timeout (proxy_read_timeout trop court pour les WS)
  b) Pas de heartbeat -> Nginx ferme la connexion inactive
  c) Exception non gérée dans la boucle WS

Diagnostic :
  # Activer les logs WebSocket dans uvicorn
  uvicorn app.main:app --log-level debug

Solution Nginx pour les WS :
  # Dans nginx.conf :
  location /ws/ {
      proxy_read_timeout 86400;  # 24h
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
  }

PROBLÈME 7 : Migrations Alembic en conflit
──────────────────────────────────────────
Symptôme : "Multiple head revisions are present"
Cause     : deux branches ont créé des migrations en parallèle

Diagnostic : alembic heads

Solution :
  # Créer une migration de merge
  alembic merge heads -m "merge_branches"
  alembic upgrade head

PROBLÈME 8 : Memory leak sur les endpoints très sollicités
─────────────────────────────────────────────────────────
Symptôme : mémoire du container augmente sans jamais redescendre
Causes fréquentes :
  a) Connexions DB non fermées (get_db() non utilisé comme context manager)
  b) Cache Redis qui grossit sans éviction (maxmemory-policy non configuré)
  c) Références circulaires Python (traceable avec tracemalloc)
  d) Sessions SQLAlchemy non fermées

Diagnostic :
  # Activer tracemalloc en développement
  import tracemalloc
  tracemalloc.start()
  snapshot = tracemalloc.take_snapshot()
  top_stats = snapshot.statistics("lineno")
  for stat in top_stats[:10]:
      print(stat)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
58.2 COMMANDES DE DIAGNOSTIC RAPIDE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # ── API ──────────────────────────────────────────────────────────────

  # Vérifier que l'API démarre
  uvicorn app.main:app --reload --log-level debug

  # Tester tous les endpoints en une commande
  python -c "
  import httpx, json
  base = 'http://localhost:8000'
  r = httpx.get(f'{base}/health')
  print('Health:', r.status_code, r.json())
  "

  # ── Docker ────────────────────────────────────────────────────────────

  # Voir les logs en temps réel
  docker-compose logs -f api --tail=100

  # Voir les ressources utilisées par chaque container
  docker stats --no-stream

  # Entrer dans un container en cours d'exécution
  docker-compose exec api bash

  # Voir les variables d'environnement d'un container
  docker-compose exec api env | grep -E "DATABASE|REDIS|SECRET"

  # ── PostgreSQL ────────────────────────────────────────────────────────

  # Connexions actives
  docker-compose exec db psql -U taskflow_user -d taskflow_db \
    -c "SELECT count(*), state FROM pg_stat_activity GROUP BY state;"

  # Taille des tables
  docker-compose exec db psql -U taskflow_user -d taskflow_db \
    -c "SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
        FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC;"

  # Requêtes lentes (nécessite pg_stat_statements)
  docker-compose exec db psql -U taskflow_user -d taskflow_db \
    -c "SELECT query, calls, mean_exec_time FROM pg_stat_statements
        ORDER BY mean_exec_time DESC LIMIT 10;"

  # ── Redis ─────────────────────────────────────────────────────────────

  # Infos générales
  docker-compose exec redis redis-cli -a $REDIS_PASSWORD INFO server

  # Utilisation mémoire
  docker-compose exec redis redis-cli -a $REDIS_PASSWORD INFO memory \
    | grep "used_memory_human"

  # Nombre de clés par DB
  docker-compose exec redis redis-cli -a $REDIS_PASSWORD INFO keyspace

  # Lister les clés de cache TaskFlow
  docker-compose exec redis redis-cli -a $REDIS_PASSWORD KEYS "tasks:*"

  # Vider le cache (DANGER : en prod seulement si nécessaire)
  docker-compose exec redis redis-cli -a $REDIS_PASSWORD FLUSHDB

================================================================================
          CHAPITRE 59 — CHEAT SHEETS DE RÉFÉRENCE RAPIDE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
59.1 FASTAPI — CHEAT SHEET COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ROUTES ET DÉCORATEURS
  @app.get("/path")                        -> GET
  @app.post("/path", status_code=201)      -> POST avec 201
  @app.put("/path")                        -> PUT
  @app.patch("/path")                      -> PATCH
  @app.delete("/path", status_code=204)    -> DELETE sans corps
  @app.websocket("/ws")                    -> WebSocket

PARAMÈTRES
  def f(id: int)                -> path param si {id} dans le chemin
  def f(q: str = None)          -> query param si pas dans le chemin
  def f(body: MonModel)         -> request body si Pydantic model
  def f(x: Annotated[int, Path(ge=1)])     -> path param avec validation
  def f(x: Annotated[str, Query(min_length=2)]) -> query param validé
  def f(x: Annotated[str, Body()])         -> body scalar

RÉPONSES
  return {"key": "val"}                    -> JSONResponse automatique
  return JSONResponse(content={}, status_code=200, headers={})
  return HTMLResponse("<html>...</html>")
  return RedirectResponse(url="/new", status_code=301)
  return StreamingResponse(generator(), media_type="text/csv")
  return FileResponse(path="file.pdf", filename="download.pdf")
  raise HTTPException(status_code=404, detail="Not found")

DÉPENDANCES
  Depends(fn)                              -> injection standard
  Annotated[Type, Depends(fn)]             -> injection avec type hint
  app.dependency_overrides[fn] = mock_fn   -> override pour les tests

MODELS
  class MyModel(BaseModel):
      field: str = Field(..., min_length=1)  # obligatoire
      opt: str | None = None                 # optionnel
      lst: list[str] = Field(default_factory=list)
      @field_validator("field")
      @classmethod
      def validate_field(cls, v): return v
      @model_validator(mode="after")
      def cross_validate(self) -> Self: return self

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
59.2 SQLALCHEMY 2.0 — CHEAT SHEET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

MODÈLE
  class Task(Base):
      __tablename__ = "tasks"
      id: Mapped[int] = mapped_column(Integer, primary_key=True)
      title: Mapped[str] = mapped_column(String(200), nullable=False)
      opt: Mapped[str | None] = mapped_column(String(100), nullable=True)
      created_at: Mapped[datetime] = mapped_column(
          DateTime(timezone=True), server_default=func.now()
      )
      user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
      user: Mapped["User"] = relationship("User", back_populates="tasks")

QUERIES COURANTES
  # SELECT * WHERE id = X
  result = await db.execute(select(Task).where(Task.id == id))
  task = result.scalar_one_or_none()

  # SELECT * WHERE id IN (1, 2, 3)
  result = await db.execute(select(Task).where(Task.id.in_([1, 2, 3])))

  # SELECT avec JOIN eager loading
  result = await db.execute(
      select(Task).options(selectinload(Task.user))
  )

  # COUNT
  result = await db.execute(
      select(func.count()).select_from(Task).where(Task.completed == True)
  )
  count = result.scalar_one()

  # INSERT
  task = Task(title="Ma tâche")
  db.add(task)
  await db.flush()
  await db.refresh(task)  # Récupérer id, created_at...

  # UPDATE partiel
  await db.execute(
      update(Task).where(Task.id == id).values(title="Nouveau titre")
  )

  # DELETE
  await db.execute(delete(Task).where(Task.id == id))

  # PAGINER
  result = await db.execute(
      select(Task).order_by(Task.created_at.desc()).offset(skip).limit(limit)
  )

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
59.3 REDIS CACHE — CHEAT SHEET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

OPÉRATIONS DE BASE
  await cache.get("key")                   -> valeur ou None
  await cache.set("key", value, ttl=300)   -> stocker 5 minutes
  await cache.delete("key")                -> supprimer
  await cache.delete_pattern("tasks:*")   -> supprimer par pattern
  await cache.exists("key")               -> True/False
  await cache.ttl_remaining("key")        -> secondes restantes

TTL RECOMMANDÉS
  60s   -> données très dynamiques (listes paginées, compteurs)
  300s  -> données semi-stables (profils, stats)
  3600s -> données stables (config, référentiels)
  86400 -> données très stables (tarifs, feature flags)

PATTERN CACHE-ASIDE
  result = await cache.get_or_set(
      key=f"task:{task_id}",
      fetch_fn=lambda: load_from_db(task_id),
      ttl=300,
  )

INVALIDATION
  # À l'écriture : toujours invalider les caches concernés
  await cache.delete(f"task:{task_id}")          # Invalidation précise
  await cache.delete_pattern("tasks:list:*")     # Invalidation large

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
59.4 PYTEST — CHEAT SHEET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

COMMANDES ESSENTIELLES
  pytest                              -> tous les tests
  pytest -v                           -> verbeux
  pytest tests/unit/                  -> dossier spécifique
  pytest tests/unit/test_security.py::TestJWT::test_expired_token
  pytest -m unit                      -> seulement marqueur "unit"
  pytest -m "not slow"                -> exclure marqueur "slow"
  pytest -x                           -> arrêter au 1er échec
  pytest --lf                         -> relancer seulement les échoués
  pytest --cov=app --cov-report=html

ASSERTIONS UTILES
  assert response.status_code == 200
  assert response.json()["id"] == 42
  assert "password" not in response.json()
  assert len(response.json()["data"]) == 5

  with pytest.raises(HTTPException) as exc:
      await service.get_or_raise(999)
  assert exc.value.status_code == 404

  with pytest.raises(ValueError, match="Token invalide"):
      decode_access_token("invalid.token.here")

FIXTURES UTILES (conftest.py)
  db_session    -> session DB avec rollback automatique
  client        -> httpx.AsyncClient vers l'app
  auth_client   -> client avec token user_regular pré-configuré
  admin_client  -> client avec token admin pré-configuré
  user_regular  -> User créé en DB, rôle "user"
  user_admin    -> User créé en DB, rôle "admin"
  token_regular -> JWT access token pour user_regular

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
59.5 DOCKER — CHEAT SHEET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

DOCKER COMPOSE QUOTIDIEN
  docker-compose up -d                   -> démarrer tous les services
  docker-compose up -d --build api       -> rebuilder et redémarrer l'API
  docker-compose down                    -> arrêter tout
  docker-compose down -v                 -> arrêter + supprimer volumes
  docker-compose logs -f api             -> suivre les logs API
  docker-compose exec api bash           -> shell dans le container API
  docker-compose exec db psql -U user -d db  -> psql
  docker-compose ps                      -> état des containers
  docker stats --no-stream               -> ressources utilisées

BUILD
  docker build -t taskflow:v1.0 .        -> builder l'image
  docker build --target runtime -t ...   -> seulement le stage runtime
  docker push taskflow/api:latest        -> pousser vers registry

================================================================================
                    RÉSUMÉ FINAL — COMPÉTENCES ACQUISES
================================================================================

En complétant ce guide, tu maîtrises maintenant :

[OK] FONDATIONS
   HTTP, JSON, REST, Python async/await, environnement professionnel

[OK] FASTAPI
   Routes, paramètres, validation Pydantic v2, réponses, middleware

[OK] BASE DE DONNÉES
   PostgreSQL, SQLAlchemy 2.0 async, migrations Alembic, optimisations

[OK] SÉCURITÉ
   OAuth2, JWT, bcrypt, dépendances d'auth, rôles, rate limiting

[OK] ARCHITECTURE
   Clean Architecture, Repository Pattern, Service Layer, exceptions

[OK] TESTS
   pytest-asyncio, TDD, tests unitaires avec mocks, tests d'intégration,
   Factory Boy, coverage 80%+

[OK] PERFORMANCE
   Redis caching, N+1 elimination, eager loading, keyset pagination

[OK] DÉPLOIEMENT
   Docker multi-stage, Docker Compose, Nginx HTTPS, Let's Encrypt

[OK] TEMPS RÉEL
   WebSockets, rooms, Redis Pub/Sub multi-instances, reconnexion

[OK] OBSERVABILITÉ
   Structlog JSON, Prometheus métriques, Grafana dashboards,
   OpenTelemetry tracing

[OK] CI/CD
   GitHub Actions, tests automatiques sur PR, déploiement automatique

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

          [COURS] FÉLICITATIONS — TASKFLOW API EST PRODUCTION-READY !

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

================================================================================
                       FIN DU GUIDE FASTAPI COMPLET
                  10 parties • ~700 Ko • ~17 000+ lignes
================================================================================