#include "ble_provisioning.h"
#include "storage.h"
#include "diagnostics_manager.h"
#include <esp_mac.h>
#include <esp_system.h>


BLEProvisioning bleProvisioning;

// Static device name storage (needed for advertising data)
static char s_deviceName[20] = "StepperMote-0000";

// ============================================================
// Characteristic Callbacks
// ============================================================

class WiFiSSIDCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pCharacteristic,
               NimBLEConnInfo &connInfo) {
    NimBLEAttValue value = pCharacteristic->getValue();
    if (value.length() > 0 && value.length() < 33) {
      // FIX: Use explicit length to handle non-null-terminated BLE data safely
      bleProvisioning._receivedSSID =
          String((const char *)value.data(), value.length());
      Serial.printf("[BLE] SSID received: '%s' (%d chars)\n",
                    bleProvisioning._receivedSSID.c_str(), value.length());
    }
  }
};

class WiFiPassCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pCharacteristic,
               NimBLEConnInfo &connInfo) {
    NimBLEAttValue value = pCharacteristic->getValue();
    if (value.length() < 65) {
      // FIX: Use explicit length to handle non-null-terminated BLE data safely
      bleProvisioning._receivedPass =
          String((const char *)value.data(), value.length());
      Serial.printf("[BLE] Password received (%d chars)\n", value.length());

      // Mark credentials as received when we have both SSID and password
      if (bleProvisioning._receivedSSID.length() > 0) {
        bleProvisioning._credentialsReceived = true;
        bleProvisioning.setStatus(BLE_STATUS_CONFIGURING);
        Serial.println("[BLE] Credentials complete - ready to connect!");
      }
    }
  }
};

class StepperJogCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pCharacteristic,
               NimBLEConnInfo &connInfo) {
    NimBLEAttValue value = pCharacteristic->getValue();
    if (value.length() >= 1) {
      int8_t cmd = (int8_t)value.data()[0];
      bleProvisioning._jogCommand = cmd;
      bleProvisioning._jogCommandPending = true;
      Serial.printf("[BLE] Stepper Jog -> %d\n", cmd);
    }
  }
};

class WiFiScanCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pCharacteristic,
               NimBLEConnInfo &connInfo) {
    NimBLEAttValue value = pCharacteristic->getValue();
    if (value.length() >= 1 && value.data()[0] == 0x01) {
      bleProvisioning._scanRequested = true;
      Serial.println("[BLE] WiFi scan requested");
    }
  }
};

// NOTE: Use a unique name to avoid conflict with NimBLE's BLEServerCallbacks
class ConfigCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pCharacteristic,
               NimBLEConnInfo &connInfo) {
    NimBLEAttValue value = pCharacteristic->getValue();
    if (value.length() > 0) {
      // FIX: Use explicit length to handle non-null-terminated BLE data safely
      bleProvisioning._receivedConfigJson =
          String((const char *)value.data(), value.length());
      bleProvisioning._configUpdatePending = true;
      Serial.printf("[BLE] Config received (%d bytes): %s\n", value.length(),
                    bleProvisioning._receivedConfigJson.c_str());
    }
  }
};

// Rescue mode command callback — receives JSON commands from the web app
class CommandCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pCharacteristic,
               NimBLEConnInfo &connInfo) {
    NimBLEAttValue value = pCharacteristic->getValue();
    if (value.length() > 0 && value.length() < 512) {
      bleProvisioning._receivedCommand =
          String((const char *)value.data(), value.length());
      bleProvisioning._commandPending = true;
      Serial.printf("[BLE] Command received (%d bytes): %s\n", value.length(),
                    bleProvisioning._receivedCommand.c_str());
    }
  }
};

// Flag to prevent onDisconnect from operating during deinit() teardown.
// Must be static because the callback accesses it from the NimBLE host task.
static volatile bool s_bleStopping = false;

