"""Tests unitaires pour browser_matcher.py.

Vérifie les 6 cas de base :
  1. Chrome full match  → famille Chrome, score ≥ 0.72 → LEGITIMATE_BROWSER
  2. Firefox full match → famille Firefox, score ≥ 0.68 → LEGITIMATE_BROWSER
  3. Safari full match  → famille Safari,  score ≥ 0.68 → LEGITIMATE_BROWSER
  4. curl rejeté        → NON_BROWSER (pas de bypass)
  5. httpcloak partial  → score < seuil, PARTIAL
  6. TLS↔H2 mismatch    → tls_h2_family_mismatch calculé correctement

Et les cas de comparaison directe SETTINGS (colonnes individuelles) :
  7. Colonnes individuelles Chrome exactes → score D1 élevé
  8. Une clé SETTINGS erronée → score D1 réduit proportionnellement
  9. Clé interdite présente → pénalité
 10. Fingerprint inconnu du dict mais SETTINGS exacts → score D1 élevé (avantage vs dict-only)
 11. Fallback dict-only quand colonnes individuelles absentes

Les tests sont auto-contenus : ils construisent des DataFrames pandas
représentant des sessions fictives sans interagir avec ClickHouse.
"""
import pytest
import pandas as pd
import numpy as np

# Les importations du module principal sont testées directement.
from bot_detector.browser_signatures import BROWSER_SIGNATURES, BROWSER_THRESHOLDS
from bot_detector.browser_matcher import (
    run_browser_matcher,
    _compute_family_score,
    _matches_non_browser,
    _d1_h2_settings,
    _H2_SETTINGS_COLS,
)


# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────

def _chrome_session(**overrides) -> pd.DataFrame:
    """Session Chrome parfaite (fingerprint complet)."""
    base = {
        "src_ip": "1.2.3.4",
        "ja4": "t13d1517h2_8daaf6152771_e5627efa2ab1",
        "browser_family": "Chromium",
        "h2_dict_family": "Chrome",
        "h2_dict_confidence": 1.0,
        "h2_window_update_value": 15663105,
        "h2_order_chromesafari": 1,
        "h2_order_firefox": 0,
        "h2_window_chrome": 1,
        "h2_window_firefox": 0,
        "h2_window_safari": 0,
        "h2_window_absent": 0,
        "h2_priority_present": 0,
        "h2_settings_known": 1,
        "h2_ja4_coherence": 1,
        "h2_settings_rare": 0,
        "has_accept_language": 1,
        "has_sec_ch_ua": 1,
        "sec_fetch_absence_rate": 0.0,
        "modern_browser_score": 1.0,
        "has_xff": 0,
        "tls12_ratio": 0.0,
    }
    base.update(overrides)
    return pd.DataFrame([base])


def _firefox_session(**overrides) -> pd.DataFrame:
    """Session Firefox parfaite."""
    base = {
        "src_ip": "2.3.4.5",
        "ja4": "t13d1516h2_8daaf6152771_e5627efa2ab2",
        "browser_family": "Firefox",
        "h2_dict_family": "Firefox",
        "h2_dict_confidence": 1.0,
        "h2_window_update_value": 12517377,
        "h2_order_chromesafari": 0,
        "h2_order_firefox": 1,
        "h2_window_chrome": 0,
        "h2_window_firefox": 1,
        "h2_window_safari": 0,
        "h2_window_absent": 0,
        "h2_priority_present": 0,
        "h2_settings_known": 1,
        "h2_ja4_coherence": 1,
        "h2_settings_rare": 0,
        "has_accept_language": 1,
        "has_sec_ch_ua": 0,          # Firefox n'envoie pas Sec-CH-UA
        "sec_fetch_absence_rate": 0.0,
        "modern_browser_score": 1.0,
        "has_xff": 0,
        "tls12_ratio": 0.0,
    }
    base.update(overrides)
    return pd.DataFrame([base])


