#include "MQTTBridge.h"
#include "../MQTTConnectionPolicy.h"
#include "../MQTTMessageBuilder.h"
#include "../MQTTPacketQueuePolicy.h"
#include "../MQTTRuntimeBufferLifecycle.h"
#include "../MQTTTopicRouter.h"
#include "../TxtDataHelpers.h"
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <Timezone.h>
#include <time.h>
#include <sys/time.h>
#include <math.h>
#include <strings.h>

#ifdef WITH_SNMP
#include "../SNMPAgent.h"
#endif

#ifdef ESP_PLATFORM
#include <esp_wifi.h>
#include <esp_heap_caps.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include <mbedtls/platform.h>
#endif

// Effective MQTT origin: empty mqtt_origin follows node_name; otherwise mqtt_origin override (quotes stripped).
static void applyEffectiveOrigin(const NodePrefs* np, const MQTTPrefs* obs, char* dest, size_t dest_size) {
  if (!np || !obs || !dest || dest_size == 0) return;
  if (obs->mqtt_origin[0] == '\0') {
    strncpy(dest, np->node_name, dest_size - 1);
  } else {
    strncpy(dest, obs->mqtt_origin, dest_size - 1);
  }
  dest[dest_size - 1] = '\0';
  StrHelper::stripSurroundingQuotes(dest, dest_size);
}

static const char* const kNtpBuiltinFallbacks[] = {
  "pool.ntp.org",
  "time.google.com",
  "time.cloudflare.com",
  "time.aws.com",
  "time.nist.gov",
};
static constexpr size_t kNtpBuiltinFallbackCount =
    sizeof(kNtpBuiltinFallbacks) / sizeof(kNtpBuiltinFallbacks[0]);
static_assert(MQTTBridge::kMaxNtpServers >= 1 + (int)kNtpBuiltinFallbackCount,
              "kMaxNtpServers must hold the custom primary plus all built-in fallbacks");

static bool ntpHostnameEquals(const char* a, const char* b) {
  if (!a || !b) return false;
  return strcasecmp(a, b) == 0;
}

static void fillNtpServerList(const MQTTPrefs* prefs, const char* servers[], int& count) {
  count = 0;
  if (prefs && prefs->mqtt_ntp_server[0] != '\0') {
    servers[count++] = prefs->mqtt_ntp_server;
  }
  for (size_t i = 0; i < kNtpBuiltinFallbackCount && count < MQTTBridge::kMaxNtpServers; i++) {
    const char* fb = kNtpBuiltinFallbacks[i];
    bool dup = false;
    for (int j = 0; j < count; j++) {
      if (ntpHostnameEquals(servers[j], fb)) {
        dup = true;
        break;
      }
    }
    if (!dup) {
      servers[count++] = fb;
    }
  }
}

const char* MQTTBridge::effectiveNtpPrimary(const MQTTPrefs* obs) {
  if (obs && obs->mqtt_ntp_server[0] != '\0') {
    return obs->mqtt_ntp_server;
  }
  return kNtpBuiltinFallbacks[0];
}

void MQTTBridge::refreshOriginFromPrefs() {
  if (!_prefs) return;
  applyEffectiveOrigin(_prefs, _obs, _origin, sizeof(_origin));
}

void MQTTBridge::getEffectiveMqttOrigin(const NodePrefs* np, const MQTTPrefs* obs, char* buf, size_t buf_size) {
  if (!buf || buf_size == 0) return;
  if (!np || !obs) {
    buf[0] = '\0';
    return;
  }
  applyEffectiveOrigin(np, obs, buf, buf_size);
}

// Helper function to check if WiFi credentials are valid
static bool isWiFiConfigValid(const MQTTPrefs* obs) {
  // Check if WiFi SSID is configured (not empty)
  if (!obs || strlen(obs->wifi_ssid) == 0) {
    return false;
  }

  // WiFi password can be empty for open networks, so we don't check it

  return true;
}

#ifdef WITH_MQTT_BRIDGE

// A custom slot endpoint is complete if a port is set, or if the host is a
// full URI with a scheme (esp-mqtt applies scheme default ports, and the URI
// builder in setupSlot() preserves any embedded port/path).
static bool customEndpointComplete(const char* host, uint16_t port) {
  return host[0] != '\0' && (port != 0 || strstr(host, "://") != nullptr);
}

bool MQTTBridge::isConfigValid(const MQTTPrefs* obs) {
  if (!obs || !isWiFiConfigValid(obs)) return false;
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    const char* preset_name = obs->mqtt_slot_preset[i];
    if (preset_name[0] == '\0' || strcmp(preset_name, MQTT_PRESET_NONE) == 0) continue;
    if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) {
      if (customEndpointComplete(obs->mqtt_slot_host[i], obs->mqtt_slot_port[i])) return true;
    } else if (findMQTTPreset(preset_name) != nullptr) {
      return true;
    }
  }
  return false;
}

// Optional embedded CA bundle symbols produced by board_build.embed_files.
// Weak linkage keeps non-bundle builds linkable and allows runtime fallback.
extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start") __attribute__((weak));
extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end") __attribute__((weak));

// Track whether the global cert bundle has been loaded into s_crt_bundle.
// Loading must happen exactly once to avoid a use-after-free race when multiple
// TLS slots are set up in sequence (each connect() launches an async task).
static bool s_ca_bundle_loaded = false;

// PSRAM-aware allocation: prefer PSRAM on ESP32 when BOARD_HAS_PSRAM, fallback to internal heap or malloc.
// Use psram_free() for any pointer returned by psram_malloc().
static void* psram_malloc(size_t size) {
  if (size == 0) return nullptr;
#if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM)
  void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM);
  if (p != nullptr) return p;
  p = heap_caps_malloc(size, MALLOC_CAP_INTERNAL);
  return p;
#else
  return malloc(size);
#endif
}

static void* psram_calloc(size_t n, size_t size) {
  if (n == 0 || size == 0) return nullptr;
#if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM)
  void* p = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM);
  if (p != nullptr) return p;
  return heap_caps_calloc(n, size, MALLOC_CAP_INTERNAL);
#else
  return calloc(n, size);
#endif
}

static void psram_free(void* ptr) {
  if (ptr == nullptr) return;
#if defined(ESP_PLATFORM)
  heap_caps_free(ptr);
#else
  free(ptr);
#endif
}

// Time (millis()) when WiFi was last seen connected; 0 when disconnected. Used for get wifi.status uptime.
static unsigned long s_wifi_connected_at = 0;

// Last WiFi disconnect reason (from ESP-IDF event). Used for get wifi.status diagnostics.
static uint8_t s_wifi_disconnect_reason = 0;
static unsigned long s_wifi_disconnect_time = 0;

#ifdef MQTT_MEMORY_DEBUG
// #region agent log
static void agentLogHeap(const char* location, const char* message, const char* hypothesisId,
                         size_t free_h, size_t max_alloc, unsigned long internal_free, unsigned long spiram_free) {
  char buf[320];
  snprintf(buf, sizeof(buf),
          "{\"sessionId\":\"debug-session\",\"location\":\"%s\",\"message\":\"%s\",\"hypothesisId\":\"%s\","
          "\"data\":{\"free\":%u,\"max_alloc\":%u,\"internal_free\":%lu,\"spiram_free\":%lu},\"timestamp\":%lu}",
          location, message, hypothesisId, (unsigned)free_h, (unsigned)max_alloc, internal_free, spiram_free,
          (unsigned long)millis());
  Serial.println(buf);
}
// #endregion
#endif

// Singleton for formatMqttStatusReply (set in begin(), cleared in end())
static MQTTBridge* s_mqtt_bridge_instance = nullptr;

unsigned long MQTTBridge::getWifiConnectedAtMillis() {
  return s_wifi_connected_at;
}

#if defined(WITH_MQTT_NEIGHBORS)
// Compact "time remaining" for the `get mqtt.status` nbr field: "3h12m" / "12m" / "45s".
static void formatDuration(char* buf, size_t len, uint32_t secs) {
  if (!buf || len == 0) return;
  uint32_t h = secs / 3600;
  uint32_t m = (secs % 3600) / 60;
  if (h > 0) {
    snprintf(buf, len, "%uh%um", (unsigned)h, (unsigned)m);
  } else if (m > 0) {
    snprintf(buf, len, "%um", (unsigned)m);
  } else {
    snprintf(buf, len, "%us", (unsigned)secs);
  }
}
#endif

void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs) {
  if (buf == nullptr || bufsize == 0) return;
  const char* msgs = (obs && obs->mqtt_status_enabled) ? "on" : "off";
  if (s_mqtt_bridge_instance == nullptr || !s_mqtt_bridge_instance->_initialized) {
    snprintf(buf, bufsize, "> msgs: %s (bridge not running)", msgs);
    return;
  }
  MQTTBridge* b = s_mqtt_bridge_instance;

  // Build per-slot status strings (compact format to fit 160-byte reply buffer)
  // Only show configured slots, skip "none" slots
  int q = 0;
#ifdef ESP_PLATFORM
  if (b->_packet_queue_handle != nullptr) {
    q = (int)uxQueueMessagesWaiting(b->_packet_queue_handle);
  }
#else
  q = b->_queue_count;
#endif

  int pos = snprintf(buf, bufsize, "> msgs: %s", msgs);
  for (int i = 0; i < RUNTIME_MQTT_SLOTS && pos < (int)bufsize - 1; i++) {
    const MQTTSlot& slot = b->_slots[i];
    const char* name = nullptr;
    const char* state = nullptr;

    if (!slot.enabled && slot.preset) {
      name = slot.preset->name;
      state = "inactive";
    } else if (!slot.enabled) {
      continue;  // Skip unconfigured slots
    } else if (!b->isSlotReady(i)) {
      name = slot.preset ? slot.preset->name : "custom";
      state = "wait";
    } else if (slot.connected) {
      name = slot.preset ? slot.preset->name : "custom";
      state = "ok";
    } else if (slot.circuit_breaker_tripped) {
      name = slot.preset ? slot.preset->name : "custom";
      state = "fail";
    } else {
      name = slot.preset ? slot.preset->name : "custom";
      state = "disc";
    }
    pos += snprintf(buf + pos, bufsize - pos, ", %d: %s (%s)", i + 1, name, state);
  }
  // snprintf returns the would-be length, so a full buffer can push pos past
  // bufsize; clamp before the remaining appends so bufsize - pos can't underflow.
  if (pos >= (int)bufsize) pos = (int)bufsize - 1;
  pos += snprintf(buf + pos, bufsize - pos, ", q:%d", q);
  if (pos >= (int)bufsize) pos = (int)bufsize - 1;

#if defined(WITH_MQTT_NEIGHBORS)
  // Periodic neighbors: time to next publish + how the last one went.
  if (obs && obs->mqtt_neighbors_enabled && pos < (int)bufsize - 1) {
    char when[16];
    switch (b->_neighbors_phase.load(std::memory_order_relaxed)) {
      case NBR_ACTIVE: strcpy(when, "active"); break;
      case NBR_DUE:    strcpy(when, "due"); break;
      default:
        formatDuration(when, sizeof(when),
                       b->_neighbors_secs_until_next.load(std::memory_order_relaxed));
        break;
    }
    const char* last;
    switch (b->_neighbors_last_result.load(std::memory_order_relaxed)) {
      case NBR_RESULT_OK:   last = "ok"; break;
      case NBR_RESULT_FAIL: last = "failed"; break;
      default:              last = "none"; break;
    }
    snprintf(buf + pos, bufsize - pos, ", nbr: %s/%s", when, last);
  }
#endif
}

// On-demand publish-health + heap snapshot for the `get mqtt.stats` CLI command.
// Same data as the (MQTT_MEMORY_DEBUG-only) periodic logMemoryStatus() line, but
// returned as a reply instead of logged. Per-slot "sN=ok/err": ok = cumulative
// accepted publishes, err = cumulative failures (socket error / network timeout).
// Outbox should read ~0 (QoS0 publishes synchronously); a rising err isolates a
// broker whose uplink is dropping writes.
void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) {
  if (buf == nullptr || bufsize == 0) return;
  if (s_mqtt_bridge_instance == nullptr || !s_mqtt_bridge_instance->_initialized) {
    snprintf(buf, bufsize, "> (bridge not running)");
    return;
  }
  MQTTBridge* b = s_mqtt_bridge_instance;

  int q = 0;
#ifdef ESP_PLATFORM
  if (b->_packet_queue_handle != nullptr) {
    q = (int)uxQueueMessagesWaiting(b->_packet_queue_handle);
  }
#else
  q = b->_queue_count;
#endif

  size_t outbox_total = 0;
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (b->_slots[i].client) outbox_total += b->_slots[i].client->getOutboxSize();
  }

  int pos = snprintf(buf, bufsize, "> Free=%d Max=%d q:%d/%d Outbox=%u |",
                     (int)ESP.getFreeHeap(), (int)ESP.getMaxAllocHeap(),
                     q, MAX_QUEUE_SIZE, (unsigned)outbox_total);
  for (int i = 0; i < RUNTIME_MQTT_SLOTS && pos < (int)bufsize - 1; i++) {
    if (!b->_slots[i].enabled || !b->_slots[i].client) continue;
    pos += snprintf(buf + pos, bufsize - pos, " s%d=%lu/%lu", i + 1,
                    b->_slots[i].client->getPublishOk(),
                    b->_slots[i].client->getPublishErr());
  }
}

// Structured per-slot status for the webconfig stats endpoint. Same state
// derivation as formatMqttStatusReply above. Returns false for out-of-range,
// unconfigured, or bridge-not-running slots.
bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out) {
  if (out == nullptr || slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false;
  if (s_mqtt_bridge_instance == nullptr || !s_mqtt_bridge_instance->_initialized) return false;
  MQTTBridge* b = s_mqtt_bridge_instance;
  const MQTTSlot& slot = b->_slots[slot_index];

  if (!slot.enabled && slot.preset) {
    out->name = slot.preset->name;
    out->state = "inactive";
  } else if (!slot.enabled) {
    return false;  // unconfigured slot
  } else {
    out->name = slot.preset ? slot.preset->name : "custom";
    if (!b->isSlotReady(slot_index)) {
      out->state = "wait";
    } else if (slot.connected) {
      out->state = "ok";
    } else if (slot.circuit_breaker_tripped) {
      out->state = "fail";
    } else {
      out->state = "disc";
    }
  }
  out->publish_ok = slot.client ? slot.client->getPublishOk() : 0;
  out->publish_err = slot.client ? slot.client->getPublishErr() : 0;
  return true;
}

int MQTTBridge::getMaxActiveSlots() {
  // Each WSS/TLS connection needs ~40KB for mbedTLS buffers. Without PSRAM even
  // 3 concurrent connections would exhaust internal heap, so cap at 2; with
  // PSRAM cap at 5 (6 configurable but 5 active max).
#if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM)
  return psramFound() ? 5 : 2;
#else
  return 2;
#endif
}

uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; }
unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; }

unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const {
  if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return 0;
  return _slots[slot_index].current_outage_started_ms;
}

bool MQTTBridge::isSlotEnabledAndAttempted(int slot_index) const {
  if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false;
  const MQTTSlot& s = _slots[slot_index];
  return s.enabled && s.initial_connect_done;
}

const char* MQTTBridge::getSlotPresetName(int slot_index) const {
  if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return "?";
  const MQTTSlot& s = _slots[slot_index];
  if (s.preset && s.preset->name) return s.preset->name;
  if (!s.enabled) return MQTT_PRESET_NONE;
  return MQTT_PRESET_CUSTOM;
}

const char* MQTTBridge::wifiReasonStr(uint8_t reason) {
  switch (reason) {
    case 2:   return "auth expired";
    case 4:   return "assoc timeout";
    case 8:   return "AP disconnected";
    case 15:  return "4-way handshake timeout";
    case 18:  return "group cipher mismatch";
    case 40:  return "cipher suite rejected";
    case 49:  return "invalid PMKID";
    case 61:  return "AP BSS management";
    case 88:  return "AP BSS management";
    case 168: return "AP band-steering kick";
    case 34:  return "AP state mismatch (class 3 frame)";
    case 39:  return "SSID not found";
    case 63:  return "SA query timeout (PMF)";
    case 200: return "signal lost";
    case 201: return "security mismatch";
    case 202: return "auth mode rejected";
    case 204: return "handshake timeout";
    default:  return nullptr;
  }
}

const char* MQTTBridge::tlsErrorStr(int32_t err) {
  switch (err) {
    case 0x8001: return "DNS failed";
    case 0x8002: return "socket error";
    case 0x8004: return "connect refused";
    case 0x8006: return "TLS timeout";
    case 0x8008: return "connection timeout";
    case 0x800B: return "cert verify failed";
    case 0x8010: return "mbedTLS error";
    case 0x801A: return "TLS handshake failed";
    default:     return nullptr;
  }
}

void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index) {
  if (!buf || bufsize == 0) return;
  if (!s_mqtt_bridge_instance || !s_mqtt_bridge_instance->_initialized) {
    snprintf(buf, bufsize, "> mqtt%d: bridge not running", slot_index + 1);
    return;
  }
  if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) {
    snprintf(buf, bufsize, "> invalid slot");
    return;
  }

  MQTTBridge* b = s_mqtt_bridge_instance;
  const MQTTSlot& slot = b->_slots[slot_index];

  // Determine state string
  const char* state;
  if (!slot.enabled && !slot.preset && slot.host[0] == '\0') {
    snprintf(buf, bufsize, "> mqtt%d: not configured", slot_index + 1);
    return;
  } else if (!slot.enabled) {
    state = "inactive";
  } else if (!slot.client) {
    state = "no client";
  } else if (slot.connected) {
    state = "ok";
  } else if (slot.circuit_breaker_tripped) {
    state = "fail";
  } else {
    state = "disc";
  }

  int pos = snprintf(buf, bufsize, "> mqtt%d: %s", slot_index + 1, state);
  if (slot.disconnect_count > 0) {
    pos += snprintf(buf + pos, bufsize - pos, ", dc:%lu", (unsigned long)slot.disconnect_count);
    if (slot.first_disconnect_time > 0) {
      unsigned long first_disc_age_sec = (millis() - slot.first_disconnect_time) / 1000;
      pos += snprintf(buf + pos, bufsize - pos, ", first_disc:%lus", first_disc_age_sec);
    }
  }

  // If connected with no errors, we're done
  if (slot.connected && slot.last_error_time == 0) {
    snprintf(buf + pos, bufsize - pos, ", no errors");
    return;
  }

  // Show last error if we have one
  if (slot.last_error_time > 0) {
    // TLS error with human-friendly description
    if (slot.last_tls_err != 0) {
      const char* desc = tlsErrorStr(slot.last_tls_err);
      if (desc) {
        pos += snprintf(buf + pos, bufsize - pos, ", %s (0x%04X)", desc, (unsigned)slot.last_tls_err);
      } else {
        pos += snprintf(buf + pos, bufsize - pos, ", tls:0x%04X", (unsigned)slot.last_tls_err);
      }
    }
    // mbedTLS stack error (shown as negative hex per convention)
    if (slot.last_tls_stack_err != 0) {
      pos += snprintf(buf + pos, bufsize - pos, ", mbedtls:-0x%04X", (unsigned)(-slot.last_tls_stack_err));
    }
    // Socket errno
    if (slot.last_sock_errno != 0) {
      pos += snprintf(buf + pos, bufsize - pos, ", sock:%d", slot.last_sock_errno);
    }
    // Time ago
    unsigned long ago_sec = (millis() - slot.last_error_time) / 1000;
    if (ago_sec < 60) {
      snprintf(buf + pos, bufsize - pos, ", %lus ago", ago_sec);
    } else if (ago_sec < 3600) {
      snprintf(buf + pos, bufsize - pos, ", %lum ago", ago_sec / 60);
    } else {
      snprintf(buf + pos, bufsize - pos, ", %luh ago", ago_sec / 3600);
    }
  } else if (!slot.connected) {
    snprintf(buf + pos, bufsize - pos, ", no error info");
  }
}

// Bounded cooperative-stop timeout for end() (see MQTTLifecycle::Coordinator).
// Phase 0 hardware characterization (2026-07-19, Heltec V3 non-PSRAM + V4 PSRAM,
// see STABILITY_TESTABILITY_HANDOFF.md): a real mbedTLS/wss client teardown
// (disconnect + esp_mqtt_client_destroy) takes ~5-6 s per CONNECTED slot, applied
// SEQUENTIALLY in destroySlotClients(). So the safe timeout scales with the
// number of slots being torn down, not a single constant: a flat 8 s tripped the
// dirty/force-kill fallback on a healthy 2-slot non-PSRAM node (~11-12 s) and a
// normal 3-slot PSRAM node (~16 s), which withholds OTA on healthy devices.
//
// The budget below gives generous headroom (~8 s/slot vs the ~5-6 s measured)
// plus a fixed base for WiFi/queue/buffer teardown. Headroom is nearly free:
// end() returns as soon as the task acks (it checks _stop_acked before ticking
// the timeout), so a larger bound does NOT slow a healthy stop — it only length-
// ens the wait before force-killing a genuinely wedged task. The timeout is set
// per stop in end() via computeStopTimeoutMs() based on the enabled-slot count.
static const uint32_t MQTT_STOP_TIMEOUT_BASE_MS     = 5000;   // fixed teardown overhead
static const uint32_t MQTT_STOP_TIMEOUT_PER_SLOT_MS = 8000;   // ~5-6 s measured + headroom

