"""
Gestionnaire des référentiels ATIH avec versionnement et indexation.

Ce module gère l'import, le chunking, la vectorisation et l'indexation des référentiels
officiels ATIH (CIM-10, CCAM, Guide Méthodologique MCO).
"""

import hashlib
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional

import faiss
import numpy as np
import pypdf
from pydantic import BaseModel, Field
from sentence_transformers import SentenceTransformer

from pipeline_mco_pmsi.models.metadata import ReferentielVersion

logger = logging.getLogger(__name__)


class Chunk(BaseModel):
    """Représente un chunk de référentiel."""

    chunk_id: str = Field(..., description="Identifiant unique du chunk")
    referentiel_type: str = Field(..., description="Type de référentiel (cim10/ccam/guide_mco)")
    referentiel_version: str = Field(..., description="Version du référentiel")
    content: str = Field(..., description="Contenu textuel du chunk")
    metadata: Dict[str, str] = Field(
        default_factory=dict, description="Métadonnées (section, page, code, etc.)"
    )
    chunk_index: int = Field(..., ge=0, description="Index du chunk dans le référentiel")


class VectorIndex(BaseModel):
    """Représente un index vectoriel."""

    index_hash: str = Field(..., description="Hash SHA-256 de l'index")
    dimension: int = Field(..., description="Dimension des vecteurs")
    num_vectors: int = Field(..., description="Nombre de vecteurs dans l'index")
    index_type: str = Field(..., description="Type d'index (HNSW, IVF, etc.)")
    created_at: datetime = Field(..., description="Date de création de l'index")


