// ============================================================================
// ble_companion.cpp — Direct phone-to-sensor BLE peripheral (NimBLE backend)
//
// Build modes:
//
//   1. Firmware build with -DPDL_BLE_COMPANION_ENABLED:
//      Pulls in NimBLE-Arduino, registers the GATT service, advertises as
//      "PDL-<SENSOR_NAME>", and runs in parallel with LoRa + WiFi promiscuous
//      scan. All NimBLE work happens on NimBLE's own FreeRTOS task; the main
//      loop only reads/writes shared state behind a critical-section guard.
//
//   2. Firmware build WITHOUT PDL_BLE_COMPANION_ENABLED (default env):
//      Every public function compiles to a no-op stub. No NimBLE symbols are
//      referenced and no flash/RAM is consumed.
//
//   3. Native UNIT_TEST host build:
//      All NimBLE machinery is replaced by simple in-memory shims. The HMAC
//      math, auth window logic, command-packet round-trip, and JSON byte
//      budgets are exercised directly by Unity tests in
//      test/test_ble_companion/test_ble_companion.cpp.
//
// All code paths are static-allocation only in the hot path. NimBLE itself
// allocates internally — that's accepted; our state stays bounded.
// ============================================================================

#include "ble_companion.h"
#include "wifi_status_server.h"

#include <cstring>
#include <cstdio>
#include <cstdlib>

// ---- Platform headers --------------------------------------------------------
#if defined(PDL_BLE_COMPANION_ENABLED) && !defined(UNIT_TEST)
#  include <Arduino.h>
#  include <esp_system.h>
#  include <esp_random.h>
#  include "mbedtls/md.h"
#  include "mbedtls/sha256.h"
#  include <NimBLEDevice.h>
#  define BLE_HAVE_NIMBLE 1
#  include "command_handler.h"
#  include "auth_gate.h"
#  include "network_id.h"
#  include "enrollment.h"
#  include "markov_scorer.h"
#  include <freertos/FreeRTOS.h>
#  include <freertos/queue.h>
#  include <freertos/semphr.h>
#elif defined(UNIT_TEST)
   // Host-side shims: portable C/C++ only.
#  include <cstdint>
#  include <cstddef>
#  include <ctime>
   // Re-implement HMAC-SHA256 in pure C for tests so we don't need mbedTLS on
   // the host. Implementation is at the bottom of this file under the
   // ble_companion_hmac_sha256 definition.
#else
   // PDL_BLE_COMPANION_ENABLED not defined and not UNIT_TEST -> stub mode.
#  define BLE_STUB_MODE 1
#endif

// =============================================================================
// STUB MODE (BLE not compiled into this build)
// =============================================================================
#ifdef BLE_STUB_MODE

void  ble_companion_init() {}
void* ble_companion_get_service() { return nullptr; }
void  ble_companion_start_advertising() {}
void  ble_companion_tick() {}
void  ble_companion_publish_detection(const DetectionEvent&) {}
void  ble_companion_publish_nll(float) {}
void  ble_companion_publish_vqc_sample(const float[5], float, uint32_t) {}
bool  ble_companion_is_connected() { return false; }
void  ble_companion_get_stats(BleCompanionStats* out) {
    if (out) std::memset(out, 0, sizeof(*out));
}
void  ble_companion_audit_cmd(const uint8_t*, size_t, const char*, uint8_t) {}
bool  ble_companion_add_authorized_pin_hmac(const uint8_t[32]) { return false; }
void  ble_companion_set_operator_pin(const char*) {}
void  ble_companion_set_challenge(const uint8_t[BLE_PDL_CHALLENGE_BYTES]) {}
void  ble_companion_hmac_sha256(const uint8_t*, size_t, const uint8_t*, size_t,
                                uint8_t out[32]) { std::memset(out, 0, 32); }
bool  ble_companion_process_auth_response(const uint8_t*, size_t, uint32_t) { return false; }
uint8_t ble_companion_process_cmd(const uint8_t*, size_t, uint32_t,
                                  uint8_t*, size_t*) { return BLE_PDL_ERR_NOT_AUTH; }
bool  ble_companion_is_authenticated(uint32_t) { return false; }

// FIXED: this was only ever defined in the full (!BLE_STUB_MODE) impl below,
// never here -- harmless as long as nothing outside ble_companion.cpp
// referenced it unconditionally, but command_handler.cpp's
// handleIncomingCommand() now does exactly that (see its own extern "C"
// forward-declaration, added to route Cmd ACKs to a BLE-connected phone —
// see command_handler.cpp for the full story). Any build with BLE compiled
// out (BLE_STUB_MODE) linked command_handler.cpp needs a real symbol here,
// not just a header declaration -- confirmed via a real link failure
// building env:heltec_wifi_lora_v4_uav_bridge (undefined reference to
// ble_companion_capture_cmd_ack in command_handler.cpp.o).
extern "C" void ble_companion_capture_cmd_ack(const uint8_t*, size_t) {}

#else  // !BLE_STUB_MODE — full impl (firmware OR test)