// Slot-scaled cooperative-stop timeout. `slots` is the number of MQTT slots that
// will be torn down (enabled/connected); clamped to >=1 so a zero-slot bridge
// still budgets for the base teardown.
static inline uint32_t mqttStopTimeoutForSlots(int slots) {
  if (slots < 1) slots = 1;
  return MQTT_STOP_TIMEOUT_BASE_MS + MQTT_STOP_TIMEOUT_PER_SLOT_MS * (uint32_t)slots;
}

// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity)
    : BridgeBase(prefs, mgr, rtc),
      _obs(obs),
      _queue_count(0),
      _last_status_publish(0), _last_status_retry(0), _status_interval(300000),
      _ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS),
      _ntp_force_requested(false), _ntp_force_done(false), _ntp_force_result(false),
      _ntp_diag_requested(false), _ntp_diag_done(false), _ntp_diag_count(0),
      // Default to UTC; setRules() will be called from syncTimeWithNTP when a
      // non-UTC timezone string is configured. Timezone has no default ctor,
      // so we must pass rules here.
      _timezone_storage(TimeChangeRule{"UTC", Last, Sun, Mar, 0, 0}, TimeChangeRule{"UTC", Last, Sun, Mar, 0, 0}),
      _timezone(&_timezone_storage),
#if defined(BOARD_HAS_PSRAM)
      _last_raw_data(nullptr),
#endif
      _last_raw_len(0), _last_snr(0), _last_rssi(0), _last_raw_timestamp(0),
#if defined(BOARD_HAS_PSRAM)
      _publish_json_buffer(nullptr), _status_json_buffer(nullptr),
#endif
      _identity(identity),
      _cached_has_connected_slots(false),
      _last_memory_check(0), _skipped_publishes(0),
      _last_no_broker_log(0), _queue_disconnected_since(0),
      _last_config_warning(0),
      _dispatcher(nullptr), _radio(nullptr), _board(nullptr), _ms(nullptr),
#ifdef WITH_SNMP
      _snmp_agent(nullptr),
#endif
      _last_wifi_check(0), _last_wifi_status(WL_DISCONNECTED), _wifi_status_initialized(false),
      _wifi_disconnected_time(0), _last_wifi_reconnect_attempt(0), _wifi_reconnect_backoff_attempt(0),
      _last_slot_reconnect_ms(0)
#ifdef ESP_PLATFORM
      , _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr),
        _mqtt_task_stack(nullptr), _packet_queue_storage(nullptr)
#else
      , _queue_head(0), _queue_tail(0)
#endif
      // Cooperative lifecycle: _lifecycle_ops must be constructed before
      // _lifecycle (declaration order guarantees this) so the reference binds.
      // Seed with the worst-case (max runtime slots) budget; end() recomputes the
      // slot-scaled timeout before each stop via setStopTimeoutMs().
      , _lifecycle_ops(this), _lifecycle(_lifecycle_ops, mqttStopTimeoutForSlots(RUNTIME_MQTT_SLOTS))
{
  // Initialize default values
  strncpy(_origin, "MeshCore-Repeater", sizeof(_origin) - 1);
  strncpy(_iata, "XXX", sizeof(_iata) - 1);
  strncpy(_device_id, "DEVICE_ID_PLACEHOLDER", sizeof(_device_id) - 1);
  strncpy(_firmware_version, "unknown", sizeof(_firmware_version) - 1);
  strncpy(_board_model, "unknown", sizeof(_board_model) - 1);
  strncpy(_build_date, "unknown", sizeof(_build_date) - 1);
  _status_enabled = true;
  _packets_enabled = true;
  _raw_enabled = false;
  _rx_enabled = true;
  _tx_mode = 0;

  // Initialize all slots to empty/disabled state
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    memset(&_slots[i], 0, sizeof(MQTTSlot));
    _slots[i].enabled = false;
    _slots[i].client = nullptr;
    _slots[i].preset = nullptr;
    // auth_token[0] == '\0' after memset above — no valid token
    _slots[i].connected = false;
    _slots[i].initial_connect_done = false;
    _slots[i].token_expires_at = 0;
    _slots[i].last_token_renewal = 0;
    _slots[i].reconnect_backoff = 0;
    _slots[i].max_backoff_failures = 0;
    _slots[i].circuit_breaker_tripped = false;
    _slots[i].last_reconnect_attempt = 0;
    _slots[i].last_log_time = 0;
    _slots[i].port = 1883;
    _slot_reconfigure_pending[i] = false;
  }

  // Reset CLI-requested forced NTP sync handshake (bridge object is reused across restarts)
  _ntp_force_requested = false;
  _ntp_force_done = false;
  _ntp_force_result = false;

  // Reset CLI-requested NTP diagnostic handshake
  _ntp_diag_requested = false;
  _ntp_diag_done = false;
  _ntp_diag_count = 0;

#if defined(WITH_MQTT_NEIGHBORS)
  // Neighbors publish handoff (buffer allocated in begin() after PSRAM probe).
  // std::atomic has no value-initializing default ctor pre-C++20, so set them here.
  _neighbors_json_buffer = nullptr;
  _neighbors_publish_len = 0;
  _neighbors_publish_pending.store(false, std::memory_order_relaxed);
  _neighbors_last_result.store(NBR_RESULT_NONE, std::memory_order_relaxed);
  _neighbors_phase.store(NBR_SCHEDULED, std::memory_order_relaxed);
  _neighbors_secs_until_next.store(0, std::memory_order_relaxed);
#endif

  // Initialize JWT username
  _jwt_username[0] = '\0';

  // Initialize packet queue (FreeRTOS queue will be created in begin())
  #ifdef ESP_PLATFORM
  // Queue and mutex will be created in begin()
  #else
  // Initialize circular buffer for non-ESP32 platforms
  memset(_packet_queue, 0, sizeof(_packet_queue));
#if defined(BOARD_HAS_PSRAM)
  for (int i = 0; i < MAX_QUEUE_SIZE; i++) {
    _packet_queue[i].has_raw_data = false;
  }
#endif
  #endif

  // Non-PSRAM boards keep the raw cache inline for the bridge lifetime.
  // PSRAM boards allocate their runtime buffers in begin(), after PSRAM has
  // been probed/initialized, and release them in end().
  #if !defined(BOARD_HAS_PSRAM)
  memset(_last_raw_data, 0, sizeof(_last_raw_data));
  #endif
  // JSON document scratch space is now a StaticJsonDocument inline class member —
  // no heap allocation needed; reused via doc.clear() on every publish.
}

void MQTTBridge::allocateRuntimeBuffers() {
  #if defined(BOARD_HAS_PSRAM)
  // Keep each allocation independent. A nullptr is deliberately retained on
  // failure: status/packet publish paths already use stack fallbacks, and the
  // next begin() will retry only the missing buffer.
  _last_raw_data = static_cast<uint8_t*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
      _last_raw_data, LAST_RAW_DATA_SIZE, psram_malloc));
  _publish_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
      _publish_json_buffer, PUBLISH_JSON_BUFFER_SIZE, psram_malloc));
  _status_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
      _status_json_buffer, STATUS_JSON_BUFFER_SIZE, psram_malloc));
#if defined(WITH_MQTT_NEIGHBORS)
  // Persistent neighbors JSON buffer. Unlike status/packet there is no stack
  // fallback: the feature is PSRAM-gated, so a nullptr simply disables publishing
  // (requestPublishNeighbors/publishNeighbors both no-op on nullptr).
  _neighbors_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
      _neighbors_json_buffer, NEIGHBORS_JSON_BUFFER_SIZE, psram_malloc));
#endif
  MQTT_DEBUG_PRINTLN("Runtime buffers: raw=%s publish=%s status=%s",
      _last_raw_data ? "PSRAM" : "unavailable",
      _publish_json_buffer ? "PSRAM" : "stack fallback",
      _status_json_buffer ? "PSRAM" : "stack fallback");
  #endif
}

void MQTTBridge::releaseRuntimeBuffers() {
  #if defined(BOARD_HAS_PSRAM)
  _last_raw_data = static_cast<uint8_t*>(MQTTRuntimeBufferLifecycle::release(
      _last_raw_data, psram_free));
  _publish_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::release(
      _publish_json_buffer, psram_free));
  _status_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::release(
      _status_json_buffer, psram_free));
#if defined(WITH_MQTT_NEIGHBORS)
  _neighbors_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::release(
      _neighbors_json_buffer, psram_free));
  _neighbors_publish_len = 0;
  _neighbors_publish_pending.store(false, std::memory_order_release);
#endif
  #endif

  // Never pair a newly allocated raw buffer with metadata from a prior bridge
  // run. This also makes non-PSRAM restarts discard their stale raw cache.
  _last_raw_len = 0;
  _last_snr = 0;
  _last_rssi = 0;
  _last_raw_timestamp = 0;
}

// ---------------------------------------------------------------------------
// begin()
// ---------------------------------------------------------------------------
void MQTTBridge::begin() {
  MQTT_DEBUG_PRINTLN("Initializing MQTT Bridge...");

  // Idempotent start (Phase 5): a second begin() on an already-running bridge
  // would re-run allocation and re-create the task, leaking the previous
  // queue/task. Guard here instead of relying on caller discipline.
  if (_initialized) {
    MQTT_DEBUG_PRINTLN("MQTT Bridge already running - begin() ignored");
    return;
  }

  // PSRAM diagnostic - helps debug memory fragmentation on boards with external RAM
  #ifdef BOARD_HAS_PSRAM
  {
    bool psram_available = psramFound();
    size_t psram_size = 0;
    size_t psram_free = 0;
    if (psram_available) {
      psram_size = ESP.getPsramSize();
      psram_free = ESP.getFreePsram();
    }
    MQTT_DEBUG_PRINTLN("PSRAM: found=%s, size=%u, free=%u",
      psram_available ? "YES" : "NO", psram_size, psram_free);
    if (!psram_available) {
      MQTT_DEBUG_PRINTLN("PSRAM: board has PSRAM flag but psramFound()=false. "
        "Trying explicit psramInit()...");
      bool init_result = psramInit();
      MQTT_DEBUG_PRINTLN("PSRAM: psramInit() returned %s", init_result ? "true" : "false");
      if (init_result) {
        psram_size = ESP.getPsramSize();
        psram_free = ESP.getFreePsram();
        MQTT_DEBUG_PRINTLN("PSRAM: after init - size=%u, free=%u", psram_size, psram_free);
      }
    }
    // Log internal heap for comparison
    MQTT_DEBUG_PRINTLN("PSRAM: internal_free=%u, internal_max_alloc=%u",
      heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
      heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
  }
  #else
  MQTT_DEBUG_PRINTLN("PSRAM: not configured for this board (no BOARD_HAS_PSRAM)");
  #endif

  // Limit active slots based on available memory (see getMaxActiveSlots()).
  _max_active_slots = getMaxActiveSlots();
  MQTT_DEBUG_PRINTLN("Max active slots: %d", _max_active_slots);

  // Check if WiFi credentials are configured first
  if (!isWiFiConfigValid(_obs)) {
    MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - WiFi credentials not configured");
    return;
  }

  // These are begin()/end()-scoped on PSRAM targets. Allocation happens after
  // the PSRAM probe above so a late psramInit() has taken effect.
  allocateRuntimeBuffers();

  refreshOriginFromPrefs();

  strncpy(_iata, _obs->mqtt_iata, sizeof(_iata) - 1);
  _iata[sizeof(_iata) - 1] = '\0';

  StrHelper::stripSurroundingQuotes(_iata, sizeof(_iata));

  // Convert IATA code to uppercase (IATA codes are conventionally uppercase)
  for (int i = 0; _iata[i]; i++) {
    _iata[i] = toupper(_iata[i]);
  }

  // Initial snapshot of the publish toggles. NOTE: the publish hot paths read
  // these live from _obs->mqtt_* (status/packets/raw/rx/tx) so a CLI/web `set`
  // takes effect without a bridge restart; these members are kept only for
  // startup logging/back-compat and are not the source of truth.
  _status_enabled = _obs->mqtt_status_enabled;
  _packets_enabled = _obs->mqtt_packets_enabled;
  _raw_enabled = _obs->mqtt_raw_enabled;
  _rx_enabled = _obs->mqtt_rx_enabled;
  _tx_mode = _obs->mqtt_tx_enabled;  // 0=off, 1=all, 2=advert
  // Set status interval to 5 minutes (300000 ms), or use preference if set and valid
  if (_obs->mqtt_status_interval >= 1000 && _obs->mqtt_status_interval <= 3600000) {
    _status_interval = _obs->mqtt_status_interval;
  } else {
    // Invalid or uninitialized value - fix it in preferences and use default
    _obs->mqtt_status_interval = 300000; // Fix the preference value
    _status_interval = 300000; // 5 minutes default
  }

  // Check for configuration mismatch: bridge.source=tx but mqtt.tx=off
  checkConfigurationMismatch();

  MQTT_DEBUG_PRINTLN("Config: Origin=%s, IATA=%s, Device=%s", _origin, _iata, _device_id);

  // Apply slot presets from preferences
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    const char* preset_name = _obs->mqtt_slot_preset[i];
    if (preset_name[0] != '\0' && strcmp(preset_name, MQTT_PRESET_NONE) != 0) {
      if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) {
        // Custom broker: copy host/port/username/password from prefs
        _slots[i].preset = nullptr;
        strncpy(_slots[i].host, _obs->mqtt_slot_host[i], sizeof(_slots[i].host) - 1);
        _slots[i].host[sizeof(_slots[i].host) - 1] = '\0';
        if (strlen(_slots[i].host) == 0) {
          MQTT_DEBUG_PRINTLN("MQTT%d: custom preset has no server configured, disabling", i + 1);
          _slots[i].enabled = false;
          continue;
        }
        _slots[i].enabled = true;
        _slots[i].port = _obs->mqtt_slot_port[i];
        strncpy(_slots[i].username, _obs->mqtt_slot_username[i], sizeof(_slots[i].username) - 1);
        _slots[i].username[sizeof(_slots[i].username) - 1] = '\0';
        strncpy(_slots[i].password, _obs->mqtt_slot_password[i], sizeof(_slots[i].password) - 1);
        _slots[i].password[sizeof(_slots[i].password) - 1] = '\0';
        strncpy(_slots[i].audience, _obs->mqtt_slot_audience[i], sizeof(_slots[i].audience) - 1);
        _slots[i].audience[sizeof(_slots[i].audience) - 1] = '\0';
      } else {
        const MQTTPresetDef* preset = findMQTTPreset(preset_name);
        if (preset) {
          _slots[i].enabled = true;
          _slots[i].preset = preset;
          if (mqttPresetNeedsSlotCredentials(preset)) {
            strncpy(_slots[i].username, _obs->mqtt_slot_username[i], sizeof(_slots[i].username) - 1);
            _slots[i].username[sizeof(_slots[i].username) - 1] = '\0';
            strncpy(_slots[i].password, _obs->mqtt_slot_password[i], sizeof(_slots[i].password) - 1);
            _slots[i].password[sizeof(_slots[i].password) - 1] = '\0';
          }
        } else {
          MQTT_DEBUG_PRINTLN("MQTT%d: unknown preset '%s', disabling", i + 1, preset_name);
          _slots[i].enabled = false;
        }
      }
    }
  }

  // Log slot configuration
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].enabled) {
      if (_slots[i].preset) {
        MQTT_DEBUG_PRINTLN("MQTT%d: preset=%s", i + 1, _slots[i].preset->name);
      } else {
        MQTT_DEBUG_PRINTLN("MQTT%d: custom=%s:%d", i + 1, _slots[i].host, _slots[i].port);
      }
    } else {
      MQTT_DEBUG_PRINTLN("MQTT%d: none", i + 1);
    }
  }

  #ifdef ESP_PLATFORM
  // Create FreeRTOS queue; use PSRAM storage when available
  #ifdef BOARD_HAS_PSRAM
  _packet_queue_storage = (uint8_t*)psram_malloc(MAX_QUEUE_SIZE * sizeof(QueuedPacket));
  if (_packet_queue_storage != nullptr) {
    _packet_queue_handle = xQueueCreateStatic(MAX_QUEUE_SIZE, sizeof(QueuedPacket), _packet_queue_storage, &_packet_queue_struct);
  } else {
    _packet_queue_handle = nullptr;
  }
  #else
  // Non-PSRAM: use inline class-member storage with static queue creation.
  // Eliminates a separate heap allocation, reducing startup fragmentation.
  _packet_queue_storage = _packet_queue_inline;
  _packet_queue_handle = xQueueCreateStatic(MAX_QUEUE_SIZE, sizeof(QueuedPacket),
                                             _packet_queue_storage, &_packet_queue_struct);
  #endif
  if (_packet_queue_handle == nullptr) {
    _packet_queue_handle = xQueueCreate(MAX_QUEUE_SIZE, sizeof(QueuedPacket));
  }
  if (_packet_queue_handle == nullptr) {
    MQTT_DEBUG_PRINTLN("Failed to create packet queue!");
    #if defined(BOARD_HAS_PSRAM)
    psram_free(_packet_queue_storage);
    #endif
    _packet_queue_storage = nullptr;
    releaseRuntimeBuffers();
    return;
  }

  // Create FreeRTOS task for MQTT/WiFi processing on Core 0
  #ifndef MQTT_TASK_CORE
  #define MQTT_TASK_CORE 0
  #endif
  #ifndef MQTT_TASK_STACK_SIZE
  #define MQTT_TASK_STACK_SIZE 8192
  #endif
  #ifndef MQTT_TASK_PRIORITY
  #define MQTT_TASK_PRIORITY 1
  #endif

  // Task stack: use dynamic allocation (internal RAM). PSRAM stack was disabled because it
  // causes resets on some boards (e.g. Heltec V4) when the task runs from PSRAM stack.
  _mqtt_task_stack = nullptr;
  _mqtt_task_handle = nullptr;
  // Clear the cooperative-stop handshake before the new task starts reading it.
  // deliverStop() leaves _stop_requested latched true after a stop cycle, so a
  // restart must reset it or the fresh task would self-terminate immediately.
  _stop_requested = false;
  _stop_acked = false;
  BaseType_t create_result = xTaskCreatePinnedToCore(
    mqttTask,
    "MQTTBridge",
    MQTT_TASK_STACK_SIZE,
    this,
    MQTT_TASK_PRIORITY,
    &_mqtt_task_handle,
    MQTT_TASK_CORE
  );
  if (create_result != pdPASS) _mqtt_task_handle = nullptr;
  if (_mqtt_task_handle == nullptr) {
    MQTT_DEBUG_PRINTLN("Failed to create MQTT task!");
    psram_free(_mqtt_task_stack);
    _mqtt_task_stack = nullptr;
    vQueueDelete(_packet_queue_handle);
    _packet_queue_handle = nullptr;
    #if defined(BOARD_HAS_PSRAM)
    psram_free(_packet_queue_storage);
    #endif
    _packet_queue_storage = nullptr;
    releaseRuntimeBuffers();
    return;
  }

  MQTT_DEBUG_PRINTLN("MQTT task created on Core %d", MQTT_TASK_CORE);
  #else
  // Non-ESP32: Initialize WiFi directly (no task)
  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.setAutoConnect(true);
  WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);

  // NOTE: Slot setup deferred until after NTP sync in loop()
  #endif

  // Allocate persistent MQTT client objects once. They live for the bridge's
  // lifetime so reconfigure/reconnect paths reuse the same mbedTLS context
  // instead of churning ~40 KB of internal heap per cycle.
  initSlotClients();

  // Sync the lifecycle Coordinator to Running now that all resources exist and
  // the task is created. Driven only on the success path: the failure rollbacks
  // above already free what they acquired and leave the bridge Stopped, so we
  // must not also fire the state machine's release effect there (double free).
  // A fresh start also clears any dirty-stop latch (re-enabling OTA flashing).
  _lifecycle.requestStart();    // Stopped -> Starting (startTask() is a no-op here)
  _lifecycle.onTaskStarted();   // Starting -> Running

  _initialized = true;
  s_mqtt_bridge_instance = this;
  MQTT_DEBUG_PRINTLN("MQTT Bridge initialized");
}

// ---------------------------------------------------------------------------
// end()
// ---------------------------------------------------------------------------
void MQTTBridge::end() {
  MQTT_DEBUG_PRINTLN("Stopping MQTT Bridge...");

  // Idempotent stop: nothing to tear down if we never started (or already stopped).
  if (!_initialized) {
    MQTT_DEBUG_PRINTLN("MQTT Bridge already stopped - end() ignored");
    return;
  }

  // Stop new diagnostic reads through the singleton before teardown begins.
  s_mqtt_bridge_instance = nullptr;

  // Size the stop timeout to the work about to happen: each enabled slot's
  // mbedTLS/wss client takes ~5-6 s to disconnect + destroy, sequentially (Phase
  // 0 hardware characterization). A flat bound force-killed healthy multi-slot
  // nodes and withheld OTA; the slot-scaled budget lets a normal teardown ack
  // cleanly. Count enabled slots (the ones that connect); a disabled slot's
  // client destroys quickly. Must run BEFORE requestStop() arms the window.
  int stop_slots = 0;
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].enabled) stop_slots++;
  }
  _lifecycle.setStopTimeoutMs(mqttStopTimeoutForSlots(stop_slots));
  MQTT_DEBUG_PRINTLN("MQTT stop: %d enabled slot(s), timeout %lu ms",
                     stop_slots, (unsigned long)_lifecycle.stopTimeoutMs());

  // Cooperative shutdown (Phase 5). Request the stop, then let the lifecycle
  // Coordinator drive it. On ESP32 the MQTT task (Core 0) tears down its own
  // clients where the mbedTLS contexts live and acknowledges via _stop_acked;
  // the queue/buffer release happens inside LifecycleOps::releaseResources()
  // once the Coordinator reaches Stopped (clean ack OR the reviewed timeout
  // fallback). This replaces the former blind vTaskDelete that could kill the
  // task mid-mbedTLS and then free client buffers on a corrupted heap.
  _lifecycle.requestStop();  // Running -> StopRequested; deliverStop() sets _stop_requested

