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

#include "c2_wifi_bridge.h"

#ifdef ARDUINO

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <nvs.h>
#include <cstdio>
#include <cstring>

#include "gps_module.h"
#include "sensor_location.h"
#include "health_report.h"
#include "mesh_timesync.h"
#include "enrollment.h"

#define C2_WIFI_HTTP_TIMEOUT_MS 6000u
// Independent of C2_WIFI_HTTP_TIMEOUT_MS (a per-request timeout) -- this is
// how often the heartbeat fires at all.
#define C2_WIFI_HEALTH_INTERVAL_MS 60000u

// Same pattern as wifi_status_server.cpp's WIFI_AUTOJOIN_NVS_NS: raw
// ESP-IDF nvs.h, not the Arduino Preferences wrapper, for consistency with
// the rest of this codebase's persisted config. Discovered live this
// session that C2_WIFI_CONNECT was in-memory only -- the ESP32-S3's
// native-USB-CDC resets on every new serial connection, which silently
// wiped it, and any real power-cycle in the field would do the same,
// permanently losing the direct-WiFi tasking/reporting path until someone
// re-typed the serial command by hand.
#define C2_WIFI_BRIDGE_NVS_NS "pdl_c2wifi"

// ClassLabel (config.h) -> OpenForge C2's DetectionClassification enum
// (sensor-ingest.ts). Previously this bridge POSTed ble_companion.cpp's
// build_detection_json() output verbatim -- {"sensor_id":<number>,"ts":...,
// "class":...,"conf":...} with no "gateway_id"/"classification"/
// "confidence"/"timestamp" keys at all. That shape was designed for a phone
// app to receive over BLE and reshape before forwarding; POSTed directly
// (this bridge's whole point is skipping that phone hop), it fails C2's
// validateRequired() and gets rejected with HTTP 400 on every real
// detection. Building the correct shape here instead.
static const char* classify(uint8_t class_label) {
    switch (class_label) {
        case CLASS_OCUSYNC:
        case CLASS_FPV_FHSS:
        case CLASS_SKYDIO:
        case CLASS_UNKNOWN_DRONE:
        case CLASS_RID_BLE_DECODED:
        case CLASS_RID_WIFI_DECODED:
            return "DRONE_RF";
        case CLASS_WIFI:
        case CLASS_BLUETOOTH:
            return "RF_ANOMALY";
        case CLASS_NOISE:
        default:
            return "UNKNOWN";
    }
}

// Resolve the lat/lon to report for a detection. A decoded Remote-ID
// detection carries the drone's own broadcast position in the event; an
// RF-only detection (no ODID decode — e.g. an OcuSync/FHSS signature, or an
// RID drone we heard on RF but couldn't decode) leaves those fields at
// 0/garbage. In that case fall back to THIS sensor's own GPS position so the
// detection still plots at the sensor. Always clamped to valid WGS84 range so
// a bad event value can never trigger a server-side 400 (which silently drops
// the detection off the map).
static void resolve_detection_latlon(const DetectionEvent& evt,
                                     double* out_lat, double* out_lon) {
    // Delegates to location_resolve_report() in sensor_location.cpp — the one
    // place that decides "where is this". MQTT and any future Reticulum/rmap
    // announce call the same function, so a given detection can no longer
    // reach C2 with a real position and another transport with 0,0.
    if (location_resolve_report(evt.lat_fixed, evt.lon_fixed, out_lat, out_lon)) {
        return;
    }
    // Genuinely unknown: no decoded position, no GPS fix, no SET_LOCATION.
    // Reported as 0,0 (unchanged behaviour) rather than dropped — losing a
    // real detection is worse than an unplaced one, and C2 can distinguish
    // 0,0 as "position unknown". Fix it by giving the unit a position:
    //     SET_LOCATION <lat> <lon>
    *out_lat = 0.0;
    *out_lon = 0.0;
}

