// ============================================================================
// wifi_extended_scan.cpp — see wifi_extended_scan.h for design notes and
// honest per-capability limitations.
//
// Frame-parsing offsets follow the IEEE 802.11-2020 spec directly (stable,
// standardized wire formats -- not vendor-specific heuristics):
//   - 802.11 MAC header: 24 bytes for management frames and non-QoS data
//     frames; QoS data frames (subtype 0x8, bit3 of subtype set) add a
//     2-byte QoS Control field immediately after -- 26 bytes total.
//   - Action frame body starts immediately after the MAC header:
//     [0]=category, [1]=action code, ...
//   - EAPOL over 802.11 data: MAC header, then 8-byte LLC/SNAP
//     (AA AA 03 00 00 00 88 8E for EAPOL ethertype), then EAPOL header
//     (version[1] type[1] length[2] BE), then for EAPOL-Key (type=3):
//     descriptor_type[1] key_info[2 BE] key_length[2 BE]
//     replay_counter[8 BE] key_nonce[32] ...
//
// VALIDATION CAVEAT (matches subghz_scanner.cpp's own honesty standard):
// the pure classifiers (eapol_classify_key_info, wifi_ext_is_ftm_action_frame,
// wifi_ext_next_channel) are standard-compliant per the IEEE spec and fully
// unit tested against known real key_info/category/action-code values used
// by real WiFi stacks (matching what Wireshark/aircrack-ng/hashcat use to
// classify real captured handshakes) -- these are HIGH confidence. The
// live frame-parsing entry points (offset math into a raw captured buffer)
// have NOT been exercised against a real captured KRACK/PMKID-harvest/FTM
// attempt or a real rogue-DHCP/DNS-hijack -- same caveat subghz_scanner.h
// documents for its own live radio path. All parsers bounds-check the
// buffer length defensively before touching any offset and silently
// no-op (never crash, never process garbage) on any mismatch.
// ============================================================================

#include "wifi_extended_scan.h"

#include <cstring>

#define WIFI_EXT_TABLE_SIZE   16
#define WIFI_EXT_MAX_FINDINGS  8

// ---------------------------------------------------------------------------
// Pure classifiers — no radio access, always compiled, directly testable.
// ---------------------------------------------------------------------------

bool wifi_ext_is_ftm_action_frame(uint8_t category, uint8_t action_code) {
    // Category 4 = Public Action; FTM Request = 32, FTM = 33
    // (IEEE 802.11-2020 Table 9-360, "Public Action field values").
    // Category 5 = Radio Measurement Action, used by some FTM-adjacent
    // measurement-request exchanges.
    if (category == 4 && (action_code == 32 || action_code == 33)) return true;
    if (category == 5) return true;
    return false;
}

EapolHandshakeMsg eapol_classify_key_info(uint16_t key_info) {
    bool pairwise = (key_info >> 3) & 1;
    bool install  = (key_info >> 6) & 1;
    bool ack      = (key_info >> 7) & 1;
    bool mic      = (key_info >> 8) & 1;
    bool secure   = (key_info >> 9) & 1;
    (void)install;
    if (!pairwise) return EAPOL_MSG_UNKNOWN;   // group-key handshake, not 4-way
    if (ack  && !mic)            return EAPOL_MSG_1;
    if (!ack &&  mic && !secure) return EAPOL_MSG_2;
    if (ack  &&  mic &&  secure) return EAPOL_MSG_3;
    if (!ack &&  mic &&  secure) return EAPOL_MSG_4;
    return EAPOL_MSG_UNKNOWN;
}

uint8_t wifi_ext_next_channel(uint8_t current_channel) {
    if (current_channel < WIFI_EXT_CHANNEL_MIN || current_channel > WIFI_EXT_CHANNEL_MAX) {
        return WIFI_EXT_CHANNEL_MIN;
    }
    return (current_channel >= WIFI_EXT_CHANNEL_MAX) ? WIFI_EXT_CHANNEL_MIN
                                                      : (current_channel + 1);
}

