// =============================================================================
// rsDeck — Main Entry Point
// LilyGo T-Deck Plus: LovyanGFX Direct UI + microReticulum + LXMF Messaging
// =============================================================================

#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <esp_netif.h>
#include <lvgl.h>

#include "config/BoardConfig.h"
#include "config/Config.h"
#include "platform/RsDeckModeSwitch.h"
#include "hal/Display.h"
#include "hal/TouchInput.h"
#include "hal/Trackball.h"
#include "hal/Keyboard.h"
#include "hal/Power.h"
#if HAS_GPS
#include "hal/GPSManager.h"
#endif
#include "radio/SX1262.h"
#include "input/InputManager.h"
#include "input/HotkeyManager.h"
#include "ui/UIManager.h"
#include "ui/Theme.h"
#include "ui/LvTabBar.h"
#include "ui/LvInput.h"
#include "ui/screens/LvBootScreen.h"
#include "ui/screens/LvHomeScreen.h"
#include "ui/screens/LvNodesScreen.h"
#include "ui/screens/LvMessagesScreen.h"
#include "ui/screens/LvMessageView.h"
#include "ui/screens/LvContactsScreen.h"
#include "ui/screens/LvSettingsScreen.h"
#include "ui/screens/LvHelpOverlay.h"
#include "ui/screens/LvQrOverlay.h"
// Map screen removed
#include "ui/screens/LvNameInputScreen.h"
#include "ui/screens/LvTimezoneScreen.h"
#include "ui/screens/LvDataCleanScreen.h"
#include "storage/FlashStore.h"
#include "storage/SDStore.h"
#include "storage/MessageStore.h"
#include "reticulum/ReticulumManager.h"
#include "reticulum/AnnounceManager.h"
#include "reticulum/LXMFManager.h"
#include "reticulum/IdentityManager.h"
#include "transport/LoRaInterface.h"
#include "transport/WiFiInterface.h"
#include <WiFiMulti.h>
#include "transport/TCPClientInterface.h"
#include "transport/AutoInterfaceWrapper.h"
#if HAS_BLE
#include "transport/BLEInterface.h"
#include "transport/BLESideband.h"
#endif
#include "config/UserConfig.h"
#include "audio/AudioNotify.h"
#include <ArduinoJson.h>
#include <Preferences.h>
#include <atomic>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <list>
#include <string>
#include <esp_system.h>
#include <esp_heap_caps.h>
#include <freertos/task.h>

// --- Hardware ---
// Single shared SPI bus for display, LoRa, and SD card
// IMPORTANT: On ESP32-S3, Arduino FSPI=0 maps to SPI2 hardware.
// Do NOT use SPI2_HOST (IDF constant = 1) — Arduino treats index 1 as HSPI/SPI3!
SPIClass sharedSPI(FSPI);

SX1262 radio(&sharedSPI,
    LORA_CS, SPI_SCK, SPI_MOSI, SPI_MISO,
    LORA_RST, LORA_IRQ, LORA_BUSY, LORA_RXEN,
    LORA_HAS_TCXO, LORA_DIO2_AS_RF_SWITCH);

Display display;
TouchInput touch;
Trackball trackball;
Keyboard keyboard;

// --- Subsystems ---
InputManager inputManager;
HotkeyManager hotkeys;
UIManager ui;
FlashStore flash;
SDStore sdStore;
MessageStore messageStore;
ReticulumManager rns;
AnnounceManager* announceManager = nullptr;
RNS::HAnnounceHandler announceHandler;
LXMFManager lxmf;
WiFiInterface* wifiImpl = nullptr;
RNS::Interface wifiIface({RNS::Type::NONE});
std::vector<TCPClientInterface*> tcpClients;
std::list<RNS::Interface> tcpIfaces;  // Must persist — Transport stores references (list: no realloc)
std::list<TCPClientInterface*> retiredTcpClients;
bool tcpReloadRequested = false;
#if HAS_BLE
BLEInterface bleInterface;
BLESideband bleSideband;
#endif
UserConfig userConfig;
Power powerMgr;
AudioNotify audio;
IdentityManager identityMgr;
#if HAS_GPS
GPSManager gps;
#endif

// --- LVGL Screens ---
LvBootScreen lvBootScreen;
LvHomeScreen lvHomeScreen;
LvNodesScreen lvNodesScreen;
LvMessagesScreen lvMessagesScreen;
LvContactsScreen lvContactsScreen;
LvMessageView lvMessageView;
LvSettingsScreen lvSettingsScreen;
LvHelpOverlay lvHelpOverlay;
LvQrOverlay lvQrOverlay;
// LvMapScreen removed
LvNameInputScreen lvNameInputScreen;
LvTimezoneScreen lvTimezoneScreen;
LvDataCleanScreen lvDataCleanScreen;

// Tab-screen mapping (4 tabs) — LVGL versions
LvScreen* lvTabScreens[LvTabBar::TAB_COUNT] = {};

// --- State ---
bool radioOnline = false;
bool bootComplete = false;
bool bootLoopRecovery = false;
bool sdHadExistingData = false;
bool wifiSTAStarted = false;
WiFiMulti wifiMulti;
bool wifiSTAConnected = false;
unsigned long lastAutoAnnounce = 0;
bool bootAnnouncePending = false;
uint8_t bootAnnounceAttempts = 0;
unsigned long bootAnnounceAt = 0;
constexpr unsigned long BOOT_ANNOUNCE_DELAY_MS = 5000;
constexpr uint8_t BOOT_ANNOUNCE_MAX_ATTEMPTS = 3;

static void applyRadioSettingsToHardware(const UserSettings& s, const char* context) {
    if (!radioOnline) return;

    if (!s.loraEnabled) {
        radio.sleep();
        Serial.printf("[%s] LoRa disabled by config\n", context);
        return;
    }

    radio.setFrequency(s.loraFrequency);
    radio.setSpreadingFactor(s.loraSF);
    radio.setSignalBandwidth(s.loraBW);
    radio.setCodingRate4(s.loraCR);
    radio.setTxPower(s.loraTxPower);
    radio.setPreambleLength(s.loraPreamble);
    radio.receive();
    Serial.printf("[%s] Radio: %lu Hz, SF%d, BW%lu, CR4/%d, %d dBm, pre=%ld\n",
                  context,
                  (unsigned long)s.loraFrequency, s.loraSF,
                  (unsigned long)s.loraBW, s.loraCR, s.loraTxPower,
                  s.loraPreamble);
}

// STA reconnects are scheduled from WiFi events and fired from loop().
std::atomic<bool> wifiNeedsReconnect{false};
std::atomic<unsigned long> wifiReconnectAt{0};
std::atomic<uint8_t> wifiReconnectAttempt{0};
constexpr unsigned long WIFI_BACKOFF_MS[4] = {5000, 15000, 60000, 300000};
constexpr unsigned long WIFI_NETIF_SETTLE_MS = 1500;

static void scheduleWiFiReconnect() {
    uint8_t attempt = wifiReconnectAttempt.load();
    uint8_t idx = attempt < 4 ? attempt : 3;
    unsigned long backoff = WIFI_BACKOFF_MS[idx];
    if (backoff < WIFI_NETIF_SETTLE_MS) backoff = WIFI_NETIF_SETTLE_MS;
    wifiReconnectAt.store(millis() + backoff);
    wifiNeedsReconnect.store(true);
    if (attempt < 4) wifiReconnectAttempt.store(attempt + 1);
}

static void onWiFiEvent(WiFiEvent_t event) {
    switch (event) {
        case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
            // Our own disconnect() below can emit another disconnect event.
            if (wifiNeedsReconnect.load()) break;
            scheduleWiFiReconnect();
            // Drop the netif and clear stale AP info before the next connect.
            WiFi.disconnect(false, true);
            break;
        case ARDUINO_EVENT_WIFI_STA_GOT_IP:
        case ARDUINO_EVENT_WIFI_STA_GOT_IP6:
            wifiNeedsReconnect.store(false);
            wifiReconnectAttempt.store(0);
            break;
        default:
            break;
    }
}

unsigned long lastStatusUpdate = 0;
constexpr unsigned long STATUS_UPDATE_MS = 1000;                // 1 Hz status bar update
unsigned long lastHeartbeat = 0;
constexpr unsigned long HEARTBEAT_INTERVAL_MS = 5000;
unsigned long loopCycleStart = 0;
unsigned long maxLoopTime = 0;
unsigned long lastLvglTime = 0;
constexpr unsigned long LVGL_INTERVAL_MS = 33;          // ~30 FPS
constexpr unsigned long TCP_GLOBAL_BUDGET_MS = 35;      // Max cumulative TCP time per loop

AutoInterfaceWrapper autoIface;
bool autoIfaceDeferredStart = false;
unsigned long autoIfaceDeferredAt = 0;
unsigned long lastAutoIfaceLinkCheck = 0;

// LXMF diagnostic counters (reset each heartbeat)
static uint32_t diagTcpSkipEvents = 0;

// =============================================================================
// Timezone helper — returns POSIX TZ string for current config
// =============================================================================

static const char* currentPosixTZ() {
    uint8_t idx = userConfig.settings().timezoneIdx;
    if (idx < TIMEZONE_COUNT) return TIMEZONE_TABLE[idx].posixTZ;
    return "EST5EDT,M3.2.0,M11.1.0";  // Fallback
}

// =============================================================================
// Announce with display name (MessagePack-encoded app_data)
// =============================================================================

// LXMF announce app_data:
//   [display_name(bin), stamp_cost(nil|uint), supported_functionality(array)]
// Always emit fixarray(3) so Python LXMF doesn't default auto_compress=True for
// our destinations. stamp_cost=nil means no inbound stamp is required. Empty
// supported_functionality list = we do NOT support SF_COMPRESSION (bz2).
RNS::Bytes encodeAnnounceName(const String& name) {
    size_t nameLen = name.length();
    if (nameLen > 31) nameLen = 31;
    uint8_t buf[5 + 31];
    size_t i = 0;
    buf[i++] = 0x93;                   // fixarray(3)
    buf[i++] = 0xC4;                   // bin 8
    buf[i++] = (uint8_t)nameLen;
    if (nameLen) { memcpy(buf + i, name.c_str(), nameLen); i += nameLen; }
    buf[i++] = 0xC0;                   // stamp_cost = nil (no stamp required)
    buf[i++] = 0x90;                   // empty fixarray (no SF_* supported)
    return RNS::Bytes(buf, i);
}

static bool hasUsableAnnounceTransport() {
    if (!rns.isTransportActive()) return false;
    auto* loraIf = rns.loraInterface();
    if (radioOnline && loraIf && loraIf->isOnline()) return true;
    if (wifiImpl && wifiImpl->isAPActive() && wifiImpl->getClientCount() > 0) return true;
    for (auto* tcp : tcpClients) {
        if (tcp && tcp->isConnected()) return true;
    }
#if HAS_BLE
    if (bleInterface.isClientConnected()) return true;
#endif
    if (autoIface.isOnline() && autoIface.peerCount() > 0) return true;
    return false;
}

static bool announceWithName(bool silent = false) {
    if (!hasUsableAnnounceTransport()) {
        if (!silent) ui.lvStatusBar().showToast("No active transport", 1500);
        Serial.println("[ANNOUNCE-TX] skipped: no active transport");
        return false;
    }
    RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName);
    Serial.printf("[ANNOUNCE-TX] name=\"%s\" appData=%d bytes silent=%s\n",
        userConfig.settings().displayName.c_str(), (int)appData.size(),
        silent ? "yes" : "no");
    rns.announce(appData);
    if (!silent) {
        ui.lvStatusBar().flashAnnounce();
        ui.lvStatusBar().showToast("Announce sent!");
    }
    return true;
}

static void manualAnnounce() {
    if (announceWithName()) Serial.println("[ANNOUNCE] Manual announce sent");
}