// =============================================================================
// SHARED STATE (firmware + host tests both use these)
// =============================================================================
namespace {

struct AuthState {
    bool     authenticated;
    uint32_t expires_at_ms;
    char     callsign[BLE_PDL_AUTHRESP_CALLSIGN_BYTES + 1];
};

static AuthState s_auth = {};
static uint8_t   s_challenge[BLE_PDL_CHALLENGE_BYTES] = {};
static uint8_t   s_authorized_hmacs[BLE_PDL_AUTHORIZED_HMAC_SLOTS][32] = {};
static int       s_authorized_count = 0;

// Latest NLL from the Markov scorer; updated by ble_companion_publish_nll().
static float     s_last_nll = 0.0f;

// Stats
static BleCompanionStats s_stats = {};

// JSON status buffer (reused; never heap-allocated)
static char      s_status_json[BLE_PDL_STATUS_JSON_MAX + 1] = {};
static size_t    s_status_json_len = 0;

// Latest detection JSON (reused)
static char      s_detect_json[BLE_PDL_STATUS_JSON_MAX + 1] = {};
static size_t    s_detect_json_len = 0;
static bool      s_detect_pending_notify = false;

// Latest threat_vqc sample JSON (reused)
static char      s_vqc_json[BLE_PDL_STATUS_JSON_MAX + 1] = {};
static size_t    s_vqc_json_len = 0;
static bool      s_vqc_pending_notify = false;

#ifdef BLE_HAVE_NIMBLE
// NimBLE handles
static NimBLEServer*         s_server         = nullptr;
static NimBLEService*        s_service        = nullptr;
static NimBLECharacteristic* s_char_status    = nullptr;
static NimBLECharacteristic* s_char_networks  = nullptr;
static NimBLECharacteristic* s_char_detect    = nullptr;
static NimBLECharacteristic* s_char_vqc       = nullptr;
static NimBLECharacteristic* s_char_challenge = nullptr;
static NimBLECharacteristic* s_char_authresp  = nullptr;
static NimBLECharacteristic* s_char_cmd       = nullptr;
static NimBLECharacteristic* s_char_cmdack    = nullptr;
static NimBLECharacteristic* s_char_wifi      = nullptr;
static NimBLEAdvertising*    s_adv            = nullptr;
static bool                  s_service_started = false;
static char                  s_local_name[32]  = {0};

// Cmd queue (NimBLE callback -> main loop)
struct CmdQueueEntry {
    uint8_t  buf[32];
    size_t   len;
    char     callsign[BLE_PDL_AUTHRESP_CALLSIGN_BYTES + 1];
    uint32_t enq_ms;
};
static QueueHandle_t s_cmd_queue = nullptr;
static SemaphoreHandle_t s_state_mutex = nullptr;

// Connection state (single-link peripheral; NimBLE supports more but T1
// sensors are paired one phone at a time).
static bool      s_connected      = false;
static uint16_t  s_conn_handle    = 0;

#define LOCK()   xSemaphoreTake(s_state_mutex, portMAX_DELAY)
#define UNLOCK() xSemaphoreGive(s_state_mutex)
#else
#define LOCK()   ((void)0)
#define UNLOCK() ((void)0)
// Host-test stubs for connection state used by ble_companion_get_stats().
static bool     s_connected    = false;
static uint16_t s_conn_handle  = 0;
#endif

static bool      s_initialized = false;
static char      s_sensor_name[24]     = {};
static uint32_t  s_sensor_id_uint      = 0;
static char      s_passkey_str[7]      = "000000";

}  // namespace

// =============================================================================
// HMAC-SHA256
// =============================================================================
#ifdef BLE_HAVE_NIMBLE
// ---------------------------------------------------------------------------
// Default operator PIN provisioning.
//
// Real gap found and fixed here: ble_companion_add_authorized_pin_hmac()
// was never called anywhere in the firmware, so s_authorized_count stayed
// 0 forever -- ble_companion_is_authenticated() (and therefore every Cmd
// write) could NEVER succeed, no matter what the phone sent, with no
// operator-facing way to provision a PIN at all. Confirmed live: a real
// REPORT_STATUS command from the Android app timed out with zero ACK,
// because ble_companion_process_cmd() was silently returning
// BLE_PDL_ERR_NOT_AUTH before ever reaching the command dispatcher.
//
// Provisions a documented factory-default PIN ("000000") at boot so
// tasking works out of the box -- SET_OPERATOR_PIN (below) lets an
// operator replace it with a real one per deployment without needing a
// firmware rebuild.
#define BLE_DEFAULT_OPERATOR_PIN "000000"

static void ble_companion_provision_default_pin() {
    uint8_t hash[32];
    mbedtls_sha256((const unsigned char*)BLE_DEFAULT_OPERATOR_PIN,
                    strlen(BLE_DEFAULT_OPERATOR_PIN), hash, 0);
    ble_companion_add_authorized_pin_hmac(hash);
}

void ble_companion_set_operator_pin(const char* pin) {
    if (!pin || !pin[0]) return;
    uint8_t hash[32];
    mbedtls_sha256((const unsigned char*)pin, strlen(pin), hash, 0);
    ble_companion_add_authorized_pin_hmac(hash);
}