def _safari_session(**overrides) -> pd.DataFrame:
    """Session Safari parfaite."""
    base = {
        "src_ip": "3.4.5.6",
        "ja4": "t13d1516h2_8daaf6152771_e5627efa2ab3",
        "browser_family": "Safari",
        "h2_dict_family": "Safari",
        "h2_dict_confidence": 1.0,
        "h2_window_update_value": 10485760,
        "h2_order_chromesafari": 1,   # m,a,s,p partagé avec Chrome
        "h2_order_firefox": 0,
        "h2_window_chrome": 0,
        "h2_window_firefox": 0,
        "h2_window_safari": 1,
        "h2_window_absent": 0,
        "h2_priority_present": 0,
        "h2_settings_known": 1,
        "h2_ja4_coherence": 1,
        "h2_settings_rare": 0,
        "has_accept_language": 1,
        "has_sec_ch_ua": 0,
        "sec_fetch_absence_rate": 1.0,  # Safari n'envoie pas Sec-Fetch-*
        "modern_browser_score": 0.8,
        "has_xff": 0,
        "tls12_ratio": 0.0,
    }
    base.update(overrides)
    return pd.DataFrame([base])


def _curl_session(**overrides) -> pd.DataFrame:
    """Session curl (WINDOW_UPDATE absent = 0, signature d'outil)."""
    base = {
        "src_ip": "4.5.6.7",
        "ja4": "t13d0606h2_915bc7144b37_curl",
        "browser_family": "",
        "h2_dict_family": "",
        "h2_dict_confidence": 0.0,
        "h2_window_update_value": 0,
        "h2_order_chromesafari": 0,
        "h2_order_firefox": 0,
        "h2_window_chrome": 0,
        "h2_window_firefox": 0,
        "h2_window_safari": 0,
        "h2_window_absent": 1,
        "h2_priority_present": 0,
        "h2_settings_known": 1,       # curl envoie des SETTINGS mais pas de WU
        "h2_ja4_coherence": 0,
        "h2_settings_rare": 1,
        "has_accept_language": 0,
        "has_sec_ch_ua": 0,
        "sec_fetch_absence_rate": 1.0,
        "modern_browser_score": 0.1,
        "has_xff": 0,
        "tls12_ratio": 0.1,
    }
    base.update(overrides)
    return pd.DataFrame([base])


# ─────────────────────────────────────────────────────────────────────────────
# Tests
# ─────────────────────────────────────────────────────────────────────────────

def test_chrome_full_match():
    """Chrome avec fingerprint parfait : score ≥ 0.72 → LEGITIMATE_BROWSER."""
    df = _chrome_session()
    result = run_browser_matcher(df)
    assert result.loc[0, "bm_decision"] == "LEGITIMATE_BROWSER"
    assert result.loc[0, "bm_family"] == "Chrome"
    assert result.loc[0, "bm_score"] >= 0.72


def test_firefox_full_match():
    """Firefox avec fingerprint parfait : score ≥ 0.68 → LEGITIMATE_BROWSER."""
    df = _firefox_session()
    result = run_browser_matcher(df)
    assert result.loc[0, "bm_decision"] == "LEGITIMATE_BROWSER"
    assert result.loc[0, "bm_family"] == "Firefox"
    assert result.loc[0, "bm_score"] >= 0.68


def test_safari_full_match():
    """Safari : absence Sec-Fetch + WU=10485760 + dict→Safari → LEGITIMATE_BROWSER."""
    df = _safari_session()
    result = run_browser_matcher(df)
    assert result.loc[0, "bm_decision"] == "LEGITIMATE_BROWSER"
    assert result.loc[0, "bm_family"] == "Safari"
    assert result.loc[0, "bm_score"] >= 0.68


def test_curl_rejected():
    """curl : WINDOW_UPDATE absent avec H2 présent → NON_BROWSER (pas de bypass)."""
    df = _curl_session()
    result = run_browser_matcher(df)
    assert result.loc[0, "bm_decision"] == "NON_BROWSER"
    assert result.loc[0, "bm_family"] == ""
    assert result.loc[0, "bm_non_browser"] is True or result.loc[0, "bm_non_browser"] == True


def test_httpcloak_partial():
    """httpcloak : SETTINGS Chrome parfaits MAIS pseudo-header order ≠ Chrome → score partiel."""
    # H2 SETTINGS Chrome (dict renvoie 'Chrome') mais pseudo-order
    # inconsistant (h2_order_chromesafari=0 = pas m,a,s,p)
    df = _chrome_session(
        h2_dict_family="Chrome",
        h2_window_update_value=15663105,  # Chrome WU ✓
        h2_order_chromesafari=0,          # mauvais pseudo-header order
        h2_order_firefox=1,               # ressemble à Firefox ici
        browser_family="",                # JA4 inconnu
        has_sec_ch_ua=0,                  # Sec-CH-UA absent (manque un required)
    )
    result = run_browser_matcher(df)
    # Doit être en dessous du seuil Chrome (0.72)
    assert result.loc[0, "bm_score"] < BROWSER_THRESHOLDS["Chrome"]
    # Avec des signaux partiels, doit être PARTIAL ou UNKNOWN (pas LEGITIMATE_BROWSER)
    assert result.loc[0, "bm_decision"] in ("PARTIAL", "UNKNOWN", "NON_BROWSER")


