#!/usr/bin/env python3
"""
PeerCortex Rotating Daily Audit
- Pool of 500+ ASNs
- Each day: deterministic 100-ASN selection (seeded by date → reproducible, never same day twice)
- Each ASN: PeerCortex response cross-validated against RIPE Stat + PeeringDB externally
- Math checks: prefix sums, RPKI sums, IX dedup, facility counts
- Results saved to /var/log/peercortex/audit-YYYY-MM-DD.json
"""
import json, urllib.request, urllib.error, time, sys, os
from datetime import datetime, timezone
from hashlib import sha256

# ── ASN Pool (500+ diverse networks across all RIRs) ──────────────────────────
ASN_POOL = [
    # Tier-1 / Large Transit
    1, 174, 209, 286, 701, 702, 1239, 1273, 1280, 1299, 2914, 3257, 3320,
    3356, 3491, 3549, 3561, 4134, 4637, 4657, 5511, 6453, 6461, 6762, 6830,
    7018, 7473, 7922, 8220, 8928, 9002, 9304, 12956, 13030,
    # Cloud / Hyperscaler
    714, 2906, 8075, 13335, 15169, 16509, 16591, 20940, 21342, 32934, 36351,
    54113, 136907, 14061, 19604, 63949, 22616,
    # European ISPs
    1257, 1853, 2119, 2603, 3209, 3215, 3216, 3233, 3243, 3269, 3292, 3301,
    3303, 3308, 3327, 5089, 5390, 5432, 5483, 6057, 6128, 6147, 6695, 6728,
    6830, 6939, 8220, 8359, 8400, 8422, 8447, 8468, 8473, 8551, 8708, 9121,
    9145, 9158, 12714, 13237, 15435, 15547, 15600, 15692, 15830, 16276,
    20562, 20932, 21202, 24940, 24961, 25091, 28716, 29049, 29208, 33891,
    34465, 34549, 35369, 39122, 41327, 42, 42739, 43350, 47692, 48159,
    50629, 51088, 58057, 197540, 199121,
    # APNIC
    2497, 2516, 4766, 7545, 9318, 9498, 17676, 18403, 23969, 24115, 38001,
    45899, 55836, 132335, 136907, 137409, 139070, 4713, 9269, 17488, 23944,
    38197, 45352, 63927, 131090, 133481, 134762, 137718, 149440,
    # ARIN
    701, 1239, 1280, 2828, 3549, 3561, 6128, 6939, 7018, 7922, 10429, 10796,
    11427, 13977, 14080, 15133, 19281, 20115, 20325, 21928, 22773, 22822,
    23106, 32934, 33070, 35911, 36351, 40191, 43996, 54113, 62240, 64271,
    393398, 11404, 14618, 14593, 7046, 26101, 10310, 32590,
    # LACNIC
    6147, 8167, 10429, 10474, 11419, 11644, 14080, 18881, 22927, 23106,
    28573, 52320, 53013, 61832, 262589, 263702,
    # AFRINIC
    10474, 12091, 23889, 24835, 30844, 36864, 36903, 37100, 37271, 37468,
    55330, 327814,
    # IXPs / Route Servers
    6695, 24115, 34307, 42476, 43366, 47498, 56393,
    # CDN / Hosting
    2906, 8100, 13335, 15133, 16509, 20940, 22822, 24429, 46489, 54113,
    60068, 60501, 62240, 64271, 132335,
    # Security / DNS
    19281, 15169, 13335, 714, 20473,
    # Mixed / Small
    29791, 34224, 35332, 39386, 41695, 42473, 44574, 47065, 48858, 50300,
    56630, 57695, 58057, 59947, 60068, 60501, 63023, 64271, 132335,
    # More European
    1200, 2611, 3265, 5388, 5430, 5444, 6128, 8309, 8365, 8560, 8607,
    9009, 12897, 13285, 15516, 16150, 20738, 21263, 24586, 24875, 25369,
    28917, 29017, 30036, 30781, 31025, 31400, 33986, 34006, 34224, 34655,
    35432, 39386, 39737, 41327, 41552, 42652, 43531, 44574, 47065,
    # Telcos worldwide
    2516, 3320, 3786, 4766, 4800, 5483, 7473, 8151, 8708, 9121, 9304,
    9318, 9498, 10429, 12479, 12714, 15704, 16276, 17676, 18403, 22773,
]
# Deduplicate while preserving order
seen = set()
ASN_POOL_DEDUP = [x for x in ASN_POOL if not (x in seen or seen.add(x))]

