#!/usr/bin/env python3
"""
Protocol Fingerprint Anomaly — Synthetic Data Generator
Generates 40k samples across 4 classes for detecting DNS tunneling,
C2 beaconing, and port scanning at the protocol level.

Feature vector (48 elements):
  [0-5]   Packet size histogram (buckets: 0-64, 65-128, 129-256, 257-512, 513-1024, 1025+) — normalized
  [6-9]   Packet size statistics (mean, std, median, mode_bucket)
  [10-13] Inter-arrival time statistics (mean, std, min, max)
  [14-17] Inter-arrival time distribution (p25, p50, p75, regularity_score)
  [18-21] Port features (unique_dst_ports, unique_src_ports, port_diversity_ratio, ephemeral_port_ratio)
  [22-25] Connection features (conn_count, conn_duration_mean, conn_duration_std, incomplete_conn_ratio)
  [26-29] DNS features (dns_query_rate, dns_response_rate, dns_label_length_mean, dns_label_length_max)
  [30-33] DNS advanced (unique_queried_domains, subdomain_depth_mean, txt_record_ratio, nxdomain_ratio)
  [34-37] Payload features (payload_entropy_mean, payload_entropy_std, ascii_ratio, binary_ratio)
  [38-41] Direction features (outbound_ratio, inbound_ratio, bytes_out_per_in, symmetric_flow_ratio)
  [42-45] Protocol mix (tcp_ratio, udp_ratio, icmp_ratio, other_ratio)
  [46-47] Derived (periodicity_score, anomaly_score_raw)

Classes: {0: normal, 1: dns_tunnel, 2: c2_beacon, 3: port_scan}
"""

import numpy as np
import os
import argparse

SEED = 42
NUM_FEATURES = 48
CLASSES = ['normal', 'dns_tunnel', 'c2_beacon', 'port_scan']
NUM_CLASSES = len(CLASSES)


