"""Scoring, dérive, validation, seuil adaptatif, SHAP et clustering.

Regroupe les fonctions de scoring utilisées par le pipeline de détection :
  - A1 : détection de dérive conceptuelle (KS-test + KL divergence + dérive adversariale)
  - A2 : seuil adaptatif basé sur le percentile des scores négatifs
  - A4 : explainabilité SHAP (top features contributives)
  - A7 : validation de complétude des features
  - A8 : clustering HDBSCAN / DBSCAN des anomalies
  - A10 : normalisation [0,1] des scores d'anomalie
  - §7 : ExIFFI — importance de features par permutation (Extended Isolation Forest)
  - §8 : MetaLearner — pondération de l'ensemble par régression logistique
"""
import numpy as np
import pandas as pd

from .config import (
    ANOMALY_THRESHOLD, ANOMALY_PERCENTILE,
    DRIFT_THRESHOLD, MIN_VALID_FEATURE_RATIO,
    ENABLE_SHAP, SHAP_AVAILABLE, ENABLE_CLUSTERING, CLUSTERING_MIN_SAMPLES,
    EIF_AVAILABLE, HDBSCAN_AVAILABLE,
    STRUCTURAL_EXCLUDED_FEATURES,
    AE_WEIGHT, XGB_WEIGHT,
)
from .log import log_info, log_decision

# Imports conditionnels depuis config
if HDBSCAN_AVAILABLE:
    from .config import _hdbscan
from .config import DBSCAN, StandardScaler
if ENABLE_SHAP:
    from .config import _shap

# Seuil de filtre de cohérence de fingerprint pour la baseline humaine (§3)
import os
FINGERPRINT_COHERENCE_THRESHOLD = float(os.getenv('FINGERPRINT_COHERENCE_THRESHOLD', '0.25'))

# Cache par modèle : dernier état des features invalides (pour ne logguer que les changements).
_feature_warning_cache: dict = {}


# ═══════════════════════════════════════════════════════════════════════════════
# A1 — DÉTECTION DE DÉRIVE CONCEPTUELLE
# ═══════════════════════════════════════════════════════════════════════════════

def _kl_divergence(p_vals: np.ndarray, q_vals: np.ndarray, n_bins: int = 20) -> float:
    """Calcule la divergence KL(P || Q) entre deux échantillons via histogramme.

    Utilise un lissage de Laplace pour éviter les divisions par zéro.
    Retourne 0.0 si les données sont insuffisantes.
    """
    if len(p_vals) < 10 or len(q_vals) < 10:
        return 0.0
    combined_min = min(p_vals.min(), q_vals.min())
    combined_max = max(p_vals.max(), q_vals.max())
    if combined_max - combined_min < 1e-10:
        return 0.0
    bins = np.linspace(combined_min, combined_max, n_bins + 1)
    p_hist, _ = np.histogram(p_vals, bins=bins, density=False)
    q_hist, _ = np.histogram(q_vals, bins=bins, density=False)
    # Lissage de Laplace : ajouter 1 à chaque bin pour éviter les 0
    p_hist = (p_hist + 1).astype(float)
    q_hist = (q_hist + 1).astype(float)
    p_hist /= p_hist.sum()
    q_hist /= q_hist.sum()
    # KL(P || Q) = Σ p * log(p / q)
    kl = float(np.sum(p_hist * np.log(p_hist / q_hist + 1e-12)))
    return max(0.0, kl)