// Resolve a real UTC-epoch timestamp (seconds) for a detection POST. The
// detection pipeline stamps evt.timestamp_utc with millis()/1000 (seconds
// since boot) on most paths, which dayKey() on the server maps to 1970 — the
// detection then stores under a day-index the "recent" query never scans and
// never appears on the map. Prefer the event's value only if it already looks
// like a real epoch; otherwise use this unit's time-synced clock (same source
// the health post uses).
static uint32_t resolve_detection_ts(const DetectionEvent& evt) {
    if (evt.timestamp_utc > 1600000000u) return evt.timestamp_utc;  // already real UTC
    if (timesync_is_synced())            return (uint32_t)(timesync_now_utc_ms() / 1000);
    uint32_t gps_utc = gps_get_utc_epoch();          // lone gateway: GPS is the clock
    if (gps_utc > 1600000000u)           return gps_utc;
    return evt.timestamp_utc;  // last resort — unsynced unit with no GPS time yet
}

static char     s_host[64] = {0};
static char     s_path[64] = {0};
static uint16_t s_port = 0;
static bool     s_configured = false;
static C2WifiBridgeStats s_stats{};
static uint32_t s_last_health_post_ms = 0;
static bool     s_have_posted_health = false;

void c2_wifi_bridge_init() {
    // Inert until c2_wifi_bridge_connect() is called via C2_WIFI_CONNECT.
}