def load_pool():
    """Load ASN pool from file, fall back to built-in list."""
    pool_file = os.path.join(os.path.dirname(__file__), 'asn_pool.json')
    try:
        with open(pool_file) as f:
            return json.load(f)
    except:
        return ASN_POOL_DEDUP

def select_daily_asns(date_str, count=100):
    """Deterministic daily selection — same date always gives same 100 ASNs."""
    import random
    pool = load_pool()
    seed = int(sha256(date_str.encode()).hexdigest(), 16)
    rng = random.Random(seed)
    rng.shuffle(pool)
    return sorted(pool[:count])

def fetch(url, timeout=12):
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "PeerCortex-Audit/5.0"})
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return json.loads(r.read())
    except:
        return None

def fetch_pc(asn, timeout=20):
    try:
        with urllib.request.urlopen(f"http://localhost:3101/api/lookup?asn={asn}", timeout=timeout) as r:
            return json.loads(r.read())
    except:
        return None

def audit_asn(asn):
    issues = []
    result = {"asn": asn, "status": "ok", "issues": [], "data": {}}

    # ── 1. PeerCortex response ───────────────────────────────────────────────
    d = fetch_pc(asn)
    if not d or "network" not in d or d.get("error"):
        result["status"] = "skip"
        result["issues"].append(("SKIP", "no_response", "PeerCortex did not respond"))
        return result

    n   = d.get("network", {})
    p   = d.get("prefixes", {})
    r   = d.get("rpki", {})
    ix  = d.get("ix_presence", {})
    fac = d.get("facilities", {})
    meta= d.get("meta", {})

    result["data"] = {
        "name": n.get("name"),
        "rir":  n.get("rir"),
        "cc":   n.get("country"),
        "pfx_total": p.get("total", 0),
        "rpki_pct":  r.get("coverage_percent", 0),
        "ix_cnt":    ix.get("total_connections", 0),
        "fac_cnt":   fac.get("total", 0),
        "version":   meta.get("version"),
    }

    # ── 2. Math checks (internal consistency) ───────────────────────────────
    if p.get("ipv4", 0) + p.get("ipv6", 0) != p.get("total", -1):
        issues.append(("CRITICAL", "prefix_math",
            f"v4({p.get('ipv4')})+v6({p.get('ipv6')}) ≠ total({p.get('total')})"))

    rpki_sum = r.get("valid", 0) + r.get("invalid", 0) + r.get("not_found", 0)
    if rpki_sum != r.get("checked", 0):
        issues.append(("CRITICAL", "rpki_sum",
            f"{rpki_sum} ≠ checked({r.get('checked')})"))

    if r.get("checked", 0) > 0:
        exp = round(r.get("valid", 0) / r.get("checked") * 100)
        if abs(exp - r.get("coverage_percent", 0)) > 1:
            issues.append(("CRITICAL", "rpki_pct",
                f"expected {exp}% got {r.get('coverage_percent')}%"))

    conns = ix.get("connections", [])
    dedup = len(set(c.get("ix_id") for c in conns if c.get("ix_id")))
    if dedup != ix.get("unique_ixps", 0):
        issues.append(("CRITICAL", "ix_dedup",
            f"dedup={dedup} ≠ unique_ixps={ix.get('unique_ixps')}"))

    if len(conns) != ix.get("total_connections", 0):
        issues.append(("WARNING", "ix_total",
            f"len={len(conns)} ≠ total_connections={ix.get('total_connections')}"))

    if len(fac.get("list", [])) != fac.get("total", 0):
        issues.append(("WARNING", "fac_total",
            f"len={len(fac.get('list',[]))} ≠ total={fac.get('total')}"))

    # ── 3. Mandatory fields ──────────────────────────────────────────────────
    if not n.get("name") or n.get("name") == "Unknown":
        issues.append(("CRITICAL", "name_empty", f"name={n.get('name')!r}"))

    if not n.get("asn"):
        issues.append(("CRITICAL", "asn_field", "ASN field missing in response"))

    if not n.get("rir"):
        issues.append(("WARNING", "rir_empty", f"rir empty, country={n.get('country')!r}"))

    if not n.get("country"):
        issues.append(("WARNING", "country_empty", "country field empty"))

    # ── 4. External cross-validation: RIPE Stat prefix count ────────────────
    ripe = fetch(f"https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS{asn}")
    if ripe and ripe.get("data", {}).get("prefixes") is not None:
        ripe_pfx = len(ripe["data"]["prefixes"])
        pc_pfx   = p.get("total", 0)
        result["data"]["ripe_stat_pfx"] = ripe_pfx
        if ripe_pfx > 0 and pc_pfx > 0:
            ratio = min(ripe_pfx, pc_pfx) / max(ripe_pfx, pc_pfx)
            if ratio < 0.85:
                issues.append(("WARNING", "prefix_ext_mismatch",
                    f"PC={pc_pfx} vs RIPE Stat={ripe_pfx} ({round((1-ratio)*100)}% diff)"))
        elif ripe_pfx == 0 and pc_pfx > 0:
            issues.append(("WARNING", "prefix_ext_zero",
                f"RIPE Stat reports 0 prefixes, PC shows {pc_pfx}"))

    # ── 5. External cross-validation: PeeringDB IX count ────────────────────
    pdb = fetch(f"https://www.peeringdb.com/api/netixlan?asn={asn}&depth=0")
    if pdb and isinstance(pdb.get("data"), list):
        pdb_ix = len(pdb["data"])
        pc_ix  = ix.get("total_connections", 0)
        result["data"]["pdb_ix_cnt"] = pdb_ix
        if pdb_ix > 0 and pc_ix > 0:
            ratio = min(pdb_ix, pc_ix) / max(pdb_ix, pc_ix)
            if ratio < 0.85:
                issues.append(("WARNING", "ix_ext_mismatch",
                    f"PC={pc_ix} vs PeeringDB={pdb_ix} ({round((1-ratio)*100)}% diff)"))

    # ── Categorize ───────────────────────────────────────────────────────────
    result["issues"] = issues
    crits = [x for x in issues if x[0] == "CRITICAL"]
    warns = [x for x in issues if x[0] == "WARNING"]
    if crits:
        result["status"] = "critical"
    elif warns:
        result["status"] = "warning"
    else:
        result["status"] = "ok"

    return result