def generate_normal(n: int, rng: np.random.Generator) -> np.ndarray:
    X = np.zeros((n, NUM_FEATURES), dtype=np.float32)

    # Packet size histogram (normalized across 6 buckets)
    X[:, 0] = rng.normal(0.15, 0.05, n).clip(0.03, 0.3)     # 0-64
    X[:, 1] = rng.normal(0.12, 0.04, n).clip(0.02, 0.25)    # 65-128
    X[:, 2] = rng.normal(0.18, 0.05, n).clip(0.05, 0.3)     # 129-256
    X[:, 3] = rng.normal(0.20, 0.06, n).clip(0.06, 0.35)    # 257-512
    X[:, 4] = rng.normal(0.22, 0.06, n).clip(0.08, 0.38)    # 513-1024
    X[:, 5] = rng.normal(0.13, 0.05, n).clip(0.02, 0.28)    # 1025+

    # Packet size stats
    X[:, 6] = rng.normal(400, 120, n).clip(80, 900)          # mean
    X[:, 7] = rng.normal(300, 100, n).clip(50, 700)          # std
    X[:, 8] = rng.normal(350, 100, n).clip(60, 800)          # median
    X[:, 9] = rng.normal(3, 1.2, n).clip(0, 5)               # mode bucket index

    # IAT statistics
    X[:, 10] = rng.normal(0.5, 0.2, n).clip(0.05, 1.2)      # mean
    X[:, 11] = rng.normal(0.8, 0.3, n).clip(0.1, 2.0)       # std
    X[:, 12] = rng.normal(0.001, 0.001, n).clip(0, 0.005)    # min
    X[:, 13] = rng.normal(5, 2, n).clip(0.5, 15)             # max

    # IAT distribution
    X[:, 14] = rng.normal(0.1, 0.06, n).clip(0.01, 0.3)     # p25
    X[:, 15] = rng.normal(0.3, 0.12, n).clip(0.05, 0.7)     # p50
    X[:, 16] = rng.normal(0.7, 0.2, n).clip(0.2, 1.5)       # p75
    X[:, 17] = rng.normal(0.3, 0.12, n).clip(0.05, 0.6)     # regularity score

    # Port features
    X[:, 18] = rng.normal(15, 6, n).clip(3, 35)              # unique dst ports
    X[:, 19] = rng.normal(8, 3, n).clip(2, 20)               # unique src ports
    X[:, 20] = rng.normal(0.3, 0.1, n).clip(0.05, 0.6)      # port diversity ratio
    X[:, 21] = rng.normal(0.6, 0.12, n).clip(0.3, 0.85)     # ephemeral port ratio

    # Connection features
    X[:, 22] = rng.normal(20, 8, n).clip(5, 50)              # conn count
    X[:, 23] = rng.normal(30, 15, n).clip(2, 80)             # conn duration mean (sec)
    X[:, 24] = rng.normal(25, 10, n).clip(3, 60)             # conn duration std
    X[:, 25] = rng.normal(0.05, 0.03, n).clip(0, 0.15)      # incomplete conn ratio

    # DNS features (normal levels)
    X[:, 26] = rng.normal(2, 1, n).clip(0.2, 5)              # query rate
    X[:, 27] = rng.normal(1.8, 0.9, n).clip(0.2, 4.5)       # response rate
    X[:, 28] = rng.normal(12, 3, n).clip(5, 22)              # label length mean
    X[:, 29] = rng.normal(20, 5, n).clip(8, 35)              # label length max

    # DNS advanced
    X[:, 30] = rng.normal(10, 4, n).clip(2, 25)              # unique queried domains
    X[:, 31] = rng.normal(2, 0.5, n).clip(1, 3.5)            # subdomain depth mean
    X[:, 32] = rng.normal(0.02, 0.015, n).clip(0, 0.06)      # txt record ratio
    X[:, 33] = rng.normal(0.05, 0.03, n).clip(0, 0.15)       # nxdomain ratio

    # Payload features
    X[:, 34] = rng.normal(4.5, 0.8, n).clip(2.5, 6.5)        # entropy mean
    X[:, 35] = rng.normal(1.0, 0.4, n).clip(0.2, 2.2)        # entropy std
    X[:, 36] = rng.normal(0.6, 0.15, n).clip(0.25, 0.9)      # ascii ratio
    X[:, 37] = rng.normal(0.3, 0.1, n).clip(0.05, 0.55)      # binary ratio

    # Direction features
    X[:, 38] = rng.normal(0.45, 0.08, n).clip(0.25, 0.65)    # outbound ratio
    X[:, 39] = rng.normal(0.55, 0.08, n).clip(0.35, 0.75)    # inbound ratio
    X[:, 40] = rng.normal(0.8, 0.3, n).clip(0.2, 1.8)        # bytes out/in
    X[:, 41] = rng.normal(0.6, 0.12, n).clip(0.3, 0.85)      # symmetric flow ratio

    # Protocol mix
    X[:, 42] = rng.normal(0.6, 0.1, n).clip(0.35, 0.85)      # tcp ratio
    X[:, 43] = rng.normal(0.3, 0.08, n).clip(0.1, 0.5)       # udp ratio
    X[:, 44] = rng.normal(0.02, 0.015, n).clip(0, 0.06)      # icmp ratio
    X[:, 45] = rng.normal(0.01, 0.008, n).clip(0, 0.03)      # other ratio

    # Derived
    X[:, 46] = rng.normal(0.2, 0.1, n).clip(0, 0.5)          # periodicity score
    X[:, 47] = rng.normal(0.05, 0.03, n).clip(0, 0.15)       # anomaly score raw

    return X