def test_tls_h2_mismatch_detection():
    """JA4 identifie Chrome mais WINDOW_UPDATE = valeur Firefox → mismatch détectable."""
    # Simuler tls_h2_family_mismatch via les colonnes disponibles
    # (la colonne SQL est calculée dans la vue, ici on vérifie la logique Python)
    chrome_ja4_firefox_wu = {
        "browser_family": "Chromium",           # JA4 dit Chrome
        "h2_window_update_value": 12517377,      # WINDOW_UPDATE Firefox
        "h2_window_chrome": 0,
        "h2_window_firefox": 1,
        "h2_window_safari": 0,
        "h2_window_absent": 0,
        "h2_order_chromesafari": 0,
        "h2_order_firefox": 1,
        "h2_dict_family": "Firefox",            # dict H2 confirme Firefox
        "h2_dict_confidence": 1.0,
        "h2_settings_known": 1,
        "h2_ja4_coherence": 0,                  # incohérence JA4 vs H2
        "has_accept_language": 1,
        "has_sec_ch_ua": 1,
        "sec_fetch_absence_rate": 0.0,
        "has_xff": 0,
        "tls12_ratio": 0.0,
    }
    df = pd.DataFrame([chrome_ja4_firefox_wu])
    result = run_browser_matcher(df)

    # Le score Chrome doit être bas (h2 ne matche pas Chrome)
    chrome_score = _compute_family_score(df, "Chrome").iloc[0]
    firefox_score = _compute_family_score(df, "Firefox").iloc[0]

    # Firefox score devrait être supérieur à Chrome (cohérence H2)
    assert firefox_score > chrome_score, (
        f"Firefox score ({firefox_score:.3f}) devrait dépasser Chrome ({chrome_score:.3f})"
    )
    # La détection de mismatch : la session ne devrait pas être classée Chrome légitime
    if result.loc[0, "bm_decision"] == "LEGITIMATE_BROWSER":
        assert result.loc[0, "bm_family"] != "Chrome", (
            "Une session avec JA4=Chrome mais H2=Firefox ne devrait pas être classée Chrome légitime"
        )


def test_cdn_proxy_neutralizes_h2_settings():
    """Session derrière un CDN (has_xff=1) : toutes les dimensions H2 neutralisées."""
    df = _chrome_session(
        has_xff=1,              # proxy CDN détecté
        h2_dict_family="",     # le proxy a terminé la connexion H2 — fingerprint perdu
        h2_settings_known=0,
        h2_window_update_value=0,  # pas de H2 du client
    )
    result = run_browser_matcher(df)
    # Le score ne devrait pas être NON_BROWSER
    assert result.loc[0, "bm_non_browser"] == False
    # Le score devrait rester significatif grâce aux dimensions HTTP headers + TLS + JA4
    # (H2 dimensions scores 0.5 × 4 = neutre, pas pénalisant)
    score = result.loc[0, "bm_score"]
    assert score > 0.2, f"Score trop bas derrière proxy: {score}"


def test_cdn_proxy_score_higher_than_no_proxy_without_h2():
    """Un vrai Chrome derrière CDN doit scorer mieux qu'un curl sans XFF.

    Derrière un proxy, les 4 dimensions H2 valent 0.5 (neutre) au lieu de 0.0,
    donc le score total est plus élevé qu'un outil qui n'a pas de H2 du tout.
    """
    # Chrome derrière CDN : H2 perdu mais headers HTTP + TLS corrects
    df_proxy = _chrome_session(
        has_xff=1,
        h2_dict_family="",
        h2_settings_known=0,
        h2_window_update_value=0,
    )
    # curl sans proxy : pas de H2 et pas de bons headers
    df_curl = _curl_session()

    r_proxy = run_browser_matcher(df_proxy)
    r_curl = run_browser_matcher(df_curl)

    proxy_score = r_proxy.loc[0, "bm_score"]
    curl_score = r_curl.loc[0, "bm_score"]
    assert proxy_score > curl_score, (
        f"Chrome via CDN ({proxy_score}) devrait scorer > curl ({curl_score})"
    )