def compute_drift_score(baseline_stats: dict, current_baseline: pd.DataFrame,
                        features: list, *, name: str = '', cycle_id: str = '') -> float:
    """Compare la distribution actuelle de la baseline humaine avec celle de l'entraînement.

    §4 — Méthode améliorée :
    - Quantile digest 9 points (p5…p95) au lieu de 5 pour une meilleure fidélité
    - KS-test + divergence KL : feature en dérive si KS p<0.05 OU KL>0.5
    - Détection de dérive adversariale : >50% des features dérivent dans la même
      direction → log_decision 'ADVERSARIAL_DRIFT'

    Retourne la fraction de features en dérive significative (en [0,1]).
    """
    if not baseline_stats or current_baseline.empty:
        return 0.0
    try:
        from scipy.stats import ks_2samp
        _HAS_SCIPY = True
    except ImportError:
        _HAS_SCIPY = False

    # Clés de quantiles : 9 points preferred, 5 points fallback
    Q9_KEYS  = ['p5', 'p10', 'p25', 'p50', 'p75', 'p90', 'p95']
    Q9_PROBS = np.array([0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95])
    Q5_KEYS  = ['p10', 'p25', 'p50', 'p75', 'p90']
    Q5_PROBS = np.array([0.10, 0.25, 0.50, 0.75, 0.90])

    drifted = 0
    tested  = 0
    drifted_features: list = []
    direction_shifts: list = []
    rng = np.random.default_rng(42)

    for feat in features:
        if feat not in baseline_stats or feat not in current_baseline.columns:
            continue
        stats = baseline_stats[feat]
        curr_values = current_baseline[feat].dropna()
        if len(curr_values) < 30:
            continue
        trained_std = stats.get('std', 0)
        if trained_std < 1e-9:
            continue

        # Reconstruction de la distribution d'entraînement par interpolation quantile
        if all(k in stats for k in Q9_KEYS):
            q_probs = Q9_PROBS
            q_vals  = np.array([stats[k] for k in Q9_KEYS])
        elif all(k in stats for k in Q5_KEYS):
            q_probs = Q5_PROBS
            q_vals  = np.array([stats[k] for k in Q5_KEYS])
        else:
            q_probs = None

        if q_probs is not None:
            u = rng.uniform(0, 1, size=len(curr_values))
            synthetic_trained = np.interp(u, q_probs, q_vals)
        else:
            synthetic_trained = rng.normal(stats['mean'], trained_std, size=len(curr_values))

        feat_drifted = False
        if _HAS_SCIPY:
            _, ks_p = ks_2samp(curr_values.values, synthetic_trained)
            if ks_p < 0.05:
                feat_drifted = True
        else:
            # Fallback Z-score
            z = abs(curr_values.mean() - stats['mean']) / trained_std
            if z > 2.0:
                feat_drifted = True

        # KL divergence comme critère complémentaire au KS
        kl = _kl_divergence(curr_values.values, synthetic_trained)
        if kl > 0.5:
            feat_drifted = True

        if feat_drifted:
            drifted += 1
            drifted_features.append(feat)
            # Direction du shift pour la détection adversariale
            direction_shifts.append(np.sign(curr_values.mean() - stats['mean']))
        tested += 1

    drift_rate = drifted / max(tested, 1)

    # Détection de dérive adversariale : >50% des features dérivent simultanément
    # dans la même direction → signe d'une manipulation intentionnelle de la distribution
    if drift_rate > 0.50 and len(direction_shifts) >= 10:
        consensus = abs(float(np.mean(direction_shifts)))
        if consensus >= 0.8:
            log_decision('ADVERSARIAL_DRIFT', cycle_id, name, {
                'drift_rate': round(drift_rate, 3),
                'consensus': round(consensus, 3),
                'n_features': len(drifted_features),
                'top_drifted': drifted_features[:10],
            })

    return drift_rate


# ═══════════════════════════════════════════════════════════════════════════════
# A7 — VALIDATION DE COMPLÉTUDE DES FEATURES
# ═══════════════════════════════════════════════════════════════════════════════