// NOTE: Use a unique name to avoid conflict with NimBLE's BLEServerCallbacks
// macro
class MyServerCallbacks : public NimBLEServerCallbacks {
  void onConnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo) {
    Serial.println("[BLE] ✓ Client connected!");
    Serial.printf("[BLE]   Address: %s\n",
                  connInfo.getAddress().toString().c_str());
  }

  void onDisconnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo,
                    int reason) {
    Serial.printf("[BLE] Client disconnected (reason: %d)\n", reason);
    // CRITICAL: Do NOT touch the server during shutdown.
    // During deinit(), this callback fires on the NimBLE host task while
    // the server is being destroyed. Calling startAdvertising() on a
    // half-freed server causes heap corruption (use-after-free).
    if (s_bleStopping) {
      Serial.println("[BLE] Shutdown in progress — skipping re-advertise");
      return;
    }
    // Restart advertising if still in provisioning mode
    if (bleProvisioning.isActive()) {
      Serial.println("[BLE] Restarting advertising...");
      pServer->startAdvertising();
    }
  }
};

// Static callback instances — these live for the entire program lifetime.
// IMPORTANT: NimBLE characteristic callbacks are NOT deleted by deinit (safe).
// NimBLE server callbacks ARE deleted by default — we pass deleteCallbacks=false
// in setCallbacks() to prevent deinit from calling 'delete' on a static object.
static WiFiSSIDCallback s_ssidCallback;
static WiFiPassCallback s_passCallback;
static StepperJogCallback s_jogCallback;
static WiFiScanCallback s_scanCallback;
static ConfigCallback s_configCallback;
static CommandCallback s_commandCallback;
static MyServerCallbacks s_serverCallbacks;

// ============================================================
// BLEProvisioning Implementation
// ============================================================

BLEProvisioning::BLEProvisioning()
    : _active(false), _rescueMode(false), _credentialsReceived(false),
      _jogCommand(0), _jogCommandPending(false), _scanRequested(false),
      _serialWritePos(0), _serialReadPos(0), _lastSerialStream(0),
      _commandPending(false), _pServer(nullptr), _pCharSSID(nullptr),
      _pCharPass(nullptr), _pCharDeviceId(nullptr), _pCharStatus(nullptr),
      _pCharStepperJog(nullptr), _pCharWifiScan(nullptr),
      _pCharWifiResults(nullptr), _pCharConfig(nullptr),
      _pCharSerialOutput(nullptr), _pCharCommand(nullptr),
      _pCharDiagnostics(nullptr) {
  memset(_serialBuffer, 0, sizeof(_serialBuffer));
}

void BLEProvisioning::begin() {
  _rescueMode = false;
  _beginInternal();
}

void BLEProvisioning::beginRescue() {
  _rescueMode = true;
  _beginInternal();
  setStatus(BLE_STATUS_RESCUE_MODE);
  Serial.println("[BLE] *** RESCUE MODE ACTIVE ***");
}

