#!/usr/bin/env python3
"""
WiFi Recon Dashboard
Reads from events.db (written by udp_ingest.py), serves a live web UI.
"""

import asyncio
import sqlite3
import datetime
import os
import json
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse

DB_PATH      = os.path.join(os.path.dirname(__file__), "events.db")
HTML_PATH    = os.path.join(os.path.dirname(__file__), "templates", "dashboard.html")
CSS_PATH     = os.path.join(os.path.dirname(__file__), "static", "dashboard.css")
JS_PATH      = os.path.join(os.path.dirname(__file__), "static", "dashboard.js")
OUI_PATH     = os.path.join(os.path.dirname(__file__), "oui.txt")
ONLINE_SECS  = 30   # node considered online if heartbeat within this many seconds
EVENT_CAP    = 100  # max events returned / shown in dashboard

# ─── Deauth reason codes ─────────────────────────────────────────────────────

# classification: 'normal', 'suspicious', 'attack'
DEAUTH_REASONS: dict[int, tuple[str, str]] = {
    0:   ("Reserved",                                    "normal"),
    1:   ("Unspecified",                                 "normal"),
    2:   ("Previous auth no longer valid",               "attack"),
    3:   ("Station leaving BSS",                         "normal"),
    4:   ("Inactivity / idle timeout",                   "normal"),
    5:   ("Too many associated stations",                "normal"),
    6:   ("Class 2 frame from non-authenticated station","attack"),
    7:   ("Class 3 frame from non-associated station",   "attack"),
    8:   ("Station leaving BSS (disassoc)",              "normal"),
    9:   ("Station not authenticated",                   "suspicious"),
    10:  ("Power capability unacceptable",               "normal"),
    11:  ("Supported channels unacceptable",             "normal"),
    13:  ("Invalid information element",                 "suspicious"),
    14:  ("MIC failure",                                 "suspicious"),
    15:  ("4-way handshake timeout",                     "normal"),
    16:  ("Group key handshake timeout",                 "normal"),
    17:  ("IE in 4-way handshake differs",               "suspicious"),
    18:  ("Invalid group cipher",                        "suspicious"),
    19:  ("Invalid pairwise cipher",                     "suspicious"),
    20:  ("Invalid AKMP",                                "suspicious"),
    21:  ("Unsupported RSNE version",                    "suspicious"),
    22:  ("Invalid RSNE capabilities",                   "suspicious"),
    23:  ("802.1X authentication failed",                "suspicious"),
    24:  ("Cipher suite rejected by policy",             "suspicious"),
    34:  ("Low ACK",                                     "normal"),
    39:  ("BSS transition management request",           "normal"),
    45:  ("Peer stakey negotiation failed",              "suspicious"),
    47:  ("Authorized access limit reached",             "normal"),
    99:  ("Peer abandoned session",                      "normal"),
    103: ("Disassociated — excessive frames",            "suspicious"),
    147: ("Vendor-specific",                             "normal"),
}

def deauth_reason_info(code: int) -> tuple[str, str]:
    return DEAUTH_REASONS.get(code, ("Unknown / vendor-specific", "normal"))

# ─── OUI lookup ──────────────────────────────────────────────────────────────

def _load_oui(path: str) -> dict[str, str]:
    db: dict[str, str] = {}
    if not os.path.exists(path):
        return db
    with open(path, encoding="utf-8", errors="ignore") as f:
        for line in f:
            if "(hex)" not in line:
                continue
            parts = line.split("(hex)")
            prefix = parts[0].strip().replace("-", ":").upper()   # "AA-BB-CC" → "AA:BB:CC"
            vendor = parts[1].strip()
            if prefix and vendor:
                db[prefix] = vendor
    return db

_OUI_DB: dict[str, str] = _load_oui(OUI_PATH)