def generate_dns_tunnel(n: int, rng: np.random.Generator) -> np.ndarray:
    """DNS tunneling — long subdomain labels, high query rate to single domain."""
    X = generate_normal(n, rng)

    # DNS features spike
    X[:, 26] = rng.normal(15, 5, n).clip(6, 30)              # query rate HIGH
    X[:, 27] = rng.normal(14, 4.5, n).clip(5, 28)            # response rate HIGH
    X[:, 28] = rng.normal(35, 8, n).clip(20, 55)             # label length mean VERY LONG
    X[:, 29] = rng.normal(55, 8, n).clip(35, 63)             # label length max near DNS limit

    X[:, 30] = rng.normal(2, 0.8, n).clip(1, 4)              # unique domains LOW (one tunnel domain)
    X[:, 31] = rng.normal(5, 1.5, n).clip(3, 9)              # subdomain depth HIGH
    X[:, 32] = rng.normal(0.25, 0.1, n).clip(0.08, 0.5)      # TXT record ratio HIGH
    X[:, 33] = rng.normal(0.15, 0.06, n).clip(0.05, 0.3)     # nxdomain ratio elevated

    # Packet sizes — DNS tunnel packets are medium-small, dominated by DNS
    X[:, 0] = rng.normal(0.08, 0.03, n).clip(0.02, 0.18)     # small bucket lower
    X[:, 1] = rng.normal(0.25, 0.06, n).clip(0.12, 0.4)      # 65-128 higher
    X[:, 2] = rng.normal(0.35, 0.08, n).clip(0.15, 0.55)     # 129-256 dominant
    X[:, 6] = rng.normal(180, 50, n).clip(80, 300)            # mean size smaller

    # Protocol mix — heavy UDP (DNS)
    X[:, 42] = rng.normal(0.2, 0.06, n).clip(0.05, 0.35)     # tcp drops
    X[:, 43] = rng.normal(0.75, 0.08, n).clip(0.55, 0.92)    # udp dominates

    # Payload entropy HIGH (encoded data)
    X[:, 34] = rng.normal(5.8, 0.4, n).clip(4.8, 6.8)
    X[:, 37] = rng.normal(0.15, 0.06, n).clip(0.03, 0.3)     # less binary (base64/hex encoded)
    X[:, 36] = rng.normal(0.8, 0.08, n).clip(0.6, 0.95)      # high ASCII (encoded)

    # Direction — mostly outbound (exfil)
    X[:, 38] = rng.normal(0.65, 0.08, n).clip(0.48, 0.82)
    X[:, 40] = rng.normal(2.5, 0.8, n).clip(1.2, 4.5)        # bytes out >> in

    # Derived
    X[:, 47] = rng.normal(0.55, 0.12, n).clip(0.3, 0.8)

    return X


def generate_c2_beacon(n: int, rng: np.random.Generator) -> np.ndarray:
    """C2 beaconing — periodic outbound connections at fixed intervals, small payloads."""
    X = generate_normal(n, rng)

    # IAT very regular (fixed beacon interval)
    X[:, 10] = rng.normal(60, 15, n).clip(20, 120)           # mean IAT (seconds between beacons)
    X[:, 11] = rng.normal(2, 1, n).clip(0.3, 5)              # std IAT VERY LOW (periodic)
    X[:, 14] = rng.normal(55, 12, n).clip(18, 100)           # p25 close to mean
    X[:, 15] = rng.normal(58, 13, n).clip(19, 110)           # p50 close to mean
    X[:, 16] = rng.normal(62, 14, n).clip(22, 125)           # p75 close to mean
    X[:, 17] = rng.normal(0.85, 0.06, n).clip(0.65, 0.95)   # regularity score VERY HIGH

    # Periodicity
    X[:, 46] = rng.normal(0.85, 0.06, n).clip(0.65, 0.96)   # periodicity score HIGH

    # Small payloads
    X[:, 0] = rng.normal(0.45, 0.1, n).clip(0.2, 0.7)       # small packets dominate
    X[:, 1] = rng.normal(0.3, 0.08, n).clip(0.1, 0.5)
    X[:, 6] = rng.normal(80, 25, n).clip(30, 150)            # mean pkt size small
    X[:, 7] = rng.normal(40, 15, n).clip(10, 80)             # std small

    # Few destination ports (C2 server)
    X[:, 18] = rng.normal(2, 0.8, n).clip(1, 4)              # unique dst ports VERY LOW
    X[:, 20] = rng.normal(0.05, 0.03, n).clip(0, 0.12)       # port diversity LOW

    # Connection features
    X[:, 22] = rng.normal(50, 15, n).clip(20, 100)           # conn count HIGH
    X[:, 23] = rng.normal(3, 1.5, n).clip(0.5, 8)            # conn duration SHORT
    X[:, 24] = rng.normal(1, 0.5, n).clip(0.2, 3)            # duration std LOW

    # Direction — outbound dominant
    X[:, 38] = rng.normal(0.7, 0.06, n).clip(0.55, 0.85)
    X[:, 40] = rng.normal(1.5, 0.4, n).clip(0.8, 2.8)

    # TCP dominant (HTTP/HTTPS beacons)
    X[:, 42] = rng.normal(0.85, 0.06, n).clip(0.65, 0.95)
    X[:, 43] = rng.normal(0.1, 0.04, n).clip(0.02, 0.2)

    # Low payload entropy (simple check-in data)
    X[:, 34] = rng.normal(3.5, 0.6, n).clip(2, 5)

    # Derived
    X[:, 47] = rng.normal(0.6, 0.12, n).clip(0.35, 0.85)

    return X


