#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include <TouchDrvCSTXXX.hpp>
#include <mbedtls/sha256.h>
#include <mbedtls/base64.h>
#include <Ed25519.h>
#include <sodium.h>
#include <vector>
#define XPOWERS_CHIP_AXP2101
#include "XPowersLib.h"
#include "shine_secret_generation.h"
#include <stdint.h>

extern "C" {
#include "../../official-demo/examples/Arduino-v3.3.5/libraries/lvgl/src/extra/libs/qrcode/lv_qrcode.h"
}

#define PIN_LCD_CS    12
#define PIN_LCD_SCLK  38
#define PIN_LCD_D0     4
#define PIN_LCD_D1     5
#define PIN_LCD_D2     6
#define PIN_LCD_D3     7
#define PIN_LCD_RST    2
#define PIN_I2C_SDA   15
#define PIN_I2C_SCL   14
#define PIN_TP_INT    11

#define DISP_W 480
#define DISP_H 480
#define LVGL_TICK_MS 2
#define SWIPE_THRESHOLD 48
#define TAP_CANCEL_THRESHOLD 18
#define MAX_SCAN_RESULTS 8
#define MAX_SAVED_WIFI_NETWORKS 8

extern "C" {
typedef int32_t fe25519[10];
typedef struct {
  fe25519 X;
  fe25519 Y;
  fe25519 Z;
  fe25519 T;
} ge25519_p3;
int ge25519_frombytes(ge25519_p3 *h, const unsigned char *s);
}
#define WIFI_CONNECT_TIMEOUT_MS 12000
#define WIFI_RECONNECT_FAST_MS 10000
#define WIFI_RECONNECT_SLOW_MS 30000
#define WIFI_RECONNECT_SLOW_AFTER_MS 90000
#define HOME_REFRESH_MS 1000
#define ACCOUNT_CHECK_RETRY_MS 15000
#define SHINE_PING_INTERVAL_MS 60000
#define SHINE_RECONNECT_MIN_MS 10000
#define SHINE_RECONNECT_MAX_MS 30000
#define SHINE_RPC_TIMEOUT_MS 2500
#define SHINE_WS_CONNECT_TIMEOUT_MS 1500
#define TEXT_EDIT_PANEL_X 10
#define TEXT_EDIT_PANEL_Y 112
#define TEXT_EDIT_PANEL_W 460
#define TEXT_EDIT_PANEL_H 330
#define TEST_VERSION "SHiNE homeserver (v.0.18)"

static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
static const char *kShineUsersUserPdaSeedPrefix = "user_login=";
static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
static const char *kSystemProgramId = "11111111111111111111111111111111";
static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111";
static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111";
static const char *kUsersSeedPrefix = "user_login=";
static const char *kUsersEconomyConfigSeed = "shine_users_economy_config";
static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault";
static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK";
static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress";
static const uint8_t kBlockTypeRootKey = 1;
static const uint8_t kBlockTypeDeviceKey = 2;
static const uint8_t kBlockTypeBlockchainRegistry = 3;
static const uint8_t kBlockTypeServerProfile = 30;
static const uint8_t kBlockTypeAccessServers = 40;
static const uint8_t kBlockTypeSessions = 50;
static const uint8_t kBlockTypeTrustedState = 70;
static const uint8_t kSessionTypeHomeserver = 100;
static const char *kSessionClientPlatformEsp32 = "ESP32";

enum Screen {
  SCREEN_HOME,
  SCREEN_WALLET_QR,
  SCREEN_SETTINGS_MENU,
  SCREEN_WIFI,
  SCREEN_SERVER,
  SCREEN_ACCOUNT,
  SCREEN_ACCOUNT_HOMESERVER,
  SCREEN_ACCOUNT_SECRET,
  SCREEN_SECRET_SHOW,
  SCREEN_SECRET_GENERATE_INFO,
  SCREEN_SECRET_GENERATE_RUNNING,
  SCREEN_SECRET_GENERATE_CANCEL_CONFIRM,
  SCREEN_TEXT_EDIT,
  SCREEN_REGISTER_ACCOUNT_CONFIRM,
  SCREEN_REGISTER_ACCOUNT_RESULT,
  SCREEN_HOMESERVER_PDA_CONFIRM,
  SCREEN_HOMESERVER_PDA_RESULT,
};

enum SwipeDirection {
  SWIPE_NONE,
  SWIPE_LEFT,
  SWIPE_RIGHT,
  SWIPE_UP,
  SWIPE_DOWN,
};

enum ActionId {
  ACTION_NONE,
  ACTION_OPEN_SETTINGS,
  ACTION_OPEN_WALLET_QR,
  ACTION_OPEN_WIFI,
  ACTION_OPEN_SERVER,
  ACTION_OPEN_ACCOUNT,
  ACTION_BACK_HOME,
  ACTION_BACK_SETTINGS,
  ACTION_WIFI_SELECT_NETWORK,
  ACTION_WIFI_CLEAR,
  ACTION_SERVER_EDIT_SOLANA,
  ACTION_SERVER_EDIT_SHINE,
  ACTION_ACCOUNT_EDIT_LOGIN,
  ACTION_ACCOUNT_EDIT_HOMESERVER,
  ACTION_ACCOUNT_EDIT_SECRET,
  ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT,
  ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL,
  ACTION_SECRET_SHOW,
  ACTION_SECRET_MANUAL,
  ACTION_SECRET_GENERATE,
  ACTION_SECRET_GENERATE_START,
  ACTION_SECRET_GENERATE_CANCEL,
  ACTION_SECRET_GENERATE_CANCEL_YES,
  ACTION_SECRET_GENERATE_CANCEL_NO,
  ACTION_BACK_SECRET_MENU,
  ACTION_BACK_ACCOUNT,
  ACTION_REFRESH_BALANCE,
  ACTION_REGISTER_ACCOUNT,
  ACTION_REGISTER_ACCOUNT_EXECUTE,
  ACTION_HOMESERVER_PDA_ACTION,
  ACTION_HOMESERVER_PDA_ADD_EXECUTE,
  ACTION_HOMESERVER_PDA_FIX_EXECUTE,
  ACTION_EDITOR_SAVE,
  ACTION_EDITOR_CANCEL,
};

enum WifiViewMode {
  WIFI_VIEW_OVERVIEW,
  WIFI_VIEW_SCAN_RESULTS,
};

enum EditContext {
  EDIT_CONTEXT_NONE,
  EDIT_CONTEXT_WIFI_PASSWORD,
  EDIT_CONTEXT_SOLANA_RPC,
  EDIT_CONTEXT_SHINE_SERVER,
  EDIT_CONTEXT_LOGIN,
  EDIT_CONTEXT_HOMESERVER,
  EDIT_CONTEXT_SECRET_MANUAL,
  EDIT_CONTEXT_SECRET_GENERATE_PASSWORD,
};

enum KeyboardMode {
  KEYBOARD_MODE_ALPHA,
  KEYBOARD_MODE_SYMBOLS,
};

enum AccountPdaStatus {
  ACCOUNT_PDA_UNKNOWN,
  ACCOUNT_PDA_OK,
  ACCOUNT_PDA_NOT_FOUND,
  ACCOUNT_PDA_MISMATCH,
};

struct ShinePdaSessionRecord {
  uint8_t sessionType = 0;
  uint8_t sessionVersion = 0;
  String sessionName;
  uint8_t sessionPubKey32[32] = {};
};

struct ShinePdaUserState {
  bool found = false;
  String login;
  uint64_t createdAtMs = 0;
  uint64_t updatedAtMs = 0;
  uint32_t recordNumber = 0;
  uint8_t prevRecordHash32[32] = {};
  uint8_t blockchainType = 1;
  bool isServer = false;
  uint8_t addressFormatType = 0;
  uint8_t addressFormatVersion = 0;
  String serverAddress;
  std::vector<String> syncServers;
  std::vector<String> accessServers;
  uint8_t sessionsMode = 1;
  uint8_t trustedCount = 0;
  uint8_t rootKey32[32] = {};
  uint8_t deviceKey32[32] = {};
  uint8_t blockchainKey32[32] = {};
  String blockchainName;
  uint64_t paidLimitBytes = 0;
  uint64_t usedBytes = 0;
  uint32_t lastBlockNumber = 0;
  uint8_t lastBlockHash32[32] = {};
  uint8_t lastBlockSignature64[64] = {};
  String arweaveTxId;
  std::vector<ShinePdaSessionRecord> sessions;
};

struct SimpleWebSocketClient {
  WiFiClientSecure client;
  bool connected = false;
  String host;
  String path;
  uint16_t port = 0;
};

static const char *kMenuItems[] = {"1. Wi-Fi", "2. Server", "3. Account"};
static const size_t kMenuCount = sizeof(kMenuItems) / sizeof(kMenuItems[0]);

static lv_disp_draw_buf_t gDrawBuf;
static lv_color_t *gBuf1 = nullptr;
static lv_color_t *gBuf2 = nullptr;
static lv_obj_t *gRoot = nullptr;
static lv_obj_t *gInputTextArea = nullptr;
static uint8_t *gWalletQrCanvasBuffer = nullptr;

Arduino_DataBus *gBus = new Arduino_ESP32QSPI(
    PIN_LCD_CS, PIN_LCD_SCLK, PIN_LCD_D0, PIN_LCD_D1, PIN_LCD_D2, PIN_LCD_D3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
    gBus, PIN_LCD_RST, 0, DISP_W, DISP_H, 0, 0, 0, 0);
TouchDrvCST92xx gTouch;
XPowersPMU gPower;
Preferences gPrefs;

static Screen gCurrentScreen = SCREEN_HOME;
static int gSettingsScrollIndex = 0;
static bool gTouchDown = false;
static int16_t gTouchStartX = 0;
static int16_t gTouchStartY = 0;
static int16_t gTouchLastX = 0;
static int16_t gTouchLastY = 0;
static SwipeDirection gPendingSwipe = SWIPE_NONE;
static bool gBlockClick = false;
static bool gSuppressTouchUntilRelease = false;
static uint32_t gTouchSequence = 0;
static uint32_t gLastHandledTouchSequence = 0;
static bool gWalletQrTapReturnPending = false;

static String gWifiSavedSsid;
static String gWifiSavedPassword;
static String gWifiSelectedSsid;
static String gKnownWifiSsids[MAX_SAVED_WIFI_NETWORKS];
static String gKnownWifiPasswords[MAX_SAVED_WIFI_NETWORKS];
static String gWifiStatusMessage = "No Wi-Fi configured";
static String gScanResults[MAX_SCAN_RESULTS];
static int gScanResultCount = 0;
static WifiViewMode gWifiViewMode = WIFI_VIEW_OVERVIEW;

static String gSolanaRpcUrl = "https://api.devnet.solana.com";
static String gShineServerUrl = "https://shineup.me";
static String gServerStatusMessage = "Edit RPC or shine host";
static String gLoginValue;
static String gHomeserverValue = "homeserver1";
static bool gSecretConfigured = false;
static String gSecretBase58;
static uint8_t gSecretBytes[32] = {};
static String gAccountStatusMessage = "Edit account fields";
static String gBalanceStatusMessage = "Balance: tap to load";
static bool gBalanceAutoRefreshPending = false;
static unsigned long gLastBalanceAutoRefreshAttemptMs = 0;
static bool gWifiKnownGood = false;
static bool gWifiReconnectEnabled = false;
static bool gWifiOperationBusy = false;
static unsigned long gWifiDisconnectedSinceMs = 0;
static unsigned long gWifiLastReconnectAttemptMs = 0;
static wl_status_t gLastWifiStatus = WL_IDLE_STATUS;
static bool gPowerReady = false;
static AccountPdaStatus gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN;
static String gAccountPdaStatusMessage = "Account not checked";
static bool gAccountCheckPending = true;
static unsigned long gLastAccountCheckMs = 0;
static bool gShowRegisterAccountButton = false;
static bool gShowHomeserverPdaActionButton = false;
static String gHomeserverPdaActionReason;
static String gUserPdaAddress;
static String gRegistrationSignature;
static String gShineStatusLine = "SHiNE: account not configured";
static String gRegisterConfirmMessage;
static String gRegisterConfirmBalanceLine;
static String gRegisterConfirmPdaLine;
static String gRegisterConfirmHomeserverLine;
static bool gRegisterConfirmCanSubmit = false;
static String gRegisterResultMessage;
static String gRegisterResultDetails;
static bool gRegisterResultSuccess = false;
static String gHomeserverPdaActionMessage;
static String gHomeserverPdaActionDetail;
static bool gHomeserverPdaCanAdd = false;
static bool gHomeserverPdaCanFix = false;
static String gHomeserverPdaResultMessage;
static String gHomeserverPdaResultDetails;
static bool gHomeserverPdaResultSuccess = false;
static String gLastRegisterDiagStatus = "none";
static String gLastRegisterDiagSummary;
static String gLastRegisterDiagDetails;
static String gLastRegisterDiagTime;
static String gRegisterTriggerSource = "manual";
static String gSerialCommandBuffer;
static String gShineSessionId;
static String gShineSessionKey;
static String gShineStoragePwd;
static bool gShineAuthenticated = false;
static unsigned long gLastShineAttemptMs = 0;
static unsigned long gLastShinePingMs = 0;
static unsigned long gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
static uint32_t gWsRequestCounter = 1;
static int64_t gShineServerTimeOffsetMs = 0;
static SimpleWebSocketClient gShineWs;

struct DerivedKeyInfo {
  String title;
  String formula;
  String value;
};

static String gRootPubB58;
static String gRootPrivB58;
static String gBlockchainPubB58;
static String gBlockchainPrivB58;
static String gDevicePubB58;
static String gDevicePrivB58;
static String gHomeserverPubB58;
static String gHomeserverPrivB58;

static EditContext gEditContext = EDIT_CONTEXT_NONE;
static Screen gEditReturnScreen = SCREEN_HOME;
static String gEditTitle;
static String gEditHint;
static String gEditValue;
static int gKeyboardPage = 0;
static bool gEditIsPassword = false;
static KeyboardMode gKeyboardMode = KEYBOARD_MODE_ALPHA;
static bool gKeyboardShift = false;

static void rebuildScreen();
static void showScreen(Screen screen);
static void handleSwipe(SwipeDirection swipe);
static void loadPrefs();
static void saveWifiPrefs();
static void clearWifiPrefs();
static void clearSavedWifiList();
static int findKnownWifiIndex(const String &ssid);
static String savedPasswordForSsid(const String &ssid);
static void upsertKnownWifi(const String &ssid, const String &password);
static bool refreshWalletBalance(String &messageOut);
static void saveServerPrefs();
static void saveAccountPrefs();
static void beginSavedWifi();
static void scanWifiNetworks();
static bool connectWifiNow(const String &ssid, const String &password);
static String wifiHomeStatus();
static String wifiSavedLabel();
static String wifiHomeSummary();
static void updateWifiReconnectState();
static void manageWifiReconnect();
static void openEditor(EditContext context,
                       Screen returnScreen,
                       const String &title,
                       const String &hint,
                       const String &value,
                       bool isPassword);
static void applyEditorValue();
static bool isTextEditKeyboardSwipeArea(int16_t x, int16_t y);
static void syncEditValueFromTextarea();
static void keepCursorAtEnd();
static void restoreTextareaFromEditValue();
static void refreshDerivedKeys();
static void clearDerivedKeys();
static String loginDisplayValue();
static String homeserverDisplayValue();
static String homeSecretStatus();
static String secretButtonValue();
static void clearSecretValue();
static void setSecretValue(const uint8_t *bytes32);
static void initPowerManagement();
static int batteryPercentValue();
static int wifiSignalLevel();
static void drawTopStatusIndicators();
static void actionButtonCb(lv_event_t *event);
static void markAccountStateDirty();
static void clearShineSessionState(bool clearStoredSession);
static void saveShineSessionPrefs();
static String normalizeLoginValue(const String &value);
static bool base58ToFixed32(const String &value, uint8_t out[32]);
static bool base64DecodeStd(const String &value, std::vector<uint8_t> &out);
static String bytesToBase64String(const uint8_t *data, size_t len);
static String jsonEscape(const String &value);
static bool jsonStringField(const String &json, const String &field, String &valueOut);
static bool jsonBoolField(const String &json, const String &field, bool &valueOut);
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut);
static String shineWsUrl();
static String shineHomeLine();
static String balanceHomeLine();
static String walletQrUri();
static String walletQrAddressLine();
static uint64_t shineNowMs();
static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut);
static void shortVecEncode(size_t value, std::vector<uint8_t> &out);
static void pushU32LE(std::vector<uint8_t> &out, uint32_t value);
static void pushU64LE(std::vector<uint8_t> &out, uint64_t value);
static void pushStrU8(std::vector<uint8_t> &out, const String &value);
static void pushFixed(std::vector<uint8_t> &out, const uint8_t *data, size_t len);
static String bytesToBase58(const uint8_t *data, size_t len);
static String buildBaseRpcRequest(const char *method, const String &paramsJson);
static bool rpcCallSolana(const char *method, const String &paramsJson, String &payloadOut);
static bool rpcResponseHasError(const String &payload);
static bool getLatestBlockhashBytes(uint8_t out[32], String &blockhashB58, String &messageOut);
static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &messageOut);
static bool loadUsersEconomyConfigState(const String &economyConfigAddress,
                                        uint64_t &registrationFeeLamportsOut,
                                        uint64_t &lamportsPerLimitStepOut,
                                        uint64_t &startBonusLimitOut,
                                        String &messageOut);
static bool loadAccountOwner(const String &address, String &ownerOut, bool &existsOut, String &messageOut);
static bool extractRpcErrorSummary(const String &payload, String &messageOut);
static String compactRpcLogs(const String &payload, int maxLines = 3);
static bool simulateTransactionPayload(const String &txBase64, String &payloadOut);
static bool simulateTransactionForError(const String &txBase64, String &messageOut);
static std::vector<uint8_t> buildLastBlockStateBytes(const String &login,
                                                     const String &blockchainName,
                                                     uint32_t lastBlockNumber = 0,
                                                     const uint8_t *lastBlockHash32 = nullptr,
                                                     uint64_t usedBytes = 0);
static std::vector<uint8_t> buildUnsignedCreateRecord(
    const String &login,
    const String &blockchainName,
    const String &serverAddress,
    const uint8_t rootPub[32],
    const uint8_t devicePub[32],
    const uint8_t blockchainPub[32],
    const uint8_t lastBlockSignature[64],
    uint64_t paidLimitBytes,
    uint64_t createdAtMs);
static std::vector<uint8_t> buildCreateInstructionData(
    const String &login,
    const String &blockchainName,
    const String &serverAddress,
    const uint8_t rootPub[32],
    const uint8_t devicePub[32],
    const uint8_t blockchainPub[32],
    const uint8_t lastBlockSignature[64],
    const uint8_t rootSignature[64],
    uint64_t createdAtMs);
static std::vector<uint8_t> buildEd25519InstructionData(const uint8_t signature[64], const uint8_t publicKey[32], const uint8_t messageHash[32]);
static std::vector<uint8_t> serializeUnsignedRecordState(const ShinePdaUserState &state);
static std::vector<uint8_t> buildUpdateInstructionData(const ShinePdaUserState &state,
                                                       uint32_t nextVersion,
                                                       uint64_t updatedAtMs,
                                                       const uint8_t prevHash32[32],
                                                       const uint8_t rootSignature64[64]);
static std::vector<uint8_t> buildLegacyMessage(
    const uint8_t recentBlockhash[32],
    const uint8_t devicePub[32],
    const uint8_t userPda[32],
    const uint8_t inflowVault[32],
    const uint8_t economyConfig[32],
    const std::vector<uint8_t> &edRootData,
    const std::vector<uint8_t> &edBchData,
    const std::vector<uint8_t> &createData);
static std::vector<uint8_t> buildUpdateLegacyMessage(
    const uint8_t recentBlockhash[32],
    const uint8_t devicePub[32],
    const uint8_t userPda[32],
    const uint8_t inflowVault[32],
    const uint8_t economyConfig[32],
    const std::vector<uint8_t> &edRootData,
    const std::vector<uint8_t> &edBchData,
    const std::vector<uint8_t> &updateData);
static bool signMessageEd25519(const std::vector<uint8_t> &message, const uint8_t secretKey[64], uint8_t signature[64]);
static String encodeTransactionBase64(const uint8_t signature[64], const std::vector<uint8_t> &message);
static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut);
static bool registerHomeserverOnSolana(String &messageOut);
static void executeRegisterAccountFlow(const char *triggerSource, bool showResultScreen);
static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messageOut);
static void loadRegisterDiagDetailsFromPrefs();
static void saveRegisterDiagDetailsToPrefs(const String &details);
static void clearRegisterDiagDetailsFromPrefs();
static void saveRegisterDiag(const String &status, const String &summary, const String &details);
static void printRegisterDiagToSerial();
static void clearRegisterDiag();
static void handleUsbSerialCommands();
static void prepareRegisterAccountScreen();
static void prepareHomeserverPdaActionScreen();
static String buildSessionKeyStringFromPublicBase64(const String &pubB64);
static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]);
static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]);
static bool findProgramAddress(const std::vector<std::vector<uint8_t>> &seeds, const char *programIdB58, uint8_t out32[32]);
static bool readShineUserPda(const String &login, ShinePdaUserState &outState, String &errorOut);
static bool parseShineUserPdaBytes(const std::vector<uint8_t> &bytes, ShinePdaUserState &outState, String &errorOut);
static void refreshAccountPdaStatus();
static void manageAccountPdaRefresh();
static void saveShineConnectDiag(const String &status, const String &summary, const String &details);
static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &url, String &errorOut);
static void closeWebSocket(SimpleWebSocketClient &ws);
static bool wsSendText(SimpleWebSocketClient &ws, const String &payload);
static bool wsReadTextFrame(SimpleWebSocketClient &ws, String &messageOut, uint32_t timeoutMs);
static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const String &payloadJson, String &responseOut, uint32_t timeoutMs = SHINE_RPC_TIMEOUT_MS);
static bool ensureShineSessionAuthenticated(String &errorOut);
static void manageShineConnection();

static void lvglFlushCb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *colorP) {
  uint32_t w = area->x2 - area->x1 + 1;
  uint32_t h = area->y2 - area->y1 + 1;
#if (LV_COLOR_16_SWAP != 0)
  gfx->draw16bitBeRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#else
  gfx->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#endif
  lv_disp_flush_ready(disp);
}

static void lvglTick(void *arg) {
  LV_UNUSED(arg);
  lv_tick_inc(LVGL_TICK_MS);
}

static SwipeDirection detectSwipe(int16_t startX, int16_t startY, int16_t endX, int16_t endY) {
  int16_t dx = endX - startX;
  int16_t dy = endY - startY;
  int16_t absDx = abs(dx);
  int16_t absDy = abs(dy);

  if (absDx < SWIPE_THRESHOLD && absDy < SWIPE_THRESHOLD) {
    return SWIPE_NONE;
  }

  if (absDx >= absDy) {
    return dx > 0 ? SWIPE_RIGHT : SWIPE_LEFT;
  }

  return dy > 0 ? SWIPE_DOWN : SWIPE_UP;
}

static bool movedTooFarForTap(int16_t startX, int16_t startY, int16_t endX, int16_t endY) {
  return abs(endX - startX) >= TAP_CANCEL_THRESHOLD || abs(endY - startY) >= TAP_CANCEL_THRESHOLD;
}

static void lvglTouchRead(lv_indev_drv_t *indevDrv, lv_indev_data_t *data) {
  LV_UNUSED(indevDrv);
  int16_t x = 0;
  int16_t y = 0;
  bool touching = gTouch.getPoint(&x, &y, 1) > 0;

  if (touching) {
    if (!gTouchDown) {
      gTouchDown = true;
      gTouchSequence++;
      gTouchStartX = x;
      gTouchStartY = y;
      gBlockClick = false;
      gSuppressTouchUntilRelease = false;
    }
    gTouchLastX = x;
    gTouchLastY = y;
    if (movedTooFarForTap(gTouchStartX, gTouchStartY, gTouchLastX, gTouchLastY)) {
      gBlockClick = true;
      gSuppressTouchUntilRelease = true;
    }
    if (gSuppressTouchUntilRelease) {
      data->state = LV_INDEV_STATE_REL;
      data->point.x = gTouchLastX;
      data->point.y = gTouchLastY;
      return;
    }
    data->state = LV_INDEV_STATE_PR;
    data->point.x = x;
    data->point.y = y;
    return;
  }

  if (gTouchDown) {
    gTouchDown = false;
    gPendingSwipe = detectSwipe(gTouchStartX, gTouchStartY, gTouchLastX, gTouchLastY);
    if (gCurrentScreen == SCREEN_WALLET_QR
        && gPendingSwipe == SWIPE_NONE
        && !movedTooFarForTap(gTouchStartX, gTouchStartY, gTouchLastX, gTouchLastY)) {
      gWalletQrTapReturnPending = true;
    }
  }

  gSuppressTouchUntilRelease = false;
  gBlockClick = false;

  data->state = LV_INDEV_STATE_REL;
  data->point.x = gTouchLastX;
  data->point.y = gTouchLastY;
}

static void setRootStyle() {
  lv_obj_set_style_bg_color(gRoot, lv_color_hex(0x08111B), 0);
  lv_obj_set_style_bg_opa(gRoot, LV_OPA_COVER, 0);
  lv_obj_set_style_pad_all(gRoot, 0, 0);
  lv_obj_set_scrollbar_mode(gRoot, LV_SCROLLBAR_MODE_OFF);
}

static lv_obj_t *makeTitle(const char *text, lv_coord_t y, const lv_font_t *font) {
  lv_obj_t *label = lv_label_create(gRoot);
  lv_label_set_text(label, text);
  lv_obj_set_style_text_font(label, font, 0);
  lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0);
  lv_obj_align(label, LV_ALIGN_TOP_MID, 0, y);
  return label;
}

static lv_obj_t *makeBody(const char *text, lv_coord_t y, lv_coord_t width) {
  lv_obj_t *label = lv_label_create(gRoot);
  lv_label_set_text(label, text);
  lv_obj_set_width(label, width);
  lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP);
  lv_obj_set_style_text_font(label, &lv_font_montserrat_18, 0);
  lv_obj_set_style_text_color(label, lv_color_hex(0xD9E1EA), 0);
  lv_obj_align(label, LV_ALIGN_TOP_MID, 0, y);
  return label;
}

static void showMessageAt(const String &text, lv_coord_t y) {
  lv_obj_t *label = lv_label_create(gRoot);
  lv_label_set_text(label, text.c_str());
  lv_obj_set_width(label, 420);
  lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP);
  lv_obj_set_style_text_font(label, &lv_font_montserrat_16, 0);
  lv_obj_set_style_text_color(label, lv_color_hex(0xB8C6D3), 0);
  lv_obj_align(label, LV_ALIGN_TOP_MID, 0, y);
}

