const fs = require("fs");
const http = require("http");
const https = require("https");
const crypto = require("crypto");

// ── LOCAL DATABASE CLIENT (BGP, RPKI, Threat Intel) ──────────────
const localDb = require('./local-db-client');
console.log('[PeerCortex] Local DB client initialized');

// Load .env file
require('./src/backend/config');

const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";

// bio-rd gRPC client (optional — graceful fallback if unavailable)
let risClient = null;
try {
  const { createRisClient } = require('./bio-rd-client');
  const BIO_RD_HOST = process.env.BIO_RD_HOST || 'localhost';
  const BIO_RD_PORT = parseInt(process.env.BIO_RD_PORT || '4321');
  risClient = createRisClient(BIO_RD_HOST, BIO_RD_PORT);
  console.log(`[bio-rd] RIS client configured → ${BIO_RD_HOST}:${BIO_RD_PORT}`);
} catch (e) {
  console.log('[bio-rd] RIS client not available (bio-rd-client.js missing or gRPC not installed)');
}

const PEERINGDB_API_KEY = process.env.PEERINGDB_API_KEY || "";
const PEERINGDB_API_URL = process.env.PEERINGDB_API_URL || "https://www.peeringdb.com/api";

// ── Local PeeringDB SQLite (peeringdb-py sync, refreshed daily by cron) ──────
const PEERINGDB_LOCAL_PATH = process.env.PEERINGDB_LOCAL_PATH || "/opt/peeringdb-data/peeringdb.sqlite3";
let _pdbLocal = null;
function getPdbLocal() {
  if (_pdbLocal) return _pdbLocal;
  try {
    const BetterSqlite3 = require("better-sqlite3");
    if (!fs.existsSync(PEERINGDB_LOCAL_PATH)) return null;
    _pdbLocal = new BetterSqlite3(PEERINGDB_LOCAL_PATH, { readonly: true, fileMustExist: true });
    console.log("[PeeringDB-local] SQLite opened:", PEERINGDB_LOCAL_PATH);
    return _pdbLocal;
  } catch (e) {
    console.warn("[PeeringDB-local] Could not open SQLite:", e.message);
    return null;
  }
}

// Map API path → SQLite result in { data: [...] } format, emulating the live PDB REST API.
function queryPeeringDBLocal(path) {
  const db = getPdbLocal();
  if (!db) return null;
  try {
    // /net?asn=X
    const netAsnMatch = path.match(/^\/net\?asn=(\d+)/);
    if (netAsnMatch) {
      const rows = db.prepare(
        "SELECT n.*, o.name AS org_name FROM peeringdb_network n " +
        "LEFT JOIN peeringdb_organization o ON n.org_id = o.id " +
        "WHERE n.asn = ? AND n.status = 'ok'"
      ).all(parseInt(netAsnMatch[1]));
      return { data: rows };
    }

    // /net?status=ok&depth=0  (coverage endpoint — all networks)
    if (path === "/net?status=ok&depth=0" || path.startsWith("/net?status=ok")) {
      const rows = db.prepare(
        "SELECT id, asn, name, aka, website, info_prefixes4, info_prefixes6, " +
        "info_type, info_traffic, info_unicast, info_ipv6, policy_general, org_id " +
        "FROM peeringdb_network WHERE status = 'ok' ORDER BY asn"
      ).all();
      return { data: rows };
    }

    // /netixlan?net_id=X&limit=...  or  /netixlan?asn=X&limit=...
    const netixlanNetId = path.match(/\/netixlan\?net_id=(\d+)/);
    if (netixlanNetId) {
      const rows = db.prepare(
        "SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " +
        "ni.operational, ni.bfd_support, il.id AS ixlan_id, " +
        "ix.id AS ix_id, ix.name, ix.city, ix.country " +
        "FROM peeringdb_network_ixlan ni " +
        "LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " +
        "LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " +
        "WHERE ni.net_id = ? AND ni.status = 'ok'"
      ).all(parseInt(netixlanNetId[1]));
      return { data: rows };
    }
    const netixlanAsn = path.match(/\/netixlan\?asn=(\d+)/);
    if (netixlanAsn) {
      const rows = db.prepare(
        "SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " +
        "ni.operational, ni.bfd_support, il.id AS ixlan_id, " +
        "ix.id AS ix_id, ix.name, ix.city, ix.country " +
        "FROM peeringdb_network_ixlan ni " +
        "LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " +
        "LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " +
        "WHERE ni.asn = ? AND ni.status = 'ok'"
      ).all(parseInt(netixlanAsn[1]));
      return { data: rows };
    }

    // /netixlan?ixlan_id=X
    const netixlanIxlanId = path.match(/\/netixlan\?ixlan_id=(\d+)/);
    if (netixlanIxlanId) {
      const rows = db.prepare(
        "SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " +
        "n.name AS net_name " +
        "FROM peeringdb_network_ixlan ni " +
        "LEFT JOIN peeringdb_network n ON ni.net_id = n.id " +
        "WHERE ni.ixlan_id = ? AND ni.status = 'ok'"
      ).all(parseInt(netixlanIxlanId[1]));
      return { data: rows };
    }

    // /netfac?net_id=X
    const netfacNetId = path.match(/\/netfac\?net_id=(\d+)/);
    if (netfacNetId) {
      const rows = db.prepare(
        "SELECT nf.id, nf.net_id, f.id AS fac_id, f.name, f.city, f.state, " +
        "f.country, f.latitude, f.longitude, f.website " +
        "FROM peeringdb_network_facility nf " +
        "LEFT JOIN peeringdb_facility f ON nf.fac_id = f.id " +
        "WHERE nf.net_id = ? AND nf.status = 'ok'"
      ).all(parseInt(netfacNetId[1]));
      return { data: rows };
    }

    // /fac?id__in=X,Y,Z&fields=...
    const facIdIn = path.match(/\/fac\?id__in=([\d,]+)/);
    if (facIdIn) {
      const ids = facIdIn[1].split(",").map(Number).filter(Boolean);
      if (ids.length === 0) return { data: [] };
      const placeholders = ids.map(() => "?").join(",");
      const rows = db.prepare(
        "SELECT id, name, city, country, latitude, longitude, website " +
        "FROM peeringdb_facility WHERE id IN (" + placeholders + ") AND status = 'ok'"
      ).all(...ids);
      return { data: rows };
    }

    // /ixfac?ix_id__in=X,Y,Z
    const ixfacIxIdIn = path.match(/\/ixfac\?ix_id__in=([\d,]+)/);
    if (ixfacIxIdIn) {
      const ids = ixfacIxIdIn[1].split(",").map(Number).filter(Boolean);
      if (ids.length === 0) return { data: [] };
      const placeholders = ids.map(() => "?").join(",");
      const rows = db.prepare(
        "SELECT ixf.id, ixf.ix_id, ixf.fac_id, f.latitude, f.longitude, f.city, f.country " +
        "FROM peeringdb_ix_facility ixf " +
        "LEFT JOIN peeringdb_facility f ON ixf.fac_id = f.id " +
        "WHERE ixf.ix_id IN (" + placeholders + ") AND ixf.status = 'ok'"
      ).all(...ids);
      return { data: rows };
    }

    // /ix?name__contains=X
    const ixNameContains = path.match(/\/ix\?name__contains=([^&]+)/);
    if (ixNameContains) {
      const term = "%" + decodeURIComponent(ixNameContains[1]) + "%";
      const rows = db.prepare(
        "SELECT id, name, name_long, city, country, website, region_continent " +
        "FROM peeringdb_ix WHERE (name LIKE ? OR name_long LIKE ?) AND status = 'ok' LIMIT 20"
      ).all(term, term);
      return { data: rows };
    }

    // /ixlan?ix_id=X
    const ixlanIxId = path.match(/\/ixlan\?ix_id=(\d+)/);
    if (ixlanIxId) {
      const rows = db.prepare(
        "SELECT id, ix_id, name, rs_asn, arp_sponge, mtu FROM peeringdb_ixlan " +
        "WHERE ix_id = ? AND status = 'ok'"
      ).all(parseInt(ixlanIxId[1]));
      return { data: rows };
    }

    // /net/X  (single network by PDB id)
    const netById = path.match(/^\/net\/(\d+)$/);
    if (netById) {
      const row = db.prepare(
        "SELECT n.*, o.name AS org_name FROM peeringdb_network n " +
        "LEFT JOIN peeringdb_organization o ON n.org_id = o.id " +
        "WHERE n.id = ? AND n.status = 'ok'"
      ).get(parseInt(netById[1]));
      return row ? { data: [row] } : { data: [] };
    }

    return null; // path not handled locally — fall through to live API
  } catch (e) {
    console.warn("[PeeringDB-local] Query error for", path, ":", e.message);
    return null;
  }
}

const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env";
const FEEDBACK_FILE  = "/opt/peercortex-app/feedback.json";
const VISITORS_FILE = "/opt/peercortex-app/visitors.json";

// ── SMTP / Email ──────────────────────────────────────────────
const { sendFeedbackMail } = require('./src/backend/services/smtp');
// ── SMTP / Email ──────────────────────────────────────────────


function loadVisitors() {
  try { return JSON.parse(fs.readFileSync(VISITORS_FILE, "utf8")); } catch (_) { return { hashes: [] }; }
}

function trackVisitor(req) {
  const ip = (req.headers["x-forwarded-for"] || "").split(",")[0].trim() || (req.socket && req.socket.remoteAddress) || "";
  const hash = crypto.createHash("sha256").update(ip + "peercortex-salt-2026").digest("hex");
  const data = loadVisitors();
  if (!data.hashes.includes(hash)) {
    data.hashes.push(hash);
    try { fs.writeFileSync(VISITORS_FILE, JSON.stringify(data)); } catch (_) {}
  }
  return data.hashes.length;
}

// ═══════════════════════════════════════════════════════════════
// PEERCORTEX v0.6.1 — New Features
// ═══════════════════════════════════════════════════════════════

// ── BGP Community Database ─────────────────────────────────────
// Migrated to src/features/bgp-communities/bgp-community-db.ts

// ── Hijack Monitoring ──────────────────────────────────────────
const HIJACK_SUBS_FILE   = '/opt/peercortex-app/hijack-subs.json';
const HIJACK_ALERTS_FILE = '/opt/peercortex-app/hijack-alerts.json';

function loadHijackSubs()   { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } }

async function checkHijacksForAsn(asn) {
  try {
    const data = await localDb.getRipeStatAnnouncedPrefixes(asn);
    const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
    return prefixes;
  } catch (_) { return []; }
}

async function runHijackCheck() {
  const subs = loadHijackSubs();
  if (!subs.length) return;
  const alerts = loadHijackAlerts();
  for (const sub of subs) {
    const current = await checkHijacksForAsn(sub.asn);
    const baseline = new Set(sub.prefixes || []);
    const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p));
    const missing    = [...baseline].filter(p => !current.includes(p));
    if (unexpected.length || missing.length) {
      const alert = {
        asn: sub.asn, ts: new Date().toISOString(),
        unexpected, missing,
        msg: `Possible hijack detected for AS${sub.asn}: ${unexpected.length} unexpected, ${missing.length} missing prefixes`
      };
      alerts.push(alert);
      try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(alerts.slice(-500), null, 2)); } catch(_) {}
    }
    // Update baseline with current prefixes if no baseline set
    if (!sub.prefixes || !sub.prefixes.length) {
      sub.prefixes = current;
      try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2)); } catch(_) {}
    }
  }
}
// Run hijack check every 30 minutes
setInterval(runHijackCheck, 30 * 60 * 1000);



const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";

// Static geocode cache for major networking cities (fallback when PDB facility coords missing)
const CITY_COORDS = {
  "amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821],
  "paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417],
  "berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820],
  "vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122],
  "copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384],
  "milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393],
  "dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025],
  "sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784],
  "moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198],
  "hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093],
  "los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298],
  "dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874],
  "seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832],
  "sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940],
  "manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735],
  "nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052],
  "auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903],
  "atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207],
  "montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780],
  "taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456],
  "scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883],
};

// ============================================================
// Task 6: In-memory cache with TTL + Rate Limiting
// ============================================================
const responseCache = new Map();

function cacheGet(key) {
  const entry = responseCache.get(key);
  if (!entry) return null;
  if (Date.now() > entry.expires) {
    responseCache.delete(key);
    return null;
  }
  return entry.data;
}

function cacheSet(key, data, ttlMs) {
  responseCache.set(key, { data, expires: Date.now() + ttlMs });
  // Evict old entries periodically (keep cache under 500 entries)
  if (responseCache.size > 500) {
    const now = Date.now();
    for (const [k, v] of responseCache) {
      if (now > v.expires) responseCache.delete(k);
    }
  }
}


// ── Tier-1 ASN Set (used for route leak heuristics) ─────────────────────────
const TIER1_ASNS = new Set([
  174,    // Cogent
  209,    // CenturyLink/Lumen
  286,    // KPN
  701,    // Verizon/UUNET
  702,    // Verizon
  1239,   // Sprint
  1273,   // Vodafone
  1280,   // Internet Systems Consortium
  1299,   // Arelion (Telia)
  2914,   // NTT
  3257,   // GTT
  3320,   // Deutsche Telekom
  3356,   // Lumen (Level3)
  3491,   // PCCW
  5511,   // Orange
  6453,   // TATA
  6461,   // Zayo
  6762,   // Telecom Italia Sparkle
  7018,   // AT&T
  7473,   // SingTel
  12956,  // Telxius
]);

const CACHE_TTL_LOOKUP = 5 * 60 * 1000;   // 5 minutes
const CACHE_TTL_ASPA = 4 * 60 * 60 * 1000;    // 4 hours
const CACHE_TTL_NEWS = 10 * 60 * 1000;    // 10 minutes
const CACHE_TTL_DEFAULT = 5 * 60 * 1000;  // 5 minutes

// ============================================================
// RDAP Cache — prevents 429 flooding on LACNIC/AFRINIC/APNIC/ARIN
// ============================================================
const rdapCache = new Map(); // key: asn string, value: { data, ts }
const RDAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours

function rdapCacheGet(asn) {
  const e = rdapCache.get(String(asn));
  if (e && (Date.now() - e.ts) < RDAP_CACHE_TTL) return e.data;
  return undefined; // undefined = not cached, null = cached miss
}
function rdapCacheSet(asn, data) {
  if (rdapCache.size > 5000) {
    rdapCache.delete(rdapCache.keys().next().value);
  }
  rdapCache.set(String(asn), { data, ts: Date.now() });
}

// ============================================================
// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering
// ============================================================
const whoisCache = new Map(); // key: asn string, value: { data, ts }
const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000;
function whoisCacheGet(asn) {
  const e = whoisCache.get(String(asn));
  if (e && (Date.now() - e.ts) < WHOIS_CACHE_TTL) return e.data;
  return undefined;
}
function whoisCacheSet(asn, data) {
  if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value);
  whoisCache.set(String(asn), { data, ts: Date.now() });
}

// ============================================================
// Quick-IX Cache — 1h TTL, for Peering Recommendations
// ============================================================
const quickIxCache = new Map(); // key: asn string, value: { data, ts }
const QUICK_IX_CACHE_TTL = 60 * 60 * 1000;
function quickIxCacheGet(asn) {
  const e = quickIxCache.get(String(asn));
  if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data;
  return undefined;
}
function quickIxCacheSet(asn, data) {
  if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value);
  quickIxCache.set(String(asn), { data, ts: Date.now() });
}

// ============================================================
// bgproutes.io Vantage Points Cache — 1h TTL, prevents 429
// ============================================================
let bgproutesVpCache = null;
let bgproutesVpCacheTs = 0;
const BGPROUTES_VP_TTL = 60 * 60 * 1000; // 1 hour

// ============================================================
// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs
// ============================================================
const bgproutesResultCache = new Map();
const aspaResultCache = new Map();
const validateResultCache = new Map();
const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
function resultCacheGet(map, key) {
  const e = map.get(String(key));
  if (e && (Date.now() - e.ts) < RESULT_CACHE_TTL) return e.data;
  return undefined;
}
function resultCacheSet(map, key, data) {
  if (map.size > 2000) map.delete(map.keys().next().value);
  map.set(String(key), { data, ts: Date.now() });
}

// ============================================================
// MANRS Participants Cache (scraped from public HTML page, 24h TTL)
// ============================================================
let manrsAsnSet = null;        // Set<string> of member ASNs
let manrsLastFetch = 0;
let manrsFetching = false;
const MANRS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours

async function ensureManrsCache() {
  const now = Date.now();
  if (manrsAsnSet && (now - manrsLastFetch) < MANRS_CACHE_TTL) return;
  if (manrsFetching) {
    // Wait up to 8s for in-progress fetch
    for (let i = 0; i < 80; i++) {
      await new Promise(r => setTimeout(r, 100));
      if (manrsAsnSet) return;
    }
    return;
  }
  manrsFetching = true;
  try {
    const html = await new Promise((resolve, reject) => {
      const opts = { hostname: "www.manrs.org", path: "/netops/participants/", method: "GET", timeout: 15000,
        headers: { "User-Agent": UA, "Accept": "text/html" } };
      const req = https.request(opts, res => {
        let body = "";
        res.on("data", d => { body += d; });
        res.on("end", () => resolve(body));
      });
      req.on("error", reject);
      req.on("timeout", () => { req.destroy(); reject(new Error("MANRS fetch timeout")); });
      req.end();
    });
    // Extract ASNs from <td class="asns">267490</td> — may contain multiple space-separated ASNs
    const set = new Set();
    const re = /<td[^>]*class="asns"[^>]*>([\d\s,]+)<\/td>/gi;
    let m;
    while ((m = re.exec(html)) !== null) {
      m[1].split(/[\s,]+/).forEach(a => { const n = a.trim(); if (n) set.add(n); });
    }
    if (set.size > 0) {
      manrsAsnSet = set;
      manrsLastFetch = Date.now();
      console.log("[MANRS] Loaded " + set.size + " participant ASNs from manrs.org");
    }
  } catch (e) {
    console.warn("[MANRS] Failed to fetch participants:", e.message);
  } finally {
    manrsFetching = false;
  }
}

function checkManrsMembership(asn) {
  if (!manrsAsnSet) return { status: "info", participant: "unknown", message: "MANRS data not yet loaded", note: "https://www.manrs.org/netops/participants/" };
  const isMember = manrsAsnSet.has(String(asn));
  return {
    status: isMember ? "pass" : "fail",
    participant: isMember,
    member_count: manrsAsnSet.size,
    note: isMember ? "Confirmed MANRS Network Operator participant" : "Not listed as MANRS participant — https://www.manrs.org/beamanrs/",
  };
}

// ============================================================
// Infrastructure overlay caches
let subCableCache = null;      // TeleGeography submarine cables (24h)
let globalFacCache = null;     // PeeringDB global facilities (24h)

// RPKI ASPA + ROA Cache from Cloudflare RPKI JSON feed
// ============================================================
const rpkiAspaMap = new Map(); // customer_asid -> Set<provider_asn>
let rpkiAspaLastFetch = 0;
let rpkiAspaFetching = false;

