// ============================================================================
// wifi_status_server.cpp — see wifi_status_server.h for design notes.
// ============================================================================

#include "wifi_status_server.h"

#ifdef ARDUINO

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <nvs.h>
#include <cstring>
#include <cstdio>
#include "esp_wifi.h"
#include "gps_module.h"
#include "wifi_odid_scanner.h"
#include "config.h"
#include "sensor_location.h"
#include "mesh_publish.h"
#include "mesh_validate.h"
#include "c2_wifi_bridge.h"
#include "mqtt_client.h"
#include "rnode_bridge.h"
#include <cmath>

#define WIFI_CONNECT_TIMEOUT_MS 20000

static WebServer s_http(80);
static bool      s_server_started = false;
static bool      s_status_route_registered = false;
static bool      s_connecting = false;
static uint32_t  s_connect_started_ms = 0;

// ---------------------------------------------------------------------------
// Auto-join state
// ---------------------------------------------------------------------------
#define WIFI_AUTOJOIN_NVS_NS    "pdl_wifi"
#define WIFI_AUTOJOIN_MAX_SAVED 4
#define WIFI_AUTOJOIN_RETRY_MS  30000

static bool    s_autojoin_enabled = true;
static uint32_t s_autojoin_last_attempt_ms = 0;
static bool     s_autojoin_have_attempted  = false;
static int      s_autojoin_saved_count = 0;
static char     s_autojoin_saved_ssid[WIFI_AUTOJOIN_MAX_SAVED][33];
static char     s_autojoin_saved_pass[WIFI_AUTOJOIN_MAX_SAVED][65];
static int      s_autojoin_next_saved_idx = 0;

// ---------------------------------------------------------------------------
// Field WiFi provisioning state (see header for design notes)
// ---------------------------------------------------------------------------
#define WIFI_PROVISION_TRIGGER_FAILURES 5

static bool     s_ever_connected = false;
static bool     s_provisioning_active = false;
static int      s_autojoin_fail_count = 0;
static char     s_provision_pending_ssid[33] = {0};
static char     s_provision_pending_pass[65] = {0};
static bool     s_provision_apply_pending = false;
static bool     s_provision_saved_promisc = false;

static void handle_status() {
    GpsStats g{};
    gps_get_stats(&g);
    WifiOdidScannerStats w{};
    wifi_odid_scanner_get_stats(&w);

    // Position as actually reported to C2/MQTT — resolved GPS-or-static, so
    // /status shows what downstream consumers see rather than only the raw
    // GPS state. A unit with no fix but a SET_LOCATION value is NOT lost.
    int32_t lat_fx = 0, lon_fx = 0;
    location_get(&lat_fx, &lon_fx);

    int  gps_rx = -1, gps_tx = -1, gps_cand = 0, gps_cand_n = 0;
    bool gps_locked = false;
    gps_get_active_pins(&gps_rx, &gps_tx, &gps_locked, &gps_cand, &gps_cand_n);

    char buf[896];
    snprintf(buf, sizeof(buf),
        "{\"sensor_name\":\"%s\",\"board\":\"" PDL_BOARD_TYPE "\","
        "\"fw\":\"" SENSOR_FW_VERSION "\",\"build\":\"" PDL_BUILD_STAMP "\","
        "\"uptime_s\":%lu,"
        "\"wifi_rssi\":%d,\"wifi_ip\":\"%s\","
        "\"gps_has_fix\":%s,\"gps_satellites\":%u,\"gps_hdop\":%.1f,"
        "\"gps_chars_processed\":%lu,\"gps_checksum_failures\":%lu,"
        // Which pins/baud the UART is really on. The T-Deck Plus ships with
        // either a 9600 L76K or a 38400 u-blox M10Q on identical pins, so baud
        // is the field that says which module is fitted.
        "\"gps_rx\":%d,\"gps_tx\":%d,\"gps_baud\":%lu,\"gps_locked\":%s,"
        "\"lat\":%.7f,\"lon\":%.7f,"
        // C2 heartbeat health — makes a wedged bridge visible remotely.
        "\"c2_configured\":%s,\"c2_secs_since_success\":%lu,"
        "\"c2_consecutive_failures\":%lu,"
        "\"c2_stall_recoveries\":%lu,"
        "\"mqtt_connected\":%s,"
        "\"wifi_odid_frames_seen\":%lu,\"wifi_odid_rid_hits\":%lu}",
        SENSOR_NAME, (unsigned long)(millis() / 1000),
        (int)WiFi.RSSI(), WiFi.localIP().toString().c_str(),
        g.has_fix ? "true" : "false", (unsigned)g.satellites, g.hdop,
        (unsigned long)g.chars_processed, (unsigned long)g.checksum_failures,
        gps_rx, gps_tx, (unsigned long)gps_get_active_baud(),
        gps_locked ? "true" : "false",
        (double)lat_fx / 1e7, (double)lon_fx / 1e7,
        c2_wifi_bridge_is_configured() ? "true" : "false",
        (unsigned long)c2_wifi_bridge_seconds_since_success(),
        (unsigned long)c2_wifi_bridge_consecutive_failures(),
        (unsigned long)c2_wifi_bridge_stall_recoveries(),
        mqtt_is_connected() ? "true" : "false",
        (unsigned long)w.frames_seen, (unsigned long)w.rid_hits);

    s_http.send(200, "application/json", buf);
}

