"""
Validateur de jeu gold pour les mises à jour de référentiels.

Ce module permet de valider les mises à jour du système en rejouant
un jeu de données gold et en comparant les KPI avant/après.
"""

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

from pydantic import BaseModel, Field

logger = logging.getLogger(__name__)


@dataclass
class GoldStayResult:
    """Résultat du traitement d'un séjour gold."""
    
    stay_id: str
    dp_correct: bool
    dp_predicted: Optional[str]
    dp_expected: str
    das_predicted: List[str]
    das_expected: List[str]
    das_precision: float
    das_recall: float
    das_f1: float
    ccam_predicted: List[str]
    ccam_expected: List[str]
    ccam_precision: float
    ccam_recall: float
    ccam_f1: float
    processing_time_seconds: float
    errors: List[str]


class GoldSetMetrics(BaseModel):
    """Métriques calculées sur le jeu gold."""
    
    total_stays: int = Field(..., description="Nombre total de séjours")
    dp_accuracy: float = Field(..., ge=0.0, le=1.0, description="Précision DP")
    das_precision: float = Field(..., ge=0.0, le=1.0, description="Précision DAS")
    das_recall: float = Field(..., ge=0.0, le=1.0, description="Rappel DAS")
    das_f1: float = Field(..., ge=0.0, le=1.0, description="F1-score DAS")
    ccam_precision: float = Field(..., ge=0.0, le=1.0, description="Précision CCAM")
    ccam_recall: float = Field(..., ge=0.0, le=1.0, description="Rappel CCAM")
    ccam_f1: float = Field(..., ge=0.0, le=1.0, description="F1-score CCAM")
    avg_processing_time: float = Field(..., description="Temps moyen de traitement (s)")
    error_rate: float = Field(..., ge=0.0, le=1.0, description="Taux d'erreur")
    timestamp: datetime = Field(default_factory=datetime.now)