// ---------------------------------------------------------------------------
// Shared state — bounded tables, no dynamic allocation.
// ---------------------------------------------------------------------------
namespace {

struct RttEntry { uint8_t bssid[6]; uint32_t last_seen_ms; uint32_t count; bool active; };
struct EapolM3Seen { uint8_t bssid[6]; uint8_t client[6]; uint8_t nonce[32]; uint32_t seen_ms; bool active; };
struct Msg1BurstCounter { uint8_t bssid[6]; uint32_t window_start_ms; uint32_t count; bool active; };
struct Wpa3Track { uint8_t bssid[6]; char ssid[33]; uint32_t sae_last_seen_ms; bool downgraded; bool active; };
struct DhcpXidTrack { uint32_t xid; uint8_t server_macs[4][6]; uint8_t server_count; uint32_t first_seen_ms; bool flagged; bool active; };
struct DnsQueryTrack { uint16_t qid; uint32_t answer_hashes[4]; uint8_t answer_count; uint32_t first_seen_ms; bool flagged; bool active; };

static RttEntry           s_rtt[WIFI_EXT_TABLE_SIZE];
static EapolM3Seen        s_m3_seen[WIFI_EXT_TABLE_SIZE];
static Msg1BurstCounter   s_msg1_burst[WIFI_EXT_TABLE_SIZE];
static Wpa3Track          s_wpa3[WIFI_EXT_TABLE_SIZE];
static DhcpXidTrack       s_dhcp[WIFI_EXT_TABLE_SIZE];
static DnsQueryTrack      s_dns[WIFI_EXT_TABLE_SIZE];

static KrackFinding       s_krack_findings[WIFI_EXT_MAX_FINDINGS];
static size_t             s_krack_finding_count = 0;
static PmkidBurstFinding  s_pmkid_findings[WIFI_EXT_MAX_FINDINGS];
static size_t             s_pmkid_finding_count = 0;
static Wpa3DowngradeFinding s_wpa3_findings[WIFI_EXT_MAX_FINDINGS];
static size_t             s_wpa3_finding_count = 0;
static RogueDhcpFinding   s_dhcp_findings[WIFI_EXT_MAX_FINDINGS];
static size_t             s_dhcp_finding_count = 0;
static DnsHijackFinding   s_dns_findings[WIFI_EXT_MAX_FINDINGS];
static size_t             s_dns_finding_count = 0;

static WifiExtendedScanStats s_stats{};

static bool mac_eq(const uint8_t* a, const uint8_t* b) { return memcmp(a, b, 6) == 0; }

}  // namespace

void wifi_extended_scan_init() {
    memset(s_rtt, 0, sizeof(s_rtt));
    memset(s_m3_seen, 0, sizeof(s_m3_seen));
    memset(s_msg1_burst, 0, sizeof(s_msg1_burst));
    memset(s_wpa3, 0, sizeof(s_wpa3));
    memset(s_dhcp, 0, sizeof(s_dhcp));
    memset(s_dns, 0, sizeof(s_dns));
    s_krack_finding_count = 0;
    s_pmkid_finding_count = 0;
    s_wpa3_finding_count  = 0;
    s_dhcp_finding_count  = 0;
    s_dns_finding_count   = 0;
    s_stats = WifiExtendedScanStats{};
    s_stats.current_channel = WIFI_EXT_CHANNEL_MIN;
}

// ---------------------------------------------------------------------------
// Internal: RTT/FTM observation
// ---------------------------------------------------------------------------
static void observe_rtt(const uint8_t* bssid, uint32_t now_ms) {
    int free_slot = -1;
    for (int i = 0; i < WIFI_EXT_TABLE_SIZE; i++) {
        if (s_rtt[i].active && mac_eq(s_rtt[i].bssid, bssid)) {
            s_rtt[i].last_seen_ms = now_ms;
            s_rtt[i].count++;
            return;
        }
        if (!s_rtt[i].active && free_slot < 0) free_slot = i;
    }
    if (free_slot < 0) free_slot = 0;   // evict oldest slot 0 if table full
    memcpy(s_rtt[free_slot].bssid, bssid, 6);
    s_rtt[free_slot].last_seen_ms = now_ms;
    s_rtt[free_slot].count = 1;
    s_rtt[free_slot].active = true;
}