// ============================================================
// Local ROA Store — validates prefixes without RIPE Stat API calls
// Parses ~400k ROAs from the same Cloudflare RPKI feed used for ASPA
// Uses sorted arrays + binary search for O(log n) lookups (~0.1ms per prefix)
// ============================================================
const roaStore = {
  v4Entries: [],    // [{start, end, asn, prefixLen, maxLen}] sorted by start
  v6Entries: [],    // [{prefixHex, prefixLen, asn, maxLen}] sorted by prefixHex
  ready: false,
  count: 0,
  lastBuild: 0,

  // Parse IPv4 prefix string to 32-bit unsigned integer
  _ipv4ToUint32(ip) {
    const parts = ip.split(".");
    return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
  },

  // Build ROA store from Cloudflare feed roas array
  build(roas) {
    const v4 = [];
    const v6 = [];
    for (let i = 0; i < roas.length; i++) {
      const r = roas[i];
      const asn = typeof r.asn === "string" ? parseInt(r.asn.replace("AS", "")) : Number(r.asn);
      const prefix = r.prefix;
      const maxLen = r.maxLength || r.maxPrefixLength || 0;
      if (!prefix || !asn) continue;

      const slashIdx = prefix.indexOf("/");
      if (slashIdx < 0) continue;
      const prefixLen = parseInt(prefix.substring(slashIdx + 1));
      const addr = prefix.substring(0, slashIdx);

      if (prefix.indexOf(":") >= 0) {
        // IPv6 — store as zero-padded hex string for sorting
        const expanded = this._expandIPv6(addr);
        if (expanded) {
          v6.push({ prefixHex: expanded, prefixLen, asn, maxLen: maxLen || prefixLen });
        }
      } else {
        // IPv4 — store as numeric range
        const start = this._ipv4ToUint32(addr);
        const hostBits = 32 - prefixLen;
        const end = (start | ((1 << hostBits) - 1)) >>> 0;
        v4.push({ start, end, asn, prefixLen, maxLen: maxLen || prefixLen });
      }
    }

    // Sort for binary search
    v4.sort((a, b) => a.start - b.start || a.prefixLen - b.prefixLen);
    v6.sort((a, b) => a.prefixHex < b.prefixHex ? -1 : a.prefixHex > b.prefixHex ? 1 : a.prefixLen - b.prefixLen);

    this.v4Entries = v4;
    this.v6Entries = v6;
    this.count = v4.length + v6.length;
    this.ready = true;
    this.lastBuild = Date.now();
  },

  // Expand IPv6 address to 32-char hex for reliable sorting
  _expandIPv6(addr) {
    try {
      let groups = addr.split("::");
      let left = groups[0] ? groups[0].split(":") : [];
      let right = groups.length > 1 && groups[1] ? groups[1].split(":") : [];
      const missing = 8 - left.length - right.length;
      const mid = [];
      for (let i = 0; i < missing; i++) mid.push("0000");
      const all = [...left, ...mid, ...right];
      return all.map(g => g.padStart(4, "0")).join("");
    } catch (_e) {
      return null;
    }
  },

  // Validate a prefix against the local ROA store
  // Returns: {prefix, status: "valid"|"invalid"|"not_found", validating_roas: N}
  validate(asn, prefix) {
    if (!this.ready) return null; // Signal caller to use fallback

    const asnNum = typeof asn === "string" ? parseInt(asn.replace("AS", "")) : Number(asn);
    const slashIdx = prefix.indexOf("/");
    if (slashIdx < 0) return { prefix, status: "not_found", validating_roas: 0 };
    const prefixLen = parseInt(prefix.substring(slashIdx + 1));
    const addr = prefix.substring(0, slashIdx);

    if (prefix.indexOf(":") >= 0) {
      return this._validateV6(asnNum, addr, prefixLen, prefix);
    }
    return this._validateV4(asnNum, addr, prefixLen, prefix);
  },

  _validateV4(asn, addr, prefixLen, prefix) {
    const queryStart = this._ipv4ToUint32(addr);
    const entries = this.v4Entries;

    // Binary search: find rightmost entry where start <= queryStart
    let lo = 0, hi = entries.length - 1;
    let insertionPoint = -1;
    while (lo <= hi) {
      const mid = (lo + hi) >>> 1;
      if (entries[mid].start <= queryStart) {
        insertionPoint = mid;
        lo = mid + 1;
      } else {
        hi = mid - 1;
      }
    }

    // Scan backwards from insertion point to find covering ROAs
    // ROAs are sorted by start, so we scan back while start could still cover our prefix
    const matched = [];
    const unmatchedAs = [];
    for (let i = insertionPoint; i >= 0; i--) {
      const e = entries[i];
      // If this ROA's network start is too far left, no more matches possible
      if (queryStart - e.start > 0x01000000) break; // heuristic: skip if > /8 away
      // Check if query prefix is contained within this ROA
      if (e.start <= queryStart && queryStart <= e.end && prefixLen >= e.prefixLen) {
        if (prefixLen <= e.maxLen) {
          if (e.asn === asn) {
            matched.push(e);
          } else {
            unmatchedAs.push(e);
          }
        }
        // prefixLen > maxLen → too specific, invalid if ASN matches
        else if (e.asn === asn) {
          unmatchedAs.push(e); // ASN matches but length exceeds maxLen
        }
      }
    }

    if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
    if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
    return { prefix, status: "not_found", validating_roas: 0 };
  },

  _validateV6(asn, addr, prefixLen, prefix) {
    const queryHex = this._expandIPv6(addr);
    if (!queryHex) return { prefix, status: "not_found", validating_roas: 0 };
    const entries = this.v6Entries;

    // Binary search for approximate position
    let lo = 0, hi = entries.length - 1;
    let insertionPoint = -1;
    while (lo <= hi) {
      const mid = (lo + hi) >>> 1;
      if (entries[mid].prefixHex <= queryHex) {
        insertionPoint = mid;
        lo = mid + 1;
      } else {
        hi = mid - 1;
      }
    }

    const matched = [];
    const unmatchedAs = [];
    // Scan backwards from insertion point
    for (let i = insertionPoint; i >= 0 && i > insertionPoint - 500; i--) {
      const e = entries[i];
      // Check if query prefix is covered by this ROA entry
      // A covering ROA has a shorter or equal prefix length and its network prefix matches
      if (e.prefixLen <= prefixLen) {
        // Compare the first prefixLen bits (in hex chars: prefixLen/4 chars, rounded up)
        const hexChars = Math.ceil(e.prefixLen / 4);
        if (queryHex.substring(0, hexChars) === e.prefixHex.substring(0, hexChars)) {
          if (prefixLen <= e.maxLen) {
            if (e.asn === asn) matched.push(e);
            else unmatchedAs.push(e);
          } else if (e.asn === asn) {
            unmatchedAs.push(e);
          }
        }
      }
      // Stop if we're too far away
      if (queryHex.substring(0, 4) !== e.prefixHex.substring(0, 4)) break;
    }

    if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
    if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
    return { prefix, status: "not_found", validating_roas: 0 };
  },

  // Persist to disk for fast restart
  saveToDisk(filePath) {
    try {
      const data = JSON.stringify({
        ts: this.lastBuild,
        v4Count: this.v4Entries.length,
        v6Count: this.v6Entries.length,
        v4: this.v4Entries,
        v6: this.v6Entries,
      });
      fs.writeFileSync(filePath, data);
      console.log("[ROA] Saved " + this.count + " ROAs to disk");
    } catch (e) {
      console.warn("[ROA] Disk save failed:", e.message);
    }
  },

  // Load from disk cache (returns true if loaded)
  loadFromDisk(filePath) {
    try {
      if (!fs.existsSync(filePath)) return false;
      const raw = fs.readFileSync(filePath, "utf8");
      const data = JSON.parse(raw);
      // Only use if less than 6 hours old
      if (Date.now() - data.ts > 6 * 60 * 60 * 1000) return false;
      this.v4Entries = data.v4;
      this.v6Entries = data.v6;
      this.count = data.v4Count + data.v6Count;
      this.lastBuild = data.ts;
      this.ready = true;
      console.log("[ROA] Loaded " + this.count + " ROAs from disk cache");
      return true;
    } catch (e) {
      console.warn("[ROA] Disk load failed:", e.message);
      return false;
    }
  },
};

// ASPA adoption tracking — store last 30 snapshots for trend analysis
const aspaAdoptionHistory = [];

// ============================================================
// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN
// Eliminates redundant PDB API calls under load
// ============================================================
const pdbSourceCache = {
  net: new Map(),       // key: asn string → {data, ts}
  netixlan: new Map(),  // key: net_id string → {data, ts}
  netfac: new Map(),    // key: net_id string → {data, ts}
  facCoords: new Map(), // key: fac_id string → {lat, lon, ts}
  TTL_NET: 6 * 60 * 60 * 1000,       // 6 hours
  TTL_IXFAC: 6 * 60 * 60 * 1000,     // 6 hours
  TTL_COORDS: 7 * 24 * 60 * 60 * 1000, // 7 days
  MAX_NET: 5000,
  MAX_IXFAC: 5000,
  MAX_COORDS: 10000,
  hits: 0,
  misses: 0,

  get(type, key) {
    const map = this[type];
    const ttl = type === "facCoords" ? this.TTL_COORDS : (type === "net" ? this.TTL_NET : this.TTL_IXFAC);
    const entry = map.get(String(key));
    if (!entry) { this.misses++; return null; }
    if (Date.now() - entry.ts > ttl) { map.delete(String(key)); this.misses++; return null; }
    this.hits++;
    return entry.data;
  },

  set(type, key, data) {
    const map = this[type];
    const max = type === "facCoords" ? this.MAX_COORDS : (type === "net" ? this.MAX_NET : this.MAX_IXFAC);
    if (map.size >= max) {
      // Evict oldest entry (Map preserves insertion order)
      map.delete(map.keys().next().value);
    }
    map.set(String(key), { data, ts: Date.now() });
  },

  // Disk persistence
  saveToDisk(filePath) {
    try {
      const serialize = (map) => {
        const obj = {};
        for (const [k, v] of map) obj[k] = v;
        return obj;
      };
      const data = JSON.stringify({
        ts: Date.now(),
        net: serialize(this.net),
        netixlan: serialize(this.netixlan),
        netfac: serialize(this.netfac),
        facCoords: serialize(this.facCoords),
      });
      fs.writeFileSync(filePath, data);
      console.log("[PDB-CACHE] Saved to disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
    } catch (e) {
      console.warn("[PDB-CACHE] Disk save failed:", e.message);
    }
  },

  loadFromDisk(filePath) {
    try {
      if (!fs.existsSync(filePath)) return false;
      const raw = fs.readFileSync(filePath, "utf8");
      const data = JSON.parse(raw);
      const now = Date.now();
      const load = (map, obj, ttl) => {
        for (const [k, v] of Object.entries(obj || {})) {
          if (now - v.ts < ttl) map.set(k, v);
        }
      };
      load(this.net, data.net, this.TTL_NET);
      load(this.netixlan, data.netixlan, this.TTL_IXFAC);
      load(this.netfac, data.netfac, this.TTL_IXFAC);
      load(this.facCoords, data.facCoords, this.TTL_COORDS);
      console.log("[PDB-CACHE] Loaded from disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
      return true;
    } catch (e) {
      console.warn("[PDB-CACHE] Disk load failed:", e.message);
      return false;
    }
  },
};

// ============================================================
// RIPE Stat Source Cache + Semaphore (L2)
// Prevents 429 rate-limiting by throttling + caching responses
// ============================================================
const ripeStatCache = new Map(); // key: "endpoint:resource" → {data, ts}
const RIPE_STAT_CACHE_MAX = 2000;
const RIPE_STAT_TTL = {
  "announced-prefixes": 15 * 60 * 1000,
  "asn-neighbours": 15 * 60 * 1000,
  "as-overview": 60 * 60 * 1000,
  "rir-stats-country": 24 * 60 * 60 * 1000,
  "visibility": 15 * 60 * 1000,
  "prefix-size-distribution": 60 * 60 * 1000,
  "abuse-contact-finder": 24 * 60 * 60 * 1000,
  "blocklist": 60 * 60 * 1000,
  "reverse-dns-consistency": 60 * 60 * 1000,
  "routing-status": 15 * 60 * 1000,
  "bgp-updates": 15 * 60 * 1000,
  "maxmind-geo-lite-pfx": 24 * 60 * 60 * 1000,
  "looking-glass": 15 * 60 * 1000,
  "whois": 24 * 60 * 60 * 1000,
  "rpki-validation": 6 * 60 * 60 * 1000,
};

// Counting semaphore — limits concurrent RIPE Stat requests
class Semaphore {
  constructor(max) { this.max = max; this.current = 0; this.queue = []; }
  acquire() {
    if (this.current < this.max) { this.current++; return Promise.resolve(); }
    return new Promise((resolve) => this.queue.push(resolve));
  }
  release() {
    this.current--;
    if (this.queue.length > 0) { this.current++; this.queue.shift()(); }
  }
}
const ripeStatSemaphore = new Semaphore(15);

// Cached + throttled RIPE Stat fetch
async function fetchRipeStatCached(url, options) {
  // Extract endpoint name from URL for TTL lookup
  const match = url.match(/\/data\/([^/]+)\//);
  const endpoint = match ? match[1] : "default";
  const resourceMatch = url.match(/resource=([^&]+)/);
  const resource = resourceMatch ? resourceMatch[1] : url;
  const cacheKey = endpoint + ":" + resource;

  // Check cache
  const cached = ripeStatCache.get(cacheKey);
  const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
  if (cached && (Date.now() - cached.ts) < ttl) {
    return cached.data;
  }

  // Throttle via semaphore
  await ripeStatSemaphore.acquire();
  try {
    // Double-check cache after acquiring semaphore (another request may have filled it)
    const cached2 = ripeStatCache.get(cacheKey);
    if (cached2 && (Date.now() - cached2.ts) < ttl) {
      return cached2.data;
    }

    const result = await fetchJSON(url, options);

    // Only cache successful results — never cache null (failed/rate-limited responses)
    // Caching null causes cascading failures: retry hits cache, returns null again
    if (result !== null) {
      if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) {
        ripeStatCache.delete(ripeStatCache.keys().next().value);
      }
      ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
    }
    return result;
  } finally {
    ripeStatSemaphore.release();
  }
}

// Cached + throttled RIPE Stat with one retry on failure
async function fetchRipeStatCachedWithRetry(url, options) {
  const result = await fetchRipeStatCached(url, options);
  if (result !== null) return result;
  await new Promise(r => setTimeout(r, 1500));
  return fetchRipeStatCached(url, options);
}

// RIPE Stat cache disk persistence (skip null entries)
function saveRipeStatCacheToDisk(filePath) {
  try {
    const obj = {};
    for (const [k, v] of ripeStatCache) {
      if (v.data !== null) obj[k] = v;
    }
    fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj }));
    console.log("[RIPE-CACHE] Saved " + Object.keys(obj).length + " entries to disk");
  } catch (e) {
    console.warn("[RIPE-CACHE] Disk save failed:", e.message);
  }
}

function loadRipeStatCacheFromDisk(filePath) {
  try {
    if (!fs.existsSync(filePath)) return false;
    const raw = fs.readFileSync(filePath, "utf8");
    const data = JSON.parse(raw);
    const now = Date.now();
    for (const [k, v] of Object.entries(data.entries || {})) {
      const match = k.match(/^([^:]+):/);
      const endpoint = match ? match[1] : "default";
      const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
      if (now - v.ts < ttl) {
        ripeStatCache.set(k, v);
      }
    }
    console.log("[RIPE-CACHE] Loaded " + ripeStatCache.size + " entries from disk");
    return true;
  } catch (e) {
    console.warn("[RIPE-CACHE] Disk load failed:", e.message);
    return false;
  }
}

function fetchRpkiAspaFeed() {
  if (rpkiAspaFetching) return Promise.resolve();
  rpkiAspaFetching = true;
  console.log("[RPKI] Fetching Cloudflare RPKI feed (ASPA + ROA)...");
  return new Promise((resolve) => {
    const options = {
      headers: { "User-Agent": UA },
      timeout: 120000,
    };
    https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => {
      let data = "";
      res.on("data", (chunk) => (data += chunk));
      res.on("end", () => {
        try {
          const parsed = JSON.parse(data);

          // Load ASPA objects
          const aspas = parsed.aspas || [];
          rpkiAspaMap.clear();
          aspas.forEach((a) => {
            const customerAsid = Number(a.customer_asid);
            const providers = (a.providers || []).map(Number);
            rpkiAspaMap.set(customerAsid, new Set(providers));
          });

          // Load ROA objects into local store (eliminates RIPE Stat per-prefix calls)
          const roas = parsed.roas || [];
          roaStore.build(roas);
          roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");

          // Track ASPA adoption
          const adoptionSnapshot = {
            ts: Date.now(),
            aspa_count: rpkiAspaMap.size,
            roa_count: roaStore.count,
          };
          aspaAdoptionHistory.push(adoptionSnapshot);
          if (aspaAdoptionHistory.length > 30) aspaAdoptionHistory.shift();

          rpkiAspaLastFetch = Date.now();
          console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed");
        } catch (e) {
          console.error("[RPKI] Failed to parse RPKI feed:", e.message);
        }
        rpkiAspaFetching = false;
        resolve();
      });
    }).on("error", (e) => {
      console.error("[RPKI] Fetch failed:", e.message);
      rpkiAspaFetching = false;
      resolve();
    });
  });
}

// Ensure ASPA + ROA cache is fresh
async function ensureAspaCache() {
  if (Date.now() - rpkiAspaLastFetch > 4 * 60 * 60 * 1000) {
    await fetchRpkiAspaFeed();
  }
}

// Lookup ASPA object for a given ASN from the RPKI feed cache
function lookupAspaFromRpki(asn) {
  const asnNum = Number(asn);
  if (rpkiAspaMap.has(asnNum)) {
    const providers = rpkiAspaMap.get(asnNum);
    return { exists: true, providers: [...providers].sort((a, b) => a - b) };
  }
  return { exists: false, providers: [] };
}



// PeeringDB semaphore — limits concurrent PDB requests to avoid 429 rate-limits
const pdbSemaphore = new Semaphore(5);

// PeeringDB authenticated fetch helper — tries local SQLite first, falls back to live API
async function fetchPeeringDB(path, options) {
  // Try local SQLite (instant, no rate-limits) — skip large "all networks" calls to live API
  const localResult = queryPeeringDBLocal(path);
  if (localResult !== null) return localResult;

  // Fallback: live PeeringDB API (throttled via semaphore)
  const url = PEERINGDB_API_URL + path;
  const headers = { "User-Agent": UA };
  if (PEERINGDB_API_KEY) {
    headers["Authorization"] = "Api-Key " + PEERINGDB_API_KEY;
  }
  await pdbSemaphore.acquire();
  try {
    return await fetchJSON(url, { ...options, headers: { ...(options && options.headers || {}), ...headers } });
  } finally {
    pdbSemaphore.release();
  }
}

// PeeringDB fetch with exponential backoff retries (handles rate-limits under concurrent load).
// Up to 3 attempts: immediate → 2s → 5s. Returns null only after all attempts exhausted.
async function fetchPeeringDBWithRetry(path, options) {
  const delays = [2000, 5000];
  let result = await fetchPeeringDB(path, options);
  for (let i = 0; i < delays.length && result === null; i++) {
    await new Promise(r => setTimeout(r, delays[i]));
    result = await fetchPeeringDB(path, options);
  }
  return result;
}

// Generic JSON fetch with one retry — for sources that occasionally fail under load (RIPE Stat, Atlas)
async function fetchJSONWithRetry(url, options) {
  const result = await fetchJSON(url, options);
  if (result !== null) return result;
  await new Promise(r => setTimeout(r, 1000));
  return fetchJSON(url, options);
}

// bgproutes.io visibility fallback helper
// Queries the RIB endpoint to estimate prefix visibility across vantage points
function fetchBgproutesVisibility(prefix) {
  if (!BGPROUTES_API_KEY) return Promise.resolve(null);
  const url = BGPROUTES_API_URL + "/rib?prefix=" + encodeURIComponent(prefix) + "&prefix_match=exact";
  return fetchJSON(url, {
    timeout: 15000,
    headers: {
      "Authorization": "Bearer " + BGPROUTES_API_KEY,
      "User-Agent": UA,
    },
  }).then(function(data) {
    if (!data || !data.data) return null;
    // data.data should be an array of RIB entries from different vantage points
    var entries = Array.isArray(data.data) ? data.data : (data.data.entries || data.data.routes || []);
    var vpSet = new Set();
    entries.forEach(function(e) {
      if (e.vantage_point || e.vp || e.collector || e.peer_asn) {
        vpSet.add(e.vantage_point || e.vp || e.collector || e.peer_asn);
      }
    });
    return { vps_seeing: vpSet.size, total_entries: entries.length, source: "bgproutes.io" };
  }).catch(function() { return null; });
}

// Rate limiting: max 60 requests per minute per IP
const rateLimitMap = new Map();
const RATE_LIMIT_WINDOW = 60 * 1000;
const RATE_LIMIT_MAX = 60;

function checkRateLimit(ip) {
  const now = Date.now();
  let entry = rateLimitMap.get(ip);
  if (!entry || now > entry.windowStart + RATE_LIMIT_WINDOW) {
    entry = { windowStart: now, count: 0 };
    rateLimitMap.set(ip, entry);
  }
  entry.count++;
  // Clean old entries periodically
  if (rateLimitMap.size > 1000) {
    for (const [k, v] of rateLimitMap) {
      if (now > v.windowStart + RATE_LIMIT_WINDOW) rateLimitMap.delete(k);
    }
  }
  return entry.count <= RATE_LIMIT_MAX;
}

function fetchJSON(url, options) {
  const timeoutMs = (options && options.timeout) || 8000;
  return new Promise((resolve) => {
    const reqOptions = {
      headers: { "User-Agent": UA, ...(options && options.headers ? options.headers : {}) },
      timeout: timeoutMs,
    };
    const timer = setTimeout(() => resolve(null), timeoutMs + 500);
    https
      .get(url, reqOptions, (res) => {
        let data = "";
        res.on("data", (chunk) => (data += chunk));
        res.on("end", () => {
          clearTimeout(timer);
          if (res.statusCode === 429) {
            console.warn("[PDB] Rate limited (429):", url.substring(0, 80));
            return resolve(null);
          }
          try {
            resolve(JSON.parse(data));
          } catch (_e) {
            resolve(null);
          }
        });
      })
      .on("timeout", () => { clearTimeout(timer); resolve(null); })
      .on("error", () => { clearTimeout(timer); resolve(null); });
  });
}


function fetchHTML(url, options) {
  return new Promise((resolve) => {
    const reqOptions = {
      headers: {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        ...(options && options.headers ? options.headers : {}),
      },
    };
    const lib = url.startsWith("https") ? require("https") : require("http");
    lib
      .get(url, reqOptions, (res) => {
        if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
          return fetchHTML(res.headers.location, options).then(resolve);
        }
        let data = "";
        res.on("data", (chunk) => (data += chunk));
        res.on("end", () => resolve(data));
      })
      .on("error", () => resolve(null));
  });
}

function postJSON(url, body, options) {
  return new Promise((resolve) => {
    const data = JSON.stringify(body);
    const parsed = new URL(url);
    const timeout = (options && options.timeout) || 10000;
    const reqOptions = {
      hostname: parsed.hostname,
      port: parsed.port || 443,
      path: parsed.pathname + parsed.search,
      method: "POST",
      headers: {
        "User-Agent": UA,
        "Content-Type": "application/json",
        "Content-Length": Buffer.byteLength(data),
        ...(options && options.headers ? options.headers : {}),
      },
    };
    let done = false;
    const timer = setTimeout(() => { if (!done) { done = true; req.destroy(); resolve(null); } }, timeout);
    const req = https.request(reqOptions, (res) => {
      let chunks = "";
      res.on("data", (chunk) => (chunks += chunk));
      res.on("end", () => {
        if (done) return;
        done = true;
        clearTimeout(timer);
        try {
          resolve(JSON.parse(chunks));
        } catch (_e) {
          resolve(null);
        }
      });
    });
    req.on("error", () => { if (!done) { done = true; clearTimeout(timer); resolve(null); } });
    req.write(data);
    req.end();
  });
}

async function resolveASNames(providers) {
  // Batch resolve AS names via RIPE Stat AS overview API
  const batchSize = 10;
  for (let i = 0; i < providers.length; i += batchSize) {
    const batch = providers.slice(i, i + batchSize);
    const results = await Promise.all(
      batch.map(p =>
        fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn)
          .then(r => ({ asn: p.asn, name: r?.data?.holder || "" }))
          .catch(() => ({ asn: p.asn, name: "" }))
      )
    );
    results.forEach(r => {
      const provider = providers.find(p => p.asn === r.asn);
      if (provider && r.name) provider.name = r.name;
    });
  }
  return providers;
}

