"""Moteur de correspondance de signatures navigateur (browser_matcher).

Remplace progressivement le système browser_confidence pour la décision de
bypass LEGITIMATE_BROWSER. Pendant la phase de validation, les deux systèmes
tournent en parallèle (BROWSER_MATCHER_REPLACE=false) : les décisions sont
journalisées côte à côte sans modifier le comportement existant.

Fonctionne en mode vectorisé sur un DataFrame pandas entier.
Les colonnes requises sont fournies par view_ai_features_1h (07_ai_features_view.sql).

Variables d'environnement :
  BROWSER_MATCHER_ENABLED  : activer le matcher (défaut: true)
  BROWSER_MATCHER_REPLACE  : remplacer browser_confidence pour le bypass (défaut: false)
"""
import os
import pandas as pd
import numpy as np

from .browser_signatures import (
    BROWSER_SIGNATURES,
    NON_BROWSER_SIGNATURES,
    BROWSER_THRESHOLDS,
    DIMENSION_WEIGHTS,
)
from .log import log_info

BROWSER_MATCHER_ENABLED: bool = os.getenv("BROWSER_MATCHER_ENABLED", "true").lower() == "true"
BROWSER_MATCHER_REPLACE: bool = os.getenv("BROWSER_MATCHER_REPLACE", "false").lower() == "true"

# Familles Chrome-like dans le dictionnaire dict_browser_ja4.
_CHROME_JA4_FAMILIES = {"Chromium", "Chrome", "Edge"}

# Correspondance SETTINGS ID → nom de colonne dans view_ai_features_1h.
_H2_SETTINGS_COLS: dict[int, str] = {
    1: "h2_header_table_size",
    2: "h2_enable_push",
    3: "h2_max_concurrent_streams",
    4: "h2_initial_window_size",
    5: "h2_max_frame_size",
    6: "h2_max_header_list_size",
    8: "h2_enable_connect_protocol",
}


def _col(df: pd.DataFrame, name: str, default=0) -> pd.Series:
    """Retourne une colonne du DataFrame ou une série de valeurs par défaut."""
    if name in df.columns:
        return df[name].fillna(default)
    return pd.Series(default, index=df.index, dtype=float)


# ─────────────────────────────────────────────────────────────────────────────
# Scoring par dimension
# ─────────────────────────────────────────────────────────────────────────────

def _d1_h2_settings(df: pd.DataFrame, family: str) -> pd.Series:
    """Dimension 1 — correspondance SETTINGS H2 (poids 0.30).

    Deux niveaux de signal selon les données disponibles :

    1. Comparaison directe par paramètre (colonnes individuelles) :
       - Pour chaque clé attendue dans h2_settings_exact : exact match → 1, écart → 0
       - Pour chaque clé interdite dans h2_settings_forbidden_keys : absente (-1) → 1
       - Score = (correct / total_vérifications)
       Pondération : direct_score × 0.60 + dict_match × 0.30 + ja4_coherence × 0.10

    2. Fallback (colonnes indisponibles) : lookup dict_browser_h2 uniquement
       Pondération : dict_match × 0.80 + ja4_coherence × 0.20

    Neutre (0.5) quand le trafic transite par un proxy CDN (has_xff > 0).
    """
    sig = BROWSER_SIGNATURES[family]
    has_xff = _col(df, "has_xff")

    expected: dict = sig["h2_settings_exact"]
    forbidden: list = sig.get("h2_settings_forbidden_keys", [])

    # Vérifie si les colonnes individuelles sont disponibles
    needed_cols = [_H2_SETTINGS_COLS[k] for k in expected if k in _H2_SETTINGS_COLS]
    individual_available = bool(needed_cols) and all(c in df.columns for c in needed_cols)

    if individual_available:
        checks: list[pd.Series] = []

        # Clés attendues : valeur exacte ET non absente
        for settings_id, expected_val in expected.items():
            col = _H2_SETTINGS_COLS.get(settings_id)
            if col and col in df.columns:
                v = _col(df, col, -1)
                checks.append(((v == expected_val) & (v >= 0)).astype(float))

        # Clés interdites : doivent être absentes (valeur -1)
        for settings_id in forbidden:
            col = _H2_SETTINGS_COLS.get(settings_id)
            if col and col in df.columns:
                checks.append((_col(df, col, -1) < 0).astype(float))

        n = len(checks)
        direct_score = sum(checks) / n if n > 0 else pd.Series(0.5, index=df.index)

        h2_dict_family = _col(df, "h2_dict_family", "")
        dict_match = (h2_dict_family.astype(str) == family).astype(float)
        h2_ja4_coherence = _col(df, "h2_ja4_coherence")

        base = direct_score * 0.60 + dict_match * 0.30 + h2_ja4_coherence * 0.10
    else:
        # Fallback : lookup dictionnaire uniquement
        h2_dict_family = _col(df, "h2_dict_family", "")
        dict_match = (h2_dict_family.astype(str) == family).astype(float)
        h2_ja4_coherence = _col(df, "h2_ja4_coherence")
        base = dict_match * 0.80 + h2_ja4_coherence * 0.20

    return pd.Series(
        np.where(has_xff > 0, 0.5, base.values if hasattr(base, "values") else base),
        index=df.index,
    )