def validate_features(df: pd.DataFrame, features: list, name: str,
                      cycle_id: str):
    """Vérifie que les features sont présentes et non constantes dans le DataFrame.

    Retourne la liste des features valides, ou None si le ratio est insuffisant.
    """
    structural = STRUCTURAL_EXCLUDED_FEATURES.get(name, [])
    active_features = [f for f in features if f not in structural]

    missing = [f for f in active_features if f not in df.columns]
    present = [f for f in active_features if f in df.columns]

    zero_val = [f for f in present if df[f].nunique() == 1 and df[f].max() == 0]
    unique_val = [f for f in present if df[f].nunique() == 1 and df[f].max() != 0]
    constant = zero_val + unique_val
    valid = [f for f in present if f not in constant]

    current_state = (frozenset(missing), frozenset(zero_val), frozenset(unique_val))
    state_changed = _feature_warning_cache.get(name) != current_state
    _feature_warning_cache[name] = current_state

    if structural:
        log_info(f"[{name}] Features exclues (structurelles / L4 indisponible) : {structural}")
    if state_changed:
        if missing:
            log_info(f"[{name}] Features absentes du schéma : {missing}")
        if zero_val:
            log_info(f"[{name}] Features à 0 (pipeline non-alimenté) : {zero_val}")
        if unique_val:
            log_info(f"[{name}] Features non-discriminantes (agrégat global) : {unique_val}")
        if missing or zero_val or unique_val:
            log_decision('FEATURE_WARNING', cycle_id, name, {
                'structural': structural, 'missing': missing,
                'zero': zero_val, 'unique_nonzero': unique_val,
                'valid_count': len(valid), 'total': len(active_features)
            })

    ratio = len(valid) / max(len(active_features), 1)
    if ratio < MIN_VALID_FEATURE_RATIO:
        log_info(f"[{name}] Ratio features valides insuffisant ({ratio:.0%} < {MIN_VALID_FEATURE_RATIO:.0%}) — cycle ignoré.")
        log_decision('SKIPPED_INVALID_FEATURES', cycle_id, name, {
            'valid_ratio': round(ratio, 3), 'threshold': MIN_VALID_FEATURE_RATIO
        })
        return None
    return valid


# ═══════════════════════════════════════════════════════════════════════════════
# A2 / A10 — SEUIL ADAPTATIF ET NORMALISATION
# ═══════════════════════════════════════════════════════════════════════════════

def compute_adaptive_threshold(scores: np.ndarray) -> float:
    """A2 : Seuil adaptatif basé sur le percentile des scores négatifs."""
    neg_scores = scores[scores < 0]
    if len(neg_scores) == 0:
        return ANOMALY_THRESHOLD
    adaptive = float(np.percentile(neg_scores, ANOMALY_PERCENTILE))
    return min(adaptive, ANOMALY_THRESHOLD)


def normalize_scores(scores: np.ndarray) -> np.ndarray:
    """A10 : Normalise les scores en [0, 1] avec 1 = le plus anomal."""
    result = np.zeros_like(scores)
    mask = scores < 0
    if mask.sum() == 0:
        return result
    s_min = scores[mask].min()
    if s_min == 0:
        return result
    result[mask] = np.clip(-scores[mask] / (-s_min + 1e-9), 0.0, 1.0)
    return result


# ═══════════════════════════════════════════════════════════════════════════════
# A4 — EXPLAINABILITÉ SHAP
# ═══════════════════════════════════════════════════════════════════════════════

def compute_shap_top_features(model, X: pd.DataFrame, features: list,
                               n_top: int = 5) -> list:
    """
    Calcule les valeurs SHAP pour chaque ligne de X et retourne les n_top features
    les plus contributives (valeur SHAP la plus négative = plus responsable de l'anomalie).
    Retourne une liste de dicts {feature: shap_value} par ligne.

    Utilise TreeExplainer pour sklearn, et un échantillon Permutation pour isotree.
    """
    if not ENABLE_SHAP or X.empty:
        return [{}] * len(X)
    try:
        if EIF_AVAILABLE:
            sample_size = min(100, len(X))
            X_sample = X.sample(n=sample_size, random_state=42) if len(X) > sample_size else X
            explainer = _shap.Explainer(model.decision_function, X_sample)
            shap_values = explainer(X).values
        else:
            explainer = _shap.TreeExplainer(model)
            shap_values = explainer.shap_values(X)
        result = []
        for sv in shap_values:
            pairs = sorted(zip(features, sv), key=lambda x: x[1])
            result.append({f: round(float(v), 4) for f, v in pairs[:n_top]})
        return result
    except Exception as e:
        log_info(f"[SHAP] Erreur de calcul SHAP: {e}")
        return [{}] * len(X)


def build_reason(name: str, row: pd.Series, shap_top: dict,
                 exiffi_top: dict = None) -> str:
    """Construit le champ reason enrichi avec SHAP, ExIFFI ou les métriques clés."""
    # Utilise le score brut pour l'affichage (plus interprétable que le score normalisé)
    score = round(float(row.get('raw_anomaly_score', row.get('anomaly_score', 0))), 3)
    threat = row.get('threat_level', '')
    if shap_top:
        top_str = ' | '.join(f"{f}({v:+.3f})" for f, v in shap_top.items())
        return f"[{name}] Score: {score} | SHAP: {top_str} | Threat: {threat}"
    if exiffi_top:
        top_str = ' | '.join(f"{f}({v:+.3f})" for f, v in exiffi_top.items())
        return f"[{name}] Score: {score} | ExIFFI: {top_str} | Threat: {threat}"
    vel = round(float(row.get('hit_velocity', 0)), 1)
    fuzz = round(float(row.get('fuzzing_index', 0)), 1)
    return f"[{name}] Score: {score} | Vel: {vel} req/s | Fuzzing: {fuzz} | Threat: {threat}"


