import os
import pytest
import pandas as pd
import numpy as np
from unittest.mock import patch, MagicMock


def test_settings_from_env(monkeypatch):
    """ClickHouseSettings loads CLICKHOUSE_HOST from env."""
    monkeypatch.setenv("CLICKHOUSE_HOST", "testhost")
    from ja4_common.settings import ClickHouseSettings
    s = ClickHouseSettings()
    assert s.CLICKHOUSE_HOST == "testhost"


def test_feature_dataframe_validation():
    """MIN_VALID_FEATURE_RATIO logic: if < ratio of features have data, skip."""
    MIN_VALID_FEATURE_RATIO = 0.5
    df = pd.DataFrame({"f1": [1.0], "f2": [None], "f3": [None], "f4": [None]})
    non_null_ratio = df.notna().mean().mean()
    assert non_null_ratio < MIN_VALID_FEATURE_RATIO, "Should detect insufficient features"


def test_anomaly_threshold():
    """Scores below ANOMALY_THRESHOLD trigger detection."""
    ANOMALY_THRESHOLD = -0.1
    anomaly_scores = np.array([-0.5, -0.3, 0.1, 0.2])
    anomalies = anomaly_scores[anomaly_scores < ANOMALY_THRESHOLD]
    assert len(anomalies) == 2, "Should detect 2 anomalies"


def test_dedup_logic():
    """Duplicate detections within DEDUP_TTL_MIN are skipped."""
    from datetime import datetime, timedelta
    DEDUP_TTL_MIN = 60
    dedup_cache = {}

    def should_insert(ip: str, now: datetime) -> bool:
        if ip in dedup_cache:
            if (now - dedup_cache[ip]).total_seconds() < DEDUP_TTL_MIN * 60:
                return False
        dedup_cache[ip] = now
        return True

    now = datetime(2024, 1, 1, 12, 0, 0)
    assert should_insert("1.2.3.4", now) is True
    assert should_insert("1.2.3.4", now + timedelta(minutes=30)) is False  # within TTL
    assert should_insert("1.2.3.4", now + timedelta(minutes=61)) is True   # past TTL