// Shared HTTP POST logic for both the detection and health paths -- same
// host:port, same headers, same insecure-TLS/WAF-UA workarounds, only the
// URL suffix and body differ.
static bool do_post(const char* path_suffix, const char* json, size_t json_len,
                     uint32_t* ok_counter, uint32_t* fail_counter) {
    WiFiClientSecure client;
    client.setInsecure();   // no root CA bundle provisioned on-device; matches
                             // this project's established pragmatic pattern

    HTTPClient http;
    char url[160];
    snprintf(url, sizeof(url), "https://%s:%u%s", s_host, (unsigned)s_port, path_suffix);
    if (!http.begin(client, url)) {
        (*fail_counter)++;
        Serial.printf("[C2WifiBridge] POST %s http.begin() FAILED (free_heap=%u, largest_block=%u)\n",
                      path_suffix, (unsigned)ESP.getFreeHeap(),
                      (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
        return false;
    }
    http.setTimeout(C2_WIFI_HTTP_TIMEOUT_MS);
    http.addHeader("Content-Type", "application/json");
    // Cloudflare's WAF blocks non-browser User-Agents (HTTP 403, "error code
    // 1010") — confirmed this session against the live pdlopenforge.com
    // deployment. A realistic mobile UA is required, not optional.
    http.addHeader("User-Agent",
        "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36");

    int code = http.POST((uint8_t*)json, json_len);

    if (code == 200 || code == 201) {
        http.end();
        (*ok_counter)++;
        return true;
    }
    // Capture the server's error body BEFORE http.end() — the validation
    // reason (e.g. "lat must be between -90 and 90") is otherwise invisible
    // in the field, which makes a bad-payload 400 impossible to diagnose.
    String err_body = http.getString();
    http.end();
    (*fail_counter)++;
    Serial.printf("[C2WifiBridge] POST %s failed, HTTP %d (free_heap=%u): %s\n",
                  path_suffix, code, (unsigned)ESP.getFreeHeap(), err_body.c_str());
    return false;
}

static void save_config_to_nvs(const char* host, uint16_t port, const char* path) {
    nvs_handle_t h;
    if (nvs_open(C2_WIFI_BRIDGE_NVS_NS, NVS_READWRITE, &h) != ESP_OK) {
        Serial.println("[C2WifiBridge] NVS open failed, config not persisted");
        return;
    }
    nvs_set_str(h, "host", host);
    nvs_set_str(h, "path", path);
    nvs_set_u16(h, "port", port);
    esp_err_t err = nvs_commit(h);
    nvs_close(h);
    if (err != ESP_OK) {
        Serial.println("[C2WifiBridge] NVS commit failed, config not persisted");
    }
}

// Returns true if a complete (non-empty host) config was loaded.
static bool load_config_from_nvs(char* host_out, size_t host_cap,
                                  uint16_t* port_out,
                                  char* path_out, size_t path_cap) {
    nvs_handle_t h;
    if (nvs_open(C2_WIFI_BRIDGE_NVS_NS, NVS_READONLY, &h) != ESP_OK) return false;

    size_t hlen = host_cap, plen = path_cap;
    bool ok = nvs_get_str(h, "host", host_out, &hlen) == ESP_OK
           && nvs_get_str(h, "path", path_out, &plen) == ESP_OK
           && nvs_get_u16(h, "port", port_out) == ESP_OK;
    nvs_close(h);
    return ok && host_out[0] != '\0';
}

void c2_wifi_bridge_connect(const char* host, uint16_t port, const char* path) {
    strncpy(s_host, host, sizeof(s_host) - 1);
    s_host[sizeof(s_host) - 1] = '\0';
    strncpy(s_path, path, sizeof(s_path) - 1);
    s_path[sizeof(s_path) - 1] = '\0';
    s_port = port;
    s_configured = true;
    s_stats.configured = true;
    Serial.printf("[C2WifiBridge] Configured: %s:%u%s\n", s_host, (unsigned)s_port, s_path);
    save_config_to_nvs(s_host, s_port, s_path);
}

// Called once from setup(), after c2_wifi_bridge_init(). Mirrors
// wifi_autojoin_init()'s restore-on-boot pattern: if a prior
// C2_WIFI_CONNECT was persisted, reconnect automatically -- no serial
// command needed to restore the direct-WiFi tasking/reporting path after
// a reboot or power cycle.
void c2_wifi_bridge_restore_from_nvs() {
    char host[64] = {0};
    char path[64] = {0};
    uint16_t port = 0;
    if (!load_config_from_nvs(host, sizeof(host), &port, path, sizeof(path))) {
        return;
    }
    Serial.printf("[C2WifiBridge] Restoring persisted config: %s:%u%s\n", host, (unsigned)port, path);
    // Deliberately does NOT call c2_wifi_bridge_connect() (which would
    // re-persist an identical config on every boot, an unnecessary NVS
    // write) -- sets the in-memory state directly instead.
    strncpy(s_host, host, sizeof(s_host) - 1);
    s_host[sizeof(s_host) - 1] = '\0';
    strncpy(s_path, path, sizeof(s_path) - 1);
    s_path[sizeof(s_path) - 1] = '\0';
    s_port = port;
    s_configured = true;
    s_stats.configured = true;
}

// Throttled diagnostic for the two silent early-return paths (unconfigured /
// WiFi down). Without this a sensor that "detects everything" on serial but
// delivers nothing to C2 is impossible to diagnose in the field.
static void log_post_skip(const char* why) {
    static uint32_t s_last_skip_log_ms = 0;
    if (millis() - s_last_skip_log_ms > 5000) {
        s_last_skip_log_ms = millis();
        Serial.printf("[C2WifiBridge] detection POST skipped: %s (wifi_status=%d)\n",
                      why, (int)WiFi.status());
    }
}

bool c2_wifi_bridge_post_detection(const DetectionEvent& evt) {
    if (!s_configured)                 { log_post_skip("not configured"); return false; }
    if (WiFi.status() != WL_CONNECTED) { log_post_skip("wifi not connected"); return false; }

    // DetectionPayload (sensor-ingest.ts): sensor_id, gateway_id,
    // classification, confidence, timestamp, lat, lon are all required.
    // gateway_id has no real T2 gateway on this path -- it's a direct
    // T1-to-C2 WiFi POST -- so a fixed marker identifies detections that
    // arrived this way instead of via a real T2, which matters for anyone
    // debugging delivery paths later.
    double lat, lon;
    resolve_detection_latlon(evt, &lat, &lon);
    uint32_t ts = resolve_detection_ts(evt);

    // Optional platform_id = decoded drone Remote-ID serial (identifies WHICH
    // aircraft). Only present when a Basic-ID message was decoded; empty for
    // RF-only / location-only detections.
    char plat[48] = {0};
    if (evt.drone_serial[0] != '\0') {
        snprintf(plat, sizeof(plat), ",\"platform_id\":\"%s\"", evt.drone_serial);
    }

    char json[352];
    int n = snprintf(json, sizeof(json),
        // gateway_id must be UNIQUE PER UNIT. It was the bare literal
        // "DIRECT_WIFI_BRIDGE" on every sensor, and C2's fleet catalog keys on
        // gateway_id — so all six units collided into a single entry and
        // silently overwrote one another. Only the most recent direct poster
        // was ever visible, which read as "Spencer03 isn't appearing on C2"
        // when in fact it was reporting normally and being clobbered.
        // Matches the LORA_RELAY:<name> convention, which never had this bug.
        "{\"sensor_id\":\"%s\",\"sensor_uid\":\"0x%08X\","
        "\"gateway_id\":\"DIRECT_WIFI_BRIDGE:%s\","
        "\"classification\":\"%s\",\"confidence\":%.3f,\"timestamp\":%u,"
        "\"lat\":%.7f,\"lon\":%.7f,\"rssi_dbm\":%d%s}",
        SENSOR_NAME, (unsigned)evt.sensor_id, SENSOR_NAME, classify(evt.class_label),
        (double)evt.confidence / 255.0, (unsigned)ts,
        lat, lon,
        (int)evt.rssi, plat);
    if (n <= 0 || (size_t)n >= sizeof(json)) return false;
    size_t json_len = (size_t)n;

    return do_post(s_path, json, json_len, &s_stats.posts_ok, &s_stats.posts_failed);
}

bool c2_wifi_bridge_post_relayed_detection(const DetectionEvent& evt) {
    if (!s_configured) return false;
    if (WiFi.status() != WL_CONNECTED) return false;

    // sensor_id here is the ORIGINATING peer (from the LoRa packet), not
    // this relay's own SENSOR_NAME — the peer has no enrolled name known
    // to this device, so its numeric sensor_id is reported as hex, the
    // same format the OLED/boot-log already use for an unenrolled ID.
    char peer_id[11];
    snprintf(peer_id, sizeof(peer_id), "0x%08X", (unsigned)evt.sensor_id);

    char gw_id[48];
    snprintf(gw_id, sizeof(gw_id), "LORA_RELAY:%s", SENSOR_NAME);

    double lat, lon;
    resolve_detection_latlon(evt, &lat, &lon);
    uint32_t ts = resolve_detection_ts(evt);

    char json[256];
    int n = snprintf(json, sizeof(json),
        "{\"sensor_id\":\"%s\",\"gateway_id\":\"%s\","
        "\"classification\":\"%s\",\"confidence\":%.3f,\"timestamp\":%u,"
        "\"lat\":%.7f,\"lon\":%.7f,\"rssi_dbm\":%d}",
        peer_id, gw_id, classify(evt.class_label),
        (double)evt.confidence / 255.0, (unsigned)ts,
        lat, lon,
        (int)evt.rssi);
    if (n <= 0 || (size_t)n >= sizeof(json)) return false;
    size_t json_len = (size_t)n;

    return do_post(s_path, json, json_len, &s_stats.posts_ok, &s_stats.posts_failed);
}

// POST /api/sensor/health with a single-entry sensors[] describing this
// sensor itself -- the only path that tells C2 "I exist, here's my
// position" when there's no T2 gateway aggregating multiple sensors'
// health. Always targets /api/sensor/health on the same host:port
// c2_wifi_bridge_connect() was given (the detection path's suffix doesn't
// apply here).
bool c2_wifi_bridge_post_health() {
    if (!s_configured) return false;
    if (WiFi.status() != WL_CONNECTED) return false;

    int32_t lat_fixed = 0, lon_fixed = 0;
    location_get(&lat_fixed, &lon_fixed);

    uint32_t ts = timesync_is_synced()
                ? (uint32_t)(timesync_now_utc_ms() / 1000)
                : (uint32_t)(millis() / 1000);

    char json[352];
    int n = snprintf(json, sizeof(json),
        // Two fixes here:
        //  - gateway_id was the bare literal "DIRECT_WIFI_BRIDGE" on every
        //    unit, so all six collided on C2's gateway-keyed fleet catalog and
        //    overwrote each other; only the newest direct poster was visible.
        //  - sensor_type was hardcoded "HELTEC_V4" regardless of board, so a
        //    Heltec V3 and a LilyGo T-Deck both reported themselves as V4s.
        "{\"gateway_id\":\"DIRECT_WIFI_BRIDGE:%s\",\"timestamp\":%u,"
        "\"sensors\":[{\"sensor_id\":\"%s\",\"sensor_uid\":\"0x%08X\","
        "\"sensor_type\":\"" PDL_BOARD_TYPE "\","
        "\"status\":\"ONLINE\",\"battery_pct\":%u,\"rssi_dbm\":%d,"
        "\"last_seen\":%u,\"lat\":%.7f,\"lon\":%.7f}]}",
        SENSOR_NAME, (unsigned)ts, SENSOR_NAME, (unsigned)enrollment_get_sensor_id(),
        (unsigned)health_read_battery_pct(),
        (int)WiFi.RSSI(), (unsigned)ts,
        (double)lat_fixed / 1e7, (double)lon_fixed / 1e7);
    if (n <= 0 || (size_t)n >= sizeof(json)) return false;
    size_t json_len = (size_t)n;

    return do_post("/api/sensor/health", json, json_len,
                    &s_stats.health_posts_ok, &s_stats.health_posts_failed);
}

bool c2_wifi_bridge_post_relayed_health(const DetectionEvent& evt) {
    if (!s_configured) return false;
    if (WiFi.status() != WL_CONNECTED) return false;

    char peer_id[11];
    snprintf(peer_id, sizeof(peer_id), "0x%08X", (unsigned)evt.sensor_id);

    char gw_id[48];
    snprintf(gw_id, sizeof(gw_id), "LORA_RELAY:%s", SENSOR_NAME);

    char json[320];
    int n = snprintf(json, sizeof(json),
        "{\"gateway_id\":\"%s\",\"timestamp\":%u,"
        "\"sensors\":[{\"sensor_id\":\"%s\",\"sensor_type\":\"HELTEC_V4\","
        "\"status\":\"ONLINE\",\"battery_pct\":%u,\"rssi_dbm\":%d,"
        "\"last_seen\":%u,\"lat\":%.7f,\"lon\":%.7f}]}",
        gw_id, (unsigned)evt.timestamp_utc, peer_id, (unsigned)evt.battery_pct,
        (int)evt.rssi, (unsigned)evt.timestamp_utc,
        (double)evt.lat_fixed / 1e7, (double)evt.lon_fixed / 1e7);
    if (n <= 0 || (size_t)n >= sizeof(json)) return false;
    size_t json_len = (size_t)n;

    return do_post("/api/sensor/health", json, json_len,
                    &s_stats.health_posts_ok, &s_stats.health_posts_failed);
}

// ---------------------------------------------------------------------------
// Health heartbeat driver — self-healing.
//
// WHY THIS IS MORE THAN A TIMER
// -----------------------------
// The original was: "if 60s have elapsed and WiFi is up, post". It advanced
// s_last_health_post_ms BEFORE knowing whether the post succeeded, so a failed
// or hung POST simply cost a full interval, and there was no state that ever
// noticed a run of failures. Observed live on TWO units (Gateway01, then
// TDeck01): the unit stays reachable, MQTT keeps publishing, LoRa keeps
// relaying — and the C2 heartbeat is silently dead for ~an hour until someone
// reboots it. That is exactly the failure mode an operator would not spot,
// because every other signal says the sensor is healthy.
//
// MQTT never suffered this: it runs its own task with connect-retry and
// exponential backoff. This brings the C2 bridge to the same standard.
//
// Three mechanisms:
//   1. RETRY FAST ON FAILURE — a failed post retries on a backoff (2s doubling
//      to 30s) instead of waiting out the full 60s heartbeat interval.
//   2. POST IMMEDIATELY ON WIFI RECOVERY — a drop/reconnect no longer leaves a
//      gap of up to a full interval before C2 hears from us again.
//   3. STALL RECOVERY — if WiFi claims to be up but nothing has posted
//      successfully for C2_STALL_RECOVER_MS, force a WiFi re-association.
//      That covers the case where the TCP stack or AP association is wedged in
//      a way WiFi.status() still reports as WL_CONNECTED.
// ---------------------------------------------------------------------------
#define C2_RETRY_BASE_MS      2000u
#define C2_RETRY_MAX_MS      30000u
#define C2_STALL_RECOVER_MS 300000u   // 5 min with no successful post

static uint32_t s_consecutive_fail  = 0;
static uint32_t s_retry_backoff_ms  = C2_RETRY_BASE_MS;
static uint32_t s_last_success_ms   = 0;
static bool     s_ever_posted_ok    = false;   // distinguishes never-posted from just-posted
static bool     s_wifi_was_down     = false;
static uint32_t s_stall_recoveries  = 0;

void c2_wifi_bridge_tick() {
    if (!s_configured) return;

    if (WiFi.status() != WL_CONNECTED) {
        s_wifi_was_down = true;      // remember, so we post the instant it returns
        return;
    }
    if (s_wifi_was_down) {
        s_wifi_was_down = false;
        s_have_posted_health = false;   // force an immediate heartbeat
        Serial.printf("[C2WifiBridge] WiFi recovered — posting health immediately\n");
    }

    uint32_t now = millis();

    // Seed the success clock on first run so the stall detector has a baseline
    // and cannot fire spuriously right after boot.
    if (s_last_success_ms == 0) s_last_success_ms = now;

    uint32_t wait = (s_consecutive_fail > 0) ? s_retry_backoff_ms
                                             : C2_WIFI_HEALTH_INTERVAL_MS;
    if (s_have_posted_health && (now - s_last_health_post_ms < wait)) {
        return;
    }

    s_last_health_post_ms = now;
    s_have_posted_health  = true;

    if (c2_wifi_bridge_post_health()) {
        if (s_consecutive_fail > 0) {
            Serial.printf("[C2WifiBridge] health recovered after %u failure(s)\n",
                          (unsigned)s_consecutive_fail);
        }
        s_consecutive_fail = 0;
        s_retry_backoff_ms = C2_RETRY_BASE_MS;
        s_last_success_ms  = now;
        s_ever_posted_ok   = true;
        return;
    }

    s_consecutive_fail++;
    s_retry_backoff_ms = (s_retry_backoff_ms < C2_RETRY_MAX_MS)
                       ? s_retry_backoff_ms * 2 : C2_RETRY_MAX_MS;
    Serial.printf("[C2WifiBridge] health post failed (%u consecutive), retry in %u ms\n",
                  (unsigned)s_consecutive_fail, (unsigned)s_retry_backoff_ms);

    // Stall recovery: association looks fine but nothing is getting through.
    if (now - s_last_success_ms > C2_STALL_RECOVER_MS) {
        s_stall_recoveries++;
        Serial.printf("[C2WifiBridge] STALLED %u s with WiFi up — forcing reassociation (#%u)\n",
                      (unsigned)((now - s_last_success_ms) / 1000),
                      (unsigned)s_stall_recoveries);
        WiFi.disconnect();
        WiFi.reconnect();
        // Reset the clock so we give the reconnect a full window to work
        // rather than thrashing the radio every tick.
        s_last_success_ms = now;
        s_wifi_was_down   = true;
    }
}

// Returns UINT32_MAX when NO health post has EVER succeeded since boot.
//
// The first version returned 0 for both "just succeeded" and "never
// succeeded" — opposite meanings from the same value. That ambiguity showed up
// immediately in the field: Spencer03 reported c2_since=0 and looked perfectly
// healthy while being entirely absent from C2. A distinct sentinel makes the
// never-posted case unmistakable.
uint32_t c2_wifi_bridge_seconds_since_success() {
    if (!s_ever_posted_ok) return UINT32_MAX;
    return (millis() - s_last_success_ms) / 1000u;
}
uint32_t c2_wifi_bridge_consecutive_failures() { return s_consecutive_fail; }
uint32_t c2_wifi_bridge_stall_recoveries()     { return s_stall_recoveries; }

void c2_wifi_bridge_get_stats(C2WifiBridgeStats* out) {
    if (out) *out = s_stats;
}

const char* c2_wifi_bridge_host() { return s_host; }
uint16_t    c2_wifi_bridge_port() { return s_port; }
bool        c2_wifi_bridge_is_configured() { return s_configured; }

#else  // !ARDUINO — native/test stub

void c2_wifi_bridge_init() {}
void c2_wifi_bridge_connect(const char*, uint16_t, const char*) {}
void c2_wifi_bridge_restore_from_nvs() {}
bool c2_wifi_bridge_post_detection(const DetectionEvent&) { return false; }
bool c2_wifi_bridge_post_relayed_detection(const DetectionEvent&) { return false; }
bool c2_wifi_bridge_post_health() { return false; }
bool c2_wifi_bridge_post_relayed_health(const DetectionEvent&) { return false; }
void c2_wifi_bridge_tick() {}
void c2_wifi_bridge_get_stats(C2WifiBridgeStats* out) {
    if (out) { C2WifiBridgeStats z{}; *out = z; }
}

const char* c2_wifi_bridge_host() { return ""; }
uint16_t    c2_wifi_bridge_port() { return 0; }
bool        c2_wifi_bridge_is_configured() { return false; }

#endif