#ifdef ESP_PLATFORM
  // Wait (bounded) for the task to acknowledge. tick() synthesizes the timeout
  // fallback if the task never acks. Checking the ack first each iteration means
  // a stop that completes right as the timeout expires is still treated as clean.
  while (_lifecycle.isStopInProgress()) {
    if (_stop_acked) {
      _lifecycle.onTaskStopped();   // StopRequested -> Stopped (clean): releaseResources()
      break;
    }
    _lifecycle.tick();              // may fire StopTimedOut -> Stopped (dirty): releaseResources()
    if (!_lifecycle.isStopInProgress()) break;
    vTaskDelay(pdMS_TO_TICKS(20));
  }
#else
  // Non-ESP32: the bridge runs cooperatively in loop(); there is no separate
  // task to signal. Drive straight to a clean Stopped and let releaseResources()
  // perform the (unchanged) synchronous teardown.
  _stop_acked = true;
  _lifecycle.onTaskStopped();
#endif

  // Timezone is inline class storage (_timezone_storage) — nothing to delete.
  // JSON documents are StaticJsonDocument inline members — no heap to free.
  _initialized = false;
  _slots_setup_done = false;  // Reset so deferred setup runs again on next begin()
  MQTT_DEBUG_PRINTLN("MQTT Bridge stopped (%s)",
                     _lifecycle.stopTimedOut() ? "forced/timeout - OTA blocked" : "clean");
}

// ---------------------------------------------------------------------------
// LifecycleOps — binds MQTTLifecycle::Ops (the pure, host-tested spec) to the
// FreeRTOS / PsychicMqttClient runtime. Every method runs on the loop task
// (Core 1): the Coordinator that calls them is driven only from begin()/end().
// ---------------------------------------------------------------------------
uint32_t MQTTBridge::LifecycleOps::nowMs() {
  return (uint32_t)millis();
}

void MQTTBridge::LifecycleOps::startTask() {
  // No-op: begin() owns task/queue/buffer creation and its rollback paths. The
  // Coordinator is synced to Running there via requestStart()/onTaskStarted().
}

void MQTTBridge::LifecycleOps::deliverStop() {
  // Clear any stale ack before raising the request (same ordering as the NTP
  // handshake: clear the done-flag, then set the request). The MQTT task polls
  // _stop_requested at the top of mqttTaskLoop().
  _b->_stop_acked = false;
  _b->_stop_requested = true;
}

void MQTTBridge::LifecycleOps::releaseResources() {
  MQTTBridge* b = _b;
#ifdef ESP_PLATFORM
  // stopTimedOut() is set before this effect fires (Coordinator::dispatch), so
  // it reliably distinguishes a clean ack from the timeout fallback.
  const bool dirty = b->_lifecycle.stopTimedOut();
  if (dirty && !b->_stop_acked) {
    // Reviewed fallback: the task never acknowledged (likely wedged in mbedTLS).
    // Force-kill it and tear down clients here on Core 1 — the pre-cooperative
    // behavior — accepting the heap risk. The dirty latch keeps OTA flashing
    // blocked (canFlashAfterStop() == false) so firmware is never written after
    // this path.
    if (b->_mqtt_task_handle != nullptr) {
      vTaskDelete(b->_mqtt_task_handle);
    }
    for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) b->teardownSlot(i);
    b->destroySlotClients();
  }
  // Clean path (or a task that acked right at the deadline): the MQTT task
  // already disconnected/deleted its clients on Core 0 and self-terminated, so
  // we must NOT touch slots here (that would be a cross-core double-delete).
  // Just drop our handle reference; FreeRTOS reclaims the self-deleted task's
  // dynamically-allocated stack/TCB in the idle task.
  b->_mqtt_task_handle = nullptr;

  // Free the PSRAM task stack (nullptr for dynamic tasks — no-op).
  psram_free(b->_mqtt_task_stack);
  b->_mqtt_task_stack = nullptr;

  // Drain and delete the FreeRTOS packet queue (value-copied packets, no
  // external pointers to clean up). Safe on Core 1: not a TLS resource.
  if (b->_packet_queue_handle != nullptr) {
    QueuedPacket queued;
    while (xQueueReceive(b->_packet_queue_handle, &queued, 0) == pdTRUE) {
      b->_queue_count--;
    }
    vQueueDelete(b->_packet_queue_handle);
    b->_packet_queue_handle = nullptr;
  }
  #if defined(BOARD_HAS_PSRAM)
  psram_free(b->_packet_queue_storage);
  #endif
  b->_packet_queue_storage = nullptr;
#else
  // Non-ESP32 circular buffer + synchronous client teardown (unchanged behavior).
  for (int i = 0; i < b->_queue_count; i++) {
    int index = (b->_queue_head + i) % MAX_QUEUE_SIZE;
    memset(&b->_packet_queue[index], 0, sizeof(QueuedPacket));
  }
  b->_queue_count = 0;
  b->_queue_head = 0;
  b->_queue_tail = 0;
  memset(b->_packet_queue, 0, sizeof(b->_packet_queue));
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) b->teardownSlot(i);
  b->destroySlotClients();
#endif

  b->releaseRuntimeBuffers();
}

void MQTTBridge::LifecycleOps::onStopComplete(bool clean) {
  MQTT_DEBUG_PRINTLN("MQTT stop %s", clean
                     ? "acknowledged (clean)"
                     : "TIMED OUT (dirty; OTA flashing withheld)");
}

// ---------------------------------------------------------------------------
// FreeRTOS task entry point
// ---------------------------------------------------------------------------
#ifdef ESP_PLATFORM
void MQTTBridge::mqttTask(void* parameter) {
  MQTTBridge* bridge = static_cast<MQTTBridge*>(parameter);
  if (bridge) {
    bridge->mqttTaskLoop();
  }
  // Task should never return, but if it does, delete itself
  vTaskDelete(nullptr);
}

void MQTTBridge::initializeWiFiInTask() {
  MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task...");

  // Initialize WiFi
  WiFi.mode(WIFI_STA);

  // Enable automatic reconnection - ESP32 will handle reconnection automatically
  WiFi.setAutoReconnect(true);
  WiFi.setAutoConnect(true);

  // Set up WiFi event handlers for better diagnostics and immediate disconnection
  // detection. Register ONCE — the bridge is reused across restarts (e.g. stopped
  // for `ota check`/`ota update`, or `set mqtt…` reconfigure) and WiFi.onEvent()
  // never removes prior callbacks, so re-registering leaks handlers and duplicates
  // every log line.
  if (!_wifi_event_registered) {
    WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) {
      switch(event) {
        case ARDUINO_EVENT_WIFI_STA_GOT_IP:
          MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str());
          // Set flag to trigger NTP sync from loop() instead of doing it here
          if (!_ntp_synced && !_ntp_sync_pending) {
            _ntp_sync_pending = true;
          }
          break;
        case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
          s_wifi_disconnect_reason = info.wifi_sta_disconnected.reason;
          s_wifi_disconnect_time = millis();
          MQTT_DEBUG_PRINTLN("WiFi disconnected: reason %d", s_wifi_disconnect_reason);
          break;
        default:
          break;
      }
    });
    _wifi_event_registered = true;
  }

  // Only (re)start the WiFi association if it isn't already up. end() leaves the
  // STA link connected, so on a restart (e.g. after `ota check`) calling
  // WiFi.begin() again forces a needless disconnect/reconnect — which also races
  // the MQTT task's first DNS lookup (getaddrinfo fails until WiFi/DNS recovers).
  // When already connected, the deferred slot setup still fires in mqttTaskLoop()
  // because _ntp_synced persists across end() (only _slots_setup_done is reset).
  if (WiFi.status() != WL_CONNECTED) {
    WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
  } else if (!_ntp_synced && !_ntp_sync_pending) {
    _ntp_sync_pending = true;  // already connected but never synced — kick NTP now
  }

  // NOTE: Slot setup is deferred until after NTP sync in mqttTaskLoop().
  // JWT-auth slots need valid timestamps for token creation, and connecting
  // before NTP sync just wastes heap on TLS handshakes that will be rejected.

  MQTT_DEBUG_PRINTLN("WiFi initialization started in task");
}

// ---------------------------------------------------------------------------
// mqttTaskLoop() - main loop running on Core 0
// ---------------------------------------------------------------------------
void MQTTBridge::mqttTaskLoop() {
  // Initialize WiFi first
  initializeWiFiInTask();

  // Wait a bit for WiFi to start connecting
  vTaskDelay(pdMS_TO_TICKS(1000));

  // Main task loop
  #ifdef MQTT_MEMORY_DEBUG
  static unsigned long last_agent_log = 0;
  #endif
  while (true) {
    // Cooperative stop (Phase 5). end() on the loop task (Core 1) set this flag.
    // Tear down our own clients HERE on Core 0 — where the mbedTLS/transport
    // state lives — instead of letting Core 1 free them after a blind
    // vTaskDelete. Acknowledge LAST so end() only frees the queue/buffers once
    // this teardown has completed, then self-terminate via the mqttTask()
    // trampoline (vTaskDelete(nullptr)).
    if (_stop_requested) {
      MQTT_DEBUG_PRINTLN("MQTT task: cooperative stop - tearing down clients on Core 0");
      for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
        teardownSlot(i);
      }
      destroySlotClients();
      _stop_acked = true;   // release semantics: set only after teardown is done
      return;
    }

    #ifdef MQTT_MEMORY_DEBUG
    // #region agent log
    unsigned long now_loop = millis();
    if (now_loop - last_agent_log >= 60000) {
      last_agent_log = now_loop;
      size_t free_h = ESP.getFreeHeap();
      size_t max_alloc = ESP.getMaxAllocHeap();
      unsigned long internal_f = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
      unsigned long spiram_f = 0;
      #ifdef BOARD_HAS_PSRAM
      spiram_f = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
      #endif
      agentLogHeap("MQTTBridge.cpp:mqttTaskLoop", "mqtt_loop_60s", "H5", free_h, max_alloc, internal_f, spiram_f);
    }
    // #endregion
    #endif

    unsigned long now = millis();

    // Periodic heap + publish-health snapshot. Gated behind MQTT_MEMORY_DEBUG (a
    // dedicated diagnostics flag, NOT enabled on production or plain MQTT_DEBUG builds)
    // so it stays off by default — the same data is available on demand via the
    // `get mqtt.stats` CLI command (formatMqttStatsReply / logMemoryStatus()).
    #ifdef MQTT_MEMORY_DEBUG
    static unsigned long last_mem_log = 0;
    if (now - last_mem_log >= 30000) {
      last_mem_log = now;
      logMemoryStatus();
    }
    #endif

    bool wifi_just_connected = handleWiFiConnection(now);
    if (wifi_just_connected) {
      // WiFi recovered — reset last_reconnect_attempt for disconnected slots so they
      // retry immediately rather than waiting up to 5 min for backoff timers to expire.
      for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
        if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) {
          _slots[i].last_reconnect_attempt = 0;
        }
      }
    }

    // Check for pending NTP sync (triggered from WiFi event handler)
    if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) {
      _ntp_sync_pending = false;
      syncTimeWithNTP();
    }

    // Retry NTP every 30s if initial sync failed (slots can't start without valid time)
    if (!_ntp_synced && WiFi.status() == WL_CONNECTED) {
      static unsigned long last_ntp_retry = 0;
      if (now - last_ntp_retry >= 30000) {
        last_ntp_retry = now;
        syncTimeWithNTP();
      }
    }

    // Process a CLI-requested forced NTP sync (queued from Core 1). Running it here
    // keeps all NTP I/O on Core 0; requestForcedNtpSync() blocks the CLI thread until
    // we publish the outcome below.
    if (_ntp_force_requested) {
      _ntp_force_requested = false;
      // primary_only: validate just the server that was set, so a typo fails fast.
      bool ok = syncTimeWithNTP(true, /*primary_only=*/true);
      _ntp_force_result = ok;
      _ntp_force_done = true;  // set last so the waiter sees a consistent result
    }

    // Process a CLI-requested NTP connectivity diagnostic (queued from Core 1).
    // Probe-only — never touches the system clock.
    if (_ntp_diag_requested) {
      _ntp_diag_requested = false;
      runNtpDiagProbe();
      _ntp_diag_done = true;  // set last so the waiter sees populated results
    }

    // Deferred slot setup: wait until NTP is synced so JWT tokens get valid timestamps.
    // This avoids wasted TLS handshakes that get rejected due to bad token times.
    if (_ntp_synced && !_slots_setup_done) {
      _slots_setup_done = true;

      // Redirect mbedTLS allocations to PSRAM to save ~40KB internal heap per TLS connection.
      // This is critical when running 3 concurrent WSS connections.
      #if defined(BOARD_HAS_PSRAM)
      mbedtls_platform_set_calloc_free(psram_calloc, psram_free);
      MQTT_DEBUG_PRINTLN("mbedTLS allocator redirected to PSRAM");
      #endif

      MQTT_DEBUG_PRINTLN("NTP synced, setting up MQTT slots (max %d active)...", _max_active_slots);
      int active_count = 0;
      for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
        if (_slots[i].enabled) {
          if (active_count >= _max_active_slots) {
            MQTT_DEBUG_PRINTLN("MQTT%d skipped: max active slots (%d) reached (no PSRAM)", i + 1, _max_active_slots);
            _slots[i].enabled = false;  // Disable so other loops skip it
            continue;
          }
          char reason[80];
          if (!isSlotReady(i, reason, sizeof(reason))) {
            MQTT_DEBUG_PRINTLN("MQTT%d not ready - run '%s' to connect", i + 1, reason);
            continue;
          }
          setupSlot(i);
          active_count++;
          // Stagger connections: 5s between slots to avoid simultaneous TLS handshakes
          // which compete for ~40KB internal heap each
          if (i < RUNTIME_MQTT_SLOTS - 1) {
            vTaskDelay(pdMS_TO_TICKS(5000));
          }
        }
      }
    }

    // Process pending slot reconfigures (queued from CLI on Core 1)
    for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
      if (_slot_reconfigure_pending[i]) {
        _slot_reconfigure_pending[i] = false;
        MQTT_DEBUG_PRINTLN("Applying deferred reconfigure for MQTT%d (preset: %s)", i + 1, _obs->mqtt_slot_preset[i]);
        applySlotPreset(i, _obs->mqtt_slot_preset[i]);
      }
    }

    // Maintain slot connections (token renewal, reconnect with backoff)
    maintainSlotConnections();

    // Process packet queue
    processPacketQueue();

#if defined(WITH_MQTT_NEIGHBORS)
    // Consume a pending neighbors snapshot handed over by the mesh (Core 1).
    // The pending flag stays raised across the whole publish so a second
    // request is rejected until this one completes (see requestPublishNeighbors).
    if (_neighbors_publish_pending.load(std::memory_order_acquire)) {
      bool ok = publishNeighbors();
      _neighbors_last_result.store(ok ? NBR_RESULT_OK : NBR_RESULT_FAIL,
                                   std::memory_order_relaxed);
      // MQTT_DEBUG_PRINTLN concatenates its format as a string literal, so the
      // argument must be a literal, not a ternary expression.
      if (ok) {
        MQTT_DEBUG_PRINTLN("Neighbors published");
      } else {
        MQTT_DEBUG_PRINTLN("Neighbors publish failed");
      }
      _neighbors_publish_pending.store(false, std::memory_order_release);
    }
#endif

#ifdef WITH_SNMP
    // SNMP agent loop — process incoming UDP requests
    if (_snmp_agent) {
      if (!_snmp_agent->isRunning() && WiFi.isConnected() && _obs->snmp_enabled) {
        _snmp_agent->begin(_obs->snmp_community);
        MQTT_DEBUG_PRINTLN("SNMP agent started on port 161 (community: %s)", _obs->snmp_community);
      }
      if (_snmp_agent->isRunning()) {
        // Update MQTT stats from this core
        int connected = 0;
        for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
          if (_slots[i].enabled && _slots[i].connected) connected++;
        }
        _snmp_agent->updateMQTTStats(connected, _queue_count, _skipped_publishes);
        _snmp_agent->loop();
      }
    }
#endif

    // Periodic configuration check (throttled to avoid spam)
    checkConfigurationMismatch();

    // Periodic NTP refresh (every hour) — lightweight, non-blocking.
    // Uses async SNTP instead of the heavy syncTimeWithNTP() which blocks Core 0
    // for up to 20+ seconds with DNS lookups, UDP sockets, and retry loops.
    if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) {
      refreshNTP();
    }

    // Publish status updates (handle millis() overflow correctly).
    // Read the toggle live from prefs (like mqtt.packets/rx/tx below) so a
    // CLI/web `set mqtt.status` change applies without a bridge restart.
    if (_obs->mqtt_status_enabled) {
      bool has_destinations = _cached_has_connected_slots;

      // Early exit if no destinations - skip all the expensive logic below
      if (!has_destinations) {
        if (_last_status_retry != 0) {
          _last_status_retry = 0;
        }
      } else {
        bool should_publish = false;

        // First, check if we need to respect retry interval (prevents spam when publish keeps failing)
        if (_last_status_retry != 0) {
          unsigned long retry_elapsed = (now >= _last_status_retry) ?
                                       (now - _last_status_retry) :
                                       (ULONG_MAX - _last_status_retry + now + 1);
          if (retry_elapsed < STATUS_RETRY_INTERVAL) {
            should_publish = false;
          } else {
            should_publish = true;
          }
        } else {
          if (_last_status_publish == 0) {
            should_publish = true;
          } else {
            unsigned long elapsed = (now >= _last_status_publish) ?
                                   (now - _last_status_publish) :
                                   (ULONG_MAX - _last_status_publish + now + 1);
            should_publish = (elapsed >= _status_interval);
          }
        }

        if (should_publish) {
          if (_last_status_publish != 0) {
            unsigned long elapsed = (now >= _last_status_publish) ?
                                   (now - _last_status_publish) :
                                   (ULONG_MAX - _last_status_publish + now + 1);
            MQTT_DEBUG_PRINTLN("Status publish timer expired (elapsed: %lu ms, interval: %lu ms)", elapsed, _status_interval);
          } else {
            MQTT_DEBUG_PRINTLN("Status publish attempt (first publish or retry)");
          }

          _last_status_retry = now;
          if (publishStatus()) {
            _last_status_publish = now;
            _last_status_retry = 0;
            MQTT_DEBUG_PRINTLN("Status published successfully, next publish in %lu ms", _status_interval);
          } else {
            MQTT_DEBUG_PRINTLN("Status publish failed, will retry in %lu ms", STATUS_RETRY_INTERVAL);
          }
        }
      }
    }

    // Update cached connection status periodically (every 5 seconds)
    // This ensures cache stays accurate even if callbacks miss updates
    static unsigned long last_slot_status_update = 0;
    if (now - last_slot_status_update > 5000) {
      updateCachedConnectionStatus();
      last_slot_status_update = now;
    }

    // Adaptive delay: 5 ms when packets are queued, 50 ms when idle.
    // The previous "status approaching" check (widening to 5 ms for 10 s before each status
    // publish) caused 2 000 unnecessary wakeups per interval; the 50 ms idle tick catches
    // the status deadline with at most 50 ms of extra latency, which is irrelevant at a
    // 5-minute interval.
    vTaskDelay(pdMS_TO_TICKS(_queue_count > 0 ? 5 : 50));
  }
}
#endif

// ---------------------------------------------------------------------------
// Slot management
// ---------------------------------------------------------------------------