void BLEProvisioning::_beginInternal() {
  Serial.println("\n========================================");
  Serial.printf("[BLE] Starting Bluetooth %s\n",
                _rescueMode ? "RESCUE MODE" : "Provisioning");
  Serial.println("========================================");

// Log NimBLE configuration for debugging
#ifdef CONFIG_BT_NIMBLE_EXT_ADV
  Serial.printf("[BLE] Extended Advertising: %s\n",
                CONFIG_BT_NIMBLE_EXT_ADV
                    ? "ENABLED (may not be visible to phones!)"
                    : "DISABLED (good)");
#else
  Serial.println(
      "[BLE] Extended Advertising: DISABLED (legacy mode - compatible)");
#endif

  // Generate device name from raw MAC bytes — guaranteed unique per IEEE
  uint8_t mac[6];
  esp_read_mac(mac, ESP_MAC_WIFI_STA);
  // mac[3..5] are the NIC-specific bytes (unique per device)
  snprintf(s_deviceName, sizeof(s_deviceName), "LumiBlind-%02X%02X%02X", mac[3],
           mac[4], mac[5]);

  Serial.printf("[BLE] Device Name: %s\n", s_deviceName);
  Serial.printf("[BLE] Service UUID: %s\n", SERVICE_UUID);
  Serial.printf("[BLE] Free heap before init: %d bytes\n", ESP.getFreeHeap());

  // Initialize NimBLE with device name
  Serial.println("[BLE] Calling NimBLEDevice::init()...");
  if (!NimBLEDevice::init(s_deviceName)) {
    Serial.println("[BLE] ERROR: NimBLEDevice::init() failed!");
    Serial.println(
        "[BLE] This usually means BLE hardware issue or memory problem");
    return;
  }
  Serial.println("[BLE] NimBLEDevice initialized successfully");
  Serial.printf("[BLE] Free heap after init: %d bytes\n", ESP.getFreeHeap());

  // Set TX power for maximum range
  NimBLEDevice::setPower(ESP_PWR_LVL_P9);
  Serial.println("[BLE] TX Power set to +9dBm");

  // Create BLE Server
  Serial.println("[BLE] Creating server...");
  _pServer = NimBLEDevice::createServer();
  if (!_pServer) {
    Serial.println("[BLE] ERROR: Failed to create server!");
    diagnosticsManager.logErrorEvent("BLE", "Failed to create server!");
    return;
  }
  // CRITICAL: Pass deleteCallbacks=false because s_serverCallbacks is a static
  // object (BSS segment). NimBLE's default is deleteCallbacks=true, which makes
  // the server destructor call 'delete' on our static object during deinit(true).
  // Deleting a non-heap pointer triggers: "heap_caps_free: target pointer is
  // outside heap areas" → crash.
  _pServer->setCallbacks(&s_serverCallbacks, false);
  Serial.println("[BLE] Server created");

  // Create the main service
  Serial.println("[BLE] Creating service...");
  NimBLEService *pService = _pServer->createService(SERVICE_UUID);
  if (!pService) {
    Serial.println("[BLE] ERROR: Failed to create service!");
    diagnosticsManager.logErrorEvent("BLE", "Failed to create service!");
    return;
  }

  // Create all characteristics
  createBLECharacteristics(pService);

  // Start the service
  if (!pService->start()) {
    Serial.println("[BLE] ERROR: Failed to start service!");
    diagnosticsManager.logErrorEvent("BLE", "Failed to start service!");
    return;
  }
  Serial.println("[BLE] Service started");

  // Configure and start advertising
  Serial.println("[BLE] Configuring advertising...");
  NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();

  // Clear any previous advertising data
  pAdvertising->reset();

  // Set the device name in advertising data
  pAdvertising->setName(s_deviceName);

  // Add our service UUID so devices can filter by it
  pAdvertising->addServiceUUID(SERVICE_UUID);

  // Set appearance (generic remote control)
  pAdvertising->setAppearance(0x0180); // Generic Remote Control

  // CRITICAL: Enable scan response - this is where the device name goes
  // Without this, Web Bluetooth won't see the device name!
  pAdvertising->enableScanResponse(true);

  // Set to undirected connectable mode (standard for peripheral devices)
  pAdvertising->setConnectableMode(BLE_GAP_CONN_MODE_UND);

  // CRITICAL: Set general discoverable mode - without this, phones won't see
  // the device!
  pAdvertising->setDiscoverableMode(BLE_GAP_DISC_MODE_GEN);

  // Set advertising interval (100-200ms) for good visibility
  // Values are in units of 0.625ms: 160 = 100ms, 320 = 200ms
  pAdvertising->setMinInterval(160); // 100ms
  pAdvertising->setMaxInterval(320); // 200ms

  // Start advertising
  Serial.println("[BLE] Starting advertising...");
  if (!pAdvertising->start()) {
    Serial.println("[BLE] ERROR: Failed to start advertising!");
    Serial.println("[BLE] Check if BLE hardware is functioning");
    return;
  }

  _active = true;

  Serial.println("[BLE] ========================================");
  Serial.printf("[BLE] *** BLUETOOTH IS NOW ADVERTISING (%s) ***\n",
                _rescueMode ? "RESCUE" : "SETUP");
  Serial.printf("[BLE] Look for: %s\n", s_deviceName);
  Serial.println("[BLE] Using LEGACY advertising (phone compatible)");
  Serial.printf("[BLE] Free heap: %d bytes\n", ESP.getFreeHeap());
  Serial.println("[BLE] ========================================\n");
}