// ---------------------------------------------------------------------------
// GET /gps-raw -- the last few NMEA sentences verbatim, as text/plain.
//
// The counters in /status cannot tell these two apart, and they need opposite
// remedies:
//
//   $GPGSV,1,1,00,*79            -> 0 satellites IN VIEW. Antenna/sky problem.
//   $GPGSV,3,1,11,01,45,180,32.. -> 11 in view with real C/N0. RF is fine;
//                                   it just has not finished building a fix.
//   (unreadable bytes)           -> wrong baud. Firmware problem.
//
// TDeck02 spent this session in the third state while being diagnosed as the
// first, up to and including physically opening the case to inspect the u.FL
// connector. Being able to read the actual sentences remotely is what makes
// that a ten-second question instead of a teardown.
// ---------------------------------------------------------------------------
static void handle_gps_raw() {
    char nmea[1024];
    const size_t n = gps_get_recent_nmea(nmea, sizeof(nmea));

    int  rx = -1, tx = -1, cand = 0, cand_n = 0;
    bool locked = false;
    gps_get_active_pins(&rx, &tx, &locked, &cand, &cand_n);

    char hdr[192];
    snprintf(hdr, sizeof(hdr),
             "# %s  rx=%d tx=%d baud=%lu locked=%s candidate=%d/%d\n"
             "# %u byte(s) buffered\n",
             SENSOR_NAME, rx, tx, (unsigned long)gps_get_active_baud(),
             locked ? "yes" : "no", cand + 1, cand_n, (unsigned)n);

    String out = String(hdr);
    if (n == 0) {
        out += "# (nothing received on this UART yet)\n";
    } else {
        out += nmea;
    }
    s_http.send(200, "text/plain", out);
}

// ---------------------------------------------------------------------------
// Field WiFi provisioning (SoftAP + web form) -- see header for design notes
// ---------------------------------------------------------------------------

static const char* provisioning_ap_ssid() {
    static char buf[48];
    snprintf(buf, sizeof(buf), "PDL-%s-Setup", SENSOR_NAME);
    return buf;
}

static void handle_provision_root() {
    char page[900];
    snprintf(page, sizeof(page),
        "<!DOCTYPE html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>"
        "<title>%s WiFi Setup</title></head>"
        "<body style='font-family:sans-serif;max-width:400px;margin:40px auto;padding:0 16px'>"
        "<h2>%s WiFi Setup</h2>"
        "<p>Enter the WiFi network this sensor should join.</p>"
        "<form method='POST' action='/wifi-setup'>"
        "<label>Network name (SSID)<br><input name='ssid' style='width:100%%;padding:8px' required></label><br><br>"
        "<label>Password (leave blank if open)<br><input name='password' type='password' style='width:100%%;padding:8px'></label><br><br>"
        "<button type='submit' style='padding:10px 20px'>Connect</button>"
        "</form></body></html>",
        SENSOR_NAME, SENSOR_NAME);
    s_http.send(200, "text/html", page);
}