def test_health_check():
    """Health check endpoint returns 200."""
    import threading
    from http.server import HTTPServer, BaseHTTPRequestHandler

    class HealthHandler(BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.end_headers()

        def log_message(self, *args):
            pass

    server = HTTPServer(("127.0.0.1", 0), HealthHandler)
    port = server.server_address[1]
    t = threading.Thread(target=server.handle_request)
    t.start()

    import urllib.request
    resp = urllib.request.urlopen(f"http://127.0.0.1:{port}/")
    assert resp.status == 200
    server.server_close()


def test_dedup_different_ips_are_independent():
    """Different IPs are tracked independently in dedup cache."""
    from datetime import datetime, timedelta
    DEDUP_TTL_MIN = 60
    dedup_cache = {}

    def should_insert(ip: str, now: datetime) -> bool:
        if ip in dedup_cache:
            if (now - dedup_cache[ip]).total_seconds() < DEDUP_TTL_MIN * 60:
                return False
        dedup_cache[ip] = now
        return True

    now = datetime(2024, 1, 1, 12, 0, 0)
    assert should_insert("1.1.1.1", now) is True
    assert should_insert("2.2.2.2", now) is True  # Different IP, should be allowed
    assert should_insert("1.1.1.1", now + timedelta(minutes=30)) is False  # Same IP within TTL
    assert should_insert("2.2.2.2", now + timedelta(minutes=30)) is False  # Same IP within TTL


def test_dedup_exact_ttl_boundary():
    """Dedup: insertion at exactly the TTL boundary is allowed (uses strict <)."""
    from datetime import datetime, timedelta
    DEDUP_TTL_MIN = 60
    dedup_cache = {}

    def should_insert(ip: str, now: datetime) -> bool:
        if ip in dedup_cache:
            if (now - dedup_cache[ip]).total_seconds() < DEDUP_TTL_MIN * 60:
                return False
        dedup_cache[ip] = now
        return True

    now = datetime(2024, 1, 1, 12, 0, 0)
    assert should_insert("1.2.3.4", now) is True
    # Exactly at 60 minutes: 3600 < 3600 is False, so insertion IS allowed
    assert should_insert("1.2.3.4", now + timedelta(minutes=60)) is True


def test_anomaly_threshold_no_anomalies():
    """No anomalies when all scores are above threshold."""
    import numpy as np
    ANOMALY_THRESHOLD = -0.1
    scores = np.array([0.0, 0.1, 0.5, 1.0])
    anomalies = scores[scores < ANOMALY_THRESHOLD]
    assert len(anomalies) == 0


def test_anomaly_threshold_all_anomalies():
    """All items flagged when all scores are below threshold."""
    import numpy as np
    ANOMALY_THRESHOLD = -0.1
    scores = np.array([-0.5, -0.3, -0.2, -0.15])
    anomalies = scores[scores < ANOMALY_THRESHOLD]
    assert len(anomalies) == 4


def test_feature_dataframe_all_valid():
    """Feature dataframe with all valid values passes ratio check."""
    import pandas as pd
    MIN_VALID_FEATURE_RATIO = 0.5
    df = pd.DataFrame({"f1": [1.0], "f2": [2.0], "f3": [3.0], "f4": [4.0]})
    non_null_ratio = df.notna().mean().mean()
    assert non_null_ratio >= MIN_VALID_FEATURE_RATIO


def test_health_check_returns_correct_status():
    """Health check endpoint body is readable."""
    import threading
    import urllib.request
    from http.server import HTTPServer, BaseHTTPRequestHandler

    class StatusHandler(BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(b'{"status": "ok"}')

        def log_message(self, *args):
            pass

    server = HTTPServer(("127.0.0.1", 0), StatusHandler)
    port = server.server_address[1]
    t = threading.Thread(target=server.handle_request)
    t.start()

    resp = urllib.request.urlopen(f"http://127.0.0.1:{port}/health")
    assert resp.status == 200
    body = resp.read()
    assert b"ok" in body
    server.server_close()


# ═══════════════════════════════════════════════════════════════════════════════
# Tests pour les améliorations état de l'art v2
# ═══════════════════════════════════════════════════════════════════════════════

def test_eif_import_fallback():
    """EIF import gracefully falls back to sklearn IF when isotree is unavailable."""
    # Verify the fallback pattern works regardless of installed packages
    try:
        from isotree import IsolationForest as EIF
        eif_avail = True
    except ImportError:
        eif_avail = False
    # The test passes as long as no unhandled exception occurs
    assert isinstance(eif_avail, bool)


def test_normalize_scores_zero_to_one():
    """Score normalization: most anomalous → 1.0, normal → 0.0."""
    scores = np.array([-0.5, -0.3, -0.1, 0.0, 0.2])
    result = np.zeros_like(scores)
    mask = scores < 0
    if mask.sum() > 0:
        s_min = scores[mask].min()
        if s_min != 0:
            result[mask] = np.clip(-scores[mask] / (-s_min + 1e-9), 0.0, 1.0)
    assert result[0] == pytest.approx(1.0, abs=0.01), "Most anomalous should be ~1.0"
    assert result[3] == 0.0, "Normal score should be 0.0"
    assert result[4] == 0.0, "Positive score should be 0.0"
    assert 0 < result[1] < result[0], "Less anomalous should be between 0 and max"


def test_normalize_scores_all_positive():
    """When all scores are positive (normal), all normalized scores should be 0."""
    scores = np.array([0.1, 0.2, 0.5, 1.0])
    result = np.zeros_like(scores)
    mask = scores < 0
    assert mask.sum() == 0
    assert np.all(result == 0.0)


def test_validation_gate_rejects_contaminated_baseline():
    """Model should be rejected if val_anomaly_rate > 0.20 (contaminated baseline)."""
    VAL_ANOMALY_GATE = 0.20
    # Simulate: 30% of validation scores are anomalous
    val_scores = np.concatenate([np.full(70, 0.1), np.full(30, -0.2)])
    val_anomaly_rate = float(np.mean(val_scores < 0))
    assert val_anomaly_rate > VAL_ANOMALY_GATE, "Should detect contaminated baseline"

    # Simulate: only 5% anomalous → passes the gate
    val_scores_clean = np.concatenate([np.full(95, 0.1), np.full(5, -0.2)])
    val_anomaly_rate_clean = float(np.mean(val_scores_clean < 0))
    assert val_anomaly_rate_clean <= VAL_ANOMALY_GATE, "Clean baseline should pass gate"


def test_feature_pruning_removes_constant_features():
    """Features with variance < threshold should be pruned."""
    PRUNE_VARIANCE_THRESHOLD = 1e-6
    df = pd.DataFrame({
        'good_feat': [1.0, 2.0, 3.0, 4.0, 5.0],
        'constant_feat': [1.0, 1.0, 1.0, 1.0, 1.0],
        'near_zero_var': [1.0, 1.0, 1.0, 1.0, 1.0 + 1e-8],
    })
    feature_variances = df.var()
    low_var = feature_variances[feature_variances < PRUNE_VARIANCE_THRESHOLD].index.tolist()
    assert 'constant_feat' in low_var, "Constant feature should be pruned"
    assert 'near_zero_var' in low_var, "Near-zero variance feature should be pruned"
    assert 'good_feat' not in low_var, "Good feature should NOT be pruned"


def test_quantile_drift_detection():
    """Quantile-based drift detection should detect distribution shift."""
    rng = np.random.default_rng(42)
    # Original distribution: N(0, 1)
    baseline_stats = {
        'feat1': {
            'mean': 0.0, 'std': 1.0,
            'p10': -1.28, 'p25': -0.67, 'p50': 0.0, 'p75': 0.67, 'p90': 1.28,
        }
    }
    # Current data: shifted to N(3, 1) — definite drift
    drifted_data = pd.DataFrame({'feat1': rng.normal(3.0, 1.0, 100)})

    # Reconstruct via quantile interpolation
    quantile_probs = np.array([0.10, 0.25, 0.50, 0.75, 0.90])
    quantile_vals = np.array([-1.28, -0.67, 0.0, 0.67, 1.28])
    u = rng.uniform(0, 1, size=100)
    synthetic = np.interp(u, quantile_probs, quantile_vals)

    from scipy.stats import ks_2samp
    _, p_value = ks_2samp(drifted_data['feat1'].values, synthetic)
    assert p_value < 0.05, "Should detect drift when distribution is shifted"

    # Same distribution — no drift
    same_data = pd.DataFrame({'feat1': rng.normal(0.0, 1.0, 100)})
    _, p_same = ks_2samp(same_data['feat1'].values, synthetic)
    assert p_same > 0.01, "Should not detect drift when distribution is similar"


def test_thread_safety_lock_exists():
    """Health lock should be a threading.Lock for thread-safe health status updates."""
    import threading as _threading
    lock = _threading.Lock()
    assert lock.acquire(blocking=False), "Lock should be acquirable"
    lock.release()
    # Simulate read-modify-write with lock
    counter = [0]
    def increment():
        with lock:
            counter[0] += 1
    threads = [_threading.Thread(target=increment) for _ in range(100)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    assert counter[0] == 100, "Lock should protect counter from race conditions"


def test_score_to_threat_level():
    """Threat level mapping: CRITICAL < -0.30, HIGH < -0.15, MEDIUM < -0.05, LOW < 0."""
    def score_to_threat_level(score):
        if score < -0.30: return 'CRITICAL'
        if score < -0.15: return 'HIGH'
        if score < -0.05: return 'MEDIUM'
        if score < 0:     return 'LOW'
        return 'NORMAL'

    assert score_to_threat_level(-0.5) == 'CRITICAL'
    assert score_to_threat_level(-0.30) == 'HIGH'
    assert score_to_threat_level(-0.15) == 'MEDIUM'
    assert score_to_threat_level(-0.05) == 'LOW'
    assert score_to_threat_level(0.0) == 'NORMAL'
    assert score_to_threat_level(0.5) == 'NORMAL'


def test_benford_expected_distribution():
    """Benford's law: P(d) = log10(1 + 1/d) for d=1..9."""
    import math
    expected = [math.log10(1 + 1/d) for d in range(1, 10)]
    assert sum(expected) == pytest.approx(1.0, abs=0.001), "Benford probs should sum to 1"
    assert expected[0] == pytest.approx(0.301, abs=0.001), "P(1) should be ~0.301"
    assert expected[8] == pytest.approx(0.046, abs=0.001), "P(9) should be ~0.046"


def test_lag1_autocorrelation_bot_vs_human():
    """Bot with constant spacing should have high autocorrelation; human should be low."""
    # Bot: constant spacing with small jitter → high autocorrelation
    rng = np.random.default_rng(42)
    bot_deltas = 100.0 + rng.normal(0, 2, 50)  # very regular
    mean_b = np.mean(bot_deltas)
    var_b = np.var(bot_deltas)
    if var_b > 1e-9:
        cov_b = np.mean((bot_deltas[:-1] - mean_b) * (bot_deltas[1:] - mean_b))
        rho_bot = cov_b / var_b
    else:
        rho_bot = 0.0

    # Human: highly variable spacing → low autocorrelation
    human_deltas = rng.exponential(500, 50)  # random, independent
    mean_h = np.mean(human_deltas)
    var_h = np.var(human_deltas)
    if var_h > 1e-9:
        cov_h = np.mean((human_deltas[:-1] - mean_h) * (human_deltas[1:] - mean_h))
        rho_human = cov_h / var_h
    else:
        rho_human = 0.0

    assert abs(rho_human) < 0.5, f"Human autocorrelation should be low, got {rho_human:.3f}"


# ═══════════════════════════════════════════════════════════════════════════════
# AUTOENCODER TESTS
# ═══════════════════════════════════════════════════════════════════════════════

def test_ae_torch_availability_flag():
    """Verify torch availability detection works without crashing."""
    try:
        import torch
        avail = True
    except ImportError:
        avail = False
    assert isinstance(avail, bool)


def _make_ae(n_features, latent_dim=4):
    """Build a standalone TrafficAutoEncoder for testing (avoids importing bot_detector module)."""
    import torch
    import torch.nn as nn

    class _AE:
        def __init__(self, n_feat, ldim):
            self.n_features = n_feat
            self.latent_dim = ldim
            self.device = torch.device('cpu')
            dim1 = min(64, max(n_feat, ldim + 4))
            dim2 = min(32, max(dim1 // 2, ldim + 2))
            self.encoder = nn.Sequential(
                nn.Linear(n_feat, dim1), nn.BatchNorm1d(dim1), nn.ReLU(),
                nn.Linear(dim1, dim2), nn.BatchNorm1d(dim2), nn.ReLU(),
                nn.Linear(dim2, ldim),
            )
            self.decoder = nn.Sequential(
                nn.Linear(ldim, dim2), nn.BatchNorm1d(dim2), nn.ReLU(),
                nn.Linear(dim2, dim1), nn.BatchNorm1d(dim1), nn.ReLU(),
                nn.Linear(dim1, n_feat), nn.Sigmoid(),
            )
            self._all_params = list(self.encoder.parameters()) + list(self.decoder.parameters())
            self._scaler_min = None
            self._scaler_range = None

        def _to_tensor(self, X):
            if self._scaler_min is not None:
                X_n = (X - self._scaler_min) / (self._scaler_range + 1e-9)
            else:
                X_n = X
            return torch.tensor(np.clip(X_n, 0, 1), dtype=torch.float32)

        def fit(self, X, epochs=50, lr=1e-3, batch_size=256):
            self._scaler_min = X.min(axis=0)
            self._scaler_range = X.max(axis=0) - self._scaler_min
            X_t = self._to_tensor(X)
            dataset = torch.utils.data.TensorDataset(X_t)
            loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True)
            optimizer = torch.optim.Adam(self._all_params, lr=lr, weight_decay=1e-5)
            criterion = nn.MSELoss()
            self.encoder.train(); self.decoder.train()
            losses = []
            for _ in range(epochs):
                epoch_loss = 0.0
                for (batch,) in loader:
                    latent = self.encoder(batch)
                    recon = self.decoder(latent)
                    loss = criterion(recon, batch)
                    optimizer.zero_grad(); loss.backward(); optimizer.step()
                    epoch_loss += loss.item() * len(batch)
                losses.append(epoch_loss / len(X_t))
            return {'final_loss': losses[-1], 'epochs': epochs, 'n_samples': len(X)}

        def score_samples(self, X):
            self.encoder.eval(); self.decoder.eval()
            X_t = self._to_tensor(X)
            with torch.no_grad():
                return ((self.decoder(self.encoder(X_t)) - X_t) ** 2).mean(dim=1).numpy()

        def encode(self, X):
            self.encoder.eval()
            X_t = self._to_tensor(X)
            with torch.no_grad():
                return self.encoder(X_t).numpy()

        def state_dict(self):
            return {'encoder': self.encoder.state_dict(), 'decoder': self.decoder.state_dict(),
                    'scaler_min': self._scaler_min, 'scaler_range': self._scaler_range,
                    'n_features': self.n_features, 'latent_dim': self.latent_dim}

        @classmethod
        def load_state_dict(cls, state):
            ae = cls(state['n_features'], state['latent_dim'])
            ae._scaler_min = state['scaler_min']
            ae._scaler_range = state['scaler_range']
            ae.encoder.load_state_dict(state['encoder'])
            ae.decoder.load_state_dict(state['decoder'])
            return ae

    return _AE(n_features, latent_dim)


def test_ae_class_train_and_score():
    """TrafficAutoEncoder trains on normal data and scores anomalies higher."""
    try:
        import torch
    except ImportError:
        pytest.skip("torch not installed")

    rng = np.random.default_rng(42)
    n_features = 10
    X_normal = rng.normal(0.5, 0.1, (200, n_features)).clip(0, 1)
    X_anomaly = rng.uniform(0.8, 1.0, (20, n_features))

    ae = _make_ae(n_features, latent_dim=4)
    stats = ae.fit(X_normal, epochs=30, lr=1e-3)
    assert stats['final_loss'] > 0, "Loss should be positive"
    assert stats['epochs'] == 30
    assert stats['n_samples'] == 200

    normal_scores = ae.score_samples(X_normal)
    anomaly_scores = ae.score_samples(X_anomaly)
    assert np.mean(anomaly_scores) > np.mean(normal_scores), \
        f"Anomaly MSE ({np.mean(anomaly_scores):.4f}) should > normal MSE ({np.mean(normal_scores):.4f})"


def test_ae_encode_latent_space():
    """Autoencoder encode() returns correct dimensionality."""
    try:
        import torch
    except ImportError:
        pytest.skip("torch not installed")

    rng = np.random.default_rng(42)
    X = rng.normal(0.5, 0.1, (50, 8)).clip(0, 1)

    ae = _make_ae(8, latent_dim=4)
    ae.fit(X, epochs=5)
    latent = ae.encode(X)
    assert latent.shape == (50, 4), f"Latent shape should be (50, 4), got {latent.shape}"


def test_ae_state_dict_save_load():
    """Autoencoder can save and load state dict."""
    try:
        import torch
    except ImportError:
        pytest.skip("torch not installed")

    rng = np.random.default_rng(42)
    X = rng.normal(0.5, 0.1, (100, 6)).clip(0, 1)

    ae = _make_ae(6, latent_dim=3)
    ae.fit(X, epochs=10)
    scores_before = ae.score_samples(X)

    state = ae.state_dict()
    ae2 = type(ae).load_state_dict(state)
    scores_after = ae2.score_samples(X)
    np.testing.assert_allclose(scores_before, scores_after, rtol=1e-5,
                               err_msg="Scores should be identical after load")


def test_ae_weight_combination():
    """Combined score should be weighted average of EIF and AE components."""
    eif_norm = np.array([0.2, 0.8, 0.5])
    ae_norm = np.array([0.3, 0.9, 0.4])
    alpha = 0.30
    combined = (1 - alpha) * eif_norm + alpha * ae_norm
    expected = np.array([0.2*0.7 + 0.3*0.3, 0.8*0.7 + 0.9*0.3, 0.5*0.7 + 0.4*0.3])
    np.testing.assert_allclose(combined, expected, rtol=1e-7)
    # Combined should be between min and max of components
    assert all(combined >= np.minimum(eif_norm, ae_norm) - 1e-9)
    assert all(combined <= np.maximum(eif_norm, ae_norm) + 1e-9)


# ═══════════════════════════════════════════════════════════════════════════════
# XGBOOST TESTS
# ═══════════════════════════════════════════════════════════════════════════════

def test_xgb_availability_flag():
    """Verify XGBoost availability detection works without crashing."""
    try:
        import xgboost
        avail = True
    except ImportError:
        avail = False
    assert isinstance(avail, bool)


def test_xgb_basic_train_and_predict():
    """XGBoost can train on labeled data and predict probabilities."""
    try:
        import xgboost as xgb
    except ImportError:
        pytest.skip("xgboost not installed")

    rng = np.random.default_rng(42)
    n_features = 10
    n_samples = 500
    X = rng.normal(0, 1, (n_samples, n_features))
    # Label: anomaly if feature 0 > 1.5 (simple rule)
    y = (X[:, 0] > 1.5).astype(int)

    model = xgb.XGBClassifier(n_estimators=50, max_depth=3,
                               eval_metric='logloss', random_state=42)
    model.fit(X, y, verbose=False)

    probs = model.predict_proba(X)[:, 1]
    assert probs.shape == (n_samples,)
    assert 0 <= probs.min() <= probs.max() <= 1
    # High feature 0 should have higher probability
    high_mask = X[:, 0] > 2.0
    low_mask = X[:, 0] < -1.0
    if high_mask.any() and low_mask.any():
        assert np.mean(probs[high_mask]) > np.mean(probs[low_mask]), \
            "XGBoost should give higher prob to anomalous samples"


def test_xgb_meta_learner_combination():
    """Meta-learner combines EIF+AE score and XGBoost probability correctly."""
    eif_ae_score = np.array([0.1, 0.5, 0.9])
    xgb_prob = np.array([0.2, 0.6, 0.8])
    beta = 0.20  # XGB_WEIGHT default
    combined = (1 - beta) * eif_ae_score + beta * xgb_prob
    expected = np.array([0.1*0.8 + 0.2*0.2, 0.5*0.8 + 0.6*0.2, 0.9*0.8 + 0.8*0.2])
    np.testing.assert_allclose(combined, expected, rtol=1e-7)
    assert all(combined >= 0) and all(combined <= 1)


def test_xgb_save_load_model():
    """XGBoost model can be saved and loaded correctly."""
    try:
        import xgboost as xgb
    except ImportError:
        pytest.skip("xgboost not installed")
    import tempfile

    rng = np.random.default_rng(42)
    X = rng.normal(0, 1, (200, 5))
    y = (X[:, 0] > 1.0).astype(int)

    model = xgb.XGBClassifier(n_estimators=20, max_depth=3,
                               eval_metric='logloss', random_state=42)
    model.fit(X, y, verbose=False)
    probs_before = model.predict_proba(X)[:, 1]

    with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
        path = f.name
    try:
        model.save_model(path)
        model2 = xgb.XGBClassifier()
        model2.load_model(path)
        probs_after = model2.predict_proba(X)[:, 1]
        np.testing.assert_allclose(probs_before, probs_after, rtol=1e-5)
    finally:
        os.remove(path)


# ═══════════════════════════════════════════════════════════════════════════════
# Tests pour la classification des navigateurs légitimes (A9)
# ═══════════════════════════════════════════════════════════════════════════════

def test_is_known_browser_feature():
    """is_known_browser=1 when browser_family is set, 0 otherwise."""
    df = pd.DataFrame({
        'browser_family': ['Chromium', 'Firefox', '', '', 'Safari'],
    })
    df['is_known_browser'] = (df['browser_family'] != '').astype(int)
    assert df['is_known_browser'].tolist() == [1, 1, 0, 0, 1]


def test_browser_consistency_score_range():
    """browser_consistency_score is in [0, 5] and sums 5 binary signals."""
    df = pd.DataFrame({
        'browser_family':       ['Chromium', '',       'Firefox', 'Safari'],
        'has_sec_ch_ua':        [1,          0,        1,         0],
        'has_accept_language':  [1,          0,        1,         1],
        'has_cookie':           [1,          0,        1,         0],
        'sec_fetch_absence_rate': [0.0,      1.0,      0.1,       0.6],
    })
    is_known = (df['browser_family'] != '').astype(int)
    scu_ok = (df['has_sec_ch_ua'] > 0).astype(int)
    hal_ok = (df['has_accept_language'] > 0).astype(int)
    hck_ok = (df['has_cookie'] > 0).astype(int)
    sfa_ok = (df['sec_fetch_absence_rate'] < 0.5).astype(int)
    bcs = is_known + scu_ok + hal_ok + hck_ok + sfa_ok

    assert bcs.min() >= 0 and bcs.max() <= 5
    assert bcs.iloc[0] == 5, "Chromium with all signals should score 5"
    assert bcs.iloc[1] == 0, "Empty browser with no signals should score 0"
    assert bcs.iloc[2] == 5, "Firefox with all signals should score 5"
    assert bcs.iloc[3] == 2, "Safari without CH/cookie and high sec_fetch_absence should score 2"


def test_legitimate_browser_classification_threshold():
    """LEGITIMATE_BROWSER requires browser_confidence >= threshold + family + NORMAL/LOW threat."""
    BROWSER_CONFIDENCE_THRESHOLD = 0.55

    sessions = [
        # (inferred_browser_family, browser_confidence, threat_level) → expected
        ('Chromium', 0.80, 'NORMAL'),      # → LEGITIMATE_BROWSER
        ('Chromium', 0.80, 'MEDIUM'),      # threat too high → keep MEDIUM
        ('Firefox',  0.60, 'LOW'),         # → LEGITIMATE_BROWSER
        ('Firefox',  0.40, 'NORMAL'),      # confidence too low → keep NORMAL
        ('',         0.90, 'NORMAL'),      # no family → keep NORMAL
        ('Chromium', 0.80, 'ANUBIS_DENY'), # Anubis DENY → keep ANUBIS_DENY
    ]
    results = []
    for bf, conf, tl in sessions:
        is_legit = (
            conf >= BROWSER_CONFIDENCE_THRESHOLD and
            bf != '' and
            tl in ('NORMAL', 'LOW')
        )
        results.append('LEGITIMATE_BROWSER' if is_legit else tl)

    assert results == [
        'LEGITIMATE_BROWSER',
        'MEDIUM',
        'LEGITIMATE_BROWSER',
        'NORMAL',
        'NORMAL',
        'ANUBIS_DENY',
    ]


def test_legitimate_browser_excluded_from_anomalies():
    """Sessions classified as LEGITIMATE_BROWSER should not appear in anomaly list."""
    threat_levels = pd.Series(['CRITICAL', 'HIGH', 'MEDIUM', 'NORMAL', 'LEGITIMATE_BROWSER', 'ANUBIS_DENY'])
    raw_scores    = pd.Series([-0.5,      -0.2,   -0.08,    0.1,      0.05,                  -0.15])
    threshold = -0.05

    # Anomalies = below threshold AND not LEGITIMATE_BROWSER AND not ANUBIS_DENY
    non_browser = threat_levels != 'LEGITIMATE_BROWSER'
    non_deny = threat_levels != 'ANUBIS_DENY'
    below_threshold = raw_scores < threshold
    anomaly_mask = non_browser & non_deny & below_threshold

    assert anomaly_mask.tolist() == [True, True, True, False, False, False]


def test_browser_spoofing_detection():
    """Spoofed browser (known JA4 but low overall confidence) stays in normal scoring."""
    BROWSER_CONFIDENCE_THRESHOLD = 0.55

    # Spoofed: JA4 looks like Chrome (axis_ja4_known=1) but no cookies,
    # no Accept-Language, high sec_fetch_absence → low overall confidence
    spoofed_bf = 'Chromium'
    spoofed_confidence = 0.30  # only JA4 known axis scores high
    spoofed_tl = 'MEDIUM'

    is_legit = (
        spoofed_confidence >= BROWSER_CONFIDENCE_THRESHOLD and
        spoofed_bf != '' and
        spoofed_tl in ('NORMAL', 'LOW')
    )
    assert not is_legit, "Spoofed browser should NOT be classified as LEGITIMATE_BROWSER"


def test_xgb_labels_treat_legitimate_browser_as_normal():
    """XGBoost training should treat LEGITIMATE_BROWSER as negative (non-threat) label."""
    # The XGBoost query only selects these threat_levels (not LOW/MEDIUM)
    threat_levels = pd.Series([
        'NORMAL', 'LEGITIMATE_BROWSER', 'HIGH', 'CRITICAL', 'KNOWN_BOT', 'ANUBIS_DENY'
    ])
    y = (~threat_levels.isin(['NORMAL', 'LEGITIMATE_BROWSER'])).astype(int)
    assert y.tolist() == [0, 0, 1, 1, 1, 1]
