"""
Moteur RAG avec recherche hybride pour les référentiels PMSI.

Ce module implémente la recherche hybride combinant:
- BM25 (recherche par mots-clés)
- Vector Search (recherche sémantique avec HNSW)
- Reciprocal Rank Fusion (RRF) pour fusionner les résultats
"""

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

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

from pipeline_mco_pmsi.rag.referentiels_manager import Chunk, ReferentielsManager

logger = logging.getLogger(__name__)


class CodeCandidate(BaseModel):
    """Représente un candidat de code avec score de similarité."""

    code: str = Field(..., description="Code CIM-10 ou CCAM")
    label: str = Field(..., description="Libellé du code")
    similarity_score: float = Field(..., ge=0.0, le=1.0, description="Score de similarité")
    source: str = Field(..., description="Source: bm25, vector, ou reranked")
    chunk_id: str = Field(..., description="ID du chunk source")
    chunk_text: str = Field(..., description="Texte du chunk source")


class EligibilityCriteria(BaseModel):
    """Critères d'éligibilité extraits du Guide Méthodologique."""

    code: str = Field(..., description="Code concerné")
    code_type: str = Field(..., description="Type de code: dp, dr, das, ccam")
    criteria_text: str = Field(..., description="Texte des critères d'éligibilité")
    exclusion_rules: List[str] = Field(
        default_factory=list, description="Règles d'exclusion"
    )
    hierarchization_rules: List[str] = Field(
        default_factory=list, description="Règles de hiérarchisation"
    )
    guide_section: str = Field(..., description="Section du guide source")