def oui_lookup(mac: str) -> tuple[str, bool]:
    """
    Returns (vendor_name, is_randomized).
    Randomized MACs have the locally-administered bit set (bit 1 of first byte).
    OUI lookup is meaningless for randomized MACs.
    """
    if not mac or len(mac) < 8:
        return ("Unknown", False)
    try:
        first_byte = int(mac.replace(":", "").replace("-", "")[:2], 16)
        randomized = bool(first_byte & 0x02)
    except ValueError:
        return ("Unknown", False)
    if randomized:
        return ("Randomized MAC", True)
    prefix = mac.upper()[:8]
    return (_OUI_DB.get(prefix, "Unknown"), False)

# ─── DB helpers ──────────────────────────────────────────────────────────────

def get_conn() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    return conn


def query(sql: str, params: tuple = ()) -> list[dict]:
    with get_conn() as conn:
        rows = conn.execute(sql, params).fetchall()
    return [dict(r) for r in rows]


def _node_status(last_seen: str | None, threshold: int = ONLINE_SECS) -> str:
    if not last_seen:
        return "offline"
    try:
        dt = datetime.datetime.fromisoformat(last_seen)
        delta = (datetime.datetime.utcnow() - dt).total_seconds()
        return "online" if delta <= threshold else "offline"
    except ValueError:
        return "offline"

# ─── API data builders ────────────────────────────────────────────────────────

def build_nodes() -> list[dict]:
    rows = query("""
        SELECT
            node_id,
            MAX(received_at)  AS last_beacon,
            COUNT(*)          AS total_events,
            MIN(received_at)  AS first_seen
        FROM beacon_events
        GROUP BY node_id
        ORDER BY node_id ASC
    """)
    # Latest heartbeat per node
    hb_rows = query("""
        SELECT node_id, received_at AS last_heartbeat,
               uptime_ms, free_heap, wifi_rssi
        FROM heartbeat_events
        WHERE id IN (SELECT MAX(id) FROM heartbeat_events GROUP BY node_id)
    """)
    hb_map = {r["node_id"]: r for r in hb_rows}
    for r in rows:
        hb = hb_map.get(r["node_id"], {})
        r["last_heartbeat"] = hb.get("last_heartbeat")
        r["uptime_ms"]      = hb.get("uptime_ms")
        r["free_heap"]      = hb.get("free_heap")
        r["wifi_rssi"]      = hb.get("wifi_rssi")
        r["last_seen"]      = r["last_heartbeat"] or r["last_beacon"]
        # Online if heartbeat within 30s; fall back to beacon within 60s if no heartbeat yet
        if r["last_heartbeat"]:
            r["status"] = _node_status(r["last_heartbeat"], threshold=30)
        else:
            r["status"] = _node_status(r["last_beacon"], threshold=60)
    return rows


def build_events(limit: int = EVENT_CAP, node_id: str | None = None) -> list[dict]:
    if node_id:
        return query("""
            SELECT * FROM beacon_events
            WHERE node_id = ?
            ORDER BY id DESC LIMIT ?
        """, (node_id, limit))
    return query("""
        SELECT * FROM beacon_events
        ORDER BY id DESC LIMIT ?
    """, (limit,))


def build_networks() -> list[dict]:
    rows = query("""
        SELECT
            ssid,
            bssid,
            MAX(rssi)                AS best_rssi,
            ROUND(AVG(rssi), 1)      AS avg_rssi,
            channel,
            encryption,
            COUNT(*)                 AS times_seen,
            COUNT(DISTINCT node_id)  AS node_count,
            MAX(received_at)         AS last_seen
        FROM beacon_events
        WHERE bssid IS NOT NULL
        GROUP BY bssid
        ORDER BY times_seen DESC
        LIMIT 300
    """)
    return rows