// =============================================================================
// TCP client management — stop old clients, create new from config
// =============================================================================

static void drainRetiredTCPClients() {
    for (auto it = retiredTcpClients.begin(); it != retiredTcpClients.end(); ) {
        TCPClientInterface* tcp = *it;
        if (!tcp || tcp->canDestroy()) {
            if (tcp) delete tcp;
            it = retiredTcpClients.erase(it);
        } else {
            ++it;
        }
    }
}

static void retireTCPClient(TCPClientInterface* tcp) {
    if (!tcp) return;
    tcp->stop();
    if (tcp->canDestroy()) {
        delete tcp;
    } else {
        retiredTcpClients.push_back(tcp);
    }
}

static void reloadTCPClients() {
    // Stop and deregister existing clients
    for (auto& iface : tcpIfaces) {
        RNS::Transport::deregister_interface(iface);
    }
    for (auto* tcp : tcpClients) {
        retireTCPClient(tcp);
    }
    tcpClients.clear();
    tcpIfaces.clear();
    drainRetiredTCPClients();

    // Create new clients from current config
    if (WiFi.status() == WL_CONNECTED) {
        for (auto& ep : userConfig.settings().tcpConnections) {
            if (ep.autoConnect && !ep.host.isEmpty()) {
                char name[32];
                snprintf(name, sizeof(name), "TCP.%s", ep.host.c_str());
                auto* tcp = new TCPClientInterface(ep.host.c_str(), ep.port, name);
                tcpIfaces.emplace_back(tcp);
                tcpIfaces.back().mode(RNS::Type::Interface::MODE_FULL);
                RNS::Transport::register_interface(tcpIfaces.back());
                tcp->start();
                tcpClients.push_back(tcp);
                Serial.printf("[TCP] Created client: %s:%d (registered with Transport, mode=FULL)\n", ep.host.c_str(), ep.port);
                Serial.printf("[TCP] Total interfaces registered: %d\n", (int)RNS::Transport::get_interfaces().size());
            }
        }
    }

    if (tcpClients.empty()) {
        Serial.println("[TCP] No active TCP connections");
    }
}

static void requestTCPClientsReload() {
    tcpReloadRequested = true;
}

// =============================================================================
// Hotkey callbacks
// =============================================================================

void onHotkeyHelp() {
    lvHelpOverlay.toggle();
}
void onHotkeyMessages() {
    ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
    ui.setScreen(&lvMessagesScreen);
}
void onHotkeyNewMsg() {
    bool hasContacts = false;
    if (announceManager) {
        for (const auto& node : announceManager->nodes()) {
            if (node.saved) { hasContacts = true; break; }
        }
    }
    if (hasContacts) {
        ui.lvTabBar().setActiveTab(LvTabBar::TAB_CONTACTS);
        ui.setScreen(&lvContactsScreen);
    } else {
        ui.lvTabBar().setActiveTab(LvTabBar::TAB_NODES);
        ui.setScreen(&lvNodesScreen);
        ui.lvStatusBar().showToast("Pick a peer to message", 1200);
    }
}
void onHotkeySettings() {
    ui.lvTabBar().setActiveTab(LvTabBar::TAB_SETTINGS);
    ui.setScreen(&lvSettingsScreen);
}
void onHotkeyAnnounce() {
    manualAnnounce();
}
void onHotkeyAutoIface() {
    Serial.println("=== AUTOIFACE DUMP ===");
    Serial.printf("Enabled in settings : %s\n",
        userConfig.settings().autoIfaceEnabled ? "YES" : "no");
    Serial.printf("Online              : %s\n", autoIface.isOnline() ? "YES" : "no");
    if (autoIface.isOnline()) {
        Serial.printf("Multicast address   : %s\n", autoIface.multicastAddress().c_str());
        Serial.printf("Link-local          : %s\n", WiFi.localIPv6().toString().c_str());
        Serial.printf("Peers               : %u\n", (unsigned)autoIface.peerCount());
    }
    Serial.printf("Deferred-start armed: %s (elapsed=%lums)\n",
        autoIfaceDeferredStart ? "YES" : "no",
        autoIfaceDeferredStart ? (millis() - autoIfaceDeferredAt) : 0UL);
    Serial.println("======================");
}
void onHotkeyDiag() {
    Serial.println("=== DIAGNOSTIC DUMP ===");
    Serial.printf("Device: rsDeck T-Deck Plus\n");
    Serial.printf("Identity: %s\n", rns.identityHash().c_str());
    Serial.printf("Transport: %s\n", rns.isTransportActive() ? "ACTIVE" : "OFFLINE");
    Serial.printf("Paths: %d  Links: %d\n", (int)rns.pathCount(), (int)rns.linkCount());
    Serial.printf("Radio: %s\n", radioOnline ? "ONLINE" : "OFFLINE");
    if (radioOnline) {
        Serial.printf("Freq: %lu Hz  SF: %d  BW: %lu  CR: 4/%d  TXP: %d dBm\n",
                      (unsigned long)radio.getFrequency(),
                      radio.getSpreadingFactor(),
                      (unsigned long)radio.getSignalBandwidth(),
                      radio.getCodingRate4(),
                      radio.getTxPower());
        Serial.printf("Regulator: %s\n", LORA_USE_DCDC_REGULATOR ? "DC-DC" : "LDO");
        Serial.printf("Preamble: %ld symbols\n", radio.getPreambleLength());
        Serial.printf("Bitrate: %lu bps  LDRO: %s  frame255: %.0f ms\n",
                      (unsigned long)radio.getBitrate(),
                      radio.lowDataRateEnabled() ? "ON" : "off",
                      radio.getAirtime(MAX_PACKET_SIZE));
        if (auto* loraIf = rns.loraInterface()) {
            Serial.printf("LoRaIF: bitrate=%lu bps split_timeout=%lu ms frame=%.0f ms airtime=%.2f%%\n",
                          (unsigned long)loraIf->bitrate(),
                          loraIf->splitRxTimeoutMs(),
                          loraIf->singleFrameAirtimeMs(),
                          loraIf->airtimeUtilization() * 100.0f);
        }
        Serial.printf("IQ invert: %s\n", radio.getInvertIQ() ? "ON" : "off");
        Serial.printf("SyncWord regs: 0x%02X%02X\n",
            radio.readRegister(REG_SYNC_WORD_MSB_6X),
            radio.readRegister(REG_SYNC_WORD_LSB_6X));
        uint16_t devErr = radio.getDeviceErrors();
        uint8_t status = radio.getStatus();
        Serial.printf("DevErrors: 0x%04X  Status: 0x%02X (mode=%d cmd=%d)\n",
            devErr, status, (status >> 4) & 0x07, (status >> 1) & 0x07);
        if (devErr & 0x40) Serial.println("  *** PLL LOCK FAILED ***");
        Serial.printf("IRQ flags: 0x%04X\n", radio.getIrqFlags());
        Serial.printf("Current RSSI: %d dBm\n", radio.currentRssi());
        uint8_t packetType = radio.getPacketType();
        const char* packetTypeName =
            (packetType == 0x00) ? "GFSK" :
            (packetType == 0x01) ? "LoRa" :
            (packetType == 0x02) ? "LR-FHSS" : "unknown";
        Serial.printf("Packet type: 0x%02X (%s)%s\n",
                      packetType, packetTypeName,
                      packetType == 0x01 ? "" : " *** NOT LoRa ***");
    }
    Serial.printf("Free heap: %lu bytes  PSRAM: %lu bytes\n",
                  (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getFreePsram());
    Serial.printf("Uptime: %lu s\n", millis() / 1000);
    Serial.println("=======================");
}

static void printIrqFlags(uint16_t flags) {
    Serial.printf("0x%04X", flags);
    if (flags & 0x0001) Serial.print(" TX_DONE");
    if (flags & 0x0002) Serial.print(" RX_DONE");
    if (flags & 0x0004) Serial.print(" PREAMBLE");
    if (flags & 0x0008) Serial.print(" SYNC");
    if (flags & 0x0010) Serial.print(" HEADER_VALID");
    if (flags & 0x0020) Serial.print(" HEADER_ERR");
    if (flags & 0x0040) Serial.print(" CRC_ERR");
    if (flags & 0x0080) Serial.print(" CAD_DONE");
    if (flags & 0x0100) Serial.print(" CAD_DET");
    if (flags & 0x0200) Serial.print(" TIMEOUT");
}

void onHotkeyIrqMonitor() {
    if (!radioOnline) { Serial.println("[IRQ] Radio offline"); return; }

    radio.receive();
    Serial.println("[IRQ] Sampling IRQ/RSSI for 5 seconds...");
    uint16_t lastFlags = 0xFFFF;
    unsigned long start = millis();
    unsigned long lastLine = 0;
    while (millis() - start < 5000) {
        uint16_t flags = radio.getIrqFlags();
        unsigned long now = millis();
        if (flags != lastFlags || now - lastLine >= 500) {
            Serial.printf("[IRQ] t=%lums rssi=%d flags=",
                          now - start, radio.currentRssi());
            printIrqFlags(flags);
            Serial.println();
            lastFlags = flags;
            lastLine = now;
        }
        delay(50);
    }
    radio.receive();
    Serial.println("[IRQ] Done");
}

// RSSI monitor — non-blocking state machine (sampled in main loop)
volatile bool rssiMonitorActive = false;
unsigned long rssiMonitorStart = 0;
unsigned long rssiLastSample = 0;
int rssiMinVal = 0, rssiMaxVal = -200, rssiSampleCount = 0;

void onHotkeyRssiMonitor() {
    if (!radioOnline) { Serial.println("[RSSI] Radio offline"); return; }
    if (rssiMonitorActive) {
        // Already running — cancel
        rssiMonitorActive = false;
        Serial.printf("[RSSI] Stopped: %d samples, min=%d max=%d dBm\n",
                      rssiSampleCount, rssiMinVal, rssiMaxVal);
        return;
    }
    Serial.println("[RSSI] Sampling for 5 seconds (non-blocking)...");
    rssiMonitorActive = true;
    rssiMonitorStart = millis();
    rssiLastSample = 0;
    rssiMinVal = 0;
    rssiMaxVal = -200;
    rssiSampleCount = 0;
}

void onHotkeyRadioTest() {
    Serial.println("[TEST] Sending raw test packet...");
    uint8_t header = 0xA0;
    const char* testPayload = "RSDECK_TEST_1234567890";
    radio.beginPacket();
    radio.write(header);
    radio.write((const uint8_t*)testPayload, strlen(testPayload));
    bool ok = radio.endPacket();
    Serial.printf("[TEST] TX %s (%d bytes)\n", ok ? "OK" : "FAILED", (int)(1 + strlen(testPayload)));
    radio.receive();
}

static void cycleDiagnosticTxPower() {
    static constexpr int8_t kPowers[] = {-9, -3, 0, 2, 6, 10, 14, 17, 22};
    int current = radio.getTxPower();
    size_t next = 0;
    for (size_t i = 0; i < sizeof(kPowers) / sizeof(kPowers[0]); i++) {
        if (current == kPowers[i]) {
            next = (i + 1) % (sizeof(kPowers) / sizeof(kPowers[0]));
            break;
        }
    }

    radio.setTxPower(kPowers[next]);
    radio.receive();
    Serial.printf("[SERIAL] transient TX power set to %d dBm\n", (int)kPowers[next]);
}

static void setDiagnosticMinTxPower() {
    radio.setTxPower(-9);
    radio.receive();
    Serial.println("[SERIAL] transient TX power set to -9 dBm");
}

static bool setDiagnosticTxPower(int powerDbm) {
    static constexpr int kMaxDiagnosticTxPower = 22;
    if (powerDbm < -9 || powerDbm > kMaxDiagnosticTxPower) {
        Serial.printf("[SERIAL] TX power out of range: %d dBm (allowed -9..%d)\n",
                      powerDbm, kMaxDiagnosticTxPower);
        return false;
    }
    radio.setTxPower((int8_t)powerDbm);
    radio.receive();
    Serial.printf("[SERIAL] transient TX power set to %d dBm\n", powerDbm);
    return true;
}

static void toggleDiagnosticInvertIQ() {
    radio.setInvertIQ(!radio.getInvertIQ());
    radio.receive();
    Serial.printf("[SERIAL] IQ inversion %s\n", radio.getInvertIQ() ? "ON" : "off");
}

static bool setDiagnosticFrequency(uint32_t frequencyHz) {
    if (frequencyHz < 150000000UL || frequencyHz > 960000000UL) {
        Serial.printf("[SERIAL] frequency out of range: %lu Hz (allowed 150000000..960000000)\n",
                      (unsigned long)frequencyHz);
        return false;
    }
    radio.setFrequency(frequencyHz);
    radio.receive();
    Serial.printf("[SERIAL] transient frequency set to %lu Hz\n", (unsigned long)frequencyHz);
    return true;
}

static void nudgeDiagnosticFrequency(int32_t deltaHz) {
    uint32_t next = radio.getFrequency() + deltaHz;
    radio.setFrequency(next);
    radio.receive();
    Serial.printf("[SERIAL] transient frequency set to %lu Hz\n", (unsigned long)next);
}

static const char* skipSerialSeparators(const char* p) {
    while (p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '=' || *p == ',')) {
        ++p;
    }
    return p;
}