class RAGEngine:
    """
    Moteur RAG avec recherche hybride.

    Responsabilités:
    - Recherche BM25 (mots-clés) dans les référentiels
    - Recherche vectorielle sémantique avec HNSW
    - Fusion des résultats avec Reciprocal Rank Fusion (RRF)
    - Extraction des critères d'éligibilité du Guide Méthodologique
    - Gestion des versions de référentiels

    Exigences: 7.1, 23.7
    """

    def __init__(
        self,
        referentiels_manager: ReferentielsManager,
        data_dir: Path = Path("data/referentiels"),
    ):
        """
        Initialise le moteur RAG.

        Args:
            referentiels_manager: Gestionnaire de référentiels
            data_dir: Répertoire des données de référentiels
        """
        self.referentiels_manager = referentiels_manager
        self.data_dir = Path(data_dir)

        # Cache des index BM25 par référentiel
        self._bm25_indexes: Dict[str, BM25Okapi] = {}

        # Cache des index FAISS par référentiel
        self._faiss_indexes: Dict[str, faiss.Index] = {}

        # Cache des chunks par référentiel
        self._chunks_cache: Dict[str, List[Chunk]] = {}

        # Modèle d'embeddings (partagé avec ReferentielsManager)
        self._embeddings_model: Optional[SentenceTransformer] = None

        # Modèle cross-encoder pour le reranking
        self._reranker_model = None

        logger.info(f"RAGEngine initialisé avec data_dir={self.data_dir}")

    def _get_embeddings_model(self) -> SentenceTransformer:
        """
        Récupère le modèle d'embeddings (lazy loading).

        Returns:
            Modèle d'embeddings chargé
        """
        if self._embeddings_model is None:
            self._embeddings_model = self.referentiels_manager._load_embeddings_model()
        return self._embeddings_model
    def _get_embeddings_model(self) -> SentenceTransformer:
        """
        Récupère le modèle d'embeddings (lazy loading).

        Returns:
            Modèle d'embeddings chargé
        """
        if self._embeddings_model is None:
            self._embeddings_model = self.referentiels_manager._load_embeddings_model()
        return self._embeddings_model

    def _get_reranker_model(self):
        """
        Récupère le modèle cross-encoder pour le reranking (lazy loading).

        Le cross-encoder est plus précis que les embeddings bi-encoders car il
        traite la paire (query, document) ensemble, mais il est plus lent.

        Returns:
            Modèle cross-encoder chargé

        Exigences: 7.4, 27.6
        """
        if self._reranker_model is None:
            from sentence_transformers import CrossEncoder

            # Utiliser un modèle cross-encoder multilingue français
            # Options possibles:
            # - "cross-encoder/ms-marco-MiniLM-L-6-v2" (anglais, rapide)
            # - "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1" (multilingue)
            # Pour le domaine médical français, on pourrait fine-tuner un modèle
            model_name = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"

            logger.info(f"Chargement du modèle cross-encoder: {model_name}")
            
            # Forcer l'utilisation du CPU pour éviter les problèmes de mémoire GPU
            import torch
            device = "cpu" if not torch.cuda.is_available() else "cuda"
            
            self._reranker_model = CrossEncoder(model_name, device=device)
            logger.info(f"Modèle cross-encoder chargé avec succès sur {device}")

        return self._reranker_model

    def _load_chunks(self, referentiel_type: str, version: str) -> List[Chunk]:
        """
        Charge les chunks d'un référentiel depuis le disque.

        Args:
            referentiel_type: Type de référentiel
            version: Version du référentiel

        Returns:
            Liste de chunks

        Raises:
            FileNotFoundError: Si les chunks n'existent pas
        """
        cache_key = f"{referentiel_type}_{version}"

        # Vérifier le cache
        if cache_key in self._chunks_cache:
            return self._chunks_cache[cache_key]

        # Charger depuis le disque
        chunks_path = self.data_dir / f"{referentiel_type}_{version}_chunks.json"
        if not chunks_path.exists():
            raise FileNotFoundError(
                f"Chunks non trouvés pour {referentiel_type} {version}: {chunks_path}"
            )

        with open(chunks_path, "r", encoding="utf-8") as f:
            chunks_data = json.load(f)
            chunks = [Chunk(**chunk_dict) for chunk_dict in chunks_data]

        # Mettre en cache
        self._chunks_cache[cache_key] = chunks
        logger.info(f"Chargé {len(chunks)} chunks pour {referentiel_type} {version}")

        return chunks

    def _load_faiss_index(self, referentiel_type: str, version: str) -> faiss.Index:
        """
        Charge l'index FAISS d'un référentiel depuis le disque.

        Args:
            referentiel_type: Type de référentiel
            version: Version du référentiel

        Returns:
            Index FAISS chargé

        Raises:
            FileNotFoundError: Si l'index n'existe pas
        """
        cache_key = f"{referentiel_type}_{version}"

        # Vérifier le cache
        if cache_key in self._faiss_indexes:
            return self._faiss_indexes[cache_key]

        # Charger depuis le disque
        index_path = self.data_dir / f"{referentiel_type}_{version}_index.faiss"
        if not index_path.exists():
            raise FileNotFoundError(
                f"Index FAISS non trouvé pour {referentiel_type} {version}: {index_path}"
            )

        index = faiss.read_index(str(index_path))

        # Mettre en cache
        self._faiss_indexes[cache_key] = index
        logger.info(
            f"Chargé index FAISS pour {referentiel_type} {version} "
            f"({index.ntotal} vecteurs)"
        )

        return index

    def _build_bm25_index(self, chunks: List[Chunk]) -> BM25Okapi:
        """
        Construit un index BM25 à partir des chunks.

        Args:
            chunks: Liste de chunks à indexer

        Returns:
            Index BM25 construit
        """
        # Tokeniser les chunks (simple split sur les espaces)
        # Pour une meilleure qualité, on pourrait utiliser un tokenizer médical
        tokenized_corpus = [chunk.content.lower().split() for chunk in chunks]

        # Créer l'index BM25
        bm25 = BM25Okapi(tokenized_corpus)

        logger.info(f"Index BM25 construit avec {len(chunks)} documents")
        return bm25

    def _get_bm25_index(self, referentiel_type: str, version: str) -> BM25Okapi:
        """
        Récupère l'index BM25 d'un référentiel (avec cache).

        Args:
            referentiel_type: Type de référentiel
            version: Version du référentiel

        Returns:
            Index BM25
        """
        cache_key = f"{referentiel_type}_{version}"

        # Vérifier le cache
        if cache_key in self._bm25_indexes:
            return self._bm25_indexes[cache_key]

        # Charger les chunks et construire l'index
        chunks = self._load_chunks(referentiel_type, version)
        bm25_index = self._build_bm25_index(chunks)

        # Mettre en cache
        self._bm25_indexes[cache_key] = bm25_index

        return bm25_index

    def _bm25_search(
        self, query: str, referentiel_type: str, version: str, top_k: int = 50
    ) -> List[Tuple[int, float]]:
        """
        Effectue une recherche BM25.

        Args:
            query: Requête de recherche
            referentiel_type: Type de référentiel
            version: Version du référentiel
            top_k: Nombre de résultats à retourner

        Returns:
            Liste de tuples (chunk_index, score)
        """
        # Récupérer l'index BM25
        bm25_index = self._get_bm25_index(referentiel_type, version)

        # Tokeniser la requête
        tokenized_query = query.lower().split()

        # Effectuer la recherche
        scores = bm25_index.get_scores(tokenized_query)

        # Trier par score décroissant et prendre les top_k
        top_indices = np.argsort(scores)[::-1][:top_k]
        results = [(int(idx), float(scores[idx])) for idx in top_indices]

        logger.debug(f"BM25 search: {len(results)} résultats pour '{query}'")
        return results

    def _vector_search(
        self, query: str, referentiel_type: str, version: str, top_k: int = 50
    ) -> List[Tuple[int, float]]:
        """
        Effectue une recherche vectorielle avec FAISS.

        Args:
            query: Requête de recherche
            referentiel_type: Type de référentiel
            version: Version du référentiel
            top_k: Nombre de résultats à retourner

        Returns:
            Liste de tuples (chunk_index, similarity_score)
        """
        # Charger l'index FAISS
        faiss_index = self._load_faiss_index(referentiel_type, version)

        # Vectoriser la requête
        embeddings_model = self._get_embeddings_model()
        query_vector = embeddings_model.encode(
            query, convert_to_numpy=True, normalize_embeddings=True
        )
        query_vector = query_vector.reshape(1, -1).astype(np.float32)

        # Effectuer la recherche
        distances, indices = faiss_index.search(query_vector, top_k)

        # Convertir les distances en scores de similarité (cosine similarity)
        # Pour HNSW avec vecteurs normalisés, distance = 2 * (1 - cosine_similarity)
        # Donc: cosine_similarity = 1 - distance / 2
        similarities = 1.0 - distances[0] / 2.0

        results = [(int(idx), float(sim)) for idx, sim in zip(indices[0], similarities)]

        logger.debug(f"Vector search: {len(results)} résultats pour '{query}'")
        return results

    def _reciprocal_rank_fusion(
        self,
        bm25_results: List[Tuple[int, float]],
        vector_results: List[Tuple[int, float]],
        k: int = 60,
    ) -> List[Tuple[int, float]]:
        """
        Fusionne les résultats BM25 et vectoriels avec Reciprocal Rank Fusion.

        RRF formula: score(d) = sum(1 / (k + rank(d)))
        où k est une constante (typiquement 60) et rank(d) est le rang du document.

        Args:
            bm25_results: Résultats BM25 (chunk_index, score)
            vector_results: Résultats vectoriels (chunk_index, score)
            k: Constante RRF (défaut: 60)

        Returns:
            Liste fusionnée de tuples (chunk_index, rrf_score) triée par score décroissant

        Exigences: 7.1, 23.7
        """
        rrf_scores: Dict[int, float] = {}

        # Ajouter les scores BM25
        for rank, (chunk_idx, _) in enumerate(bm25_results, start=1):
            rrf_scores[chunk_idx] = rrf_scores.get(chunk_idx, 0.0) + 1.0 / (k + rank)

        # Ajouter les scores vectoriels
        for rank, (chunk_idx, _) in enumerate(vector_results, start=1):
            rrf_scores[chunk_idx] = rrf_scores.get(chunk_idx, 0.0) + 1.0 / (k + rank)

        # Trier par score RRF décroissant
        sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)

        logger.debug(f"RRF fusion: {len(sorted_results)} résultats uniques")
        return sorted_results
    def _reciprocal_rank_fusion(
        self,
        bm25_results: List[Tuple[int, float]],
        vector_results: List[Tuple[int, float]],
        k: int = 60,
    ) -> List[Tuple[int, float]]:
        """
        Fusionne les résultats BM25 et vectoriels avec Reciprocal Rank Fusion.

        RRF formula: score(d) = sum(1 / (k + rank(d)))
        où k est une constante (typiquement 60) et rank(d) est le rang du document.

        Args:
            bm25_results: Résultats BM25 (chunk_index, score)
            vector_results: Résultats vectoriels (chunk_index, score)
            k: Constante RRF (défaut: 60)

        Returns:
            Liste fusionnée de tuples (chunk_index, rrf_score) triée par score décroissant

        Exigences: 7.1, 23.7
        """
        rrf_scores: Dict[int, float] = {}

        # Ajouter les scores BM25
        for rank, (chunk_idx, _) in enumerate(bm25_results, start=1):
            rrf_scores[chunk_idx] = rrf_scores.get(chunk_idx, 0.0) + 1.0 / (k + rank)

        # Ajouter les scores vectoriels
        for rank, (chunk_idx, _) in enumerate(vector_results, start=1):
            rrf_scores[chunk_idx] = rrf_scores.get(chunk_idx, 0.0) + 1.0 / (k + rank)

        # Trier par score RRF décroissant
        sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)

        logger.debug(f"RRF fusion: {len(sorted_results)} résultats uniques")
        return sorted_results

    def _rerank_results(
        self,
        query: str,
        candidates: List[Tuple[int, float]],
        chunks: List[Chunk],
        top_k: int = 10,
    ) -> List[Tuple[int, float]]:
        """
        Reclasse les candidats avec un modèle cross-encoder.

        Le cross-encoder évalue la pertinence de chaque paire (query, document)
        de manière plus précise que les embeddings bi-encoders.

        Les résultats provenant de l'index alphabétique sont priorisés en
        ajoutant un bonus à leur score.

        Args:
            query: Requête de recherche
            candidates: Liste de tuples (chunk_index, rrf_score)
            chunks: Liste complète des chunks
            top_k: Nombre de résultats à retourner après reranking

        Returns:
            Liste de tuples (chunk_index, rerank_score) triée par score décroissant

        Exigences: 7.4, 27.6
        """
        if not candidates:
            return []

        # Préparer les paires (query, document) pour le cross-encoder
        pairs = []
        chunk_indices = []

        for chunk_idx, _ in candidates:
            if chunk_idx >= len(chunks):
                logger.warning(f"Index de chunk invalide pour reranking: {chunk_idx}")
                continue

            chunk = chunks[chunk_idx]
            # Limiter la taille du texte pour le cross-encoder (max 512 tokens)
            chunk_text = chunk.content[:2000]  # Approximativement 512 tokens
            pairs.append([query, chunk_text])
            chunk_indices.append(chunk_idx)

        if not pairs:
            logger.warning("Aucune paire valide pour le reranking")
            return []

        # Obtenir les scores du cross-encoder
        reranker = self._get_reranker_model()
        logger.debug(f"Reranking {len(pairs)} candidats avec cross-encoder")

        try:
            scores = reranker.predict(pairs)

            # Appliquer le bonus pour les résultats de l'index alphabétique
            # Les chunks avec chunk_type="alphabetical_index" reçoivent un bonus
            boosted_scores = []
            for idx, (chunk_idx, score) in enumerate(zip(chunk_indices, scores)):
                chunk = chunks[chunk_idx]
                final_score = float(score)

                # Bonus pour l'index alphabétique (Exigence 27.6)
                if chunk.metadata.get("chunk_type") == "alphabetical_index":
                    # Ajouter un bonus de 0.1 au score
                    final_score += 0.1
                    logger.debug(
                        f"Bonus index alphabétique appliqué au chunk {chunk_idx}: "
                        f"{score:.4f} -> {final_score:.4f}"
                    )

                boosted_scores.append((chunk_idx, final_score))

            # Trier par score décroissant et prendre les top_k
            reranked = sorted(boosted_scores, key=lambda x: x[1], reverse=True)[:top_k]

            logger.debug(
                f"Reranking terminé: {len(reranked)} résultats "
                f"(score max: {reranked[0][1]:.4f}, min: {reranked[-1][1]:.4f})"
            )

            return reranked

        except Exception as e:
            logger.error(f"Erreur lors du reranking: {e}")
            # En cas d'erreur, retourner les candidats originaux (sans reranking)
            return candidates[:top_k]

    def search_icd10(
        self, query: str, top_k: int = 10, version: str = "2026"
    ) -> List[CodeCandidate]:
        """
        Recherche de codes CIM-10 avec recherche hybride et reranking.

        Pipeline:
        1. BM25 search (top 50)
        2. Vector search (top 50)
        3. Reciprocal Rank Fusion
        4. Reranking avec cross-encoder (priorisation index alphabétique)
        5. Retourner top_k résultats

        Args:
            query: Terme clinique ou description
            top_k: Nombre de candidats à retourner
            version: Version du référentiel CIM-10

        Returns:
            Liste de candidats avec scores de similarité

        Exigences: 7.1, 7.2, 7.4, 7.5, 23.7, 27.6
        """
        logger.info(f"Recherche CIM-10 pour: '{query}' (version {version})")

        try:
            # 1. Recherche BM25
            bm25_results = self._bm25_search(query, "cim10", version, top_k=50)

            # 2. Recherche vectorielle
            vector_results = self._vector_search(query, "cim10", version, top_k=50)

            # 3. Fusion RRF
            fused_results = self._reciprocal_rank_fusion(bm25_results, vector_results)

            # 4. Charger les chunks pour le reranking
            chunks = self._load_chunks("cim10", version)

            # 5. Reranking avec cross-encoder (priorisation index alphabétique)
            reranked_results = self._rerank_results(
                query, fused_results, chunks, top_k=top_k
            )

            # 6. Construire les candidats
            candidates = []
            for chunk_idx, rerank_score in reranked_results:
                if chunk_idx >= len(chunks):
                    logger.warning(f"Index de chunk invalide: {chunk_idx}")
                    continue

                chunk = chunks[chunk_idx]

                # Extraire le code et le libellé du chunk
                # Format typique: "A00.0 Choléra dû à Vibrio cholerae 01, biovar cholerae"
                code, label = self._extract_code_and_label(chunk.content, "cim10")

                candidate = CodeCandidate(
                    code=code,
                    label=label,
                    similarity_score=max(0.0, min(rerank_score, 1.0)),  # Normaliser à [0, 1]
                    source="reranked",
                    chunk_id=chunk.chunk_id,
                    chunk_text=chunk.content[:500],  # Limiter la taille
                )
                candidates.append(candidate)

            logger.info(f"Trouvé {len(candidates)} candidats CIM-10")
            return candidates

        except Exception as e:
            logger.error(f"Erreur lors de la recherche CIM-10: {e}")
            raise

    def search_ccam(
        self, query: str, top_k: int = 10, version: str = "2025"
    ) -> List[CodeCandidate]:
        """
        Recherche de codes CCAM avec recherche hybride et reranking.

        Pipeline identique à search_icd10 mais pour les codes CCAM.

        Args:
            query: Description de l'acte médical
            top_k: Nombre de candidats à retourner
            version: Version du référentiel CCAM

        Returns:
            Liste de candidats avec scores de similarité

        Exigences: 7.1, 7.3, 7.4, 7.5, 23.7, 27.6
        """
        logger.info(f"Recherche CCAM pour: '{query}' (version {version})")

        try:
            # 1. Recherche BM25
            bm25_results = self._bm25_search(query, "ccam", version, top_k=50)

            # 2. Recherche vectorielle
            vector_results = self._vector_search(query, "ccam", version, top_k=50)

            # 3. Fusion RRF
            fused_results = self._reciprocal_rank_fusion(bm25_results, vector_results)

            # 4. Charger les chunks
            chunks = self._load_chunks("ccam", version)

            # 5. Reranking avec cross-encoder (priorisation index alphabétique)
            reranked_results = self._rerank_results(
                query, fused_results, chunks, top_k=top_k
            )

            # 6. Construire les candidats
            candidates = []
            for chunk_idx, rerank_score in reranked_results:
                if chunk_idx >= len(chunks):
                    logger.warning(f"Index de chunk invalide: {chunk_idx}")
                    continue

                chunk = chunks[chunk_idx]

                # Extraire le code et le libellé
                code, label = self._extract_code_and_label(chunk.content, "ccam")

                candidate = CodeCandidate(
                    code=code,
                    label=label,
                    similarity_score=max(0.0, min(rerank_score, 1.0)),
                    source="reranked",
                    chunk_id=chunk.chunk_id,
                    chunk_text=chunk.content[:500],
                )
                candidates.append(candidate)

            logger.info(f"Trouvé {len(candidates)} candidats CCAM")
            return candidates

        except Exception as e:
            logger.error(f"Erreur lors de la recherche CCAM: {e}")
            raise

    def _extract_code_and_label(
        self, chunk_text: str, referentiel_type: str
    ) -> Tuple[str, str]:
        """
        Extrait le code et le libellé d'un chunk de texte.

        Args:
            chunk_text: Texte du chunk
            referentiel_type: Type de référentiel (cim10 ou ccam)

        Returns:
            Tuple (code, label)
        """
        import re

        lines = chunk_text.split("\n")

        if referentiel_type == "cim10":
            # Pattern CIM-10: A00.0 Libellé
            pattern = re.compile(r"^([A-Z][0-9]{2}\.?[0-9]*)\s+(.+)$")
            for line in lines:
                match = pattern.match(line.strip())
                if match:
                    return match.group(1), match.group(2).strip()

            # Si pas trouvé, retourner valeurs par défaut
            return "UNKNOWN", chunk_text[:100]

        elif referentiel_type == "ccam":
            # Pattern CCAM avec plusieurs formats possibles:
            # 1. ### YYYY001 (titre markdown)
            # 2. YYYY001 Libellé
            # 3. YYYY001+ABC Libellé (avec extension ATIH)
            # 4. **Description**: Libellé (après le code)
            
            # Chercher d'abord le code dans les titres markdown
            pattern_title = re.compile(r"^###\s+([A-Z]{4}[0-9]{3}(?:\+[A-Z0-9]{3})?)(?:\s|$)")
            for line in lines:
                match = pattern_title.match(line.strip())
                if match:
                    code = match.group(1)
                    # Chercher la description dans les lignes suivantes
                    for next_line in lines[lines.index(line)+1:]:
                        if next_line.strip().startswith("**Description**:"):
                            label = next_line.strip().replace("**Description**:", "").strip()
                            return code, label
                        elif next_line.strip() and not next_line.strip().startswith("**"):
                            # Première ligne non-vide qui n'est pas un titre
                            return code, next_line.strip()
                    # Si pas de description trouvée, utiliser le code comme label
                    return code, f"Acte {code}"
            
            # Sinon chercher le pattern classique
            pattern = re.compile(r"^([A-Z]{4}[0-9]{3}(?:\+[A-Z0-9]{3})?)\s+(.+)$")
            for line in lines:
                match = pattern.match(line.strip())
                if match:
                    return match.group(1), match.group(2).strip()

            # Si toujours pas trouvé, chercher juste un code CCAM n'importe où
            pattern_anywhere = re.compile(r"([A-Z]{4}[0-9]{3}(?:\+[A-Z0-9]{3})?)")
            for line in lines:
                match = pattern_anywhere.search(line.strip())
                if match:
                    code = match.group(1)
                    # Extraire le reste de la ligne comme label
                    label = line.strip().replace(code, "").strip()
                    if label:
                        return code, label
                    return code, f"Acte {code}"

            # Si vraiment pas trouvé
            return "UNKNOWN", chunk_text[:100]

        else:
            return "UNKNOWN", chunk_text[:100]

    def retrieve_eligibility_criteria(
        self, code: str, code_type: str
    ) -> Optional[EligibilityCriteria]:
        """
        Récupère les critères d'éligibilité depuis le Guide Méthodologique.

        Recherche dans le Guide MCO les critères d'éligibilité pour un code donné,
        incluant les règles d'exclusion et de hiérarchisation.

        Args:
            code: Code CIM-10 ou CCAM
            code_type: Type de code ("dp" | "dr" | "das" | "ccam")

        Returns:
            EligibilityCriteria si trouvé, None sinon

        Exigences: 26.1, 26.2, 26.3, 26.4
        """
        logger.info(f"Récupération des critères d'éligibilité pour {code} ({code_type})")

        try:
            # Construire la requête de recherche
            if code_type in ["dp", "dr", "das"]:
                query = f"critères éligibilité {code_type.upper()} {code}"
            else:  # ccam
                query = f"conditions application acte {code}"

            # Rechercher dans le Guide MCO
            # Version par défaut du guide
            guide_version = "2026"

            # Recherche hybride dans le guide
            bm25_results = self._bm25_search(query, "guide_mco", guide_version, top_k=20)
            vector_results = self._vector_search(
                query, "guide_mco", guide_version, top_k=20
            )
            fused_results = self._reciprocal_rank_fusion(bm25_results, vector_results)

            # Charger les chunks du guide
            chunks = self._load_chunks("guide_mco", guide_version)

            # Analyser les meilleurs résultats pour extraire les critères
            if not fused_results:
                logger.warning(f"Aucun critère trouvé pour {code}")
                return None

            # Prendre le meilleur chunk
            best_chunk_idx, _ = fused_results[0]
            if best_chunk_idx >= len(chunks):
                return None

            best_chunk = chunks[best_chunk_idx]

            # Extraire les règles d'exclusion et de hiérarchisation
            exclusion_rules = self._extract_exclusion_rules(best_chunk.content)
            hierarchization_rules = self._extract_hierarchization_rules(best_chunk.content)

            criteria = EligibilityCriteria(
                code=code,
                code_type=code_type,
                criteria_text=best_chunk.content,
                exclusion_rules=exclusion_rules,
                hierarchization_rules=hierarchization_rules,
                guide_section=best_chunk.metadata.get("section", "Unknown"),
            )

            logger.info(f"Critères d'éligibilité trouvés pour {code}")
            return criteria

        except Exception as e:
            logger.error(f"Erreur lors de la récupération des critères: {e}")
            return None

    def _extract_exclusion_rules(self, text: str) -> List[str]:
        """
        Extrait les règles d'exclusion d'un texte du Guide MCO.

        Args:
            text: Texte du chunk

        Returns:
            Liste de règles d'exclusion
        """
        import re

        rules = []
        lines = text.split("\n")

        # Patterns pour détecter les exclusions
        exclusion_patterns = [
            re.compile(r"^[-•*]\s*Exclut?:?\s*(.+)$", re.IGNORECASE),
            re.compile(r"^[-•*]\s*À l'exclusion de:?\s*(.+)$", re.IGNORECASE),
            re.compile(r"^[-•*]\s*Ne pas coder:?\s*(.+)$", re.IGNORECASE),
        ]

        for line in lines:
            for pattern in exclusion_patterns:
                match = pattern.match(line.strip())
                if match:
                    rules.append(match.group(1).strip())

        return rules

    def _extract_hierarchization_rules(self, text: str) -> List[str]:
        """
        Extrait les règles de hiérarchisation d'un texte du Guide MCO.

        Args:
            text: Texte du chunk

        Returns:
            Liste de règles de hiérarchisation
        """
        import re

        rules = []
        lines = text.split("\n")

        # Patterns pour détecter la hiérarchisation
        hierarchization_patterns = [
            re.compile(r"^[-•*]\s*Hiérarchisation:?\s*(.+)$", re.IGNORECASE),
            re.compile(r"^[-•*]\s*Priorité:?\s*(.+)$", re.IGNORECASE),
            re.compile(r"^[-•*]\s*Ordre:?\s*(.+)$", re.IGNORECASE),
        ]

        for line in lines:
            for pattern in hierarchization_patterns:
                match = pattern.match(line.strip())
                if match:
                    rules.append(match.group(1).strip())

        return rules

    def map_clinical_term_to_codes(
        self,
        clinical_term: str,
        referentiel_type: str = "cim10",
        version: str = "2026",
        top_k: int = 5
    ) -> List[CodeCandidate]:
        """
        Mappe un terme clinique en langage naturel vers les codes correspondants
        en utilisant les index alphabétiques.

        Cette méthode recherche d'abord dans les chunks d'index alphabétique,
        puis dans les codes analytiques si nécessaire.

        Args:
            clinical_term: Terme clinique (ex: "Gastrite", "Appendicectomie")
            referentiel_type: Type de référentiel ("cim10" | "ccam")
            version: Version du référentiel
            top_k: Nombre de codes à retourner

        Returns:
            Liste de codes candidats avec scores de similarité

        Exigences: 27.3, 27.4
        """
        import re
        
        logger.info(f"Mapping terme clinique '{clinical_term}' vers codes {referentiel_type}")
        
        # Charger les chunks (incluant les index alphabétiques)
        chunks = self._load_chunks(referentiel_type, version)
        
        # Séparer les chunks d'index alphabétique des chunks de codes
        alpha_chunks = [c for c in chunks if c.metadata.get("chunk_type") == "alphabetical_index"]
        code_chunks = [c for c in chunks if c.metadata.get("chunk_type") != "alphabetical_index"]
        
        logger.info(f"Chunks disponibles: {len(alpha_chunks)} index alphabétiques, {len(code_chunks)} codes analytiques")
        
        # Étape 1: Recherche dans les index alphabétiques (prioritaire)
        alpha_candidates = []
        if alpha_chunks:
            # Recherche simple par similarité textuelle dans les index alphabétiques
            from rank_bm25 import BM25Okapi
            
            # Construire un index BM25 temporaire pour les chunks alpha
            alpha_corpus = [chunk.content.lower().split() for chunk in alpha_chunks]
            alpha_bm25 = BM25Okapi(alpha_corpus)
            
            # Rechercher
            tokenized_query = clinical_term.lower().split()
            alpha_scores = alpha_bm25.get_scores(tokenized_query)
            
            # Trier et prendre les meilleurs
            top_alpha_indices = np.argsort(alpha_scores)[::-1][:top_k * 2]
            
            # Extraire les codes des chunks d'index alphabétique
            for idx in top_alpha_indices:
                if alpha_scores[idx] > 0:  # Seulement les résultats pertinents
                    chunk = alpha_chunks[idx]
                    score = float(alpha_scores[idx])
                    
                    # Extraire les codes mentionnés dans le chunk
                    if referentiel_type == "cim10":
                        codes = re.findall(r'([A-Z][0-9]{2}\.?[0-9]*)', chunk.content)
                    else:  # ccam
                        codes = re.findall(r'([A-Z]{4}[0-9]{3})', chunk.content)
                    
                    # Créer un candidat pour chaque code trouvé
                    for code in set(codes):  # Utiliser set pour éviter les doublons
                        # Extraire le libellé du code depuis le chunk
                        label = self._extract_label_for_code(code, chunk.content)
                        
                        alpha_candidates.append(CodeCandidate(
                            code=code,
                            label=label,
                            similarity_score=score * 1.2,  # Boost pour index alphabétique
                            source="alphabetical_index",
                            chunk_id=chunk.chunk_id,
                            chunk_text=chunk.content[:200]
                        ))
        
        # Étape 2: Recherche dans les codes analytiques (complémentaire)
        code_candidates = []
        if len(alpha_candidates) < top_k:
            # Utiliser la recherche standard pour les codes analytiques
            standard_results = self.search_icd10(clinical_term, top_k=top_k, version=version) \
                if referentiel_type == "cim10" else \
                self.search_ccam(clinical_term, top_k=top_k, version=version)
            
            code_candidates = standard_results
        
        # Combiner les résultats (index alphabétique en priorité)
        all_candidates = alpha_candidates + code_candidates
        
        # Dédupliquer par code (garder le meilleur score)
        seen_codes = {}
        for candidate in all_candidates:
            if candidate.code not in seen_codes or candidate.similarity_score > seen_codes[candidate.code].similarity_score:
                seen_codes[candidate.code] = candidate
        
        # Trier par score et retourner top_k
        final_candidates = sorted(seen_codes.values(), key=lambda x: x.similarity_score, reverse=True)[:top_k]
        
        logger.info(f"Mapping trouvé {len(final_candidates)} codes pour '{clinical_term}'")
        return final_candidates
    
    def _extract_label_for_code(self, code: str, text: str) -> str:
        """
        Extrait le libellé d'un code depuis un texte d'index alphabétique.

        Args:
            code: Code à rechercher
            text: Texte contenant le code

        Returns:
            Libellé du code ou chaîne vide
        """
        import re
        
        # Chercher le code dans le texte et extraire le contexte
        lines = text.split("\n")
        for line in lines:
            if code in line:
                # Extraire le terme avant le code
                parts = re.split(r'[-–]', line)
                if len(parts) >= 2:
                    label = parts[0].strip()
                    return label
        
        return ""
    
    def get_synonyms_and_variations(
        self,
        term: str,
        referentiel_type: str = "cim10",
        version: str = "2026"
    ) -> List[str]:
        """
        Récupère les synonymes et variations terminologiques d'un terme
        depuis les index alphabétiques.

        Args:
            term: Terme à rechercher
            referentiel_type: Type de référentiel
            version: Version du référentiel

        Returns:
            Liste de synonymes et variations

        Exigences: 27.4
        """
        import re
        
        logger.info(f"Recherche de synonymes pour '{term}'")
        
        # Charger les chunks d'index alphabétique
        chunks = self._load_chunks(referentiel_type, version)
        alpha_chunks = [c for c in chunks if c.metadata.get("chunk_type") == "alphabetical_index"]
        
        if not alpha_chunks:
            logger.warning("Aucun chunk d'index alphabétique disponible")
            return []
        
        # Rechercher le terme dans les index
        candidates = self.map_clinical_term_to_codes(
            clinical_term=term,
            referentiel_type=referentiel_type,
            version=version,
            top_k=3
        )
        
        if not candidates:
            return []
        
        # Récupérer le code principal
        main_code = candidates[0].code
        
        # Chercher tous les termes qui pointent vers ce code
        synonyms = set()
        for chunk in alpha_chunks:
            if main_code in chunk.content:
                # Extraire tous les termes de ce chunk
                lines = chunk.content.split("\n")
                for line in lines:
                    if main_code in line:
                        # Extraire le terme avant le code
                        parts = re.split(r'[-–]', line)
                        if len(parts) >= 2:
                            synonym = parts[0].strip()
                            if synonym and synonym.lower() != term.lower():
                                synonyms.add(synonym)
        
        logger.info(f"Trouvé {len(synonyms)} synonymes pour '{term}'")
        return list(synonyms)
