"""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 / Z-score)
  - 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
"""
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,
)
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


# 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 _compute_drift_score(baseline_stats: dict, current_baseline: pd.DataFrame,
                         features: list) -> float:
    """Compare la distribution actuelle de la baseline humaine avec celle de l'entraînement.

    Utilise le test de Kolmogorov-Smirnov bilatéral par feature. La distribution
    d'entraînement est reconstruite à partir d'un quantile digest (p10..p90) par
    interpolation linéaire — bien plus fidèle qu'une approximation N(μ,σ) pour les
    features asymétriques ou multimodales.
    Retourne la fraction de features en dérive significative (p < 0.05).
    """
    if not baseline_stats or current_baseline.empty:
        return 0.0
    try:
        from scipy.stats import ks_2samp
    except ImportError:
        return _compute_drift_score_zscore(baseline_stats, current_baseline, features)

    drifted = 0
    tested = 0
    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

        quantile_keys = ['p10', 'p25', 'p50', 'p75', 'p90']
        if all(k in stats for k in quantile_keys):
            quantile_probs = np.array([0.10, 0.25, 0.50, 0.75, 0.90])
            quantile_vals = np.array([stats[k] for k in quantile_keys])
            u = rng.uniform(0, 1, size=len(curr_values))
            synthetic_trained = np.interp(u, quantile_probs, quantile_vals)
        else:
            synthetic_trained = rng.normal(stats['mean'], trained_std, size=len(curr_values))

        _, p_value = ks_2samp(curr_values.values, synthetic_trained)
        if p_value < 0.05:
            drifted += 1
        tested += 1
    return drifted / max(tested, 1)


def _compute_drift_score_zscore(baseline_stats: dict, current_baseline: pd.DataFrame,
                                features: list) -> float:
    """Fallback Z-score pour la détection de dérive quand scipy n'est pas disponible."""
    if not baseline_stats or current_baseline.empty:
        return 0.0
    drifted = 0
    tested = 0
    for feat in features:
        if feat not in baseline_stats or feat not in current_baseline.columns:
            continue
        stats = baseline_stats[feat]
        curr_mean = current_baseline[feat].mean()
        trained_std = stats.get('std', 0)
        if trained_std < 1e-9:
            continue
        z = abs(curr_mean - stats['mean']) / trained_std
        if z > 2.0:
            drifted += 1
        tested += 1
    return drifted / max(tested, 1)


# ═══════════════════════════════════════════════════════════════════════════════
# 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) -> str:
    """Construit le champ reason enrichi avec le top SHAP 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}"
    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