// ── MASTER RIPE Stat API wrapper (Local-first, zero external API calls) ──
// Analyzes RIPE Stat URL and dispatches to appropriate localDb function
async function fetchRipeStatCached(url, options = {}) {
  try {
    // Detect which RIPE Stat endpoint this is and call local DB
    if (url.includes('/announced-prefixes/')) {
      const asnMatch = url.match(/resource=AS(\d+)/);
      if (asnMatch) return await localDb.getRipeStatAnnouncedPrefixes(parseInt(asnMatch[1]));
    }
    if (url.includes('/asn-neighbours/')) {
      const asnMatch = url.match(/resource=AS(\d+)/);
      if (asnMatch) return await localDb.getRipeStatAsnNeighbours(parseInt(asnMatch[1]));
    }
    if (url.includes('/as-overview/')) {
      const asnMatch = url.match(/resource=AS(\d+)/);
      if (asnMatch) return await localDb.getRipeStatAsOverview(parseInt(asnMatch[1]));
    }
    if (url.includes('/visibility/')) {
      const asnMatch = url.match(/resource=AS(\d+)/);
      if (asnMatch) return await localDb.getRipeStatVisibility(parseInt(asnMatch[1]));
    }
    if (url.includes('/prefix-size-distribution/')) {
      const asnMatch = url.match(/resource=AS(\d+)/);
      if (asnMatch) return await localDb.getRipeStatPrefixSizeDistribution(parseInt(asnMatch[1]));
    }

    // For other RIPE Stat endpoints (not in localDb): return empty/null gracefully
    // Examples: rir-stats-country, bgp-updates, reverse-dns-consistency, routing-status, maxmind-geo-lite, etc.
    return Promise.resolve(null);
  } catch (e) {
    console.error("[fetchRipeStatCached] Error:", e.message);
    return Promise.resolve(null);
  }
}

// RPKI per-prefix validation — uses local ROA store (instant, no API calls)
// Falls back to RIPE Stat only if ROA store is not yet loaded (cold start)
// Validate RPKI for a prefix — uses local PostgreSQL database (sub-10ms, zero external API calls)
// Returns: { prefix, status: "valid"|"invalid"|"not_found", validating_roas: N }
async function validateRPKIWithCache(asn, prefix) {
  try {
    // Query local database (sub-10ms, no external API calls)
    const result = await localDb.validateRpki(prefix, asn);

    // Adapt response to match expected format
    if (result.status === 'valid') {
      return { prefix, status: "valid", validating_roas: 1 };
    } else if (result.status === 'invalid') {
      return { prefix, status: "invalid", validating_roas: 1 };
    } else {
      // 'not-found' or 'unknown'
      return { prefix, status: "not_found", validating_roas: 0 };
    }
  } catch (_e) {
    console.error("[RPKI] Error validating " + prefix + ":", _e.message);
    return { prefix, status: "not_found", validating_roas: 0 };
  }
}


// ============================================================
// RFC-Compliant ASPA Verification Engine
// ============================================================

// Check if AS path contains AS_SET segments (curly braces indicate sets)
function hasAsSet(asPath) {
  if (typeof asPath === "string") {
    return asPath.includes("{") || asPath.includes("}");
  }
  return false;
}

// Hop Check function (core of ASPA verification)
// aspaStore = Map<number, Set<number>> (CAS -> provider set)
function hopCheck(asI, asJ, aspaStore) {
  const providers = aspaStore.get(asI);
  if (!providers) return "NoAttestation";
  return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus";
}

// Collapse AS path prepends (remove consecutive duplicates)
function collapsePrepends(path) {
  return path.filter((as, i) => i === 0 || as !== path[i - 1]);
}

// Upstream Verification (RFC Section 6.1)
function verifyUpstream(asPath, aspaStore, rawPathStr) {
  if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
  const collapsed = collapsePrepends(asPath);
  if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" };

  const hops = [];
  let hasNoAttestation = false;

  for (let i = 1; i < collapsed.length; i++) {
    const check = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
    hops.push({
      from: collapsed[i - 1],
      to: collapsed[i],
      result: check,
    });
    if (check === "NotProviderPlus") {
      return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops };
    }
    if (check === "NoAttestation") hasNoAttestation = true;
  }

  return {
    result: hasNoAttestation ? "Unknown" : "Valid",
    reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus",
    hops,
  };
}

// Downstream Verification (RFC Section 6.2)
function verifyDownstream(asPath, aspaStore, rawPathStr) {
  if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
  const collapsed = collapsePrepends(asPath);
  const N = collapsed.length;
  if (N <= 2) return { result: "Valid", reason: "Path length <= 2" };

  const hops = [];
  for (let i = 1; i < N; i++) {
    hops.push({
      from: collapsed[i - 1],
      to: collapsed[i],
      result: hopCheck(collapsed[i - 1], collapsed[i], aspaStore),
    });
  }

  // Find u_min: first index where forward hop is NotProviderPlus
  let uMin = N + 1;
  for (let u = 1; u < N; u++) {
    if (hopCheck(collapsed[u - 1], collapsed[u], aspaStore) === "NotProviderPlus") {
      uMin = u;
      break;
    }
  }

  // Find v_max: last index where reverse hop is NotProviderPlus
  let vMax = 0;
  for (let v = N - 2; v >= 0; v--) {
    if (hopCheck(collapsed[v + 1], collapsed[v], aspaStore) === "NotProviderPlus") {
      vMax = v;
      break;
    }
  }

  if (uMin <= vMax) {
    return { result: "Invalid", reason: "uMin(" + uMin + ") <= vMax(" + vMax + "): valley detected", hops };
  }

  // Compute up-ramp K
  let K = 0;
  for (let i = 1; i < N; i++) {
    if (hopCheck(collapsed[i - 1], collapsed[i], aspaStore) === "ProviderPlus") {
      K = i;
    } else {
      break;
    }
  }

  // Compute down-ramp L
  let L = N - 1;
  for (let j = N - 2; j >= 0; j--) {
    if (hopCheck(collapsed[j + 1], collapsed[j], aspaStore) === "ProviderPlus") {
      L = j;
    } else {
      break;
    }
  }

  const gap = L - K;
  if (gap <= 1) {
    return { result: "Valid", reason: "Valid up-down path (K=" + K + ", L=" + L + ")", hops };
  }

  return { result: "Unknown", reason: "Gap between up-ramp and down-ramp (K=" + K + ", L=" + L + ", gap=" + gap + ")", hops };
}

// Valley Detection: scan path for up-down-up pattern (route leak indicator)
function detectValleys(asPath, aspaStore) {
  const collapsed = collapsePrepends(asPath);
  if (collapsed.length < 4) return [];

  const valleys = [];
  // Walk the path and look at relationship transitions
  const relationships = [];
  for (let i = 1; i < collapsed.length; i++) {
    const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
    const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
    let rel = "unknown";
    if (fwd === "ProviderPlus") rel = "customer-to-provider";
    else if (rev === "ProviderPlus") rel = "provider-to-customer";
    else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer";
    relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel });
  }

  // Detect c2p -> p2c -> c2p pattern
  for (let i = 0; i < relationships.length - 2; i++) {
    if (
      relationships[i].rel === "customer-to-provider" &&
      relationships[i + 1].rel === "provider-to-customer" &&
      relationships[i + 2].rel === "customer-to-provider"
    ) {
      valleys.push({
        position: i,
        path_segment: [
          relationships[i].from,
          relationships[i].to,
          relationships[i + 1].to,
          relationships[i + 2].to,
        ].map((a) => "AS" + a),
        description:
          "Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to +
          " (c2p) -> AS" + relationships[i + 1].to +
          " (p2c) -> AS" + relationships[i + 2].to + " (c2p)",
      });
    }
  }

  return valleys;
}

// Build ASPA store from detected provider relationships
function buildAspaStore(detectedProviders, targetAsn) {
  const store = new Map();
  // Add the target ASN's providers
  if (detectedProviders.length > 0) {
    const providerSet = new Set(detectedProviders.map((p) => p.asn));
    store.set(targetAsn, providerSet);
  }
  return store;
}

// Calculate ASPA Readiness Score (0-100)
function calculateAspaReadinessScore(params) {
  const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params;

  // ROA coverage (0-25 points)
  const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25);

  // ASPA object exists (0-25 points)
  const aspaScore = aspaObjectExists ? 25 : 0;

  // Provider completeness (0-25 points)
  const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25);

  // Path validation results (0-25 points)
  const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25);

  return {
    total: roaScore + aspaScore + provScore + pathScore,
    breakdown: {
      roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage },
      aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists },
      provider_completeness: { score: provScore, max: 25, value: providerCompleteness },
      path_validation: { score: pathScore, max: 25, value: pathValidationPct },
    },
  };
}


// ============================================================
// Feature 30: RIPE NCC RPKI Validator cross-check (max 5 prefixes)
// ============================================================
async function fetchRipeRpkiValidator(asn, prefix) {
  try {
    const encoded = encodeURIComponent(prefix);
    const url = "https://rpki-validator.ripe.net/api/v1/validity/AS" + asn + "/" + encoded;
    const result = await fetchJSON(url, { timeout: 5000 });
    if (result && result.validated_route) {
      return {
        prefix: prefix,
        validity: result.validated_route.validity || {},
        state: (result.validated_route.validity && result.validated_route.validity.state) || "unknown",
      };
    }
    return { prefix: prefix, state: "unknown", error: "no_data" };
  } catch (_e) {
    return { prefix: prefix, state: "error", error: "timeout_or_unavailable" };
  }
}

// Cross-check a sample of prefixes against RIPE RPKI Validator (max 5, in parallel)
async function crossCheckRpki(asn, prefixes, localResults) {
  const sample = prefixes.slice(0, 5);
  if (sample.length === 0) return { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: 100, disagreements: [], sample_size: 0 };

  const ripeResults = await Promise.all(
    sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx))
  );

  const localMap = new Map();
  for (const lr of localResults) {
    localMap.set(lr.prefix, lr.status);
  }

  let cloudflareValid = 0;
  let ripeValid = 0;
  let agreements = 0;
  const disagreements = [];

  for (let i = 0; i < sample.length; i++) {
    const pfx = sample[i];
    const cfStatus = localMap.get(pfx) || "not_found";
    const ripeState = ripeResults[i].state;

    const cfIsValid = cfStatus === "valid";
    const ripeIsValid = ripeState === "valid" || ripeState === "VALID";

    if (cfIsValid) cloudflareValid++;
    if (ripeIsValid) ripeValid++;

    // Skip comparison if RIPE returned error/unknown
    if (ripeState === "error" || ripeState === "unknown") {
      agreements++; // Don't count failed lookups as disagreements
      continue;
    }

    if (cfIsValid === ripeIsValid) {
      agreements++;
    } else {
      disagreements.push({
        prefix: pfx,
        cloudflare: cfStatus,
        ripe: ripeState,
      });
    }
  }

  const agreementPct = sample.length > 0 ? Math.round((agreements / sample.length) * 100) : 100;
  return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length };
}

// ============================================================
// Feature 24: bgp.he.net Integration
// ============================================================
async function fetchBgpHeNet(asn) {
  try {
    const html = await fetchHTML("https://bgp.he.net/AS" + asn);
    if (!html) return null;
    const result = {};
    const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
    if (titleMatch) result.title = titleMatch[1].trim();
    const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i);
    if (peerMatch) result.peer_count = parseInt(peerMatch[1].replace(/,/g, ''));
    const countryMatch = html.match(/Country[^<]*<[^>]*>[^<]*<[^>]*>\s*<[^>]*>([^<]+)/i);
    if (countryMatch) result.country = countryMatch[1].trim();
    // Extract 2-letter country code from href="/country/XX"
    const ccMatch = html.match(/href="\/country\/([A-Z]{2})"/i);
    if (ccMatch) result.country_code = ccMatch[1].toUpperCase();
    // Extract clean AS name from title: "AS12345 Some Name - bgp.he.net" → "Some Name"
    if (titleMatch) {
      const rawTitle = titleMatch[1].trim();
      const nameFromTitle = rawTitle.replace(/^AS\d+\s+/i, '').replace(/\s+-\s+bgp\.he\.net.*$/i, '').trim();
      if (nameFromTitle && !nameFromTitle.toLowerCase().includes('bgp.he.net')) {
        result.name_from_title = nameFromTitle;
      }
    }

    const lgMatch = html.match(/Looking\s+Glass[^<]*<[^>]*href="([^"]+)"/i);
    if (lgMatch) result.looking_glass = lgMatch[1];
    const descMatch = html.match(/AS\s+Name[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
    if (descMatch) result.description = descMatch[1].trim();
    const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
    if (irrMatch) result.irr_record = irrMatch[1].trim();
    // bgp.he.net format: "Prefixes Originated (v4): 147<br/>" or "Prefixes v4 ... <td>147"
    const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i);
    if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, ''));
    const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i);
    if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, ''));
    const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i);
    if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, ''));
    result.source_url = "https://bgp.he.net/AS" + asn;
    return result;
  } catch (_e) {
    return null;
  }
}

// ============================================================
// Feature 25: Topology / AS-Relationships
// ============================================================
async function fetchTopology(targetAsn, depth) {
  const maxDepth = Math.min(depth || 2, 3);
  const nodes = new Map();
  const edges = [];
  async function fetchNeighboursForAsn(asn, currentDepth) {
    if (nodes.has(asn) && nodes.get(asn).depth <= currentDepth) return;
    const [data, overview] = await Promise.all([
      fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn),
      fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
    ]);
    const name = overview?.data?.holder || "";
    const neighbours = data?.data?.neighbours || [];
    const upstreams = neighbours.filter((n) => n.type === "left");
    const downstreams = neighbours.filter((n) => n.type === "right");
    const peers = neighbours.filter((n) => n.type === "uncertain" || n.type === "peer");
    const nodeType = asn === targetAsn ? "target" : currentDepth === 1 ? "direct" : "indirect";
    nodes.set(asn, { asn, name, type: nodeType, depth: currentDepth });
    upstreams.forEach((n) => {
      if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "upstream", depth: currentDepth + 1 });
      edges.push({ from: n.asn, to: asn, relationship: "provider-to-customer" });
    });
    downstreams.forEach((n) => {
      if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "downstream", depth: currentDepth + 1 });
      edges.push({ from: asn, to: n.asn, relationship: "provider-to-customer" });
    });
    peers.slice(0, 10).forEach((n) => {
      if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "peer", depth: currentDepth + 1 });
      edges.push({ from: asn, to: n.asn, relationship: "peer" });
    });
    if (currentDepth < maxDepth && upstreams.length > 0) {
      const top5 = upstreams.sort((a, b) => (b.power || 0) - (a.power || 0)).slice(0, 5);
      await Promise.all(top5.map((u) => fetchNeighboursForAsn(u.asn, currentDepth + 1)));
    }
  }
  await fetchNeighboursForAsn(targetAsn, 0);
  const edgeSet = new Set();
  const uniqueEdges = edges.filter((e) => {
    const key = e.from + "-" + e.to + "-" + e.relationship;
    if (edgeSet.has(key)) return false;
    edgeSet.add(key);
    return true;
  });
  return { nodes: [...nodes.values()], edges: uniqueEdges, target_asn: targetAsn, depth: maxDepth };
}

// ============================================================
// Feature 27: WHOIS via RIPE DB
// ============================================================

// ── Resilience Score ─────────────────────────────────────────────────────────
// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25%
// Hard cap at 5.0 when single transit provider detected.
// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
function computeResilienceScore(upstreams, peers, ixConnections, prefixes) {
  const upstreamCount = upstreams.length;
  const peerCount     = peers.length;
  const ixCount       = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length;
  const prefixCount   = prefixes.length;

  // Transit Diversity (0-10)
  let transitRaw = 0;
  if (upstreamCount === 0) transitRaw = 0;
  else if (upstreamCount === 1) transitRaw = 2;
  else if (upstreamCount === 2) transitRaw = 5;
  else if (upstreamCount <= 4) transitRaw = 7;
  else transitRaw = 10;

  // Peering Breadth (0-10)
  let peeringRaw = 0;
  if (peerCount >= 100) peeringRaw = 10;
  else if (peerCount >= 50)  peeringRaw = 8;
  else if (peerCount >= 20)  peeringRaw = 6;
  else if (peerCount >= 5)   peeringRaw = 4;
  else if (peerCount >= 1)   peeringRaw = 2;

  // IXP Presence (0-10)
  let ixpRaw = 0;
  if (ixCount >= 10) ixpRaw = 10;
  else if (ixCount >= 6) ixpRaw = 8;
  else if (ixCount >= 3) ixpRaw = 6;
  else if (ixCount >= 1) ixpRaw = 4;

  // Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination
  let pathRaw = 0;
  if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10;
  else if (upstreamCount >= 2)            pathRaw = 7;
  else if (ixCount >= 2)                  pathRaw = 6;
  else if (upstreamCount === 1)           pathRaw = 3;
  else if (prefixCount > 0)              pathRaw = 1;

  const weighted =
    transitRaw * 0.30 +
    peeringRaw * 0.25 +
    ixpRaw     * 0.20 +
    pathRaw    * 0.25;

  const singleTransitCap = upstreamCount === 1;
  let score = Math.round(weighted * 10) / 10;
  if (singleTransitCap) score = Math.min(score, 5.0);
  score = Math.max(1.0, Math.min(10.0, score));

  // Only return null if truly no data at all
  if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) {
    return null;
  }

  return {
    score,
    breakdown: {
      transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount },
      peering_breadth:   { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount },
      ixp_presence:      { raw: ixpRaw,     weighted: Math.round(ixpRaw     * 0.20 * 10) / 10, unique_ixps: ixCount },
      path_redundancy:   { raw: pathRaw,    weighted: Math.round(pathRaw    * 0.25 * 10) / 10, prefix_count: prefixCount },
    },
    single_transit_cap_applied: singleTransitCap,
    _provenance: {
      source:     "RIPE Stat asn-neighbours + PeeringDB netixlan",
      validation: "cross-validated",
      confidence: "high",
      note:       "All inputs independently validated daily against external sources",
    },
  };
}

// ── Route Leak Detection ─────────────────────────────────────────────────────
// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data.
// NOT real-time. False positives possible for large networks with many Tier-1 relationships.
// Confidence: MEDIUM — pattern-based, not path-level analysis.
function computeRouteLeakDetection(upstreams, downstreams, peers) {
  const upstreamAsns   = new Set(upstreams.map(n => n.asn));
  const downstreamAsns = new Set(downstreams.map(n => n.asn));

  const tier1Upstreams   = upstreams.filter(n => TIER1_ASNS.has(n.asn));
  const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn));

  const patterns = [];

  // Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate
  const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn));
  sandwich.forEach(n => {
    patterns.push({
      type:        "sandwich_candidate",
      asn:         n.asn,
      name:        n.name,
      description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`,
    });
  });

  // Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s)
  tier1Downstreams.forEach(n => {
    if (!upstreamAsns.has(n.asn)) {
      patterns.push({
        type:        "tier1_downstream",
        asn:         n.asn,
        name:        n.name,
        description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`,
      });
    }
  });

  const detected = patterns.length > 0;

  return {
    detected,
    patterns,
    tier1_upstream_count:   tier1Upstreams.length,
    tier1_downstream_count: tier1Downstreams.length,
    _provenance: {
      source:     "RIPE Stat asn-neighbours",
      validation: "heuristic",
      confidence: "medium",
      note:       "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.",
    },
  };
}

async function fetchWhois(resource) {
  const result = { resource, type: null, data: null, error: null };
  try {
    const trimmed = resource.trim();
    if (/^(AS)?\d+$/i.test(trimmed)) {
      result.type = "aut-num";
      const asn = trimmed.replace(/^AS/i, "");

      // Check cache first
      const cached = whoisCacheGet(asn);
      if (cached !== undefined) {
        result.data = cached;
        if (!cached) result.error = "Not found in any RIR database (cached)";
        return result;
      }

      // Try RIPE first
      const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null);
      if (ripeData && ripeData.objects && ripeData.objects.object) {
        const obj = ripeData.objects.object[0];
        const attrs = obj.attributes?.attribute || [];
        const parsed = {};
        attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
        result.data = {
          aut_num: (parsed["aut-num"] || [])[0] || "",
          as_name: (parsed["as-name"] || [])[0] || "",
          descr: parsed["descr"] || [],
          org: (parsed["org"] || [])[0] || "",
          admin_c: parsed["admin-c"] || [],
          tech_c: parsed["tech-c"] || [],
          mnt_by: parsed["mnt-by"] || [],
          status: (parsed["status"] || [])[0] || "",
          created: (parsed["created"] || [])[0] || "",
          last_modified: (parsed["last-modified"] || [])[0] || "",
          source: (parsed["source"] || [])[0] || "",
          import: parsed["import"] || [],
          export: parsed["export"] || [],
          remarks: parsed["remarks"] || [],
        };
        whoisCacheSet(asn, result.data);
      }

      // If RIPE didn't find it, try all other RIRs via RDAP in parallel (3s timeout)
      if (!result.data) {
        const rdapEndpoints = [
          { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn },
          { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn },
          { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn },
          { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn },
        ];
        const rdapResults = await Promise.all(rdapEndpoints.map((ep) =>
          fetchJSON(ep.url, { timeout: 3000 }).then((d) => {
            if (!d || d.errorCode || !d.handle) return null;
            return { source: ep.name, data: d };
          }).catch(() => null)
        ));
        const found = rdapResults.find((r) => r !== null);
        if (found) {
          const d = found.data;
          const remarks = (d.remarks || []).map((r) => (r.description || []).join(" "));
          const entities = d.entities || [];
          const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || "");
          const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || "");
          const events = d.events || [];
          const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || "";
          const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || "";
          result.data = {
            aut_num: "AS" + asn,
            as_name: d.name || "",
            descr: remarks,
            org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "",
            admin_c: adminContacts,
            tech_c: techContacts,
            mnt_by: [],
            status: (d.status || []).join(", "),
            created: created,
            last_modified: lastMod,
            source: found.source + " (RDAP)",
            import: [],
            export: [],
            remarks: remarks,
          };
          whoisCacheSet(asn, result.data);
        } else {
          result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
          whoisCacheSet(asn, null); // cache miss to avoid repeated hammering
        }
      }
    } else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
      result.type = "inetnum";
      const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num");
      if (ripeData && ripeData.objects && ripeData.objects.object) {
        const results = ripeData.objects.object.map((obj) => {
          const attrs = obj.attributes?.attribute || [];
          const parsed = {};
          attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
          return {
            inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "",
            netname: (parsed["netname"] || [])[0] || "",
            descr: parsed["descr"] || [],
            country: (parsed["country"] || [])[0] || "",
            org: (parsed["org"] || [])[0] || "",
            admin_c: parsed["admin-c"] || [],
            tech_c: parsed["tech-c"] || [],
            mnt_by: parsed["mnt-by"] || [],
            status: (parsed["status"] || [])[0] || "",
            created: (parsed["created"] || [])[0] || "",
            last_modified: (parsed["last-modified"] || [])[0] || "",
            source: (parsed["source"] || [])[0] || "",
          };
        });
        result.data = results.length === 1 ? results[0] : results;
      } else { result.error = "Not found in RIPE DB"; }
    } else {
      result.type = "domain";
      const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain");
      if (ripeData && ripeData.objects && ripeData.objects.object) {
        const obj = ripeData.objects.object[0];
        const attrs = obj.attributes?.attribute || [];
        const parsed = {};
        attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
        result.data = {
          domain: (parsed["domain"] || [])[0] || "",
          descr: parsed["descr"] || [],
          admin_c: parsed["admin-c"] || [],
          tech_c: parsed["tech-c"] || [],
          zone_c: parsed["zone-c"] || [],
          nserver: parsed["nserver"] || [],
          mnt_by: parsed["mnt-by"] || [],
          created: (parsed["created"] || [])[0] || "",
          last_modified: (parsed["last-modified"] || [])[0] || "",
          source: (parsed["source"] || [])[0] || "",
        };
      } else { result.error = "Not found in RIPE DB"; }
    }
  } catch (err) { result.error = err.message; }
  return result;
}