# ═══════════════════════════════════════════════════════════════════════════════
# A8 — CLUSTERING COMPORTEMENTAL DES ANOMALIES
# ═══════════════════════════════════════════════════════════════════════════════

def cluster_anomalies(anomalies: pd.DataFrame, features: list,
                       ae_model=None) -> pd.DataFrame:
    """A8 : Applique HDBSCAN (ou DBSCAN en fallback) sur les features normalisées des anomalies.

    HDBSCAN est préféré car il détermine automatiquement le nombre de clusters
    et la densité optimale (pas de paramètre eps à régler manuellement).
    Si un autoencoder est disponible, utilise l'espace latent (16-dim) au lieu des features brutes
    pour un clustering plus expressif dans un espace de dimension réduite.
    Ajoute une colonne campaign_id : −1 = IP isolée, ≥0 = identifiant de campagne coordonnée.
    """
    anomalies = anomalies.copy()
    if len(anomalies) < CLUSTERING_MIN_SAMPLES:
        anomalies['campaign_id'] = -1
        return anomalies
    try:
        X = anomalies[features].replace([np.inf, -np.inf], np.nan).fillna(0)
        if ae_model is not None:
            try:
                X_scaled = ae_model.encode(X.values)
                algo_prefix = 'AE+'
            except Exception:
                X_scaled = StandardScaler().fit_transform(X)
                algo_prefix = ''
        else:
            X_scaled = StandardScaler().fit_transform(X)
            algo_prefix = ''
        if HDBSCAN_AVAILABLE:
            clusterer = _hdbscan.HDBSCAN(
                min_cluster_size=CLUSTERING_MIN_SAMPLES,
                min_samples=max(2, CLUSTERING_MIN_SAMPLES - 1),
                cluster_selection_method='eom'
            )
            labels = clusterer.fit_predict(X_scaled)
        else:
            labels = DBSCAN(eps=0.5, min_samples=CLUSTERING_MIN_SAMPLES).fit_predict(X_scaled)
        anomalies['campaign_id'] = labels
        n_campaigns = len(set(labels)) - (1 if -1 in labels else 0)
        algo = algo_prefix + ('HDBSCAN' if HDBSCAN_AVAILABLE else 'DBSCAN')
        if n_campaigns > 0:
            log_info(f"[{algo}] {n_campaigns} campagne(s) détectée(s) parmi {len(anomalies)} anomalies.")
    except Exception as e:
        log_info(f"[Clustering] Erreur de clustering: {e}")
        anomalies['campaign_id'] = -1
    return anomalies


# ═══════════════════════════════════════════════════════════════════════════════
# §7 — ExIFFI : IMPORTANCE DE FEATURES PAR PERMUTATION
# ═══════════════════════════════════════════════════════════════════════════════