static void handle_provision_submit() {
    if (!s_http.hasArg("ssid") || s_http.arg("ssid").length() == 0) {
        s_http.send(400, "text/plain", "Missing SSID");
        return;
    }
    String ssid = s_http.arg("ssid");
    String pass = s_http.hasArg("password") ? s_http.arg("password") : "";
    strncpy(s_provision_pending_ssid, ssid.c_str(), sizeof(s_provision_pending_ssid) - 1);
    s_provision_pending_ssid[sizeof(s_provision_pending_ssid) - 1] = '\0';
    strncpy(s_provision_pending_pass, pass.c_str(), sizeof(s_provision_pending_pass) - 1);
    s_provision_pending_pass[sizeof(s_provision_pending_pass) - 1] = '\0';
    // Applied on the next tick, not inline here -- tearing down the AP
    // interface mid-request (WiFi.mode()/softAPdisconnect()) while this
    // handler is still running inside WebServer's own request context has
    // caused crashes on other ESP32 Arduino projects; deferring to the main
    // loop keeps the mode switch outside the HTTP callback stack.
    s_provision_apply_pending = true;

    char page[320];
    snprintf(page, sizeof(page),
        "<!DOCTYPE html><html><body style='font-family:sans-serif;max-width:400px;margin:40px auto;padding:0 16px'>"
        "<h2>Connecting&hellip;</h2><p>Trying to join \"%s\". This device's setup WiFi will "
        "disappear once it connects &mdash; reconnect your phone/laptop to your normal WiFi.</p></body></html>",
        s_provision_pending_ssid);
    s_http.send(200, "text/html", page);
}

// ---------------------------------------------------------------------------
// Remote configuration endpoints
//
// All unauthenticated on the LAN, matching the existing /status endpoint and
// the OTA responder — same trust model, same caveat.
// ---------------------------------------------------------------------------

// GET /set-location?lat=38.5822030&lon=-78.1450658
// Gives a unit with no GNSS a real position. location_get() prefers a live GPS
// fix and falls back to this, and location_resolve_report() feeds it to C2,
// MQTT and Reticulum alike — so one call places the sensor on every map.
static void handle_set_location() {
    if (!s_http.hasArg("lat") || !s_http.hasArg("lon")) {
        s_http.send(400, "application/json",
                    "{\"error\":\"usage: /set-location?lat=<deg>&lon=<deg>\"}");
        return;
    }
    double lat = s_http.arg("lat").toDouble();
    double lon = s_http.arg("lon").toDouble();
    if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) {
        s_http.send(400, "application/json", "{\"error\":\"out of WGS84 range\"}");
        return;
    }
    bool ok = location_set((int32_t)llround(lat * 1e7), (int32_t)llround(lon * 1e7));
    char body[128];
    snprintf(body, sizeof(body),
             "{\"ok\":%s,\"lat\":%.7f,\"lon\":%.7f}", ok ? "true" : "false", lat, lon);
    s_http.send(ok ? 200 : 500, "application/json", body);
}

// POST /peer-detection  body = hex of the 33-byte mesh wire format.
// Option B ingest: a peer that cannot reach C2 hands us its detection over
// WiFi and we forward it, restamping with OUR clock exactly as the LoRa relay
// path does. Same wire format on purpose, so the identical deserialize +
// mesh_event_is_plausible() validation applies.
static void handle_peer_detection() {
    String body = s_http.arg("plain");
    if (body.length() != DETECTION_PACKET_SIZE * 2) {
        s_http.send(400, "application/json", "{\"error\":\"expected 33-byte hex\"}");
        return;
    }
    uint8_t pkt[DETECTION_PACKET_SIZE];
    for (size_t i = 0; i < DETECTION_PACKET_SIZE; i++) {
        auto nib = [](char c) -> int {
            if (c >= '0' && c <= '9') return c - '0';
            if (c >= 'A' && c <= 'F') return c - 'A' + 10;
            if (c >= 'a' && c <= 'f') return c - 'a' + 10;
            return -1;
        };
        int hi = nib(body[i * 2]), lo = nib(body[i * 2 + 1]);
        if (hi < 0 || lo < 0) {
            s_http.send(400, "application/json", "{\"error\":\"bad hex\"}");
            return;
        }
        pkt[i] = (uint8_t)((hi << 4) | lo);
    }

    DetectionEvent evt;
    if (!mesh_deserialize_event(pkt, sizeof(pkt), evt)) {
        s_http.send(422, "application/json", "{\"error\":\"failed validation\"}");
        return;
    }

    bool posted = c2_wifi_bridge_post_relayed_detection(evt);
    char out[96];
    snprintf(out, sizeof(out), "{\"accepted\":true,\"forwarded_to_c2\":%s}",
             posted ? "true" : "false");
    s_http.send(200, "application/json", out);
}