def test_cdn_proxy_go_not_flagged_non_browser():
    """go net/http derrière un proxy CDN ne devrait pas être marqué NON_BROWSER.

    Le H2 WINDOW_UPDATE observé vient du proxy, pas du client.
    """
    df = _curl_session(
        has_xff=1,
        h2_window_update_value=1073676289,  # signature go, mais provient du proxy
        h2_window_absent=0,
        h2_settings_known=1,
    )
    result = run_browser_matcher(df)
    assert result.loc[0, "bm_non_browser"] == False


def test_non_browser_go_net_http():
    """go net/http : WINDOW_UPDATE=1073676289 → NON_BROWSER."""
    df = _curl_session(
        h2_window_update_value=1073676289,
        h2_window_absent=0,
        h2_settings_known=1,
    )
    result = run_browser_matcher(df)
    assert result.loc[0, "bm_non_browser"] is True or result.loc[0, "bm_non_browser"] == True


# ─────────────────────────────────────────────────────────────────────────────
# Tests comparaison directe SETTINGS (colonnes individuelles)
# ─────────────────────────────────────────────────────────────────────────────

def _chrome_session_with_settings(**overrides) -> pd.DataFrame:
    """Session Chrome avec colonnes SETTINGS individuelles (valeurs exactes Chrome)."""
    base = {
        "src_ip": "1.2.3.4",
        "ja4": "t13d1517h2_8daaf6152771_e5627efa2ab1",
        "browser_family": "Chromium",
        "h2_dict_family": "Chrome",
        "h2_dict_confidence": 1.0,
        "h2_window_update_value": 15663105,
        "h2_order_chromesafari": 1,
        "h2_order_firefox": 0,
        "h2_priority_present": 0,
        "h2_settings_known": 1,
        "h2_ja4_coherence": 1,
        "has_accept_language": 1,
        "has_sec_ch_ua": 1,
        "sec_fetch_absence_rate": 0.0,
        "has_xff": 0,
        "tls12_ratio": 0.0,
        # Colonnes SETTINGS individuelles — Chrome exact
        "h2_header_table_size":       65536,    # ID 1 ✓
        "h2_enable_push":             0,        # ID 2 ✓
        "h2_max_concurrent_streams":  -1,       # ID 3 absent ✓ (forbidden)
        "h2_initial_window_size":     6291456,  # ID 4 ✓
        "h2_max_frame_size":          -1,       # ID 5 absent ✓ (forbidden)
        "h2_max_header_list_size":    262144,   # ID 6 ✓
        "h2_enable_connect_protocol": -1,       # ID 8 absent (pas dans les expected ni forbidden)
    }
    base.update(overrides)
    return pd.DataFrame([base])


def test_d1_direct_chrome_exact_settings():
    """Colonnes individuelles Chrome exactes → score D1 ≥ 0.90."""
    df = _chrome_session_with_settings()
    d1 = _d1_h2_settings(df, "Chrome")
    # 4 expected keys toutes exactes + 2 forbidden absentes = 6/6 direct_score=1.0
    # base = 1.0×0.60 + 1.0×0.30 + 1.0×0.10 = 1.0
    assert d1.iloc[0] >= 0.90, f"Score D1 direct Chrome attendu ≥0.90, obtenu {d1.iloc[0]:.3f}"


def test_d1_direct_one_wrong_setting():
    """Une clé SETTINGS incorrecte réduit D1 proportionnellement."""
    # ENABLE_PUSH=1 au lieu de 0 → 1 clé fausse sur 6 vérifications
    df = _chrome_session_with_settings(h2_enable_push=1)
    d1 = _d1_h2_settings(df, "Chrome")
    d1_perfect = _d1_h2_settings(_chrome_session_with_settings(), "Chrome")
    assert d1.iloc[0] < d1_perfect.iloc[0], "Erreur SETTINGS doit réduire D1"
    # 5/6 correct → direct_score ≈ 0.833 → base ≈ 0.833×0.60 + 1.0×0.30 + 1.0×0.10 ≈ 0.90
    assert d1.iloc[0] >= 0.80, f"Score D1 avec 1 erreur attendu ≥0.80, obtenu {d1.iloc[0]:.3f}"