static void clearSavedWifiList() {
  for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) {
    gKnownWifiSsids[i] = "";
    gKnownWifiPasswords[i] = "";
  }
}

static int findKnownWifiIndex(const String &ssid) {
  if (ssid.isEmpty()) {
    return -1;
  }
  for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) {
    if (gKnownWifiSsids[i] == ssid) {
      return i;
    }
  }
  return -1;
}

static String savedPasswordForSsid(const String &ssid) {
  int index = findKnownWifiIndex(ssid);
  return index >= 0 ? gKnownWifiPasswords[index] : "";
}

static String splitFixedWidth(const String &value, size_t chunkSize) {
  if (chunkSize == 0 || value.isEmpty()) {
    return value;
  }

  String out;
  for (size_t i = 0; i < value.length(); ++i) {
    if (i > 0 && (i % chunkSize) == 0) {
      out += '\n';
    }
    out += value.charAt(i);
  }
  return out;
}

static void sha256calc(const uint8_t *in, size_t len, uint8_t *out32) {
  mbedtls_sha256_context ctx;
  mbedtls_sha256_init(&ctx);
  mbedtls_sha256_starts(&ctx, 0);
  mbedtls_sha256_update(&ctx, in, len);
  mbedtls_sha256_finish(&ctx, out32);
  mbedtls_sha256_free(&ctx);
}

static String base64Std(const uint8_t *data, size_t len) {
  size_t needed = ((len + 2) / 3) * 4 + 4;
  std::vector<uint8_t> out(needed, 0);
  size_t outLen = 0;
  if (mbedtls_base64_encode(out.data(), out.size(), &outLen, data, len) != 0) {
    return "";
  }
  if (outLen >= out.size()) {
    return "";
  }
  out[outLen] = '\0';
  return String(reinterpret_cast<const char *>(out.data()));
}

static String base58From32(const uint8_t *data32) {
  char out[64] = {};
  shineSecretBase58Encode(data32, 32, out, sizeof(out));
  return String(out);
}

static bool base58ToFixed32(const String &value, uint8_t out[32]) {
  size_t outLen = 32;
  String error;
  if (!shineSecretBase58Decode(value.c_str(), out, &outLen, 32, error)) {
    return false;
  }
  return outLen == 32;
}

static bool base64DecodeStd(const String &value, std::vector<uint8_t> &out) {
  out.clear();
  String normalized = value;
  normalized.trim();
  if (normalized.isEmpty()) {
    return false;
  }
  size_t maxLen = ((normalized.length() + 3) / 4) * 3 + 4;
  out.resize(maxLen);
  size_t outLen = 0;
  if (mbedtls_base64_decode(out.data(), out.size(), &outLen,
                            reinterpret_cast<const uint8_t *>(normalized.c_str()),
                            normalized.length()) != 0) {
    out.clear();
    return false;
  }
  out.resize(outLen);
  return true;
}

static String bytesToBase64String(const uint8_t *data, size_t len) {
  return base64Std(data, len);
}

static String bytesToHexString(const uint8_t *data, size_t len) {
  static const char *kHex = "0123456789abcdef";
  String out;
  out.reserve(len * 2);
  for (size_t i = 0; i < len; ++i) {
    uint8_t v = data[i];
    out += kHex[(v >> 4) & 0x0F];
    out += kHex[v & 0x0F];
  }
  return out;
}

static String normalizeLoginValue(const String &value) {
  String out = value;
  out.trim();
  out.toLowerCase();
  return out;
}

static String abbreviateValue(const String &value, size_t head = 8, size_t tail = 6) {
  if (value.length() <= head + tail + 3) {
    return value;
  }
  return value.substring(0, head) + "..." + value.substring(value.length() - tail);
}

static String jsonEscape(const String &value) {
  String out;
  out.reserve(value.length() + 8);
  for (size_t i = 0; i < value.length(); ++i) {
    char ch = value.charAt(i);
    switch (ch) {
      case '\\': out += "\\\\"; break;
      case '"': out += "\\\""; break;
      case '\n': out += "\\n"; break;
      case '\r': out += "\\r"; break;
      case '\t': out += "\\t"; break;
      default: out += ch; break;
    }
  }
  return out;
}

static bool jsonStringField(const String &json, const String &field, String &valueOut) {
  String needle = "\"" + field + "\"";
  int keyPos = json.indexOf(needle);
  if (keyPos < 0) {
    return false;
  }
  int colon = json.indexOf(':', keyPos + needle.length());
  if (colon < 0) {
    return false;
  }
  int quote = json.indexOf('"', colon + 1);
  if (quote < 0) {
    return false;
  }
  String out;
  bool escape = false;
  for (int i = quote + 1; i < (int)json.length(); ++i) {
    char ch = json.charAt(i);
    if (escape) {
      switch (ch) {
        case 'n': out += '\n'; break;
        case 'r': out += '\r'; break;
        case 't': out += '\t'; break;
        default: out += ch; break;
      }
      escape = false;
      continue;
    }
    if (ch == '\\') {
      escape = true;
      continue;
    }
    if (ch == '"') {
      valueOut = out;
      return true;
    }
    out += ch;
  }
  return false;
}

static bool jsonBoolField(const String &json, const String &field, bool &valueOut) {
  String needle = "\"" + field + "\"";
  int keyPos = json.indexOf(needle);
  if (keyPos < 0) {
    return false;
  }
  int colon = json.indexOf(':', keyPos + needle.length());
  if (colon < 0) {
    return false;
  }
  int pos = colon + 1;
  while (pos < (int)json.length() && isspace((unsigned char)json[pos])) {
    pos++;
  }
  if (json.startsWith("true", pos)) {
    valueOut = true;
    return true;
  }
  if (json.startsWith("false", pos)) {
    valueOut = false;
    return true;
  }
  return false;
}

static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut) {
  hostOut = "";
  pathOut = "/";
  portOut = 0;
  secureOut = false;

  int schemeEnd = url.indexOf("://");
  if (schemeEnd <= 0) {
    return false;
  }
  String scheme = url.substring(0, schemeEnd);
  secureOut = (scheme == "https" || scheme == "wss");
  int hostStart = schemeEnd + 3;
  int slash = url.indexOf('/', hostStart);
  String hostPort = slash >= 0 ? url.substring(hostStart, slash) : url.substring(hostStart);
  pathOut = slash >= 0 ? url.substring(slash) : "/";
  int atPos = hostPort.lastIndexOf('@');
  if (atPos >= 0) {
    hostPort = hostPort.substring(atPos + 1);
  }
  int colon = hostPort.lastIndexOf(':');
  if (colon > 0 && hostPort.indexOf(']') < 0) {
    hostOut = hostPort.substring(0, colon);
    portOut = (uint16_t)hostPort.substring(colon + 1).toInt();
  } else {
    hostOut = hostPort;
    portOut = secureOut ? 443 : 80;
  }
  hostOut.trim();
  if (pathOut.isEmpty()) {
    pathOut = "/";
  }
  return hostOut.length() > 0 && portOut > 0;
}

static String homeserverKeySuffix() {
  String name = gHomeserverValue;
  name.trim();
  if (name.isEmpty()) {
    name = "homeserver1";
  }
  return String("homeserver.key:") + name;
}

static void deriveKeyPairFromSecretSuffix(const uint8_t *secret32, const String &suffix, String &pubB58, String &privB58) {
  pubB58 = "";
  privB58 = "";
  if (!secret32) {
    return;
  }
  String material = base64Std(secret32, 32) + "|" + suffix;
  uint8_t seed[32] = {};
  uint8_t pub[32] = {};
  sha256calc(reinterpret_cast<const uint8_t *>(material.c_str()), material.length(), seed);
  Ed25519::derivePublicKey(pub, seed);
  privB58 = base58From32(seed);
  pubB58 = base58From32(pub);
}

static void clearDerivedKeys() {
  gRootPubB58 = "";
  gRootPrivB58 = "";
  gBlockchainPubB58 = "";
  gBlockchainPrivB58 = "";
  gDevicePubB58 = "";
  gDevicePrivB58 = "";
  gHomeserverPubB58 = "";
  gHomeserverPrivB58 = "";
}

static void refreshDerivedKeys() {
  clearDerivedKeys();
  if (!gSecretConfigured) {
    return;
  }
  deriveKeyPairFromSecretSuffix(gSecretBytes, "root.key", gRootPubB58, gRootPrivB58);
  deriveKeyPairFromSecretSuffix(gSecretBytes, "bch.key", gBlockchainPubB58, gBlockchainPrivB58);
  deriveKeyPairFromSecretSuffix(gSecretBytes, "dev.key", gDevicePubB58, gDevicePrivB58);
  deriveKeyPairFromSecretSuffix(gSecretBytes, homeserverKeySuffix(), gHomeserverPubB58, gHomeserverPrivB58);
}

static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) {
  return crypto_sign_seed_keypair(pub32, sec64, seed32) == 0;
}

static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) {
  if (!base58ToFixed32(seedB58, seed32)) {
    return false;
  }
  return deriveKeypairFromSeed32(seed32, pub32, sec64);
}

static void saveShineSessionPrefs() {
  if (gShineSessionId.isEmpty() || gShineSessionKey.isEmpty()) {
    gPrefs.remove("shine_sess_id");
    gPrefs.remove("shine_sess_key");
    gPrefs.remove("shine_store_pwd");
    return;
  }
  gPrefs.putString("shine_sess_id", gShineSessionId);
  gPrefs.putString("shine_sess_key", gShineSessionKey);
  gPrefs.putString("shine_store_pwd", gShineStoragePwd);
}

static void closeWebSocket(SimpleWebSocketClient &ws) {
  if (ws.client.connected()) {
    ws.client.stop();
  }
  ws.connected = false;
}

static void clearShineSessionState(bool clearStoredSession) {
  closeWebSocket(gShineWs);
  gShineAuthenticated = false;
  gLastShinePingMs = 0;
  if (clearStoredSession) {
    gShineSessionId = "";
    gShineSessionKey = "";
    gShineStoragePwd = "";
    saveShineSessionPrefs();
  }
}

static bool shouldPauseShineReconnectForUi() {
  switch (gCurrentScreen) {
    case SCREEN_SETTINGS_MENU:
    case SCREEN_WIFI:
    case SCREEN_SERVER:
    case SCREEN_ACCOUNT:
    case SCREEN_ACCOUNT_HOMESERVER:
    case SCREEN_ACCOUNT_SECRET:
    case SCREEN_SECRET_SHOW:
    case SCREEN_SECRET_GENERATE_INFO:
    case SCREEN_SECRET_GENERATE_RUNNING:
    case SCREEN_SECRET_GENERATE_CANCEL_CONFIRM:
    case SCREEN_TEXT_EDIT:
      return true;
    default:
      return false;
  }
}

static void markAccountStateDirty() {
  gAccountCheckPending = true;
  gLastAccountCheckMs = 0;
  gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN;
  gAccountPdaStatusMessage = "Account not checked";
  gShowRegisterAccountButton = false;
  gShowHomeserverPdaActionButton = false;
  gHomeserverPdaActionReason = "";
  gHomeserverPdaActionMessage = "";
  gHomeserverPdaActionDetail = "";
  gHomeserverPdaCanAdd = false;
  gHomeserverPdaCanFix = false;
  gHomeserverPdaResultMessage = "";
  gHomeserverPdaResultDetails = "";
  gHomeserverPdaResultSuccess = false;
  gUserPdaAddress = "";
  gRegistrationSignature = "";
  gRegisterConfirmMessage = "";
  gRegisterConfirmBalanceLine = "";
  gRegisterConfirmPdaLine = "";
  gRegisterConfirmHomeserverLine = "";
  gRegisterConfirmCanSubmit = false;
  gRegisterResultMessage = "";
  gRegisterResultDetails = "";
  gRegisterResultSuccess = false;
  clearShineSessionState(true);
  gShineStatusLine = "SHiNE: account not configured";
}

static bool findProgramAddress(const std::vector<std::vector<uint8_t>> &seeds, const char *programIdB58, uint8_t out32[32]) {
  uint8_t programId[32] = {};
  if (!base58ToFixed32(String(programIdB58), programId)) {
    return false;
  }
  for (int bump = 255; bump >= 0; --bump) {
    crypto_hash_sha256_state st;
    crypto_hash_sha256_init(&st);
    for (const auto &seed : seeds) {
      crypto_hash_sha256_update(&st, seed.data(), seed.size());
    }
    uint8_t bumpByte = (uint8_t)bump;
    crypto_hash_sha256_update(&st, &bumpByte, 1);
    crypto_hash_sha256_update(&st, programId, 32);
    crypto_hash_sha256_update(&st,
                              reinterpret_cast<const unsigned char *>(kProgramDerivedAddressMarker),
                              strlen(kProgramDerivedAddressMarker));
    crypto_hash_sha256_final(&st, out32);
    ge25519_p3 point;
    if (ge25519_frombytes(&point, out32) != 0) {
      return true;
    }
  }
  return false;
}

static bool isHttpUrl(const String &url) {
  return url.startsWith("http://") || url.startsWith("https://");
}

static bool httpPostJson(const String &url, const String &body, int &statusCode, String &payload) {
  statusCode = -1;
  payload = "";
  if (!isHttpUrl(url)) {
    return false;
  }

  HTTPClient http;
  WiFiClientSecure secureClient;
  WiFiClient plainClient;
  bool secure = url.startsWith("https://");
  if (secure) {
    secureClient.setInsecure();
    if (!http.begin(secureClient, url)) {
      return false;
    }
  } else {
    if (!http.begin(plainClient, url)) {
      return false;
    }
  }

  http.setTimeout(7000);
  http.addHeader("Content-Type", "application/json");
  statusCode = http.POST(body);
  if (statusCode > 0) {
    payload = http.getString();
  }
  http.end();
  return statusCode > 0;
}

static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut) {
  String needle = "\"" + field + "\"";
  int keyPos = json.indexOf(needle);
  if (keyPos < 0) {
    return false;
  }
  int colon = json.indexOf(':', keyPos + needle.length());
  if (colon < 0) {
    return false;
  }
  int pos = colon + 1;
  while (pos < (int)json.length() && (json[pos] == ' ' || json[pos] == '\n' || json[pos] == '\r' || json[pos] == '\t')) {
    pos++;
  }
  int start = pos;
  while (pos < (int)json.length() && isDigit((unsigned char)json[pos])) {
    pos++;
  }
  if (pos == start) {
    return false;
  }
  valueOut = strtoull(json.substring(start, pos).c_str(), nullptr, 10);
  return true;
}

static String formatSolValue(uint64_t lamports) {
  uint64_t whole = lamports / 1000000000ULL;
  uint64_t frac = (lamports % 1000000000ULL) / 1000000ULL;
  char out[48];
  snprintf(out, sizeof(out), "Balance: %llu.%03llu SOL",
           (unsigned long long)whole,
           (unsigned long long)frac);
  return String(out);
}

static bool refreshWalletBalance(String &messageOut) {
  uint64_t lamports = 0;
  if (!loadWalletBalanceLamports(lamports, messageOut)) {
    gBalanceStatusMessage = messageOut;
    return false;
  }

  gBalanceStatusMessage = formatSolValue(lamports);
  messageOut = gBalanceStatusMessage;
  return true;
}

static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut) {
  messageOut = "";
  lamportsOut = 0;
  if (WiFi.status() != WL_CONNECTED) {
    messageOut = "Balance: Wi-Fi not connected";
    return false;
  }
  if (gDevicePubB58.isEmpty()) {
    messageOut = "Balance: secret not set";
    return false;
  }

  int code = -1;
  String payload;
  String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"" + gDevicePubB58 + "\",{\"commitment\":\"confirmed\"}]}";
  if (!httpPostJson(gSolanaRpcUrl, req, code, payload)) {
    messageOut = "Balance: RPC unavailable";
    return false;
  }

  if (!jsonInt64Field(payload, "value", lamportsOut)) {
    messageOut = "Balance: failed to load";
    return false;
  }

  return true;
}

static String balanceHomeLine() {
  return gBalanceStatusMessage;
}

static String wifiHomeRichLine() {
  String ssid = gWifiSavedSsid.isEmpty() ? String("not configured") : gWifiSavedSsid;
  if (WiFi.status() == WL_CONNECTED) {
    return String("Wi-Fi: ") + ssid + " #38B26D connected#";
  }
  return String("Wi-Fi: ") + ssid + " disconnected";
}

static String shineHomeRichLine() {
  String serverLabel = gShineServerUrl.isEmpty() ? String("not set") : gShineServerUrl;
  if (gShineStatusLine.endsWith(" connected")) {
    return String("SHiNE: ") + serverLabel + " #38B26D connected#";
  }
  if (gShineStatusLine.endsWith(" account not configured")) {
    return String("SHiNE: ") + serverLabel + " account not configured";
  }
  return String("SHiNE: ") + serverLabel + " unavailable";
}

static String walletQrUri() {
  if (gDevicePubB58.isEmpty()) {
    return "";
  }
  return String("solana:") + gDevicePubB58;
}

static String walletQrAddressLine() {
  return gDevicePubB58.isEmpty() ? String("Wallet not set") : gDevicePubB58;
}

static void releaseTransientUiBuffers() {
  if (gWalletQrCanvasBuffer) {
    heap_caps_free(gWalletQrCanvasBuffer);
    gWalletQrCanvasBuffer = nullptr;
  }
}

static void saveUiErrorDiag(const String &summary, const String &details) {
  saveRegisterDiag("error", summary, String("kind=ui_runtime\nscreen=wallet_qr\n") + details);
}

static String buildSessionKeyStringFromPublicBase64(const String &pubB64) {
  return String("ed25519/") + pubB64;
}

static String shineWsUrl() {
  String raw = gShineServerUrl;
  raw.trim();
  if (raw.isEmpty()) {
    return "";
  }
  if (raw.startsWith("ws://") || raw.startsWith("wss://")) {
    return raw;
  }
  if (raw.startsWith("http://")) {
    String tail = raw.substring(strlen("http://"));
    if (tail.indexOf('/') < 0) {
      tail += "/ws";
    } else if (tail.endsWith("/")) {
      tail += "ws";
    }
    return "ws://" + tail;
  }
  if (raw.startsWith("https://")) {
    String tail = raw.substring(strlen("https://"));
    if (tail.indexOf('/') < 0) {
      tail += "/ws";
    } else if (tail.endsWith("/")) {
      tail += "ws";
    }
    return "wss://" + tail;
  }
  if (raw.indexOf('/') < 0) {
    return "wss://" + raw + "/ws";
  }
  return "wss://" + raw;
}

static String shineHomeLine() {
  return gShineStatusLine;
}

static uint64_t shineNowMs() {
  int64_t value = (int64_t)millis() + gShineServerTimeOffsetMs;
  return value > 0 ? (uint64_t)value : (uint64_t)millis();
}

static void shortVecEncode(size_t value, std::vector<uint8_t> &out) {
  do {
    uint8_t byte = value & 0x7F;
    value >>= 7;
    if (value) {
      byte |= 0x80;
    }
    out.push_back(byte);
  } while (value);
}

static void pushU32LE(std::vector<uint8_t> &out, uint32_t value) {
  out.push_back((uint8_t)(value & 0xFF));
  out.push_back((uint8_t)((value >> 8) & 0xFF));
  out.push_back((uint8_t)((value >> 16) & 0xFF));
  out.push_back((uint8_t)((value >> 24) & 0xFF));
}

static void pushU64LE(std::vector<uint8_t> &out, uint64_t value) {
  for (int i = 0; i < 8; ++i) {
    out.push_back((uint8_t)((value >> (8 * i)) & 0xFF));
  }
}

static void pushStrU8(std::vector<uint8_t> &out, const String &value) {
  shortVecEncode(value.length(), out);
  for (size_t i = 0; i < value.length(); ++i) {
    out.push_back((uint8_t)value.charAt(i));
  }
}

static void pushFixed(std::vector<uint8_t> &out, const uint8_t *data, size_t len) {
  out.insert(out.end(), data, data + len);
}

static String bytesToBase58(const uint8_t *data, size_t len) {
  char out[160] = {};
  shineSecretBase58Encode(data, len, out, sizeof(out));
  return String(out);
}

static String buildBaseRpcRequest(const char *method, const String &paramsJson) {
  return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + String(method) + "\",\"params\":" + paramsJson + "}";
}

static bool rpcCallSolana(const char *method, const String &paramsJson, String &payloadOut) {
  int code = -1;
  if (!httpPostJson(gSolanaRpcUrl, buildBaseRpcRequest(method, paramsJson), code, payloadOut)) {
    return false;
  }
  return code >= 200 && code < 300;
}

static bool rpcResponseHasError(const String &payload) {
  return payload.indexOf("\"error\"") >= 0;
}

static std::vector<uint8_t> buildLastBlockStateBytes(const String &login,
                                                     const String &blockchainName,
                                                     uint32_t lastBlockNumber,
                                                     const uint8_t *lastBlockHash32,
                                                     uint64_t usedBytes) {
  std::vector<uint8_t> out;
  out.reserve(80);
  pushFixed(out, (const uint8_t *)kLastBlockPrefix, strlen(kLastBlockPrefix));
  pushStrU8(out, login);
  pushStrU8(out, blockchainName);
  pushU32LE(out, lastBlockNumber);
  if (lastBlockHash32) {
    pushFixed(out, lastBlockHash32, 32);
  } else {
    out.insert(out.end(), 32, 0);
  }
  pushU64LE(out, usedBytes);
  return out;
}

static std::vector<uint8_t> serializeUnsignedRecordState(const ShinePdaUserState &state) {
  std::vector<uint8_t> out;
  out.reserve(640);
  pushFixed(out, (const uint8_t *)"SHiNE", 5);
  out.push_back(1);
  out.push_back(0);
  out.push_back(0);
  out.push_back(0);
  pushU64LE(out, state.createdAtMs);
  pushU64LE(out, state.updatedAtMs);
  pushU32LE(out, state.recordNumber);
  pushFixed(out, state.prevRecordHash32, 32);
  pushStrU8(out, state.login);
  out.push_back(state.isServer ? 7 : 6);

  out.push_back(kBlockTypeRootKey);
  out.push_back(0);
  pushFixed(out, state.rootKey32, 32);

  out.push_back(kBlockTypeDeviceKey);
  out.push_back(0);
  pushFixed(out, state.deviceKey32, 32);

  out.push_back(kBlockTypeBlockchainRegistry);
  out.push_back(0);
  out.push_back(1);
  out.push_back(state.blockchainType);
  pushStrU8(out, state.blockchainName);
  pushFixed(out, state.blockchainKey32, 32);
  pushU64LE(out, state.paidLimitBytes);
  pushU64LE(out, state.usedBytes);
  pushU32LE(out, state.lastBlockNumber);
  pushFixed(out, state.lastBlockHash32, 32);
  pushFixed(out, state.lastBlockSignature64, 64);
  if (state.arweaveTxId.isEmpty()) {
    out.push_back(0);
  } else {
    out.push_back(1);
    pushStrU8(out, state.arweaveTxId);
  }

  if (state.isServer) {
    out.push_back(kBlockTypeServerProfile);
    out.push_back(0);
    out.push_back(1);
    out.push_back(state.addressFormatType);
    out.push_back(state.addressFormatVersion);
    pushStrU8(out, state.serverAddress);
    out.push_back((uint8_t)state.syncServers.size());
    for (const auto &value : state.syncServers) {
      pushStrU8(out, value);
    }
  }

  out.push_back(kBlockTypeAccessServers);
  out.push_back(0);
  out.push_back((uint8_t)state.accessServers.size());
  for (const auto &value : state.accessServers) {
    pushStrU8(out, value);
  }

  out.push_back(kBlockTypeSessions);
  out.push_back(0);
  out.push_back(state.sessionsMode);
  out.push_back((uint8_t)state.sessions.size());
  for (const auto &session : state.sessions) {
    out.push_back(session.sessionType);
    out.push_back(session.sessionVersion);
    pushStrU8(out, session.sessionName);
    pushFixed(out, session.sessionPubKey32, 32);
  }

  out.push_back(kBlockTypeTrustedState);
  out.push_back(0);
  out.push_back(state.trustedCount);

  uint16_t recordLen = (uint16_t)(out.size() + 64);
  out[7] = (uint8_t)(recordLen & 0xFF);
  out[8] = (uint8_t)((recordLen >> 8) & 0xFF);
  return out;
}

static std::vector<uint8_t> buildUpdateInstructionData(const ShinePdaUserState &state,
                                                       uint32_t nextVersion,
                                                       uint64_t updatedAtMs,
                                                       const uint8_t prevHash32[32],
                                                       const uint8_t rootSignature64[64]) {
  std::vector<uint8_t> out;
  out.reserve(640);
  out.push_back(4);
  pushStrU8(out, state.login);
  pushFixed(out, state.rootKey32, 32);
  pushU64LE(out, state.createdAtMs);
  pushU64LE(out, updatedAtMs);
  pushU32LE(out, nextVersion);
  pushFixed(out, prevHash32, 32);
  pushU64LE(out, 0);
  pushFixed(out, state.deviceKey32, 32);
  pushFixed(out, state.blockchainKey32, 32);
  pushStrU8(out, state.blockchainName);
  pushU64LE(out, state.usedBytes);
  pushU32LE(out, state.lastBlockNumber);
  pushFixed(out, state.lastBlockHash32, 32);
  pushFixed(out, state.lastBlockSignature64, 64);
  if (state.arweaveTxId.isEmpty()) {
    out.push_back(0);
  } else {
    out.push_back(1);
    pushStrU8(out, state.arweaveTxId);
  }
  out.push_back(state.isServer ? 1 : 0);
  if (state.isServer) {
    out.push_back(state.addressFormatType);
    out.push_back(state.addressFormatVersion);
    pushStrU8(out, state.serverAddress);
    out.push_back((uint8_t)state.syncServers.size());
    for (const auto &value : state.syncServers) {
      pushStrU8(out, value);
    }
  }
  out.push_back((uint8_t)state.accessServers.size());
  for (const auto &value : state.accessServers) {
    pushStrU8(out, value);
  }
  out.push_back(state.sessionsMode);
  out.push_back((uint8_t)state.sessions.size());
  for (const auto &session : state.sessions) {
    out.push_back(session.sessionType);
    out.push_back(session.sessionVersion);
    pushStrU8(out, session.sessionName);
    pushFixed(out, session.sessionPubKey32, 32);
  }
  out.push_back(state.trustedCount);
  pushFixed(out, rootSignature64, 64);
  return out;
}