def build_clients() -> list[dict]:
    """
    Per unique src_mac: aggregated stats and the SSIDs they were probing for.
    """
    rows = query("""
        SELECT
            src_mac,
            COUNT(*)                        AS times_seen,
            COUNT(DISTINCT node_id)         AS node_count,
            MAX(rssi)                        AS best_rssi,
            ROUND(AVG(rssi), 1)             AS avg_rssi,
            COUNT(DISTINCT NULLIF(ssid,'')) AS unique_ssids,
            MIN(received_at)                AS first_seen,
            MAX(received_at)                AS last_seen
        FROM probe_events
        WHERE src_mac IS NOT NULL
        GROUP BY src_mac
        ORDER BY times_seen DESC
        LIMIT 300
    """)
    # Fetch SSIDs only for the MACs we actually returned
    if not rows:
        return []
    mac_list     = [r["src_mac"] for r in rows]
    placeholders = ",".join("?" * len(mac_list))
    ssid_rows = query(f"""
        SELECT DISTINCT src_mac, ssid FROM probe_events
        WHERE src_mac IN ({placeholders}) AND ssid IS NOT NULL AND ssid != ''
        ORDER BY src_mac, ssid
    """, tuple(mac_list))
    ssids_by_mac: dict = {}
    for s in ssid_rows:
        ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"])

    for r in rows:
        r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], [])
        vendor, randomized = oui_lookup(r["src_mac"])
        r["vendor"]     = vendor
        r["randomized"] = randomized
    return rows


def build_cross_node() -> dict:
    """
    For every BSSID, return per-node RSSI stats side by side.
    Result: { node_ids: [...], networks: [{bssid, ssid, channel, encryption,
              node_count, nodes: {node_id: {best_rssi, avg_rssi, times_seen}}}] }
    """
    node_rows = query("SELECT DISTINCT node_id FROM beacon_events ORDER BY node_id")
    node_ids  = [r["node_id"] for r in node_rows]

    rows = query("""
        SELECT
            b.bssid,
            MAX(b.ssid)            AS ssid,
            MAX(b.channel)         AS channel,
            MAX(b.encryption)      AS encryption,
            b.node_id,
            MAX(b.rssi)            AS best_rssi,
            ROUND(AVG(b.rssi), 1)  AS avg_rssi,
            COUNT(*)               AS times_seen
        FROM beacon_events b
        WHERE b.bssid IN (
            SELECT bssid FROM beacon_events
            WHERE bssid IS NOT NULL
            GROUP BY bssid
            ORDER BY COUNT(DISTINCT node_id) DESC, COUNT(*) DESC
            LIMIT 300
        )
        GROUP BY b.bssid, b.node_id
        ORDER BY b.bssid, b.node_id
    """)

    networks: dict = {}
    for r in rows:
        bssid = r["bssid"]
        if bssid not in networks:
            networks[bssid] = {
                "bssid":      bssid,
                "ssid":       r["ssid"],
                "channel":    r["channel"],
                "encryption": r["encryption"],
                "nodes":      {},
            }
        networks[bssid]["nodes"][r["node_id"]] = {
            "best_rssi":  r["best_rssi"],
            "avg_rssi":   r["avg_rssi"],
            "times_seen": r["times_seen"],
        }

    result = sorted(
        networks.values(),
        key=lambda x: len(x["nodes"]),
        reverse=True,
    )
    for net in result:
        net["node_count"] = len(net["nodes"])

    return {"node_ids": node_ids, "networks": result}