// ---------------------------------------------------------------------------
// Internal: EAPOL Message-3 nonce-reuse (KRACK) + Message-1 burst (PMKID)
// ---------------------------------------------------------------------------
static void observe_eapol_msg3(const uint8_t* bssid, const uint8_t* client,
                               const uint8_t* nonce, uint32_t now_ms) {
    int free_slot = -1;
    for (int i = 0; i < WIFI_EXT_TABLE_SIZE; i++) {
        if (s_m3_seen[i].active && mac_eq(s_m3_seen[i].bssid, bssid) &&
            mac_eq(s_m3_seen[i].client, client)) {
            if (memcmp(s_m3_seen[i].nonce, nonce, 32) == 0) {
                // Same nonce reused for the same (BSSID, client) -- the
                // real KRACK signature (a legitimate retransmit still
                // increments the replay counter but does NOT reuse the
                // nonce value in a way that matters here; nonce reuse
                // itself is what re-installs an all-zero/known key).
                if (s_krack_finding_count < WIFI_EXT_MAX_FINDINGS) {
                    KrackFinding& f = s_krack_findings[s_krack_finding_count++];
                    memcpy(f.bssid, bssid, 6);
                    memcpy(f.client_mac, client, 6);
                    f.detected_at_ms = now_ms;
                }
            } else {
                memcpy(s_m3_seen[i].nonce, nonce, 32);
                s_m3_seen[i].seen_ms = now_ms;
            }
            return;
        }
        if (!s_m3_seen[i].active && free_slot < 0) free_slot = i;
    }
    if (free_slot < 0) free_slot = 0;
    memcpy(s_m3_seen[free_slot].bssid, bssid, 6);
    memcpy(s_m3_seen[free_slot].client, client, 6);
    memcpy(s_m3_seen[free_slot].nonce, nonce, 32);
    s_m3_seen[free_slot].seen_ms = now_ms;
    s_m3_seen[free_slot].active = true;
}

static void observe_eapol_msg1(const uint8_t* bssid, uint32_t now_ms) {
    int free_slot = -1;
    for (int i = 0; i < WIFI_EXT_TABLE_SIZE; i++) {
        if (s_msg1_burst[i].active && mac_eq(s_msg1_burst[i].bssid, bssid)) {
            if (now_ms - s_msg1_burst[i].window_start_ms > WIFI_EXT_PMKID_BURST_WINDOW_MS) {
                // Window expired -- start a fresh count.
                s_msg1_burst[i].window_start_ms = now_ms;
                s_msg1_burst[i].count = 1;
                return;
            }
            s_msg1_burst[i].count++;
            if (s_msg1_burst[i].count >= WIFI_EXT_PMKID_BURST_THRESHOLD &&
                s_pmkid_finding_count < WIFI_EXT_MAX_FINDINGS) {
                PmkidBurstFinding& f = s_pmkid_findings[s_pmkid_finding_count++];
                memcpy(f.bssid, bssid, 6);
                f.msg1_count_in_window = s_msg1_burst[i].count;
                f.window_start_ms = s_msg1_burst[i].window_start_ms;
            }
            return;
        }
        if (!s_msg1_burst[i].active && free_slot < 0) free_slot = i;
    }
    if (free_slot < 0) free_slot = 0;
    memcpy(s_msg1_burst[free_slot].bssid, bssid, 6);
    s_msg1_burst[free_slot].window_start_ms = now_ms;
    s_msg1_burst[free_slot].count = 1;
    s_msg1_burst[free_slot].active = true;
}

// ---------------------------------------------------------------------------
// Internal: WPA3 (SAE) downgrade tracking
// ---------------------------------------------------------------------------
static void observe_akm(const uint8_t* bssid, const char* ssid, bool has_sae,
                        uint32_t now_ms) {
    int free_slot = -1;
    for (int i = 0; i < WIFI_EXT_TABLE_SIZE; i++) {
        if (s_wpa3[i].active && mac_eq(s_wpa3[i].bssid, bssid)) {
            if (has_sae) {
                s_wpa3[i].sae_last_seen_ms = now_ms;
            } else if (s_wpa3[i].sae_last_seen_ms > 0 && !s_wpa3[i].downgraded) {
                // Previously saw SAE from this exact BSSID, now it's gone.
                s_wpa3[i].downgraded = true;
                if (s_wpa3_finding_count < WIFI_EXT_MAX_FINDINGS) {
                    Wpa3DowngradeFinding& f = s_wpa3_findings[s_wpa3_finding_count++];
                    memcpy(f.bssid, bssid, 6);
                    strncpy(f.ssid, ssid, sizeof(f.ssid) - 1);
                    f.ssid[sizeof(f.ssid) - 1] = '\0';
                    f.sae_last_seen_ms = s_wpa3[i].sae_last_seen_ms;
                    f.downgrade_detected_ms = now_ms;
                }
            }
            return;
        }
        if (!s_wpa3[i].active && free_slot < 0) free_slot = i;
    }
    if (free_slot < 0) free_slot = 0;
    memcpy(s_wpa3[free_slot].bssid, bssid, 6);
    strncpy(s_wpa3[free_slot].ssid, ssid, sizeof(s_wpa3[free_slot].ssid) - 1);
    s_wpa3[free_slot].ssid[sizeof(s_wpa3[free_slot].ssid) - 1] = '\0';
    s_wpa3[free_slot].sae_last_seen_ms = has_sae ? now_ms : 0;
    s_wpa3[free_slot].downgraded = false;
    s_wpa3[free_slot].active = true;
}