static bool hasSerialArgument(const char* p) {
    p = skipSerialSeparators(p);
    return p && *p != '\0';
}

static bool parseSerialLong(const char* p, long& value, const char** rest = nullptr) {
    p = skipSerialSeparators(p);
    if (!p || *p == '\0') return false;
    char* end = nullptr;
    value = std::strtol(p, &end, 10);
    if (end == p) return false;
    if (rest) *rest = end;
    return true;
}

static bool parseSerialDestinationHash(const char* p, RNS::Bytes& hash) {
    p = skipSerialSeparators(p);
    if (!p || *p == '\0') return false;

    char hex[33] = {0};
    size_t len = 0;
    while (*p && len < 32) {
        unsigned char ch = (unsigned char)*p;
        if (std::isxdigit(ch)) {
            hex[len++] = (char)*p;
        } else if (*p != ' ' && *p != '\t' && *p != ':' && *p != '=' && *p != ',' && *p != '-') {
            return false;
        }
        ++p;
    }

    if (len != 32) return false;
    hash.assignHex(hex);
    return hash.size() == 16;
}

static bool selectDiagnosticPeer(const char* explicitArg, RNS::Bytes& destHash, std::string& label) {
    if (hasSerialArgument(explicitArg)) {
        if (!parseSerialDestinationHash(explicitArg, destHash)) {
            Serial.println("[SERIAL] invalid LXMF destination hash; expected 32 hex characters");
            return false;
        }
        label = destHash.toHex();
        return true;
    }

    if (!announceManager) {
        Serial.println("[SERIAL] LXMF test failed: announce manager is not ready");
        return false;
    }

    const std::string localHex = rns.destination().hash().toHex();
    for (const auto& node : announceManager->nodes()) {
        if (node.hash.size() != 16) continue;
        const std::string nodeHex = node.hash.toHex();
        if (nodeHex == localHex) continue;
        destHash = node.hash;
        label = node.name.empty() ? nodeHex : (node.name + " " + nodeHex);
        return true;
    }

    Serial.println("[SERIAL] LXMF test failed: no peer known; send/receive announces first or pass a hash");
    return false;
}

static std::string makeDiagnosticLxmfPayload(size_t length) {
    static constexpr char kPrefix[] = "RSDECK-LXMF-TEST:";
    static constexpr char kPattern[] =
        "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

    std::string out;
    out.reserve(length);
    for (size_t i = 0; kPrefix[i] && out.size() < length; ++i) {
        out.push_back(kPrefix[i]);
    }
    for (size_t i = 0; out.size() < length; ++i) {
        out.push_back(kPattern[i % (sizeof(kPattern) - 1)]);
    }
    return out;
}

static bool sendDiagnosticLxmf(size_t length, const char* explicitDest) {
    static constexpr size_t kMaxDiagnosticLxmfChars = 512;
    if (length == 0 || length > kMaxDiagnosticLxmfChars) {
        Serial.printf("[SERIAL] LXMF test length out of range: %u (allowed 1..%u)\n",
                      (unsigned)length, (unsigned)kMaxDiagnosticLxmfChars);
        return false;
    }

    RNS::Bytes destHash;
    std::string peerLabel;
    if (!selectDiagnosticPeer(explicitDest, destHash, peerLabel)) return false;

    std::string payload = makeDiagnosticLxmfPayload(length);
    bool ok = lxmf.sendMessage(destHash, payload);
    Serial.printf("[SERIAL] LXMF test %s: len=%u dest=%s queue=%d\n",
                  ok ? "queued" : "rejected",
                  (unsigned)payload.size(),
                  peerLabel.c_str(),
                  lxmf.queuedCount());
    return ok;
}

static constexpr uint8_t LITE_TRANSPORT_ID[16] = {
    'r', 's', 'l', 'i', 't', 'e', '-', 'h',
    'e', 'l', 't', 'e', 'c', '-', 'v', '3'
};
static constexpr size_t RNODE_DIAG_SINGLE_MTU = RSDECK_RNODE_SINGLE_FRAME_RAW_MAX;
static RNS::Bytes diagnosticLiteLinkId;

static bool sendDiagnosticRawReticulum(const RNS::Bytes& raw, const char* label) {
    if (!radioOnline || !radio.isRadioOnline()) {
        Serial.println("[SERIAL] lite diag failed: radio offline");
        return false;
    }
    if (raw.empty() || raw.size() > RNODE_DIAG_SINGLE_MTU) {
        Serial.printf("[SERIAL] lite diag %s rejected: raw len=%u (allowed 1..%u)\n",
                      label ? label : "packet",
                      (unsigned)raw.size(),
                      (unsigned)RNODE_DIAG_SINGLE_MTU);
        return false;
    }

    uint8_t rnodeHeader = (uint8_t)(random(256)) & 0xF0;
    radio.beginPacket();
    radio.write(rnodeHeader);
    radio.write(raw.data(), raw.size());
    bool ok = radio.endPacket();
    radio.receive();
    Serial.printf("[SERIAL] lite diag %s TX %s raw=%u air=%u rnode=0x%02X\n",
                  label ? label : "packet",
                  ok ? "OK" : "FAILED",
                  (unsigned)raw.size(),
                  (unsigned)(raw.size() + 1),
                  rnodeHeader);
    return ok;
}

static RNS::Bytes diagnosticHeader2LinkId(const RNS::Bytes& raw) {
    RNS::Bytes material;
    if (raw.empty()) return material;

    material.append((uint8_t)(raw[0] & 0x0F));
    if (raw.size() > 18) {
        size_t headerEnd = raw.size() < 35 ? raw.size() : 35;
        material.append(raw.data() + 18, headerEnd - 18);
    }
    if (raw.size() > 35) {
        size_t payloadLen = raw.size() - 35;
        if (payloadLen > 64) payloadLen = 64;
        material.append(raw.data() + 35, payloadLen);
    }

    return RNS::Identity::full_hash(material).left(16);
}

static bool buildDiagnosticHeader2(uint8_t packetType, uint8_t context, const RNS::Bytes& destHash,
                                   const uint8_t* payload, size_t payloadLen, RNS::Bytes& out) {
    if (destHash.size() != 16) return false;
    out.clear();
    out.append((uint8_t)(0x40 | 0x10 | (packetType & 0x03)));  // Header2 + Transport + Single
    out.append((uint8_t)0x00);                                  // hops
    out.append(LITE_TRANSPORT_ID, sizeof(LITE_TRANSPORT_ID));
    out.append(destHash.data(), destHash.size());
    out.append(context);
    out.append(payload, payloadLen);
    return out.size() <= RNODE_DIAG_SINGLE_MTU;
}

static bool buildDiagnosticLinkPacket(uint8_t packetType, uint8_t context, const uint8_t* payload,
                                      size_t payloadLen, RNS::Bytes& out) {
    if (diagnosticLiteLinkId.size() != 16) {
        Serial.println("[SERIAL] lite link diag failed: send J [dest_hash] first");
        return false;
    }
    out.clear();
    out.append((uint8_t)(0x0C | (packetType & 0x03)));  // Header1 + Broadcast + Link
    out.append((uint8_t)0x00);                          // hops
    out.append(diagnosticLiteLinkId.data(), diagnosticLiteLinkId.size());
    out.append(context);
    out.append(payload, payloadLen);
    return out.size() <= RNODE_DIAG_SINGLE_MTU;
}

static bool parseSerialContextByte(const char* p, uint8_t defaultContext, uint8_t& context) {
    p = skipSerialSeparators(p);
    if (!p || *p == '\0') {
        context = defaultContext;
        return true;
    }

    char* end = nullptr;
    long parsed = std::strtol(p, &end, 16);
    if (end == p || parsed < 0 || parsed > 0xFF) {
        Serial.println("[SERIAL] invalid context; expected one hex byte, for example K0E or YFD");
        return false;
    }
    context = (uint8_t)parsed;
    return true;
}

static void fillDiagnosticPayload(uint8_t* payload, size_t len, uint8_t seed) {
    for (size_t i = 0; i < len; i++) {
        payload[i] = (uint8_t)(seed + i);
    }
}

static bool sendDiagnosticLiteHeader2Data(size_t length, const char* explicitDest) {
    static constexpr size_t kMaxDiagnosticTransportPayload = 160;
    if (length == 0 || length > kMaxDiagnosticTransportPayload) {
        Serial.printf("[SERIAL] usage: H<len> [dest_hash], length 1..%u\n",
                      (unsigned)kMaxDiagnosticTransportPayload);
        return false;
    }

    RNS::Bytes destHash;
    std::string peerLabel;
    if (!selectDiagnosticPeer(explicitDest, destHash, peerLabel)) return false;

    uint8_t payload[kMaxDiagnosticTransportPayload];
    fillDiagnosticPayload(payload, length, 0x48);

    RNS::Bytes raw;
    if (!buildDiagnosticHeader2(0x00, 0x00, destHash, payload, length, raw)) {
        Serial.println("[SERIAL] lite Header2 data build failed");
        return false;
    }

    Serial.printf("[SERIAL] lite Header2 DATA to Heltec transport, dest=%s payload=%u\n",
                  peerLabel.c_str(), (unsigned)length);
    return sendDiagnosticRawReticulum(raw, "H2-DATA");
}

static bool sendDiagnosticLiteLinkRequest(const char* explicitDest) {
    RNS::Bytes destHash;
    std::string peerLabel;
    if (!selectDiagnosticPeer(explicitDest, destHash, peerLabel)) return false;

    uint8_t payload[64];
    fillDiagnosticPayload(payload, sizeof(payload), 0xA5);

    RNS::Bytes raw;
    if (!buildDiagnosticHeader2(0x02, 0x00, destHash, payload, sizeof(payload), raw)) {
        Serial.println("[SERIAL] lite link request build failed");
        return false;
    }

    diagnosticLiteLinkId = diagnosticHeader2LinkId(raw);
    Serial.printf("[SERIAL] lite LINKREQUEST to Heltec transport, dest=%s link=%s\n",
                  peerLabel.c_str(), diagnosticLiteLinkId.toHex().c_str());
    return sendDiagnosticRawReticulum(raw, "LINKREQUEST");
}