void BLEProvisioning::createBLECharacteristics(NimBLEService *pService) {
  Serial.println("[BLE] Creating characteristics...");

  // WiFi SSID - Write only
  _pCharSSID = pService->createCharacteristic(
      CHAR_WIFI_SSID_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR);
  // FIX: Use static instance instead of 'new' to avoid memory leak
  _pCharSSID->setCallbacks(&s_ssidCallback);
  Serial.printf("[BLE]   SSID: %s\n", CHAR_WIFI_SSID_UUID);

  // WiFi Password - Write only
  _pCharPass = pService->createCharacteristic(
      CHAR_WIFI_PASS_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR);
  // FIX: Use static instance instead of 'new' to avoid memory leak
  _pCharPass->setCallbacks(&s_passCallback);
  Serial.printf("[BLE]   PASS: %s\n", CHAR_WIFI_PASS_UUID);

  // Device ID - Read only
  _pCharDeviceId = pService->createCharacteristic(CHAR_DEVICE_ID_UUID,
                                                  NIMBLE_PROPERTY::READ);
  char deviceId[8];
  {
    uint8_t mac[6];
    esp_read_mac(mac, ESP_MAC_WIFI_STA);
    snprintf(deviceId, sizeof(deviceId), "%02X%02X%02X", mac[3], mac[4],
             mac[5]);
  }
  _pCharDeviceId->setValue(deviceId);
  Serial.printf("[BLE]   ID:   %s = '%s'\n", CHAR_DEVICE_ID_UUID, deviceId);

  // Status - Read + Notify
  _pCharStatus = pService->createCharacteristic(
      CHAR_STATUS_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
  uint8_t initialStatus = _rescueMode ? BLE_STATUS_RESCUE_MODE : BLE_STATUS_IDLE;
  _pCharStatus->setValue(&initialStatus, 1);
  Serial.printf("[BLE]   STAT: %s\n", CHAR_STATUS_UUID);

  // Stepper Jog Control - Write only
  _pCharStepperJog = pService->createCharacteristic(
      CHAR_STEPPER_JOG_UUID,
      NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR);
  // FIX: Use static instance instead of 'new' to avoid memory leak
  _pCharStepperJog->setCallbacks(&s_jogCallback);
  Serial.printf("[BLE]   STEPPER JOG: %s\n", CHAR_STEPPER_JOG_UUID);

  // WiFi Scan Trigger - Write only (write 0x01 to start scan)
  _pCharWifiScan = pService->createCharacteristic(
      CHAR_WIFI_SCAN_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR);
  // FIX: Use static instance instead of 'new' to avoid memory leak
  _pCharWifiScan->setCallbacks(&s_scanCallback);
  Serial.printf("[BLE]   SCAN: %s\n", CHAR_WIFI_SCAN_UUID);

  // WiFi Scan Results - Read + Notify (JSON format)
  _pCharWifiResults = pService->createCharacteristic(
      CHAR_WIFI_RESULTS_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
  _pCharWifiResults->setValue("");
  Serial.printf("[BLE]   WIFI_RESULTS: %s\n", CHAR_WIFI_RESULTS_UUID);

  // Config - Write only (JSON)
  _pCharConfig = pService->createCharacteristic(
      CHAR_CONFIG_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR);
  _pCharConfig->setCallbacks(&s_configCallback);
  Serial.printf("[BLE]   CONFIG: %s\n", CHAR_CONFIG_UUID);

  // === Rescue Mode Characteristics ===

  // Serial Output - Read + Notify (streams captured serial output)
  _pCharSerialOutput = pService->createCharacteristic(
      CHAR_SERIAL_OUTPUT_UUID,
      NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
  _pCharSerialOutput->setValue("");
  Serial.printf("[BLE]   SERIAL: %s\n", CHAR_SERIAL_OUTPUT_UUID);

  // Command - Write (receives JSON commands from web app)
  _pCharCommand = pService->createCharacteristic(
      CHAR_COMMAND_UUID,
      NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR);
  _pCharCommand->setCallbacks(&s_commandCallback);
  Serial.printf("[BLE]   COMMAND: %s\n", CHAR_COMMAND_UUID);

  // Diagnostics - Read + Notify (sends diagnostics JSON to client)
  _pCharDiagnostics = pService->createCharacteristic(
      CHAR_DIAGNOSTICS_UUID,
      NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
  _pCharDiagnostics->setValue("");
  Serial.printf("[BLE]   DIAGNOSTICS: %s\n", CHAR_DIAGNOSTICS_UUID);

  Serial.println("[BLE] All characteristics created");
}

void BLEProvisioning::stop() {
  if (_active) {
    Serial.println("[BLE] Stopping Bluetooth...");

    // CRITICAL FIX: Set the stopping flag FIRST to prevent the onDisconnect
    // callback from calling pServer->startAdvertising() during teardown.
    // Without this, deinit() fires onDisconnect on the NimBLE host task,
    // which tries to restart advertising on a half-destroyed server,
    // causing a heap_caps_free assertion (use-after-free crash).
    s_bleStopping = true;
    _active = false;
    _rescueMode = false;

    // Gracefully stop advertising and disconnect clients BEFORE deinit.
    // This ensures onDisconnect callbacks fire while the server is still
    // alive, and since _active is false + s_bleStopping is true, the
    // callbacks will safely no-op.
    if (_pServer) {
      NimBLEDevice::getAdvertising()->stop();

      // NimBLE has no "disconnect all" API — must iterate connected peers.
      // Iterate BACKWARDS: disconnecting a peer may shrink the connected
      // list asynchronously, so forward iteration risks out-of-bounds access.
      size_t connCount = _pServer->getConnectedCount();
      for (int i = (int)connCount - 1; i >= 0; i--) {
        NimBLEConnInfo peer = _pServer->getPeerInfo(i);
        _pServer->disconnect(peer.getConnHandle());
      }
      if (connCount > 0) {
        delay(150);  // Give NimBLE host task time to process disconnections
      }
    }

    NimBLEDevice::deinit(true);

    _pServer = nullptr;
    _pCharSSID = nullptr;
    _pCharPass = nullptr;
    _pCharDeviceId = nullptr;
    _pCharStatus = nullptr;
    _pCharStepperJog = nullptr;
    _pCharWifiScan = nullptr;
    _pCharWifiResults = nullptr;
    _pCharConfig = nullptr;
    _pCharSerialOutput = nullptr;
    _pCharCommand = nullptr;
    _pCharDiagnostics = nullptr;

    // Reset the stopping flag after teardown is complete
    s_bleStopping = false;

    Serial.println("[BLE] Bluetooth stopped");
  }
}

void BLEProvisioning::update() {
  // NimBLE runs in FreeRTOS task - nothing to poll
  // But in rescue mode, we periodically stream serial output
  if (_active && _rescueMode) {
    streamSerialOutput();
  }
}

void BLEProvisioning::setStatus(uint8_t status) {
  if (_pCharStatus && _active) {
    _pCharStatus->setValue(&status, 1);
    _pCharStatus->notify();

    const char *statusNames[] = {"IDLE",        "CONFIGURING", "CONNECTING",
                                 "CONNECTED",   "MQTT_OK",     "WIFI_FAIL",
                                 "MQTT_FAIL",   "SCANNING",    "SCAN_DONE",
                                 "RESCUE_MODE", "OTA_PROGRESS","OTA_SUCCESS",
                                 "OTA_FAIL",    "FACTORY_RESET"};
    if (status < 14) {
      Serial.printf("[BLE] Status -> %s (%d)\n", statusNames[status], status);
    }
  }
}

void BLEProvisioning::clearCredentials() {
  _receivedSSID = "";
  _receivedPass = "";
  _credentialsReceived = false;
  Serial.println("[BLE] Credentials cleared");
}

const char *BLEProvisioning::getDeviceName() { return s_deviceName; }

void BLEProvisioning::sendScanResults(const String &jsonResults) {
  if (_pCharWifiResults && _active) {
    // BLE MTU is typically 512 bytes max
    const size_t MAX_BLE_PAYLOAD = 500;

    if (jsonResults.length() <= MAX_BLE_PAYLOAD) {
      _pCharWifiResults->setValue((const uint8_t *)jsonResults.c_str(),
                                  jsonResults.length());
      _pCharWifiResults->notify();
      Serial.printf("[BLE] Sent scan results (%d bytes)\n",
                    jsonResults.length());
    } else {
      // Truncate but ensure valid JSON array
      String truncated = jsonResults.substring(0, MAX_BLE_PAYLOAD);
      int lastClose = truncated.lastIndexOf('}');
      if (lastClose > 0) {
        truncated = truncated.substring(0, lastClose + 1) + "]";
      } else {
        truncated = "[]";
      }
      _pCharWifiResults->setValue((const uint8_t *)truncated.c_str(),
                                  truncated.length());
      _pCharWifiResults->notify();
      Serial.printf(
          "[BLE] Sent truncated scan results (%d bytes, valid JSON)\n",
          truncated.length());
    }
    setStatus(BLE_STATUS_SCAN_DONE);
  }
}

// ============================================================
// Rescue Mode: Serial Output Capture & Streaming
// ============================================================

void BLEProvisioning::captureSerial(const char *data, size_t len) {
  // Write incoming serial data into the ring buffer
  // This is called from the Serial tee — must be fast and non-blocking
  for (size_t i = 0; i < len; i++) {
    _serialBuffer[_serialWritePos] = data[i];
    _serialWritePos = (_serialWritePos + 1) % BLE_SERIAL_BUFFER_SIZE;
    // If write catches up to read, advance read (lose oldest data)
    if (_serialWritePos == _serialReadPos) {
      _serialReadPos = (_serialReadPos + 1) % BLE_SERIAL_BUFFER_SIZE;
    }
  }
}

void BLEProvisioning::streamSerialOutput() {
  if (!_pCharSerialOutput || !_active) return;

  // Rate-limit streaming to every 200ms to avoid BLE congestion
  unsigned long now = millis();
  if (now - _lastSerialStream < 200) return;
  _lastSerialStream = now;

  // Check if there's data to send
  if (_serialReadPos == _serialWritePos) return; // Buffer empty

  // Read up to 128 bytes from the ring buffer to respect safe BLE MTU ceilings
  const size_t MAX_CHUNK = 128;
  char chunk[MAX_CHUNK + 1];
  size_t chunkLen = 0;

  // Snapshot write position to avoid race conditions
  size_t wp = _serialWritePos;

  while (_serialReadPos != wp && chunkLen < MAX_CHUNK) {
    chunk[chunkLen++] = _serialBuffer[_serialReadPos];
    _serialReadPos = (_serialReadPos + 1) % BLE_SERIAL_BUFFER_SIZE;
  }

  if (chunkLen > 0) {
    chunk[chunkLen] = '\0';
    _pCharSerialOutput->setValue((const uint8_t *)chunk, chunkLen);
    _pCharSerialOutput->notify();
  }
}

// ============================================================
// Rescue Mode: Diagnostics Push
// ============================================================

void BLEProvisioning::sendDiagnostics(const String &json) {
  if (!_pCharDiagnostics || !_active) return;

  // Diagnostics JSON can be large — send in chunks via notifications
  // Using 128 bytes guarantees it sneaks gracefully under iOS's 185 MTU 
  // limit without triggering silent dropped payloads in WebBluetooth
  const size_t MAX_CHUNK = 128;
  size_t offset = 0;
  size_t totalLen = json.length();

  // For small payloads, send directly
  if (totalLen <= MAX_CHUNK) {
    _pCharDiagnostics->setValue((const uint8_t *)json.c_str(), totalLen);
    _pCharDiagnostics->notify();
    Serial.printf("[BLE] Sent diagnostics (%d bytes)\n", totalLen);
    return;
  }

  // For large payloads, send in chunks with a small delimiter
  // The web app will reassemble them
  while (offset < totalLen) {
    size_t chunkSize = min(MAX_CHUNK, totalLen - offset);
    _pCharDiagnostics->setValue(
        (const uint8_t *)(json.c_str() + offset), chunkSize);
    _pCharDiagnostics->notify();
    offset += chunkSize;
    delay(20); // Small delay between chunks to avoid BLE congestion
  }
  Serial.printf("[BLE] Sent diagnostics in chunks (%d bytes total)\n", totalLen);
}

// ============================================================
// Rescue Mode: OTA Progress
// ============================================================

void BLEProvisioning::sendOTAProgress(uint8_t percent, const char *statusMsg) {
  if (!_pCharStatus || !_active) return;

  // Send progress via status characteristic
  // Encode: byte 0 = BLE_STATUS_OTA_PROGRESS, byte 1 = percent
  uint8_t data[2] = { BLE_STATUS_OTA_PROGRESS, percent };
  _pCharStatus->setValue(data, 2);
  _pCharStatus->notify();

  // Also log to serial (which gets captured for the serial monitor)
  Serial.printf("[OTA] Progress: %d%% - %s\n", percent, statusMsg);
}