// ---------------------------------------------------------------------------
// Internal: rogue DHCP + DNS hijack (open networks only)
// ---------------------------------------------------------------------------
static void observe_dhcp_offer(uint32_t xid, const uint8_t* server_mac, uint32_t now_ms) {
    int free_slot = -1;
    for (int i = 0; i < WIFI_EXT_TABLE_SIZE; i++) {
        if (s_dhcp[i].active && s_dhcp[i].xid == xid) {
            for (int j = 0; j < s_dhcp[i].server_count; j++) {
                if (mac_eq(s_dhcp[i].server_macs[j], server_mac)) return;  // already counted
            }
            if (s_dhcp[i].server_count < 4) {
                memcpy(s_dhcp[i].server_macs[s_dhcp[i].server_count], server_mac, 6);
                s_dhcp[i].server_count++;
            }
            if (s_dhcp[i].server_count >= 2 && !s_dhcp[i].flagged &&
                s_dhcp_finding_count < WIFI_EXT_MAX_FINDINGS) {
                s_dhcp[i].flagged = true;
                RogueDhcpFinding& f = s_dhcp_findings[s_dhcp_finding_count++];
                f.transaction_xid = xid;
                f.server_count = s_dhcp[i].server_count;
                f.detected_at_ms = now_ms;
            }
            return;
        }
        if (!s_dhcp[i].active && free_slot < 0) free_slot = i;
    }
    if (free_slot < 0) free_slot = 0;
    s_dhcp[free_slot].xid = xid;
    memcpy(s_dhcp[free_slot].server_macs[0], server_mac, 6);
    s_dhcp[free_slot].server_count = 1;
    s_dhcp[free_slot].first_seen_ms = now_ms;
    s_dhcp[free_slot].flagged = false;
    s_dhcp[free_slot].active = true;
}

static void observe_dns_answer(uint16_t qid, uint32_t answer_hash, uint32_t now_ms) {
    int free_slot = -1;
    for (int i = 0; i < WIFI_EXT_TABLE_SIZE; i++) {
        if (s_dns[i].active && s_dns[i].qid == qid &&
            now_ms - s_dns[i].first_seen_ms < 3000u) {
            for (int j = 0; j < s_dns[i].answer_count; j++) {
                if (s_dns[i].answer_hashes[j] == answer_hash) return;  // already counted
            }
            if (s_dns[i].answer_count < 4) {
                s_dns[i].answer_hashes[s_dns[i].answer_count] = answer_hash;
                s_dns[i].answer_count++;
            }
            if (s_dns[i].answer_count >= 2 && !s_dns[i].flagged &&
                s_dns_finding_count < WIFI_EXT_MAX_FINDINGS) {
                s_dns[i].flagged = true;
                DnsHijackFinding& f = s_dns_findings[s_dns_finding_count++];
                f.query_id = qid;
                f.distinct_answer_count = s_dns[i].answer_count;
                f.detected_at_ms = now_ms;
            }
            return;
        }
        if (!s_dns[i].active && free_slot < 0) free_slot = i;
    }
    if (free_slot < 0) free_slot = 0;
    s_dns[free_slot].qid = qid;
    s_dns[free_slot].answer_hashes[0] = answer_hash;
    s_dns[free_slot].answer_count = 1;
    s_dns[free_slot].first_seen_ms = now_ms;
    s_dns[free_slot].flagged = false;
    s_dns[free_slot].active = true;
}

// ---------------------------------------------------------------------------
// Frame processing entry point
// ---------------------------------------------------------------------------
#define MAC_HDR_LEN        24u
#define QOS_CTRL_LEN        2u
#define LLC_SNAP_LEN        8u
#define EAPOL_HDR_LEN       4u
#define EAPOL_KEY_MIN_LEN  93u   // descriptor+key_info+key_len+replay+nonce, before optional data