static bool sendDiagnosticLiteLinkData(const char* contextArg) {
    uint8_t context = 0x0E;  // Channel
    if (!parseSerialContextByte(contextArg, context, context)) return false;

    uint8_t payload[24];
    fillDiagnosticPayload(payload, sizeof(payload), context);

    RNS::Bytes raw;
    if (!buildDiagnosticLinkPacket(0x00, context, payload, sizeof(payload), raw)) {
        Serial.println("[SERIAL] lite link data build failed");
        return false;
    }

    Serial.printf("[SERIAL] lite LINK DATA context=0x%02X link=%s\n",
                  context, diagnosticLiteLinkId.toHex().c_str());
    return sendDiagnosticRawReticulum(raw, "LINK-DATA");
}

static bool sendDiagnosticLiteLinkProof(const char* contextArg) {
    uint8_t context = 0xFD;  // LinkProof
    if (!parseSerialContextByte(contextArg, context, context)) return false;

    uint8_t payload[64];
    fillDiagnosticPayload(payload, sizeof(payload), 0x7A);

    RNS::Bytes raw;
    if (!buildDiagnosticLinkPacket(0x03, context, payload, sizeof(payload), raw)) {
        Serial.println("[SERIAL] lite link proof build failed");
        return false;
    }

    Serial.printf("[SERIAL] lite LINK PROOF context=0x%02X link=%s\n",
                  context, diagnosticLiteLinkId.toHex().c_str());
    return sendDiagnosticRawReticulum(raw, "LINK-PROOF");
}

static void handleSerialLineCommand(const char* line) {
    if (!line || !*line) return;

    switch ((char)std::toupper((unsigned char)line[0])) {
        case 'F': {
            long value = 0;
            if (!parseSerialLong(line + 1, value) || value < 0) {
                Serial.println("[SERIAL] usage: F<frequency_hz>, for example F915000000");
                return;
            }
            setDiagnosticFrequency((uint32_t)value);
            break;
        }
        case 'P': {
            long value = 0;
            if (!parseSerialLong(line + 1, value)) {
                Serial.println("[SERIAL] usage: P<tx_power_dbm>, for example P1 or P5");
                return;
            }
            setDiagnosticTxPower((int)value);
            break;
        }
        case 'L': {
            long length = 0;
            const char* rest = nullptr;
            if (!parseSerialLong(line + 1, length, &rest) || length <= 0) {
                Serial.println("[SERIAL] usage: L<payload_chars> [dest_hash], for example L120");
                return;
            }
            sendDiagnosticLxmf((size_t)length, rest);
            break;
        }
        case 'H': {
            long length = 0;
            const char* rest = nullptr;
            if (!parseSerialLong(line + 1, length, &rest) || length <= 0) {
                Serial.println("[SERIAL] usage: H<len> [dest_hash], for example H32 2db8...");
                return;
            }
            sendDiagnosticLiteHeader2Data((size_t)length, rest);
            break;
        }
        case 'J': {
            sendDiagnosticLiteLinkRequest(line + 1);
            break;
        }
        case 'K': {
            sendDiagnosticLiteLinkData(line + 1);
            break;
        }
        case 'Y': {
            sendDiagnosticLiteLinkProof(line + 1);
            break;
        }
        default:
            Serial.printf("[SERIAL] unknown line command '%c'\n", line[0]);
            break;
    }
}

static void printSerialHelp() {
    Serial.println("[SERIAL] commands: ? help | a announce | t raw-test | d diag | r rssi | i irq | p tx-power-cycle | m min-power | q iq | +/- freq");
    Serial.println("[SERIAL] line commands: F<hz> exact-frequency | P<dBm> exact-tx-power | L<len> [dest_hash] LXMF test");
    Serial.println("[SERIAL] lite relay diag: H<len> [dest] Header2 data | J [dest] linkreq | K<ctx_hex> link-data | Y<ctx_hex> link-proof");
}

static void handleSerialCommands() {
    static char line[96];
    static size_t lineLen = 0;
    static bool lineActive = false;

    while (Serial.available() > 0) {
        char c = (char)Serial.read();
        if (lineActive) {
            if (c == '\r' || c == '\n') {
                line[lineLen] = '\0';
                handleSerialLineCommand(line);
                lineLen = 0;
                lineActive = false;
                continue;
            }
            if (lineLen + 1 >= sizeof(line)) {
                Serial.println("[SERIAL] line command too long; discarded");
                lineLen = 0;
                lineActive = false;
                continue;
            }
            line[lineLen++] = c;
            continue;
        }

        if (c == '\r' || c == '\n' || c == ' ' || c == '\t') continue;
        if (c == 'F' || c == 'P' || c == 'L' || c == 'H' || c == 'J' || c == 'K' || c == 'Y') {
            lineActive = true;
            lineLen = 0;
            line[lineLen++] = c;
            continue;
        }

        switch (c) {
            case '?':
                printSerialHelp();
                break;
            case 'a':
            case 'A':
                onHotkeyAnnounce();
                break;
            case 't':
            case 'T':
                onHotkeyRadioTest();
                break;
            case 'd':
            case 'D':
                onHotkeyDiag();
                break;
            case 'r':
            case 'R':
                onHotkeyRssiMonitor();
                break;
            case 'i':
            case 'I':
                onHotkeyIrqMonitor();
                break;
            case 'p':
                cycleDiagnosticTxPower();
                break;
            case 'm':
            case 'M':
                setDiagnosticMinTxPower();
                break;
            case 'q':
            case 'Q':
                toggleDiagnosticInvertIQ();
                break;
            case '+':
            case '=':
                nudgeDiagnosticFrequency(1000);
                break;
            case '-':
            case '_':
                nudgeDiagnosticFrequency(-1000);
                break;
            default:
                Serial.printf("[SERIAL] unknown command '%c'\n", c);
                printSerialHelp();
                break;
        }
    }
}

// =============================================================================
// Helper: render boot screen immediately
// =============================================================================
static void bootRender() {
    // LVGL boot screen calls lv_timer_handler() internally via setProgress()
    // Legacy render kept as fallback
}

// =============================================================================
// Setup — 26-step boot sequence
// =============================================================================