void ble_companion_hmac_sha256(const uint8_t* key, size_t key_len,
                               const uint8_t* msg, size_t msg_len,
                               uint8_t out[32]) {
    const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
    mbedtls_md_context_t ctx;
    mbedtls_md_init(&ctx);
    mbedtls_md_setup(&ctx, info, /*hmac=*/1);
    mbedtls_md_hmac_starts(&ctx, key, key_len);
    mbedtls_md_hmac_update(&ctx, msg, msg_len);
    mbedtls_md_hmac_finish(&ctx, out);
    mbedtls_md_free(&ctx);
}
#else
// Pure-C SHA-256 + HMAC-SHA256 for the host build. Reference impl — small
// enough that pulling in OpenSSL/mbedtls just for tests is overkill.
namespace {
struct Sha256Ctx { uint32_t state[8]; uint64_t bits; uint8_t buf[64]; size_t buf_len; };

static const uint32_t K256[64] = {
    0x428a2f98u,0x71374491u,0xb5c0fbcfu,0xe9b5dba5u,0x3956c25bu,0x59f111f1u,
    0x923f82a4u,0xab1c5ed5u,0xd807aa98u,0x12835b01u,0x243185beu,0x550c7dc3u,
    0x72be5d74u,0x80deb1feu,0x9bdc06a7u,0xc19bf174u,0xe49b69c1u,0xefbe4786u,
    0x0fc19dc6u,0x240ca1ccu,0x2de92c6fu,0x4a7484aau,0x5cb0a9dcu,0x76f988dau,
    0x983e5152u,0xa831c66du,0xb00327c8u,0xbf597fc7u,0xc6e00bf3u,0xd5a79147u,
    0x06ca6351u,0x14292967u,0x27b70a85u,0x2e1b2138u,0x4d2c6dfcu,0x53380d13u,
    0x650a7354u,0x766a0abbu,0x81c2c92eu,0x92722c85u,0xa2bfe8a1u,0xa81a664bu,
    0xc24b8b70u,0xc76c51a3u,0xd192e819u,0xd6990624u,0xf40e3585u,0x106aa070u,
    0x19a4c116u,0x1e376c08u,0x2748774cu,0x34b0bcb5u,0x391c0cb3u,0x4ed8aa4au,
    0x5b9cca4fu,0x682e6ff3u,0x748f82eeu,0x78a5636fu,0x84c87814u,0x8cc70208u,
    0x90befffau,0xa4506cebu,0xbef9a3f7u,0xc67178f2u,
};

static inline uint32_t ror32(uint32_t x, int n){ return (x >> n) | (x << (32-n)); }

static void sha256_compress(uint32_t state[8], const uint8_t blk[64]) {
    uint32_t w[64];
    for (int i=0; i<16; i++) {
        w[i] = ((uint32_t)blk[i*4]<<24)|((uint32_t)blk[i*4+1]<<16)|
               ((uint32_t)blk[i*4+2]<<8)|((uint32_t)blk[i*4+3]);
    }
    for (int i=16; i<64; i++) {
        uint32_t s0 = ror32(w[i-15],7) ^ ror32(w[i-15],18) ^ (w[i-15]>>3);
        uint32_t s1 = ror32(w[i-2],17) ^ ror32(w[i-2],19) ^ (w[i-2]>>10);
        w[i] = w[i-16] + s0 + w[i-7] + s1;
    }
    uint32_t a=state[0],b=state[1],c=state[2],d=state[3];
    uint32_t e=state[4],f=state[5],g=state[6],h=state[7];
    for (int i=0; i<64; i++) {
        uint32_t S1 = ror32(e,6)^ror32(e,11)^ror32(e,25);
        uint32_t ch = (e&f)^((~e)&g);
        uint32_t t1 = h + S1 + ch + K256[i] + w[i];
        uint32_t S0 = ror32(a,2)^ror32(a,13)^ror32(a,22);
        uint32_t mj = (a&b)^(a&c)^(b&c);
        uint32_t t2 = S0 + mj;
        h=g; g=f; f=e; e=d+t1; d=c; c=b; b=a; a=t1+t2;
    }
    state[0]+=a; state[1]+=b; state[2]+=c; state[3]+=d;
    state[4]+=e; state[5]+=f; state[6]+=g; state[7]+=h;
}

static void sha256_init(Sha256Ctx* c) {
    c->state[0]=0x6a09e667u; c->state[1]=0xbb67ae85u; c->state[2]=0x3c6ef372u;
    c->state[3]=0xa54ff53au; c->state[4]=0x510e527fu; c->state[5]=0x9b05688cu;
    c->state[6]=0x1f83d9abu; c->state[7]=0x5be0cd19u;
    c->bits=0; c->buf_len=0;
}
static void sha256_update(Sha256Ctx* c, const uint8_t* data, size_t len) {
    c->bits += (uint64_t)len * 8;
    while (len) {
        size_t take = 64 - c->buf_len;
        if (take > len) take = len;
        std::memcpy(c->buf + c->buf_len, data, take);
        c->buf_len += take; data += take; len -= take;
        if (c->buf_len == 64) { sha256_compress(c->state, c->buf); c->buf_len = 0; }
    }
}
static void sha256_final(Sha256Ctx* c, uint8_t out[32]) {
    uint64_t bits = c->bits;
    uint8_t pad = 0x80;
    sha256_update(c, &pad, 1);
    uint8_t zero = 0;
    while (c->buf_len != 56) sha256_update(c, &zero, 1);
    uint8_t lenbe[8];
    for (int i=0; i<8; i++) lenbe[i] = (uint8_t)(bits >> (56 - i*8));
    sha256_update(c, lenbe, 8);
    for (int i=0; i<8; i++) {
        out[i*4]   = (uint8_t)(c->state[i] >> 24);
        out[i*4+1] = (uint8_t)(c->state[i] >> 16);
        out[i*4+2] = (uint8_t)(c->state[i] >> 8);
        out[i*4+3] = (uint8_t)(c->state[i]);
    }
}

static void hmac_sha256_compute(const uint8_t* key, size_t klen,
                                const uint8_t* msg, size_t mlen,
                                uint8_t out[32]) {
    uint8_t k[64] = {};
    if (klen > 64) {
        Sha256Ctx c; sha256_init(&c); sha256_update(&c, key, klen); sha256_final(&c, k);
    } else {
        std::memcpy(k, key, klen);
    }
    uint8_t kipad[64], kopad[64];
    for (int i=0; i<64; i++) { kipad[i] = k[i] ^ 0x36; kopad[i] = k[i] ^ 0x5c; }
    Sha256Ctx c1; sha256_init(&c1);
    sha256_update(&c1, kipad, 64);
    sha256_update(&c1, msg, mlen);
    uint8_t inner[32]; sha256_final(&c1, inner);
    Sha256Ctx c2; sha256_init(&c2);
    sha256_update(&c2, kopad, 64);
    sha256_update(&c2, inner, 32);
    sha256_final(&c2, out);
}
}  // namespace

void ble_companion_hmac_sha256(const uint8_t* key, size_t key_len,
                               const uint8_t* msg, size_t msg_len,
                               uint8_t out[32]) {
    hmac_sha256_compute(key, key_len, msg, msg_len, out);
}

// Host-build counterparts of the default-PIN provisioning added to the real
// NimBLE ble_companion_init() -- link-only parity (declared unconditionally
// in ble_companion.h). Not auto-invoked by the host's ble_companion_init()
// stub so existing tests keep their clean/empty allowlist by default; tests
// that want a provisioned PIN call this directly.
void ble_companion_set_operator_pin(const char* pin) {
    if (!pin || !pin[0]) return;
    Sha256Ctx c; sha256_init(&c);
    sha256_update(&c, (const uint8_t*)pin, std::strlen(pin));
    uint8_t hash[32]; sha256_final(&c, hash);
    ble_companion_add_authorized_pin_hmac(hash);
}
#endif  // BLE_HAVE_NIMBLE / host

// =============================================================================
// Auth handshake (platform-independent)
// =============================================================================

bool ble_companion_add_authorized_pin_hmac(const uint8_t pin_hmac[32]) {
    if (!pin_hmac) return false;
    if (s_authorized_count >= BLE_PDL_AUTHORIZED_HMAC_SLOTS) return false;
    std::memcpy(s_authorized_hmacs[s_authorized_count], pin_hmac, 32);
    s_authorized_count++;
    return true;
}

void ble_companion_set_challenge(const uint8_t nonce[BLE_PDL_CHALLENGE_BYTES]) {
    if (!nonce) return;
    LOCK();
    std::memcpy(s_challenge, nonce, BLE_PDL_CHALLENGE_BYTES);
    UNLOCK();
}

bool ble_companion_is_authenticated(uint32_t now_ms) {
    if (!s_auth.authenticated) return false;
    // Auth expires after BLE_PDL_AUTH_WINDOW_MS. Handle wrap-around safely:
    // expires_at_ms is stored as the absolute deadline; a clock that wraps
    // every 49 days is a hard reboot anyway, so direct compare is fine.
    if ((int32_t)(now_ms - s_auth.expires_at_ms) >= 0) return false;
    return true;
}