def build_presence() -> dict:
    now        = datetime.datetime.utcnow()
    cutoff_1h  = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds")
    cutoff_24h = (now - datetime.timedelta(hours=24)).isoformat(timespec="seconds")

    # ── Present now: real MACs seen 2+ times in the last hour ────────────────
    present_rows = query("""
        SELECT src_mac,
               COUNT(*)              AS times_seen,
               MAX(rssi)             AS best_rssi,
               ROUND(AVG(rssi), 1)   AS avg_rssi,
               MAX(received_at)      AS last_seen,
               COUNT(DISTINCT node_id) AS node_count
        FROM probe_events
        WHERE src_mac IS NOT NULL AND received_at > ?
        GROUP BY src_mac
        HAVING COUNT(*) > 1
        ORDER BY last_seen DESC
    """, (cutoff_1h,))
    present = []
    for r in present_rows:
        vendor, randomized = oui_lookup(r["src_mac"])
        if not randomized:
            r["vendor"] = vendor
            present.append(r)

    # ── New arrivals: first seen in last 24h ─────────────────────────────────
    new_mac_rows = query("""
        SELECT src_mac,
               MIN(received_at)      AS first_seen,
               MAX(rssi)             AS best_rssi,
               COUNT(*)              AS times_seen,
               COUNT(DISTINCT node_id) AS node_count
        FROM probe_events
        WHERE src_mac IS NOT NULL
        GROUP BY src_mac
        HAVING MIN(received_at) > ?
        ORDER BY first_seen DESC
        LIMIT 100
    """, (cutoff_24h,))
    new_macs = []
    for r in new_mac_rows:
        vendor, randomized = oui_lookup(r["src_mac"])
        r["vendor"]     = vendor
        r["randomized"] = randomized
        new_macs.append(r)

    new_networks = query("""
        SELECT bssid,
               MAX(ssid)             AS ssid,
               MIN(received_at)      AS first_seen,
               MAX(rssi)             AS best_rssi,
               MAX(channel)          AS channel,
               MAX(encryption)       AS encryption,
               COUNT(DISTINCT node_id) AS node_count
        FROM beacon_events
        WHERE bssid IS NOT NULL
        GROUP BY bssid
        HAVING MIN(received_at) > ?
        ORDER BY first_seen DESC
        LIMIT 100
    """, (cutoff_24h,))

    # ── Regulars: seen on 2+ distinct calendar days ───────────────────────────
    reg_mac_rows = query("""
        SELECT src_mac,
               COUNT(DISTINCT date(received_at)) AS days_seen,
               MIN(received_at)                  AS first_seen,
               MAX(received_at)                  AS last_seen,
               MAX(rssi)                         AS best_rssi,
               COUNT(*)                          AS times_seen,
               COUNT(DISTINCT node_id)           AS node_count
        FROM probe_events
        WHERE src_mac IS NOT NULL
        GROUP BY src_mac
        HAVING COUNT(DISTINCT date(received_at)) >= 2
        ORDER BY days_seen DESC, times_seen DESC
        LIMIT 50
    """)
    reg_macs = []
    for r in reg_mac_rows:
        vendor, randomized = oui_lookup(r["src_mac"])
        r["vendor"]     = vendor
        r["randomized"] = randomized
        reg_macs.append(r)

    reg_networks = query("""
        SELECT bssid,
               MAX(ssid)                         AS ssid,
               COUNT(DISTINCT date(received_at)) AS days_seen,
               MIN(received_at)                  AS first_seen,
               MAX(received_at)                  AS last_seen,
               MAX(rssi)                         AS best_rssi,
               MAX(channel)                      AS channel,
               MAX(encryption)                   AS encryption,
               COUNT(DISTINCT node_id)           AS node_count
        FROM beacon_events
        WHERE bssid IS NOT NULL
        GROUP BY bssid
        HAVING COUNT(DISTINCT date(received_at)) >= 2
        ORDER BY days_seen DESC, best_rssi DESC
        LIMIT 50
    """)

    return {
        "present":      present,
        "new_macs":     new_macs,
        "new_networks": new_networks,
        "reg_macs":     reg_macs,
        "reg_networks": reg_networks,
    }