// GET /c2-config?host=<host>&port=<n>&path=</api/sensor/detection>
//
// The C2 endpoint was serial-only (C2_WIFI_CONNECT), which is unusable on a
// battery-powered unit across the site. Spencer03 came back from a battery
// swap with no C2 endpoint and no way to give it one remotely — it looked
// healthy on every other axis while being entirely absent from C2.
// c2_wifi_bridge_connect() persists to NVS, so this survives reboot/reflash.
static void handle_c2_config() {
    if (!s_http.hasArg("host")) {
        s_http.send(400, "application/json",
                    "{\"error\":\"usage: /c2-config?host=<h>&port=<n>&path=<p>\"}");
        return;
    }
    String host = s_http.arg("host");
    uint16_t port = s_http.hasArg("port") ? (uint16_t)s_http.arg("port").toInt() : 443;
    String path = s_http.hasArg("path") ? s_http.arg("path") : String("/api/sensor/detection");

    c2_wifi_bridge_connect(host.c_str(), port, path.c_str());

    char out[224];
    snprintf(out, sizeof(out),
             "{\"ok\":true,\"host\":\"%s\",\"port\":%u,\"path\":\"%s\"}",
             host.c_str(), (unsigned)port, path.c_str());
    s_http.send(200, "application/json", out);
}

// GET /mqtt-config?broker=host&port=1883&client=NAME
// MQTT settings live in NVS with no runtime setter, so a deployed wireless
// unit had no way to be pointed at a broker. Writes the same NVS keys
// mqtt_client.cpp reads at init.
static void handle_mqtt_config() {
    if (!s_http.hasArg("broker")) {
        s_http.send(400, "application/json",
                    "{\"error\":\"usage: /mqtt-config?broker=<host>&port=<n>&client=<id>\"}");
        return;
    }
    String broker = s_http.arg("broker");
    uint16_t port = s_http.hasArg("port") ? (uint16_t)s_http.arg("port").toInt() : 1883;
    String client = s_http.hasArg("client") ? s_http.arg("client") : String(SENSOR_NAME);

    nvs_handle_t h;
    if (nvs_open(MQTT_NVS_NS, NVS_READWRITE, &h) != ESP_OK) {
        s_http.send(500, "application/json", "{\"error\":\"nvs open failed\"}");
        return;
    }
    nvs_set_str(h, MQTT_NVS_BROKER, broker.c_str());
    nvs_set_u16(h, MQTT_NVS_PORT, port);
    nvs_set_str(h, MQTT_NVS_CLIENT, client.c_str());
    nvs_set_u8(h, MQTT_NVS_TLS, 0);
    nvs_commit(h);
    nvs_close(h);

    char out[192];
    snprintf(out, sizeof(out),
             "{\"ok\":true,\"broker\":\"%s\",\"port\":%u,\"client\":\"%s\","
             "\"note\":\"applies on next boot\"}",
             broker.c_str(), (unsigned)port, client.c_str());
    s_http.send(200, "application/json", out);
}

// GET /rnode-enable — announce this node's Reticulum identity now. The
// announce is what makes it discoverable to the wider Reticulum network, and
// therefore to rmap.world given an RNS host bridging outward.
static void handle_rnode_enable() {
    // Must flip the RUNTIME flag, not just NVS: rnode_init() latches
    // RNODE_NVS_ENABLED into a static at boot and every rnode_* entry point
    // early-returns on it. The first version of this endpoint wrote nothing
    // and returned {"announced":false} on all five units for exactly that
    // reason.
    //
    // (rnode_is_enabled() is declared in rnode_bridge.h but has no definition
    // anywhere in the tree — a dead declaration. rnode_is_rn_enabled() is the
    // real one.)
    rnode_set_enabled(true);

    const char* aspects[] = { SENSOR_NAME };
    bool announced = rnode_announce("pdl", aspects, 1, nullptr, 0);
    char out[192];
    snprintf(out, sizeof(out),
             "{\"enabled\":%s,\"announced\":%s,\"identity\":\"pdl.%s\"}",
             rnode_is_rn_enabled() ? "true" : "false",
             announced ? "true" : "false", SENSOR_NAME);
    s_http.send(200, "application/json", out);
}