bool ble_companion_process_auth_response(const uint8_t* payload, size_t len,
                                         uint32_t now_ms) {
    if (!payload || len != BLE_PDL_AUTHRESP_TOTAL_BYTES) {
        s_stats.auth_fail_count++;
        return false;
    }
    // Layout: callsign[16] | hmac[32]
    const uint8_t* callsign_bytes = payload + 0;
    const uint8_t* response_hmac  = payload + BLE_PDL_AUTHRESP_CALLSIGN_BYTES;

    // Constant-time compare against each authorized pin_hmac. The phone
    // computes HMAC-SHA256(pin_hmac, challenge); if any allowlisted operator
    // matches, auth succeeds.
    bool matched = false;
    for (int i = 0; i < s_authorized_count; i++) {
        uint8_t expected[32];
        ble_companion_hmac_sha256(s_authorized_hmacs[i], 32,
                                  s_challenge, BLE_PDL_CHALLENGE_BYTES,
                                  expected);
        // Constant-time compare
        uint8_t diff = 0;
        for (int j = 0; j < 32; j++) diff |= (uint8_t)(expected[j] ^ response_hmac[j]);
        if (diff == 0) { matched = true; break; }
    }

    if (!matched) {
        s_stats.auth_fail_count++;
        return false;
    }

    LOCK();
    s_auth.authenticated  = true;
    s_auth.expires_at_ms  = now_ms + BLE_PDL_AUTH_WINDOW_MS;
    std::memcpy(s_auth.callsign, callsign_bytes, BLE_PDL_AUTHRESP_CALLSIGN_BYTES);
    s_auth.callsign[BLE_PDL_AUTHRESP_CALLSIGN_BYTES] = '\0';
    // Strip trailing whitespace/zeros for printability
    for (int i = BLE_PDL_AUTHRESP_CALLSIGN_BYTES - 1; i >= 0; i--) {
        if (s_auth.callsign[i] == '\0' || s_auth.callsign[i] == ' ') {
            s_auth.callsign[i] = '\0';
        } else {
            break;
        }
    }
    std::memcpy(s_stats.last_callsign, s_auth.callsign,
                sizeof(s_stats.last_callsign));
    s_stats.auth_success_count++;
    UNLOCK();
    return true;
}

// =============================================================================
// Cmd processing
// =============================================================================
// Forward-declared command handler hook. In the firmware build this is
// command_handler.h's handleIncomingCommand(); under UNIT_TEST the test TU
// provides a stub that captures the buffer.
#ifndef UNIT_TEST
// command_handler.h declares this with C++ linkage; match it.
extern void handleIncomingCommand(const uint8_t* data, size_t len);
#else
extern void test_stub_handle_command(const uint8_t* data, size_t len);
#endif

// The most recently emitted CMD_ACK bytes (16). The firmware build patches
// command_handler's mesh_publish_raw output path; for v1 we capture by hooking
// a sentinel global that command_handler writes on every dispatch. Until that
// hook lands we synthesize a generic OK ack here.
static uint8_t  s_last_ack[16] = {};
static size_t   s_last_ack_len = 0;

// Public hook for command_handler integrators: when a Cmd dispatched via BLE
// finishes, the integrator can call this to feed the ACK back to the phone.
extern "C" void ble_companion_capture_cmd_ack(const uint8_t* ack_buf, size_t len) {
    if (!ack_buf || len == 0 || len > 16) return;
    LOCK();
    std::memcpy(s_last_ack, ack_buf, len);
    s_last_ack_len = len;
    UNLOCK();
}

uint8_t ble_companion_process_cmd(const uint8_t* cmd_buf, size_t cmd_len,
                                  uint32_t now_ms,
                                  uint8_t* out_ack, size_t* out_ack_len) {
    if (out_ack_len) *out_ack_len = 0;
    if (!ble_companion_is_authenticated(now_ms)) {
#ifdef BLE_HAVE_NIMBLE
        Serial.printf("[BLE] process_cmd: NOT_AUTH now_ms=%u auth=%d expires=%u\n",
                      now_ms, (int)s_auth.authenticated, s_auth.expires_at_ms);
#endif
        s_stats.cmd_rejected_count++;
        return BLE_PDL_ERR_NOT_AUTH;
    }
    if (!cmd_buf || cmd_len < 1 || cmd_len > 32) {
        s_stats.cmd_rejected_count++;
        return BLE_PDL_ERR_BAD_LEN;
    }

    // Reset captured ack
    LOCK();
    s_last_ack_len = 0;
    UNLOCK();

#ifndef UNIT_TEST
    handleIncomingCommand(cmd_buf, cmd_len);
#else
    test_stub_handle_command(cmd_buf, cmd_len);
#endif

    s_stats.cmd_accepted_count++;
    ble_companion_audit_cmd(cmd_buf, cmd_len, s_auth.callsign, /*result=*/0);

    if (out_ack && out_ack_len) {
        LOCK();
        if (s_last_ack_len > 0) {
            *out_ack_len = s_last_ack_len;
            std::memcpy(out_ack, s_last_ack, s_last_ack_len);
        }
        UNLOCK();
    }
    return BLE_PDL_OK;
}

// =============================================================================
// Audit-origin helper
// =============================================================================
void ble_companion_audit_cmd(const uint8_t* cmd_buf, size_t cmd_len,
                             const char* callsign, uint8_t result) {
#ifdef BLE_HAVE_NIMBLE
    // Use the existing audit ring. We pass an opaque "synthetic token" that
    // is just the cmd buffer prefixed with "BLE:" + callsign so the audit
    // entry's token_hash_first8 captures the BLE origin.
    uint8_t synth[128] = {};
    size_t off = 0;
    const char* prefix = "BLE:";
    std::memcpy(synth + off, prefix, 4); off += 4;
    if (callsign) {
        size_t cl = strnlen(callsign, BLE_PDL_AUTHRESP_CALLSIGN_BYTES);
        std::memcpy(synth + off, callsign, cl); off += cl;
    }
    if (cmd_buf && cmd_len > 0 && off + cmd_len <= sizeof(synth)) {
        std::memcpy(synth + off, cmd_buf, cmd_len); off += cmd_len;
    }
    // Pad to AUTHGATE_TOKEN_LEN (84) so the audit module accepts it as a token
    // for hashing purposes.
    if (off < AUTHGATE_TOKEN_LEN) off = AUTHGATE_TOKEN_LEN;
    uint8_t bssid[6] = {0};
    authgate_audit_append((uint64_t)millis(),
                          enrollment_get_sensor_id(),
                          bssid,
                          /*payload_id=*/0,
                          result,
                          synth,
                          off);
#else
    (void)cmd_buf; (void)cmd_len; (void)callsign; (void)result;
#endif
}