class ReferentielsManager:
    """
    Gestionnaire des référentiels ATIH.

    Responsabilités:
    - Import et normalisation des référentiels PDF
    - Génération de hash SHA-256 pour versionnement
    - Chunking intelligent par type de référentiel
    - Vectorisation avec modèle d'embeddings français
    - Indexation HNSW avec FAISS
    - Gestion des versions et métadonnées
    """

    def __init__(
        self,
        data_dir: Path = Path("data/referentiels"),
        embedding_model: Optional[str] = None,
    ):
        """
        Initialise le gestionnaire de référentiels.

        Args:
            data_dir: Répertoire de stockage des référentiels
            embedding_model: Nom du modèle d'embeddings (défaut: CamemBERT-bio)
        """
        self.data_dir = Path(data_dir)
        self.data_dir.mkdir(parents=True, exist_ok=True)

        # Modèle d'embeddings par défaut
        self.embedding_model_name = embedding_model or "camembert-bio"

        # Cache des versions de référentiels
        self._versions_cache: Dict[str, ReferentielVersion] = {}

        logger.info(
            f"ReferentielsManager initialisé avec data_dir={self.data_dir}, "
            f"embedding_model={self.embedding_model_name}"
        )

    def import_referentiel(
        self, file_path: str, referentiel_type: str, version: str
    ) -> ReferentielVersion:
        """
        Importe et normalise un référentiel ATIH.

        Cette méthode:
        1. Lit le fichier PDF
        2. Extrait le texte
        3. Génère un hash SHA-256 du contenu
        4. Enregistre les métadonnées de version

        Args:
            file_path: Chemin vers le fichier PDF du référentiel
            referentiel_type: Type de référentiel ("cim10" | "ccam" | "guide_mco")
            version: Version du référentiel (ex: "2026")

        Returns:
            ReferentielVersion avec hash et métadonnées

        Raises:
            FileNotFoundError: Si le fichier n'existe pas
            ValueError: Si le type de référentiel est invalide
            RuntimeError: Si l'extraction du PDF échoue

        Exigences: 3.1, 3.2, 3.3, 13.1
        """
        # Validation du type de référentiel
        valid_types = ["cim10", "ccam", "guide_mco"]
        if referentiel_type not in valid_types:
            raise ValueError(
                f"Type de référentiel invalide: {referentiel_type}. "
                f"Types valides: {valid_types}"
            )

        # Vérification de l'existence du fichier
        file_path_obj = Path(file_path)
        if not file_path_obj.exists():
            raise FileNotFoundError(f"Fichier référentiel introuvable: {file_path}")

        logger.info(
            f"Import du référentiel {referentiel_type} version {version} depuis {file_path}"
        )

        try:
            # Lecture du fichier PDF
            with open(file_path_obj, "rb") as f:
                pdf_content = f.read()

            # Génération du hash SHA-256 du fichier
            file_hash = hashlib.sha256(pdf_content).hexdigest()
            logger.info(f"Hash SHA-256 du fichier: {file_hash}")

            # Extraction du texte du PDF
            text_content = self._extract_text_from_pdf(file_path_obj)
            logger.info(f"Texte extrait: {len(text_content)} caractères")

            # Sauvegarde du texte extrait pour chunking ultérieur
            text_file_path = self.data_dir / f"{referentiel_type}_{version}_text.txt"
            with open(text_file_path, "w", encoding="utf-8") as f:
                f.write(text_content)
            logger.info(f"Texte sauvegardé dans {text_file_path}")

            # Création de la version du référentiel (sans chunking pour l'instant)
            # Le chunk_count et index_hash seront mis à jour après chunking et indexation
            # Utiliser un hash placeholder (tous zéros) pour l'index_hash
            placeholder_hash = "0" * 64
            
            referentiel_version = ReferentielVersion(
                type=referentiel_type,
                version=version,
                import_date=datetime.now(),
                file_hash=file_hash,
                chunk_count=0,  # Sera mis à jour après chunking
                index_hash=placeholder_hash,  # Sera mis à jour après indexation
            )

            # Mise en cache
            cache_key = f"{referentiel_type}_{version}"
            self._versions_cache[cache_key] = referentiel_version

            logger.info(
                f"Référentiel {referentiel_type} version {version} importé avec succès"
            )

            return referentiel_version

        except Exception as e:
            logger.error(f"Erreur lors de l'import du référentiel: {e}")
            raise RuntimeError(f"Échec de l'import du référentiel: {e}") from e

    def _extract_text_from_pdf(self, pdf_path: Path) -> str:
        """
        Extrait le texte d'un fichier PDF.

        Args:
            pdf_path: Chemin vers le fichier PDF

        Returns:
            Texte extrait du PDF

        Raises:
            RuntimeError: Si l'extraction échoue
        """
        try:
            reader = pypdf.PdfReader(pdf_path)
            text_parts = []

            for page_num, page in enumerate(reader.pages):
                try:
                    text = page.extract_text()
                    if text:
                        text_parts.append(text)
                except Exception as e:
                    logger.warning(f"Erreur extraction page {page_num}: {e}")
                    continue

            full_text = "\n\n".join(text_parts)

            if not full_text.strip():
                logger.warning("Aucun texte extrait du PDF - PDF vide ou sans texte")
                return ""  # Retourner chaîne vide plutôt que lever une erreur

            return full_text

        except Exception as e:
            logger.error(f"Erreur lors de l'extraction du PDF: {e}")
            raise RuntimeError(f"Échec de l'extraction du PDF: {e}") from e

    def get_version_info(self, referentiel_type: str) -> Optional[ReferentielVersion]:
        """
        Récupère les informations de version active d'un référentiel.

        Args:
            referentiel_type: Type de référentiel ("cim10" | "ccam" | "guide_mco")

        Returns:
            ReferentielVersion si trouvé, None sinon

        Exigences: 3.1, 3.2, 3.3
        """
        # Recherche dans le cache
        for key, version in self._versions_cache.items():
            if version.type == referentiel_type:
                return version

        # Si pas dans le cache, recherche dans le répertoire de données
        # (pour une implémentation complète, on pourrait charger depuis une DB)
        logger.warning(
            f"Version du référentiel {referentiel_type} non trouvée dans le cache"
        )
        return None

    def chunk_referentiel(
        self, referentiel: ReferentielVersion
    ) -> List[Chunk]:
        """
        Chunke le référentiel selon la stratégie appropriée.

        Délègue au chunker spécifique selon le type de référentiel:
        - Guide MCO: chunk_guide_mco()
        - CIM-10: chunk_cim10()
        - CCAM: chunk_ccam()

        Args:
            referentiel: Version du référentiel à chunker

        Returns:
            Liste de chunks avec contexte préservé

        Raises:
            ValueError: Si le type de référentiel est inconnu
            RuntimeError: Si le chunking échoue

        Exigences: 23.2, 23.3, 23.4
        """
        logger.info(f"Chunking du référentiel {referentiel.type} version {referentiel.version}")

        # Chargement du texte extrait
        text_file_path = self.data_dir / f"{referentiel.type}_{referentiel.version}_text.txt"
        if not text_file_path.exists():
            raise RuntimeError(
                f"Fichier texte du référentiel introuvable: {text_file_path}"
            )

        with open(text_file_path, "r", encoding="utf-8") as f:
            text_content = f.read()

        # Délégation au chunker spécifique
        if referentiel.type == "guide_mco":
            chunks = self.chunk_guide_mco(text_content, referentiel.version)
        elif referentiel.type == "cim10":
            chunks = self.chunk_cim10(text_content, referentiel.version)
        elif referentiel.type == "ccam":
            chunks = self.chunk_ccam(text_content, referentiel.version)
        else:
            raise ValueError(f"Type de référentiel inconnu: {referentiel.type}")

        logger.info(f"Chunking terminé: {len(chunks)} chunks créés")

        return chunks

    def chunk_guide_mco(self, text: str, version: str) -> List[Chunk]:
        """
        Chunke le Guide Méthodologique MCO avec préservation des règles.

        Stratégie:
        - Chunking par section logique (Chapitre → Sous-section → Règle)
        - Préservation des règles d'exclusion et hiérarchisation
        - Extraction des critères d'éligibilité DP/DAS
        - Taille cible: 512-1024 tokens (≈2048-4096 caractères) avec overlap de 100 tokens (≈400 caractères)

        Args:
            text: Texte complet du guide
            version: Version du guide

        Returns:
            Liste de chunks avec règles complètes

        Exigences: 23.2
        """
        import re
        
        chunks = []
        lines = text.split("\n")
        
        # Paramètres de chunking (en caractères, approximation de tokens)
        target_chunk_size = 3000  # ≈750 tokens
        max_chunk_size = 4096  # ≈1024 tokens
        overlap_size = 400  # ≈100 tokens
        
        current_chunk_lines = []
        current_chunk_size = 0
        chunk_index = 0
        current_section = "Introduction"
        
        # Patterns pour détecter la structure
        chapter_pattern = re.compile(r'^(CHAPITRE|Chapitre|TITRE|Titre)\s+[IVX0-9]+', re.IGNORECASE)
        section_pattern = re.compile(r'^[0-9]+\.[0-9]+\.?\s+', re.IGNORECASE)
        rule_pattern = re.compile(r'^(Règle|RÈGLE|Critère|CRITÈRE|Exclusion|EXCLUSION)', re.IGNORECASE)
        bullet_pattern = re.compile(r'^\s*[-•*]\s+')
        
        def is_structural_boundary(line: str) -> bool:
            """Détecte si une ligne est une frontière structurelle importante."""
            return bool(chapter_pattern.match(line) or section_pattern.match(line))
        
        def is_rule_start(line: str) -> bool:
            """Détecte si une ligne commence une règle."""
            return bool(rule_pattern.match(line))
        
        def should_create_chunk(current_size: int, next_line: str) -> bool:
            """Détermine si on doit créer un nouveau chunk."""
            # Ne jamais créer un chunk trop petit (sauf si on dépasse vraiment la limite)
            min_chunk_size = 500  # Minimum 500 caractères pour avoir du contenu significatif
            
            # Si le chunk actuel est trop petit, ne pas créer de chunk
            # (sauf si on dépasse vraiment max_chunk_size)
            if current_size < min_chunk_size:
                # Seulement créer si on dépasse vraiment la limite max
                if current_size + len(next_line) + 1 > max_chunk_size:
                    return True
                return False
            
            # Ne jamais dépasser la taille max (hard limit) - vérifier AVANT d'ajouter la ligne
            if current_size + len(next_line) + 1 > max_chunk_size:
                return True
            # Si on a atteint la taille cible et qu'on est à une frontière structurelle
            if current_size >= target_chunk_size and is_structural_boundary(next_line):
                return True
            return False
        
        i = 0
        while i < len(lines):
            line = lines[i]
            
            # Mettre à jour la section courante si on détecte un titre
            if chapter_pattern.match(line) or section_pattern.match(line):
                current_section = line.strip()
            
            # Cas spécial: si une seule ligne dépasse max_chunk_size, la découper en morceaux
            if len(line) > max_chunk_size:
                # Si on a du contenu en cours ET qu'il est suffisamment grand, créer d'abord ce chunk
                if current_chunk_lines and current_chunk_size >= 500:
                    chunk_content = "\n".join(current_chunk_lines)
                    chunk = Chunk(
                        chunk_id=f"guide_mco_{version}_{chunk_index}",
                        referentiel_type="guide_mco",
                        referentiel_version=version,
                        content=chunk_content.strip(),
                        metadata={
                            "chunk_type": "section",
                            "section": current_section,
                        },
                        chunk_index=chunk_index,
                    )
                    chunks.append(chunk)
                    chunk_index += 1
                    
                    # Créer l'overlap pour le prochain chunk
                    overlap_lines = []
                    overlap_chars = 0
                    for prev_line in reversed(current_chunk_lines):
                        line_len = len(prev_line) + 1
                        if overlap_chars + line_len <= overlap_size:
                            overlap_lines.insert(0, prev_line)
                            overlap_chars += line_len
                        else:
                            if overlap_chars < 100 and len(prev_line) > 20:
                                overlap_lines.insert(0, prev_line)
                                overlap_chars += line_len
                            break
                    
                    if overlap_chars < 100 or len(overlap_lines) < 3:
                        additional_lines = []
                        additional_chars = overlap_chars
                        for prev_line in reversed(current_chunk_lines):
                            if prev_line not in overlap_lines:
                                line_len = len(prev_line) + 1
                                if additional_chars + line_len <= overlap_size:
                                    additional_lines.insert(0, prev_line)
                                    additional_chars += line_len
                                else:
                                    break
                        if additional_lines:
                            overlap_lines = additional_lines + overlap_lines
                            overlap_chars = additional_chars
                    
                    current_chunk_lines = overlap_lines
                    current_chunk_size = overlap_chars
                else:
                    # Le contenu actuel est trop petit, on va l'inclure dans le premier morceau
                    # de la ligne longue
                    pass
                
                # Découper la ligne longue en morceaux de max_chunk_size
                line_start = 0
                first_piece = True
                while line_start < len(line):
                    # Pour le premier morceau, inclure le contenu actuel s'il existe
                    if first_piece and current_chunk_lines:
                        # Calculer combien d'espace on a pour la ligne
                        available_space = max_chunk_size - current_chunk_size
                        if available_space > 100:  # Au moins 100 caractères
                            line_end = min(line_start + available_space, len(line))
                            line_piece = line[line_start:line_end]
                            
                            # Ajouter au chunk actuel
                            current_chunk_lines.append(line_piece)
                            chunk_content = "\n".join(current_chunk_lines)
                            
                            chunk = Chunk(
                                chunk_id=f"guide_mco_{version}_{chunk_index}",
                                referentiel_type="guide_mco",
                                referentiel_version=version,
                                content=chunk_content.strip(),
                                metadata={
                                    "chunk_type": "section",
                                    "section": current_section,
                                },
                                chunk_index=chunk_index,
                            )
                            chunks.append(chunk)
                            chunk_index += 1
                            line_start = line_end
                            first_piece = False
                            
                            # Créer l'overlap
                            overlap_text = line_piece[-overlap_size:] if len(line_piece) > overlap_size else line_piece
                            current_chunk_lines = [overlap_text]
                            current_chunk_size = len(overlap_text) + 1
                            continue
                    
                    # Morceaux suivants ou si pas de contenu actuel
                    line_end = min(line_start + max_chunk_size, len(line))
                    line_piece = line[line_start:line_end]
                    
                    chunk = Chunk(
                        chunk_id=f"guide_mco_{version}_{chunk_index}",
                        referentiel_type="guide_mco",
                        referentiel_version=version,
                        content=line_piece.strip(),
                        metadata={
                            "chunk_type": "section",
                            "section": current_section,
                            "split_line": "true",  # Marquer comme ligne découpée
                        },
                        chunk_index=chunk_index,
                    )
                    chunks.append(chunk)
                    chunk_index += 1
                    
                    # Créer l'overlap pour le prochain morceau
                    if line_start + max_chunk_size < len(line):  # Pas le dernier morceau
                        overlap_text = line_piece[-overlap_size:] if len(line_piece) > overlap_size else line_piece
                        current_chunk_lines = [overlap_text]
                        current_chunk_size = len(overlap_text) + 1
                    else:
                        # Dernier morceau, réinitialiser
                        current_chunk_lines = []
                        current_chunk_size = 0
                    
                    line_start = line_end
                    first_piece = False
                
                i += 1
                continue
            
            # Vérifier si on doit créer un nouveau chunk AVANT d'ajouter la ligne
            if current_chunk_lines and should_create_chunk(current_chunk_size, line):
                # Créer le chunk actuel
                chunk_content = "\n".join(current_chunk_lines)
                chunk = Chunk(
                    chunk_id=f"guide_mco_{version}_{chunk_index}",
                    referentiel_type="guide_mco",
                    referentiel_version=version,
                    content=chunk_content.strip(),
                    metadata={
                        "chunk_type": "section",
                        "section": current_section,
                    },
                    chunk_index=chunk_index,
                )
                chunks.append(chunk)
                chunk_index += 1
                
                # Créer l'overlap: garder les dernières lignes jusqu'à overlap_size
                # TOUJOURS créer un overlap, même aux frontières structurelles
                overlap_lines = []
                overlap_chars = 0
                
                # Parcourir les lignes en sens inverse pour créer l'overlap
                for prev_line in reversed(current_chunk_lines):
                    line_len = len(prev_line) + 1  # +1 pour le \n
                    if overlap_chars + line_len <= overlap_size:
                        overlap_lines.insert(0, prev_line)
                        overlap_chars += line_len
                    else:
                        # On a atteint la limite, mais on veut s'assurer d'avoir du contenu
                        # Si on n'a que des lignes courtes (titres), ajouter encore une ligne
                        if overlap_chars < 100 and len(prev_line) > 20:
                            overlap_lines.insert(0, prev_line)
                            overlap_chars += line_len
                        break
                
                # S'assurer qu'on a au moins quelques lignes de contenu dans l'overlap
                # Si l'overlap est trop court ou ne contient que des titres, ajouter plus de lignes
                if overlap_chars < 100 or len(overlap_lines) < 3:
                    # Ajouter plus de lignes depuis le chunk précédent
                    additional_lines = []
                    additional_chars = overlap_chars
                    for prev_line in reversed(current_chunk_lines):
                        if prev_line not in overlap_lines:
                            line_len = len(prev_line) + 1
                            if additional_chars + line_len <= overlap_size:
                                additional_lines.insert(0, prev_line)
                                additional_chars += line_len
                            else:
                                break
                    if additional_lines:
                        overlap_lines = additional_lines + overlap_lines
                        overlap_chars = additional_chars
                
                current_chunk_lines = overlap_lines
                current_chunk_size = overlap_chars
            
            # Ajouter la ligne courante
            current_chunk_lines.append(line)
            current_chunk_size += len(line) + 1  # +1 pour le \n
            i += 1
        
        # Créer le dernier chunk s'il reste du contenu
        if current_chunk_lines:
            chunk_content = "\n".join(current_chunk_lines)
            chunk = Chunk(
                chunk_id=f"guide_mco_{version}_{chunk_index}",
                referentiel_type="guide_mco",
                referentiel_version=version,
                content=chunk_content.strip(),
                metadata={
                    "chunk_type": "section",
                    "section": current_section,
                },
                chunk_index=chunk_index,
            )
            chunks.append(chunk)
        
        logger.info(f"Guide MCO chunké en {len(chunks)} chunks avec préservation des règles")
        return chunks

    def chunk_cim10(self, text: str, version: str) -> List[Chunk]:
        """
        Chunke la CIM-10 FR avec préservation des notes d'inclusion/exclusion.

        Stratégie:
        - Chunking par bloc de codes avec notes d'inclusion/exclusion
        - Vectorisation séparée des index alphabétiques et codes analytiques
        - Maintien des liens langage naturel ↔ codes
        - Taille cible: 512-1024 tokens (≈2048-4096 caractères)

        Args:
            text: Texte complet de la CIM-10
            version: Version de la CIM-10

        Returns:
            Liste de chunks avec notes préservées

        Exigences: 23.3
        """
        import re
        
        chunks = []
        lines = text.split("\n")
        
        # Paramètres de chunking
        target_chunk_size = 2500  # ≈625 tokens
        max_chunk_size = 4096  # ≈1024 tokens
        overlap_size = 400  # ≈100 tokens
        
        current_chunk_lines = []
        current_chunk_size = 0
        chunk_index = 0
        current_chapter = "Codes"
        in_note_block = False
        
        # Patterns pour détecter la structure CIM-10
        code_pattern = re.compile(r'^[A-Z][0-9]{2}(\.[0-9]+)?')  # Ex: A00, A00.1
        chapter_pattern = re.compile(r'^(CHAPITRE|Chapitre)\s+[IVX0-9]+', re.IGNORECASE)
        block_pattern = re.compile(r'^[A-Z][0-9]{2}-[A-Z][0-9]{2}')  # Ex: A00-A09
        note_pattern = re.compile(r'^(Inclus|Exclut|Note|Comprend|À l\'exclusion)', re.IGNORECASE)
        
        def is_code_line(line: str) -> bool:
            """Détecte si une ligne contient un code CIM-10."""
            return bool(code_pattern.match(line.strip()))
        
        def is_note_line(line: str) -> bool:
            """Détecte si une ligne est une note d'inclusion/exclusion."""
            return bool(note_pattern.match(line.strip()))
        
        def is_structural_boundary(line: str) -> bool:
            """Détecte une frontière structurelle (chapitre, bloc)."""
            return bool(chapter_pattern.match(line) or block_pattern.match(line))
        
        def should_create_chunk(current_size: int, next_line: str, in_note: bool) -> bool:
            """Détermine si on doit créer un nouveau chunk."""
            # Ne jamais couper au milieu d'un bloc de notes
            if in_note:
                return False
            # Ne jamais dépasser la taille max (hard limit)
            if current_size + len(next_line) + 1 > max_chunk_size:
                return True
            # Si on a atteint la taille cible et qu'on est à une frontière
            if current_size >= target_chunk_size and is_structural_boundary(next_line):
                return True
            return False
        
        i = 0
        while i < len(lines):
            line = lines[i]
            
            # Détecter les blocs de notes
            if is_note_line(line):
                in_note_block = True
            elif line.strip() == "" and in_note_block:
                # Ligne vide après une note = fin du bloc de notes
                in_note_block = False
            
            # Mettre à jour le chapitre courant
            if chapter_pattern.match(line) or block_pattern.match(line):
                current_chapter = line.strip()
            
            # Vérifier si on doit créer un nouveau chunk
            if current_chunk_lines and should_create_chunk(current_chunk_size, line, in_note_block):
                # Créer le chunk actuel
                chunk_content = "\n".join(current_chunk_lines)
                chunk = Chunk(
                    chunk_id=f"cim10_{version}_{chunk_index}",
                    referentiel_type="cim10",
                    referentiel_version=version,
                    content=chunk_content.strip(),
                    metadata={
                        "chunk_type": "code_block",
                        "chapter": current_chapter,
                    },
                    chunk_index=chunk_index,
                )
                chunks.append(chunk)
                chunk_index += 1
                
                # Créer l'overlap
                overlap_lines = []
                overlap_chars = 0
                for prev_line in reversed(current_chunk_lines):
                    if overlap_chars + len(prev_line) <= overlap_size:
                        overlap_lines.insert(0, prev_line)
                        overlap_chars += len(prev_line) + 1
                    else:
                        break
                
                current_chunk_lines = overlap_lines
                current_chunk_size = overlap_chars
            
            # Ajouter la ligne courante
            current_chunk_lines.append(line)
            current_chunk_size += len(line) + 1
            i += 1
        
        # Créer le dernier chunk
        if current_chunk_lines:
            chunk_content = "\n".join(current_chunk_lines)
            chunk = Chunk(
                chunk_id=f"cim10_{version}_{chunk_index}",
                referentiel_type="cim10",
                referentiel_version=version,
                content=chunk_content.strip(),
                metadata={
                    "chunk_type": "code_block",
                    "chapter": current_chapter,
                },
                chunk_index=chunk_index,
            )
            chunks.append(chunk)
        
        logger.info(f"CIM-10 chunkée en {len(chunks)} chunks avec préservation des notes")
        return chunks

    def chunk_ccam(self, text: str, version: str) -> List[Chunk]:
        """
        Chunke la CCAM descriptive avec préservation des extensions ATIH.

        Stratégie:
        - Chunking par acte avec extensions ATIH (codes à 7+3 caractères)
        - Préservation des notes techniques et conditions d'application
        - Vectorisation des index alphabétiques pour recherche en langage naturel
        - Taille cible: 512-1024 tokens (≈2048-4096 caractères)

        Args:
            text: Texte complet de la CCAM
            version: Version de la CCAM

        Returns:
            Liste de chunks avec extensions préservées

        Exigences: 23.4
        """
        import re
        
        chunks = []
        lines = text.split("\n")
        
        # Paramètres de chunking
        target_chunk_size = 2800  # ≈700 tokens
        max_chunk_size = 4096  # ≈1024 tokens
        overlap_size = 400  # ≈100 tokens
        
        current_chunk_lines = []
        current_chunk_size = 0
        chunk_index = 0
        current_section = "Actes"
        in_technical_note = False
        
        # Patterns pour détecter la structure CCAM
        # Code CCAM: 7 caractères (ex: YYYY001) + optionnel 3 caractères extension ATIH
        ccam_code_pattern = re.compile(r'^[A-Z]{4}[0-9]{3}(\+[A-Z0-9]{3})?')
        chapter_pattern = re.compile(r'^(CHAPITRE|Chapitre|SECTION|Section)\s+[0-9]+', re.IGNORECASE)
        note_pattern = re.compile(r'^(Note|NOTE|Remarque|REMARQUE|Condition|CONDITION)', re.IGNORECASE)
        extension_pattern = re.compile(r'^\s*\+[A-Z0-9]{3}')  # Extension ATIH
        
        def is_ccam_code_line(line: str) -> bool:
            """Détecte si une ligne contient un code CCAM."""
            return bool(ccam_code_pattern.match(line.strip()))
        
        def is_extension_line(line: str) -> bool:
            """Détecte si une ligne est une extension ATIH."""
            return bool(extension_pattern.match(line))
        
        def is_note_line(line: str) -> bool:
            """Détecte si une ligne est une note technique."""
            return bool(note_pattern.match(line.strip()))
        
        def is_structural_boundary(line: str) -> bool:
            """Détecte une frontière structurelle (chapitre, section)."""
            return bool(chapter_pattern.match(line))
        
        def should_create_chunk(current_size: int, next_line: str, in_note: bool) -> bool:
            """Détermine si on doit créer un nouveau chunk."""
            # Ne jamais couper au milieu d'une note technique
            if in_note:
                return False
            # Ne jamais dépasser la taille max
            if current_size + len(next_line) > max_chunk_size:
                return True
            # Si on a atteint la taille cible et qu'on est à une frontière
            if current_size >= target_chunk_size and is_structural_boundary(next_line):
                return True
            return False
        
        i = 0
        while i < len(lines):
            line = lines[i]
            
            # Détecter les blocs de notes techniques
            if is_note_line(line):
                in_technical_note = True
            elif line.strip() == "" and in_technical_note:
                # Ligne vide après une note = fin du bloc de notes
                in_technical_note = False
            
            # Mettre à jour la section courante
            if chapter_pattern.match(line):
                current_section = line.strip()
            
            # Vérifier si on doit créer un nouveau chunk
            if current_chunk_lines and should_create_chunk(current_chunk_size, line, in_technical_note):
                # Créer le chunk actuel
                chunk_content = "\n".join(current_chunk_lines)
                chunk = Chunk(
                    chunk_id=f"ccam_{version}_{chunk_index}",
                    referentiel_type="ccam",
                    referentiel_version=version,
                    content=chunk_content.strip(),
                    metadata={
                        "chunk_type": "acte",
                        "section": current_section,
                    },
                    chunk_index=chunk_index,
                )
                chunks.append(chunk)
                chunk_index += 1
                
                # Créer l'overlap
                overlap_lines = []
                overlap_chars = 0
                for prev_line in reversed(current_chunk_lines):
                    if overlap_chars + len(prev_line) <= overlap_size:
                        overlap_lines.insert(0, prev_line)
                        overlap_chars += len(prev_line) + 1
                    else:
                        break
                
                current_chunk_lines = overlap_lines
                current_chunk_size = overlap_chars
            
            # Ajouter la ligne courante
            current_chunk_lines.append(line)
            current_chunk_size += len(line) + 1
            i += 1
        
        # Créer le dernier chunk
        if current_chunk_lines:
            chunk_content = "\n".join(current_chunk_lines)
            chunk = Chunk(
                chunk_id=f"ccam_{version}_{chunk_index}",
                referentiel_type="ccam",
                referentiel_version=version,
                content=chunk_content.strip(),
                metadata={
                    "chunk_type": "acte",
                    "section": current_section,
                },
                chunk_index=chunk_index,
            )
            chunks.append(chunk)
        
        logger.info(f"CCAM chunkée en {len(chunks)} chunks avec préservation des extensions ATIH")
        return chunks

    def build_index(self, chunks: List[Chunk]) -> VectorIndex:
        """
        Construit l'index vectoriel HNSW avec FAISS.

        Cette méthode:
        1. Charge le modèle d'embeddings français médical
        2. Vectorise tous les chunks
        3. Crée un index HNSW avec FAISS
        4. Génère un hash de l'index pour versionnement
        5. Sauvegarde l'index sur disque

        Args:
            chunks: Liste de chunks à indexer

        Returns:
            VectorIndex avec métadonnées

        Raises:
            RuntimeError: Si la vectorisation ou l'indexation échoue
            ValueError: Si la liste de chunks est vide

        Exigences: 23.1, 23.5, 27.1
        """
        if not chunks:
            raise ValueError("La liste de chunks ne peut pas être vide")

        logger.info(f"Construction de l'index vectoriel pour {len(chunks)} chunks")

        try:
            # 1. Charger le modèle d'embeddings
            embeddings_model = self._load_embeddings_model()
            logger.info(f"Modèle d'embeddings chargé: {self.embedding_model_name}")

            # 2. Vectoriser tous les chunks
            logger.info("Vectorisation des chunks en cours...")
            vectors = []
            for i, chunk in enumerate(chunks):
                if i % 100 == 0:
                    logger.info(f"Vectorisation: {i}/{len(chunks)} chunks traités")
                
                # Encoder le contenu du chunk
                vector = embeddings_model.encode(
                    chunk.content,
                    convert_to_numpy=True,
                    normalize_embeddings=True,  # L2 normalization pour cosine similarity
                )
                vectors.append(vector)

            # Convertir en array numpy
            import numpy as np
            vectors_array = np.array(vectors, dtype=np.float32)
            logger.info(f"Vectorisation terminée: {vectors_array.shape}")

            # 3. Créer l'index HNSW avec FAISS
            dimension = vectors_array.shape[1]
            logger.info(f"Création de l'index HNSW avec dimension={dimension}")

            import faiss

            # Paramètres HNSW optimisés pour la recherche
            # M: nombre de connexions par nœud (16-64 recommandé)
            # efConstruction: taille de la liste dynamique pendant la construction (40-500)
            M = 32
            efConstruction = 200

            # Créer l'index HNSW
            index = faiss.IndexHNSWFlat(dimension, M)
            index.hnsw.efConstruction = efConstruction

            # Ajouter les vecteurs à l'index
            index.add(vectors_array)
            logger.info(f"Index HNSW créé avec {index.ntotal} vecteurs")

            # 4. Générer un hash de l'index pour versionnement
            # On hash les paramètres de l'index + le nombre de vecteurs
            index_data = f"{dimension}_{M}_{efConstruction}_{index.ntotal}_{self.embedding_model_name}"
            index_hash = hashlib.sha256(index_data.encode()).hexdigest()
            logger.info(f"Hash de l'index: {index_hash}")

            # 5. Sauvegarder l'index sur disque
            if chunks:
                ref_type = chunks[0].referentiel_type
                ref_version = chunks[0].referentiel_version
                index_path = self.data_dir / f"{ref_type}_{ref_version}_index.faiss"
                faiss.write_index(index, str(index_path))
                logger.info(f"Index sauvegardé dans {index_path}")

                # Sauvegarder aussi les chunks pour pouvoir les récupérer plus tard
                chunks_path = self.data_dir / f"{ref_type}_{ref_version}_chunks.json"
                import json
                with open(chunks_path, "w", encoding="utf-8") as f:
                    chunks_data = [chunk.model_dump() for chunk in chunks]
                    json.dump(chunks_data, f, ensure_ascii=False, indent=2, default=str)
                logger.info(f"Chunks sauvegardés dans {chunks_path}")

            # Créer l'objet VectorIndex
            vector_index = VectorIndex(
                index_hash=index_hash,
                dimension=dimension,
                num_vectors=index.ntotal,
                index_type="HNSW",
                created_at=datetime.now(),
            )

            logger.info("Index vectoriel construit avec succès")
            return vector_index

        except Exception as e:
            logger.error(f"Erreur lors de la construction de l'index: {e}")
            raise RuntimeError(f"Échec de la construction de l'index: {e}") from e

    def _load_embeddings_model(self):
        """
        Charge le modèle d'embeddings français médical.

        Utilise sentence-transformers pour charger un modèle multilingue
        optimisé pour le domaine médical français.

        Modèles recommandés:
        - "camembert-bio" (si disponible)
        - "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" (fallback)

        Returns:
            Modèle d'embeddings chargé

        Raises:
            RuntimeError: Si le chargement du modèle échoue
        """
        try:
            from sentence_transformers import SentenceTransformer

            # Essayer de charger le modèle spécifié
            # Pour un vrai déploiement, on utiliserait un modèle médical français
            # comme DrBERT ou CamemBERT-bio
            # Pour le POC, on utilise un modèle multilingue disponible
            
            model_name = self.embedding_model_name
            
            # Mapping des noms de modèles vers les modèles réels
            model_mapping = {
                "camembert-bio": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
                "drbert": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
            }
            
            # Utiliser le mapping si le modèle est dans la liste
            actual_model_name = model_mapping.get(model_name, model_name)
            
            logger.info(f"Chargement du modèle d'embeddings: {actual_model_name}")
            
            # Force CPU usage to avoid CUDA out of memory errors in tests
            model = SentenceTransformer(actual_model_name, device='cpu')
            
            return model

        except Exception as e:
            logger.error(f"Erreur lors du chargement du modèle d'embeddings: {e}")
            raise RuntimeError(f"Échec du chargement du modèle d'embeddings: {e}") from e

    def vectorize_alphabetical_indexes(
        self, 
        alphabetical_index_text: str, 
        referentiel_type: str,
        version: str
    ) -> List[Chunk]:
        """
        Vectorise les index alphabétiques séparément des codes analytiques.

        Les index alphabétiques permettent de mapper les termes en langage naturel
        (ex: "Gastrite") vers les codes correspondants (ex: "K29.7").

        Stratégie:
        - Extraire les entrées de l'index alphabétique
        - Créer un chunk par entrée ou groupe d'entrées liées
        - Maintenir les liens bidirectionnels terme ↔ code
        - Taille cible: plus petite que les chunks de codes (256-512 tokens)

        Args:
            alphabetical_index_text: Texte de l'index alphabétique
            referentiel_type: Type de référentiel ("cim10" | "ccam")
            version: Version du référentiel

        Returns:
            Liste de chunks d'index alphabétique

        Exigences: 23.5, 27.1, 27.2
        """
        import re
        
        logger.info(f"Vectorisation de l'index alphabétique {referentiel_type} version {version}")
        
        chunks = []
        lines = alphabetical_index_text.split("\n")
        
        # Paramètres de chunking pour index alphabétique (plus petits)
        target_chunk_size = 1500  # ≈375 tokens
        max_chunk_size = 2048  # ≈512 tokens
        
        current_chunk_lines = []
        current_chunk_size = 0
        chunk_index = 0
        current_letter = ""
        
        # Pattern pour détecter les entrées d'index
        # Format typique: "Terme - voir Code" ou "Terme Code"
        if referentiel_type == "cim10":
            entry_pattern = re.compile(r'^([A-ZÀ-Ÿ][a-zà-ÿ\s\-\']+)\s*[-–]\s*(voir|V\.|code)?\s*([A-Z][0-9]{2}\.?[0-9]*)', re.IGNORECASE)
            letter_pattern = re.compile(r'^[A-ZÀ-Ÿ]$')
        else:  # ccam
            entry_pattern = re.compile(r'^([A-ZÀ-Ÿ][a-zà-ÿ\s\-\']+)\s*[-–]\s*([A-Z]{4}[0-9]{3})', re.IGNORECASE)
            letter_pattern = re.compile(r'^[A-ZÀ-Ÿ]$')
        
        def is_letter_header(line: str) -> bool:
            """Détecte si une ligne est un en-tête de lettre (A, B, C, etc.)."""
            return bool(letter_pattern.match(line.strip()))
        
        def should_create_chunk(current_size: int, next_line: str) -> bool:
            """Détermine si on doit créer un nouveau chunk."""
            # Ne jamais dépasser la taille max
            if current_size + len(next_line) > max_chunk_size:
                return True
            # Si on a atteint la taille cible et qu'on est à un en-tête de lettre
            if current_size >= target_chunk_size and is_letter_header(next_line):
                return True
            return False
        
        i = 0
        while i < len(lines):
            line = lines[i]
            
            # Mettre à jour la lettre courante
            if is_letter_header(line):
                current_letter = line.strip()
            
            # Vérifier si on doit créer un nouveau chunk
            if current_chunk_lines and should_create_chunk(current_chunk_size, line):
                # Créer le chunk actuel
                chunk_content = "\n".join(current_chunk_lines)
                
                # Extraire les codes mentionnés dans le chunk pour les métadonnées
                codes_in_chunk = []
                if referentiel_type == "cim10":
                    codes_in_chunk = re.findall(r'[A-Z][0-9]{2}\.?[0-9]*', chunk_content)
                else:  # ccam
                    codes_in_chunk = re.findall(r'[A-Z]{4}[0-9]{3}', chunk_content)
                
                chunk = Chunk(
                    chunk_id=f"{referentiel_type}_alpha_{version}_{chunk_index}",
                    referentiel_type=referentiel_type,
                    referentiel_version=version,
                    content=chunk_content.strip(),
                    metadata={
                        "chunk_type": "alphabetical_index",
                        "letter": current_letter,
                        "codes": ",".join(set(codes_in_chunk[:10])),  # Max 10 codes dans metadata
                    },
                    chunk_index=chunk_index,
                )
                chunks.append(chunk)
                chunk_index += 1
                
                # Pas d'overlap pour les index alphabétiques (entrées indépendantes)
                current_chunk_lines = []
                current_chunk_size = 0
            
            # Ajouter la ligne courante
            current_chunk_lines.append(line)
            current_chunk_size += len(line) + 1
            i += 1
        
        # Créer le dernier chunk
        if current_chunk_lines:
            chunk_content = "\n".join(current_chunk_lines)
            
            # Extraire les codes
            codes_in_chunk = []
            if referentiel_type == "cim10":
                codes_in_chunk = re.findall(r'[A-Z][0-9]{2}\.?[0-9]*', chunk_content)
            else:  # ccam
                codes_in_chunk = re.findall(r'[A-Z]{4}[0-9]{3}', chunk_content)
            
            chunk = Chunk(
                chunk_id=f"{referentiel_type}_alpha_{version}_{chunk_index}",
                referentiel_type=referentiel_type,
                referentiel_version=version,
                content=chunk_content.strip(),
                metadata={
                    "chunk_type": "alphabetical_index",
                    "letter": current_letter,
                    "codes": ",".join(set(codes_in_chunk[:10])),
                },
                chunk_index=chunk_index,
            )
            chunks.append(chunk)
        
        logger.info(f"Index alphabétique vectorisé en {len(chunks)} chunks")
        return chunks

    def rebuild_index(
        self,
        referentiel_type: str,
        version: str,
        code_mapper: Optional['CodeMapper'] = None
    ) -> VectorIndex:
        """
        Reconstruit l'index vectoriel après une mise à jour du référentiel.

        Cette méthode:
        1. Recharge le référentiel depuis le fichier texte
        2. Applique les mappings de codes obsolètes si fournis
        3. Re-chunke le référentiel
        4. Reconstruit l'index vectoriel HNSW
        5. Génère un nouveau index_hash
        6. Met à jour les métadonnées de version

        Args:
            referentiel_type: Type de référentiel ("cim10" | "ccam" | "guide_mco")
            version: Version du référentiel
            code_mapper: Optionnel - CodeMapper pour gérer les codes obsolètes

        Returns:
            VectorIndex avec nouveau hash

        Raises:
            FileNotFoundError: Si le fichier texte du référentiel n'existe pas
            RuntimeError: Si la reconstruction échoue

        Exigences: 13.4
        """
        logger.info(
            f"Reconstruction de l'index pour {referentiel_type} version {version}"
        )

        try:
            # 1. Récupérer la version du référentiel
            cache_key = f"{referentiel_type}_{version}"
            referentiel_version = self._versions_cache.get(cache_key)

            if not referentiel_version:
                raise RuntimeError(
                    f"Version du référentiel {referentiel_type} {version} non trouvée dans le cache"
                )

            # 2. Recharger le texte du référentiel
            text_file_path = self.data_dir / f"{referentiel_type}_{version}_text.txt"
            if not text_file_path.exists():
                raise FileNotFoundError(
                    f"Fichier texte du référentiel introuvable: {text_file_path}"
                )

            with open(text_file_path, "r", encoding="utf-8") as f:
                text_content = f.read()

            logger.info(f"Texte du référentiel rechargé: {len(text_content)} caractères")

            # 3. Appliquer les mappings de codes obsolètes si fournis
            if code_mapper:
                logger.info("Application des mappings de codes obsolètes...")
                text_content = self._apply_code_mappings(
                    text_content, referentiel_type, code_mapper
                )

            # 4. Re-chunker le référentiel
            logger.info("Re-chunking du référentiel...")
            chunks = self.chunk_referentiel(referentiel_version)

            # 5. Reconstruire l'index vectoriel
            logger.info("Reconstruction de l'index vectoriel...")
            vector_index = self.build_index(chunks)

            # 6. Mettre à jour les métadonnées de version avec le nouveau hash
            # Créer une nouvelle instance car ReferentielVersion est frozen
            updated_version = ReferentielVersion(
                type=referentiel_version.type,
                version=referentiel_version.version,
                import_date=referentiel_version.import_date,
                file_hash=referentiel_version.file_hash,
                chunk_count=len(chunks),
                index_hash=vector_index.index_hash
            )

            # Mettre à jour le cache
            self._versions_cache[cache_key] = updated_version

            logger.info(
                f"Index reconstruit avec succès. Nouveau hash: {vector_index.index_hash}"
            )

            return vector_index

        except Exception as e:
            logger.error(f"Erreur lors de la reconstruction de l'index: {e}")
            raise RuntimeError(f"Échec de la reconstruction de l'index: {e}") from e

    def _apply_code_mappings(
        self,
        text: str,
        referentiel_type: str,
        code_mapper: 'CodeMapper'
    ) -> str:
        """
        Applique les mappings de codes obsolètes au texte du référentiel.

        Cette méthode recherche les codes obsolètes dans le texte et les remplace
        par leurs équivalents actuels selon les mappings fournis.

        Args:
            text: Texte du référentiel
            referentiel_type: Type de référentiel ("cim10" | "ccam")
            code_mapper: CodeMapper contenant les mappings

        Returns:
            Texte avec codes obsolètes remplacés

        Note:
            Cette méthode est conservative et ne remplace que les codes
            qui ont un mapping explicite. Les codes inconnus sont laissés intacts.
        """
        import re

        logger.info(f"Application des mappings de codes pour {referentiel_type}")

        # Patterns pour détecter les codes selon le type de référentiel
        if referentiel_type == "cim10":
            # Pattern CIM-10: A00, A00.1, A00.X (includes aliases with letters)
            code_pattern = re.compile(r'\b([A-Z][0-9]{2}(?:\.[0-9A-Z]+)?)\b')
        elif referentiel_type == "ccam":
            # Pattern CCAM: YYYY001, YYYY001+ABC
            code_pattern = re.compile(r'\b([A-Z]{4}[0-9]{3}(?:\+[A-Z0-9]{3})?)\b')
        else:
            # Pour guide_mco, on ne fait pas de remplacement de codes
            return text

        # Fonction de remplacement
        def replace_code(match):
            code = match.group(1)

            # Vérifier si le code est obsolète
            if code_mapper.is_obsolete(code, referentiel_type):
                # Obtenir le code actuel
                current_code = code_mapper.map_code(code, referentiel_type)
                if current_code:
                    logger.debug(f"Remplacement: {code} → {current_code}")
                    return current_code

            # Vérifier si c'est un alias
            mapped_code = code_mapper.map_code(code, referentiel_type)
            if mapped_code:
                logger.debug(f"Alias résolu: {code} → {mapped_code}")
                return mapped_code

            # Code actuel ou inconnu - ne pas modifier
            return code

        # Appliquer les remplacements
        updated_text = code_pattern.sub(replace_code, text)

        # Compter les remplacements effectués
        original_codes = set(code_pattern.findall(text))
        updated_codes = set(code_pattern.findall(updated_text))
        replaced_count = len(original_codes - updated_codes)

        logger.info(f"Mappings appliqués: {replaced_count} codes remplacés")

        return updated_text