def compute_exiffi_importance(model, X: pd.DataFrame, features: list,
                               n_top: int = 3, sample_size: int = 200) -> list:
    """§7 — Importance de features pour l'EIF par permutation (ExIFFI simplifié).

    Pour chaque feature, calcule la dégradation de score lors de la permutation
    de la colonne. La différence de score (permuted − original) mesure l'importance :
    une valeur positive élevée signifie que permuter cette feature rend la session
    moins anomale → la feature est un signal fort de détection.

    Compatible sklearn IsolationForest et isotree ExtendedIsolationForest.
    Retourne une liste de dicts {feature: importance_score} par ligne de X.
    """
    if X.empty or len(features) < 2:
        return [{}] * len(X)
    try:
        # Limiter la taille pour la performance
        if len(X) > sample_size:
            X_sample = X[features].sample(n=sample_size, random_state=42)
        else:
            X_sample = X[features]
        X_arr = X_sample.fillna(0).values.copy()
        baseline_scores = model.decision_function(X_arr)

        feat_importance: dict = {}
        rng = np.random.default_rng(42)
        for i, feat in enumerate(features):
            if feat not in X_sample.columns:
                continue
            X_perm = X_arr.copy()
            # Permuter la colonne (brise la corrélation signal/label)
            X_perm[:, i] = rng.permutation(X_perm[:, i])
            perm_scores = model.decision_function(X_perm)
            # Importance = gain moyen de score lors de la permutation
            feat_importance[feat] = float(np.mean(perm_scores - baseline_scores))

        # Trier par importance décroissante : les plus grandes valeurs = features clés
        sorted_feats = sorted(feat_importance.items(), key=lambda kv: kv[1], reverse=True)
        top_feats = {f: round(v, 4) for f, v in sorted_feats[:n_top]}

        # L'importance est globale (calculée sur un sample) : même top pour toutes les lignes
        return [top_feats] * len(X)
    except Exception as e:
        log_info(f"[ExIFFI] Erreur de calcul : {e}")
        return [{}] * len(X)


def compute_ae_feature_errors(ae_model, X: pd.DataFrame, features: list,
                               n_top: int = 3) -> list:
    """§7 — Erreur de reconstruction par feature de l'Autoencoder.

    Pour chaque session anomale, détermine quelles features ont la plus grande
    erreur de reconstruction individuelle (signal de l'anomalie la plus saillante).
    Retourne une liste de dicts {feature: reconstruction_error} par ligne de X.
    """
    if ae_model is None or X.empty:
        return [{}] * len(X)
    try:
        import torch
        X_arr = X[features].fillna(0).values.astype(np.float32)
        X_t = torch.tensor(X_arr)
        with torch.no_grad():
            X_rec = ae_model.forward(X_t).numpy()
        per_feature_err = (X_arr - X_rec) ** 2  # shape: (n_samples, n_features)
        result = []
        for row_err in per_feature_err:
            pairs = sorted(zip(features, row_err.tolist()), key=lambda kv: kv[1], reverse=True)
            result.append({f: round(v, 6) for f, v in pairs[:n_top]})
        return result
    except Exception as e:
        log_info(f"[AE features] Erreur de calcul erreur par feature : {e}")
        return [{}] * len(X)


# ═══════════════════════════════════════════════════════════════════════════════
# §8 — META-LEARNER : PONDÉRATION APPRISE DE L'ENSEMBLE
# ═══════════════════════════════════════════════════════════════════════════════