#ifdef BLE_HAVE_NIMBLE
// =============================================================================
// NimBLE callbacks (firmware only)
// =============================================================================

class PdlServerCallbacks : public NimBLEServerCallbacks {
public:
    void onConnect(NimBLEServer* /*srv*/, ble_gap_conn_desc* desc) override {
        s_connected = true;
        s_conn_handle = desc->conn_handle;
        s_stats.connect_count++;

        // Generate a fresh challenge for this connection.
        uint8_t nonce[BLE_PDL_CHALLENGE_BYTES];
        for (int i = 0; i < BLE_PDL_CHALLENGE_BYTES; i++) {
            nonce[i] = (uint8_t)(esp_random() & 0xFF);
        }
        ble_companion_set_challenge(nonce);
        if (s_char_challenge) s_char_challenge->setValue(nonce, BLE_PDL_CHALLENGE_BYTES);

        // Pause advertising (single-link peripheral).
        if (s_adv) NimBLEDevice::stopAdvertising();
    }

    void onDisconnect(NimBLEServer* /*srv*/) override {
        s_connected = false;
        // Drop authentication — phone must re-auth on reconnect.
        LOCK();
        s_auth.authenticated = false;
        UNLOCK();
        // Resume advertising.
        if (s_adv) NimBLEDevice::startAdvertising();
    }

    void onDisconnect(NimBLEServer* /*srv*/, ble_gap_conn_desc* desc) override {
        Serial.printf("[BLE] onDisconnect: encrypted=%u authenticated=%u bonded=%u key_size=%u conn_handle=%u\n",
                      desc->sec_state.encrypted, desc->sec_state.authenticated,
                      desc->sec_state.bonded, desc->sec_state.key_size,
                      desc->conn_handle);
    }

    // DIAGNOSTIC (temporary): confirmed via `dumpsys bluetooth_manager` on
    // the phone that "Bonded devices: 0" persists across repeated
    // BOND_STATE_BONDING -> BOND_STATE_NONE cycles even after the phone-side
    // passkey entry succeeds -- meaning the LTK/bond either never gets
    // created on this side, or gets dropped right after. None of NimBLE's
    // pairing callbacks previously logged anything, so there was no way to
    // tell which. This prints the actual security state NimBLE believes it
    // reached, to find the real root cause instead of guessing further.
    void onAuthenticationComplete(ble_gap_conn_desc* desc) override {
        Serial.printf("[BLE] onAuthenticationComplete: encrypted=%u authenticated=%u bonded=%u key_size=%u conn_handle=%u\n",
                      desc->sec_state.encrypted, desc->sec_state.authenticated,
                      desc->sec_state.bonded, desc->sec_state.key_size,
                      desc->conn_handle);
    }

    uint32_t onPassKeyRequest() override {
        // Display passkey = numeric form of last 6 hex digits of sensor_id.
        // We render as a 6-digit decimal so NimBLE's pairing UX works.
        uint32_t pk = s_sensor_id_uint & 0xFFFFFFu;
        uint32_t out = pk % 1000000u;
        Serial.printf("[BLE] onPassKeyRequest -> %06u\n", out);
        return out;
    }
    bool onConfirmPIN(uint32_t pin) override {
        Serial.printf("[BLE] onConfirmPIN(%u) -> true\n", pin);
        return true;
    }
};

class PdlAuthRespCallbacks : public NimBLECharacteristicCallbacks {
public:
    void onWrite(NimBLECharacteristic* chr) override {
        std::string val = chr->getValue();
        bool ok = ble_companion_process_auth_response(
            (const uint8_t*)val.data(), val.size(), millis());
        Serial.printf("[BLE] AuthResp onWrite: len=%u ok=%d authorized_count=%d\n",
                      (unsigned)val.size(), (int)ok, s_authorized_count);
        // NimBLE doesn't have a great way to surface a 1-byte error code on
        // the same write; we set the AuthResponse char value to a 1-byte
        // status that the phone reads back after the write completes.
        uint8_t status = ok ? BLE_PDL_OK : BLE_PDL_ERR_AUTH_FAIL;
        chr->setValue(&status, 1);
        chr->notify();  // notify in case the phone is subscribed
    }
};

class PdlCmdCallbacks : public NimBLECharacteristicCallbacks {
public:
    void onWrite(NimBLECharacteristic* chr) override {
        std::string val = chr->getValue();

        // Queue the command for the main-loop core to drain. We deliberately
        // do NOT call into command_handler / RadioLib from inside the NimBLE
        // task — that risks SX1262 SPI contention and stack-depth issues.
        if (s_cmd_queue && val.size() > 0 && val.size() <= 32) {
            CmdQueueEntry e;
            std::memcpy(e.buf, val.data(), val.size());
            e.len = val.size();
            std::memcpy(e.callsign, s_auth.callsign, sizeof(e.callsign));
            e.enq_ms = millis();
            // Non-blocking; if the queue is full we drop and increment stats.
            if (xQueueSend(s_cmd_queue, &e, 0) != pdTRUE) {
                s_stats.notify_dropped_count++;
            }
        }
        // The CmdAck notify will fire from ble_companion_tick() on the main
        // core after the command is dispatched.
    }
};

// Field WiFi provisioning over BLE -- the phone-app counterpart to the
// USB-serial WIFI_REMEMBER/WIFI_CONNECT console commands and to the
// SoftAP+web-form path (wifi_provisioning_start()). Payload is
// "SSID\nPASSWORD" (newline-separated -- SSIDs/passwords can't contain a
// literal newline, so no escaping is needed); password half may be empty
// for an open network. Unlike PdlCmdCallbacks, this calls straight into
// wifi_autojoin_remember()/wifi_status_server_connect() from inside the
// NimBLE callback rather than deferring to the main-loop queue -- those
// two only touch WiFi/NVS state, never the SX1262 SPI bus or RadioLib, so
// the SPI-contention/stack-depth concern that motivates the command queue
// doesn't apply here.
class PdlWifiCallbacks : public NimBLECharacteristicCallbacks {
public:
    void onWrite(NimBLECharacteristic* chr) override {
        std::string val = chr->getValue();
        size_t nl = val.find('\n');
        std::string ssid = (nl == std::string::npos) ? val : val.substr(0, nl);
        std::string pass = (nl == std::string::npos) ? std::string() : val.substr(nl + 1);
        if (ssid.empty() || ssid.size() > 32 || pass.size() > 64) {
            Serial.printf("[BLE] Wifi onWrite: rejected (ssid_len=%u pass_len=%u)\n",
                          (unsigned)ssid.size(), (unsigned)pass.size());
            return;
        }
        Serial.printf("[BLE] Wifi onWrite: ssid=\"%s\"\n", ssid.c_str());
        wifi_autojoin_remember(ssid.c_str(), pass.c_str());
        wifi_status_server_connect(ssid.c_str(), pass.c_str());
    }
};