// GET /reboot — restart the unit. Needed because several subsystems (MQTT
// most importantly) read their configuration from NVS only at init, so a
// remotely-written config has no effect until a restart. Without this the
// only way to apply an MQTT broker change on a wireless unit was to push a
// firmware image purely for its side effect of rebooting.
static void handle_reboot() {
    s_http.send(200, "application/json", "{\"rebooting\":true}");
    delay(250);            // let the response flush before the reset
    ESP.restart();
}

void wifi_provisioning_start() {
    if (s_provisioning_active) return;
    s_provisioning_active = true;

    // The sensor runs WiFi promiscuous-mode sniffing continuously for
    // RID/ODID detection (see main.cpp's esp_wifi_set_promiscuous(true) at
    // boot) -- that has to come down while hosting a SoftAP, or softAP()
    // silently fails to actually broadcast. Restored once provisioning ends.
    esp_wifi_get_promiscuous(&s_provision_saved_promisc);
    esp_wifi_set_promiscuous(false);

    // A direct STA -> WIFI_AP mode switch is a known, widely-reported
    // Arduino-ESP32 core bug (espressif/arduino-esp32#7805, #1040;
    // tzapu/WiFiManager#1482/#1488): WiFi.mode(WIFI_AP)/softAP() both
    // report success but the AP never actually beacons -- confirmed live,
    // twice, with fresh scans from both a phone and this PC. A first
    // attempt at a fix (WiFi.disconnect + WIFI_OFF + delays + WIFI_AP)
    // still didn't broadcast and additionally logged an IDF-level
    // "timeout when WiFi un-init" error. The community-verified working
    // sequence (confirmed by multiple independent reports against this
    // same bug) is simpler: go through WIFI_AP_STA on the way to WIFI_AP,
    // never straight from STA and never through WIFI_OFF.
    WiFi.mode(WIFI_AP_STA);
    WiFi.mode(WIFI_AP);
    bool ap_ok = WiFi.softAP(provisioning_ap_ssid());
    s_http.on("/", HTTP_GET, handle_provision_root);
    s_http.on("/wifi-setup", HTTP_POST, handle_provision_submit);
    if (!s_server_started) {
        s_http.begin();
        s_server_started = true;
    }
    Serial.printf("[WiFiProvision] AP up: \"%s\" (open, softAP()=%s, IP=%s) -- "
                  "connect and browse to http://192.168.4.1/\n",
                  provisioning_ap_ssid(), ap_ok ? "ok" : "FAILED",
                  WiFi.softAPIP().toString().c_str());
}

static void wifi_provisioning_tick() {
    if (!s_provisioning_active) return;
    s_http.handleClient();
    if (s_provision_apply_pending) {
        s_provision_apply_pending = false;
        Serial.printf("[WiFiProvision] Applying submitted credentials for \"%s\"\n",
                      s_provision_pending_ssid);
        wifi_autojoin_remember(s_provision_pending_ssid, s_provision_pending_pass);
        WiFi.softAPdisconnect(true);
        s_provisioning_active = false;
        s_autojoin_fail_count = 0;
        wifi_status_server_connect(s_provision_pending_ssid, s_provision_pending_pass);
        if (s_provision_saved_promisc) {
            esp_wifi_set_promiscuous(true);
        }
    }
}

void wifi_status_server_init() {
    // No auto-connect — stays fully inert until wifi_status_server_connect()
    // is called (via the WIFI_CONNECT serial console command).
}

void wifi_status_server_connect(const char* ssid, const char* password) {
    WiFi.mode(WIFI_STA);
    // WiFi.begin(ssid, "") does NOT reliably behave like an open-network
    // join on every ESP32 Arduino core version -- the single-arg overload
    // is the one actually documented for unencrypted networks.
    if (password == nullptr || password[0] == '\0') {
        WiFi.begin(ssid);
    } else {
        WiFi.begin(ssid, password);
    }
    s_connecting = true;
    s_connect_started_ms = millis();
    Serial.printf("[WiFiStatus] Connecting to \"%s\"...\n", ssid);
}

// ---------------------------------------------------------------------------
// Auto-join
// ---------------------------------------------------------------------------