static std::vector<uint8_t> buildUnsignedCreateRecord(
    const String &login,
    const String &blockchainName,
    const String &serverAddress,
    const uint8_t rootPub[32],
    const uint8_t devicePub[32],
    const uint8_t blockchainPub[32],
    const uint8_t lastBlockSignature[64],
    uint64_t paidLimitBytes,
    uint64_t createdAtMs) {
  std::vector<uint8_t> out;
  out.reserve(512);
  pushFixed(out, (const uint8_t *)"SHiNE", 5);
  out.push_back(1);
  out.push_back(0);
  out.push_back(0);
  out.push_back(0);
  pushU64LE(out, createdAtMs);
  pushU64LE(out, createdAtMs);
  pushU32LE(out, 0);
  out.insert(out.end(), 32, 0);
  pushStrU8(out, login);
  out.push_back(7);

  out.push_back(kBlockTypeRootKey);
  out.push_back(0);
  pushFixed(out, rootPub, 32);

  out.push_back(kBlockTypeDeviceKey);
  out.push_back(0);
  pushFixed(out, devicePub, 32);

  out.push_back(kBlockTypeBlockchainRegistry);
  out.push_back(0);
  out.push_back(1);
  out.push_back(1);
  pushStrU8(out, blockchainName);
  pushFixed(out, blockchainPub, 32);
  pushU64LE(out, paidLimitBytes);
  pushU64LE(out, 0);
  pushU32LE(out, 0);
  out.insert(out.end(), 32, 0);
  pushFixed(out, lastBlockSignature, 64);
  out.push_back(0);

  out.push_back(kBlockTypeServerProfile);
  out.push_back(0);
  out.push_back(1);
  out.push_back(0);
  out.push_back(0);
  pushStrU8(out, serverAddress);
  out.push_back(0);

  out.push_back(kBlockTypeAccessServers);
  out.push_back(0);
  out.push_back(0);

  out.push_back(kBlockTypeSessions);
  out.push_back(0);
  out.push_back(1);
  out.push_back(0);

  out.push_back(kBlockTypeTrustedState);
  out.push_back(0);
  out.push_back(0);

  uint16_t recordLen = (uint16_t)(out.size() + 64);
  out[7] = (uint8_t)(recordLen & 0xFF);
  out[8] = (uint8_t)((recordLen >> 8) & 0xFF);
  return out;
}

static std::vector<uint8_t> buildCreateInstructionData(
    const String &login,
    const String &blockchainName,
    const String &serverAddress,
    const uint8_t rootPub[32],
    const uint8_t devicePub[32],
    const uint8_t blockchainPub[32],
    const uint8_t lastBlockSignature[64],
    const uint8_t rootSignature[64],
    uint64_t createdAtMs) {
  std::vector<uint8_t> out;
  out.reserve(512);
  out.push_back(3);
  pushStrU8(out, login);
  pushFixed(out, rootPub, 32);
  pushU64LE(out, createdAtMs);
  pushU64LE(out, 0);
  pushFixed(out, devicePub, 32);
  pushFixed(out, blockchainPub, 32);
  pushStrU8(out, blockchainName);
  pushU64LE(out, 0);
  pushU32LE(out, 0);
  out.insert(out.end(), 32, 0);
  pushFixed(out, lastBlockSignature, 64);
  out.push_back(0);
  out.push_back(1);
  out.push_back(0);
  out.push_back(0);
  pushStrU8(out, serverAddress);
  out.push_back(0);
  out.push_back(0);
  out.push_back(1);
  out.push_back(0);
  out.push_back(0);
  pushFixed(out, rootSignature, 64);
  return out;
}

static std::vector<uint8_t> buildEd25519InstructionData(const uint8_t signature[64], const uint8_t publicKey[32], const uint8_t messageHash[32]) {
  const uint16_t sigOff = 16;
  const uint16_t pkOff = sigOff + 64;
  const uint16_t msgOff = pkOff + 32;
  std::vector<uint8_t> out(msgOff + 32, 0);
  out[0] = 1;
  out[1] = 0;
  out[2] = (uint8_t)(sigOff & 0xFF);
  out[3] = (uint8_t)((sigOff >> 8) & 0xFF);
  out[6] = (uint8_t)(pkOff & 0xFF);
  out[7] = (uint8_t)((pkOff >> 8) & 0xFF);
  out[10] = (uint8_t)(msgOff & 0xFF);
  out[11] = (uint8_t)((msgOff >> 8) & 0xFF);
  out[12] = 32;
  out[4] = out[8] = out[14] = 0xFF;
  out[5] = out[9] = out[15] = 0xFF;
  memcpy(out.data() + sigOff, signature, 64);
  memcpy(out.data() + pkOff, publicKey, 32);
  memcpy(out.data() + msgOff, messageHash, 32);
  return out;
}

static bool getLatestBlockhashBytes(uint8_t out[32], String &blockhashB58, String &messageOut) {
  String payload;
  if (!rpcCallSolana("getLatestBlockhash", "[{\"commitment\":\"confirmed\"}]", payload)) {
    messageOut = "RPC did not return blockhash";
    return false;
  }
  if (!jsonStringField(payload, "blockhash", blockhashB58) || blockhashB58.isEmpty()) {
    messageOut = "Blockhash missing in response";
    return false;
  }
  if (!base58ToFixed32(blockhashB58, out)) {
    messageOut = "Invalid blockhash";
    return false;
  }
  return true;
}

static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &messageOut) {
  uint8_t userPda[32];
  std::vector<std::vector<uint8_t>> seeds = {
      std::vector<uint8_t>((const uint8_t *)kUsersSeedPrefix, (const uint8_t *)kUsersSeedPrefix + strlen(kUsersSeedPrefix)),
      std::vector<uint8_t>((const uint8_t *)login.c_str(), (const uint8_t *)login.c_str() + login.length())};
  if (!findProgramAddress(seeds, kShineUsersProgramId, userPda)) {
    messageOut = "Failed to derive user PDA";
    return false;
  }
  pdaAddress = bytesToBase58(userPda, 32);
  String payload;
  if (!rpcCallSolana("getAccountInfo", "[\"" + pdaAddress + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]", payload)) {
    messageOut = "Failed to check PDA";
    return false;
  }
  if (payload.indexOf("\"value\":null") >= 0) {
    return false;
  }
  if (payload.indexOf("\"value\"") >= 0) {
    messageOut = "Login is already registered";
    return true;
  }
  messageOut = "Unexpected getAccountInfo response";
  return false;
}

static bool loadAccountOwner(const String &address, String &ownerOut, bool &existsOut, String &messageOut) {
  ownerOut = "";
  existsOut = false;
  String payload;
  if (!rpcCallSolana("getAccountInfo", "[\"" + address + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]", payload)) {
    messageOut = "Failed to read account info";
    return false;
  }
  if (payload.indexOf("\"value\":null") >= 0) {
    existsOut = false;
    return true;
  }
  existsOut = true;
  if (!jsonStringField(payload, "owner", ownerOut) || ownerOut.isEmpty()) {
    messageOut = "Account owner missing in RPC response";
    return false;
  }
  return true;
}

static bool loadUsersEconomyConfigState(const String &economyConfigAddress,
                                        uint64_t &registrationFeeLamportsOut,
                                        uint64_t &lamportsPerLimitStepOut,
                                        uint64_t &startBonusLimitOut,
                                        String &messageOut) {
  registrationFeeLamportsOut = 0;
  lamportsPerLimitStepOut = 0;
  startBonusLimitOut = 0;

  String payload;
  if (!rpcCallSolana("getAccountInfo", "[\"" + economyConfigAddress + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]", payload)) {
    messageOut = "Failed to read users economy config";
    return false;
  }
  if (payload.indexOf("\"value\":null") >= 0) {
    messageOut = "Users economy config is missing on this Solana RPC";
    return false;
  }

  String owner;
  if (!jsonStringField(payload, "owner", owner) || owner.isEmpty()) {
    messageOut = "Users economy config owner is missing";
    return false;
  }
  if (owner != String(kShineUsersProgramId)) {
    messageOut = String("Users economy config owner mismatch: ") + owner;
    return false;
  }

  String dataB64;
  if (!jsonStringField(payload, "data", dataB64) || dataB64.isEmpty()) {
    messageOut = "Users economy config base64 is missing";
    return false;
  }

  std::vector<uint8_t> raw;
  if (!base64DecodeStd(dataB64, raw)) {
    messageOut = "Users economy config base64 decode failed";
    return false;
  }
  if (raw.size() < 25) {
    messageOut = "Users economy config is too short";
    return false;
  }

  registrationFeeLamportsOut = 0;
  lamportsPerLimitStepOut = 0;
  startBonusLimitOut = 0;
  for (int i = 0; i < 8; ++i) {
    registrationFeeLamportsOut |= ((uint64_t)raw[1 + i]) << (8 * i);
    lamportsPerLimitStepOut |= ((uint64_t)raw[9 + i]) << (8 * i);
    startBonusLimitOut |= ((uint64_t)raw[17 + i]) << (8 * i);
  }
  return true;
}

static String compactRpcLogs(const String &payload, int maxLines) {
  String out;
  int pos = payload.indexOf("\"logs\"");
  if (pos < 0) {
    return "";
  }
  int bracket = payload.indexOf('[', pos);
  if (bracket < 0) {
    return "";
  }
  int i = bracket + 1;
  int lines = 0;
  while (i < (int)payload.length() && lines < maxLines) {
    while (i < (int)payload.length() && payload[i] != '"' && payload[i] != ']') {
      i++;
    }
    if (i >= (int)payload.length() || payload[i] == ']') {
      break;
    }
    String line;
    bool escape = false;
    i++;
    for (; i < (int)payload.length(); ++i) {
      char ch = payload[i];
      if (escape) {
        switch (ch) {
          case 'n': line += ' '; break;
          case 'r': break;
          case 't': line += ' '; break;
          default: line += ch; break;
        }
        escape = false;
        continue;
      }
      if (ch == '\\') {
        escape = true;
        continue;
      }
      if (ch == '"') {
        i++;
        break;
      }
      line += ch;
    }
    line.trim();
    if (!line.isEmpty()) {
      if (!out.isEmpty()) {
        out += " | ";
      }
      out += line;
      lines++;
    }
  }
  return out;
}

static bool extractRpcErrorSummary(const String &payload, String &messageOut) {
  messageOut = "";
  String errorMessage;
  if (jsonStringField(payload, "message", errorMessage) && !errorMessage.isEmpty()) {
    messageOut = errorMessage;
  }
  String logs = compactRpcLogs(payload, 3);
  if (!logs.isEmpty()) {
    if (!messageOut.isEmpty()) {
      messageOut += " | ";
    }
    messageOut += logs;
  }
  return !messageOut.isEmpty();
}

static bool simulateTransactionPayload(const String &txBase64, String &payloadOut) {
  payloadOut = "";
  return rpcCallSolana("simulateTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"sigVerify\":true,\"commitment\":\"processed\"}]", payloadOut);
}

static bool simulateTransactionForError(const String &txBase64, String &messageOut) {
  String payload;
  if (!simulateTransactionPayload(txBase64, payload)) {
    return false;
  }
  return extractRpcErrorSummary(payload, messageOut);
}

static std::vector<uint8_t> buildLegacyMessage(
    const uint8_t recentBlockhash[32],
    const uint8_t devicePub[32],
    const uint8_t userPda[32],
    const uint8_t inflowVault[32],
    const uint8_t economyConfig[32],
    const std::vector<uint8_t> &edRootData,
    const std::vector<uint8_t> &edBchData,
    const std::vector<uint8_t> &createData) {
  uint8_t systemProgram[32];
  uint8_t ed25519Program[32];
  uint8_t sysvarInstructions[32];
  uint8_t usersProgram[32];
  uint8_t loginGuardProgram[32];
  base58ToFixed32(kSystemProgramId, systemProgram);
  base58ToFixed32(kEd25519ProgramId, ed25519Program);
  base58ToFixed32(kSysvarInstructionsId, sysvarInstructions);
  base58ToFixed32(kShineUsersProgramId, usersProgram);
  base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram);

  std::vector<std::vector<uint8_t>> accountKeys;
  accountKeys.emplace_back(devicePub, devicePub + 32);
  accountKeys.emplace_back(userPda, userPda + 32);
  accountKeys.emplace_back(inflowVault, inflowVault + 32);
  accountKeys.emplace_back(systemProgram, systemProgram + 32);
  accountKeys.emplace_back(loginGuardProgram, loginGuardProgram + 32);
  accountKeys.emplace_back(economyConfig, economyConfig + 32);
  accountKeys.emplace_back(ed25519Program, ed25519Program + 32);
  accountKeys.emplace_back(usersProgram, usersProgram + 32);
  accountKeys.emplace_back(sysvarInstructions, sysvarInstructions + 32);

  std::vector<uint8_t> msg;
  msg.reserve(512);
  msg.push_back(1);
  msg.push_back(0);
  msg.push_back(6);
  shortVecEncode(accountKeys.size(), msg);
  for (const auto &key : accountKeys) {
    msg.insert(msg.end(), key.begin(), key.end());
  }
  msg.insert(msg.end(), recentBlockhash, recentBlockhash + 32);
  shortVecEncode(3, msg);

  msg.push_back(6);
  msg.push_back(0);
  shortVecEncode(edRootData.size(), msg);
  msg.insert(msg.end(), edRootData.begin(), edRootData.end());

  msg.push_back(6);
  msg.push_back(0);
  shortVecEncode(edBchData.size(), msg);
  msg.insert(msg.end(), edBchData.begin(), edBchData.end());

  msg.push_back(7);
  msg.push_back(7);
  msg.push_back(0);
  msg.push_back(1);
  msg.push_back(3);
  msg.push_back(2);
  msg.push_back(8);
  msg.push_back(5);
  msg.push_back(4);
  shortVecEncode(createData.size(), msg);
  msg.insert(msg.end(), createData.begin(), createData.end());
  return msg;
}

static std::vector<uint8_t> buildUpdateLegacyMessage(
    const uint8_t recentBlockhash[32],
    const uint8_t devicePub[32],
    const uint8_t userPda[32],
    const uint8_t inflowVault[32],
    const uint8_t economyConfig[32],
    const std::vector<uint8_t> &edRootData,
    const std::vector<uint8_t> &edBchData,
    const std::vector<uint8_t> &updateData) {
  uint8_t systemProgram[32];
  uint8_t ed25519Program[32];
  uint8_t usersProgram[32];
  uint8_t sysvarInstructions[32];
  base58ToFixed32(kSystemProgramId, systemProgram);
  base58ToFixed32(kEd25519ProgramId, ed25519Program);
  base58ToFixed32(kShineUsersProgramId, usersProgram);
  base58ToFixed32(kSysvarInstructionsId, sysvarInstructions);

  std::vector<std::vector<uint8_t>> accountKeys;
  accountKeys.emplace_back(devicePub, devicePub + 32);
  accountKeys.emplace_back(userPda, userPda + 32);
  accountKeys.emplace_back(inflowVault, inflowVault + 32);
  accountKeys.emplace_back(systemProgram, systemProgram + 32);
  accountKeys.emplace_back(economyConfig, economyConfig + 32);
  accountKeys.emplace_back(ed25519Program, ed25519Program + 32);
  accountKeys.emplace_back(usersProgram, usersProgram + 32);
  accountKeys.emplace_back(sysvarInstructions, sysvarInstructions + 32);

  std::vector<uint8_t> msg;
  msg.reserve(512);
  msg.push_back(1);
  msg.push_back(0);
  msg.push_back(5);
  shortVecEncode(accountKeys.size(), msg);
  for (const auto &key : accountKeys) {
    msg.insert(msg.end(), key.begin(), key.end());
  }
  msg.insert(msg.end(), recentBlockhash, recentBlockhash + 32);
  shortVecEncode(3, msg);

  msg.push_back(5);
  msg.push_back(0);
  shortVecEncode(edRootData.size(), msg);
  msg.insert(msg.end(), edRootData.begin(), edRootData.end());

  msg.push_back(5);
  msg.push_back(0);
  shortVecEncode(edBchData.size(), msg);
  msg.insert(msg.end(), edBchData.begin(), edBchData.end());

  msg.push_back(6);
  msg.push_back(6);
  msg.push_back(0);
  msg.push_back(1);
  msg.push_back(3);
  msg.push_back(2);
  msg.push_back(7);
  msg.push_back(4);
  shortVecEncode(updateData.size(), msg);
  msg.insert(msg.end(), updateData.begin(), updateData.end());
  return msg;
}

static bool signMessageEd25519(const std::vector<uint8_t> &message, const uint8_t secretKey[64], uint8_t signature[64]) {
  return crypto_sign_ed25519_detached(signature, nullptr, message.data(), (unsigned long long)message.size(), secretKey) == 0;
}

static String encodeTransactionBase64(const uint8_t signature[64], const std::vector<uint8_t> &message) {
  std::vector<uint8_t> tx;
  tx.reserve(1 + 64 + message.size());
  shortVecEncode(1, tx);
  pushFixed(tx, signature, 64);
  tx.insert(tx.end(), message.begin(), message.end());
  return bytesToBase64String(tx.data(), tx.size());
}

static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut) {
  for (int attempt = 0; attempt < 15; attempt++) {
    String payload;
    if (!rpcCallSolana("getSignatureStatuses", "[[\"" + signatureB58 + "\"],{\"searchTransactionHistory\":true}]", payload)) {
      delay(1000);
      continue;
    }
    if (payload.indexOf("\"err\":null") >= 0 &&
        (payload.indexOf("\"confirmationStatus\":\"confirmed\"") >= 0 ||
         payload.indexOf("\"confirmationStatus\":\"finalized\"") >= 0)) {
      return true;
    }
    if (payload.indexOf("\"err\":{") >= 0 || payload.indexOf("\"err\":\"") >= 0) {
      messageOut = "Transaction rejected by network";
      return false;
    }
    delay(1000);
  }
  messageOut = "RPC did not confirm transaction in time";
  return false;
}

static bool registerHomeserverOnSolana(String &messageOut) {
  messageOut = "";
  String diagDetails;
  auto failWithDiag = [&](const String &summary) -> bool {
    messageOut = summary;
    saveRegisterDiag("error", summary, diagDetails);
    printRegisterDiagToSerial();
    return false;
  };

  String cleanLogin = normalizeLoginValue(gLoginValue);
  diagDetails += String("trigger=") + gRegisterTriggerSource + "\n";
  diagDetails += String("test_uptime_ms=") + String(millis()) + "\n";
  diagDetails += String("login=") + cleanLogin + "\n";
  diagDetails += String("rpc=") + gSolanaRpcUrl + "\n";
  diagDetails += String("shine_server=") + gShineServerUrl + "\n";
  diagDetails += String("homeserver=") + gHomeserverValue + "\n";

  if (cleanLogin.isEmpty()) {
    return failWithDiag("Login is not set");
  }
  if (!gSecretConfigured || gRootPrivB58.isEmpty() || gBlockchainPrivB58.isEmpty() || gDevicePrivB58.isEmpty()) {
    return failWithDiag("Secret is not ready");
  }
  if (WiFi.status() != WL_CONNECTED) {
    diagDetails += "wifi=disconnected\n";
    return failWithDiag("Connect Wi-Fi first");
  }
  diagDetails += String("wifi=connected:") + WiFi.localIP().toString() + "\n";
  if (gSolanaRpcUrl.isEmpty()) {
    return failWithDiag("Set Solana RPC first");
  }

  String existingPda;
  String pdaCheckMessage;
  if (pdaAlreadyExists(cleanLogin, existingPda, pdaCheckMessage)) {
    gUserPdaAddress = existingPda;
    gRegistrationSignature = "";
    saveAccountPrefs();
    gAccountPdaStatus = ACCOUNT_PDA_OK;
    gAccountPdaStatusMessage = "User is already registered";
    gShowRegisterAccountButton = false;
    gAccountStatusMessage = "User is already registered";
    gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " registered";
    refreshAccountPdaStatus();
    diagDetails += String("user_pda=") + existingPda + "\n";
    saveRegisterDiag("ok", "User is already registered", diagDetails);
    return true;
  }
  if (pdaCheckMessage == "Failed to derive user PDA" || pdaCheckMessage == "Failed to check PDA" || pdaCheckMessage == "Unexpected getAccountInfo response") {
    diagDetails += String("user_pda_check=") + pdaCheckMessage + "\n";
    return failWithDiag(pdaCheckMessage);
  }

  uint8_t userPda[32];
  uint8_t economyConfig[32];
  uint8_t inflowVault[32];
  if (!findProgramAddress({
          std::vector<uint8_t>((const uint8_t *)kUsersSeedPrefix, (const uint8_t *)kUsersSeedPrefix + strlen(kUsersSeedPrefix)),
          std::vector<uint8_t>((const uint8_t *)cleanLogin.c_str(), (const uint8_t *)cleanLogin.c_str() + cleanLogin.length())
      }, kShineUsersProgramId, userPda) ||
      !findProgramAddress({
          std::vector<uint8_t>((const uint8_t *)kUsersEconomyConfigSeed, (const uint8_t *)kUsersEconomyConfigSeed + strlen(kUsersEconomyConfigSeed))
      }, kShineUsersProgramId, economyConfig) ||
      !findProgramAddress({
          std::vector<uint8_t>((const uint8_t *)kPaymentsInflowSeed, (const uint8_t *)kPaymentsInflowSeed + strlen(kPaymentsInflowSeed))
      }, kShinePaymentsProgramId, inflowVault)) {
    return failWithDiag("Failed to derive required PDAs");
  }

  String userPdaB58 = bytesToBase58(userPda, 32);
  String economyConfigB58 = bytesToBase58(economyConfig, 32);
  String inflowVaultB58 = bytesToBase58(inflowVault, 32);
  diagDetails += String("user_pda=") + userPdaB58 + "\n";
  diagDetails += String("users_economy_config_pda=") + economyConfigB58 + "\n";
  diagDetails += String("inflow_vault_pda=") + inflowVaultB58 + "\n";

  String accountInfoMessage;
  String ownerValue;
  bool ownerExists = false;
  if (!loadAccountOwner(userPdaB58, ownerValue, ownerExists, accountInfoMessage)) {
    diagDetails += String("user_pda_rpc_error=") + accountInfoMessage + "\n";
    return failWithDiag(accountInfoMessage);
  }
  diagDetails += String("user_pda_exists=") + (ownerExists ? "true" : "false") + "\n";
  if (ownerExists) {
    diagDetails += String("user_pda_owner=") + ownerValue + "\n";
    return failWithDiag("User PDA already exists on RPC");
  }

  uint64_t registrationFeeLamports = 0;
  uint64_t lamportsPerLimitStep = 0;
  uint64_t startBonusLimit = 0;
  if (!loadUsersEconomyConfigState(economyConfigB58,
                                   registrationFeeLamports,
                                   lamportsPerLimitStep,
                                   startBonusLimit,
                                   accountInfoMessage)) {
    diagDetails += String("users_economy_config_error=") + accountInfoMessage + "\n";
    return failWithDiag(accountInfoMessage);
  }
  diagDetails += String("registration_fee_lamports=") + String((unsigned long long)registrationFeeLamports) + "\n";
  diagDetails += String("lamports_per_limit_step=") + String((unsigned long long)lamportsPerLimitStep) + "\n";
  diagDetails += String("start_bonus_limit=") + String((unsigned long long)startBonusLimit) + "\n";

  uint8_t rootSeed[32] = {};
  uint8_t rootPub[32] = {};
  uint8_t rootSec[64] = {};
  uint8_t blockchainSeed[32] = {};
  uint8_t blockchainPub[32] = {};
  uint8_t blockchainSec[64] = {};
  uint8_t deviceSeed[32] = {};
  uint8_t devicePub[32] = {};
  uint8_t deviceSec[64] = {};
  if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) ||
      !deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec) ||
      !deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) {
    return failWithDiag("Failed to restore keys");
  }
  diagDetails += String("root_pub=") + bytesToBase58(rootPub, 32) + "\n";
  diagDetails += String("blockchain_pub=") + bytesToBase58(blockchainPub, 32) + "\n";
  diagDetails += String("device_pub=") + bytesToBase58(devicePub, 32) + "\n";

  String blockchainName = cleanLogin + "-001";
  diagDetails += String("blockchain_name=") + blockchainName + "\n";
  std::vector<uint8_t> lastBlockState = buildLastBlockStateBytes(cleanLogin, blockchainName, 0, nullptr, 0);
  uint8_t lastBlockHash[32];
  uint8_t lastBlockSignature[64];
  sha256calc(lastBlockState.data(), lastBlockState.size(), lastBlockHash);
  if (!signMessageEd25519(std::vector<uint8_t>(lastBlockHash, lastBlockHash + 32), blockchainSec, lastBlockSignature)) {
    return failWithDiag("Failed to sign LastBlockState");
  }
  diagDetails += String("last_block_hash=") + bytesToHexString(lastBlockHash, 32) + "\n";
  diagDetails += String("last_block_signature_b64=") + bytesToBase64String(lastBlockSignature, 64) + "\n";

  uint64_t createdAtMs = shineNowMs();
  diagDetails += String("created_at_ms=") + String((unsigned long long)createdAtMs) + "\n";
  std::vector<uint8_t> unsignedRecord = buildUnsignedCreateRecord(
      cleanLogin, blockchainName, gShineServerUrl,
      rootPub, devicePub, blockchainPub,
      lastBlockSignature, startBonusLimit, createdAtMs);
  uint8_t unsignedHash[32];
  uint8_t rootSignature[64];
  sha256calc(unsignedRecord.data(), unsignedRecord.size(), unsignedHash);
  if (!signMessageEd25519(std::vector<uint8_t>(unsignedHash, unsignedHash + 32), rootSec, rootSignature)) {
    return failWithDiag("Failed to sign PDA record");
  }
  diagDetails += String("unsigned_record_len=") + String((unsigned long)unsignedRecord.size()) + "\n";
  diagDetails += String("unsigned_record_hash=") + bytesToHexString(unsignedHash, 32) + "\n";
  diagDetails += String("root_signature_b64=") + bytesToBase64String(rootSignature, 64) + "\n";

  std::vector<uint8_t> createData = buildCreateInstructionData(
      cleanLogin, blockchainName, gShineServerUrl,
      rootPub, devicePub, blockchainPub,
      lastBlockSignature, rootSignature, createdAtMs);
  std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, rootPub, unsignedHash);
  std::vector<uint8_t> edBchData = buildEd25519InstructionData(lastBlockSignature, blockchainPub, lastBlockHash);

  uint8_t recentBlockhash[32];
  String recentBlockhash58;
  if (!getLatestBlockhashBytes(recentBlockhash, recentBlockhash58, messageOut)) {
    diagDetails += String("blockhash_error=") + messageOut + "\n";
    return failWithDiag(messageOut);
  }
  diagDetails += String("recent_blockhash=") + recentBlockhash58 + "\n";

  std::vector<uint8_t> message = buildLegacyMessage(
      recentBlockhash,
      devicePub,
      userPda,
      inflowVault,
      economyConfig,
      edRootData,
      edBchData,
      createData);
  uint8_t txSignature[64];
  if (!signMessageEd25519(message, deviceSec, txSignature)) {
    return failWithDiag("Failed to sign Solana transaction");
  }
  String txBase64 = encodeTransactionBase64(txSignature, message);
  String signatureB58 = bytesToBase58(txSignature, 64);
  diagDetails += String("tx_signature=") + signatureB58 + "\n";

  String payload;
  if (!rpcCallSolana("sendTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"preflightCommitment\":\"confirmed\"}]", payload)) {
    diagDetails += "send_transaction_rpc_error=true\n";
    return failWithDiag("RPC did not accept transaction");
  }
  if (rpcResponseHasError(payload)) {
    if (!extractRpcErrorSummary(payload, messageOut)) {
      messageOut = "RPC returned sendTransaction error";
    }
    diagDetails += "send_transaction_payload<<\n";
    diagDetails += payload;
    diagDetails += "\n>>send_transaction_payload\n";
    String simulated;
    String simulatedPayload;
    if (simulateTransactionPayload(txBase64, simulatedPayload)) {
      extractRpcErrorSummary(simulatedPayload, simulated);
      if (messageOut.isEmpty()) {
        messageOut = simulated;
      } else if (messageOut.indexOf(simulated) < 0) {
        messageOut += " | simulate: " + simulated;
      }
      diagDetails += "simulate_payload<<\n";
      diagDetails += simulatedPayload;
      diagDetails += "\n>>simulate_payload\n";
    }
    diagDetails += String("send_transaction_error=") + messageOut + "\n";
    return failWithDiag(messageOut);
  }
  if (!awaitTransactionConfirmation(signatureB58, messageOut)) {
    diagDetails += String("confirmation_error=") + messageOut + "\n";
    return failWithDiag(messageOut);
  }

  gUserPdaAddress = userPdaB58;
  gRegistrationSignature = signatureB58;
  gAccountStatusMessage = "Solana registration complete";
  gAccountPdaStatus = ACCOUNT_PDA_OK;
  gAccountPdaStatusMessage = "User registered";
  gShowRegisterAccountButton = false;
  gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " registered";
  saveAccountPrefs();
  refreshAccountPdaStatus();
  messageOut = "Solana registration confirmed";
  saveRegisterDiag("ok", messageOut, diagDetails);
  printRegisterDiagToSerial();
  return true;
}