void setup() {
    bool flashMounted = false;

    // Step 1: Power pin — CRITICAL: enables all T-Deck Plus peripherals
    Power::enablePeripherals();

    // Step 2: Serial
    Serial.begin(SERIAL_BAUD);
    delay(100);
    Serial.println();
    Serial.println("=================================");
    Serial.printf("  rsDeck v%s\n", RSDECK_VERSION_STRING);
    Serial.println("  LilyGo T-Deck Plus");
    Serial.println("=================================");

    esp_reset_reason_t reason = esp_reset_reason();
    const char* reasonStr = "UNKNOWN";
    switch (reason) {
        case ESP_RST_POWERON:   reasonStr = "POWER_ON"; break;
        case ESP_RST_SW:        reasonStr = "SOFTWARE"; break;
        case ESP_RST_PANIC:     reasonStr = "PANIC"; break;
        case ESP_RST_INT_WDT:   reasonStr = "INT_WDT"; break;
        case ESP_RST_TASK_WDT:  reasonStr = "TASK_WDT"; break;
        case ESP_RST_WDT:       reasonStr = "WDT"; break;
        case ESP_RST_BROWNOUT:  reasonStr = "BROWNOUT"; break;
        case ESP_RST_DEEPSLEEP: reasonStr = "DEEP_SLEEP"; break;
        default: break;
    }
    Serial.printf("[BOOT] Reset: %s (%d)\n", reasonStr, (int)reason);
    Serial.printf("[BOOT] Heap: %lu  PSRAM: %lu\n",
                  (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getPsramSize());

    // Dual-boot layout: re-arm the launcher so the next reset shows the chooser.
    auto launcherBoot = rs_deck::returnToLauncherNextBoot();
    if (!launcherBoot.ok) {
        Serial.printf("[BOOT] Launcher return unavailable: %s\n", launcherBoot.message);
    }
    if (!psramFound() || heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) < 1024 * 1024) {
        Serial.printf("[BOOT] FATAL: PSRAM unavailable or too fragmented (largest=%lu)\n",
                      (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM));
        while (true) delay(1000);
    }

    // Step 3: Initialize I2C bus (shared by keyboard + touchscreen)
    Wire.begin(I2C_SDA, I2C_SCL);
    Wire.setClock(400000);
    Wire.setTimeOut(20);

    // Step 3.5: Initialize shared SPI bus
    sharedSPI.begin(SPI_SCK, SPI_MISO, SPI_MOSI);
    // Deassert all slave CS pins to prevent bus contention
    pinMode(LORA_CS, OUTPUT); digitalWrite(LORA_CS, HIGH);
    pinMode(SD_CS, OUTPUT);   digitalWrite(SD_CS, HIGH);

    // Mount flash before radio bring-up so persisted RF settings are used from
    // the first SX1262 init, instead of always booting at the US default first.
    Serial.println("[BOOT] Mounting flash for early config...");
    if (flash.begin()) {
        flashMounted = true;
        userConfig.load(flash);
    } else {
        Serial.println("[BOOT] Early flash mount failed; using default radio config");
    }
    // Select palette before any LVGL styles are built
    Theme::setScheme(userConfig.settings().themeLight ? Theme::Scheme::LIGHT : Theme::Scheme::DARK);

    // Step 4: Radio + SD init BEFORE display
    // Radio and SD must init while SPIClass exclusively owns SPI2_HOST.
    // LovyanGFX's init() later joins the bus via spi_bus_add_device().
    // This avoids any bus re-init dance that would invalidate device handles.
    Serial.println("[BOOT] Initializing radio...");
    if (radio.begin(userConfig.settings().loraFrequency)) {
        radioOnline = true;
        applyRadioSettingsToHardware(userConfig.settings(), "RADIO");
        Serial.printf("[RADIO] SX1262 online at %lu Hz\n",
                      (unsigned long)userConfig.settings().loraFrequency);
    } else {
        Serial.println("[RADIO] SX1262 not detected!");
    }

    // SD card init (shared SPI, right after radio)
    digitalWrite(LORA_CS, HIGH);
    delay(10);
    if (sdStore.begin(&sharedSPI, SD_CS)) {
        sdHadExistingData = sdStore.hasExistingData();
        sdStore.formatForRsDeck();
        Serial.println("[SD] Card ready");
    } else {
        Serial.println("[SD] Not detected");
    }

    // Verify radio SPI still works after SD init
    if (radioOnline) {
        uint8_t sw_msb = radio.readRegister(0x0740);
        uint8_t sw_lsb = radio.readRegister(0x0741);
        Serial.printf("[BOOT] Radio SPI pre-display: syncword=0x%02X%02X %s\n",
            sw_msb, sw_lsb, (sw_msb == 0xFF && sw_lsb == 0xFF) ? "DEAD!" : "OK");
    }

    // Step 5: Display HAL — LovyanGFX + ST7789V
    // LovyanGFX's Bus_SPI::init() calls spi_bus_initialize() which will
    // return ESP_ERR_INVALID_STATE (bus already owned by SPIClass) and
    // then spi_bus_add_device() to join the existing bus. Both LGFX and
    // SPIClass get valid device handles on the same SPI2_HOST bus.
    display.begin();
    Serial.println("[BOOT] Display initialized (LovyanGFX direct)");

    // Step 5.5: Initialize LVGL display driver
    if (!display.beginLVGL()) {
        display.gfx().fillScreen(TFT_BLACK);
        display.gfx().setTextColor(TFT_RED, TFT_BLACK);
        display.gfx().drawString("LVGL/PSRAM failed", 24, 106);
        display.setBrightness(160);
        while (true) delay(1000);
    }
    Serial.println("[BOOT] LVGL initialized");

    // Verify radio SPI survives display init
    if (radioOnline) {
        uint8_t sw_msb = radio.readRegister(0x0740);
        uint8_t sw_lsb = radio.readRegister(0x0741);
        Serial.printf("[BOOT] Radio SPI post-display: syncword=0x%02X%02X %s\n",
            sw_msb, sw_lsb, (sw_msb == 0xFF && sw_lsb == 0xFF) ? "DEAD!" : "OK");
    }

    // Step 6: UI manager (initializes both legacy and LVGL UI layers)
    ui.begin();
    ui.setBootMode(true);
    ui.setScreen(&lvBootScreen);
    ui.lvStatusBar().setLoRaOnline(radioOnline);
    lvBootScreen.setProgress(0.45f, radioOnline ? "Radio online" : "Radio FAILED");

    // Display::begin() left the backlight at 0 to hide an unpainted
    // framebuffer; the setProgress() above has now flushed the boot screen.
    // powerMgr at step 24 overrides with the user's configured value.
    display.setBrightness(128);

    // Step 7: Touch HAL — GT911 I2C
    touch.begin();
    lvBootScreen.setProgress(0.50f, "Touch ready");
    // (LVGL boot renders via lv_timer_handler in setProgress)

    // Step 8: Keyboard HAL — ESP32-C3 I2C
    keyboard.begin();
    lvBootScreen.setProgress(0.52f, "Keyboard ready");
    // (LVGL boot renders via lv_timer_handler in setProgress)

    // Step 9: Trackball HAL — GPIO interrupts
    trackball.begin();
    lvBootScreen.setProgress(0.54f, "Trackball ready");
    // (LVGL boot renders via lv_timer_handler in setProgress)

    // Step 10: Input manager
    inputManager.begin(&keyboard, &trackball, &touch);
    inputManager.setPowerMgr(&powerMgr);

    // Step 10.5: LVGL input drivers
    LvInput::init(&keyboard, &trackball, &touch);

    lvBootScreen.setProgress(0.55f, "Input ready");
    // (LVGL boot renders via lv_timer_handler in setProgress)

    // Step 11: Register hotkeys
    hotkeys.registerHotkey('h', "Help", onHotkeyHelp);
    hotkeys.registerHotkey('m', "Messages", onHotkeyMessages);
    hotkeys.registerHotkey('n', "New Message", onHotkeyNewMsg);
    hotkeys.registerHotkey('s', "Settings", onHotkeySettings);
    hotkeys.registerHotkey('a', "Announce", onHotkeyAnnounce);
    hotkeys.registerHotkey('d', "Diagnostics", onHotkeyDiag);
    hotkeys.registerHotkey('i', "AutoIface dump", onHotkeyAutoIface);
    hotkeys.registerHotkey('t', "Radio Test", onHotkeyRadioTest);
    hotkeys.registerHotkey('r', "RSSI Monitor", onHotkeyRssiMonitor);
    hotkeys.setTabCycleCallback([](int dir) {
        ui.lvTabBar().cycleTab(dir);
        int tab = ui.lvTabBar().getActiveTab();
        if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]);
    });
    lvBootScreen.setProgress(0.58f, "Hotkeys registered");
    // (LVGL boot renders via lv_timer_handler in setProgress)

    // Step 12: Mount LittleFS
    lvBootScreen.setProgress(0.60f, "Mounting flash...");
    // (LVGL boot renders via lv_timer_handler in setProgress)
    if (flashMounted) {
        Serial.println("[BOOT] LittleFS already mounted OK");
    } else if (!flash.begin()) {
        Serial.println("[BOOT] Flash init failed; automatic formatting disabled");
        lvBootScreen.setProgress(0.62f, "Flash mount failed");
    } else {
        flashMounted = true;
        Serial.println("[BOOT] LittleFS mounted OK");
    }

    // Step 13: Boot loop detection (NVS)
    {
        Preferences prefs;
        if (prefs.begin("ratdeck", false)) {
            int bc = prefs.getInt("bootc", 0);
            prefs.putInt("bootc", bc + 1);
            prefs.end();
            if (bc >= 3) {
                Serial.printf("[BOOT] Boot loop detected (%d failures)\n", bc);
                bootLoopRecovery = true;
            }
        }
    }

    lvBootScreen.setProgress(0.64f, "Loading config...");
    userConfig.load(sdStore, flash);
    // SD config may override the early flash-only load; re-sync palette
    {
        Theme::Scheme want = userConfig.settings().themeLight ? Theme::Scheme::LIGHT : Theme::Scheme::DARK;
        if (want != Theme::scheme()) { Theme::setScheme(want); ui.applyTheme(); }
    }
    inputManager.setTrackballSpeed(userConfig.settings().trackballSpeed);
    applyRadioSettingsToHardware(userConfig.settings(), "BOOT PRE-RNS");

    lvBootScreen.setProgress(0.65f, "Starting Reticulum...");
    // (LVGL boot renders via lv_timer_handler in setProgress)
    rns.setSDStore(&sdStore);
    if (rns.begin(&radio, &flash, userConfig.settings().loraEnabled)) {
        Serial.printf("[BOOT] Identity: %s\n", rns.identityHash().c_str());
        lvBootScreen.setProgress(0.72f, "Reticulum active");
    } else {
        Serial.println("[BOOT] Reticulum init failed!");
        lvBootScreen.setProgress(0.72f, "RNS: FAILED");
    }
    // (LVGL boot renders via lv_timer_handler in setProgress)

    // Step 15.5: Identity manager
    identityMgr.begin(&flash, &sdStore);

    // Step 16: Message store
    lvBootScreen.setProgress(0.72f, "Starting messaging...");
    // (LVGL boot renders via lv_timer_handler in setProgress)
    messageStore.begin(&flash, &sdStore, userConfig.settings().sdStorageEnabled);

    // Step 17: LXMF init
    lxmf.begin(&rns, &messageStore);
    lxmf.setMessageCallback([](const LXMFMessage& msg) {
        Serial.printf("[LXMF] Message from %s\n", msg.sourceHash.toHex().substr(0, 8).c_str());
        ui.lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, lxmf.unreadCount());
        audio.requestMessage();
    });
    // Pre-cache unread counts so first tab switch to Messages is instant
    lxmf.unreadCount();
    lvBootScreen.setProgress(0.75f, "LXMF ready");
    // (LVGL boot renders via lv_timer_handler in setProgress)

    // Step 18: Announce manager
    lvBootScreen.setProgress(0.78f, "Loading contacts...");
    // (LVGL boot renders via lv_timer_handler in setProgress)
    // Filter to lxmf.delivery so we don't capture every aspect (lxmf.propagation,
    // nomadnetwork.node, etc.) from the same peer as separate "doubled" entries.
    announceManager = new AnnounceManager("lxmf.delivery");
    announceManager->setStorage(&sdStore, &flash);
    announceManager->setLocalDestHash(rns.destination().hash());
    if (rns.loraInterface()) announceManager->setLoRaInterface(rns.loraInterface());
    announceManager->loadContacts();
    announceManager->loadNameCache();
    announceHandler = RNS::HAnnounceHandler(announceManager);
    RNS::Transport::register_announce_handler(announceHandler);

    // No default TCP hub.  Users opt in via Settings → TCP Server →
    // "Ratspeak Hub" (seeds rns.ratspeak.org) or "Custom" (host/port).

    // Sync display name between active identity slot and config.
    // The identity slot is the source of truth for the name.
    {
        String slotName;
        if (identityMgr.syncNameFromActive(slotName)) {
            if (!slotName.isEmpty()) {
                // Slot has a name — use it (overrides any stale config value)
                if (userConfig.settings().displayName != slotName) {
                    Serial.printf("[BOOT] Name from identity slot: '%s'\n", slotName.c_str());
                    userConfig.settings().displayName = slotName;
                    userConfig.save(sdStore, flash);
                }
            } else if (!userConfig.settings().displayName.isEmpty()) {
                // Slot has no name but config does — seed the slot (first boot migration)
                identityMgr.setDisplayName(identityMgr.activeIndex(),
                    userConfig.settings().displayName);
                Serial.printf("[BOOT] Seeded identity slot name: '%s'\n",
                    userConfig.settings().displayName.c_str());
            }
        }
    }

    // Step 20: Boot loop recovery
    if (bootLoopRecovery) {
        userConfig.settings().wifiMode = RAT_WIFI_OFF;
        Serial.println("[BOOT] WiFi forced OFF (boot loop recovery)");
    }
    lvBootScreen.setProgress(0.83f, "Config loaded");
    // (LVGL boot renders via lv_timer_handler in setProgress)

    // Step 21: Apply radio config
    if (radioOnline && userConfig.settings().loraEnabled) {
        applyRadioSettingsToHardware(userConfig.settings(), "BOOT");
        ui.lvStatusBar().setLoRaOnline(true);
    } else if (radioOnline) {
        radio.sleep();
        ui.lvStatusBar().setLoRaOnline(false);
        Serial.println("[BOOT] LoRa disabled by config");
    }
    lvBootScreen.setProgress(0.84f, "Radio configured");
    // (LVGL boot renders via lv_timer_handler in setProgress)

    // Step 22: WiFi start
    RatWiFiMode wifiMode = userConfig.settings().wifiMode;
    ui.lvStatusBar().setWiFiEnabled(wifiMode != RAT_WIFI_OFF);
    if (wifiMode == RAT_WIFI_AP) {
        lvBootScreen.setProgress(0.87f, "Starting WiFi AP...");
        // (LVGL boot renders via lv_timer_handler in setProgress)
        wifiImpl = new WiFiInterface("WiFi.AP");
        if (!userConfig.settings().wifiAPSSID.isEmpty()) {
            wifiImpl->setAPCredentials(
                userConfig.settings().wifiAPSSID.c_str(),
                userConfig.settings().wifiAPPassword.c_str());
        }
        wifiIface = wifiImpl;
        wifiIface.mode(RNS::Type::Interface::MODE_GATEWAY);
        RNS::Transport::register_interface(wifiIface);
        wifiImpl->start();
        ui.lvStatusBar().setWiFiActive(true);
    } else if (wifiMode == RAT_WIFI_STA) {
        lvBootScreen.setProgress(0.87f, "WiFi STA starting...");
        auto& settings = userConfig.settings();
        auto& nets = settings.wifiSTANetworks;
        size_t selectedSlot = settings.wifiSTASelected < WIFI_STA_MAX_NETWORKS ? settings.wifiSTASelected : 0;
        int registered = 0;
        if (selectedSlot < nets.size() && !nets[selectedSlot].ssid.isEmpty()) {
            const auto& n = nets[selectedSlot];
            wifiMulti.addAP(n.ssid.c_str(), n.password.c_str());
            registered++;
            Serial.printf("[WIFI] STA: using profile %u (%s)\n",
                          (unsigned)(selectedSlot + 1), n.ssid.c_str());
        } else {
            Serial.printf("[WIFI] STA: selected profile %u is empty\n",
                          (unsigned)(selectedSlot + 1));
        }
        // WiFi is enabled but not yet connected — indicator will be yellow
        if (registered > 0) {
            WiFi.mode(WIFI_STA);
            WiFi.onEvent(onWiFiEvent);
            // AutoInterface needs an IPv6 link-local address.  Must be enabled
            // BEFORE WiFi.begin() so SLAAC starts on STA association.
            if (userConfig.settings().autoIfaceEnabled) {
                WiFi.enableIpV6();
                Serial.println("[WIFI] IPv6 enabled (AutoInterface ON)");
            }
            uint8_t initialStatus = wifiMulti.run(5000);
            wifiSTAStarted = true;
            if (initialStatus != WL_CONNECTED && WiFi.status() != WL_CONNECTED &&
                !wifiNeedsReconnect.load()) {
                scheduleWiFiReconnect();
            }
            Serial.printf("[WIFI] STA: %d selected profile registered\n", registered);
        }
    } else {
        lvBootScreen.setProgress(0.87f, "WiFi disabled");
        // (LVGL boot renders via lv_timer_handler in setProgress)
    }

    // Step 23: BLE stays disabled in default builds.
    lvBootScreen.setProgress(0.90f, "Links ready");
    // (LVGL boot renders via lv_timer_handler in setProgress)