static const uint8_t kEapolSnap[8] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x88, 0x8E};

void wifi_extended_scan_process_frame(const uint8_t* frame, size_t len,
                                      uint8_t frame_type, uint8_t frame_subtype,
                                      uint32_t now_ms) {
    if (!frame) return;

    // frame_type: 0=management, 2=data (802.11-2020 Table 9-1)
    if (frame_type == 0 && frame_subtype == 0x0D /* Action */) {
        // 1. RTT/FTM ranging.
        if (len < MAC_HDR_LEN + 2) return;
        uint8_t category = frame[MAC_HDR_LEN];
        uint8_t action_code = frame[MAC_HDR_LEN + 1];
        if (wifi_ext_is_ftm_action_frame(category, action_code)) {
            const uint8_t* bssid = frame + 16;   // Address 3 in mgmt header
            observe_rtt(bssid, now_ms);
        }
        return;
    }

    if (frame_type == 0 && (frame_subtype == 0x08 /* Beacon */ ||
                             frame_subtype == 0x05 /* Probe Response */)) {
        // 3. WPA3 downgrade -- walk the IE list after the 12-byte fixed
        // Beacon/Probe-Response fields (timestamp[8] + interval[2] +
        // cap_info[2]) looking for the SSID (element 0) and RSN (element
        // 48) IEs. RSN IE layout (802.11-2020 9.4.2.24): version[2 LE]
        // group_cipher[4] pairwise_count[2 LE] pairwise_list[4*count]
        // akm_count[2 LE] akm_list[4*count] ... -- only need the AKM
        // list, checking each 4-byte suite's last byte against 8 (SAE,
        // OUI 00-0F-AC).
        size_t fixed_end = MAC_HDR_LEN + 12;
        if (len <= fixed_end) return;
        const uint8_t* bssid = frame + 16;   // Address 3
        char ssid[33] = {0};
        bool have_ssid = false;
        bool has_sae = false;
        bool have_rsn = false;

        size_t pos = fixed_end;
        while (pos + 2 <= len) {
            uint8_t elem_id = frame[pos];
            uint8_t elem_len = frame[pos + 1];
            size_t elem_start = pos + 2;
            if (elem_start + elem_len > len) break;   // truncated IE -- stop

            if (elem_id == 0 && !have_ssid) {          // SSID
                size_t n = elem_len < 32 ? elem_len : 32;
                memcpy(ssid, frame + elem_start, n);
                ssid[n] = '\0';
                have_ssid = true;
            } else if (elem_id == 48) {                 // RSN
                have_rsn = true;
                if (elem_len >= 8) {
                    const uint8_t* rsn = frame + elem_start;
                    size_t off = 2 + 4;   // version + group cipher
                    if (off + 2 <= elem_len) {
                        uint16_t pairwise_count = rsn[off] | ((uint16_t)rsn[off + 1] << 8);
                        off += 2 + (size_t)pairwise_count * 4;
                        if (off + 2 <= elem_len) {
                            uint16_t akm_count = rsn[off] | ((uint16_t)rsn[off + 1] << 8);
                            off += 2;
                            for (uint16_t i = 0; i < akm_count && off + 4 <= elem_len; i++) {
                                if (rsn[off + 3] == 8) has_sae = true;   // 00-0F-AC:8
                                off += 4;
                            }
                        }
                    }
                }
            }
            pos = elem_start + elem_len;
        }

        if (have_rsn) {
            observe_akm(bssid, have_ssid ? ssid : "", has_sae, now_ms);
        }
        return;
    }

    if (frame_type == 2) {
        // Data frame. QoS data subtypes have bit3 set (0x08); non-QoS data
        // frames don't.
        size_t hdr_len = MAC_HDR_LEN + ((frame_subtype & 0x08) ? QOS_CTRL_LEN : 0);
        if (len < hdr_len + LLC_SNAP_LEN + 2) return;
        const uint8_t* snap = frame + hdr_len;
        const uint8_t* addr2 = frame + 10;   // transmitter -- used by both branches below

        if (memcmp(snap, kEapolSnap, LLC_SNAP_LEN) == 0) {
            // 2. KRACK / PMKID-harvest (EAPOL, ethertype 0x888E).
            if (len < hdr_len + LLC_SNAP_LEN + EAPOL_HDR_LEN) return;
            const uint8_t* eapol = snap + LLC_SNAP_LEN;
            if (eapol[1] != 3 /* EAPOL-Key */) return;
            const uint8_t* key_body = eapol + EAPOL_HDR_LEN;
            size_t key_body_len = len - (hdr_len + LLC_SNAP_LEN + EAPOL_HDR_LEN);
            if (key_body_len < EAPOL_KEY_MIN_LEN) return;

            uint16_t key_info = ((uint16_t)key_body[1] << 8) | key_body[2];
            EapolHandshakeMsg msg = eapol_classify_key_info(key_info);

            // Address 2 (transmitter) is the BSSID for AP->STA frames
            // typical of Msg1/Msg3; Address 1 (receiver) is the client in
            // that direction. Good enough for a passive burst/replay
            // signature without needing full DA/SA/ToDS/FromDS
            // disambiguation.
            const uint8_t* addr1 = frame + 4;
            if (msg == EAPOL_MSG_1) {
                observe_eapol_msg1(addr2, now_ms);
            } else if (msg == EAPOL_MSG_3) {
                const uint8_t* nonce = key_body + 13;   // descriptor+key_info+key_len+replay = 13
                observe_eapol_msg3(addr2, addr1, nonce, now_ms);
            }
            return;
        }

        // 4. Rogue DHCP / DNS hijack -- HONEST LIMITATION: only reachable
        // here at all on OPEN networks. A WPA2/3-encrypted data frame's
        // body is opaque ciphertext to a passive monitor without the
        // network key -- its LLC/SNAP header won't match the plain IPv4
        // ethertype below (it'll just look like noise and fail this check
        // harmlessly), so this branch is naturally a no-op on encrypted
        // traffic rather than something that needs a separate "is this
        // network open" check.
        static const uint8_t kIpv4Snap[8] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00};
        if (memcmp(snap, kIpv4Snap, LLC_SNAP_LEN) != 0) return;
        const uint8_t* ip = snap + LLC_SNAP_LEN;
        size_t ip_avail = len - (hdr_len + LLC_SNAP_LEN);
        if (ip_avail < 20) return;                    // min IPv4 header
        uint8_t ihl_words = ip[0] & 0x0F;
        size_t ip_hdr_len = (size_t)ihl_words * 4;
        if (ihl_words < 5 || ip_avail < ip_hdr_len + 8) return;   // + min UDP header
        if (ip[9] != 17 /* UDP */) return;
        const uint8_t* udp = ip + ip_hdr_len;
        uint16_t src_port = ((uint16_t)udp[0] << 8) | udp[1];
        uint16_t dst_port = ((uint16_t)udp[2] << 8) | udp[3];
        const uint8_t* udp_payload = udp + 8;
        size_t udp_payload_len = ip_avail - ip_hdr_len - 8;

        if (src_port == 67 && dst_port == 68) {
            // DHCP server -> client. BOOTP op=2 (BOOTREPLY) at offset 0,
            // xid at offset 4 (4 bytes BE). Uses the transmitting MAC as
            // the "which physical device answered" signal rather than
            // parsing option 54 (Server Identifier) out of the variable-
            // length options list -- simpler and just as effective for
            // "how many distinct devices answered this transaction."
            if (udp_payload_len < 8) return;
            if (udp_payload[0] != 2 /* BOOTREPLY */) return;
            uint32_t xid = ((uint32_t)udp_payload[4] << 24) | ((uint32_t)udp_payload[5] << 16) |
                           ((uint32_t)udp_payload[6] << 8)  |  (uint32_t)udp_payload[7];
            observe_dhcp_offer(xid, addr2, now_ms);
        } else if (src_port == 53) {
            // DNS response. Query ID at offset 0 (2 bytes BE). Answer
            // diversity is approximated by hashing the tail of the
            // payload (where A/AAAA rdata typically lands for simple
            // responses) rather than a full resource-record parser
            // (variable-length names + compression pointers) -- flagged
            // here as an approximation, not a complete DNS parser.
            if (udp_payload_len < 12) return;
            uint16_t qid = ((uint16_t)udp_payload[0] << 8) | udp_payload[1];
            uint32_t answer_hash = 2166136261u;   // FNV-1a
            size_t tail_start = udp_payload_len > 16 ? udp_payload_len - 16 : 0;
            for (size_t i = tail_start; i < udp_payload_len; i++) {
                answer_hash ^= udp_payload[i];
                answer_hash *= 16777619u;
            }
            observe_dns_answer(qid, answer_hash, now_ms);
        }
    }
}