static void autojoin_load_saved() {
    s_autojoin_saved_count = 0;
    nvs_handle_t h;
    if (nvs_open(WIFI_AUTOJOIN_NVS_NS, NVS_READONLY, &h) != ESP_OK) return;

    int32_t count = 0;
    nvs_get_i32(h, "count", &count);
    if (count < 0) count = 0;
    if (count > WIFI_AUTOJOIN_MAX_SAVED) count = WIFI_AUTOJOIN_MAX_SAVED;

    for (int i = 0; i < count; i++) {
        char kssid[8]; snprintf(kssid, sizeof(kssid), "ssid%d", i);
        char kpass[8]; snprintf(kpass, sizeof(kpass), "pass%d", i);
        size_t slen = sizeof(s_autojoin_saved_ssid[i]);
        size_t plen = sizeof(s_autojoin_saved_pass[i]);
        if (nvs_get_str(h, kssid, s_autojoin_saved_ssid[i], &slen) != ESP_OK) {
            s_autojoin_saved_ssid[i][0] = '\0';
        }
        if (nvs_get_str(h, kpass, s_autojoin_saved_pass[i], &plen) != ESP_OK) {
            s_autojoin_saved_pass[i][0] = '\0';
        }
    }
    s_autojoin_saved_count = count;
    nvs_close(h);
}

void wifi_autojoin_init() {
    autojoin_load_saved();
    s_autojoin_last_attempt_ms = 0;
    s_autojoin_have_attempted = false;
    Serial.printf("[WiFiAutoJoin] Init -- %d saved network(s); will fall back to the "
                  "strongest open network in range if none connect\n",
                  s_autojoin_saved_count);
}

void wifi_autojoin_remember(const char* ssid, const char* password) {
    nvs_handle_t h;
    if (nvs_open(WIFI_AUTOJOIN_NVS_NS, NVS_READWRITE, &h) != ESP_OK) {
        Serial.println("[WiFiAutoJoin] NVS open failed, credential not saved");
        return;
    }

    int32_t count = 0;
    nvs_get_i32(h, "count", &count);
    if (count < 0) count = 0;

    int slot = -1;
    for (int i = 0; i < count && i < WIFI_AUTOJOIN_MAX_SAVED; i++) {
        char kssid[8]; snprintf(kssid, sizeof(kssid), "ssid%d", i);
        char existing[33]; size_t elen = sizeof(existing);
        if (nvs_get_str(h, kssid, existing, &elen) == ESP_OK && strcmp(existing, ssid) == 0) {
            slot = i;
            break;
        }
    }
    if (slot < 0) {
        if (count >= WIFI_AUTOJOIN_MAX_SAVED) {
            slot = 0; // table full -- overwrite oldest slot
        } else {
            slot = (int)count;
            count++;
        }
    }

    char kssid[8]; snprintf(kssid, sizeof(kssid), "ssid%d", slot);
    char kpass[8]; snprintf(kpass, sizeof(kpass), "pass%d", slot);
    nvs_set_str(h, kssid, ssid);
    nvs_set_str(h, kpass, password ? password : "");
    nvs_set_i32(h, "count", count);
    nvs_commit(h);
    nvs_close(h);

    autojoin_load_saved();
    Serial.printf("[WiFiAutoJoin] Remembered \"%s\" (slot %d/%d)\n", ssid, slot, WIFI_AUTOJOIN_MAX_SAVED);
}

void wifi_autojoin_trigger_now() {
    s_autojoin_last_attempt_ms = 0;
    s_autojoin_have_attempted = false;
}