static void executeRegisterAccountFlow(const char *triggerSource, bool showResultScreen) {
  gRegisterTriggerSource = triggerSource ? String(triggerSource) : String("manual");
  prepareRegisterAccountScreen();
  String registerMessage;
  if (registerHomeserverOnSolana(registerMessage)) {
    gRegisterResultSuccess = true;
    gRegisterResultMessage = registerMessage;
    gRegisterResultDetails = String("user_pda and tx signature were saved");
    gAccountStatusMessage = "Registration completed";
  } else {
    gRegisterResultSuccess = false;
    gRegisterResultMessage = registerMessage;
    gRegisterResultDetails = "";
    gAccountStatusMessage = registerMessage;
  }
  gRegisterConfirmMessage = "";
  gRegisterConfirmBalanceLine = "";
  gRegisterConfirmPdaLine = "";
  gRegisterConfirmHomeserverLine = "";
  gRegisterConfirmCanSubmit = false;
  if (showResultScreen) {
    showScreen(SCREEN_REGISTER_ACCOUNT_RESULT);
  }
}

static void prepareRegisterAccountScreen() {
  gRegisterResultMessage = "";
  gRegisterResultDetails = "";
  gRegisterResultSuccess = false;
  gRegisterConfirmMessage = "";
  gRegisterConfirmBalanceLine = "";
  gRegisterConfirmPdaLine = "";
  gRegisterConfirmHomeserverLine = "";
  gRegisterConfirmCanSubmit = false;

  String cleanLogin = normalizeLoginValue(gLoginValue);
  if (cleanLogin.isEmpty()) {
    gRegisterConfirmMessage = "Login is not set";
    return;
  }
  if (!gSecretConfigured) {
    gRegisterConfirmMessage = "Secret is not set";
    return;
  }
  if (WiFi.status() != WL_CONNECTED) {
    gRegisterConfirmMessage = "Wi-Fi is not connected";
    return;
  }

  String balanceMessage;
  uint64_t lamports = 0;
  if (!loadWalletBalanceLamports(lamports, balanceMessage)) {
    gRegisterConfirmMessage = balanceMessage;
  } else {
    gRegisterConfirmBalanceLine = formatSolValue(lamports);
    if (lamports < 20000000ULL) {
      gRegisterConfirmMessage = "Balance is below 0.020 SOL";
    } else {
      gRegisterConfirmMessage = "Balance is OK: 0.020 SOL or more";
    }
  }

  if (gHomeserverValue.isEmpty()) {
    gRegisterConfirmHomeserverLine = "Homeserver is not set";
  } else if (gHomeserverValue == "homeserver1") {
    gRegisterConfirmHomeserverLine = "Homeserver: homeserver1 (default value)";
  } else {
    gRegisterConfirmHomeserverLine = String("Homeserver: ") + gHomeserverValue;
  }

  ShinePdaUserState pdaState;
  String pdaError;
  if (!readShineUserPda(cleanLogin, pdaState, pdaError)) {
    gRegisterConfirmPdaLine = pdaError.isEmpty() ? "PDA: failed to check" : String("PDA: ") + pdaError;
    if (gRegisterConfirmMessage.isEmpty()) {
      gRegisterConfirmMessage = pdaError.isEmpty() ? "Failed to check PDA" : pdaError;
    }
    return;
  }

  if (pdaState.found) {
    gRegisterConfirmPdaLine = "PDA is already occupied, login is not free";
    if (gRegisterConfirmMessage.isEmpty()) {
      gRegisterConfirmMessage = "Login is already registered";
    }
    return;
  }

  gRegisterConfirmPdaLine = "PDA is free for registration";
  if (gRegisterConfirmMessage.isEmpty()) {
    gRegisterConfirmMessage = "All checks passed";
  }
  gRegisterConfirmCanSubmit = true;
}

static void prepareHomeserverPdaActionScreen() {
  gHomeserverPdaActionMessage = "";
  gHomeserverPdaActionDetail = "";
  gHomeserverPdaCanAdd = false;
  gHomeserverPdaCanFix = false;
  gHomeserverPdaResultMessage = "";
  gHomeserverPdaResultDetails = "";
  gHomeserverPdaResultSuccess = false;

  if (gLoginValue.isEmpty()) {
    gHomeserverPdaActionMessage = "Login is not set";
    return;
  }
  if (!gSecretConfigured) {
    gHomeserverPdaActionMessage = "Secret is not set";
    return;
  }
  if (gHomeserverValue.isEmpty()) {
    gHomeserverPdaActionMessage = "Homeserver is not set";
    return;
  }
  if (WiFi.status() != WL_CONNECTED) {
    gHomeserverPdaActionMessage = "Wi-Fi is not connected";
    gHomeserverPdaActionDetail = "Connect Wi-Fi, then try again.";
    return;
  }

  if (gHomeserverPdaActionReason == "homeserver key mismatch") {
    gHomeserverPdaActionMessage = "Homeserver key in PDA does not match local secret";
    gHomeserverPdaActionDetail = String("Homeserver: ") + gHomeserverValue;
    gHomeserverPdaCanFix = true;
    return;
  }
  if (gHomeserverPdaActionReason == "homeserver not in PDA") {
    gHomeserverPdaActionMessage = "Homeserver session is missing from user PDA";
    gHomeserverPdaActionDetail = String("Homeserver: ") + gHomeserverValue;
    gHomeserverPdaCanAdd = true;
    return;
  }

  gHomeserverPdaActionMessage = "Homeserver PDA action is not available";
}

static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messageOut) {
  messageOut = "";
  String diagDetails;
  auto failWithDiag = [&](const String &summary) -> bool {
    messageOut = summary;
    saveRegisterDiag("error", summary, diagDetails);
    printRegisterDiagToSerial();
    return false;
  };

  String cleanLogin = normalizeLoginValue(gLoginValue);
  diagDetails += String("trigger=") + gRegisterTriggerSource + "\n";
  diagDetails += String("action=") + (requireExisting ? "fix_homeserver_key" : "add_homeserver") + "\n";
  diagDetails += String("uptime_ms=") + String(millis()) + "\n";
  diagDetails += String("login=") + cleanLogin + "\n";
  diagDetails += String("homeserver=") + gHomeserverValue + "\n";
  diagDetails += String("rpc=") + gSolanaRpcUrl + "\n";

  if (cleanLogin.isEmpty()) {
    return failWithDiag("Login is not set");
  }
  if (!gSecretConfigured) {
    return failWithDiag("Secret is not ready");
  }
  if (gHomeserverValue.isEmpty()) {
    return failWithDiag("Homeserver is not set");
  }
  if (WiFi.status() != WL_CONNECTED) {
    diagDetails += "wifi=disconnected\n";
    return failWithDiag("Connect Wi-Fi first");
  }
  diagDetails += String("wifi=connected:") + WiFi.localIP().toString() + "\n";

  ShinePdaUserState currentState;
  String stateError;
  if (!readShineUserPda(cleanLogin, currentState, stateError)) {
    diagDetails += String("read_pda_error=") + stateError + "\n";
    return failWithDiag(stateError.isEmpty() ? "Failed to read user PDA" : stateError);
  }
  if (!currentState.found) {
    return failWithDiag("User PDA does not exist yet");
  }

  uint8_t rootSeed[32] = {};
  uint8_t rootPub[32] = {};
  uint8_t rootSec[64] = {};
  uint8_t deviceSeed[32] = {};
  uint8_t devicePub[32] = {};
  uint8_t deviceSec[64] = {};
  uint8_t homeserverPub[32] = {};
  if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec)
      || !deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)
      || !base58ToFixed32(gHomeserverPubB58, homeserverPub)) {
    return failWithDiag("Failed to restore local keys");
  }
  if (memcmp(devicePub, currentState.deviceKey32, 32) != 0) {
    return failWithDiag("Device key does not match PDA");
  }

  uint8_t userPda[32] = {};
  uint8_t economyConfig[32] = {};
  uint8_t inflowVault[32] = {};
  if (!findProgramAddress({
          std::vector<uint8_t>((const uint8_t *)kUsersSeedPrefix, (const uint8_t *)kUsersSeedPrefix + strlen(kUsersSeedPrefix)),
          std::vector<uint8_t>((const uint8_t *)cleanLogin.c_str(), (const uint8_t *)cleanLogin.c_str() + cleanLogin.length())
      }, kShineUsersProgramId, userPda)
      || !findProgramAddress({
          std::vector<uint8_t>((const uint8_t *)kUsersEconomyConfigSeed, (const uint8_t *)kUsersEconomyConfigSeed + strlen(kUsersEconomyConfigSeed))
      }, kShineUsersProgramId, economyConfig)
      || !findProgramAddress({
          std::vector<uint8_t>((const uint8_t *)kPaymentsInflowSeed, (const uint8_t *)kPaymentsInflowSeed + strlen(kPaymentsInflowSeed))
      }, kShinePaymentsProgramId, inflowVault)) {
    return failWithDiag("Failed to derive required PDAs");
  }

  String userPdaB58 = bytesToBase58(userPda, 32);
  diagDetails += String("user_pda=") + userPdaB58 + "\n";
  diagDetails += String("users_economy_config_pda=") + bytesToBase58(economyConfig, 32) + "\n";
  diagDetails += String("inflow_vault_pda=") + bytesToBase58(inflowVault, 32) + "\n";

  ShinePdaUserState nextState = currentState;
  memcpy(nextState.rootKey32, rootPub, 32);
  memcpy(nextState.deviceKey32, devicePub, 32);
  nextState.updatedAtMs = shineNowMs();
  nextState.recordNumber = currentState.recordNumber + 1;
  if (nextState.sessionsMode == 0) {
    nextState.sessionsMode = 1;
  }

  bool foundSession = false;
  for (auto &session : nextState.sessions) {
    if (session.sessionType == kSessionTypeHomeserver && session.sessionName == gHomeserverValue) {
      foundSession = true;
      memcpy(session.sessionPubKey32, homeserverPub, 32);
      session.sessionVersion = 1;
      break;
    }
  }
  if (!foundSession) {
    if (requireExisting) {
      return failWithDiag("Homeserver session is missing in PDA");
    }
    ShinePdaSessionRecord rec;
    rec.sessionType = kSessionTypeHomeserver;
    rec.sessionVersion = 1;
    rec.sessionName = gHomeserverValue;
    memcpy(rec.sessionPubKey32, homeserverPub, 32);
    nextState.sessions.push_back(rec);
  }

  std::vector<uint8_t> oldUnsigned = serializeUnsignedRecordState(currentState);
  uint8_t prevHash32[32] = {};
  sha256calc(oldUnsigned.data(), oldUnsigned.size(), prevHash32);
  memcpy(nextState.prevRecordHash32, prevHash32, 32);
  diagDetails += String("prev_hash=") + bytesToHexString(prevHash32, 32) + "\n";

  std::vector<uint8_t> newUnsigned = serializeUnsignedRecordState(nextState);
  uint8_t unsignedHash[32] = {};
  uint8_t rootSignature[64] = {};
  sha256calc(newUnsigned.data(), newUnsigned.size(), unsignedHash);
  if (!signMessageEd25519(std::vector<uint8_t>(unsignedHash, unsignedHash + 32), rootSec, rootSignature)) {
    return failWithDiag("Failed to sign updated PDA record");
  }
  diagDetails += String("unsigned_record_len=") + String((unsigned long)newUnsigned.size()) + "\n";
  diagDetails += String("unsigned_record_hash=") + bytesToHexString(unsignedHash, 32) + "\n";

  std::vector<uint8_t> lastBlockState = buildLastBlockStateBytes(
      cleanLogin,
      currentState.blockchainName,
      currentState.lastBlockNumber,
      currentState.lastBlockHash32,
      currentState.usedBytes);
  uint8_t lastBlockHash[32] = {};
  sha256calc(lastBlockState.data(), lastBlockState.size(), lastBlockHash);
  diagDetails += String("last_block_hash=") + bytesToHexString(lastBlockHash, 32) + "\n";

  std::vector<uint8_t> updateData = buildUpdateInstructionData(
      nextState,
      nextState.recordNumber,
      nextState.updatedAtMs,
      prevHash32,
      rootSignature);
  std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, rootPub, unsignedHash);
  std::vector<uint8_t> edBchData = buildEd25519InstructionData(currentState.lastBlockSignature64, currentState.blockchainKey32, lastBlockHash);

  uint8_t recentBlockhash[32] = {};
  String recentBlockhash58;
  if (!getLatestBlockhashBytes(recentBlockhash, recentBlockhash58, messageOut)) {
    diagDetails += String("blockhash_error=") + messageOut + "\n";
    return failWithDiag(messageOut);
  }
  diagDetails += String("recent_blockhash=") + recentBlockhash58 + "\n";

  std::vector<uint8_t> message = buildUpdateLegacyMessage(
      recentBlockhash,
      devicePub,
      userPda,
      inflowVault,
      economyConfig,
      edRootData,
      edBchData,
      updateData);
  uint8_t txSignature[64] = {};
  if (!signMessageEd25519(message, deviceSec, txSignature)) {
    return failWithDiag("Failed to sign Solana transaction");
  }
  String txBase64 = encodeTransactionBase64(txSignature, message);
  String signatureB58 = bytesToBase58(txSignature, 64);
  diagDetails += String("tx_signature=") + signatureB58 + "\n";

  String payload;
  if (!rpcCallSolana("sendTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"preflightCommitment\":\"confirmed\"}]", payload)) {
    diagDetails += "send_transaction_rpc_error=true\n";
    return failWithDiag("RPC did not accept transaction");
  }
  if (rpcResponseHasError(payload)) {
    if (!extractRpcErrorSummary(payload, messageOut)) {
      messageOut = "RPC returned sendTransaction error";
    }
    diagDetails += "send_transaction_payload<<\n";
    diagDetails += payload;
    diagDetails += "\n>>send_transaction_payload\n";
    String simulated;
    String simulatedPayload;
    if (simulateTransactionPayload(txBase64, simulatedPayload)) {
      extractRpcErrorSummary(simulatedPayload, simulated);
      if (messageOut.isEmpty()) {
        messageOut = simulated;
      } else if (messageOut.indexOf(simulated) < 0) {
        messageOut += " | simulate: " + simulated;
      }
      diagDetails += "simulate_payload<<\n";
      diagDetails += simulatedPayload;
      diagDetails += "\n>>simulate_payload\n";
    }
    return failWithDiag(messageOut);
  }
  if (!awaitTransactionConfirmation(signatureB58, messageOut)) {
    diagDetails += String("confirmation_error=") + messageOut + "\n";
    return failWithDiag(messageOut);
  }

  gUserPdaAddress = userPdaB58;
  gRegistrationSignature = signatureB58;
  saveAccountPrefs();
  refreshAccountPdaStatus();
  gHomeserverPdaResultSuccess = true;
  gHomeserverPdaResultMessage = requireExisting ? "Homeserver key updated in PDA" : "Homeserver added to user PDA";
  gHomeserverPdaResultDetails = String("tx: ") + abbreviateValue(signatureB58, 12, 8);
  messageOut = gHomeserverPdaResultMessage;
  saveRegisterDiag("ok", messageOut, diagDetails);
  printRegisterDiagToSerial();
  return true;
}

static bool parseShineUserPdaBytes(const std::vector<uint8_t> &bytes, ShinePdaUserState &outState, String &errorOut) {
  outState = ShinePdaUserState{};
  errorOut = "";
  if (bytes.size() < 64) {
    errorOut = "PDA too short";
    return false;
  }

  size_t offset = 0;
  auto ensure = [&](size_t need) -> bool {
    return offset + need <= bytes.size();
  };
  auto readU8 = [&](uint8_t &value) -> bool {
    if (!ensure(1)) return false;
    value = bytes[offset++];
    return true;
  };
  auto readU16 = [&](uint16_t &value) -> bool {
    if (!ensure(2)) return false;
    value = (uint16_t)bytes[offset] | ((uint16_t)bytes[offset + 1] << 8);
    offset += 2;
    return true;
  };
  auto readU32 = [&](uint32_t &value) -> bool {
    if (!ensure(4)) return false;
    value = (uint32_t)bytes[offset]
          | ((uint32_t)bytes[offset + 1] << 8)
          | ((uint32_t)bytes[offset + 2] << 16)
          | ((uint32_t)bytes[offset + 3] << 24);
    offset += 4;
    return true;
  };
  auto readU64 = [&](uint64_t &value) -> bool {
    if (!ensure(8)) return false;
    value = 0;
    for (int i = 0; i < 8; ++i) {
      value |= ((uint64_t)bytes[offset + i]) << (8 * i);
    }
    offset += 8;
    return true;
  };
  auto readBytes = [&](uint8_t *dst, size_t len) -> bool {
    if (!ensure(len)) return false;
    memcpy(dst, bytes.data() + offset, len);
    offset += len;
    return true;
  };
  auto readStringU8 = [&](String &value) -> bool {
    uint8_t len = 0;
    if (!readU8(len) || !ensure(len)) return false;
    value = "";
    value.reserve(len);
    for (uint8_t i = 0; i < len; ++i) {
      value += (char)bytes[offset + i];
    }
    offset += len;
    return true;
  };

  if (!ensure(5) || memcmp(bytes.data(), "SHiNE", 5) != 0) {
    errorOut = "Bad PDA magic";
    return false;
  }
  offset += 5;
  uint8_t version = 0;
  uint8_t flags = 0;
  uint16_t recordLen = 0;
  if (!readU8(version) || !readU8(flags) || !readU16(recordLen)) {
    errorOut = "Bad PDA header";
    return false;
  }
  if (recordLen < 73 || recordLen > bytes.size()) {
    errorOut = "Bad PDA record len";
    return false;
  }
  offset = 9;
  if (!readU64(outState.createdAtMs) || !readU64(outState.updatedAtMs) || !readU32(outState.recordNumber)) {
    errorOut = "Bad PDA fixed fields";
    return false;
  }
  if (!readBytes(outState.prevRecordHash32, 32)) {
    errorOut = "Bad PDA prev hash";
    return false;
  }
  if (!readStringU8(outState.login)) {
    errorOut = "Bad PDA login";
    return false;
  }
  uint8_t blocksCount = 0;
  if (!readU8(blocksCount)) {
    errorOut = "Bad PDA blocks count";
    return false;
  }

  for (uint8_t i = 0; i < blocksCount; ++i) {
    uint8_t blockType = 0;
    uint8_t blockVersion = 0;
    if (!readU8(blockType) || !readU8(blockVersion)) {
      errorOut = "Bad PDA block header";
      return false;
    }
    if (blockType == kBlockTypeRootKey) {
      if (!readBytes(outState.rootKey32, 32)) {
        errorOut = "Bad root key block";
        return false;
      }
      continue;
    }
    if (blockType == kBlockTypeDeviceKey) {
      if (!readBytes(outState.deviceKey32, 32)) {
        errorOut = "Bad device key block";
        return false;
      }
      continue;
    }
    if (blockType == kBlockTypeBlockchainRegistry) {
      uint8_t count = 0;
      if (!readU8(count)) {
        errorOut = "Bad blockchain count";
        return false;
      }
      for (uint8_t j = 0; j < count; ++j) {
        uint8_t blockchainType = 0;
        String blockchainName;
        uint8_t blockchainKey[32] = {};
        uint64_t paidLimit = 0;
        uint64_t usedBytes = 0;
        uint32_t lastBlockNumber = 0;
        uint8_t lastBlockHash[32] = {};
        uint8_t lastBlockSig[64] = {};
        uint8_t arweavePresent = 0;
        String arweaveTxId;
        if (!readU8(blockchainType)
            || !readStringU8(blockchainName)
            || !readBytes(blockchainKey, 32)
            || !readU64(paidLimit)
            || !readU64(usedBytes)
            || !readU32(lastBlockNumber)
            || !readBytes(lastBlockHash, 32)
            || !readBytes(lastBlockSig, 64)
            || !readU8(arweavePresent)) {
          errorOut = "Bad blockchain block";
          return false;
        }
        if (arweavePresent == 1 && !readStringU8(arweaveTxId)) {
          errorOut = "Bad arweave field";
          return false;
        }
        if (j == 0) {
          outState.blockchainType = blockchainType;
          outState.blockchainName = blockchainName;
          memcpy(outState.blockchainKey32, blockchainKey, 32);
          outState.paidLimitBytes = paidLimit;
          outState.usedBytes = usedBytes;
          outState.lastBlockNumber = lastBlockNumber;
          memcpy(outState.lastBlockHash32, lastBlockHash, 32);
          memcpy(outState.lastBlockSignature64, lastBlockSig, 64);
          outState.arweaveTxId = arweaveTxId;
        }
      }
      continue;
    }
    if (blockType == kBlockTypeServerProfile) {
      uint8_t isServer = 0;
      if (!readU8(isServer)) {
        errorOut = "Bad server profile";
        return false;
      }
      outState.isServer = isServer == 1;
      if (outState.isServer) {
        outState.addressFormatType = 0;
        outState.addressFormatVersion = 0;
        uint8_t syncCount = 0;
        if (!readU8(outState.addressFormatType)
            || !readU8(outState.addressFormatVersion)
            || !readStringU8(outState.serverAddress)
            || !readU8(syncCount)) {
          errorOut = "Bad server address";
          return false;
        }
        outState.syncServers.clear();
        for (uint8_t j = 0; j < syncCount; ++j) {
          String syncServer;
          if (!readStringU8(syncServer)) {
            errorOut = "Bad sync_servers";
            return false;
          }
          outState.syncServers.push_back(syncServer);
        }
      }
      continue;
    }
    if (blockType == kBlockTypeAccessServers) {
      uint8_t accessCount = 0;
      if (!readU8(accessCount)) {
        errorOut = "Bad access servers";
        return false;
      }
      outState.accessServers.clear();
      for (uint8_t j = 0; j < accessCount; ++j) {
        String accessServer;
        if (!readStringU8(accessServer)) {
          errorOut = "Bad access server item";
          return false;
        }
        outState.accessServers.push_back(accessServer);
      }
      continue;
    }
    if (blockType == kBlockTypeSessions) {
      uint8_t sessionsMode = 0;
      uint8_t sessionsCount = 0;
      if (!readU8(sessionsMode) || !readU8(sessionsCount)) {
        errorOut = "Bad sessions block";
        return false;
      }
      outState.sessionsMode = sessionsMode;
      outState.sessions.clear();
      for (uint8_t j = 0; j < sessionsCount; ++j) {
        ShinePdaSessionRecord rec;
        if (!readU8(rec.sessionType)
            || !readU8(rec.sessionVersion)
            || !readStringU8(rec.sessionName)
            || !readBytes(rec.sessionPubKey32, 32)) {
          errorOut = "Bad session item";
          return false;
        }
        outState.sessions.push_back(rec);
      }
      continue;
    }
    if (blockType == kBlockTypeTrustedState) {
      if (!readU8(outState.trustedCount)) {
        errorOut = "Bad trusted block";
        return false;
      }
      continue;
    }
    errorOut = "Unknown PDA block";
    return false;
  }

  if (offset + 64 > recordLen) {
    errorOut = "Missing PDA signature";
    return false;
  }
  outState.found = true;
  return true;
}

static bool readShineUserPda(const String &login, ShinePdaUserState &outState, String &errorOut) {
  outState = ShinePdaUserState{};
  errorOut = "";
  String cleanLogin = normalizeLoginValue(login);
  if (cleanLogin.isEmpty()) {
    errorOut = "Login not set";
    return false;
  }
  if (gSolanaRpcUrl.isEmpty()) {
    errorOut = "Solana RPC not set";
    return false;
  }

  uint8_t userPda[32] = {};
  std::vector<std::vector<uint8_t>> seeds = {
      std::vector<uint8_t>((const uint8_t *)kShineUsersUserPdaSeedPrefix,
                           (const uint8_t *)kShineUsersUserPdaSeedPrefix + strlen(kShineUsersUserPdaSeedPrefix)),
      std::vector<uint8_t>((const uint8_t *)cleanLogin.c_str(),
                           (const uint8_t *)cleanLogin.c_str() + cleanLogin.length()),
  };
  if (!findProgramAddress(seeds, kShineUsersProgramId, userPda)) {
    errorOut = "Cannot derive user PDA";
    return false;
  }

  String pdaB58 = base58From32(userPda);
  int code = -1;
  String payload;
  String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getAccountInfo\",\"params\":[\"" + pdaB58 + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]}";
  if (!httpPostJson(gSolanaRpcUrl, req, code, payload)) {
    errorOut = "Solana RPC unavailable";
    return false;
  }
  if (payload.indexOf("\"value\":null") >= 0) {
    outState.found = false;
    return true;
  }
  String dataB64;
  if (!jsonStringField(payload, "data", dataB64)) {
    errorOut = "PDA base64 missing";
    return false;
  }
  std::vector<uint8_t> raw;
  if (!base64DecodeStd(dataB64, raw)) {
    errorOut = "PDA base64 decode failed";
    return false;
  }
  return parseShineUserPdaBytes(raw, outState, errorOut);
}