// =============================================================================
// JSON building (firmware)
// =============================================================================
static int batt_pct_now() {
#ifdef BATT_ADC_PIN
    int raw = analogRead(BATT_ADC_PIN);
    int mv = (int)((float)raw * 3300.0f / 4095.0f * BATT_ADC_DIVIDER);
    int pct = 100 * (mv - BATT_EMPTY_MV) / (BATT_FULL_MV - BATT_EMPTY_MV);
    if (pct < 0) pct = 0; if (pct > 100) pct = 100;
    return pct;
#else
    return 0;
#endif
}

static size_t build_status_json(char* buf, size_t cap) {
    uint32_t sid = enrollment_get_sensor_id();
    uint32_t up_s = millis() / 1000;
    int nets = network_id_count();
    int n = snprintf(buf, cap,
        "{\"name\":\"%s\",\"sensor_id_hex\":\"%08X\","
        "\"mesh_state\":\"%s\",\"nll\":%.2f,\"networks_count\":%d,"
        "\"uptime_s\":%u,\"battery_pct\":%d}",
        s_sensor_name, (unsigned)sid,
        enrollment_is_complete() ? "enrolled" : "boot",
        s_last_nll, nets, (unsigned)up_s, batt_pct_now());
    if (n < 0) return 0;
    return (size_t)n;
}

static size_t build_networks_json(char* buf, size_t cap) {
    int n = network_id_count();
    if (n > 8) n = 8;   // cap to fit BLE MTU; phone can re-read for full list
    size_t off = 0;
    int w = snprintf(buf + off, cap - off, "[");
    if (w < 0) return 0;
    off += (size_t)w;
    for (int i = 0; i < n && off < cap - 4; i++) {
        NetworkEntry e;
        if (!network_id_get(i, &e)) break;
        // Sanitize SSID to ASCII printable
        char ssid_safe[NETWORK_ID_SSID_MAX];
        size_t k = 0;
        for (size_t j = 0; j < NETWORK_ID_SSID_MAX - 1 && e.ssid[j]; j++) {
            char c = e.ssid[j];
            if (c >= 0x20 && c < 0x7f && c != '"' && c != '\\') ssid_safe[k++] = c;
        }
        ssid_safe[k] = '\0';
        w = snprintf(buf + off, cap - off,
            "%s{\"bssid\":\"%02X%02X%02X%02X%02X%02X\",\"ssid\":\"%s\","
            "\"ch\":%u,\"rssi\":%d}",
            (i == 0 ? "" : ","),
            e.bssid[0], e.bssid[1], e.bssid[2],
            e.bssid[3], e.bssid[4], e.bssid[5],
            ssid_safe, (unsigned)e.channel, (int)e.last_rssi);
        if (w < 0 || (size_t)w >= cap - off) break;
        off += (size_t)w;
    }
    if (off < cap - 1) { buf[off++] = ']'; }
    if (off < cap) buf[off] = '\0';
    return off;
}
#endif  // BLE_HAVE_NIMBLE

// =============================================================================
// Detection JSON (used by both firmware and host tests)
// =============================================================================
size_t build_detection_json(char* buf, size_t cap, const DetectionEvent& e) {
    // ADDED lat/lon: previously omitted entirely, but any downstream bridge
    // (phone app or otherwise) forwarding this to OpenForge C2 needs them --
    // sensor-ingest.ts's DetectionPayload requires lat/lon. DetectionEvent
    // already carries lat_fixed/lon_fixed (fixed-point, deg*1e7); this just
    // exposes them over the wire like the LoRa path already does.
    int n = snprintf(buf, cap,
        "{\"sensor_id\":%u,\"ts\":%u,\"class\":%u,\"conf\":%u,"
        "\"rssi\":%d,\"flags\":%u,\"lat\":%.7f,\"lon\":%.7f,"
        "\"sig\":\"%02X%02X%02X%02X%02X%02X%02X%02X\"}",
        (unsigned)e.sensor_id, (unsigned)e.timestamp_utc,
        (unsigned)e.class_label, (unsigned)e.confidence,
        (int)e.rssi, (unsigned)e.flags,
        (double)e.lat_fixed / 1e7, (double)e.lon_fixed / 1e7,
        e.signature_hash[0], e.signature_hash[1],
        e.signature_hash[2], e.signature_hash[3],
        e.signature_hash[4], e.signature_hash[5],
        e.signature_hash[6], e.signature_hash[7]);
    if (n < 0) return 0;
    return (size_t)n;
}

// =============================================================================
// threat_vqc sample JSON (used by both firmware and host tests) -- shape
// matches main.cpp's existing MQTT vqc payload (sensor_id/gateway_id/
// timestamp/feats/vqc_score) so both transports feed the same C2 endpoint
// shape. gateway_id is deliberately left blank here for the same reason as
// the MQTT path: T1 doesn't know which bridge (T2 or phone) will forward it;
// whichever side does the forwarding fills it in.
// =============================================================================
size_t build_vqc_json(char* buf, size_t cap, const char* sensor_name,
                      const float feats[5], float vqc_score,
                      uint32_t ts_utc_s) {
    int n = snprintf(buf, cap,
        "{\"sensor_id\":\"%s\",\"gateway_id\":\"\",\"timestamp\":%u,"
        "\"feats\":[%.4f,%.4f,%.4f,%.4f,%.4f],\"vqc_score\":%.4f}",
        sensor_name, (unsigned)ts_utc_s,
        (double)feats[0], (double)feats[1], (double)feats[2],
        (double)feats[3], (double)feats[4], (double)vqc_score);
    if (n < 0) return 0;
    return (size_t)n;
}

// =============================================================================
// Public entry points
// =============================================================================