def _d2_h2_window(df: pd.DataFrame, family: str) -> pd.Series:
    """Dimension 2 — valeur WINDOW_UPDATE H2 (poids 0.15).

    Score 1.0 si la valeur correspond à la famille attendue.
    Score 0.0 si absent (= outil non-navigateur) ou mauvaise famille.
    Neutre (0.5) si proxy CDN (has_xff) car le H2 client est perdu.
    """
    sig = BROWSER_SIGNATURES[family]
    has_xff = _col(df, "has_xff")
    wu = _col(df, "h2_window_update_value")
    tol = sig["h2_window_update_tolerance"]
    expected = sig["h2_window_update"]

    match = ((wu - expected).abs() <= tol).astype(float)
    absent = (wu == 0).astype(float)

    base = pd.Series(
        np.where(absent > 0, 0.0, match.values),
        index=df.index,
    )
    return pd.Series(
        np.where(has_xff > 0, 0.5, base.values),
        index=df.index,
    )


def _d3_pseudo_order(df: pd.DataFrame, family: str) -> pd.Series:
    """Dimension 3 — ordre des pseudo-headers H2 (poids 0.15).

    Correspondance exacte → 1.0.
    Mauvais ordre mais non vide → 0.0.
    Données H2 absentes ou proxy CDN (has_xff) → neutre 0.5.
    """
    sig = BROWSER_SIGNATURES[family]
    expected = sig["pseudo_header_order"]
    has_xff = _col(df, "has_xff")

    # Colonne h2_pseudo_ord_raw si disponible, sinon reconstruire depuis signaux atomiques
    if "h2_pseudo_ord_raw" in df.columns:
        h2_order = df["h2_pseudo_ord_raw"].fillna("").astype(str)
        absent = h2_order == ""
        match = (h2_order == expected).astype(float)
        base = pd.Series(
            np.where(absent, 0.5, match.values),
            index=df.index,
        )
        return pd.Series(
            np.where(has_xff > 0, 0.5, base.values),
            index=df.index,
        )

    # Fallback sur les signaux atomiques précalculés en SQL
    if family == "Chrome":
        # Chrome et Safari partagent l'ordre m,a,s,p : utiliser WU pour distinguer
        h2_order_cs = _col(df, "h2_order_chromesafari")
        wu = _col(df, "h2_window_update_value")
        chrome_wu = ((wu - 15663105).abs() <= 1000).astype(float)
        base = (h2_order_cs * chrome_wu).clip(0, 1)
    elif family == "Firefox":
        base = _col(df, "h2_order_firefox")
    elif family == "Safari":
        h2_order_cs = _col(df, "h2_order_chromesafari")
        wu = _col(df, "h2_window_update_value")
        safari_wu = ((wu - 10485760).abs() <= 1000).astype(float)
        base = (h2_order_cs * safari_wu).clip(0, 1)
    else:
        base = pd.Series(0.5, index=df.index, dtype=float)

    return pd.Series(
        np.where(has_xff > 0, 0.5, base.values),
        index=df.index,
    )