static void autojoin_attempt() {
    // Never fight an in-flight connection attempt, ours or a manual
    // WIFI_CONNECT issued from the console.
    if (s_connecting || WiFi.status() == WL_CONNECTED) return;

    WiFi.mode(WIFI_STA);

    // 1) Round-robin through saved credentials, one per attempt cycle so a
    // single bad/out-of-range entry doesn't block trying the others.
    if (s_autojoin_saved_count > 0) {
        const char* ssid = s_autojoin_saved_ssid[s_autojoin_next_saved_idx];
        const char* pass = s_autojoin_saved_pass[s_autojoin_next_saved_idx];
        s_autojoin_next_saved_idx = (s_autojoin_next_saved_idx + 1) % s_autojoin_saved_count;
        if (ssid[0] != '\0') {
            Serial.printf("[WiFiAutoJoin] Trying saved network \"%s\"...\n", ssid);
            s_autojoin_fail_count = 0;
            wifi_status_server_connect(ssid, pass);
            return;
        }
    }

    // 2) No saved network worked this cycle -- scan and join the strongest
    // OPEN (unencrypted) network in range. Deliberately restricted to open
    // networks: the sensor has no way to know an arbitrary secured
    // network's password.
    int n = WiFi.scanNetworks();
    int best_idx = -1;
    int32_t best_rssi = -1000;
    for (int i = 0; i < n; i++) {
        if (WiFi.encryptionType(i) == WIFI_AUTH_OPEN && WiFi.RSSI(i) > best_rssi) {
            best_rssi = WiFi.RSSI(i);
            best_idx = i;
        }
    }
    if (best_idx >= 0) {
        String ssid = WiFi.SSID(best_idx);
        Serial.printf("[WiFiAutoJoin] No saved network available -- joining open network \"%s\" (%d dBm)\n",
                      ssid.c_str(), (int)best_rssi);
        s_autojoin_fail_count = 0;
        wifi_status_server_connect(ssid.c_str(), "");
    } else {
        Serial.printf("[WiFiAutoJoin] Scanned %d network(s) -- none usable "
                      "(no saved match in range, no open AP found)\n", n);
        // Only counts toward provisioning if this sensor has NEVER connected
        // to anything since boot -- an already-working, already-deployed
        // unit that's temporarily out of range should just keep retrying
        // quietly, not pop up its own setup AP and confuse whoever's used
        // to it just working.
        if (!s_ever_connected) {
            s_autojoin_fail_count++;
            if (s_autojoin_fail_count >= WIFI_PROVISION_TRIGGER_FAILURES) {
                Serial.printf("[WiFiAutoJoin] %d consecutive failed cycles with no prior "
                              "connection -- starting field provisioning\n", s_autojoin_fail_count);
                wifi_provisioning_start();
            }
        }
    }
    WiFi.scanDelete();
}

void wifi_autojoin_tick() {
    if (!s_autojoin_enabled) return;
    if (s_provisioning_active) return; // provisioning owns the radio right now
    if (WiFi.status() == WL_CONNECTED) return;
    if (s_connecting) return; // an attempt is already in flight

    uint32_t now = millis();
    if (s_autojoin_have_attempted && (now - s_autojoin_last_attempt_ms < WIFI_AUTOJOIN_RETRY_MS)) {
        return;
    }
    s_autojoin_last_attempt_ms = now;
    s_autojoin_have_attempted = true;
    autojoin_attempt();
}

void wifi_status_server_tick() {
    if (s_connecting) {
        if (WiFi.status() == WL_CONNECTED) {
            s_connecting = false;
            s_ever_connected = true;
            Serial.printf("[WiFiStatus] Connected. IP=%s RSSI=%d dBm\n",
                          WiFi.localIP().toString().c_str(), (int)WiFi.RSSI());
            // Registering "/status" is separate from s_server_started/.begin()
            // -- if this connect came via wifi_provisioning_start() (which
            // already called .begin() for the setup form), s_server_started
            // is already true here, and gating "/status" behind that same
            // flag would mean it never gets registered for the rest of this
            // boot session even though the server itself is still running.
            if (!s_status_route_registered) {
                s_http.on("/status", handle_status);
                s_http.on("/gps-raw", handle_gps_raw);
                // Remote configuration + peer ingest. These exist as HTTP
                // rather than serial because every deployed unit is wireless —
                // a serial-only SET_LOCATION cannot reach a sensor running on
                // battery across the site.
                s_http.on("/set-location",  handle_set_location);
                s_http.on("/peer-detection", HTTP_POST, handle_peer_detection);
                s_http.on("/c2-config",     handle_c2_config);
                s_http.on("/mqtt-config",   handle_mqtt_config);
                s_http.on("/rnode-enable",  handle_rnode_enable);
                s_http.on("/reboot",        handle_reboot);
                s_status_route_registered = true;
            }
            if (!s_server_started) {
                s_http.begin();
                s_server_started = true;
            }
            Serial.println("[WiFiStatus] HTTP status server up on :80/status");
        } else if (millis() - s_connect_started_ms > WIFI_CONNECT_TIMEOUT_MS) {
            s_connecting = false;
            Serial.println("[WiFiStatus] Connect attempt timed out.");
        }
    }
    wifi_provisioning_tick();
    if (s_server_started) {
        s_http.handleClient();
    }
}

#else  // !ARDUINO — native/test stub

void wifi_status_server_init() {}
void wifi_status_server_connect(const char*, const char*) {}
void wifi_status_server_tick() {}
void wifi_autojoin_init() {}
void wifi_autojoin_remember(const char*, const char*) {}
void wifi_autojoin_tick() {}
void wifi_autojoin_trigger_now() {}
void wifi_provisioning_start() {}

#endif