static void refreshAccountPdaStatus() {
  gAccountCheckPending = false;
  gShowRegisterAccountButton = false;
  gShowHomeserverPdaActionButton = false;
  gHomeserverPdaActionReason = "";
  gHomeserverPdaCanAdd = false;
  gHomeserverPdaCanFix = false;

  if (gLoginValue.isEmpty() || !gSecretConfigured) {
    gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN;
    gAccountPdaStatusMessage = "account not configured";
    gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured";
    clearShineSessionState(false);
    return;
  }

  ShinePdaUserState pdaState;
  String error;
  if (!readShineUserPda(gLoginValue, pdaState, error)) {
    gAccountPdaStatus = ACCOUNT_PDA_MISMATCH;
    gAccountPdaStatusMessage = error.isEmpty() ? "solana check failed" : error;
    gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " unavailable";
    clearShineSessionState(false);
    if (error == "Solana RPC unavailable") {
      gAccountCheckPending = true;
    }
    return;
  }

  if (!pdaState.found) {
    gAccountPdaStatus = ACCOUNT_PDA_NOT_FOUND;
    gAccountPdaStatusMessage = "user not found";
    gShowRegisterAccountButton = true;
    gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured";
    clearShineSessionState(false);
    return;
  }

  uint8_t rootPub[32] = {};
  uint8_t devicePub[32] = {};
  uint8_t blockchainPub[32] = {};
  if (!base58ToFixed32(gRootPubB58, rootPub)
      || !base58ToFixed32(gDevicePubB58, devicePub)
      || !base58ToFixed32(gBlockchainPubB58, blockchainPub)) {
    gAccountPdaStatus = ACCOUNT_PDA_MISMATCH;
    gAccountPdaStatusMessage = "local keys invalid";
    gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured";
    clearShineSessionState(false);
    return;
  }

  String mismatch;
  if (memcmp(rootPub, pdaState.rootKey32, 32) != 0) {
    mismatch = "root key mismatch";
  } else if (memcmp(blockchainPub, pdaState.blockchainKey32, 32) != 0) {
    mismatch = "blockchain key mismatch";
  } else if (memcmp(devicePub, pdaState.deviceKey32, 32) != 0) {
    mismatch = "device key mismatch";
  } else if (gHomeserverValue.isEmpty()) {
    mismatch = "homeserver not set";
  } else {
    bool foundSession = false;
    bool sessionMismatch = false;
    for (const auto &session : pdaState.sessions) {
      if (session.sessionType == kSessionTypeHomeserver && session.sessionName == gHomeserverValue) {
        foundSession = true;
        if (gHomeserverPubB58.isEmpty()) {
          sessionMismatch = true;
        } else {
          uint8_t homeserverPub[32] = {};
          if (!base58ToFixed32(gHomeserverPubB58, homeserverPub)
              || memcmp(homeserverPub, session.sessionPubKey32, 32) != 0) {
            sessionMismatch = true;
          }
        }
        break;
      }
    }
    if (!foundSession) {
      mismatch = "homeserver not in PDA";
    } else if (sessionMismatch) {
      mismatch = "homeserver key mismatch";
    }
  }

  if (!mismatch.isEmpty()) {
    gAccountPdaStatus = ACCOUNT_PDA_MISMATCH;
    gAccountPdaStatusMessage = mismatch;
    if (mismatch == "homeserver not in PDA") {
      gShowHomeserverPdaActionButton = true;
      gHomeserverPdaActionReason = mismatch;
      gHomeserverPdaCanAdd = true;
    } else if (mismatch == "homeserver key mismatch") {
      gShowHomeserverPdaActionButton = true;
      gHomeserverPdaActionReason = mismatch;
      gHomeserverPdaCanFix = true;
    }
    gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured";
    clearShineSessionState(false);
    return;
  }

  gAccountPdaStatus = ACCOUNT_PDA_OK;
  gAccountPdaStatusMessage = "ok";
}

static void manageAccountPdaRefresh() {
  if (!gAccountCheckPending) {
    return;
  }
  if (WiFi.status() != WL_CONNECTED) {
    return;
  }
  unsigned long now = millis();
  if (gLastAccountCheckMs != 0 && now - gLastAccountCheckMs < ACCOUNT_CHECK_RETRY_MS) {
    return;
  }
  gLastAccountCheckMs = now;
  refreshAccountPdaStatus();
}

static bool waitForClientBytes(WiFiClientSecure &client, size_t need, uint32_t timeoutMs) {
  unsigned long start = millis();
  while (client.connected() && millis() - start < timeoutMs) {
    if ((size_t)client.available() >= need) {
      return true;
    }
    delay(2);
  }
  return false;
}

static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &url, String &errorOut) {
  errorOut = "";
  if (ws.connected && ws.client.connected()) {
    return true;
  }

  closeWebSocket(ws);

  bool secure = false;
  if (!parseUrlHostPortPath(url, ws.host, ws.port, ws.path, secure)) {
    errorOut = "bad WS url";
    return false;
  }

  ws.client.setInsecure();
  ws.client.setTimeout(SHINE_WS_CONNECT_TIMEOUT_MS);
  if (!ws.client.connect(ws.host.c_str(), ws.port)) {
    errorOut = "WS connect failed";
    return false;
  }

  uint8_t keyRaw[16];
  for (size_t i = 0; i < sizeof(keyRaw); ++i) {
    keyRaw[i] = (uint8_t)esp_random();
  }
  String secKey = bytesToBase64String(keyRaw, sizeof(keyRaw));
  String request =
      String("GET ") + ws.path + " HTTP/1.1\r\n"
      + "Host: " + ws.host + ":" + String(ws.port) + "\r\n"
      + "Upgrade: websocket\r\n"
      + "Connection: Upgrade\r\n"
      + "Sec-WebSocket-Version: 13\r\n"
      + "Sec-WebSocket-Key: " + secKey + "\r\n"
      + "User-Agent: SHiNE-ESP32\r\n\r\n";
  ws.client.print(request);

  String statusLine = ws.client.readStringUntil('\n');
  if (statusLine.indexOf("101") < 0) {
    errorOut = String("WS handshake failed: ") + statusLine;
    closeWebSocket(ws);
    return false;
  }

  while (ws.client.connected()) {
    String headerLine = ws.client.readStringUntil('\n');
    headerLine.trim();
    if (headerLine.isEmpty()) {
      break;
    }
  }

  ws.connected = true;
  return true;
}

static bool wsSendFrame(SimpleWebSocketClient &ws, uint8_t opcode, const uint8_t *data, size_t len) {
  if (!ws.connected || !ws.client.connected()) {
    return false;
  }
  uint8_t header[10];
  size_t headerLen = 0;
  header[headerLen++] = 0x80 | (opcode & 0x0F);
  if (len < 126) {
    header[headerLen++] = 0x80 | (uint8_t)len;
  } else {
    header[headerLen++] = 0x80 | 126;
    header[headerLen++] = (uint8_t)((len >> 8) & 0xFF);
    header[headerLen++] = (uint8_t)(len & 0xFF);
  }
  uint8_t mask[4];
  for (int i = 0; i < 4; ++i) {
    mask[i] = (uint8_t)esp_random();
    header[headerLen++] = mask[i];
  }
  if (ws.client.write(header, headerLen) != (int)headerLen) {
    closeWebSocket(ws);
    return false;
  }
  for (size_t i = 0; i < len; ++i) {
    uint8_t byte = data ? (data[i] ^ mask[i % 4]) : mask[i % 4];
    if (ws.client.write(&byte, 1) != 1) {
      closeWebSocket(ws);
      return false;
    }
  }
  return true;
}

static bool wsSendText(SimpleWebSocketClient &ws, const String &payload) {
  return wsSendFrame(ws, 0x1, reinterpret_cast<const uint8_t *>(payload.c_str()), payload.length());
}

static bool wsReadTextFrame(SimpleWebSocketClient &ws, String &messageOut, uint32_t timeoutMs) {
  messageOut = "";
  while (ws.connected && ws.client.connected()) {
    if (!waitForClientBytes(ws.client, 2, timeoutMs)) {
      closeWebSocket(ws);
      return false;
    }
    uint8_t hdr[2];
    if (ws.client.read(hdr, 2) != 2) {
      closeWebSocket(ws);
      return false;
    }
    uint8_t opcode = hdr[0] & 0x0F;
    bool masked = (hdr[1] & 0x80) != 0;
    uint64_t payloadLen = hdr[1] & 0x7F;
    if (payloadLen == 126) {
      if (!waitForClientBytes(ws.client, 2, timeoutMs)) {
        closeWebSocket(ws);
        return false;
      }
      uint8_t ext[2];
      if (ws.client.read(ext, 2) != 2) {
        closeWebSocket(ws);
        return false;
      }
      payloadLen = ((uint16_t)ext[0] << 8) | ext[1];
    } else if (payloadLen == 127) {
      closeWebSocket(ws);
      return false;
    }
    uint8_t mask[4] = {};
    if (masked) {
      if (!waitForClientBytes(ws.client, 4, timeoutMs) || ws.client.read(mask, 4) != 4) {
        closeWebSocket(ws);
        return false;
      }
    }
    if (payloadLen > 4096) {
      closeWebSocket(ws);
      return false;
    }
    std::vector<uint8_t> payload(payloadLen);
    if (payloadLen > 0) {
      if (!waitForClientBytes(ws.client, payloadLen, timeoutMs)
          || ws.client.read(payload.data(), payloadLen) != (int)payloadLen) {
        closeWebSocket(ws);
        return false;
      }
      if (masked) {
        for (size_t i = 0; i < payloadLen; ++i) {
          payload[i] ^= mask[i % 4];
        }
      }
    }

    if (opcode == 0x8) {
      closeWebSocket(ws);
      return false;
    }
    if (opcode == 0x9) {
      wsSendFrame(ws, 0xA, payload.data(), payload.size());
      continue;
    }
    if (opcode == 0xA) {
      continue;
    }
    if (opcode == 0x1) {
      messageOut = "";
      messageOut.reserve(payload.size());
      for (uint8_t byte : payload) {
        messageOut += (char)byte;
      }
      return true;
    }
  }
  closeWebSocket(ws);
  return false;
}

static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const String &payloadJson, String &responseOut, uint32_t timeoutMs) {
  responseOut = "";
  String requestId = String("esp32-") + String(gWsRequestCounter++);
  String request = String("{\"op\":\"") + op + "\",\"requestId\":\"" + requestId + "\",\"payload\":" + payloadJson + "}";
  if (!wsSendText(ws, request)) {
    return false;
  }
  unsigned long start = millis();
  while (millis() - start < timeoutMs) {
    String frame;
    if (!wsReadTextFrame(ws, frame, timeoutMs)) {
      return false;
    }
    if (frame.indexOf("\"requestId\":\"" + requestId + "\"") >= 0 && frame.indexOf("\"op\":\"" + op + "\"") >= 0) {
      responseOut = frame;
      return true;
    }
  }
  return false;
}

static bool ensureShineSessionAuthenticated(String &errorOut) {
  errorOut = "";
  String diagDetails;
  auto failWithDiag = [&](const String &summary) -> bool {
    errorOut = summary;
    saveShineConnectDiag("error", summary, diagDetails);
    printRegisterDiagToSerial();
    return false;
  };
  diagDetails += "kind=shine_connect\n";
  diagDetails += String("uptime_ms=") + String(millis()) + "\n";
  diagDetails += String("login=") + gLoginValue + "\n";
  diagDetails += String("homeserver=") + gHomeserverValue + "\n";
  diagDetails += String("server_url=") + gShineServerUrl + "\n";
  diagDetails += String("ws_url=") + shineWsUrl() + "\n";
  diagDetails += String("pda_status=") + gAccountPdaStatusMessage + "\n";
  if (WiFi.status() != WL_CONNECTED) {
    diagDetails += "wifi=disconnected\n";
    return failWithDiag("Wi-Fi disconnected");
  }
  diagDetails += String("wifi=connected:") + WiFi.localIP().toString() + "\n";
  if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) {
    return failWithDiag("account not configured");
  }
  if (gAccountPdaStatus != ACCOUNT_PDA_OK) {
    return failWithDiag("account not ready");
  }

  String wsUrl = shineWsUrl();
  if (wsUrl.isEmpty()) {
    return failWithDiag("shine server not set");
  }
  if (!ensureWebSocketConnected(gShineWs, wsUrl, errorOut)) {
    diagDetails += String("ws_connect_error=") + errorOut + "\n";
    return failWithDiag(errorOut.isEmpty() ? "WS connect failed" : errorOut);
  }
  diagDetails += "ws_connected=true\n";

  {
    String pingResp;
    if (!shineWsRequest(gShineWs, "Ping", "{\"ts\":0}", pingResp, SHINE_RPC_TIMEOUT_MS)) {
      diagDetails += "ping_error=request_failed\n";
      return failWithDiag("Ping failed");
    }
    diagDetails += "ping_response<<\n";
    diagDetails += pingResp;
    diagDetails += "\n>>ping_response\n";
    uint64_t pingStatus = 0;
    uint64_t serverTs = 0;
    if (!jsonInt64Field(pingResp, "status", pingStatus) || pingStatus != 200 || !jsonInt64Field(pingResp, "ts", serverTs)) {
      return failWithDiag("Ping rejected");
    }
    gShineServerTimeOffsetMs = (int64_t)serverTs - (int64_t)millis();
    diagDetails += String("server_time_offset_ms=") + String((long long)gShineServerTimeOffsetMs) + "\n";
  }

  uint8_t deviceSeed[32] = {};
  uint8_t devicePub[32] = {};
  uint8_t deviceSec[64] = {};
  uint8_t subSeed[32] = {};
  uint8_t subPub[32] = {};
  uint8_t subSec[64] = {};
  if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)
      || !deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) {
    return failWithDiag("local key derive failed");
  }

  String sessionKey = buildSessionKeyStringFromPublicBase64(bytesToBase64String(subPub, 32));
  diagDetails += String("session_key=") + sessionKey + "\n";
  if (!gShineSessionKey.isEmpty() && gShineSessionKey != sessionKey) {
    clearShineSessionState(true);
    diagDetails += "stored_session_key_reset=true\n";
  }

  if (!gShineSessionId.isEmpty()) {
    diagDetails += String("stored_session_id=") + gShineSessionId + "\n";
    String response;
    if (shineWsRequest(gShineWs,
                       "SessionChallenge",
                       String("{\"sessionId\":\"") + jsonEscape(gShineSessionId) + "\"}",
                       response)) {
      diagDetails += "session_challenge_response<<\n";
      diagDetails += response;
      diagDetails += "\n>>session_challenge_response\n";
      uint64_t statusCode = 0;
      String nonce;
      if (jsonInt64Field(response, "status", statusCode) && statusCode == 200 && jsonStringField(response, "nonce", nonce)) {
        uint64_t timeMs = shineNowMs();
        String preimage = String("SESSION_LOGIN:") + gShineSessionId + ":" + String((unsigned long long)timeMs) + ":" + nonce;
        uint8_t signature[64] = {};
        crypto_sign_ed25519_detached(signature, nullptr,
                                     reinterpret_cast<const unsigned char *>(preimage.c_str()),
                                     preimage.length(), subSec);
        String loginReq = String("{\"sessionId\":\"") + jsonEscape(gShineSessionId)
                        + "\",\"sessionKey\":\"" + jsonEscape(sessionKey)
                        + "\",\"timeMs\":" + String((unsigned long long)timeMs)
                        + ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
                        + "\",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver)
                        + ",\"clientPlatform\":\"" + jsonEscape(kSessionClientPlatformEsp32)
                        + "\",\"clientInfo\":\"ESP32 homeserver\"}";
        String loginResp;
        if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) {
          diagDetails += "session_login_response<<\n";
          diagDetails += loginResp;
          diagDetails += "\n>>session_login_response\n";
          if (jsonInt64Field(loginResp, "status", statusCode) && statusCode == 200) {
            String storagePwd;
            if (jsonStringField(loginResp, "storagePwd", storagePwd)) {
              gShineStoragePwd = storagePwd;
            }
            gShineSessionKey = sessionKey;
            gShineAuthenticated = true;
            saveShineSessionPrefs();
            saveShineConnectDiag("ok", "SessionLogin success", diagDetails);
            return true;
          }
        }
      }
    }
    clearShineSessionState(true);
    if (!ensureWebSocketConnected(gShineWs, wsUrl, errorOut)) {
      diagDetails += String("ws_reconnect_error=") + errorOut + "\n";
      return failWithDiag(errorOut.isEmpty() ? "WS reconnect failed" : errorOut);
    }
    diagDetails += "stored_session_login_failed=true\n";
  }

  if (gShineStoragePwd.isEmpty()) {
    uint8_t storageRaw[32];
    for (size_t i = 0; i < sizeof(storageRaw); ++i) {
      storageRaw[i] = (uint8_t)esp_random();
    }
    gShineStoragePwd = bytesToBase64String(storageRaw, sizeof(storageRaw));
  }

  String authResp;
  if (!shineWsRequest(gShineWs, "AuthChallenge",
                      String("{\"login\":\"") + jsonEscape(gLoginValue) + "\"}",
                      authResp)) {
    diagDetails += "auth_challenge_error=request_failed\n";
    return failWithDiag("AuthChallenge failed");
  }
  diagDetails += "auth_challenge_response<<\n";
  diagDetails += authResp;
  diagDetails += "\n>>auth_challenge_response\n";
  uint64_t statusCode = 0;
  String authNonce;
  if (!jsonInt64Field(authResp, "status", statusCode) || statusCode != 200 || !jsonStringField(authResp, "authNonce", authNonce)) {
    return failWithDiag("AuthChallenge rejected");
  }

  uint64_t timeMs = shineNowMs();
  String preimage = String("AUTH_CREATE_SESSION:") + gLoginValue + ":" + sessionKey + ":" + gShineStoragePwd + ":" + String((unsigned long long)timeMs) + ":" + authNonce;
  uint8_t signature[64] = {};
  crypto_sign_ed25519_detached(signature, nullptr,
                               reinterpret_cast<const unsigned char *>(preimage.c_str()),
                               preimage.length(), deviceSec);
  String createReq = String("{\"login\":\"") + jsonEscape(gLoginValue)
                   + "\",\"sessionKey\":\"" + jsonEscape(sessionKey)
                   + "\",\"storagePwd\":\"" + jsonEscape(gShineStoragePwd)
                   + "\",\"timeMs\":" + String((unsigned long long)timeMs)
                   + ",\"authNonce\":\"" + jsonEscape(authNonce)
                   + "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32))
                   + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
                   + "\",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver)
                   + ",\"clientPlatform\":\"" + jsonEscape(kSessionClientPlatformEsp32)
                   + "\",\"clientInfo\":\"ESP32 homeserver\"}";
  String createResp;
  if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) {
    diagDetails += "create_auth_session_error=request_failed\n";
    return failWithDiag("CreateAuthSession failed");
  }
  diagDetails += "create_auth_session_response<<\n";
  diagDetails += createResp;
  diagDetails += "\n>>create_auth_session_response\n";
  if (!jsonInt64Field(createResp, "status", statusCode) || statusCode != 200 || !jsonStringField(createResp, "sessionId", gShineSessionId)) {
    return failWithDiag("CreateAuthSession rejected");
  }

  gShineSessionKey = sessionKey;
  gShineAuthenticated = true;
  saveShineSessionPrefs();
  diagDetails += String("new_session_id=") + gShineSessionId + "\n";
  saveShineConnectDiag("ok", "CreateAuthSession success", diagDetails);
  return true;
}

static void manageShineConnection() {
  String serverLabel = gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl;
  if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) {
    gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured";
    clearShineSessionState(false);
    gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
    return;
  }
  if (gAccountPdaStatus != ACCOUNT_PDA_OK) {
    gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured";
    clearShineSessionState(false);
    gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
    return;
  }
  if (WiFi.status() != WL_CONNECTED) {
    gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable";
    clearShineSessionState(false);
    gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
    return;
  }

  unsigned long now = millis();
  if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
    if (shouldPauseShineReconnectForUi()) {
      return;
    }
    if (now - gLastShineAttemptMs < gShineReconnectDelayMs) {
      return;
    }
    gLastShineAttemptMs = now;
    String error;
    if (ensureShineSessionAuthenticated(error)) {
      gShineStatusLine = String("SHiNE: ") + serverLabel + " connected";
      gLastShinePingMs = now;
      gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
    } else {
      gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable";
      clearShineSessionState(false);
      gShineReconnectDelayMs = min(gShineReconnectDelayMs + SHINE_RECONNECT_MIN_MS, (unsigned long)SHINE_RECONNECT_MAX_MS);
    }
    return;
  }

  if (now - gLastShinePingMs >= SHINE_PING_INTERVAL_MS) {
    String pingResp;
    if (shineWsRequest(gShineWs, "Ping", "{\"ts\":0}", pingResp, SHINE_RPC_TIMEOUT_MS)) {
      uint64_t statusCode = 0;
      if (jsonInt64Field(pingResp, "status", statusCode) && statusCode == 200) {
        gLastShinePingMs = now;
        gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
        gShineStatusLine = String("SHiNE: ") + serverLabel + " connected";
        return;
      }
    }
    String details;
    details += "kind=shine_ping\n";
    details += String("uptime_ms=") + String(millis()) + "\n";
    details += String("server_url=") + gShineServerUrl + "\n";
    details += "ping_response<<\n";
    details += pingResp;
    details += "\n>>ping_response\n";
    saveShineConnectDiag("error", "Ping keepalive failed", details);
    printRegisterDiagToSerial();
    gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable";
    clearShineSessionState(false);
    gShineReconnectDelayMs = min(gShineReconnectDelayMs + SHINE_RECONNECT_MIN_MS, (unsigned long)SHINE_RECONNECT_MAX_MS);
  }
}

static void upsertKnownWifi(const String &ssid, const String &password) {
  if (ssid.isEmpty()) {
    return;
  }

  int existing = findKnownWifiIndex(ssid);
  if (existing >= 0) {
    gKnownWifiPasswords[existing] = password;
    return;
  }

  for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) {
    if (gKnownWifiSsids[i].isEmpty()) {
      gKnownWifiSsids[i] = ssid;
      gKnownWifiPasswords[i] = password;
      return;
    }
  }

  for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS - 1; ++i) {
    gKnownWifiSsids[i] = gKnownWifiSsids[i + 1];
    gKnownWifiPasswords[i] = gKnownWifiPasswords[i + 1];
  }
  gKnownWifiSsids[MAX_SAVED_WIFI_NETWORKS - 1] = ssid;
  gKnownWifiPasswords[MAX_SAVED_WIFI_NETWORKS - 1] = password;
}

static void loadPrefs() {
  clearSavedWifiList();
  gWifiSavedSsid = gPrefs.getString("wifi_ssid", "");
  gWifiSavedPassword = gPrefs.getString("wifi_pass", "");
  gWifiKnownGood = gPrefs.getBool("wifi_known_good", false);
  for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) {
    String ssidKey = String("wifi_ssid_") + i;
    String passKey = String("wifi_pass_") + i;
    gKnownWifiSsids[i] = gPrefs.getString(ssidKey.c_str(), "");
    gKnownWifiPasswords[i] = gPrefs.getString(passKey.c_str(), "");
  }
  if (!gWifiSavedSsid.isEmpty() && findKnownWifiIndex(gWifiSavedSsid) < 0) {
    upsertKnownWifi(gWifiSavedSsid, gWifiSavedPassword);
  }
  gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com");
  gShineServerUrl = gPrefs.getString("shine_server", "https://shineup.me");
  gLoginValue = gPrefs.getString("login", "");
  gHomeserverValue = gPrefs.getString("homeserver", "homeserver1");
  gSecretConfigured = gPrefs.getBool("secret_set", false);
  gSecretBase58 = gPrefs.getString("secret_b58", "");
  if (gSecretConfigured && gPrefs.getBytesLength("secret_bytes") == 32) {
    gPrefs.getBytes("secret_bytes", gSecretBytes, 32);
  } else {
    memset(gSecretBytes, 0, sizeof(gSecretBytes));
    if (!gSecretConfigured) {
      gSecretBase58 = "";
    }
  }
  refreshDerivedKeys();
  gShineSessionId = gPrefs.getString("shine_sess_id", "");
  gShineSessionKey = gPrefs.getString("shine_sess_key", "");
  gShineStoragePwd = gPrefs.getString("shine_store_pwd", "");
  gUserPdaAddress = gPrefs.getString("user_pda", "");
  gRegistrationSignature = gPrefs.getString("registration_sig", "");
  gLastRegisterDiagStatus = gPrefs.getString("reg_diag_status", "none");
  gLastRegisterDiagSummary = gPrefs.getString("reg_diag_summary", "");
  loadRegisterDiagDetailsFromPrefs();
  gLastRegisterDiagTime = gPrefs.getString("reg_diag_time", "");
  gBalanceStatusMessage = gDevicePubB58.isEmpty() ? "Balance: secret not set" : "Balance: tap to load";
  gBalanceAutoRefreshPending = !gDevicePubB58.isEmpty();
  gLastBalanceAutoRefreshAttemptMs = 0;
  gAccountCheckPending = true;
  gLastAccountCheckMs = 0;
  gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN;
  gAccountPdaStatusMessage = "Account not checked";
  gShowRegisterAccountButton = false;
  gShowHomeserverPdaActionButton = false;
  gHomeserverPdaActionReason = "";
  gHomeserverPdaActionMessage = "";
  gHomeserverPdaActionDetail = "";
  gHomeserverPdaCanAdd = false;
  gHomeserverPdaCanFix = false;
  gHomeserverPdaResultMessage = "";
  gHomeserverPdaResultDetails = "";
  gHomeserverPdaResultSuccess = false;
  gShineStatusLine = "SHiNE: account not configured";
}

static void saveWifiPrefs() {
  gPrefs.putString("wifi_ssid", gWifiSavedSsid);
  gPrefs.putString("wifi_pass", gWifiSavedPassword);
  gPrefs.putBool("wifi_known_good", gWifiKnownGood);
  for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) {
    String ssidKey = String("wifi_ssid_") + i;
    String passKey = String("wifi_pass_") + i;
    if (gKnownWifiSsids[i].isEmpty()) {
      gPrefs.remove(ssidKey.c_str());
      gPrefs.remove(passKey.c_str());
    } else {
      gPrefs.putString(ssidKey.c_str(), gKnownWifiSsids[i]);
      gPrefs.putString(passKey.c_str(), gKnownWifiPasswords[i]);
    }
  }
}

static void saveServerPrefs() {
  gPrefs.putString("solana_rpc", gSolanaRpcUrl);
  gPrefs.putString("shine_server", gShineServerUrl);
}