// Allocate one PsychicMqttClient per slot and register its persistent callbacks.
// Called exactly once per bridge lifetime from begin(); the objects live until
// destroySlotClients(). Reconfiguring a slot (preset change, JWT renewal,
// reconnect) reuses the same client — no delete/new cycles, so the mbedTLS
// context and its ~40 KB of internal-heap buffers are allocated once instead
// of every reconfigure.
void MQTTBridge::initSlotClients() {
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    MQTTSlot& slot = _slots[i];
    if (slot.client != nullptr) continue;

    slot.client = new PsychicMqttClient();
    slot.client->setAutoReconnect(false);  // we handle reconnect with our own backoff

    const int index = i;  // capture a fresh copy so lambdas refer to the right slot
    slot.client->onConnect([this, index](bool sessionPresent) {
      MQTT_DEBUG_PRINTLN("MQTT%d connected", index + 1);
      _slots[index].connected = true;
      // NOTE: reconnect_backoff / max_backoff_failures are NOT reset here.
      // A CONNACK alone doesn't prove the link is healthy — a broker that
      // accepts and then drops within seconds would reset the ladder every
      // cycle and retry at the 10 s rung forever, and each retry is a full
      // TLS session alloc/free (~40 KB of internal-heap churn, a known
      // fragmentation driver). The ladder is instead cleared by
      // maintainSlotConnection() once the connection has stayed up for
      // BACKOFF_STABLE_RESET_MS, so flapping endpoints keep their earned
      // backoff level. The breaker itself does clear now: while connected
      // the diag/status must not claim the slot gave up, and the next
      // disconnect should be governed by the (still-elevated) ladder.
      _slots[index].connected_at_ms = millis();
      _slots[index].circuit_breaker_tripped = false;
      _slots[index].last_tls_err = 0;
      _slots[index].last_tls_stack_err = 0;
      _slots[index].last_sock_errno = 0;
      _slots[index].last_error_time = 0;
      _slots[index].current_outage_started_ms = 0;  // clear current-outage timer for AlertReporter
      updateCachedConnectionStatus();
      publishStatusToSlot(index);
    });
    slot.client->onDisconnect([this, index](bool sessionPresent) {
      MQTT_DEBUG_PRINTLN("MQTT%d disconnected", index + 1);
      _slots[index].disconnect_count++;
      if (_slots[index].first_disconnect_time == 0) {
        _slots[index].first_disconnect_time = millis();
      }
      if (_slots[index].current_outage_started_ms == 0) {
        _slots[index].current_outage_started_ms = millis();
      }
      _slots[index].connected = false;
      _slots[index].connected_at_ms = 0;  // stability clock only runs while connected
      updateCachedConnectionStatus();
    });
    slot.client->onError([this, index](esp_mqtt_error_codes error) {
      _slots[index].last_tls_err = error.esp_tls_last_esp_err;
      _slots[index].last_tls_stack_err = error.esp_tls_stack_err;
      _slots[index].last_sock_errno = error.esp_transport_sock_errno;
      _slots[index].last_error_time = millis();
      if (error.error_type == MQTT_ERROR_TYPE_CONNECTION_REFUSED) {
        // Broker rejected the MQTT CONNECT itself — not a transport failure.
        // return code: 1=protocol, 2=client-id rejected, 3=server unavailable,
        // 4=bad username/password, 5=not authorized. Codes 3/4/5 point at a
        // server-side lockout or auth problem rather than the network.
        MQTT_DEBUG_PRINTLN("MQTT%d connection refused by broker (return code=%d)",
          index + 1, (int)error.connect_return_code);
      } else if (error.esp_tls_last_esp_err != 0 || error.esp_tls_stack_err != 0 || error.esp_transport_sock_errno != 0) {
        MQTT_DEBUG_PRINTLN("MQTT%d error: tls=%d, tls_stack=%d, sock=%d, type=%d",
          index + 1, error.esp_tls_last_esp_err, error.esp_tls_stack_err,
          error.esp_transport_sock_errno, error.error_type);
      } else {
        MQTT_DEBUG_PRINTLN("MQTT%d error: type=%d", index + 1, error.error_type);
      }
    });
  }
}

void MQTTBridge::destroySlotClients() {
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    MQTTSlot& slot = _slots[i];
    if (slot.client == nullptr) continue;

    if (slot.client->connected()) {
      slot.client->disconnect();
    }
    #ifdef ESP_PLATFORM
    vTaskDelay(pdMS_TO_TICKS(50));
    #else
    delay(50);
    #endif
    delete slot.client;
    slot.client = nullptr;
  }
}

void MQTTBridge::setupSlot(int index) {
  if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return;
  MQTTSlot& slot = _slots[index];

  if (!slot.enabled) {
    teardownSlot(index);
    return;
  }

  // Persistent client is expected to have been allocated by initSlotClients().
  // If it hasn't, we can't proceed — bail loudly rather than silently leaking.
  if (slot.client == nullptr) {
    MQTT_DEBUG_PRINTLN("MQTT%d: setupSlot before initSlotClients() - skipping", index + 1);
    return;
  }

  // Reconfigure path: if we're re-applying (e.g. after a preset change), stop
  // the existing connection cleanly first. The client object (and its mbedTLS
  // context) is reused; setCredentials / setServer below overwrite the config
  // fields in place before connect() restarts the ESP-IDF client.
  if (slot.initial_connect_done) {
    if (slot.client->connected()) {
      slot.client->disconnect();
    }
    // Clear TLS verification fields so a stale CA-bundle attach or cert
    // pointer from a prior preset doesn't override the new one.
    esp_mqtt_client_config_t* cfg = slot.client->getMqttConfig();
    #if ESP_IDF_VERSION_MAJOR == 5
    cfg->broker.verification.certificate = nullptr;
    cfg->broker.verification.certificate_len = 0;
    cfg->broker.verification.crt_bundle_attach = nullptr;
    cfg->credentials.username = nullptr;
    cfg->credentials.authentication.password = nullptr;
    #else
    cfg->cert_pem = nullptr;
    cfg->cert_len = 0;
    cfg->crt_bundle_attach = nullptr;
    cfg->username = nullptr;
    cfg->password = nullptr;
    #endif
    slot.auth_token[0] = '\0';
    slot.connected = false;
    slot.token_expires_at = 0;
    slot.last_token_renewal = 0;
    slot.reconnect_backoff = 0;
    slot.max_backoff_failures = 0;
    slot.circuit_breaker_tripped = false;
    slot.last_reconnect_attempt = 0;
  }

  bool uses_jwt = (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) || slot.audience[0] != '\0';
  optimizeMqttClientConfig(slot.client, uses_jwt);  // sets keepalive (45s PSRAM, 75s non-PSRAM)
  #ifndef MQTT_FORCE_KEEPALIVE_45
  #if defined(BOARD_HAS_PSRAM)
  if (slot.preset && slot.preset->keepalive > 0) {
    slot.client->setKeepAlive(slot.preset->keepalive);  // preset overrides default
  }
  #else
  // Non-PSRAM: keep the longer 75s default to reduce TLS churn.
  // Preset keepalive (55s) is more aggressive than needed behind Cloudflare.
  #endif
  #endif

  if (slot.preset) {
    // Preset-based slot
    slot.client->setServer(slot.preset->server_url);
    if (slot.preset->ca_cert) {
      slot.client->setCACert(slot.preset->ca_cert);
    }

    // Try to create token and connect (will succeed only if NTP synced)
    if (slot.preset->auth_type == MQTT_AUTH_JWT) {
      createSlotAuthToken(index);
      if (slot.auth_token[0] != '\0') {
        slot.client->setCredentials(_jwt_username, slot.auth_token);
      }
    } else if (slot.preset->auth_type == MQTT_AUTH_USERPASS) {
      if (slot.preset->userpass_username && slot.preset->userpass_password) {
        slot.client->setCredentials(slot.preset->userpass_username, slot.preset->userpass_password);
      } else if (strlen(slot.username) > 0) {
        slot.client->setCredentials(slot.username, slot.password);
      }
    }
  } else {
    // Custom broker slot — build persistent URI
    // If host already has a scheme (mqtt://, mqtts://, ws://, wss://), preserve the full URI
    // (including optional path/query) and only inject :port when the authority has no explicit port.
    // Otherwise, infer protocol from port number.
    bool has_scheme = (strncmp(slot.host, "mqtt://", 7) == 0 ||
                       strncmp(slot.host, "mqtts://", 8) == 0 ||
                       strncmp(slot.host, "ws://", 5) == 0 ||
                       strncmp(slot.host, "wss://", 6) == 0);
    if (has_scheme) {
      const char* authority = strstr(slot.host, "://");
      authority = authority ? authority + 3 : slot.host;
      const char* path = strchr(authority, '/');
      const char* authority_end = path ? path : slot.host + strlen(slot.host);
      bool has_explicit_port = false;

      // Detect host:port in URI authority (IPv6 literals in [addr]:port are supported).
      if (authority < authority_end) {
        if (*authority == '[') {
          const char* close = (const char*)memchr(authority, ']', authority_end - authority);
          if (close && (close + 1) < authority_end && *(close + 1) == ':') {
            has_explicit_port = true;
          }
        } else {
          const char* colon = (const char*)memchr(authority, ':', authority_end - authority);
          if (colon != nullptr) {
            has_explicit_port = true;
          }
        }
      }

      if (has_explicit_port || slot.port == 0) {
        snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%s", slot.host);
      } else {
        const size_t authority_len = (size_t)(authority_end - slot.host);
        snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%.*s:%u%s",
                 (int)authority_len,
                 slot.host,
                 (unsigned)slot.port,
                 path ? path : "");
      }
    } else {
      const char* proto = "mqtt";
      if (slot.port == 8883) {
        proto = "mqtts";
      } else if (slot.port == 443) {
        proto = "wss";
      }
      snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%s://%s:%d", proto, slot.host, slot.port);
    }
    slot.client->setServer(slot.broker_uri);
    MQTT_DEBUG_PRINTLN("MQTT%d custom broker URI: %s (host='%s', port=%u)",
      index + 1, slot.broker_uri, slot.host, (unsigned)slot.port);

    // Custom TLS/WSS slots need a CA bundle for server verification.
    // The bundle is loaded into the global s_crt_bundle exactly once to avoid
    // a use-after-free race: connect() launches an async FreeRTOS task, and
    // calling setCACertBundle() again from a later slot would free the global
    // crts array while a prior slot's TLS handshake may still be reading it.
    bool needs_tls = (strncmp(slot.broker_uri, "mqtts://", 8) == 0 ||
                      strncmp(slot.broker_uri, "wss://", 6) == 0);
    if (needs_tls) {
      if (!s_ca_bundle_loaded) {
        size_t bundle_len = 0;
        if (rootca_crt_bundle_start != nullptr &&
            rootca_crt_bundle_end != nullptr &&
            rootca_crt_bundle_end > rootca_crt_bundle_start) {
          bundle_len = static_cast<size_t>(rootca_crt_bundle_end - rootca_crt_bundle_start);
        }

        if (bundle_len > 0) {
          MQTT_DEBUG_PRINTLN("MQTT global CA bundle init: embedded bundle (%u bytes)",
            (unsigned)bundle_len);
          // Load the bundle into the global s_crt_bundle via the first client.
          // This is a one-time operation; subsequent clients reuse via attachArduinoCACertBundle.
          slot.client->setCACertBundle(rootca_crt_bundle_start, bundle_len);
          s_ca_bundle_loaded = true;
        } else {
          MQTT_DEBUG_PRINTLN("MQTT%d TLS: no embedded cert bundle available", index + 1);
        }
      } else {
        // Global bundle already loaded — just attach the callback for this client.
        slot.client->attachArduinoCACertBundle(true);
      }
      MQTT_DEBUG_PRINTLN("MQTT%d TLS verify: CA bundle %s", index + 1,
        s_ca_bundle_loaded ? "active" : "unavailable");
    } else {
      MQTT_DEBUG_PRINTLN("MQTT%d custom broker uses non-TLS transport", index + 1);
    }

    // Custom slot authentication: JWT if audience is set, else username/password
    if (slot.audience[0] != '\0') {
      // JWT auth for custom slot — create initial token (buffer is always inline)
      createSlotAuthToken(index);
      if (slot.auth_token[0] != '\0') {
        slot.client->setCredentials(_jwt_username, slot.auth_token);
      }
      MQTT_DEBUG_PRINTLN("MQTT%d custom broker using JWT auth (audience: %s)", index + 1, slot.audience);
    } else if (strlen(slot.username) > 0) {
      slot.client->setCredentials(slot.username, slot.password);
    }
  }

  slot.client->connect();
  slot.initial_connect_done = true;
}

// Disconnect the slot's MQTT client and clear per-connection state, but leave
// the client object alive so a subsequent setupSlot() can reuse its mbedTLS
// context. This is called both on reconfigure (preset change) and at shutdown;
// destruction of the underlying client happens once in destroySlotClients().
void MQTTBridge::teardownSlot(int index) {
  if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return;
  MQTTSlot& slot = _slots[index];

  if (slot.client && slot.client->connected()) {
    slot.client->disconnect();
    #ifdef ESP_PLATFORM
    vTaskDelay(pdMS_TO_TICKS(50));
    #else
    delay(50);
    #endif
  }

  slot.auth_token[0] = '\0';
  slot.connected = false;
  slot.initial_connect_done = false;
  slot.broker_uri[0] = '\0';
  slot.token_expires_at = 0;
  slot.last_token_renewal = 0;
  slot.reconnect_backoff = 0;
  slot.max_backoff_failures = 0;
  slot.circuit_breaker_tripped = false;
  slot.last_reconnect_attempt = 0;
  slot.last_log_time = 0;
  slot.last_deferred_log_ms = 0;
}

void MQTTBridge::maintainSlotConnections() {
  if (!_identity) return;

  // Check WiFi status first
  if (WiFi.status() != WL_CONNECTED) return;

  unsigned long now_millis = millis();
  unsigned long current_time = time(nullptr);
  bool time_synced = (current_time >= 1000000000); // After year 2001

  // JWT tokens require valid timestamps
  unsigned long clock_sec = current_time;
  bool can_do_jwt = MQTTConnectionPolicy::jwtClockAvailable(
      _ntp_synced, static_cast<uint32_t>(clock_sec));

  // Count connected slots to inform reconnect decisions
  int connected_count = 0;
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].enabled && _slots[i].connected) connected_count++;
  }

  // Only allow one reconnect attempt per maintenance cycle to avoid
  // multiple simultaneous TLS handshakes blocking the network stack.
  // Time-based guard: block reconnects if any slot reconnected within the last 15 s,
  // ensuring the previous TLS handshake (and its Core-0-expensive completion events)
  // finish before the next slot begins its own handshake.
  bool reconnect_attempted_this_cycle = MQTTConnectionPolicy::reconnectGuardActive(
      static_cast<uint32_t>(now_millis), static_cast<uint32_t>(_last_slot_reconnect_ms));
  // Only allow one full teardown+setup per cycle to limit heap fragmentation
  // when multiple slots fail simultaneously
  bool teardown_attempted_this_cycle = false;

  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (!_slots[i].enabled || !_slots[i].client) continue;

    // JWT slots need time sync before we can manage tokens
    bool slot_jwt = (_slots[i].preset && _slots[i].preset->auth_type == MQTT_AUTH_JWT) ||
                    (!_slots[i].preset && _slots[i].audience[0] != '\0');
    if (slot_jwt && !can_do_jwt) {
      continue;
    }

    maintainSlotConnection(i, now_millis, current_time, time_synced, reconnect_attempted_this_cycle, teardown_attempted_this_cycle);
  }
}