#if HAS_BLE
    ui.lvStatusBar().setBLEEnabled(userConfig.settings().bleEnabled);
    if (userConfig.settings().bleEnabled) {
        bleInterface.setSideband(&bleSideband);

        if (bleInterface.start()) {
            static RNS::Interface bleIface(&bleInterface);
            bleIface.mode(RNS::Type::Interface::MODE_GATEWAY);
            RNS::Transport::register_interface(bleIface);

            bleSideband.begin(bleInterface.getServer());
            bleSideband.setPacketCallback([](const uint8_t* data, size_t len) {
                RNS::Bytes pkt(data, len);
                bleInterface.injectIncoming(pkt);
            });

            ui.lvStatusBar().setBLEActive(true);
            Serial.println("[BLE] Transport + Sideband ready");
        }
    } else {
        Serial.println("[BLE] Disabled by config");
    }
#else
    ui.lvStatusBar().setBLEEnabled(false);
    ui.lvStatusBar().setBLEActive(false);
    Serial.println("[BLE] Disabled in default firmware build");
#endif

    // Step 24: Power manager
    lvBootScreen.setProgress(0.92f, "Power manager...");
    // (LVGL boot renders via lv_timer_handler in setProgress)
    powerMgr.begin();
    powerMgr.setDimTimeout(userConfig.settings().screenDimTimeout);
    powerMgr.setOffTimeout(userConfig.settings().screenOffTimeout);
    powerMgr.setBrightness(userConfig.settings().brightness);
    powerMgr.setKbBrightness(userConfig.settings().keyboardBrightness);
    powerMgr.setKbAutoOn(userConfig.settings().keyboardAutoOn);
    powerMgr.setKbAutoOff(userConfig.settings().keyboardAutoOff);

    // Step 24.5: GPS init
#if HAS_GPS
    if (userConfig.settings().gpsTimeEnabled) {
        lvBootScreen.setProgress(0.93f, "Starting GPS...");
        gps.setPosixTZ(currentPosixTZ());
        gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled);
        gps.begin();
        Serial.println("[BOOT] GPS UART started (MIA-M10Q)");
    }
#endif

    // Step 25: Audio init
    lvBootScreen.setProgress(0.94f, "Audio...");
    // (LVGL boot renders via lv_timer_handler in setProgress)
    audio.setEnabled(userConfig.settings().audioEnabled);
    audio.setVolume(userConfig.settings().audioVolume);
    audio.begin();

    // Boot complete — transition to Home screen
    // Yield to LVGL instead of blocking delay
    lvBootScreen.setProgress(0.98f, "Ready");
    for (int i = 0; i < 6; i++) { lv_timer_handler(); delay(1); }
    lvBootScreen.setProgress(1.0f, "Ready");
    audio.playBoot();

    bootComplete = true;

    // Keep LVGL responsive during blocking radio operations (if screen is on)
    // Re-entrancy guard prevents nested lv_timer_handler() calls
    radio.setYieldCallback([]() {
        static bool inYield = false;
        if (inYield) return;
        inYield = true;
        if (powerMgr.isScreenOn()) {
            lv_timer_handler();
        }
        inYield = false;
    });

    // Wire up LVGL screen dependencies
    lvHomeScreen.setReticulumManager(&rns);
    lvHomeScreen.setRadio(&radio);
    lvHomeScreen.setUserConfig(&userConfig);
    lvHomeScreen.setLXMFManager(&lxmf);
    lvHomeScreen.setAnnounceManager(announceManager);
    lvHomeScreen.setRadioOnline(radioOnline);
    lvHomeScreen.setTCPClients(&tcpClients);
    lvHomeScreen.setAnnounceCallback([]() {
        manualAnnounce();
        Serial.println("[HOME] Announce triggered via Enter");
    });
    lvHomeScreen.setAudioToggleCallback([]() {
        userConfig.settings().audioEnabled = !userConfig.settings().audioEnabled;
        audio.setEnabled(userConfig.settings().audioEnabled);
        bool ok = userConfig.save(sdStore, flash);
        ui.lvStatusBar().showToast(userConfig.settings().audioEnabled ? "Audio ON" : "Audio OFF", 1000);
        Serial.printf("[AUDIO] Notifications %s (save %s)\n",
                      userConfig.settings().audioEnabled ? "ON" : "OFF",
                      ok ? "OK" : "FAILED");
    });
    lvHomeScreen.setLoraToggleCallback([]() {
        auto& s = userConfig.settings();
        s.loraEnabled = !s.loraEnabled;
        bool ok = userConfig.save(sdStore, flash);
        ui.lvStatusBar().showToast(
            ok ? "LoRa saved; reboot to apply" : "Save failed",
            ok ? 3000 : 2000);
        Serial.printf("[LORA] Saved %s (save %s, reboot required)\n",
                      s.loraEnabled ? "ON" : "OFF",
                      ok ? "OK" : "FAILED");
    });
    lvHomeScreen.setTCPToggleCallback([]() {
        auto& s = userConfig.settings();
        bool enabled = false;
        bool hasSavedRelay = false;
        for (const auto& ep : s.tcpConnections) {
            if (!ep.host.isEmpty()) hasSavedRelay = true;
            if (!ep.host.isEmpty() && ep.autoConnect) { enabled = true; break; }
        }
        if (enabled) {
            for (auto& ep : s.tcpConnections) ep.autoConnect = false;
        } else if (hasSavedRelay) {
            for (auto& ep : s.tcpConnections) {
                if (!ep.host.isEmpty()) ep.autoConnect = true;
            }
        } else {
            s.tcpConnections.clear();
            TCPEndpoint ep;
            ep.host = "rns.ratspeak.org";
            ep.port = TCP_DEFAULT_PORT;
            ep.autoConnect = true;
            s.tcpConnections.push_back(ep);
        }
        bool ok = userConfig.save(sdStore, flash);
        ui.lvStatusBar().showToast(
            ok ? "TCP relay saved; reboot to apply" : "Save failed",
            ok ? 3000 : 2000);
        Serial.printf("[TCP] Saved relay %s (save %s, reboot required)\n",
                      enabled ? "OFF" : "ON",
                      ok ? "OK" : "FAILED");
    });
    lvHomeScreen.setWiFiToggleCallback([]() {
        auto& s = userConfig.settings();
        if (s.wifiMode == RAT_WIFI_OFF) {
            RatWiFiMode restoreMode = s.wifiRestoreMode == RAT_WIFI_OFF ? RAT_WIFI_STA : s.wifiRestoreMode;
            if (restoreMode == RAT_WIFI_STA) {
                size_t slot = s.wifiSTASelected < s.wifiSTANetworks.size() ? s.wifiSTASelected : 0;
                if (slot >= s.wifiSTANetworks.size() || s.wifiSTANetworks[slot].ssid.isEmpty()) {
                    ui.lvStatusBar().showToast("Add WiFi in Settings", 2000);
                    return;
                }
            } else if (restoreMode != RAT_WIFI_AP) {
                ui.lvStatusBar().showToast("Add WiFi in Settings", 2000);
                return;
            }
            s.wifiMode = restoreMode;
        } else {
            s.wifiRestoreMode = s.wifiMode;
            s.wifiMode = RAT_WIFI_OFF;
        }
        bool ok = userConfig.save(sdStore, flash);
        ui.lvStatusBar().showToast(
            ok ? "WiFi saved; reboot to apply" : "Save failed",
            ok ? 3000 : 2000);
        Serial.printf("[WIFI] Saved mode %d (save %s, reboot required)\n",
                      (int)s.wifiMode, ok ? "OK" : "FAILED");
    });
#if HAS_GPS
    lvHomeScreen.setGPSToggleCallback([]() {
        auto& s = userConfig.settings();
        bool oldTime = s.gpsTimeEnabled;
        s.gpsTimeEnabled = !s.gpsTimeEnabled;
        bool ok = userConfig.save(sdStore, flash);
        if (!ok) {
            s.gpsTimeEnabled = oldTime;
            ui.lvStatusBar().showToast("Save failed", 2000);
            Serial.println("[GPS] Toggle save failed");
            return;
        }
        if (s.gpsTimeEnabled) {
            gps.setPosixTZ(currentPosixTZ());
            gps.setLocationEnabled(s.gpsLocationEnabled);
            gps.begin();
            ui.lvStatusBar().showToast("GPS ON", 1000);
            Serial.println("[GPS] Enabled via Home");
        } else {
            gps.stop();
            ui.lvStatusBar().setGPSFix(false);
            ui.lvStatusBar().showToast("GPS OFF", 1000);
            Serial.println("[GPS] Disabled via Home");
        }
    });
#else
    lvHomeScreen.setGPSToggleCallback([]() {
        ui.lvStatusBar().showToast("GPS unavailable", 1500);
    });
#endif
    lvHomeScreen.setPeersCallback([]() {
        ui.lvTabBar().setActiveTab(LvTabBar::TAB_NODES);
        ui.setScreen(&lvNodesScreen);
    });

    lvContactsScreen.setAnnounceManager(announceManager);
    lvContactsScreen.setUIManager(&ui);
    lvContactsScreen.setNodeSelectedCallback([](const std::string& peerHex) {
        lvMessageView.setPeerHex(peerHex);
        ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
        ui.setScreen(&lvMessageView);
    });

    lvNodesScreen.setAnnounceManager(announceManager);
    lvNodesScreen.setUIManager(&ui);
    lvNodesScreen.setUserConfig(&userConfig);
    lvNodesScreen.setNodeSelectedCallback([](const std::string& peerHex) {
        lvMessageView.setPeerHex(peerHex);
        ui.lvTabBar().setActiveTab(LvTabBar::TAB_MSGS);
        ui.setScreen(&lvMessageView);
    });

    lvMessagesScreen.setLXMFManager(&lxmf);
    lvMessagesScreen.setAnnounceManager(announceManager);
    lvMessagesScreen.setUIManager(&ui);
    lvMessagesScreen.setOpenCallback([](const std::string& peerHex) {
        lvMessageView.setPeerHex(peerHex);
        ui.setScreen(&lvMessageView);
    });

    lvMessageView.setLXMFManager(&lxmf);
    lvMessageView.setAnnounceManager(announceManager);
    lvMessageView.setUIManager(&ui);
    lvMessageView.setBackCallback([]() {
        ui.setScreen(&lvMessagesScreen);
    });

    lvSettingsScreen.setUserConfig(&userConfig);
    lvSettingsScreen.setFlashStore(&flash);
    lvSettingsScreen.setSDStore(&sdStore);
    lvSettingsScreen.setRadio(&radio);
    lvSettingsScreen.setAudio(&audio);
    lvSettingsScreen.setPower(&powerMgr);
    lvSettingsScreen.setWiFi(wifiImpl);
    lvSettingsScreen.setTCPClients(&tcpClients);
    lvSettingsScreen.setRNS(&rns);
    lvSettingsScreen.setIdentityManager(&identityMgr);
    lvSettingsScreen.setUIManager(&ui);
    lvSettingsScreen.setIdentityHash(rns.destinationHashStr());
    lvSettingsScreen.setDestinationHash(rns.destinationHashHex());
    lvSettingsScreen.setSaveCallback([]() -> bool {
        inputManager.setTrackballSpeed(userConfig.settings().trackballSpeed);
        bool ok = userConfig.save(sdStore, flash);
        Serial.printf("[CONFIG] Save %s\n", ok ? "OK" : "FAILED");
        return ok;
    });
    lvSettingsScreen.setTCPChangeCallback([]() {
        Serial.println("[TCP] Settings changed, scheduling reload...");
        requestTCPClientsReload();
    });