def _d4_h2_priority(df: pd.DataFrame, family: str) -> pd.Series:
    """Dimension 4 — présence de PRIORITY frames H2 (poids 0.10).

    Firefox attendait des PRIORITY frames (versions anciennes).
    Chrome et Safari n'en envoient pas.
    Score 1.0 si présence/absence correspond à l'attendu.
    Neutre (0.5) si proxy CDN (has_xff) ou pas de données H2.
    """
    sig = BROWSER_SIGNATURES[family]
    expected_present = sig["h2_priority_frames_expected"]
    has_xff = _col(df, "has_xff")

    if "h2_priority_present" not in df.columns:
        return pd.Series(0.5, index=df.index, dtype=float)

    priority_present = _col(df, "h2_priority_present").astype(bool)
    match = (priority_present == expected_present).astype(float)

    # Neutre si pas de données H2
    h2_known = _col(df, "h2_settings_known")
    wu = _col(df, "h2_window_update_value")
    no_h2 = ((h2_known == 0) & (wu == 0)).astype(float)

    base = pd.Series(
        np.where(no_h2 > 0, 0.5, match.values),
        index=df.index,
    )
    return pd.Series(
        np.where(has_xff > 0, 0.5, base.values),
        index=df.index,
    )


def _d5_http_headers(df: pd.DataFrame, family: str) -> pd.Series:
    """Dimension 5 — cohérence des headers HTTP (poids 0.15).

    +0.50 : tous les headers requis sont présents
    +0.50 : aucun header interdit n'est présent
    """
    sig = BROWSER_SIGNATURES[family]

    score = pd.Series(0.0, index=df.index, dtype=float)

    # Accept-Language requis pour tous les navigateurs
    if sig["accept_language_required"]:
        has_lang = _col(df, "has_accept_language")
        score += has_lang.clip(0, 1) * 0.25

    # Sec-Fetch-* : Chrome et Firefox les envoient, Safari non
    if sig["headers_sec_fetch_required"]:
        sec_fetch_rate = 1.0 - _col(df, "sec_fetch_absence_rate", 0.5).clip(0, 1)
        score += sec_fetch_rate * 0.25
    elif sig.get("headers_sec_fetch_forbidden"):
        # Safari : pénaliser si Sec-Fetch présent
        sec_fetch_absent = _col(df, "sec_fetch_absence_rate", 1.0).clip(0, 1)
        score += sec_fetch_absent * 0.25
    else:
        score += 0.125  # neutre

    # Sec-CH-UA : Chrome l'envoie, Firefox/Safari non
    has_ch_ua = _col(df, "has_sec_ch_ua")
    if sig["headers_ch_ua_required"]:
        score += has_ch_ua.clip(0, 1) * 0.25
    else:
        # Pénaliser si présent alors qu'il ne devrait pas l'être
        absent_ch_ua = (1 - has_ch_ua.clip(0, 1))
        score += absent_ch_ua * 0.25

    return score.clip(0, 1)