static void saveAccountPrefs() {
  gPrefs.putString("login", gLoginValue);
  gPrefs.putString("homeserver", gHomeserverValue);
  gPrefs.putBool("secret_set", gSecretConfigured);
  gPrefs.putString("secret_b58", gSecretBase58);
  if (gUserPdaAddress.isEmpty()) {
    gPrefs.remove("user_pda");
  } else {
    gPrefs.putString("user_pda", gUserPdaAddress);
  }
  if (gRegistrationSignature.isEmpty()) {
    gPrefs.remove("registration_sig");
  } else {
    gPrefs.putString("registration_sig", gRegistrationSignature);
  }
  if (gSecretConfigured) {
    gPrefs.putBytes("secret_bytes", gSecretBytes, 32);
  } else {
    gPrefs.remove("secret_b58");
    gPrefs.remove("secret_bytes");
  }
}

static void clearWifiPrefs() {
  gWifiSavedSsid = "";
  gWifiSavedPassword = "";
  gWifiSelectedSsid = "";
  clearSavedWifiList();
  gWifiKnownGood = false;
  gWifiReconnectEnabled = false;
  gWifiDisconnectedSinceMs = 0;
  gWifiLastReconnectAttemptMs = 0;
  saveWifiPrefs();
  WiFi.disconnect(true, false);
  gWifiStatusMessage = "Saved Wi-Fi cleared";
}

static void beginSavedWifi() {
  if (gWifiSavedSsid.isEmpty() || !gWifiKnownGood) {
    gWifiReconnectEnabled = false;
    return;
  }

  WiFi.mode(WIFI_STA);
  WiFi.begin(gWifiSavedSsid.c_str(), gWifiSavedPassword.c_str());
  gWifiReconnectEnabled = true;
  gWifiDisconnectedSinceMs = millis();
  gWifiLastReconnectAttemptMs = millis();
}

static String wifiHomeStatus() {
  if (gWifiSavedSsid.isEmpty()) {
    return "Wi-Fi not configured";
  }
  if (WiFi.status() == WL_CONNECTED) {
    return "Wi-Fi connected";
  }
  return "Wi-Fi disconnected";
}

static String wifiSavedLabel() {
  if (gWifiSavedSsid.isEmpty()) {
    return "Saved: none";
  }
  int knownCount = 0;
  for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) {
    if (!gKnownWifiSsids[i].isEmpty()) {
      knownCount++;
    }
  }
  return String("Saved: ") + gWifiSavedSsid + " (" + knownCount + ")";
}

static String wifiHomeSummary() {
  if (gWifiSavedSsid.isEmpty()) {
    return "Wi-Fi (not configured) not configured";
  }
  if (WiFi.status() == WL_CONNECTED) {
    return String("Wi-Fi (") + WiFi.SSID() + ") connected";
  }
  return String("Wi-Fi (") + gWifiSavedSsid + ") disconnected";
}

static void loadRegisterDiagDetailsFromPrefs() {
  gLastRegisterDiagDetails = "";
  int chunks = gPrefs.getInt("rddn", 0);
  if (chunks <= 0) {
    gLastRegisterDiagDetails = gPrefs.getString("reg_diag_details", "");
    return;
  }
  if (chunks > 12) {
    chunks = 12;
  }
  for (int i = 0; i < chunks; ++i) {
    String key = String("rdd") + i;
    gLastRegisterDiagDetails += gPrefs.getString(key.c_str(), "");
  }
}

static void saveRegisterDiagDetailsToPrefs(const String &details) {
  const int chunkSize = 1400;
  const int maxChunks = 20;
  gPrefs.remove("reg_diag_details");
  for (int i = 0; i < maxChunks; ++i) {
    String key = String("rdd") + i;
    gPrefs.remove(key.c_str());
  }

  String stored = details;
  int requiredChunks = (stored.length() + chunkSize - 1) / chunkSize;
  if (requiredChunks > maxChunks) {
    int maxLen = chunkSize * maxChunks;
    stored = stored.substring(0, maxLen);
    stored += "\n[truncated in NVS]\n";
    requiredChunks = (stored.length() + chunkSize - 1) / chunkSize;
    if (requiredChunks > maxChunks) {
      stored = stored.substring(0, chunkSize * maxChunks);
      requiredChunks = maxChunks;
    }
  }

  for (int i = 0; i < requiredChunks; ++i) {
    int start = i * chunkSize;
    String key = String("rdd") + i;
    gPrefs.putString(key.c_str(), stored.substring(start, min((int)stored.length(), start + chunkSize)));
  }
  gPrefs.putInt("rddn", requiredChunks);
  gLastRegisterDiagDetails = stored;
}

static void clearRegisterDiagDetailsFromPrefs() {
  gPrefs.remove("reg_diag_details");
  gPrefs.remove("rddn");
  for (int i = 0; i < 20; ++i) {
    String key = String("rdd") + i;
    gPrefs.remove(key.c_str());
  }
}

static void saveRegisterDiag(const String &status, const String &summary, const String &details) {
  gLastRegisterDiagStatus = status;
  gLastRegisterDiagSummary = summary;
  gLastRegisterDiagTime = String(shineNowMs());
  gPrefs.putString("reg_diag_status", gLastRegisterDiagStatus);
  gPrefs.putString("reg_diag_summary", gLastRegisterDiagSummary);
  gPrefs.putString("reg_diag_time", gLastRegisterDiagTime);
  saveRegisterDiagDetailsToPrefs(details);
}

static void clearRegisterDiag() {
  gLastRegisterDiagStatus = "none";
  gLastRegisterDiagSummary = "";
  gLastRegisterDiagDetails = "";
  gLastRegisterDiagTime = "";
  gPrefs.remove("reg_diag_status");
  gPrefs.remove("reg_diag_summary");
  gPrefs.remove("reg_diag_time");
  clearRegisterDiagDetailsFromPrefs();
}

static void saveShineConnectDiag(const String &status, const String &summary, const String &details) {
  saveRegisterDiag(status, summary, details);
}

static void printRegisterDiagToSerial() {
  Serial.println("LAST_REGISTER_DIAG_BEGIN");
  Serial.println(String("status=") + gLastRegisterDiagStatus);
  Serial.println(String("time_ms=") + gLastRegisterDiagTime);
  Serial.println(String("summary=") + gLastRegisterDiagSummary);
  Serial.println("details<<");
  Serial.println(gLastRegisterDiagDetails);
  Serial.println(">>details");
  Serial.println("LAST_REGISTER_DIAG_END");
}

static void handleUsbSerialCommands() {
  while (Serial.available() > 0) {
    char ch = (char)Serial.read();
    if (ch == '\r') {
      continue;
    }
    if (ch == '\n') {
      String cmd = gSerialCommandBuffer;
      gSerialCommandBuffer = "";
      cmd.trim();
      cmd.toLowerCase();
      if (cmd.isEmpty()) {
        continue;
      }
      if (cmd == "last_error" || cmd == "last_diag" || cmd == "reg_diag") {
        printRegisterDiagToSerial();
      } else if (cmd == "clear_error" || cmd == "clear_diag") {
        clearRegisterDiag();
        Serial.println("register diag cleared");
      } else if (cmd == "help") {
        Serial.println("commands: last_error, last_diag, reg_diag, clear_error, clear_diag, help");
      } else {
        Serial.println(String("unknown command: ") + cmd);
      }
      continue;
    }
    if (gSerialCommandBuffer.length() < 120) {
      gSerialCommandBuffer += ch;
    }
  }
}

static String loginDisplayValue() {
  return gLoginValue.isEmpty() ? "login not set" : gLoginValue;
}

static String homeserverDisplayValue() {
  return gHomeserverValue.isEmpty() ? "homeserver not set" : gHomeserverValue;
}

static String homeSecretStatus() {
  return gSecretConfigured ? "" : "secret not set";
}

static String secretButtonValue() {
  return gSecretConfigured ? "*****" : "not set";
}

static void initPowerManagement() {
  gPowerReady = gPower.begin(Wire, AXP2101_SLAVE_ADDRESS, PIN_I2C_SDA, PIN_I2C_SCL);
  if (!gPowerReady) {
    return;
  }
  gPower.disableIRQ(XPOWERS_AXP2101_ALL_IRQ);
  gPower.setChargeTargetVoltage(3);
  gPower.clearIrqStatus();
  gPower.enableTemperatureMeasure();
  gPower.enableBattDetection();
  gPower.enableBattVoltageMeasure();
  gPower.enableVbusVoltageMeasure();
  gPower.enableSystemVoltageMeasure();
}

static int batteryPercentValue() {
  if (!gPowerReady || !gPower.isBatteryConnect()) {
    return -1;
  }
  int value = gPower.getBatteryPercent();
  if (value < 0) value = 0;
  if (value > 100) value = 100;
  return value;
}

static int wifiSignalLevel() {
  if (WiFi.status() != WL_CONNECTED) {
    return 0;
  }
  int rssi = WiFi.RSSI();
  if (rssi >= -55) return 4;
  if (rssi >= -67) return 3;
  if (rssi >= -75) return 2;
  return 1;
}

static void drawTopStatusIndicators() {
  int batt = batteryPercentValue();
  String battText = batt >= 0 ? String(batt) + "%" : "--";

  lv_obj_t *battLabel = lv_label_create(gRoot);
  lv_label_set_text(battLabel, battText.c_str());
  lv_obj_set_style_text_font(battLabel, &lv_font_montserrat_16, 0);
  lv_obj_set_style_text_color(battLabel, lv_color_hex(0xC9D3DE), 0);
  lv_obj_set_pos(battLabel, 297, 18);

  lv_obj_t *battery = lv_obj_create(gRoot);
  lv_obj_set_size(battery, 32, 16);
  lv_obj_set_pos(battery, 349, 20);
  lv_obj_set_style_radius(battery, 4, 0);
  lv_obj_set_style_bg_opa(battery, LV_OPA_TRANSP, 0);
  lv_obj_set_style_border_width(battery, 2, 0);
  lv_obj_set_style_border_color(battery, lv_color_hex(0xC9D3DE), 0);
  lv_obj_clear_flag(battery, LV_OBJ_FLAG_SCROLLABLE);

  lv_obj_t *batteryCap = lv_obj_create(gRoot);
  lv_obj_set_size(batteryCap, 4, 8);
  lv_obj_set_pos(batteryCap, 381, 24);
  lv_obj_set_style_radius(batteryCap, 2, 0);
  lv_obj_set_style_bg_color(batteryCap, lv_color_hex(0xC9D3DE), 0);
  lv_obj_set_style_border_width(batteryCap, 0, 0);

  if (batt > 10) {
    lv_obj_t *batteryFill = lv_obj_create(gRoot);
    int fillWidth = max(2, min(26, (batt * 26) / 100));
    lv_obj_set_size(batteryFill, fillWidth, 10);
    lv_obj_set_pos(batteryFill, 352, 23);
    lv_obj_set_style_radius(batteryFill, 2, 0);
    lv_obj_set_style_bg_color(batteryFill, lv_color_hex(0x38B26D), 0);
    lv_obj_set_style_border_width(batteryFill, 0, 0);
  }

  int level = wifiSignalLevel();
  int baseX = 399;
  int baseY = 34;
  for (int i = 0; i < 4; ++i) {
    lv_obj_t *bar = lv_obj_create(gRoot);
    int h = 4 + i * 4;
    lv_obj_set_size(bar, 5, h);
    lv_obj_set_pos(bar, baseX + i * 8, baseY - h);
    lv_obj_set_style_radius(bar, 2, 0);
    lv_obj_set_style_border_width(bar, 0, 0);
    uint32_t color = (level > i) ? 0x4FB3FF : 0x4A5560;
    if (level == 0) color = 0x4A5560;
    lv_obj_set_style_bg_color(bar, lv_color_hex(color), 0);
  }
  if (level == 0) {
    lv_obj_t *off = lv_label_create(gRoot);
    lv_label_set_text(off, "x");
    lv_obj_set_style_text_font(off, &lv_font_montserrat_18, 0);
    lv_obj_set_style_text_color(off, lv_color_hex(0xD26969), 0);
    lv_obj_set_pos(off, baseX + 10, 10);
  }
}

static void clearSecretValue() {
  gSecretConfigured = false;
  gSecretBase58 = "";
  memset(gSecretBytes, 0, sizeof(gSecretBytes));
  refreshDerivedKeys();
  gBalanceStatusMessage = "Balance: secret not set";
  gBalanceAutoRefreshPending = false;
  gLastBalanceAutoRefreshAttemptMs = 0;
  saveAccountPrefs();
  markAccountStateDirty();
}

static void setSecretValue(const uint8_t *bytes32) {
  memcpy(gSecretBytes, bytes32, 32);
  char b58[64];
  shineSecretBase58Encode(bytes32, 32, b58, sizeof(b58));
  gSecretBase58 = b58;
  gSecretConfigured = true;
  refreshDerivedKeys();
  gBalanceStatusMessage = "Balance: tap to load";
  gBalanceAutoRefreshPending = true;
  gLastBalanceAutoRefreshAttemptMs = 0;
  saveAccountPrefs();
  markAccountStateDirty();
}

static void updateWifiReconnectState() {
  wl_status_t status = WiFi.status();
  if (status == WL_CONNECTED) {
    if (gLastWifiStatus != WL_CONNECTED) {
      gWifiStatusMessage = String("Connected: ") + WiFi.localIP().toString();
    }
    gWifiDisconnectedSinceMs = 0;
    gLastWifiStatus = status;
    return;
  }

  if (gLastWifiStatus == WL_CONNECTED && gWifiReconnectEnabled) {
    gWifiDisconnectedSinceMs = millis();
    gWifiLastReconnectAttemptMs = 0;
    gWifiStatusMessage = "Wi-Fi lost. Reconnecting...";
  }
  gLastWifiStatus = status;
}

static void manageWifiReconnect() {
  updateWifiReconnectState();

  if (!gWifiReconnectEnabled || gWifiSavedSsid.isEmpty() || gWifiOperationBusy) {
    return;
  }

  if (WiFi.status() == WL_CONNECTED) {
    return;
  }

  if (gWifiDisconnectedSinceMs == 0) {
    gWifiDisconnectedSinceMs = millis();
  }

  unsigned long disconnectedFor = millis() - gWifiDisconnectedSinceMs;
  unsigned long interval = disconnectedFor >= WIFI_RECONNECT_SLOW_AFTER_MS
                               ? WIFI_RECONNECT_SLOW_MS
                               : WIFI_RECONNECT_FAST_MS;
  if (gWifiLastReconnectAttemptMs != 0 &&
      millis() - gWifiLastReconnectAttemptMs < interval) {
    return;
  }

  gWifiLastReconnectAttemptMs = millis();
  gWifiStatusMessage = disconnectedFor >= WIFI_RECONNECT_SLOW_AFTER_MS
                           ? "Wi-Fi retry every 30s"
                           : "Wi-Fi retry every 10s";
  WiFi.disconnect(false, false);
  WiFi.mode(WIFI_STA);
  WiFi.begin(gWifiSavedSsid.c_str(), gWifiSavedPassword.c_str());
}

static void scanWifiNetworks() {
  gScanResultCount = 0;
  gWifiStatusMessage = "Scanning networks...";
  gWifiOperationBusy = true;
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(false, false);
  delay(100);

  int found = WiFi.scanNetworks();
  if (found <= 0) {
    gWifiStatusMessage = "No networks found";
    gWifiViewMode = WIFI_VIEW_OVERVIEW;
    gWifiOperationBusy = false;
    return;
  }

  gScanResultCount = min(found, MAX_SCAN_RESULTS);
  for (int i = 0; i < gScanResultCount; ++i) {
    gScanResults[i] = WiFi.SSID(i);
  }
  WiFi.scanDelete();
  gWifiStatusMessage = String("Found ") + found + " networks";
  gWifiViewMode = WIFI_VIEW_SCAN_RESULTS;
  gWifiOperationBusy = false;
}

static bool connectWifiNow(const String &ssid, const String &password) {
  gWifiStatusMessage = String("Connecting to ") + ssid + "...";
  rebuildScreen();
  lv_timer_handler();

  gWifiOperationBusy = true;
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(false, false);
  delay(150);
  WiFi.begin(ssid.c_str(), password.c_str());

  unsigned long started = millis();
  while (millis() - started < WIFI_CONNECT_TIMEOUT_MS) {
    if (WiFi.status() == WL_CONNECTED) {
      gWifiSavedSsid = ssid;
      gWifiSavedPassword = password;
      upsertKnownWifi(ssid, password);
      gWifiKnownGood = true;
      gWifiReconnectEnabled = true;
      gWifiDisconnectedSinceMs = 0;
      gWifiLastReconnectAttemptMs = 0;
      gLastWifiStatus = WL_CONNECTED;
      saveWifiPrefs();
      gWifiStatusMessage = String("Connected: ") + WiFi.localIP().toString();
      gWifiOperationBusy = false;
      return true;
    }
    delay(200);
  }

  WiFi.disconnect(false, false);
  gWifiStatusMessage = "Connection failed";
  gWifiOperationBusy = false;
  return false;
}

static void openEditor(EditContext context,
                       Screen returnScreen,
                       const String &title,
                       const String &hint,
                       const String &value,
                       bool isPassword) {
  gEditContext = context;
  gEditReturnScreen = returnScreen;
  gEditTitle = title;
  gEditHint = hint;
  gEditValue = value;
  gEditIsPassword = isPassword;
  gKeyboardPage = 0;
  gKeyboardMode = KEYBOARD_MODE_ALPHA;
  gKeyboardShift = false;
  showScreen(SCREEN_TEXT_EDIT);
}

static void applyEditorValue() {
  String value = gInputTextArea ? String(lv_textarea_get_text(gInputTextArea)) : gEditValue;

  if (gEditContext == EDIT_CONTEXT_WIFI_PASSWORD) {
    bool ok = connectWifiNow(gWifiSelectedSsid, value);
    gWifiViewMode = WIFI_VIEW_OVERVIEW;
    if (ok) {
      showScreen(SCREEN_HOME);
    } else {
      showScreen(SCREEN_WIFI);
    }
    return;
  }

  if (gEditContext == EDIT_CONTEXT_SOLANA_RPC) {
    gSolanaRpcUrl = value;
    saveServerPrefs();
    gServerStatusMessage = "Solana RPC saved";
    markAccountStateDirty();
    showScreen(SCREEN_SERVER);
    return;
  }

  if (gEditContext == EDIT_CONTEXT_SHINE_SERVER) {
    gShineServerUrl = value;
    saveServerPrefs();
    gServerStatusMessage = "Shine server saved";
    clearShineSessionState(false);
    gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
    gLastShineAttemptMs = 0;
    gShineStatusLine = "SHiNE: reconnect pending";
    showScreen(SCREEN_SERVER);
    return;
  }

  if (gEditContext == EDIT_CONTEXT_LOGIN) {
    value.trim();
    String oldLogin = gLoginValue;
    gLoginValue = value;
    if (gLoginValue != oldLogin) {
      clearSecretValue();
    } else {
      markAccountStateDirty();
    }
    saveAccountPrefs();
    gAccountStatusMessage = gLoginValue.isEmpty() ? "Login cleared" : "Login saved";
    showScreen(SCREEN_ACCOUNT);
    return;
  }

  if (gEditContext == EDIT_CONTEXT_HOMESERVER) {
    value.trim();
    gHomeserverValue = value;
    refreshDerivedKeys();
    saveAccountPrefs();
    markAccountStateDirty();
    gAccountStatusMessage = gHomeserverValue.isEmpty() ? "Homeserver cleared" : "Homeserver saved";
    showScreen(SCREEN_ACCOUNT);
    return;
  }

  if (gEditContext == EDIT_CONTEXT_SECRET_MANUAL) {
    value.trim();
    uint8_t decoded[64] = {};
    size_t decodedLen = 0;
    String error;
    if (!shineSecretBase58Decode(value.c_str(), decoded, &decodedLen, sizeof(decoded), error)) {
      gAccountStatusMessage = error;
      showScreen(SCREEN_ACCOUNT_SECRET);
      return;
    }
    if (decodedLen < 32) {
      gAccountStatusMessage = "secret too short";
      showScreen(SCREEN_ACCOUNT_SECRET);
      return;
    }
    if (decodedLen > 32) {
      gAccountStatusMessage = "secret too long";
      showScreen(SCREEN_ACCOUNT_SECRET);
      return;
    }
    setSecretValue(decoded);
    gAccountStatusMessage = "Secret saved";
    showScreen(SCREEN_SECRET_SHOW);
    return;
  }

  if (gEditContext == EDIT_CONTEXT_SECRET_GENERATE_PASSWORD) {
    String error;
    if (gLoginValue.isEmpty()) {
      gAccountStatusMessage = "Login not set";
      showScreen(SCREEN_SECRET_GENERATE_INFO);
      return;
    }
    if (!shineSecretStart(gLoginValue.c_str(), value.c_str(), error)) {
      gAccountStatusMessage = error;
      showScreen(SCREEN_SECRET_GENERATE_INFO);
      return;
    }
    gAccountStatusMessage = "Secret generation started";
    showScreen(SCREEN_SECRET_GENERATE_RUNNING);
    return;
  }
}

static void cancelEditor() {
  if (gEditContext == EDIT_CONTEXT_WIFI_PASSWORD) {
    showScreen(SCREEN_WIFI);
  } else {
    showScreen(gEditReturnScreen);
  }
}

static void syncEditValueFromTextarea() {
  if (gInputTextArea) {
    gEditValue = String(lv_textarea_get_text(gInputTextArea));
  }
}

static void keepCursorAtEnd() {
  if (gInputTextArea) {
    lv_textarea_set_cursor_pos(gInputTextArea, LV_TEXTAREA_CURSOR_LAST);
  }
}

static void restoreTextareaFromEditValue() {
  if (gInputTextArea) {
    lv_textarea_set_text(gInputTextArea, gEditValue.c_str());
    keepCursorAtEnd();
  }
}

static void networkSelectCb(lv_event_t *event) {
  if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence) {
    return;
  }
  gLastHandledTouchSequence = gTouchSequence;
  gSuppressTouchUntilRelease = true;
  gBlockClick = true;

  int index = static_cast<int>(reinterpret_cast<uintptr_t>(lv_event_get_user_data(event)));
  if (index < 0 || index >= gScanResultCount) {
    return;
  }

  gWifiSelectedSsid = gScanResults[index];
  openEditor(EDIT_CONTEXT_WIFI_PASSWORD,
             SCREEN_WIFI,
             "ENTER PASSWORD",
             String("SSID: ") + gWifiSelectedSsid,
             savedPasswordForSsid(gWifiSelectedSsid),
             true);
}

static void editorKeyCb(lv_event_t *event) {
  if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence || !gInputTextArea) {
    return;
  }
  gLastHandledTouchSequence = gTouchSequence;
  gSuppressTouchUntilRelease = true;
  gBlockClick = true;

  const char *token = static_cast<const char *>(lv_event_get_user_data(event));
  if (!token) {
    return;
  }

  if (strcmp(token, "<DEL>") == 0) {
    lv_textarea_del_char(gInputTextArea);
    syncEditValueFromTextarea();
    keepCursorAtEnd();
    return;
  }
  if (strcmp(token, "<CLR>") == 0) {
    lv_textarea_set_text(gInputTextArea, "");
    syncEditValueFromTextarea();
    keepCursorAtEnd();
    return;
  }
  if (strcmp(token, "<SAVE>") == 0) {
    applyEditorValue();
    return;
  }
  if (strcmp(token, "<CANCEL>") == 0) {
    cancelEditor();
    return;
  }
  if (strcmp(token, "<MODE>") == 0) {
    syncEditValueFromTextarea();
    gKeyboardMode = (gKeyboardMode == KEYBOARD_MODE_ALPHA) ? KEYBOARD_MODE_SYMBOLS : KEYBOARD_MODE_ALPHA;
    gKeyboardPage = 0;
    rebuildScreen();
    return;
  }
  if (strcmp(token, "<SHIFT>") == 0) {
    syncEditValueFromTextarea();
    gKeyboardShift = !gKeyboardShift;
    rebuildScreen();
    return;
  }

  String out = token;
  if (gKeyboardMode == KEYBOARD_MODE_ALPHA && gKeyboardShift) {
    out.toUpperCase();
  }

  keepCursorAtEnd();
  lv_textarea_add_text(gInputTextArea, out.c_str());
  syncEditValueFromTextarea();
  keepCursorAtEnd();
}