def test_d1_forbidden_key_present_penalizes():
    """Clé interdite présente (MAX_CONCURRENT_STREAMS) → pénalité D1."""
    # MAX_CONCURRENT_STREAMS=100 alors qu'il devrait être absent
    df = _chrome_session_with_settings(h2_max_concurrent_streams=100)
    d1 = _d1_h2_settings(df, "Chrome")
    d1_perfect = _d1_h2_settings(_chrome_session_with_settings(), "Chrome")
    assert d1.iloc[0] < d1_perfect.iloc[0], "Clé interdite présente doit pénaliser D1"


def test_d1_unknown_fingerprint_but_correct_settings():
    """Fingerprint inconnu du dict (h2_dict_family='') mais SETTINGS exacts → D1 élevé."""
    # httpcloak scenario : fingerprint légèrement modifié → non reconnu par dict
    # mais tous les SETTINGS individuels sont corrects
    df = _chrome_session_with_settings(
        h2_dict_family="",       # dict lookup rate
        h2_settings_known=0,     # non reconnu
        h2_ja4_coherence=0,
    )
    d1 = _d1_h2_settings(df, "Chrome")
    # direct_score = 1.0 (SETTINGS exacts)
    # dict_match = 0 (non reconnu)
    # ja4_coherence = 0
    # base = 1.0×0.60 + 0.0×0.30 + 0.0×0.10 = 0.60
    assert d1.iloc[0] >= 0.55, (
        f"SETTINGS exacts mais dict inconnu : D1 attendu ≥0.55 (0.60), obtenu {d1.iloc[0]:.3f}"
    )
    # Et sans colonnes individuelles, ce même cas donnerait 0.0 (dict=0, coherence=0)
    df_no_cols = _chrome_session_with_settings(
        h2_dict_family="",
        h2_settings_known=0,
        h2_ja4_coherence=0,
    ).drop(columns=["h2_header_table_size", "h2_enable_push", "h2_max_concurrent_streams",
                     "h2_initial_window_size", "h2_max_frame_size", "h2_max_header_list_size",
                     "h2_enable_connect_protocol"])
    d1_fallback = _d1_h2_settings(df_no_cols, "Chrome")
    assert d1_fallback.iloc[0] < 0.10, (
        f"Fallback dict-only sans colonnes : D1 attendu ≈0.0, obtenu {d1_fallback.iloc[0]:.3f}"
    )
    # Avantage direct vs fallback
    assert d1.iloc[0] > d1_fallback.iloc[0], "Comparaison directe doit surpasser le fallback dict-only"


def test_d1_fallback_when_no_individual_cols():
    """Sans colonnes individuelles, fallback sur dict lookup (comportement original)."""
    df = _chrome_session_with_settings().drop(
        columns=["h2_header_table_size", "h2_enable_push", "h2_max_concurrent_streams",
                 "h2_initial_window_size", "h2_max_frame_size", "h2_max_header_list_size",
                 "h2_enable_connect_protocol"]
    )
    d1 = _d1_h2_settings(df, "Chrome")
    # dict=Chrome(1.0) × 0.80 + ja4_coherence(1.0) × 0.20 = 1.0
    assert d1.iloc[0] >= 0.90, f"Fallback dict-only Chrome parfait attendu ≥0.90, obtenu {d1.iloc[0]:.3f}"


def test_cdn_proxy_neutralizes_individual_settings():
    """has_xff=1 : score D1 neutre (0.5) même avec SETTINGS exacts."""
    df = _chrome_session_with_settings(has_xff=1)
    d1 = _d1_h2_settings(df, "Chrome")
    assert abs(d1.iloc[0] - 0.5) < 0.01, f"CDN proxy : D1 attendu 0.5, obtenu {d1.iloc[0]:.3f}"


def test_chrome_full_match_with_individual_settings():
    """Chrome avec colonnes individuelles exactes : score final ≥ 0.72 → LEGITIMATE_BROWSER."""
    df = _chrome_session_with_settings()
    result = run_browser_matcher(df)
    assert result.loc[0, "bm_decision"] == "LEGITIMATE_BROWSER"
    assert result.loc[0, "bm_family"] == "Chrome"
    assert result.loc[0, "bm_score"] >= 0.72