void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted) {
  MQTTSlot& slot = _slots[index];

  // Forgive past failures only after the connection has proven stable.
  // 2 minutes covers at least one keepalive round-trip (keepalive is 75 s),
  // so a link that can't survive a single keepalive period never resets the
  // ladder. Flapping endpoints therefore stay at their earned backoff rung
  // (worst case the 300 s rung / 30-minute breaker probes) instead of
  // hammering full TLS handshakes at the 10 s rung — see the onConnect
  // handler in initSlotClients() for why this doesn't happen on CONNACK.
  if (slot.connected &&
      (slot.reconnect_backoff != 0 || slot.max_backoff_failures != 0) &&
      MQTTConnectionPolicy::stableConnection(static_cast<uint32_t>(now_millis),
                                             static_cast<uint32_t>(slot.connected_at_ms))) {
    MQTT_DEBUG_PRINTLN("MQTT%d stable for %lus - clearing reconnect backoff (was level %d)",
        index + 1, (now_millis - slot.connected_at_ms) / 1000UL, slot.reconnect_backoff);
    slot.reconnect_backoff = 0;
    slot.max_backoff_failures = 0;
  }

  // JWT token renewal (for preset JWT slots and custom slots with audience set)
  bool slot_uses_jwt = (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) ||
                       (!slot.preset && slot.audience[0] != '\0');
  if (slot_uses_jwt) {
    // Renew (and below, reconnect) this many seconds before the token's exp
    // claim. Scaled to the slot's token lifetime — see renewalBufferSecs()
    // for why a flat 60 s lost the renewal race against brokers that enforce
    // exp on live sessions (waev's 55-minute tokens).
    const unsigned long renewal_buffer = MQTTConnectionPolicy::renewalBufferSecs(
        static_cast<uint32_t>(slotTokenLifetime(index)));
    bool token_needs_renewal = MQTTConnectionPolicy::tokenNeedsRenewal(
        time_synced, static_cast<uint32_t>(current_time),
        static_cast<uint32_t>(slot.token_expires_at),
        static_cast<uint32_t>(renewal_buffer));

    // Throttle renewal attempts to once per minute
    bool can_attempt_renewal = MQTTConnectionPolicy::renewalAttemptAllowed(
        static_cast<uint32_t>(now_millis), static_cast<uint32_t>(slot.last_token_renewal));

    if (token_needs_renewal && can_attempt_renewal) {
      slot.last_token_renewal = now_millis;

      unsigned long old_token_expires_at = slot.token_expires_at;

      if (createSlotAuthToken(index)) {
        MQTT_DEBUG_PRINTLN("MQTT%d token renewed", index + 1);

        // Bounce the connection while WE control the timing whenever the old
        // token is inside the renewal buffer — waiting for the broker to
        // enforce exp mid-session means a FIN plus a trip through the backoff
        // ladder instead of one clean reconnect. Same buffer as the renewal
        // trigger above, so a renewal implies a proactive reconnect.
        bool old_token_expired_or_imminent = !time_synced ||
                                            (old_token_expires_at == 0) ||
                                            (current_time >= old_token_expires_at) ||
                                            (time_synced && old_token_expires_at >= 1000000000 &&
                                             current_time >= (old_token_expires_at - renewal_buffer));

        if (old_token_expired_or_imminent || !slot.client->connected()) {
          // Disconnect + reconnect with fresh credentials, reusing existing client
          // to avoid internal heap leak/fragmentation from destroy/create cycles
          MQTT_DEBUG_PRINTLN("MQTT%d token renewal: reconnecting with fresh credentials", index + 1);
          if (slot.client->connected()) {
            slot.client->disconnect();  // stops the client internally
          }
          slot.client->setCredentials(_jwt_username, slot.auth_token);
          slot.client->connect();  // restart stopped client; reconnect() fails silently on a stopped client
          reconnect_attempted = true;
          _last_slot_reconnect_ms = now_millis;
          MQTT_DEBUG_PRINTLN("MQTT%d int_heap=%d at token renewal reconnect", index + 1,
              (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
          MQTT_DEBUG_PRINTLN("  radio_state=%d, last_rx=%lums ago",
              _radio ? _radio->getRadioState() : -1,
              (_radio && _radio->getLastRecvMillis() > 0) ? (_ms->getMillis() - _radio->getLastRecvMillis()) : 0);
        } else {
          // Token renewed but old one still valid — just update credentials for next reconnect
          slot.client->setCredentials(_jwt_username, slot.auth_token);
        }
      } else {
        MQTT_DEBUG_PRINTLN("MQTT%d token renewal failed", index + 1);
        slot.token_expires_at = 0;
      }
      return; // Token renewal handled connect; skip backoff logic below
    }
  }

  // Phase 4 (MQTT memory-defrag): the MIN_TLS_HEAP preflight was a workaround
  // for the fragmentation caused by per-reconnect mbedTLS allocations. With
  // persistent clients (Phase 1), the mbedTLS context is allocated once at
  // startup and the preflight is no longer necessary.

  // Periodic probe for circuit-breaker-tripped slots (recovery from transient outages)
  // Attempts a single reconnect every 30 minutes to see if the server has come back
  if (slot.circuit_breaker_tripped && !reconnect_attempted) {
    unsigned long probe_elapsed = MQTTConnectionPolicy::elapsedMs(
        static_cast<uint32_t>(now_millis), static_cast<uint32_t>(slot.last_reconnect_attempt));
    if (MQTTConnectionPolicy::circuitBreakerProbeDue(
            static_cast<uint32_t>(now_millis), static_cast<uint32_t>(slot.last_reconnect_attempt))) {
      slot.last_reconnect_attempt = now_millis;
      reconnect_attempted = true;
      _last_slot_reconnect_ms = now_millis;
      MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker probe (attempting single reconnect after %lu ms, int_heap=%d)", index + 1, probe_elapsed,
          (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
      MQTT_DEBUG_PRINTLN("  radio_state=%d, last_rx=%lums ago",
          _radio ? _radio->getRadioState() : -1,
          (_radio && _radio->getLastRecvMillis() > 0) ? (_ms->getMillis() - _radio->getLastRecvMillis()) : 0);
      if (slot_uses_jwt) {
        // Regenerate or refresh token, then reconnect the persistent client.
        // The client object and its mbedTLS context are always live post
        // initSlotClients(), so no full setup is ever needed here.
        if (createSlotAuthToken(index)) {
          slot.client->setCredentials(_jwt_username, slot.auth_token);
          MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker probe (fresh token)", index + 1);
        }
        slot.client->reconnect();
      } else {
        slot.client->reconnect();
      }
      // If the connect callback fires and sets slot.connected = true,
      // it will clear circuit_breaker_tripped via the onConnect handler
    }
  }

  // Reconnect with exponential backoff (for disconnected slots that already have valid config)
  // Only one reconnect per maintenance cycle to prevent TLS handshakes from blocking other slots
  if (!slot.connected && slot.initial_connect_done && !slot.circuit_breaker_tripped && !reconnect_attempted) {
    if (MQTTConnectionPolicy::reconnectDue(
            static_cast<uint32_t>(now_millis), static_cast<uint32_t>(slot.last_reconnect_attempt),
            slot.reconnect_backoff, static_cast<uint8_t>(index))) {
      slot.last_reconnect_attempt = now_millis;
      MQTTConnectionPolicy::BackoffAdvance advance = MQTTConnectionPolicy::advanceBackoff(
          slot.reconnect_backoff, slot.max_backoff_failures);
      slot.reconnect_backoff = advance.reconnect_backoff;
      slot.max_backoff_failures = advance.max_backoff_failures;
      slot.circuit_breaker_tripped = advance.circuit_breaker_tripped;
      if (!advance.should_reconnect) {
        MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker tripped after %d failures at max backoff - stopping reconnect attempts. Reconfigure slot to retry.", index + 1, slot.max_backoff_failures);
        return;
      }
      MQTT_DEBUG_PRINTLN("MQTT%d reconnecting (backoff level %d, failures at max: %d, int_heap=%d)", index + 1, slot.reconnect_backoff, slot.max_backoff_failures,
          (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
      MQTT_DEBUG_PRINTLN("  radio_state=%d, last_rx=%lums ago",
          _radio ? _radio->getRadioState() : -1,
          (_radio && _radio->getLastRecvMillis() > 0) ? (_ms->getMillis() - _radio->getLastRecvMillis()) : 0);
      reconnect_attempted = true;
      _last_slot_reconnect_ms = now_millis;
      if (slot_uses_jwt) {
        // Always lightweight reconnect on the persistent client. A stale/expired
        // token is handled by regenerating it in place and updating credentials
        // — no teardown is needed because the client and its mbedTLS context
        // persist for the bridge lifetime.
        if (createSlotAuthToken(index)) {
          slot.client->setCredentials(_jwt_username, slot.auth_token);
          MQTT_DEBUG_PRINTLN("MQTT%d reconnect (fresh token, backoff %d)", index + 1, slot.reconnect_backoff);
        } else {
          MQTT_DEBUG_PRINTLN("MQTT%d reconnect (token refresh failed, backoff %d)", index + 1, slot.reconnect_backoff);
        }
        slot.client->reconnect();
      } else {
        // Non-JWT slots — lightweight reconnect on existing client.
        MQTT_DEBUG_PRINTLN("MQTT%d reconnect (non-JWT, backoff %d)", index + 1, slot.reconnect_backoff);
        slot.client->reconnect();
      }
    }
  }
}

// Effective JWT lifetime for a slot: the preset's token_lifetime (or the 24 h
// default for custom/audience slots), minus the per-slot expiry stagger that
// keeps multiple JWT slots from renewing/reconnecting simultaneously. This is
// the exact value createSlotAuthToken() puts in the token's exp claim, so the
// renewal scheduling in maintainSlotConnection() can be derived from it.
unsigned long MQTTBridge::slotTokenLifetime(int index) const {
  const MQTTSlot& slot = _slots[index];
  unsigned long base_lifetime = MQTTConnectionPolicy::kDefaultJwtLifetimeSecs;
  if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT && slot.preset->token_lifetime > 0) {
    base_lifetime = slot.preset->token_lifetime;
  }
  return MQTTConnectionPolicy::jwtLifetimeSecs(
      static_cast<uint32_t>(base_lifetime), static_cast<uint8_t>(index));
}

// How early (seconds before the token's exp claim) to renew the token AND
// proactively bounce the connection with fresh credentials. exp and the
// renewal schedule are locked together (both derive from slotTokenLifetime),
// so this buffer is the ONLY margin between "device re-authenticates" and
// "broker enforces exp and FIN-closes the session mid-stream" — shortening a
// preset's token_lifetime moves both times together and cannot widen it.
// The old flat 60 s lost that race whenever the device clock ran slow, or a
// single renewal attempt failed (the 60 s renewal throttle then ate the whole
// margin) — observed on the waev preset, whose 55-minute tokens are the only
// ones short enough for brokers to enforce exp against a live session.
// lifetime/10 with a 60 s floor and 300 s cap: 24 h tokens renew 5 min early
// (unchanged in practice), waev renews ~5 min early with ~5 throttled retry
// windows, and degenerate short lifetimes still renew inside their validity.
bool MQTTBridge::createSlotAuthToken(int index) {
  if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false;
  MQTTSlot& slot = _slots[index];
  if (!_identity) return false;

  // Determine JWT audience: preset takes priority, then custom slot audience field
  const char* audience = nullptr;
  if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) {
    audience = slot.preset->jwt_audience;
  } else if (slot.audience[0] != '\0') {
    audience = slot.audience;
  }
  if (!audience || audience[0] == '\0') return false;

  // Ensure JWT username is set
  if (_jwt_username[0] == '\0') {
    char public_key_hex[65];
    mesh::Utils::toHex(public_key_hex, _identity->pub_key, PUB_KEY_SIZE);
    snprintf(_jwt_username, sizeof(_jwt_username), "v1_%s", public_key_hex);
  }

  // Prepare owner key
  const char* owner_key = nullptr;
  char owner_key_uppercase[65];
  if (_obs->mqtt_owner_public_key[0] != '\0') {
    strncpy(owner_key_uppercase, _obs->mqtt_owner_public_key, sizeof(owner_key_uppercase) - 1);
    owner_key_uppercase[sizeof(owner_key_uppercase) - 1] = '\0';
    for (int i = 0; owner_key_uppercase[i]; i++) {
      owner_key_uppercase[i] = toupper(owner_key_uppercase[i]);
    }
    owner_key = owner_key_uppercase;
  }

  char client_version[64];
  getClientVersion(client_version, sizeof(client_version));
  const char* email = (_obs->mqtt_email[0] != '\0') ? _obs->mqtt_email : nullptr;

  unsigned long current_time = time(nullptr);
  unsigned long expires_in = slotTokenLifetime(index);  // preset/default lifetime minus per-slot stagger
  bool time_synced = (current_time >= 1000000000);

  if (JWTHelper::createAuthToken(
      *_identity, audience,
      0, expires_in, slot.auth_token, AUTH_TOKEN_SIZE,
      owner_key, client_version, email)) {
    slot.token_expires_at = time_synced ? (current_time + expires_in) : 0;
    return true;
  }

  slot.token_expires_at = 0;
  return false;
}

bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload, bool retained, uint8_t qos) {
  if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false;
  MQTTSlot& slot = _slots[index];
  if (!slot.client || !slot.connected) {
    unsigned long now = millis();
    if (now - slot.last_log_time > SLOT_LOG_INTERVAL) {
      slot.last_log_time = now;
      MQTT_DEBUG_PRINTLN("MQTT%d not connected - skipping publish", index + 1);
    }
    return false;
  }

  // Publish path by QoS:
  //  - QoS 0 (high-rate packets/raw): SYNCHRONOUS (async=false → esp_mqtt_client_publish),
  //    which writes straight to the socket. The async/outbox path drains only one queued
  //    item per esp-mqtt task loop (~1 msg/s/conn, gated by the 1s poll_read), so under
  //    even light packet load the outbox pins at its cap and drops ~20-30%. A synchronous
  //    write bypasses that drain ceiling entirely and does not store in the outbox. It can
  //    block the (Core-0, prio-1) MQTT task on a stalled socket, but only up to
  //    network_timeout_ms (lowered in optimizeMqttClientConfig); mesh RX (Core 1) and the
  //    WiFi/TCP stack (higher-prio system tasks) are unaffected, and a failed write flips
  //    the slot to disconnected so subsequent packets skip it.
  //  - QoS 1 (low-rate retained status): async, so it keeps the durable outbox + retransmit.
  //
  // Return convention: QoS 0 sync publish returns msg_id == 0 on success (no PUBACK
  // tracking). Negative values (-1 write/failure) are the only actual failures; the queue
  // retry/drop path below handles them.
  bool async = (qos > 0);
  int result = slot.client->publish(topic, qos, retained, payload, strlen(payload), async);
  if (result < 0) {
    // QoS0 packet/raw publishes are best-effort and may be retried from the
    // bridge queue; avoid logging transient first-attempt failures here.
    if (qos > 0) {
      static unsigned long last_fail_log = 0;
      unsigned long now = millis();
      if (now - last_fail_log > 60000) {
        MQTT_DEBUG_PRINTLN("MQTT%d publish failed (result=%d qos=%u)", index + 1, result, (unsigned)qos);
        last_fail_log = now;
      }
    }
    return false;
  }
  return true;
}

bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, bool retained, uint8_t qos) {
  bool published = false;
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
      if (publishToSlot(i, topic, payload, retained, qos)) {
        published = true;
      }
    }
  }
  return published;
}

// ---------------------------------------------------------------------------
// Topic building - resolves the correct topic for a given slot and message type.
// Presets use hardcoded topic logic; custom slots support user-defined templates.
// ---------------------------------------------------------------------------
bool MQTTBridge::substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size) {
  return mqttBuildPublicationTopic(MQTT_ROUTE_CUSTOM, (int)type, tmpl,
                                   _iata, _device_id, _obs->mqtt_slot_token[slot_index],
                                   buf, buf_size);
}

bool MQTTBridge::buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size) {
  static_assert(
      static_cast<int>(MSG_STATUS) == MQTT_PUBLICATION_STATUS &&
      static_cast<int>(MSG_PACKETS) == MQTT_PUBLICATION_PACKETS &&
      static_cast<int>(MSG_RAW) == MQTT_PUBLICATION_RAW &&
      static_cast<int>(MSG_NEIGHBORS) == MQTT_PUBLICATION_NEIGHBORS,
      "topic router enum drift");

  if (!mqttTopicSlotIndexValid(index, RUNTIME_MQTT_SLOTS)) return false;
  const MQTTSlot& slot = _slots[index];

  // Preset slots: use hardcoded topic logic
  if (slot.preset) {
    MQTTTopicRouteStyle style = (slot.preset->topic_style == MQTT_TOPIC_MESHRANK)
        ? MQTT_ROUTE_MESHRANK : MQTT_ROUTE_MESHCORE;
    return mqttBuildPublicationTopic(style, (int)type, nullptr,
                                     _iata, _device_id, _obs->mqtt_slot_token[index],
                                     topic_buf, buf_size);
  }

  // Custom slots: use topic template if set, otherwise default meshcore format
  if (_obs->mqtt_slot_topic[index][0] != '\0') {
    return substituteTopicTemplate(_obs->mqtt_slot_topic[index], type, index, topic_buf, buf_size);
  }
  // Default: meshcore format
  return mqttBuildPublicationTopic(MQTT_ROUTE_MESHCORE, (int)type, nullptr,
                                   _iata, _device_id, _obs->mqtt_slot_token[index],
                                   topic_buf, buf_size);
}

void MQTTBridge::publishStatusToSlot(int index) {
  if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return;
  MQTTSlot& slot = _slots[index];
  if (!slot.client || !slot.connected) return;

  refreshOriginFromPrefs();

  // Build per-slot topic (handles IATA check for meshcore, token check for meshrank)
  char status_topic[128];
  if (!buildTopicForSlot(index, MSG_STATUS, status_topic, sizeof(status_topic))) {
    return;  // Slot doesn't support status (e.g., meshrank) or missing required config
  }

  // Reuse pre-allocated buffer to avoid heap alloc/free churn under memory pressure.
  // _status_json_buffer and _last_raw_data are both Core 0-owned; no mutex needed.
  #if defined(BOARD_HAS_PSRAM)
  char fallback_status_buffer[STATUS_JSON_BUFFER_SIZE];
  char* json_buffer = (_status_json_buffer != nullptr) ? _status_json_buffer : fallback_status_buffer;
  #else
  char* json_buffer = _status_json_buffer;
  #endif

  char origin_id[65];
  char timestamp[40];
  char radio_info[64];

  // Status timestamp: UTC with explicit +00:00 offset, same as packet/raw JSON
  // `timestamp` (system clock is UTC — SNTP offset 0; prefs Timezone is separate).
  struct timeval now_tv;
  gettimeofday(&now_tv, nullptr);
  MQTTMessageBuilder::formatIsoTimestampForMqtt(now_tv.tv_sec, now_tv.tv_usec, _timezone, timestamp, sizeof(timestamp));

  snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d",
           _prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr);

  strncpy(origin_id, _device_id, sizeof(origin_id) - 1);
  origin_id[sizeof(origin_id) - 1] = '\0';

  char client_version[64];
  getClientVersion(client_version, sizeof(client_version));

  // Collect stats on-demand if sources are available
  int battery_mv = -1;
  int uptime_secs = -1;
  int errors = -1;
  int noise_floor = -999;
  int tx_air_secs = -1;
  int rx_air_secs = -1;
  int recv_errors = -1;
  int packets_sent = -1;
  int packets_received = -1;

  if (_board) battery_mv = _board->getBattMilliVolts();
  if (_ms) uptime_secs = _ms->getMillis() / 1000;
  if (_dispatcher) {
    errors = _dispatcher->getErrFlags();
    tx_air_secs = _dispatcher->getTotalAirTime() / 1000;
    rx_air_secs = _dispatcher->getReceiveAirTime() / 1000;
    packets_sent = (int)(_dispatcher->getNumSentFlood() + _dispatcher->getNumSentDirect());
    packets_received = (int)(_dispatcher->getNumRecvFlood() + _dispatcher->getNumRecvDirect());
  }
  if (_radio) {
    noise_floor = (int16_t)_radio->getNoiseFloor();
    recv_errors = (int)_radio->getPacketsRecvErrors();
  }

  // Internal heap free (for diagnosing repeater hangs from internal heap exhaustion)
  int internal_heap_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL);

  int len = MQTTMessageBuilder::buildStatusMessage(
    _status_json_doc,
    _origin, origin_id, _board_model, _firmware_version, radio_info,
    client_version, "online", timestamp, json_buffer, STATUS_JSON_BUFFER_SIZE,
    battery_mv, uptime_secs, errors, _queue_count, noise_floor,
    tx_air_secs, rx_air_secs, recv_errors, internal_heap_free,
    packets_sent, packets_received,
    _prefs->disable_fwd ? "off" : "on"
  );

  if (len > 0) {
    // Honor the preset's retain policy, matching publishStatus() — brokers that
    // set allow_retain=false (e.g. waev) reject retained publishes, so this
    // on-connect status must not force retain=true. Custom slots default to
    // non-retained here too, keeping both status paths consistent.
    bool use_retain = slot.preset ? slot.preset->allow_retain : false;
    int result = slot.client->publish(status_topic, 1, use_retain, json_buffer, strlen(json_buffer));
    if (result <= 0) {
      MQTT_DEBUG_PRINTLN("MQTT%d status publish failed", index + 1);
    }
  }
}

void MQTTBridge::updateCachedConnectionStatus() {
  bool any_connected = false;
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].enabled && _slots[i].connected) {
      any_connected = true;
      break;
    }
  }
  _cached_has_connected_slots = any_connected;
}

bool MQTTBridge::isAnySlotConnected() {
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].enabled && _slots[i].connected) {
      return true;
    }
  }
  return false;
}

void MQTTBridge::setSlotPreset(int slot_index, const char* preset_name) {
  if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return;

  // On ESP32, teardown/setup involves TLS and must run on the MQTT task (Core 0).
  // Set a flag so the MQTT task picks it up on its next loop iteration.
  #ifdef ESP_PLATFORM
  if (_mqtt_task_handle != nullptr) {
    _slot_reconfigure_pending[slot_index] = true;
    MQTT_DEBUG_PRINTLN("MQTT%d reconfigure queued (preset: %s)", slot_index + 1, preset_name);
    return;
  }
  #endif

  // Non-ESP32 or bridge not yet started: apply directly
  applySlotPreset(slot_index, preset_name);
}

void MQTTBridge::applySlotPreset(int slot_index, const char* preset_name) {
  if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return;
  MQTTSlot& slot = _slots[slot_index];

  teardownSlot(slot_index);

  if (strcmp(preset_name, MQTT_PRESET_NONE) == 0 || preset_name[0] == '\0') {
    slot.enabled = false;
    slot.preset = nullptr;
    return;
  }

  if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) {
    slot.preset = nullptr;
    // Re-sync every custom field from prefs (same copy begin() does at startup)
    // so a CLI/web edit to the host, port, credentials, or JWT audience is
    // actually picked up on reconfigure. Previously this branch reused the
    // stale slot fields, so e.g. changing mqttN.server or mqttN.username had no
    // effect on the live connection. Token and topic are read live from _obs in
    // setupSlot()/buildTopicForSlot(), so they don't need copying here.
    strncpy(slot.host, _obs->mqtt_slot_host[slot_index], sizeof(slot.host) - 1);
    slot.host[sizeof(slot.host) - 1] = '\0';
    slot.port = _obs->mqtt_slot_port[slot_index];
    strncpy(slot.username, _obs->mqtt_slot_username[slot_index], sizeof(slot.username) - 1);
    slot.username[sizeof(slot.username) - 1] = '\0';
    strncpy(slot.password, _obs->mqtt_slot_password[slot_index], sizeof(slot.password) - 1);
    slot.password[sizeof(slot.password) - 1] = '\0';
    strncpy(slot.audience, _obs->mqtt_slot_audience[slot_index], sizeof(slot.audience) - 1);
    slot.audience[sizeof(slot.audience) - 1] = '\0';
    slot.enabled = (slot.host[0] != '\0');
    if (_initialized && slot.enabled && customEndpointComplete(slot.host, slot.port)) {
      setupSlot(slot_index);
    }
    return;
  }

  const MQTTPresetDef* preset = findMQTTPreset(preset_name);
  if (preset) {
    slot.enabled = true;
    slot.preset = preset;
    if (mqttPresetNeedsSlotCredentials(preset)) {
      strncpy(slot.username, _obs->mqtt_slot_username[slot_index], sizeof(slot.username) - 1);
      slot.username[sizeof(slot.username) - 1] = '\0';
      strncpy(slot.password, _obs->mqtt_slot_password[slot_index], sizeof(slot.password) - 1);
      slot.password[sizeof(slot.password) - 1] = '\0';
    }
    if (_initialized) {
      char reason[80];
      if (!isSlotReady(slot_index, reason, sizeof(reason))) {
        MQTT_DEBUG_PRINTLN("MQTT%d (%s) not ready - run '%s' to connect", slot_index + 1, preset_name, reason);
        return;
      }
      setupSlot(slot_index);
    }
  }
}

void MQTTBridge::setSlotCustomBroker(int slot_index, const char* host, uint16_t port,
                                      const char* username, const char* password) {
  if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return;
  MQTTSlot& slot = _slots[slot_index];

  strncpy(slot.host, host ? host : "", sizeof(slot.host) - 1);
  slot.host[sizeof(slot.host) - 1] = '\0';
  slot.port = port;
  strncpy(slot.username, username ? username : "", sizeof(slot.username) - 1);
  slot.username[sizeof(slot.username) - 1] = '\0';
  strncpy(slot.password, password ? password : "", sizeof(slot.password) - 1);
  slot.password[sizeof(slot.password) - 1] = '\0';
}

// ---------------------------------------------------------------------------
// WiFi connection handling
// ---------------------------------------------------------------------------

void MQTTBridge::checkConfigurationMismatch() {
  // Warn if packets are enabled but both rx and tx are off — nothing will be published
  if (_obs->mqtt_packets_enabled && !_obs->mqtt_rx_enabled && _obs->mqtt_tx_enabled == 0) {
    unsigned long now = millis();
    if (_last_config_warning == 0 || (now - _last_config_warning > CONFIG_WARNING_INTERVAL)) {
      MQTT_DEBUG_PRINTLN("MQTT: Both mqtt.rx and mqtt.tx are off - no packets will be published. Run 'set mqtt.rx on' or 'set mqtt.tx on' to fix.");
      _last_config_warning = now;
    }
  } else {
    _last_config_warning = 0;
  }
}

bool MQTTBridge::handleWiFiConnection(unsigned long now) {
  wl_status_t current_wifi_status = WiFi.status();
  bool transitioned_to_connected = false;

  if (current_wifi_status == WL_CONNECTED && s_wifi_connected_at == 0) {
    s_wifi_connected_at = now;
  }
  if (!_wifi_status_initialized) {
    _last_wifi_status = current_wifi_status;
    _wifi_status_initialized = true;
    if (current_wifi_status != WL_CONNECTED) {
      _wifi_disconnected_time = now;
    }
  }
  if (now - _last_wifi_check <= 10000) {
    return false;
  }
  _last_wifi_check = now;

  if (current_wifi_status == WL_CONNECTED) {
    if (_last_wifi_status != WL_CONNECTED) {
      transitioned_to_connected = true;
      _wifi_disconnected_time = 0;
      s_wifi_connected_at = now;
      _wifi_reconnect_backoff_attempt = 0;
      #ifdef ESP_PLATFORM
      wifi_ps_type_t ps_mode;
      uint8_t ps_pref = _obs->wifi_power_save;
      if (ps_pref == 1) {
        ps_mode = WIFI_PS_NONE;
      } else if (ps_pref == 2) {
        ps_mode = WIFI_PS_MAX_MODEM;
      } else {
        ps_mode = WIFI_PS_NONE;  // default: no power save; eliminates DTIM wake latency on mains-powered bridges
      }
      esp_wifi_set_ps(ps_mode);
      #ifdef MQTT_WIFI_TX_POWER
      WiFi.setTxPower(MQTT_WIFI_TX_POWER);
      #else
      WiFi.setTxPower(WIFI_POWER_11dBm);
      #endif
      #endif
    }
    if (s_wifi_connected_at == 0) {
      s_wifi_connected_at = now;
    }
    _last_wifi_status = WL_CONNECTED;
  } else {
    if (_last_wifi_status == WL_CONNECTED) {
      _wifi_disconnected_time = now;
      s_wifi_connected_at = 0;
      // Disconnect all slot clients when WiFi drops
      for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
        if (_slots[i].client && _slots[i].connected) {
          _slots[i].client->disconnect();
        }
      }
    } else if (_wifi_disconnected_time > 0) {
      // Backoff ladder + wrap-safe timing live in MQTTConnectionPolicy (Phase 6),
      // exercised by host tests. Behavior is unchanged: both the link-down
      // duration and the since-last-attempt interval must clear the current rung
      // (elapsedMs is the wrap-safe form of the old ULONG_MAX branch).
      if (MQTTConnectionPolicy::wifiReconnectDue(
              (uint32_t)now, (uint32_t)_wifi_disconnected_time,
              (uint32_t)_last_wifi_reconnect_attempt,
              _wifi_reconnect_backoff_attempt)) {
        _last_wifi_reconnect_attempt = now;
        _wifi_reconnect_backoff_attempt =
            MQTTConnectionPolicy::nextWifiBackoffAttempt(_wifi_reconnect_backoff_attempt);
        WiFi.disconnect();
        WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
      }
    }
    _last_wifi_status = current_wifi_status;
  }
  return transitioned_to_connected;
}