# ── Main ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    asns = select_daily_asns(date_str, 100)
    start = time.time()

    print(f"PeerCortex Rotating Audit — {date_str}")
    print(f"Pool: {len(ASN_POOL_DEDUP)} ASNs | Today: {len(asns)} selected")
    print("=" * 70)

    results = []
    counts = {"ok": 0, "warning": 0, "critical": 0, "skip": 0}

    for i, asn in enumerate(asns):
        sys.stdout.write(f"\r  [{i+1:3d}/100] AS{asn}...    ")
        sys.stdout.flush()
        r = audit_asn(asn)
        results.append(r)
        counts[r["status"]] += 1
        time.sleep(0.3)  # be gentle to external APIs

    elapsed = time.time() - start
    print(f"\r  Done in {elapsed:.0f}s" + " " * 30)

    # ── Summary ───────────────────────────────────────────────────────────────
    print()
    print("=" * 70)
    print(f"SUMMARY — {date_str}")
    print("=" * 70)
    print(f"  ✓ PERFECT:   {counts['ok']:3d}")
    print(f"  ⚠ WARNING:   {counts['warning']:3d}")
    print(f"  ✗ CRITICAL:  {counts['critical']:3d}")
    print(f"  ⏭ SKIP:      {counts['skip']:3d}")

    issues_found = [r for r in results if r["status"] in ("critical", "warning")]
    if issues_found:
        print(f"\n  ISSUES ({len(issues_found)} ASNs):")
        for r in issues_found:
            for sev, field, msg in r["issues"]:
                print(f"    [{sev[:4]}] AS{r['asn']:>7} | {field:30s} | {msg[:55]}")

    # ── Save JSON report ──────────────────────────────────────────────────────
    report = {
        "date": date_str,
        "elapsed_s": round(elapsed),
        "asns_tested": asns,
        "pool_size": len(ASN_POOL_DEDUP),
        "counts": counts,
        "results": results,
        "generated": datetime.now(timezone.utc).isoformat(),
    }
    out_path = f"/var/log/peercortex/audit-{date_str}.json"
    os.makedirs("/var/log/peercortex", exist_ok=True)
    with open(out_path, "w") as f:
        json.dump(report, f, indent=2)
    print(f"\nReport: {out_path}")
    print(f"Audit complete: {datetime.now(timezone.utc).isoformat()}")