def generate_port_scan(n: int, rng: np.random.Generator) -> np.ndarray:
    """Port scanning — sequential port probes, SYN without ACK."""
    X = generate_normal(n, rng)

    # Port features spike
    X[:, 18] = rng.normal(200, 80, n).clip(50, 500)          # unique dst ports EXTREME
    X[:, 20] = rng.normal(0.9, 0.05, n).clip(0.75, 0.98)    # port diversity VERY HIGH
    X[:, 21] = rng.normal(0.15, 0.06, n).clip(0.03, 0.3)    # ephemeral ratio LOW (scanning well-known)

    # Packet sizes — SYN packets are tiny
    X[:, 0] = rng.normal(0.7, 0.1, n).clip(0.45, 0.9)       # 0-64 bucket dominates
    X[:, 6] = rng.normal(60, 10, n).clip(40, 90)             # mean pkt size very small
    X[:, 7] = rng.normal(15, 8, n).clip(3, 35)               # std very small

    # IAT — very fast sequential probes
    X[:, 10] = rng.normal(0.005, 0.003, n).clip(0.001, 0.015)  # mean IAT tiny
    X[:, 11] = rng.normal(0.003, 0.002, n).clip(0.0005, 0.008) # std IAT small

    # Connection features — many incomplete
    X[:, 22] = rng.normal(300, 100, n).clip(100, 600)        # conn count EXTREME
    X[:, 23] = rng.normal(0.1, 0.05, n).clip(0.02, 0.25)    # duration very short
    X[:, 25] = rng.normal(0.8, 0.08, n).clip(0.6, 0.95)     # incomplete conn ratio HIGH

    # TCP dominant (SYN scan)
    X[:, 42] = rng.normal(0.92, 0.04, n).clip(0.8, 0.99)
    X[:, 43] = rng.normal(0.03, 0.02, n).clip(0, 0.08)

    # Direction — all outbound
    X[:, 38] = rng.normal(0.85, 0.05, n).clip(0.72, 0.95)
    X[:, 39] = rng.normal(0.15, 0.05, n).clip(0.05, 0.28)
    X[:, 40] = rng.normal(5, 2, n).clip(2, 12)               # bytes out >> in

    # Payload — empty (SYN only)
    X[:, 34] = rng.normal(1.5, 0.5, n).clip(0.5, 3)          # entropy LOW (no payload)
    X[:, 36] = rng.normal(0.1, 0.05, n).clip(0, 0.25)        # ASCII LOW
    # Empty payload ratio would be very high
    X[:, 5] = rng.normal(0.02, 0.01, n).clip(0, 0.05)        # 1025+ bucket near zero

    # Derived
    X[:, 47] = rng.normal(0.7, 0.1, n).clip(0.45, 0.9)

    return X


GENERATORS = [generate_normal, generate_dns_tunnel, generate_c2_beacon, generate_port_scan]


def main():
    parser = argparse.ArgumentParser(description='Generate synthetic protocol anomaly data')
    parser.add_argument('--total', type=int, default=40000)
    parser.add_argument('--test-frac', type=float, default=0.2)
    parser.add_argument('--seed', type=int, default=SEED)
    parser.add_argument('--output-dir', type=str, default=os.path.dirname(os.path.abspath(__file__)))
    args = parser.parse_args()

    rng = np.random.default_rng(args.seed)
    samples_per_class = args.total // NUM_CLASSES
    remainder = args.total - samples_per_class * NUM_CLASSES

    all_X, all_y = [], []
    for cls_idx, gen_fn in enumerate(GENERATORS):
        n_cls = samples_per_class + (1 if cls_idx < remainder else 0)
        all_X.append(gen_fn(n_cls, rng))
        all_y.append(np.full(n_cls, cls_idx, dtype=np.int32))
        print(f"  Class {cls_idx} ({CLASSES[cls_idx]}): {n_cls} samples")

    X = np.concatenate(all_X)
    y = np.concatenate(all_y)
    X += rng.normal(0, 0.01, X.shape).astype(np.float32)

    idx = rng.permutation(len(X))
    X, y = X[idx], y[idx]

    n_test = int(len(X) * args.test_frac)
    np.savez_compressed(os.path.join(args.output_dir, 'train.npz'), X=X[n_test:], y=y[n_test:])
    np.savez_compressed(os.path.join(args.output_dir, 'test.npz'), X=X[:n_test], y=y[:n_test])

    print(f"\nTotal: {len(X)} | Train: {len(X)-n_test} | Test: {n_test}")
    print(f"Features: {NUM_FEATURES} | Classes: {NUM_CLASSES} — {CLASSES}")


if __name__ == '__main__':
    main()