bool MQTTBridge::isReady() const {
  return _initialized && isWiFiConfigValid(_obs);
}

bool MQTTBridge::isIATAValid() const {
  if (strlen(_iata) == 0 || strcmp(_iata, "XXX") == 0) {
    return false;
  }
  return true;
}

bool MQTTBridge::isSlotReady(int index, char* reason_buf, size_t reason_size) const {
  if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false;
  const MQTTSlot& slot = _slots[index];

  if (!slot.enabled) return true;  // disabled slots are "ready" (nothing to do)

  if (slot.preset) {
    if (slot.preset->topic_style == MQTT_TOPIC_MESHRANK) {
      if (_obs->mqtt_slot_token[index][0] == '\0') {
        if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.token <your_token>", index + 1);
        return false;
      }
    } else if (slot.preset->topic_style == MQTT_TOPIC_MESHCORE) {
      if (!isIATAValid()) {
        if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt.iata <airport_code>");
        return false;
      }
    }
    if (mqttPresetNeedsSlotCredentials(slot.preset)) {
      if (_obs->mqtt_slot_username[index][0] == '\0') {
        if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.username <user>", index + 1);
        return false;
      }
      if (_obs->mqtt_slot_password[index][0] == '\0') {
        if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.password <pass>", index + 1);
        return false;
      }
    }
  } else {
    // Custom slot without a topic template uses meshcore format, needs IATA
    if (_obs->mqtt_slot_topic[index][0] == '\0' && !isIATAValid()) {
      if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt.iata <airport_code> or set mqtt%d.topic <template>", index + 1);
      return false;
    }
  }

  return true;
}

// ---------------------------------------------------------------------------
// loop() - non-ESP32 main loop (ESP32 uses mqttTaskLoop via FreeRTOS task)
// ---------------------------------------------------------------------------
void MQTTBridge::loop() {
  if (!_initialized) return;

  #ifdef ESP_PLATFORM
  // On ESP32, loop() is a no-op - all processing happens in the FreeRTOS task
  return;
  #else
  unsigned long now = millis();
  if (handleWiFiConnection(now) && !_ntp_synced) {
    syncTimeWithNTP();
  }
  if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) {
    _ntp_sync_pending = false;
    syncTimeWithNTP();
  }

  // Deferred slot setup after NTP sync (non-ESP32 path)
  if (_ntp_synced && !_slots_setup_done) {
    _slots_setup_done = true;
    int active_count = 0;
    for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
      if (_slots[i].enabled) {
        if (active_count >= _max_active_slots) {
          _slots[i].enabled = false;
          continue;
        }
        if (!isSlotReady(i)) {
          continue;
        }
        setupSlot(i);
        active_count++;
      }
    }
  }

  // Process pending slot reconfigures
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slot_reconfigure_pending[i]) {
      _slot_reconfigure_pending[i] = false;
      applySlotPreset(i, _obs->mqtt_slot_preset[i]);
    }
  }

  // Maintain slot connections (token renewal, reconnect with backoff)
  maintainSlotConnections();

  // Process packet queue
  processPacketQueue();

  // Periodic configuration check (throttled to avoid spam)
  checkConfigurationMismatch();

  // Periodic NTP refresh (every hour) — lightweight, non-blocking.
  if (WiFi.status() == WL_CONNECTED && millis() - _last_ntp_sync > 3600000) {
    refreshNTP();
  }

  // Publish status updates (handle millis() overflow correctly).
  // Read the toggle live from prefs so a CLI/web `set mqtt.status` change
  // applies without a bridge restart.
  if (_obs->mqtt_status_enabled) {
    bool has_destinations = _cached_has_connected_slots;

    if (has_destinations) {
      unsigned long now = millis();
      bool should_publish = false;

      if (_last_status_retry != 0) {
        unsigned long retry_elapsed = (now >= _last_status_retry) ?
                                     (now - _last_status_retry) :
                                     (ULONG_MAX - _last_status_retry + now + 1);
        if (retry_elapsed >= STATUS_RETRY_INTERVAL) {
          should_publish = true;
        }
      } else {
        if (_last_status_publish == 0) {
          should_publish = true;
        } else {
          unsigned long elapsed = (now >= _last_status_publish) ?
                               (now - _last_status_publish) :
                               (ULONG_MAX - _last_status_publish + now + 1);
          should_publish = (elapsed >= _status_interval);
        }
      }

      if (should_publish) {
        if (_last_status_publish != 0) {
          unsigned long elapsed = (now >= _last_status_publish) ?
                                 (now - _last_status_publish) :
                                 (ULONG_MAX - _last_status_publish + now + 1);
          MQTT_DEBUG_PRINTLN("Status publish timer expired (elapsed: %lu ms, interval: %lu ms)", elapsed, _status_interval);
        } else {
          MQTT_DEBUG_PRINTLN("Status publish attempt (first publish or retry)");
        }

        _last_status_retry = now;
        if (publishStatus()) {
          _last_status_publish = now;
          _last_status_retry = 0;
          MQTT_DEBUG_PRINTLN("Status published successfully, next publish in %lu ms", _status_interval);
        } else {
          MQTT_DEBUG_PRINTLN("Status publish failed, will retry in %lu ms", STATUS_RETRY_INTERVAL);
        }
      }
    } else {
      if (_last_status_retry != 0) {
        _last_status_retry = 0;
      }
    }

    // Phase 4 (MQTT memory-defrag): the "recreate on prolonged status failure"
    // path and the periodic runCriticalMemoryCheckAndRecovery() call have been
    // removed. They were both symptoms of the heap churn introduced by
    // delete/new cycles of the MQTT client; with persistent clients the
    // allocator stays healthy and these recovery hooks aren't required.
  }
  #endif
}

// ---------------------------------------------------------------------------
// Packet handling
// ---------------------------------------------------------------------------

void MQTTBridge::onPacketReceived(mesh::Packet *packet) {
  if (!_initialized || !_obs->mqtt_packets_enabled || !_obs->mqtt_rx_enabled) return;

  // Check if we have any enabled slots to send to
  bool has_valid_slots = false;
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].enabled && _slots[i].client) {
      has_valid_slots = true;
      break;
    }
  }
  if (!has_valid_slots) return;

  // Queue packet for transmission
  queuePacket(packet, false);
}

void MQTTBridge::sendPacket(mesh::Packet *packet) {
  uint8_t tx_mode = _obs->mqtt_tx_enabled;  // Read live from prefs (no restart needed)
  if (!_initialized || !_obs->mqtt_packets_enabled || tx_mode == 0) return;

  // Advert mode: only queue self-originated advert packets
  if (tx_mode == 2) {
    if (packet->getPayloadType() != PAYLOAD_TYPE_ADVERT) return;
    if (packet->payload_len < PUB_KEY_SIZE) return;
    // Advert payload starts with advertiser's 32-byte public key — compare to our identity
    if (!_identity || memcmp(_identity->pub_key, packet->payload, PUB_KEY_SIZE) != 0) return;
  }

  // Queue packet for transmission
  queuePacket(packet, true);
}

void MQTTBridge::processPacketQueue() {
  #ifdef ESP_PLATFORM
  // Use FreeRTOS queue
  if (_packet_queue_handle == nullptr) {
    return;
  }

  // Update queue count from actual queue state
  _queue_count = uxQueueMessagesWaiting(_packet_queue_handle);

  if (_queue_count == 0) {
    _queue_disconnected_since = 0;
    return;
  }

  // Use cached connection status to avoid redundant checks
  bool has_connected_slots = _cached_has_connected_slots;

  if (!has_connected_slots) {
    if (_queue_count > 0) {
      unsigned long now = millis();
      if (now - _last_no_broker_log > NO_BROKER_LOG_INTERVAL) {
        MQTT_DEBUG_PRINTLN("Queue has %d packets but no slots connected", _queue_count);
        _last_no_broker_log = now;
      }
      // Flush stale packets after extended disconnect
      if (_queue_disconnected_since == 0) {
        _queue_disconnected_since = now;
      } else if (MQTTPacketQueuePolicy::shouldFlushDisconnected(
                     static_cast<uint32_t>(now),
                     static_cast<uint32_t>(_queue_disconnected_since))) {
        QueuedPacket discard;
        while (xQueueReceive(_packet_queue_handle, &discard, 0) == pdTRUE) {}
        _queue_count = 0;
        MQTT_DEBUG_PRINTLN("Flushed stale packet queue after %lu ms disconnected", now - _queue_disconnected_since);
        _queue_disconnected_since = now;
      }
    }
    return;
  }

  _queue_disconnected_since = 0;
  _last_no_broker_log = 0;

  // Adaptive drain: burst-process when queue has backlog, gentle otherwise
  int processed = 0;
  const MQTTPacketQueuePolicy::DrainBudget drain_budget =
      MQTTPacketQueuePolicy::drainBudget(static_cast<size_t>(_queue_count));
  unsigned long loop_start_time = millis();
  #ifdef MQTT_DIAG_VERBOSE
  static unsigned long last_retry_schedule_log = 0;
  #endif

  while (processed < drain_budget.max_packets) {
    if (!MQTTPacketQueuePolicy::drainTimeAvailable(
            static_cast<uint32_t>(millis()),
            static_cast<uint32_t>(loop_start_time),
            drain_budget.max_time_ms)) {
      break;
    }

    QueuedPacket queued;
    // Try to receive from queue (non-blocking)
    if (xQueueReceive(_packet_queue_handle, &queued, 0) != pdTRUE) {
      break;  // No more packets
    }

    unsigned long now_ms = millis();
    if (!MQTTPacketQueuePolicy::retryReady(
            static_cast<uint32_t>(now_ms),
            static_cast<uint32_t>(queued.next_retry_ms),
            queued.retry_attempts)) {
      // Not ready yet; put it back and stop draining this cycle.
      xQueueSend(_packet_queue_handle, &queued, 0);
      break;
    }

    // Update Core 0-owned last-raw-data for publishStatus() — no mutex needed since
    // _last_raw_data is now written only here (Core 0) and read only by publishStatus() (Core 0).
    if (!queued.is_tx && queued.has_raw_data && _last_raw_data) {
      memcpy(_last_raw_data, queued.raw_data, queued.raw_len);
      _last_raw_len       = queued.raw_len;
      _last_snr           = queued.snr;
      _last_rssi          = queued.rssi;
      _last_raw_timestamp = millis();
    }

    bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx,
                                          queued.has_raw_data ? queued.raw_data : nullptr,
                                          queued.has_raw_data ? queued.raw_len  : 0,
                                          queued.snr, queued.rssi);
    taskYIELD();  // allow higher-priority tasks to run between packet publishes

    // Publish raw if enabled (live from prefs so `set mqtt.raw` applies without
    // a bridge restart)
    bool raw_published = false;
    if (_obs->mqtt_raw_enabled) {
      raw_published = publishRaw(&queued.packet_copy);
    }

    bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
    const MQTTPacketQueuePolicy::RetryDecision retry =
        MQTTPacketQueuePolicy::retryDecision(
            any_published, queued.retry_attempts,
            static_cast<uint32_t>(now_ms));
    if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) {
      queued.retry_attempts = retry.retry_attempts;
      queued.next_retry_ms = retry.next_retry_ms;
      #ifdef MQTT_DIAG_VERBOSE
      if (now_ms - last_retry_schedule_log > 5000UL) {
        unsigned long age_ms = queued.timestamp > 0
            ? MQTTPacketQueuePolicy::elapsedMs(static_cast<uint32_t>(now_ms),
                                               static_cast<uint32_t>(queued.timestamp))
            : 0;
        MQTT_DEBUG_PRINTLN("Retry scheduled: attempt=%u/%u delay=%lu age=%lu q=%u pkt_type=%u packet_ok=%d raw_ok=%d",
          (unsigned)queued.retry_attempts, (unsigned)MQTTPacketQueuePolicy::kMaxQos0RetryAttempts,
          (unsigned long)retry.delay_ms, age_ms, (unsigned)uxQueueMessagesWaiting(_packet_queue_handle),
          (unsigned)queued.packet_copy.getPayloadType(), packet_published ? 1 : 0, raw_published ? 1 : 0);
        last_retry_schedule_log = now_ms;
      }
      #endif
      if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) {
        MQTT_DEBUG_PRINTLN("Retry requeue failed, dropping packet (attempt=%u)", queued.retry_attempts);
      }
    } else if (retry.action == MQTTPacketQueuePolicy::RetryAction::Drop) {
      // Intentional: QoS0 best-effort packets are dropped silently in normal
      // builds; detailed exhaustion logs are only emitted in verbose mode.
      #ifdef MQTT_DIAG_VERBOSE
      static unsigned long last_retry_drop_log = 0;
      if (now_ms - last_retry_drop_log > 60000UL) {
        unsigned long age_ms = queued.timestamp > 0
            ? MQTTPacketQueuePolicy::elapsedMs(static_cast<uint32_t>(now_ms),
                                               static_cast<uint32_t>(queued.timestamp))
            : 0;
        MQTT_DEBUG_PRINTLN("Packet dropped after retry exhaustion (attempts=%u age=%lu pkt_type=%u packet_ok=%d raw_ok=%d)",
          queued.retry_attempts, age_ms, (unsigned)queued.packet_copy.getPayloadType(),
          packet_published ? 1 : 0, raw_published ? 1 : 0);
        last_retry_drop_log = now_ms;
      }
      #endif
    }

    _queue_count = uxQueueMessagesWaiting(_packet_queue_handle);
    processed++;
  }
  #else
  // Non-ESP32: Use circular buffer
  if (_queue_count == 0) {
    _queue_disconnected_since = 0;
    return;
  }

  bool has_connected_slots = _cached_has_connected_slots;

  if (!has_connected_slots) {
    if (_queue_count > 0) {
      unsigned long now = millis();
      if (now - _last_no_broker_log > NO_BROKER_LOG_INTERVAL) {
        MQTT_DEBUG_PRINTLN("Queue has %d packets but no slots connected", _queue_count);
        _last_no_broker_log = now;
      }
      if (_queue_disconnected_since == 0) {
        _queue_disconnected_since = now;
      } else if (MQTTPacketQueuePolicy::shouldFlushDisconnected(
                     static_cast<uint32_t>(now),
                     static_cast<uint32_t>(_queue_disconnected_since))) {
        while (_queue_count > 0) {
          dequeuePacket();
        }
        MQTT_DEBUG_PRINTLN("Flushed stale packet queue after %lu ms disconnected",
                           now - _queue_disconnected_since);
        _queue_disconnected_since = now;
      }
    }
    return;
  }

  _queue_disconnected_since = 0;
  _last_no_broker_log = 0;

  // Adaptive drain: burst-process when queue has backlog, gentle otherwise
  int processed = 0;
  const MQTTPacketQueuePolicy::DrainBudget drain_budget =
      MQTTPacketQueuePolicy::drainBudget(static_cast<size_t>(_queue_count));
  unsigned long loop_start_time = millis();
  #ifdef MQTT_DIAG_VERBOSE
  static unsigned long last_retry_schedule_log = 0;
  #endif

  while (_queue_count > 0 && processed < drain_budget.max_packets) {
    if (!MQTTPacketQueuePolicy::drainTimeAvailable(
            static_cast<uint32_t>(millis()),
            static_cast<uint32_t>(loop_start_time),
            drain_budget.max_time_ms)) {
      break;
    }

    QueuedPacket& queued = _packet_queue[_queue_head];
    unsigned long now_ms = millis();
    if (!MQTTPacketQueuePolicy::retryReady(
            static_cast<uint32_t>(now_ms),
            static_cast<uint32_t>(queued.next_retry_ms),
            queued.retry_attempts)) {
      break;
    }

    if (!queued.is_tx && queued.has_raw_data && _last_raw_data) {
      memcpy(_last_raw_data, queued.raw_data, queued.raw_len);
      _last_raw_len       = queued.raw_len;
      _last_snr           = queued.snr;
      _last_rssi          = queued.rssi;
      _last_raw_timestamp = millis();
    }

    bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx,
                                          queued.has_raw_data ? queued.raw_data : nullptr,
                                          queued.has_raw_data ? queued.raw_len  : 0,
                                          queued.snr, queued.rssi);
    // No taskYIELD() on non-ESP32 platforms (non-FreeRTOS, cooperative scheduling not needed)

    // Live from prefs so `set mqtt.raw` applies without a bridge restart.
    bool raw_published = false;
    if (_obs->mqtt_raw_enabled) {
      raw_published = publishRaw(&queued.packet_copy);
    }

    bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
    const MQTTPacketQueuePolicy::RetryDecision retry =
        MQTTPacketQueuePolicy::retryDecision(
            any_published, queued.retry_attempts,
            static_cast<uint32_t>(now_ms));
    if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) {
      queued.retry_attempts = retry.retry_attempts;
      queued.next_retry_ms = retry.next_retry_ms;
      #ifdef MQTT_DIAG_VERBOSE
      if (now_ms - last_retry_schedule_log > 5000UL) {
        unsigned long age_ms = queued.timestamp > 0
            ? MQTTPacketQueuePolicy::elapsedMs(static_cast<uint32_t>(now_ms),
                                               static_cast<uint32_t>(queued.timestamp))
            : 0;
        MQTT_DEBUG_PRINTLN("Retry scheduled: attempt=%u/%u delay=%lu age=%lu q=%d pkt_type=%u packet_ok=%d raw_ok=%d",
          (unsigned)queued.retry_attempts, (unsigned)MQTTPacketQueuePolicy::kMaxQos0RetryAttempts,
          (unsigned long)retry.delay_ms, age_ms, _queue_count,
          (unsigned)queued.packet_copy.getPayloadType(), packet_published ? 1 : 0, raw_published ? 1 : 0);
        last_retry_schedule_log = now_ms;
      }
      #endif
      break;  // keep packet at head for delayed retry
    } else if (retry.action == MQTTPacketQueuePolicy::RetryAction::Drop) {
      // Intentional: QoS0 best-effort packets are dropped silently in normal
      // builds; detailed exhaustion logs are only emitted in verbose mode.
      #ifdef MQTT_DIAG_VERBOSE
      static unsigned long last_retry_drop_log = 0;
      if (now_ms - last_retry_drop_log > 60000UL) {
        unsigned long age_ms = queued.timestamp > 0
            ? MQTTPacketQueuePolicy::elapsedMs(static_cast<uint32_t>(now_ms),
                                               static_cast<uint32_t>(queued.timestamp))
            : 0;
        MQTT_DEBUG_PRINTLN("Packet dropped after retry exhaustion (attempts=%u age=%lu pkt_type=%u packet_ok=%d raw_ok=%d)",
          queued.retry_attempts, age_ms, (unsigned)queued.packet_copy.getPayloadType(),
          packet_published ? 1 : 0, raw_published ? 1 : 0);
        last_retry_drop_log = now_ms;
      }
      #endif
    }

    dequeuePacket();
    processed++;
  }
  #endif
}

// ---------------------------------------------------------------------------
// Publishing
// ---------------------------------------------------------------------------

bool MQTTBridge::publishStatus() {
  if (!_cached_has_connected_slots) {
    return false;
  }

  refreshOriginFromPrefs();

  // Reuse pre-allocated buffer to avoid heap alloc/free churn under memory pressure.
  // _status_json_buffer and _last_raw_data are both Core 0-owned; no mutex needed.
  #if defined(BOARD_HAS_PSRAM)
  char fallback_status_buffer[STATUS_JSON_BUFFER_SIZE];
  char* json_buffer = (_status_json_buffer != nullptr) ? _status_json_buffer : fallback_status_buffer;
  #else
  char* json_buffer = _status_json_buffer;
  #endif
  char origin_id[65];
  char timestamp[40];
  char radio_info[64];

  // Status timestamp: UTC with explicit +00:00 offset, same as packet/raw JSON
  // `timestamp` (system clock is UTC — SNTP offset 0; prefs Timezone is separate).
  struct timeval now_tv;
  gettimeofday(&now_tv, nullptr);
  MQTTMessageBuilder::formatIsoTimestampForMqtt(now_tv.tv_sec, now_tv.tv_usec, _timezone, timestamp, sizeof(timestamp));

  snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d",
           _prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr);

  strncpy(origin_id, _device_id, sizeof(origin_id) - 1);
  origin_id[sizeof(origin_id) - 1] = '\0';

  char client_version[64];
  getClientVersion(client_version, sizeof(client_version));

  // Collect stats on-demand if sources are available
  int battery_mv = -1;
  int uptime_secs = -1;
  int errors = -1;
  int noise_floor = -999;
  int tx_air_secs = -1;
  int rx_air_secs = -1;
  int recv_errors = -1;
  int packets_sent = -1;
  int packets_received = -1;

  if (_board) battery_mv = _board->getBattMilliVolts();
  if (_ms) uptime_secs = _ms->getMillis() / 1000;
  if (_dispatcher) {
    errors = _dispatcher->getErrFlags();
    tx_air_secs = _dispatcher->getTotalAirTime() / 1000;
    rx_air_secs = _dispatcher->getReceiveAirTime() / 1000;
    packets_sent = (int)(_dispatcher->getNumSentFlood() + _dispatcher->getNumSentDirect());
    packets_received = (int)(_dispatcher->getNumRecvFlood() + _dispatcher->getNumRecvDirect());
  }
  if (_radio) {
    noise_floor = (int16_t)_radio->getNoiseFloor();
    recv_errors = (int)_radio->getPacketsRecvErrors();
  }

  // Internal heap free (for diagnosing repeater hangs from internal heap exhaustion)
  int internal_heap_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL);

  int len = MQTTMessageBuilder::buildStatusMessage(
    _status_json_doc,
    _origin, origin_id, _board_model, _firmware_version, radio_info,
    client_version, "online", timestamp, json_buffer, STATUS_JSON_BUFFER_SIZE,
    battery_mv, uptime_secs, errors, _queue_count, noise_floor,
    tx_air_secs, rx_air_secs, recv_errors, internal_heap_free,
    packets_sent, packets_received,
    _prefs->disable_fwd ? "off" : "on"
  );

  if (len > 0) {
    bool published = false;
    bool any_slot_wants_status = false;
    char topic[128];
    for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
      if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
        if (buildTopicForSlot(i, MSG_STATUS, topic, sizeof(topic))) {
          any_slot_wants_status = true;
          bool use_retain = _slots[i].preset ? _slots[i].preset->allow_retain : false;
          if (publishToSlot(i, topic, json_buffer, use_retain, 1)) {
            published = true;
          }
        }
      }
    }
    // If no connected slot accepts status topics (e.g. meshrank is packets-only),
    // treat as success to avoid infinite retry loops
    if (published || !any_slot_wants_status) {
      if (published) MQTT_DEBUG_PRINTLN("Status published");
      return true;
    }
  }

  return false;
}

bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
                                const uint8_t* raw_data, int raw_len,
                                float snr, float rssi) {
  if (!packet) return false;

  refreshOriginFromPrefs();

  // Memory pressure check: Skip publishes when there's not enough contiguous
  // heap for the publish itself (JSON buffer + esp-mqtt outbox frame + WiFi TX
  // path). Headroom only — NOT an mbedTLS preflight: persistent clients keep
  // their TLS contexts allocated for the bridge lifetime, so the old ~52 KB
  // "reserve space for reconnect" guard is obsolete post Phase 1. Publish
  // payload is capped at PUBLISH_JSON_BUFFER_SIZE (2 KB); 8 KB is a safe
  // ceiling including esp-mqtt frame overhead and transient TCP buffers.
  #ifdef ESP32
  #if defined(BOARD_HAS_PSRAM)
  static const size_t PUBLISH_SKIP_MAX_ALLOC_THRESHOLD = 16000;
  #else
  static const size_t PUBLISH_SKIP_MAX_ALLOC_THRESHOLD = 8000;
  #endif
  unsigned long now = millis();
  if (now - _last_memory_check > 5000) {
    size_t max_alloc = ESP.getMaxAllocHeap();
    if (max_alloc < PUBLISH_SKIP_MAX_ALLOC_THRESHOLD) {
      _skipped_publishes++;
      static unsigned long last_skip_log = 0;
      if (now - last_skip_log > 60000) {
        MQTT_DEBUG_PRINTLN("MQTT: Skipping publish due to memory pressure (Max alloc: %d, threshold: %d, skipped: %d)",
                           max_alloc, (int)PUBLISH_SKIP_MAX_ALLOC_THRESHOLD, _skipped_publishes);
        last_skip_log = now;
      }
      return false;
    }
    _last_memory_check = now;
  }
  #endif

  // Use pre-allocated buffer; stack fallback only when PSRAM heap alloc may be null.
#if defined(BOARD_HAS_PSRAM)
  char json_buffer_stack[PUBLISH_JSON_BUFFER_SIZE];
  char* active_buffer;
  size_t active_buffer_size;
  if (_publish_json_buffer != nullptr) {
    active_buffer = _publish_json_buffer;
    active_buffer_size = PUBLISH_JSON_BUFFER_SIZE;
  } else {
    active_buffer = json_buffer_stack;
    active_buffer_size = PUBLISH_JSON_BUFFER_SIZE;
  }
#else
  char* active_buffer = _publish_json_buffer;
  const size_t active_buffer_size = PUBLISH_JSON_BUFFER_SIZE;
#endif
  char origin_id[65];

  strncpy(origin_id, _device_id, sizeof(origin_id) - 1);
  origin_id[sizeof(origin_id) - 1] = '\0';

  // Firmware rebroadcast "score" for this packet — only meaningful for RX packets
  // (depends on receive SNR). Recomputed here exactly as Dispatcher::checkRecv() does,
  // via the radio's packetScore(snr, len); NaN signals "omit" (tx, or no radio).
  // buildPacketMessage scales it x1000 to match the integer in the serial RX log.

  // Build packet message using raw radio data if provided
  int len;
  if (raw_data && raw_len > 0) {
    float score = (_radio && !is_tx) ? _radio->packetScore(snr, raw_len) : NAN;
    len = MQTTMessageBuilder::buildPacketJSONFromRaw(
      _packet_json_doc,
      raw_data, raw_len, packet, is_tx, _origin, origin_id,
      snr, rssi, score, _timezone, active_buffer, active_buffer_size
    );
  } else if (!is_tx && _last_raw_data && _last_raw_len > 0 && (millis() - _last_raw_timestamp) < 1000) {
    float score = _radio ? _radio->packetScore(_last_snr, _last_raw_len) : NAN;
    len = MQTTMessageBuilder::buildPacketJSONFromRaw(
      _packet_json_doc,
      _last_raw_data, _last_raw_len, packet, is_tx, _origin, origin_id,
      _last_snr, _last_rssi, score, _timezone, active_buffer, active_buffer_size
    );
  } else {
    // Reconstruct wire-format bytes from packet (same as MQTTMessageBuilder::packetToHex).
    // This path is used on non-PSRAM boards where raw_data is not stored in the queue,
    // and ensures the "raw" hex field and SNR/RSSI are accurate in the JSON output.
    uint8_t reconstructed[512];
    uint8_t rlen = packet->writeTo(reconstructed);
    if (rlen > 0) {
      float score = (_radio && !is_tx) ? _radio->packetScore(snr, rlen) : NAN;
      len = MQTTMessageBuilder::buildPacketJSONFromRaw(
        _packet_json_doc,
        reconstructed, rlen, packet, is_tx, _origin, origin_id,
        snr, rssi, score, _timezone, active_buffer, active_buffer_size
      );
    } else {
      len = MQTTMessageBuilder::buildPacketJSON(
        _packet_json_doc,
        packet, is_tx, _origin, origin_id, _timezone, active_buffer, active_buffer_size
      );
    }
  }

  if (len > 0) {
    bool published = false;
    char topic[128];
    for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
      if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
        if (buildTopicForSlot(i, MSG_PACKETS, topic, sizeof(topic))) {
          if (publishToSlot(i, topic, active_buffer, false)) {
            published = true;
          }
        }
      }
    }
    return published;
  } else {
    uint8_t packet_type = packet->getPayloadType();
    if (packet_type == 4 || packet_type == 9) {
      MQTT_DEBUG_PRINTLN("Failed to build packet JSON for type=%d (len=%d), packet not published", packet_type, len);
    }
  }
  return false;
}

bool MQTTBridge::publishRaw(mesh::Packet* packet) {
  if (!packet) return false;

  refreshOriginFromPrefs();

#if defined(BOARD_HAS_PSRAM)
  char json_buffer_stack[PUBLISH_JSON_BUFFER_SIZE];
  char* active_buffer;
  size_t active_buffer_size;
  if (_publish_json_buffer != nullptr) {
    active_buffer = _publish_json_buffer;
    active_buffer_size = PUBLISH_JSON_BUFFER_SIZE;
  } else {
    active_buffer = json_buffer_stack;
    active_buffer_size = PUBLISH_JSON_BUFFER_SIZE;
  }
#else
  char* active_buffer = _publish_json_buffer;
  const size_t active_buffer_size = PUBLISH_JSON_BUFFER_SIZE;
#endif
  char origin_id[65];

  strncpy(origin_id, _device_id, sizeof(origin_id) - 1);
  origin_id[sizeof(origin_id) - 1] = '\0';

  int len = MQTTMessageBuilder::buildRawJSON(
    packet, _origin, origin_id, _timezone, active_buffer, active_buffer_size
  );

  if (len > 0) {
    bool published = false;
    char topic[128];
    for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
      if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
        if (buildTopicForSlot(i, MSG_RAW, topic, sizeof(topic))) {
          if (publishToSlot(i, topic, active_buffer, false)) {
            published = true;
          }
        }
      }
    }
    return published;
  }
  return false;
}

#if defined(WITH_MQTT_NEIGHBORS)
// ---------------------------------------------------------------------------
// Periodic neighbors publication
// ---------------------------------------------------------------------------

void MQTTBridge::setNeighborsSchedule(NeighborsPhase phase, uint32_t secs_until_next) {
  _neighbors_phase.store((uint8_t)phase, std::memory_order_relaxed);
  _neighbors_secs_until_next.store(secs_until_next, std::memory_order_relaxed);
}

void MQTTBridge::requestPublishNeighbors(const char* json, size_t len) {
  if (!_neighbors_json_buffer || !json || len == 0) return;
  // Drop a new snapshot while one is still being published (Core 0 clears the
  // flag when done). Acquire pairs with the task loop's release store.
  if (_neighbors_publish_pending.load(std::memory_order_acquire)) return;
  if (len >= NEIGHBORS_JSON_BUFFER_SIZE) {
    len = NEIGHBORS_JSON_BUFFER_SIZE - 1;
  }
  memcpy(_neighbors_json_buffer, json, len);
  _neighbors_json_buffer[len] = '\0';
  _neighbors_publish_len = len;
  _neighbors_publish_pending.store(true, std::memory_order_release);
}

bool MQTTBridge::publishNeighbors() {
  if (!_neighbors_json_buffer || _neighbors_publish_len == 0) return false;
  if (!_cached_has_connected_slots) return false;

  refreshOriginFromPrefs();

  bool published = false;
  char topic[128];
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
      // MeshRank slots reject non-packets by contract, so buildTopicForSlot
      // returns false for them here and the slot is skipped.
      if (buildTopicForSlot(i, MSG_NEIGHBORS, topic, sizeof(topic))) {
        bool use_retain = _slots[i].preset ? _slots[i].preset->allow_retain : false;
        if (publishToSlot(i, topic, _neighbors_json_buffer, use_retain, 1)) {
          published = true;
        }
      }
    }
  }
  return published;
}
#endif  // WITH_MQTT_NEIGHBORS

// ---------------------------------------------------------------------------
// Queue management
// ---------------------------------------------------------------------------

void MQTTBridge::queuePacket(mesh::Packet* packet, bool is_tx) {
  #ifdef ESP_PLATFORM
  // Use FreeRTOS queue for thread-safe operation
  if (_packet_queue_handle == nullptr) {
    return;
  }

  QueuedPacket queued;
  memset(&queued, 0, sizeof(QueuedPacket));

  queued.packet_copy = *packet;  // full value copy — safe from Dispatcher free
  queued.timestamp = millis();
  queued.is_tx = is_tx;
  queued.snr = 0.0f;
  queued.rssi = 0.0f;

  // Consume staged raw data (written by storeRawRadioData() on Core 1, same call sequence).
  // No mutex needed — both sites run on Core 1 before xQueueSend() crosses the core boundary.
  if (!is_tx && _staged_raw_valid) {
    if (_staged_raw_len <= (int)sizeof(queued.raw_data)) {
      memcpy(queued.raw_data, _staged_raw, _staged_raw_len);
      queued.raw_len    = (uint8_t)_staged_raw_len;
      queued.has_raw_data = true;
    }
    queued.snr          = _staged_snr;
    queued.rssi         = _staged_rssi;
    _staged_raw_valid   = false;  // consumed; cleared before xQueueSend
  } else if (is_tx) {
    // For TX packets, snapshot the exact serialized wire bytes at enqueue time so
    // publishPacket() can use the direct raw-data path (not reconstruction fallback).
    uint8_t tx_len = packet->writeTo(queued.raw_data);
    if (tx_len > 0) {
      queued.raw_len = tx_len;
      queued.has_raw_data = true;
    }
  }

  // Try to send to queue (non-blocking)
  if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) {
    const MQTTPacketQueuePolicy::EnqueueAction action =
        MQTTPacketQueuePolicy::enqueueAction(
            static_cast<size_t>(uxQueueMessagesWaiting(_packet_queue_handle)),
            static_cast<size_t>(MAX_QUEUE_SIZE));
    QueuedPacket oldest;
    if (action == MQTTPacketQueuePolicy::EnqueueAction::EvictOldestThenEnqueue &&
        xQueueReceive(_packet_queue_handle, &oldest, 0) == pdTRUE) {
      MQTT_DEBUG_PRINTLN("Queue full, dropping oldest packet reference");
    } else if (action == MQTTPacketQueuePolicy::EnqueueAction::Reject) {
      MQTT_DEBUG_PRINTLN("Queue has no capacity");
      return;
    } else if (action == MQTTPacketQueuePolicy::EnqueueAction::EvictOldestThenEnqueue) {
      MQTT_DEBUG_PRINTLN("Queue full and cannot remove oldest packet");
      return;
    }
    // If the consumer made room after the failed send, retry without evicting.
    if (xQueueSend(_packet_queue_handle, &queued, 0) != pdTRUE) {
      MQTT_DEBUG_PRINTLN("Failed to queue packet after overflow handling");
      return;
    }
  }

  UBaseType_t queue_messages = uxQueueMessagesWaiting(_packet_queue_handle);
  _queue_count = queue_messages;
  #else
  // Non-ESP32: Use circular buffer
  const MQTTPacketQueuePolicy::EnqueueAction action =
      MQTTPacketQueuePolicy::enqueueAction(
          static_cast<size_t>(_queue_count), static_cast<size_t>(MAX_QUEUE_SIZE));
  if (action == MQTTPacketQueuePolicy::EnqueueAction::EvictOldestThenEnqueue) {
    QueuedPacket& oldest = _packet_queue[_queue_head];
    MQTT_DEBUG_PRINTLN("Queue full, dropping oldest packet (queue size: %d)", _queue_count);
    dequeuePacket();
  } else if (action == MQTTPacketQueuePolicy::EnqueueAction::Reject) {
    return;
  }

  QueuedPacket& queued = _packet_queue[_queue_tail];
  memset(&queued, 0, sizeof(QueuedPacket));

  queued.packet_copy = *packet;  // full value copy — safe from Dispatcher free
  queued.timestamp = millis();
  queued.is_tx = is_tx;
  queued.snr = 0.0f;
  queued.rssi = 0.0f;

  if (!is_tx && _staged_raw_valid) {
    if (_staged_raw_len <= (int)sizeof(queued.raw_data)) {
      memcpy(queued.raw_data, _staged_raw, _staged_raw_len);
      queued.raw_len    = (uint8_t)_staged_raw_len;
      queued.has_raw_data = true;
    }
    queued.snr          = _staged_snr;
    queued.rssi         = _staged_rssi;
    _staged_raw_valid   = false;
  } else if (is_tx) {
    // Mirror ESP32 path: persist serialized TX bytes directly in queue entry.
    uint8_t tx_len = packet->writeTo(queued.raw_data);
    if (tx_len > 0) {
      queued.raw_len = tx_len;
      queued.has_raw_data = true;
    }
  }

  _queue_tail = (_queue_tail + 1) % MAX_QUEUE_SIZE;
  _queue_count++;
  #endif
}

void MQTTBridge::dequeuePacket() {
  #ifdef ESP_PLATFORM
  // On ESP32, dequeuePacket() is not used - we use FreeRTOS queue operations directly
  return;
  #else
  if (_queue_count == 0) return;

  QueuedPacket& dequeued = _packet_queue[_queue_head];
  memset(&dequeued, 0, sizeof(QueuedPacket));
  dequeued.has_raw_data = false;

  _queue_head = (_queue_head + 1) % MAX_QUEUE_SIZE;
  _queue_count--;
  #endif
}

// ---------------------------------------------------------------------------
// Raw radio data storage
// ---------------------------------------------------------------------------

void MQTTBridge::storeRawRadioData(const uint8_t* raw_data, int len, float snr, float rssi) {
  // Writes into the Core 1-only staging area. No mutex needed: this function and
  // queuePacket() are both called from Core 1 in guaranteed sequence for each packet.
  if (len > 0 && len <= (int)LAST_RAW_DATA_SIZE) {
    memcpy(_staged_raw, raw_data, len);
    _staged_raw_len   = len;
    _staged_snr       = snr;
    _staged_rssi      = rssi;
    _staged_raw_valid = true;
    MQTT_DEBUG_PRINTLN("Stored raw radio data: %d bytes, SNR=%.1f, RSSI=%.1f", len, snr, rssi);
  }
}

// ---------------------------------------------------------------------------
// NTP time sync
// ---------------------------------------------------------------------------

void MQTTBridge::refreshNTP() {
  // Lightweight periodic refresh: just restart SNTP which runs async in the background.
  // No blocking DNS, no UDP sockets, no retry loops on the MQTT task loop.
  // The heavy syncTimeWithNTP() is only used for initial sync and WiFi reconnect recovery.
  configTime(0, 0, effectiveNtpPrimary(_obs));
  _last_ntp_sync = millis();
  MQTT_DEBUG_PRINTLN("NTP refresh triggered (async SNTP)");
}

bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
  if (!WiFi.isConnected()) {
    MQTT_DEBUG_PRINTLN("Cannot sync time - WiFi not connected");
    return false;
  }

  unsigned long now = millis();
  if (!force && _ntp_synced && (now - _last_ntp_sync) < 5000) {
    return false;
  }

  static bool sync_in_progress = false;
  if (sync_in_progress) {
    return false;
  }
  sync_in_progress = true;

  MQTT_DEBUG_PRINTLN("Syncing time with NTP...");

  const char* servers[kMaxNtpServers];
  int server_count = 0;
  if (primary_only) {
    // Validation path (e.g. set mqtt.ntp): test only the configured primary so a
    // typo fails fast instead of walking the entire fallback list.
    servers[0] = effectiveNtpPrimary(_obs);
    server_count = 1;
  } else {
    fillNtpServerList(_obs, servers, server_count);
  }

  bool ntp_ok = false;
  unsigned long epochTime = 0;
  const unsigned long kMinValidEpoch = 1767225600;  // 2026-01-01 00:00:00 UTC
  const char* ntp_server_used = nullptr;

  _ntp_client.begin();
  const int kMaxNtpRetriesPerServer = 2;
  for (int s = 0; s < server_count && !ntp_ok; s++) {
    const char* server = servers[s];
    _ntp_client.setPoolServerName(server);

    #ifdef ESP_PLATFORM
    IPAddress resolved_ip;
    if (!WiFi.hostByName(server, resolved_ip)) {
      MQTT_DEBUG_PRINTLN("WARNING: DNS resolution failed for %s - NTP sync may fail", server);
    }
    #endif

    for (int attempt = 1; attempt <= kMaxNtpRetriesPerServer && !ntp_ok; attempt++) {
      if (attempt > 1) {
        MQTT_DEBUG_PRINTLN("NTP retry %d/%d on %s...", attempt, kMaxNtpRetriesPerServer, server);
        delay(1000);
      }
      if (_ntp_client.forceUpdate()) {
        epochTime = _ntp_client.getEpochTime();
        if (epochTime >= kMinValidEpoch) {
          ntp_ok = true;
          ntp_server_used = server;
        }
      }
    }
  }
  _ntp_client.end();

  // Fallback: use ESP32 built-in SNTP (configTime) when NTPClient fails
  #ifdef ESP_PLATFORM
  if (!ntp_ok) {
    MQTT_DEBUG_PRINTLN("NTP client failed, trying SNTP fallback...");
    for (int s = 0; s < server_count && !ntp_ok; s++) {
      const char* server = servers[s];
      MQTT_DEBUG_PRINTLN("SNTP fallback trying %s...", server);
      configTime(0, 0, server);
      for (int i = 0; i < 20; i++) {
        delay(500);
        epochTime = (unsigned long)time(nullptr);
        if (epochTime >= kMinValidEpoch) {
          ntp_ok = true;
          ntp_server_used = server;
          MQTT_DEBUG_PRINTLN("SNTP fallback succeeded on %s: %lu", server, epochTime);
          break;
        }
      }
    }
  }
  #endif

  if (ntp_ok && ntp_server_used) {
    configTime(0, 0, ntp_server_used);

    if (_rtc) {
      _rtc->setCurrentTime(epochTime);
    }

    bool was_ntp_synced = _ntp_synced;
    _ntp_synced = true;
    _last_ntp_sync = millis();
    sync_in_progress = false;

    MQTT_DEBUG_PRINTLN("Time synced: %lu (via %s)", epochTime, ntp_server_used);

    // If slots are already set up and the time jumped significantly (e.g., SNTP
    // initially returned stale RTC time, then a later sync corrected it), tear down
    // and re-setup all JWT-authenticated slots so they get fresh tokens.
    if (_slots_setup_done && was_ntp_synced) {
      unsigned long current_time = (unsigned long)time(nullptr);
      for (int i = 0; i < _max_active_slots; i++) {
        bool slot_jwt = (_slots[i].preset && _slots[i].preset->auth_type == MQTT_AUTH_JWT) ||
                        (!_slots[i].preset && _slots[i].audience[0] != '\0');
        if (_slots[i].enabled && slot_jwt && _slots[i].client) {
          // Token created before NTP corrected the clock — refresh credentials
          // in place and reconnect the persistent client. No teardown needed.
          if (_slots[i].token_expires_at > 0 && current_time > _slots[i].token_expires_at) {
            MQTT_DEBUG_PRINTLN("MQTT%d token stale after time correction, re-creating", i + 1);
            if (createSlotAuthToken(i)) {
              _slots[i].client->setCredentials(_jwt_username, _slots[i].auth_token);
            }
            _slots[i].client->reconnect();
          }
        }
      }
    }

    // Set timezone from string (with DST support) — only if changed.
    // Reuses the inline _timezone_storage via setRules() instead of
    // deleting/newing a Timezone, which was a per-change heap alloc pair.
    static char last_timezone[64] = "";
    if (strcmp(_obs->timezone_string, last_timezone) != 0) {
      TimeChangeRule dst_rule, std_rule;
      if (!timezoneRulesFromString(_obs->timezone_string, dst_rule, std_rule)) {
        TimeChangeRule utc = {"UTC", Last, Sun, Mar, 0, 0};
        dst_rule = utc;
        std_rule = utc;
      }
      _timezone_storage.setRules(dst_rule, std_rule);
      strncpy(last_timezone, _obs->timezone_string, sizeof(last_timezone) - 1);
      last_timezone[sizeof(last_timezone) - 1] = '\0';
    }

    (void)gmtime((time_t*)&epochTime);
    (void)localtime((time_t*)&epochTime);
    return true;
  }

  MQTT_DEBUG_PRINTLN("NTP sync failed");
  sync_in_progress = false;
  return false;
}

