================================================================================
         GUIDE COMPLET DES ARCHITECTURES LOGICIELLES
         Vue d'ensemble et concepts fondamentaux
         Pour débutants - Niveau progressif
================================================================================

AUTEUR      : Guide pédagogique Architecture Logicielle
VERSION     : 1.0
LANGAGE     : Python / Flask
NIVEAU      : Débutant -> Intermédiaire -> Avancé

================================================================================
TABLE DES MATIÈRES
================================================================================

  1. Qu'est-ce qu'une architecture logicielle ?
  2. Pourquoi l'architecture est-elle importante ?
  3. Les grandes familles d'architectures
  4. Critères de choix d'une architecture
  5. Évolution naturelle d'un projet
  6. Vocabulaire fondamental
  7. Carte mentale des architectures
  8. Guide de décision rapide
  9. Prérequis techniques
  10. Comment utiliser ce guide

================================================================================
1. QU'EST-CE QU'UNE ARCHITECTURE LOGICIELLE ?
================================================================================

DÉFINITION SIMPLE :
-------------------
L'architecture logicielle, c'est le PLAN DE CONSTRUCTION de ton application.

Exactement comme un architecte dessine les plans d'une maison AVANT de construire
(pour savoir où mettre les murs, les portes, les pièces), le développeur conçoit
l'architecture de son logiciel AVANT d'écrire du code.

ANALOGIE CONCRÉTE :
-------------------
  Maison                          Logiciel
  ───────────────────────────────────────────
  Plan de l'architecte        ->   Architecture
  Pièces (salon, cuisine...)  ->   Modules / couches
  Murs porteurs               ->   Composants critiques
  Portes entre pièces         ->   Interfaces / API
  Fondations                  ->   Base de données / infrastructure
  Électricité, plomberie      ->   Services transversaux (auth, logs...)

DÉFINITION TECHNIQUE :
----------------------
L'architecture logicielle définit :
  - La STRUCTURE du système (comment organiser le code)
  - Les COMPOSANTS et leurs responsabilités
  - Les INTERACTIONS entre composants
  - Les CONTRAINTES et règles à respecter
  - Les DÉCISIONS techniques majeures

================================================================================
2. POURQUOI L'ARCHITECTURE EST-ELLE IMPORTANTE ?
================================================================================

PROBLÈME SANS ARCHITECTURE :
-----------------------------
Imagine que tu construis une maison sans plan :
  -> Tu poses les fenêtres avant les murs
  -> Tu mets la cuisine dans la cave
  -> Tu ne sais plus où passe l'électricité
  -> Pour changer quelque chose, tu dois tout détruire

C'est EXACTEMENT ce qui arrive avec du code sans architecture :

  # Mauvais exemple - CODE SPAGHETTI (sans architecture)
  # Tout est mélangé : base de données, logique, affichage

  @app.route('/users')
  def get_users():
      # Connexion DB directement dans la route (MAUVAIS)
      conn = sqlite3.connect('mydb.db')
      cursor = conn.cursor()

      # Logique métier dans la route (MAUVAIS)
      cursor.execute("SELECT * FROM users WHERE active=1")
      users = cursor.fetchall()

      # Calcul dans la route (MAUVAIS)
      result = []
      for user in users:
          if user[3] > 18:  # age > 18
              result.append({'id': user[0], 'name': user[1]})

      # Formatage HTML dans la route (MAUVAIS)
      html = "<ul>"
      for u in result:
          html += f"<li>{u['name']}</li>"
      html += "</ul>"
      return html

PROBLÈMES DE CE CODE :
  [X] Impossible à tester (tout est couplé)
  [X] Impossible à réutiliser (logique enfouie dans la route)
  [X] Impossible à modifier (changer la DB casse tout)
  [X] Impossible à comprendre (tout dans un seul endroit)
  [X] Impossible à travailler en équipe (conflits constants)

BÉNÉFICES D'UNE BONNE ARCHITECTURE :
--------------------------------------
  [OK] MAINTENABILITÉ  : Le code est facile à modifier et corriger
  [OK] TESTABILITÉ     : Chaque partie peut être testée indépendamment
  [OK] SCALABILITÉ     : Le système peut grossir sans tout réécrire
  [OK] RÉUTILISABILITÉ : Les composants s'utilisent dans d'autres contextes
  [OK] LISIBILITÉ      : Un nouveau développeur comprend vite l'organisation
  [OK] TRAVAIL ÉQUIPE  : Plusieurs personnes travaillent sans se bloquer

================================================================================
3. LES GRANDES FAMILLES D'ARCHITECTURES
================================================================================

NIVEAU 1 - ARCHITECTURES SIMPLES (pour débuter)
-------------------------------------------------

  [PACKAGE] MONOLITHIQUE
  ───────────────
  Tout dans une seule application.
  Simple à démarrer, difficile à faire évoluer.
  -> Fichier : architecture_monolithique.txt

  [MESURE] MVC (Model-View-Controller)
  ───────────────────────────────
  Séparation en 3 couches : Données / Affichage / Contrôle
  Le standard de nombreux frameworks web.
  -> Fichier : architecture_mvc.txt

NIVEAU 2 - ARCHITECTURES STRUCTURÉES (pour projets moyens)
-----------------------------------------------------------

  [CONSTRUCTION] N-TIERS / LAYERED (En couches)
  ──────────────────────────────────
  Couches superposées avec responsabilités distinctes.
  Controller -> Service -> Repository -> Database
  -> Fichier : architecture_layered.txt

NIVEAU 3 - ARCHITECTURES AVANCÉES (pour grands projets)
--------------------------------------------------------

  [ANALYSE] CLEAN ARCHITECTURE
  ──────────────────────
  Indépendance totale du domaine métier.
  Inspirée des travaux de Robert C. Martin (Uncle Bob).
  -> Fichier : architecture_clean.txt

  [PLUGIN] HEXAGONALE (Ports & Adapters)
  ─────────────────────────────────
  Le cœur de l'application ne dépend de rien.
  Connections via des ports standardisés.
  -> Fichier : architecture_hexagonale.txt

NIVEAU 4 - ARCHITECTURES DISTRIBUÉES (pour systèmes complexes)
--------------------------------------------------------------

  [RAPIDE] MICROSERVICES
  ─────────────────
  Application découpée en petits services indépendants.
  Chaque service = une responsabilité.
  -> Fichier : architecture_microservices.txt

  [RESEAU] EVENT-DRIVEN (Orientée événements)
  ──────────────────────────────────────
  Les composants communiquent via des événements.
  Découplage maximum.
  -> Fichier : architecture_event_driven.txt

  [CLOUD] SERVERLESS
  ──────────────
  Pas de serveur à gérer.
  Fonctions déclenchées par des événements.
  -> Fichier : architecture_serverless.txt

================================================================================
4. CRITÈRES DE CHOIX D'UNE ARCHITECTURE
================================================================================

QUESTIONS À SE POSER AVANT DE CHOISIR :
-----------------------------------------

  1. TAILLE DU PROJET
     ├── Petit projet personnel        -> Monolithique ou MVC
     ├── Projet moyen / startup        -> Layered ou MVC
     ├── Projet d'entreprise           -> Clean ou Hexagonale
     └── Très grand système            -> Microservices + Event-Driven

  2. ÉQUIPE
     ├── Seul développeur              -> Monolithique ou MVC
     ├── Petite équipe (2-5)           -> Layered ou MVC
     ├── Grande équipe (5-20)          -> Clean, Hexagonale
     └── Plusieurs équipes (20+)       -> Microservices

  3. FRÉQUENCE DE CHANGEMENT
     ├── Application stable            -> Monolithique
     ├── Évolutions régulières         -> Layered, MVC
     └── Évolutions constantes         -> Clean, Microservices

  4. PERFORMANCE ET SCALABILITÉ
     ├── Trafic modéré                 -> Monolithique, MVC, Layered
     ├── Trafic variable               -> Serverless
     └── Trafic très élevé             -> Microservices

  5. BUDGET ET DÉLAIS
     ├── Budget limité / livraison rapide -> Monolithique
     ├── Budget moyen                     -> Layered
     └── Budget important                 -> Microservices, Clean

  6. TESTABILITÉ REQUISE
     ├── Tests basiques                -> MVC
     ├── Tests unitaires importants    -> Layered, Clean
     └── Tests exhaustifs              -> Hexagonale, Clean

================================================================================
5. ÉVOLUTION NATURELLE D'UN PROJET
================================================================================

La plupart des projets suivent cette évolution naturelle :

  PHASE 1 : PROTOTYPE / VALIDATION
  ─────────────────────────────────
  Architecture : Monolithique
  Pourquoi     : Aller vite, valider l'idée
  Durée        : Quelques jours à quelques semaines

        [Tout dans app.py]
              v

  PHASE 2 : CROISSANCE
  ────────────────────
  Architecture : MVC ou Layered
  Pourquoi     : Organiser le code qui grossit
  Durée        : Quelques mois

        [Models / Views / Controllers]
              v

  PHASE 3 : MATURITÉ
  ──────────────────
  Architecture : Clean ou Hexagonale
  Pourquoi     : Maintenir la qualité à long terme
  Durée        : Années

        [Domain / UseCases / Infrastructure]
              v

  PHASE 4 : ÉCHELLE
  ─────────────────
  Architecture : Microservices
  Pourquoi     : Permettre des équipes indépendantes, scaler
  Durée        : Grande maturité organisationnelle

        [Service A] [Service B] [Service C]

[ATTENTION] ATTENTION : Ce n'est pas une règle absolue.
Certaines startups commencent directement avec des microservices (mauvaise idée).
D'autres gardent le monolithe toute leur vie (très bien si ça marche !).

================================================================================
6. VOCABULAIRE FONDAMENTAL
================================================================================

Voici les termes que tu rencontreras dans tous les fichiers suivants :

TERME                   DÉFINITION
────────────────────────────────────────────────────────────────────────────────
Module                  Unité de code avec une responsabilité claire
Composant               Bloc fonctionnel autonome (peut être un module, service...)
Couche (Layer)          Niveau d'abstraction (ex: présentation, logique, données)
Interface               Contrat entre deux composants (méthodes attendues)
Couplage                Dépendance entre deux composants
Cohésion                Logique d'un composant d'être regroupé ensemble
Dépendance              Un composant A a besoin de B pour fonctionner
Injection (DI)          Fournir les dépendances de l'extérieur plutôt qu'en dur
Repository              Objet qui gère l'accès aux données
Service                 Composant contenant la logique métier
Controller              Point d'entrée qui reçoit les requêtes
Entité (Entity)         Objet métier avec identité (ex: User, Product)
Use Case                Cas d'usage = action que l'utilisateur peut faire
Port                    Interface standardisée d'entrée/sortie
Adapter                 Implémentation concrète d'un port
Événement               Message notifiant qu'il s'est passé quelque chose
Publisher               Composant qui émet des événements
Subscriber              Composant qui reçoit des événements
Payload                 Données transportées avec un événement ou une requête
DTO                     Data Transfer Object - objet pour transférer des données
ORM                     Object-Relational Mapping - mapper les objets en DB
CRUD                    Create, Read, Update, Delete - opérations de base
REST                    Style d'API basé sur HTTP
API                     Interface de programmation entre deux systèmes
Endpoint                URL d'une API (ex: /api/users)
Middleware              Logiciel entre deux couches (ex: auth, logging)
────────────────────────────────────────────────────────────────────────────────

================================================================================
7. CARTE MENTALE DES ARCHITECTURES
================================================================================

                        ARCHITECTURES LOGICIELLES
                                   │
              ┌────────────────────┼────────────────────┐
              │                    │                    │
         SIMPLES            STRUCTURÉES             AVANCÉES
              │                    │                    │
    ┌─────────┴──────┐    ┌────────┴───────┐    ┌───────┴────────┐
    │                │    │                │    │                │
MONOLITHIQUE       MVC  LAYERED(N-tiers) CLEAN  HEXAGONALE  MICROSERVICES
    │                │    │                │    │                │
Tout en un    M/V/C  Couches          Domaine  Ports       Services
              séparés empilées        isolé    Adapters     indépendants


              ARCHITECTURES ÉVÉNEMENTIELLES
                         │
              ┌──────────┴──────────┐
              │                     │
          EVENT-DRIVEN          SERVERLESS
              │                     │
         Pub/Sub              Fonctions
         Messages             déclenchées

================================================================================
8. GUIDE DE DÉCISION RAPIDE
================================================================================

Réponds à ces questions pour choisir ton architecture :

  Q1 : C'est un prototype / POC ?
       OUI -> Monolithique
       NON -> Q2

  Q2 : L'équipe est de moins de 5 personnes ?
       OUI -> Q3
       NON -> Q5

  Q3 : Le projet est simple (CRUD basique) ?
       OUI -> MVC
       NON -> Q4

  Q4 : Il y a une logique métier complexe ?
       OUI -> Layered
       NON -> MVC

  Q5 : Les domaines sont très différents (users, payments, shipping...) ?
       OUI -> Microservices
       NON -> Q6

  Q6 : La testabilité est critique ?
       OUI -> Clean ou Hexagonale
       NON -> Layered

================================================================================
9. PRÉREQUIS TECHNIQUES
================================================================================

Pour suivre ce guide, tu as besoin de connaître :

  PYTHON (BASES)
  ──────────────
  - Variables, fonctions, classes
  - Import de modules
  - Gestion des exceptions (try/except)
  - Listes, dictionnaires

  FLASK (BASES)
  ─────────────
  - Créer une app Flask
  - Définir des routes (@app.route)
  - Retourner des réponses JSON
  - Lancer le serveur

  INSTALLATION DES OUTILS :
  ─────────────────────────

  # Installer Python (si pas fait)
  # https://python.org

  # Créer un environnement virtuel
  python -m venv venv

  # Activer l'environnement (Linux/Mac)
  source venv/bin/activate

  # Activer l'environnement (Windows)
  venv\Scripts\activate

  # Installer Flask
  pip install flask

  # Installer les autres dépendances au fil des exemples
  pip install flask-sqlalchemy  # Pour la base de données
  pip install pytest            # Pour les tests

================================================================================
10. COMMENT UTILISER CE GUIDE
================================================================================

ORDRE DE LECTURE RECOMMANDÉ :
──────────────────────────────

  DÉBUTANT (commence ici) :
  ─────────────────────────
    1. architecture.txt              <- Tu es ici !
    2. architecture_monolithique.txt <- Le plus simple
    3. architecture_mvc.txt          <- Très utilisé
    4. architecture_layered.txt      <- Très utilisé en entreprise

  INTERMÉDIAIRE :
  ───────────────
    5. architecture_clean.txt
    6. architecture_hexagonale.txt
    7. architecture_patterns.txt

  AVANCÉ :
  ────────
    8. architecture_microservices.txt
    9. architecture_event_driven.txt
    10. architecture_serverless.txt
    11. architecture_comparaison.txt
    12. architecture_pratique.txt    <- Exercices complets

CONSEILS DE LECTURE :
──────────────────────
  [OK] LIS et COMPRENDS avant de coder
  [OK] TAPE le code toi-même (ne pas copier-coller)
  [OK] EXPÉRIMENTE en modifiant les exemples
  [OK] FAIS les exercices pratiques
  [OK] POSE-TOI la question "Pourquoi ce choix ?"

  [X] Ne saute pas de fichier
  [X] Ne te décourage pas si c'est long
  [X] Ne cherche pas à tout mémoriser d'un coup

================================================================================
RÉSUMÉ
================================================================================

  Une architecture logicielle est le plan de ton application.
  
  Sans architecture -> Code spaghetti -> Cauchemar à maintenir
  Avec architecture -> Code structuré -> Plaisir à travailler
  
  Ton guide :
    architecture_monolithique.txt -> Tout en un
    architecture_mvc.txt          -> 3 couches (M/V/C)
    architecture_layered.txt      -> N couches superposées
    architecture_clean.txt        -> Domaine isolé du monde
    architecture_hexagonale.txt   -> Ports & Adapters
    architecture_microservices.txt-> Services indépendants
    architecture_event_driven.txt -> Communication par événements
    architecture_serverless.txt   -> Fonctions dans le cloud
    architecture_comparaison.txt  -> Quel choix pour quel projet
    architecture_patterns.txt     -> Design patterns associés
    architecture_pratique.txt     -> Exercices complets

  Commence par architecture_monolithique.txt !

================================================================================
FIN DU FICHIER architecture.txt
================================================================================

================================================================================
         ARCHITECTURE MONOLITHIQUE
         Guide complet avec exemples Flask
================================================================================

================================================================================
1. INTRODUCTION PÉDAGOGIQUE
================================================================================

DÉFINITION :
────────────
Une architecture monolithique est une application où TOUT le code est regroupé
dans un SEUL déploiement. Toutes les fonctionnalités (utilisateurs, produits,
commandes, paiements...) sont dans la même base de code, tournent sur le même
processus, et sont déployées ensemble.

ANALOGIE :
──────────
C'est comme un couteau suisse : tout est intégré dans un seul outil.
Pratique à transporter, mais si une lame casse, tu dois tout remplacer.

AVANTAGES :
───────────
  [OK] Simple à développer et démarrer
  [OK] Facile à tester localement
  [OK] Pas de communication réseau entre composants
  [OK] Déploiement simple (un seul artefact)
  [OK] Debugging facile (tout dans un seul processus)
  [OK] Parfait pour les petits projets et prototypes

LIMITES :
─────────
  [X] Difficile à faire évoluer quand le code grossit
  [X] Un bug peut planter toute l'application
  [X] Difficile de scaler indépendamment les parties
  [X] Couplage fort entre les modules
  [X] Temps de build/déploiement croissant
  [X] Difficile de changer de technologie partiellement

================================================================================
2. DIAGRAMMES EXPLICATIFS
================================================================================

FLUX DE DONNÉES :
─────────────────

  Utilisateur
      │
      │ HTTP Request
      [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────────────────────────────────┐
  │              APPLICATION                │
  │(Un seul processus, un seul déploiement) │
  │                                         │
  │  ┌──────────┐   ┌──────────┐            │
  │  │  Routes  │──[BLACK_RIGHT-POINTING_TRIANGLE]│  Logique │            │
  │  │  (URLs)  │   │  Métier  │            │
  │  └──────────┘   └────┬─────┘            │
  │                       │                 │
  │                  ┌────[BLACK_DOWN-POINTING_TRIANGLE]─────┐           │
  │                  │ Modèles  │           │
  │                  │  (DB)    │           │
  │                  └──────────┘           │
  └─────────────────────────────────────────┘
      │
      [BLACK_DOWN-POINTING_TRIANGLE]
   Base de données

STRUCTURE DE DOSSIERS (SIMPLE) :
──────────────────────────────────

  mon_projet/
  ├── app.py          <- Point d'entrée, configuration Flask
  ├── models.py       <- Structures de données (utilisateurs, produits...)
  ├── routes.py       <- URLs et logique de chaque page/endpoint
  ├── config.py       <- Configuration (DB, clés secrètes...)
  ├── templates/      <- Fichiers HTML (si applicable)
  │   ├── index.html
  │   └── user.html
  ├── static/         <- CSS, JS, images
  └── requirements.txt <- Dépendances Python

================================================================================
3. QUAND UTILISER CETTE ARCHITECTURE ?
================================================================================

SITUATIONS IDÉALES :
────────────────────
  [OK] Prototype ou MVP (Minimum Viable Product)
  [OK] Projet personnel ou de démonstration
  [OK] Petite application (< 10 000 lignes de code)
  [OK] Équipe de 1 à 3 développeurs
  [OK] Délais très courts
  [OK] Trafic faible à modéré
  [OK] Application interne d'entreprise simple

EXEMPLES RÉELS :
────────────────
  - Blog personnel
  - Application de gestion de tâches (To-Do)
  - Système d'inventaire simple
  - Site vitrine avec formulaire de contact
  - API interne d'une PME

QUAND ÉVITER :
──────────────
  [X] Application avec des millions d'utilisateurs
  [X] Équipe de plus de 10 développeurs
  [X] Domaines métier très différents
  [X] Besoin de scaler différentes parties indépendamment

================================================================================
4. POURQUOI UTILISER CETTE ARCHITECTURE ?
================================================================================

MAINTENABILITÉ :
────────────────
  Pour les petits projets, tout voir en un seul endroit est un avantage.
  Pas besoin de chercher dans 10 services différents.

TESTABILITÉ :
─────────────
  Les tests sont simples : on lance l'app, on teste les endpoints.
  Pas de mocking complexe entre services.

SCALABILITÉ :
─────────────
  Limitée, mais suffisante pour la majorité des projets.
  On peut scaler horizontalement (plusieurs instances) pour quelque temps.

PERFORMANCE :
─────────────
  Excellente pour les petits projets : pas de latence réseau entre composants.
  Tout s'exécute dans le même processus.

================================================================================
5. IMPLÉMENTATION COMPLÈTE
================================================================================

PROJET : API de gestion d'utilisateurs et de tâches (To-Do)

──────────────────────────────────────────────
FICHIER 1 : config.py
──────────────────────────────────────────────

# config.py
# Ce fichier centralise toute la configuration de l'application.
# L'avantage : changer la config ne nécessite de modifier qu'un seul fichier.

import os  # Pour lire les variables d'environnement du système

class Config:
    """
    Classe de configuration principale.
    On utilise une classe pour pouvoir créer plusieurs configurations
    (développement, production, test) par héritage.
    """

    # SECRET_KEY : clé secrète pour signer les cookies de session Flask
    # os.environ.get() essaie d'abord de lire la variable d'env,
    # sinon utilise la valeur par défaut 'dev-secret-key'
    SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')

    # SQLALCHEMY_DATABASE_URI : chemin vers la base de données
    # sqlite:///app.db = SQLite dans le fichier app.db du dossier courant
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        'DATABASE_URL',
        'sqlite:///app.db'  # Fichier SQLite local par défaut
    )

    # SQLALCHEMY_TRACK_MODIFICATIONS : désactivé pour économiser de la mémoire
    # Flask-SQLAlchemy enverrait des signaux à chaque modification sinon
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class DevelopmentConfig(Config):
    """
    Configuration pour le développement.
    Hérite de Config et ajoute/surcharge des valeurs.
    """
    DEBUG = True  # Active le mode debug (rechargement auto, erreurs détaillées)

class ProductionConfig(Config):
    """
    Configuration pour la production.
    Paramètres plus sécurisés.
    """
    DEBUG = False  # Jamais de debug en production !
    # En production, SECRET_KEY doit OBLIGATOIREMENT venir d'une variable d'env

# Dictionnaire pour accéder à la bonne config par son nom
config = {
    'development': DevelopmentConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig  # Par défaut, on utilise le mode développement
}


──────────────────────────────────────────────
FICHIER 2 : models.py
──────────────────────────────────────────────

# models.py
# Ce fichier définit les MODÈLES de données : la structure de notre base de données.
# Chaque classe = une table en base de données.
# SQLAlchemy traduit nos classes Python en SQL automatiquement.

from datetime import datetime  # Pour gérer les dates
# db sera importé depuis app.py pour éviter les imports circulaires
# (On ne l'importe pas ici, on le recevra par paramètre ou import)

# NOTE : dans l'architecture monolithique simple, on peut initialiser db ici
# ou dans app.py. Nous le ferons dans app.py pour garder la logique centralisée.

from flask_sqlalchemy import SQLAlchemy

# Création de l'objet db (sera configuré dans app.py via db.init_app(app))
db = SQLAlchemy()

class User(db.Model):
    """
    Modèle utilisateur.
    Hérite de db.Model pour être reconnu comme une table SQLAlchemy.
    SQLAlchemy créera automatiquement la table 'user' en base de données.
    """

    # __tablename__ : nom de la table en base de données
    # Sans ça, SQLAlchemy utilise le nom de la classe en minuscules
    __tablename__ = 'users'

    # Colonne 'id' : entier, clé primaire (unique, auto-incrémenté)
    id = db.Column(db.Integer, primary_key=True)

    # Colonne 'username' : chaîne de max 80 caractères, obligatoire, unique
    username = db.Column(db.String(80), nullable=False, unique=True)

    # Colonne 'email' : chaîne de max 120 caractères, obligatoire, unique
    email = db.Column(db.String(120), nullable=False, unique=True)

    # Colonne 'password_hash' : mot de passe hashé (jamais en clair !)
    password_hash = db.Column(db.String(256), nullable=False)

    # Colonne 'created_at' : date de création, remplie automatiquement
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    # Colonne 'is_active' : compte actif ou non
    is_active = db.Column(db.Boolean, default=True)

    # RELATION : un User peut avoir plusieurs Tasks
    # backref='owner' crée automatiquement task.owner pour accéder au User depuis Task
    # lazy=True signifie que les tasks ne sont chargées que quand on y accède
    tasks = db.relationship('Task', backref='owner', lazy=True)

    def __repr__(self):
        """
        Représentation textuelle de l'objet.
        Utile pour le debugging : print(user) affichera <User jean>
        """
        return f'<User {self.username}>'

    def to_dict(self):
        """
        Convertit l'objet en dictionnaire Python.
        Utile pour sérialiser en JSON dans les réponses API.
        """
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'created_at': self.created_at.isoformat(),  # Format ISO 8601
            'is_active': self.is_active
            # On n'inclut JAMAIS le password_hash dans la réponse !
        }


class Task(db.Model):
    """
    Modèle de tâche (To-Do).
    Appartient à un utilisateur (relation Many-to-One).
    Un utilisateur peut avoir plusieurs tâches, une tâche appartient à un utilisateur.
    """

    __tablename__ = 'tasks'

    id = db.Column(db.Integer, primary_key=True)

    # Titre de la tâche : obligatoire, max 200 caractères
    title = db.Column(db.String(200), nullable=False)

    # Description : optionnelle (nullable=True par défaut)
    description = db.Column(db.Text)

    # Statut de complétion
    completed = db.Column(db.Boolean, default=False)

    # Date de création
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    # Clé étrangère : référence vers la table users
    # Ça crée le lien entre Task et User en base de données
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)

    def __repr__(self):
        return f'<Task {self.title}>'

    def to_dict(self):
        """Sérialisation pour l'API."""
        return {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'completed': self.completed,
            'created_at': self.created_at.isoformat(),
            'user_id': self.user_id
        }


──────────────────────────────────────────────
FICHIER 3 : routes.py
──────────────────────────────────────────────

# routes.py
# Ce fichier définit TOUTES les routes (URLs) de l'application.
# Chaque route correspond à une action (lister les users, créer une task, etc.)
# Dans l'architecture monolithique, la logique métier est souvent ici.

from flask import Blueprint, request, jsonify
# Blueprint = façon Flask de grouper des routes liées
# request = objet Flask contenant les données de la requête HTTP
# jsonify = convertit un dict Python en réponse JSON

from models import db, User, Task  # Import des modèles depuis models.py
import hashlib  # Pour hasher les mots de passe (en vrai, utilise bcrypt !)
import json  # Pour traiter le JSON

# Création d'un Blueprint pour les routes utilisateurs
# Permet de grouper toutes les routes /users/* ensemble
user_bp = Blueprint('users', __name__, url_prefix='/api/users')

# Création d'un Blueprint pour les routes tâches
task_bp = Blueprint('tasks', __name__, url_prefix='/api/tasks')


# ─────────────────────────────────────────
# ROUTES UTILISATEURS
# ─────────────────────────────────────────

@user_bp.route('/', methods=['GET'])
def get_all_users():
    """
    GET /api/users/
    Récupère tous les utilisateurs de la base de données.
    """
    # User.query.all() : requête SQLAlchemy pour récupérer TOUS les users
    # Équivalent SQL : SELECT * FROM users
    users = User.query.all()

    # On convertit chaque user en dict avec la méthode to_dict()
    # puis on crée une liste de ces dicts
    users_list = [user.to_dict() for user in users]

    # jsonify() convertit la liste Python en réponse JSON HTTP
    # Le code de statut 200 signifie "OK" (succès)
    return jsonify(users_list), 200


@user_bp.route('/<int:user_id>', methods=['GET'])
def get_user(user_id):
    """
    GET /api/users/<id>
    Récupère un utilisateur spécifique par son ID.
    <int:user_id> : Flask extrait automatiquement l'ID de l'URL et le convertit en int
    """
    # get_or_404 : récupère le user avec cet ID, ou retourne une erreur 404
    # C'est plus propre que de gérer manuellement "si user est None"
    user = User.query.get_or_404(user_id)

    return jsonify(user.to_dict()), 200


@user_bp.route('/', methods=['POST'])
def create_user():
    """
    POST /api/users/
    Crée un nouvel utilisateur.
    Les données arrivent dans le corps (body) de la requête en JSON.
    """
    # request.get_json() : lit le corps de la requête et le parse en dict Python
    # Si le Content-Type n'est pas application/json, retourne None
    data = request.get_json()

    # Validation des données reçues
    if not data:
        # Si aucune donnée JSON, on retourne une erreur 400 (Bad Request)
        return jsonify({'error': 'Données JSON requises'}), 400

    # Vérification que les champs obligatoires sont présents
    required_fields = ['username', 'email', 'password']
    for field in required_fields:
        if field not in data:
            return jsonify({'error': f'Champ manquant : {field}'}), 400

    # Vérification de l'unicité de l'email
    # .first() retourne None si aucun résultat (pas d'erreur si non trouvé)
    existing_user = User.query.filter_by(email=data['email']).first()
    if existing_user:
        return jsonify({'error': 'Email déjà utilisé'}), 409  # 409 = Conflict

    # Hachage du mot de passe (JAMAIS stocker en clair !)
    # En production, utilise bcrypt ou werkzeug.security.generate_password_hash
    password_hash = hashlib.sha256(data['password'].encode()).hexdigest()

    # Création de l'objet User (pas encore en DB)
    new_user = User(
        username=data['username'],
        email=data['email'],
        password_hash=password_hash
    )

    # Ajout à la session SQLAlchemy (prépare l'insertion)
    db.session.add(new_user)

    # Commit = exécute réellement l'INSERT en base de données
    db.session.commit()

    # 201 Created : indique qu'une ressource a été créée avec succès
    return jsonify(new_user.to_dict()), 201


@user_bp.route('/<int:user_id>', methods=['PUT'])
def update_user(user_id):
    """
    PUT /api/users/<id>
    Modifie un utilisateur existant.
    """
    user = User.query.get_or_404(user_id)  # Récupère ou retourne 404
    data = request.get_json()

    if not data:
        return jsonify({'error': 'Données JSON requises'}), 400

    # Mise à jour uniquement des champs fournis (PATCH-like behavior)
    if 'username' in data:
        user.username = data['username']
    if 'email' in data:
        user.email = data['email']

    # SQLAlchemy détecte automatiquement les modifications sur l'objet
    db.session.commit()  # Commit = exécute l'UPDATE en base

    return jsonify(user.to_dict()), 200


@user_bp.route('/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
    """
    DELETE /api/users/<id>
    Supprime un utilisateur.
    """
    user = User.query.get_or_404(user_id)

    # Suppression de l'objet de la session SQLAlchemy
    db.session.delete(user)
    db.session.commit()

    # 204 No Content : succès mais pas de contenu à retourner
    return '', 204


# ─────────────────────────────────────────
# ROUTES TÂCHES
# ─────────────────────────────────────────

@task_bp.route('/', methods=['GET'])
def get_all_tasks():
    """
    GET /api/tasks/
    Récupère toutes les tâches.
    Supporte le filtrage par user_id via query parameter.
    Ex: GET /api/tasks/?user_id=1
    """
    # request.args : dictionnaire des paramètres d'URL (?key=value)
    user_id = request.args.get('user_id', type=int)

    if user_id:
        # Si user_id est fourni, filtrer les tâches de cet utilisateur
        # filter_by() génère un WHERE en SQL
        tasks = Task.query.filter_by(user_id=user_id).all()
    else:
        tasks = Task.query.all()

    return jsonify([task.to_dict() for task in tasks]), 200


@task_bp.route('/<int:task_id>', methods=['GET'])
def get_task(task_id):
    """GET /api/tasks/<id> - Récupère une tâche spécifique."""
    task = Task.query.get_or_404(task_id)
    return jsonify(task.to_dict()), 200


@task_bp.route('/', methods=['POST'])
def create_task():
    """
    POST /api/tasks/
    Crée une nouvelle tâche pour un utilisateur.
    """
    data = request.get_json()

    if not data:
        return jsonify({'error': 'Données JSON requises'}), 400

    # Vérification des champs obligatoires
    if 'title' not in data or 'user_id' not in data:
        return jsonify({'error': 'title et user_id requis'}), 400

    # Vérification que l'utilisateur existe
    user = User.query.get(data['user_id'])
    if not user:
        return jsonify({'error': 'Utilisateur non trouvé'}), 404

    new_task = Task(
        title=data['title'],
        description=data.get('description', ''),  # .get() retourne '' si absent
        user_id=data['user_id']
    )

    db.session.add(new_task)
    db.session.commit()

    return jsonify(new_task.to_dict()), 201


@task_bp.route('/<int:task_id>/complete', methods=['PATCH'])
def complete_task(task_id):
    """
    PATCH /api/tasks/<id>/complete
    Marque une tâche comme complétée.
    """
    task = Task.query.get_or_404(task_id)

    # Basculer l'état de complétion
    task.completed = not task.completed  # True -> False ou False -> True

    db.session.commit()

    return jsonify(task.to_dict()), 200


@task_bp.route('/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
    """DELETE /api/tasks/<id> - Supprime une tâche."""
    task = Task.query.get_or_404(task_id)
    db.session.delete(task)
    db.session.commit()
    return '', 204


──────────────────────────────────────────────
FICHIER 4 : app.py (POINT D'ENTRÉE)
──────────────────────────────────────────────

# app.py
# Point d'entrée de l'application.
# Ce fichier orchestre tout : configuration, initialisation, démarrage.

from flask import Flask  # Framework web Flask
from config import config  # Notre fichier de configuration
from models import db     # L'objet SQLAlchemy (pas encore configuré)
from routes import user_bp, task_bp  # Les blueprints de routes

import os  # Pour lire les variables d'environnement


def create_app(config_name='default'):
    """
    Factory function (pattern de création).
    Plutôt que de créer l'app directement, on utilise une fonction.
    Avantage : permet de créer l'app avec différentes configurations
    (dev, test, prod) facilement.

    Args:
        config_name: nom de la configuration à utiliser ('default', 'production'...)

    Returns:
        app: l'application Flask configurée
    """

    # Création de l'instance Flask
    # __name__ = nom du module courant (utilisé pour localiser les ressources)
    app = Flask(__name__)

    # Chargement de la configuration depuis notre classe Config
    # config[config_name] = 'DevelopmentConfig' ou 'ProductionConfig'
    app.config.from_object(config[config_name])

    # Initialisation de la base de données avec l'app Flask
    # db.init_app() connecte l'objet SQLAlchemy à notre application
    # On n'a pas mis db = SQLAlchemy(app) directement pour permettre
    # le pattern Application Factory (on peut créer plusieurs apps)
    db.init_app(app)

    # Enregistrement des Blueprints (groupes de routes)
    # Tous les endpoints de user_bp seront préfixés par /api/users (défini dans routes.py)
    app.register_blueprint(user_bp)
    app.register_blueprint(task_bp)

    # Création des tables en base de données si elles n'existent pas
    # with app.app_context() : nécessaire pour que SQLAlchemy sache quelle app utiliser
    with app.app_context():
        db.create_all()  # Crée toutes les tables définies dans models.py
        print("[OK] Base de données initialisée")

    return app  # On retourne l'app configurée


# Ce bloc ne s'exécute que si on lance directement : python app.py
# Il ne s'exécute PAS si app.py est importé par un autre module
if __name__ == '__main__':
    # Lire l'environnement depuis une variable d'env, 'default' si non définie
    env = os.environ.get('FLASK_ENV', 'default')

    # Créer l'application
    app = create_app(env)

    # Lancer le serveur de développement
    # host='0.0.0.0' : écoute sur toutes les interfaces réseau
    # port=5000 : port par défaut Flask
    # debug=True : rechargement automatique + messages d'erreur détaillés
    app.run(host='0.0.0.0', port=5000, debug=True)


──────────────────────────────────────────────
FICHIER 5 : requirements.txt
──────────────────────────────────────────────

# requirements.txt
# Liste de toutes les dépendances Python du projet
# Pour installer : pip install -r requirements.txt

flask==3.0.0              # Framework web
flask-sqlalchemy==3.1.1   # ORM pour la base de données
pytest==7.4.0             # Framework de tests

================================================================================
6. TESTS COMPLETS
================================================================================

──────────────────────────────────────────────
FICHIER : test_app.py
──────────────────────────────────────────────

# test_app.py
# Tests automatisés pour vérifier que notre API fonctionne correctement.
# On utilise pytest, le framework de test standard Python.

import pytest  # Framework de test
import json    # Pour encoder/décoder le JSON dans les tests

# Import de notre application factory et de la DB
from app import create_app
from models import db


@pytest.fixture
def app():
    """
    Fixture pytest : prépare l'environnement de test.
    Un fixture est exécuté avant chaque test qui en a besoin.
    C'est comme un "setup" automatique.
    """
    # Créer l'app avec une configuration de test
    app = create_app('default')

    # Reconfigurer pour les tests : utiliser une DB en mémoire
    # sqlite:///:memory: = base de données temporaire en RAM, disparaît après le test
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
    app.config['TESTING'] = True  # Active le mode test Flask

    # Créer toutes les tables pour les tests
    with app.app_context():
        db.create_all()

    yield app  # yield = pause ici, donne l'app au test

    # Code après yield = teardown (nettoyage après le test)
    with app.app_context():
        db.drop_all()  # Supprime toutes les tables après le test


@pytest.fixture
def client(app):
    """
    Fixture : client de test Flask.
    Permet de faire des requêtes HTTP sans démarrer un vrai serveur.
    """
    return app.test_client()  # Client de test Flask intégré


def test_get_users_empty(client):
    """
    Test : GET /api/users/ quand la DB est vide.
    On s'attend à recevoir une liste vide [].
    """
    # Faire une requête GET sur l'endpoint
    response = client.get('/api/users/')

    # Vérifier que la réponse a le bon code HTTP
    assert response.status_code == 200

    # Décoder le JSON de la réponse
    data = json.loads(response.data)

    # Vérifier que c'est bien une liste vide
    assert data == []


def test_create_user(client):
    """
    Test : POST /api/users/ pour créer un utilisateur.
    """
    # Données à envoyer dans la requête
    user_data = {
        'username': 'alice',
        'email': 'alice@example.com',
        'password': 'motdepasse123'
    }

    # Faire la requête POST avec les données JSON
    response = client.post(
        '/api/users/',
        data=json.dumps(user_data),         # Convertir le dict en JSON string
        content_type='application/json'      # Indiquer que c'est du JSON
    )

    # Vérifier le code 201 (Created)
    assert response.status_code == 201

    # Vérifier les données retournées
    data = json.loads(response.data)
    assert data['username'] == 'alice'
    assert data['email'] == 'alice@example.com'
    assert 'id' in data  # L'ID doit être présent
    assert 'password' not in data  # Le mot de passe ne doit PAS être retourné


def test_create_user_missing_field(client):
    """
    Test : Créer un utilisateur sans email -> doit retourner 400.
    """
    incomplete_data = {'username': 'bob'}  # Pas d'email, pas de password

    response = client.post(
        '/api/users/',
        data=json.dumps(incomplete_data),
        content_type='application/json'
    )

    assert response.status_code == 400  # Bad Request


def test_get_nonexistent_user(client):
    """
    Test : Récupérer un user inexistant -> doit retourner 404.
    """
    response = client.get('/api/users/999')
    assert response.status_code == 404  # Not Found


================================================================================
7. COMMANDES POUR LANCER LE PROJET
================================================================================

  # 1. Créer l'environnement virtuel
  python -m venv venv

  # 2. Activer l'environnement
  source venv/bin/activate  # Linux/Mac
  # ou
  venv\Scripts\activate     # Windows

  # 3. Installer les dépendances
  pip install -r requirements.txt

  # 4. Lancer l'application
  python app.py

  # 5. Tester avec curl (dans un autre terminal)
  curl http://localhost:5000/api/users/

  # 6. Créer un utilisateur
  curl -X POST http://localhost:5000/api/users/ \
       -H "Content-Type: application/json" \
       -d '{"username":"alice","email":"alice@test.com","password":"secret"}'

  # 7. Lancer les tests
  pytest test_app.py -v

================================================================================
8. BONNES PRATIQUES PROFESSIONNELLES
================================================================================

  ORGANISATION DU CODE :
  ──────────────────────
  [OK] Un fichier par responsabilité (models.py, routes.py, config.py)
  [OK] Des noms de variables et fonctions explicites
  [OK] Des commentaires sur le "pourquoi", pas le "quoi"
  [OK] Valider toujours les données en entrée
  [OK] Ne jamais exposer des données sensibles (mots de passe, clés API)

  GESTION DES ERREURS :
  ──────────────────────
  [OK] Retourner les bons codes HTTP (200, 201, 400, 404, 409, 500)
  [OK] Des messages d'erreur clairs et utiles
  [OK] Logger les erreurs pour le debugging

  SÉCURITÉ :
  ──────────
  [OK] Hasher les mots de passe (bcrypt en production)
  [OK] Valider et nettoyer les entrées utilisateur
  [OK] SECRET_KEY depuis les variables d'environnement
  [OK] Ne jamais commiter .env ou des secrets

================================================================================
9. ERREURS FRÉQUENTES
================================================================================

  [X] ERREUR 1 : Import circulaire
  ───────────────────────────────
  Problème : app.py importe models.py, models.py importe app.py -> boucle infinie
  Solution : Utiliser db = SQLAlchemy() dans models.py et db.init_app(app) dans app.py

  [X] ERREUR 2 : Oublier db.session.commit()
  ──────────────────────────────────────────
  Problème : db.session.add(obj) prépare l'insertion mais ne l'exécute pas
  Solution : Toujours appeler db.session.commit() après add() ou delete()

  [X] ERREUR 3 : Retourner des objets SQLAlchemy directement
  ──────────────────────────────────────────────────────────
  Problème : return jsonify(user) -> erreur, Flask ne sait pas sérialiser SQLAlchemy
  Solution : Toujours convertir : return jsonify(user.to_dict())

  [X] ERREUR 4 : Pas de validation des données
  ───────────────────────────────────────────
  Problème : Un utilisateur envoie n'importe quoi et ça plante
  Solution : Toujours valider les données reçues avant de les utiliser

  [X] ERREUR 5 : Stocker les mots de passe en clair
  ─────────────────────────────────────────────────
  Problème : Si la DB est volée, tous les mots de passe sont exposés
  Solution : Toujours hasher avec bcrypt : pip install flask-bcrypt

================================================================================
10. EXERCICE PRATIQUE
================================================================================

EXERCICE : Ajouter la gestion des catégories de tâches

OBJECTIF : Étendre le projet avec un modèle Category et les routes associées

ÉTAPES :
────────
  1. Dans models.py, ajouter le modèle Category :
     - id (Integer, PK)
     - name (String(50), not null, unique)
     - color (String(7)) -> pour stocker une couleur hex (#FF5733)

  2. Modifier le modèle Task pour ajouter category_id (ForeignKey)

  3. Dans routes.py, ajouter un Blueprint category_bp avec :
     - GET /api/categories/ -> liste toutes les catégories
     - POST /api/categories/ -> crée une catégorie
     - GET /api/tasks/?category_id=X -> filtre les tâches par catégorie

  4. Enregistrer category_bp dans app.py

  5. Tester avec curl ou Postman

CORRECTION :
────────────

  # models.py - Ajouter :

  class Category(db.Model):
      __tablename__ = 'categories'

      id = db.Column(db.Integer, primary_key=True)

      # Nom de la catégorie (unique pour éviter les doublons)
      name = db.Column(db.String(50), nullable=False, unique=True)

      # Couleur en format hexadécimal (ex: #FF5733)
      color = db.Column(db.String(7), default='#3498db')

      # Relation avec les tâches
      tasks = db.relationship('Task', backref='category', lazy=True)

      def to_dict(self):
          return {
              'id': self.id,
              'name': self.name,
              'color': self.color
          }

  # Modifier Task pour ajouter :
  # category_id = db.Column(db.Integer, db.ForeignKey('categories.id'))

================================================================================
RÉSUMÉ
================================================================================

  L'architecture monolithique est parfaite pour démarrer.
  
  Structure simple :
    config.py  -> Configuration
    models.py  -> Structure de la DB
    routes.py  -> URLs et logique
    app.py     -> Assemblage et démarrage
  
  Règles d'or :
    [OK] Un fichier par responsabilité
    [OK] Toujours valider les données
    [OK] Jamais les mots de passe en clair
    [OK] Toujours commit() après les modifications DB
  
  Prochain fichier : architecture_mvc.txt
  (On va structurer encore mieux avec le pattern Model-View-Controller)

================================================================================
FIN DU FICHIER architecture_monolithique.txt
================================================================================

================================================================================
         ARCHITECTURE MVC (Model - View - Controller)
         Guide complet avec exemples Flask
================================================================================

================================================================================
1. INTRODUCTION PÉDAGOGIQUE
================================================================================

DÉFINITION :
────────────
MVC est un patron de conception (design pattern) qui divise une application en
TROIS parties distinctes avec des responsabilités claires :

  M -> MODEL      : Les données et les règles métier
  V -> VIEW       : L'affichage et la présentation
  C -> CONTROLLER : La coordination entre M et V

ANALOGIE DU RESTAURANT :
─────────────────────────
  Cuisine (Chef)    = MODEL      -> prépare les données (plats)
  Salle (Serveur)   = CONTROLLER -> coordonne les demandes
  Menu / Assiette   = VIEW       -> présente l'information au client

Un serveur ne cuisine pas.
Un chef ne sert pas.
Chacun fait son travail.

FLUX DE DONNÉES MVC :
─────────────────────

  Utilisateur
      │
      │ 1. Envoie une requête (clic, URL...)
      [BLACK_DOWN-POINTING_TRIANGLE]
  CONTROLLER
      │
      │ 2. Demande les données
      [BLACK_DOWN-POINTING_TRIANGLE]
   MODEL <-──── Base de données
      │
      │ 3. Retourne les données
      [BLACK_DOWN-POINTING_TRIANGLE]
  CONTROLLER
      │
      │ 4. Envoie les données à la vue
      [BLACK_DOWN-POINTING_TRIANGLE]
    VIEW
      │
      │ 5. Affiche la réponse
      [BLACK_DOWN-POINTING_TRIANGLE]
  Utilisateur

AVANTAGES :
───────────
  [OK] Séparation des responsabilités
  [OK] Modifications indépendantes (changer la vue sans toucher aux données)
  [OK] Réutilisabilité (même model pour plusieurs vues)
  [OK] Testabilité améliorée
  [OK] Travail en équipe facilité (front / back séparés)

LIMITES :
─────────
  [X] Plus complexe que le monolithique
  [X] Peut devenir un "Fat Controller" si la logique métier y est mise
  [X] Pour les très petites apps, peut sembler surdimensionné

================================================================================
2. STRUCTURE DE DOSSIERS MVC
================================================================================

  todo_mvc/
  ├── app.py                     <- Point d'entrée
  ├── config.py                  <- Configuration
  ├── requirements.txt
  │
  ├── models/                    <- LAYER M (Model)
  │   ├── __init__.py            <- Initialise le package
  │   ├── user.py                <- Modèle User
  │   └── task.py                <- Modèle Task
  │
  ├── views/                     <- LAYER V (View)
  │   ├── __init__.py
  │   └── templates/             <- Templates HTML
  │       ├── base.html
  │       ├── users/
  │       │   ├── list.html
  │       │   └── detail.html
  │       └── tasks/
  │           └── list.html
  │
  ├── controllers/               <- LAYER C (Controller)
  │   ├── __init__.py
  │   ├── user_controller.py     <- Logique de coordination pour User
  │   └── task_controller.py     <- Logique de coordination pour Task
  │
  └── tests/                     <- Tests
      ├── test_models.py
      └── test_controllers.py

EXPLICATION DE LA STRUCTURE :
──────────────────────────────

  models/     -> Contient uniquement la définition des données et leurs validations
               -> Communique UNIQUEMENT avec la base de données
               -> NE CONNAÎT PAS les controllers ni les views

  controllers/ -> Reçoit les requêtes HTTP
                -> Appelle les models pour avoir les données
                -> Envoie les données aux views
                -> Contient la logique de COORDINATION (pas la logique métier !)

  views/       -> Templates HTML ou réponses JSON
                -> Reçoit des données, les formate et les affiche
                -> NE CONTIENT PAS de logique métier

================================================================================
3. IMPLÉMENTATION COMPLÈTE
================================================================================

──────────────────────────────────────────────
FICHIER : models/__init__.py
──────────────────────────────────────────────

# models/__init__.py
# Ce fichier rend le dossier models/ importable comme un package Python.
# Il centralise l'initialisation de la DB et l'export des models.

from flask_sqlalchemy import SQLAlchemy

# db est l'objet SQLAlchemy partagé par tous les modèles
# On l'initialise ici (sans app) pour éviter les imports circulaires
db = SQLAlchemy()

# On importe les modèles pour qu'ils soient disponibles depuis 'models'
# Exemple : from models import User, Task, db
from .user import User    # Le point . signifie "dans le même package"
from .task import Task


──────────────────────────────────────────────
FICHIER : models/user.py
──────────────────────────────────────────────

# models/user.py
# Modèle User : définit la structure des utilisateurs EN BASE DE DONNÉES.
# Le modèle contient aussi les validations et méthodes utilitaires.
# Il NE DOIT PAS contenir de logique HTTP (requêtes, réponses...).

from datetime import datetime
from models import db  # Import de l'objet db depuis models/__init__.py
import hashlib


class User(db.Model):
    """
    Modèle représentant un utilisateur.
    En MVC, le Model est responsable de :
    - La structure des données (colonnes, types, contraintes)
    - Les validations métier (email valide, password fort...)
    - Les méthodes d'accès aux données (classiques et personnalisées)
    """

    __tablename__ = 'users'

    id         = db.Column(db.Integer, primary_key=True)
    username   = db.Column(db.String(80), nullable=False, unique=True)
    email      = db.Column(db.String(120), nullable=False, unique=True)
    password_hash = db.Column(db.String(256), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    is_active  = db.Column(db.Boolean, default=True)

    # Relation One-to-Many : un User a plusieurs Tasks
    tasks = db.relationship('Task', backref='user', lazy=True,
                            cascade='all, delete-orphan')
    # cascade='all, delete-orphan' : si on supprime un User,
    # toutes ses Tasks sont aussi supprimées automatiquement

    # ── MÉTHODES MÉTIER DU MODÈLE ──────────────────────────────

    def set_password(self, password):
        """
        Hashe et stocke le mot de passe.
        Cette logique appartient au MODEL : c'est une règle sur les données.
        En production, utilise bcrypt : pip install bcrypt
        """
        # sha256 est simple mais pas optimal pour les mots de passe
        # En prod : self.password_hash = bcrypt.generate_password_hash(password)
        self.password_hash = hashlib.sha256(password.encode()).hexdigest()

    def check_password(self, password):
        """
        Vérifie si le mot de passe fourni correspond au hash stocké.
        Retourne True si correct, False sinon.
        """
        return self.password_hash == hashlib.sha256(password.encode()).hexdigest()

    def deactivate(self):
        """Désactive le compte utilisateur."""
        self.is_active = False

    # ── MÉTHODES DE REQUÊTE ────────────────────────────────────

    @classmethod
    def find_by_email(cls, email):
        """
        Cherche un utilisateur par email.
        @classmethod : méthode appelée sur la classe, pas sur une instance
        cls = User (la classe elle-même)
        Permet d'écrire : user = User.find_by_email('alice@test.com')
        """
        return cls.query.filter_by(email=email).first()

    @classmethod
    def find_all_active(cls):
        """Retourne tous les utilisateurs actifs."""
        return cls.query.filter_by(is_active=True).all()

    # ── SÉRIALISATION ──────────────────────────────────────────

    def to_dict(self):
        """
        Convertit l'objet en dictionnaire sérialisable.
        Appelé par le Controller pour préparer la réponse JSON.
        """
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'created_at': self.created_at.isoformat(),
            'is_active': self.is_active,
            'tasks_count': len(self.tasks)  # Nombre de tâches de l'utilisateur
        }

    def __repr__(self):
        return f'<User {self.username}>'


──────────────────────────────────────────────
FICHIER : models/task.py
──────────────────────────────────────────────

# models/task.py
# Modèle Task : représente une tâche dans la base de données.

from datetime import datetime
from models import db


class Task(db.Model):
    """
    Modèle Task avec priorités et statuts.
    Contient la logique liée AUX DONNÉES (règles métier des tâches).
    """

    __tablename__ = 'tasks'

    # Constantes de priorité : définies dans le modèle car c'est une règle métier
    PRIORITY_LOW    = 'low'
    PRIORITY_MEDIUM = 'medium'
    PRIORITY_HIGH   = 'high'

    # Constantes de statut
    STATUS_PENDING    = 'pending'
    STATUS_IN_PROGRESS = 'in_progress'
    STATUS_DONE       = 'done'

    id          = db.Column(db.Integer, primary_key=True)
    title       = db.Column(db.String(200), nullable=False)
    description = db.Column(db.Text)
    priority    = db.Column(db.String(20), default=PRIORITY_MEDIUM)
    status      = db.Column(db.String(20), default=STATUS_PENDING)
    due_date    = db.Column(db.DateTime)
    created_at  = db.Column(db.DateTime, default=datetime.utcnow)

    # Clé étrangère vers la table users
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)

    # ── MÉTHODES MÉTIER ────────────────────────────────────────

    def mark_done(self):
        """Marque la tâche comme terminée."""
        self.status = self.STATUS_DONE

    def start_progress(self):
        """Démarre le travail sur la tâche."""
        self.status = self.STATUS_IN_PROGRESS

    def is_overdue(self):
        """
        Vérifie si la tâche est en retard.
        Retourne True si la date d'échéance est passée ET que la tâche n'est pas done.
        """
        if self.due_date and self.status != self.STATUS_DONE:
            return datetime.utcnow() > self.due_date
        return False

    @classmethod
    def find_by_user(cls, user_id, status=None):
        """
        Trouve les tâches d'un utilisateur.
        Si status est fourni, filtre par statut.
        """
        query = cls.query.filter_by(user_id=user_id)
        if status:
            query = query.filter_by(status=status)
        return query.all()

    def to_dict(self):
        """Sérialise la tâche en dictionnaire."""
        return {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'priority': self.priority,
            'status': self.status,
            'due_date': self.due_date.isoformat() if self.due_date else None,
            'created_at': self.created_at.isoformat(),
            'user_id': self.user_id,
            'is_overdue': self.is_overdue()
        }

    def __repr__(self):
        return f'<Task {self.title} [{self.status}]>'


──────────────────────────────────────────────
FICHIER : controllers/user_controller.py
──────────────────────────────────────────────

# controllers/user_controller.py
# Le Controller est le CHEF D'ORCHESTRE.
# Il :
#   1. Reçoit la requête HTTP (via Flask)
#   2. Valide et extrait les données
#   3. Appelle le Model pour les opérations sur les données
#   4. Retourne la réponse (via la View ou directement en JSON)
#
# ATTENTION : le Controller ne doit PAS contenir de logique SQL directe.
# La logique SQL reste dans le Model. Le Controller appelle les méthodes du Model.

from flask import Blueprint, request, jsonify
from models import db
from models.user import User

# Blueprint avec préfixe d'URL
user_controller = Blueprint('user_controller', __name__, url_prefix='/api/users')


@user_controller.route('/', methods=['GET'])
def index():
    """
    GET /api/users/
    ACTION : Lister tous les utilisateurs actifs.
    
    Le Controller :
    1. Ne fait PAS de requête SQL directement
    2. Délègue au Model : User.find_all_active()
    3. Formate la réponse
    """
    # Délégation au Model (méthode définie dans user.py)
    users = User.find_all_active()

    # Sérialisation et réponse
    return jsonify({
        'users': [user.to_dict() for user in users],
        'total': len(users)  # Métadonnée utile pour la pagination
    }), 200


@user_controller.route('/<int:user_id>', methods=['GET'])
def show(user_id):
    """
    GET /api/users/<id>
    ACTION : Afficher un utilisateur.
    """
    # Le Controller gère le cas "non trouvé" avec get_or_404
    user = User.query.get_or_404(user_id)
    return jsonify(user.to_dict()), 200


@user_controller.route('/', methods=['POST'])
def create():
    """
    POST /api/users/
    ACTION : Créer un nouvel utilisateur.
    
    Le Controller :
    1. Valide les données reçues
    2. Vérifie les règles métier (email unique)
    3. Crée via le Model
    4. Retourne la réponse
    """
    data = request.get_json()

    # ── VALIDATION ──────────────────────────────────────────
    # Le Controller valide les données de la requête HTTP
    # (format, présence des champs obligatoires)

    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    errors = _validate_user_data(data)
    if errors:
        return jsonify({'errors': errors}), 400

    # ── RÈGLE MÉTIER : unicité de l'email ────────────────────
    # On vérifie via le Model si l'email existe déjà
    if User.find_by_email(data['email']):
        return jsonify({'error': 'Email déjà utilisé'}), 409

    # ── CRÉATION VIA LE MODEL ────────────────────────────────
    # On instancie le modèle et utilise ses méthodes
    new_user = User(
        username=data['username'],
        email=data['email']
    )
    new_user.set_password(data['password'])  # Méthode du Model pour le hash

    db.session.add(new_user)
    db.session.commit()

    return jsonify(new_user.to_dict()), 201


@user_controller.route('/<int:user_id>', methods=['PUT'])
def update(user_id):
    """
    PUT /api/users/<id>
    ACTION : Modifier un utilisateur.
    """
    user = User.query.get_or_404(user_id)
    data = request.get_json()

    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    # Mise à jour des champs fournis
    if 'username' in data:
        user.username = data['username']
    if 'email' in data:
        # Vérifier que le nouvel email n'est pas déjà pris par quelqu'un d'autre
        existing = User.find_by_email(data['email'])
        if existing and existing.id != user_id:
            return jsonify({'error': 'Email déjà utilisé'}), 409
        user.email = data['email']
    if 'password' in data:
        user.set_password(data['password'])  # Méthode du Model

    db.session.commit()
    return jsonify(user.to_dict()), 200


@user_controller.route('/<int:user_id>', methods=['DELETE'])
def delete(user_id):
    """
    DELETE /api/users/<id>
    ACTION : Supprimer (ou désactiver) un utilisateur.
    """
    user = User.query.get_or_404(user_id)

    # Option 1 : suppression physique
    db.session.delete(user)
    db.session.commit()

    # Option 2 (mieux) : soft delete (désactivation)
    # user.deactivate()  # Méthode du Model
    # db.session.commit()

    return '', 204


@user_controller.route('/<int:user_id>/tasks', methods=['GET'])
def user_tasks(user_id):
    """
    GET /api/users/<id>/tasks
    ACTION : Récupérer toutes les tâches d'un utilisateur.
    
    C'est une action de coordination : elle implique DEUX models (User + Task).
    Le Controller est le bon endroit pour cette coordination.
    """
    # Vérifier que le user existe
    user = User.query.get_or_404(user_id)

    # Accéder aux tâches via la relation SQLAlchemy définie dans le Model
    tasks = user.tasks

    return jsonify({
        'user': user.to_dict(),
        'tasks': [task.to_dict() for task in tasks]
    }), 200


# ── FONCTION PRIVÉE DE VALIDATION ────────────────────────────────────────────
# Les fonctions préfixées de _ sont des conventions Python pour "privé"
# (pas vraiment privé en Python, mais signal aux autres développeurs)

def _validate_user_data(data):
    """
    Valide les données d'un utilisateur.
    Retourne une liste d'erreurs (vide si tout est OK).
    
    Cette fonction est dans le Controller car elle valide le FORMAT de la requête
    (pas les règles métier qui elles sont dans le Model).
    """
    errors = []

    # Vérification des champs obligatoires
    if 'username' not in data:
        errors.append('username est requis')
    elif len(data['username']) < 3:
        errors.append('username doit faire au moins 3 caractères')

    if 'email' not in data:
        errors.append('email est requis')
    elif '@' not in data['email']:
        errors.append('email invalide')

    if 'password' not in data:
        errors.append('password est requis')
    elif len(data['password']) < 6:
        errors.append('password doit faire au moins 6 caractères')

    return errors


──────────────────────────────────────────────
FICHIER : controllers/task_controller.py
──────────────────────────────────────────────

# controllers/task_controller.py
# Controller pour les tâches.

from flask import Blueprint, request, jsonify
from models import db
from models.task import Task
from models.user import User

task_controller = Blueprint('task_controller', __name__, url_prefix='/api/tasks')


@task_controller.route('/', methods=['GET'])
def index():
    """
    GET /api/tasks/?user_id=X&status=Y
    ACTION : Lister les tâches avec filtres optionnels.
    """
    # Lecture des paramètres de filtre depuis l'URL
    user_id = request.args.get('user_id', type=int)
    status  = request.args.get('status')

    if user_id:
        # Utilise la méthode de classe définie dans le Model
        tasks = Task.find_by_user(user_id, status=status)
    else:
        tasks = Task.query.all()

    return jsonify({
        'tasks': [task.to_dict() for task in tasks],
        'total': len(tasks)
    }), 200


@task_controller.route('/<int:task_id>', methods=['GET'])
def show(task_id):
    """GET /api/tasks/<id>"""
    task = Task.query.get_or_404(task_id)
    return jsonify(task.to_dict()), 200


@task_controller.route('/', methods=['POST'])
def create():
    """POST /api/tasks/ - Créer une tâche"""
    data = request.get_json()

    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    # Validation
    if 'title' not in data:
        return jsonify({'error': 'title est requis'}), 400
    if 'user_id' not in data:
        return jsonify({'error': 'user_id est requis'}), 400

    # Vérifier que l'utilisateur existe
    user = User.query.get(data['user_id'])
    if not user:
        return jsonify({'error': 'Utilisateur non trouvé'}), 404

    # Valider la priorité si fournie
    valid_priorities = [Task.PRIORITY_LOW, Task.PRIORITY_MEDIUM, Task.PRIORITY_HIGH]
    priority = data.get('priority', Task.PRIORITY_MEDIUM)
    if priority not in valid_priorities:
        return jsonify({'error': f'Priorité invalide. Valeurs: {valid_priorities}'}), 400

    new_task = Task(
        title=data['title'],
        description=data.get('description', ''),
        priority=priority,
        user_id=data['user_id']
    )

    db.session.add(new_task)
    db.session.commit()

    return jsonify(new_task.to_dict()), 201


@task_controller.route('/<int:task_id>/status', methods=['PATCH'])
def update_status(task_id):
    """
    PATCH /api/tasks/<id>/status
    ACTION : Changer le statut d'une tâche.
    
    On délègue la logique de changement au Model (méthodes mark_done, start_progress).
    """
    task = Task.query.get_or_404(task_id)
    data = request.get_json()

    new_status = data.get('status')

    # Utilisation des méthodes du Model pour changer l'état
    # Le Controller ne sait pas comment changer l'état, il délègue au Model
    if new_status == Task.STATUS_DONE:
        task.mark_done()           # Méthode du Model
    elif new_status == Task.STATUS_IN_PROGRESS:
        task.start_progress()      # Méthode du Model
    else:
        return jsonify({'error': 'Statut invalide'}), 400

    db.session.commit()
    return jsonify(task.to_dict()), 200


──────────────────────────────────────────────
FICHIER : views/templates/base.html
──────────────────────────────────────────────
<!-- base.html -->
<!-- Template de base dont héritent tous les autres templates -->
<!-- En Flask, on utilise le moteur de templates Jinja2 -->

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    
    <!-- {% block title %} : zone remplaçable par les templates enfants -->
    <title>{% block title %}Todo MVC{% endblock %}</title>
    
    <style>
        body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
        .container { max-width: 800px; margin: 0 auto; }
        .task { border: 1px solid #ddd; padding: 10px; margin: 5px 0; }
        .done { background-color: #e8f5e9; text-decoration: line-through; }
        .overdue { background-color: #ffebee; }
    </style>
</head>
<body>
    <nav>
        <!-- url_for() génère l'URL d'un endpoint à partir de son nom -->
        <a href="{{ url_for('user_controller.index') }}">Utilisateurs</a>
        <a href="{{ url_for('task_controller.index') }}">Tâches</a>
    </nav>

    <div class="container">
        <!-- {% block content %} : zone de contenu principale -->
        {% block content %}
        <!-- Les templates enfants remplissent ce bloc -->
        {% endblock %}
    </div>
</body>
</html>

──────────────────────────────────────────────
FICHIER : app.py
──────────────────────────────────────────────

# app.py
# Point d'entrée de l'application MVC.

from flask import Flask
from config import config
from models import db

# Import des Controllers (pas des modèles directement !)
from controllers.user_controller import user_controller
from controllers.task_controller import task_controller


def create_app(config_name='default'):
    """Factory function pour créer l'application."""

    app = Flask(__name__)
    app.config.from_object(config[config_name])

    # Initialisation de la DB
    db.init_app(app)

    # Enregistrement des Blueprints (Controllers)
    # Dans MVC, l'app enregistre les CONTROLLERS, pas directement les routes
    app.register_blueprint(user_controller)
    app.register_blueprint(task_controller)

    with app.app_context():
        db.create_all()
        print("[OK] Application MVC démarrée")

    return app


if __name__ == '__main__':
    app = create_app()
    app.run(debug=True)

================================================================================
4. TESTS MVC
================================================================================

# tests/test_models.py
# Les tests MVC se concentrent sur chaque couche indépendamment.
# Les tests des Models vérifient la logique métier des données.

import pytest
from app import create_app
from models import db
from models.user import User
from models.task import Task


@pytest.fixture
def app():
    """Setup de l'environnement de test."""
    app = create_app()
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
    app.config['TESTING'] = True

    with app.app_context():
        db.create_all()
        yield app
        db.drop_all()


@pytest.fixture
def ctx(app):
    """Contexte d'application pour les tests de Model (sans HTTP)."""
    with app.app_context():
        yield


def test_user_password_hashing(ctx):
    """
    Test du Model : vérifier que le hash de mot de passe fonctionne.
    Ce test ne fait PAS de requête HTTP, il teste le Model directement.
    """
    user = User(username='alice', email='alice@test.com')
    user.set_password('motdepasse123')

    # Le password_hash ne doit pas être le mot de passe en clair
    assert user.password_hash != 'motdepasse123'

    # La vérification doit retourner True pour le bon mot de passe
    assert user.check_password('motdepasse123') is True

    # Et False pour un mauvais mot de passe
    assert user.check_password('mauvais') is False


def test_task_is_overdue(ctx):
    """Test que is_overdue() fonctionne correctement."""
    from datetime import datetime, timedelta

    # Tâche avec date d'échéance passée
    old_task = Task(
        title='Vieille tâche',
        due_date=datetime.utcnow() - timedelta(days=1),  # Hier
        user_id=1,
        status=Task.STATUS_PENDING
    )
    assert old_task.is_overdue() is True  # En retard

    # Tâche terminée (même en retard, is_overdue = False)
    done_task = Task(
        title='Tâche terminée',
        due_date=datetime.utcnow() - timedelta(days=1),
        user_id=1,
        status=Task.STATUS_DONE
    )
    assert done_task.is_overdue() is False  # Terminée = pas en retard


def test_task_status_transitions(ctx):
    """Test des transitions d'état d'une tâche."""
    task = Task(title='Test', user_id=1)

    # État initial
    assert task.status == Task.STATUS_PENDING

    # Démarrer
    task.start_progress()
    assert task.status == Task.STATUS_IN_PROGRESS

    # Terminer
    task.mark_done()
    assert task.status == Task.STATUS_DONE

================================================================================
5. COMPARAISON MVC vs MONOLITHIQUE
================================================================================

  ASPECT              MONOLITHIQUE          MVC
  ─────────────────────────────────────────────────────────
  Organisation        Tout dans routes.py   M/V/C séparés
  Testabilité         Difficile             Plus facile (tester M seul)
  Réutilisabilité     Limitée               Model réutilisable
  Séparation concerns Non                   Oui
  Complexité          Simple                Moyenne
  Taille projet       Petits                Petits à moyens
  Équipe              Solo                  2-5 personnes
  Logique métier      Dans routes.py        Dans Model

================================================================================
6. BONNES PRATIQUES MVC
================================================================================

  [OK] FAT MODEL, THIN CONTROLLER
  ─────────────────────────────
  La logique métier va dans le Model, pas dans le Controller.
  Le Controller coordonne, le Model décide.

  MAUVAIS (logique dans Controller) :
    @user_controller.route('/<id>/deactivate')
    def deactivate(user_id):
        user = User.query.get_or_404(user_id)
        user.is_active = False  # <- Logique métier dans le Controller !
        db.session.commit()

  BON (logique dans Model) :
    @user_controller.route('/<id>/deactivate')
    def deactivate(user_id):
        user = User.query.get_or_404(user_id)
        user.deactivate()  # <- Délègue au Model
        db.session.commit()

  [OK] UNE RESPONSABILITÉ PAR FICHIER
  ─────────────────────────────────
  user.py     -> Tout sur les utilisateurs
  task.py     -> Tout sur les tâches
  Ne pas mélanger les modèles dans un seul fichier.

  [OK] NOMMAGE COHÉRENT
  ───────────────────
  Controllers -> verbes d'action : index, show, create, update, delete
  Models      -> noms : User, Task, Category
  Views       -> noms descriptifs : user_list.html, task_detail.html

================================================================================
7. ERREURS FRÉQUENTES EN MVC
================================================================================

  [X] FAT CONTROLLER
  ─────────────────
  Mettre TOUTE la logique dans le Controller.
  Le Controller devient un monolithique déguisé.
  -> Déplacer la logique dans le Model

  [X] REQUÊTES SQL DANS LA VIEW
  ────────────────────────────
  Faire des requêtes DB directement dans les templates HTML.
  -> Toute requête DB = dans le Model ou le Controller

  [X] MODÈLES QUI CONNAISSENT LES CONTROLLERS
  ───────────────────────────────────────────
  Un Model ne doit JAMAIS importer un Controller.
  -> Flux à sens unique : Controller -> Model (jamais l'inverse)

  [X] LOGIQUE DE PRÉSENTATION DANS LE MODEL
  ─────────────────────────────────────────
  Le Model ne doit pas générer du HTML ou formater pour l'affichage.
  -> La sérialisation (to_dict) est acceptable, pas le HTML

================================================================================
8. EXERCICE PRATIQUE MVC
================================================================================

OBJECTIF : Ajouter une fonctionnalité de commentaires sur les tâches.

ÉTAPES :
  1. Créer models/comment.py avec le modèle Comment
     (id, content, created_at, task_id FK, user_id FK)

  2. Créer controllers/comment_controller.py
     GET /api/tasks/<id>/comments -> liste commentaires d'une tâche
     POST /api/tasks/<id>/comments -> ajouter un commentaire

  3. Ajouter la relation dans models/task.py :
     comments = db.relationship('Comment', backref='task', lazy=True)

  4. Enregistrer le blueprint dans app.py

  5. Écrire un test qui vérifie :
     - On peut créer un commentaire
     - On peut lister les commentaires d'une tâche

================================================================================
RÉSUMÉ
================================================================================

  MVC sépare en 3 couches avec responsabilités claires :
  
    MODEL      -> Données + Règles métier
    VIEW       -> Affichage (HTML ou JSON)
    CONTROLLER -> Coordination (reçoit req -> appelle model -> retourne view)
  
  Règle d'or : FAT MODEL, THIN CONTROLLER
  
  Flux : Requête -> Controller -> Model -> Controller -> View -> Réponse
  
  Prochain fichier : architecture_layered.txt
  (On va ajouter encore plus de couches : Service et Repository)

================================================================================
FIN DU FICHIER architecture_mvc.txt
================================================================================

================================================================================
         ARCHITECTURE N-TIERS / LAYERED (En Couches)
         Guide complet avec exemples Flask
================================================================================

================================================================================
1. INTRODUCTION PÉDAGOGIQUE
================================================================================

DÉFINITION :
────────────
L'architecture en couches (Layered ou N-Tiers) organise l'application en
NIVEAUX SUPERPOSÉS, où chaque couche a une responsabilité unique et ne communique
qu'avec la couche directement adjacent à elle.

C'est une extension de MVC avec une couche supplémentaire : la couche SERVICE.

LES 4 COUCHES CLASSIQUES :
───────────────────────────

  ┌──────────────────────────────┐
  │  CONTROLLER (Présentation)   │  <- Reçoit les requêtes HTTP
  ├──────────────────────────────┤
  │  SERVICE (Logique Métier)    │  <- Contient les règles du domaine
  ├──────────────────────────────┤
  │  REPOSITORY (Accès données)  │  <- Abstraction de la base de données
  ├──────────────────────────────┤
  │  MODEL (Entités)             │  <- Structure des données
  └──────────────────────────────┘
              │
              [BLACK_DOWN-POINTING_TRIANGLE]
         Base de données

RÈGLE FONDAMENTALE : DÉPENDANCES VERS LE BAS SEULEMENT
─────────────────────────────────────────────────────────
  Controller peut appeler Service
  Service peut appeler Repository
  Repository peut appeler Model
  
  JAMAIS l'inverse ! Model ne connaît pas Repository.
  Repository ne connaît pas Service. Etc.

AVANTAGES :
───────────
  [OK] Séparation claire des responsabilités
  [OK] Testabilité excellente (tester Service sans HTTP ni DB)
  [OK] Changement de couche indépendant (changer la DB sans toucher au Service)
  [OK] Logique métier centralisée dans le Service
  [OK] Standard en entreprise

LIMITES :
─────────
  [X] Plus de code à écrire
  [X] Pour les très simples CRUD, peut sembler surdimensionné
  [X] Risque de "Architecture astronaut" (sur-engineering)

================================================================================
2. RÔLE DE CHAQUE COUCHE EN DÉTAIL
================================================================================

COUCHE 1 : CONTROLLER
─────────────────────
  Responsabilité : Interface avec le monde extérieur (HTTP)
  
  FAIT :
  - Reçoit les requêtes HTTP
  - Valide le format des données (JSON correct ? Champs présents ?)
  - Appelle le Service approprié
  - Retourne la réponse HTTP (JSON, HTML)
  - Gère les codes HTTP (200, 201, 400, 404...)
  
  NE FAIT PAS :
  - Logique métier
  - Requêtes SQL directes
  - Accès à la base de données

COUCHE 2 : SERVICE
──────────────────
  Responsabilité : Logique métier de l'application
  
  FAIT :
  - Contient toutes les règles du domaine métier
  - Orchestre plusieurs repositories si nécessaire
  - Applique les validations métier
  - Lance des événements (emails, notifications...)
  - Transactions complexes (plusieurs opérations atomiques)
  
  NE FAIT PAS :
  - Logique HTTP (pas de request/response)
  - SQL direct
  - Logique d'affichage (HTML)
  
  EXEMPLE : "Un utilisateur ne peut pas avoir plus de 100 tâches actives"
  -> Cette règle va dans UserService, pas dans le Controller ni le Repository

COUCHE 3 : REPOSITORY
──────────────────────
  Responsabilité : Accès aux données (UNIQUEMENT)
  
  FAIT :
  - Requêtes SQL (via SQLAlchemy ou raw SQL)
  - CRUD de base sur les entités
  - Recherches et filtres
  - Pagination
  
  NE FAIT PAS :
  - Logique métier
  - Logique HTTP
  - Orchestration entre plusieurs tables (sauf jointures simples)

COUCHE 4 : MODEL (Entity)
──────────────────────────
  Responsabilité : Structure des données
  
  FAIT :
  - Définit la forme des objets (colonnes, types)
  - Méthodes utilitaires simples (to_dict, __repr__)
  - Relations entre entités
  
  NE FAIT PAS :
  - Logique métier complexe
  - Accès DB directs

================================================================================
3. STRUCTURE DE DOSSIERS
================================================================================

  todo_layered/
  ├── app.py
  ├── config.py
  ├── requirements.txt
  │
  ├── models/                    <- COUCHE 4 : Entités
  │   ├── __init__.py
  │   ├── user.py
  │   └── task.py
  │
  ├── repositories/              <- COUCHE 3 : Accès aux données
  │   ├── __init__.py
  │   ├── base_repository.py     <- Opérations CRUD communes
  │   ├── user_repository.py
  │   └── task_repository.py
  │
  ├── services/                  <- COUCHE 2 : Logique métier
  │   ├── __init__.py
  │   ├── user_service.py
  │   └── task_service.py
  │
  ├── controllers/               <- COUCHE 1 : Interface HTTP
  │   ├── __init__.py
  │   ├── user_controller.py
  │   └── task_controller.py
  │
  └── tests/
      ├── test_repositories.py
      ├── test_services.py
      └── test_controllers.py

================================================================================
4. IMPLÉMENTATION COMPLÈTE
================================================================================

──────────────────────────────────────────────
FICHIER : models/user.py
──────────────────────────────────────────────

# models/user.py
# Dans l'architecture en couches, le Model est une entité PURE.
# Il définit la structure des données. Point.
# La logique métier va dans le SERVICE, pas ici.

from datetime import datetime
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()


class User(db.Model):
    """
    Entité User : représentation d'un utilisateur en base de données.
    Dans l'architecture layered, le Model est le plus bas niveau.
    Il ne connaît rien des autres couches.
    """

    __tablename__ = 'users'

    id            = db.Column(db.Integer, primary_key=True)
    username      = db.Column(db.String(80), nullable=False, unique=True)
    email         = db.Column(db.String(120), nullable=False, unique=True)
    password_hash = db.Column(db.String(256), nullable=False)
    created_at    = db.Column(db.DateTime, default=datetime.utcnow)
    is_active     = db.Column(db.Boolean, default=True)

    # Relations définies au niveau du modèle (structure)
    tasks = db.relationship('Task', backref='user', lazy='dynamic',
                            cascade='all, delete-orphan')
    # lazy='dynamic' : retourne une requête SQLAlchemy au lieu de la liste
    # Permet de filtrer SANS charger tout en mémoire

    def to_dict(self):
        """Sérialisation simple. Pas de logique métier ici."""
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'created_at': self.created_at.isoformat(),
            'is_active': self.is_active
        }

    def __repr__(self):
        return f'<User {self.username}>'


class Task(db.Model):
    """Entité Task."""

    __tablename__ = 'tasks'

    PRIORITY_LOW    = 'low'
    PRIORITY_MEDIUM = 'medium'
    PRIORITY_HIGH   = 'high'

    STATUS_PENDING     = 'pending'
    STATUS_IN_PROGRESS = 'in_progress'
    STATUS_DONE        = 'done'

    id          = db.Column(db.Integer, primary_key=True)
    title       = db.Column(db.String(200), nullable=False)
    description = db.Column(db.Text)
    priority    = db.Column(db.String(20), default=PRIORITY_MEDIUM)
    status      = db.Column(db.String(20), default=STATUS_PENDING)
    due_date    = db.Column(db.DateTime)
    created_at  = db.Column(db.DateTime, default=datetime.utcnow)
    user_id     = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)

    def to_dict(self):
        return {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'priority': self.priority,
            'status': self.status,
            'due_date': self.due_date.isoformat() if self.due_date else None,
            'created_at': self.created_at.isoformat(),
            'user_id': self.user_id
        }

    def __repr__(self):
        return f'<Task {self.title}>'


──────────────────────────────────────────────
FICHIER : repositories/base_repository.py
──────────────────────────────────────────────

# repositories/base_repository.py
# Un Repository de base avec les opérations CRUD communes.
# Tous les autres repositories héritent de celui-ci.
# Avantage : éviter la répétition de code (DRY : Don't Repeat Yourself)

from models.user import db  # L'objet SQLAlchemy


class BaseRepository:
    """
    Repository générique avec les opérations CRUD de base.
    Utilise le pattern Generic Repository.
    
    Usage :
        class UserRepository(BaseRepository):
            def __init__(self):
                super().__init__(User)  # Précise le modèle
    """

    def __init__(self, model):
        """
        Args:
            model: La classe du modèle SQLAlchemy (ex: User, Task)
        """
        self.model = model  # Stocke la référence à la classe modèle

    def find_by_id(self, entity_id):
        """
        Trouve une entité par son ID.
        Retourne None si non trouvé (ne lève pas d'exception).
        """
        # self.model.query.get() : équivalent de SELECT * FROM table WHERE id = ?
        return self.model.query.get(entity_id)

    def find_all(self):
        """Récupère toutes les entités."""
        return self.model.query.all()

    def find_by(self, **kwargs):
        """
        Cherche des entités par critères.
        Usage : find_by(email='alice@test.com', is_active=True)
        **kwargs : paramètres nommés variables
        """
        return self.model.query.filter_by(**kwargs).all()

    def find_one_by(self, **kwargs):
        """Comme find_by mais retourne le premier résultat seulement."""
        return self.model.query.filter_by(**kwargs).first()

    def save(self, entity):
        """
        Sauvegarde une entité (création ou modification).
        Si l'entité a déjà un ID -> UPDATE
        Si pas d'ID -> INSERT
        """
        db.session.add(entity)
        db.session.commit()
        db.session.refresh(entity)  # Recharge depuis la DB pour avoir l'ID généré
        return entity

    def delete(self, entity):
        """Supprime une entité."""
        db.session.delete(entity)
        db.session.commit()

    def delete_by_id(self, entity_id):
        """Supprime une entité par son ID. Retourne True si trouvé et supprimé."""
        entity = self.find_by_id(entity_id)
        if entity:
            self.delete(entity)
            return True
        return False

    def count(self, **kwargs):
        """Compte le nombre d'entités correspondant aux critères."""
        if kwargs:
            return self.model.query.filter_by(**kwargs).count()
        return self.model.query.count()

    def exists(self, **kwargs):
        """Vérifie si une entité existe selon les critères."""
        return self.find_one_by(**kwargs) is not None


──────────────────────────────────────────────
FICHIER : repositories/user_repository.py
──────────────────────────────────────────────

# repositories/user_repository.py
# Repository spécifique aux utilisateurs.
# Hérite de BaseRepository pour les CRUD de base.
# Ajoute les méthodes de requête spécifiques aux Users.

from repositories.base_repository import BaseRepository
from models.user import User, db


class UserRepository(BaseRepository):
    """
    Repository pour les opérations de base de données sur User.
    
    Responsabilité UNIQUE : accès aux données des utilisateurs.
    Ne contient pas de logique métier.
    """

    def __init__(self):
        """Initialise avec le modèle User."""
        super().__init__(User)  # Passe User à BaseRepository

    def find_by_email(self, email):
        """
        Cherche un utilisateur par son email.
        Requête SQL : SELECT * FROM users WHERE email = ?
        """
        return self.find_one_by(email=email)

    def find_by_username(self, username):
        """Cherche par username."""
        return self.find_one_by(username=username)

    def find_active_users(self):
        """Récupère tous les utilisateurs actifs."""
        return self.find_by(is_active=True)

    def find_with_task_count(self):
        """
        Requête avancée : récupère les users avec le nombre de tâches.
        Utilise SQLAlchemy pour une requête SQL complexe.
        """
        # Pour les requêtes complexes, on peut écrire du SQL via SQLAlchemy
        from sqlalchemy import func
        from models.user import Task

        # Jointure avec comptage (LEFT JOIN + GROUP BY + COUNT)
        result = db.session.query(
            User,
            func.count(Task.id).label('task_count')  # COUNT(tasks.id)
        ).outerjoin(Task).group_by(User.id).all()

        return result

    def search_by_username(self, search_term):
        """
        Recherche des utilisateurs dont le username contient le terme.
        LIKE SQL : SELECT * FROM users WHERE username LIKE '%term%'
        """
        # ilike = case-insensitive LIKE
        return User.query.filter(
            User.username.ilike(f'%{search_term}%')
        ).all()

    def paginate(self, page=1, per_page=10, **filters):
        """
        Retourne une page de résultats.
        Essentiel pour les grandes listes.
        
        Args:
            page: numéro de page (commence à 1)
            per_page: nombre d'éléments par page
            **filters: filtres à appliquer
        
        Returns:
            dict avec les users de la page et les métadonnées de pagination
        """
        query = User.query
        if filters:
            query = query.filter_by(**filters)

        # paginate() est une méthode SQLAlchemy qui gère LIMIT/OFFSET
        pagination = query.paginate(
            page=page,
            per_page=per_page,
            error_out=False  # Ne lève pas d'erreur si page vide
        )

        return {
            'items': pagination.items,
            'total': pagination.total,
            'page': page,
            'per_page': per_page,
            'pages': pagination.pages,
            'has_next': pagination.has_next,
            'has_prev': pagination.has_prev
        }


──────────────────────────────────────────────
FICHIER : repositories/task_repository.py
──────────────────────────────────────────────

# repositories/task_repository.py

from repositories.base_repository import BaseRepository
from models.user import Task, db
from datetime import datetime


class TaskRepository(BaseRepository):
    """Repository pour les tâches."""

    def __init__(self):
        super().__init__(Task)

    def find_by_user(self, user_id):
        """Toutes les tâches d'un utilisateur."""
        return self.find_by(user_id=user_id)

    def find_by_user_and_status(self, user_id, status):
        """Tâches d'un user filtrées par statut."""
        return self.find_by(user_id=user_id, status=status)

    def find_overdue(self):
        """
        Trouve toutes les tâches en retard.
        Requête SQL complexe : WHERE due_date < NOW AND status != 'done'
        """
        return Task.query.filter(
            Task.due_date < datetime.utcnow(),  # Date passée
            Task.status != Task.STATUS_DONE     # Pas encore terminée
        ).all()

    def find_by_priority(self, user_id, priority):
        """Tâches d'un user par priorité."""
        return Task.query.filter_by(
            user_id=user_id,
            priority=priority
        ).order_by(Task.created_at.desc()).all()  # Triées par date décroissante

    def count_by_status(self, user_id):
        """
        Compte les tâches par statut pour un utilisateur.
        Utile pour les statistiques / dashboards.
        """
        from sqlalchemy import func

        result = db.session.query(
            Task.status,
            func.count(Task.id).label('count')
        ).filter_by(user_id=user_id).group_by(Task.status).all()

        # Convertit en dictionnaire {status: count}
        return {row.status: row.count for row in result}


──────────────────────────────────────────────
FICHIER : services/user_service.py
──────────────────────────────────────────────

# services/user_service.py
# Le SERVICE est le cœur de l'application.
# C'est ici que vivent les RÈGLES MÉTIER.
# Il coordonne les Repositories et applique la logique du domaine.

import hashlib
from repositories.user_repository import UserRepository
from repositories.task_repository import TaskRepository
from models.user import User


class UserService:
    """
    Service pour la logique métier liée aux utilisateurs.
    
    Responsabilité : Appliquer les règles du domaine utilisateur.
    
    Le Service :
    - Ne connaît pas HTTP (pas de request/response/jsonify)
    - Ne fait pas de SQL direct (délègue aux Repository)
    - Contient les règles : "un email doit être unique", "max 5 comptes/IP", etc.
    """

    # Constantes métier
    MAX_TASKS_PER_USER = 100  # Règle métier : pas plus de 100 tâches par user
    MIN_PASSWORD_LENGTH = 6

    def __init__(self):
        """
        Injection des repositories.
        Le Service dépend des Repositories (couche inférieure).
        On les instancie ici (dans un vrai projet, on les inject via DI).
        """
        self.user_repo = UserRepository()
        self.task_repo = TaskRepository()

    def get_all_users(self):
        """Récupère tous les utilisateurs actifs."""
        return self.user_repo.find_active_users()

    def get_user_by_id(self, user_id):
        """
        Récupère un utilisateur par ID.
        Lève une exception si non trouvé (logique métier).
        
        Pourquoi une exception ? Car "utilisateur non trouvé" est une
        situation exceptionnelle dans notre domaine métier.
        Le Controller attrape cette exception et retourne 404.
        """
        user = self.user_repo.find_by_id(user_id)
        if not user:
            raise ValueError(f"Utilisateur {user_id} introuvable")
        return user

    def create_user(self, username, email, password):
        """
        Crée un nouvel utilisateur.
        
        RÈGLES MÉTIER APPLIQUÉES ICI :
        1. Le mot de passe doit faire au moins 6 caractères
        2. L'email doit être unique
        3. Le username doit être unique
        4. Le mot de passe est hashé avant stockage
        
        Args:
            username: nom d'utilisateur
            email: adresse email
            password: mot de passe en clair (sera hashé)
        
        Returns:
            L'objet User créé
        
        Raises:
            ValueError: si les données sont invalides ou en doublon
        """

        # ── RÈGLES DE VALIDATION MÉTIER ──────────────────────────

        # Règle 1 : mot de passe assez long
        if len(password) < self.MIN_PASSWORD_LENGTH:
            raise ValueError(
                f"Le mot de passe doit faire au moins {self.MIN_PASSWORD_LENGTH} caractères"
            )

        # Règle 2 : email unique
        if self.user_repo.exists(email=email):
            raise ValueError(f"L'email '{email}' est déjà utilisé")

        # Règle 3 : username unique
        if self.user_repo.exists(username=username):
            raise ValueError(f"Le username '{username}' est déjà pris")

        # ── CRÉATION DE L'ENTITÉ ──────────────────────────────────

        # Hash du mot de passe (règle de sécurité métier)
        password_hash = self._hash_password(password)

        new_user = User(
            username=username,
            email=email,
            password_hash=password_hash
        )

        # Délègue la persistence au Repository
        return self.user_repo.save(new_user)

    def update_user(self, user_id, **update_data):
        """
        Met à jour un utilisateur.
        
        **update_data : dictionnaire de champs à mettre à jour
        Exemple : update_user(1, username='bob', email='bob@test.com')
        """
        user = self.get_user_by_id(user_id)  # Lève ValueError si non trouvé

        # Règle : si l'email change, vérifier qu'il est unique
        if 'email' in update_data and update_data['email'] != user.email:
            if self.user_repo.exists(email=update_data['email']):
                raise ValueError("Cet email est déjà utilisé")
            user.email = update_data['email']

        if 'username' in update_data:
            user.username = update_data['username']

        if 'password' in update_data:
            if len(update_data['password']) < self.MIN_PASSWORD_LENGTH:
                raise ValueError("Mot de passe trop court")
            user.password_hash = self._hash_password(update_data['password'])

        return self.user_repo.save(user)

    def deactivate_user(self, user_id):
        """
        Désactive un compte utilisateur (soft delete).
        
        RÈGLE MÉTIER : On ne supprime jamais réellement un compte.
        On le désactive. Les données sont conservées (RGPD, audit...).
        """
        user = self.get_user_by_id(user_id)
        user.is_active = False
        self.user_repo.save(user)

    def delete_user(self, user_id):
        """
        Supprime physiquement un utilisateur.
        RÈGLE : Vérifier qu'il n'a pas de tâches en cours avant de supprimer.
        """
        user = self.get_user_by_id(user_id)

        # Règle métier : ne pas supprimer si tâches en cours
        in_progress_tasks = self.task_repo.find_by_user_and_status(
            user_id, 'in_progress'
        )
        if in_progress_tasks:
            raise ValueError(
                "Impossible de supprimer un utilisateur avec des tâches en cours"
            )

        self.user_repo.delete(user)

    def get_user_stats(self, user_id):
        """
        Calcule les statistiques d'un utilisateur.
        Orchestre plusieurs repositories pour assembler les données.
        """
        user = self.get_user_by_id(user_id)
        task_counts = self.task_repo.count_by_status(user_id)
        overdue = self.task_repo.find_overdue()
        user_overdue = [t for t in overdue if t.user_id == user_id]

        return {
            'user': user.to_dict(),
            'task_stats': {
                'pending': task_counts.get('pending', 0),
                'in_progress': task_counts.get('in_progress', 0),
                'done': task_counts.get('done', 0),
                'total': sum(task_counts.values())
            },
            'overdue_count': len(user_overdue)
        }

    def authenticate(self, email, password):
        """
        Authentifie un utilisateur.
        RÈGLES : email existant + mot de passe correct + compte actif.
        
        Retourne l'utilisateur si authentifié, lève ValueError sinon.
        """
        user = self.user_repo.find_by_email(email)

        if not user:
            raise ValueError("Email ou mot de passe incorrect")

        if not user.is_active:
            raise ValueError("Compte désactivé")

        # Vérification du mot de passe
        if user.password_hash != self._hash_password(password):
            raise ValueError("Email ou mot de passe incorrect")

        return user

    # ── MÉTHODES PRIVÉES DU SERVICE ──────────────────────────────

    def _hash_password(self, password):
        """Hash un mot de passe. Méthode privée du Service."""
        return hashlib.sha256(password.encode()).hexdigest()


──────────────────────────────────────────────
FICHIER : services/task_service.py
──────────────────────────────────────────────

# services/task_service.py
# Service pour la logique métier des tâches.

from repositories.task_repository import TaskRepository
from repositories.user_repository import UserRepository
from models.user import Task


class TaskService:
    """Logique métier pour les tâches."""

    # Règle métier : nombre maximum de tâches par utilisateur
    MAX_TASKS_PER_USER = 50

    def __init__(self):
        self.task_repo = TaskRepository()
        self.user_repo = UserRepository()

    def create_task(self, user_id, title, description='', priority=None):
        """
        Crée une tâche pour un utilisateur.
        
        RÈGLES :
        1. L'utilisateur doit exister
        2. Le titre ne peut pas être vide
        3. Un utilisateur ne peut pas avoir plus de MAX_TASKS_PER_USER tâches
        """
        # Règle 1 : l'utilisateur existe ?
        user = self.user_repo.find_by_id(user_id)
        if not user:
            raise ValueError(f"Utilisateur {user_id} introuvable")

        # Règle 2 : titre non vide
        if not title or not title.strip():
            raise ValueError("Le titre ne peut pas être vide")

        # Règle 3 : limite de tâches
        existing_tasks_count = self.task_repo.count(user_id=user_id)
        if existing_tasks_count >= self.MAX_TASKS_PER_USER:
            raise ValueError(
                f"Limite de {self.MAX_TASKS_PER_USER} tâches atteinte"
            )

        # Priorité par défaut si non fournie
        priority = priority or Task.PRIORITY_MEDIUM

        # Validation de la priorité (règle métier)
        valid_priorities = [Task.PRIORITY_LOW, Task.PRIORITY_MEDIUM, Task.PRIORITY_HIGH]
        if priority not in valid_priorities:
            raise ValueError(f"Priorité invalide. Utilisez : {valid_priorities}")

        new_task = Task(
            title=title.strip(),
            description=description,
            priority=priority,
            user_id=user_id
        )

        return self.task_repo.save(new_task)

    def advance_task_status(self, task_id, user_id):
        """
        Fait avancer le statut d'une tâche (pending -> in_progress -> done).
        
        RÈGLE : Un utilisateur ne peut faire avancer QUE ses propres tâches.
        """
        task = self.task_repo.find_by_id(task_id)

        if not task:
            raise ValueError("Tâche introuvable")

        # Règle de propriété : vérifier que la tâche appartient à l'utilisateur
        if task.user_id != user_id:
            raise PermissionError("Vous ne pouvez pas modifier cette tâche")

        # Logique de transition d'état
        if task.status == Task.STATUS_PENDING:
            task.status = Task.STATUS_IN_PROGRESS
        elif task.status == Task.STATUS_IN_PROGRESS:
            task.status = Task.STATUS_DONE
        else:
            raise ValueError("La tâche est déjà terminée")

        return self.task_repo.save(task)

    def get_user_tasks_summary(self, user_id):
        """
        Résumé des tâches d'un utilisateur : combinaison de plusieurs requêtes.
        Le Service orchestre le tout.
        """
        all_tasks = self.task_repo.find_by_user(user_id)
        overdue_tasks = [t for t in all_tasks
                         if t.due_date and t.status != Task.STATUS_DONE]

        return {
            'all': [t.to_dict() for t in all_tasks],
            'by_priority': {
                'high': [t.to_dict() for t in all_tasks if t.priority == Task.PRIORITY_HIGH],
                'medium': [t.to_dict() for t in all_tasks if t.priority == Task.PRIORITY_MEDIUM],
                'low': [t.to_dict() for t in all_tasks if t.priority == Task.PRIORITY_LOW]
            },
            'overdue_count': len(overdue_tasks)
        }


──────────────────────────────────────────────
FICHIER : controllers/user_controller.py
──────────────────────────────────────────────

# controllers/user_controller.py
# Dans l'architecture en couches, le Controller est MINCE.
# Il délègue TOUT au Service.

from flask import Blueprint, request, jsonify
from services.user_service import UserService

user_controller = Blueprint('user_controller', __name__, url_prefix='/api/users')

# Instanciation du Service
# En production, utiliser l'injection de dépendances pour mieux tester
user_service = UserService()


@user_controller.route('/', methods=['GET'])
def index():
    """
    GET /api/users/
    Le Controller :
    1. Reçoit la requête
    2. Appelle le Service
    3. Retourne la réponse JSON
    Rien de plus !
    """
    users = user_service.get_all_users()
    return jsonify([u.to_dict() for u in users]), 200


@user_controller.route('/<int:user_id>', methods=['GET'])
def show(user_id):
    """GET /api/users/<id>"""
    try:
        user = user_service.get_user_by_id(user_id)
        return jsonify(user.to_dict()), 200
    except ValueError as e:
        # Le Controller attrape les exceptions du Service et les traduit en HTTP
        return jsonify({'error': str(e)}), 404


@user_controller.route('/', methods=['POST'])
def create():
    """POST /api/users/ - Créer un utilisateur"""
    data = request.get_json()

    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    # Validation du FORMAT (Controller) vs Règles métier (Service)
    required = ['username', 'email', 'password']
    missing = [f for f in required if f not in data]
    if missing:
        return jsonify({'error': f'Champs manquants : {missing}'}), 400

    try:
        # Délégation totale au Service pour la logique
        user = user_service.create_user(
            username=data['username'],
            email=data['email'],
            password=data['password']
        )
        return jsonify(user.to_dict()), 201

    except ValueError as e:
        # ValueError du Service -> 400 Bad Request ou 409 Conflict
        return jsonify({'error': str(e)}), 400


@user_controller.route('/<int:user_id>', methods=['PUT'])
def update(user_id):
    """PUT /api/users/<id>"""
    data = request.get_json()
    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    try:
        user = user_service.update_user(user_id, **data)
        return jsonify(user.to_dict()), 200
    except ValueError as e:
        return jsonify({'error': str(e)}), 400


@user_controller.route('/<int:user_id>', methods=['DELETE'])
def delete(user_id):
    """DELETE /api/users/<id>"""
    try:
        user_service.delete_user(user_id)
        return '', 204
    except ValueError as e:
        return jsonify({'error': str(e)}), 400


@user_controller.route('/<int:user_id>/stats', methods=['GET'])
def stats(user_id):
    """GET /api/users/<id>/stats - Statistiques d'un utilisateur"""
    try:
        stats_data = user_service.get_user_stats(user_id)
        return jsonify(stats_data), 200
    except ValueError as e:
        return jsonify({'error': str(e)}), 404


@user_controller.route('/authenticate', methods=['POST'])
def authenticate():
    """POST /api/users/authenticate - Authentifier un utilisateur"""
    data = request.get_json()
    if not data or 'email' not in data or 'password' not in data:
        return jsonify({'error': 'Email et password requis'}), 400

    try:
        user = user_service.authenticate(data['email'], data['password'])
        return jsonify({'user': user.to_dict(), 'message': 'Authentifié'}), 200
    except ValueError as e:
        return jsonify({'error': str(e)}), 401  # 401 Unauthorized


================================================================================
5. TESTS PAR COUCHE
================================================================================

──────────────────────────────────────────────
FICHIER : tests/test_services.py
──────────────────────────────────────────────

# tests/test_services.py
# Tests du Service SANS base de données réelle.
# On utilise des MOCKS pour simuler le Repository.
# Avantage : tests rapides et indépendants de la DB.

import pytest
from unittest.mock import MagicMock, patch
from services.user_service import UserService
from models.user import User


@pytest.fixture
def mock_user_repo():
    """Crée un mock du UserRepository."""
    mock = MagicMock()
    return mock


@pytest.fixture
def user_service(mock_user_repo):
    """Crée un UserService avec un repository mocké."""
    service = UserService()
    # On remplace le vrai repository par le mock
    service.user_repo = mock_user_repo
    return service


def test_create_user_password_too_short(user_service):
    """
    Test : création avec mot de passe trop court -> ValueError.
    Ce test ne touche PAS la base de données.
    Il teste uniquement la règle métier du Service.
    """
    with pytest.raises(ValueError, match="au moins 6 caractères"):
        user_service.create_user('alice', 'alice@test.com', '123')  # Trop court


def test_create_user_duplicate_email(user_service, mock_user_repo):
    """
    Test : email déjà utilisé -> ValueError.
    On configure le mock pour simuler un email existant.
    """
    # Configuration du mock : exists() retourne True (email existe)
    mock_user_repo.exists.return_value = True

    with pytest.raises(ValueError, match="déjà utilisé"):
        user_service.create_user('alice', 'alice@test.com', 'motdepasse123')


def test_create_user_success(user_service, mock_user_repo):
    """
    Test : création réussie.
    Le mock simule que l'email n'existe pas et que la sauvegarde réussit.
    """
    # Mock : aucun utilisateur existant avec cet email
    mock_user_repo.exists.return_value = False

    # Mock : save() retourne un User fictif
    fake_user = User(id=1, username='alice', email='alice@test.com', password_hash='hash')
    mock_user_repo.save.return_value = fake_user

    result = user_service.create_user('alice', 'alice@test.com', 'motdepasse123')

    # Vérifier que save() a été appelé une fois
    mock_user_repo.save.assert_called_once()

    # Vérifier les données retournées
    assert result.username == 'alice'


def test_delete_user_with_in_progress_tasks(user_service, mock_user_repo):
    """
    Test : suppression impossible si tâches en cours.
    Règle métier : ne pas supprimer si work in progress.
    """
    # Mock du task repo
    from models.user import Task
    in_progress_task = Task(id=1, title='Task', status=Task.STATUS_IN_PROGRESS, user_id=1)

    mock_task_repo = MagicMock()
    mock_task_repo.find_by_user_and_status.return_value = [in_progress_task]
    user_service.task_repo = mock_task_repo

    fake_user = User(id=1, username='alice', email='alice@test.com', password_hash='hash')
    mock_user_repo.find_by_id.return_value = fake_user

    with pytest.raises(ValueError, match="tâches en cours"):
        user_service.delete_user(1)

================================================================================
6. GESTION DES ERREURS ET EXCEPTIONS
================================================================================

  STRATÉGIE D'ERREURS PAR COUCHE :
  ──────────────────────────────────

  Repository  -> Ne lève pas d'exception (retourne None si non trouvé)
  Service     -> Lève ValueError (erreur métier) ou PermissionError (droits)
  Controller  -> Attrape les exceptions du Service, retourne HTTP approprié

  TABLEAU DE CORRESPONDANCE :
  ───────────────────────────
  Exception Service       -> Code HTTP Controller
  ValueError("not found") -> 404 Not Found
  ValueError("duplicate") -> 409 Conflict
  ValueError("invalid")   -> 400 Bad Request
  PermissionError(...)    -> 403 Forbidden
  Exception inattendue    -> 500 Internal Server Error

  HANDLER D'ERREUR GLOBAL dans app.py :

    @app.errorhandler(Exception)
    def handle_error(error):
        if isinstance(error, ValueError):
            return jsonify({'error': str(error)}), 400
        if isinstance(error, PermissionError):
            return jsonify({'error': 'Accès refusé'}), 403
        # Erreur non gérée -> log + 500
        app.logger.error(f'Erreur non gérée: {error}')
        return jsonify({'error': 'Erreur interne'}), 500

================================================================================
7. COMPARAISON MVC vs LAYERED
================================================================================

  ASPECT             MVC                      LAYERED
  ──────────────────────────────────────────────────────────────────
  Couches            3 (M/V/C)                4+ (Controller/Service/Repo/Model)
  Logique métier     Dans Model               Dans Service (séparé)
  Accès DB           Dans Model               Dans Repository (séparé)
  Testabilité        Bonne                    Excellente (mock Repository)
  Complexité         Moyenne                  Plus élevée
  Projet idéal       Moyen                    Moyen à grand
  Équipe idéale      2-5                      3-10
  Réutilisabilité    Bonne                    Excellente

================================================================================
8. BONNES PRATIQUES LAYERED
================================================================================

  [OK] SERVICES SANS ÉTAT (Stateless)
  ──────────────────────────────────
  Un Service ne doit pas avoir d'état propre entre les requêtes.
  Toute donnée persistante va dans la DB via le Repository.

  [OK] INJECTION DE DÉPENDANCES
  ────────────────────────────
  Au lieu d'instancier les repositories dans le service :
  
    # MAUVAIS :
    class UserService:
        def __init__(self):
            self.repo = UserRepository()  # Dépendance fixe, difficile à tester
  
    # MIEUX :
    class UserService:
        def __init__(self, user_repo=None):
            self.user_repo = user_repo or UserRepository()  # Injecté ou par défaut
  
    # Dans les tests :
    service = UserService(user_repo=MockUserRepository())  # On inject le mock

  [OK] NOMMAGE DES MÉTHODES
  ────────────────────────
  Repository -> find_*, save, delete, count, exists (vocabulaire DB)
  Service    -> create_*, update_*, delete_*, get_*, validate_* (vocabulaire métier)
  Controller -> index, show, create, update, destroy (vocabulaire HTTP/REST)

================================================================================
9. EXERCICE PRATIQUE
================================================================================

OBJECTIF : Ajouter un système de notification par email quand une tâche
           change de statut.

ÉTAPES :
  1. Créer services/notification_service.py avec :
     - send_task_status_email(user_email, task_title, new_status)
     - (Pour l'exercice, juste print() au lieu d'envoyer vraiment)

  2. Modifier TaskService.advance_task_status() pour appeler
     notification_service.send_task_status_email() après le changement

  3. Écrire un test avec mock qui vérifie que send_task_status_email
     est appelé quand une tâche passe à STATUS_DONE

CORRECTION :
────────────

  # services/notification_service.py
  class NotificationService:
      def send_task_status_email(self, user_email, task_title, new_status):
          """
          Envoie un email de notification.
          En vrai, utiliserait smtplib ou flask-mail.
          Ici on simule avec print().
          """
          print(f"EMAIL -> {user_email}: La tâche '{task_title}' est maintenant '{new_status}'")

  # Dans TaskService :
  from services.notification_service import NotificationService

  class TaskService:
      def __init__(self):
          self.task_repo = TaskRepository()
          self.user_repo = UserRepository()
          self.notif_service = NotificationService()  # Ajout

      def advance_task_status(self, task_id, user_id):
          task = self.task_repo.find_by_id(task_id)
          # ... (même logique)

          result = self.task_repo.save(task)

          # Notification après changement d'état
          user = self.user_repo.find_by_id(user_id)
          self.notif_service.send_task_status_email(
              user.email, task.title, task.status
          )

          return result

================================================================================
RÉSUMÉ
================================================================================

  L'architecture en couches ajoute le SERVICE entre Controller et Repository.
  
  Flux : HTTP -> Controller -> Service -> Repository -> Model -> DB
  
  Chaque couche a UNE responsabilité :
    Controller  -> Interface HTTP (pas de logique métier)
    Service     -> Règles métier (pas de SQL)
    Repository  -> Accès DB (pas de logique métier)
    Model       -> Structure des données
  
  Bénéfice principal : tester la logique métier SANS base de données.
  
  Prochain fichier : architecture_microservices.txt

================================================================================
FIN DU FICHIER architecture_layered.txt
================================================================================

================================================================================
         ARCHITECTURE MICROSERVICES
         Guide complet avec exemples Flask
================================================================================

================================================================================
1. INTRODUCTION PÉDAGOGIQUE
================================================================================

DÉFINITION :
────────────
L'architecture microservices décompose une application en un ensemble de
PETITS SERVICES INDÉPENDANTS, chacun :
  - Ayant une responsabilité unique (Single Responsibility)
  - Déployable indépendamment
  - Communicant via des API (HTTP/REST ou messages)
  - Ayant sa propre base de données
  - Développable avec sa propre technologie

ANALOGIE DE LA VILLE :
───────────────────────
Un monolithe = Une grande ferme qui fait TOUT
  (cultive, transforme, vend, livre, comptabilise...)

Microservices = Une ville avec des spécialistes
  - Fermiers (Données agricoles)
  - Usines (Transformation)
  - Magasins (Vente)
  - Livreurs (Livraison)
  - Banque (Comptabilité)
  Chacun est expert dans son domaine.
  Ils communiquent entre eux pour les besoins communs.

AVANTAGES :
───────────
  [OK] Déploiement indépendant (mettre à jour Users sans toucher Payments)
  [OK] Scalabilité ciblée (scaler uniquement le service sous charge)
  [OK] Technologie indépendante (Service A en Python, Service B en Java)
  [OK] Équipes autonomes (chaque équipe = un service)
  [OK] Résilience (un service tombe, les autres continuent)
  [OK] Maintenabilité (petits codes = plus faciles à comprendre)

LIMITES :
─────────
  [X] Complexité opérationnelle (gérer N services, N DBs, N déploiements)
  [X] Communication réseau (latence, pannes réseau)
  [X] Transactions distribuées (difficile d'être ACID entre services)
  [X] Testing plus complexe (tests d'intégration entre services)
  [X] Surengineering pour les petits projets

================================================================================
2. QUAND UTILISER LES MICROSERVICES ?
================================================================================

  [OK] Application avec des domaines clairement séparés
  [OK] Grande équipe (5+ développeurs)
  [OK] Besoin de scaler différentes parties indépendamment
  [OK] Différentes technologies nécessaires par partie
  [OK] SLAs (disponibilités) différents par fonctionnalité
  [OK] Équipes indépendantes (Conway's Law : l'architecture suit l'organisation)

  [X] Éviter si startup early-stage
  [X] Éviter si équipe < 5 personnes
  [X] Éviter si domaines pas encore bien définis

  CONSEIL : Commencer monolithique, migrer vers microservices quand nécessaire.
  "Monolith first" - Martin Fowler

================================================================================
3. STRUCTURE DE NOTRE PROJET EXEMPLE
================================================================================

  PROJET : Application de gestion de tâches d'équipe
  
  SERVICES :
    - users_service/    -> Gestion des utilisateurs (port 5001)
    - tasks_service/    -> Gestion des tâches (port 5002)
    - gateway/          -> API Gateway (point d'entrée, port 5000)

  microservices_project/
  ├── docker-compose.yml        <- Orchestration des services
  ├── README.md
  │
  ├── gateway/                  <- SERVICE : Passerelle API
  │   ├── app.py
  │   ├── requirements.txt
  │   └── Dockerfile
  │
  ├── users_service/            <- SERVICE : Utilisateurs
  │   ├── app.py
  │   ├── models.py
  │   ├── routes.py
  │   ├── requirements.txt
  │   └── Dockerfile
  │
  └── tasks_service/            <- SERVICE : Tâches
      ├── app.py
      ├── models.py
      ├── routes.py
      ├── requirements.txt
      └── Dockerfile

================================================================================
4. IMPLÉMENTATION COMPLÈTE
================================================================================

════════════════════════════════════════════════════════════
SERVICE 1 : USERS SERVICE (Port 5001)
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : users_service/models.py
──────────────────────────────────────────────

# users_service/models.py
# Le service Users gère UNIQUEMENT les données des utilisateurs.
# Il a sa PROPRE base de données (séparation des données entre services).

from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
import hashlib

db = SQLAlchemy()


class User(db.Model):
    """
    Modèle User DANS LE SERVICE USERS.
    Ce modèle est LOCAL à ce service.
    Le service Tasks ne connaît pas cette classe.
    Il utilise uniquement l'API du service Users.
    """

    __tablename__ = 'users'

    id            = db.Column(db.Integer, primary_key=True)
    username      = db.Column(db.String(80), unique=True, nullable=False)
    email         = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(256), nullable=False)
    created_at    = db.Column(db.DateTime, default=datetime.utcnow)
    is_active     = db.Column(db.Boolean, default=True)

    def set_password(self, password):
        self.password_hash = hashlib.sha256(password.encode()).hexdigest()

    def check_password(self, password):
        return self.password_hash == hashlib.sha256(password.encode()).hexdigest()

    def to_dict(self):
        """
        Ce to_dict() est le CONTRAT PUBLIC du service.
        Les autres services reçoivent ces données via l'API.
        Changer ce format est un "breaking change" pour les autres services.
        """
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'created_at': self.created_at.isoformat(),
            'is_active': self.is_active
        }


──────────────────────────────────────────────
FICHIER : users_service/routes.py
──────────────────────────────────────────────

# users_service/routes.py
# API REST du service Users.
# C'est l'interface publique du service : les autres services l'appellent via HTTP.

from flask import Blueprint, request, jsonify
from models import db, User

users_bp = Blueprint('users', __name__)


@users_bp.route('/health', methods=['GET'])
def health():
    """
    Endpoint de santé (Health Check).
    ESSENTIEL dans les microservices !
    Permet à l'orchestrateur (Docker, Kubernetes) de vérifier si le service est UP.
    """
    return jsonify({
        'status': 'healthy',
        'service': 'users-service',
        'version': '1.0.0'
    }), 200


@users_bp.route('/users', methods=['GET'])
def get_users():
    """GET /users - Liste tous les utilisateurs."""
    users = User.query.filter_by(is_active=True).all()
    return jsonify([u.to_dict() for u in users]), 200


@users_bp.route('/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    """
    GET /users/<id>
    Appelé par d'autres services pour valider un user_id.
    Ex: Le service Tasks appelle ceci avant de créer une tâche.
    """
    user = User.query.get(user_id)
    if not user:
        # Code 404 : le service demandeur sait que l'utilisateur n'existe pas
        return jsonify({'error': 'Utilisateur non trouvé'}), 404
    return jsonify(user.to_dict()), 200


@users_bp.route('/users', methods=['POST'])
def create_user():
    """POST /users - Crée un utilisateur."""
    data = request.get_json()

    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    # Validation
    for field in ['username', 'email', 'password']:
        if field not in data:
            return jsonify({'error': f'{field} est requis'}), 400

    # Vérification unicité
    if User.query.filter_by(email=data['email']).first():
        return jsonify({'error': 'Email déjà utilisé'}), 409

    user = User(username=data['username'], email=data['email'])
    user.set_password(data['password'])

    db.session.add(user)
    db.session.commit()

    return jsonify(user.to_dict()), 201


@users_bp.route('/users/<int:user_id>/validate', methods=['POST'])
def validate_credentials(user_id):
    """
    POST /users/<id>/validate
    Endpoint interne : vérifie si les credentials sont valides.
    Appelé par la Gateway pour l'authentification.
    Retourne uniquement True/False + info minimale (pas le hash !).
    """
    data = request.get_json()
    user = User.query.get(user_id)

    if not user or not user.is_active:
        return jsonify({'valid': False}), 200

    is_valid = user.check_password(data.get('password', ''))
    return jsonify({'valid': is_valid, 'user_id': user.id}), 200


──────────────────────────────────────────────
FICHIER : users_service/app.py
──────────────────────────────────────────────

# users_service/app.py

from flask import Flask
from models import db
from routes import users_bp
import os

app = Flask(__name__)

# Configuration propre au service Users
# CHAQUE SERVICE a sa propre configuration et sa propre DB
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get(
    'DATABASE_URL',
    'sqlite:///users.db'  # Base de données LOCALE au service
)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db.init_app(app)
app.register_blueprint(users_bp)

with app.app_context():
    db.create_all()
    print("[OK] Users Service démarré sur le port 5001")

if __name__ == '__main__':
    # Port 5001 pour ce service
    app.run(host='0.0.0.0', port=5001, debug=True)


════════════════════════════════════════════════════════════
SERVICE 2 : TASKS SERVICE (Port 5002)
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : tasks_service/models.py
──────────────────────────────────────────────

# tasks_service/models.py
# Le service Tasks a ses propres modèles et sa propre DB.
# Il stocke user_id (un entier) mais NE connaît PAS le modèle User.
# Pour obtenir les infos d'un user, il appelle le service Users via HTTP.

from flask_sqlalchemy import SQLAlchemy
from datetime import datetime

db = SQLAlchemy()


class Task(db.Model):
    """
    Modèle Task LOCAL au service Tasks.
    user_id est stocké comme un simple entier (pas de ForeignKey cross-service !).
    La validation de l'existence du user se fait via appel HTTP au service Users.
    """

    __tablename__ = 'tasks'

    PRIORITY_LOW    = 'low'
    PRIORITY_MEDIUM = 'medium'
    PRIORITY_HIGH   = 'high'

    STATUS_PENDING     = 'pending'
    STATUS_IN_PROGRESS = 'in_progress'
    STATUS_DONE        = 'done'

    id          = db.Column(db.Integer, primary_key=True)
    title       = db.Column(db.String(200), nullable=False)
    description = db.Column(db.Text)
    priority    = db.Column(db.String(20), default=PRIORITY_MEDIUM)
    status      = db.Column(db.String(20), default=STATUS_PENDING)
    created_at  = db.Column(db.DateTime, default=datetime.utcnow)

    # IMPORTANT : user_id est juste un entier, pas une vraie FK
    # Car la table users est dans une AUTRE base de données (autre service)
    user_id = db.Column(db.Integer, nullable=False)

    def to_dict(self):
        return {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'priority': self.priority,
            'status': self.status,
            'created_at': self.created_at.isoformat(),
            'user_id': self.user_id
        }


──────────────────────────────────────────────
FICHIER : tasks_service/client.py
──────────────────────────────────────────────

# tasks_service/client.py
# CLIENT HTTP pour communiquer avec d'autres services.
# Le service Tasks utilise ce client pour appeler le service Users.
# C'est le pattern "HTTP Client" dans les microservices.

import requests  # pip install requests
import os

# URL du service Users (depuis variable d'environnement ou par défaut)
# En production : USERS_SERVICE_URL=http://users-service:5001
# En développement : http://localhost:5001
USERS_SERVICE_URL = os.environ.get('USERS_SERVICE_URL', 'http://localhost:5001')


class UsersServiceClient:
    """
    Client HTTP pour le service Users.
    Encapsule tous les appels HTTP vers le service Users.
    
    AVANTAGES d'encapsuler dans une classe :
    - Si l'URL du service change, on modifie ici seulement
    - Facile de mocker pour les tests
    - Gestion des erreurs centralisée
    - Timeouts configurables
    """

    def __init__(self, base_url=USERS_SERVICE_URL):
        self.base_url = base_url
        # Timeout par défaut : 5 secondes
        # Essentiel pour éviter qu'un service lent bloque tout
        self.timeout = 5

    def get_user(self, user_id):
        """
        Récupère un utilisateur depuis le service Users.
        
        Returns:
            dict avec les données de l'utilisateur si trouvé
            None si non trouvé
            
        Raises:
            ServiceUnavailableError si le service Users ne répond pas
        """
        try:
            # Appel HTTP GET vers le service Users
            response = requests.get(
                f'{self.base_url}/users/{user_id}',
                timeout=self.timeout
            )

            if response.status_code == 200:
                return response.json()  # Convertit la réponse JSON en dict Python
            elif response.status_code == 404:
                return None  # Utilisateur non trouvé
            else:
                # Autre erreur du service
                raise Exception(f"Service Users erreur: {response.status_code}")

        except requests.exceptions.Timeout:
            # Le service n'a pas répondu dans le délai imparti
            raise Exception("Service Users timeout - réessayez plus tard")

        except requests.exceptions.ConnectionError:
            # Le service est inaccessible (arrêté, réseau...)
            raise Exception("Service Users indisponible - réessayez plus tard")

    def user_exists(self, user_id):
        """
        Vérifie si un utilisateur existe.
        
        Returns:
            True si l'utilisateur existe
            False sinon
        """
        user = self.get_user(user_id)
        return user is not None

    def health_check(self):
        """Vérifie si le service Users est disponible."""
        try:
            response = requests.get(
                f'{self.base_url}/health',
                timeout=2
            )
            return response.status_code == 200
        except:
            return False


# Instance partagée du client (singleton de facto)
users_client = UsersServiceClient()


──────────────────────────────────────────────
FICHIER : tasks_service/routes.py
──────────────────────────────────────────────

# tasks_service/routes.py
# API REST du service Tasks.
# Communique avec le service Users via le client HTTP.

from flask import Blueprint, request, jsonify
from models import db, Task
from client import users_client  # Notre client HTTP

tasks_bp = Blueprint('tasks', __name__)


@tasks_bp.route('/health', methods=['GET'])
def health():
    """Health check du service Tasks."""

    # On vérifie aussi la santé des services dont on dépend
    users_healthy = users_client.health_check()

    status = 'healthy' if users_healthy else 'degraded'
    # 'degraded' = le service fonctionne mais avec des limitations

    return jsonify({
        'status': status,
        'service': 'tasks-service',
        'dependencies': {
            'users-service': 'up' if users_healthy else 'down'
        }
    }), 200


@tasks_bp.route('/tasks', methods=['GET'])
def get_tasks():
    """
    GET /tasks
    GET /tasks?user_id=1  -> filtre par utilisateur
    """
    user_id = request.args.get('user_id', type=int)

    if user_id:
        tasks = Task.query.filter_by(user_id=user_id).all()
    else:
        tasks = Task.query.all()

    return jsonify([t.to_dict() for t in tasks]), 200


@tasks_bp.route('/tasks/<int:task_id>', methods=['GET'])
def get_task(task_id):
    """GET /tasks/<id>"""
    task = Task.query.get(task_id)
    if not task:
        return jsonify({'error': 'Tâche non trouvée'}), 404
    return jsonify(task.to_dict()), 200


@tasks_bp.route('/tasks', methods=['POST'])
def create_task():
    """
    POST /tasks - Créer une tâche.
    
    Avant de créer, on vérifie que l'utilisateur existe
    en appelant le service Users.
    C'est la communication INTER-SERVICES.
    """
    data = request.get_json()

    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    if 'title' not in data:
        return jsonify({'error': 'title est requis'}), 400
    if 'user_id' not in data:
        return jsonify({'error': 'user_id est requis'}), 400

    # ── APPEL INTER-SERVICE ──────────────────────────────────
    # Le service Tasks appelle le service Users pour valider user_id
    try:
        if not users_client.user_exists(data['user_id']):
            return jsonify({'error': 'Utilisateur non trouvé'}), 404
    except Exception as e:
        # Si le service Users est indisponible, on retourne 503
        # 503 Service Unavailable : notre service dépend d'un service qui est down
        return jsonify({
            'error': 'Service utilisateurs indisponible',
            'detail': str(e)
        }), 503

    new_task = Task(
        title=data['title'],
        description=data.get('description', ''),
        priority=data.get('priority', Task.PRIORITY_MEDIUM),
        user_id=data['user_id']
    )

    db.session.add(new_task)
    db.session.commit()

    return jsonify(new_task.to_dict()), 201


@tasks_bp.route('/tasks/<int:task_id>/status', methods=['PATCH'])
def update_status(task_id):
    """PATCH /tasks/<id>/status - Changer le statut."""
    task = Task.query.get(task_id)
    if not task:
        return jsonify({'error': 'Tâche non trouvée'}), 404

    data = request.get_json()
    new_status = data.get('status')

    valid_statuses = [Task.STATUS_PENDING, Task.STATUS_IN_PROGRESS, Task.STATUS_DONE]
    if new_status not in valid_statuses:
        return jsonify({'error': f'Statut invalide. Valeurs: {valid_statuses}'}), 400

    task.status = new_status
    db.session.commit()

    return jsonify(task.to_dict()), 200


──────────────────────────────────────────────
FICHIER : tasks_service/app.py
──────────────────────────────────────────────

# tasks_service/app.py

from flask import Flask
from models import db
from routes import tasks_bp
import os

app = Flask(__name__)

# Base de données PROPRE au service Tasks
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get(
    'DATABASE_URL',
    'sqlite:///tasks.db'  # Fichier séparé de users.db !
)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db.init_app(app)
app.register_blueprint(tasks_bp)

with app.app_context():
    db.create_all()
    print("[OK] Tasks Service démarré sur le port 5002")

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5002, debug=True)


════════════════════════════════════════════════════════════
SERVICE 3 : API GATEWAY (Port 5000)
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : gateway/app.py
──────────────────────────────────────────────

# gateway/app.py
# L'API Gateway est le POINT D'ENTRÉE UNIQUE pour les clients.
# 
# Rôles de la Gateway :
# 1. Routage : diriger les requêtes vers le bon service
# 2. Authentification : vérifier que le client est autorisé
# 3. Rate limiting : limiter le nombre de requêtes
# 4. Aggregation : combiner les réponses de plusieurs services
# 5. Logging centralisé

from flask import Flask, request, jsonify, Response
import requests  # Pour proxifier les requêtes
import os

app = Flask(__name__)

# URLs des services internes
USERS_SERVICE = os.environ.get('USERS_SERVICE_URL', 'http://localhost:5001')
TASKS_SERVICE = os.environ.get('TASKS_SERVICE_URL', 'http://localhost:5002')


def proxy_request(service_url, path, method, data=None, params=None):
    """
    Fonction helper pour proxifier une requête vers un service.
    
    Args:
        service_url: URL de base du service (ex: http://localhost:5001)
        path: chemin de l'endpoint (ex: /users/1)
        method: méthode HTTP (GET, POST, PUT, DELETE)
        data: corps de la requête (pour POST/PUT)
        params: paramètres d'URL (pour GET)
    
    Returns:
        Response Flask avec le contenu du service
    """
    url = f"{service_url}{path}"

    try:
        # Effectue la requête vers le service cible
        response = requests.request(
            method=method,
            url=url,
            json=data,           # Corps JSON si fourni
            params=params,       # Query params si fournis
            timeout=10,          # Timeout de 10 secondes
            headers={
                'Content-Type': 'application/json',
                # Peut propager des headers d'auth, correlation ID, etc.
            }
        )

        # Retourne la réponse du service telle quelle
        return Response(
            response.content,                              # Corps de la réponse
            status=response.status_code,                  # Code HTTP
            mimetype='application/json'                   # Type de contenu
        )

    except requests.exceptions.Timeout:
        return jsonify({'error': 'Service timeout'}), 504  # Gateway Timeout

    except requests.exceptions.ConnectionError:
        return jsonify({'error': 'Service indisponible'}), 503  # Service Unavailable


# ── ROUTES USERS ─────────────────────────────────────────

@app.route('/api/users', methods=['GET'])
def list_users():
    """Redirige vers le service Users."""
    return proxy_request(USERS_SERVICE, '/users', 'GET',
                          params=request.args)


@app.route('/api/users', methods=['POST'])
def create_user():
    """Redirige la création d'utilisateur."""
    return proxy_request(USERS_SERVICE, '/users', 'POST',
                          data=request.get_json())


@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    return proxy_request(USERS_SERVICE, f'/users/{user_id}', 'GET')


@app.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
    return proxy_request(USERS_SERVICE, f'/users/{user_id}', 'PUT',
                          data=request.get_json())


# ── ROUTES TASKS ─────────────────────────────────────────

@app.route('/api/tasks', methods=['GET'])
def list_tasks():
    return proxy_request(TASKS_SERVICE, '/tasks', 'GET',
                          params=request.args)


@app.route('/api/tasks', methods=['POST'])
def create_task():
    return proxy_request(TASKS_SERVICE, '/tasks', 'POST',
                          data=request.get_json())


@app.route('/api/tasks/<int:task_id>', methods=['GET'])
def get_task(task_id):
    return proxy_request(TASKS_SERVICE, f'/tasks/{task_id}', 'GET')


@app.route('/api/tasks/<int:task_id>/status', methods=['PATCH'])
def update_task_status(task_id):
    return proxy_request(TASKS_SERVICE, f'/tasks/{task_id}/status', 'PATCH',
                          data=request.get_json())


# ── ENDPOINT D'AGGREGATION ────────────────────────────────

@app.route('/api/users/<int:user_id>/dashboard', methods=['GET'])
def user_dashboard(user_id):
    """
    Endpoint d'aggregation : combine les données de DEUX services.
    Récupère l'utilisateur ET ses tâches en une seule réponse.
    
    Le client n'a pas à faire 2 requêtes séparées.
    La Gateway fait le travail d'agréger les données.
    """
    # Appel 1 : récupérer l'utilisateur
    user_response = requests.get(
        f'{USERS_SERVICE}/users/{user_id}',
        timeout=5
    )

    if user_response.status_code != 200:
        return jsonify({'error': 'Utilisateur non trouvé'}), 404

    user_data = user_response.json()

    # Appel 2 : récupérer les tâches de cet utilisateur
    tasks_response = requests.get(
        f'{TASKS_SERVICE}/tasks',
        params={'user_id': user_id},
        timeout=5
    )

    tasks_data = tasks_response.json() if tasks_response.status_code == 200 else []

    # Aggregation des données des deux services
    dashboard = {
        'user': user_data,
        'tasks': tasks_data,
        'task_count': len(tasks_data),
        'done_count': sum(1 for t in tasks_data if t.get('status') == 'done')
    }

    return jsonify(dashboard), 200


@app.route('/api/health', methods=['GET'])
def health():
    """
    Health check global : vérifie tous les services.
    Utile pour le monitoring.
    """
    services_status = {}

    for name, url in [('users', USERS_SERVICE), ('tasks', TASKS_SERVICE)]:
        try:
            r = requests.get(f'{url}/health', timeout=2)
            services_status[name] = 'up' if r.status_code == 200 else 'degraded'
        except:
            services_status[name] = 'down'

    # La Gateway est unhealthy si un service critique est down
    all_up = all(s == 'up' for s in services_status.values())

    return jsonify({
        'gateway': 'up',
        'services': services_status,
        'overall': 'healthy' if all_up else 'degraded'
    }), 200


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

================================================================================
5. ORCHESTRATION AVEC DOCKER COMPOSE
================================================================================

──────────────────────────────────────────────
FICHIER : docker-compose.yml
──────────────────────────────────────────────

# docker-compose.yml
# Orchestre tous les services : démarre, configure, connecte.
# Commande : docker-compose up
# Chaque service tourne dans son propre container.

version: '3.8'  # Version du format Docker Compose

services:

  # ── SERVICE GATEWAY ──────────────────────────────────────
  gateway:
    build: ./gateway              # Construire depuis le dossier gateway/
    ports:
      - "5000:5000"               # Port hôte:port container
    environment:
      # Variables d'environnement pour la configuration
      # Dans Docker, les services se parlent par leur nom (pas localhost)
      - USERS_SERVICE_URL=http://users_service:5001
      - TASKS_SERVICE_URL=http://tasks_service:5002
    depends_on:
      - users_service              # Démarre après les autres services
      - tasks_service
    networks:
      - microservices_network      # Réseau interne partagé

  # ── SERVICE USERS ────────────────────────────────────────
  users_service:
    build: ./users_service
    ports:
      - "5001:5001"
    environment:
      - DATABASE_URL=sqlite:///users.db
    volumes:
      - users_data:/app/data      # Persistance des données
    networks:
      - microservices_network

  # ── SERVICE TASKS ────────────────────────────────────────
  tasks_service:
    build: ./tasks_service
    ports:
      - "5002:5002"
    environment:
      - DATABASE_URL=sqlite:///tasks.db
      - USERS_SERVICE_URL=http://users_service:5001
    volumes:
      - tasks_data:/app/data
    depends_on:
      - users_service
    networks:
      - microservices_network

# Volumes persistants pour les bases de données
volumes:
  users_data:
  tasks_data:

# Réseau interne pour la communication entre services
networks:
  microservices_network:
    driver: bridge                # Réseau virtuel bridge (standard Docker)

──────────────────────────────────────────────
FICHIER : Dockerfile (exemple pour users_service)
──────────────────────────────────────────────

# Dockerfile
# Instructions pour construire l'image Docker du service.

# Image de base : Python 3.11 slim (légère)
FROM python:3.11-slim

# Répertoire de travail dans le container
WORKDIR /app

# Copier et installer les dépendances d'abord (optimisation cache Docker)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copier le code source
COPY . .

# Port exposé par ce service
EXPOSE 5001

# Commande de démarrage
CMD ["python", "app.py"]

================================================================================
6. LANCER LES SERVICES
================================================================================

  # Option 1 : Manuellement (3 terminaux)

  # Terminal 1 : Users Service
  cd users_service
  pip install -r requirements.txt
  python app.py  # Écoute sur 5001

  # Terminal 2 : Tasks Service
  cd tasks_service
  pip install -r requirements.txt
  python app.py  # Écoute sur 5002

  # Terminal 3 : Gateway
  cd gateway
  pip install -r requirements.txt
  python app.py  # Écoute sur 5000

  # Option 2 : Docker Compose (recommandé)
  docker-compose up --build

  # Tester
  curl http://localhost:5000/api/health

  # Créer un utilisateur
  curl -X POST http://localhost:5000/api/users \
       -H "Content-Type: application/json" \
       -d '{"username":"alice","email":"alice@test.com","password":"secret123"}'

  # Créer une tâche
  curl -X POST http://localhost:5000/api/tasks \
       -H "Content-Type: application/json" \
       -d '{"title":"Ma tâche","user_id":1}'

  # Dashboard aggregé
  curl http://localhost:5000/api/users/1/dashboard

================================================================================
7. PATTERNS IMPORTANTS EN MICROSERVICES
================================================================================

  1. CIRCUIT BREAKER
  ──────────────────
  Si le service Users est down, arrêter d'essayer pendant X secondes.
  Évite de surcharger un service en panne.
  
  # Exemple simplifié de Circuit Breaker
  class CircuitBreaker:
      def __init__(self, max_failures=5, timeout=30):
          self.max_failures = max_failures
          self.timeout = timeout
          self.failures = 0
          self.last_failure_time = None
          self.state = 'closed'  # closed = normal, open = bloqué
      
      def call(self, func, *args, **kwargs):
          if self.state == 'open':
              if time.time() - self.last_failure_time > self.timeout:
                  self.state = 'half-open'  # Test si le service revient
              else:
                  raise Exception("Circuit ouvert - service indisponible")
          
          try:
              result = func(*args, **kwargs)
              self.failures = 0
              self.state = 'closed'
              return result
          except Exception as e:
              self.failures += 1
              self.last_failure_time = time.time()
              if self.failures >= self.max_failures:
                  self.state = 'open'
              raise

  2. IDEMPOTENCE
  ──────────────
  Une requête identique répétée doit produire le même résultat.
  Utiliser un ID de requête unique pour détecter les doublons.

  3. EVENTUAL CONSISTENCY
  ────────────────────────
  Les données entre services ne sont pas immédiatement cohérentes.
  C'est normal et acceptable dans les microservices.

  4. SERVICE DISCOVERY
  ─────────────────────
  Au lieu d'adresses en dur, les services se découvrent dynamiquement.
  Outils : Consul, Eureka, Kubernetes DNS.

================================================================================
8. COMMUNICATION ALTERNATIVE : MESSAGE QUEUE
================================================================================

  # Au lieu d'appels HTTP synchrones, on peut utiliser des messages asynchrones
  # Exemple avec Redis Pub/Sub (simplifié)

  # tasks_service/events.py
  import redis

  r = redis.Redis(host='localhost', port=6379)

  def publish_task_created(task):
      """Publie un événement quand une tâche est créée."""
      import json
      r.publish(
          'task.created',           # Canal/Topic
          json.dumps(task.to_dict())  # Données de l'événement
      )

  # Dans une autre partie du système (notification_service par exemple)
  def listen_for_tasks():
      """Écoute les événements de création de tâches."""
      pubsub = r.pubsub()
      pubsub.subscribe('task.created')

      for message in pubsub.listen():
          if message['type'] == 'message':
              task_data = json.loads(message['data'])
              print(f"Nouvelle tâche créée : {task_data['title']}")
              # Envoyer une notification, etc.

================================================================================
9. BONNES PRATIQUES MICROSERVICES
================================================================================

  [OK] UN SERVICE = UNE RESPONSABILITÉ
  ─────────────────────────────────────
  Ne pas créer de "mega-service" qui fait tout.
  Chaque service doit avoir une raison d'exister clairement définie.

  [OK] BASE DE DONNÉES PAR SERVICE
  ──────────────────────────────
  Chaque service a sa propre DB.
  Jamais de base de données partagée entre services !
  (Sinon couplage fort entre services)

  [OK] CONTRATS D'API VERSIONNÉS
  ────────────────────────────
  Versionner les APIs : /api/v1/users, /api/v2/users
  Un changement breaking = nouvelle version, pas modifier l'ancienne.

  [OK] HEALTH CHECKS OBLIGATOIRES
  ──────────────────────────────
  Chaque service expose un /health.
  L'orchestrateur (Kubernetes) surveille les health checks.

  [OK] LOGGING CORRÉLÉ
  ───────────────────
  Chaque requête reçoit un ID unique (Correlation ID).
  Ce même ID est transmis à tous les services impliqués.
  Permet de tracer une requête à travers plusieurs services.

  [OK] COMMUNICATION ASYNCHRONE PRÉFÉRÉE
  ─────────────────────────────────────
  Quand possible, utiliser des messages (Kafka, RabbitMQ) plutôt que HTTP.
  Meilleure résilience : si un service est down, les messages s'accumulent
  et sont traités quand le service revient.

================================================================================
10. ERREURS FRÉQUENTES
================================================================================

  [X] TROP DE MICROSERVICES
  ────────────────────────
  "Microservice" ne signifie pas "minuscule".
  Un service doit avoir une taille raisonnable.
  "Nano-services" créent plus de problèmes qu'ils n'en résolvent.

  [X] BASE DE DONNÉES PARTAGÉE
  ───────────────────────────
  Partager une DB entre services détruit l'indépendance.
  Chaque service = sa propre DB.

  [X] APPELS SYNCHRONES EN CASCADE
  ────────────────────────────────
  Service A -> Service B -> Service C -> Service D
  Si D est lent, toute la chaîne est lente.
  Préférer l'asynchrone ou limiter les chaînes.

  [X] PAS DE GESTION D'ERREURS RÉSEAU
  ─────────────────────────────────────
  Les appels HTTP entre services PEUVENT échouer.
  Toujours gérer : timeout, connexion échouée, 500, etc.

  [X] TRANSACTION DISTRIBUÉE NAÏVE
  ─────────────────────────────────
  Vouloir une transaction ACID entre plusieurs services = erreur.
  Utiliser le pattern SAGA pour les transactions distribuées.

================================================================================
RÉSUMÉ
================================================================================

  Les microservices = petits services indépendants qui communiquent via API.
  
  Structure :
    Service Users  -> Gère les utilisateurs (port 5001)
    Service Tasks  -> Gère les tâches (port 5002)
    API Gateway    -> Point d'entrée unique (port 5000)
  
  Communication :
    Synchrone  -> HTTP/REST (simple, mais couplé)
    Asynchrone -> Messages (Kafka, RabbitMQ) (résilient, découplé)
  
  Règles d'or :
    [OK] Une DB par service
    [OK] Un service = une responsabilité
    [OK] Health checks obligatoires
    [OK] Gérer les erreurs réseau
    [OK] Commencer monolithique, migrer si nécessaire
  
  Prochain fichier : architecture_clean.txt

================================================================================
FIN DU FICHIER architecture_microservices.txt
================================================================================

================================================================================
         CLEAN ARCHITECTURE
         Guide complet avec exemples Flask
         Inspirée de Robert C. Martin (Uncle Bob)
================================================================================

================================================================================
1. INTRODUCTION PÉDAGOGIQUE
================================================================================

DÉFINITION :
────────────
La Clean Architecture est un ensemble de principes qui permet de créer des
systèmes où :
  - Le DOMAINE MÉTIER est au centre et ne dépend de rien
  - Les détails techniques (DB, framework, UI) sont aux bords
  - Chaque couche ne connaît que la couche intérieure (jamais extérieure)

PRINCIPE FONDAMENTAL : LA RÈGLE DE DÉPENDANCE
──────────────────────────────────────────────

  Les dépendances ne peuvent pointer QUE vers l'intérieur.
  Le code intérieur ne sait rien du code extérieur.

                    ┌─────────────────────────────────┐
                    │   FRAMEWORK / INTERFACE (Flask)  │
                    │   ┌─────────────────────────┐   │
                    │   │  INFRASTRUCTURE (DB)    │   │
                    │   │   ┌─────────────────┐   │   │
                    │   │   │  USE CASES      │   │   │
                    │   │   │  ┌───────────┐  │   │   │
                    │   │   │  │  ENTITIES │  │   │   │
                    │   │   │  │  (Domain) │  │   │   │
                    │   │   │  └───────────┘  │   │   │
                    │   │   └─────────────────┘   │   │
                    │   └─────────────────────────┘   │
                    └─────────────────────────────────┘
                    
  Les flèches de dépendance vont de l'extérieur vers l'intérieur.
  JAMAIS de l'intérieur vers l'extérieur.

LES 4 COUCHES :
───────────────
  1. ENTITIES (Cœur)     : Objets métier avec leurs règles
  2. USE CASES           : Actions que les utilisateurs peuvent faire
  3. INTERFACE ADAPTERS  : Convertisseurs (Controller, Presenter, Gateway)
  4. FRAMEWORKS          : Détails techniques (Flask, SQLAlchemy, Redis)

AVANTAGES :
───────────
  [OK] Le domaine métier est testable SANS framework, SANS base de données
  [OK] Changer de framework (Flask -> FastAPI) n'affecte pas le domaine
  [OK] Changer de DB (SQLite -> PostgreSQL) n'affecte pas le domaine
  [OK] Code maintenable même après des années
  [OK] Logique métier très claire et lisible

LIMITES :
─────────
  [X] Plus de code à écrire (mais code plus propre)
  [X] Courbe d'apprentissage
  [X] Peut être surdimensionné pour les petits projets

================================================================================
2. STRUCTURE DE DOSSIERS CLEAN ARCHITECTURE
================================================================================

  clean_todo/
  ├── app.py                       <- Point d'entrée (détail technique)
  ├── config.py
  ├── requirements.txt
  │
  ├── domain/                      <- COUCHE 1 : ENTITIES (cœur)
  │   ├── __init__.py
  │   ├── entities/
  │   │   ├── user.py              <- Entité User (objet métier pur)
  │   │   └── task.py              <- Entité Task (objet métier pur)
  │   └── repositories/
  │       ├── user_repository.py   <- INTERFACE (contrat) du repository
  │       └── task_repository.py
  │
  ├── use_cases/                   <- COUCHE 2 : USE CASES
  │   ├── __init__.py
  │   ├── user/
  │   │   ├── create_user.py       <- Use case : Créer un utilisateur
  │   │   ├── get_user.py          <- Use case : Récupérer un utilisateur
  │   │   └── delete_user.py
  │   └── task/
  │       ├── create_task.py
  │       └── complete_task.py
  │
  ├── infrastructure/              <- COUCHE 3 : INFRASTRUCTURE (détail technique)
  │   ├── __init__.py
  │   ├── database/
  │   │   ├── models.py            <- Modèles SQLAlchemy (détail technique)
  │   │   ├── user_repository.py   <- Implémentation concrète du repository
  │   │   └── task_repository.py
  │   └── services/
  │       └── email_service.py     <- Service email concret
  │
  └── interfaces/                  <- COUCHE 4 : INTERFACE ADAPTERS
      ├── __init__.py
      ├── controllers/
      │   ├── user_controller.py   <- Controller Flask
      │   └── task_controller.py
      └── presenters/
          └── user_presenter.py    <- Formatage de la réponse

================================================================================
3. IMPLÉMENTATION COMPLÈTE
================================================================================

════════════════════════════════════════════════════════════
COUCHE 1 : DOMAIN (ENTITIES)
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : domain/entities/user.py
──────────────────────────────────────────────

# domain/entities/user.py
# L'ENTITÉ est un objet métier PUR.
# Elle ne dépend d'AUCUN framework, d'AUCUNE bibliothèque externe.
# C'est du Python pur avec des dataclasses ou des classes simples.
# Elle peut être testée SANS installer Flask, SQLAlchemy, ou quoi que ce soit.

from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
import re  # Module Python standard pour les expressions régulières


@dataclass
class User:
    """
    Entité User : représentation PURE du concept utilisateur dans le domaine métier.
    
    Une dataclass génère automatiquement __init__, __repr__, __eq__.
    
    IMPORTANT : Cette classe ne connaît pas :
    - Flask (pas de request, jsonify...)
    - SQLAlchemy (pas de db.Model, db.Column...)
    - La base de données (pas de SQL)
    C'est intentionnel : c'est le principe de Clean Architecture.
    """

    # Champs avec types explicites (type hints)
    username: str
    email: str
    password_hash: str

    # Champs optionnels avec valeurs par défaut
    id: Optional[int] = None                          # None si pas encore sauvegardé en DB
    created_at: datetime = field(default_factory=datetime.utcnow)
    is_active: bool = True

    # ── RÈGLES MÉTIER (VALIDATION) ────────────────────────────

    def __post_init__(self):
        """
        Appelé automatiquement après __init__ par dataclass.
        On y met les validations qui doivent toujours être respectées.
        Ces règles sont des INVARIANTS MÉTIER.
        """
        self._validate_username()
        self._validate_email()

    def _validate_username(self):
        """
        RÈGLE MÉTIER : Le username doit faire entre 3 et 30 caractères,
        et ne contenir que des lettres, chiffres et underscore.
        """
        if not self.username:
            raise ValueError("Le username ne peut pas être vide")
        if len(self.username) < 3:
            raise ValueError("Le username doit faire au moins 3 caractères")
        if len(self.username) > 30:
            raise ValueError("Le username ne peut pas dépasser 30 caractères")
        if not re.match(r'^[a-zA-Z0-9_]+$', self.username):
            raise ValueError("Le username ne peut contenir que des lettres, chiffres et _")

    def _validate_email(self):
        """
        RÈGLE MÉTIER : Format email valide.
        Validation simple (une vraie validation serait plus complexe).
        """
        if not self.email:
            raise ValueError("L'email ne peut pas être vide")
        if '@' not in self.email or '.' not in self.email.split('@')[-1]:
            raise ValueError("Format d'email invalide")

    # ── COMPORTEMENTS MÉTIER ───────────────────────────────────

    def deactivate(self):
        """
        COMPORTEMENT MÉTIER : Désactivation du compte.
        Cette méthode encode la RÈGLE de désactivation dans l'entité.
        """
        if not self.is_active:
            raise ValueError("Le compte est déjà désactivé")
        self.is_active = False

    def reactivate(self):
        """Réactivation du compte."""
        if self.is_active:
            raise ValueError("Le compte est déjà actif")
        self.is_active = True

    def is_saved(self):
        """Indique si l'entité a été persistée (a un ID)."""
        return self.id is not None

    def to_dict(self):
        """
        Représentation en dictionnaire.
        Utilisé par le Presenter pour formater la réponse.
        """
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'created_at': self.created_at.isoformat(),
            'is_active': self.is_active
        }


──────────────────────────────────────────────
FICHIER : domain/entities/task.py
──────────────────────────────────────────────

# domain/entities/task.py

from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from enum import Enum  # Pour les valeurs énumérées


class TaskStatus(Enum):
    """
    Énumération des statuts possibles d'une tâche.
    Utiliser une Enum évite les erreurs de frappe ('pendig' au lieu de 'pending').
    """
    PENDING     = 'pending'
    IN_PROGRESS = 'in_progress'
    DONE        = 'done'


class TaskPriority(Enum):
    """Énumération des priorités."""
    LOW    = 'low'
    MEDIUM = 'medium'
    HIGH   = 'high'


@dataclass
class Task:
    """
    Entité Task : objet métier pur représentant une tâche.
    Contient toute la logique métier liée aux tâches.
    Ne dépend d'aucun framework.
    """

    title: str
    user_id: int
    description: str = ''
    priority: TaskPriority = TaskPriority.MEDIUM
    status: TaskStatus = TaskStatus.PENDING
    id: Optional[int] = None
    created_at: datetime = field(default_factory=datetime.utcnow)
    due_date: Optional[datetime] = None

    def __post_init__(self):
        """Validation à la création."""
        if not self.title or not self.title.strip():
            raise ValueError("Le titre ne peut pas être vide")
        if len(self.title) > 200:
            raise ValueError("Le titre ne peut pas dépasser 200 caractères")
        self.title = self.title.strip()  # Nettoyer les espaces

    # ── RÈGLES DE TRANSITION D'ÉTAT ───────────────────────────

    def start(self):
        """
        RÈGLE MÉTIER : Une tâche ne peut être démarrée que si elle est en attente.
        Encode la machine d'états de la tâche.
        """
        if self.status != TaskStatus.PENDING:
            raise ValueError(
                f"Impossible de démarrer une tâche avec le statut '{self.status.value}'"
            )
        self.status = TaskStatus.IN_PROGRESS

    def complete(self):
        """
        RÈGLE MÉTIER : Seule une tâche en cours peut être complétée.
        """
        if self.status == TaskStatus.DONE:
            raise ValueError("La tâche est déjà terminée")
        self.status = TaskStatus.DONE

    def reopen(self):
        """Réouvrir une tâche terminée."""
        if self.status != TaskStatus.DONE:
            raise ValueError("Seule une tâche terminée peut être réouverte")
        self.status = TaskStatus.PENDING

    # ── REQUÊTES SUR L'ÉTAT ────────────────────────────────────

    def is_overdue(self):
        """RÈGLE : est en retard si date dépassée ET pas terminée."""
        if self.due_date and self.status != TaskStatus.DONE:
            return datetime.utcnow() > self.due_date
        return False

    def is_high_priority(self):
        """RÈGLE : tâche haute priorité."""
        return self.priority == TaskPriority.HIGH

    def to_dict(self):
        return {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'priority': self.priority.value,
            'status': self.status.value,
            'created_at': self.created_at.isoformat(),
            'due_date': self.due_date.isoformat() if self.due_date else None,
            'user_id': self.user_id,
            'is_overdue': self.is_overdue()
        }


──────────────────────────────────────────────
FICHIER : domain/repositories/user_repository.py
──────────────────────────────────────────────

# domain/repositories/user_repository.py
# INTERFACE du repository : définit le CONTRAT sans l'implémenter.
# Le Use Case dépend de cette interface, PAS de l'implémentation concrète.
# Cela permet de changer de DB sans modifier le Use Case.

from abc import ABC, abstractmethod  # ABC = Abstract Base Class
from typing import Optional, List
from domain.entities.user import User


class UserRepositoryInterface(ABC):
    """
    Interface (contrat) pour le repository User.
    
    ABC (Abstract Base Class) : définit des méthodes obligatoires.
    Toute classe héritant de cette interface DOIT implémenter ces méthodes.
    
    POURQUOI une interface ?
    Le Use Case dépend de cette interface, pas de l'implémentation SQLAlchemy.
    On peut créer une implémentation SQLAlchemy, une pour les tests (en mémoire),
    une MongoDB, etc. Le Use Case n'a pas à changer.
    """

    @abstractmethod
    def find_by_id(self, user_id: int) -> Optional[User]:
        """Trouve un utilisateur par ID. Retourne None si inexistant."""
        pass  # Implémentation dans la couche Infrastructure

    @abstractmethod
    def find_by_email(self, email: str) -> Optional[User]:
        """Trouve un utilisateur par email."""
        pass

    @abstractmethod
    def find_all(self) -> List[User]:
        """Retourne tous les utilisateurs."""
        pass

    @abstractmethod
    def save(self, user: User) -> User:
        """Sauvegarde un utilisateur (création ou modification)."""
        pass

    @abstractmethod
    def delete(self, user_id: int) -> bool:
        """Supprime un utilisateur. Retourne True si supprimé."""
        pass

    @abstractmethod
    def exists_by_email(self, email: str) -> bool:
        """Vérifie si un utilisateur avec cet email existe."""
        pass


# De même pour TaskRepositoryInterface dans domain/repositories/task_repository.py


════════════════════════════════════════════════════════════
COUCHE 2 : USE CASES
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : use_cases/user/create_user.py
──────────────────────────────────────────────

# use_cases/user/create_user.py
# UN USE CASE = UNE ACTION UTILISATEUR
# Ce fichier représente le cas d'usage "Créer un utilisateur".
# Il orchestre les règles métier et les repositories.
# Il NE connaît pas Flask, SQLAlchemy, HTTP.

import hashlib
from dataclasses import dataclass
from domain.entities.user import User
from domain.repositories.user_repository import UserRepositoryInterface


@dataclass
class CreateUserRequest:
    """
    DTO (Data Transfer Object) d'entrée du Use Case.
    Encapsule les données nécessaires pour créer un utilisateur.
    Pas de dépendance à Flask : on peut appeler ce Use Case depuis
    un CLI, un test, une tâche cron, etc.
    """
    username: str
    email: str
    password: str


@dataclass
class CreateUserResponse:
    """
    DTO de sortie du Use Case.
    Contient les données à retourner après la création.
    Le Controller (Interface Adapter) prend ce DTO et le convertit en JSON.
    """
    user_id: int
    username: str
    email: str
    message: str = "Utilisateur créé avec succès"


class CreateUserUseCase:
    """
    Use Case : Créer un utilisateur.
    
    Un Use Case :
    1. Reçoit un Request (DTO d'entrée)
    2. Applique les règles métier
    3. Appelle les repositories
    4. Retourne un Response (DTO de sortie)
    
    DÉPENDANCES :
    - UserRepositoryInterface (injection de dépendances)
    
    Le Use Case ne sait pas SI c'est SQLAlchemy ou MongoDB qui persiste.
    Il utilise juste l'interface.
    """

    def __init__(self, user_repository: UserRepositoryInterface):
        """
        INJECTION DE DÉPENDANCES :
        Le repository est injecté, pas instancié ici.
        Avantage : pour les tests, on peut injecter un faux repository en mémoire.
        """
        self.user_repository = user_repository

    def execute(self, request: CreateUserRequest) -> CreateUserResponse:
        """
        Point d'entrée du Use Case.
        
        Règles appliquées :
        1. Le mot de passe doit faire au moins 6 caractères
        2. L'email doit être unique
        3. Le mot de passe est hashé
        
        Args:
            request: données nécessaires (username, email, password)
            
        Returns:
            CreateUserResponse avec les données du nouvel utilisateur
            
        Raises:
            ValueError: si les règles métier ne sont pas respectées
        """

        # ── RÈGLE 1 : Longueur du mot de passe ───────────────
        if len(request.password) < 6:
            raise ValueError("Le mot de passe doit faire au moins 6 caractères")

        # ── RÈGLE 2 : Email unique ────────────────────────────
        if self.user_repository.exists_by_email(request.email):
            raise ValueError(f"L'email '{request.email}' est déjà utilisé")

        # ── CRÉATION DE L'ENTITÉ ──────────────────────────────
        # On crée l'entité domaine (User de domain/entities/user.py)
        # La validation du username et email se fait dans User.__post_init__
        password_hash = self._hash_password(request.password)

        new_user = User(
            username=request.username,
            email=request.email,
            password_hash=password_hash
        )
        # Note : User.__post_init__ valide username et email ici

        # ── PERSISTENCE ───────────────────────────────────────
        # On délègue la sauvegarde au Repository
        saved_user = self.user_repository.save(new_user)

        # ── RÉPONSE ───────────────────────────────────────────
        # On retourne un DTO de sortie (pas l'entité directement)
        return CreateUserResponse(
            user_id=saved_user.id,
            username=saved_user.username,
            email=saved_user.email
        )

    def _hash_password(self, password: str) -> str:
        """Hash du mot de passe. Logique métier de sécurité."""
        return hashlib.sha256(password.encode()).hexdigest()


──────────────────────────────────────────────
FICHIER : use_cases/user/get_user.py
──────────────────────────────────────────────

# use_cases/user/get_user.py

from dataclasses import dataclass
from typing import Optional
from domain.entities.user import User
from domain.repositories.user_repository import UserRepositoryInterface


@dataclass
class GetUserRequest:
    """DTO d'entrée : juste l'ID de l'utilisateur à récupérer."""
    user_id: int


class GetUserUseCase:
    """Use Case : Récupérer un utilisateur par son ID."""

    def __init__(self, user_repository: UserRepositoryInterface):
        self.user_repository = user_repository

    def execute(self, request: GetUserRequest) -> User:
        """
        Récupère l'utilisateur.
        
        Raises:
            ValueError: si l'utilisateur n'existe pas
        """
        user = self.user_repository.find_by_id(request.user_id)

        if not user:
            raise ValueError(f"Utilisateur {request.user_id} introuvable")

        if not user.is_active:
            raise ValueError(f"Compte utilisateur {request.user_id} désactivé")

        return user


──────────────────────────────────────────────
FICHIER : use_cases/task/create_task.py
──────────────────────────────────────────────

# use_cases/task/create_task.py

from dataclasses import dataclass
from typing import Optional
from domain.entities.task import Task, TaskPriority
from domain.repositories.task_repository import TaskRepositoryInterface
from domain.repositories.user_repository import UserRepositoryInterface


@dataclass
class CreateTaskRequest:
    """DTO d'entrée pour la création de tâche."""
    title: str
    user_id: int
    description: str = ''
    priority: str = 'medium'


@dataclass
class CreateTaskResponse:
    """DTO de sortie après création."""
    task_id: int
    title: str
    status: str
    user_id: int


class CreateTaskUseCase:
    """
    Use Case : Créer une tâche pour un utilisateur.
    
    Dépend de DEUX repositories (interfaces, pas implémentations).
    Orchestre la vérification de l'utilisateur ET la création de la tâche.
    """

    # Règle métier : nombre max de tâches par utilisateur
    MAX_TASKS_PER_USER = 50

    def __init__(
        self,
        task_repository: TaskRepositoryInterface,
        user_repository: UserRepositoryInterface
    ):
        """Deux repositories injectés."""
        self.task_repository = task_repository
        self.user_repository = user_repository

    def execute(self, request: CreateTaskRequest) -> CreateTaskResponse:
        """
        Crée une tâche.
        
        Règles :
        1. L'utilisateur doit exister et être actif
        2. La priorité doit être valide
        3. L'utilisateur ne peut pas avoir plus de MAX_TASKS_PER_USER tâches
        """

        # ── RÈGLE 1 : Utilisateur existant ───────────────────
        user = self.user_repository.find_by_id(request.user_id)
        if not user or not user.is_active:
            raise ValueError(f"Utilisateur {request.user_id} introuvable ou inactif")

        # ── RÈGLE 2 : Priorité valide ─────────────────────────
        try:
            priority = TaskPriority(request.priority)
        except ValueError:
            valid = [p.value for p in TaskPriority]
            raise ValueError(f"Priorité invalide. Valeurs: {valid}")

        # ── RÈGLE 3 : Limite de tâches ───────────────────────
        existing_count = self.task_repository.count_by_user(request.user_id)
        if existing_count >= self.MAX_TASKS_PER_USER:
            raise ValueError(
                f"Limite de {self.MAX_TASKS_PER_USER} tâches atteinte pour cet utilisateur"
            )

        # ── CRÉATION DE L'ENTITÉ DOMAINE ──────────────────────
        # L'entité Task valide le titre dans __post_init__
        new_task = Task(
            title=request.title,
            description=request.description,
            priority=priority,
            user_id=request.user_id
        )

        # ── PERSISTENCE ───────────────────────────────────────
        saved_task = self.task_repository.save(new_task)

        return CreateTaskResponse(
            task_id=saved_task.id,
            title=saved_task.title,
            status=saved_task.status.value,
            user_id=saved_task.user_id
        )


════════════════════════════════════════════════════════════
COUCHE 3 : INFRASTRUCTURE
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : infrastructure/database/models.py
──────────────────────────────────────────────

# infrastructure/database/models.py
# Les modèles SQLAlchemy sont des DÉTAILS TECHNIQUES.
# Ils vivent dans la couche Infrastructure.
# Ce sont de simples tables SQL, pas les entités du domaine.
# On convertit entre les deux dans les Repositories.

from flask_sqlalchemy import SQLAlchemy
from datetime import datetime

db = SQLAlchemy()


class UserModel(db.Model):
    """
    Modèle SQLAlchemy pour la persistance des Users.
    DIFFÉRENT de domain/entities/user.py !
    
    UserModel = représentation SQL
    User (domain) = représentation métier
    
    La conversion entre les deux est faite dans le Repository.
    """

    __tablename__ = 'users'

    id            = db.Column(db.Integer, primary_key=True)
    username      = db.Column(db.String(80), unique=True, nullable=False)
    email         = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(256), nullable=False)
    created_at    = db.Column(db.DateTime, default=datetime.utcnow)
    is_active     = db.Column(db.Boolean, default=True)

    # Relation SQLAlchemy vers TaskModel
    tasks = db.relationship('TaskModel', backref='user', lazy='dynamic',
                            cascade='all, delete-orphan')


class TaskModel(db.Model):
    """Modèle SQLAlchemy pour la persistance des Tasks."""

    __tablename__ = 'tasks'

    id          = db.Column(db.Integer, primary_key=True)
    title       = db.Column(db.String(200), nullable=False)
    description = db.Column(db.Text)
    priority    = db.Column(db.String(20), default='medium')
    status      = db.Column(db.String(20), default='pending')
    created_at  = db.Column(db.DateTime, default=datetime.utcnow)
    due_date    = db.Column(db.DateTime)
    user_id     = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)


──────────────────────────────────────────────
FICHIER : infrastructure/database/user_repository.py
──────────────────────────────────────────────

# infrastructure/database/user_repository.py
# IMPLÉMENTATION CONCRÈTE du UserRepositoryInterface.
# C'est ici que vit le code SQLAlchemy.
# Le Use Case ne connaît pas ce fichier (il connaît juste l'interface).

from typing import Optional, List
from domain.entities.user import User
from domain.repositories.user_repository import UserRepositoryInterface
from infrastructure.database.models import UserModel, db


class SQLAlchemyUserRepository(UserRepositoryInterface):
    """
    Implémentation SQLAlchemy du UserRepository.
    
    Implémente TOUTES les méthodes abstraites de UserRepositoryInterface.
    
    CONVERSION :
    - UserModel (SQLAlchemy) <-> User (Domain Entity)
    - On convertit dans les deux sens pour découpler domaine et DB.
    """

    def find_by_id(self, user_id: int) -> Optional[User]:
        """Requête SQL -> Entité domaine."""
        model = UserModel.query.get(user_id)
        return self._to_entity(model) if model else None

    def find_by_email(self, email: str) -> Optional[User]:
        """Trouve par email."""
        model = UserModel.query.filter_by(email=email).first()
        return self._to_entity(model) if model else None

    def find_all(self) -> List[User]:
        """Retourne tous les utilisateurs comme entités domaine."""
        models = UserModel.query.filter_by(is_active=True).all()
        return [self._to_entity(m) for m in models]

    def save(self, user: User) -> User:
        """
        Sauvegarde une entité domaine.
        Si user.id est None -> INSERT
        Si user.id est fourni -> UPDATE
        """
        if user.id:
            # UPDATE : trouve le modèle existant et met à jour
            model = UserModel.query.get(user.id)
            if not model:
                raise ValueError(f"User {user.id} non trouvé pour mise à jour")
            model.username = user.username
            model.email = user.email
            model.password_hash = user.password_hash
            model.is_active = user.is_active
        else:
            # INSERT : crée un nouveau modèle SQLAlchemy
            model = UserModel(
                username=user.username,
                email=user.email,
                password_hash=user.password_hash,
                is_active=user.is_active
            )
            db.session.add(model)

        db.session.commit()
        db.session.refresh(model)

        # Retourne l'entité domaine mise à jour (avec l'ID généré)
        return self._to_entity(model)

    def delete(self, user_id: int) -> bool:
        """Supprime un utilisateur."""
        model = UserModel.query.get(user_id)
        if not model:
            return False
        db.session.delete(model)
        db.session.commit()
        return True

    def exists_by_email(self, email: str) -> bool:
        """Vérifie l'existence par email."""
        return UserModel.query.filter_by(email=email).count() > 0

    # ── MÉTHODE DE CONVERSION ─────────────────────────────────

    def _to_entity(self, model: UserModel) -> User:
        """
        MAPPING : Convertit un UserModel SQLAlchemy en User domaine.
        
        C'est ici qu'on traduit la représentation DB en représentation métier.
        Si la DB change (ajouter une colonne, renommer...), seule cette méthode change.
        """
        return User(
            id=model.id,
            username=model.username,
            email=model.email,
            password_hash=model.password_hash,
            created_at=model.created_at,
            is_active=model.is_active
        )


════════════════════════════════════════════════════════════
COUCHE 4 : INTERFACE ADAPTERS (Controllers)
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : interfaces/controllers/user_controller.py
──────────────────────────────────────────────

# interfaces/controllers/user_controller.py
# Le Controller est un ADAPTATEUR entre Flask et les Use Cases.
# Il traduit les requêtes HTTP en appels de Use Cases.
# Il traduit les réponses des Use Cases en réponses HTTP.

from flask import Blueprint, request, jsonify
from use_cases.user.create_user import CreateUserUseCase, CreateUserRequest
from use_cases.user.get_user import GetUserUseCase, GetUserRequest
from infrastructure.database.user_repository import SQLAlchemyUserRepository

user_controller = Blueprint('users', __name__, url_prefix='/api/users')


def _get_use_cases():
    """
    Factory pour créer les Use Cases avec leurs dépendances.
    En Clean Architecture, on gère l'injection de dépendances ici (ou via un IoC container).
    """
    repo = SQLAlchemyUserRepository()  # Implémentation concrète
    return {
        'create': CreateUserUseCase(repo),
        'get': GetUserUseCase(repo),
    }


@user_controller.route('/', methods=['POST'])
def create_user():
    """
    POST /api/users/
    
    RÔLE DU CONTROLLER :
    1. Extraire les données de la requête HTTP
    2. Créer le Request DTO
    3. Appeler le Use Case
    4. Convertir la Response en JSON HTTP
    """
    data = request.get_json()

    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    # Validation du FORMAT de la requête (Controller)
    # (pas des règles métier qui sont dans le Use Case)
    required = ['username', 'email', 'password']
    for field_name in required:
        if field_name not in data:
            return jsonify({'error': f'{field_name} est requis'}), 400

    # Création du DTO d'entrée
    create_request = CreateUserRequest(
        username=data['username'],
        email=data['email'],
        password=data['password']
    )

    try:
        # Appel du Use Case
        use_cases = _get_use_cases()
        response = use_cases['create'].execute(create_request)

        # Conversion de la réponse en HTTP JSON
        return jsonify({
            'id': response.user_id,
            'username': response.username,
            'email': response.email,
            'message': response.message
        }), 201

    except ValueError as e:
        # Use Case a levé ValueError -> 400 ou 409 selon le cas
        error_msg = str(e)
        status = 409 if 'déjà utilisé' in error_msg else 400
        return jsonify({'error': error_msg}), status


@user_controller.route('/<int:user_id>', methods=['GET'])
def get_user(user_id):
    """GET /api/users/<id>"""
    get_request = GetUserRequest(user_id=user_id)

    try:
        use_cases = _get_use_cases()
        user = use_cases['get'].execute(get_request)
        return jsonify(user.to_dict()), 200
    except ValueError as e:
        return jsonify({'error': str(e)}), 404

================================================================================
4. TESTS CLEAN ARCHITECTURE
================================================================================

──────────────────────────────────────────────
FICHIER : tests/test_use_cases.py
──────────────────────────────────────────────

# tests/test_use_cases.py
# Tests des Use Cases SANS base de données, SANS Flask.
# On utilise des repositories en mémoire (InMemory).

import pytest
from use_cases.user.create_user import CreateUserUseCase, CreateUserRequest
from domain.entities.user import User
from domain.repositories.user_repository import UserRepositoryInterface
from typing import Optional, List, Dict


class InMemoryUserRepository(UserRepositoryInterface):
    """
    Implémentation en mémoire du repository.
    Utilisée UNIQUEMENT pour les tests.
    Pas de vraie DB, tout est dans un dictionnaire Python.
    Très rapide et sans effet de bord.
    """

    def __init__(self):
        self._users: Dict[int, User] = {}  # Dictionnaire ID -> User
        self._next_id = 1                  # Auto-increment simulé

    def find_by_id(self, user_id: int) -> Optional[User]:
        return self._users.get(user_id)

    def find_by_email(self, email: str) -> Optional[User]:
        for user in self._users.values():
            if user.email == email:
                return user
        return None

    def find_all(self) -> List[User]:
        return list(self._users.values())

    def save(self, user: User) -> User:
        if user.id is None:
            user.id = self._next_id
            self._next_id += 1
        self._users[user.id] = user
        return user

    def delete(self, user_id: int) -> bool:
        if user_id in self._users:
            del self._users[user_id]
            return True
        return False

    def exists_by_email(self, email: str) -> bool:
        return self.find_by_email(email) is not None


@pytest.fixture
def user_repo():
    """Repository en mémoire pour les tests."""
    return InMemoryUserRepository()


@pytest.fixture
def create_user_use_case(user_repo):
    """Use Case avec repository en mémoire."""
    return CreateUserUseCase(user_repo)


def test_create_user_success(create_user_use_case):
    """
    Test : création réussie.
    PAS DE FLASK, PAS DE DB, PAS DE NETWORK.
    Test pure Python, ultra rapide.
    """
    req = CreateUserRequest(
        username='alice',
        email='alice@test.com',
        password='motdepasse123'
    )

    response = create_user_use_case.execute(req)

    assert response.user_id is not None
    assert response.username == 'alice'
    assert response.email == 'alice@test.com'
    assert "succès" in response.message.lower()


def test_create_user_password_too_short(create_user_use_case):
    """Test : mot de passe trop court -> ValueError."""
    req = CreateUserRequest(username='alice', email='alice@test.com', password='123')

    with pytest.raises(ValueError, match="6 caractères"):
        create_user_use_case.execute(req)


def test_create_user_duplicate_email(create_user_use_case, user_repo):
    """Test : email en doublon -> ValueError."""
    # Créer un premier user
    req1 = CreateUserRequest(username='alice', email='alice@test.com', password='motdepasse123')
    create_user_use_case.execute(req1)

    # Essayer de créer un second avec le même email
    req2 = CreateUserRequest(username='alice2', email='alice@test.com', password='autremotdepasse')

    with pytest.raises(ValueError, match="déjà utilisé"):
        create_user_use_case.execute(req2)


def test_entity_validates_username():
    """Test que l'entité domaine valide le username."""
    with pytest.raises(ValueError, match="3 caractères"):
        User(username='ab', email='test@test.com', password_hash='hash')


def test_task_state_machine():
    """Test la machine d'états des tâches SANS aucun framework."""
    from domain.entities.task import Task, TaskStatus

    task = Task(title='Ma tâche', user_id=1)
    assert task.status == TaskStatus.PENDING

    task.start()
    assert task.status == TaskStatus.IN_PROGRESS

    task.complete()
    assert task.status == TaskStatus.DONE

    # On ne peut pas démarrer une tâche déjà terminée
    with pytest.raises(ValueError):
        task.start()

================================================================================
5. AVANTAGES CONCRETS DE LA CLEAN ARCHITECTURE
================================================================================

  SCÉNARIO : Migrer de SQLite vers PostgreSQL
  ────────────────────────────────────────────
  
  Sans Clean Architecture :
    -> Modifier models.py, routes.py, services.py...
    -> Risque de casser beaucoup de choses
  
  Avec Clean Architecture :
    -> Créer PostgreSQLUserRepository implémentant UserRepositoryInterface
    -> Changer dans app.py l'injection de dépendance
    -> Les Use Cases, Entités, ne changent PAS du tout

  SCÉNARIO : Ajouter une API GraphQL
  ────────────────────────────────────
  
  Sans Clean Architecture :
    -> Tout est couplé à Flask/REST, difficile d'ajouter GraphQL
  
  Avec Clean Architecture :
    -> Créer de nouveaux Adapters GraphQL qui appellent les mêmes Use Cases
    -> Le domaine et les Use Cases ne changent pas

================================================================================
6. BONNES PRATIQUES CLEAN ARCHITECTURE
================================================================================

  [OK] LES ENTITÉS SONT PURES
  ──────────────────────────
  Aucun import de Flask, SQLAlchemy dans domain/entities/*.py

  [OK] LES USE CASES NE CONNAISSENT QUE LES INTERFACES
  ───────────────────────────────────────────────────
  Jamais importer SQLAlchemyUserRepository dans un Use Case.
  Seulement UserRepositoryInterface.

  [OK] INJECTION DE DÉPENDANCES
  ────────────────────────────
  Toujours injecter les dépendances, jamais les instancier dans le Use Case.

  [OK] DTOs POUR CHAQUE USE CASE
  ─────────────────────────────
  CreateUserRequest / CreateUserResponse sont des classes dédiées.
  Évite de passer des dictionnaires non typés.

================================================================================
RÉSUMÉ
================================================================================

  Clean Architecture = Domain au centre, frameworks aux bords.
  
  Couches (intérieur -> extérieur) :
    1. Entities (domain)     -> Objets métier purs, aucune dépendance
    2. Use Cases             -> Actions métier, dépendent des interfaces
    3. Interface Adapters    -> Controllers, Presenters (convertisseurs)
    4. Frameworks/DB         -> Flask, SQLAlchemy, Email (détails)
  
  La règle de dépendance : le code pointe vers l'intérieur, JAMAIS vers l'extérieur.
  
  Bénéfice : tester le domaine SANS Flask, SANS base de données.
  
  Prochain fichier : architecture_hexagonale.txt

================================================================================
FIN DU FICHIER architecture_clean.txt
================================================================================

================================================================================
         ARCHITECTURE HEXAGONALE (Ports & Adapters)
         Guide complet avec exemples Flask
         Inventée par Alistair Cockburn
================================================================================

================================================================================
1. INTRODUCTION PÉDAGOGIQUE
================================================================================

DÉFINITION :
────────────
L'architecture hexagonale (aussi appelée "Ports & Adapters") place le DOMAINE
MÉTIER au centre d'un hexagone. Tout ce qui veut interagir avec le domaine
doit passer par un PORT (interface standardisée) et utiliser un ADAPTER
(implémentation concrète).

ANALOGIE : LA PRISE ÉLECTRIQUE
────────────────────────────────
  Un appareil électrique (ton domaine métier) ne se connecte pas directement
  au réseau électrique brut.
  Il utilise une PRISE (port standardisé).
  L'ADAPTATEUR (adaptateur de voyage en Europe/USA) traduit le format.
  
  Changer de pays (changer de DB, de framework) = changer l'adaptateur.
  L'appareil (le domaine) reste le même.

CONCEPTS CLÉS :
───────────────

  PORT (Interface)
  ─────────────────
  Un Port définit COMMENT on communique avec le domaine.
  C'est un contrat (interface Python / ABC).
  Deux types :
    - Port ENTRANT (Driving Port) : comment l'extérieur appelle le domaine
    - Port SORTANT (Driven Port)  : comment le domaine appelle l'extérieur

  ADAPTER (Implémentation)
  ─────────────────────────
  Un Adapter est l'implémentation concrète d'un Port.
  Il traduit entre le monde extérieur et le domaine.
  Deux types :
    - Adapter ENTRANT (Driving Adapter) : Flask, CLI, Tests...
    - Adapter SORTANT (Driven Adapter)  : SQLAlchemy, Email, Kafka...

DIAGRAMME HEXAGONAL :
─────────────────────

                 ┌─────────────────┐
  Flask API ───[BLACK_RIGHT-POINTING_TRIANGLE] │  Port Entrant   │
                 │  (HTTP In)      │
  Tests     ───[BLACK_RIGHT-POINTING_TRIANGLE] │                 │
                 └────────┬────────┘
                          │
                ┌─────────[BLACK_DOWN-POINTING_TRIANGLE]──────────┐
  ┌─────────────┤                    ├──────────────┐
  │  Port       │    DOMAINE         │  Port        │
  │  Sortant    │    MÉTIER          │  Sortant     │
  │  (DB Out)   │    (CŒUR)         │  (Email Out) │
  └─────────────┤                    ├──────────────┘
                └─────────[BLACK_UP-POINTING_TRIANGLE]──────────┘
                          │
                 ┌────────┴────────┐
  SQLAlchemy ──[BLACK_RIGHT-POINTING_TRIANGLE] │  Adapter DB     │
                 │  (implémente    │
  In-Memory  ──[BLACK_RIGHT-POINTING_TRIANGLE] │   Port Sortant) │
                 └─────────────────┘

DIFFÉRENCE AVEC CLEAN ARCHITECTURE :
──────────────────────────────────────
  Clean Architecture = Couches concentriques (cercles)
  Hexagonale         = Symétrie : entrées ET sorties sont des ports
  
  En pratique, les deux partagent les mêmes principes.
  L'hexagonale insiste sur la symétrie et les ports explicites.

AVANTAGES :
───────────
  [OK] Domaine totalement isolé du monde technique
  [OK] Tests unitaires ultra-rapides (faux adapters)
  [OK] Swap technologique facile (changer SQLite -> MongoDB = changer l'adapter)
  [OK] Multiple interfaces possibles (HTTP, CLI, gRPC...) sans toucher le domaine
  [OK] Architecture testable en isolation totale

LIMITES :
─────────
  [X] Beaucoup d'interfaces à définir
  [X] Plus de fichiers, plus de complexité
  [X] Courbe d'apprentissage significative

================================================================================
2. STRUCTURE DE DOSSIERS
================================================================================

  hexagonal_todo/
  ├── app.py
  ├── config.py
  │
  ├── domain/                          <- CŒUR du domaine (aucune dépendance)
  │   ├── __init__.py
  │   ├── model/                       <- Entités métier
  │   │   ├── user.py
  │   │   └── task.py
  │   ├── ports/                       <- Définition des Ports (interfaces)
  │   │   ├── incoming/                <- Ports ENTRANTS (comment appeler le domaine)
  │   │   │   ├── user_service_port.py
  │   │   │   └── task_service_port.py
  │   │   └── outgoing/               <- Ports SORTANTS (ce dont le domaine a besoin)
  │   │       ├── user_repository_port.py
  │   │       ├── task_repository_port.py
  │   │       └── notification_port.py
  │   └── services/                    <- Services du domaine (USE CASES)
  │       ├── user_domain_service.py
  │       └── task_domain_service.py
  │
  ├── adapters/                        <- Adapters (implémentations concrètes)
  │   ├── incoming/                    <- Adapters ENTRANTS (qui appellent le domaine)
  │   │   ├── http/                    <- Adapter HTTP (Flask)
  │   │   │   ├── user_controller.py
  │   │   │   └── task_controller.py
  │   │   └── cli/                     <- Adapter CLI (ligne de commande)
  │   │       └── cli_commands.py
  │   └── outgoing/                    <- Adapters SORTANTS (appelés par le domaine)
  │       ├── persistence/             <- Adapter base de données
  │       │   ├── sqlalchemy_models.py
  │       │   ├── sqlalchemy_user_repo.py
  │       │   └── in_memory_user_repo.py  <- Pour les tests
  │       └── notification/            <- Adapter notifications
  │           ├── email_adapter.py
  │           └── fake_email_adapter.py   <- Pour les tests
  │
  └── tests/
      ├── test_domain_services.py      <- Tests du domaine seul
      └── test_adapters.py             <- Tests des adapters

================================================================================
3. IMPLÉMENTATION COMPLÈTE
================================================================================

════════════════════════════════════════════════════════════
DOMAINE : MODÈLES ET PORTS
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : domain/model/user.py
──────────────────────────────────────────────

# domain/model/user.py
# Entité User du domaine. AUCUNE dépendance extérieure.

from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
import re


class UserValidationError(Exception):
    """Exception spécifique aux erreurs de validation User."""
    pass


@dataclass
class User:
    """
    Entité domaine User.
    Règles métier encodées directement dans l'entité.
    Aucune dépendance à Flask, SQLAlchemy ou toute bibliothèque externe.
    """

    username: str
    email: str
    password_hash: str
    id: Optional[int] = None
    created_at: datetime = field(default_factory=datetime.utcnow)
    is_active: bool = True

    def __post_init__(self):
        """Invariants métier : toujours vérifiés à la création."""
        self._ensure_valid_username()
        self._ensure_valid_email()

    def _ensure_valid_username(self):
        if not self.username or len(self.username.strip()) < 3:
            raise UserValidationError("Username doit faire au moins 3 caractères")
        if not re.match(r'^[a-zA-Z0-9_]+$', self.username):
            raise UserValidationError("Username ne peut contenir que lettres, chiffres, _")

    def _ensure_valid_email(self):
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        if not re.match(pattern, self.email):
            raise UserValidationError(f"Email invalide : {self.email}")

    def deactivate(self) -> None:
        """Désactive le compte. Invariant : ne peut désactiver un compte déjà inactif."""
        if not self.is_active:
            raise UserValidationError("Compte déjà désactivé")
        self.is_active = False

    def reactivate(self) -> None:
        """Réactive le compte."""
        if self.is_active:
            raise UserValidationError("Compte déjà actif")
        self.is_active = True

    def to_dict(self) -> dict:
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'created_at': self.created_at.isoformat(),
            'is_active': self.is_active
        }

    def __eq__(self, other) -> bool:
        """Deux users sont égaux s'ils ont le même ID."""
        if not isinstance(other, User):
            return False
        return self.id is not None and self.id == other.id


──────────────────────────────────────────────
FICHIER : domain/model/task.py
──────────────────────────────────────────────

# domain/model/task.py

from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from enum import Enum


class TaskStatus(Enum):
    PENDING     = 'pending'
    IN_PROGRESS = 'in_progress'
    DONE        = 'done'


class TaskPriority(Enum):
    LOW    = 'low'
    MEDIUM = 'medium'
    HIGH   = 'high'


class TaskError(Exception):
    """Erreurs spécifiques aux tâches."""
    pass


@dataclass
class Task:
    """Entité Task du domaine."""

    title: str
    user_id: int
    description: str = ''
    priority: TaskPriority = TaskPriority.MEDIUM
    status: TaskStatus = TaskStatus.PENDING
    id: Optional[int] = None
    created_at: datetime = field(default_factory=datetime.utcnow)
    due_date: Optional[datetime] = None

    def __post_init__(self):
        if not self.title or not self.title.strip():
            raise TaskError("Le titre est obligatoire")

    def start(self) -> None:
        if self.status != TaskStatus.PENDING:
            raise TaskError(f"Impossible de démarrer : statut actuel '{self.status.value}'")
        self.status = TaskStatus.IN_PROGRESS

    def complete(self) -> None:
        if self.status == TaskStatus.DONE:
            raise TaskError("Tâche déjà terminée")
        self.status = TaskStatus.DONE

    def change_priority(self, new_priority: TaskPriority) -> None:
        if self.status == TaskStatus.DONE:
            raise TaskError("Impossible de changer la priorité d'une tâche terminée")
        self.priority = new_priority

    def is_overdue(self) -> bool:
        return (
            self.due_date is not None
            and self.status != TaskStatus.DONE
            and datetime.utcnow() > self.due_date
        )

    def to_dict(self) -> dict:
        return {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'priority': self.priority.value,
            'status': self.status.value,
            'user_id': self.user_id,
            'created_at': self.created_at.isoformat(),
            'due_date': self.due_date.isoformat() if self.due_date else None,
            'is_overdue': self.is_overdue()
        }


──────────────────────────────────────────────
FICHIER : domain/ports/outgoing/user_repository_port.py
──────────────────────────────────────────────

# domain/ports/outgoing/user_repository_port.py
# PORT SORTANT : Ce que le domaine a besoin de la persistance.
# Le domaine définit CE dont il a besoin, pas COMMENT c'est fait.

from abc import ABC, abstractmethod
from typing import Optional, List
from domain.model.user import User


class UserRepositoryPort(ABC):
    """
    Port SORTANT pour la persistence des Users.
    
    "Sortant" signifie que le domaine SORT vers ce port pour persister.
    L'adapter sortant (SQLAlchemy, InMemory) implémente ce port.
    
    C'est le CONTRAT que le domaine impose à toute implémentation de persistance.
    """

    @abstractmethod
    def save(self, user: User) -> User:
        """Persiste un utilisateur. Retourne l'entité avec ID généré."""
        ...

    @abstractmethod
    def find_by_id(self, user_id: int) -> Optional[User]:
        """Trouve par ID. None si inexistant."""
        ...

    @abstractmethod
    def find_by_email(self, email: str) -> Optional[User]:
        """Trouve par email. None si inexistant."""
        ...

    @abstractmethod
    def find_all_active(self) -> List[User]:
        """Liste tous les utilisateurs actifs."""
        ...

    @abstractmethod
    def delete(self, user_id: int) -> bool:
        """Supprime. True si supprimé, False si non trouvé."""
        ...

    @abstractmethod
    def email_exists(self, email: str) -> bool:
        """Vérifie l'unicité de l'email."""
        ...


──────────────────────────────────────────────
FICHIER : domain/ports/outgoing/notification_port.py
──────────────────────────────────────────────

# domain/ports/outgoing/notification_port.py
# PORT SORTANT : Envoi de notifications.
# Le domaine veut envoyer des notifications, mais ne sait pas comment.

from abc import ABC, abstractmethod
from domain.model.user import User
from domain.model.task import Task


class NotificationPort(ABC):
    """
    Port SORTANT pour l'envoi de notifications.
    
    Le domaine appelle ce port pour notifier.
    Peu importe SI c'est par email, SMS, Slack... (c'est l'adapter qui décide).
    """

    @abstractmethod
    def notify_user_created(self, user: User) -> None:
        """Notifie qu'un utilisateur vient d'être créé."""
        ...

    @abstractmethod
    def notify_task_completed(self, user: User, task: Task) -> None:
        """Notifie qu'une tâche est terminée."""
        ...

    @abstractmethod
    def notify_task_overdue(self, user: User, task: Task) -> None:
        """Alerte qu'une tâche est en retard."""
        ...


──────────────────────────────────────────────
FICHIER : domain/ports/incoming/user_service_port.py
──────────────────────────────────────────────

# domain/ports/incoming/user_service_port.py
# PORT ENTRANT : Interface que le domaine expose au monde extérieur.
# L'adapter entrant (Flask, CLI, Test) appelle ces méthodes.

from abc import ABC, abstractmethod
from typing import List, Optional
from domain.model.user import User


class UserServicePort(ABC):
    """
    Port ENTRANT pour les opérations sur les utilisateurs.
    
    "Entrant" signifie que les appels ENTRENT dans le domaine via ce port.
    Flask appelle ces méthodes, sans savoir comment elles sont implémentées.
    
    C'est l'API PUBLIC du domaine.
    """

    @abstractmethod
    def create_user(self, username: str, email: str, password: str) -> User:
        """Crée un utilisateur. Retourne l'entité créée."""
        ...

    @abstractmethod
    def get_user(self, user_id: int) -> User:
        """Récupère un utilisateur. Lève ValueError si inexistant."""
        ...

    @abstractmethod
    def get_all_users(self) -> List[User]:
        """Liste tous les utilisateurs actifs."""
        ...

    @abstractmethod
    def update_user(self, user_id: int, **fields) -> User:
        """Met à jour des champs d'un utilisateur."""
        ...

    @abstractmethod
    def deactivate_user(self, user_id: int) -> None:
        """Désactive un utilisateur."""
        ...

    @abstractmethod
    def authenticate(self, email: str, password: str) -> User:
        """Authentifie. Lève ValueError si credentials invalides."""
        ...


════════════════════════════════════════════════════════════
DOMAINE : SERVICES (IMPLÉMENTENT LES PORTS ENTRANTS)
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : domain/services/user_domain_service.py
──────────────────────────────────────────────

# domain/services/user_domain_service.py
# Service du domaine qui IMPLÉMENTE le Port entrant UserServicePort.
# Il utilise les Ports sortants (repositories, notifications) via injection.

import hashlib
from typing import List
from domain.model.user import User, UserValidationError
from domain.ports.incoming.user_service_port import UserServicePort
from domain.ports.outgoing.user_repository_port import UserRepositoryPort
from domain.ports.outgoing.notification_port import NotificationPort


class UserDomainService(UserServicePort):
    """
    Service du domaine pour les utilisateurs.
    
    IMPLÉMENTE : UserServicePort (port entrant)
    UTILISE     : UserRepositoryPort + NotificationPort (ports sortants)
    
    Ce service ORCHESTRE les règles métier.
    Il ne connaît pas Flask, SQLAlchemy, SMTP.
    Il parle UNIQUEMENT en termes de ports (interfaces).
    """

    MIN_PASSWORD_LENGTH = 6
    MAX_USERNAME_LENGTH = 30

    def __init__(
        self,
        user_repo: UserRepositoryPort,          # Port sortant injecté
        notification: NotificationPort          # Port sortant injecté
    ):
        """
        INJECTION DE DÉPENDANCES via les interfaces (ports).
        
        Le service ne sait pas si user_repo est SQLAlchemy, MongoDB, InMemory...
        Il sait juste que ça respecte UserRepositoryPort.
        Même chose pour notification (email ? SMS ? log ?).
        """
        self._user_repo = user_repo
        self._notification = notification

    def create_user(self, username: str, email: str, password: str) -> User:
        """
        RÈGLES MÉTIER :
        1. Mot de passe assez long
        2. Email unique
        3. Hash du mot de passe
        4. Notification après création
        """
        # Règle 1 : longueur mot de passe
        if len(password) < self.MIN_PASSWORD_LENGTH:
            raise UserValidationError(
                f"Mot de passe trop court (minimum {self.MIN_PASSWORD_LENGTH} caractères)"
            )

        # Règle 2 : email unique
        if self._user_repo.email_exists(email):
            raise UserValidationError(f"Email '{email}' déjà utilisé")

        # Règle 3 : hash du mot de passe
        hashed = self._hash_password(password)

        # Création de l'entité (la validation username/email est dans User.__post_init__)
        new_user = User(username=username, email=email, password_hash=hashed)

        # Persistance via le port sortant
        saved_user = self._user_repo.save(new_user)

        # Notification via le port sortant (email de bienvenue, etc.)
        self._notification.notify_user_created(saved_user)

        return saved_user

    def get_user(self, user_id: int) -> User:
        """Récupère un utilisateur actif."""
        user = self._user_repo.find_by_id(user_id)
        if not user:
            raise ValueError(f"Utilisateur {user_id} introuvable")
        if not user.is_active:
            raise ValueError(f"Compte {user_id} désactivé")
        return user

    def get_all_users(self) -> List[User]:
        """Liste tous les utilisateurs actifs."""
        return self._user_repo.find_all_active()

    def update_user(self, user_id: int, **fields) -> User:
        """Met à jour les champs fournis."""
        user = self.get_user(user_id)

        if 'email' in fields and fields['email'] != user.email:
            if self._user_repo.email_exists(fields['email']):
                raise UserValidationError("Email déjà utilisé")
            user.email = fields['email']

        if 'username' in fields:
            user.username = fields['username']

        if 'password' in fields:
            if len(fields['password']) < self.MIN_PASSWORD_LENGTH:
                raise UserValidationError("Mot de passe trop court")
            user.password_hash = self._hash_password(fields['password'])

        return self._user_repo.save(user)

    def deactivate_user(self, user_id: int) -> None:
        """Désactive un compte (soft delete)."""
        user = self.get_user(user_id)
        user.deactivate()  # Méthode de l'entité (règle métier dans l'entité)
        self._user_repo.save(user)

    def authenticate(self, email: str, password: str) -> User:
        """Authentifie un utilisateur."""
        user = self._user_repo.find_by_email(email)

        if not user:
            # Même message pour email/password invalide (sécurité)
            raise UserValidationError("Credentials invalides")

        if not user.is_active:
            raise UserValidationError("Compte désactivé")

        if user.password_hash != self._hash_password(password):
            raise UserValidationError("Credentials invalides")

        return user

    def _hash_password(self, password: str) -> str:
        """Hash du mot de passe. Logique métier de sécurité."""
        return hashlib.sha256(password.encode()).hexdigest()


════════════════════════════════════════════════════════════
ADAPTERS SORTANTS (DRIVEN ADAPTERS)
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : adapters/outgoing/persistence/sqlalchemy_user_repo.py
──────────────────────────────────────────────

# adapters/outgoing/persistence/sqlalchemy_user_repo.py
# Adapter SORTANT SQLAlchemy : implémente UserRepositoryPort avec SQLAlchemy.
# Le domaine ne connaît pas ce fichier. Il ne connaît que UserRepositoryPort.

from typing import Optional, List
from domain.model.user import User
from domain.ports.outgoing.user_repository_port import UserRepositoryPort
from adapters.outgoing.persistence.sqlalchemy_models import UserModel, db


class SQLAlchemyUserRepository(UserRepositoryPort):
    """
    Adapter SQLAlchemy pour la persistence des Users.
    Implémente toutes les méthodes de UserRepositoryPort avec SQLAlchemy.
    """

    def save(self, user: User) -> User:
        """Sauvegarde (INSERT ou UPDATE)."""
        if user.id:
            model = UserModel.query.get(user.id)
            model.username      = user.username
            model.email         = user.email
            model.password_hash = user.password_hash
            model.is_active     = user.is_active
        else:
            model = UserModel(
                username=user.username,
                email=user.email,
                password_hash=user.password_hash
            )
            db.session.add(model)

        db.session.commit()
        db.session.refresh(model)
        return self._to_domain(model)

    def find_by_id(self, user_id: int) -> Optional[User]:
        model = UserModel.query.get(user_id)
        return self._to_domain(model) if model else None

    def find_by_email(self, email: str) -> Optional[User]:
        model = UserModel.query.filter_by(email=email).first()
        return self._to_domain(model) if model else None

    def find_all_active(self) -> List[User]:
        models = UserModel.query.filter_by(is_active=True).all()
        return [self._to_domain(m) for m in models]

    def delete(self, user_id: int) -> bool:
        model = UserModel.query.get(user_id)
        if not model:
            return False
        db.session.delete(model)
        db.session.commit()
        return True

    def email_exists(self, email: str) -> bool:
        return UserModel.query.filter_by(email=email).count() > 0

    def _to_domain(self, model: UserModel) -> User:
        """Mapping : SQLAlchemy Model -> Domain Entity."""
        return User(
            id=model.id,
            username=model.username,
            email=model.email,
            password_hash=model.password_hash,
            created_at=model.created_at,
            is_active=model.is_active
        )


──────────────────────────────────────────────
FICHIER : adapters/outgoing/persistence/in_memory_user_repo.py
──────────────────────────────────────────────

# adapters/outgoing/persistence/in_memory_user_repo.py
# Adapter SORTANT InMemory : pour les tests. Aucune vraie DB.
# Implémente la même interface que SQLAlchemyUserRepository.

from typing import Optional, List, Dict
from domain.model.user import User
from domain.ports.outgoing.user_repository_port import UserRepositoryPort


class InMemoryUserRepository(UserRepositoryPort):
    """
    Adapter in-memory pour les tests.
    
    Stocke les données dans un dictionnaire Python.
    Aucune DB, aucune configuration, ultra-rapide.
    
    Utilisé dans les tests au lieu de SQLAlchemyUserRepository.
    Le domaine ne voit aucune différence (même interface !).
    """

    def __init__(self):
        self._store: Dict[int, User] = {}
        self._next_id: int = 1

    def save(self, user: User) -> User:
        if user.id is None:
            user.id = self._next_id
            self._next_id += 1
        self._store[user.id] = user
        return user

    def find_by_id(self, user_id: int) -> Optional[User]:
        return self._store.get(user_id)

    def find_by_email(self, email: str) -> Optional[User]:
        return next(
            (u for u in self._store.values() if u.email == email),
            None
        )

    def find_all_active(self) -> List[User]:
        return [u for u in self._store.values() if u.is_active]

    def delete(self, user_id: int) -> bool:
        if user_id in self._store:
            del self._store[user_id]
            return True
        return False

    def email_exists(self, email: str) -> bool:
        return self.find_by_email(email) is not None

    def clear(self):
        """Utilitaire de test : vide le store."""
        self._store.clear()
        self._next_id = 1


──────────────────────────────────────────────
FICHIER : adapters/outgoing/notification/email_adapter.py
──────────────────────────────────────────────

# adapters/outgoing/notification/email_adapter.py
# Adapter SORTANT Email : implémente NotificationPort via SMTP.

import smtplib
from email.mime.text import MIMEText
from domain.model.user import User
from domain.model.task import Task
from domain.ports.outgoing.notification_port import NotificationPort


class SmtpNotificationAdapter(NotificationPort):
    """
    Adapter email réel utilisant SMTP.
    En production, on utiliserait flask-mail ou sendgrid.
    """

    def __init__(self, smtp_host: str, smtp_port: int, sender: str):
        self.smtp_host = smtp_host
        self.smtp_port = smtp_port
        self.sender    = sender

    def notify_user_created(self, user: User) -> None:
        """Envoie un email de bienvenue."""
        self._send_email(
            to=user.email,
            subject="Bienvenue !",
            body=f"Bonjour {user.username}, votre compte a été créé."
        )

    def notify_task_completed(self, user: User, task: Task) -> None:
        """Notifie la complétion d'une tâche."""
        self._send_email(
            to=user.email,
            subject=f"Tâche terminée : {task.title}",
            body=f"Félicitations ! La tâche '{task.title}' est terminée."
        )

    def notify_task_overdue(self, user: User, task: Task) -> None:
        """Alerte de retard."""
        self._send_email(
            to=user.email,
            subject=f"Tâche en retard : {task.title}",
            body=f"La tâche '{task.title}' est en retard !"
        )

    def _send_email(self, to: str, subject: str, body: str) -> None:
        """Envoi réel via SMTP."""
        msg = MIMEText(body)
        msg['Subject'] = subject
        msg['From']    = self.sender
        msg['To']      = to

        with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
            server.send_message(msg)


──────────────────────────────────────────────
FICHIER : adapters/outgoing/notification/fake_email_adapter.py
──────────────────────────────────────────────

# adapters/outgoing/notification/fake_email_adapter.py
# Adapter SORTANT FAKE : pour les tests. Ne fait que loguer.

from domain.model.user import User
from domain.model.task import Task
from domain.ports.outgoing.notification_port import NotificationPort
from typing import List


class FakeNotificationAdapter(NotificationPort):
    """
    Adapter de notification FAKE pour les tests.
    
    Au lieu d'envoyer de vrais emails, stocke les notifications envoyées.
    Permet d'ASSERTER dans les tests que les bonnes notifications ont été envoyées.
    """

    def __init__(self):
        # Historique des notifications envoyées (pour les assertions de test)
        self.sent_notifications: List[dict] = []

    def notify_user_created(self, user: User) -> None:
        self.sent_notifications.append({
            'type': 'user_created',
            'user_email': user.email,
            'user_id': user.id
        })
        print(f"[FAKE EMAIL] User créé : {user.email}")

    def notify_task_completed(self, user: User, task: Task) -> None:
        self.sent_notifications.append({
            'type': 'task_completed',
            'user_email': user.email,
            'task_id': task.id,
            'task_title': task.title
        })
        print(f"[FAKE EMAIL] Tâche terminée : {task.title} -> {user.email}")

    def notify_task_overdue(self, user: User, task: Task) -> None:
        self.sent_notifications.append({
            'type': 'task_overdue',
            'user_email': user.email,
            'task_title': task.title
        })

    def was_notified(self, notification_type: str) -> bool:
        """Vérifie si une notification d'un certain type a été envoyée."""
        return any(n['type'] == notification_type for n in self.sent_notifications)

    def clear(self):
        """Réinitialise l'historique (entre les tests)."""
        self.sent_notifications.clear()


════════════════════════════════════════════════════════════
ADAPTERS ENTRANTS (DRIVING ADAPTERS)
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : adapters/incoming/http/user_controller.py
──────────────────────────────────────────────

# adapters/incoming/http/user_controller.py
# Adapter ENTRANT HTTP (Flask).
# Traduit les requêtes HTTP en appels sur le port entrant (UserServicePort).

from flask import Blueprint, request, jsonify
from domain.ports.incoming.user_service_port import UserServicePort
from domain.model.user import UserValidationError

user_bp = Blueprint('users', __name__, url_prefix='/api/users')


class UserHttpAdapter:
    """
    Adapter HTTP pour les opérations utilisateur.
    
    Reçoit les requêtes Flask et les traduit en appels sur UserServicePort.
    
    PATTERN : On injecte le service (port entrant) dans le constructeur.
    Permet de tester l'adapter avec un service mocké.
    """

    def __init__(self, user_service: UserServicePort):
        """
        Injection du service (port entrant).
        L'adapter ne crée pas le service, il le reçoit.
        """
        self.user_service = user_service
        self._register_routes()

    def _register_routes(self):
        """Enregistre les routes Flask sur le blueprint."""

        @user_bp.route('/', methods=['GET'])
        def list_users():
            users = self.user_service.get_all_users()
            return jsonify([u.to_dict() for u in users]), 200

        @user_bp.route('/<int:user_id>', methods=['GET'])
        def get_user(user_id):
            try:
                user = self.user_service.get_user(user_id)
                return jsonify(user.to_dict()), 200
            except ValueError as e:
                return jsonify({'error': str(e)}), 404

        @user_bp.route('/', methods=['POST'])
        def create_user():
            data = request.get_json()
            if not data:
                return jsonify({'error': 'Corps JSON requis'}), 400

            for field in ['username', 'email', 'password']:
                if field not in data:
                    return jsonify({'error': f'{field} requis'}), 400

            try:
                user = self.user_service.create_user(
                    username=data['username'],
                    email=data['email'],
                    password=data['password']
                )
                return jsonify(user.to_dict()), 201

            except UserValidationError as e:
                status = 409 if 'déjà utilisé' in str(e) else 400
                return jsonify({'error': str(e)}), status

        @user_bp.route('/<int:user_id>', methods=['PUT'])
        def update_user(user_id):
            data = request.get_json()
            if not data:
                return jsonify({'error': 'Corps JSON requis'}), 400
            try:
                user = self.user_service.update_user(user_id, **data)
                return jsonify(user.to_dict()), 200
            except (ValueError, UserValidationError) as e:
                return jsonify({'error': str(e)}), 400

        @user_bp.route('/<int:user_id>/deactivate', methods=['POST'])
        def deactivate_user(user_id):
            try:
                self.user_service.deactivate_user(user_id)
                return jsonify({'message': 'Compte désactivé'}), 200
            except (ValueError, UserValidationError) as e:
                return jsonify({'error': str(e)}), 400

        @user_bp.route('/authenticate', methods=['POST'])
        def authenticate():
            data = request.get_json()
            if not data or 'email' not in data or 'password' not in data:
                return jsonify({'error': 'Email et password requis'}), 400
            try:
                user = self.user_service.authenticate(data['email'], data['password'])
                return jsonify({'user': user.to_dict(), 'authenticated': True}), 200
            except UserValidationError as e:
                return jsonify({'error': str(e)}), 401


──────────────────────────────────────────────
FICHIER : adapters/incoming/cli/cli_commands.py
──────────────────────────────────────────────

# adapters/incoming/cli/cli_commands.py
# Adapter ENTRANT CLI : interface ligne de commande.
# Appelle les MÊMES Use Cases que l'API HTTP.
# Le domaine ne sait pas si c'est Flask ou un CLI qui l'appelle.

import click  # pip install click
from domain.ports.incoming.user_service_port import UserServicePort


class UserCliAdapter:
    """
    Adapter CLI pour les opérations utilisateur.
    Utilise le même UserServicePort que l'adapter HTTP.
    """

    def __init__(self, user_service: UserServicePort):
        self.user_service = user_service

    def register_commands(self, app):
        """Enregistre les commandes Flask CLI."""

        @app.cli.command('create-user')
        @click.argument('username')
        @click.argument('email')
        @click.password_option()  # Demande le mot de passe de façon sécurisée
        def create_user(username, email, password):
            """Crée un utilisateur via la CLI."""
            try:
                user = self.user_service.create_user(username, email, password)
                click.echo(f"[OK] Utilisateur créé : {user.username} (ID: {user.id})")
            except Exception as e:
                click.echo(f"[X] Erreur : {e}", err=True)

        @app.cli.command('list-users')
        def list_users():
            """Liste tous les utilisateurs actifs."""
            users = self.user_service.get_all_users()
            if not users:
                click.echo("Aucun utilisateur")
                return
            for user in users:
                status = "[OK]" if user.is_active else "[X]"
                click.echo(f"{status} [{user.id}] {user.username} ({user.email})")


════════════════════════════════════════════════════════════
ASSEMBLAGE : APP.PY (COMPOSITION ROOT)
════════════════════════════════════════════════════════════

──────────────────────────────────────────────
FICHIER : app.py
──────────────────────────────────────────────

# app.py
# La "Composition Root" : le seul endroit où tout est assemblé.
# C'est ici qu'on branche les adapters sur les ports.
# C'est le seul fichier qui connaît TOUS les adapters et les ports.

from flask import Flask
import os

# Import des adapters SORTANTS (driven)
from adapters.outgoing.persistence.sqlalchemy_models import db
from adapters.outgoing.persistence.sqlalchemy_user_repo import SQLAlchemyUserRepository
from adapters.outgoing.notification.email_adapter import SmtpNotificationAdapter
from adapters.outgoing.notification.fake_email_adapter import FakeNotificationAdapter

# Import des services du domaine
from domain.services.user_domain_service import UserDomainService

# Import des adapters ENTRANTS (driving)
from adapters.incoming.http.user_controller import UserHttpAdapter, user_bp
from adapters.incoming.cli.cli_commands import UserCliAdapter


def create_app(use_fake_email=False):
    """
    Composition Root : assemble tous les composants.
    
    BRANCHER les Adapters sur les Ports :
    - Adapter SQLAlchemy -> Port Repository
    - Adapter Email      -> Port Notification
    - Service Domaine    -> Ports Entrants
    - Adapter Flask      -> Service Domaine
    """

    app = Flask(__name__)

    # Configuration
    app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DB_URL', 'sqlite:///app.db')
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

    # ── INITIALISATION DB ─────────────────────────────────────
    db.init_app(app)

    with app.app_context():
        db.create_all()

    # ── CRÉATION DES ADAPTERS SORTANTS ────────────────────────
    user_repo = SQLAlchemyUserRepository()

    # En développement/test : faux email pour ne pas envoyer de vrais mails
    if use_fake_email or os.environ.get('FLASK_ENV') == 'development':
        notification = FakeNotificationAdapter()
    else:
        notification = SmtpNotificationAdapter(
            smtp_host=os.environ.get('SMTP_HOST', 'localhost'),
            smtp_port=int(os.environ.get('SMTP_PORT', '587')),
            sender=os.environ.get('SMTP_SENDER', 'noreply@example.com')
        )

    # ── CRÉATION DU SERVICE DOMAINE ───────────────────────────
    # Le service domaine reçoit les adapters sortants injectés
    user_service = UserDomainService(
        user_repo=user_repo,
        notification=notification
    )

    # ── CRÉATION DES ADAPTERS ENTRANTS ────────────────────────
    # L'adapter HTTP reçoit le service domaine injecté
    http_adapter = UserHttpAdapter(user_service=user_service)

    # L'adapter CLI reçoit le même service domaine
    cli_adapter = UserCliAdapter(user_service=user_service)

    # ── ENREGISTREMENT ───────────────────────────────────────
    app.register_blueprint(user_bp)
    cli_adapter.register_commands(app)

    return app


if __name__ == '__main__':
    app = create_app(use_fake_email=True)
    app.run(debug=True)

================================================================================
4. TESTS HEXAGONAUX
================================================================================

──────────────────────────────────────────────
FICHIER : tests/test_domain_services.py
──────────────────────────────────────────────

# tests/test_domain_services.py
# Tests du DOMAINE seul : aucun Flask, aucune SQLAlchemy, aucun SMTP.
# Utilise les adapters InMemory et Fake.

import pytest
from domain.services.user_domain_service import UserDomainService
from domain.model.user import UserValidationError
from adapters.outgoing.persistence.in_memory_user_repo import InMemoryUserRepository
from adapters.outgoing.notification.fake_email_adapter import FakeNotificationAdapter


@pytest.fixture
def user_repo():
    return InMemoryUserRepository()


@pytest.fixture
def notification():
    return FakeNotificationAdapter()


@pytest.fixture
def user_service(user_repo, notification):
    return UserDomainService(user_repo=user_repo, notification=notification)


def test_create_user_sends_welcome_notification(user_service, notification):
    """
    Test : la notification de bienvenue est envoyée après création.
    
    Sans architecture hexagonale, tester ça nécessiterait un vrai serveur SMTP.
    Avec l'architecture hexagonale, on utilise le FakeNotificationAdapter
    et on vérifie que la méthode a été appelée.
    """
    user_service.create_user('alice', 'alice@test.com', 'motdepasse123')

    # Vérification que la notification a été envoyée
    assert notification.was_notified('user_created')
    notif = notification.sent_notifications[0]
    assert notif['user_email'] == 'alice@test.com'


def test_create_user_email_uniqueness(user_service):
    """Règle métier : email unique."""
    user_service.create_user('alice', 'alice@test.com', 'motdepasse123')

    with pytest.raises(UserValidationError, match="déjà utilisé"):
        user_service.create_user('alice2', 'alice@test.com', 'autremdp')


def test_deactivate_user(user_service, user_repo):
    """Test désactivation de compte."""
    user = user_service.create_user('alice', 'alice@test.com', 'motdepasse123')

    user_service.deactivate_user(user.id)

    # Vérifier en récupérant depuis le repo
    saved_user = user_repo.find_by_id(user.id)
    assert not saved_user.is_active


def test_authenticate_success(user_service):
    """Test authentification réussie."""
    user_service.create_user('alice', 'alice@test.com', 'motdepasse123')

    authenticated = user_service.authenticate('alice@test.com', 'motdepasse123')
    assert authenticated.username == 'alice'


def test_authenticate_wrong_password(user_service):
    """Test mauvais mot de passe."""
    user_service.create_user('alice', 'alice@test.com', 'motdepasse123')

    with pytest.raises(UserValidationError, match="invalides"):
        user_service.authenticate('alice@test.com', 'mauvaismdp')


def test_invalid_email_in_entity():
    """Test que l'entité rejette un email invalide."""
    with pytest.raises(UserValidationError, match="invalide"):
        from domain.model.user import User
        User(username='alice', email='pasunemail', password_hash='hash')

================================================================================
5. COMPARAISON HEXAGONALE vs CLEAN ARCHITECTURE
================================================================================

  ASPECT              CLEAN ARCHITECTURE       HEXAGONALE
  ──────────────────────────────────────────────────────────────────
  Visualisation       Cercles concentriques    Hexagone avec ports
  Ports explicites    Non (implicites)         Oui (ports in/out)
  Naming              Use Cases, Entities      Ports, Adapters
  Symétrie            Couches                  Entrée/Sortie symétriques
  Flexibilité         Très bonne               Excellente
  Complexité          Élevée                   Très élevée
  Concept clé         Règle de dépendance      Isolation via ports

  -> En pratique, les deux sont très similaires et souvent confondus.
  -> On peut les combiner : Clean Architecture + terminologie Ports & Adapters.

================================================================================
6. BONNES PRATIQUES HEXAGONALE
================================================================================

  [OK] PORTS DANS LE DOMAINE, ADAPTERS À L'EXTÉRIEUR
  ──────────────────────────────────────────────────
  Les interfaces (ports) vivent dans domain/ports/
  Les implémentations (adapters) vivent dans adapters/

  [OK] UN ADAPTER PAR TECHNOLOGIE
  ──────────────────────────────
  SQLAlchemyUserRepository  = adapter pour SQLAlchemy
  InMemoryUserRepository    = adapter pour les tests
  MongoUserRepository       = adapter futur pour MongoDB

  [OK] COMPOSITION ROOT UNIQUE
  ───────────────────────────
  Un seul endroit où tout est assemblé (app.py).
  Nulle part ailleurs on ne crée des adapters concrets.

  [OK] FAKE ADAPTERS POUR CHAQUE PORT SORTANT
  ──────────────────────────────────────────
  Toujours créer un Fake adapter pour les tests.
  FakeNotificationAdapter, InMemoryUserRepository, etc.

================================================================================
RÉSUMÉ
================================================================================

  L'architecture hexagonale = domaine isolé par des ports standardisés.
  
  Ports ENTRANTS  : comment l'extérieur appelle le domaine (UserServicePort)
  Ports SORTANTS  : ce dont le domaine a besoin (UserRepositoryPort, NotificationPort)
  
  Adapters ENTRANTS  : Flask, CLI, Tests (appellent les ports entrants)
  Adapters SORTANTS  : SQLAlchemy, Email, Fake (implémentent les ports sortants)
  
  Composition Root (app.py) : branche les adapters sur les ports.
  
  Super-pouvoir : tester TOUT le domaine sans base de données ni HTTP.
  
  Prochain fichier : architecture_event_driven.txt

================================================================================
FIN DU FICHIER architecture_hexagonale.txt
================================================================================

================================================================================
         ARCHITECTURE EVENT-DRIVEN (Orientée Événements)
         Guide complet avec exemples Flask + Pub/Sub
================================================================================

================================================================================
1. INTRODUCTION PÉDAGOGIQUE
================================================================================

DÉFINITION :
────────────
Une architecture orientée événements est un style où les composants
communiquent en produisant et en consommant des ÉVÉNEMENTS.
Un événement = quelque chose qui s'est passé dans le système.

ANALOGIE : LE JOURNAL DE BORD D'UN NAVIRE
──────────────────────────────────────────
  Quand quelque chose se passe sur le navire, le capitaine l'écrit dans le journal :
    "10:00 - Le navire a quitté le port de Marseille"
    "12:30 - Tempête détectée, changement de cap"
    "14:00 - Tempête évitée, nouveau cap confirmé"
  
  D'autres systèmes (météo, port de destination, armateurs) peuvent LIRE ces
  événements et réagir chacun à leur façon.
  
  Le capitaine n'a pas besoin de contacter chacun individuellement.
  Il écrit l'événement. Les autres s'en chargent.

CONCEPTS CLÉS :
───────────────

  ÉVÉNEMENT : Fait passé immuable (quelque chose S'EST PASSÉ)
  ──────────────────────────────────────────────────────────
  "UserCreated"   -> Un utilisateur vient d'être créé
  "TaskCompleted" -> Une tâche vient d'être terminée
  "OrderPlaced"   -> Une commande vient d'être passée
  
  Un événement est immuable : il décrit le passé, on ne le modifie pas.

  PUBLISHER (Émetteur) : Composant qui publie un événement
  ────────────────────────────────────────────────────────
  Le service Users publie "UserCreated" quand un user est créé.
  Il ne sait PAS qui va recevoir cet événement.

  SUBSCRIBER (Abonné) : Composant qui écoute des événements
  ─────────────────────────────────────────────────────────
  Le service Email s'abonne à "UserCreated" pour envoyer un email de bienvenue.
  Le service Analytics s'abonne aussi à "UserCreated" pour ses statistiques.
  
  Le Publisher ne connaît pas les Subscribers -> découplage total.

  EVENT BUS / MESSAGE BROKER : Le canal de communication
  ──────────────────────────────────────────────────────
  Redis Pub/Sub, Kafka, RabbitMQ, AWS SNS/SQS
  Reçoit les événements des publishers et les distribue aux subscribers.

FLUX D'UN ÉVÉNEMENT :
─────────────────────

  Service Users                Event Bus              Subscribers
       │                           │                       │
       │──"UserCreated"──[BLACK_RIGHT-POINTING_TRIANGLE]         │──[BLACK_RIGHT-POINTING_TRIANGLE] Email Service      │
       │   {id:1, email:...}       │──[BLACK_RIGHT-POINTING_TRIANGLE] Analytics Service  │
       │                           │──[BLACK_RIGHT-POINTING_TRIANGLE] Audit Service      │
       │                           │                       │

AVANTAGES :
───────────
  [OK] Découplage total entre producteurs et consommateurs
  [OK] Scalabilité : plusieurs consumers en parallèle
  [OK] Résilience : si un consumer est down, les events s'accumulent
  [OK] Extensibilité : ajouter un nouveau consumer sans modifier le publisher
  [OK] Traçabilité : historique complet de ce qui s'est passé
  [OK] Performance : traitement asynchrone

LIMITES :
─────────
  [X] Complexité : difficile à débugger (flux indirect)
  [X] Consistance éventuelle (eventual consistency)
  [X] Ordre des événements pas garanti (selon le broker)
  [X] Gestion des erreurs plus complexe
  [X] Overhead du message broker

================================================================================
2. TYPES D'ARCHITECTURES EVENT-DRIVEN
================================================================================

  TYPE 1 : PUB/SUB (Publish-Subscribe)
  ──────────────────────────────────────
  Publisher -> Topic -> Multiple Subscribers
  
  Ex: "UserCreated" publié -> Email + Analytics + Audit reçoivent
  
  Outils : Redis Pub/Sub, Google Pub/Sub, AWS SNS

  TYPE 2 : MESSAGE QUEUE
  ───────────────────────
  Publisher -> Queue -> UN seul Consumer (round-robin)
  
  Ex: Traitement d'emails dans une queue -> un seul worker traite chaque email
  
  Outils : RabbitMQ, AWS SQS, Celery + Redis

  TYPE 3 : EVENT STREAMING
  ─────────────────────────
  Events persistés dans un log -> multiples consumers lisent à leur rythme
  
  Ex: Kafka -> chaque consumer lit les events depuis son propre curseur
  
  Outils : Apache Kafka, AWS Kinesis

================================================================================
3. IMPLÉMENTATION COMPLÈTE
================================================================================

  PROJET : Système de gestion de tâches avec notifications asynchrones
  
  Structure :
  ──────────
  event_driven_todo/
  ├── app.py
  ├── config.py
  │
  ├── events/                       <- Définitions des événements
  │   ├── __init__.py
  │   ├── base_event.py             <- Classe de base
  │   ├── user_events.py            <- Événements liés aux users
  │   └── task_events.py            <- Événements liés aux tâches
  │
  ├── event_bus/                    <- Bus d'événements
  │   ├── __init__.py
  │   ├── event_bus.py              <- Bus en mémoire (dev/test)
  │   └── redis_event_bus.py        <- Bus Redis (production)
  │
  ├── publishers/                   <- Composants qui émettent des événements
  │   ├── user_publisher.py
  │   └── task_publisher.py
  │
  ├── subscribers/                  <- Composants qui écoutent des événements
  │   ├── email_subscriber.py       <- Envoie des emails
  │   ├── analytics_subscriber.py   <- Met à jour les stats
  │   └── audit_subscriber.py       <- Journal d'audit
  │
  ├── models/
  │   ├── user.py
  │   └── task.py
  │
  └── routes/
      ├── user_routes.py
      └── task_routes.py

──────────────────────────────────────────────
FICHIER : events/base_event.py
──────────────────────────────────────────────

# events/base_event.py
# Classe de base pour tous les événements du système.
# Un événement est une VALEUR IMMUABLE décrivant ce qui s'est passé.

from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict
import uuid  # Pour générer des IDs uniques


@dataclass(frozen=True)  # frozen=True = immuable (comme un événement doit l'être)
class BaseEvent:
    """
    Classe de base pour tous les événements.
    
    frozen=True signifie qu'on ne peut pas modifier l'objet après création.
    Un événement est IMMUABLE car il décrit le PASSÉ.
    
    Chaque événement a :
    - event_id  : identifiant unique (pour dédupliquer)
    - event_type: type de l'événement (pour le routing)
    - occurred_at: quand ça s'est passé
    - payload   : données de l'événement
    """

    # field(default_factory=...) : valeur calculée automatiquement
    event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    occurred_at: datetime = field(default_factory=datetime.utcnow)

    @property
    def event_type(self) -> str:
        """
        Le type de l'événement est le nom de la classe.
        Ex: UserCreatedEvent -> "UserCreatedEvent"
        
        Les subscribers s'abonnent par event_type.
        """
        return self.__class__.__name__

    def to_dict(self) -> Dict[str, Any]:
        """Sérialise l'événement pour le transport (JSON, Redis...)."""
        return {
            'event_id': self.event_id,
            'event_type': self.event_type,
            'occurred_at': self.occurred_at.isoformat(),
        }


──────────────────────────────────────────────
FICHIER : events/user_events.py
──────────────────────────────────────────────

# events/user_events.py
# Événements liés aux utilisateurs.
# Chaque classe = un type d'événement distinct.

from dataclasses import dataclass
from events.base_event import BaseEvent


@dataclass(frozen=True)
class UserCreatedEvent(BaseEvent):
    """
    Événement émis quand un utilisateur est créé.
    
    Contient les données MINIMALES nécessaires aux subscribers.
    On ne met pas le mot de passe (sécurité).
    On ne met pas trop de données (responsabilité : juste ce qui est pertinent).
    """
    user_id: int = 0
    username: str = ''
    email: str = ''

    def to_dict(self) -> dict:
        """Sérialisation complète incluant les données du user."""
        base = super().to_dict()
        base.update({
            'user_id': self.user_id,
            'username': self.username,
            'email': self.email
        })
        return base


@dataclass(frozen=True)
class UserDeactivatedEvent(BaseEvent):
    """Événement émis quand un compte est désactivé."""
    user_id: int = 0
    username: str = ''

    def to_dict(self) -> dict:
        base = super().to_dict()
        base.update({'user_id': self.user_id, 'username': self.username})
        return base


@dataclass(frozen=True)
class UserAuthenticatedEvent(BaseEvent):
    """Événement émis après une authentification réussie. Utile pour l'audit."""
    user_id: int = 0
    email: str = ''
    ip_address: str = ''

    def to_dict(self) -> dict:
        base = super().to_dict()
        base.update({
            'user_id': self.user_id,
            'email': self.email,
            'ip_address': self.ip_address
        })
        return base


──────────────────────────────────────────────
FICHIER : events/task_events.py
──────────────────────────────────────────────

# events/task_events.py

from dataclasses import dataclass
from events.base_event import BaseEvent


@dataclass(frozen=True)
class TaskCreatedEvent(BaseEvent):
    """Émis quand une tâche est créée."""
    task_id: int = 0
    title: str = ''
    user_id: int = 0
    priority: str = 'medium'

    def to_dict(self) -> dict:
        base = super().to_dict()
        base.update({
            'task_id': self.task_id,
            'title': self.title,
            'user_id': self.user_id,
            'priority': self.priority
        })
        return base


@dataclass(frozen=True)
class TaskCompletedEvent(BaseEvent):
    """Émis quand une tâche est marquée terminée."""
    task_id: int = 0
    title: str = ''
    user_id: int = 0
    user_email: str = ''

    def to_dict(self) -> dict:
        base = super().to_dict()
        base.update({
            'task_id': self.task_id,
            'title': self.title,
            'user_id': self.user_id,
            'user_email': self.user_email
        })
        return base


@dataclass(frozen=True)
class TaskOverdueEvent(BaseEvent):
    """Émis quand une tâche dépasse sa date d'échéance."""
    task_id: int = 0
    title: str = ''
    user_id: int = 0
    due_date: str = ''  # ISO format

    def to_dict(self) -> dict:
        base = super().to_dict()
        base.update({
            'task_id': self.task_id,
            'title': self.title,
            'user_id': self.user_id,
            'due_date': self.due_date
        })
        return base


──────────────────────────────────────────────
FICHIER : event_bus/event_bus.py
──────────────────────────────────────────────

# event_bus/event_bus.py
# Bus d'événements IN-MEMORY (pour développement et tests).
# En production, remplacer par Redis, Kafka, RabbitMQ.
#
# Le bus gère :
# - L'enregistrement des subscribers
# - La distribution des événements aux bons subscribers
# - L'exécution des handlers

from typing import Callable, Dict, List, Type
from events.base_event import BaseEvent
import logging
import traceback

# Logger pour tracer les événements
logger = logging.getLogger(__name__)


class EventBus:
    """
    Bus d'événements en mémoire (synchrone).
    
    PATTERN : Observer / Pub-Sub
    
    Les subscribers s'enregistrent pour un type d'événement.
    Quand un publisher publie un événement, le bus appelle tous les
    subscribers enregistrés pour ce type.
    
    SYNCHRONE vs ASYNCHRONE :
    Ce bus est synchrone : les handlers sont appelés dans le même thread.
    En production, on utiliserait un bus asynchrone (Celery + Redis).
    """

    def __init__(self):
        """
        _subscribers : dictionnaire {event_type: [handler1, handler2, ...]}
        Un handler est une fonction qui reçoit l'événement.
        """
        self._subscribers: Dict[str, List[Callable]] = {}

    def subscribe(self, event_type: Type[BaseEvent], handler: Callable) -> None:
        """
        Enregistre un subscriber pour un type d'événement.
        
        Args:
            event_type : La CLASSE de l'événement (ex: UserCreatedEvent)
            handler    : La fonction à appeler quand l'événement est publié
        
        Usage :
            bus.subscribe(UserCreatedEvent, email_service.on_user_created)
        """
        # Utilise le nom de la classe comme clé
        event_name = event_type.__name__

        # Crée la liste si elle n'existe pas encore
        if event_name not in self._subscribers:
            self._subscribers[event_name] = []

        # Ajoute le handler à la liste
        self._subscribers[event_name].append(handler)

        logger.debug(f"Subscriber enregistré : {handler.__name__} -> {event_name}")

    def unsubscribe(self, event_type: Type[BaseEvent], handler: Callable) -> None:
        """Retire un subscriber (utile pour les tests)."""
        event_name = event_type.__name__
        if event_name in self._subscribers:
            self._subscribers[event_name].remove(handler)

    def publish(self, event: BaseEvent) -> None:
        """
        Publie un événement -> appelle tous les subscribers enregistrés.
        
        Args:
            event: L'événement à publier
        
        Comportement :
        - Si aucun subscriber : log un warning (événement ignoré)
        - Si un handler lève une exception : log l'erreur, CONTINUE les autres
          (un subscriber en erreur ne doit pas bloquer les autres)
        """
        event_name = event.event_type
        handlers = self._subscribers.get(event_name, [])

        if not handlers:
            logger.warning(f"Aucun subscriber pour l'événement : {event_name}")
            return

        logger.info(f"Publication de {event_name} (ID: {event.event_id}) -> {len(handlers)} subscriber(s)")

        for handler in handlers:
            try:
                handler(event)  # Appel du handler avec l'événement
                logger.debug(f"Handler {handler.__name__} exécuté avec succès")
            except Exception as e:
                # IMPORTANT : on NE propagre PAS l'exception
                # Un handler en erreur ne doit pas casser les autres
                logger.error(
                    f"Erreur dans {handler.__name__} pour {event_name}: {e}\n"
                    f"{traceback.format_exc()}"
                )

    def clear_subscribers(self) -> None:
        """Vide tous les subscribers (utile pour réinitialiser entre les tests)."""
        self._subscribers.clear()

    def get_subscriber_count(self, event_type: Type[BaseEvent]) -> int:
        """Retourne le nombre de subscribers pour un type d'événement."""
        return len(self._subscribers.get(event_type.__name__, []))


# Instance globale partagée par toute l'application
# En production, utiliser une instance gérée par l'IoC container
event_bus = EventBus()


──────────────────────────────────────────────
FICHIER : event_bus/redis_event_bus.py
──────────────────────────────────────────────

# event_bus/redis_event_bus.py
# Bus d'événements basé sur Redis Pub/Sub.
# Permet la communication entre différents processus/serveurs.
# Utiliser en production.

import redis
import json
import threading
from typing import Callable, Dict, List, Type
from events.base_event import BaseEvent
import logging

logger = logging.getLogger(__name__)


class RedisEventBus:
    """
    Bus d'événements basé sur Redis Pub/Sub.
    
    Différences avec le bus in-memory :
    - ASYNCHRONE : les handlers s'exécutent dans un thread séparé
    - DISTRIBUÉ  : fonctionne entre plusieurs processus/machines
    - PERSISTÉ   : Redis stocke les messages temporairement
    
    Prérequis : pip install redis
    Redis doit tourner : redis-server (ou docker run -p 6379:6379 redis)
    """

    def __init__(self, host='localhost', port=6379, db=0):
        # Connexion Redis pour publier
        self._publisher = redis.Redis(host=host, port=port, db=db)
        # Connexion Redis pour s'abonner
        self._subscriber_conn = redis.Redis(host=host, port=port, db=db)

        # Handlers enregistrés localement (pour les subscribers de ce processus)
        self._local_handlers: Dict[str, List[Callable]] = {}

        # Thread d'écoute Redis (tourne en arrière-plan)
        self._listen_thread = None
        self._pubsub = None

    def subscribe(self, event_type: Type[BaseEvent], handler: Callable) -> None:
        """
        S'abonne à un événement.
        Enregistre le handler localement ET s'abonne au canal Redis.
        """
        event_name = event_type.__name__

        if event_name not in self._local_handlers:
            self._local_handlers[event_name] = []
            # S'abonner au canal Redis correspondant
            if self._pubsub is None:
                self._pubsub = self._subscriber_conn.pubsub()
            self._pubsub.subscribe(**{event_name: self._dispatch})

        self._local_handlers[event_name].append(handler)

    def publish(self, event: BaseEvent) -> None:
        """
        Publie un événement sur Redis.
        Tous les processus abonnés à ce canal recevront l'événement.
        """
        channel = event.event_type
        message = json.dumps(event.to_dict())

        # PUBLISH est la commande Redis pour envoyer sur un canal
        subscribers_count = self._publisher.publish(channel, message)
        logger.info(f"Publié {channel} -> {subscribers_count} subscriber(s)")

    def _dispatch(self, message: dict) -> None:
        """
        Callback Redis : appelé quand un message est reçu.
        Parse le JSON et appelle les handlers locaux.
        """
        if message['type'] != 'message':
            return  # Ignorer les messages de contrôle Redis

        channel = message['channel'].decode('utf-8')
        data = json.loads(message['data'])

        handlers = self._local_handlers.get(channel, [])
        for handler in handlers:
            try:
                handler(data)  # Passe le dict (pas l'objet événement)
            except Exception as e:
                logger.error(f"Erreur handler {handler.__name__}: {e}")

    def start_listening(self) -> None:
        """
        Démarre le thread d'écoute Redis en arrière-plan.
        DOIT être appelé après avoir enregistré tous les subscribers.
        """
        if self._pubsub is None:
            return

        def _listen():
            """Boucle d'écoute (tourne dans un thread séparé)."""
            for message in self._pubsub.listen():
                pass  # _dispatch est appelé automatiquement

        self._listen_thread = threading.Thread(target=_listen, daemon=True)
        self._listen_thread.start()
        logger.info("Thread d'écoute Redis démarré")


──────────────────────────────────────────────
FICHIER : subscribers/email_subscriber.py
──────────────────────────────────────────────

# subscribers/email_subscriber.py
# Subscriber qui envoie des emails en réponse aux événements.
# Ce composant NE CONNAÎT PAS le publisher.
# Il est complètement découplé : il réagit aux événements.

from events.user_events import UserCreatedEvent, UserDeactivatedEvent, UserAuthenticatedEvent
from events.task_events import TaskCompletedEvent, TaskOverdueEvent
from event_bus.event_bus import event_bus
import logging

logger = logging.getLogger(__name__)


class EmailSubscriber:
    """
    Subscriber pour les notifications par email.
    
    S'abonne aux événements pertinents et envoie les emails correspondants.
    
    DÉCOUPLAGE TOTAL :
    - Le service Users ne sait pas qu'un email est envoyé
    - L'EmailSubscriber ne sait pas qui a créé l'utilisateur
    - Ils communiquent uniquement via l'event bus
    """

    def __init__(self):
        """
        À la création, on s'abonne aux événements pertinents.
        """
        # Enregistrement des handlers pour chaque type d'événement
        event_bus.subscribe(UserCreatedEvent, self.on_user_created)
        event_bus.subscribe(UserDeactivatedEvent, self.on_user_deactivated)
        event_bus.subscribe(TaskCompletedEvent, self.on_task_completed)
        event_bus.subscribe(TaskOverdueEvent, self.on_task_overdue)

        logger.info("EmailSubscriber enregistré")

    def on_user_created(self, event: UserCreatedEvent) -> None:
        """
        Handler appelé quand un utilisateur est créé.
        
        Args:
            event: UserCreatedEvent avec les données du nouvel user
        """
        logger.info(f"EMAIL : Bienvenue envoyé à {event.email}")

        # En production, ici on utiliserait smtplib ou sendgrid
        self._send_welcome_email(
            to_email=event.email,
            username=event.username
        )

    def on_user_deactivated(self, event: UserDeactivatedEvent) -> None:
        """Handler pour la désactivation de compte."""
        logger.info(f"EMAIL : Notification désactivation pour user {event.user_id}")
        # Envoyer email de confirmation de désactivation

    def on_task_completed(self, event: TaskCompletedEvent) -> None:
        """Handler pour la complétion d'une tâche."""
        logger.info(f"EMAIL : Félicitations à {event.user_email} pour '{event.title}'")
        self._send_task_completed_email(
            to_email=event.user_email,
            task_title=event.title
        )

    def on_task_overdue(self, event: TaskOverdueEvent) -> None:
        """Handler pour les tâches en retard."""
        logger.warning(f"EMAIL : Alerte retard pour tâche {event.task_id}")
        # Envoyer email d'alerte

    def _send_welcome_email(self, to_email: str, username: str) -> None:
        """
        Envoi de l'email de bienvenue.
        En développement : juste un log.
        En production : utiliser flask-mail ou sendgrid.
        """
        subject = "Bienvenue sur notre plateforme !"
        body = f"""
Bonjour {username},

Votre compte a été créé avec succès.
Bonne utilisation !

L'équipe
        """
        # Simulation d'envoi
        print(f"\n[EMAIL] EMAIL envoyé à {to_email}")
        print(f"   Sujet: {subject}")
        print(f"   Corps: {body[:50]}...")

    def _send_task_completed_email(self, to_email: str, task_title: str) -> None:
        """Email de félicitations pour tâche terminée."""
        print(f"\n[EMAIL] EMAIL 'Tâche terminée' -> {to_email} : '{task_title}'")


──────────────────────────────────────────────
FICHIER : subscribers/analytics_subscriber.py
──────────────────────────────────────────────

# subscribers/analytics_subscriber.py
# Subscriber pour les statistiques.
# Réagit aux mêmes événements que l'EmailSubscriber mais pour les stats.

from events.user_events import UserCreatedEvent
from events.task_events import TaskCreatedEvent, TaskCompletedEvent
from event_bus.event_bus import event_bus
from datetime import datetime
from typing import Dict, List
import logging

logger = logging.getLogger(__name__)


class AnalyticsSubscriber:
    """
    Subscriber pour la collecte de métriques et statistiques.
    
    En production, enverrait les données à un système d'analytics
    (Google Analytics, Mixpanel, Elasticsearch, InfluxDB...).
    Ici, on garde les stats en mémoire pour la démonstration.
    """

    def __init__(self):
        # Stats in-memory (en prod : DB analytics ou service externe)
        self.stats: Dict[str, int] = {
            'total_users_created': 0,
            'total_tasks_created': 0,
            'total_tasks_completed': 0
        }
        self.events_log: List[dict] = []

        # Enregistrement des handlers
        event_bus.subscribe(UserCreatedEvent, self.on_user_created)
        event_bus.subscribe(TaskCreatedEvent, self.on_task_created)
        event_bus.subscribe(TaskCompletedEvent, self.on_task_completed)

        logger.info("AnalyticsSubscriber enregistré")

    def on_user_created(self, event: UserCreatedEvent) -> None:
        """Comptabilise les nouvelles inscriptions."""
        self.stats['total_users_created'] += 1
        self._log_event(event)
        logger.info(f"ANALYTICS : Nouvel utilisateur (total: {self.stats['total_users_created']})")

    def on_task_created(self, event: TaskCreatedEvent) -> None:
        """Comptabilise les tâches créées."""
        self.stats['total_tasks_created'] += 1
        self._log_event(event)

    def on_task_completed(self, event: TaskCompletedEvent) -> None:
        """Comptabilise les tâches terminées."""
        self.stats['total_tasks_completed'] += 1
        self._log_event(event)
        logger.info(f"ANALYTICS : Tâche terminée (total: {self.stats['total_tasks_completed']})")

    def _log_event(self, event) -> None:
        """Enregistre l'événement dans le log."""
        self.events_log.append({
            'event_type': event.event_type,
            'event_id': event.event_id,
            'occurred_at': event.occurred_at.isoformat()
        })

    def get_stats(self) -> dict:
        """Retourne les statistiques actuelles."""
        completion_rate = 0
        if self.stats['total_tasks_created'] > 0:
            completion_rate = (
                self.stats['total_tasks_completed'] /
                self.stats['total_tasks_created'] * 100
            )

        return {
            **self.stats,
            'task_completion_rate': round(completion_rate, 2),
            'total_events_processed': len(self.events_log)
        }


──────────────────────────────────────────────
FICHIER : subscribers/audit_subscriber.py
──────────────────────────────────────────────

# subscribers/audit_subscriber.py
# Subscriber d'audit : enregistre toutes les actions pour la conformité.

from events.base_event import BaseEvent
from events.user_events import UserCreatedEvent, UserDeactivatedEvent, UserAuthenticatedEvent
from events.task_events import TaskCreatedEvent, TaskCompletedEvent
from event_bus.event_bus import event_bus
from datetime import datetime
from typing import List
import json
import logging

logger = logging.getLogger(__name__)


class AuditSubscriber:
    """
    Subscriber d'audit : enregistre TOUS les événements pour la traçabilité.
    
    RGPD, ISO 27001, et autres certifications requièrent souvent un journal d'audit.
    L'architecture event-driven rend ça trivial : on s'abonne à tous les événements.
    
    En production : écrire dans une DB append-only, Elasticsearch, ou AWS CloudTrail.
    """

    def __init__(self):
        self.audit_log: List[dict] = []

        # S'abonner à TOUS les événements importants
        event_bus.subscribe(UserCreatedEvent, self.audit)
        event_bus.subscribe(UserDeactivatedEvent, self.audit)
        event_bus.subscribe(UserAuthenticatedEvent, self.audit)
        event_bus.subscribe(TaskCreatedEvent, self.audit)
        event_bus.subscribe(TaskCompletedEvent, self.audit)

        logger.info("AuditSubscriber enregistré")

    def audit(self, event: BaseEvent) -> None:
        """
        Handler générique pour tous les événements.
        Enregistre l'événement dans le journal d'audit.
        """
        audit_entry = {
            'audit_timestamp': datetime.utcnow().isoformat(),
            'event_id': event.event_id,
            'event_type': event.event_type,
            'occurred_at': event.occurred_at.isoformat(),
            'data': event.to_dict()
        }

        self.audit_log.append(audit_entry)

        # En production : db.session.add(AuditLogModel(**audit_entry))
        logger.info(f"AUDIT : {event.event_type} enregistré (ID: {event.event_id})")

    def get_audit_log(self, event_type: str = None) -> List[dict]:
        """Retourne le journal d'audit, filtré par type si fourni."""
        if event_type:
            return [e for e in self.audit_log if e['event_type'] == event_type]
        return self.audit_log


──────────────────────────────────────────────
FICHIER : publishers/user_publisher.py
──────────────────────────────────────────────

# publishers/user_publisher.py
# Le publisher publie des événements sur le bus.
# Il ne sait pas qui les reçoit.

from event_bus.event_bus import event_bus
from events.user_events import UserCreatedEvent, UserDeactivatedEvent, UserAuthenticatedEvent
from models.user import User


class UserEventPublisher:
    """
    Publisher pour les événements utilisateur.
    
    Est appelé par le service utilisateur après chaque action.
    Publie l'événement approprié.
    
    DÉCOUPLAGE :
    - Le publisher ne connaît pas les subscribers
    - Les subscribers ne connaissent pas le publisher
    - Le bus est le seul point de contact
    """

    def publish_user_created(self, user: User) -> None:
        """
        Publie un événement UserCreated.
        Appelé APRÈS la sauvegarde réussie en base de données.
        """
        event = UserCreatedEvent(
            user_id=user.id,
            username=user.username,
            email=user.email
        )
        event_bus.publish(event)

    def publish_user_deactivated(self, user: User) -> None:
        """Publie un événement UserDeactivated."""
        event = UserDeactivatedEvent(
            user_id=user.id,
            username=user.username
        )
        event_bus.publish(event)

    def publish_user_authenticated(self, user: User, ip_address: str = '') -> None:
        """Publie un événement UserAuthenticated (pour l'audit)."""
        event = UserAuthenticatedEvent(
            user_id=user.id,
            email=user.email,
            ip_address=ip_address
        )
        event_bus.publish(event)


──────────────────────────────────────────────
FICHIER : routes/user_routes.py
──────────────────────────────────────────────

# routes/user_routes.py
# Routes Flask qui utilisent le publisher pour émettre des événements.

from flask import Blueprint, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from models.user import db, User
from publishers.user_publisher import UserEventPublisher
import hashlib

user_routes = Blueprint('users', __name__, url_prefix='/api/users')

# Instance du publisher
publisher = UserEventPublisher()


@user_routes.route('/', methods=['GET'])
def list_users():
    users = User.query.filter_by(is_active=True).all()
    return jsonify([u.to_dict() for u in users]), 200


@user_routes.route('/', methods=['POST'])
def create_user():
    """
    Crée un utilisateur ET publie un événement UserCreated.
    L'email de bienvenue sera envoyé par l'EmailSubscriber (asynchrone).
    """
    data = request.get_json()
    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    for field in ['username', 'email', 'password']:
        if field not in data:
            return jsonify({'error': f'{field} requis'}), 400

    if User.query.filter_by(email=data['email']).first():
        return jsonify({'error': 'Email déjà utilisé'}), 409

    new_user = User(
        username=data['username'],
        email=data['email'],
        password_hash=hashlib.sha256(data['password'].encode()).hexdigest()
    )

    db.session.add(new_user)
    db.session.commit()

    # ── PUBLICATION DE L'ÉVÉNEMENT ─────────────────────────────
    # Après la sauvegarde réussie, on publie l'événement
    # Les subscribers (Email, Analytics, Audit) réagiront automatiquement
    publisher.publish_user_created(new_user)

    return jsonify(new_user.to_dict()), 201


@user_routes.route('/<int:user_id>/deactivate', methods=['POST'])
def deactivate_user(user_id):
    """Désactive un utilisateur et publie l'événement."""
    user = User.query.get_or_404(user_id)
    user.is_active = False
    db.session.commit()

    # Publication de l'événement
    publisher.publish_user_deactivated(user)

    return jsonify({'message': 'Compte désactivé'}), 200


──────────────────────────────────────────────
FICHIER : app.py
──────────────────────────────────────────────

# app.py
# Point d'entrée : initialise l'app ET enregistre les subscribers.

from flask import Flask, jsonify
from models.user import db
from routes.user_routes import user_routes

# Import des subscribers pour les initialiser (et enregistrer leurs handlers)
from subscribers.email_subscriber import EmailSubscriber
from subscribers.analytics_subscriber import AnalyticsSubscriber
from subscribers.audit_subscriber import AuditSubscriber

import os

# Instances des subscribers (doivent être créés AVANT le démarrage)
email_subscriber      = EmailSubscriber()
analytics_subscriber  = AnalyticsSubscriber()
audit_subscriber      = AuditSubscriber()


def create_app():
    app = Flask(__name__)
    app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DB_URL', 'sqlite:///app.db')
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

    db.init_app(app)
    app.register_blueprint(user_routes)

    # Endpoint pour voir les stats en temps réel
    @app.route('/api/analytics/stats')
    def get_stats():
        return jsonify(analytics_subscriber.get_stats()), 200

    # Endpoint pour voir le journal d'audit
    @app.route('/api/audit/log')
    def get_audit_log():
        return jsonify(audit_subscriber.get_audit_log()), 200

    with app.app_context():
        db.create_all()

    return app


if __name__ == '__main__':
    app = create_app()
    app.run(debug=True)

================================================================================
4. TESTS EVENT-DRIVEN
================================================================================

# tests/test_event_driven.py

import pytest
from events.user_events import UserCreatedEvent
from event_bus.event_bus import EventBus
from subscribers.analytics_subscriber import AnalyticsSubscriber


@pytest.fixture
def bus():
    """Bus d'événements frais pour chaque test."""
    b = EventBus()
    yield b
    b.clear_subscribers()


def test_publish_calls_all_subscribers(bus):
    """
    Test : publier un événement appelle tous les subscribers enregistrés.
    """
    received_events = []

    def handler1(event):
        received_events.append(('handler1', event))

    def handler2(event):
        received_events.append(('handler2', event))

    bus.subscribe(UserCreatedEvent, handler1)
    bus.subscribe(UserCreatedEvent, handler2)

    event = UserCreatedEvent(user_id=1, username='alice', email='alice@test.com')
    bus.publish(event)

    assert len(received_events) == 2
    assert received_events[0][0] == 'handler1'
    assert received_events[1][0] == 'handler2'


def test_failing_subscriber_does_not_block_others(bus):
    """
    Test crucial : si un subscriber échoue, les autres continuent.
    """
    executed = []

    def failing_handler(event):
        raise Exception("Je plante !")

    def working_handler(event):
        executed.append(event)

    bus.subscribe(UserCreatedEvent, failing_handler)
    bus.subscribe(UserCreatedEvent, working_handler)

    event = UserCreatedEvent(user_id=1, username='alice', email='alice@test.com')
    bus.publish(event)  # Ne doit pas propager l'exception

    # working_handler a quand même été appelé
    assert len(executed) == 1


def test_event_is_immutable():
    """
    Test : un événement ne peut pas être modifié (frozen=True).
    """
    event = UserCreatedEvent(user_id=1, username='alice', email='alice@test.com')

    with pytest.raises(Exception):  # FrozenInstanceError
        event.user_id = 2


def test_event_has_unique_id():
    """Test : chaque événement a un ID unique."""
    event1 = UserCreatedEvent(user_id=1, username='alice', email='a@test.com')
    event2 = UserCreatedEvent(user_id=2, username='bob', email='b@test.com')

    assert event1.event_id != event2.event_id


================================================================================
5. PATTERNS AVANCÉS EVENT-DRIVEN
================================================================================

  SAGA PATTERN : Transactions distribuées via événements
  ──────────────────────────────────────────────────────
  Pour les transactions impliquant plusieurs services.
  
  Ex : Créer une commande = Réserver stock + Débiter paiement + Envoyer
  
  OrderSaga écoute OrderCreated
  -> Publie ReserveStock
  -> Si succès : publie ProcessPayment
  -> Si échec : publie CancelReservation (compensation)

  OUTBOX PATTERN : Garantir la publication d'événements
  ──────────────────────────────────────────────────────
  Problème : si l'app plante ENTRE la sauvegarde DB et la publication de l'event ?
  Solution : Sauvegarder l'event dans la DB (outbox) dans la même transaction.
  Un worker lit l'outbox et publie les events non encore publiés.

  EVENT SOURCING : Les événements SONT la source de vérité
  ─────────────────────────────────────────────────────────
  Au lieu de sauvegarder l'état actuel, on sauvegarde TOUS les événements.
  L'état actuel est recalculé en rejouant les événements.
  
  Ex au lieu de User{is_active: false}, on stocke :
  [UserCreated, UserActivated, UserDeactivated]

================================================================================
6. BONNES PRATIQUES
================================================================================

  [OK] ÉVÉNEMENTS AU PASSÉ
  ─────────────────────
  UserCreated (pas CreateUser)
  TaskCompleted (pas CompleteTask)
  Les événements décrivent le passé, les commandes le futur.

  [OK] ÉVÉNEMENTS IMMUABLES
  ────────────────────────
  Utiliser frozen=True dans les dataclasses.
  Un événement ne se modifie pas, on en crée un nouveau.

  [OK] IDEMPOTENCE DES HANDLERS
  ────────────────────────────
  Si le même event est reçu deux fois, le handler doit produire le même résultat.
  Utiliser l'event_id pour détecter les doublons.

  [OK] HANDLERS INDÉPENDANTS
  ─────────────────────────
  Chaque handler doit fonctionner sans connaître les autres.
  Pas de dépendance entre handlers pour le même événement.

================================================================================
RÉSUMÉ
================================================================================

  Architecture Event-Driven = communication via événements.
  
  Composants :
    Publisher   -> émet des événements après des actions
    Event Bus   -> distribue les événements aux subscribers
    Subscriber  -> réagit aux événements (email, analytics, audit...)
  
  Flux :
    Action -> Publisher -> Event Bus -> [Subscriber 1, Subscriber 2, ...]
  
  Bénéfice : découplage total. Ajouter un subscriber ne modifie rien d'existant.
  
  Prochain fichier : architecture_serverless.txt

================================================================================
FIN DU FICHIER architecture_event_driven.txt
================================================================================

================================================================================
         ARCHITECTURE SERVERLESS
         Guide complet avec exemples Python (AWS Lambda)
================================================================================

================================================================================
1. INTRODUCTION PÉDAGOGIQUE
================================================================================

DÉFINITION :
────────────
Serverless (sans serveur) signifie que TU NE GÈRES PAS DE SERVEUR.
Tu écris des FONCTIONS, et le cloud provider (AWS, Azure, GCP) s'occupe de :
  - Démarrer les serveurs quand nécessaire
  - Les arrêter quand ils ne servent plus
  - Les scaler automatiquement
  - Patcher les OS et mises à jour sécurité

[ATTENTION] ATTENTION : Il y a TOUJOURS des serveurs. "Serverless" = tu n'en gères pas.

ANALOGIE : L'ÉLECTRICITÉ DANS TA MAISON
────────────────────────────────────────
  Sans serverless = Tu as ton propre générateur électrique.
    Tu le démarres, tu fais la maintenance, tu paies même quand tu n'en as pas besoin.
  
  Avec serverless = Tu es branché au réseau électrique.
    EDF (le cloud) gère tout. Tu paies uniquement ce que tu consommes.
    Tu n'appelles pas EDF pour démarrer ton générateur.

FONCTIONNEMENT :
────────────────
  1. Tu écris une fonction Python (AWS Lambda)
  2. Tu la déploies sur le cloud
  3. Tu définis ce qui la déclenche (trigger) : HTTP, timer, S3 event...
  4. Quand un trigger se produit, le cloud démarre la fonction
  5. La fonction s'exécute et s'arrête
  6. Tu paies uniquement la durée d'exécution (en millisecondes)

AVANTAGES :
───────────
  [OK] Pas de serveur à gérer (OS, patchs, scaling...)
  [OK] Paiement à l'usage (pas de serveur qui tourne inutilement)
  [OK] Scaling automatique (10 ou 10 000 requêtes : ça scale tout seul)
  [OK] Haute disponibilité incluse
  [OK] Déploiement simple

LIMITES :
─────────
  [X] Cold start : première invocation peut être lente (100ms à 10s)
  [X] Durée maximale d'exécution (15 min pour AWS Lambda)
  [X] Pas d'état persistant entre les invocations (stateless obligatoire)
  [X] Debugging plus difficile
  [X] Vendor lock-in (dépendance au cloud provider)
  [X] Coût peut exploser si très gros volume

================================================================================
2. QUAND UTILISER SERVERLESS ?
================================================================================

  [OK] Trafic très variable (pics soudains)
  [OK] Traitements par batch (crons, traitement de fichiers)
  [OK] Webhooks (réception d'événements externes)
  [OK] APIs peu sollicitées (économique : zéro coût si zéro trafic)
  [OK] Prototypes rapides

  [X] Applications avec état persistant
  [X] Latence critique (cold start problématique)
  [X] Tâches de plus de 15 minutes
  [X] Connexions DB longues (pooling difficile)

================================================================================
3. OUTILS
================================================================================

  FOURNISSEURS CLOUD :
  ────────────────────
  AWS Lambda         -> Le plus utilisé, écosystème riche
  Azure Functions    -> Intégration Microsoft
  Google Cloud Run   -> Containers serverless
  Vercel / Netlify   -> Pour les apps web/frontend

  FRAMEWORK PYTHON SERVERLESS :
  ──────────────────────────────
  Mangum       -> Adapte Flask/FastAPI pour AWS Lambda
  Chalice      -> Framework AWS dédié (Amazon)
  Zappa        -> Déploiement Django/Flask sur Lambda
  AWS SAM      -> Infrastructure as Code pour Lambda

  LOCAL DEVELOPMENT :
  ────────────────────
  AWS SAM CLI  -> Simuler Lambda localement
  LocalStack   -> Simuler tous les services AWS en local

================================================================================
4. STRUCTURE D'UNE FUNCTION LAMBDA
================================================================================

  ANATOMIE D'UNE LAMBDA PYTHON :
  ───────────────────────────────

  def handler(event, context):
      """
      event   : données de l'événement déclencheur (dict)
      context : informations sur l'exécution (timeout restant, memory, etc.)
      """
      return {
          'statusCode': 200,
          'body': 'Hello World'
      }

  TYPES D'ÉVÉNEMENTS :
  ─────────────────────
  HTTP (via API Gateway)     -> event contient la requête HTTP
  S3 (upload de fichier)     -> event contient le chemin du fichier
  SQS (message queue)        -> event contient les messages à traiter
  CloudWatch Events (cron)   -> event contient la date/heure
  DynamoDB Streams           -> event contient les modifications DB

================================================================================
5. IMPLÉMENTATION COMPLÈTE
================================================================================

  PROJET : API To-Do serverless sur AWS Lambda

  Structure :
  ──────────
  serverless_todo/
  ├── template.yaml            <- AWS SAM : définition de l'infrastructure
  ├── requirements.txt
  ├── Makefile                 <- Commandes utiles
  │
  ├── src/
  │   ├── __init__.py
  │   ├── models.py            <- Modèles de données
  │   ├── db.py                <- Connexion base de données
  │   │
  │   ├── functions/           <- Une fonction Lambda par action
  │   │   ├── create_user.py
  │   │   ├── get_user.py
  │   │   ├── list_users.py
  │   │   ├── create_task.py
  │   │   ├── list_tasks.py
  │   │   └── process_overdue.py  <- Tâche cron
  │   │
  │   └── shared/              <- Code partagé entre les fonctions
  │       ├── response.py      <- Helpers pour les réponses HTTP
  │       ├── validators.py    <- Validations communes
  │       └── exceptions.py    <- Exceptions personnalisées
  │
  └── tests/
      └── test_functions.py

──────────────────────────────────────────────
FICHIER : src/shared/response.py
──────────────────────────────────────────────

# src/shared/response.py
# Helpers pour créer des réponses HTTP standardisées pour Lambda.
# Une Lambda HTTP doit retourner un dict avec statusCode, headers, body.

import json
from typing import Any, Dict, Optional


def make_response(
    body: Any,
    status_code: int = 200,
    headers: Optional[Dict] = None
) -> dict:
    """
    Crée une réponse HTTP valide pour AWS Lambda + API Gateway.
    
    AWS API Gateway attend ce format exact :
    {
        "statusCode": 200,
        "headers": {...},
        "body": "string JSON"
    }
    
    Args:
        body       : données à retourner (dict, list, string)
        status_code: code HTTP (200, 201, 400, 404, 500...)
        headers    : headers HTTP supplémentaires
    
    Returns:
        dict au format Lambda response
    """
    default_headers = {
        'Content-Type': 'application/json',
        # CORS : permet les appels depuis un navigateur
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Headers': 'Content-Type,Authorization',
        'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS'
    }

    if headers:
        default_headers.update(headers)

    return {
        'statusCode': status_code,
        'headers': default_headers,
        'body': json.dumps(body, default=str)  # default=str : sérialise datetime
    }


def success(body: Any, status_code: int = 200) -> dict:
    """Réponse de succès."""
    return make_response(body, status_code)


def created(body: Any) -> dict:
    """201 Created."""
    return make_response(body, 201)


def error(message: str, status_code: int = 400) -> dict:
    """Réponse d'erreur."""
    return make_response({'error': message}, status_code)


def not_found(message: str = "Ressource non trouvée") -> dict:
    """404 Not Found."""
    return error(message, 404)


def server_error(message: str = "Erreur interne du serveur") -> dict:
    """500 Internal Server Error."""
    return error(message, 500)


def parse_body(event: dict) -> Optional[dict]:
    """
    Parse le corps JSON d'une requête Lambda.
    Le corps arrive comme une string JSON dans event['body'].
    """
    body = event.get('body')
    if not body:
        return None
    try:
        return json.loads(body)
    except json.JSONDecodeError:
        return None


def get_path_param(event: dict, param_name: str) -> Optional[str]:
    """
    Récupère un paramètre de chemin URL.
    Ex: GET /users/{user_id} -> event['pathParameters']['user_id']
    """
    params = event.get('pathParameters', {})
    return params.get(param_name) if params else None


def get_query_param(event: dict, param_name: str) -> Optional[str]:
    """
    Récupère un paramètre de query string.
    Ex: GET /users?status=active -> event['queryStringParameters']['status']
    """
    params = event.get('queryStringParameters', {})
    return params.get(param_name) if params else None


──────────────────────────────────────────────
FICHIER : src/db.py
──────────────────────────────────────────────

# src/db.py
# Gestion de la connexion à la base de données.
# IMPORTANT : En serverless, la connexion doit être réutilisable entre les invocations.
# Le container Lambda peut être réutilisé (warm start), donc la connexion aussi.

import os
import boto3  # SDK AWS (pip install boto3)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from typing import Optional
import logging

logger = logging.getLogger(__name__)

# ── PATTERN SINGLETON DE CONNEXION ────────────────────────────────────────────
# Le engine SQLAlchemy est créé une seule fois par container Lambda.
# Si le container est "warm" (réutilisé), on réutilise la même connexion.
# Si le container est "cold" (nouveau), on crée une nouvelle connexion.

_engine = None
_SessionLocal = None


def get_engine():
    """
    Retourne l'engine SQLAlchemy, le crée si nécessaire.
    Pattern Singleton pour réutiliser la connexion entre les invocations.
    """
    global _engine

    if _engine is None:
        # Lire l'URL de la DB depuis les variables d'environnement
        # En Lambda, les variables d'env sont configurées dans template.yaml
        db_url = os.environ.get('DATABASE_URL', 'sqlite:///todos.db')

        logger.info(f"Création d'un nouvel engine DB")

        _engine = create_engine(
            db_url,
            # pool_pre_ping : vérifie la connexion avant chaque utilisation
            # Important en serverless car la connexion peut expirer
            pool_pre_ping=True,
            # pool_size : nombre de connexions dans le pool
            # En serverless, garder petit (une connexion par container)
            pool_size=1,
            max_overflow=0  # Pas de connexions supplémentaires
        )

    return _engine


def get_session() -> Session:
    """
    Retourne une session SQLAlchemy.
    À utiliser dans un contexte with :
        with get_session() as session:
            users = session.query(User).all()
    """
    global _SessionLocal

    if _SessionLocal is None:
        engine = get_engine()
        _SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)

    session = _SessionLocal()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()


──────────────────────────────────────────────
FICHIER : src/functions/create_user.py
──────────────────────────────────────────────

# src/functions/create_user.py
# Fonction Lambda pour créer un utilisateur.
# Chaque action = une fonction Lambda séparée.
# C'est le principe "Single Responsibility" poussé à l'extrême.

import json
import hashlib
import logging
from datetime import datetime
from src.shared.response import (
    success, created, error, not_found, server_error, parse_body
)
from src.db import get_session
from src.models import User

# Configurer le logging (les logs vont dans CloudWatch)
logger = logging.getLogger()
logger.setLevel(logging.INFO)


def handler(event: dict, context) -> dict:
    """
    Handler Lambda pour la création d'utilisateur.
    
    Trigger : POST /users (via API Gateway)
    
    Args:
        event   : événement API Gateway contenant la requête HTTP
        context : contexte d'exécution Lambda
    
    Returns:
        Réponse HTTP au format Lambda
    
    Structure de event (HTTP) :
    {
        "httpMethod": "POST",
        "path": "/users",
        "headers": {...},
        "body": '{"username":"alice","email":"alice@test.com","password":"secret"}',
        "pathParameters": null,
        "queryStringParameters": null
    }
    """

    logger.info(f"create_user invoquée - RequestId: {context.aws_request_id}")

    # ── PARSE DU CORPS ────────────────────────────────────────
    data = parse_body(event)

    if not data:
        return error("Corps JSON requis", 400)

    # ── VALIDATION ────────────────────────────────────────────
    required = ['username', 'email', 'password']
    for field in required:
        if field not in data:
            return error(f"Champ requis manquant : {field}", 400)

    if len(data['password']) < 6:
        return error("Le mot de passe doit faire au moins 6 caractères", 400)

    if '@' not in data['email']:
        return error("Email invalide", 400)

    # ── LOGIQUE MÉTIER ────────────────────────────────────────
    try:
        # Utilisation du générateur de session
        session_gen = get_session()
        session = next(session_gen)

        # Vérifier l'unicité de l'email
        existing = session.query(User).filter_by(email=data['email']).first()
        if existing:
            return error("Email déjà utilisé", 409)

        # Créer l'utilisateur
        password_hash = hashlib.sha256(data['password'].encode()).hexdigest()
        new_user = User(
            username=data['username'],
            email=data['email'],
            password_hash=password_hash
        )

        session.add(new_user)
        session.commit()
        session.refresh(new_user)

        logger.info(f"Utilisateur créé : ID={new_user.id}, email={new_user.email}")

        return created(new_user.to_dict())

    except Exception as e:
        logger.error(f"Erreur création utilisateur: {e}", exc_info=True)
        return server_error("Erreur lors de la création de l'utilisateur")


──────────────────────────────────────────────
FICHIER : src/functions/list_users.py
──────────────────────────────────────────────

# src/functions/list_users.py
# Lambda pour lister les utilisateurs.

import logging
from src.shared.response import success, error, server_error, get_query_param
from src.db import get_session
from src.models import User

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def handler(event: dict, context) -> dict:
    """
    Handler Lambda pour lister les utilisateurs.
    Trigger : GET /users
    Supporte le paramètre ?limit=N pour la pagination.
    """
    logger.info("list_users invoquée")

    # Paramètre de limite (pagination simple)
    limit_str = get_query_param(event, 'limit')
    limit = int(limit_str) if limit_str and limit_str.isdigit() else 50

    try:
        session_gen = get_session()
        session = next(session_gen)

        users = session.query(User).filter_by(is_active=True).limit(limit).all()

        return success({
            'users': [u.to_dict() for u in users],
            'count': len(users)
        })

    except Exception as e:
        logger.error(f"Erreur listing users: {e}", exc_info=True)
        return server_error()


──────────────────────────────────────────────
FICHIER : src/functions/get_user.py
──────────────────────────────────────────────

# src/functions/get_user.py
# Lambda pour récupérer un utilisateur par ID.

import logging
from src.shared.response import success, not_found, server_error, get_path_param
from src.db import get_session
from src.models import User

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def handler(event: dict, context) -> dict:
    """
    Handler Lambda : GET /users/{user_id}
    
    user_id est extrait de event['pathParameters']['user_id']
    Défini dans template.yaml : /users/{user_id}
    """
    user_id_str = get_path_param(event, 'user_id')

    if not user_id_str or not user_id_str.isdigit():
        return not_found("ID utilisateur invalide")

    user_id = int(user_id_str)

    try:
        session_gen = get_session()
        session = next(session_gen)

        user = session.query(User).get(user_id)

        if not user:
            return not_found(f"Utilisateur {user_id} introuvable")

        return success(user.to_dict())

    except Exception as e:
        logger.error(f"Erreur get user {user_id}: {e}", exc_info=True)
        return server_error()


──────────────────────────────────────────────
FICHIER : src/functions/process_overdue.py
──────────────────────────────────────────────

# src/functions/process_overdue.py
# Lambda déclenchée par un CRON (CloudWatch Events / EventBridge).
# Traite les tâches en retard (envoie des rappels).
# Pas de requête HTTP ici : événement planifié.

import logging
from datetime import datetime
from src.db import get_session
from src.models import Task, User

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def handler(event: dict, context) -> dict:
    """
    Handler Lambda pour le traitement des tâches en retard.
    
    Trigger : CloudWatch Events (cron) - Ex: Tous les jours à 9h00
    
    event (CloudWatch) :
    {
        "version": "0",
        "id": "...",
        "source": "aws.events",
        "time": "2024-01-15T09:00:00Z",
        "detail-type": "Scheduled Event"
    }
    
    Returns:
        dict avec le résultat du traitement (pour CloudWatch Logs)
    """
    logger.info(f"Traitement des tâches en retard - {datetime.utcnow().isoformat()}")

    processed_count = 0
    error_count = 0

    try:
        session_gen = get_session()
        session = next(session_gen)

        # Trouver toutes les tâches en retard
        overdue_tasks = session.query(Task).filter(
            Task.due_date < datetime.utcnow(),
            Task.status != 'done'
        ).all()

        logger.info(f"{len(overdue_tasks)} tâche(s) en retard trouvée(s)")

        for task in overdue_tasks:
            try:
                # Récupérer l'utilisateur associé
                user = session.query(User).get(task.user_id)
                if not user:
                    logger.warning(f"User {task.user_id} non trouvé pour tâche {task.id}")
                    continue

                # Envoyer un rappel (ici juste un log, en prod : email/notification)
                logger.info(f"RAPPEL -> {user.email} : tâche '{task.title}' en retard")
                # send_overdue_reminder(user.email, task)
                processed_count += 1

            except Exception as task_error:
                logger.error(f"Erreur tâche {task.id}: {task_error}")
                error_count += 1

        result = {
            'status': 'completed',
            'overdue_tasks_found': len(overdue_tasks),
            'processed': processed_count,
            'errors': error_count,
            'execution_time': datetime.utcnow().isoformat()
        }

        logger.info(f"Traitement terminé : {result}")
        return result

    except Exception as e:
        logger.error(f"Erreur critique dans process_overdue: {e}", exc_info=True)
        return {
            'status': 'error',
            'message': str(e)
        }


──────────────────────────────────────────────
FICHIER : template.yaml (AWS SAM)
──────────────────────────────────────────────

# template.yaml
# Infrastructure as Code pour AWS SAM (Serverless Application Model).
# Définit :
# - Les fonctions Lambda
# - Leurs triggers (HTTP, cron...)
# - Les variables d'environnement
# - Les permissions IAM

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31  # Indique que c'est un template SAM

Description: API To-Do Serverless

# Variables globales partagées entre toutes les fonctions
Globals:
  Function:
    # Runtime Python 3.11
    Runtime: python3.11
    # Timeout de 30 secondes par défaut
    Timeout: 30
    # Mémoire allouée (128MB - 10GB)
    MemorySize: 256
    # Variables d'environnement communes à toutes les functions
    Environment:
      Variables:
        DATABASE_URL: !Sub "sqlite:///todos.db"
        LOG_LEVEL: "INFO"

Resources:

  # ── API GATEWAY ─────────────────────────────────────────────
  # Point d'entrée HTTP pour toutes les fonctions
  TodoApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Cors:
        AllowMethods: "'GET,POST,PUT,DELETE,PATCH,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization'"
        AllowOrigin: "'*'"

  # ── FONCTIONS LAMBDA ─────────────────────────────────────────

  # Lambda : Lister les utilisateurs
  ListUsersFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/                     # Répertoire du code source
      Handler: functions.list_users.handler  # module.fonction
      Events:
        ListUsers:
          Type: Api
          Properties:
            RestApiId: !Ref TodoApi
            Path: /users               # URL de l'endpoint
            Method: get                # Méthode HTTP

  # Lambda : Créer un utilisateur
  CreateUserFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: functions.create_user.handler
      Events:
        CreateUser:
          Type: Api
          Properties:
            RestApiId: !Ref TodoApi
            Path: /users
            Method: post

  # Lambda : Récupérer un utilisateur
  GetUserFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: functions.get_user.handler
      Events:
        GetUser:
          Type: Api
          Properties:
            RestApiId: !Ref TodoApi
            Path: /users/{user_id}     # Paramètre de chemin
            Method: get

  # Lambda : Cron - Traitement des tâches en retard
  ProcessOverdueFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: functions.process_overdue.handler
      Timeout: 300                     # 5 minutes pour le batch
      Events:
        DailySchedule:
          Type: Schedule               # Trigger : cron
          Properties:
            Schedule: cron(0 9 * * ? *)  # Tous les jours à 9h00 UTC
            Description: "Traitement quotidien des tâches en retard"

# ── SORTIES ──────────────────────────────────────────────────────
Outputs:
  ApiUrl:
    Description: "URL de l'API"
    Value: !Sub "https://${TodoApi}.execute-api.${AWS::Region}.amazonaws.com/prod"


──────────────────────────────────────────────
FICHIER : src/functions/flask_wrapper.py
──────────────────────────────────────────────

# src/functions/flask_wrapper.py
# ALTERNATIVE : Utiliser Flask avec Mangum pour déployer sur Lambda.
# Mangum adapte les requêtes API Gateway au format Flask.
# Pratique pour migrer une app Flask existante vers Lambda.

# pip install flask mangum

from flask import Flask, request, jsonify
from mangum import Mangum  # Adaptateur Flask -> Lambda
import hashlib

app = Flask(__name__)

# ── ROUTES FLASK NORMALES ─────────────────────────────────────

@app.route('/users', methods=['GET'])
def list_users():
    """Endpoint Flask normal."""
    # ... logique normale
    return jsonify({'users': [], 'message': 'Fonctionne en local ET sur Lambda !'}), 200


@app.route('/users', methods=['POST'])
def create_user():
    data = request.get_json()
    if not data:
        return jsonify({'error': 'Données requises'}), 400
    # ... logique de création
    return jsonify({'message': 'Utilisateur créé'}), 201


@app.route('/health', methods=['GET'])
def health():
    return jsonify({'status': 'healthy', 'runtime': 'Lambda via Mangum'}), 200


# ── ADAPTER LAMBDA ───────────────────────────────────────────
# Mangum crée un handler Lambda qui adapte les requêtes API Gateway
# vers les requêtes Flask standard.
handler = Mangum(app, lifespan="off")
# "handler" est le Lambda handler défini dans template.yaml :
# Handler: flask_wrapper.handler

# ── DÉVELOPPEMENT LOCAL ───────────────────────────────────────
if __name__ == '__main__':
    # En local : Flask normal sur le port 5000
    app.run(debug=True, port=5000)
# Sur Lambda : Mangum gère la traduction des événements


================================================================================
6. DÉPLOIEMENT ET COMMANDES
================================================================================

  # Prérequis
  pip install aws-sam-cli boto3 mangum

  # Configurer AWS CLI (une seule fois)
  aws configure
  # (Entrer Access Key, Secret Key, Région)

  # Développement local avec SAM
  sam local start-api  # Lance un serveur local simulant API Gateway

  # Tester une fonction localement
  sam local invoke CreateUserFunction --event events/create_user_test.json

  # Construire le projet
  sam build

  # Déployer sur AWS
  sam deploy --guided  # Première fois (interactif)
  sam deploy           # Fois suivantes

  # Voir les logs en temps réel
  sam logs -n CreateUserFunction --stack-name todo-serverless --tail

  # Tester l'API déployée
  curl https://xxx.execute-api.us-east-1.amazonaws.com/prod/users

──────────────────────────────────────────────
FICHIER : events/create_user_test.json
──────────────────────────────────────────────

{
    "httpMethod": "POST",
    "path": "/users",
    "headers": {
        "Content-Type": "application/json"
    },
    "body": "{\"username\":\"alice\",\"email\":\"alice@test.com\",\"password\":\"motdepasse123\"}",
    "pathParameters": null,
    "queryStringParameters": null,
    "requestContext": {
        "requestId": "test-request-id"
    }
}

================================================================================
7. TESTS SERVERLESS
================================================================================

# tests/test_functions.py
# Tests des fonctions Lambda SANS AWS (en local).

import json
import pytest
from unittest.mock import patch, MagicMock


@pytest.fixture
def lambda_event_post():
    """Event HTTP POST simulé pour les tests."""
    return {
        'httpMethod': 'POST',
        'path': '/users',
        'headers': {'Content-Type': 'application/json'},
        'body': json.dumps({
            'username': 'alice',
            'email': 'alice@test.com',
            'password': 'motdepasse123'
        }),
        'pathParameters': None,
        'queryStringParameters': None
    }


@pytest.fixture
def lambda_context():
    """Context Lambda simulé."""
    context = MagicMock()
    context.aws_request_id = 'test-request-123'
    context.function_name = 'CreateUserFunction'
    context.remaining_time_in_millis = lambda: 30000
    return context


def test_create_user_success(lambda_event_post, lambda_context):
    """
    Test de la Lambda create_user.
    On mock la base de données pour tester sans vraie DB.
    """
    # Mock de la session DB
    mock_user = MagicMock()
    mock_user.id = 1
    mock_user.username = 'alice'
    mock_user.email = 'alice@test.com'
    mock_user.to_dict.return_value = {
        'id': 1, 'username': 'alice', 'email': 'alice@test.com'
    }

    with patch('src.functions.create_user.get_session') as mock_get_session:
        # Configuration du mock
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None  # Email unique
        mock_get_session.return_value = iter([mock_session])
        mock_session.query.return_value.get.return_value = mock_user

        from src.functions.create_user import handler
        response = handler(lambda_event_post, lambda_context)

    assert response['statusCode'] == 201
    body = json.loads(response['body'])
    assert 'id' in body or 'username' in body


def test_create_user_missing_email(lambda_context):
    """Test avec email manquant -> 400."""
    event = {
        'body': json.dumps({'username': 'alice', 'password': 'secret123'}),
        'pathParameters': None,
        'queryStringParameters': None
    }

    from src.functions.create_user import handler
    response = handler(event, lambda_context)

    assert response['statusCode'] == 400
    body = json.loads(response['body'])
    assert 'error' in body


def test_list_users_empty(lambda_context):
    """Test listing quand aucun utilisateur."""
    event = {'queryStringParameters': None, 'pathParameters': None}

    with patch('src.functions.list_users.get_session') as mock_get_session:
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.limit.return_value.all.return_value = []
        mock_get_session.return_value = iter([mock_session])

        from src.functions.list_users import handler
        response = handler(event, lambda_context)

    assert response['statusCode'] == 200
    body = json.loads(response['body'])
    assert body['users'] == []
    assert body['count'] == 0

================================================================================
8. BONNES PRATIQUES SERVERLESS
================================================================================

  [OK] STATELESS OBLIGATOIRE
  ─────────────────────────
  Une Lambda ne doit jamais stocker d'état entre les invocations.
  Toute donnée persistante -> base de données (RDS, DynamoDB) ou S3.

  [OK] VARIABLES D'ENVIRONNEMENT
  ─────────────────────────────
  Jamais de secrets en dur dans le code.
  Utiliser les variables d'environnement Lambda + AWS Secrets Manager.

  [OK] LOGGING STRUCTURÉ
  ─────────────────────
  Utiliser JSON dans les logs pour faciliter la recherche dans CloudWatch.
  logger.info(json.dumps({'action': 'create_user', 'user_id': 1, 'status': 'success'}))

  [OK] GESTION DU COLD START
  ─────────────────────────
  - Garder les fonctions petites (peu de dépendances = démarrage rapide)
  - Utiliser "Provisioned Concurrency" pour les fonctions critiques
  - Initialiser les connexions DB HORS du handler (au niveau module)

  [OK] TIMEOUT ET MÉMOIRE ADAPTÉS
  ──────────────────────────────
  Donner assez de mémoire (Lambda plus rapide avec plus de RAM).
  Timeout adapté : pas 30s pour une fonction qui devrait prendre <1s.

  [OK] DEAD LETTER QUEUE
  ─────────────────────
  Pour les Lambda asynchrones : configurer une Dead Letter Queue (SQS/SNS)
  pour capturer les invocations qui ont échoué.

================================================================================
9. ERREURS FRÉQUENTES
================================================================================

  [X] CONNEXION DB À CHAQUE INVOCATION
  ─────────────────────────────────────
  Mauvais : créer une nouvelle connexion dans le handler.
  Bon     : connexion au niveau module, réutilisée entre les invocations (warm).

  [X] TIMEOUT TROP COURT
  ──────────────────────
  Penser au cold start + connexion DB + traitement.
  Prévoir de la marge.

  [X] PACKAGES TROP LOURDS
  ────────────────────────
  Chaque MB de code = cold start plus lent.
  N'importer que ce qui est nécessaire.
  Utiliser les Lambda Layers pour les dépendances communes.

  [X] PAS DE GESTION D'ERREURS
  ─────────────────────────────
  Une Lambda qui plante = retry automatique (AWS) -> doublons potentiels.
  Toujours gérer les erreurs et retourner des réponses appropriées.

================================================================================
RÉSUMÉ
================================================================================

  Serverless = fonctions déclenchées par des événements, sans gestion de serveur.
  
  Concepts :
    Function (Lambda) -> unité de code déclenchée par un événement
    Trigger           -> ce qui déclenche la fonction (HTTP, cron, S3...)
    Event             -> données transmises à la fonction
    Context           -> informations sur l'exécution
  
  Règles d'or :
    [OK] Stateless (aucun état entre les invocations)
    [OK] Connexion DB en dehors du handler (warm start)
    [OK] Variables d'environnement pour les secrets
    [OK] Logging structuré vers CloudWatch
    [OK] Timeout et mémoire bien dimensionnés
  
  Outils : AWS Lambda + API Gateway + SAM CLI, ou Flask + Mangum
  
  Prochain fichier : architecture_comparaison.txt

================================================================================
FIN DU FICHIER architecture_serverless.txt
================================================================================

================================================================================
         COMPARAISON DES ARCHITECTURES
         Guide de décision complet
================================================================================

================================================================================
1. TABLEAU COMPARATIF GLOBAL
================================================================================

  CRITÈRE          MONO  MVC   LAYER MICRO CLEAN HEXA  EVENT SERVER
  ─────────────────────────────────────────────────────────────────────────────
  Complexité        *     **    ***   ****  ****  ***** ****  ***
  Vitesse dev init  ***** ****  ***   **    **    *     **    ***
  Maintenabilité    *     **    ***   ****  ***** ***** ***   ***
  Testabilité       *     **    ***   ***   ***** ***** ****  ***
  Scalabilité       **    **    **    ***** ***   ***   ****  *****
  Équipe solo       ***** ****  ***   *     **    *     **    ***
  Grande équipe     *     **    ***   ***** ****  ***** ****  ***
  Résilience        **    **    **    ****  ***   ***   ****  *****
  Coût initial      Faible Faible Moyen Élevé Élevé Très é. Moyen Faible
  Coût à l'échelle  Élevé Élevé Moyen Faible Moyen Moyen  Faible Faible

  * = Faible/Mauvais   ***** = Excellent

================================================================================
2. COMPARAISON DÉTAILLÉE PAR CRITÈRE
================================================================================

  CRITÈRE : VITESSE DE DÉVELOPPEMENT INITIAL
  ────────────────────────────────────────────

  Le plus rapide -> Le plus lent :

  1. MONOLITHIQUE  ████████████████████ 100%  "Écrire et déployer en 1 heure"
  2. SERVERLESS    ████████████████░░░░  80%  "Quelques fonctions vite faites"
  3. MVC           ████████████████░░░░  75%  "Structure connue, rapide à setup"
  4. LAYERED       ████████████░░░░░░░░  60%  "Plus de fichiers, mais habitude"
  5. MICROSERVICES ████████░░░░░░░░░░░░  40%  "Infra complexe, communication..."
  6. EVENT-DRIVEN  ██████░░░░░░░░░░░░░░  30%  "Bus, events, subscribers..."
  7. CLEAN         █████░░░░░░░░░░░░░░░  25%  "Interfaces, DTOs, couches..."
  8. HEXAGONALE    ████░░░░░░░░░░░░░░░░  20%  "Ports, adapters, injection..."

  ──────────────────────────────────────────────────────────────────────────────

  CRITÈRE : MAINTENABILITÉ À LONG TERME (1+ an)
  ──────────────────────────────────────────────

  La plus maintenable -> La moins maintenable :

  1. HEXAGONALE    ████████████████████ 100%  "Domaine pur, swap technologie"
  2. CLEAN         ████████████████████  95%  "Séparation absolue, testable"
  3. MICROSERVICES ████████████████░░░░  80%  "Services indépendants"
  4. LAYERED       ████████████░░░░░░░░  60%  "Bien structuré pour équipes"
  5. EVENT-DRIVEN  ██████████░░░░░░░░░░  50%  "Difficile à tracer les flux"
  6. MVC           ████████░░░░░░░░░░░░  40%  "Devient Fat Controller"
  7. SERVERLESS    ██████░░░░░░░░░░░░░░  30%  "Éparpillement des fonctions"
  8. MONOLITHIQUE  ████░░░░░░░░░░░░░░░░  20%  "Big ball of mud après 1 an"

  ──────────────────────────────────────────────────────────────────────────────

  CRITÈRE : SCALABILITÉ
  ──────────────────────

  La plus scalable -> La moins scalable :

  1. SERVERLESS    ████████████████████ 100%  "Infinie, automatique, payée à l'usage"
  2. MICROSERVICES ████████████████░░░░  80%  "Chaque service scale indépendamment"
  3. EVENT-DRIVEN  ████████████░░░░░░░░  60%  "Asynchrone, consumers parallèles"
  4. MONOLITHIQUE  ██████░░░░░░░░░░░░░░  30%  "Scale horizontal mais tout ou rien"
  5. MVC / LAYERED ██████░░░░░░░░░░░░░░  30%  "Scale horizontal similaire"
  6. CLEAN / HEXA  ████████████░░░░░░░░  60%  "Facilement migrable vers micro"

================================================================================
3. QUAND UTILISER QUELLE ARCHITECTURE ?
================================================================================

  ┌─────────────────────────────────────────────────────────────────────────┐
  │                     ARBRE DE DÉCISION COMPLET                          │
  └─────────────────────────────────────────────────────────────────────────┘

  ÉTAPE 1 : Quelle est la taille de votre équipe ?
  ─────────────────────────────────────────────────

  Solo (1 dev) :
    -> Monolithique ou MVC (selon complexité)

  Petite équipe (2-5 devs) :
    -> MVC ou Layered

  Équipe moyenne (5-15 devs) :
    -> Layered, Clean, ou Hexagonale

  Grande équipe (15+ devs) :
    -> Microservices (avec Clean/Hexa dans chaque service)

  ─────────────────────────────────────────────────

  ÉTAPE 2 : Quel est le stade du projet ?
  ─────────────────────────────────────────

  Prototype / POC :
    -> MONOLITHIQUE. Toujours.
    Valider l'idée avant de s'investir dans l'architecture.

  MVP (Minimum Viable Product) :
    -> Monolithique ou MVC selon la complexité domaine.

  Produit en croissance :
    -> Layered ou MVC.

  Produit mature :
    -> Clean, Hexagonale, ou migration vers Microservices.

  ─────────────────────────────────────────────────

  ÉTAPE 3 : Quelles sont les exigences techniques ?
  ──────────────────────────────────────────────────

  Logique métier très complexe :
    -> Clean Architecture ou Hexagonale.
    (Le domaine isolé permet de la comprendre et la tester seule)

  Trafic très variable / imprévisible :
    -> Serverless ou Microservices avec auto-scaling.

  Résilience critique (pannes tolérées) :
    -> Microservices ou Event-Driven.

  Intégration de nombreux systèmes externes :
    -> Hexagonale (ports pour chaque système externe).

  Traitement asynchrone / temps réel :
    -> Event-Driven.

  ─────────────────────────────────────────────────

  ÉTAPE 4 : Quelles sont les contraintes ?
  ─────────────────────────────────────────

  Budget limité :
    -> Monolithique ou Serverless (pay-as-you-go).

  Délais très courts :
    -> Monolithique. Toujours.

  Équipe junior :
    -> MVC (très enseigné, beaucoup de ressources).

  Équipe expérimentée :
    -> Clean, Hexagonale, ou Microservices.

================================================================================
4. RECOMMANDATIONS PAR TYPE DE PROJET
================================================================================

  ── PROJET PERSONNEL / PORTFOLIO ──────────────────────────────────────────
  -> MVC ou Layered
  Pourquoi : Montre une architecture structurée sans over-engineering.
             Donne une bonne image lors des entretiens.
  
  Exemple :
    Blog personnel     -> MVC
    Gestionnaire de finance -> Layered

  ── STARTUP EARLY-STAGE ───────────────────────────────────────────────────
  -> MONOLITHIQUE, puis évoluer si nécessaire
  
  Pourquoi : "Move fast, break things". Valider le marché avant tout.
             Refactorer quand vous avez des utilisateurs réels.
  
  Citation : "The monolith is not the enemy. The big ball of mud is."
             - Stefan Tilkov

  ── APPLICATION D'ENTREPRISE (PME) ────────────────────────────────────────
  -> LAYERED ou MVC
  
  Pourquoi : Équipe de 2-5 personnes, besoin de clarté.
             Pas besoin de microservices pour 100 utilisateurs.
  
  Exemple :
    ERP interne       -> Layered
    Intranet          -> MVC

  ── APPLICATION E-COMMERCE (MOYENNE) ─────────────────────────────────────
  -> LAYERED ou début de MICROSERVICES
  
  Services potentiels :
    - Users & Auth
    - Products & Catalog
    - Orders
    - Payments
    - Shipping
  
  Commencer Layered, extraire les services au besoin.

  ── GRANDE PLATEFORME (TYPE AMAZON, NETFLIX) ──────────────────────────────
  -> MICROSERVICES + EVENT-DRIVEN
  
  Caractéristiques :
    - Des dizaines d'équipes indépendantes
    - Millions d'utilisateurs
    - Services en différentes technologies
    - Besoin de scalabilité indépendante
  
  Note : Ces entreprises ont commencé en monolithe !
         Amazon était un monolithe, Netflix aussi.

  ── API MOBILE BACKEND ────────────────────────────────────────────────────
  -> LAYERED (ou Clean si domaine complexe)
  
  Pourquoi : API REST claire, logique métier dans les services.
             Les mobiles changent souvent les specs -> architecture flexible.

  ── TRAITEMENT DE DONNÉES / ETL ───────────────────────────────────────────
  -> SERVERLESS + EVENT-DRIVEN
  
  Exemple :
    Traitement d'images uploadées -> Lambda déclenchée par S3
    Pipeline de données -> Event-Driven avec Kafka
    Crons de maintenance -> Lambda schedulée

  ── FINTECH / BANKING ─────────────────────────────────────────────────────
  -> CLEAN ou HEXAGONALE
  
  Pourquoi : Domaine métier très complexe (réglementations, règles financières).
             Tests exhaustifs obligatoires.
             Le domaine doit être lisible par des non-développeurs.
             Changements technologiques fréquents (nouveaux systèmes).

  ── HEALTHCARE / MEDICAL ──────────────────────────────────────────────────
  -> HEXAGONALE + EVENT-DRIVEN (pour les audits)
  
  Pourquoi : HIPAA, RGPD, traçabilité obligatoire.
             Les événements créent automatiquement un journal d'audit.
             Isolation du domaine pour la conformité réglementaire.

================================================================================
5. ANTI-PATTERNS À ÉVITER
================================================================================

  [X] MICRO-FRONTIÈRES TROP PETITES
  ─────────────────────────────────
  Créer des microservices pour chaque fonction CRUD.
  "Nano-services" : Service pour chaque table de la DB.
  -> Trop de communication réseau, trop de complexité
  -> Règle : un service = un DOMAINE, pas une table

  [X] MICROSERVICES AU DÉMARRAGE
  ──────────────────────────────
  "On va direct en microservices pour scalabilité"
  -> Complexité opérationnelle dès le jour 1
  -> Tu ne connais pas encore tes domaines
  -> Commencer monolithique, extraire quand ça fait mal

  [X] ARCHITECTURE POUR L'ARCHITECTURE
  ────────────────────────────────────
  Choisir Clean Architecture pour un CRUD de 10 endpoints.
  Over-engineering : 10x plus de code pour le même résultat.
  -> "Simple things should be simple, complex things should be possible"

  [X] FAT CONTROLLER (MVC/LAYERED)
  ────────────────────────────────
  Mettre toute la logique dans les controllers.
  -> MVC sans le M(odel) et sans le S(ervice)
  -> Le Controller devient un monolithe déguisé

  [X] BASE DE DONNÉES PARTAGÉE EN MICROSERVICES
  ─────────────────────────────────────────────
  Plusieurs services qui partagent la même table SQL.
  -> Couplage fort, pire que le monolithe
  -> Chaque service doit avoir SA base de données

  [X] ABSTRACTION PRÉMATURÉE
  ──────────────────────────
  Créer des interfaces pour tout dès le début.
  -> YAGNI (You Ain't Gonna Need It)
  -> Créer l'abstraction quand vous en avez besoin, pas avant

================================================================================
6. MIGRATION ENTRE ARCHITECTURES
================================================================================

  MONOLITHIQUE -> MVC (Refactoring)
  ─────────────────────────────────
  Durée estimée : 1-2 semaines

  1. Identifier les entités (ce qui persiste en DB)
  2. Créer le dossier models/ avec les classes
  3. Identifier les routes -> créer controllers/
  4. Extraire les templates -> views/
  5. Tester progressivement

  MVC -> LAYERED (Refactoring)
  ────────────────────────────
  Durée estimée : 2-4 semaines

  1. Créer le dossier services/
  2. Pour chaque "grosse méthode" dans les controllers :
     -> La déplacer dans le service correspondant
  3. Créer le dossier repositories/
  4. Déplacer les requêtes DB des models vers les repos
  5. Mettre à jour les controllers pour appeler les services

  LAYERED -> CLEAN (Refactoring)
  ──────────────────────────────
  Durée estimée : 1-3 mois

  1. Créer le dossier domain/ avec les entités pures (sans SQLAlchemy)
  2. Créer les interfaces (ports) pour chaque repository
  3. Renommer les repositories actuels en "SQLAlchemy...Repository"
  4. Créer les Use Cases (un par action métier)
  5. Adapter les controllers pour appeler les Use Cases
  6. Progressif : migrer feature par feature, pas tout d'un coup

  MONOLITHIQUE -> MICROSERVICES (The Strangler Fig Pattern)
  ─────────────────────────────────────────────────────────
  Durée estimée : 6-18 mois

  1. Ne pas tout réécrire d'un coup !
  2. Identifier le premier domaine à extraire (le moins couplé)
  3. Créer le microservice pour ce domaine
  4. Mettre un proxy devant le monolithe pour rediriger ce domaine
  5. Progressivement "étrangler" le monolithe service par service
  6. Le monolithe disparaît progressivement comme une plante étranglée

  Pattern du Strangler Fig :
    
    ┌────────────────────────────────┐
    │           PROXY / API Gateway  │
    └────┬────────────────┬──────────┘
         │                │
    ┌────[BLACK_DOWN-POINTING_TRIANGLE]────┐      ┌────[BLACK_DOWN-POINTING_TRIANGLE]─────────┐
    │ MONOLITHE│      │ Microservice │
    │(le vieux)│      │  Users       │
    └──────────┘      └─────────────┘
    
    Au fur et à mesure, le monolithe rétrécit, les microservices grandissent.

================================================================================
7. CRITÈRES DE CHOIX EN UNE PAGE
================================================================================

  SI tu réponds OUI à ces questions, utilise cette architecture :
  ──────────────────────────────────────────────────────────────

  OUI à "C'est un prototype rapide / POC"
  -> MONOLITHIQUE

  OUI à "Petite app CRUD avec quelques entités"
  -> MVC

  OUI à "Application métier avec de la logique dans les services"
  -> LAYERED

  OUI à "Domaine métier complexe, testabilité critique"
  -> CLEAN ARCHITECTURE

  OUI à "Plusieurs systèmes externes à intégrer, swap technologique"
  -> HEXAGONALE

  OUI à "Plusieurs équipes, domaines très différents, scaler parties"
  -> MICROSERVICES

  OUI à "Réactions en temps réel, découplage entre features"
  -> EVENT-DRIVEN

  OUI à "Trafic très variable, batch jobs, pas de serveur à gérer"
  -> SERVERLESS

  ──────────────────────────────────────────────────────────────

  EN CAS DE DOUTE : commencer par LAYERED.
  C'est l'architecture qui offre le meilleur compromis :
  - Assez simple pour les petits projets
  - Assez structurée pour les projets moyens
  - Facilement refactorable vers Clean ou Microservices

================================================================================
8. COMBINAISONS D'ARCHITECTURES
================================================================================

  Les architectures ne sont pas mutuellement exclusives.
  En pratique, on en combine souvent plusieurs :

  Exemple 1 : E-commerce réel
  ────────────────────────────
    Architecture globale    : Microservices
    Dans chaque service     : Layered ou Clean
    Communication services  : Event-Driven (Kafka)
    Service de rapports     : Serverless (Lambda pour les jobs)

  Exemple 2 : Application SaaS
  ──────────────────────────────
    Backend API             : Layered (Flask)
    Envoi d'emails          : Event-Driven (Celery + Redis)
    Traitement images       : Serverless (Lambda)
    Tests                   : Hexagonale (ports/adapters pour les mocks)

  Exemple 3 : Startup en croissance
  ───────────────────────────────────
    Phase 1 (0-100 users)   : Monolithique
    Phase 2 (100-1000)      : MVC ou Layered
    Phase 3 (1000-10000)    : Layered + Event-Driven
    Phase 4 (10000+)        : Extraction progressive en Microservices

================================================================================
RÉSUMÉ FINAL
================================================================================

  Il n'existe pas de "meilleure" architecture.
  Il existe des architectures ADAPTÉES au contexte.

  La bonne architecture est celle qui :
  [OK] Résout le PROBLÈME ACTUEL
  [OK] Peut ÉVOLUER avec le projet
  [OK] Est COMPRISE par toute l'équipe
  [OK] Ne coûte pas plus qu'elle n'apporte

  Règle d'or : KISS (Keep It Simple, Stupid)
  Commencer simple. Complexifier SEULEMENT si nécessaire.

  "Premature optimization is the root of all evil" - Donald Knuth
  "Premature architecture is the root of all complexity" - Paraphrase

  Prochain fichier : architecture_patterns.txt

================================================================================
FIN DU FICHIER architecture_comparaison.txt
================================================================================

================================================================================
         DESIGN PATTERNS ET ARCHITECTURES
         Guide complet des patterns associés
================================================================================

================================================================================
1. INTRODUCTION
================================================================================

Un Design Pattern (patron de conception) est une SOLUTION RÉUTILISABLE à un
problème récurrent dans la conception logicielle.

Les patterns s'utilisent À L'INTÉRIEUR des architectures pour résoudre des
problèmes spécifiques : comment créer un objet ? Comment communiquer entre
composants ? Comment gérer les changements d'état ?

CATÉGORIES DE PATTERNS (GoF - Gang of Four) :
──────────────────────────────────────────────
  CRÉATIONNELS  : Comment créer les objets
    -> Singleton, Factory, Abstract Factory, Builder, Prototype

  STRUCTURAUX   : Comment assembler les objets
    -> Adapter, Decorator, Facade, Composite, Proxy

  COMPORTEMENTAUX : Comment les objets communiquent
    -> Observer, Strategy, Command, Iterator, Template Method

================================================================================
2. SINGLETON
================================================================================

DÉFINITION : Une seule instance d'une classe dans tout le programme.

UTILISÉ DANS : Toutes les architectures, pour les ressources partagées.

EXEMPLE D'UTILISATION :
  - Connexion à la base de données (une seule connexion)
  - Event Bus (un seul bus partagé)
  - Configuration (un seul objet config)
  - Logger (un seul logger)

──────────────────────────────────────────────
CODE PYTHON COMPLET
──────────────────────────────────────────────

# patterns/singleton.py

class Singleton:
    """
    Implémentation du pattern Singleton en Python.
    
    __new__ : méthode appelée AVANT __init__ pour créer l'instance.
    On y intercepte la création pour s'assurer de n'en créer qu'une.
    """

    # Attribut de classe (partagé entre toutes les instances)
    _instance = None

    def __new__(cls):
        """
        Surcharge de la création d'objet.
        Si l'instance n'existe pas encore -> on la crée.
        Si elle existe déjà -> on retourne l'existante.
        """
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self):
        """
        __init__ est appelé même si on retourne l'instance existante.
        On utilise un flag pour éviter la réinitialisation.
        """
        if not hasattr(self, '_initialized'):
            self._initialized = True
            self.data = {}  # Données partagées
            print("Singleton créé !")


# ── EXEMPLE CONCRET : Event Bus Singleton ─────────────────────

class EventBusSingleton:
    """Event Bus implémenté comme Singleton."""

    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._subscribers = {}
        return cls._instance

    def subscribe(self, event_type, handler):
        if event_type not in self._subscribers:
            self._subscribers[event_type] = []
        self._subscribers[event_type].append(handler)

    def publish(self, event_type, data):
        for handler in self._subscribers.get(event_type, []):
            handler(data)


# ── UTILISATION ───────────────────────────────────────────────

# Peu importe combien de fois on crée EventBusSingleton(),
# c'est toujours la même instance.
bus1 = EventBusSingleton()
bus2 = EventBusSingleton()

bus1.subscribe('user.created', lambda d: print(f"Email envoyé à {d['email']}"))
bus2.publish('user.created', {'email': 'alice@test.com'})
# Output: Email envoyé à alice@test.com
# bus1 et bus2 sont le MÊME objet
print(bus1 is bus2)  # True


# ── ALTERNATIVE PYTHONIQUE : Module comme Singleton ───────────

# En Python, un module est naturellement un Singleton.
# Les variables de module ne sont initialisées qu'une fois.

# config.py (utilisé comme Singleton)
# DATABASE_URL = "sqlite:///app.db"
# SECRET_KEY = "super-secret"
# Ces valeurs sont partagées par tous les imports de config.py


# ── ATTENTION : PROBLÈMES DU SINGLETON ───────────────────────

# 1. DIFFICILE À TESTER (état partagé entre tests)
#    Solution : permettre de réinitialiser pour les tests
class TestableSingleton:
    _instance = None

    @classmethod
    def reset(cls):
        """Réinitialise le Singleton pour les tests."""
        cls._instance = None

# 2. PROBLÈMES AVEC LE MULTITHREADING
#    Solution : utiliser threading.Lock
import threading

class ThreadSafeSingleton:
    _instance = None
    _lock = threading.Lock()  # Verrou pour la concurrence

    def __new__(cls):
        with cls._lock:  # Un seul thread à la fois
            if cls._instance is None:
                cls._instance = super().__new__(cls)
        return cls._instance

================================================================================
3. FACTORY METHOD
================================================================================

DÉFINITION : Déléguer la création d'objets à des sous-classes ou méthodes.

UTILISÉ DANS : Layered, Clean, Hexagonale (pour créer les objets des couches inférieures)

EXEMPLE D'UTILISATION :
  - Créer différents types de notifications (email, SMS, push)
  - Créer différentes connexions DB (SQLite, PostgreSQL, MongoDB)
  - Créer différents parsers (JSON, XML, CSV)

──────────────────────────────────────────────
CODE PYTHON COMPLET
──────────────────────────────────────────────

# patterns/factory.py
from abc import ABC, abstractmethod


# ── PRODUITS : Les objets créés par la Factory ────────────────

class Notification(ABC):
    """Interface commune pour toutes les notifications."""

    @abstractmethod
    def send(self, recipient: str, message: str) -> bool:
        """Envoie la notification. Retourne True si succès."""
        ...

    @abstractmethod
    def get_type(self) -> str:
        """Retourne le type de notification."""
        ...


class EmailNotification(Notification):
    """Notification par email."""

    def __init__(self, smtp_host: str = 'localhost'):
        self.smtp_host = smtp_host

    def send(self, recipient: str, message: str) -> bool:
        print(f"[EMAIL] EMAIL -> {recipient}: {message}")
        # En vrai : smtplib.SMTP(self.smtp_host)...
        return True

    def get_type(self) -> str:
        return 'email'


class SMSNotification(Notification):
    """Notification par SMS."""

    def __init__(self, api_key: str = ''):
        self.api_key = api_key

    def send(self, recipient: str, message: str) -> bool:
        print(f"[MOBILE] SMS -> {recipient}: {message}")
        # En vrai : Twilio API...
        return True

    def get_type(self) -> str:
        return 'sms'


class PushNotification(Notification):
    """Notification push (application mobile)."""

    def send(self, recipient: str, message: str) -> bool:
        print(f"[NOTIF] PUSH -> {recipient}: {message}")
        return True

    def get_type(self) -> str:
        return 'push'


# ── FACTORY : Crée les objets selon un paramètre ─────────────

class NotificationFactory:
    """
    Factory pour créer des Notifications.
    
    AVANTAGE : Le code appelant ne connaît pas les classes concrètes.
    Il demande juste "donne-moi une notification de type email".
    Si on ajoute WhatsApp, on n'a qu'à modifier la Factory.
    """

    @staticmethod
    def create(notification_type: str, **config) -> Notification:
        """
        Crée une Notification selon le type demandé.
        
        Args:
            notification_type : 'email', 'sms', 'push'
            **config          : configuration spécifique au type
        
        Returns:
            Instance de la bonne sous-classe de Notification
        
        Raises:
            ValueError: si le type est inconnu
        """
        creators = {
            'email': lambda: EmailNotification(
                smtp_host=config.get('smtp_host', 'localhost')
            ),
            'sms': lambda: SMSNotification(
                api_key=config.get('api_key', '')
            ),
            'push': lambda: PushNotification()
        }

        creator = creators.get(notification_type.lower())
        if not creator:
            raise ValueError(
                f"Type de notification inconnu: '{notification_type}'. "
                f"Types valides: {list(creators.keys())}"
            )

        return creator()


# ── UTILISATION ───────────────────────────────────────────────

# Dans le service, on crée la notification sans connaître les détails
def notify_user(user_email: str, message: str, channel: str = 'email'):
    """
    Notifie un utilisateur via le canal choisi.
    Utilise la Factory pour créer la bonne notification.
    """
    notification = NotificationFactory.create(channel)
    success = notification.send(user_email, message)
    return success

# Exemples d'utilisation
notify_user('alice@test.com', 'Bienvenue !', channel='email')
notify_user('+33612345678', 'Code: 1234', channel='sms')
notify_user('user_token', 'Nouvelle tâche', channel='push')

# ── ABSTRACT FACTORY : Familles d'objets liés ─────────────────

class DatabaseFactory(ABC):
    """
    Abstract Factory pour créer des composants DB.
    Permet de switcher toute la DB d'un coup (SQLite -> PostgreSQL).
    """

    @abstractmethod
    def create_connection(self):
        """Crée une connexion à la DB."""
        ...

    @abstractmethod
    def create_query_builder(self):
        """Crée un constructeur de requêtes."""
        ...


class SQLiteFactory(DatabaseFactory):
    """Factory pour SQLite."""
    def create_connection(self):
        return "sqlite:///app.db"
    def create_query_builder(self):
        return "SQLiteQueryBuilder"


class PostgreSQLFactory(DatabaseFactory):
    """Factory pour PostgreSQL."""
    def create_connection(self):
        return "postgresql://user:pass@localhost/db"
    def create_query_builder(self):
        return "PostgreSQLQueryBuilder"

================================================================================
4. REPOSITORY
================================================================================

DÉFINITION : Abstraction de l'accès aux données. Présente une collection
             d'objets du domaine sans exposer les détails de la persistance.

UTILISÉ DANS : Layered, Clean, Hexagonale (port sortant)

──────────────────────────────────────────────
CODE PYTHON COMPLET
──────────────────────────────────────────────

# patterns/repository.py
from abc import ABC, abstractmethod
from typing import Optional, List, TypeVar, Generic
from dataclasses import dataclass

T = TypeVar('T')  # Type générique pour les entités


class Repository(ABC, Generic[T]):
    """
    Interface Repository générique.
    T = type de l'entité (User, Task, Product...)
    
    Toutes les opérations CRUD de base sont définies ici.
    """

    @abstractmethod
    def find_by_id(self, entity_id: int) -> Optional[T]:
        ...

    @abstractmethod
    def find_all(self) -> List[T]:
        ...

    @abstractmethod
    def save(self, entity: T) -> T:
        ...

    @abstractmethod
    def delete(self, entity_id: int) -> bool:
        ...


@dataclass
class Product:
    """Entité Produit pour l'exemple."""
    name: str
    price: float
    id: Optional[int] = None
    stock: int = 0

    def to_dict(self):
        return {'id': self.id, 'name': self.name, 'price': self.price, 'stock': self.stock}


class ProductRepository(Repository[Product]):
    """Repository spécifique aux produits avec méthodes métier."""

    @abstractmethod
    def find_by_name(self, name: str) -> Optional[Product]:
        ...

    @abstractmethod
    def find_by_price_range(self, min_price: float, max_price: float) -> List[Product]:
        ...

    @abstractmethod
    def find_out_of_stock(self) -> List[Product]:
        ...


# ── IMPLÉMENTATION IN-MEMORY (Tests) ──────────────────────────

class InMemoryProductRepository(ProductRepository):
    """Repository en mémoire pour les tests."""

    def __init__(self):
        self._products = {}
        self._next_id = 1

    def find_by_id(self, entity_id: int) -> Optional[Product]:
        return self._products.get(entity_id)

    def find_all(self) -> List[Product]:
        return list(self._products.values())

    def save(self, entity: Product) -> Product:
        if entity.id is None:
            entity.id = self._next_id
            self._next_id += 1
        self._products[entity.id] = entity
        return entity

    def delete(self, entity_id: int) -> bool:
        if entity_id in self._products:
            del self._products[entity_id]
            return True
        return False

    def find_by_name(self, name: str) -> Optional[Product]:
        return next((p for p in self._products.values() if p.name == name), None)

    def find_by_price_range(self, min_price: float, max_price: float) -> List[Product]:
        return [p for p in self._products.values() if min_price <= p.price <= max_price]

    def find_out_of_stock(self) -> List[Product]:
        return [p for p in self._products.values() if p.stock == 0]

================================================================================
5. OBSERVER (PUB/SUB)
================================================================================

DÉFINITION : Des objets (Observers) s'abonnent à un sujet (Subject/Observable)
             pour être notifiés quand son état change.

UTILISÉ DANS : Event-Driven, pour les notifications dans toutes les architectures

──────────────────────────────────────────────
CODE PYTHON COMPLET
──────────────────────────────────────────────

# patterns/observer.py
from abc import ABC, abstractmethod
from typing import List, Any


# ── INTERFACE OBSERVER ─────────────────────────────────────────

class Observer(ABC):
    """Interface que chaque observer doit implémenter."""

    @abstractmethod
    def update(self, event_type: str, data: Any) -> None:
        """
        Méthode appelée quand l'observable notifie les observers.
        
        Args:
            event_type : type de l'événement
            data       : données associées à l'événement
        """
        ...


# ── OBSERVABLE (SUJET) ────────────────────────────────────────

class Observable:
    """Mixin qui ajoute la capacité d'observer à une classe."""

    def __init__(self):
        self._observers: List[Observer] = []

    def add_observer(self, observer: Observer) -> None:
        """Enregistre un observer."""
        if observer not in self._observers:
            self._observers.append(observer)

    def remove_observer(self, observer: Observer) -> None:
        """Retire un observer."""
        self._observers.remove(observer)

    def notify_observers(self, event_type: str, data: Any) -> None:
        """Notifie tous les observers enregistrés."""
        for observer in self._observers:
            try:
                observer.update(event_type, data)
            except Exception as e:
                print(f"Erreur observer {observer}: {e}")


# ── EXEMPLE CONCRET : Gestion du stock ────────────────────────

class InventoryService(Observable):
    """
    Service d'inventaire qui notifie les observers lors des changements.
    """

    def __init__(self):
        super().__init__()
        self._stock = {}

    def update_stock(self, product_id: int, quantity: int) -> None:
        """Met à jour le stock et notifie les observers si nécessaire."""
        old_quantity = self._stock.get(product_id, 0)
        self._stock[product_id] = quantity

        # Notifier les observers selon la situation
        if quantity == 0:
            self.notify_observers('out_of_stock', {
                'product_id': product_id,
                'old_quantity': old_quantity
            })
        elif old_quantity == 0 and quantity > 0:
            self.notify_observers('back_in_stock', {
                'product_id': product_id,
                'new_quantity': quantity
            })
        elif quantity < 10:
            self.notify_observers('low_stock', {
                'product_id': product_id,
                'quantity': quantity
            })


class EmailAlertObserver(Observer):
    """Observer qui envoie des emails selon le stock."""

    def update(self, event_type: str, data: Any) -> None:
        if event_type == 'out_of_stock':
            print(f"[EMAIL] EMAIL : Produit {data['product_id']} en rupture de stock !")
        elif event_type == 'low_stock':
            print(f"[EMAIL] EMAIL : Stock faible pour produit {data['product_id']} : {data['quantity']} unités")
        elif event_type == 'back_in_stock':
            print(f"[EMAIL] EMAIL : Produit {data['product_id']} à nouveau en stock !")


class SlackAlertObserver(Observer):
    """Observer qui envoie des alertes Slack."""

    def update(self, event_type: str, data: Any) -> None:
        if event_type == 'out_of_stock':
            print(f"[SPEECH_BALLOON] SLACK #stock-alerts : [ALERTE] Produit {data['product_id']} ÉPUISÉ")


# ── UTILISATION ───────────────────────────────────────────────

inventory = InventoryService()

# Enregistrement des observers
inventory.add_observer(EmailAlertObserver())
inventory.add_observer(SlackAlertObserver())

# Les observers sont notifiés automatiquement
inventory.update_stock(product_id=42, quantity=5)   # -> Low stock alerts
inventory.update_stock(product_id=42, quantity=0)    # -> Out of stock alerts
inventory.update_stock(product_id=42, quantity=100)  # -> Back in stock alerts

================================================================================
6. STRATEGY
================================================================================

DÉFINITION : Définir une famille d'algorithmes, les encapsuler et les rendre
             interchangeables. Permet de changer d'algorithme sans modifier
             le code appelant.

UTILISÉ DANS : Services (différentes stratégies de calcul, tri, authentification)

──────────────────────────────────────────────
CODE PYTHON COMPLET
──────────────────────────────────────────────

# patterns/strategy.py
from abc import ABC, abstractmethod
from typing import List


# ── INTERFACE STRATÉGIE ───────────────────────────────────────

class PricingStrategy(ABC):
    """Stratégie de calcul de prix."""

    @abstractmethod
    def calculate_price(self, base_price: float, user_type: str) -> float:
        """Calcule le prix final."""
        ...

    @abstractmethod
    def get_name(self) -> str:
        """Nom de la stratégie (pour affichage)."""
        ...


# ── IMPLÉMENTATIONS ───────────────────────────────────────────

class RegularPricingStrategy(PricingStrategy):
    """Prix normal, aucune réduction."""

    def calculate_price(self, base_price: float, user_type: str) -> float:
        return base_price

    def get_name(self) -> str:
        return "Prix normal"


class PremiumPricingStrategy(PricingStrategy):
    """20% de réduction pour les membres premium."""

    DISCOUNT = 0.20

    def calculate_price(self, base_price: float, user_type: str) -> float:
        return base_price * (1 - self.DISCOUNT)

    def get_name(self) -> str:
        return "Prix Premium (-20%)"


class StudentPricingStrategy(PricingStrategy):
    """50% de réduction pour les étudiants."""

    def calculate_price(self, base_price: float, user_type: str) -> float:
        return base_price * 0.5

    def get_name(self) -> str:
        return "Prix étudiant (-50%)"


class DynamicPricingStrategy(PricingStrategy):
    """
    Prix dynamique selon la demande.
    Exemple : prix monte si stock faible.
    """

    def __init__(self, stock_level: int):
        self.stock_level = stock_level

    def calculate_price(self, base_price: float, user_type: str) -> float:
        if self.stock_level < 5:
            return base_price * 1.5   # +50% si stock faible
        elif self.stock_level < 20:
            return base_price * 1.2   # +20% si stock moyen
        return base_price

    def get_name(self) -> str:
        return f"Prix dynamique (stock: {self.stock_level})"


# ── CONTEXTE : Utilise la stratégie ───────────────────────────

class PriceCalculator:
    """
    Contexte qui utilise une stratégie de pricing.
    La stratégie peut être changée à l'exécution.
    """

    def __init__(self, strategy: PricingStrategy):
        self._strategy = strategy

    def set_strategy(self, strategy: PricingStrategy) -> None:
        """Change la stratégie à l'exécution."""
        self._strategy = strategy

    def get_price(self, base_price: float, user_type: str = 'regular') -> dict:
        """Calcule le prix avec la stratégie actuelle."""
        final_price = self._strategy.calculate_price(base_price, user_type)
        return {
            'base_price': base_price,
            'final_price': round(final_price, 2),
            'strategy': self._strategy.get_name(),
            'savings': round(base_price - final_price, 2)
        }


# ── FACTORY + STRATEGY : Choisir la stratégie selon le contexte

def get_pricing_strategy(user_type: str, stock: int = 100) -> PricingStrategy:
    """
    Retourne la stratégie de pricing appropriée.
    Combine Factory (création) et Strategy (comportement).
    """
    strategies = {
        'premium': PremiumPricingStrategy(),
        'student': StudentPricingStrategy(),
        'regular': RegularPricingStrategy()
    }

    base_strategy = strategies.get(user_type, RegularPricingStrategy())

    # Si stock faible, utiliser la stratégie dynamique (override)
    if stock < 10:
        return DynamicPricingStrategy(stock)

    return base_strategy


# ── UTILISATION ───────────────────────────────────────────────

calc = PriceCalculator(RegularPricingStrategy())

# Calcul pour différents types d'utilisateurs
for user_type in ['regular', 'premium', 'student']:
    strategy = get_pricing_strategy(user_type)
    calc.set_strategy(strategy)
    result = calc.get_price(100.0, user_type)
    print(f"{user_type}: {result}")

================================================================================
7. DECORATOR
================================================================================

DÉFINITION : Ajouter dynamiquement des comportements à un objet sans modifier
             sa classe. Alternative à l'héritage.

UTILISÉ DANS : Middleware Flask, ajout de logging/cache/validation

──────────────────────────────────────────────
CODE PYTHON COMPLET
──────────────────────────────────────────────

# patterns/decorator.py
import time
import functools
import logging
from typing import Callable, Any


# ── DECORATEURS DE FONCTIONS (style Python) ───────────────────

def log_execution(func: Callable) -> Callable:
    """
    Décorateur qui logue l'exécution d'une fonction.
    Utile pour débugger et monitorer les performances.
    """
    @functools.wraps(func)  # Préserve le nom et docstring de la fonction
    def wrapper(*args, **kwargs):
        logger = logging.getLogger(func.__module__)
        logger.info(f"Appel de {func.__name__}({args}, {kwargs})")

        start_time = time.time()
        try:
            result = func(*args, **kwargs)
            duration = (time.time() - start_time) * 1000  # En millisecondes
            logger.info(f"{func.__name__} exécutée en {duration:.2f}ms")
            return result
        except Exception as e:
            logger.error(f"{func.__name__} a échoué: {e}")
            raise  # Repropage de l'exception

    return wrapper


def validate_input(required_fields: list):
    """
    Décorateur factory : valide que les champs requis sont présents.
    
    Usage :
        @validate_input(['username', 'email', 'password'])
        def create_user(data):
            ...
    """
    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(data: dict, *args, **kwargs):
            missing = [f for f in required_fields if f not in data]
            if missing:
                raise ValueError(f"Champs manquants : {missing}")
            return func(data, *args, **kwargs)
        return wrapper
    return decorator


def cache_result(ttl_seconds: int = 60):
    """
    Décorateur de cache simple.
    Mémorise le résultat pendant TTL secondes.
    """
    def decorator(func: Callable) -> Callable:
        _cache = {}
        _timestamps = {}

        @functools.wraps(func)
        def wrapper(*args):
            cache_key = str(args)
            now = time.time()

            # Vérifier si le résultat est en cache et pas expiré
            if cache_key in _cache:
                if now - _timestamps[cache_key] < ttl_seconds:
                    print(f"[BLEU] Cache HIT pour {func.__name__}({args})")
                    return _cache[cache_key]

            # Calculer et mettre en cache
            print(f"[ROUGE] Cache MISS pour {func.__name__}({args})")
            result = func(*args)
            _cache[cache_key] = result
            _timestamps[cache_key] = now
            return result

        return wrapper
    return decorator


def retry(max_attempts: int = 3, delay: float = 1.0):
    """
    Décorateur de retry : réessaie si la fonction échoue.
    Utile pour les appels réseau qui peuvent échouer temporairement.
    """
    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_error = e
                    if attempt < max_attempts:
                        print(f"Tentative {attempt} échouée. Retry dans {delay}s...")
                        time.sleep(delay)
            raise last_error  # Si toutes les tentatives ont échoué
        return wrapper
    return decorator


# ── UTILISATION ───────────────────────────────────────────────

@log_execution
@validate_input(['username', 'email', 'password'])
def create_user(data: dict):
    """Crée un utilisateur (exemple)."""
    print(f"Création de {data['username']}")
    return {'id': 1, **data}


@cache_result(ttl_seconds=30)
def get_user_stats(user_id: int):
    """Récupère les stats (mise en cache 30 secondes)."""
    time.sleep(0.5)  # Simuler une requête DB lente
    return {'user_id': user_id, 'tasks': 10, 'completed': 7}


@retry(max_attempts=3, delay=0.5)
def call_external_api(url: str):
    """Appel API externe avec retry automatique."""
    import random
    if random.random() < 0.7:  # 70% de chance d'échouer (simulation)
        raise ConnectionError("API temporairement indisponible")
    return {'status': 'ok', 'data': 'résultat'}


# Test
user_data = {'username': 'alice', 'email': 'alice@test.com', 'password': 'secret'}
create_user(user_data)                # Logué + validé automatiquement

stats1 = get_user_stats(1)            # Cache MISS
stats2 = get_user_stats(1)            # Cache HIT (30s)

================================================================================
8. COMMAND PATTERN
================================================================================

DÉFINITION : Encapsuler une action dans un objet Command.
             Permet : undo/redo, queuing, logging des actions.

UTILISÉ DANS : CQRS (Command Query Responsibility Segregation), Event Sourcing

──────────────────────────────────────────────
CODE PYTHON COMPLET
──────────────────────────────────────────────

# patterns/command.py
from abc import ABC, abstractmethod
from typing import List, Any
from dataclasses import dataclass, field
from datetime import datetime


# ── INTERFACE COMMAND ─────────────────────────────────────────

class Command(ABC):
    """Interface pour toutes les commandes."""

    @abstractmethod
    def execute(self) -> Any:
        """Exécute la commande."""
        ...

    @abstractmethod
    def undo(self) -> None:
        """Annule la commande (pour undo/redo)."""
        ...

    @property
    @abstractmethod
    def description(self) -> str:
        """Description de la commande pour le logging."""
        ...


# ── COMMANDES CONCRÈTES ───────────────────────────────────────

class CreateTaskCommand(Command):
    """Commande pour créer une tâche."""

    def __init__(self, task_repo, title: str, user_id: int):
        self.task_repo = task_repo
        self.title = title
        self.user_id = user_id
        self._created_task = None  # Stocké pour le undo

    def execute(self):
        """Crée la tâche."""
        from dataclasses import dataclass

        @dataclass
        class Task:
            title: str
            user_id: int
            id: int = None

        self._created_task = Task(title=self.title, user_id=self.user_id)
        self._created_task = self.task_repo.save(self._created_task)
        return self._created_task

    def undo(self):
        """Supprime la tâche créée (annulation)."""
        if self._created_task and self._created_task.id:
            self.task_repo.delete(self._created_task.id)
            print(f"UNDO: Tâche '{self.title}' supprimée")

    @property
    def description(self) -> str:
        return f"Créer tâche '{self.title}' pour user {self.user_id}"


# ── COMMAND BUS / INVOKER ─────────────────────────────────────

class CommandBus:
    """
    Bus de commandes : exécute et log les commandes.
    Garde un historique pour undo/redo.
    """

    def __init__(self):
        self._history: List[Command] = []
        self._undo_stack: List[Command] = []

    def execute(self, command: Command) -> Any:
        """Exécute une commande et la met dans l'historique."""
        print(f"[RAPIDE] Exécution : {command.description}")
        result = command.execute()
        self._history.append(command)
        self._undo_stack.append(command)
        return result

    def undo(self) -> None:
        """Annule la dernière commande."""
        if not self._undo_stack:
            print("Rien à annuler")
            return
        command = self._undo_stack.pop()
        print(f"<- Annulation : {command.description}")
        command.undo()

    def get_history(self) -> List[str]:
        """Retourne l'historique des commandes (pour l'audit)."""
        return [cmd.description for cmd in self._history]

================================================================================
9. RÉCAPITULATIF : PATTERNS PAR ARCHITECTURE
================================================================================

  ARCHITECTURE       PATTERNS RECOMMANDÉS
  ─────────────────────────────────────────────────────────────────────────────
  Monolithique       Singleton (DB, Config)
                     Factory (création d'objets)
  
  MVC                Singleton (DB)
                     Observer (notifications)
                     Template Method (base controller)
  
  Layered            Repository (accès données)
                     Factory (création services)
                     Strategy (différents algos métier)
                     Decorator (logging, cache, validation)
  
  Clean Architecture Repository (interface + implémentation)
                     Factory (injection de dépendances)
                     Command (Use Cases)
  
  Hexagonale         Repository (port sortant)
                     Adapter (implémentations concrètes)
                     Factory (composition root)
  
  Microservices      Singleton (connexions partagées)
                     Circuit Breaker (résilience)
                     Proxy (communication entre services)
  
  Event-Driven       Observer (Pub/Sub)
                     Command (événements immuables)
                     Chain of Responsibility (pipelines d'events)
  
  Serverless         Singleton (connexion DB warm start)
                     Decorator (logging Lambda)
                     Factory (création de handlers)

================================================================================
RÉSUMÉ
================================================================================

  Les Design Patterns s'utilisent DANS les architectures :
  
  CRÉATIONNELS :
    Singleton -> Un seul objet partagé (EventBus, Config, DB Connection)
    Factory   -> Créer des objets sans connaître la classe concrète
  
  STRUCTURAUX :
    Repository -> Abstraire l'accès aux données
    Decorator  -> Ajouter des comportements (cache, log, validation)
    Adapter    -> Traduire entre deux interfaces incompatibles
  
  COMPORTEMENTAUX :
    Observer  -> Notifier des changements (Pub/Sub)
    Strategy  -> Algorithmes interchangeables
    Command   -> Encapsuler les actions (undo/redo, audit)
  
  Prochain fichier : architecture_pratique.txt (Exercices complets)

================================================================================
FIN DU FICHIER architecture_patterns.txt
================================================================================

================================================================================
         EXERCICES PRATIQUES COMPLETS
         Mini-projets pour chaque architecture
         Avec corrections ultra-détaillées
================================================================================

================================================================================
INTRODUCTION
================================================================================

Ce fichier contient 6 exercices pratiques, un par architecture principale.
Chaque exercice :
  - Décrit le projet à créer
  - Donne des étapes guidées
  - Fournit une correction complète commentée
  - Explique les décisions architecturales

COMMENT UTILISER CE FICHIER :
  1. Lire l'énoncé de l'exercice
  2. Essayer de coder sans regarder la correction (au moins 30 min)
  3. Comparer avec la correction
  4. Comprendre les différences et pourquoi

================================================================================
EXERCICE 1 : MONOLITHIQUE - Gestionnaire de contacts
================================================================================

ÉNONCÉ :
─────────
Créer une API REST monolithique pour gérer des contacts (carnet d'adresses).

FONCTIONNALITÉS :
  - Créer un contact (nom, email, téléphone, catégorie)
  - Lister tous les contacts
  - Rechercher par nom ou email
  - Mettre à jour un contact
  - Supprimer un contact

CONTRAINTES :
  - Architecture monolithique simple
  - Flask + SQLAlchemy
  - Base de données SQLite
  - Réponses JSON

STRUCTURE ATTENDUE :
  contacts_app/
  ├── app.py
  ├── models.py
  └── routes.py

──────────────────────────────────────────────
CORRECTION COMPLÈTE
──────────────────────────────────────────────

# ═══════════════════════════════════════════
# models.py
# ═══════════════════════════════════════════

from flask_sqlalchemy import SQLAlchemy  # ORM pour la DB
from datetime import datetime             # Pour les timestamps

db = SQLAlchemy()  # Objet DB (sera configuré dans app.py)


class Contact(db.Model):
    """
    Modèle Contact : représente un contact dans le carnet d'adresses.
    
    DÉCISION ARCHITECTURALE : Dans un monolithe, le modèle est simple.
    Il définit la structure ET contient des méthodes utilitaires (to_dict).
    Dans une architecture plus avancée, ces responsabilités seraient séparées.
    """

    __tablename__ = 'contacts'

    # Identifiant unique auto-incrémenté
    id = db.Column(db.Integer, primary_key=True)

    # Nom complet : obligatoire, max 100 chars
    name = db.Column(db.String(100), nullable=False)

    # Email : obligatoire, unique (pas deux contacts avec le même email)
    email = db.Column(db.String(120), nullable=False, unique=True)

    # Téléphone : optionnel
    phone = db.Column(db.String(20))

    # Catégorie : 'personal', 'professional', 'family'
    category = db.Column(db.String(50), default='personal')

    # Date de création (remplie automatiquement)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    # Date de modification (mise à jour automatiquement)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    def to_dict(self):
        """
        Convertit le contact en dictionnaire pour la sérialisation JSON.
        
        NOTE : On utilise isoformat() pour les dates car JSON ne connaît
        pas le type datetime de Python. isoformat() donne une string standard.
        """
        return {
            'id': self.id,
            'name': self.name,
            'email': self.email,
            'phone': self.phone,
            'category': self.category,
            'created_at': self.created_at.isoformat(),
            'updated_at': self.updated_at.isoformat() if self.updated_at else None
        }

    def __repr__(self):
        """Représentation pour le debugging."""
        return f'<Contact {self.name} ({self.email})>'


# ═══════════════════════════════════════════
# routes.py
# ═══════════════════════════════════════════

from flask import Blueprint, request, jsonify
from models import db, Contact
from sqlalchemy import or_  # Pour les requêtes OR (recherche)

# Blueprint : groupe de routes avec un préfixe commun
contacts_bp = Blueprint('contacts', __name__, url_prefix='/api/contacts')


@contacts_bp.route('/', methods=['GET'])
def list_contacts():
    """
    GET /api/contacts/
    GET /api/contacts/?search=alice     <- Recherche
    GET /api/contacts/?category=family  <- Filtre par catégorie
    
    DÉCISION : On supporte plusieurs filtres via query parameters.
    C'est la manière RESTful de filtrer des collections.
    """
    # Lecture des paramètres de recherche depuis l'URL
    search = request.args.get('search', '').strip()
    category = request.args.get('category', '').strip()

    # Construction de la requête SQLAlchemy
    query = Contact.query

    if search:
        # Recherche insensible à la casse dans le nom ET l'email
        # ilike = case-insensitive LIKE SQL
        # or_() = condition OR (SQLAlchemy)
        query = query.filter(
            or_(
                Contact.name.ilike(f'%{search}%'),   # Nom contient search
                Contact.email.ilike(f'%{search}%')   # OU email contient search
            )
        )

    if category:
        query = query.filter_by(category=category)

    # Tri alphabétique par nom
    contacts = query.order_by(Contact.name).all()

    return jsonify({
        'contacts': [c.to_dict() for c in contacts],
        'total': len(contacts),
        'filters': {'search': search, 'category': category}
    }), 200


@contacts_bp.route('/<int:contact_id>', methods=['GET'])
def get_contact(contact_id):
    """GET /api/contacts/<id> - Récupère un contact spécifique."""
    # get_or_404 : retourne automatiquement 404 si non trouvé
    contact = Contact.query.get_or_404(contact_id)
    return jsonify(contact.to_dict()), 200


@contacts_bp.route('/', methods=['POST'])
def create_contact():
    """
    POST /api/contacts/
    Corps attendu : {"name":"Alice","email":"alice@test.com","phone":"0612","category":"personal"}
    """
    data = request.get_json()

    # Vérification que le corps JSON est présent
    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    # Validation des champs obligatoires
    if 'name' not in data or not data['name'].strip():
        return jsonify({'error': 'Le nom est requis'}), 400
    if 'email' not in data or not data['email'].strip():
        return jsonify({'error': 'L\'email est requis'}), 400

    # Validation du format email (simple)
    if '@' not in data['email'] or '.' not in data['email']:
        return jsonify({'error': 'Format d\'email invalide'}), 400

    # Vérification de l'unicité de l'email
    if Contact.query.filter_by(email=data['email']).first():
        return jsonify({'error': 'Un contact avec cet email existe déjà'}), 409

    # Validation de la catégorie
    valid_categories = ['personal', 'professional', 'family']
    category = data.get('category', 'personal')
    if category not in valid_categories:
        return jsonify({'error': f'Catégorie invalide. Valeurs: {valid_categories}'}), 400

    # Création du contact
    new_contact = Contact(
        name=data['name'].strip(),
        email=data['email'].strip().lower(),  # Email en minuscules
        phone=data.get('phone', '').strip() or None,  # None si vide
        category=category
    )

    db.session.add(new_contact)
    db.session.commit()

    return jsonify(new_contact.to_dict()), 201


@contacts_bp.route('/<int:contact_id>', methods=['PUT'])
def update_contact(contact_id):
    """PUT /api/contacts/<id> - Met à jour un contact."""
    contact = Contact.query.get_or_404(contact_id)
    data = request.get_json()

    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    # Mise à jour uniquement des champs fournis
    if 'name' in data:
        if not data['name'].strip():
            return jsonify({'error': 'Le nom ne peut pas être vide'}), 400
        contact.name = data['name'].strip()

    if 'email' in data:
        new_email = data['email'].strip().lower()
        # Vérifier l'unicité SEULEMENT si l'email change
        if new_email != contact.email:
            if Contact.query.filter_by(email=new_email).first():
                return jsonify({'error': 'Email déjà utilisé'}), 409
        contact.email = new_email

    if 'phone' in data:
        contact.phone = data['phone'].strip() or None

    if 'category' in data:
        valid_categories = ['personal', 'professional', 'family']
        if data['category'] not in valid_categories:
            return jsonify({'error': f'Catégorie invalide'}), 400
        contact.category = data['category']

    db.session.commit()
    return jsonify(contact.to_dict()), 200


@contacts_bp.route('/<int:contact_id>', methods=['DELETE'])
def delete_contact(contact_id):
    """DELETE /api/contacts/<id> - Supprime un contact."""
    contact = Contact.query.get_or_404(contact_id)
    db.session.delete(contact)
    db.session.commit()
    return '', 204  # 204 No Content = succès sans réponse


# ═══════════════════════════════════════════
# app.py
# ═══════════════════════════════════════════

from flask import Flask
from models import db
from routes import contacts_bp

def create_app():
    app = Flask(__name__)
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///contacts.db'
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

    db.init_app(app)
    app.register_blueprint(contacts_bp)

    with app.app_context():
        db.create_all()

    return app

if __name__ == '__main__':
    app = create_app()
    app.run(debug=True)

# ═══════════════════════════════════════════
# COMMANDES DE TEST
# ═══════════════════════════════════════════

# python app.py

# Créer un contact
# curl -X POST http://localhost:5000/api/contacts/ \
#      -H "Content-Type: application/json" \
#      -d '{"name":"Alice Dupont","email":"alice@test.com","phone":"0612345678","category":"personal"}'

# Rechercher
# curl "http://localhost:5000/api/contacts/?search=alice"

# Lister par catégorie
# curl "http://localhost:5000/api/contacts/?category=personal"

================================================================================
EXERCICE 2 : MVC - Bibliothèque de livres
================================================================================

ÉNONCÉ :
─────────
Créer une API MVC pour gérer une bibliothèque de livres.

FONCTIONNALITÉS :
  - CRUD complet sur les livres (titre, auteur, ISBN, année, genre)
  - CRUD complet sur les auteurs
  - Un auteur peut avoir plusieurs livres (relation One-to-Many)
  - Recherche de livres par auteur, genre, ou titre

STRUCTURE MVC ATTENDUE :
  library_mvc/
  ├── app.py
  ├── models/
  │   ├── __init__.py  <- db + exports
  │   ├── author.py    <- Modèle Author avec méthodes métier
  │   └── book.py      <- Modèle Book avec méthodes métier
  └── controllers/
      ├── author_controller.py
      └── book_controller.py

──────────────────────────────────────────────
CORRECTION COMPLÈTE
──────────────────────────────────────────────

# ═══════════════════════════════════════
# models/__init__.py
# ═══════════════════════════════════════

from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
from .author import Author
from .book import Book

# ═══════════════════════════════════════
# models/author.py
# ═══════════════════════════════════════

from models import db
from datetime import datetime

class Author(db.Model):
    """
    Modèle Author.
    DÉCISION MVC : Le Model contient la logique métier de l'entité.
    Les méthodes de recherche sont dans le Model (pas dans le Controller).
    """
    __tablename__ = 'authors'

    id         = db.Column(db.Integer, primary_key=True)
    name       = db.Column(db.String(100), nullable=False)
    nationality = db.Column(db.String(50))
    birth_year = db.Column(db.Integer)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    # Un auteur a plusieurs livres
    books = db.relationship('Book', backref='author', lazy=True,
                            cascade='all, delete-orphan')

    # ── MÉTHODES DE REQUÊTE (dans le Model, c'est la règle MVC) ──

    @classmethod
    def find_by_name(cls, name):
        """Recherche par nom (insensible à la casse)."""
        return cls.query.filter(cls.name.ilike(f'%{name}%')).all()

    @classmethod
    def find_all_with_books(cls):
        """Retourne les auteurs qui ont au moins un livre."""
        return cls.query.join(cls.books).distinct().all()

    # ── MÉTHODES MÉTIER ───────────────────────────────────────

    def get_book_count(self):
        """Retourne le nombre de livres de cet auteur."""
        return len(self.books)

    def get_latest_book(self):
        """Retourne le livre le plus récent."""
        if not self.books:
            return None
        return max(self.books, key=lambda b: b.publication_year or 0)

    def to_dict(self, include_books=False):
        result = {
            'id': self.id,
            'name': self.name,
            'nationality': self.nationality,
            'birth_year': self.birth_year,
            'book_count': self.get_book_count()
        }
        if include_books:
            result['books'] = [b.to_dict() for b in self.books]
        return result

    def __repr__(self):
        return f'<Author {self.name}>'


# ═══════════════════════════════════════
# models/book.py
# ═══════════════════════════════════════

from models import db
from datetime import datetime

class Book(db.Model):
    """Modèle Book."""
    __tablename__ = 'books'

    GENRES = ['fiction', 'non-fiction', 'science', 'history', 'biography', 'other']

    id               = db.Column(db.Integer, primary_key=True)
    title            = db.Column(db.String(200), nullable=False)
    isbn             = db.Column(db.String(20), unique=True)
    publication_year = db.Column(db.Integer)
    genre            = db.Column(db.String(50), default='other')
    summary          = db.Column(db.Text)
    author_id        = db.Column(db.Integer, db.ForeignKey('authors.id'), nullable=False)
    created_at       = db.Column(db.DateTime, default=datetime.utcnow)

    @classmethod
    def find_by_genre(cls, genre):
        return cls.query.filter_by(genre=genre).all()

    @classmethod
    def find_by_author(cls, author_id):
        return cls.query.filter_by(author_id=author_id).all()

    @classmethod
    def search(cls, query):
        """Recherche multi-champs."""
        from sqlalchemy import or_
        return cls.query.filter(
            or_(
                cls.title.ilike(f'%{query}%'),
                cls.summary.ilike(f'%{query}%')
            )
        ).all()

    def to_dict(self):
        return {
            'id': self.id,
            'title': self.title,
            'isbn': self.isbn,
            'publication_year': self.publication_year,
            'genre': self.genre,
            'summary': self.summary,
            'author_id': self.author_id,
            'author_name': self.author.name if self.author else None
        }


# ═══════════════════════════════════════
# controllers/author_controller.py
# ═══════════════════════════════════════

from flask import Blueprint, request, jsonify
from models import db
from models.author import Author

author_ctrl = Blueprint('authors', __name__, url_prefix='/api/authors')


@author_ctrl.route('/', methods=['GET'])
def index():
    """
    DÉCISION MVC : Le Controller délègue la LOGIQUE DE REQUÊTE au Model.
    Il ne fait pas de SQL direct.
    """
    name_filter = request.args.get('name')

    if name_filter:
        authors = Author.find_by_name(name_filter)  # Méthode du Model
    else:
        authors = Author.query.order_by(Author.name).all()

    return jsonify([a.to_dict() for a in authors]), 200


@author_ctrl.route('/<int:author_id>', methods=['GET'])
def show(author_id):
    """Retourne l'auteur avec ses livres."""
    author = Author.query.get_or_404(author_id)
    # include_books=True : inclut la liste des livres dans la réponse
    return jsonify(author.to_dict(include_books=True)), 200


@author_ctrl.route('/', methods=['POST'])
def create():
    data = request.get_json()
    if not data or 'name' not in data:
        return jsonify({'error': 'name requis'}), 400

    author = Author(
        name=data['name'].strip(),
        nationality=data.get('nationality'),
        birth_year=data.get('birth_year')
    )
    db.session.add(author)
    db.session.commit()
    return jsonify(author.to_dict()), 201


@author_ctrl.route('/<int:author_id>', methods=['DELETE'])
def delete(author_id):
    """
    DÉCISION MVC : La cascade (suppression des livres aussi) est définie
    dans le Model (cascade='all, delete-orphan'), pas dans le Controller.
    """
    author = Author.query.get_or_404(author_id)
    db.session.delete(author)
    db.session.commit()
    return '', 204


# ═══════════════════════════════════════
# controllers/book_controller.py
# ═══════════════════════════════════════

from flask import Blueprint, request, jsonify
from models import db
from models.book import Book
from models.author import Author

book_ctrl = Blueprint('books', __name__, url_prefix='/api/books')


@book_ctrl.route('/', methods=['GET'])
def index():
    """Lister les livres avec filtres."""
    genre  = request.args.get('genre')
    author = request.args.get('author_id', type=int)
    search = request.args.get('search')

    if genre:
        books = Book.find_by_genre(genre)
    elif author:
        books = Book.find_by_author(author)
    elif search:
        books = Book.search(search)
    else:
        books = Book.query.order_by(Book.title).all()

    return jsonify({'books': [b.to_dict() for b in books], 'total': len(books)}), 200


@book_ctrl.route('/', methods=['POST'])
def create():
    data = request.get_json()
    if not data:
        return jsonify({'error': 'Corps JSON requis'}), 400

    for field in ['title', 'author_id']:
        if field not in data:
            return jsonify({'error': f'{field} requis'}), 400

    # Vérifier que l'auteur existe
    if not Author.query.get(data['author_id']):
        return jsonify({'error': 'Auteur non trouvé'}), 404

    # Vérifier genre valide
    genre = data.get('genre', 'other')
    if genre not in Book.GENRES:
        return jsonify({'error': f'Genre invalide. Genres: {Book.GENRES}'}), 400

    book = Book(
        title=data['title'],
        isbn=data.get('isbn'),
        publication_year=data.get('publication_year'),
        genre=genre,
        summary=data.get('summary', ''),
        author_id=data['author_id']
    )
    db.session.add(book)
    db.session.commit()
    return jsonify(book.to_dict()), 201


@book_ctrl.route('/<int:book_id>', methods=['DELETE'])
def delete(book_id):
    book = Book.query.get_or_404(book_id)
    db.session.delete(book)
    db.session.commit()
    return '', 204


================================================================================
EXERCICE 3 : LAYERED - API e-commerce simple
================================================================================

ÉNONCÉ :
─────────
Créer une API en architecture layered pour un mini e-commerce.

FONCTIONNALITÉS :
  - Gestion des produits (CRUD)
  - Gestion des commandes
  - Règles métier : vérifier le stock avant de commander

STRUCTURE ATTENDUE :
  ecommerce/
  ├── app.py
  ├── models/           <- Entités
  ├── repositories/     <- Accès données
  ├── services/         <- Logique métier
  └── controllers/      <- Interface HTTP

──────────────────────────────────────────────
CORRECTION PARTIELLE (Structure et Service)
──────────────────────────────────────────────

# ═══════════════════════════════════════
# services/order_service.py
# ═══════════════════════════════════════
# La partie la plus importante : le Service qui applique les règles métier

from repositories.product_repository import ProductRepository
from repositories.order_repository import OrderRepository
from models.order import Order, OrderItem


class OrderService:
    """
    Service de gestion des commandes.
    RÈGLES MÉTIER :
    1. Un produit doit être disponible (stock > 0)
    2. On ne peut pas commander plus que le stock disponible
    3. Le stock est décrémenté après la commande
    4. Une commande ne peut pas être modifiée après confirmation
    """

    def __init__(self):
        self.product_repo = ProductRepository()
        self.order_repo = OrderRepository()

    def create_order(self, user_id: int, items: list) -> Order:
        """
        Crée une commande.
        
        Args:
            user_id : ID de l'utilisateur qui commande
            items   : [{'product_id': 1, 'quantity': 2}, ...]
        
        Raises:
            ValueError: si produit inexistant ou stock insuffisant
        """

        # ── RÈGLE 1 : Valider tous les produits avant de commencer ────
        # On vérifie TOUT avant de modifier quoi que ce soit.
        # Si une vérification échoue, on annule tout.
        validated_items = []

        for item in items:
            product = self.product_repo.find_by_id(item['product_id'])

            if not product:
                raise ValueError(f"Produit {item['product_id']} non trouvé")

            quantity = item['quantity']
            if quantity <= 0:
                raise ValueError(f"Quantité invalide pour {product.name}: {quantity}")

            # ── RÈGLE 2 : Stock suffisant ──────────────────────────────
            if product.stock < quantity:
                raise ValueError(
                    f"Stock insuffisant pour '{product.name}': "
                    f"{product.stock} disponible, {quantity} demandé"
                )

            validated_items.append({
                'product': product,
                'quantity': quantity,
                'unit_price': product.price  # Prix au moment de la commande
            })

        # ── CRÉATION DE LA COMMANDE ────────────────────────────────────

        # Calculer le total
        total = sum(
            item['unit_price'] * item['quantity']
            for item in validated_items
        )

        # Créer la commande
        order = Order(
            user_id=user_id,
            total_amount=total,
            status='confirmed'
        )
        saved_order = self.order_repo.save(order)

        # ── RÈGLE 3 : Décrémenter le stock ────────────────────────────
        # SEULEMENT après que la commande est confirmée (pas avant !)
        for item in validated_items:
            product = item['product']
            product.stock -= item['quantity']
            self.product_repo.save(product)

            # Créer les lignes de commande
            order_item = OrderItem(
                order_id=saved_order.id,
                product_id=product.id,
                quantity=item['quantity'],
                unit_price=item['unit_price']
            )
            # (Sauvegarder order_item...)

        return saved_order

    def cancel_order(self, order_id: int, user_id: int) -> Order:
        """
        Annule une commande.
        RÈGLE 4 : On ne peut annuler que si le statut le permet.
        """
        order = self.order_repo.find_by_id(order_id)

        if not order:
            raise ValueError(f"Commande {order_id} non trouvée")

        # Vérifier que c'est la commande de l'utilisateur
        if order.user_id != user_id:
            raise PermissionError("Vous ne pouvez pas annuler cette commande")

        # ── RÈGLE 4 : Statuts annulables ──────────────────────────────
        cancellable_statuses = ['pending', 'confirmed']
        if order.status not in cancellable_statuses:
            raise ValueError(
                f"Impossible d'annuler une commande avec le statut '{order.status}'"
            )

        order.status = 'cancelled'
        self.order_repo.save(order)

        # Remettre le stock en place
        for item in order.items:
            product = self.product_repo.find_by_id(item.product_id)
            if product:
                product.stock += item.quantity
                self.product_repo.save(product)

        return order

================================================================================
EXERCICE 4 : CLEAN ARCHITECTURE - Système de réservation
================================================================================

ÉNONCÉ :
─────────
Créer un système de réservation de salles de réunion en Clean Architecture.

FONCTIONNALITÉS :
  - Réserver une salle pour un créneau
  - Vérifier la disponibilité
  - Annuler une réservation
  - Lister ses réservations

RÈGLES MÉTIER :
  - Une salle ne peut pas avoir deux réservations qui se chevauchent
  - On ne peut pas réserver dans le passé
  - Une réservation dure au minimum 30 minutes
  - On ne peut pas réserver plus de 8 heures d'affilée

──────────────────────────────────────────────
CORRECTION : ENTITÉS ET USE CASES CLÉS
──────────────────────────────────────────────

# ═══════════════════════════════════════
# domain/entities/reservation.py
# ═══════════════════════════════════════

from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional


class ReservationError(Exception):
    """Erreurs de réservation."""
    pass


@dataclass
class Reservation:
    """
    Entité Reservation : encode TOUTES les règles métier d'une réservation.
    Pure Python, aucune dépendance externe.
    """

    room_id: int
    user_id: int
    start_time: datetime
    end_time: datetime
    title: str
    id: Optional[int] = None
    status: str = 'active'
    created_at: datetime = field(default_factory=datetime.utcnow)

    # ── CONSTANTES MÉTIER ─────────────────────────────────────
    MIN_DURATION_MINUTES = 30
    MAX_DURATION_HOURS = 8

    def __post_init__(self):
        """Validation des invariants à la création."""
        self._validate_times()
        self._validate_duration()
        self._validate_not_in_past()
        self._validate_title()

    def _validate_times(self):
        """start_time doit être avant end_time."""
        if self.start_time >= self.end_time:
            raise ReservationError("L'heure de fin doit être après l'heure de début")

    def _validate_duration(self):
        """Durée entre MIN_DURATION_MINUTES et MAX_DURATION_HOURS."""
        duration = self.end_time - self.start_time

        min_duration = timedelta(minutes=self.MIN_DURATION_MINUTES)
        max_duration = timedelta(hours=self.MAX_DURATION_HOURS)

        if duration < min_duration:
            raise ReservationError(
                f"Durée minimale : {self.MIN_DURATION_MINUTES} minutes"
            )
        if duration > max_duration:
            raise ReservationError(
                f"Durée maximale : {self.MAX_DURATION_HOURS} heures"
            )

    def _validate_not_in_past(self):
        """On ne peut pas réserver dans le passé."""
        if self.start_time < datetime.utcnow():
            raise ReservationError("Impossible de réserver dans le passé")

    def _validate_title(self):
        """Le titre est obligatoire."""
        if not self.title or not self.title.strip():
            raise ReservationError("Le titre de la réservation est obligatoire")

    def overlaps_with(self, other: 'Reservation') -> bool:
        """
        Vérifie si cette réservation chevauche une autre.
        Règle métier : pas de chevauchement pour la même salle.
        
        Algorithme :
        Deux intervalles [A, B] et [C, D] se chevauchent si :
        A < D ET C < B
        (pas si A >= D ou C >= B)
        """
        if self.room_id != other.room_id:
            return False  # Pas la même salle, pas de conflit

        return self.start_time < other.end_time and other.start_time < self.end_time

    def cancel(self) -> None:
        """Annule la réservation."""
        if self.status != 'active':
            raise ReservationError("Cette réservation est déjà annulée ou terminée")
        self.status = 'cancelled'

    def duration_minutes(self) -> int:
        """Retourne la durée en minutes."""
        return int((self.end_time - self.start_time).total_seconds() / 60)

    def to_dict(self) -> dict:
        return {
            'id': self.id,
            'room_id': self.room_id,
            'user_id': self.user_id,
            'start_time': self.start_time.isoformat(),
            'end_time': self.end_time.isoformat(),
            'title': self.title,
            'status': self.status,
            'duration_minutes': self.duration_minutes()
        }


# ═══════════════════════════════════════
# use_cases/create_reservation.py
# ═══════════════════════════════════════

from dataclasses import dataclass
from datetime import datetime
from domain.entities.reservation import Reservation, ReservationError
from domain.repositories.reservation_repository import ReservationRepositoryInterface


@dataclass
class CreateReservationRequest:
    room_id: int
    user_id: int
    start_time: datetime
    end_time: datetime
    title: str


class CreateReservationUseCase:
    """
    Use Case : Créer une réservation.
    
    RÈGLES APPLIQUÉES :
    1. Validation via l'entité (durée, passé, titre...)
    2. Vérification de disponibilité (pas de chevauchement)
    """

    def __init__(self, reservation_repo: ReservationRepositoryInterface):
        self.repo = reservation_repo

    def execute(self, request: CreateReservationRequest) -> Reservation:
        """
        Crée une réservation si la salle est disponible.
        
        L'entité valide les règles intrinsèques (durée, passé...).
        Le Use Case vérifie les règles contextuelles (disponibilité).
        """

        # ── ÉTAPE 1 : Créer l'entité (valide les règles intrinsèques) ──
        # ReservationError est levée si les règles ne sont pas respectées
        new_reservation = Reservation(
            room_id=request.room_id,
            user_id=request.user_id,
            start_time=request.start_time,
            end_time=request.end_time,
            title=request.title
        )

        # ── ÉTAPE 2 : Vérifier la disponibilité ───────────────────────
        # Récupérer les réservations existantes pour cette salle et période
        existing = self.repo.find_by_room_and_period(
            room_id=request.room_id,
            start_time=request.start_time,
            end_time=request.end_time
        )

        # Vérifier les chevauchements avec chaque réservation existante
        for existing_res in existing:
            if existing_res.status == 'active' and new_reservation.overlaps_with(existing_res):
                raise ReservationError(
                    f"La salle est déjà réservée du {existing_res.start_time} "
                    f"au {existing_res.end_time}"
                )

        # ── ÉTAPE 3 : Sauvegarder ─────────────────────────────────────
        return self.repo.save(new_reservation)


# ═══════════════════════════════════════
# TESTS
# ═══════════════════════════════════════

import pytest
from datetime import datetime, timedelta
from domain.entities.reservation import Reservation, ReservationError


def make_reservation(**kwargs):
    """Helper pour créer des réservations de test."""
    defaults = {
        'room_id': 1,
        'user_id': 1,
        'start_time': datetime.utcnow() + timedelta(hours=1),
        'end_time': datetime.utcnow() + timedelta(hours=2),
        'title': 'Réunion test'
    }
    defaults.update(kwargs)
    return Reservation(**defaults)


def test_valid_reservation():
    """Une réservation valide ne lève pas d'exception."""
    res = make_reservation()
    assert res.status == 'active'
    assert res.duration_minutes() == 60


def test_reservation_too_short():
    """Moins de 30 minutes -> ReservationError."""
    with pytest.raises(ReservationError, match="30 minutes"):
        make_reservation(
            start_time=datetime.utcnow() + timedelta(hours=1),
            end_time=datetime.utcnow() + timedelta(hours=1, minutes=15)
        )


def test_reservation_in_past():
    """Dans le passé -> ReservationError."""
    with pytest.raises(ReservationError, match="passé"):
        make_reservation(
            start_time=datetime.utcnow() - timedelta(hours=2),
            end_time=datetime.utcnow() - timedelta(hours=1)
        )


def test_overlapping_reservations():
    """Deux réservations qui se chevauchent."""
    res1 = make_reservation(
        start_time=datetime.utcnow() + timedelta(hours=1),
        end_time=datetime.utcnow() + timedelta(hours=3)
    )
    res2 = make_reservation(
        start_time=datetime.utcnow() + timedelta(hours=2),
        end_time=datetime.utcnow() + timedelta(hours=4)
    )
    assert res1.overlaps_with(res2) is True


def test_non_overlapping_reservations():
    """Deux réservations consécutives ne se chevauchent pas."""
    res1 = make_reservation(
        start_time=datetime.utcnow() + timedelta(hours=1),
        end_time=datetime.utcnow() + timedelta(hours=2)
    )
    res2 = make_reservation(
        start_time=datetime.utcnow() + timedelta(hours=2),  # Commence exactement quand res1 finit
        end_time=datetime.utcnow() + timedelta(hours=3)
    )
    assert res1.overlaps_with(res2) is False


def test_cancel_reservation():
    """Annulation d'une réservation active."""
    res = make_reservation()
    res.cancel()
    assert res.status == 'cancelled'


def test_cannot_cancel_twice():
    """On ne peut pas annuler deux fois."""
    res = make_reservation()
    res.cancel()
    with pytest.raises(ReservationError):
        res.cancel()

================================================================================
EXERCICE 5 : EVENT-DRIVEN - Système de notifications
================================================================================

ÉNONCÉ :
─────────
Créer un système de notifications multi-canal utilisant l'architecture event-driven.

FONCTIONNALITÉS :
  - Quand un utilisateur s'inscrit -> Email de bienvenue + SMS de confirmation
  - Quand une commande est passée -> Email de confirmation + mise à jour stock
  - Quand le stock est bas -> Alerte admin
  - Dashboard temps réel des statistiques

OBJECTIF : Aucun des subscribers ne doit connaître les autres.
           Ajouter un subscriber = zéro modification du code existant.

──────────────────────────────────────────────
CORRECTION : Exercice de composition
──────────────────────────────────────────────

# Ce système utilise les concepts du fichier architecture_event_driven.txt.
# La correction complète est dans ce fichier.
# Exercice additionnel : ajouter un WebhookSubscriber.

# ═══════════════════════════════════════
# subscribers/webhook_subscriber.py
# ═══════════════════════════════════════

import requests  # Pour les appels HTTP
from events.user_events import UserCreatedEvent
from event_bus.event_bus import event_bus


class WebhookSubscriber:
    """
    Subscriber qui envoie des webhooks vers des URLs externes.
    Permet d'intégrer des services tiers (Zapier, Slack, etc.)
    
    DÉCISION : Ce subscriber est ajouté SANS modifier le code existant.
    C'est la puissance de l'Event-Driven : extensibilité sans modification.
    """

    def __init__(self, webhook_url: str):
        self.webhook_url = webhook_url
        event_bus.subscribe(UserCreatedEvent, self.on_user_created)

    def on_user_created(self, event: UserCreatedEvent) -> None:
        """Envoie un webhook quand un utilisateur est créé."""
        payload = {
            'event': event.event_type,
            'data': event.to_dict(),
            'timestamp': event.occurred_at.isoformat()
        }

        try:
            response = requests.post(
                self.webhook_url,
                json=payload,
                timeout=5,
                headers={'Content-Type': 'application/json'}
            )
            if response.status_code == 200:
                print(f"[OK] Webhook envoyé à {self.webhook_url}")
            else:
                print(f"[ATTENTION] Webhook échoué: {response.status_code}")

        except requests.exceptions.Timeout:
            print(f"[X] Webhook timeout vers {self.webhook_url}")
        except requests.exceptions.ConnectionError:
            print(f"[X] Webhook échec connexion vers {self.webhook_url}")

================================================================================
EXERCICE FINAL : CHOISIR LA BONNE ARCHITECTURE
================================================================================

Pour chaque scénario, choisir et JUSTIFIER l'architecture appropriée.

SCÉNARIO 1 :
────────────
"Je veux créer une application de gestion de budget personnel.
 Je suis seul développeur. Je veux la finir en 2 semaines."

RÉPONSE : MONOLITHIQUE ou MVC
─────────────────────────────
  Pourquoi Monolithique :
  - Seul développeur -> pas besoin de séparation pour le travail en équipe
  - 2 semaines -> délai court, monolithe = démarrage rapide
  - Budget personnel -> domaine simple, pas de logique métier complexe
  
  Pourquoi pas Microservices :
  - Overhead de déploiement disproportionné pour la taille du projet
  
  Structure minimale :
    app.py, models.py (User, Transaction, Category), routes.py


SCÉNARIO 2 :
────────────
"Notre startup traite des données médicales (analyses, prescriptions).
 L'équipe est de 8 développeurs. Les données sont très sensibles.
 On doit être conformes HIPAA. Les règles métier sont complexes."

RÉPONSE : CLEAN ARCHITECTURE ou HEXAGONALE
───────────────────────────────────────────
  Pourquoi Clean/Hexagonale :
  - Données sensibles -> domaine métier isolé, testable sans infrastructure
  - HIPAA -> traçabilité requise (Event-Driven pour les audits)
  - Règles complexes -> domaine séparé facilite la compréhension
  - 8 devs -> architecture claire avec responsabilités bien définies
  
  Architecture recommandée :
  - Hexagonale (domaine métier isolé)
  - + Event-Driven pour le journal d'audit
  - Tests exhaustifs du domaine sans DB


SCÉNARIO 3 :
────────────
"Plateforme e-commerce gérant 50 000 commandes par jour.
 3 équipes indépendantes : Catalogue, Commandes, Livraisons.
 Chaque équipe veut déployer indépendamment."

RÉPONSE : MICROSERVICES + EVENT-DRIVEN
───────────────────────────────────────
  Pourquoi Microservices :
  - 3 équipes indépendantes -> 3 domaines -> 3 services
  - Déploiement indépendant requis -> Microservices
  - Volume élevé -> Scalabilité par service
  
  Pourquoi Event-Driven :
  - Commande passée -> notifie Livraisons (async, découplé)
  - Livraison confirmée -> notifie Finance (async)
  
  Architecture :
  Service Catalogue  : Gestion produits + stock
  Service Commandes  : Création + suivi commandes
  Service Livraisons : Suivi livraisons + notifications client
  Event Bus (Kafka)  : Communication asynchrone entre services


SCÉNARIO 4 :
────────────
"Je veux créer un traitement quotidien qui :
 - Analyse les commandes du jour
 - Génère des rapports PDF
 - Envoie des emails aux managers
 Le trafic est nul 23h/24h et pic à 1h/24h."

RÉPONSE : SERVERLESS
─────────────────────
  Pourquoi Serverless :
  - Trafic très variable (nul 23h/24h) -> payer à l'usage
  - Traitement batch planifié -> Lambda + CloudWatch Events (cron)
  - Pas besoin de serveur qui tourne H24
  
  Architecture :
  Lambda 1 : Cron 23h -> Analyse des commandes du jour -> publie résultat S3
  Lambda 2 : Déclenchée par S3 -> Génère les PDFs -> stocke sur S3
  Lambda 3 : Déclenchée par S3 -> Envoie les emails aux managers

================================================================================
RÉSUMÉ FINAL DU GUIDE COMPLET
================================================================================

  Tu as maintenant un guide complet sur les architectures logicielles.
  
  ARCHITECTURES COUVERTES :
  ─────────────────────────
    [OK] Monolithique     -> Simple, rapide, pour les débuts
    [OK] MVC              -> Standard du web, bien structuré
    [OK] Layered          -> Enterprise, séparation claire des couches
    [OK] Microservices    -> Scalabilité, équipes indépendantes
    [OK] Clean            -> Domaine isolé, testabilité maximale
    [OK] Hexagonale       -> Ports & Adapters, swap technologique
    [OK] Event-Driven     -> Découplage via événements
    [OK] Serverless       -> Pay-per-use, pas de serveur
  
  PATTERNS COUVERTS :
  ───────────────────
    [OK] Singleton        -> Une seule instance partagée
    [OK] Factory          -> Création d'objets polymorphes
    [OK] Repository       -> Abstraction de la persistance
    [OK] Observer         -> Notification automatique
    [OK] Strategy         -> Algorithmes interchangeables
    [OK] Decorator        -> Comportements ajoutés dynamiquement
    [OK] Command          -> Encapsulation des actions
  
  PROCHAINES ÉTAPES :
  ───────────────────
    1. Choisir UN projet personnel et l'implémenter en MVC d'abord
    2. Refactorer ce projet en Layered
    3. Identifier les tests difficiles -> migrer vers Clean Architecture
    4. Lire : "Clean Architecture" - Robert C. Martin
    5. Lire : "Designing Data-Intensive Applications" - Martin Kleppmann

================================================================================
FIN DU FICHIER architecture_pratique.txt
FIN DU GUIDE COMPLET DES ARCHITECTURES
================================================================================