class GoldSetValidator:
    """
    Validateur de jeu gold pour les mises à jour de référentiels.
    
    Ce validateur permet de :
    - Charger un jeu gold de séjours de référence
    - Rejouer le pipeline sur le jeu gold
    - Calculer les KPI (DP accuracy, DAS precision/recall, etc.)
    - Comparer les KPI avant/après mise à jour
    - Bloquer les releases si dégradation détectée
    
    Exigences: 13.5, 13.6, 13.7
    """
    
    def __init__(
        self,
        gold_set_path: Path,
        min_dp_accuracy: float = 0.70,
        min_das_f1: float = 0.60,
        min_ccam_f1: float = 0.65,
        max_degradation: float = 0.05
    ):
        """
        Initialise le validateur de jeu gold.
        
        Args:
            gold_set_path: Chemin vers le répertoire du jeu gold
            min_dp_accuracy: Seuil minimum de précision DP (défaut: 70%)
            min_das_f1: Seuil minimum de F1-score DAS (défaut: 60%)
            min_ccam_f1: Seuil minimum de F1-score CCAM (défaut: 65%)
            max_degradation: Dégradation maximale acceptable (défaut: 5%)
        """
        self.gold_set_path = Path(gold_set_path)
        self.min_dp_accuracy = min_dp_accuracy
        self.min_das_f1 = min_das_f1
        self.min_ccam_f1 = min_ccam_f1
        self.max_degradation = max_degradation
        
        logger.info(
            f"GoldSetValidator initialisé avec gold_set_path={self.gold_set_path}, "
            f"seuils: DP={min_dp_accuracy}, DAS_F1={min_das_f1}, CCAM_F1={min_ccam_f1}"
        )
    
    def load_gold_set(self) -> List[Dict]:
        """
        Charge le jeu gold depuis le disque.
        
        Le jeu gold doit être au format JSON avec la structure suivante:
        [
            {
                "stay_id": "SEJ001",
                "documents": [...],
                "expected_codes": {
                    "dp": "I21.0",
                    "das": ["I10", "E11.9"],
                    "ccam": ["YYYY001", "YYYY002"]
                }
            },
            ...
        ]
        
        Returns:
            Liste de séjours gold
        
        Raises:
            FileNotFoundError: Si le fichier gold n'existe pas
            ValueError: Si le jeu gold contient moins de 200 séjours
        
        Exigences: 13.5
        """
        gold_file = self.gold_set_path / "gold_set.json"
        
        if not gold_file.exists():
            raise FileNotFoundError(
                f"Fichier jeu gold introuvable: {gold_file}"
            )
        
        logger.info(f"Chargement du jeu gold depuis {gold_file}")
        
        with open(gold_file, "r", encoding="utf-8") as f:
            gold_stays = json.load(f)
        
        # Validation: minimum 200 séjours
        if len(gold_stays) < 200:
            raise ValueError(
                f"Le jeu gold doit contenir au moins 200 séjours. "
                f"Trouvé: {len(gold_stays)}"
            )
        
        logger.info(f"Jeu gold chargé: {len(gold_stays)} séjours")
        
        return gold_stays
    
    def run_gold_set(
        self,
        pipeline: 'Pipeline',
        gold_stays: List[Dict]
    ) -> List[GoldStayResult]:
        """
        Exécute le pipeline sur le jeu gold.
        
        Args:
            pipeline: Instance du pipeline de codage
            gold_stays: Liste de séjours gold
        
        Returns:
            Liste de résultats par séjour
        
        Exigences: 13.6
        """
        logger.info(f"Exécution du pipeline sur {len(gold_stays)} séjours gold")
        
        results = []
        
        for i, stay in enumerate(gold_stays):
            if i % 50 == 0:
                logger.info(f"Traitement: {i}/{len(gold_stays)} séjours")
            
            try:
                # Exécuter le pipeline
                start_time = datetime.now()
                pipeline_result = pipeline.process_stay(
                    stay_id=stay["stay_id"],
                    documents=stay["documents"]
                )
                processing_time = (datetime.now() - start_time).total_seconds()
                
                # Extraire les codes prédits
                dp_predicted = None
                das_predicted = []
                ccam_predicted = []
                
                for code in pipeline_result.proposed_codes:
                    if code.type == "dp":
                        dp_predicted = code.code
                    elif code.type == "das":
                        das_predicted.append(code.code)
                    elif code.type == "ccam":
                        ccam_predicted.append(code.code)
                
                # Extraire les codes attendus
                expected = stay["expected_codes"]
                dp_expected = expected["dp"]
                das_expected = expected.get("das", [])
                ccam_expected = expected.get("ccam", [])
                
                # Calculer les métriques
                dp_correct = (dp_predicted == dp_expected)
                
                das_precision, das_recall, das_f1 = self._calculate_metrics(
                    das_predicted, das_expected
                )
                
                ccam_precision, ccam_recall, ccam_f1 = self._calculate_metrics(
                    ccam_predicted, ccam_expected
                )
                
                result = GoldStayResult(
                    stay_id=stay["stay_id"],
                    dp_correct=dp_correct,
                    dp_predicted=dp_predicted,
                    dp_expected=dp_expected,
                    das_predicted=das_predicted,
                    das_expected=das_expected,
                    das_precision=das_precision,
                    das_recall=das_recall,
                    das_f1=das_f1,
                    ccam_predicted=ccam_predicted,
                    ccam_expected=ccam_expected,
                    ccam_precision=ccam_precision,
                    ccam_recall=ccam_recall,
                    ccam_f1=ccam_f1,
                    processing_time_seconds=processing_time,
                    errors=[]
                )
                
            except Exception as e:
                logger.error(f"Erreur traitement séjour {stay['stay_id']}: {e}")
                result = GoldStayResult(
                    stay_id=stay["stay_id"],
                    dp_correct=False,
                    dp_predicted=None,
                    dp_expected=stay["expected_codes"]["dp"],
                    das_predicted=[],
                    das_expected=stay["expected_codes"].get("das", []),
                    das_precision=0.0,
                    das_recall=0.0,
                    das_f1=0.0,
                    ccam_predicted=[],
                    ccam_expected=stay["expected_codes"].get("ccam", []),
                    ccam_precision=0.0,
                    ccam_recall=0.0,
                    ccam_f1=0.0,
                    processing_time_seconds=0.0,
                    errors=[str(e)]
                )
            
            results.append(result)
        
        logger.info(f"Exécution terminée: {len(results)} résultats")
        
        return results
    
    def calculate_metrics(
        self,
        results: List[GoldStayResult]
    ) -> GoldSetMetrics:
        """
        Calcule les métriques globales sur le jeu gold.
        
        Args:
            results: Liste de résultats par séjour
        
        Returns:
            Métriques globales
        
        Exigences: 13.6
        """
        logger.info(f"Calcul des métriques sur {len(results)} résultats")
        
        # DP accuracy
        dp_correct_count = sum(1 for r in results if r.dp_correct)
        dp_accuracy = dp_correct_count / len(results) if results else 0.0
        
        # DAS metrics (moyenne)
        das_precision = sum(r.das_precision for r in results) / len(results) if results else 0.0
        das_recall = sum(r.das_recall for r in results) / len(results) if results else 0.0
        das_f1 = sum(r.das_f1 for r in results) / len(results) if results else 0.0
        
        # CCAM metrics (moyenne)
        ccam_precision = sum(r.ccam_precision for r in results) / len(results) if results else 0.0
        ccam_recall = sum(r.ccam_recall for r in results) / len(results) if results else 0.0
        ccam_f1 = sum(r.ccam_f1 for r in results) / len(results) if results else 0.0
        
        # Temps de traitement moyen
        avg_processing_time = sum(r.processing_time_seconds for r in results) / len(results) if results else 0.0
        
        # Taux d'erreur
        error_count = sum(1 for r in results if r.errors)
        error_rate = error_count / len(results) if results else 0.0
        
        metrics = GoldSetMetrics(
            total_stays=len(results),
            dp_accuracy=dp_accuracy,
            das_precision=das_precision,
            das_recall=das_recall,
            das_f1=das_f1,
            ccam_precision=ccam_precision,
            ccam_recall=ccam_recall,
            ccam_f1=ccam_f1,
            avg_processing_time=avg_processing_time,
            error_rate=error_rate
        )
        
        logger.info(
            f"Métriques calculées: DP={dp_accuracy:.2%}, "
            f"DAS_F1={das_f1:.2%}, CCAM_F1={ccam_f1:.2%}"
        )
        
        return metrics
    
    def compare_metrics(
        self,
        before: GoldSetMetrics,
        after: GoldSetMetrics
    ) -> Dict[str, float]:
        """
        Compare les métriques avant/après mise à jour.
        
        Args:
            before: Métriques avant mise à jour
            after: Métriques après mise à jour
        
        Returns:
            Dictionnaire des différences (valeurs négatives = dégradation)
        
        Exigences: 13.6
        """
        logger.info("Comparaison des métriques avant/après")
        
        differences = {
            "dp_accuracy": after.dp_accuracy - before.dp_accuracy,
            "das_precision": after.das_precision - before.das_precision,
            "das_recall": after.das_recall - before.das_recall,
            "das_f1": after.das_f1 - before.das_f1,
            "ccam_precision": after.ccam_precision - before.ccam_precision,
            "ccam_recall": after.ccam_recall - before.ccam_recall,
            "ccam_f1": after.ccam_f1 - before.ccam_f1,
            "error_rate": after.error_rate - before.error_rate,
        }
        
        for metric, diff in differences.items():
            sign = "+" if diff >= 0 else ""
            logger.info(f"  {metric}: {sign}{diff:.2%}")
        
        return differences
    
    def validate_release(
        self,
        before: GoldSetMetrics,
        after: GoldSetMetrics
    ) -> tuple[bool, List[str]]:
        """
        Valide si une release peut être déployée.
        
        Bloque la release si:
        - Les métriques après sont en dessous des seuils minimums
        - La dégradation dépasse le seuil maximum
        
        Args:
            before: Métriques avant mise à jour
            after: Métriques après mise à jour
        
        Returns:
            Tuple (release_ok, raisons_blocage)
        
        Exigences: 13.7
        """
        logger.info("Validation de la release")
        
        blocking_reasons = []
        
        # Vérifier les seuils minimums
        if after.dp_accuracy < self.min_dp_accuracy:
            blocking_reasons.append(
                f"DP accuracy ({after.dp_accuracy:.2%}) < seuil minimum ({self.min_dp_accuracy:.2%})"
            )
        
        if after.das_f1 < self.min_das_f1:
            blocking_reasons.append(
                f"DAS F1 ({after.das_f1:.2%}) < seuil minimum ({self.min_das_f1:.2%})"
            )
        
        if after.ccam_f1 < self.min_ccam_f1:
            blocking_reasons.append(
                f"CCAM F1 ({after.ccam_f1:.2%}) < seuil minimum ({self.min_ccam_f1:.2%})"
            )
        
        # Vérifier la dégradation
        differences = self.compare_metrics(before, after)
        
        if differences["dp_accuracy"] < -self.max_degradation:
            blocking_reasons.append(
                f"Dégradation DP accuracy ({differences['dp_accuracy']:.2%}) > seuil ({-self.max_degradation:.2%})"
            )
        
        if differences["das_f1"] < -self.max_degradation:
            blocking_reasons.append(
                f"Dégradation DAS F1 ({differences['das_f1']:.2%}) > seuil ({-self.max_degradation:.2%})"
            )
        
        if differences["ccam_f1"] < -self.max_degradation:
            blocking_reasons.append(
                f"Dégradation CCAM F1 ({differences['ccam_f1']:.2%}) > seuil ({-self.max_degradation:.2%})"
            )
        
        release_ok = len(blocking_reasons) == 0
        
        if release_ok:
            logger.info("✓ Release validée - aucune dégradation détectée")
        else:
            logger.warning(f"✗ Release bloquée - {len(blocking_reasons)} raisons:")
            for reason in blocking_reasons:
                logger.warning(f"  - {reason}")
        
        return release_ok, blocking_reasons
    
    def save_metrics(
        self,
        metrics: GoldSetMetrics,
        output_path: Path
    ) -> None:
        """
        Sauvegarde les métriques sur disque.
        
        Args:
            metrics: Métriques à sauvegarder
            output_path: Chemin du fichier de sortie
        """
        logger.info(f"Sauvegarde des métriques dans {output_path}")
        
        output_path.parent.mkdir(parents=True, exist_ok=True)
        
        with open(output_path, "w", encoding="utf-8") as f:
            json.dump(metrics.model_dump(), f, ensure_ascii=False, indent=2, default=str)
        
        logger.info("Métriques sauvegardées")
    
    def _calculate_metrics(
        self,
        predicted: List[str],
        expected: List[str]
    ) -> tuple[float, float, float]:
        """
        Calcule precision, recall et F1-score.
        
        Args:
            predicted: Codes prédits
            expected: Codes attendus
        
        Returns:
            Tuple (precision, recall, f1)
        """
        if not predicted and not expected:
            return 1.0, 1.0, 1.0
        
        if not predicted:
            return 0.0, 0.0, 0.0
        
        if not expected:
            return 0.0, 0.0, 0.0
        
        predicted_set = set(predicted)
        expected_set = set(expected)
        
        true_positives = len(predicted_set & expected_set)
        
        precision = true_positives / len(predicted_set) if predicted_set else 0.0
        recall = true_positives / len(expected_set) if expected_set else 0.0
        
        if precision + recall > 0:
            f1 = 2 * (precision * recall) / (precision + recall)
        else:
            f1 = 0.0
        
        return precision, recall, f1