static void actionButtonCb(lv_event_t *event) {
  if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence) {
    return;
  }
  gLastHandledTouchSequence = gTouchSequence;
  gSuppressTouchUntilRelease = true;
  gBlockClick = true;

  ActionId action = static_cast<ActionId>(reinterpret_cast<uintptr_t>(lv_event_get_user_data(event)));
  switch (action) {
    case ACTION_OPEN_SETTINGS:
      showScreen(SCREEN_SETTINGS_MENU);
      break;
    case ACTION_OPEN_WALLET_QR:
      showScreen(SCREEN_WALLET_QR);
      break;
    case ACTION_REGISTER_ACCOUNT:
      prepareRegisterAccountScreen();
      showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM);
      break;
    case ACTION_REGISTER_ACCOUNT_EXECUTE:
      executeRegisterAccountFlow("manual", true);
      break;
    case ACTION_HOMESERVER_PDA_ACTION:
      prepareHomeserverPdaActionScreen();
      showScreen(SCREEN_HOMESERVER_PDA_CONFIRM);
      break;
    case ACTION_HOMESERVER_PDA_ADD_EXECUTE: {
      gRegisterTriggerSource = "manual-homeserver-add";
      String updateMessage;
      if (!updateHomeserverSessionOnSolana(false, updateMessage)) {
        gHomeserverPdaResultSuccess = false;
        gHomeserverPdaResultMessage = updateMessage;
        gHomeserverPdaResultDetails = "";
      }
      showScreen(SCREEN_HOMESERVER_PDA_RESULT);
      break;
    }
    case ACTION_HOMESERVER_PDA_FIX_EXECUTE: {
      gRegisterTriggerSource = "manual-homeserver-fix";
      String updateMessage;
      if (!updateHomeserverSessionOnSolana(true, updateMessage)) {
        gHomeserverPdaResultSuccess = false;
        gHomeserverPdaResultMessage = updateMessage;
        gHomeserverPdaResultDetails = "";
      }
      showScreen(SCREEN_HOMESERVER_PDA_RESULT);
      break;
    }
    case ACTION_OPEN_WIFI:
      gWifiViewMode = WIFI_VIEW_OVERVIEW;
      showScreen(SCREEN_WIFI);
      break;
    case ACTION_OPEN_SERVER:
      showScreen(SCREEN_SERVER);
      break;
    case ACTION_OPEN_ACCOUNT:
      showScreen(SCREEN_ACCOUNT);
      break;
    case ACTION_BACK_ACCOUNT:
      showScreen(SCREEN_ACCOUNT);
      break;
    case ACTION_BACK_HOME:
      showScreen(SCREEN_HOME);
      break;
    case ACTION_BACK_SETTINGS:
      gWifiViewMode = WIFI_VIEW_OVERVIEW;
      showScreen(SCREEN_SETTINGS_MENU);
      break;
    case ACTION_WIFI_SELECT_NETWORK:
      scanWifiNetworks();
      rebuildScreen();
      break;
    case ACTION_WIFI_CLEAR:
      clearWifiPrefs();
      gWifiViewMode = WIFI_VIEW_OVERVIEW;
      rebuildScreen();
      break;
    case ACTION_SERVER_EDIT_SOLANA:
      openEditor(EDIT_CONTEXT_SOLANA_RPC,
                 SCREEN_SERVER,
                 "EDIT SOLANA RPC",
                 "",
                 gSolanaRpcUrl,
                 false);
      break;
    case ACTION_SERVER_EDIT_SHINE:
      openEditor(EDIT_CONTEXT_SHINE_SERVER,
                 SCREEN_SERVER,
                 "EDIT SHINE HOST",
                 "",
                 gShineServerUrl,
                 false);
      break;
    case ACTION_ACCOUNT_EDIT_LOGIN:
      openEditor(EDIT_CONTEXT_LOGIN,
                 SCREEN_ACCOUNT,
                 "EDIT LOGIN",
                 "",
                 gLoginValue,
                 false);
      break;
    case ACTION_ACCOUNT_EDIT_HOMESERVER:
      showScreen(SCREEN_ACCOUNT_HOMESERVER);
      break;
    case ACTION_ACCOUNT_EDIT_SECRET:
      showScreen(SCREEN_ACCOUNT_SECRET);
      break;
    case ACTION_SECRET_SHOW:
      if (gSecretConfigured) {
        showScreen(SCREEN_SECRET_SHOW);
      }
      break;
    case ACTION_SECRET_MANUAL:
      openEditor(EDIT_CONTEXT_SECRET_MANUAL,
                 SCREEN_ACCOUNT_SECRET,
                 "ENTER SECRET",
                 "",
                 gSecretBase58,
                 false);
      break;
    case ACTION_SECRET_GENERATE:
      showScreen(SCREEN_SECRET_GENERATE_INFO);
      break;
    case ACTION_SECRET_GENERATE_START:
      openEditor(EDIT_CONTEXT_SECRET_GENERATE_PASSWORD,
                 SCREEN_SECRET_GENERATE_INFO,
                 "ENTER PASSWORD",
                 "",
                 "",
                 false);
      break;
    case ACTION_SECRET_GENERATE_CANCEL:
      showScreen(SCREEN_SECRET_GENERATE_CANCEL_CONFIRM);
      break;
    case ACTION_SECRET_GENERATE_CANCEL_YES:
      shineSecretAbort();
      gAccountStatusMessage = "Secret generation cancelled";
      showScreen(SCREEN_ACCOUNT_SECRET);
      break;
    case ACTION_SECRET_GENERATE_CANCEL_NO:
      showScreen(SCREEN_SECRET_GENERATE_RUNNING);
      break;
    case ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT:
      gHomeserverValue = "homeserver1";
      refreshDerivedKeys();
      saveAccountPrefs();
      markAccountStateDirty();
      gAccountStatusMessage = "Homeserver set to homeserver1";
      showScreen(SCREEN_ACCOUNT);
      break;
    case ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL:
      openEditor(EDIT_CONTEXT_HOMESERVER,
                 SCREEN_ACCOUNT,
                 "EDIT HOMESERVER",
                 "",
                 gHomeserverValue,
                 false);
      break;
    case ACTION_BACK_SECRET_MENU:
      showScreen(SCREEN_ACCOUNT_SECRET);
      break;
    case ACTION_REFRESH_BALANCE: {
      String message;
      gBalanceStatusMessage = "Balance: loading...";
      rebuildScreen();
      lv_timer_handler();
      refreshWalletBalance(message);
      if (gCurrentScreen == SCREEN_HOME) {
        rebuildScreen();
      }
      break;
    }
    case ACTION_EDITOR_SAVE:
      applyEditorValue();
      break;
    case ACTION_EDITOR_CANCEL:
      cancelEditor();
      break;
    case ACTION_NONE:
    default:
      break;
  }
}

static lv_obj_t *makeButton(const char *text,
                            lv_coord_t x,
                            lv_coord_t y,
                            lv_coord_t w,
                            lv_coord_t h,
                            uint32_t bgColor,
                            ActionId action,
                            const lv_font_t *font = &lv_font_montserrat_22) {
  lv_obj_t *btn = lv_btn_create(gRoot);
  lv_obj_set_size(btn, w, h);
  lv_obj_set_pos(btn, x, y);
  lv_obj_set_style_radius(btn, 18, 0);
  lv_obj_set_style_bg_color(btn, lv_color_hex(bgColor), 0);
  lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
  lv_obj_set_style_border_width(btn, 2, 0);
  lv_obj_set_style_border_color(btn, lv_color_hex(0x6E8AA3), 0);
  lv_obj_set_style_shadow_width(btn, 0, 0);
  lv_obj_add_event_cb(btn, actionButtonCb, LV_EVENT_CLICKED, reinterpret_cast<void *>(static_cast<uintptr_t>(action)));

  lv_obj_t *label = lv_label_create(btn);
  lv_label_set_text(label, text);
  lv_obj_set_width(label, w - 24);
  lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
  lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP);
  lv_obj_set_style_text_font(label, font, 0);
  lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0);
  lv_obj_center(label);
  return btn;
}

static lv_obj_t *makeButtonLeftText(const char *text,
                                    lv_coord_t x,
                                    lv_coord_t y,
                                    lv_coord_t w,
                                    lv_coord_t h,
                                    uint32_t bgColor,
                                    ActionId action,
                                    const lv_font_t *font = &lv_font_montserrat_22) {
  lv_obj_t *btn = makeButton(text, x, y, w, h, bgColor, action, font);
  lv_obj_t *label = lv_obj_get_child(btn, 0);
  if (label) {
    lv_obj_set_width(label, w - 40);
    lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, 0);
    lv_obj_align(label, LV_ALIGN_LEFT_MID, 20, 0);
  }
  return btn;
}

static void makeRichStatusLine(const String &text,
                               lv_coord_t x,
                               lv_coord_t y,
                               lv_coord_t w,
                               const lv_font_t *font = &lv_font_montserrat_18) {
  lv_obj_t *label = lv_label_create(gRoot);
  lv_label_set_recolor(label, true);
  lv_label_set_text(label, text.c_str());
  lv_obj_set_width(label, w);
  lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP);
  lv_obj_set_style_text_font(label, font, 0);
  lv_obj_set_style_text_color(label, lv_color_hex(0xD5DEE7), 0);
  lv_obj_set_pos(label, x, y);
}

static lv_obj_t *makeKeyboardButton(const char *label,
                                    const char *token,
                                    lv_coord_t x,
                                    lv_coord_t y,
                                    lv_coord_t w,
                                    lv_coord_t h,
                                    uint32_t bgColor,
                                    const lv_font_t *font = &lv_font_montserrat_18) {
  lv_obj_t *btn = lv_btn_create(gRoot);
  lv_obj_set_size(btn, w, h);
  lv_obj_set_pos(btn, x, y);
  lv_obj_set_style_radius(btn, 12, 0);
  lv_obj_set_style_bg_color(btn, lv_color_hex(bgColor), 0);
  lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
  lv_obj_set_style_border_width(btn, 1, 0);
  lv_obj_set_style_border_color(btn, lv_color_hex(0x6E8AA3), 0);
  lv_obj_set_style_shadow_width(btn, 0, 0);
  lv_obj_add_event_cb(btn, editorKeyCb, LV_EVENT_CLICKED, const_cast<char *>(token));

  lv_obj_t *textObj = lv_label_create(btn);
  lv_label_set_text(textObj, label);
  lv_obj_set_style_text_font(textObj, font, 0);
  lv_obj_set_style_text_color(textObj, lv_color_hex(0xFFFFFF), 0);
  lv_obj_center(textObj);
  return btn;
}

static void makeVersionTag() {
  lv_obj_t *tag = lv_label_create(gRoot);
  lv_label_set_text(tag, TEST_VERSION);
  lv_obj_set_style_text_font(tag, &lv_font_montserrat_14, 0);
  lv_obj_set_style_text_color(tag, lv_color_hex(0x8398AD), 0);
  lv_obj_align(tag, LV_ALIGN_BOTTOM_MID, 0, -10);
}


static void drawHome() {
  setRootStyle();

  lv_obj_t *homeserver = lv_label_create(gRoot);
  lv_label_set_text(homeserver, homeserverDisplayValue().c_str());
  lv_obj_set_style_text_font(homeserver, &lv_font_montserrat_18, 0);
  lv_obj_set_style_text_color(homeserver, lv_color_hex(0xFFFFFF), 0);
  lv_obj_align(homeserver, LV_ALIGN_TOP_LEFT, 24, 18);

  lv_obj_t *login = lv_label_create(gRoot);
  lv_label_set_text(login, loginDisplayValue().c_str());
  lv_obj_set_style_text_font(login, &lv_font_montserrat_22, 0);
  lv_obj_set_style_text_color(login, lv_color_hex(0xD5DEE7), 0);
  lv_obj_align_to(login, homeserver, LV_ALIGN_OUT_BOTTOM_LEFT, 22, 6);

  lv_obj_t *accountDot = lv_obj_create(gRoot);
  lv_obj_set_size(accountDot, 14, 14);
  lv_obj_set_pos(accountDot, 24, 52);
  lv_obj_set_style_radius(accountDot, 7, 0);
  lv_obj_set_style_border_width(accountDot, 2, 0);
  lv_obj_set_style_bg_opa(accountDot, LV_OPA_COVER, 0);
  lv_obj_clear_flag(accountDot, LV_OBJ_FLAG_SCROLLABLE);

  uint32_t dotColor = 0x8AA2B7;
  uint32_t dotBg = 0x08111B;
  uint32_t statusColor = 0xAFC0D1;
  if (gAccountPdaStatus == ACCOUNT_PDA_OK) {
    dotColor = 0x38B26D;
    dotBg = 0x38B26D;
    statusColor = 0x38B26D;
  } else if (gAccountPdaStatus == ACCOUNT_PDA_MISMATCH) {
    dotColor = 0xD26969;
    dotBg = 0xD26969;
    statusColor = 0xD26969;
  } else if (gAccountPdaStatus == ACCOUNT_PDA_NOT_FOUND) {
    dotColor = 0xF0F4F8;
    dotBg = 0x08111B;
    statusColor = 0xF0F4F8;
  }
  lv_obj_set_style_border_color(accountDot, lv_color_hex(dotColor), 0);
  lv_obj_set_style_bg_color(accountDot, lv_color_hex(dotBg), 0);

  if (gAccountPdaStatus != ACCOUNT_PDA_OK) {
    lv_obj_t *accountStatus = lv_label_create(gRoot);
    lv_label_set_text(accountStatus, gAccountPdaStatusMessage.c_str());
    lv_obj_set_style_text_font(accountStatus, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(accountStatus, lv_color_hex(statusColor), 0);
    lv_obj_set_pos(accountStatus, 24, 80);
  }

  if (!gSecretConfigured) {
    lv_obj_t *secret = lv_label_create(gRoot);
    lv_label_set_text(secret, homeSecretStatus().c_str());
    lv_obj_set_style_text_font(secret, &lv_font_montserrat_16, 0);
    lv_obj_set_style_text_color(secret, lv_color_hex(0xB8C6D3), 0);
    lv_obj_align_to(secret, login, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6);
  }

  drawTopStatusIndicators();
  makeTitle("STATUS", 112, &lv_font_montserrat_28);
  makeRichStatusLine(wifiHomeRichLine(), 24, 164, 432, &lv_font_montserrat_18);
  makeRichStatusLine(shineHomeRichLine(), 24, 202, 432, &lv_font_montserrat_16);
  makeButtonLeftText(balanceHomeLine().c_str(), 22, 254, 340, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18);
  makeButton("QR", 374, 254, 84, 56, 0x2A6F97, ACTION_OPEN_WALLET_QR, &lv_font_montserrat_20);
  if (gShowRegisterAccountButton) {
    makeButton("REGISTER ACCOUNT", 20, 360, 210, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20);
  } else if (gShowHomeserverPdaActionButton) {
    const char *label = gHomeserverPdaActionReason == "homeserver key mismatch"
                            ? "FIX HOMESERVER PASSWORD"
                            : "ADD HOMESERVER";
    makeButton(label, 20, 360, 210, 78, 0xC59B2A, ACTION_HOMESERVER_PDA_ACTION, &lv_font_montserrat_18);
  }
  makeButton("SETTINGS", 250, 360, 210, 78, 0x2A6F97, ACTION_OPEN_SETTINGS, &lv_font_montserrat_24);
  makeVersionTag();
}

static void drawWalletQrScreen() {
  setRootStyle();
  gWalletQrTapReturnPending = false;

  lv_obj_t *title = lv_label_create(gRoot);
  lv_label_set_text(title, "WALLET QR");
  lv_obj_set_style_text_font(title, &lv_font_montserrat_24, 0);
  lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
  lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 12);
  String qrPayload = walletQrUri();
  if (qrPayload.isEmpty()) {
    saveUiErrorDiag("Wallet QR unavailable", "wallet_address_empty=true\n");
    lv_obj_t *error = lv_label_create(gRoot);
    lv_label_set_text(error, "Wallet not set");
    lv_obj_set_width(error, 380);
    lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0);
    lv_obj_set_style_text_font(error, &lv_font_montserrat_20, 0);
    lv_obj_set_style_text_color(error, lv_color_hex(0xD26969), 0);
    lv_obj_set_pos(error, 50, 174);
  } else {
    lv_obj_t *panel = lv_obj_create(gRoot);
    lv_obj_set_size(panel, 336, 336);
    lv_obj_set_pos(panel, 72, 54);
    lv_obj_set_style_radius(panel, 18, 0);
    lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFFFFF), 0);
    lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
    lv_obj_set_style_border_width(panel, 0, 0);
    lv_obj_set_style_shadow_width(panel, 0, 0);
    lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE);

    lv_obj_t *qr = lv_qrcode_create(panel, 320, lv_color_hex(0x111111), lv_color_hex(0xFFFFFF));
    if (qr == nullptr) {
      saveUiErrorDiag("Wallet QR create failed", "stage=create\n");
      lv_obj_t *error = lv_label_create(panel);
      lv_label_set_text(error, "QR create failed");
      lv_obj_set_width(error, 248);
      lv_label_set_long_mode(error, LV_LABEL_LONG_WRAP);
      lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0);
      lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0);
      lv_obj_set_style_text_color(error, lv_color_hex(0xA23232), 0);
      lv_obj_align(error, LV_ALIGN_CENTER, 0, 0);
    } else {
      lv_obj_center(qr);
      if (lv_qrcode_update(qr, qrPayload.c_str(), qrPayload.length()) != LV_RES_OK) {
        saveUiErrorDiag("Wallet QR update failed",
                        String("stage=update\npayload_len=") + String(qrPayload.length()) + "\n");
        lv_obj_del(qr);
        lv_obj_t *error = lv_label_create(panel);
        lv_label_set_text(error, "QR update failed");
        lv_obj_set_width(error, 248);
        lv_label_set_long_mode(error, LV_LABEL_LONG_WRAP);
        lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0);
        lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0);
        lv_obj_set_style_text_color(error, lv_color_hex(0xA23232), 0);
        lv_obj_align(error, LV_ALIGN_CENTER, 0, 0);
      }
    }
  }

  lv_obj_t *address = lv_label_create(gRoot);
  lv_label_set_text(address, walletQrAddressLine().c_str());
  lv_obj_set_width(address, 420);
  lv_label_set_long_mode(address, LV_LABEL_LONG_WRAP);
  lv_obj_set_style_text_align(address, LV_TEXT_ALIGN_CENTER, 0);
  lv_obj_set_style_text_font(address, &lv_font_montserrat_24, 0);
  lv_obj_set_style_text_color(address, lv_color_hex(0xB8C6D3), 0);
  lv_obj_set_pos(address, 30, 404);
}

static void drawSettingsMenu() {
  setRootStyle();

  makeTitle("SETTINGS", 18, &lv_font_montserrat_24);
  makeBody("Swipe up/down to move. Swipe right to go home.", 56, 420);

  for (int visibleIndex = 0; visibleIndex < 2; ++visibleIndex) {
    int itemIndex = gSettingsScrollIndex + visibleIndex;
    if (itemIndex >= static_cast<int>(kMenuCount)) {
      break;
    }

    ActionId action = ACTION_NONE;
    if (itemIndex == 0) action = ACTION_OPEN_WIFI;
    if (itemIndex == 1) action = ACTION_OPEN_SERVER;
    if (itemIndex == 2) action = ACTION_OPEN_ACCOUNT;

    makeButton(kMenuItems[itemIndex], 22, 132 + visibleIndex * 126, 436, 104,
               0x355C7D, action, &lv_font_montserrat_24);
  }

  lv_obj_t *hint = lv_label_create(gRoot);
  char hintText[48];
  snprintf(hintText, sizeof(hintText), "Items %d-%d of %d",
           gSettingsScrollIndex + 1,
           min(gSettingsScrollIndex + 2, static_cast<int>(kMenuCount)),
           static_cast<int>(kMenuCount));
  lv_label_set_text(hint, hintText);
  lv_obj_set_style_text_font(hint, &lv_font_montserrat_16, 0);
  lv_obj_set_style_text_color(hint, lv_color_hex(0xA8B8C7), 0);
  lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -34);

  makeVersionTag();
}

static lv_obj_t *makeNetworkButton(const char *text, int index, lv_coord_t y) {
  lv_obj_t *btn = lv_btn_create(gRoot);
  lv_obj_set_size(btn, 436, 52);
  lv_obj_set_pos(btn, 22, y);
  lv_obj_set_style_radius(btn, 14, 0);
  lv_obj_set_style_bg_color(btn, lv_color_hex(0x2A6F97), 0);
  lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
  lv_obj_set_style_border_width(btn, 2, 0);
  lv_obj_set_style_border_color(btn, lv_color_hex(0x6E8AA3), 0);
  lv_obj_add_event_cb(btn, networkSelectCb, LV_EVENT_CLICKED, reinterpret_cast<void *>(static_cast<uintptr_t>(index)));

  lv_obj_t *label = lv_label_create(btn);
  lv_label_set_text(label, text);
  lv_obj_set_width(label, 404);
  lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
  lv_obj_set_style_text_font(label, &lv_font_montserrat_18, 0);
  lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0);
  lv_obj_center(label);
  return btn;
}

static void drawWifiOverview() {
  makeTitle("Wi-Fi", 18, &lv_font_montserrat_24);
  showMessageAt(wifiHomeStatus(), 60);
  showMessageAt(wifiSavedLabel(), 92);
  showMessageAt(gWifiStatusMessage, 124);
  makeButton("SELECT NETWORK", 22, 182, 436, 68, 0x2A6F97, ACTION_WIFI_SELECT_NETWORK, &lv_font_montserrat_22);
  makeButton("CLEAR SAVED WI-FI", 22, 270, 436, 68, 0x7D3A3A, ACTION_WIFI_CLEAR, &lv_font_montserrat_20);
  makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_BACK_SETTINGS, &lv_font_montserrat_22);
}

static void drawWifiScanResults() {
  makeTitle("SELECT NETWORK", 18, &lv_font_montserrat_24);
  showMessageAt(gWifiStatusMessage, 58);
  if (gScanResultCount == 0) {
    showMessageAt("No networks available", 110);
  }
  for (int i = 0; i < gScanResultCount; ++i) {
    makeNetworkButton(gScanResults[i].c_str(), i, 100 + i * 58);
  }
  makeButton("SCAN AGAIN", 22, 370, 210, 64, 0x2A6F97, ACTION_WIFI_SELECT_NETWORK, &lv_font_montserrat_20);
  makeButton("BACK", 248, 370, 210, 64, 0x5A6570, ACTION_BACK_SETTINGS, &lv_font_montserrat_20);
}

static void drawWifiScreen() {
  setRootStyle();

  if (gWifiViewMode == WIFI_VIEW_OVERVIEW) {
    drawWifiOverview();
  } else {
    drawWifiScanResults();
  }

  makeVersionTag();
}

static void drawServerScreen() {
  setRootStyle();
  makeTitle("SERVER", 18, &lv_font_montserrat_24);
  showMessageAt(gServerStatusMessage, 56);
  showMessageAt(String("Solana: ") + gSolanaRpcUrl, 96);
  makeButton("SOLANA RPC", 22, 146, 436, 84, 0x355C7D, ACTION_SERVER_EDIT_SOLANA, &lv_font_montserrat_24);
  showMessageAt(String("Shine: ") + gShineServerUrl, 248);
  makeButton("SHINE SERVER", 22, 298, 436, 84, 0x355C7D, ACTION_SERVER_EDIT_SHINE, &lv_font_montserrat_24);
  makeBody("Swipe right to return to Settings.", 396, 420);
  makeVersionTag();
}

static void drawAccountScreen() {
  setRootStyle();

  makeTitle("ACCOUNT", 18, &lv_font_montserrat_24);
  showMessageAt(gAccountStatusMessage, 56);
  if (!gUserPdaAddress.isEmpty()) {
    showMessageAt(String("PDA: ") + abbreviateValue(gUserPdaAddress, 10, 6), 80);
  }
  if (!gRegistrationSignature.isEmpty()) {
    showMessageAt(String("TX: ") + abbreviateValue(gRegistrationSignature, 10, 6), 100);
  }

  String loginButton = String("Login (") + (gLoginValue.isEmpty() ? "not set" : gLoginValue) + ")";
  String homeserverButton = String("Homeserver (") + (gHomeserverValue.isEmpty() ? "not set" : gHomeserverValue) + ")";
  String secretButton = String("Secret (") + secretButtonValue() + ")";

  makeButton(loginButton.c_str(), 22, 118, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_LOGIN, &lv_font_montserrat_20);
  makeButton(homeserverButton.c_str(), 22, 222, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_HOMESERVER, &lv_font_montserrat_20);
  makeButton(secretButton.c_str(), 22, 326, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_SECRET, &lv_font_montserrat_20);
  makeBody("Swipe right to return to Settings.", 420, 420);
  makeVersionTag();
}

static void drawAccountHomeserverScreen() {
  setRootStyle();
  makeTitle("HOMESERVER", 18, &lv_font_montserrat_24);
  showMessageAt(String("Current: ") + homeserverDisplayValue(), 56);
  makeBody("If you only use one homeserver, keep the default name homeserver1.", 98, 420);
  makeButton("USE HOMESERVER1", 22, 202, 436, 84, 0x2A9D8F, ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT, &lv_font_montserrat_22);
  makeButton("EDIT MANUALLY", 22, 306, 436, 84, 0x355C7D, ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL, &lv_font_montserrat_22);
  makeButton("BACK", 140, 402, 200, 54, 0x5A6570, ACTION_BACK_ACCOUNT, &lv_font_montserrat_20);
  makeVersionTag();
}

static void drawAccountSecretScreen() {
  setRootStyle();
  makeTitle("SECRET", 18, &lv_font_montserrat_24);
  showMessageAt(gAccountStatusMessage, 56);
  makeButton(gSecretConfigured ? "SHOW SECRET" : "SECRET NOT SET",
             22, 118, 436, 84, gSecretConfigured ? 0x2A6F97 : 0x4A5560,
             gSecretConfigured ? ACTION_SECRET_SHOW : ACTION_NONE, &lv_font_montserrat_22);
  makeButton("ENTER SECRET MANUALLY (NOT RECOMMENDED)",
             22, 222, 436, 84, 0x355C7D, ACTION_SECRET_MANUAL, &lv_font_montserrat_18);
  makeButton("GENERATE SECRET",
             22, 326, 436, 84, 0x2A9D8F, ACTION_SECRET_GENERATE, &lv_font_montserrat_22);
  makeBody("Swipe right to return to Account.", 420, 420);
  makeVersionTag();
}