bool MQTTBridge::requestForcedNtpSync(uint32_t timeout_ms) {
  if (!isRunning()) return false;

  // Publish the request to the MQTT task. Clear the completion flags before
  // raising _ntp_force_requested so the task can't observe a stale result.
  _ntp_force_done = false;
  _ntp_force_result = false;
  _ntp_force_requested = true;

  // Fire-and-forget: callers on the Arduino loop task (web config batch, and
  // the CLI which shares that task) must not block up to 30 s polling the MQTT
  // task — that stalls mesh/radio forwarding, portal DNS, and reboot timers.
  // The task still performs the sync; the result is observable via
  // `get mqtt.ntp.diag`. Blocking callers pass a non-zero timeout.
  if (timeout_ms == 0) return true;

  unsigned long start = millis();
  while (!_ntp_force_done) {
    if (millis() - start >= timeout_ms) {
      MQTT_DEBUG_PRINTLN("Forced NTP sync timed out waiting for MQTT task");
      return false;  // task still running; result is ignored when it eventually completes
    }
    vTaskDelay(pdMS_TO_TICKS(50));
  }
  return _ntp_force_result;
}

// Runs on the MQTT task (Core 0). Probes every configured NTP server for connectivity
// and records the time each reports. Deliberately does NOT call configTime() or update
// the RTC — this is a read-only diagnostic and must leave the system clock untouched.
void MQTTBridge::runNtpDiagProbe() {
  const char* servers[kMaxNtpServers];
  int count = 0;
  fillNtpServerList(_obs, servers, count);

  _ntp_client.begin();
  for (int i = 0; i < count; i++) {
    _ntp_client.setPoolServerName(servers[i]);
    bool ok = _ntp_client.forceUpdate();
    NtpDiagResult& r = _ntp_diag_results[i];
    strncpy(r.server, servers[i], sizeof(r.server) - 1);
    r.server[sizeof(r.server) - 1] = '\0';
    r.ok = ok;
    r.epoch = ok ? (uint32_t)_ntp_client.getEpochTime() : 0;
  }
  _ntp_diag_count = count;
}

bool MQTTBridge::ntpDiag(char* reply, size_t reply_size, bool verbose) {
  if (!isRunning() || reply == nullptr || reply_size == 0) return false;

  // Marshal the probe onto the MQTT task (Core 0); clear the completion flag first.
  _ntp_diag_done = false;
  _ntp_diag_requested = true;

  unsigned long start = millis();
  while (!_ntp_diag_done) {
    if (millis() - start >= 30000) {
      snprintf(reply, reply_size, "Error: NTP diag timed out");
      return true;
    }
    vTaskDelay(pdMS_TO_TICKS(50));
  }

  int ok_count = 0;
  for (int i = 0; i < _ntp_diag_count; i++) {
    if (_ntp_diag_results[i].ok) ok_count++;
  }

  if (verbose) {
    // Detailed table to the serial console; reply carries a short summary (the operator
    // sees the table above the "-> <reply>" line). Mirrors the dumpLogFile() convention.
    Serial.printf("NTP diag - %d server(s):\r\n", _ntp_diag_count);
    for (int i = 0; i < _ntp_diag_count; i++) {
      const NtpDiagResult& r = _ntp_diag_results[i];
      if (r.ok) {
        time_t t = (time_t)r.epoch;
        struct tm* tmv = gmtime(&t);
        Serial.printf("  %-20s OK    %04d-%02d-%02d %02d:%02d:%02d UTC\r\n",
                      r.server, tmv->tm_year + 1900, tmv->tm_mon + 1, tmv->tm_mday,
                      tmv->tm_hour, tmv->tm_min, tmv->tm_sec);
      } else {
        Serial.printf("  %-20s FAIL\r\n", r.server);
      }
    }
    snprintf(reply, reply_size, "> NTP diag: %d/%d OK (see console)", ok_count, _ntp_diag_count);
  } else {
    // Compact "<server> ok|fail" list for LoRa, bounded to reply_size.
    size_t used = 0;
    reply[0] = '\0';
    for (int i = 0; i < _ntp_diag_count; i++) {
      const NtpDiagResult& r = _ntp_diag_results[i];
      int n = snprintf(reply + used, reply_size - used, "%s%s %s",
                       used ? "\n" : "", r.server, r.ok ? "ok" : "fail");
      if (n < 0 || (size_t)n >= reply_size - used) {
        reply[used] = '\0';  // out of room — truncate cleanly
        break;
      }
      used += (size_t)n;
    }
  }
  return true;
}

// ---------------------------------------------------------------------------
// Timezone helper
// ---------------------------------------------------------------------------

// Populates dst_out and std_out with the DST/standard TimeChangeRules for the
// given timezone string. Returns true on match, false on unknown strings. Zero
// heap allocation — the caller then passes these into Timezone::setRules() on
// an existing Timezone object.
bool MQTTBridge::timezoneRulesFromString(const char* tz_string, TimeChangeRule& dst_out, TimeChangeRule& std_out) {
  // GCC refuses to implicitly build a TimeChangeRule temporary from a bare
  // braced-init-list on the right-hand side of operator= (the aggregate has a
  // char[6] member). Name the type explicitly so a proper temporary is formed.
  // North America
  if (strcmp(tz_string, "America/Los_Angeles") == 0 || strcmp(tz_string, "America/Vancouver") == 0) {
    std_out = TimeChangeRule{"PST", First, Sun, Nov, 2, -480};
    dst_out = TimeChangeRule{"PDT", Second, Sun, Mar, 2, -420};
    return true;
  } else if (strcmp(tz_string, "America/Denver") == 0) {
    std_out = TimeChangeRule{"MST", First, Sun, Nov, 2, -420};
    dst_out = TimeChangeRule{"MDT", Second, Sun, Mar, 2, -360};
    return true;
  } else if (strcmp(tz_string, "America/Chicago") == 0) {
    std_out = TimeChangeRule{"CST", First, Sun, Nov, 2, -360};
    dst_out = TimeChangeRule{"CDT", Second, Sun, Mar, 2, -300};
    return true;
  } else if (strcmp(tz_string, "America/New_York") == 0 || strcmp(tz_string, "America/Toronto") == 0) {
    std_out = TimeChangeRule{"EST", First, Sun, Nov, 2, -300};
    dst_out = TimeChangeRule{"EDT", Second, Sun, Mar, 2, -240};
    return true;
  } else if (strcmp(tz_string, "America/Anchorage") == 0) {
    std_out = TimeChangeRule{"AKST", First, Sun, Nov, 2, -540};
    dst_out = TimeChangeRule{"AKDT", Second, Sun, Mar, 2, -480};
    return true;
  } else if (strcmp(tz_string, "Pacific/Honolulu") == 0) {
    TimeChangeRule hst = {"HST", Last, Sun, Oct, 2, -600};
    dst_out = hst; std_out = hst;
    return true;

  // Europe
  } else if (strcmp(tz_string, "Europe/London") == 0) {
    std_out = TimeChangeRule{"GMT", Last, Sun, Oct, 2, 0};
    dst_out = TimeChangeRule{"BST", Last, Sun, Mar, 1, 60};
    return true;
  } else if (strcmp(tz_string, "Europe/Paris") == 0 || strcmp(tz_string, "Europe/Berlin") == 0) {
    std_out = TimeChangeRule{"CET", Last, Sun, Oct, 3, 60};
    dst_out = TimeChangeRule{"CEST", Last, Sun, Mar, 2, 120};
    return true;
  } else if (strcmp(tz_string, "Europe/Moscow") == 0) {
    TimeChangeRule msk = {"MSK", Last, Sun, Oct, 3, 180};
    dst_out = msk; std_out = msk;
    return true;

  // Asia
  } else if (strcmp(tz_string, "Asia/Tokyo") == 0) {
    TimeChangeRule jst = {"JST", Last, Sun, Oct, 2, 540};
    dst_out = jst; std_out = jst;
    return true;
  } else if (strcmp(tz_string, "Asia/Shanghai") == 0 || strcmp(tz_string, "Asia/Hong_Kong") == 0) {
    TimeChangeRule cst = {"CST", Last, Sun, Oct, 2, 480};
    dst_out = cst; std_out = cst;
    return true;
  } else if (strcmp(tz_string, "Asia/Kolkata") == 0) {
    TimeChangeRule ist = {"IST", Last, Sun, Oct, 2, 330};
    dst_out = ist; std_out = ist;
    return true;
  } else if (strcmp(tz_string, "Asia/Dubai") == 0) {
    TimeChangeRule gst = {"GST", Last, Sun, Oct, 2, 240};
    dst_out = gst; std_out = gst;
    return true;

  // Australia
  } else if (strcmp(tz_string, "Australia/Sydney") == 0 || strcmp(tz_string, "Australia/Melbourne") == 0) {
    std_out = TimeChangeRule{"AEST", First, Sun, Apr, 3, 600};
    dst_out = TimeChangeRule{"AEDT", First, Sun, Oct, 2, 660};
    return true;
  } else if (strcmp(tz_string, "Australia/Perth") == 0) {
    TimeChangeRule awst = {"AWST", Last, Sun, Oct, 2, 480};
    dst_out = awst; std_out = awst;
    return true;

  // Timezone abbreviations (with DST handling)
  } else if (strcmp(tz_string, "PDT") == 0 || strcmp(tz_string, "PST") == 0) {
    std_out = TimeChangeRule{"PST", First, Sun, Nov, 2, -480};
    dst_out = TimeChangeRule{"PDT", Second, Sun, Mar, 2, -420};
    return true;
  } else if (strcmp(tz_string, "MDT") == 0 || strcmp(tz_string, "MST") == 0) {
    std_out = TimeChangeRule{"MST", First, Sun, Nov, 2, -420};
    dst_out = TimeChangeRule{"MDT", Second, Sun, Mar, 2, -360};
    return true;
  } else if (strcmp(tz_string, "CDT") == 0 || strcmp(tz_string, "CST") == 0) {
    std_out = TimeChangeRule{"CST", First, Sun, Nov, 2, -360};
    dst_out = TimeChangeRule{"CDT", Second, Sun, Mar, 2, -300};
    return true;
  } else if (strcmp(tz_string, "EDT") == 0 || strcmp(tz_string, "EST") == 0) {
    std_out = TimeChangeRule{"EST", First, Sun, Nov, 2, -300};
    dst_out = TimeChangeRule{"EDT", Second, Sun, Mar, 2, -240};
    return true;
  } else if (strcmp(tz_string, "BST") == 0 || strcmp(tz_string, "GMT") == 0) {
    std_out = TimeChangeRule{"GMT", Last, Sun, Oct, 2, 0};
    dst_out = TimeChangeRule{"BST", Last, Sun, Mar, 1, 60};
    return true;
  } else if (strcmp(tz_string, "CEST") == 0 || strcmp(tz_string, "CET") == 0) {
    std_out = TimeChangeRule{"CET", Last, Sun, Oct, 3, 60};
    dst_out = TimeChangeRule{"CEST", Last, Sun, Mar, 2, 120};
    return true;

  // UTC and simple offsets
  } else if (strcmp(tz_string, "UTC") == 0) {
    TimeChangeRule utc = {"UTC", Last, Sun, Mar, 0, 0};
    dst_out = utc; std_out = utc;
    return true;
  } else if (strncmp(tz_string, "UTC", 3) == 0) {
    int offset = atoi(tz_string + 3);
    TimeChangeRule utc_offset = {"UTC", Last, Sun, Mar, 0, offset * 60};
    dst_out = utc_offset; std_out = utc_offset;
    return true;
  } else if (strncmp(tz_string, "GMT", 3) == 0) {
    int offset = atoi(tz_string + 3);
    TimeChangeRule gmt_offset = {"GMT", Last, Sun, Mar, 0, offset * 60};
    dst_out = gmt_offset; std_out = gmt_offset;
    return true;
  } else if (tz_string[0] == '+' || tz_string[0] == '-') {
    int offset = atoi(tz_string);
    TimeChangeRule offset_tz = {"TZ", Last, Sun, Mar, 0, offset * 60};
    dst_out = offset_tz; std_out = offset_tz;
    return true;
  } else {
    MQTT_DEBUG_PRINTLN("Unknown timezone: %s", tz_string);
    return false;
  }
}

// ---------------------------------------------------------------------------
// Utility methods
// ---------------------------------------------------------------------------

void MQTTBridge::getClientVersion(char* buffer, size_t buffer_size) const {
  if (!buffer || buffer_size == 0) {
    return;
  }
  snprintf(buffer, buffer_size, "meshcore/%s", _firmware_version);
}

void MQTTBridge::optimizeMqttClientConfig(PsychicMqttClient* client, bool needs_large_buffer) {
  if (!client) return;

  // Cloudflare closes WebSocket connections after 100s idle (non-configurable).
#if defined(BOARD_HAS_PSRAM)
  client->setKeepAlive(45);
#else
  // Non-PSRAM: use a longer keepalive to reduce TLS teardown/reconnect churn.
  // 75s is safe behind Cloudflare (100s idle timeout, 25s margin).
  client->setKeepAlive(75);
#endif

  // QoS 1 retransmit timeout for unacked PUBLISHes (status messages). esp-mqtt's
  // 1000 ms default resends a byte-identical duplicate every second whenever the
  // broker's PUBACK takes >1s — on a congested or recovering uplink this floods
  // subscribers with exact copies of one /status message (observed 6 copies ~1s
  // apart after an ISP outage; brokers may drop the session as spam). 15s allows
  // one retry before the outbox entry expires (esp-mqtt outbox expiry is 30s),
  // preserving at-least-once delivery while capping duplicates at one.
  client->setMessageRetransmitTimeout(15000);

  // Buffer sizing: 896 is the minimum safe size for JWT clients (CONNECT + 768-byte JWT).
  // On PSRAM boards, use a uniform size to reduce fragmentation from mixed allocations.
  // On non-PSRAM boards, use smaller buffers for non-JWT slots to reduce heap usage and
  // leave smaller holes during teardown/recreate cycles.
#if defined(BOARD_HAS_PSRAM)
  static const int MQTT_CLIENT_BUFFER_SIZE = 896;
#else
  const int MQTT_CLIENT_BUFFER_SIZE = needs_large_buffer ? 896 : 512;
#endif

  client->setBufferSize(MQTT_CLIENT_BUFFER_SIZE);

  // Bound how long a synchronous QoS0 publish (see publishToSlot) can block the MQTT
  // task on a stalled/half-open socket before esp-mqtt aborts the write. Default is 10s;
  // 2.5s lets a first stall resolve fast (write fails → slot flips to disconnected →
  // subsequent packets skip it) without holding up publishing to the other slots. Mesh
  // RX (Core 1) and the WiFi/TCP stack are unaffected by this block regardless.
  client->setNetworkTimeout(2500);

  // Dormant safety net: cap the esp-mqtt outbox for any residual async QoS0 path. QoS0
  // packets now publish synchronously (store=false, no outbox), so this normally never
  // engages, but it bounds internal-heap growth if a QoS0 message ever takes the async
  // path. Non-PSRAM (outbox on internal heap) gets the tighter cap.
#if defined(BOARD_HAS_PSRAM)
  client->setOutboxLimit(16384);
#else
  client->setOutboxLimit(8192);
#endif

  // Access ESP-IDF config to optimize additional settings
  esp_mqtt_client_config_t* config = client->getMqttConfig();
  if (config) {
    #if defined(ESP_IDF_VERSION_MAJOR) && ESP_IDF_VERSION_MAJOR >= 5
      // Keep the output buffer (used to build the CONNECT/PUBLISH frames) in lockstep
      // with the input buffer. setBufferSize() above only sets buffer.size, so out_size
      // must be set here. The previous conditional only ever shrank out_size or set it
      // from 0 — when a slot was reconfigured from a small non-JWT buffer (512) up to
      // the JWT buffer (896), out_size stayed at 512 and the JWT CONNECT frame (username
      // + ~537-768B token) overflowed it, producing esp-mqtt "Connect message cannot be
      // created". Always matching MQTT_CLIENT_BUFFER_SIZE fixes the grow case.
      config->buffer.out_size = MQTT_CLIENT_BUFFER_SIZE;
    #endif
  }
}

void MQTTBridge::logMemoryStatus() {
  // QoS0 packets now publish synchronously, so the outbox stays ~0 and is only a sanity
  // check (a non-zero total would mean the QoS1 status path is backing up or the dormant
  // async cap engaged). The live signal is per-slot publish health: ok = cumulative
  // accepted writes, err = cumulative failures (socket error / network_timeout on a
  // stalled link). A rising err on a slot means that broker's uplink is dropping packets;
  // ok climbing with err flat is healthy delivery.
  char pub_detail[200];
  size_t pos = 0;
  size_t outbox_total = 0;
  pub_detail[0] = '\0';
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].client) {
      outbox_total += _slots[i].client->getOutboxSize();
      if (_slots[i].enabled) {
        pos += snprintf(pub_detail + pos, sizeof(pub_detail) - pos, "%ss%d=%lu/%lu",
                        pos ? " " : "", i + 1,
                        _slots[i].client->getPublishOk(),
                        _slots[i].client->getPublishErr());
        if (pos >= sizeof(pub_detail)) break;
      }
    }
  }
  MQTT_DEBUG_PRINTLN("Memory: Free=%d, Max=%d, Queue=%d/%d, Outbox=%u | pub(ok/err) %s",
                     ESP.getFreeHeap(), ESP.getMaxAllocHeap(), _queue_count, MAX_QUEUE_SIZE,
                     (unsigned)outbox_total, pub_detail);
}

// ---------------------------------------------------------------------------
// Setters and accessors
// ---------------------------------------------------------------------------

void MQTTBridge::setOrigin(const char* origin) {
  strncpy(_origin, origin, sizeof(_origin) - 1);
  _origin[sizeof(_origin) - 1] = '\0';
}

void MQTTBridge::setIATA(const char* iata) {
  strncpy(_iata, iata, sizeof(_iata) - 1);
  _iata[sizeof(_iata) - 1] = '\0';
  for (int i = 0; _iata[i]; i++) {
    _iata[i] = toupper(_iata[i]);
  }
}

void MQTTBridge::setDeviceID(const char* device_id) {
  strncpy(_device_id, device_id, sizeof(_device_id) - 1);
  _device_id[sizeof(_device_id) - 1] = '\0';
  MQTT_DEBUG_PRINTLN("Device ID set to: %s", _device_id);
}

void MQTTBridge::setFirmwareVersion(const char* firmware_version) {
  strncpy(_firmware_version, firmware_version, sizeof(_firmware_version) - 1);
  _firmware_version[sizeof(_firmware_version) - 1] = '\0';
}

void MQTTBridge::setBoardModel(const char* board_model) {
  strncpy(_board_model, board_model, sizeof(_board_model) - 1);
  _board_model[sizeof(_board_model) - 1] = '\0';
}

void MQTTBridge::setBuildDate(const char* build_date) {
  strncpy(_build_date, build_date, sizeof(_build_date) - 1);
  _build_date[sizeof(_build_date) - 1] = '\0';
}

void MQTTBridge::setMessageTypes(bool status, bool packets, bool raw) {
  _status_enabled = status;
  _packets_enabled = packets;
  _raw_enabled = raw;
}

int MQTTBridge::getConnectedBrokers() const {
  int count = 0;
  for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
    if (_slots[i].enabled && _slots[i].connected) {
      count++;
    }
  }
  return count;
}

int MQTTBridge::getQueueSize() const {
  #ifdef ESP_PLATFORM
  if (_packet_queue_handle != nullptr) {
    return uxQueueMessagesWaiting(_packet_queue_handle);
  }
  return 0;
  #else
  return _queue_count;
  #endif
}

void MQTTBridge::setStatsSources(mesh::Dispatcher* dispatcher, mesh::Radio* radio,
                                  mesh::MainBoard* board, mesh::MillisecondClock* ms) {
  _dispatcher = dispatcher;
  _radio = radio;
  _board = board;
  _ms = ms;
}

#endif