void ble_companion_publish_detection(const DetectionEvent& evt) {
    LOCK();
    s_detect_json_len = build_detection_json(s_detect_json,
                                             BLE_PDL_STATUS_JSON_MAX, evt);
    s_detect_pending_notify = true;
    UNLOCK();
}

void ble_companion_publish_nll(float nll) {
    s_last_nll = nll;
}

void ble_companion_publish_vqc_sample(const float feats[5], float vqc_score,
                                      uint32_t ts_utc_s) {
    if (!feats) return;
    LOCK();
    s_vqc_json_len = build_vqc_json(s_vqc_json, BLE_PDL_STATUS_JSON_MAX,
                                    s_sensor_name, feats, vqc_score, ts_utc_s);
    s_vqc_pending_notify = true;
    UNLOCK();
}

void ble_companion_get_stats(BleCompanionStats* out) {
    if (!out) return;
    LOCK();
    *out = s_stats;
    out->advertising = (s_initialized && !s_connected);
    out->connected   = s_connected;
    out->authenticated = s_auth.authenticated;
    out->auth_expires_at_ms = s_auth.expires_at_ms;
    UNLOCK();
}

bool ble_companion_is_connected() {
#ifdef BLE_HAVE_NIMBLE
    return s_connected;
#else
    return false;
#endif
}

#ifdef BLE_HAVE_NIMBLE
void ble_companion_init() {
    if (s_initialized) return;

    // Mutex + queue
    s_state_mutex = xSemaphoreCreateMutex();
    s_cmd_queue   = xQueueCreate(BLE_PDL_NOTIFY_QUEUE_DEPTH, sizeof(CmdQueueEntry));

    // Sensor identity
    s_sensor_id_uint = enrollment_get_sensor_id();
    std::strncpy(s_sensor_name, SENSOR_NAME, sizeof(s_sensor_name) - 1);
    // FIXED: this was "%06X" (hex) while onPassKeyRequest() / setSecurityPasskey()
    // both compute the actual NimBLE passkey as a DECIMAL value
    // ((sensor_id & 0xFFFFFF) % 1000000) -- so whenever sensor_id contained a
    // hex digit A-F, or its decimal and hex digit strings simply differed (the
    // common case), the OLED showed a code that could never match what NimBLE
    // was actually asking the phone to enter, making passkey-entry pairing
    // impossible to complete by reading the device. Must match
    // onPassKeyRequest()'s computation exactly.
    snprintf(s_passkey_str, sizeof(s_passkey_str), "%06u",
             (unsigned)((s_sensor_id_uint & 0xFFFFFFu) % 1000000u));

    // Local name = "PDL-<NAME>" — kept in s_local_name (not a local var) so
    // ble_companion_start_advertising() can use it after this function returns.
    snprintf(s_local_name, sizeof(s_local_name), "PDL-%s", s_sensor_name);

    NimBLEDevice::init(s_local_name);
    NimBLEDevice::setPower(ESP_PWR_LVL_P9);  // +9 dBm
    // sc=false (legacy pairing, not LE Secure Connections): confirmed live
    // against a real Samsung phone that sc=true's SMP handshake fails during
    // the SC/ECDH key-exchange phase every time -- onAuthenticationComplete
    // always reports encrypted=0 authenticated=0 bonded=0, and neither
    // onPassKeyRequest nor onConfirmPIN ever fire, meaning the phone never
    // even reaches passkey entry before NimBLE gives up and disconnects.
    // Legacy pairing still gets MITM protection via the same passkey-entry
    // association model (DISPLAY_ONLY peripheral + phone keyboard entry) --
    // it just skips the ECDH exchange that's failing on this stack.
    NimBLEDevice::setSecurityAuth(/*bond=*/true, /*mitm=*/true, /*sc=*/false);
    NimBLEDevice::setSecurityIOCap(BLE_HS_IO_DISPLAY_ONLY);
    NimBLEDevice::setSecurityPasskey(s_sensor_id_uint % 1000000u);

    // Server + service + characteristics
    s_server = NimBLEDevice::createServer();
    s_server->setCallbacks(new PdlServerCallbacks());

    s_service = s_server->createService(BLE_PDL_SERVICE_UUID);

    // Standard device-info characteristics
    NimBLECharacteristic* mfr = s_service->createCharacteristic(
        NimBLEUUID((uint16_t)BLE_STD_CHAR_MFR_NAME), NIMBLE_PROPERTY::READ);
    mfr->setValue("PDL Consulting LLC");

    NimBLECharacteristic* model = s_service->createCharacteristic(
        NimBLEUUID((uint16_t)BLE_STD_CHAR_MODEL_NUMBER), NIMBLE_PROPERTY::READ);
    model->setValue("T1 RID Sensor");

    NimBLECharacteristic* serial = s_service->createCharacteristic(
        NimBLEUUID((uint16_t)BLE_STD_CHAR_SERIAL_NUMBER), NIMBLE_PROPERTY::READ);
    serial->setValue(s_sensor_name);

    // Custom characteristics
    s_char_status = s_service->createCharacteristic(
        BLE_PDL_CHAR_STATUS_UUID,
        NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);

    s_char_networks = s_service->createCharacteristic(
        BLE_PDL_CHAR_NETWORKS_UUID, NIMBLE_PROPERTY::READ);

    s_char_detect = s_service->createCharacteristic(
        BLE_PDL_CHAR_DETECT_UUID,
        NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);

    s_char_vqc = s_service->createCharacteristic(
        BLE_PDL_CHAR_VQC_UUID,
        NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);

    s_char_challenge = s_service->createCharacteristic(
        BLE_PDL_CHAR_CHALLENGE_UUID, NIMBLE_PROPERTY::READ);

    // FIXED: these two previously also required WRITE_ENC | WRITE_AUTHEN,
    // i.e. an OS-level bonded + encrypted BLE link before either could be
    // written at all. That's redundant with (and strictly weaker than
    // dependent on) the app-layer operator-PIN-HMAC challenge/response this
    // module already implements below (AuthChallenge/AuthResponse -> 8h
    // authenticated window enforced in ble_companion_process_cmd()) -- the
    // header docs describe that HMAC scheme as THE auth mechanism, not a
    // second layer on top of BLE bonding. Confirmed live and repeatedly
    // against a real Samsung phone that NimBLE's SMP pairing procedure
    // (both sc=true and sc=false) fails before ever reaching a passkey
    // callback -- onAuthenticationComplete always reports
    // encrypted=0/authenticated=0/bonded=0 -- which made the Cmd/AuthResp
    // characteristics permanently unwritable regardless of the app-layer
    // HMAC fix. Dropping the BLE-encryption requirement here removes that
    // broken dependency; the HMAC scheme (already covered by
    // test_set_operator_pin_provisions_matching_hmac etc.) remains the real
    // gate on command execution.
    s_char_authresp = s_service->createCharacteristic(
        BLE_PDL_CHAR_AUTHRESP_UUID,
        NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::NOTIFY);
    s_char_authresp->setCallbacks(new PdlAuthRespCallbacks());

    s_char_cmd = s_service->createCharacteristic(
        BLE_PDL_CHAR_CMD_UUID,
        NIMBLE_PROPERTY::WRITE);
    s_char_cmd->setCallbacks(new PdlCmdCallbacks());

    s_char_cmdack = s_service->createCharacteristic(
        BLE_PDL_CHAR_CMDACK_UUID, NIMBLE_PROPERTY::NOTIFY);

    s_char_wifi = s_service->createCharacteristic(
        BLE_PDL_CHAR_WIFI_UUID, NIMBLE_PROPERTY::WRITE);
    s_char_wifi->setCallbacks(new PdlWifiCallbacks());

    // NOTE: service start() + advertising moved to ble_companion_start_advertising()
    // (called separately by main.cpp) so a caller can add MORE characteristics
    // to s_service (e.g. ble_proxy_chars.cpp's ProxyTx/ProxyRx) in between —
    // NimBLE rejects new characteristics on an already-started service. This
    // was the reason ble_proxy_register() was silently adding nothing: it was
    // called after this function had already started the service, AND with a
    // null service pointer since ble_companion_get_service() didn't exist yet.

    // Provision the factory-default operator PIN so command auth has at
    // least one authorized entry at boot. Without this, s_authorized_count
    // stays 0 forever and every Cmd write is rejected with
    // BLE_PDL_ERR_NOT_AUTH before it ever reaches the dispatcher -- confirmed
    // live against Spencer01 (a REPORT_STATUS from the phone timed out with
    // zero ACK, silently, since ack_len is left at 0 on the auth-reject path).
    ble_companion_provision_default_pin();

    s_initialized = true;
}