def _d6_tls_structure(df: pd.DataFrame, family: str) -> pd.Series:
    """Dimension 6 — structure TLS (poids 0.10).

    Utilise browser_family (du dictionnaire dict_browser_ja4) comme proxy
    de la famille TLS, puisque le JA4 encode la version TLS et les ciphers.
    """
    sig = BROWSER_SIGNATURES[family]
    expected_families = set(sig["tls"]["ja4_families"])

    browser_family = _col(df, "browser_family", "").astype(str)
    family_match = browser_family.isin(expected_families).astype(float)

    # TLS 1.2 = signal négatif pour tous les navigateurs modernes
    tls12_ratio = _col(df, "tls12_ratio", 0.0).clip(0, 1)
    tls13_bonus = 1.0 - tls12_ratio

    # GREASE : Chrome/Edge l'utilisent, Firefox/Safari non
    if sig["tls"]["grease_expected"]:
        # Pas de signal GREASE explicite dans le schéma actuel — se fier au family_match
        return (family_match * 0.7 + tls13_bonus * 0.3).clip(0, 1)
    else:
        return (family_match * 0.7 + tls13_bonus * 0.3).clip(0, 1)


def _d7_ja4_dict(df: pd.DataFrame, family: str) -> pd.Series:
    """Dimension 7 — correspondance dans le dictionnaire dict_browser_ja4 (poids 0.05)."""
    sig = BROWSER_SIGNATURES[family]
    expected_families = set(sig["tls"]["ja4_families"])

    browser_family = _col(df, "browser_family", "").astype(str)
    return browser_family.isin(expected_families).astype(float)


# ─────────────────────────────────────────────────────────────────────────────
# Score composite
# ─────────────────────────────────────────────────────────────────────────────

def _compute_family_score(df: pd.DataFrame, family: str) -> pd.Series:
    """Calcule le score de correspondance [0.0, 1.0] pour une famille navigateur.

    Score = somme pondérée des 7 dimensions.
    """
    w = DIMENSION_WEIGHTS
    score = (
        _d1_h2_settings(df, family)  * w["h2_settings"]
        + _d2_h2_window(df, family)  * w["h2_window"]
        + _d3_pseudo_order(df, family) * w["pseudo_order"]
        + _d4_h2_priority(df, family) * w["h2_priority"]
        + _d5_http_headers(df, family) * w["http_headers"]
        + _d6_tls_structure(df, family) * w["tls_structure"]
        + _d7_ja4_dict(df, family)   * w["ja4_dict"]
    )
    return score.clip(0.0, 1.0)


def _matches_non_browser(df: pd.DataFrame) -> pd.Series:
    """Détecte les signatures d'outils non-navigateur (curl, httpx, go).

    Retourne un masque booléen.
    Désactivé derrière un proxy CDN (has_xff) : le H2 observé est celui du proxy.
    """
    has_xff = _col(df, "has_xff")
    wu = _col(df, "h2_window_update_value")

    # go net/http a une valeur de WINDOW_UPDATE très distincte
    go_sig = (wu - NON_BROWSER_SIGNATURES["go_net_http"]["h2_window_update"]).abs() < 1000

    # curl / httpx : WINDOW_UPDATE absent (= 0) alors que des données H2 existent
    h2_present = _col(df, "h2_settings_known").astype(bool) | (wu > 0)
    curl_sig = (wu == 0) & h2_present

    # Ignorer les signaux H2 non-browser derrière un proxy
    return ((go_sig | curl_sig) & (has_xff == 0)).astype(bool)


# ─────────────────────────────────────────────────────────────────────────────
# Point d'entrée principal
# ─────────────────────────────────────────────────────────────────────────────