def build_alerts() -> dict:
    now         = datetime.datetime.utcnow()
    cutoff_5m   = (now - datetime.timedelta(minutes=5)).isoformat(timespec="seconds")
    cutoff_1h   = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds")

    # Recent raw deauth/disassoc events
    recent = query("""
        SELECT * FROM deauth_events
        ORDER BY id DESC LIMIT 100
    """)

    # Burst detection: >= 10 frames for the same BSSID within the last 5 minutes.
    # node_count > 1 means multiple sensors confirmed the burst — much higher confidence.
    bursts = query("""
        SELECT
            bssid,
            subtype,
            COUNT(*)                AS count,
            COUNT(DISTINCT node_id) AS node_count,
            COUNT(DISTINCT src)     AS unique_srcs,
            MIN(received_at)        AS first_seen,
            MAX(received_at)        AS last_seen
        FROM deauth_events
        WHERE received_at > ?
        GROUP BY bssid, subtype
        HAVING COUNT(*) >= 10
        ORDER BY count DESC
    """, (cutoff_5m,))

    # Correlate burst BSSIDs with known network SSIDs
    if bursts:
        bssid_list = [b["bssid"] for b in bursts]
        placeholders = ",".join("?" * len(bssid_list))
        ssid_rows = query(f"""
            SELECT bssid, MAX(ssid) AS ssid, MAX(encryption) AS encryption
            FROM beacon_events WHERE bssid IN ({placeholders})
            GROUP BY bssid
        """, tuple(bssid_list))
        ssid_map = {r["bssid"]: r for r in ssid_rows}
        for b in bursts:
            net = ssid_map.get(b["bssid"], {})
            b["ssid"]       = net.get("ssid")
            b["encryption"] = net.get("encryption")

    # Summary counts for the last hour
    summary = query("""
        SELECT
            COUNT(*)                AS total,
            COUNT(DISTINCT bssid)   AS unique_bssids,
            COUNT(DISTINCT src)     AS unique_srcs,
            SUM(CASE WHEN subtype='deauth' THEN 1 ELSE 0 END)   AS deauth_count,
            SUM(CASE WHEN subtype='disassoc' THEN 1 ELSE 0 END) AS disassoc_count
        FROM deauth_events
        WHERE received_at > ?
    """, (cutoff_1h,))

    # Most targeted BSSIDs — all time
    top_targets = query("""
        SELECT
            bssid,
            COUNT(*)                AS total_frames,
            COUNT(DISTINCT src)     AS unique_srcs,
            COUNT(DISTINCT node_id) AS node_count,
            MIN(received_at)        AS first_seen,
            MAX(received_at)        AS last_seen
        FROM deauth_events
        GROUP BY bssid
        ORDER BY total_frames DESC
        LIMIT 10
    """)
    if top_targets:
        bssid_list   = [t["bssid"] for t in top_targets]
        placeholders = ",".join("?" * len(bssid_list))
        ssid_rows    = query(f"""
            SELECT bssid, MAX(ssid) AS ssid
            FROM beacon_events WHERE bssid IN ({placeholders})
            GROUP BY bssid
        """, tuple(bssid_list))
        ssid_map = {r["bssid"]: r["ssid"] for r in ssid_rows}
        for t in top_targets:
            t["ssid"] = ssid_map.get(t["bssid"])

    # Most targeted devices — all time (grouped by dst, the actual victim)
    top_targeted_devices = query("""
        SELECT
            dst,
            COUNT(*)                    AS total_frames,
            COUNT(DISTINCT bssid)       AS networks_used,
            COUNT(DISTINCT node_id)     AS node_count,
            MIN(received_at)            AS first_seen,
            MAX(received_at)            AS last_seen
        FROM deauth_events
        GROUP BY dst
        ORDER BY total_frames DESC
        LIMIT 10
    """)
    for d in top_targeted_devices:
        vendor, randomized = oui_lookup(d["dst"])
        d["vendor"]     = vendor
        d["randomized"] = randomized

    # Reason code breakdown — all time
    reason_rows = query("""
        SELECT reason,
               COUNT(*)                AS total_frames,
               COUNT(DISTINCT bssid)   AS unique_bssids,
               COUNT(DISTINCT dst)     AS unique_targets,
               MIN(received_at)        AS first_seen,
               MAX(received_at)        AS last_seen
        FROM deauth_events
        GROUP BY reason
        ORDER BY total_frames DESC
    """)
    for r in reason_rows:
        desc, classification = deauth_reason_info(r["reason"])
        r["description"]    = desc
        r["classification"] = classification

    # Activity heatmap — frame count per day × hour
    heatmap_rows = query("""
        SELECT date(received_at)                            AS day,
               CAST(strftime('%H', received_at) AS INTEGER) AS hour,
               COUNT(*)                                     AS frames
        FROM deauth_events
        GROUP BY day, hour
        ORDER BY day, hour
    """)

    return {
        "recent":               recent,
        "bursts":               bursts,
        "summary":              summary[0] if summary else {},
        "top_targets":          top_targets,
        "top_targeted_devices": top_targeted_devices,
        "reason_stats":         reason_rows,
        "heatmap":              heatmap_rows,
    }