#if HAS_GPS
    lvSettingsScreen.setGPSChangeCallback([](bool timeEnabled) {
        if (timeEnabled) {
            gps.setPosixTZ(currentPosixTZ());
            gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled);
            gps.begin();
            Serial.println("[GPS] Time enabled via settings");
        } else {
            gps.stop();
            ui.lvStatusBar().setGPSFix(false);
            Serial.println("[GPS] Disabled via settings");
        }
    });
#endif

    auto showQr = []() {
        // Encode `lxma://<destHash>:<publicKey>` so Columba/Sideband
        // scanners get a full identity (no PENDING_IDENTITY round-trip).
        String destHex = rns.destinationHashHex();
        String pubHex;
        if (auto identity = rns.destination().identity()) {
            pubHex = String(identity.get_public_key().toHex().c_str());
        }
        lvQrOverlay.show(destHex, pubHex);
    };
    lvSettingsScreen.setShowQrCallback(showQr);
    lvContactsScreen.setShowQrCallback(showQr);

    // LVGL help overlay
    lvHelpOverlay.create();
    lvQrOverlay.create();

    // Tab bar callbacks — LVGL
    lvTabScreens[LvTabBar::TAB_HOME]     = &lvHomeScreen;
    lvTabScreens[LvTabBar::TAB_CONTACTS] = &lvContactsScreen;
    lvTabScreens[LvTabBar::TAB_MSGS]     = &lvMessagesScreen;
    lvTabScreens[LvTabBar::TAB_NODES]    = &lvNodesScreen;
    lvTabScreens[LvTabBar::TAB_SETTINGS] = &lvSettingsScreen;

    ui.lvTabBar().setTabCallback([](int tab) {
        if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]);
    });

    // Data clean screen (first boot only — when SD has old data)
    lvDataCleanScreen.setDoneCallback([](bool wipe) {
        if (wipe) {
            Serial.println("[BOOT] User chose to wipe old data");
            lvDataCleanScreen.showStatus("Clearing old data...");
            sdStore.wipeRsDeck();
            if (announceManager) announceManager->clearAll();
            Serial.println("[BOOT] Old data cleared");
            lvDataCleanScreen.showStatus("Done! Rebooting...");
            delay(1500);
            ESP.restart();
        } else {
            Serial.println("[BOOT] User chose to keep old data");
            userConfig.settings().sdStorageEnabled = true;
            userConfig.save(sdStore, flash);
            lvDataCleanScreen.showStatus("SD storage enabled. Rebooting...");
            delay(1500);
            ESP.restart();
        }
    });

    // --- Boot flow helpers ---
    // Transition to home screen (shared by name input, timezone, and normal boot)
    auto goHome = []() {
        ui.setBootMode(false);
        ui.setScreen(&lvHomeScreen);
        ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
        bootAnnouncePending = true;
        bootAnnounceAttempts = 0;
        bootAnnounceAt = millis() + BOOT_ANNOUNCE_DELAY_MS;
        Serial.println("[BOOT] Home ready; startup announce scheduled");
    };

    // Show timezone screen, then go home
    auto showTimezone = [goHome]() {
        if (!userConfig.settings().timezoneSet) {
            lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx);
            ui.setScreen(&lvTimezoneScreen);
            Serial.println("[BOOT] Showing timezone selection");
        } else {
            goHome();
        }
    };

    // Timezone screen done callback
    lvTimezoneScreen.setDoneCallback([goHome](int tzIdx) {
        userConfig.settings().timezoneIdx = (uint8_t)tzIdx;
        userConfig.settings().timezoneSet = true;
        bool saved = userConfig.save(sdStore, flash);
        if (!saved) {
            Serial.println("[BOOT] Timezone save failed; staying in setup");
            ui.lvStatusBar().showToast("Save failed; storage unavailable", 3000);
            return;
        }
        Serial.printf("[BOOT] Timezone set: %s (%s)\n",
            TIMEZONE_TABLE[tzIdx].label, TIMEZONE_TABLE[tzIdx].posixTZ);
        // Apply timezone immediately
        const char* tz = TIMEZONE_TABLE[tzIdx].posixTZ;
        setenv("TZ", tz, 1);
        tzset();
#if HAS_GPS
        if (userConfig.settings().gpsTimeEnabled) {
            gps.setPosixTZ(tz);
        }
#endif
        // Warn if timezone suggests a different radio region
        uint8_t tzRegion = TIMEZONE_TABLE[tzIdx].radioRegion;
        if (tzRegion != userConfig.settings().radioRegion) {
            char msg[64];
            snprintf(msg, sizeof(msg), "TZ suggests %s region", REGION_LABELS[tzRegion]);
            ui.lvStatusBar().showToast(msg, 3000);
            Serial.printf("[REGION] Timezone suggests %s, current is %s\n",
                REGION_LABELS[tzRegion], REGION_LABELS[userConfig.settings().radioRegion]);
        }
        goHome();
    });

    // Name input screen (first boot only — when no display name is set)
    lvNameInputScreen.setDoneCallback([showTimezone](const String& name) {
        String finalName = name;
        if (finalName.isEmpty()) {
            // Auto-generate: Ratspeak.org-xxx (first 3 chars of LXMF dest hash)
            String dh = rns.destinationHashHex();
            finalName = "Ratspeak.org-" + dh.substring(0, 3);
        }
        userConfig.settings().displayName = finalName;
        bool saved = userConfig.save(sdStore, flash);
        if (!saved) {
            Serial.println("[BOOT] Display name save failed; staying in setup");
            ui.lvStatusBar().showToast("Save failed; storage unavailable", 3000);
            return;
        }
        // Also save to active identity slot
        if (identityMgr.activeIndex() >= 0) {
            identityMgr.setDisplayName(identityMgr.activeIndex(), finalName);
        }
        Serial.printf("[BOOT] Display name set: '%s'\n", finalName.c_str());

        // Next step: timezone selection (or home if already set)
        showTimezone();
    });

    if (sdHadExistingData && !userConfig.settings().sdStorageEnabled) {
        ui.setScreen(&lvDataCleanScreen);
        Serial.println("[BOOT] Existing SD data found; waiting for user choice");
    } else if (userConfig.settings().displayName.isEmpty()) {
        // First boot — go to name input
        ui.setScreen(&lvNameInputScreen);
        Serial.println("[BOOT] Showing name input screen");
    } else if (!userConfig.settings().timezoneSet) {
        // Name set but timezone not — show timezone picker
        lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx);
        ui.setScreen(&lvTimezoneScreen);
        Serial.println("[BOOT] Showing timezone selection (name already set)");
    } else {
        // Everything configured — go straight to home
        goHome();
    }

    // Clear boot loop counter — we survived!
    {
        Preferences prefs;
        if (prefs.begin("ratdeck", false)) {
            prefs.putInt("bootc", 0);
            prefs.end();
        }
    }

    if (userConfig.settings().keyboardAutoOn) {
        // We are in ACTIVE power state here, switch keyboard backlight ON
        keyboard.backlightOn();
    }

    Serial.println("[BOOT] rsDeck ready");
    Serial.printf("[BOOT] Summary: radio=%s flash=%s sd=%s\n",
                  radioOnline ? "ONLINE" : "OFFLINE",
                  flash.isReady() ? "OK" : "FAIL",
                  sdStore.isReady() ? "OK" : "FAIL");
}

// =============================================================================
// Main Loop
// =============================================================================