static void drawSecretShowScreen() {
  setRootStyle();
  makeTitle("SECRET", 18, &lv_font_montserrat_24);
  if (gSecretConfigured) {
    lv_obj_t *panel = lv_obj_create(gRoot);
    lv_obj_set_size(panel, 440, 320);
    lv_obj_set_pos(panel, 20, 60);
    lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0);
    lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
    lv_obj_set_style_border_width(panel, 1, 0);
    lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
    lv_obj_set_style_radius(panel, 14, 0);
    lv_obj_set_style_pad_all(panel, 14, 0);
    lv_obj_set_style_pad_row(panel, 8, 0);
    lv_obj_set_scroll_dir(panel, LV_DIR_VER);
    lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE);
    lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);

    auto addKeyBlock = [&](const String &title, const String &formula, const String &value) {
      lv_obj_t *titleLabel = lv_label_create(panel);
      lv_label_set_text(titleLabel, title.c_str());
      lv_obj_set_width(titleLabel, 400);
      lv_obj_set_style_text_font(titleLabel, &lv_font_montserrat_18, 0);
      lv_obj_set_style_text_color(titleLabel, lv_color_hex(0xFFFFFF), 0);

      lv_obj_t *formulaLabel = lv_label_create(panel);
      lv_label_set_text(formulaLabel, formula.c_str());
      lv_obj_set_width(formulaLabel, 400);
      lv_obj_set_style_text_font(formulaLabel, &lv_font_montserrat_12, 0);
      lv_obj_set_style_text_color(formulaLabel, lv_color_hex(0x8FA4B8), 0);

      lv_obj_t *valueLabel = lv_label_create(panel);
      lv_label_set_text(valueLabel, value.c_str());
      lv_obj_set_width(valueLabel, 400);
      lv_label_set_long_mode(valueLabel, LV_LABEL_LONG_WRAP);
      lv_obj_set_style_text_font(valueLabel, &lv_font_montserrat_20, 0);
      lv_obj_set_style_text_color(valueLabel, lv_color_hex(0xD9E1EA), 0);
    };

    addKeyBlock("Secret (base58)", "master secret", gSecretBase58);
    addKeyBlock("Root key (base58)", "pub from sha256(base64(secret)|root.key)", gRootPubB58);
    addKeyBlock("Root key priv (base58)", "sha256(base64(secret)|root.key)", gRootPrivB58);
    addKeyBlock("Blockchain key (base58)", "pub from sha256(base64(secret)|bch.key)", gBlockchainPubB58);
    addKeyBlock("Blockchain key priv (base58)", "sha256(base64(secret)|bch.key)", gBlockchainPrivB58);
    addKeyBlock("Device key (base58)", "pub from sha256(base64(secret)|dev.key)", gDevicePubB58);
    addKeyBlock("Device key priv (base58)", "sha256(base64(secret)|dev.key)", gDevicePrivB58);
    addKeyBlock("Homeserver key (base58)", String("pub from sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPubB58);
    addKeyBlock("Homeserver key priv (base58)", String("sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPrivB58);
  } else {
    showMessageAt("Secret not set", 96);
  }
  makeButton("BACK", 140, 392, 200, 56, 0x5A6570, ACTION_BACK_SECRET_MENU, &lv_font_montserrat_20);
  makeVersionTag();
}

static void drawSecretGenerateInfoScreen() {
  setRootStyle();
  makeTitle("GENERATE SECRET", 18, &lv_font_montserrat_24);
  showMessageAt(String("Login: ") + (gLoginValue.isEmpty() ? "not set" : gLoginValue), 56);
  makeBody("SD card is required. After password input, secret generation will start and can take 10-30 min.", 100, 420);
  makeButton("ENTER PASSWORD AND START", 22, 248, 436, 84, 0x2A9D8F, ACTION_SECRET_GENERATE_START, &lv_font_montserrat_20);
  makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_BACK_SECRET_MENU, &lv_font_montserrat_22);
  makeVersionTag();
}

static void drawSecretGenerateRunningScreen() {
  setRootStyle();
  makeTitle("GENERATING SECRET", 18, &lv_font_montserrat_24);
  ShineSecretGenerationStatus status = shineSecretStatus();
  showMessageAt(status.message, 56);
  showMessageAt(String("Login: ") + gLoginValue, 88);

  lv_obj_t *bar = lv_bar_create(gRoot);
  lv_obj_set_size(bar, 436, 34);
  lv_obj_set_pos(bar, 22, 150);
  lv_bar_set_range(bar, 0, 1000);
  int value = status.totalBlocks == 0 ? 0 : (int)((uint64_t)status.doneBlocks * 1000ULL / status.totalBlocks);
  lv_bar_set_value(bar, value, LV_ANIM_OFF);

  char progress[96];
  snprintf(progress, sizeof(progress), "%lu / %lu blocks", (unsigned long)status.doneBlocks, (unsigned long)status.totalBlocks);
  showMessageAt(progress, 204);
  char elapsed[64];
  snprintf(elapsed, sizeof(elapsed), "Elapsed: %lum %lus", (unsigned long)(status.elapsedSec / 60), (unsigned long)(status.elapsedSec % 60));
  showMessageAt(elapsed, 236);
  if (status.done) {
    showMessageAt("Secret generated successfully", 280);
    makeButton("SHOW SECRET", 22, 328, 210, 72, 0x2A6F97, ACTION_SECRET_SHOW, &lv_font_montserrat_20);
    makeButton("BACK", 248, 328, 210, 72, 0x5A6570, ACTION_BACK_SECRET_MENU, &lv_font_montserrat_20);
  } else {
    makeBody("Keep device powered and SD card inserted.", 328, 420);
    makeButton("CANCEL", 140, 372, 200, 56, 0x7D3A3A, ACTION_SECRET_GENERATE_CANCEL, &lv_font_montserrat_20);
  }
  makeVersionTag();
}

static void drawSecretGenerateCancelConfirmScreen() {
  setRootStyle();
  makeTitle("STOP GENERATION?", 26, &lv_font_montserrat_24);
  makeBody("Are you sure you want to stop secret generation?", 102, 420);
  makeBody("Existing saved secret will remain unchanged.", 146, 420);
  makeButton("YES, STOP", 22, 262, 200, 76, 0x7D3A3A, ACTION_SECRET_GENERATE_CANCEL_YES, &lv_font_montserrat_20);
  makeButton("NO, CONTINUE", 238, 262, 220, 76, 0x355C7D, ACTION_SECRET_GENERATE_CANCEL_NO, &lv_font_montserrat_20);
  makeVersionTag();
}

static void drawRegisterAccountConfirmScreen() {
  setRootStyle();
  makeTitle("REGISTER ACCOUNT", 22, &lv_font_montserrat_24);
  String topLine = gRegisterConfirmMessage.isEmpty() ? String("Registration check") : gRegisterConfirmMessage;
  makeBody(topLine.c_str(), 96, 420);
  if (!gRegisterConfirmPdaLine.isEmpty()) {
    makeBody(gRegisterConfirmPdaLine.c_str(), 138, 420);
  }
  if (!gRegisterConfirmBalanceLine.isEmpty()) {
    makeBody(gRegisterConfirmBalanceLine.c_str(), 180, 420);
  }
  if (!gRegisterConfirmHomeserverLine.isEmpty()) {
    makeBody(gRegisterConfirmHomeserverLine.c_str(), 222, 420);
  }
  if (gRegisterConfirmCanSubmit) {
    makeButton("REGISTER IN SHINE", 22, 296, 436, 74, 0x2A9D8F, ACTION_REGISTER_ACCOUNT_EXECUTE, &lv_font_montserrat_18);
  } else {
    makeButton("UNAVAILABLE", 22, 296, 436, 74, 0x4A5560, ACTION_NONE, &lv_font_montserrat_20);
  }
  makeButton("BACK", 140, 384, 200, 54, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20);
  makeVersionTag();
}

static void drawRegisterAccountResultScreen() {
  setRootStyle();
  makeTitle("REGISTER RESULT", 22, &lv_font_montserrat_24);
  String resultTopLine = gRegisterResultSuccess ? String("Registration completed successfully") : String("Registration failed");
  makeBody(resultTopLine.c_str(), 96, 420);
  makeBody(gRegisterResultMessage.c_str(), 140, 420);
  if (!gRegisterResultDetails.isEmpty()) {
    makeBody(gRegisterResultDetails.c_str(), 184, 420);
  }
  if (gRegisterResultSuccess) {
    if (!gUserPdaAddress.isEmpty()) {
      String pdaLine = String("user_pda: ") + abbreviateValue(gUserPdaAddress, 12, 8);
      makeBody(pdaLine.c_str(), 228, 420);
    }
    if (!gRegistrationSignature.isEmpty()) {
      String txLine = String("tx: ") + abbreviateValue(gRegistrationSignature, 12, 8);
      makeBody(txLine.c_str(), 270, 420);
    }
  }
  makeButton("BACK HOME", 22, 372, 200, 58, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20);
  makeButton("ACCOUNT", 258, 372, 200, 58, 0x2A6F97, ACTION_OPEN_ACCOUNT, &lv_font_montserrat_20);
  makeVersionTag();
}

static void drawHomeserverPdaConfirmScreen() {
  setRootStyle();
  makeTitle("HOMESERVER PDA", 22, &lv_font_montserrat_24);
  String topLine = gHomeserverPdaActionMessage.isEmpty() ? String("Homeserver action") : gHomeserverPdaActionMessage;
  makeBody(topLine.c_str(), 96, 420);
  if (!gHomeserverPdaActionDetail.isEmpty()) {
    makeBody(gHomeserverPdaActionDetail.c_str(), 146, 420);
  }
  String loginLine = String("Login: ") + loginDisplayValue();
  String homeserverLine = String("Homeserver: ") + homeserverDisplayValue();
  makeBody(loginLine.c_str(), 196, 420);
  makeBody(homeserverLine.c_str(), 236, 420);
  if (gHomeserverPdaCanAdd) {
    makeButton("ADD HOMESERVER", 22, 300, 436, 70, 0xC59B2A, ACTION_HOMESERVER_PDA_ADD_EXECUTE, &lv_font_montserrat_20);
  } else if (gHomeserverPdaCanFix) {
    makeButton("FIX HOMESERVER PASSWORD", 22, 300, 436, 70, 0xC59B2A, ACTION_HOMESERVER_PDA_FIX_EXECUTE, &lv_font_montserrat_16);
  } else {
    makeButton("UNAVAILABLE", 22, 300, 436, 70, 0x4A5560, ACTION_NONE, &lv_font_montserrat_20);
  }
  makeButton("BACK", 140, 386, 200, 54, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20);
  makeVersionTag();
}

static void drawHomeserverPdaResultScreen() {
  setRootStyle();
  makeTitle("HOMESERVER RESULT", 22, &lv_font_montserrat_24);
  String resultTopLine = gHomeserverPdaResultSuccess ? String("Homeserver PDA updated") : String("Homeserver update failed");
  makeBody(resultTopLine.c_str(), 96, 420);
  makeBody(gHomeserverPdaResultMessage.c_str(), 146, 420);
  if (!gHomeserverPdaResultDetails.isEmpty()) {
    makeBody(gHomeserverPdaResultDetails.c_str(), 196, 420);
  }
  makeButton("BACK HOME", 22, 372, 200, 58, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20);
  makeButton("ACCOUNT", 258, 372, 200, 58, 0x2A6F97, ACTION_OPEN_ACCOUNT, &lv_font_montserrat_20);
  makeVersionTag();
}

static void drawKeyRow(const char *const *tokens,
                       int count,
                       lv_coord_t x,
                       lv_coord_t y,
                       lv_coord_t w,
                       lv_coord_t h,
                       lv_coord_t gap,
                       uint32_t bgColor,
                       bool uppercase = false,
                       const lv_font_t *font = &lv_font_montserrat_18) {
  for (int i = 0; i < count; ++i) {
    String label = tokens[i];
    if (uppercase) {
      label.toUpperCase();
    }
    makeKeyboardButton(label.c_str(), tokens[i], x + i * (w + gap), y, w, h, bgColor, font);
  }
}

static void drawTextEditorKeyboard(lv_coord_t originY) {
  static const char *alphaLeftRow0[] = {"q", "w", "e", "r", "t"};
  static const char *alphaLeftRow1[] = {"a", "s", "d", "f", "g"};
  static const char *alphaLeftRow2[] = {"z", "x", "c", "v", "b"};
  static const char *alphaRightRow0[] = {"y", "u", "i", "o", "p"};
  static const char *alphaRightRow1[] = {"h", "j", "k", "l"};
  static const char *alphaRightRow2[] = {"n", "m", ".", "-"};

  static const char *symLeftRow0[] = {"1", "2", "3", "4", "5"};
  static const char *symLeftRow1[] = {"://", "/", ":", "_", "@"};
  static const char *symLeftRow2[] = {".com", ".net", ".org", ".me", ".io"};
  static const char *symRightRow0[] = {"6", "7", "8", "9", "0"};
  static const char *symRightRow1[] = {"?", "&", "=", "+", "-"};
  static const char *symRightRow2[] = {"%", "#", "~", ":", "/"};

  const lv_coord_t keyH = 62;
  const lv_coord_t keyW5 = 84;
  const lv_coord_t keyW4 = 84;
  const lv_coord_t gap = 8;
  const lv_coord_t row0Y = originY;
  const lv_coord_t row1Y = originY + 68;
  const lv_coord_t row2Y = originY + 136;
  const lv_coord_t row3Y = originY + 208;
  const bool uppercase = gKeyboardMode == KEYBOARD_MODE_ALPHA && gKeyboardShift;

  if (gKeyboardMode == KEYBOARD_MODE_ALPHA) {
    if (gKeyboardPage == 0) {
      drawKeyRow(alphaLeftRow0, 5, 20, row0Y, keyW5, keyH, gap, 0x2A6F97, uppercase);
      drawKeyRow(alphaLeftRow1, 5, 20, row1Y, keyW5, keyH, gap, 0x2A6F97, uppercase);
      drawKeyRow(alphaLeftRow2, 5, 20, row2Y, keyW5, keyH, gap, 0x2A6F97, uppercase);
    } else {
      drawKeyRow(alphaRightRow0, 5, 20, row0Y, keyW5, keyH, gap, 0x2A6F97, uppercase);
      drawKeyRow(alphaRightRow1, 4, 20, row1Y, keyW5, keyH, gap, 0x2A6F97, uppercase);
      drawKeyRow(alphaRightRow2, 4, 20, row2Y, keyW5, keyH, gap, 0x2A6F97, uppercase);
    }
  } else {
    if (gKeyboardPage == 0) {
      drawKeyRow(symLeftRow0, 5, 20, row0Y, keyW5, keyH, gap, 0x2A6F97);
      drawKeyRow(symLeftRow1, 5, 20, row1Y, keyW5, keyH, gap, 0x2A6F97, false, &lv_font_montserrat_16);
      drawKeyRow(symLeftRow2, 5, 20, row2Y, keyW5, keyH, gap, 0x2A6F97, false, &lv_font_montserrat_16);
    } else {
      drawKeyRow(symRightRow0, 5, 20, row0Y, keyW5, keyH, gap, 0x2A6F97);
      drawKeyRow(symRightRow1, 5, 20, row1Y, keyW5, keyH, gap, 0x2A6F97, false, &lv_font_montserrat_16);
      drawKeyRow(symRightRow2, 5, 20, row2Y, keyW5, keyH, gap, 0x2A6F97, false, &lv_font_montserrat_16);
    }
  }

  makeKeyboardButton(gKeyboardMode == KEYBOARD_MODE_ALPHA ? "123" : "ABC", "<MODE>", 20, row3Y, 88, 64, 0x5C6F82, &lv_font_montserrat_18);
  makeKeyboardButton(gKeyboardShift ? "SHIFT*" : "SHIFT", "<SHIFT>", 116, row3Y, 88, 64, 0x7A5C9B, &lv_font_montserrat_16);
  makeKeyboardButton("DEL", "<DEL>", 212, row3Y, 76, 64, 0x7D3A3A, &lv_font_montserrat_18);
  makeKeyboardButton("SAVE", "<SAVE>", 296, row3Y, 76, 64, 0x2A9D8F, &lv_font_montserrat_18);
  makeKeyboardButton("CANCEL", "<CANCEL>", 380, row3Y, 80, 64, 0x5A6570, &lv_font_montserrat_16);
}

static void drawTextEditScreen() {
  setRootStyle();
  gInputTextArea = nullptr;

  makeTitle(gEditTitle.c_str(), 8, &lv_font_montserrat_24);

  gInputTextArea = lv_textarea_create(gRoot);
  lv_obj_set_size(gInputTextArea, 436, 58);
  lv_obj_set_pos(gInputTextArea, 22, 48);
  lv_textarea_set_one_line(gInputTextArea, true);
  lv_textarea_set_text(gInputTextArea, gEditValue.c_str());
  lv_textarea_set_cursor_pos(gInputTextArea, LV_TEXTAREA_CURSOR_LAST);
  lv_textarea_set_password_mode(gInputTextArea, false);
  lv_textarea_set_cursor_click_pos(gInputTextArea, false);
  lv_textarea_set_text_selection(gInputTextArea, false);
  lv_obj_set_style_text_font(gInputTextArea, &lv_font_montserrat_18, 0);
  lv_obj_set_style_bg_color(gInputTextArea, lv_color_hex(0x132131), 0);
  lv_obj_set_style_text_color(gInputTextArea, lv_color_hex(0xFFFFFF), 0);
  lv_obj_set_style_border_color(gInputTextArea, lv_color_hex(0x6E8AA3), 0);
  lv_obj_set_style_border_width(gInputTextArea, 2, 0);
  lv_obj_set_style_bg_opa(gInputTextArea, LV_OPA_TRANSP, LV_PART_CURSOR);
  lv_obj_set_style_border_opa(gInputTextArea, LV_OPA_TRANSP, LV_PART_CURSOR);
  lv_obj_set_style_outline_opa(gInputTextArea, LV_OPA_TRANSP, LV_PART_CURSOR);
  lv_obj_set_style_pad_left(gInputTextArea, 12, 0);
  lv_obj_set_style_pad_right(gInputTextArea, 12, 0);
  lv_obj_add_state(gInputTextArea, LV_STATE_FOCUSED);
  restoreTextareaFromEditValue();

  lv_obj_t *panel = lv_obj_create(gRoot);
  lv_obj_set_size(panel, TEXT_EDIT_PANEL_W, TEXT_EDIT_PANEL_H);
  lv_obj_set_pos(panel, TEXT_EDIT_PANEL_X, TEXT_EDIT_PANEL_Y);
  lv_obj_set_style_radius(panel, 16, 0);
  lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1A27), 0);
  lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
  lv_obj_set_style_border_width(panel, 1, 0);
  lv_obj_set_style_border_color(panel, lv_color_hex(0x2E455A), 0);
  lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE);

  drawTextEditorKeyboard(TEXT_EDIT_PANEL_Y + 8);

  makeVersionTag();
}

static void rebuildScreen() {
  if (!gRoot) {
    gRoot = lv_scr_act();
  }

  releaseTransientUiBuffers();
  lv_obj_clean(gRoot);

  switch (gCurrentScreen) {
    case SCREEN_HOME:
      drawHome();
      break;
    case SCREEN_WALLET_QR:
      drawWalletQrScreen();
      break;
    case SCREEN_SETTINGS_MENU:
      drawSettingsMenu();
      break;
    case SCREEN_WIFI:
      drawWifiScreen();
      break;
    case SCREEN_SERVER:
      drawServerScreen();
      break;
    case SCREEN_ACCOUNT:
      drawAccountScreen();
      break;
    case SCREEN_ACCOUNT_HOMESERVER:
      drawAccountHomeserverScreen();
      break;
    case SCREEN_ACCOUNT_SECRET:
      drawAccountSecretScreen();
      break;
    case SCREEN_SECRET_SHOW:
      drawSecretShowScreen();
      break;
    case SCREEN_SECRET_GENERATE_INFO:
      drawSecretGenerateInfoScreen();
      break;
    case SCREEN_SECRET_GENERATE_RUNNING:
      drawSecretGenerateRunningScreen();
      break;
    case SCREEN_SECRET_GENERATE_CANCEL_CONFIRM:
      drawSecretGenerateCancelConfirmScreen();
      break;
    case SCREEN_TEXT_EDIT:
      drawTextEditScreen();
      break;
    case SCREEN_REGISTER_ACCOUNT_CONFIRM:
      drawRegisterAccountConfirmScreen();
      break;
    case SCREEN_REGISTER_ACCOUNT_RESULT:
      drawRegisterAccountResultScreen();
      break;
    case SCREEN_HOMESERVER_PDA_CONFIRM:
      drawHomeserverPdaConfirmScreen();
      break;
    case SCREEN_HOMESERVER_PDA_RESULT:
      drawHomeserverPdaResultScreen();
      break;
  }
}

static void showScreen(Screen screen) {
  gCurrentScreen = screen;
  rebuildScreen();
}

static void handleHomeSwipe(SwipeDirection swipe) {
  if (swipe == SWIPE_LEFT) {
    showScreen(SCREEN_SETTINGS_MENU);
  }
}

static void handleSettingsSwipe(SwipeDirection swipe) {
  if (swipe == SWIPE_RIGHT) {
    showScreen(SCREEN_HOME);
    return;
  }

  if (swipe == SWIPE_UP) {
    gSettingsScrollIndex++;
    if (gSettingsScrollIndex > static_cast<int>(kMenuCount) - 2) {
      gSettingsScrollIndex = static_cast<int>(kMenuCount) - 2;
    }
    rebuildScreen();
    return;
  }

  if (swipe == SWIPE_DOWN) {
    gSettingsScrollIndex--;
    if (gSettingsScrollIndex < 0) {
      gSettingsScrollIndex = 0;
    }
    rebuildScreen();
  }
}

static void handleWifiSwipe(SwipeDirection swipe) {
  if (swipe == SWIPE_RIGHT) {
    gWifiViewMode = WIFI_VIEW_OVERVIEW;
    showScreen(SCREEN_SETTINGS_MENU);
  }
}

static void handleServerSwipe(SwipeDirection swipe) {
  if (swipe == SWIPE_RIGHT) {
    showScreen(SCREEN_SETTINGS_MENU);
  }
}

static void handleAccountSwipe(SwipeDirection swipe) {
  if (swipe == SWIPE_RIGHT) {
    showScreen(SCREEN_SETTINGS_MENU);
  }
}

static void handleAccountSubscreenSwipe(SwipeDirection swipe) {
  if (swipe == SWIPE_RIGHT) {
    showScreen(SCREEN_ACCOUNT);
  }
}

static void handleSecretSubscreenSwipe(SwipeDirection swipe) {
  if (swipe == SWIPE_RIGHT) {
    showScreen(SCREEN_ACCOUNT_SECRET);
  }
}

static void handleTextEditSwipe(SwipeDirection swipe) {
  if (!isTextEditKeyboardSwipeArea(gTouchStartX, gTouchStartY)) {
    return;
  }
  syncEditValueFromTextarea();
  if (swipe == SWIPE_LEFT) {
    gKeyboardPage = min(gKeyboardPage + 1, 1);
    rebuildScreen();
    return;
  }
  if (swipe == SWIPE_RIGHT) {
    gKeyboardPage = max(gKeyboardPage - 1, 0);
    rebuildScreen();
  }
}

static bool isTextEditKeyboardSwipeArea(int16_t x, int16_t y) {
  return x >= TEXT_EDIT_PANEL_X && x <= (TEXT_EDIT_PANEL_X + TEXT_EDIT_PANEL_W) &&
         y >= TEXT_EDIT_PANEL_Y && y <= (TEXT_EDIT_PANEL_Y + TEXT_EDIT_PANEL_H);
}

static void handleSwipe(SwipeDirection swipe) {
  if (swipe == SWIPE_NONE) {
    return;
  }

  switch (gCurrentScreen) {
    case SCREEN_HOME:
      handleHomeSwipe(swipe);
      break;
    case SCREEN_WALLET_QR:
      handleHomeSwipe(swipe);
      break;
    case SCREEN_SETTINGS_MENU:
      handleSettingsSwipe(swipe);
      break;
    case SCREEN_WIFI:
      handleWifiSwipe(swipe);
      break;
    case SCREEN_SERVER:
      handleServerSwipe(swipe);
      break;
    case SCREEN_ACCOUNT:
      handleAccountSwipe(swipe);
      break;
    case SCREEN_ACCOUNT_HOMESERVER:
    case SCREEN_ACCOUNT_SECRET:
      handleAccountSubscreenSwipe(swipe);
      break;
    case SCREEN_SECRET_SHOW:
    case SCREEN_SECRET_GENERATE_INFO:
    case SCREEN_SECRET_GENERATE_RUNNING:
    case SCREEN_SECRET_GENERATE_CANCEL_CONFIRM:
      handleSecretSubscreenSwipe(swipe);
      break;
    case SCREEN_TEXT_EDIT:
      handleTextEditSwipe(swipe);
      break;
    case SCREEN_REGISTER_ACCOUNT_CONFIRM:
    case SCREEN_REGISTER_ACCOUNT_RESULT:
    case SCREEN_HOMESERVER_PDA_CONFIRM:
    case SCREEN_HOMESERVER_PDA_RESULT:
      handleHomeSwipe(swipe);
      break;
  }
}

void setup() {
  Serial.begin(115200);
  sodium_init();
  Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
  initPowerManagement();

  gfx->begin();
  gBus->writeC8D8(0x36, 0xA0);
  gfx->setBrightness(220);
  gfx->fillScreen(0x0000);

  gTouch.setPins(PIN_TP_INT, -1);
  gTouch.begin(Wire, CST92XX_SLAVE_ADDRESS, PIN_I2C_SDA, PIN_I2C_SCL);
  gTouch.setMaxCoordinates(DISP_W, DISP_H);
  gTouch.setSwapXY(true);
  gTouch.setMirrorXY(true, false);

  gPrefs.begin("shine-nav-ui", false);
  loadPrefs();
  beginSavedWifi();
  gLastWifiStatus = WiFi.status();

  lv_init();

  uint32_t screenWidth = gfx->width();
  uint32_t screenHeight = gfx->height();
  uint32_t bufPixels = screenWidth * 40;
  gBuf1 = (lv_color_t *)heap_caps_malloc(bufPixels * sizeof(lv_color_t), MALLOC_CAP_DMA);
  gBuf2 = (lv_color_t *)heap_caps_malloc(bufPixels * sizeof(lv_color_t), MALLOC_CAP_DMA);
  lv_disp_draw_buf_init(&gDrawBuf, gBuf1, gBuf2, bufPixels);

  static lv_disp_drv_t dispDrv;
  lv_disp_drv_init(&dispDrv);
  dispDrv.hor_res = screenWidth;
  dispDrv.ver_res = screenHeight;
  dispDrv.flush_cb = lvglFlushCb;
  dispDrv.draw_buf = &gDrawBuf;
  lv_disp_drv_register(&dispDrv);

  static lv_indev_drv_t indevDrv;
  lv_indev_drv_init(&indevDrv);
  indevDrv.type = LV_INDEV_TYPE_POINTER;
  indevDrv.read_cb = lvglTouchRead;
  lv_indev_drv_register(&indevDrv);

  const esp_timer_create_args_t tickArgs = {
      .callback = &lvglTick,
      .name = "lvgl_tick"};
  esp_timer_handle_t tickTimer = nullptr;
  esp_timer_create(&tickArgs, &tickTimer);
  esp_timer_start_periodic(tickTimer, LVGL_TICK_MS * 1000);

  rebuildScreen();
  Serial.println("Minimal nav test ready");
  Serial.println("USB diag commands: last_error, last_diag, reg_diag, clear_error, help");
}

void loop() {
  handleUsbSerialCommands();
  lv_timer_handler();
  if (gWalletQrTapReturnPending) {
    gWalletQrTapReturnPending = false;
    showScreen(SCREEN_HOME);
  }
  manageWifiReconnect();
  manageAccountPdaRefresh();
  manageShineConnection();

  if (gBalanceAutoRefreshPending && gSecretConfigured && WiFi.status() == WL_CONNECTED) {
    unsigned long now = millis();
    if (gLastBalanceAutoRefreshAttemptMs == 0 || now - gLastBalanceAutoRefreshAttemptMs >= 15000) {
      gLastBalanceAutoRefreshAttemptMs = now;
      String message;
      if (refreshWalletBalance(message)) {
        gBalanceAutoRefreshPending = false;
      }
      if (gCurrentScreen == SCREEN_HOME) {
        rebuildScreen();
      }
    }
  }

  static unsigned long lastHomeRefreshMs = 0;
  if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= HOME_REFRESH_MS) {
    lastHomeRefreshMs = millis();
    rebuildScreen();
  }

  if (gCurrentScreen == SCREEN_SECRET_GENERATE_RUNNING) {
    ShineSecretGenerationStatus status = shineSecretStatus();
    if (status.running) {
      shineSecretStep(4);
      static unsigned long lastSecretUiMs = 0;
      if (millis() - lastSecretUiMs >= 500) {
        lastSecretUiMs = millis();
        rebuildScreen();
      }
    } else if (status.done) {
      setSecretValue(shineSecretBytes());
      gAccountStatusMessage = "Secret generated";
      showScreen(SCREEN_SECRET_SHOW);
    }
  }

  if (gPendingSwipe != SWIPE_NONE) {
    SwipeDirection swipe = gPendingSwipe;
    gPendingSwipe = SWIPE_NONE;
    handleSwipe(swipe);
  }

  delay(5);
}