def build_search(q: str) -> dict:
    if not q or len(q) < 2:
        return {}
    like = f"%{q}%"
    results = {}

    # As a network — BSSID or SSID match in beacon_events
    as_network = query("""
        SELECT bssid, MAX(ssid) AS ssid,
               COUNT(*) AS times_seen,
               MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi,
               MAX(channel) AS channel, MAX(encryption) AS encryption,
               COUNT(DISTINCT node_id) AS node_count,
               MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
        FROM beacon_events
        WHERE bssid LIKE ? OR ssid LIKE ?
        GROUP BY bssid ORDER BY times_seen DESC LIMIT 20
    """, (like, like))
    if as_network:
        results["as_network"] = as_network

    # As a client device — src_mac match in probe_events
    as_client = query("""
        SELECT src_mac,
               COUNT(*) AS times_seen,
               MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi,
               COUNT(DISTINCT node_id) AS node_count,
               MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
        FROM probe_events
        WHERE src_mac LIKE ?
        GROUP BY src_mac LIMIT 10
    """, (like,))
    if as_client:
        ssid_rows = query("""
            SELECT DISTINCT src_mac, ssid FROM probe_events
            WHERE src_mac LIKE ? AND ssid IS NOT NULL AND ssid != ''
        """, (like,))
        ssids_by_mac: dict = {}
        for s in ssid_rows:
            ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"])
        for r in as_client:
            vendor, randomized = oui_lookup(r["src_mac"])
            r["vendor"]       = vendor
            r["randomized"]   = randomized
            r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], [])
        results["as_client"] = as_client

    # Devices probing for this SSID
    probing_for = query("""
        SELECT src_mac,
               COUNT(*) AS times_seen,
               MAX(rssi) AS best_rssi,
               MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
        FROM probe_events
        WHERE ssid LIKE ?
        GROUP BY src_mac ORDER BY times_seen DESC LIMIT 20
    """, (like,))
    if probing_for:
        for r in probing_for:
            vendor, randomized = oui_lookup(r["src_mac"])
            r["vendor"]     = vendor
            r["randomized"] = randomized
        results["probing_for"] = probing_for

    # As a deauth victim — dst match
    as_victim = query("""
        SELECT dst,
               COUNT(*) AS total_frames,
               COUNT(DISTINCT bssid) AS networks_used,
               COUNT(DISTINCT node_id) AS node_count,
               MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
        FROM deauth_events
        WHERE dst LIKE ?
        GROUP BY dst LIMIT 10
    """, (like,))
    if as_victim:
        for r in as_victim:
            vendor, randomized = oui_lookup(r["dst"])
            r["vendor"]     = vendor
            r["randomized"] = randomized
        results["as_victim"] = as_victim

    # As an impersonated AP — bssid match in deauth_events
    as_impersonated = query("""
        SELECT bssid,
               COUNT(*) AS total_frames,
               COUNT(DISTINCT dst) AS unique_targets,
               COUNT(DISTINCT node_id) AS node_count,
               MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
        FROM deauth_events
        WHERE bssid LIKE ?
        GROUP BY bssid LIMIT 10
    """, (like,))
    if as_impersonated:
        results["as_impersonated"] = as_impersonated

    # Vendor name search — match query against OUI database, then find those MACs
    q_lower = q.lower()
    matching_prefixes = [
        prefix for prefix, vendor in _OUI_DB.items()
        if q_lower in vendor.lower()
    ][:60]  # cap at 60 prefixes to keep query manageable

    if matching_prefixes:
        ph = ",".join("?" * len(matching_prefixes))

        vendor_clients = query(f"""
            SELECT src_mac,
                   COUNT(*) AS times_seen,
                   MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi,
                   COUNT(DISTINCT node_id) AS node_count,
                   MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
            FROM probe_events
            WHERE substr(src_mac,1,8) IN ({ph})
            GROUP BY src_mac ORDER BY times_seen DESC LIMIT 30
        """, tuple(matching_prefixes))
        if vendor_clients:
            ssid_rows = query(f"""
                SELECT DISTINCT src_mac, ssid FROM probe_events
                WHERE substr(src_mac,1,8) IN ({ph})
                  AND ssid IS NOT NULL AND ssid != ''
            """, tuple(matching_prefixes))
            ssids_by_mac: dict = {}
            for s in ssid_rows:
                ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"])
            for r in vendor_clients:
                vendor, randomized = oui_lookup(r["src_mac"])
                r["vendor"]       = vendor
                r["randomized"]   = randomized
                r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], [])
            results["vendor_clients"] = vendor_clients

        vendor_networks = query(f"""
            SELECT bssid, MAX(ssid) AS ssid,
                   COUNT(*) AS times_seen,
                   MAX(rssi) AS best_rssi, ROUND(AVG(rssi),1) AS avg_rssi,
                   MAX(channel) AS channel, MAX(encryption) AS encryption,
                   COUNT(DISTINCT node_id) AS node_count,
                   MIN(received_at) AS first_seen, MAX(received_at) AS last_seen
            FROM beacon_events
            WHERE substr(bssid,1,8) IN ({ph})
            GROUP BY bssid ORDER BY times_seen DESC LIMIT 30
        """, tuple(matching_prefixes))
        if vendor_networks:
            results["vendor_networks"] = vendor_networks

        if "vendor_clients" in results or "vendor_networks" in results:
            results["vendor_name"] = next(
                (v for k, v in _OUI_DB.items() if q_lower in v.lower()), q
            )

    return results