void loop() {
    handleSerialCommands();

    // 1. Input polling
    bool screenWasOn = powerMgr.isScreenOn();
    inputManager.update();
    bool wakeOnlyInput = !screenWasOn && inputManager.hadStrongActivity();
    if (inputManager.hadStrongActivity()) {
        powerMgr.activity();       // Keyboard/touch: wake from any state
    } else if (inputManager.hadActivity()) {
        powerMgr.weakActivity();   // Trackball: wake from dim only
    }

    // 2. Long-press dispatch — screen blanking is the default if no screen consumes it
    if (inputManager.hadLongPress()) {
        if (!ui.handleLongPress()) {
            powerMgr.forceScreenOff();
        }
    }

    // 3. Key event dispatch
    if (inputManager.hasKeyEvent() && !wakeOnlyInput) {
        const KeyEvent& evt = inputManager.getKeyEvent();

        // Help overlay intercepts all keys when visible
        if (lvHelpOverlay.isVisible()) {
            lvHelpOverlay.handleKey(evt);
        }
        // QR overlay also dismisses on any keypress while visible
        else if (lvQrOverlay.isVisible()) {
            lvQrOverlay.handleKey(evt);
        }
        else {
            // Screen-local input owns the keyboard. This keeps message and
            // settings text entry from being preempted by global shortcuts.
            bool consumed = ui.handleKey(evt);
            if (!consumed) {
                bool hotkeyAllowed = !ui.isBootMode() || (evt.ctrl && evt.character == 'h');
                bool hotkeyConsumed = hotkeyAllowed && hotkeys.process(evt);
                if (!hotkeyConsumed) {

                    // Feed to LVGL input system only if the screen didn't consume it
                    LvInput::feedKey(evt);

                    // Tab cycling: ,=left /=right OR trackball left/right (only if screen didn't consume)
                    if (!evt.ctrl && !ui.isBootMode()) {
                        bool tabLeft  = (evt.character == ',') || evt.left;
                        bool tabRight = (evt.character == '/') || evt.right;
                        if (tabLeft) {
                            ui.lvTabBar().cycleTab(-1);
                            int tab = ui.lvTabBar().getActiveTab();
                            if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]);
                        }
                        if (tabRight) {
                            ui.lvTabBar().cycleTab(1);
                            int tab = ui.lvTabBar().getActiveTab();
                            if (lvTabScreens[tab]) ui.setScreen(lvTabScreens[tab]);
                        }
                    }
                }
            }
        }
    }

    // 3. LVGL timer handler — 30 FPS active, 5 FPS dimmed.
    // Bypass the throttle on input activity so a keypress/scroll renders this
    // iteration instead of waiting up to a full frame interval.
    {
        unsigned long now = millis();
        unsigned long lvglInterval = powerMgr.isDimmed() ? 200 : LVGL_INTERVAL_MS;
        bool inputBurst = inputManager.hadActivity();
        if (powerMgr.isScreenOn() && (inputBurst || now - lastLvglTime >= lvglInterval)) {
            lastLvglTime = now;
            lv_timer_handler();
        }
    }

    // 4. Reticulum loop (radio RX via LoRaInterface) — throttle to ~100Hz
    unsigned long rnsDuration = 0;
    {
        static unsigned long lastRNS = 0;
        unsigned long now = millis();
        if (now - lastRNS >= 10) {
            lastRNS = now;
            unsigned long rnsStart = millis();
            rns.loop();
            rnsDuration = millis() - rnsStart;
        }
    }

    // 4.5 Keep LVGL responsive after heavy RNS processing (announce floods)
    if (rnsDuration > LVGL_INTERVAL_MS && powerMgr.isScreenOn()) {
        lv_timer_handler();
    }

    if (bootComplete && bootAnnouncePending && (long)(millis() - bootAnnounceAt) >= 0) {
        bootAnnounceAttempts++;
        if (announceWithName(true)) {
            bootAnnouncePending = false;
            lastAutoAnnounce = millis();
            Serial.println("[BOOT] Startup announce sent");
        } else if (bootAnnounceAttempts < BOOT_ANNOUNCE_MAX_ATTEMPTS) {
            bootAnnounceAt = millis() + BOOT_ANNOUNCE_DELAY_MS;
            Serial.printf("[BOOT] Startup announce retry scheduled (%u/%u)\n",
                          (unsigned)bootAnnounceAttempts,
                          (unsigned)BOOT_ANNOUNCE_MAX_ATTEMPTS);
        } else {
            bootAnnouncePending = false;
            Serial.println("[BOOT] Startup announce skipped after retries");
        }
    }

    // 5. Auto-announce every 30-360 minutes from boot. Manual announces do
    // not reset this schedule.
    const unsigned long announceInterval = (unsigned long)userConfig.settings().announceInterval * 60000; // m -> ms
    if (bootComplete && millis() - lastAutoAnnounce >= announceInterval) {
        lastAutoAnnounce = millis();
        if (rns.loraInterface() && rns.loraInterface()->airtimeUtilization() > LoRaInterface::AIRTIME_THROTTLE) {
            Serial.println("[AUTO] Skipping announce: LoRa airtime > 25%");
        } else {
            announceWithName(!powerMgr.isScreenOn());
            Serial.println("[AUTO] Periodic announce");
        }
    }

    // 6. LXMF outgoing queue + announce manager deferred saves
    lxmf.loop();
    if (announceManager) announceManager->loop();
    audio.loop();

    // 7. WiFi STA connection handler
    if (wifiSTAStarted) {
        if (wifiNeedsReconnect.load() && WiFi.status() != WL_CONNECTED &&
            (long)(millis() - wifiReconnectAt.load()) >= 0) {
            wifiNeedsReconnect.store(false);
            uint8_t attempt = wifiReconnectAttempt.load();
            Serial.printf("[WIFI] Reconnect attempt #%u\n", (unsigned)attempt);
            uint8_t result = wifiMulti.run(2000);
            if (result != WL_CONNECTED && WiFi.status() != WL_CONNECTED &&
                !wifiNeedsReconnect.load()) {
                scheduleWiFiReconnect();
            }
        }
        bool connected = (WiFi.status() == WL_CONNECTED);
        if (connected && !wifiSTAConnected) {
            wifiSTAConnected = true;
            ui.lvStatusBar().setWiFiActive(true);
            Serial.printf("[WIFI] STA connected: %s\n", WiFi.localIP().toString().c_str());

            // NTP time sync (DST-aware POSIX TZ string)
            {
                const char* tz = currentPosixTZ();
                configTzTime(tz, "pool.ntp.org", "time.nist.gov");
                Serial.printf("[NTP] Time sync started (TZ=%s)\n", tz);
            }

            // Recreate TCP clients on every WiFi connect (old clients may have stale sockets)
            reloadTCPClients();
            // Arm AutoInterface deferred-start; SLAAC needs ~1.5–10s to assign
            // a link-local IPv6 address, so we don't start the interface here.
            // Trigger link-local creation AFTER association (calling
            // esp_netif_create_ip6_linklocal pre-association is a no-op on
            // some Arduino-ESP32 versions).
            if (userConfig.settings().autoIfaceEnabled) {
                WiFi.enableIpV6();
                autoIfaceDeferredStart = true;
                autoIfaceDeferredAt = millis();
            }
        } else if (!connected && wifiSTAConnected) {
            wifiSTAConnected = false;
            ui.lvStatusBar().setWiFiActive(false);
            ui.lvStatusBar().setTCPConnected(false);
            // Stop and deregister TCP clients cleanly
            for (auto& iface : tcpIfaces) {
                RNS::Transport::deregister_interface(iface);
            }
            for (auto* tcp : tcpClients) {
                retireTCPClient(tcp);
            }
            tcpClients.clear();
            tcpIfaces.clear();
            Serial.println("[WIFI] STA disconnected, TCP interfaces deregistered");
            autoIface.stop();
            autoIfaceDeferredStart = false;
        }
    }

    // 7.6. AutoInterface deferred start — fire once SLAAC assigns a link-local
    // IPv6 address.  Arduino's IPv6Address::toString returns the expanded
    // form ("0000:0000:..." for unset; "fe80:0000:..." once SLAAC completes),
    // so check the prefix bytes directly: link-local is fe80::/10.
    if (autoIfaceDeferredStart) {
        unsigned long elapsed = millis() - autoIfaceDeferredAt;
        if (elapsed >= 1500) {
            IPv6Address ll = WiFi.localIPv6();
            bool isLinkLocal = (ll[0] == 0xfe) && ((ll[1] & 0xc0) == 0x80);
            if (isLinkLocal) {
                autoIfaceDeferredStart = false;
                esp_netif_t* sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
                uint32_t scope = sta ? esp_netif_get_netif_impl_index(sta) : 1;
                autoIface.start(
                    userConfig.settings().autoIfaceGroupId.c_str(),
                    userConfig.settings().autoIfaceMaxPeers,
                    ll.toString(),
                    scope);
            } else if (elapsed >= 10000) {
                autoIfaceDeferredStart = false;
                Serial.println("[AUTOIFACE] SLAAC timeout — no link-local after 10s");
            }
        }
    }

    // 7.7. AutoInterface link-local rotation watch — covers SLAAC privacy
    // address rotation while STA stays associated.  notify_link_change()
    // is idempotent in the library, so polling here is cheap (string
    // compare, no socket churn) and only does real work on actual change.
    if (autoIface.isOnline() && wifiSTAConnected &&
        millis() - lastAutoIfaceLinkCheck >= 2000) {
        lastAutoIfaceLinkCheck = millis();
        IPv6Address ll = WiFi.localIPv6();
        bool isLinkLocal = (ll[0] == 0xfe) && ((ll[1] & 0xc0) == 0x80);
        if (isLinkLocal) {
            esp_netif_t* sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
            uint32_t scope = sta ? esp_netif_get_netif_impl_index(sta) : 1;
            autoIface.notifyLinkChange(ll.toString(), scope);
        }
    }

    // 7.8. Deferred TCP reload from Settings. Avoid tearing down/recreating
    // Transport interfaces inside the LVGL key event path.
    if (tcpReloadRequested) {
        tcpReloadRequested = false;
        Serial.println("[TCP] Applying deferred settings reload...");
        reloadTCPClients();
        if (announceManager) announceManager->clearTransientNodes();
    }

    // 8. WiFi + TCP loops (with global budget) — skip only if RNS severely overloaded
    {
        drainRetiredTCPClients();
        bool skipTcp = (rnsDuration > 500);
        if (skipTcp) diagTcpSkipEvents++;
        if (!skipTcp && wifiImpl) wifiImpl->loop();
        if (!skipTcp) {
            unsigned long tcpBudgetStart = millis();
            for (auto* tcp : tcpClients) {
                if (millis() - tcpBudgetStart >= TCP_GLOBAL_BUDGET_MS) break;
                tcp->loop();
                yield();
            }
        }
        // AutoInterface always runs — its loop is non-blocking, capped at 4
        // packets per socket per call, time-gated for announces/peer-jobs.
        // Skipping it under TCP load causes peers to time out (22 s silence
        // window) when a TCP flood holds the loop above the skip threshold.
        autoIface.loop();
    }

    // 9. BLE loops
#if HAS_BLE
    bleInterface.loop();
    bleSideband.loop();
#endif

    // 9.5. GPS poll (non-blocking, reads available UART bytes)
#if HAS_GPS
    if (userConfig.settings().gpsTimeEnabled) {
        gps.loop();
    }
#endif

    // 10. Power management
    powerMgr.loop();

    // 11. Periodic status bar update (1 Hz) + render
    if (millis() - lastStatusUpdate >= STATUS_UPDATE_MS) {
        lastStatusUpdate = millis();
        if (powerMgr.isScreenOn()) {
            ui.lvStatusBar().setBatteryPercent(powerMgr.batteryPercent());
            // Update TCP connection indicator
            bool anyTcpUp = false;
            for (auto* tcp : tcpClients) {
                if (tcp && tcp->isConnected()) { anyTcpUp = true; break; }
            }
            ui.lvStatusBar().setTCPConnected(anyTcpUp);
            ui.lvStatusBar().setAutoIfacePeers(
                autoIface.isOnline() ? (int)autoIface.peerCount() : -1);
#if HAS_GPS
            if (userConfig.settings().gpsTimeEnabled) {
                ui.lvStatusBar().setGPSFix(gps.hasTimeFix());
            }
#endif
            // Update clock display (shows time from any valid source: GPS, NTP, etc.)
            ui.lvStatusBar().setUse24Hour(userConfig.settings().use24HourTime);
            ui.lvStatusBar().updateTime();
            ui.update();
        }
    }



    // 12.5. RSSI monitor (non-blocking, one sample per loop iteration)
    if (rssiMonitorActive && radioOnline) {
        unsigned long now = millis();
        if (now - rssiMonitorStart >= 5000) {
            rssiMonitorActive = false;
            Serial.printf("[RSSI] Done: %d samples, min=%d max=%d dBm\n",
                          rssiSampleCount, rssiMinVal, rssiMaxVal);
        } else if (now - rssiLastSample >= 100) {
            rssiLastSample = now;
            int rssi = radio.currentRssi();
            if (rssi < rssiMinVal) rssiMinVal = rssi;
            if (rssi > rssiMaxVal) rssiMaxVal = rssi;
            rssiSampleCount++;
            Serial.printf("[RSSI] %d dBm\n", rssi);
        }
    }

    // 13. Heartbeat for crash diagnosis
    {
        unsigned long cycleTime = millis() - loopCycleStart;
        if (cycleTime > maxLoopTime) maxLoopTime = cycleTime;

        if (millis() - lastHeartbeat >= HEARTBEAT_INTERVAL_MS) {
            lastHeartbeat = millis();
            Serial.printf("[HEART] heap=%lu psram=%lu min=%lu loop=%lums nodes=%d paths=%d links=%d lxmfQ=%d up=%lus radio=%s sd=%s flash=%s\n",
                          (unsigned long)ESP.getFreeHeap(),
                          (unsigned long)ESP.getFreePsram(),
                          (unsigned long)ESP.getMinFreeHeap(),
                          maxLoopTime,
                          announceManager ? announceManager->nodeCount() : 0,
                          (int)rns.pathCount(),
                          (int)rns.linkCount(),
                          lxmf.queuedCount(),
                          millis() / 1000,
                          radioOnline ? "ON" : "OFF",
                          sdStore.isReady() ? "OK" : "FAIL",
                          flash.isReady() ? "OK" : "FAIL");
            // Diagnostic: show registered transport interfaces and TCP connection status
            {
                auto& ifaces = RNS::Transport::get_interfaces();
                int tcpUp = 0;
                int tcpRx = 0;
                for (auto* tcp : tcpClients) {
                    if (tcp && tcp->isConnected()) tcpUp++;
                    if (tcp) tcpRx += tcp->hubRxCount();
                }
                Serial.printf("[HEART-DIAG] ifaces=%d tcp=%d/%d wifi=%s autoiface=%s peers=%u\n",
                    (int)ifaces.size(), tcpUp, (int)tcpClients.size(),
                    wifiSTAConnected ? "STA" : (wifiImpl ? "AP" : "OFF"),
                    autoIface.isOnline() ? "ON" : "off",
                    (unsigned)autoIface.peerCount());
                Serial.printf("[LXMF-DIAG] tcp_rx=%d tcp_skip=%lu ann_filt=%lu\n",
                    tcpRx, (unsigned long)diagTcpSkipEvents,
                    (unsigned long)rns.announceFilterCount());
                diagTcpSkipEvents = 0;
            }
#if HAS_GPS
            if (userConfig.settings().gpsTimeEnabled) {
                Serial.printf("[GPS] sats=%d timeFix=%s locFix=%s syncs=%lu chars=%lu\n",
                    gps.satellites(),
                    gps.hasTimeFix() ? "YES" : "NO",
                    gps.hasLocationFix() ? "YES" : "NO",
                    (unsigned long)gps.timeSyncCount(),
                    (unsigned long)gps.charsProcessed());
            }
#endif
            maxLoopTime = 0;
        }
    }
    loopCycleStart = millis();

    yield();
}