def run_browser_matcher(df: pd.DataFrame) -> pd.DataFrame:
    """Exécute le matcher de signatures sur le DataFrame entier (vectorisé).

    Ajoute les colonnes :
      bm_family (str)   — famille identifiée, '' si aucune
      bm_score  (float) — score de la meilleure correspondance [0.0, 1.0]
      bm_partial (bool) — True si score entre 0.45 et le seuil (zone grise)
      bm_non_browser (bool) — True si signature d'outil détectée
      bm_decision (str) — 'LEGITIMATE_BROWSER', 'PARTIAL', 'NON_BROWSER', 'UNKNOWN'

    Retourne le DataFrame avec ces colonnes ajoutées.
    """
    if not BROWSER_MATCHER_ENABLED or df.empty:
        df["bm_family"] = ""
        df["bm_score"] = 0.0
        df["bm_partial"] = False
        df["bm_non_browser"] = False
        df["bm_decision"] = "UNKNOWN"
        return df

    scores: dict[str, pd.Series] = {}
    for family in BROWSER_SIGNATURES:
        scores[family] = _compute_family_score(df, family)

    score_df = pd.DataFrame(scores, index=df.index)
    best_family = score_df.idxmax(axis=1)
    best_score = score_df.max(axis=1)

    non_browser_mask = _matches_non_browser(df)

    # Réduire le score si l'IP utilise une signature d'outil
    adjusted_score = pd.Series(
        np.where(non_browser_mask, best_score.values * 0.6, best_score.values),
        index=df.index,
    )
    adjusted_family = best_family.copy()
    adjusted_family[non_browser_mask] = ""

    # Calculer le seuil par famille
    thresholds = adjusted_family.map(lambda f: BROWSER_THRESHOLDS.get(f, 1.0))
    above_threshold = adjusted_score >= thresholds
    partial_mask = (~above_threshold) & (adjusted_score >= 0.45) & (~non_browser_mask)

    # Décision finale
    def _decide(row):
        family, score, is_nb, is_above, is_partial = row
        if is_nb:
            return "NON_BROWSER"
        if is_above and family:
            return "LEGITIMATE_BROWSER"
        if is_partial and family:
            return "PARTIAL"
        return "UNKNOWN"

    decision = pd.Series(
        [
            _decide(r)
            for r in zip(
                adjusted_family,
                adjusted_score,
                non_browser_mask,
                above_threshold,
                partial_mask,
            )
        ],
        index=df.index,
    )

    df["bm_family"] = adjusted_family.where(above_threshold | partial_mask, "")
    df["bm_score"] = adjusted_score.round(4)
    df["bm_partial"] = partial_mask
    df["bm_non_browser"] = non_browser_mask
    df["bm_decision"] = decision

    return df


def log_dual_mode_comparison(
    df: pd.DataFrame, cycle_id: str, model_name: str
) -> None:
    """Journalise la comparaison entre browser_confidence (ancien) et browser_matcher (nouveau).

    Appelé en mode DUAL_MODE pour suivre les divergences entre les deux systèmes
    avant de basculer vers browser_matcher comme décideur unique.
    """
    if "browser_confidence" not in df.columns or "bm_decision" not in df.columns:
        return

    from .config import BROWSER_CONFIDENCE_THRESHOLD

    old_legit = (
        df["browser_confidence"] >= BROWSER_CONFIDENCE_THRESHOLD
    ) & df.get("inferred_browser_family", pd.Series("", index=df.index)).fillna("").astype(str).ne("")
    new_legit = df["bm_decision"] == "LEGITIMATE_BROWSER"

    only_old = int((old_legit & ~new_legit).sum())
    only_new = int((~old_legit & new_legit).sum())
    both = int((old_legit & new_legit).sum())
    neither = int((~old_legit & ~new_legit).sum())

    log_info(
        f"[{model_name}][DUAL_MODE] browser_confidence vs browser_matcher —"
        f" concordance={both}, seul_ancien={only_old},"
        f" seul_nouveau={only_new}, aucun={neither}"
    )

    if only_old > 0:
        sample = df[old_legit & ~new_legit][["src_ip", "ja4", "browser_confidence",
                                              "bm_family", "bm_score"]].head(3)
        log_info(f"[{model_name}][DUAL_MODE] Exemples seul_ancien:\n{sample.to_string()}")
    if only_new > 0:
        sample = df[~old_legit & new_legit][["src_ip", "ja4", "browser_confidence",
                                              "bm_family", "bm_score"]].head(3)
        log_info(f"[{model_name}][DUAL_MODE] Exemples seul_nouveau:\n{sample.to_string()}")