class MetaLearner:
    """§8 — Méta-learner (régression logistique) pour la pondération de l'ensemble.

    Remplace les poids fixes (1−XGB_W)×((1−AE_W)×eif + AE_W×ae) + XGB_W×xgb
    par une régression logistique entraînée sur les labels SOC/Anubis/bots connus.

    Formule apprise :
        P(bot) = logistic(w1×eif_norm + w2×ae_norm + w3×xgb_prob
                         + w4×log1p(hits) + w5×correlated + bias)

    Fallback automatique aux poids fixes quand < MIN_SAMPLES labels disponibles.
    """

    MIN_SAMPLES = 1000  # Nombre minimal de labels pour activer le méta-learner

    def __init__(self):
        self._clf = None
        self._n_samples: int = 0
        self._feature_names = ['eif_norm', 'ae_norm', 'xgb_prob', 'log_hits', 'correlated']
        self._weights_log: dict = {}  # Pour la transparence SOC

    def fit(self, df: pd.DataFrame) -> bool:
        """Entraîne le méta-learner sur un DataFrame de sessions labelisées.

        Colonnes requises : eif_norm, ae_norm (ou 0), xgb_prob (ou 0),
                            hits, correlated, label (0=normal, 1=bot).
        Retourne True si l'entraînement a réussi.
        """
        try:
            from sklearn.linear_model import LogisticRegression
            from sklearn.preprocessing import StandardScaler as _SS
        except ImportError:
            return False

        required = {'eif_norm', 'label'}
        if not required.issubset(df.columns):
            return False

        df = df.dropna(subset=['eif_norm', 'label'])
        if len(df) < self.MIN_SAMPLES:
            return False

        X_meta = pd.DataFrame({
            'eif_norm':   df['eif_norm'].clip(0, 1),
            'ae_norm':    df.get('ae_norm', pd.Series(0.0, index=df.index)).clip(0, 1),
            'xgb_prob':   df.get('xgb_prob', pd.Series(0.0, index=df.index)).clip(0, 1),
            'log_hits':   np.log1p(df.get('hits', pd.Series(1, index=df.index)).clip(1)),
            'correlated': df.get('correlated', pd.Series(0, index=df.index)).clip(0, 1),
        }).fillna(0)
        y = df['label'].astype(int)

        scaler = _SS()
        X_scaled = scaler.fit_transform(X_meta)
        clf = LogisticRegression(max_iter=500, C=1.0, random_state=42)
        clf.fit(X_scaled, y)

        # Enregistrer les poids pour la transparence SOC
        coefs = dict(zip(self._feature_names, clf.coef_[0].tolist()))
        self._clf = (clf, scaler)
        self._n_samples = len(df)
        self._weights_log = {
            'coefs': {k: round(v, 4) for k, v in coefs.items()},
            'intercept': round(float(clf.intercept_[0]), 4),
            'n_samples': self._n_samples,
        }
        log_info(f"[MetaLearner] Entraîné sur {self._n_samples} labels — coefs: {coefs}")
        return True

    def predict(self, eif_norm: np.ndarray, ae_norm: np.ndarray,
                xgb_prob: np.ndarray, hits: np.ndarray = None,
                correlated: np.ndarray = None) -> np.ndarray:
        """Prédit P(bot) avec le méta-learner ou les poids fixes en fallback.

        Retourne un array de probabilités dans [0, 1].
        """
        n = len(eif_norm)
        if hits is None:
            hits = np.ones(n)
        if correlated is None:
            correlated = np.zeros(n)

        if self.is_trained:
            clf, scaler = self._clf
            X_meta = np.column_stack([
                np.clip(eif_norm, 0, 1),
                np.clip(ae_norm, 0, 1),
                np.clip(xgb_prob, 0, 1),
                np.log1p(np.clip(hits, 1, None)),
                np.clip(correlated, 0, 1),
            ])
            try:
                X_scaled = scaler.transform(X_meta)
                return clf.predict_proba(X_scaled)[:, 1]
            except Exception as e:
                log_info(f"[MetaLearner] Prédiction échouée ({e}) — fallback poids fixes")

        # Fallback : formule linéaire avec poids fixes
        _ae_w = AE_WEIGHT
        _xgb_w = XGB_WEIGHT
        return (1 - _xgb_w) * ((1 - _ae_w) * eif_norm + _ae_w * ae_norm) + _xgb_w * xgb_prob

    @property
    def is_trained(self) -> bool:
        """Vrai si le méta-learner est actif (assez de labels pour être fiable)."""
        return self._clf is not None and self._n_samples >= self.MIN_SAMPLES

    def build_labels_from_df(self, df: pd.DataFrame) -> pd.DataFrame:
        """Construit les labels supervisés pour l'entraînement du méta-learner.

        Label=1 : bot connu (bot_name), Anubis DENY, anomalie CRITICAL/HIGH confirmée.
        Label=0 : navigateur légitime LEGITIMATE_BROWSER, Anubis ALLOW.
        Retourne seulement les lignes avec un label fiable.
        """
        labeled = []
        for _, row in df.iterrows():
            threat = str(row.get('threat_level', ''))
            bot_name = str(row.get('bot_name', ''))
            action = str(row.get('anubis_bot_action', ''))
            if bot_name != '' or action == 'DENY' or threat in ('CRITICAL', 'HIGH', 'KNOWN_BOT', 'ANUBIS_DENY'):
                labeled.append({**row, 'label': 1})
            elif threat == 'LEGITIMATE_BROWSER' or action == 'ALLOW':
                labeled.append({**row, 'label': 0})
        if not labeled:
            return pd.DataFrame()
        return pd.DataFrame(labeled)


# Singleton partagé entre les modèles Complet et Applicatif
_meta_learner_complet    = MetaLearner()
_meta_learner_applicatif = MetaLearner()


def get_meta_learner(name: str) -> MetaLearner:
    """Retourne le singleton MetaLearner correspondant au modèle ``name``."""
    if 'Applicatif' in name:
        return _meta_learner_applicatif
    return _meta_learner_complet