// ============================================================
// HTTP Server
// ============================================================

const server = http.createServer(async (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");

  if (req.method === "OPTIONS") {
    res.writeHead(204);
    return res.end();
  }

  let url, reqPath;
  try {
    url = new URL(req.url, "http://localhost");
    reqPath = url.pathname;
  } catch (_urlErr) {
    res.writeHead(400);
    return res.end('Bad Request');
  }

  // Serve static files — host-based routing
  const host = (req.headers.host || '').split(':')[0];

  if (reqPath === "/" || reqPath === "/index.html") {
    // shell.peercortex.org → admin feedback terminal (check first)
    if (host === 'shell.peercortex.org') {
      try {
        const html = fs.readFileSync('/opt/peercortex-app/public/shell.html', 'utf8');
        res.setHeader('Content-Type', 'text/html; charset=utf-8');
        return res.end(html);
      } catch (_e) {
        res.writeHead(500);
        return res.end('shell.html not found');
      }
    }
    // v2.peercortex.org → redirect to main domain
    if (host === 'v2.peercortex.org') {
      res.writeHead(301, { Location: 'https://peercortex.org' + reqPath });
      return res.end();
    }
    const htmlFile = "index.html";
    try {
      const html = fs.readFileSync("/opt/peercortex-app/public/" + htmlFile, "utf8");
      res.setHeader("Content-Type", "text/html; charset=utf-8");
      return res.end(html);
    } catch (_e) {
      res.writeHead(500);
      return res.end(htmlFile + " not found");
    }
  }

  // Direct access to editorial version
  if (reqPath === "/index-editorial.html") {
    try {
      const html = fs.readFileSync("/opt/peercortex-app/public/index-editorial.html", "utf8");
      res.setHeader("Content-Type", "text/html; charset=utf-8");
      return res.end(html);
    } catch (_e) {
      res.writeHead(500);
      return res.end("index-editorial.html not found");
    }
  }

  // ============================================================
  // Feedback API
  // ============================================================


  // ── Name Search (RIPE Stat + PeeringDB combined) ─────────────
  if (reqPath === '/api/search') {
    const params = new URL(req.url, 'http://localhost').searchParams;
    const q = (params.get('q') || '').trim();
    res.setHeader('Content-Type', 'application/json');
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Cache-Control', 'public, max-age=120');
    if (!q || q.length < 2) { res.writeHead(400); return res.end(JSON.stringify({error:'query too short'})); }
    try {
      const results = [];
      const seen = new Set();

      // Source 1: RIPE Stat searchcomplete (fast, covers ASNs + org names)
      try {
        const ripeUrl = 'https://stat.ripe.net/data/searchcomplete/data.json?resource=' + encodeURIComponent(q);
        const ripeData = await fetchJSONWithRetry(ripeUrl, { timeout: 6000 });
        const cats = ripeData && ripeData.data && ripeData.data.categories || [];
        for (var ci = 0; ci < cats.length; ci++) {
          var suggs = cats[ci].suggestions || [];
          for (var si = 0; si < suggs.length; si++) {
            var s = suggs[si];
            var val = (s.value || '').toString();
            // Only ASN results
            if (/^AS\d+$/i.test(val) && !seen.has(val)) {
              seen.add(val);
              // Use description (e.g. "FLEXOPTIX, DE") as the display label
              var ripeName = s.description || s.label || val;
              results.push({ asn: val.replace(/^AS/i,''), label: ripeName, description: '', source: 'RIPE Stat' });
            }
          }
        }
      } catch(e) { /* RIPE Stat failed, continue */ }

      // Source 2: PeeringDB name search (best for network operator names)
      try {
        var pdbUrl = 'https://www.peeringdb.com/api/net?name__icontains=' + encodeURIComponent(q) + '&depth=1&limit=10';
        if (PEERINGDB_API_KEY) pdbUrl += '&key=' + PEERINGDB_API_KEY;
        const pdbData = await fetchJSONWithRetry(pdbUrl, { timeout: 8000 });
        var nets = pdbData && pdbData.data || [];
        for (var ni = 0; ni < nets.length; ni++) {
          var net = nets[ni];
          var asnKey = 'AS' + net.asn;
          if (net.asn && !seen.has(asnKey)) {
            seen.add(asnKey);
            var pdbDesc = [net.info_type, net.country].filter(Boolean).join(' · ');
            results.push({
              asn: String(net.asn),
              label: net.name || asnKey,
              description: pdbDesc,
              source: 'PeeringDB'
            });
          }
        }
      } catch(e) { /* PeeringDB failed, continue */ }

      // Sort: RIPE results first (usually more relevant for ASN lookup), then PeeringDB
      results.sort((a, b) => {
        if (a.source === b.source) return 0;
        return a.source === 'RIPE Stat' ? -1 : 1;
      });

      res.writeHead(200);
      return res.end(JSON.stringify({ q: q, results: results.slice(0, 12) }));
    } catch(e) {
      res.writeHead(500); return res.end(JSON.stringify({error: e.message}));
    }
  }

  // GET /api/visitors — unique visitor count
  if (reqPath === "/api/visitors" && req.method === "GET") {
    res.setHeader("Content-Type", "application/json");
    res.setHeader("Access-Control-Allow-Origin", "*");
    res.setHeader("Cache-Control", "no-store");
    const count = trackVisitor(req);
    res.writeHead(200);
    return res.end(JSON.stringify({ visitors: count }));
  }

  // OPTIONS preflight (CORS)
  if (reqPath === '/api/feedback' && req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
    res.writeHead(204);
    return res.end();
  }

  // POST /api/feedback — submit feedback entry
  if (reqPath === '/api/feedback' && req.method === 'POST') {
    res.setHeader('Content-Type', 'application/json');
    res.setHeader('Access-Control-Allow-Origin', '*');
    let body = '';
    req.on('data', function(chunk) { body += chunk; });
    req.on('end', function() {
      try {
        const data = JSON.parse(body);
        if (!data.message || String(data.message).trim().length < 3) {
          res.writeHead(400);
          return res.end(JSON.stringify({ ok: false, error: 'Message too short' }));
        }
        const entry = {
          id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
          timestamp: new Date().toISOString(),
          category: String(data.category || 'General').slice(0, 50),
          message: String(data.message || '').slice(0, 2000),
          name: String(data.name || 'Anonymous').slice(0, 100),
          asn: data.asn ? String(data.asn).slice(0, 20) : null,
          ip: req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || null,
          ua: String(req.headers['user-agent'] || '').slice(0, 200)
        };
        let entries = [];
        try { entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); } catch (_e) { /* no file yet */ }
        entries.push(entry);
        fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2));
        // Send email async — don't block response
        sendFeedbackMail(entry).catch(e => console.error('[MAIL] Failed:', e.message));
        return res.end(JSON.stringify({ ok: true, id: entry.id }));
      } catch (_e) {
        res.writeHead(500);
        return res.end(JSON.stringify({ ok: false, error: 'Server error' }));
      }
    });
    return;
  }

  // GET /api/feedback?token=... — admin: fetch all entries as JSON
  if (reqPath === '/api/feedback' && req.method === 'GET') {
    res.setHeader('Content-Type', 'application/json');
    res.setHeader('Access-Control-Allow-Origin', '*');
    const token = url.searchParams.get('token');
    if (!token || token !== FEEDBACK_TOKEN) {
      res.writeHead(401);
      return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
    }
    try {
      const entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8'));
      return res.end(JSON.stringify({ ok: true, entries: entries, count: entries.length }));
    } catch (_e) {
      return res.end(JSON.stringify({ ok: true, entries: [], count: 0 }));
    }
  }

  // Serve favicon
  if (reqPath === "/favicon.ico") {
    res.writeHead(204);
    return res.end();
  }

  // Lia's Atlas Paradise - Easter egg page
  if (reqPath === "/lia" || reqPath === "/lia/") {
    try {
      const liaHtml = fs.readFileSync(__dirname + "/public/lia.html", "utf8");
      res.setHeader("Content-Type", "text/html; charset=utf-8");
      return res.end(liaHtml);
    } catch (_e) {
      res.writeHead(500);
      return res.end("lia.html not found");
    }
  }


  // ============================================================
  // Lia's Atlas Paradise: Atlas probe coverage endpoint
  // ============================================================
  if (reqPath === "/api/atlas/coverage") {
    res.setHeader("Content-Type", "application/json");
    if (!atlasProbeCache) {
      res.writeHead(503);
      return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
    }
    return res.end(JSON.stringify(atlasProbeCache, null, 2));
  }

  // ============================================================
  // Lia's Paradise: File parsing endpoint (for binary uploads)
  // ============================================================
  if (reqPath === "/api/lia/parse-file" && req.method === "POST") {
    res.setHeader("Content-Type", "application/json");
    let body = "";
    req.on("data", function(chunk) { body += chunk; });
    req.on("end", function() {
      try {
        var parsed = JSON.parse(body);
        var filename = parsed.filename || "";
        var ext = filename.split(".").pop().toLowerCase();
        // For text-based formats, decode base64 and extract text
        if (ext === "csv" || ext === "txt") {
          var text = Buffer.from(parsed.data, "base64").toString("utf8");
          return res.end(JSON.stringify({ text: text }));
        }
        // For binary formats (PDF, XLS, DOC), we can't parse server-side without
        // heavy dependencies. Return helpful error.
        return res.end(JSON.stringify({
          error: "Binary file parsing (" + ext.toUpperCase() + ") requires client-side extraction. Please use CSV or TXT format, or copy-paste the content.",
          suggestion: "Export your spreadsheet as CSV first, then upload the CSV file."
        }));
      } catch(e) {
        return res.end(JSON.stringify({ error: "Parse error: " + e.message }));
      }
    });
    return;
  }

  // ============================================================
  // Lia's Paradise: Combined PeeringDB + Atlas coverage data
  // ============================================================
  if (reqPath === "/api/lia/coverage") {
    res.setHeader("Content-Type", "application/json");
    if (!atlasProbeCache) {
      res.writeHead(503);
      return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
    }

    // Cache this expensive response for 30 min
    var liaCacheKey = "lia_coverage";
    var liaCached = cacheGet(liaCacheKey);
    if (liaCached) return res.end(liaCached);

    // Fetch PeeringDB network list (all networks with status "ok")
    // Use pre-cached org→country map (loaded at startup, 16MB response cached in memory)
    fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) {
      if (!pdbData || !pdbData.data) {
        return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" }));
      }

      var probeAsns = new Set(atlasProbeCache.asns_with_probes || []);

      var enriched = pdbData.data.map(function(n) {
        var org = pdbOrgCountryMap.get(n.org_id) || {};
        var cc = org.country || "";
        return {
          asn: n.asn,
          name: n.name || "",
          org_name: org.name || "",
          country: cc,
          country_name: cc,
          info_type: n.info_type || "",
          has_probe: probeAsns.has(n.asn),
        };
      }).filter(function(n) { return n.asn > 0 && n.country; });

      var result = JSON.stringify({
        networks: enriched,
        total: enriched.length,
        with_probes: enriched.filter(function(n) { return n.has_probe; }).length,
        without_probes: enriched.filter(function(n) { return !n.has_probe; }).length,
        atlas_unique_asns: probeAsns.size,
        org_countries_loaded: pdbOrgCountryMap.size,
        fetched_at: new Date().toISOString(),
      });

      cacheSet(liaCacheKey, result, 30 * 60 * 1000);
      res.end(result);
    }).catch(function(e) {
      res.end(JSON.stringify({ error: "PeeringDB fetch failed: " + e.message }));
    });
    return;
  }

  res.setHeader("Content-Type", "application/json");

  // Health endpoint — extended with cache status, ASPA metrics, and local DB stats
  if (reqPath === "/api/health") {
    const mem = process.memoryUsage();
    const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1;
    const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses;

    // Query local DB stats (async, but return partial if needed)
    localDb.getLocalDbStats().then(function(dbStats) {
      // Determine health status based on local DB data availability
      const hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally
      const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally
      const status = (hasLocalBgp && hasLocalRpki && aspaAge < 300) ? "ok" : "degraded";

      const healthResponse = {
        status,
        service: "PeerCortex",
        version: "0.6.9",
        timestamp: new Date().toISOString(),
        uptime_seconds: Math.floor(process.uptime()),
        memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
        bgproutes_configured: !!BGPROUTES_API_KEY,
        caches: {
          aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
          pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
          pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
          pdb_netfac: { entries: pdbSourceCache.netfac.size },
          ripe_stat: { entries: ripeStatCache.size },
          response_cache: { entries: responseCache.size },
        },
        local_db: dbStats ? {
          bgp_routes: dbStats.bgp_routes,
          rpki_roas: dbStats.rpki_roas,
          threat_intel: dbStats.threat_intel,
          rdap_cache_entries: dbStats.rdap_cache_entries,
          source: "PostgreSQL (local)",
          healthy: hasLocalBgp && hasLocalRpki,
        } : null,
        aspa_adoption: {
          total_objects: rpkiAspaMap.size,
          roa_count: dbStats ? dbStats.rpki_roas : 0,
          history_samples: aspaAdoptionHistory.length,
          delta_last: aspaAdoptionHistory.length >= 2
            ? aspaAdoptionHistory[aspaAdoptionHistory.length - 1].aspa_count - aspaAdoptionHistory[aspaAdoptionHistory.length - 2].aspa_count
            : 0,
        },
      };
      return res.end(JSON.stringify(healthResponse, null, 2));
    }).catch(function(e) {
      console.error('[/api/health] Local DB stats error:', e.message);
      // Return health without local DB stats on error
      return res.end(
        JSON.stringify({
          status,
          service: "PeerCortex",
          version: "0.6.9",
          timestamp: new Date().toISOString(),
          uptime_seconds: Math.floor(process.uptime()),
          memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
          bgproutes_configured: !!BGPROUTES_API_KEY,
          caches: {
            roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready },
            aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
            pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
            pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
            pdb_netfac: { entries: pdbSourceCache.netfac.size },
            ripe_stat: { entries: ripeStatCache.size },
            response_cache: { entries: responseCache.size },
          },
          local_db: { error: "Could not fetch local DB stats", message: e.message },
          aspa_adoption: {
            total_objects: rpkiAspaMap.size,
            roa_count: roaStore.count,
            history_samples: aspaAdoptionHistory.length,
            delta_last: aspaAdoptionHistory.length >= 2
              ? aspaAdoptionHistory[aspaAdoptionHistory.length - 1].aspa_count - aspaAdoptionHistory[aspaAdoptionHistory.length - 2].aspa_count
              : 0,
          },
        }, null, 2)
      );
    });
    return;
  }

  // ============================================================
  // ASPA Deep Verification endpoint: /api/aspa/verify?asn=X
  // ============================================================
  if (reqPath === "/api/aspa/verify") {
    const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
    if (!rawAsn) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
    }
    const cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn);
    if (cachedVerify !== undefined) {
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(cachedVerify));
    }
    const targetAsn = parseInt(rawAsn);
    const start = Date.now();

    try {
      // Fetch neighbour and prefix data first
      const [neighbourData, prefixData] = await Promise.all([
        fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
        fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
      ]);

      // Use looking-glass with actual prefixes to get BGP paths
      const announcedPrefixes = prefixData?.data?.prefixes || [];
      const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3

      // Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each)
      const lgResults = await Promise.all(
        samplePrefixes.map((pfx) =>
          fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null)
        )
      );

      // Extract AS paths from looking glass results
      const allPaths = [];
      const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths

      lgResults.forEach((lgData) => {
        const rrcs = lgData?.data?.rrcs || [];
        rrcs.forEach((rrc) => {
          const peers = rrc.peers || [];
          peers.forEach((peer) => {
            const rawPath = peer.as_path || "";
            const pathArr = rawPath.split(" ").map(Number).filter(Boolean);
            if (pathArr.length > 1) {
              allPaths.push({
                rrc: rrc.rrc,
                path: pathArr,
                rawPath: rawPath,
                prefix: peer.prefix || "",
                hasAsSet: hasAsSet(rawPath),
              });
              const idx = pathArr.indexOf(targetAsn);
              if (idx > 0) {
                const neighbour = pathArr[idx - 1];
                pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1);
              }
            }
          });
        });
      });

      // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
      // AS-path analysis is used for frequency/confirmation, NOT as standalone provider source
      const neighbours = neighbourData?.data?.neighbours || [];
      const leftNeighbours = neighbours.filter((n) => n.type === "left");
      const upstreamSet = new Set();
      leftNeighbours.forEach((n) => upstreamSet.add(n.asn));

      // Classify left neighbours: high-power = likely upstream, low-power = likely peer
      const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
      const detectedProviders = [...upstreamSet].map((asn) => {
        const nb = leftNeighbours.find((n) => n.asn === asn);
        const power = nb ? (nb.power || 0) : 0;
        const powerPct = Math.round((power / maxPower) * 100);
        const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
        return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
      });

      await resolveASNames(detectedProviders);

      // Count how often each provider appears in paths
      const providerFrequency = new Map();
      allPaths.forEach((p) => {
        const idx = p.path.indexOf(targetAsn);
        if (idx > 0) {
          const prov = p.path[idx - 1];
          providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1);
        }
      });

      // Check Cloudflare RPKI feed for ASPA object
      await ensureAspaCache();
      const aspaLookup = lookupAspaFromRpki(targetAsn);
      const aspaObjectExists = aspaLookup.exists;
      const aspaDeclaredProviders = aspaLookup.providers;

      // Build ASPA store from RPKI feed data (real ASPA objects)
      const aspaStore = new Map();
      // Add the target ASN's RPKI-declared providers
      if (aspaObjectExists) {
        aspaStore.set(targetAsn, new Set(aspaDeclaredProviders));
      } else {
        // Fallback: use detected providers for path verification
        const providerSet = new Set(detectedProviders.map((p) => p.asn));
        aspaStore.set(targetAsn, providerSet);
      }
      // Also populate store with all known ASPA objects from the RPKI feed
      // for providers that have their own ASPA objects (enables full path verification)
      for (const [cas, provSet] of rpkiAspaMap) {
        if (!aspaStore.has(cas)) {
          aspaStore.set(cas, provSet);
        }
      }

      // Also add reverse relationships for providers we know about
      // (each provider has the target as customer)
      detectedProviders.forEach((p) => {
        if (!aspaStore.has(p.asn)) {
          aspaStore.set(p.asn, new Set());
        }
      });

      // Sample paths for verification (up to 50)
      const samplePaths = allPaths.slice(0, 50);
      const pathResults = samplePaths.map((p) => {
        const upstream = verifyUpstream(p.path, aspaStore, p.rawPath);
        const downstream = verifyDownstream(p.path, aspaStore, p.rawPath);
        const valleys = detectValleys(p.path, aspaStore);

        return {
          rrc: p.rrc,
          prefix: p.prefix,
          path: p.path.map((a) => "AS" + a).join(" "),
          collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "),
          has_as_set: p.hasAsSet,
          upstream_verification: upstream,
          downstream_verification: downstream,
          valleys: valleys,
          overall: p.hasAsSet
            ? "Invalid"
            : upstream.result === "Valid" && downstream.result === "Valid"
            ? "Valid"
            : upstream.result === "Invalid" || downstream.result === "Invalid"
            ? "Invalid"
            : "Unknown",
        };
      });

      // Calculate statistics
      const validPaths = pathResults.filter((p) => p.overall === "Valid").length;
      const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length;
      const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length;
      const asSetPaths = pathResults.filter((p) => p.has_as_set).length;
      const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length;

      // For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal),
      // only Invalid actually indicates problems
      const pathNotInvalidPct = pathResults.length > 0
        ? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100)
        : 0;
      const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0;

      // Provider audit: compare detected vs declared
      const detectedSet = new Set(detectedProviders.map((p) => p.asn));
      const declaredSet = new Set(aspaDeclaredProviders);

      const missingFromAspa = detectedProviders
        .filter((p) => !declaredSet.has(p.asn))
        .map((p) => ({
          asn: p.asn,
          name: p.name,
          frequency: providerFrequency.get(p.asn) || 0,
          frequency_pct: allPaths.length > 0
            ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
            : 0,
        }))
        .sort((a, b) => b.frequency - a.frequency);

      const extraInAspa = aspaDeclaredProviders
        .filter((asn) => !detectedSet.has(asn))
        .map((asn) => ({
          asn,
          name: "",
          seen_in_paths: false,
        }));

      const providerCompleteness = detectedProviders.length > 0
        ? Math.round(
            (detectedProviders.filter((p) => declaredSet.has(p.asn)).length /
              detectedProviders.length) *
              100
          )
        : aspaObjectExists ? 100 : 0;

      // Get RPKI coverage for readiness score
      // Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs)
      await ensureAspaCache();
      const rpkiBatch = announcedPrefixes.map((p) => p.prefix);
      const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx)));
      const rpkiValid = rpkiResults.filter((r) => r.status === "valid").length;
      const rpkiCoverage = rpkiResults.length > 0 ? Math.round((rpkiValid / rpkiResults.length) * 100) : 0;

      // Calculate readiness score
      const readinessScore = calculateAspaReadinessScore({
        rpkiCoverage,
        aspaObjectExists,
        providerCompleteness,
        pathValidationPct: pathNotInvalidPct,
      });

      const duration = Date.now() - start;
      const verifyResult = {
        meta: {
          query: "AS" + rawAsn,
          duration_ms: duration,
          timestamp: new Date().toISOString(),
          paths_analyzed: pathResults.length,
          total_paths_seen: allPaths.length,
        },
        asn: targetAsn,
        readiness_score: readinessScore,
        aspa_object_exists: aspaObjectExists,
        detected_providers: detectedProviders.map((p) => ({
          ...p,
          frequency: providerFrequency.get(p.asn) || 0,
          frequency_pct: allPaths.length > 0
            ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
            : 0,
        })),
        provider_audit: {
          declared_count: aspaDeclaredProviders.length,
          detected_count: detectedProviders.length,
          completeness_pct: providerCompleteness,
          missing_from_aspa: missingFromAspa,
          extra_in_aspa: extraInAspa,
        },
        path_verification: {
          total: pathResults.length,
          valid: validPaths,
          invalid: invalidPaths,
          unknown: unknownPaths,
          as_set_flagged: asSetPaths,
          valley_detected: valleyPaths,
          valid_pct: pathValidPct,
          not_invalid_pct: pathNotInvalidPct,
          results: pathResults,
        },
        rpki_coverage: rpkiCoverage,
      };
      resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult);
      return res.end(JSON.stringify(verifyResult, null, 2));
    } catch (err) {
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message }));
    }
  }

  // ============================================================
  // ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat)
  // ============================================================
  if (reqPath === "/api/aspa") {
    const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
    if (!rawAsn) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
    }
    const cachedAspa = resultCacheGet(aspaResultCache, rawAsn);
    if (cachedAspa !== undefined) {
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(cachedAspa));
    }
    const start = Date.now();
    let _aspaDone = false;
    const _aspaTimer = setTimeout(() => {
      if (!_aspaDone) {
        _aspaDone = true;
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) }));
      }
    }, 12000);
    try {
      const [lgData, neighbourData] = await Promise.all([
        fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null),
        fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }),
      ]);

      const rrcs = lgData?.data?.rrcs || [];
      const asPaths = [];

      rrcs.forEach((rrc) => {
        const peers = rrc.peers || [];
        peers.forEach((peer) => {
          const path = peer.as_path || "";
          const pathArr = path.split(" ").map(Number).filter(Boolean);
          if (pathArr.length > 1) {
            asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" });
          }
        });
      });

      // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
      const neighbours = neighbourData?.data?.neighbours || [];
      const leftNeighbours = neighbours.filter((n) => n.type === "left");
      const upstreamSet = new Set();
      leftNeighbours.forEach((n) => upstreamSet.add(n.asn));

      // Classify left neighbours: high-power = likely upstream, low-power = likely peer
      const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
      const detectedProviders = [...upstreamSet].map((asn) => {
        const nb = leftNeighbours.find((n) => n.asn === asn);
        const power = nb ? (nb.power || 0) : 0;
        const powerPct = Math.round((power / maxPower) * 100);
        const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
        return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
      });

      await resolveASNames(detectedProviders);

      // Check Cloudflare RPKI feed for ASPA object
      await ensureAspaCache();
      const aspaLookup = lookupAspaFromRpki(rawAsn);
      const aspaObjectExists = aspaLookup.exists;
      const aspaDeclaredProviders = aspaLookup.providers;

      const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
      let recommendedAspa =
        "aut-num:        AS" + rawAsn + "\n" +
        "# Recommended ASPA object:\n" +
        "# customer:     AS" + rawAsn + "\n" +
        "# provider-set: " + providerList + "\n" +
        "# AFI:          ipv4, ipv6\n" +
        "#\n" +
        "# Detected providers from BGP path analysis:\n" +
        detectedProviders.map((p) => "#   AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n");

      // If ASPA object exists, show RPKI-declared providers
      if (aspaObjectExists && aspaDeclaredProviders.length > 0) {
        recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" +
          aspaDeclaredProviders.map((a) => "#   AS" + a).join("\n");
      }

      const samplePaths = asPaths.slice(0, 10).map((p) => {
        const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
        const idx = p.path.indexOf(parseInt(rawAsn));
        const provider = idx > 0 ? p.path[idx - 1] : null;
        return {
          rrc: p.rrc,
          prefix: p.prefix,
          path: pathStr,
          detected_provider: provider ? "AS" + provider : null,
          provider_in_set: provider ? upstreamSet.has(provider) : false,
        };
      });

      if (_aspaDone) return; // hard timeout already responded
      _aspaDone = true;
      clearTimeout(_aspaTimer);
      const duration = Date.now() - start;
      const aspaResult = {
        meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() },
        asn: parseInt(rawAsn),
        detected_providers: detectedProviders,
        provider_count: detectedProviders.length,
        aspa_object_exists: aspaObjectExists,
        aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
        aspa_declared_count: aspaDeclaredProviders.length,
        recommended_aspa: recommendedAspa,
        path_analysis: {
          total_paths_seen: asPaths.length,
          sample_paths: samplePaths,
        },
      };
      resultCacheSet(aspaResultCache, rawAsn, aspaResult);
      return res.end(JSON.stringify(aspaResult, null, 2));
    } catch (err) {
      if (!_aspaDone) {
        _aspaDone = true;
        clearTimeout(_aspaTimer);
        res.writeHead(500);
        return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message }));
      }
    }
  }

  // ============================================================
  // BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X)
  // Queries local PostgreSQL bgp_routes table — zero external API calls
  // ============================================================
  if (reqPath === "/api/bgp") {
    const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
    const prefix = url.searchParams.get("prefix") || "";
    if (!rawAsn && !prefix) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Need asn or prefix parameter" }));
    }
    const cacheKey = rawAsn || prefix;
    const cached = resultCacheGet(bgproutesResultCache, cacheKey);
    if (cached !== undefined) {
      res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
      return res.end(JSON.stringify(cached));
    }
    const start = Date.now();
    try {
      const result = {
        meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" },
        bgp_status: null,
        threat_intel: null,
      };

      // ---- BGP Status (local DB lookup) ----
      if (prefix) {
        // Prefix lookup: Get BGP status for this prefix
        const bgpStatus = await localDb.getBgpStatus(prefix);
        if (bgpStatus) {
          result.bgp_status = {
            prefix,
            announced: bgpStatus.announced,
            origin_asns: bgpStatus.origin_asns,
            visibility_percent: bgpStatus.visibility_percent,
            last_seen: bgpStatus.last_seen,
            source: "local_bgp",
          };
          // Check for hijack (multiple origin ASNs)
          const hijackAsns = await localDb.checkBgpHijack(prefix);
          if (hijackAsns.length > 1) {
            result.bgp_status.hijack_warning = {
              detected: true,
              origin_asns: hijackAsns,
              message: `Multiple origin ASNs detected for ${prefix}`,
            };
          }
        }
      } else if (rawAsn) {
        // ASN lookup: Get all announced prefixes for this ASN
        const prefixes = await localDb.getAnnouncedPrefixes(rawAsn);
        if (prefixes && prefixes.length > 0) {
          result.bgp_status = {
            asn: rawAsn,
            announced_count: prefixes.length,
            prefixes: prefixes.slice(0, 50).map((p) => ({
              prefix: p.prefix,
              origin_asn: p.origin_asn,
              visibility_percent: p.visibility_percent,
              last_seen: p.last_seen,
            })),
            source: "local_bgp",
          };
        } else {
          result.bgp_status = {
            asn: rawAsn,
            announced: false,
            announced_count: 0,
            message: "No prefixes found for this ASN in local BGP table",
            source: "local_bgp",
          };
        }
      }

      // ---- Threat Intelligence (local cache lookup) ----
      // If we have an IP context, look up threat intel
      if (prefix && prefix.includes(".")) {
        // Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0")
        const ipAddr = prefix.split("/")[0];
        const threat = await localDb.getThreatIntel(ipAddr);
        if (threat) {
          result.threat_intel = {
            ip_address: threat.ip_address,
            threat_level: threat.threat_level,
            confidence_score: threat.confidence_score,
            source: threat.source,
            cached_at: threat.cached_at,
          };
        }
      }

      result.meta.duration_ms = Date.now() - start;
      resultCacheSet(bgproutesResultCache, cacheKey, result);
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(result, null, 2));
    } catch (err) {
      console.error("[/api/bgp] Error:", err.message);
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "BGP query failed", message: err.message }));
    }
  }


  // ============================================================
  // Unified Validation endpoint: /api/validate?asn=X
  // Runs ALL validations in parallel, returns comprehensive report
  // ============================================================
  if (reqPath === "/api/validate") {
    const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
    if (!rawAsn) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
    }
    const cachedValidate = resultCacheGet(validateResultCache, rawAsn);
    if (cachedValidate !== undefined) {
      res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
      return res.end(JSON.stringify(cachedValidate));
    }
    const start = Date.now();
    const targetAsn = parseInt(rawAsn);

    try {
      // Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2
      const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([
        fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
        fetchPeeringDB("/net?asn=" + rawAsn),
        fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
        fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
      ]);

      const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; });
      // Use all prefixes for RPKI validation (local lookup is fast, no API calls)
      const samplePrefixes = allPrefixes;
      const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {};
      const netId = net.id;
      const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : [];

      // ---- 11. Bogon Detection (local check) ----
      function checkBogonPrefix(prefix) {
        var bogonV4 = [
          { net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 },
          { net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 },
          { net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 },
          { net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 },
          { net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 },
          { net: "240.0.0.0", mask: 4 },
        ];
        if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" };
        var split = prefix.split("/");
        var addr = split[0];
        var mask = parseInt(split[1] || "0");
        var parts = addr.split(".").map(Number);
        var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
        for (var bi = 0; bi < bogonV4.length; bi++) {
          var b = bogonV4[bi];
          var bParts = b.net.split(".").map(Number);
          var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0;
          var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0;
          if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) {
            return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask };
          }
        }
        return { prefix: prefix, is_bogon: false };
      }

      function checkBogonAsn(asnNum) {
        if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true;
        if (asnNum >= 64496 && asnNum <= 64511) return true;
        if (asnNum >= 64512 && asnNum <= 65534) return true;
        return false;
      }

      var bogonPrefixResults = allPrefixes.map(checkBogonPrefix);
      var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; });
      var asnInPaths = neighbours.map(function(n) { return n.asn; });
      var bogonAsns = asnInPaths.filter(checkBogonAsn);
      var bogonResult = {
        status: bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail",
        bogon_prefixes: bogonPrefixes,
        bogon_asns_in_paths: bogonAsns,
        total_prefixes_checked: allPrefixes.length,
      };

      // Phase 2: All API-dependent validations in parallel
      var validationPromises = {};

      // 12. IRR Validation
      validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) {
        var entries = Array.isArray(irrData) ? irrData : [];
        var mismatches = entries.filter(function(e) {
          if (!e.bgpOrigins && !e.bgp_origins) return false;
          if (!e.irrRoutes && !e.irr_origins) return false;
          var bgpArr = e.bgpOrigins || e.bgp_origins || [];
          var irrArr = e.irrRoutes || e.irr_origins || [];
          var bgpSet = {};
          bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; });
          var match = false;
          irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; });
          return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match;
        });
        return {
          status: mismatches.length === 0 ? "pass" : "warning",
          total_entries: entries.length,
          mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }),
          mismatch_count: mismatches.length,
        };
      }).catch(function(e) { return { status: "error", error: String(e) }; });

      // 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs)
      await ensureAspaCache(); // Ensure ROA data is loaded
      validationPromises.rpki_completeness = Promise.all(
        allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
      ).then(function(rpkiResults) {
        var withRoa = rpkiResults.filter(function(r) { return r.status === "valid"; });
        var coverage = rpkiResults.length > 0 ? Math.round((withRoa.length / rpkiResults.length) * 100) : 0;
        var overSpecific = rpkiResults.filter(function(r) {
          var mask = parseInt((r.prefix || "").split("/")[1] || "0");
          return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid";
        });
        return {
          status: coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
          coverage_pct: coverage,
          total_checked: rpkiResults.length,
          with_roa: withRoa.length,
          over_specific: overSpecific.map(function(r) { return r.prefix; }),
          details: rpkiResults,
        };
      }).catch(function(e) { return { status: "error", error: String(e) }; });

      // 14. Abuse Contact Validation
      validationPromises.abuse_contact = fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) {
        var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : [];
        var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); });
        return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail };
      }).catch(function(e) { return { status: "error", error: String(e) }; });

      // 15. Spamhaus DROP/Blocklist
      validationPromises.blocklist = Promise.all(
        samplePrefixes.slice(0, 5).map(function(pfx) {
          return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) {
            var sources = data && data.data && data.data.sources ? data.data.sources : [];
            var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); });
            return { prefix: pfx, listed: listed.length > 0, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) };
          }).catch(function() { return { prefix: pfx, listed: false, error: true }; });
        })
      ).then(function(results) {
        var listedPrefixes = results.filter(function(r) { return r.listed; });
        return { status: listedPrefixes.length === 0 ? "pass" : "fail", checked: results.length, listed_prefixes: listedPrefixes };
      }).catch(function(e) { return { status: "error", error: String(e) }; });

      // 16. MANRS Compliance — scraped from public participants list (24h cache)
      validationPromises.manrs = ensureManrsCache().then(function() {
        return checkManrsMembership(rawAsn);
      }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; });

      // 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs)
      var rdnsSampleSize = Math.min(3, samplePrefixes.length);
      validationPromises.rdns = Promise.all(
        samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) {
          return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) {
            var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
            var hasDelegation = false;
            var details = [];
            // API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } }
            ["ipv4", "ipv6"].forEach(function(af) {
              var afData = pfxData[af] || {};
              Object.keys(afData).forEach(function(p) {
                var entry = afData[p];
                if (entry && entry.complete) hasDelegation = true;
                if (entry && entry.domains) {
                  entry.domains.forEach(function(d) {
                    if (d.found) hasDelegation = true;
                    details.push({ domain: d.domain, found: !!d.found });
                  });
                }
              });
            });
            // Fallback: old array format
            if (Array.isArray(pfxData)) {
              pfxData.forEach(function(p) {
                if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true;
              });
            }
            return { prefix: pfx, has_rdns: hasDelegation, details: details };
          }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; });
        })
      ).then(function(results) {
        var withRdns = results.filter(function(r) { return r.has_rdns; });
        var coverage = results.length > 0 ? Math.round((withRdns.length / results.length) * 100) : 0;
        // Include details of what failed
        var failedPrefixes = results.filter(function(r) { return !r.has_rdns && !r.error; }).map(function(r) { return r.prefix; });
        return { status: coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail", coverage_pct: coverage, checked: results.length, results: results, failed_prefixes: failedPrefixes };
      }).catch(function(e) { return { status: "error", error: String(e) }; });

      // 18. BGP Visibility (uses routing-status API which is more reliable than visibility API)
      validationPromises.visibility = fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) {
        var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {};
        var v4 = vis.v4 || {};
        var v6 = vis.v6 || {};
        var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0);
        var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0);
        var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0;
        var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0;
        // If routing-status returned no data, try bgproutes.io
        if (totalPeers === 0 && samplePrefixes[0]) {
          return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) {
            if (bgprFb && bgprFb.vps_seeing > 0) {
              seeingPeers = bgprFb.vps_seeing;
              totalPeers = Math.max(bgprFb.vps_seeing, 300);
              score = Math.round((seeingPeers / totalPeers) * 100);
            }
            return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" };
          }).catch(function() {
            return { status: "fail", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" };
          });
        }
        return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" };
      }).catch(function(e) { return { status: "error", error: String(e) }; });

      // 19. BGP Communities Analysis
      validationPromises.communities = (samplePrefixes.length > 0
        ? (function() {
            var now = new Date();
            var end = now.toISOString().replace(/\.\d+Z/, "");
            var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, "");
            return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end);
          })()
        : Promise.resolve(null)
      ).then(function(data) {
        var updates = data && data.data && data.data.updates ? data.data.updates : [];
        var communityMap = {};
        var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" };
        updates.forEach(function(u) {
          var attrs = u.attrs || {};
          var communities = attrs.community || [];
          communities.forEach(function(c) {
            var key = Array.isArray(c) ? c.join(":") : String(c);
            if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null };
            communityMap[key].count++;
          });
        });
        var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; });
        var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; });
        return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) };
      }).catch(function(e) { return { status: "error", error: String(e) }; });

      // 20. Geolocation Verification
      validationPromises.geolocation = (samplePrefixes.length > 0
        ? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0]))
        : Promise.resolve(null)
      ).then(function(data) {
        var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : [];
        var countries = {};
        locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); });
        return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length };
      }).catch(function(e) { return { status: "error", error: String(e) }; });

      // 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel)
      validationPromises.rpsl = (function() {
        // Try RIPE first (has richest policy data), then RDAP for other RIRs
        var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) {
          var objects = data && data.objects && data.objects.object ? data.objects.object : [];
          if (objects.length === 0) return null;
          var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : [];
          var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; });
          var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; });
          var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; });
          return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" };
        }).catch(function() { return null; });

        var rdapEndpoints = [
          { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn },
          { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn },
          { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn },
          { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn },
        ];
        var rdapPromises = rdapEndpoints.map(function(ep) {
          return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) {
            if (!data || data.errorCode || !data.handle) return null;
            var hasRemarks = !!(data.remarks && data.remarks.length > 0);
            var name = data.name || "";
            return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" };
          }).catch(function() { return null; });
        });

        return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) {
          // Take first successful result
          for (var ri = 0; ri < results.length; ri++) {
            if (results[ri] !== null) return results[ri];
          }
          return { status: "warning", exists: false, has_policy: false };
        });
      })();

      // 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering)
      // Always use asn= for netixlan (more reliable than net_id when PDB rate-limits)
      var ixRsQueryUrl = "/netixlan?asn=" + rawAsn;
      {
        validationPromises.ix_route_server = fetchPeeringDB(ixRsQueryUrl).then(function(ixData) {
          var connections = ixData && ixData.data ? ixData.data : [];
          var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; });
          var totalIx = connections.length;
          var rsCount = rsParticipants.length;
          var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0;
          var status, note;

          if (totalIx > 0 && rsCount > 0) {
            // Using route servers - good
            status = "pass";
            note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)";
          } else if (totalIx >= 10 && rsCount === 0) {
            // Network with 10+ IX connections but no RS = deliberate bilateral peering policy
            status = "pass";
            note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)";
          } else if (totalIx < 3 && rsCount === 0) {
            // Very small IX presence and no RS
            status = "warning";
            note = "Only " + totalIx + " IX connection(s) and no route server usage";
          } else {
            // Small-medium network (3-9 IX) without RS - informational
            status = "info";
            note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability";
          }

          return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note };
        }).catch(function(e) { return { status: "error", error: String(e) }; });
      }

      // 23. Resource Certification (local RPKI validation - all prefixes, all RIRs)
      validationPromises.resource_cert = Promise.all(
        allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
      ).then(function(results) {
        var hasRoa = results.some(function(r) { return r.status === "valid" || r.validating_roas > 0; });
        return { status: hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: results.length, roa_count: results.filter(function(r) { return r.status === "valid"; }).length };
      }).catch(function(e) { return { status: "error", error: String(e) }; });

      // Geolocation cross-ref with PeeringDB facilities
      var facCountriesPromise = netId
        ? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) {
            return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean);
          }).catch(function() { return []; })
        : Promise.resolve([]);

      // Run all validations in parallel — 5s cap per check, total validate bounded to ~10s
      var keys = Object.keys(validationPromises);
      var promises = keys.map(function(k) {
        return Promise.race([
          validationPromises[k],
          new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }),
        ]);
      });
      var settled = await Promise.allSettled(promises);
      var facCountries = await facCountriesPromise;

      var validations = {};
      keys.forEach(function(key, i) {
        if (settled[i].status === "fulfilled") {
          validations[key] = settled[i].value;
        } else {
          validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" };
        }
      });

      // Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks)
      if (validations.geolocation && validations.geolocation.status !== "error") {
        var uniqueFacCountries = {};
        facCountries.forEach(function(c) { uniqueFacCountries[c] = true; });
        var facCountryCount = Object.keys(uniqueFacCountries).length;
        validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries);
        var geoSet = {};
        (validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; });
        var geoCountryCount = Object.keys(geoSet).length;
        var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; });
        validations.geolocation.country_mismatches = mismatches;

        // Detect global/anycast networks: 5+ facility countries OR Content/NSP type
        var netInfoType = (net.info_type || "").toLowerCase();
        var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp";
        if (isGlobalNetwork) {
          // Global/anycast/CDN network: geo mismatches are expected, not anomalies
          validations.geolocation.status = "pass";
          if (geoCountryCount === 0) {
            validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available";
          } else {
            validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")";
          }
          validations.geolocation.country_mismatches = [];
        } else if (facCountryCount <= 2 && geoCountryCount >= 10) {
          // Actual anomaly: small network appearing in many countries
          validations.geolocation.status = "warning";
          validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration";
        }
      }

      validations.bogon = bogonResult;

      // Calculate overall health score (0-100)
      var checks = [
        { key: "bogon", weight: 15 },
        { key: "irr", weight: 10 },
        { key: "rpki_completeness", weight: 15 },
        { key: "abuse_contact", weight: 5 },
        { key: "blocklist", weight: 15 },
        { key: "manrs", weight: 5 },
        { key: "rdns", weight: 5 },
        { key: "visibility", weight: 10 },
        { key: "rpsl", weight: 5 },
        { key: "ix_route_server", weight: 5 },
        { key: "resource_cert", weight: 10 },
      ];

      var totalWeight = 0;
      var earnedScore = 0;
      var checkResults = [];

      checks.forEach(function(c) {
        var v = validations[c.key];
        var points = 0;
        if (v && v.status === "info") {
          // "info" = unable to verify (e.g. API auth required) — exclude from scoring
          checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" });
          return;
        }
        if (v && v.status === "pass") points = c.weight;
        else if (v && v.status === "warning") points = Math.round(c.weight * 0.5);
        totalWeight += c.weight;
        earnedScore += points;
        checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" });
      });

      var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0;
      var duration = Date.now() - start;

      // Build relationships from neighbour data
      var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts
        ? neighbourData.data.neighbour_counts : {};
      var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours
        ? neighbourData.data.neighbours : [];
      var relUpstreams = relList.filter(function(n) { return n.type === "left"; })
        .sort(function(a, b) { return (b.power || 0) - (a.power || 0); })
        .slice(0, 20)
        .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; });
      var relDownstreams = relList.filter(function(n) { return n.type === "right"; })
        .sort(function(a, b) { return (b.power || 0) - (a.power || 0); })
        .slice(0, 20)
        .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; });
      var relPeers = relList.filter(function(n) { return n.type === "uncertain"; })
        .sort(function(a, b) { return (b.power || 0) - (a.power || 0); })
        .slice(0, 30)
        .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; });

      const validateResult = {
        meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length },
        asn: targetAsn,
        name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown",
        health_score: healthScore,
        score_breakdown: checkResults,
        validations: validations,
        relationships: {
          counts: { upstreams: relNeighbours.left || relUpstreams.length, downstreams: relNeighbours.right || relDownstreams.length, peers: relNeighbours.unique || relPeers.length, uncertain: relNeighbours.uncertain || 0 },
          upstreams: relUpstreams,
          downstreams: relDownstreams,
          top_peers: relPeers,
          source: "RIPE Stat asn-neighbours",
          note: "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score.",
        },
      };
      resultCacheSet(validateResultCache, rawAsn, validateResult);
      return res.end(JSON.stringify(validateResult, null, 2));
    } catch (err) {
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "Validation failed", message: err.message }));
    }
  }

  // ============================================================
  // Main lookup endpoint: /api/lookup?asn=X
  // ============================================================
  if (reqPath === "/api/lookup") {
    const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
    if (!rawAsn) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
    }
    const asn = rawAsn;
    const cacheKey = "lookup:" + asn;
    const cached = cacheGet(cacheKey);
    if (cached) {
      res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
      return res.end(JSON.stringify(cached));
    }
    const start = Date.now();

    try {
      // Phase 0: Get PDB net first — check L2 cache, then API with retry
      let pdbNet = pdbSourceCache.get("net", asn);
      if (!pdbNet) {
        pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn);
        if (pdbNet) pdbSourceCache.set("net", asn, pdbNet);
      }
      const net = pdbNet?.data?.[0] || {};
      const netId = net.id;

      // Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net
      const ixQuery = netId
        ? "/netixlan?net_id=" + netId + "&limit=1000"
        : "/netixlan?asn=" + asn + "&limit=1000";
      const ixCacheKey = netId ? String(netId) : "asn:" + asn;

      // Check PDB source cache for IX/Fac data
      let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
      let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;

      // Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking
      const sourceTiming = {};
      function timedFetch(name, promise) {
        const ts = Date.now();
        return Promise.race([
          Promise.resolve(promise),
          new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }),
        ])
          .then(function(r)  { sourceTiming[name] = Date.now() - ts; return r; })
          .catch(function() { sourceTiming[name] = null; return null; });
      }

      const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null;

      // RDAP: check module-level cache first, only hit RIR endpoints on cache miss
      const rdapCached = rdapCacheGet(asn);
      const rdapPromise = rdapCached !== undefined
        ? Promise.resolve(rdapCached)
        : Promise.race([
            ...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn,
                "https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn,
                "https://rdap.afrinic.net/rdap/autnum/"+asn].map(url =>
              fetchJSON(url, { timeout: 4000 })
                .then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {}))
                .catch(() => new Promise(() => {}))
            ),
            new Promise(resolve => setTimeout(() => resolve(null), 5000)),
          ]).then(d => { rdapCacheSet(asn, d); return d; });

      const promises = [
        timedFetch("RIPE Stat Prefixes",    localDb.getRipeStatAnnouncedPrefixes(asn)),
        timedFetch("RIPE Stat Neighbours",  localDb.getRipeStatAsnNeighbours(asn)),
        timedFetch("RIPE Stat Overview",    localDb.getRipeStatAsOverview(asn)),
        timedFetch("RIPE Stat RIR",         Promise.resolve(null)),
        timedFetch("RIPE Atlas",            Promise.resolve(null)),
        timedFetch("bgp.he.net",            Promise.resolve(null)),
        timedFetch("RIPE Stat Visibility",  localDb.getRipeStatVisibility(asn)),
        timedFetch("RIPE Stat PrefixSize",  localDb.getRipeStatPrefixSizeDistribution(asn)),
        timedFetch("PeeringDB IXLan",       cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)),
        timedFetch("PeeringDB Facilities",  cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))),
        timedFetch("PeeringDB Contacts",    pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)),
        timedFetch("RDAP Registration",     rdapPromise),
      ];
      const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises);

      // Store PDB results in L2 source cache for future lookups
      if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
      if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData);

      const prefixes = prefixData?.data?.prefixes || [];
      const neighbours = neighbourData?.data?.neighbours || [];
      const overview = overviewData?.data || {};
      const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || [];

      // Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat)
      const atlasProbes = atlasProbeData?.results || [];
      const atlasConnected = atlasProbes.filter(p => {
        const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase();
        return sName === "connected";
      });
      const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true);

      // RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant)
      await ensureAspaCache();
      const allPrefixes = prefixes.map((p) => p.prefix);
      const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx)));

      const ixConnections = (ixlanData?.data || [])
        .map((ix) => ({
          ix_name: ix.name || "",
          ix_id: ix.ix_id,
          speed_mbps: ix.speed || 0,
          ipv4: ix.ipaddr4 || null,
          ipv6: ix.ipaddr6 || null,
          city: ix.city || "",
          is_rs_peer: ix.is_rs_peer === true,
        }))
        .sort((a, b) => b.speed_mbps - a.speed_mbps);

      const facilitiesRaw = (facData?.data || []).map((f) => ({
        fac_id: f.fac_id,
        name: f.name || "",
        city: f.city || "",
        country: f.country || "",
      }));

      // Batch-fetch facility coordinates for map (max 50 facilities)
      const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50);
      let facCoordMap = {};
      if (facIds.length > 0) {
        try {
          const chunks = [];
          for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25));
          const coordResults = await Promise.race([
            Promise.all(chunks.map(chunk =>
              fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
            )),
            new Promise(r => setTimeout(() => r([]), 5000))
          ]);
          (coordResults || []).forEach(res => {
            (res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
          });
        } catch(e) { /* graceful degradation */ }
      }
      const facilities = facilitiesRaw.map(f => ({
        ...f,
        latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null,
        longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null,
      }));

      // Get IX locations for map via ixfac -> fac coordinates (max 20 IXs)
      const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20);
      let ixLocations = [];
      if (uniqueIxIds.length > 0) {
        try {
          const ixFacData = await Promise.race([
            fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")),
            new Promise(r => setTimeout(() => r(null), 5000))
          ]);
          const ixFacs = ixFacData?.data || [];
          // Collect unique fac_ids we don't already have coords for
          const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30);
          if (extraFacIds.length > 0) {
            const extraChunks = [];
            for (let i = 0; i < extraFacIds.length; i += 25) extraChunks.push(extraFacIds.slice(i, i + 25));
            const extraRes = await Promise.race([
              Promise.all(extraChunks.map(chunk =>
                fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
              )),
              new Promise(r => setTimeout(() => r([]), 4000))
            ]);
            (extraRes || []).forEach(res => {
              (res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
            });
          }
          // Build IX locations: pick first facility with coords per IX
          const ixNameMap = {};
          ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; });
          const seenIx = {};
          ixFacs.forEach(f => {
            if (seenIx[f.ix_id]) return;
            const coords = facCoordMap[f.fac_id];
            if (coords) {
              seenIx[f.ix_id] = true;
              ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon });
            }
          });
        } catch(e) { /* graceful degradation */ }
      }

      const rpkiStatuses = rpkiAllResults;
      const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
      const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
      const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid").length;
      const rpkiTotal = rpkiStatuses.length;
      const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;

      const upstreams = neighbours
        .filter((n) => n.type === "left")
        .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
        .sort((a, b) => b.power - a.power);
      const downstreams = neighbours
        .filter((n) => n.type === "right")
        .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
        .sort((a, b) => b.power - a.power);
      const peers = neighbours
        .filter((n) => n.type === "uncertain" || n.type === "peer")
        .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
        .sort((a, b) => b.power - a.power);

      // Resolve empty AS names — all in parallel, with 3s timeout
      const emptyNameNeighbours = [...upstreams, ...downstreams, ...peers].filter(n => !n.name);
      if (emptyNameNeighbours.length > 0) {
        const resolvePromise = Promise.all(
          emptyNameNeighbours.map(n =>
            fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
              .then(r => { if (r?.data?.holder) n.name = r.data.holder; })
              .catch(() => {})
          )
        );
        await Promise.race([resolvePromise, new Promise(r => setTimeout(r, 3000))]);
      }

      // ---- Threat Intelligence Enrichment for Neighbors ----
      // Enrich neighbor data with threat status from local threat_intel table
      const threatEnrichNeighbors = async (neighbors) => {
        const allNeighbors = [...neighbors];
        const threatMap = {};

        // Batch threat intel lookups (cap at 50 to avoid overwhelming DB)
        const toCheck = allNeighbors.slice(0, 50);
        const threatPromises = toCheck.map(async (n) => {
          try {
            // Try to get threat intel by AS number or typical AS IP pattern
            // For now, we'll mark neighbors without direct IP threat data
            const asNum = String(n.asn);
            const threat = await localDb.getThreatIntel(asNum);
            if (threat) {
              threatMap[n.asn] = {
                threat_level: threat.threat_level,
                confidence_score: threat.confidence_score,
                source: threat.source,
                cached_at: threat.cached_at,
              };
            }
          } catch (e) {
            // Gracefully skip on error
            console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
          }
        });

        // Run threat lookups with 4s timeout
        await Promise.race([
          Promise.all(threatPromises),
          new Promise(r => setTimeout(r, 4000)),
        ]);

        return threatMap;
      };

      const threatMap = await threatEnrichNeighbors([...upstreams, ...downstreams, ...peers]);

      // Attach threat status to neighbor objects
      const addThreatToNeighbor = (n) => ({
        ...n,
        threat_level: threatMap[n.asn]?.threat_level || null,
        threat_confidence: threatMap[n.asn]?.confidence_score || null,
        threat_source: threatMap[n.asn]?.source || null,
      });

      upstreams = upstreams.map(addThreatToNeighbor);
      downstreams = downstreams.map(addThreatToNeighbor);
      peers = peers.map(addThreatToNeighbor);

      let rir = "";
      let country = "";
      // RIPE Stat rir-stats-country uses 'location' field (not 'country' or 'rir')
      if (Array.isArray(rirEntries) && rirEntries.length > 0) {
        country = rirEntries[0]?.location || rirEntries[0]?.country || "";
        rir = rirEntries[0]?.rir || "";
      }
      if (!rir && rirData?.data) {
        const rirField = rirData.data.rirs || [];
        if (rirField.length > 0) rir = rirField[0]?.rir || "";
      }
      // Derive RIR from rdapData.port43 (e.g. "whois.ripe.net" → "RIPE")
      if (!rir && rdapData && rdapData.port43) {
        const p43 = (rdapData.port43 || "").toLowerCase();
        if (p43.includes("ripe"))    rir = "RIPE";
        else if (p43.includes("arin"))    rir = "ARIN";
        else if (p43.includes("apnic"))   rir = "APNIC";
        else if (p43.includes("lacnic"))  rir = "LACNIC";
        else if (p43.includes("afrinic")) rir = "AFRINIC";
      }
      // Also derive RIR from RDAP links (URL of the RDAP endpoint that responded)
      if (!rir && rdapData && rdapData.links) {
        const selfLink = (rdapData.links.find(l => l.rel === "self") || {}).href || "";
        if (selfLink.includes("ripe"))    rir = "RIPE";
        else if (selfLink.includes("arin"))   rir = "ARIN";
        else if (selfLink.includes("apnic"))  rir = "APNIC";
        else if (selfLink.includes("lacnic")) rir = "LACNIC";
        else if (selfLink.includes("afrinic")) rir = "AFRINIC";
      }
      // bgp.he.net country_code fallback (for unannounced/reserve ASNs)
      if (!country && bgpHeData && bgpHeData.country_code) {
        country = bgpHeData.country_code;
      }
      // Last resort: derive RIR from country code (common assignments)
      if (!rir && country) {
        const ARIN_CC = new Set(["US","CA","AI","AG","BS","BB","BZ","VG","KY","DM","DO","GD","GP","HT","JM","MQ","MS","PR","KN","LC","VC","TT","TC","VI","UM"]);
        const APNIC_CC = new Set(["AU","NZ","JP","CN","KR","IN","HK","SG","TW","VN","TH","ID","MY","PK","BD","LK","NP","PH","AF","KH","LA","MM","MN","BT","BN","FJ","PG","WS","TO","VU","SB","KI","NR","TV","FM","MH","PW","CK","NU","TK","WF","PF","NC","GU","MP","AS","CC","CX","HM","NF"]);
        const LACNIC_CC = new Set(["BR","AR","MX","CO","CL","PE","VE","EC","UY","BO","PY","CU","GT","HN","SV","NI","CR","PA","GY","SR","GF","AW","CW","SX","BQ","AN"]);
        const AFRINIC_CC = new Set(["ZA","NG","KE","EG","GH","TZ","UG","MA","CI","SN","ZM","ZW","AO","MZ","CM","ET","SD","MG","DZ","TN","LY","RW","NA","BW","MW","ML","BF","NE","GN","TD","SO","LS","SZ","ER","DJ","GM","SL","LR","TG","BJ","GW","CF","CG","CD","GQ","ST","KM","MR","SC","MU","RE","CV","BU","SS","EH"]);
        if (ARIN_CC.has(country))    rir = "ARIN";
        else if (APNIC_CC.has(country))  rir = "APNIC";
        else if (LACNIC_CC.has(country)) rir = "LACNIC";
        else if (AFRINIC_CC.has(country)) rir = "AFRINIC";
        else                              rir = "RIPE";  // Europe + rest = RIPE NCC
      }

      const duration = Date.now() - start;

      // Compute routing visibility and prefix size distribution
      const routingInfo = await (async function() {
        const ipv4Prefixes = prefixes.filter(function(p) { return !p.prefix.includes(":"); });
        const ipv6Prefixes = prefixes.filter(function(p) { return p.prefix.includes(":"); });
        var ipv4VisAvg = 0, ipv6VisAvg = 0, totalRisPeersV4 = 0, totalRisPeersV6 = 0;

        // Visibility API returns per-RIS-collector data
        // Each collector has ipv4_full_table_peer_count and ipv4_full_table_peers_not_seeing[]
        // Bug 3 fix: visibility API may timeout for large ASNs — handle gracefully
        var visibilities = (visibilityData && visibilityData.data && visibilityData.data.visibilities) || [];
        var v4Seeing = 0, v4Total = 0, v6Seeing = 0, v6Total = 0;
        var visTimedOut = !visibilityData || !visibilityData.data;
        visibilities.forEach(function(v) {
          if (!v || !v.probe) return;
          var v4PeerCount = v.ipv4_full_table_peer_count || 0;
          var v4NotSeeing = (v.ipv4_full_table_peers_not_seeing || []).length;
          var v6PeerCount = v.ipv6_full_table_peer_count || 0;
          var v6NotSeeing = (v.ipv6_full_table_peers_not_seeing || []).length;
          v4Total += v4PeerCount;
          v4Seeing += (v4PeerCount - v4NotSeeing);
          v6Total += v6PeerCount;
          v6Seeing += (v6PeerCount - v6NotSeeing);
        });
        if (v4Total > 0) ipv4VisAvg = Math.round((v4Seeing / v4Total) * 1000) / 10;
        if (v6Total > 0) ipv6VisAvg = Math.round((v6Seeing / v6Total) * 1000) / 10;
        // If visibility API timed out but we have prefixes, try bgproutes.io fallback
        if (visTimedOut && prefixes.length > 0) {
          var fallbackPrefix = prefixes.find(function(p) { return !p.prefix.includes(":"); });
          if (!fallbackPrefix) fallbackPrefix = prefixes[0];
          if (fallbackPrefix) {
            var bgprFallback = await fetchBgproutesVisibility(fallbackPrefix.prefix);
            if (bgprFallback && bgprFallback.vps_seeing > 0) {
              // Estimate visibility: % of VPs seeing the prefix (assume ~300 total RIS-equivalent VPs)
              var estimatedTotal = Math.max(bgprFallback.vps_seeing, 300);
              ipv4VisAvg = Math.round((bgprFallback.vps_seeing / estimatedTotal) * 1000) / 10;
              ipv6VisAvg = -1; // bgproutes fallback is per-prefix, not per-AF aggregate
              totalRisPeersV4 = bgprFallback.vps_seeing;
              console.log("[Visibility] RIPE Stat timed out, used bgproutes.io fallback for " + fallbackPrefix.prefix + ": " + bgprFallback.vps_seeing + " VPs seeing it");
            } else {
              ipv4VisAvg = -1;
              ipv6VisAvg = -1;
              console.log("[Visibility] RIPE Stat timed out and bgproutes.io fallback returned no data");
            }
          } else {
            ipv4VisAvg = -1;
            ipv6VisAvg = -1;
          }
        }
        totalRisPeersV4 = v4Total;
        totalRisPeersV6 = v6Total;

        // Prefix size distribution: data.ipv4[] and data.ipv6[] arrays with {size, count}
        var psdData = (prefixSizeData && prefixSizeData.data) || {};
        var psV4 = (psdData.ipv4 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
        var psV6 = (psdData.ipv6 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });

        return {
          ipv4_prefixes: ipv4Prefixes.length,
          ipv6_prefixes: ipv6Prefixes.length,
          ipv4_visibility_avg: ipv4VisAvg,
          ipv6_visibility_avg: ipv6VisAvg,
          total_ris_peers_v4: totalRisPeersV4,
          total_ris_peers_v6: totalRisPeersV6,
          prefix_sizes_v4: psV4,
          prefix_sizes_v6: psV6,
        };
      })();

      // ============================================================
      // Multi-source cross-checks (run in parallel, non-blocking)
      // ============================================================
      let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: 100, disagreements: [], sample_size: 0 };
      let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" };
      let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null };

      try {
        // RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout)
        const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses);
        const rpkiCrossResult = await Promise.race([
          rpkiCrossPromise,
          new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
        ]);
        if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult;
      } catch (_e) { /* cross-check failed, keep defaults */ }

      // Prefix count cross-check: compare RIPE Stat vs bgp.he.net
      if (bgpHeData) {
        const heV4 = bgpHeData.prefixes_v4 || 0;
        const heV6 = bgpHeData.prefixes_v6 || 0;
        const heTotal = heV4 + heV6;
        if (heTotal > 0) {
          prefixCrossCheck.bgp_he_net = heTotal;
          const ripeStat = prefixes.length;
          if (ripeStat > 0 && heTotal > 0) {
            const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal);
            prefixCrossCheck.agreement = ratio >= 0.9;
            const diff = Math.abs(ripeStat - heTotal);
            prefixCrossCheck.note = diff === 0
              ? "Exact match"
              : "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)";
          }
        } else {
          prefixCrossCheck.note = "bgp.he.net prefix count unavailable";
        }

        // Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count
        if (bgpHeData.peer_count != null) {
          neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count;
        }
      } else {
        prefixCrossCheck.note = "bgp.he.net data unavailable";
      }

      // Compute overall data quality
      const crossCheckScores = [];
      // RPKI agreement
      crossCheckScores.push(rpkiCrossCheck.agreement_pct);
      // Prefix agreement: convert to percentage
      if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) {
        const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net);
        crossCheckScores.push(Math.round(pfxRatio * 100));
      }
      // Neighbour agreement
      if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) {
        const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total);
        crossCheckScores.push(Math.round(nbrRatio * 100));
      }
      const avgAgreement = crossCheckScores.length > 0
        ? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length)
        : 100;
      const overallConfidence = avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";

      const dataQuality = {
        sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"],
        cross_checks: {
          rpki: { sources: 2, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, disagreements: rpkiCrossCheck.disagreements },
          prefixes: { sources: 2, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note },
          neighbours: { sources: 2, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total },
        },
        overall_confidence: overallConfidence,
        overall_agreement_pct: avgAgreement,
      };

      // === IX Location Geocode Fallback ===
      // Some IXPs have no facility coordinates in PeeringDB.
      // Use ix_name city extraction + hard-coded IX→city map as fallback.
      var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; }));
      ixConnections.forEach(function(conn) {
        if (ixIdsWithCoords.has(conn.ix_id)) return;
        var name = conn.ix_name || "";
        if (name) {
          var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean);
          for (var w = 0; w < words.length; w++) {
            if (CITY_COORDS[words[w]]) {
              ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" });
              ixIdsWithCoords.add(conn.ix_id);
              return;
            }
            if (w < words.length - 1) {
              var tw = words[w] + " " + words[w + 1];
              if (CITY_COORDS[tw]) {
                ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" });
                ixIdsWithCoords.add(conn.ix_id);
                return;
              }
            }
          }
        }
      });
      // Hard-coded IX ID → city for well-known IXPs whose names don't contain city
      var IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" };
      ixConnections.forEach(function(conn) {
        if (ixIdsWithCoords.has(conn.ix_id)) return;
        var city = IX_CITY_MAP[conn.ix_id];
        if (city && CITY_COORDS[city]) {
          ixLocations.push({ ix_id: conn.ix_id, name: conn.ix_name || ("IX " + conn.ix_id), city: city.charAt(0).toUpperCase() + city.slice(1), country: "", latitude: CITY_COORDS[city][0], longitude: CITY_COORDS[city][1], source: "ix_city_map" });
        }
      });

      const result = {
        meta: {
          service: "PeerCortex",
          version: "0.6.9",
          query: "AS" + asn,
          duration_ms: duration,
          sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
          timestamp: new Date().toISOString(),
          rpki_prefixes_checked: rpkiTotal,
          total_prefixes: prefixes.length,
        },
        network: {
          asn: parseInt(asn),
          name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown",
          aka: net.aka || "",
          org_name: (net.org && net.org.name) ? net.org.name : "",
          website: net.website || "",
          type: net.info_type || "",
          policy: net.policy_general || "",
          traffic: net.info_traffic || "",
          ratio: net.info_ratio || "",
          scope: net.info_scope || "",
          notes: net.notes ? net.notes.substring(0, 500) : "",
          peeringdb_id: netId || null,
          rir: rir,
          country: country,
          city: net.city || "",
          latitude: (net.latitude != null) ? net.latitude : null,
          longitude: (net.longitude != null) ? net.longitude : null,
          looking_glass: net.looking_glass || "",
          route_server: net.route_server || "",
          info_prefixes4: net.info_prefixes4 || 0,
          info_prefixes6: net.info_prefixes6 || 0,
          status: net.status || "",
          peeringdb_created: net.created ? net.created.slice(0, 10) : "",
          peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "",
        },
        prefixes: {
          total: prefixes.length,
          ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length,
          ipv6: prefixes.filter((p) => p.prefix.includes(":")).length,
          list: prefixes.map((p) => p.prefix),
          cross_check: prefixCrossCheck,
        },
        rpki: {
          coverage_percent: rpkiCoverage,
          valid: rpkiValid,
          invalid: rpkiInvalid,
          not_found: rpkiNotFound,
          checked: rpkiTotal,
          details: rpkiStatuses,
          cross_check: rpkiCrossCheck,
        },
        neighbours: {
          total: neighbours.length,
          upstream_count: upstreams.length,
          downstream_count: downstreams.length,
          peer_count: peers.length,
          upstreams: upstreams.slice(0, 20),
          downstreams: downstreams.slice(0, 20),
          peers: peers.slice(0, 20),
          cross_check: neighbourCrossCheck,
        },
        ix_presence: {
          total_connections: ixConnections.length,
          unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
          connections: ixConnections,
        },
        ix_locations: ixLocations,
        facilities: {
          total: facilities.length,
          list: facilities,
        },
        routing: routingInfo,
        resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes),
        route_leak: computeRouteLeakDetection(upstreams, downstreams, peers),
        bgp_he_net: bgpHeData || null,
        atlas: {
          total_probes: atlasProbes.length,
          connected: atlasConnected.length,
          disconnected: atlasProbes.length - atlasConnected.length,
          anchors: atlasAnchors.length,
          probes: atlasProbes.slice(0, 100).map(p => ({
            id: p.id,
            status: p.status_name || p.status || "Unknown",
            is_anchor: p.is_anchor || false,
            country: p.country_code || "",
            prefix_v4: p.prefix_v4 || "",
            prefix_v6: p.prefix_v6 || "",
            description: p.description || "",
          })),
        },
        data_quality: dataQuality,
        source_timing: sourceTiming,
        contacts: (() => {
          const pocs = (pocData && pocData.data) ? pocData.data : [];
          return pocs.slice(0, 20).map(p => ({
            role: p.role || "",
            name: p.name || "",
            email: p.email || "",
            url: p.url || "",
            visible: p.visible || "",
          }));
        })(),
        registration: (() => {
          const events = (rdapData && rdapData.events) ? rdapData.events : [];
          const created   = (events.find(e => e.eventAction === "registration")  || {}).eventDate || "";
          const lastChg   = (events.find(e => e.eventAction === "last changed")  || {}).eventDate || "";
          return {
            created:       created   ? created.slice(0, 10)   : "",
            last_modified: lastChg   ? lastChg.slice(0, 10)   : "",
            rir:           rir       || "",
            handle:        (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn),
            rdap_source:   (rdapData && rdapData.port43) ? rdapData.port43 : "",
          };
        })(),
        _provenance: {
          prefixes:       { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high",   note: "Cross-checked with bgp.he.net prefix count daily" },
          neighbours:     { source: "RIPE Stat asn-neighbours",     validation: "cross-validated", confidence: "high",   note: "Cross-checked with bgp.he.net peer count daily" },
          rpki:           { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" },
          ix_presence:    { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" },
          facilities:     { source: "PeeringDB netfac (local mirror)",  validation: "single-source",  confidence: "medium" },
          bgp_he_net:     { source: "bgp.he.net HTML scrape",           validation: "single-source",  confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" },
          atlas:          { source: "RIPE Atlas API",                   validation: "single-source",  confidence: "medium", note: "Probe availability varies by region" },
          resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed",  confidence: "high",   note: "All inputs cross-validated daily" },
          route_leak:     { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic",    confidence: "medium", note: "Pattern-based, not real-time — false positives possible" },
          registration:   { source: "RDAP (RIR registry)",              validation: "single-source",  confidence: "high" },
          contacts:       { source: "PeeringDB POC API",                validation: "single-source",  confidence: "medium", note: "Subject to PeeringDB rate limiting" },
        },
      };

      // Update duration to include cross-check time
      result.meta.duration_ms = Date.now() - start;

      cacheSet(cacheKey, result, CACHE_TTL_LOOKUP);
      res.end(JSON.stringify(result, null, 2));
    } catch (err) {
      const duration = Date.now() - start;
      res.writeHead(500);
      res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration }));
    }
    return;
  }

  // ============================================================
  // AS Relationships endpoint: /api/relationships?asn=X
  // Returns upstream providers, downstream customers, and peers
  // with resolved names. Based on RIPE Stat asn-neighbours.
  // ============================================================
  if (reqPath === "/api/relationships") {
    const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
    if (!rawAsn) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
    }
    const cacheKey = "relationships:" + rawAsn;
    const cached = cacheGet(cacheKey);
    if (cached) {
      res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
      return res.end(JSON.stringify(cached));
    }
    const start = Date.now();
    try {
      const neighbourData = await fetchRipeStatCached(
        "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn + "&lod=1",
        { timeout: 8000 }
      );
      const neighbours = (neighbourData && neighbourData.data && neighbourData.data.neighbours) || [];
      const counts = (neighbourData && neighbourData.data && neighbourData.data.neighbour_counts) || {};

      const upstreams = neighbours.filter(n => n.type === "left").sort((a,b) => (b.power||0)-(a.power||0));
      const downstreams = neighbours.filter(n => n.type === "right").sort((a,b) => (b.power||0)-(a.power||0));
      const peers = neighbours.filter(n => n.type === "uncertain").sort((a,b) => (b.power||0)-(a.power||0));

      // Resolve AS names for top entries (upstreams + downstreams all, top 20 peers)
      const toResolve = [...upstreams, ...downstreams, ...peers.slice(0, 20)];
      const resolvedNames = {};
      await Promise.race([
        Promise.all(toResolve.map(n =>
          fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
            .then(r => { if (r && r.data && r.data.holder) resolvedNames[n.asn] = r.data.holder; })
            .catch(() => {})
        )),
        new Promise(r => setTimeout(r, 5000)),
      ]);

      // ---- Threat Intelligence Enrichment ----
      // Enrich neighbors with threat status from local threat_intel table
      const threatMap = {};
      const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers];
      const threatPromises = allNeighborsForThreat.slice(0, 100).map(async (n) => {
        try {
          const asNum = String(n.asn);
          const threat = await localDb.getThreatIntel(asNum);
          if (threat) {
            threatMap[n.asn] = {
              threat_level: threat.threat_level,
              confidence_score: threat.confidence_score,
              source: threat.source,
            };
          }
        } catch (e) {
          console.error(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
        }
      });
      await Promise.race([
        Promise.all(threatPromises),
        new Promise(r => setTimeout(r, 3000)),
      ]);

      const fmt = n => ({
        asn: n.asn,
        name: resolvedNames[n.asn] || "",
        power: n.power || 0,
        v4_peers: n.v4_peers || 0,
        v6_peers: n.v6_peers || 0,
        threat_level: threatMap[n.asn]?.threat_level || null,
        threat_confidence: threatMap[n.asn]?.confidence_score || null,
        threat_source: threatMap[n.asn]?.source || null,
      });

      const result = {
        asn: parseInt(rawAsn),
        query_time: new Date().toISOString(),
        duration_ms: Date.now() - start,
        counts: {
          upstreams: counts.left || upstreams.length,
          downstreams: counts.right || downstreams.length,
          peers_total: counts.unique || peers.length,
          uncertain: counts.uncertain || peers.length,
        },
        upstreams: upstreams.map(fmt),
        downstreams: downstreams.map(fmt),
        peers: peers.slice(0, 50).map(fmt),
        methodology: "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).",
        source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn,
      };

      cacheSet(cacheKey, result, 10 * 60 * 1000); // 10 min cache
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(result, null, 2));
    } catch (err) {
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "Relationships lookup failed", message: err.message }));
    }
  }

  // ============================================================
  // Compare endpoint: /api/compare?asn1=X&asn2=Y
  // ============================================================
  if (reqPath === "/api/compare") {
    const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, "");
    const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, "");
    if (!asn1 || !asn2) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" }));
    }

    const compareCacheKey = "compare:" + asn1 + ":" + asn2;
    const compareCached = cacheGet(compareCacheKey);
    if (compareCached) {
      res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
      return res.end(JSON.stringify(compareCached));
    }
    const start = Date.now();
    try {
      // ALL calls in parallel — single batch
      // Phase 1: Get PDB net objects + RIPE data
      const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([
        fetchPeeringDB("/net?asn=" + asn1),
        fetchPeeringDB("/net?asn=" + asn2),
        fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }),
        fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }),
        fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }),
        fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }),
      ]);

      const net1 = pdb1?.data?.[0] || {};
      const net2 = pdb2?.data?.[0] || {};
      const netId1 = net1.id;
      const netId2 = net2.id;

      // Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn)
      const ixFacPromises = [];
      ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null));
      ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null));
      ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null));
      ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null));
      const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises);

      const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id));
      const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id));
      const ix1Names = {};
      (ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name));
      const ix2Names = {};
      (ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name));

      const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" }));
      const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" }));
      const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" }));

      const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id));
      const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id));
      const fac1Names = {};
      (fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name));
      const fac2Names = {};
      (fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name));

      const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" }));

      const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left");
      const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left");
      const up1Set = new Set(nb1.map((n) => n.asn));
      const up2Set = new Set(nb2.map((n) => n.asn));
      const nb1Map = {};
      nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || ""));
      const nb2Map = {};
      nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || ""));

      const commonUpstreams = [...up1Set]
        .filter((a) => up2Set.has(a))
        .map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" }));

      // ---- Threat Intelligence Enrichment for Common Upstreams ----
      const threatMap = {};
      const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => {
        try {
          const asNum = String(n.asn);
          const threat = await localDb.getThreatIntel(asNum);
          if (threat) {
            threatMap[n.asn] = {
              threat_level: threat.threat_level,
              confidence_score: threat.confidence_score,
              source: threat.source,
            };
          }
        } catch (e) {
          console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
        }
      });
      await Promise.race([
        Promise.all(threatPromises),
        new Promise(r => setTimeout(r, 2000)),
      ]);

      // Attach threat status to upstream objects
      commonUpstreams.forEach((n) => {
        if (threatMap[n.asn]) {
          n.threat_level = threatMap[n.asn].threat_level;
          n.threat_confidence = threatMap[n.asn].confidence_score;
          n.threat_source = threatMap[n.asn].source;
        }
      });

      // Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout
      const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
      const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
      const [, rpki1Results, rpki2Results] = await Promise.race([
        Promise.all([
          commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n =>
            fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
              .then(r => { if (r?.data?.holder) n.name = r.data.holder; })
              .catch(() => {})
          )) : Promise.resolve([]),
          Promise.all(pfx1.map((p) => fetchRPKIPerPrefix(asn1, p))),
          Promise.all(pfx2.map((p) => fetchRPKIPerPrefix(asn2, p))),
        ]),
        new Promise(r => setTimeout(() => r([[], [], []]), 5000)),
      ]);

      const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length;
      const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length;
      const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0;
      const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0;

      const duration = Date.now() - start;
      const compareResult = {
            meta: { duration_ms: duration, timestamp: new Date().toISOString() },
            asn1: {
              asn: parseInt(asn1),
              name: net1.name || "Unknown",
              ix_count: ix1Set.size,
              fac_count: fac1Set.size,
              upstream_count: up1Set.size,
              rpki_coverage: rpki1Pct,
            },
            asn2: {
              asn: parseInt(asn2),
              name: net2.name || "Unknown",
              ix_count: ix2Set.size,
              fac_count: fac2Set.size,
              upstream_count: up2Set.size,
              rpki_coverage: rpki2Pct,
            },
            common_ixps: commonIX,
            only_asn1_ixps: only1IX,
            only_asn2_ixps: only2IX,
            common_facilities: commonFac,
            common_upstreams: commonUpstreams,
            rpki_comparison: {
              asn1_coverage: rpki1Pct,
              asn2_coverage: rpki2Pct,
              asn1_checked: rpki1Results.length,
              asn2_checked: rpki2Results.length,
              better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal",
            },
          };
      cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT);
      res.end(JSON.stringify(compareResult, null, 2));
    } catch (err) {
      res.writeHead(500);
      res.end(JSON.stringify({ error: "Compare failed", message: err.message }));
    }
    return;
  }


  // ============================================================
  // Quick-IX endpoint: /api/quick-ix?asn=X
  // Lightweight: only IX connections from PeeringDB, 1h cached
  // Used by Peering Recommendations to avoid 20x full lookups
  // ============================================================
  if (reqPath === "/api/quick-ix") {
    const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
    if (!rawAsn) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing asn parameter" }));
    }
    const cached = quickIxCacheGet(rawAsn);
    if (cached !== undefined) {
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(cached));
    }
    try {
      const [pdbNetData, pdbIxlanData] = await Promise.all([
        fetchJSON("https://www.peeringdb.com/api/net?asn=" + rawAsn + "&depth=0", { timeout: 5000 }).catch(() => null),
        fetchJSON("https://www.peeringdb.com/api/netixlan?asn=" + rawAsn + "&limit=100", { timeout: 6000 }).catch(() => null),
      ]);
      const netName = pdbNetData?.data?.[0]?.name || "";
      const ixConnections = [];
      if (pdbIxlanData && pdbIxlanData.data) {
        pdbIxlanData.data.forEach((row) => {
          ixConnections.push({ ix_id: row.ixlan_id, name: row.name || "", speed: row.speed || 0 });
        });
      }
      // Fall back to RIPE Stat if PeeringDB returns nothing
      if (ixConnections.length === 0) {
        const rsStat = await fetchRipeStatCached("https://stat.ripe.net/data/ixs/data.json?resource=AS" + rawAsn, { timeout: 5000 }).catch(() => null);
        const ixs = rsStat?.data?.ixs || [];
        ixs.forEach((ix) => ixConnections.push({ ix_id: ix.ixp_id || 0, name: ix.name || "", speed: 0 }));
      }
      const result = { asn: parseInt(rawAsn), name: netName, ix_connections: ixConnections };
      quickIxCacheSet(rawAsn, result);
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify(result));
    } catch (err) {
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "quick-ix lookup failed", message: err.message }));
    }
  }

  // ============================================================
  // Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000
  // ============================================================
  if (reqPath === "/api/peers/find") {
    const ixName = url.searchParams.get("ix") || "";
    const policy = url.searchParams.get("policy") || "";
    const minSpeed = parseInt(url.searchParams.get("min_speed") || "0");
    const netType = url.searchParams.get("type") || "";

    if (!ixName) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing ix parameter (IX name)" }));
    }

    const start = Date.now();
    try {
      // Search for IX by name
      const ixSearch = await fetchPeeringDB("/ix?name__contains=" + encodeURIComponent(ixName));
      const ixResults = ixSearch?.data || [];
      if (ixResults.length === 0) {
        return res.end(JSON.stringify({ error: "No IX found matching: " + ixName, matches: [] }));
      }

      // Use first matching IX
      const ix = ixResults[0];
      const ixId = ix.id;

      // Get ixlan for this IX
      const ixlanData = await fetchPeeringDB("/ixlan?ix_id=" + ixId);
      const ixlans = ixlanData?.data || [];
      if (ixlans.length === 0) {
        return res.end(JSON.stringify({ ix: { id: ixId, name: ix.name }, matches: [] }));
      }

      const ixlanId = ixlans[0].id;

      // Get all networks at this IX
      const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
      const netixlans = netixlanData?.data || [];

      // Get unique net_ids
      const netIds = [...new Set(netixlans.map(n => n.net_id))];

      // Fetch network details in batches
      const networks = [];
      const batchSize = 20;
      for (let i = 0; i < Math.min(netIds.length, 200); i += batchSize) {
        const batch = netIds.slice(i, i + batchSize);
        const batchResults = await Promise.all(
          batch.map(nid => fetchPeeringDB("/net/" + nid))
        );
        batchResults.forEach(r => {
          if (r?.data?.[0]) networks.push(r.data[0]);
        });
      }

      // Filter and rank
      let filtered = networks.map(net => {
        const nix = netixlans.filter(n => n.net_id === net.id);
        const maxSpeed = Math.max(...nix.map(n => n.speed || 0));
        return {
          asn: net.asn,
          name: net.name || "",
          policy: net.policy_general || "",
          type: net.info_type || "",
          speed_mbps: maxSpeed,
          speed_gbps: maxSpeed >= 1000 ? (maxSpeed / 1000) + " Gbps" : maxSpeed + " Mbps",
          traffic: net.info_traffic || "",
          website: net.website || "",
          peeringdb_id: net.id,
          ipv4: nix[0]?.ipaddr4 || null,
          ipv6: nix[0]?.ipaddr6 || null,
        };
      });

      // Apply filters
      if (policy) {
        filtered = filtered.filter(n => n.policy.toLowerCase().includes(policy.toLowerCase()));
      }
      if (minSpeed > 0) {
        filtered = filtered.filter(n => n.speed_mbps >= minSpeed);
      }
      if (netType) {
        filtered = filtered.filter(n => n.type.toLowerCase().includes(netType.toLowerCase()));
      }

      // Sort by speed desc
      filtered.sort((a, b) => b.speed_mbps - a.speed_mbps);

      // Also find common IXPs for each match (check if they share other IXPs)
      const duration = Date.now() - start;
      return res.end(JSON.stringify({
        meta: { duration_ms: duration, timestamp: new Date().toISOString() },
        ix: { id: ixId, name: ix.name, ixlan_id: ixlanId },
        total_members: netixlans.length,
        filtered_count: filtered.length,
        matches: filtered.slice(0, 50),
      }, null, 2));
    } catch (err) {
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "Peer matching failed", message: err.message }));
    }
  }

  // ============================================================
  // Prefix Detail endpoint: /api/prefix/detail?prefix=X
  // ============================================================
  if (reqPath === "/api/prefix/detail") {
    const prefix = url.searchParams.get("prefix") || "";
    if (!prefix) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing prefix parameter" }));
    }
    const start = Date.now();
    try {
      const [routingStatus, visibility] = await Promise.all([
        fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)),
        fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)),
      ]);

      const origins = routingStatus?.data?.origins || [];
      const firstSeen = routingStatus?.data?.first_seen?.time || null;

      // RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls)
      let rpkiStatus = "unknown";
      let rpkiRoas = [];
      const originAsn = origins.length > 0 ? origins[0].asn : null;
      if (originAsn) {
        try {
          const localRpki = await validateRPKIWithCache(originAsn, prefix);
          rpkiStatus = localRpki.status;
          rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail
        } catch (e) {
          console.error("[Prefix Detail] RPKI validation error:", e.message);
          rpkiStatus = "unknown";
          rpkiRoas = [];
        }
      }
      var visData = visibility?.data?.visibilities || [];
      var risPeersSeeingIt = visData.length > 0 ? visData.filter(v => v.ris_peers_seeing > 0).length : 0;
      var visibilitySource = "ripe_stat";
      // bgproutes.io fallback if RIPE Stat visibility returned no data
      if (visData.length === 0 && BGPROUTES_API_KEY) {
        var bgprVis = await fetchBgproutesVisibility(prefix);
        if (bgprVis && bgprVis.vps_seeing > 0) {
          risPeersSeeingIt = bgprVis.vps_seeing;
          visData = []; // keep empty, use risPeersSeeingIt
          visibilitySource = "bgproutes.io";
        }
      }

      // Try to get IRR data
      let irrStatus = "unknown";
      try {
        const whoisData = await fetchRipeStatCached("https://stat.ripe.net/data/whois/data.json?resource=" + encodeURIComponent(prefix));
        const records = whoisData?.data?.records || [];
        if (records.length > 0) irrStatus = "found";
      } catch(_e) {}

      const duration = Date.now() - start;
      return res.end(JSON.stringify({
        meta: { duration_ms: duration, timestamp: new Date().toISOString() },
        prefix: prefix,
        origins: origins.map(o => ({ asn: o.asn, prefix: o.prefix })),
        rpki: { status: rpkiStatus, validating_roas: rpkiRoas.length },
        irr_status: irrStatus,
        visibility: { ris_peers_seeing: risPeersSeeingIt, total_probes: visData.length || risPeersSeeingIt, source: visibilitySource },
        first_seen: firstSeen,
      }, null, 2));
    } catch (err) {
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "Prefix detail failed", message: err.message }));
    }
  }

  // ============================================================
  // IX Detail endpoint: /api/ix/detail?ix_id=X
  // ============================================================
  if (reqPath === "/api/ix/detail") {
    const ixId = (url.searchParams.get("ix_id") || "").replace(/[^0-9]/g, "");
    if (!ixId) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing ix_id parameter" }));
    }
    const start = Date.now();
    try {
      const [ixData, ixlanData] = await Promise.all([
        fetchPeeringDB("/ix/" + ixId),
        fetchPeeringDB("/ixlan?ix_id=" + ixId),
      ]);

      const ix = ixData?.data?.[0] || {};
      const ixlans = ixlanData?.data || [];
      const ixlanId = ixlans.length > 0 ? ixlans[0].id : null;

      let members = [];
      if (ixlanId) {
        const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
        members = (netixlanData?.data || []).map(m => ({
          asn: m.asn,
          name: m.name || "",
          speed_mbps: m.speed || 0,
          speed_display: (m.speed || 0) >= 1000 ? ((m.speed || 0) / 1000) + " Gbps" : (m.speed || 0) + " Mbps",
          ipv4: m.ipaddr4 || null,
          ipv6: m.ipaddr6 || null,
        }));
      }

      // Sort by speed desc for top members
      const sorted = members.slice().sort((a, b) => b.speed_mbps - a.speed_mbps);

      const duration = Date.now() - start;
      return res.end(JSON.stringify({
        meta: { duration_ms: duration, timestamp: new Date().toISOString() },
        ix: {
          id: parseInt(ixId),
          name: ix.name || "",
          city: ix.city || "",
          country: ix.country || "",
          website: ix.website || "",
          peeringdb_url: "https://www.peeringdb.com/ix/" + ixId,
        },
        total_members: members.length,
        top_members_by_speed: sorted.slice(0, 20),
        all_members: sorted,
      }, null, 2));
    } catch (err) {
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "IX detail failed", message: err.message }));
    }
  }


  // ============================================================
  // Feature 25: Topology endpoint
  // ============================================================
  if (reqPath === "/api/topology") {
    const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
    const depth = parseInt(url.searchParams.get("depth") || "2") || 2;
    if (!rawAsn) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
    }
    const start = Date.now();
    try {
      const topology = await fetchTopology(parseInt(rawAsn), depth);

      // ---- Threat Intelligence Enrichment for Topology Nodes ----
      const threatMap = {};
      const threatPromises = topology.nodes.slice(0, 100).map(async (node) => {
        try {
          const asNum = String(node.asn);
          const threat = await localDb.getThreatIntel(asNum);
          if (threat) {
            threatMap[node.asn] = {
              threat_level: threat.threat_level,
              confidence_score: threat.confidence_score,
              source: threat.source,
            };
          }
        } catch (e) {
          console.error(`[Topology Threat Lookup] Error checking ASN ${node.asn}:`, e.message);
        }
      });
      await Promise.race([
        Promise.all(threatPromises),
        new Promise(r => setTimeout(r, 3000)),
      ]);

      // Attach threat status to node objects
      topology.nodes.forEach((node) => {
        if (threatMap[node.asn]) {
          node.threat_level = threatMap[node.asn].threat_level;
          node.threat_confidence = threatMap[node.asn].confidence_score;
          node.threat_source = threatMap[node.asn].source;
        }
      });

      topology.meta = {
        query: "AS" + rawAsn, depth: depth, duration_ms: Date.now() - start,
        timestamp: new Date().toISOString(), node_count: topology.nodes.length, edge_count: topology.edges.length,
      };
      return res.end(JSON.stringify(topology, null, 2));
    } catch (err) {
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "Topology query failed", message: err.message }));
    }
  }

  // ============================================================
  // Feature 27: WHOIS endpoint
  // ============================================================
  if (reqPath === "/api/whois") {
    const resource = url.searchParams.get("resource") || "";
    if (!resource) {
      res.writeHead(400);
      return res.end(JSON.stringify({ error: "Missing resource parameter (ASN, prefix, or domain)" }));
    }
    const start = Date.now();
    try {
      const whoisResult = await fetchWhois(resource);
      if (!whoisResult || typeof whoisResult !== "object") {
        res.writeHead(503);
        return res.end(JSON.stringify({ error: "WHOIS data temporarily unavailable" }));
      }
      whoisResult.meta = { duration_ms: Date.now() - start, timestamp: new Date().toISOString() };
      return res.end(JSON.stringify(whoisResult, null, 2));
    } catch (err) {
      res.writeHead(500);
      return res.end(JSON.stringify({ error: "WHOIS lookup failed", message: err.message }));
    }
  }

  // Feature 28b: Company enrichment via Wikipedia + website meta scraping
  if (reqPath === "/api/enrich") {
    res.setHeader("Content-Type", "application/json");
    res.setHeader("Access-Control-Allow-Origin", "*");
    res.setHeader("Cache-Control", "no-store");
    const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
    const companyName = (url.searchParams.get("name") || "").trim();
    const website = (url.searchParams.get("website") || "").trim();
    const UA_SCRAPE = "Mozilla/5.0 (compatible; PeerCortex/1.0; +https://peercortex.org)";

    let description = null;
    let wikiUrl = null;

    try {
      // 1. Direct Wikipedia lookup by company name
      if (companyName) {
        const nameLower = companyName.toLowerCase();
        const isRelevant = (title, extract) => {
          const titleLower = (title || "").toLowerCase();
          const extractLower = (extract || "").toLowerCase();
          const nameWords = nameLower.replace(/\s+(gmbh|ag|ltd|inc|llc|bv|sa|sas|oy|ab)\s*$/i, "").split(/\s+/).filter(w => w.length > 3);
          const titleMatch = nameWords.some(w => titleLower.includes(w));
          const netTerms = ["internet", "network", "isp", "hosting", "provider", "transit", "data center", "datacenter", "telecommunications", "telecom", "bandwidth", "peering", "routing", "autonomous system", "colocation", "colo", "fiber", "optical", "transceiver"];
          const hasNetContext = netTerms.some(t => extractLower.includes(t));
          return titleMatch && (hasNetContext || titleMatch);
        };

        // Direct title lookup — try full name first, then first word as fallback
        const cleanName = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC|BV|SA|SAS|Oy|AB)$/i, "").trim();
        const firstName = cleanName.split(/\s+/)[0];
        const namesToTry = cleanName === firstName ? [cleanName] : [cleanName, firstName];
        for (const tryName of namesToTry) {
          const wikiDirect = await fetchJSON(
            "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(tryName),
            { timeout: 5000 }
          );
          if (wikiDirect && wikiDirect.type === "disambiguation") continue; // skip disambiguation pages
          if (wikiDirect && wikiDirect.extract && wikiDirect.extract.length > 30 && isRelevant(wikiDirect.title, wikiDirect.extract)) {
            description = wikiDirect.extract.replace(/\s+/g, " ").trim().slice(0, 300);
            wikiUrl = wikiDirect.content_urls && wikiDirect.content_urls.desktop && wikiDirect.content_urls.desktop.page;
            break;
          }
        }

        // 2. Wikipedia search if direct lookup didn't match
        if (!description) {
          const searchQuery = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC)$/i, "") + " internet service provider";
          const searchData = await fetchJSON(
            "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=" + encodeURIComponent(searchQuery) + "&limit=3",
            { timeout: 5000 }
          );
          if (searchData && Array.isArray(searchData) && searchData[1] && searchData[1].length > 0) {
            const topTitle = searchData[1][0];
            const wikiSearch = await fetchJSON(
              "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(topTitle),
              { timeout: 5000 }
            );
            if (wikiSearch && wikiSearch.extract && wikiSearch.extract.length > 30) {
              if (isRelevant(wikiSearch.title, wikiSearch.extract)) {
                description = wikiSearch.extract.replace(/\s+/g, " ").trim().slice(0, 300);
                wikiUrl = wikiSearch.content_urls && wikiSearch.content_urls.desktop && wikiSearch.content_urls.desktop.page;
              }
            }
          }
        }
      }

      // 3. Fallback: scrape website meta description (follows up to 3 redirects)
      function fetchPage(pageUrl, hops) {
        if (hops <= 0) return Promise.resolve(null);
        return new Promise((resolve) => {
          const mod = pageUrl.startsWith("https") ? https : http;
          const req = mod.get(pageUrl, { headers: { "User-Agent": UA_SCRAPE }, timeout: 6000 }, (res) => {
            if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
              res.resume(); // drain to free socket
              const next = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, pageUrl).href;
              return resolve(fetchPage(next, hops - 1));
            }
            let data = "";
            res.on("data", (c) => { data += c; if (data.length > 40000) { req.destroy(); resolve(data); } });
            res.on("end", () => resolve(data));
          });
          req.on("error", () => resolve(null));
          req.on("timeout", () => { req.destroy(); resolve(null); });
        });
      }
      if (!description && website) {
        let wsUrl = website;
        if (!wsUrl.startsWith("http")) wsUrl = "https://" + wsUrl;
        const aboutUrl = wsUrl.replace(/\/$/, "") + "/about";
        const tryUrls = [aboutUrl, wsUrl];
        for (const tryUrl of tryUrls) {
          const resp = await fetchPage(tryUrl, 3);
          if (!resp) continue;
          const metaPatterns = [
            /<meta[^>]+name=["']description["'][^>]+content=["']([^"']{20,500})["']/i,
            /<meta[^>]+content=["']([^"']{20,500})["'][^>]+name=["']description["']/i,
            /<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']{20,500})["']/i,
            /<meta[^>]+content=["']([^"']{20,500})["'][^>]+property=["']og:description["']/i,
          ];
          let matched = false;
          for (const pat of metaPatterns) {
            const m = resp.match(pat);
            if (m && m[1]) {
              description = m[1].replace(/&#10;|&#10003;|&amp;/g, " ").replace(/\s+/g, " ").trim().slice(0, 300);
              matched = true;
              break;
            }
          }
          if (matched) break;
        }
      }
    } catch (_e) {
      // Return whatever we have
    }

    return res.end(JSON.stringify({ asn: rawAsn, description, wiki_url: wikiUrl }));
  }

  // Feature 28: Submarine Cable overlay (TeleGeography proxy)
  // ── Submarine Cables map data ─────────────────────────────────
  // Migrated to src/features/submarine-cables/

  // ── Global datacenter/IXP map (PeeringDB proxy) ───────────────
  // Migrated to src/features/global-infra/


  // ── Changelog page ─────────────────────────────────────────
  if (reqPath === '/changelog') {
    try {
      const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8');
      const lines = md.split('\n');
      let html = '';
      for (const line of lines) {
        if (line.startsWith('## ')) {
          html += `<h2 style="font-family:var(--serif);font-size:1.4rem;font-weight:800;margin:2rem 0 .5rem;border-top:2px solid var(--text);padding-top:1rem">${line.slice(3)}</h2>`;
        } else if (line.startsWith('### ')) {
          html += `<h3 style="font-family:var(--body);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:1rem 0 .4rem">${line.slice(4)}</h3>`;
        } else if (line.startsWith('- **')) {
          const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '<strong>$1</strong>$2');
          html += `<p style="font-family:var(--body);font-size:.85rem;margin:.2rem 0;padding-left:1rem;border-left:2px solid var(--border)">· ${m}</p>`;
        } else if (line.startsWith('- ')) {
          html += `<p style="font-family:var(--body);font-size:.82rem;margin:.15rem 0;color:var(--muted);padding-left:1rem">· ${line.slice(2)}</p>`;
        } else if (line.startsWith('# ')) {
          html += `<h1 style="font-family:var(--serif);font-size:2rem;font-weight:900;margin-bottom:.25rem">${line.slice(2)}</h1>`;
        }
      }
      const page = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>PeerCortex Changelog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#F5F2EC;--text:#1C1917;--muted:#57534E;--border:#C9C3B6;--serif:'Playfair Display',Georgia,serif;--body:'Source Serif 4',Georgia,serif;--mono:'IBM Plex Mono',monospace;--purple:#B83A1B}
body{font-family:var(--body);background:var(--bg);color:var(--text);max-width:760px;margin:0 auto;padding:2rem}
a{color:var(--purple);text-decoration:none}
.back{font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-bottom:2rem;display:block}
body.dark{--bg:#0f0f0f;--text:#e8e4dc;--muted:#a09890;--border:#333}
</style></head><body>
<a href="/" class="back">← peercortex.org</a>
${html}
<p style="margin-top:3rem;font-family:var(--mono);font-size:.6rem;color:var(--muted)">PeerCortex · v0.5.0 · Open Source · MIT</p>
</body></html>`;
      res.setHeader('Content-Type', 'text/html; charset=utf-8');
      res.setHeader('Cache-Control', 'no-store');
      res.writeHead(200);
      return res.end(page);
    } catch(e) {
      res.writeHead(500); return res.end('Changelog not available');
    }
  }

  // ── BGP Community Decoder ────────────────────────────────────
  // Migrated to src/features/bgp-communities/

  // ── IRR Audit ─────────────────────────────────────────────────
  // Migrated to src/features/irr-audit/


  // ── AS-SET Expander ───────────────────────────────────────────
  // Migrated to src/features/asset-expand/
  
  // ── Routing History (prefix table via RIPE Stat routing-history) ──
  // Migrated to src/features/rpki-history/

  // ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
  // Migrated to src/features/aspath/

  // ── Looking Glass (RIPE Stat) ─────────────────────────────────
  // Migrated to src/features/looking-glass/

  // ── IXP Peering Matrix ────────────────────────────────────────
  // Migrated to src/features/ix-matrix/


  // ── Hijack Subscribe ──────────────────────────────────────────
  // Migrated to src/features/hijack-subscribe/

  // ── Hijack Alerts (legacy read) ───────────────────────────────
  // Migrated to src/routes/hijack-alerts (Fastify feature)

  // ── Changelog JSON API ────────────────────────────────────────
  // Migrated to src/features/changelog/

  // ── bio-rd RIB routes ─────────────────────────────────────────
  // Migrated to src/features/rib/

  // ── Prefix Changes ──────────────────────────────────────────────
  // Migrated to src/features/prefix-changes/

  // 404
  res.writeHead(404);
  res.end(
    JSON.stringify({
      error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X",
    })
  );
});


// ============================================================
// Atlas Probe Cache (for Lia's Atlas Paradise)
// ============================================================
let atlasProbeCache = null;
let atlasProbeFetching = false;

function fetchAllAtlasProbes() {
  if (atlasProbeFetching) return Promise.resolve();
  atlasProbeFetching = true;
  console.log("[ATLAS] Fetching all Atlas probes...");

  return new Promise(function(resolve) {
    var allAsns = new Set();
    var byCountry = {};
    var pageCount = 0;
    var maxPages = 40;

    function fetchPage(pageUrl) {
      if (pageCount >= maxPages) return finish();
      pageCount++;

      fetchJSON(pageUrl).then(function(data) {
        if (!data || !data.results) return finish();

        data.results.forEach(function(probe) {
          var asn4 = probe.asn_v4;
          var asn6 = probe.asn_v6;
          var cc = probe.country_code || "XX";

          if (!byCountry[cc]) byCountry[cc] = { total: 0, connected: 0, asnSet: new Set() };
          byCountry[cc].total++;
          if (probe.status && probe.status.id === 1) byCountry[cc].connected++;
          if (asn4) { allAsns.add(asn4); byCountry[cc].asnSet.add(asn4); }
          if (asn6) { allAsns.add(asn6); byCountry[cc].asnSet.add(asn6); }
        });

        if (data.next) {
          fetchPage(data.next);
        } else {
          finish();
        }
      }).catch(function() { finish(); });
    }

    function finish() {
      var byCountryOut = {};
      Object.keys(byCountry).forEach(function(cc) {
        var info = byCountry[cc];
        byCountryOut[cc] = { total: info.total, connected: info.connected, asn_count: info.asnSet.size };
      });

      atlasProbeCache = {
        total_probes: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].total; }, 0),
        total_connected: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].connected; }, 0),
        unique_asns_with_probes: allAsns.size,
        asns_with_probes: Array.from(allAsns).sort(function(a, b) { return a - b; }),
        by_country: byCountryOut,
        fetched_at: new Date().toISOString(),
        pages_fetched: pageCount,
      };

      console.log("[ATLAS] Loaded " + allAsns.size + " unique ASNs with probes (" + pageCount + " pages)");
      atlasProbeFetching = false;
      resolve();
    }

    fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json");
  });
}

// ============================================================
// PeeringDB Org → Country Cache (for Lia's Paradise)
// ============================================================
let pdbOrgCountryMap = new Map(); // org_id → { country, name }

function fetchPdbOrgCountries() {
  var cacheFile = require("path").join(__dirname, ".pdb-org-cache.json");
  var fs = require("fs");
  
  // Try disk cache first (valid for 24h)
  try {
    var stat = fs.statSync(cacheFile);
    var ageHours = (Date.now() - stat.mtimeMs) / 3600000;
    if (ageHours < 24) {
      var cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
      pdbOrgCountryMap = new Map(Object.entries(cached));
      console.log("[PDB-ORG] Loaded " + pdbOrgCountryMap.size + " orgs from disk cache (" + Math.round(ageHours) + "h old)");
      return Promise.resolve();
    }
  } catch (_) { /* no cache or invalid */ }

  console.log("[PDB-ORG] Fetching PeeringDB org countries (fresh)...");
  return new Promise(function(resolve) {
    var chunks = [];
    var req = require("https").get("https://www.peeringdb.com/api/org?status=ok&depth=0", {
      headers: {
        "User-Agent": UA,
        "Authorization": PEERINGDB_API_KEY ? "Api-Key " + PEERINGDB_API_KEY : undefined,
      },
      timeout: 120000,
    }, function(res) {
      if (res.statusCode !== 200) {
        console.error("[PDB-ORG] HTTP " + res.statusCode + " — using stale cache or empty");
        resolve();
        return;
      }
      res.on("data", function(chunk) { chunks.push(chunk); });
      res.on("end", function() {
        try {
          var body = Buffer.concat(chunks).toString("utf8");
          var data = JSON.parse(body);
          if (data && data.data) {
            pdbOrgCountryMap = new Map();
            var cacheObj = {};
            data.data.forEach(function(o) {
              if (o.id && o.country) {
                pdbOrgCountryMap.set(o.id, { country: o.country, name: o.name || "" });
                cacheObj[o.id] = { country: o.country, name: o.name || "" };
              }
            });
            // Save to disk cache
            try { fs.writeFileSync(cacheFile, JSON.stringify(cacheObj)); } catch (_) {}
            console.log("[PDB-ORG] Loaded " + pdbOrgCountryMap.size + " org→country mappings (cached to disk)");
          }
        } catch (e) {
          console.error("[PDB-ORG] Parse error:", e.message);
        }
        resolve();
      });
    });
    req.on("error", function(e) {
      console.error("[PDB-ORG] Fetch error:", e.message);
      resolve();
    });
    req.on("timeout", function() {
      console.error("[PDB-ORG] Timeout after 120s");
      req.destroy();
      resolve();
    });
  });
}

const PORT = process.env.PORT || 3101;

// ============================================================
// Startup Sequence — load disk caches first, then fetch fresh data
// ============================================================

// Phase 0: Load disk caches for fast restart (instant)
roaStore.loadFromDisk("/opt/peercortex-app/.roa-cache.json");
pdbSourceCache.loadFromDisk("/opt/peercortex-app/.pdb-source-cache.json");
loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json");

// Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries + MANRS participants
ensureManrsCache(); // fire-and-forget, 24h cache
Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => {
  server.listen(PORT, "0.0.0.0", () => {
    console.log("PeerCortex v0.6.1 running on http://0.0.0.0:" + PORT);
    console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
    console.log("PeeringDB API key: " + (PEERINGDB_API_KEY ? "configured" : "NOT configured"));
    console.log("RPKI ASPA objects: " + rpkiAspaMap.size);
    console.log("ROA store: " + roaStore.count + " entries (" + (roaStore.ready ? "ready" : "loading...") + ")");
    console.log("PDB source cache: net=" + pdbSourceCache.net.size + " ix=" + pdbSourceCache.netixlan.size + " fac=" + pdbSourceCache.netfac.size);
    console.log("RIPE Stat cache: " + ripeStatCache.size + " entries");
  });
});

// ============================================================
// bio-rd RIB WebSocket — live route streaming on /ws/rib
// ============================================================
let WebSocketServer = null;
try { WebSocketServer = require('ws').Server; } catch(_e) {}

if (WebSocketServer) {
  const ribWss = new WebSocketServer({ server, path: '/ws/rib' });
  ribWss.on('connection', function(ws) {
    let cancelStream = null;

    ws.on('message', function(raw) {
      try {
        const msg = JSON.parse(raw);
        if (msg.type === 'rib-subscribe') {
          if (cancelStream) { cancelStream(); cancelStream = null; }
          if (!risClient) {
            ws.send(JSON.stringify({ type: 'error', error: 'bio-rd RIS not configured' }));
            return;
          }
          const router = msg.router || 'default';
          cancelStream = risClient.observeRib(
            router,
            'default',
            function(update) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(update)); },
            function(err) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: err.message })); }
          );
        }
      } catch(e) {
        if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: e.message }));
      }
    });

    ws.on('close', function() {
      if (cancelStream) { cancelStream(); cancelStream = null; }
    });
  });
  console.log('[bio-rd] RIB WebSocket server listening on /ws/rib');
} else {
  console.log('[bio-rd] WebSocket server skipped (ws package not installed)');
}

// ============================================================
// Refresh timers — jittered to avoid thundering herd
// ============================================================

// RPKI feed (ASPA + ROA): every 4h ± 5min jitter
setInterval(() => {
  fetchRpkiAspaFeed();
}, 4 * 60 * 60 * 1000 + Math.floor(Math.random() * 10 * 60 * 1000) - 5 * 60 * 1000);

// Atlas probe cache: every 12h ± 10min jitter
setInterval(function() {
  fetchAllAtlasProbes();
}, 12 * 60 * 60 * 1000 + Math.floor(Math.random() * 20 * 60 * 1000) - 10 * 60 * 1000);

// Disk cache persistence: every 30 minutes
setInterval(function() {
  pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json");
  saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json");
}, 30 * 60 * 1000);

// Save caches on graceful shutdown
process.on("SIGTERM", function() {
  console.log("[SHUTDOWN] Saving caches to disk...");
  pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json");
  saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json");
  roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
  process.exit(0);
});
process.on("SIGINT", function() {
  console.log("[SHUTDOWN] Saving caches to disk...");
  pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json");
  saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json");
  roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
  process.exit(0);
});