void wifi_extended_scan_get_stats(WifiExtendedScanStats* out) {
    if (!out) return;
    uint16_t rtt_count = 0;
    for (int i = 0; i < WIFI_EXT_TABLE_SIZE; i++) if (s_rtt[i].active) rtt_count++;
    s_stats.rtt_devices_observed = rtt_count;
    s_stats.krack_suspects = (uint16_t)s_krack_finding_count;
    s_stats.pmkid_burst_suspects = (uint16_t)s_pmkid_finding_count;
    s_stats.wpa3_downgrade_suspects = (uint16_t)s_wpa3_finding_count;
    s_stats.rogue_dhcp_suspects = (uint16_t)s_dhcp_finding_count;
    s_stats.dns_hijack_suspects = (uint16_t)s_dns_finding_count;
    *out = s_stats;
}

size_t wifi_ext_list_rtt(RttFinding* out, size_t max) {
    if (!out) return 0;
    size_t n = 0;
    for (int i = 0; i < WIFI_EXT_TABLE_SIZE && n < max; i++) {
        if (!s_rtt[i].active) continue;
        memcpy(out[n].bssid, s_rtt[i].bssid, 6);
        out[n].last_seen_ms = s_rtt[i].last_seen_ms;
        out[n].observation_count = s_rtt[i].count;
        n++;
    }
    return n;
}