void* ble_companion_get_service() {
    return (void*)s_service;
}

void ble_companion_start_advertising() {
    if (!s_service || s_service_started) return;
    s_service->start();
    s_service_started = true;

    s_adv = NimBLEDevice::getAdvertising();
    s_adv->addServiceUUID(BLE_PDL_SERVICE_UUID);
    s_adv->setName(s_local_name);
    s_adv->setScanResponse(true);
    s_adv->setMinPreferred(0x06);
    s_adv->setMaxPreferred(0x12);
    NimBLEDevice::startAdvertising();

    Serial.printf("[BLE] companion up: name=%s passkey=%s\n",
                  s_local_name, s_passkey_str);
}

void ble_companion_tick() {
    if (!s_initialized) return;

    // Drain any queued Cmd writes (run on main core, NOT the NimBLE task).
    CmdQueueEntry e;
    while (xQueueReceive(s_cmd_queue, &e, 0) == pdTRUE) {
        uint8_t ack[16] = {};
        size_t  ack_len = 0;
        uint8_t rc = ble_companion_process_cmd(e.buf, e.len, millis(),
                                               ack, &ack_len);
        if (rc == BLE_PDL_OK && ack_len > 0 && s_char_cmdack && s_connected) {
            s_char_cmdack->setValue(ack, ack_len);
            s_char_cmdack->notify();
        }
    }

    // 1 Hz status notify
    static uint32_t s_last_status_ms = 0;
    uint32_t now = millis();
    if (now - s_last_status_ms >= 1000) {
        s_last_status_ms = now;
        s_status_json_len = build_status_json(s_status_json,
                                              BLE_PDL_STATUS_JSON_MAX);
        if (s_char_status && s_status_json_len > 0) {
            s_char_status->setValue((uint8_t*)s_status_json, s_status_json_len);
            if (s_connected) s_char_status->notify();
        }
        // Also refresh networks JSON (read-only, no notify)
        size_t nlen = build_networks_json(s_status_json, BLE_PDL_STATUS_JSON_MAX);
        if (s_char_networks && nlen > 0) {
            s_char_networks->setValue((uint8_t*)s_status_json, nlen);
        }
    }

    // Drain detection notification
    bool need_notify = false;
    LOCK();
    if (s_detect_pending_notify && s_detect_json_len > 0) {
        if (s_char_detect && s_connected) {
            s_char_detect->setValue((uint8_t*)s_detect_json, s_detect_json_len);
            need_notify = true;
        }
        s_detect_pending_notify = false;
    }
    UNLOCK();
    if (need_notify) s_char_detect->notify();

    // Drain threat_vqc sample notification
    bool need_vqc_notify = false;
    LOCK();
    if (s_vqc_pending_notify && s_vqc_json_len > 0) {
        if (s_char_vqc && s_connected) {
            s_char_vqc->setValue((uint8_t*)s_vqc_json, s_vqc_json_len);
            need_vqc_notify = true;
        }
        s_vqc_pending_notify = false;
    }
    UNLOCK();
    if (need_vqc_notify) s_char_vqc->notify();
}
#else  // !BLE_HAVE_NIMBLE — host-test stubs

void ble_companion_init() {
    s_initialized = true;
    s_authorized_count = 0;
    std::memset(&s_auth, 0, sizeof(s_auth));
    std::memset(&s_stats, 0, sizeof(s_stats));
    std::memset(s_challenge, 0, sizeof(s_challenge));
}
void ble_companion_tick() {}

#endif

#ifdef UNIT_TEST
void ble_companion_test_reset() {
    s_initialized = false;
    s_authorized_count = 0;
    std::memset(&s_auth, 0, sizeof(s_auth));
    std::memset(&s_stats, 0, sizeof(s_stats));
    std::memset(s_challenge, 0, sizeof(s_challenge));
    std::memset(s_authorized_hmacs, 0, sizeof(s_authorized_hmacs));
    s_last_nll = 0.0f;
    s_status_json_len = 0;
    s_detect_json_len = 0;
    s_detect_pending_notify = false;
    s_vqc_json_len = 0;
    s_vqc_pending_notify = false;
    s_last_ack_len = 0;
    std::memset(s_last_ack, 0, sizeof(s_last_ack));
}
#endif

#endif  // !BLE_STUB_MODE