def build_node_detail(node_id: str) -> dict | None:
    rows = query("""
        SELECT
            node_id,
            MIN(received_at) AS first_seen,
            MAX(received_at) AS last_seen,
            COUNT(*)         AS total_events,
            COUNT(DISTINCT ssid)  AS unique_ssids,
            COUNT(DISTINCT bssid) AS unique_bssids,
            AVG(rssi)        AS avg_rssi,
            MIN(rssi)        AS min_rssi,
            MAX(rssi)        AS max_rssi
        FROM beacon_events
        WHERE node_id = ?
        GROUP BY node_id
    """, (node_id,))
    if not rows:
        return None
    detail = rows[0]
    hb_rows = query("""
        SELECT received_at AS last_heartbeat, uptime_ms, free_heap, wifi_rssi
        FROM heartbeat_events
        WHERE node_id = ?
        ORDER BY id DESC LIMIT 1
    """, (node_id,))
    hb = hb_rows[0] if hb_rows else {}
    detail["last_heartbeat"] = hb.get("last_heartbeat")
    detail["uptime_ms"]      = hb.get("uptime_ms")
    detail["free_heap"]      = hb.get("free_heap")
    detail["wifi_rssi"]      = hb.get("wifi_rssi")
    if detail["last_heartbeat"]:
        detail["status"] = _node_status(detail["last_heartbeat"], threshold=30)
    else:
        detail["status"] = _node_status(detail["last_seen"], threshold=60)
    detail["avg_rssi"] = round(detail["avg_rssi"], 1) if detail["avg_rssi"] else None
    detail["events"]   = build_events(limit=EVENT_CAP, node_id=node_id)
    return detail

# ─── Routes ──────────────────────────────────────────────────────────────────