size_t wifi_ext_list_krack(KrackFinding* out, size_t max) {
    if (!out) return 0;
    size_t n = s_krack_finding_count < max ? s_krack_finding_count : max;
    memcpy(out, s_krack_findings, n * sizeof(KrackFinding));
    return n;
}

size_t wifi_ext_list_pmkid_bursts(PmkidBurstFinding* out, size_t max) {
    if (!out) return 0;
    size_t n = s_pmkid_finding_count < max ? s_pmkid_finding_count : max;
    memcpy(out, s_pmkid_findings, n * sizeof(PmkidBurstFinding));
    return n;
}

size_t wifi_ext_list_wpa3_downgrades(Wpa3DowngradeFinding* out, size_t max) {
    if (!out) return 0;
    size_t n = s_wpa3_finding_count < max ? s_wpa3_finding_count : max;
    memcpy(out, s_wpa3_findings, n * sizeof(Wpa3DowngradeFinding));
    return n;
}

size_t wifi_ext_list_rogue_dhcp(RogueDhcpFinding* out, size_t max) {
    if (!out) return 0;
    size_t n = s_dhcp_finding_count < max ? s_dhcp_finding_count : max;
    memcpy(out, s_dhcp_findings, n * sizeof(RogueDhcpFinding));
    return n;
}

size_t wifi_ext_list_dns_hijacks(DnsHijackFinding* out, size_t max) {
    if (!out) return 0;
    size_t n = s_dns_finding_count < max ? s_dns_finding_count : max;
    memcpy(out, s_dns_findings, n * sizeof(DnsHijackFinding));
    return n;
}

#ifdef ARDUINO

// FIXED before ever shipping: this originally drove its own independent
// esp_wifi_set_channel() timer here. Found during review that main.cpp's
// capture_rf_spectrum() ALREADY does a full 1-13 channel sweep every
// single ~1 Hz main-loop cycle (for the drone_classifier spectrum input)
// and explicitly restores the radio to the AP's channel afterward so an
// active WiFi STA connection (c2_wifi_bridge, wifi_status_server) keeps
// receiving. An independent channel-hop timer on its own 3s schedule
// would have raced that restore -- at best redundant (this module's
// frame observation already gets full-channel coverage for free, since
// its callback fires for whatever channel is active at that instant,
// regardless of which subsystem set it), at worst a real connectivity
// hazard (setting the radio to a channel neither sweep function knows
// about, right as capture_rf_spectrum() expects to have just restored
// the AP channel). wifi_ext_next_channel() is kept as a tested pure
// utility in case a future redesign wants this module to own hopping
// independently of capture_rf_spectrum() -- it just isn't called live
// here.
void wifi_extended_scan_tick() {}

#else  // !ARDUINO — native/test stub

void wifi_extended_scan_tick() {}

#endif