def ensure_schema():
    """Create tables if they don't exist. Runs once at startup."""
    with get_conn() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS beacon_events (
                id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL,
                node_id TEXT, node_ts INTEGER, ssid TEXT, bssid TEXT,
                rssi INTEGER, channel INTEGER, encryption TEXT, importance TEXT, confidence TEXT
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS probe_events (
                id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL,
                node_id TEXT, node_ts INTEGER, src_mac TEXT, ssid TEXT,
                rssi INTEGER, importance TEXT
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS heartbeat_events (
                id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL,
                node_id TEXT, node_ts INTEGER,
                uptime_ms INTEGER, free_heap INTEGER, wifi_rssi INTEGER
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS deauth_events (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                received_at TEXT NOT NULL,
                node_id     TEXT,
                node_ts     INTEGER,
                subtype     TEXT,
                src         TEXT,
                dst         TEXT,
                bssid       TEXT,
                reason      INTEGER,
                rssi        INTEGER
            )
        """)
        conn.commit()


app = FastAPI(title="ESP32 Recon Dashboard")
ensure_schema()


@app.get("/", response_class=HTMLResponse)
async def index():
    return FileResponse(HTML_PATH)


@app.get("/static/dashboard.css")
async def serve_css():
    return FileResponse(CSS_PATH, media_type="text/css")


@app.get("/static/dashboard.js")
async def serve_js():
    return FileResponse(JS_PATH, media_type="application/javascript")


@app.get("/api/nodes")
async def api_nodes():
    return build_nodes()


@app.get("/api/events")
async def api_events(limit: int = EVENT_CAP):
    limit = min(limit, EVENT_CAP)
    return build_events(limit=limit)


@app.get("/api/networks")
async def api_networks():
    return build_networks()


@app.get("/api/clients")
async def api_clients():
    return build_clients()


@app.get("/api/cross-node")
async def api_cross_node():
    return build_cross_node()


@app.get("/api/presence")
async def api_presence():
    return build_presence()


@app.get("/api/rssi-history")
async def api_rssi_history(bssid: str, hours: int = 2):
    hours = min(max(hours, 1), 48)
    cutoff = (datetime.datetime.utcnow() - datetime.timedelta(hours=hours)).isoformat(timespec="seconds")
    rows = query("""
        SELECT node_id, received_at, rssi
        FROM beacon_events
        WHERE bssid = ? AND received_at >= ?
        ORDER BY received_at ASC
    """, (bssid, cutoff))
    series: dict = {}
    for r in rows:
        nid = r["node_id"]
        if nid not in series:
            series[nid] = []
        series[nid].append({"t": r["received_at"], "rssi": r["rssi"]})
    return series


@app.get("/api/search")
async def api_search(q: str = ""):
    return build_search(q)


@app.get("/api/alerts")
async def api_alerts():
    return build_alerts()


@app.get("/api/heartbeats")
async def api_heartbeats():
    """Latest heartbeat per node."""
    return query("""
        SELECT node_id, received_at, uptime_ms, free_heap, wifi_rssi
        FROM heartbeat_events
        WHERE id IN (SELECT MAX(id) FROM heartbeat_events GROUP BY node_id)
    """)


@app.get("/api/nodes/{node_id}")
async def api_node_detail(node_id: str):
    detail = build_node_detail(node_id)
    if detail is None:
        raise HTTPException(status_code=404, detail="Node not found")
    return detail


@app.get("/api/stream")
async def api_stream():
    """SSE stream — polls DB every 2 s, pushes new events to the browser."""
    async def event_generator():
        try:
            last_id = 0
            # Start from the current latest event so we don't replay history on connect
            rows = query("SELECT MAX(id) AS max_id FROM beacon_events")
            if rows and rows[0]["max_id"]:
                last_id = rows[0]["max_id"]

            while True:
                await asyncio.sleep(2)
                new_rows = query("""
                    SELECT * FROM beacon_events
                    WHERE id > ?
                    ORDER BY id ASC
                """, (last_id,))
                for row in new_rows:
                    last_id = row["id"]
                    payload = json.dumps(row)
                    yield f"data: {payload}\n\n"
        except asyncio.CancelledError:
            pass  # client disconnected or server shutting down — expected, not an error

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )


# ─── Entry point ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("dashboard:app", host="0.0.0.0", port=8080, reload=False)
