#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>

#define DISPLAY_BACKEND_2432S028R     0
#define DISPLAY_BACKEND_JC3248W535C   1

#if defined(JC3248W535C)
  #undef DISPLAY_BACKEND
  #define DISPLAY_BACKEND DISPLAY_BACKEND_JC3248W535C
#elif !defined(DISPLAY_BACKEND)
  #define DISPLAY_BACKEND 1
#endif

#define DISPLAY_VERSION "0.0.0.4"

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  #define USER_SETUP_INFO "ESP32-2432S028R"
  #define ILI9341_DRIVER

  #define TFT_WIDTH  320
  #define TFT_HEIGHT 240

  #define TFT_MISO 12
  #define TFT_MOSI 13
  #define TFT_SCLK 14
  #define TFT_CS   15
  #define TFT_DC    2
  #define TFT_RST  -1

  #define TOUCH_IRQ      36
  #define TOUCH_MOSI     32
  #define TOUCH_MISO     39
  #define TOUCH_CLK      25
  #define TOUCH_CS       33

  #define TFT_BL   21
  #define TFT_BACKLIGHT_ON HIGH

  #define SD_CS   5
  #define SD_MISO 19
  #define SD_MOSI 23
  #define SD_SCLK 18

  #define SPI_FREQUENCY       27000000
  #define SPI_READ_FREQUENCY  16000000
  #define SPI_TOUCH_FREQUENCY 2500000

  #define LOAD_GLCD
  #define LOAD_FONT2
  #define LOAD_FONT4
  #define LOAD_FONT6
  #define LOAD_FONT7
  #define LOAD_FONT8
  #define LOAD_GFXFF
  #define SMOOTH_FONT

  #include <TFT_eSPI.h>
  #include <XPT2046_Touchscreen.h>
#elif DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  #include <Arduino_GFX_Library.h>
  #include <lvgl.h>
  #include <Wire.h>

  #define USER_SETUP_INFO "JC3248W535C"

  #define TFT_WIDTH  480
  #define TFT_HEIGHT 320

  #define TFT_BL   1
  #define TFT_BACKLIGHT_ON HIGH

  #define TFT_MISO -1
  #define TFT_MOSI -1
  #define TFT_SCLK 47
  #define TFT_CS   45
  #define TFT_DC   -1
  #define TFT_RST  -1

  #define TOUCH_IRQ    3
  #define TOUCH_MOSI   4
  #define TOUCH_MISO  -1
  #define TOUCH_CLK    8L
  #define TOUCH_CS    -1

  #define SD_CS   10
  #define SD_MISO 13
  #define SD_MOSI 11
  #define SD_SCLK 12

  #define SPI_FREQUENCY       40000000
  #define SPI_READ_FREQUENCY  40000000
  #define SPI_TOUCH_FREQUENCY 400000

  #ifndef JC3248_ENABLE_LVGL
    #define JC3248_ENABLE_LVGL 1
  #endif

  static inline uint16_t color565(uint8_t r, uint8_t g, uint8_t b) {
    return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
  }

  #ifndef TFT_BLACK
    #define TFT_BLACK     color565(0, 0, 0)
    #define TFT_WHITE     color565(255, 255, 255)
    #define TFT_RED       color565(255, 0, 0)
    #define TFT_GREEN     color565(0, 255, 0)
    #define TFT_BLUE      color565(0, 0, 255)
    #define TFT_YELLOW    color565(255, 255, 0)
    #define TFT_CYAN      color565(0, 255, 255)
    #define TFT_DARKGREY  color565(64, 64, 64)
    #define TFT_LIGHTGREY color565(192, 192, 192)
  #endif

  #ifndef TL_DATUM
    #define TL_DATUM 0
    #define TC_DATUM 1
    #define TR_DATUM 2
    #define ML_DATUM 3
    #define MC_DATUM 4
    #define MR_DATUM 5
  #endif

  struct TS_Point {
    int16_t x;
    int16_t y;
    int16_t z;
    TS_Point(int16_t px = 0, int16_t py = 0, int16_t pz = 0) : x(px), y(py), z(pz) {}
  };

  class DisplayCompat {
  public:
    static const int16_t PHYS_W = 480;
    static const int16_t PHYS_H = 320;
    static const int16_t LOGICAL_W = PHYS_W;
    static const int16_t LOGICAL_H = PHYS_H;

    Arduino_DataBus *bus = nullptr;
    Arduino_AXS15231B *panel = nullptr;
    Arduino_Canvas *gfx = nullptr;
    uint8_t datum = TL_DATUM;
    uint8_t rotation = 1;
    uint8_t textSize = 1;
    uint16_t fg = TFT_WHITE;
    uint16_t bg = TFT_BLACK;

    bool initOk = false;

    float scale() const {
      float sx = (float)PHYS_W / (float)LOGICAL_W;
      float sy = (float)PHYS_H / (float)LOGICAL_H;
      return (sx < sy) ? sx : sy;
    }

    int16_t scaledW() const { return (int16_t)lroundf(LOGICAL_W * scale()); }
    int16_t scaledH() const { return (int16_t)lroundf(LOGICAL_H * scale()); }
    int16_t originX() const { return (PHYS_W - scaledW()) / 2; }
    int16_t originY() const { return (PHYS_H - scaledH()) / 2; }
    int16_t tx(int16_t x) const { return originX() + (int16_t)lroundf(x * scale()); }
    int16_t ty(int16_t y) const { return originY() + (int16_t)lroundf(y * scale()); }
    int16_t tw(int16_t w) const {
      int16_t v = (int16_t)lroundf(w * scale());
      return (v < 1) ? 1 : v;
    }
    int16_t th(int16_t h) const {
      int16_t v = (int16_t)lroundf(h * scale());
      return (v < 1) ? 1 : v;
    }
    int16_t tr(int16_t r) const {
      int16_t v = (int16_t)lroundf(r * scale());
      return (v < 1) ? 1 : v;
    }

    void init() {
      bus = new Arduino_ESP32QSPI(TFT_CS, TFT_SCLK, 21, 48, 40, 39);
      panel = new Arduino_AXS15231B(bus, TFT_RST, 0, false, 320, 480);
      gfx = new Arduino_Canvas(320, 480, panel, 0, 0, rotation);

      if (gfx) {
        initOk = gfx->begin();
        gfx->setRotation(rotation);
        gfx->setTextWrap(false);
        gfx->setTextSize(textSize);
        gfx->setTextColor(fg, bg);
        gfx->fillScreen(TFT_BLACK);
        gfx->flush();
      }
    }

    bool ready() const { return initOk && gfx != nullptr; }

    void setRotation(uint8_t r) {
      rotation = r;
      if (gfx) gfx->setRotation(rotation);
      flush();
    }

    int16_t width() const { return LOGICAL_W; }
    int16_t height() const { return LOGICAL_H; }

    void flush() { if (gfx) gfx->flush(); }

    void fillScreen(uint16_t c) { if (gfx) gfx->fillScreen(c); }
    void fillRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t c) {
      if (gfx) gfx->fillRect(tx(x), ty(y), tw(w), th(h), c);
    }
    void drawPixel(int16_t x, int16_t y, uint16_t c) { if (gfx) gfx->drawPixel(tx(x), ty(y), c); }
    void drawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t c) {
      if (gfx) gfx->drawLine(tx(x0), ty(y0), tx(x1), ty(y1), c);
    }
    void drawFastHLine(int16_t x, int16_t y, int16_t w, uint16_t c) {
      if (gfx) gfx->drawFastHLine(tx(x), ty(y), tw(w), c);
    }
    void drawFastVLine(int16_t x, int16_t y, int16_t h, uint16_t c) {
      if (gfx) gfx->drawFastVLine(tx(x), ty(y), th(h), c);
    }
    void drawCircle(int16_t x, int16_t y, int16_t r, uint16_t c) {
      if (gfx) gfx->drawCircle(tx(x), ty(y), tr(r), c);
    }
    void fillCircle(int16_t x, int16_t y, int16_t r, uint16_t c) {
      if (gfx) gfx->fillCircle(tx(x), ty(y), tr(r), c);
    }
    void drawRoundRect(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint16_t c) {
      if (gfx) gfx->drawRoundRect(tx(x), ty(y), tw(w), th(h), tr(r), c);
    }
    void fillRoundRect(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint16_t c) {
      if (gfx) gfx->fillRoundRect(tx(x), ty(y), tw(w), th(h), tr(r), c);
    }

    void setTextColor(uint16_t fgColor, uint16_t bgColor = TFT_BLACK) {
      fg = fgColor;
      bg = bgColor;
      if (gfx) gfx->setTextColor(fg, bg);
    }

    void setTextDatum(uint8_t d) { datum = d; }

    void setTextFont(uint8_t font) {
      if (font >= 8) textSize = 10;
      else if (font >= 7) textSize = 8;
      else if (font >= 6) textSize = 6;
      else if (font >= 4) textSize = 4;
      else if (font >= 2) textSize = 2;
      else textSize = 1;

      if (gfx) {
        gfx->setFont(nullptr);
        gfx->setTextSize(textSize);
      }
    }

    int16_t textWidth(const String& s) {
      if (!gfx) return 0;

      gfx->setFont(nullptr);
      gfx->setTextSize(textSize);
      gfx->setTextWrap(false);

      int16_t bx = 0;
      int16_t by = 0;
      uint16_t bw = 0;
      uint16_t bh = 0;
      gfx->getTextBounds(s, 0, 0, &bx, &by, &bw, &bh);

      return (int16_t)lroundf((float)bw / scale());
    }

    int16_t drawString(const String& s, int32_t x, int32_t y) {
      if (!gfx) return 0;

      gfx->setFont(nullptr);
      gfx->setTextSize(textSize);
      gfx->setTextWrap(false);

      int16_t bx = 0;
      int16_t by = 0;
      uint16_t bw = 0;
      uint16_t bh = 0;
      gfx->getTextBounds(s, 0, 0, &bx, &by, &bw, &bh);

      int16_t logicalW = (int16_t)lroundf((float)bw / scale());
      int16_t logicalH = (int16_t)lroundf((float)bh / scale());

      int32_t px = tx((int16_t)x);
      int32_t py = ty((int16_t)y);

      switch (datum) {
        case TC_DATUM:
          px -= (int32_t)bw / 2;
          break;
        case TR_DATUM:
          px -= (int32_t)bw;
          break;
        case ML_DATUM:
          py -= (int32_t)bh / 2;
          break;
        case MC_DATUM:
          px -= (int32_t)bw / 2;
          py -= (int32_t)bh / 2;
          break;
        case MR_DATUM:
          px -= (int32_t)bw;
          py -= (int32_t)bh / 2;
          break;
        default:
          break;
      }

      px -= bx;
      py -= by;

      gfx->setCursor(px, py);
      gfx->print(s);
      return logicalW > 0 ? logicalW : logicalH;
    }

    bool pushLvglArea(int32_t x, int32_t y, int32_t w, int32_t h, const uint16_t *pixels) {
      if (!panel || !pixels || w <= 0 || h <= 0) return false;
      panel->draw16bitRGBBitmap(tx((int16_t)x), ty((int16_t)y), const_cast<uint16_t*>(pixels), w, h);
      return true;
    }
  };

  class TouchCompat {
  public:
    static const uint8_t TOUCH_ADDR = 0x3B;
    static const uint8_t MAX_TOUCHES = 1;
    static const int16_t PHYS_W = 480;
    static const int16_t PHYS_H = 320;
    static const int16_t LOGICAL_W = PHYS_W;
    static const int16_t LOGICAL_H = PHYS_H;

    bool initOk = false;
    uint8_t rotation = 1;
    TS_Point lastPoint;
    bool lastTouched = false;
    uint32_t lastPollMs = 0;

    float scale() const {
      float sx = (float)PHYS_W / (float)LOGICAL_W;
      float sy = (float)PHYS_H / (float)LOGICAL_H;
      return (sx < sy) ? sx : sy;
    }

    int16_t scaledW() const { return (int16_t)lroundf(LOGICAL_W * scale()); }
    int16_t scaledH() const { return (int16_t)lroundf(LOGICAL_H * scale()); }
    int16_t originX() const { return (PHYS_W - scaledW()) / 2; }
    int16_t originY() const { return (PHYS_H - scaledH()) / 2; }

    void begin(SPIClass&) {
      Wire.begin(TOUCH_MOSI, TOUCH_CLK);
      Wire.setClock(400000);
      if (TOUCH_IRQ >= 0) pinMode(TOUCH_IRQ, INPUT_PULLUP);
      if (TOUCH_CS >= 0) {
        pinMode(TOUCH_CS, OUTPUT);
        digitalWrite(TOUCH_CS, LOW);
        delay(20);
        digitalWrite(TOUCH_CS, HIGH);
        delay(200);
      } else {
        delay(80);
      }
      initOk = true;
      lastTouched = false;
    }

    void setRotation(uint8_t r) { rotation = r; }

    bool touched() {
      poll();
      return lastTouched;
    }

    TS_Point getPoint() const { return lastPoint; }

  private:
    void poll() {
      if (!initOk) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      if ((millis() - lastPollMs) < 12) return;
      lastPollMs = millis();

      uint8_t data[MAX_TOUCHES * 6 + 2] = {0};
      const uint8_t read_cmd[11] = {
        0xB5, 0xAB, 0xA5, 0x5A, 0x00, 0x00,
        (uint8_t)(((MAX_TOUCHES * 6) + 2) >> 8),
        (uint8_t)(((MAX_TOUCHES * 6) + 2) & 0xFF),
        0x00, 0x00, 0x00
      };

      Wire.beginTransmission(TOUCH_ADDR);
      Wire.write(read_cmd, sizeof(read_cmd));
      if (Wire.endTransmission() != 0) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      if (Wire.requestFrom((int)TOUCH_ADDR, (int)sizeof(data)) != (int)sizeof(data)) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      for (size_t i = 0; i < sizeof(data); ++i) {
        data[i] = Wire.read();
      }

      if (data[1] == 0 || data[1] > MAX_TOUCHES) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      uint16_t rawX = ((data[2] & 0x0F) << 8) | data[3];
      uint16_t rawY = ((data[4] & 0x0F) << 8) | data[5];

      if ((rawX == 273 && rawY == 273) || rawX > 4000 || rawY > 4000) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      int16_t physX = constrain((int16_t)rawY, 0, PHYS_W - 1);
      int16_t physY = constrain((int16_t)map(rawX, 0, 320, PHYS_H, 0), 0, PHYS_H - 1);

      if (rotation == 3) {
        physX = (PHYS_W - 1) - physX;
        physY = (PHYS_H - 1) - physY;
      }

      if (physX < originX() || physX >= (originX() + scaledW()) || physY < originY() || physY >= (originY() + scaledH())) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      int16_t logicalX = (int16_t)((physX - originX()) / scale());
      int16_t logicalY = (int16_t)((physY - originY()) / scale());

      lastPoint = TS_Point(
        constrain(logicalX, 0, LOGICAL_W - 1),
        constrain(logicalY, 0, LOGICAL_H - 1),
        1
      );
      lastTouched = true;
    }
  };

  extern DisplayCompat tft;

  #if JC3248_ENABLE_LVGL
    static uint32_t jc3248_lv_last_tick = 0;
    static bool jc3248_lv_ready = false;

    #if LVGL_VERSION_MAJOR >= 9
      static lv_display_t *jc3248_lv_display = nullptr;
      static lv_color_t *jc3248_lv_buf_1 = nullptr;
      static lv_color_t *jc3248_lv_buf_2 = nullptr;

      static void jc3248LvglFlush(lv_display_t *disp, const lv_area_t *area, uint8_t *color_p) {
        (void)disp;
        int32_t w = area->x2 - area->x1 + 1;
        int32_t h = area->y2 - area->y1 + 1;
        tft.pushLvglArea(area->x1, area->y1, w, h, reinterpret_cast<const uint16_t *>(color_p));
        lv_display_flush_ready(disp);
      }
    #else
      static lv_disp_draw_buf_t jc3248_lv_draw_buf;
      static lv_color_t *jc3248_lv_buf_1 = nullptr;
      static lv_color_t *jc3248_lv_buf_2 = nullptr;
      static lv_disp_drv_t jc3248_lv_drv;

      static void jc3248LvglFlush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
        int32_t w = area->x2 - area->x1 + 1;
        int32_t h = area->y2 - area->y1 + 1;
        tft.pushLvglArea(area->x1, area->y1, w, h, reinterpret_cast<const uint16_t *>(color_p));
        lv_disp_flush_ready(disp);
      }
    #endif

    static void jc3248InitLvglBridge() {
      if (jc3248_lv_ready) return;

      lv_init();

      size_t buffer_pixels = TFT_WIDTH * 40;
      jc3248_lv_buf_1 = static_cast<lv_color_t*>(heap_caps_malloc(buffer_pixels * sizeof(lv_color_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
      jc3248_lv_buf_2 = static_cast<lv_color_t*>(heap_caps_malloc(buffer_pixels * sizeof(lv_color_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
      if (!jc3248_lv_buf_1) jc3248_lv_buf_1 = static_cast<lv_color_t*>(heap_caps_malloc(buffer_pixels * sizeof(lv_color_t), MALLOC_CAP_8BIT));
      if (!jc3248_lv_buf_2) jc3248_lv_buf_2 = static_cast<lv_color_t*>(heap_caps_malloc(buffer_pixels * sizeof(lv_color_t), MALLOC_CAP_8BIT));
      if (!jc3248_lv_buf_1 || !jc3248_lv_buf_2) return;

      #if LVGL_VERSION_MAJOR >= 9
        jc3248_lv_display = lv_display_create(TFT_WIDTH, TFT_HEIGHT);
        if (!jc3248_lv_display) return;
        lv_display_set_flush_cb(jc3248_lv_display, jc3248LvglFlush);
        lv_display_set_buffers(
          jc3248_lv_display,
          jc3248_lv_buf_1,
          jc3248_lv_buf_2,
          buffer_pixels * sizeof(lv_color_t),
          LV_DISPLAY_RENDER_MODE_PARTIAL
        );
      #else
        lv_disp_draw_buf_init(&jc3248_lv_draw_buf, jc3248_lv_buf_1, jc3248_lv_buf_2, buffer_pixels);
        lv_disp_drv_init(&jc3248_lv_drv);
        jc3248_lv_drv.hor_res = TFT_WIDTH;
        jc3248_lv_drv.ver_res = TFT_HEIGHT;
        jc3248_lv_drv.flush_cb = jc3248LvglFlush;
        jc3248_lv_drv.draw_buf = &jc3248_lv_draw_buf;
        lv_disp_drv_register(&jc3248_lv_drv);
      #endif

      jc3248_lv_last_tick = millis();
      jc3248_lv_ready = true;
    }

    static void jc3248HandleLvgl() {
      if (!jc3248_lv_ready) return;
      uint32_t now = millis();
      uint32_t elapsed = now - jc3248_lv_last_tick;
      if (elapsed > 0) {
        #if LVGL_VERSION_MAJOR >= 9
          lv_tick_inc(elapsed);
          lv_timer_handler();
        #else
          lv_tick_inc(elapsed);
          lv_timer_handler();
        #endif
        jc3248_lv_last_tick = now;
      }
    }
  #else
    static void jc3248InitLvglBridge() {}
    static void jc3248HandleLvgl() {}
  #endif
#else
  #error "Unsupported DISPLAY_BACKEND value"
#endif

#include <FS.h>
using fs::FS;
using fs::File;
#include <SD.h>
#include <SPI.h>
#include <time.h>
#include <WebServer.h>
#include <esp_system.h>
#include <esp_heap_caps.h>
#include <rom/rtc.h>

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
TFT_eSPI tft = TFT_eSPI();
XPT2046_Touchscreen ts(TOUCH_CS, TOUCH_IRQ);
#else
DisplayCompat tft;
TouchCompat ts;
#endif

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  #if defined(ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S3)
    SPIClass touchSPI(VSPI);
  #else
    SPIClass touchSPI(FSPI);
  #endif
#else
  SPIClass touchSPI(FSPI);
#endif
SPIClass sdSPI(HSPI);
WebServer server(80);

int touchMinX = 200;
int touchMaxX = 3700;
int touchMinY = 240;
int touchMaxY = 3800;

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
const int SCREEN_W = TFT_HEIGHT;
const int SCREEN_H = TFT_WIDTH;

static const int UI_REF_W = 320;
static const int UI_REF_H = 240;
#elif DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
const int SCREEN_W = TFT_WIDTH;
const int SCREEN_H = TFT_HEIGHT;

static const int UI_REF_W = 320;
static const int UI_REF_H = 240;
#else

#endif

#define UX(v) ((int)((((int32_t)(v)) * SCREEN_W + (UI_REF_W / 2)) / UI_REF_W))
#define UY(v) ((int)((((int32_t)(v)) * SCREEN_H + (UI_REF_H / 2)) / UI_REF_H))
#define UW(v) ((int)((((int32_t)(v)) * SCREEN_W + (UI_REF_W / 2)) / UI_REF_W))
#define UH(v) ((int)((((int32_t)(v)) * SCREEN_H + (UI_REF_H / 2)) / UI_REF_H))
#define UR(v) ((int)min(UW(v), UH(v)))

const char* CONFIG_FILE = "/config.json";
const char* DEVICE_CALLSIGN = "13MAD86";

#ifndef AP_SETUP_TIMEOUT_MS
  #define AP_SETUP_TIMEOUT_MS 180000UL
#endif

#ifndef WIFI_CONNECT_TIMEOUT_MS
  #define WIFI_CONNECT_TIMEOUT_MS 15000UL
#endif

#ifndef WIFI_RETRY_INTERVAL_MS
  #define WIFI_RETRY_INTERVAL_MS 30000UL
#endif

#ifndef WIFI_RETRY_TIMEOUT_MS
  #define WIFI_RETRY_TIMEOUT_MS 3000UL
#endif

#ifndef HTTP_CONNECT_TIMEOUT_MS
  #define HTTP_CONNECT_TIMEOUT_MS 3000
#endif

#ifndef HTTP_READ_TIMEOUT_MS
  #define HTTP_READ_TIMEOUT_MS 3000
#endif

bool apModeActive = false;
String activeStorageLabel = "SD";
String apSsid = "";
String apPass = "12345678";

struct HeardRecord;
struct RadioIdLookupCacheEntry;

String normalizeHamCallsign(const String& input);
String countryCodeFromCallsign(const String& cs);
String flagBmpPathFromCode(const String& code);
bool drawFlagForCallsign(int x, int y, int w, int h, const String& callsign);
bool drawBmp24FromSD(const char *filename, int16_t x, int16_t y, int16_t maxW, int16_t maxH);
String csvFieldAt(const String& line, int wantedIndex);
String buildOperatorName(const String& firstName, const String& lastName);
String clipWithTilde(const String& input, size_t maxChars);
String formatCallsignName(const String& callsign, const String& name, size_t maxNameChars = 10, size_t maxCallsignChars = 10);
String urlEncodeComponent(const String& value);
String buildRadioIdUsersUrl(const String& callsign);
bool radioIdLookupNeeded(const String& callsign, const String& name);
String radioIdLookupSignatureForRecord(const HeardRecord& r);
void queueRadioIdLookupForRecord(const HeardRecord& r);
void clearRadioIdLookupCache();
int radioIdLookupCacheIndexForCallsign(const String& normalizedCallsign);
struct RadioIdLookupCacheEntry* radioIdLookupCacheSlotForCallsign(const String& normalizedCallsign, bool createIfMissing);
bool parseRadioIdUserResponse(const String& payload, struct RadioIdLookupCacheEntry& entry);
bool fetchRadioIdProfile(const String& normalizedCallsign, struct RadioIdLookupCacheEntry& outEntry);
void applyRadioIdCacheToRecord(struct HeardRecord& r, bool allowFetch = true);
String radioIdDisplayNameForRecord(const struct HeardRecord& r, bool allowFetch = true);
String radioIdDisplayCountryForRecord(const struct HeardRecord& r, bool allowFetch = true);
void applyRadioIdCacheToList(struct HeardRecord* list, int count);
bool applyRadioIdCacheToActiveData();
void processRadioIdLookups();

struct ModeStatus {
  bool known = false;
  bool enabled = false;
};

File storageOpen(const char* path, const char* mode = FILE_READ) {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  const char* openMode = mode;
  if (strcmp(mode, "rb") == 0) openMode = FILE_READ;
  else if (strcmp(mode, "wb") == 0) openMode = FILE_WRITE;
  else if (strcmp(mode, "ab") == 0) openMode = FILE_APPEND;
  return SD.open(path, openMode);
#else
  const char* openMode = mode;
  if (strcmp(mode, "rb") == 0) openMode = FILE_READ;
  else if (strcmp(mode, "wb") == 0) openMode = FILE_WRITE;
  else if (strcmp(mode, "ab") == 0) openMode = FILE_APPEND;
  return SD.open(path, openMode);
#endif
}

bool storageExists(const char* path) {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  return SD.exists(path);
#else
  return SD.exists(path);
#endif
}

bool storageRemove(const char* path) {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  return !SD.exists(path) || SD.remove(path);
#else
  return !SD.exists(path) || SD.remove(path);
#endif
}

static const char* DEFAULT_TZ_RULE = "CET-1CEST,M3.5.0/2,M10.5.0/3";

struct AppConfig {
  String backend;
  String wifiSsid;
  String wifiPass;
  String pistarHost;
  String deviceCallsign = "";
  String themeColor = "#35C2FF";
  uint32_t refreshMs = 1500;
  uint32_t callsignHoldMs = 12000;
  bool apiUtcToLocalTime = true;
  String timezoneTz = DEFAULT_TZ_RULE;
  uint8_t brightness = 220;
  bool flipScreen = false;
  bool screensaverEnabled = true;
  uint32_t screensaverTimeoutMs = 240000;
  uint8_t screensaverDimBrightness = 25;
  bool radioIdLookupEnabled = true;
  String radioIdUsersApiBase = "https://radioid.net/api/users?callsign=";
} cfg;

static const char* RADIOID_USERS_API_BASE_DEFAULT = "https://radioid.net/api/users?callsign=";
static const int RADIOID_LOOKUP_CACHE_SIZE = 24;
static const unsigned long RADIOID_LOOKUP_TTL_MS = 24UL * 60UL * 60UL * 1000UL;
static const unsigned long RADIOID_LOOKUP_RETRY_MS = 15UL * 60UL * 1000UL;
static const unsigned long RADIOID_LOOKUP_MIN_INTERVAL_MS = 1500UL;

struct RadioIdLookupCacheEntry {
  String callsign = "";
  String name = "";
  String country = "";
  String city = "";
  String state = "";
  bool valid = false;
  bool found = false;
  unsigned long lastFetchMillis = 0;
};

RadioIdLookupCacheEntry radioIdLookupCache[RADIOID_LOOKUP_CACHE_SIZE];
int radioIdLookupCacheWriteIndex = 0;
unsigned long lastRadioIdLookupMillis = 0;
String pendingRadioIdLookupCallsign = "";
String lastRadioIdLookupSignature = "";

struct Theme {
  uint16_t bg;
  uint16_t panel;
  uint16_t panel2;
  uint16_t border;
  uint16_t text;
  uint16_t subtext;
  uint16_t accent;
  uint16_t good;
  uint16_t warn;
  uint16_t bad;
  uint16_t live;
  uint16_t rx;
  uint16_t tx;
  uint16_t tabIdle;
  uint16_t tabActive;
} theme;

ModeStatus modeDMR, modeDSTAR, modeYSF, modeP25, modeNXDN, modePOCSAG;

enum Page {
  PAGE_LIVE = 0,
  PAGE_LAST = 1,
  PAGE_STATUS = 2,
  PAGE_AP = 3,
  PAGE_STAT = 4,
  PAGE_ABOUT = 5
};

Page currentPage = PAGE_LIVE;
Page previousPage = PAGE_LIVE;
unsigned long lastPoll = 0;
bool wifiOk = false;
bool configOkState = false;
bool setupRequired = false;
String footerText = "Booting...";
bool timeConfigured = false;
String lastError = "";
bool uiDirty = true;
bool parseHexColor(String s, uint16_t &out565);
bool footerDirty = true;
bool lastLiveActiveState = false;
int lastRenderedPage = -1;
bool lastDetailPopupState = false;
bool screensaverActive = false;
unsigned long lastInteractionMillis = 0;
unsigned long lastScreensaverFrameMillis = 0;
unsigned long lastWifiRetryMillis = 0;
bool pendingConfigApply = false;
unsigned long pendingConfigApplyAt = 0;
bool wifiConnectInProgress = false;
unsigned long wifiConnectStartedAt = 0;
unsigned long lastWifiStateCheck = 0;
int screensaverPosX = 16;
int screensaverPosY = 72;
int screensaverVelX = 4;
int screensaverVelY = 3;
String lastHeaderClock = "";
unsigned long lastClockCheck = 0;
bool nextFetchHeard = false;

void requestRedraw() {
  uiDirty = true;
}

String getDeviceCallsign();
void noteScreenActivity(bool fromTouch = false);

String resolveTargetDisplay(const HeardRecord& rec);
String resolveTargetLabel(const HeardRecord& rec);
String prettyMetricValue(const String& value, const char* suffix = nullptr, bool keepPercent = false);
String firstNonEmpty(const String& a, const String& b, const String& c = "", const String& d = "");
void updateSessionStatsFromRecord(const HeardRecord& rec);
void drawStatsPage();
void drawAboutPage();
String getDateText();
String getDetailedClockText();

void setFooterText(const String& text) {
  if (footerText != text) {
    footerText = text;
    footerDirty = true;
    noteScreenActivity(false);
  }
}

void applyDisplaySettings();
void setScreensaverActive(bool active);
void noteUserInteraction(bool fromTouch = false);
void drawScreensaver(bool forceClear = false);
bool configLooksValid();
bool startSetupAccessPoint();
void stopSetupAccessPoint();
void beginWiFiConnectAttempt(bool stopApFirst = true);
void handleWiFiConnectProgress();
void syncTimeIfNeeded();

String formatUptimeBrief() {
  unsigned long totalSeconds = millis() / 1000UL;
  unsigned long days = totalSeconds / 86400UL;
  unsigned long hours = (totalSeconds % 86400UL) / 3600UL;
  unsigned long minutes = (totalSeconds % 3600UL) / 60UL;

  if (days > 0) return String(days) + "d " + String(hours) + "h";
  if (hours > 0) return String(hours) + "h " + String(minutes) + "m";
  return String(minutes) + "m";
}

String getBuildStamp() {
  return String(__DATE__) + " " + String(__TIME__);
}

String getWifiSignalText() {
  if (!wifiOk) return "Offline";
  long rssi = WiFi.RSSI();
  if (rssi == 0) return "Connected";
  return String(rssi) + " dBm";
}

bool detailPopupOpen = false;
int selectedHeardIndex = -1;

const int BRIGHT_SLIDER_X = UX(108);
const int BRIGHT_SLIDER_Y = UY(193);
const int BRIGHT_SLIDER_W = UW(150);
const int BRIGHT_SLIDER_H = UH(8);

struct HeardRecord {
  String timeUtc;
  String mode;
  String callsign;
  String name;
  String country;
  String grid;
  String target;
  String targetName;
  String src;
  String duration;
  String loss;
  String ber;
  String rssi;
  String rxFreq;
  String txFreq;
  bool valid = false;
};

void setModeStatusIfPresent(ModeStatus& st, JsonVariantConst v);
bool modeStatusEqual(const ModeStatus& a, const ModeStatus& b);
bool allModeStatusesEqual(const ModeStatus& dmr, const ModeStatus& dstar, const ModeStatus& ysf,
                          const ModeStatus& p25, const ModeStatus& nxdn, const ModeStatus& pocsag);
void inferModeStatusesFromHeard(const HeardRecord* list, int count);
uint16_t modeColor(const ModeStatus& st, bool active);
bool sameRecord(const HeardRecord& a, const HeardRecord& b);
String liveStatusText(bool active, const HeardRecord& rec);
void drawModeButton(int x, int y, int w, int h, const String& label, const ModeStatus& st, bool active);
void drawSignalMeter(int x, int y, int w, int h, bool active, const HeardRecord* rec);
void drawDetailPopup(const HeardRecord& r);
bool heardListsEqual(const HeardRecord* a, int aCount, const HeardRecord* b, int bCount);
bool extractWpsdStatusPill(const String& html, const String& label, ModeStatus& st);
void parseModeCellWPSD(const String& html, const String& label, ModeStatus& st);
void parseModeCellPiStar(const String& html, const String& label, ModeStatus& st);
uint16_t bmpRead16(fs::File &f);
uint32_t bmpRead32(fs::File &f);

struct SessionStats {
  uint32_t totalQsos = 0;
  uint32_t totalLiveHits = 0;
  uint32_t modeDMR = 0;
  uint32_t modeDSTAR = 0;
  uint32_t modeYSF = 0;
  uint32_t modeP25 = 0;
  uint32_t modeNXDN = 0;
  uint32_t modePOCSAG = 0;
  int32_t bestRssi = -999;
  int32_t worstRssi = 0;
  bool hasRssi = false;
} sessionStats;

const int MAX_HEARD = 12;
HeardRecord heardList[MAX_HEARD];
int heardCount = 0;

HeardRecord liveRecord;
unsigned long liveRecordMillis = 0;

String currentRxFreq = "438.8000";
String currentTxFreq = "430.8000";
String hotspotRxFreq = "";
String hotspotTxFreq = "";
String hotspotStatusText = "";

String trimSafe(const String& s) {
  String out = s;
  out.trim();
  return out;
}

String buildOperatorName(const String& firstName, const String& lastName) {
  String full = trimSafe(firstName);
  String last = trimSafe(lastName);
  if (last.length()) {
    if (full.length()) full += " ";
    full += last;
  }
  return trimSafe(full);
}

String clipWithTilde(const String& input, size_t maxChars) {
  String value = trimSafe(input);
  if (!maxChars || value.length() <= maxChars) return value;
  if (maxChars == 1) return String("~");
  return value.substring(0, maxChars - 1) + "~";
}

String formatCallsignName(const String& callsign, const String& name, size_t maxNameChars, size_t maxCallsignChars) {
  String shownCallsign = normalizeHamCallsign(callsign);
  if (!shownCallsign.length()) shownCallsign = "-";
  if (maxCallsignChars) shownCallsign = clipWithTilde(shownCallsign, maxCallsignChars);

  String shownName = trimSafe(name);
  if (!shownName.length()) return shownCallsign;
  if (maxNameChars) shownName = clipWithTilde(shownName, maxNameChars);

  return shownCallsign + " - " + shownName;
}

String fitLivePanelPrimaryText(const String& input, int availableWidth, int& font) {
  String value = trimSafe(input);
  if (!value.length()) value = "-";

  const uint8_t candidateFonts[] = {4, 2, 1};
  for (size_t i = 0; i < sizeof(candidateFonts) / sizeof(candidateFonts[0]); ++i) {
    font = candidateFonts[i];
    tft.setTextFont(font);
    if (tft.textWidth(value) <= availableWidth) return value;
  }

  font = 1;
  tft.setTextFont(font);
  if (tft.textWidth(value) <= availableWidth) return value;

  String clipped = value;
  while (clipped.length() > 1) {
    clipped.remove(clipped.length() - 1);
    String trial = clipped + "~";
    if (tft.textWidth(trial) <= availableWidth) return trial;
  }

  return String("~");
}

String getDeviceCallsign() {
  String callsign = trimSafe(cfg.deviceCallsign);
  return callsign.length() ? callsign : String(DEVICE_CALLSIGN);
}
String normalizeModeName(String s) {
  s = trimSafe(s);
  s.toUpperCase();
  return s;
}

String extractFreqString(JsonVariantConst v) {
  if (v.isNull()) return "";

  String out;
  if (v.is<const char*>()) out = String(v.as<const char*>());
  else if (v.is<String>()) out = v.as<String>();
  else if (v.is<float>() || v.is<double>()) out = String(v.as<double>(), 4);
  else if (v.is<int>() || v.is<long>() || v.is<unsigned int>() || v.is<unsigned long>()) {
    double raw = v.as<double>();
    if (raw > 1000000.0) raw /= 1000000.0;
    out = String(raw, 4);
  } else {
    return "";
  }

  out = trimSafe(out);
  out.replace(" MHz", "");
  out.replace("MHz", "");
  out.trim();
  return out;
}

void setFreqIfPresent(String& dst, JsonVariantConst v) {
  String parsed = extractFreqString(v);
  if (parsed.length()) dst = parsed;
}

void parseFrequencyFieldsFromObject(JsonObjectConst obj) {
  setFreqIfPresent(hotspotRxFreq, obj["rxFreq"]);
  setFreqIfPresent(hotspotRxFreq, obj["rx_freq"]);
  setFreqIfPresent(hotspotRxFreq, obj["RXFrequency"]);
  setFreqIfPresent(hotspotRxFreq, obj["rx_frequency"]);
  setFreqIfPresent(hotspotRxFreq, obj["rxfrequency"]);
  setFreqIfPresent(hotspotRxFreq, obj["RX Freq"]);
  setFreqIfPresent(hotspotRxFreq, obj["rx_mhz"]);

  setFreqIfPresent(hotspotTxFreq, obj["txFreq"]);
  setFreqIfPresent(hotspotTxFreq, obj["tx_freq"]);
  setFreqIfPresent(hotspotTxFreq, obj["TXFrequency"]);
  setFreqIfPresent(hotspotTxFreq, obj["tx_frequency"]);
  setFreqIfPresent(hotspotTxFreq, obj["txfrequency"]);
  setFreqIfPresent(hotspotTxFreq, obj["TX Freq"]);
  setFreqIfPresent(hotspotTxFreq, obj["tx_mhz"]);

  JsonVariantConst modem = obj["modem"];
  if (!modem.isNull() && modem.is<JsonObjectConst>()) {
    JsonObjectConst mo = modem.as<JsonObjectConst>();
    setFreqIfPresent(hotspotRxFreq, mo["rxFreq"]);
    setFreqIfPresent(hotspotRxFreq, mo["rx_freq"]);
    setFreqIfPresent(hotspotRxFreq, mo["RXFrequency"]);
    setFreqIfPresent(hotspotRxFreq, mo["rx_frequency"]);
    setFreqIfPresent(hotspotTxFreq, mo["txFreq"]);
    setFreqIfPresent(hotspotTxFreq, mo["tx_freq"]);
    setFreqIfPresent(hotspotTxFreq, mo["TXFrequency"]);
    setFreqIfPresent(hotspotTxFreq, mo["tx_frequency"]);
  }

  JsonVariantConst frequencies = obj["frequencies"];
  if (!frequencies.isNull() && frequencies.is<JsonObjectConst>()) {
    JsonObjectConst fq = frequencies.as<JsonObjectConst>();
    setFreqIfPresent(hotspotRxFreq, fq["rx"]);
    setFreqIfPresent(hotspotRxFreq, fq["RX"]);
    setFreqIfPresent(hotspotTxFreq, fq["tx"]);
    setFreqIfPresent(hotspotTxFreq, fq["TX"]);
  }

  JsonVariantConst status = obj["status"];
  if (!status.isNull() && status.is<JsonObjectConst>()) {
    JsonObjectConst st = status.as<JsonObjectConst>();
    setFreqIfPresent(hotspotRxFreq, st["rxFreq"]);
    setFreqIfPresent(hotspotRxFreq, st["rx_frequency"]);
    setFreqIfPresent(hotspotTxFreq, st["txFreq"]);
    setFreqIfPresent(hotspotTxFreq, st["tx_frequency"]);
  }
}

bool strToBoolLoose(String v, bool &out) {
  v = trimSafe(v);
  v.toLowerCase();
  if (v == "1" || v == "true" || v == "on" || v == "yes" || v == "enabled") { out = true; return true; }
  if (v == "0" || v == "false" || v == "off" || v == "no" || v == "disabled") { out = false; return true; }
  return false;
}

bool jsonValueToBoolLoose(JsonVariantConst v, bool &out) {
  if (v.is<bool>()) { out = v.as<bool>(); return true; }
  if (v.is<int>() || v.is<long>() || v.is<unsigned int>() || v.is<unsigned long>()) { out = v.as<long>() != 0; return true; }
  if (v.is<const char*>()) return strToBoolLoose(String(v.as<const char*>()), out);
  if (v.is<String>()) return strToBoolLoose(v.as<String>(), out);
  return false;
}

void setModeStatusIfPresent(ModeStatus& st, JsonVariantConst v) {
  bool parsed = false;
  if (jsonValueToBoolLoose(v, parsed)) {
    st.known = true;
    st.enabled = parsed;
  }
}

void resetModeStatuses() {
  modeDMR = ModeStatus();
  modeDSTAR = ModeStatus();
  modeYSF = ModeStatus();
  modeP25 = ModeStatus();
  modeNXDN = ModeStatus();
  modePOCSAG = ModeStatus();
}

bool modeStatusEqual(const ModeStatus& a, const ModeStatus& b) {
  return a.known == b.known && a.enabled == b.enabled;
}

bool allModeStatusesEqual(const ModeStatus& dmr, const ModeStatus& dstar, const ModeStatus& ysf,
                          const ModeStatus& p25, const ModeStatus& nxdn, const ModeStatus& pocsag) {
  return modeStatusEqual(modeDMR, dmr) &&
         modeStatusEqual(modeDSTAR, dstar) &&
         modeStatusEqual(modeYSF, ysf) &&
         modeStatusEqual(modeP25, p25) &&
         modeStatusEqual(modeNXDN, nxdn) &&
         modeStatusEqual(modePOCSAG, pocsag);
}

void inferModeStatusesFromHeard(const HeardRecord* list, int count) {
  for (int i = 0; i < count; i++) {
    String m = normalizeModeName(list[i].mode);
    if (m == "DMR")   { modeDMR.known = true; modeDMR.enabled = true; }
    if (m == "DSTAR" || m == "D-STAR") { modeDSTAR.known = true; modeDSTAR.enabled = true; }
    if (m == "YSF" || m == "FUSION") { modeYSF.known = true; modeYSF.enabled = true; }
    if (m == "P25")   { modeP25.known = true; modeP25.enabled = true; }
    if (m == "NXDN")  { modeNXDN.known = true; modeNXDN.enabled = true; }
    if (m == "POCSAG") { modePOCSAG.known = true; modePOCSAG.enabled = true; }
  }
}

void parseModeStatusesFromObject(JsonObjectConst obj) {
  parseFrequencyFieldsFromObject(obj);

  JsonVariantConst modes = obj["modes"];
  if (!modes.isNull() && modes.is<JsonObjectConst>()) {
    JsonObjectConst mo = modes.as<JsonObjectConst>();
    setModeStatusIfPresent(modeDMR, mo["DMR"]);
    setModeStatusIfPresent(modeDMR, mo["dmr"]);
    setModeStatusIfPresent(modeDSTAR, mo["DSTAR"]);
    setModeStatusIfPresent(modeDSTAR, mo["dstar"]);
    setModeStatusIfPresent(modeDSTAR, mo["d_star"]);
    setModeStatusIfPresent(modeYSF, mo["YSF"]);
    setModeStatusIfPresent(modeYSF, mo["ysf"]);
    setModeStatusIfPresent(modeP25, mo["P25"]);
    setModeStatusIfPresent(modeP25, mo["p25"]);
    setModeStatusIfPresent(modeNXDN, mo["NXDN"]);
    setModeStatusIfPresent(modeNXDN, mo["nxdn"]);
    setModeStatusIfPresent(modePOCSAG, mo["POCSAG"]);
    setModeStatusIfPresent(modePOCSAG, mo["pocsag"]);
  }

  setModeStatusIfPresent(modeDMR, obj["dmr_enabled"]);
  setModeStatusIfPresent(modeDMR, obj["DMREnabled"]);
  setModeStatusIfPresent(modeDMR, obj["dmrEnabled"]);
  setModeStatusIfPresent(modeDMR, obj["DMR"]);

  setModeStatusIfPresent(modeDSTAR, obj["dstar_enabled"]);
  setModeStatusIfPresent(modeDSTAR, obj["DSTAREnabled"]);
  setModeStatusIfPresent(modeDSTAR, obj["dstarEnabled"]);
  setModeStatusIfPresent(modeDSTAR, obj["DSTAR"]);
  setModeStatusIfPresent(modeDSTAR, obj["D-Star"]);

  setModeStatusIfPresent(modeYSF, obj["ysf_enabled"]);
  setModeStatusIfPresent(modeYSF, obj["YSFEnabled"]);
  setModeStatusIfPresent(modeYSF, obj["ysfEnabled"]);
  setModeStatusIfPresent(modeYSF, obj["YSF"]);
  setModeStatusIfPresent(modeYSF, obj["fusion_enabled"]);

  setModeStatusIfPresent(modeP25, obj["p25_enabled"]);
  setModeStatusIfPresent(modeP25, obj["P25Enabled"]);
  setModeStatusIfPresent(modeP25, obj["p25Enabled"]);
  setModeStatusIfPresent(modeP25, obj["P25"]);

  setModeStatusIfPresent(modeNXDN, obj["nxdn_enabled"]);
  setModeStatusIfPresent(modeNXDN, obj["NXDNEnabled"]);
  setModeStatusIfPresent(modeNXDN, obj["nxdnEnabled"]);
  setModeStatusIfPresent(modeNXDN, obj["NXDN"]);

  setModeStatusIfPresent(modePOCSAG, obj["pocsag_enabled"]);
  setModeStatusIfPresent(modePOCSAG, obj["POCSAGEnabled"]);
  setModeStatusIfPresent(modePOCSAG, obj["pocsagEnabled"]);
  setModeStatusIfPresent(modePOCSAG, obj["POCSAG"]);
}

uint16_t modeColor(const ModeStatus& st, bool active) {
  if (active) return theme.warn;
  if (st.known) return st.enabled ? theme.good : theme.bad;
  return theme.border;
}

bool sameRecord(const HeardRecord& a, const HeardRecord& b) {
  return a.callsign == b.callsign &&
         a.timeUtc   == b.timeUtc &&
         a.mode      == b.mode &&
         a.target    == b.target &&
         a.src       == b.src &&
         a.rxFreq    == b.rxFreq &&
         a.txFreq    == b.txFreq;
}

String normalizedBackend() {
  String b = trimSafe(cfg.backend);
  b.toLowerCase();
  if (b == "wpsd") return "wpsd";
  if (b == "pi-star" || b == "pistar") return "pistar";
  if (b == "pi star") return "pistar";
  return "pistar";
}

String heardUrl() {
  if (normalizedBackend() == "wpsd") {
    return "http://" + cfg.pistarHost + "/api/?limit=12&names=true&country=true";
  }
  return "http://" + cfg.pistarHost + "/api/last_heard.php";
}

String dashboardStatusUrl() {
  return "http://" + cfg.pistarHost + "/mmdvmhost/repeaterinfo.php";
}

String dashboardFreqUrl() {
  if (normalizedBackend() == "wpsd") {
    return "http://" + cfg.pistarHost + "/mmdvmhost/radioinfo.php";
  }
  return "http://" + cfg.pistarHost + "/mmdvmhost/repeaterinfo.php";
}

bool isLiveActive() {
  if (!liveRecord.valid) return false;
  return (millis() - liveRecordMillis) <= cfg.callsignHoldMs;
}

String liveStatusText(bool active, const HeardRecord& rec) {
  String status = trimSafe(hotspotStatusText);
  if (status.length()) {
    if (status.equalsIgnoreCase("Listening")) {
      String mode = normalizeModeName(rec.mode);
      if (active && mode.length()) return "RX: " + mode;
      return "Listening";
    }
    return status;
  }

  String mode = normalizeModeName(rec.mode);
  if (active && mode.length()) return "RX: " + mode;
  return active ? "RX ACTIVE" : "MMDVM IDLE";
}

void setDefaultTheme() {
  theme.bg        = TFT_BLACK;
  theme.panel     = 0x18C3;
  theme.panel2    = 0x2104;
  theme.border    = TFT_DARKGREY;
  theme.text      = TFT_WHITE;
  theme.subtext   = TFT_LIGHTGREY;
  theme.accent    = TFT_CYAN;
  theme.good      = TFT_GREEN;
  theme.warn      = TFT_YELLOW;
  theme.bad       = TFT_RED;
  theme.live      = 0xFBE0;
  theme.rx        = TFT_GREEN;
  theme.tx        = TFT_BLUE;
  theme.tabIdle   = 0x39C7;
  theme.tabActive = 0x059F;
}

uint16_t mix565(uint16_t a, uint16_t b, float t) {
  t = constrain(t, 0.0f, 1.0f);

  uint8_t ar = (a >> 11) & 0x1F;
  uint8_t ag = (a >> 5)  & 0x3F;
  uint8_t ab = a & 0x1F;

  uint8_t br = (b >> 11) & 0x1F;
  uint8_t bg = (b >> 5)  & 0x3F;
  uint8_t bb = b & 0x1F;

  uint8_t rr = ar + (int)((br - ar) * t);
  uint8_t rg = ag + (int)((bg - ag) * t);
  uint8_t rb = ab + (int)((bb - ab) * t);

  return (rr << 11) | (rg << 5) | rb;
}

void applyBaseThemeColor(const String& baseColorHex) {
  uint16_t base;
  if (!parseHexColor(baseColorHex, base)) return;

  theme.accent    = base;
  theme.tabActive = base;
  theme.warn      = mix565(base, TFT_YELLOW, 0.35f);
  theme.good      = mix565(base, TFT_GREEN, 0.60f);
  theme.bad       = mix565(base, TFT_RED, 0.65f);
  theme.live      = mix565(base, TFT_WHITE, 0.40f);
  theme.tx        = mix565(base, TFT_BLUE, 0.55f);
  theme.rx        = mix565(base, TFT_GREEN, 0.45f);
  theme.panel     = mix565(TFT_BLACK, base, 0.16f);
  theme.panel2    = mix565(TFT_BLACK, base, 0.10f);
  theme.border    = mix565(base, TFT_WHITE, 0.22f);
  theme.tabIdle   = mix565(TFT_BLACK, base, 0.24f);
  theme.subtext   = mix565(TFT_LIGHTGREY, base, 0.08f);
}

void fillSoftPanel(int x, int y, int w, int h, uint16_t fill, uint16_t border, int radius = 10) {
  int rr = max(1, UR(radius));
  tft.fillRoundRect(x, y, w, h, rr, fill);
  tft.drawRoundRect(x, y, w, h, rr, border);
  if (w > 2 && h > 2) {
    tft.drawRoundRect(x + 1, y + 1, w - 2, h - 2, rr, mix565(fill, TFT_WHITE, 0.10f));
  }
}

void drawInfoRow(int x, int y, int w, const String& label, const String& value,
                 uint16_t labelBg, uint16_t valueBg, uint16_t labelColor, uint16_t valueColor) {
  const int labelW = UW(76);
  const int rowH = UH(22);
  const int gap = UW(4);
  const int valueX = x + labelW + gap;

  fillSoftPanel(x, y, labelW, rowH, labelBg, theme.border, 5);
  fillSoftPanel(valueX, y, w - labelW - gap, rowH, valueBg, theme.border, 5);

  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(2);

  tft.setTextColor(labelColor, labelBg);
  tft.drawString(label, x + UW(8), y + rowH / 2);

  tft.setTextColor(valueColor, valueBg);
  String clipped = value;
  int maxChars = max(4, (w - labelW - UW(16)) / 6);
  if (maxChars > 4 && clipped.length() > maxChars) clipped = clipped.substring(0, maxChars - 1) + "~";
  tft.drawString(clipped, valueX + UW(8), y + rowH / 2);
}

void drawLiveInfoPanel(int x, int y, int w, int h,
                       const String& callsign, const String& status, const String& target,
                       bool active) {
  fillSoftPanel(x, y, w, h, theme.panel2, theme.border, 10);

  const int flagW = UW(32);
  const int flagH = UH(32);
  const int gap = UW(8);
  const int rowY = y + UH(34);

  String caller = trimSafe(callsign);
  if (!caller.length()) caller = "-";

  String flagCallsign = caller;
  int callerSeparator = flagCallsign.indexOf(" - ");
  if (callerSeparator > 0) flagCallsign = flagCallsign.substring(0, callerSeparator);

  int textX = x + UW(58);
  int availableTextW = max(UW(36), w - (textX - x) - UW(10));
  int callerFont = 4;
  caller = fitLivePanelPrimaryText(caller, availableTextW, callerFont);

  int flagX = x + UW(14);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  int flagY = rowY - UH(28);
  flagY = constrain(flagY, y + UH(6), y + h - flagH - UH(24)) - 4;
#else
  int flagY = rowY - UH(24);
  flagY = constrain(flagY, y + UH(6), y + h - flagH - UH(24)) - 4;
#endif
  drawFlagForCallsign(flagX, flagY, flagW, flagH, flagCallsign);

  tft.setTextFont(callerFont);
  tft.setTextDatum(ML_DATUM);
  tft.setTextColor(active ? theme.warn : theme.text, theme.panel2);
  int callerTextY = rowY - (callerFont >= 4 ? UH(8) : (callerFont == 2 ? UH(8) : UH(6)));
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.drawString(caller, textX, callerTextY);
#else
  tft.drawString(caller, textX, callerTextY - 4);
#endif

  int metaY = y + h - UH(22);
  int statusW = (w - UW(22)) / 2;
  int targetW = w - UW(22) - statusW;
  int statusX = x + UW(8);
  int targetX = statusX + statusW + UW(6);

  fillSoftPanel(statusX, metaY, statusW, UH(16), theme.panel, theme.border, 4);
  fillSoftPanel(targetX, metaY, targetW, UH(16), theme.panel, theme.border, 4);

  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(1);

  String statusText = status.length() ? status : "-";
  tft.setTextColor(active ? theme.good : theme.text, theme.panel);
  tft.drawString(statusText, statusX + statusW / 2, metaY + UH(8));

  String targetText = target.length() ? target : "-";
  tft.setTextColor(theme.text, theme.panel);
  tft.drawString(targetText, targetX + targetW / 2, metaY + UH(8));

  tft.setTextDatum(TL_DATUM);
}

void drawModeButton(int x, int y, int w, int h, const String& label, const ModeStatus& st, bool active) {
  uint16_t textColor = modeColor(st, active);
  uint16_t fill = theme.panel2;
  uint16_t border = active ? mix565(textColor, TFT_WHITE, 0.20f) : theme.border;
  fillSoftPanel(x, y, w, h, fill, border, 8);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(textColor, fill);
  tft.setTextFont(2);
  tft.drawString(label, x + w / 2, y + h / 2);
  tft.setTextDatum(TL_DATUM);
}

String compactRssiValue(const String& value) {
  String out = trimSafe(value);
  if (!out.length()) return "-";
  if (out.equalsIgnoreCase("n/a") || out.equalsIgnoreCase("nan") || out.equalsIgnoreCase("null")) return "-";

  int p1 = out.indexOf('(');
  int p2 = out.indexOf(')', p1 + 1);

  if (p1 >= 0 && p2 > p1) {
    String inside = trimSafe(out.substring(p1 + 1, p2));
    if (inside.indexOf("dBm") >= 0) return inside;
  }

  if (out.indexOf("dBm") >= 0) return out;

  return prettyMetricValue(out, " dBm", false);
}

void drawSignalMeter(int x, int y, int w, int h, bool active, const HeardRecord* rec) {
  fillSoftPanel(x, y, w, h, theme.panel2, theme.border, 8);

  int cx = x + UW(34);
  int cy = y + h - UH(14);
  int radius = UR(24);

  for (int i = 0; i <= 6; i++) {
    float frac = (float)i / 6.0f;
    float angle = PI - frac * (PI * 0.85f);
    int x1 = cx + cos(angle) * (radius - UR(6));
    int y1 = cy - sin(angle) * (radius - UR(6));
    int x2 = cx + cos(angle) * radius;
    int y2 = cy - sin(angle) * radius;
    uint16_t tickColor = (i >= 4) ? theme.warn : theme.subtext;
    tft.drawLine(x1, y1, x2, y2, tickColor);
  }

  int level = 1;
  if (active) {
    if (rec && rec->rssi.length()) {
      int raw = rec->rssi.toInt();
      if (raw != 0 || trimSafe(rec->rssi) == "0") level = constrain(map(raw, -130, -70, 0, 9), 0, 9);
      else level = 8;
    } else level = 8;
  }

  float needleAngle = PI - ((float)level / 9.0f) * (PI * 0.85f);
  int nx = cx + cos(needleAngle) * (radius - UR(8));
  int ny = cy - sin(needleAngle) * (radius - UR(8));
  tft.drawLine(cx, cy, nx, ny, theme.bad);
  tft.fillCircle(cx, cy, max(2, UR(3)), theme.bad);

  tft.setTextFont(1);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.text, theme.panel2);
  tft.drawString("QUALITY", cx, y + UH(8));

  int infoX = x + UW(72);
  int infoW = w - UW(80);
  int rowY = y + UH(6);
  fillSoftPanel(infoX, rowY, infoW, UH(13), theme.panel, theme.border, 4);
  fillSoftPanel(infoX, rowY + UH(16), infoW, UH(13), theme.panel, theme.border, 4);
  fillSoftPanel(infoX, rowY + UH(32), infoW, UH(13), theme.panel, theme.border, 4);

  String rssiText = active && rec ? compactRssiValue(rec->rssi) : "-";
  String berText  = active && rec ? prettyMetricValue(rec->ber, "%", false) : "-";
  String lossText = active && rec ? prettyMetricValue(rec->loss, "%", true) : "-";

  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(1);
  tft.setTextColor(theme.warn, theme.panel);
  tft.drawString("RSSI", infoX + UW(5), rowY + UH(7));
  tft.drawString("BER",  infoX + UW(5), rowY + UH(23));
  tft.drawString("LOSS", infoX + UW(5), rowY + UH(39));

  tft.setTextDatum(MR_DATUM);
  tft.setTextColor(theme.text, theme.panel);
  tft.drawString(rssiText, infoX + infoW - UW(5), rowY + UH(7));
  tft.drawString(berText,  infoX + infoW - UW(5), rowY + UH(23));
  tft.drawString(lossText, infoX + infoW - UW(5), rowY + UH(39));

  tft.setTextDatum(TL_DATUM);
}

void drawFrequencyBox(int x, int y, int w, const String& label, const String& value) {
  fillSoftPanel(x, y, w, UH(24), theme.panel2, theme.border, 6);
  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.warn, theme.panel2);
  tft.drawString(label, x + UW(8), y + UH(12));
  tft.setTextColor(theme.good, theme.panel2);
  tft.drawString(value, x + UW(42), y + UH(12));
}

bool bootSplashReady = false;
int bootLastProgress = -1;
int bootLastScanX = -1;
String bootLastStatus = "";

void drawBootSplashFrame() {
  int cx = SCREEN_W / 2;

  tft.fillScreen(theme.bg);
  tft.drawRoundRect(UX(12), UY(16), SCREEN_W - UW(24), SCREEN_H - UH(32), UR(12), theme.border);
  tft.drawRoundRect(UX(16), UY(20), SCREEN_W - UW(32), SCREEN_H - UH(40), UR(10), theme.panel2);

  tft.fillCircle(cx, UY(72), UR(8), theme.accent);
  for (int r = UR(18); r <= UR(54); r += UR(12)) {
    tft.drawCircle(cx, UY(72), r, theme.border);
  }

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.text, theme.bg);
  tft.setTextFont(4);
  tft.drawString("MMDVM", cx, UY(106));

  tft.setTextFont(2);
  tft.setTextColor(theme.subtext, theme.bg);
  tft.drawString("BOOT SEQUENCE", cx, UY(130));

  const int barX = UX(34);
  const int barY = UY(202);
  const int barW = UW(252);
  const int barH = UH(14);
  tft.drawRoundRect(barX, barY, barW, barH, UR(7), theme.border);

  bootSplashReady = true;
  bootLastProgress = -1;
  bootLastScanX = -1;
  bootLastStatus = "";
}

void drawBootSplash(const String& status, int progress, int frame = 0) {
  progress = constrain(progress, 0, 100);

  if (!bootSplashReady) {
    drawBootSplashFrame();
  }

  const int cx = SCREEN_W / 2;
  const int barX = UX(34);
  const int barY = UY(202);
  const int barW = UW(252);
  const int barH = UH(14);
  const int innerX = barX + max(1, UW(2));
  const int innerY = barY + max(1, UH(2));
  const int innerW = max(1, barW - max(2, UW(4)));
  const int innerH = max(1, barH - max(2, UH(4)));

  int pulse = frame % 16;
  for (int r = UR(18); r <= UR(54); r += UR(12)) {
    uint16_t c = ((pulse / 2) % 3 == ((r / max(1, UR(12))) % 3)) ? theme.warn : theme.border;
    tft.drawCircle(cx, UY(72), r, c);
  }

  if (status != bootLastStatus) {
    tft.fillRect(UX(34), UY(182), UW(220), UH(16), theme.bg);
    tft.setTextDatum(TL_DATUM);
    tft.setTextColor(theme.subtext, theme.bg);
    tft.setTextFont(2);
    tft.drawString(status, UX(34), UY(182));
    bootLastStatus = status;
  }

  if (progress != bootLastProgress) {
    int oldFillW = bootLastProgress < 0 ? 0 : map(bootLastProgress, 0, 100, 0, innerW);
    int newFillW = map(progress, 0, 100, 0, innerW);

    if (newFillW > oldFillW) {
      tft.fillRoundRect(innerX, innerY, newFillW, innerH, UR(5), theme.accent);
    } else if (newFillW < oldFillW) {
      tft.fillRect(innerX + newFillW, innerY, oldFillW - newFillW, innerH, theme.bg);
    }

    tft.fillRect(UX(258), UY(182), UW(28), UH(16), theme.bg);
    tft.setTextDatum(TR_DATUM);
    tft.setTextColor(theme.text, theme.bg);
    tft.setTextFont(2);
    tft.drawString(String(progress) + "%", UX(286), UY(182));

    bootLastProgress = progress;
  }

  int scanMinX = innerX;
  int progressFillW = (int)map(progress, 0, 100, 0, innerW);
  int scanWidth = progressFillW > 1 ? progressFillW : 1;
  int scanMaxX = innerX + scanWidth - max(1, UW(2));
  int scanX = scanMinX;
  if (scanMaxX > scanMinX) {
    int scanRange = scanMaxX - scanMinX + 1;
    if (scanRange < 1) scanRange = 1;
    scanX = scanMinX + ((frame * 7) % scanRange);
  }

  if (bootLastScanX >= scanMinX && bootLastScanX <= innerX + innerW - max(1, UW(2))) {
    uint16_t restore = (bootLastScanX - innerX) < progressFillW ? theme.accent : theme.bg;
    tft.drawFastVLine(bootLastScanX, innerY, innerH, restore);
    tft.drawFastVLine(bootLastScanX + 1, innerY, innerH, restore);
  }

  tft.drawFastVLine(scanX, innerY, innerH, theme.warn);
  tft.drawFastVLine(scanX + 1, innerY, innerH, theme.warn);
  bootLastScanX = scanX;
}

void animateBootProgress(const String& status, int fromProgress, int toProgress, int frameDelay = 20) {
  if (toProgress < fromProgress) toProgress = fromProgress;
  int frame = 0;
  if (!bootSplashReady) drawBootSplashFrame();
  for (int p = fromProgress; p <= toProgress; p += 2) {
    drawBootSplash(status, p, frame++);
    delay(frameDelay);
  }
  drawBootSplash(status, toProgress, frame);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.flush();
#endif
}
uint16_t rgb888To565(uint8_t r, uint8_t g, uint8_t b) {
  return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}

String color565ToHex(uint16_t c) {
  uint8_t r = ((c >> 11) & 0x1F) * 255 / 31;
  uint8_t g = ((c >> 5) & 0x3F) * 255 / 63;
  uint8_t b = (c & 0x1F) * 255 / 31;

  char buf[8];
  snprintf(buf, sizeof(buf), "#%02X%02X%02X", r, g, b);
  return String(buf);
}

bool parseHexColor(String s, uint16_t &out565) {
  s.trim();

  if (s.startsWith("#")) s.remove(0, 1);
  else if (s.startsWith("0x") || s.startsWith("0X")) s.remove(0, 2);

  if (s.length() != 6) return false;

  char buf[7];
  s.toCharArray(buf, sizeof(buf));
  char* endptr = nullptr;
  long value = strtol(buf, &endptr, 16);

  if (*endptr != '\0') return false;
  if (value < 0 || value > 0xFFFFFF) return false;

  uint8_t r = (value >> 16) & 0xFF;
  uint8_t g = (value >> 8) & 0xFF;
  uint8_t b = value & 0xFF;
  out565 = rgb888To565(r, g, b);
  return true;
}

void loadThemeColor(JsonObject themeObj, const char* key, uint16_t &target) {
  if (!themeObj.containsKey(key)) return;
  String s = String(themeObj[key] | "");
  uint16_t c;
  if (parseHexColor(s, c)) target = c;
}

void loadThemeFromJson(JsonObject themeObj) {
  loadThemeColor(themeObj, "bg", theme.bg);
  loadThemeColor(themeObj, "panel", theme.panel);
  loadThemeColor(themeObj, "panel2", theme.panel2);
  loadThemeColor(themeObj, "border", theme.border);
  loadThemeColor(themeObj, "text", theme.text);
  loadThemeColor(themeObj, "subtext", theme.subtext);
  loadThemeColor(themeObj, "accent", theme.accent);
  loadThemeColor(themeObj, "good", theme.good);
  loadThemeColor(themeObj, "warn", theme.warn);
  loadThemeColor(themeObj, "bad", theme.bad);
  loadThemeColor(themeObj, "live", theme.live);
  loadThemeColor(themeObj, "rx", theme.rx);
  loadThemeColor(themeObj, "tx", theme.tx);
  loadThemeColor(themeObj, "tabIdle", theme.tabIdle);
  loadThemeColor(themeObj, "tabActive", theme.tabActive);
}

void fillPanel(int x, int y, int w, int h, uint16_t fill, uint16_t border) {
  int rr = max(1, UR(8));
  tft.fillRoundRect(x, y, w, h, rr, fill);
  tft.drawRoundRect(x, y, w, h, rr, border);
}

void drawLabelValue(int x, int y, const String& label, const String& value,
                    uint16_t labelColor, uint16_t valueColor, uint16_t bg) {
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(labelColor, bg);
  tft.drawString(label, x, y);
  tft.setTextColor(valueColor, bg);
  tft.drawString(value, x + UW(78), y);
}

String normalizeHamCallsign(const String& input) {
  String s = input;
  s.trim();
  s.toUpperCase();
  if (!s.length()) return s;

  int slash = s.indexOf('/');
  if (slash >= 0) {
    String left = s.substring(0, slash);
    String right = s.substring(slash + 1);
    bool leftHasDigit = false;
    bool rightHasDigit = false;
    for (size_t i = 0; i < left.length(); ++i) if (isDigit(left[i])) { leftHasDigit = true; break; }
    for (size_t i = 0; i < right.length(); ++i) if (isDigit(right[i])) { rightHasDigit = true; break; }
    if (leftHasDigit && !rightHasDigit) s = left;
    else if (!leftHasDigit && rightHasDigit) s = right;
    else s = left;
  }

  s.replace(" ", "");
  return s;
}

bool ensureLookupDirectory() {
  if (storageExists("/lookup")) return true;
  return SD.mkdir("/lookup");
}

String urlEncodeComponent(const String& value) {
  static const char hex[] = "0123456789ABCDEF";
  String encoded;
  encoded.reserve(value.length() * 3);

  for (size_t i = 0; i < value.length(); ++i) {
    uint8_t c = (uint8_t)value[i];
    if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
      encoded += (char)c;
    } else {
      encoded += '%';
      encoded += hex[(c >> 4) & 0x0F];
      encoded += hex[c & 0x0F];
    }
  }

  return encoded;
}

String buildRadioIdUsersUrl(const String& callsign) {
  String normalized = normalizeHamCallsign(callsign);
  if (!normalized.length()) return "";

  String encodedCallsign = urlEncodeComponent(normalized);
  String base = trimSafe(cfg.radioIdUsersApiBase);
  if (!base.length()) base = String(RADIOID_USERS_API_BASE_DEFAULT);

  if (base.indexOf("%CALLSIGN%") >= 0) {
    base.replace("%CALLSIGN%", encodedCallsign);
    return base;
  }

  return base + encodedCallsign;
}

void clearRadioIdLookupCache() {
  for (int i = 0; i < RADIOID_LOOKUP_CACHE_SIZE; ++i) {
    radioIdLookupCache[i] = RadioIdLookupCacheEntry();
  }
  radioIdLookupCacheWriteIndex = 0;
}

int radioIdLookupCacheIndexForCallsign(const String& normalizedCallsign) {
  if (!normalizedCallsign.length()) return -1;
  for (int i = 0; i < RADIOID_LOOKUP_CACHE_SIZE; ++i) {
    if (radioIdLookupCache[i].valid && radioIdLookupCache[i].callsign == normalizedCallsign) return i;
  }
  return -1;
}

struct RadioIdLookupCacheEntry* radioIdLookupCacheSlotForCallsign(const String& normalizedCallsign, bool createIfMissing) {
  int existing = radioIdLookupCacheIndexForCallsign(normalizedCallsign);
  if (existing >= 0) return &radioIdLookupCache[existing];
  if (!createIfMissing) return nullptr;

  RadioIdLookupCacheEntry& slot = radioIdLookupCache[radioIdLookupCacheWriteIndex];
  slot = RadioIdLookupCacheEntry();
  slot.callsign = normalizedCallsign;
  slot.valid = true;
  radioIdLookupCacheWriteIndex = (radioIdLookupCacheWriteIndex + 1) % RADIOID_LOOKUP_CACHE_SIZE;
  return &slot;
}

bool radioIdLookupNeeded(const String& callsign, const String& name) {
  if (!trimSafe(callsign).length()) return false;

  String currentName = trimSafe(name);
  if (!currentName.length()) return true;

  String normalizedCurrent = normalizeHamCallsign(currentName);
  String normalizedCallsign = normalizeHamCallsign(callsign);
  if (normalizedCurrent == normalizedCallsign) return true;

  bool digitsOnly = currentName.length() > 0;
  for (size_t i = 0; i < currentName.length(); ++i) {
    if (!isDigit(currentName[i])) {
      digitsOnly = false;
      break;
    }
  }

  return digitsOnly;
}

String radioIdLookupSignatureForRecord(const HeardRecord& r) {
  String mode = trimSafe(r.mode);
  mode.toUpperCase();

  String callsign = normalizeHamCallsign(r.callsign);
  String target = trimSafe(r.target);
  target.toUpperCase();

  if (!callsign.length()) return "";
  return mode + "|" + callsign + "|" + target;
}

void queueRadioIdLookupForRecord(const HeardRecord& r) {
  if (!cfg.radioIdLookupEnabled) return;

  String normalized = normalizeHamCallsign(r.callsign);
  if (!normalized.length()) return;

  if (!radioIdLookupNeeded(r.callsign, r.name) && trimSafe(r.country).length()) return;

  String signature = radioIdLookupSignatureForRecord(r);
  if (!signature.length() || signature == lastRadioIdLookupSignature) return;

  lastRadioIdLookupSignature = signature;
  pendingRadioIdLookupCallsign = normalized;
}

bool parseRadioIdUserResponse(const String& payload, struct RadioIdLookupCacheEntry& entry) {
  DynamicJsonDocument doc(4096);
  DeserializationError err = deserializeJson(doc, payload);
  if (err) return false;
  if (!doc.is<JsonObject>()) return false;

  JsonObject root = doc.as<JsonObject>();
  entry.found = false;
  entry.name = "";
  entry.country = "";
  entry.city = "";
  entry.state = "";

  JsonArray results = root["results"].as<JsonArray>();
  if (results.isNull() || results.size() == 0) return true;

  JsonObject obj = results[0];
  String apiName = firstNonEmpty(
    String(obj["name"] | ""),
    String(obj["display_name"] | ""),
    String(obj["full_name"] | ""),
    String(obj["fullname"] | "")
  );
  if (!apiName.length()) {
    apiName = buildOperatorName(
      firstNonEmpty(String(obj["fname"] | ""), String(obj["first_name"] | ""), String(obj["firstname"] | ""), String(obj["forename"] | "")),
      firstNonEmpty(String(obj["lname"] | ""), String(obj["last_name"] | ""), String(obj["lastname"] | ""), String(obj["surname"] | ""))
    );
  }

  entry.name = trimSafe(apiName);
  entry.country = trimSafe(String(obj["country"] | obj["country_name"] | ""));
  entry.city = trimSafe(String(obj["city"] | ""));
  entry.state = trimSafe(String(obj["state"] | obj["region"] | ""));
  entry.found = entry.name.length() || entry.country.length() || entry.city.length() || entry.state.length();
  return true;
}

bool fetchRadioIdProfile(const String& normalizedCallsign, struct RadioIdLookupCacheEntry& outEntry) {
  String url = buildRadioIdUsersUrl(normalizedCallsign);
  if (!url.length()) return false;

  WiFiClientSecure client;
  client.setInsecure();

  HTTPClient http;
  http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
  http.setTimeout(HTTP_READ_TIMEOUT_MS);
  if (!http.begin(client, url)) return false;

  String userAgent = String("MMDVM-Display/") + DISPLAY_VERSION + " (operator=" + getDeviceCallsign() + ")";
  http.setUserAgent(userAgent);
  http.addHeader("Accept", "application/json");

  int code = http.GET();
  if (code != 200) {
    http.end();
    return false;
  }

  String payload = http.getString();
  http.end();

  outEntry = RadioIdLookupCacheEntry();
  outEntry.callsign = normalizedCallsign;
  outEntry.valid = true;
  outEntry.lastFetchMillis = millis();
  return parseRadioIdUserResponse(payload, outEntry);
}

void applyRadioIdCacheToRecord(struct HeardRecord& r, bool allowFetch) {
  if (!cfg.radioIdLookupEnabled) return;

  String normalized = normalizeHamCallsign(r.callsign);
  if (!normalized.length()) return;

  struct RadioIdLookupCacheEntry* entry = radioIdLookupCacheSlotForCallsign(normalized, false);
  if (!entry || !entry->valid || !entry->lastFetchMillis) {
    if (allowFetch && radioIdLookupNeeded(r.callsign, r.name)) { }
    return;
  }

  if (radioIdLookupNeeded(r.callsign, r.name) && entry->name.length()) r.name = entry->name;
  if (!trimSafe(r.country).length() && entry->country.length()) r.country = entry->country;
}

void applyRadioIdCacheToList(struct HeardRecord* list, int count) {
  if (count <= 0) return;
  for (int i = 0; i < count; ++i) applyRadioIdCacheToRecord(list[i], false);
}

bool applyRadioIdCacheToActiveData() {
  bool changed = false;

  for (int i = 0; i < heardCount; ++i) {
    HeardRecord before = heardList[i];
    applyRadioIdCacheToRecord(heardList[i], false);
    if (!sameRecord(before, heardList[i])) changed = true;
  }

  HeardRecord beforeLive = liveRecord;
  applyRadioIdCacheToRecord(liveRecord, false);
  if (!sameRecord(beforeLive, liveRecord)) changed = true;

  return changed;
}

String radioIdDisplayNameForRecord(const struct HeardRecord& r, bool allowFetch) {
  String displayName = trimSafe(r.name);
  if (!radioIdLookupNeeded(r.callsign, displayName)) return displayName;

  String normalized = normalizeHamCallsign(r.callsign);
  if (!normalized.length()) return displayName;

  struct RadioIdLookupCacheEntry* entry = radioIdLookupCacheSlotForCallsign(normalized, false);
  if (entry && entry->valid && entry->name.length()) return trimSafe(entry->name);
  if (allowFetch) { }

  return displayName;
}

String radioIdDisplayCountryForRecord(const struct HeardRecord& r, bool allowFetch) {
  String currentCountry = trimSafe(r.country);
  if (currentCountry.length()) return currentCountry;

  String normalized = normalizeHamCallsign(r.callsign);
  if (!normalized.length()) return currentCountry;

  struct RadioIdLookupCacheEntry* entry = radioIdLookupCacheSlotForCallsign(normalized, false);
  if (entry && entry->valid && entry->country.length()) return trimSafe(entry->country);
  if (allowFetch) { }

  return currentCountry;
}

String nextRadioIdLookupCandidate() {
  if (!cfg.radioIdLookupEnabled || WiFi.status() != WL_CONNECTED) return "";

  auto pick = [&](const HeardRecord& rec) -> String {
    String normalized = normalizeHamCallsign(rec.callsign);
    if (!normalized.length()) return "";

    struct RadioIdLookupCacheEntry* entry = radioIdLookupCacheSlotForCallsign(normalized, false);
    unsigned long now = millis();
    if (entry && entry->valid && entry->lastFetchMillis) {
      unsigned long age = now - entry->lastFetchMillis;
      if (entry->found && age < RADIOID_LOOKUP_TTL_MS) return "";
      if (!entry->found && age < RADIOID_LOOKUP_RETRY_MS) return "";
    }

    if (!radioIdLookupNeeded(rec.callsign, rec.name) && trimSafe(rec.country).length()) return "";
    return normalized;
  };

  if (detailPopupOpen && selectedHeardIndex >= 0 && selectedHeardIndex < heardCount) {
    String candidate = pick(heardList[selectedHeardIndex]);
    if (candidate.length()) return candidate;
  }

  if (isLiveActive()) {
    String candidate = pick(liveRecord);
    if (candidate.length()) return candidate;
  }

  int rows = min(heardCount, 5);
  for (int i = 0; i < rows; ++i) {
    String candidate = pick(heardList[i]);
    if (candidate.length()) return candidate;
  }

  return "";
}

void processRadioIdLookups() {
  if (!cfg.radioIdLookupEnabled || WiFi.status() != WL_CONNECTED) return;
  if (!pendingRadioIdLookupCallsign.length()) return;

  unsigned long now = millis();
  if (now - lastRadioIdLookupMillis < RADIOID_LOOKUP_MIN_INTERVAL_MS) return;

  String normalized = pendingRadioIdLookupCallsign;
  pendingRadioIdLookupCallsign = "";

  struct RadioIdLookupCacheEntry* cached = radioIdLookupCacheSlotForCallsign(normalized, false);
  if (cached && cached->valid && cached->lastFetchMillis) {
    unsigned long age = now - cached->lastFetchMillis;
    if ((cached->found && age < RADIOID_LOOKUP_TTL_MS) || (!cached->found && age < RADIOID_LOOKUP_RETRY_MS)) {
      if (applyRadioIdCacheToActiveData()) requestRedraw();
      return;
    }
  }

  lastRadioIdLookupMillis = now;

  struct RadioIdLookupCacheEntry fetched;
  if (!fetchRadioIdProfile(normalized, fetched)) {
    return;
  }

  struct RadioIdLookupCacheEntry* slot = radioIdLookupCacheSlotForCallsign(normalized, true);
  if (!slot) return;
  *slot = fetched;

  if (applyRadioIdCacheToActiveData()) requestRedraw();
}

String flagBmpPathFromCode(const String& code) {
  String cc = code;
  cc.toLowerCase();
  if (cc.length()) {
    String p = "/flags/" + cc + ".bmp";
    if (storageExists(p.c_str())) return p;
    p = "/" + cc + ".bmp";
    if (storageExists(p.c_str())) return p;
  }
  if (storageExists("/flags/de.bmp")) return "/flags/de.bmp";
  return "/de.bmp";
}

bool drawFlagForCallsign(int x, int y, int w, int h, const String& callsign) {
  String path = flagBmpPathFromCode(countryCodeFromCallsign(callsign));

  const int bmpW = 32;
  const int bmpH = 32;

  if (w < bmpW || h < bmpH) return false;

  int drawX = x + (w - bmpW) / 2;
  int drawY = y + (h - bmpH) / 2;

  return drawBmp24FromSD(path.c_str(), drawX, drawY, bmpW, bmpH);
}

String getHeaderTimeText();

void drawTopTabs() {
  const int tabY = UY(6);
  const int tabH = UH(24);
  const int tabGap = UW(4);
  const int tabW = UW(54);
  const int startX = UX(6);
  const bool showApTab = setupRequired && apModeActive;

  const Page pages[4] = {PAGE_LIVE, PAGE_LAST, PAGE_STATUS, showApTab ? PAGE_AP : PAGE_STAT};
  const char* labels[4] = {"LIVE", "HEARD", "STATUS", showApTab ? "AP" : "STAT"};

  for (int i = 0; i < 4; i++) {
    int x = startX + i * (tabW + tabGap);
    uint16_t fill = (currentPage == pages[i]) ? theme.tabActive : theme.tabIdle;
    if (pages[i] == PAGE_AP && apModeActive) fill = theme.tabActive;
    fillSoftPanel(x, tabY, tabW, tabH, fill, theme.border, 8);

    tft.setTextDatum(MC_DATUM);
    tft.setTextFont(2);
    tft.setTextColor(TFT_WHITE, fill);
    tft.drawString(labels[i], x + tabW / 2, tabY + tabH / 2);
  }

  const int clockX = UX(246);
  const int clockY = UY(6);
  const int clockW = UW(68);
  const int clockH = UH(24);

  fillSoftPanel(clockX, clockY, clockW, clockH, theme.panel2, theme.border, 8);

  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(TFT_WHITE, theme.panel2);
  tft.drawString(getHeaderTimeText(), clockX + clockW / 2, clockY + clockH / 2);
}

void drawFooter() {
  tft.fillRect(0, UY(222), SCREEN_W, UH(18), theme.panel2);
  tft.drawFastHLine(0, UY(221), SCREEN_W, theme.border);
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel2);
  tft.drawString(footerText, UX(4), UY(227));
}

void clearBody() {
  tft.fillRect(0, UY(34), SCREEN_W, UY(188), theme.bg);
}

String countryCodeFromCallsign(const String& cs) {
  String c = normalizeHamCallsign(cs);
  c.toUpperCase();
  if (!c.length()) return "";

  auto hasPrefix = [&](const char* p) -> bool {
    return c.startsWith(p);
  };

  auto hasAnyPrefix = [&](std::initializer_list<const char*> prefixes) -> bool {
    for (const char* p : prefixes) {
      if (c.startsWith(p)) return true;
    }
    return false;
  };

  if (hasPrefix("4U")) return "un";
  if (hasPrefix("4Y")) return "icao";
  if (hasPrefix("C7")) return "wmo";
  if (hasPrefix("3Y") || hasPrefix("JW") || hasPrefix("JX") || hasAnyPrefix({"LA","LB","LC","LD","LE","LF","LG","LH","LI","LJ","LK","LL","LM","LN"})) return "no";
  if (hasPrefix("VR")) return "hk";
  if (hasPrefix("XX")) return "mo";
  if (hasPrefix("E4")) return "ps";

  if (hasAnyPrefix({"2","G","M","VP","VQ","VS","ZB","ZC","ZD","ZE","ZF","ZG","ZH","ZI","ZJ","ZN","ZO","ZQ"})) return "uk";
  if (hasAnyPrefix({"DA","DB","DC","DD","DE","DF","DG","DH","DI","DJ","DK","DL","DM","DN","DO","DP","DQ","DR",
                    "Y2","Y3","Y4","Y5","Y6","Y7","Y8","Y9"})) return "de";
  if (hasAnyPrefix({"F","HW","HX","HY","TH","TK","TM","TO","TP","TQ","TV","TW","TX"})) return "fr";
  if (hasAnyPrefix({"EA","EB","EC","ED","EE","EF","EG","EH","AM","AN","AO"})) return "es";
  if (hasAnyPrefix({"I"})) return "it";
  if (hasAnyPrefix({"PA","PB","PC","PD","PE","PF","PG","PH","PI"})) return "nl";
  if (hasAnyPrefix({"ON","OO","OP","OQ","OR","OS","OT"})) return "be";
  if (hasAnyPrefix({"OU","OV","OW","OX","OY","OZ","5P","5Q","XP"})) return "dk";
  if (hasAnyPrefix({"OH","OF","OG","OI","OJ"})) return "fi";
  if (hasAnyPrefix({"OE"})) return "at";
  if (hasAnyPrefix({"OK","OL"})) return "cz";
  if (hasAnyPrefix({"OM"})) return "sk";
  if (hasAnyPrefix({"HB","HE"})) return "ch";
  if (hasAnyPrefix({"LX"})) return "lu";
  if (hasAnyPrefix({"LY"})) return "lt";
  if (hasAnyPrefix({"LZ"})) return "bg";
  if (hasAnyPrefix({"ER"})) return "md";
  if (hasAnyPrefix({"ES"})) return "ee";
  if (hasAnyPrefix({"EU","EV","EW"})) return "by";
  if (hasAnyPrefix({"EK"})) return "am";
  if (hasAnyPrefix({"EX"})) return "kg";
  if (hasAnyPrefix({"EY"})) return "tj";
  if (hasAnyPrefix({"EZ"})) return "tm";
  if (hasAnyPrefix({"S5"})) return "si";
  if (hasAnyPrefix({"9A"})) return "hr";
  if (hasAnyPrefix({"HA","HG"})) return "hu";
  if (hasAnyPrefix({"HF","3Z","SN","SO","SP","SQ","SR"})) return "pl";
  if (hasAnyPrefix({"YO","YP","YQ","YR"})) return "ro";
  if (hasAnyPrefix({"YT","YU"})) return "rs";
  if (hasAnyPrefix({"Z3"})) return "mk";
  if (hasAnyPrefix({"E7"})) return "ba";
  if (hasAnyPrefix({"4O"})) return "me";
  if (hasAnyPrefix({"SV","SW","SX","SY","SZ","J4"})) return "gr";
  if (hasAnyPrefix({"TA","TB","TC","YM"})) return "tr";
  if (hasAnyPrefix({"T7"})) return "sm";
  if (hasAnyPrefix({"CT","CQ","CR","CS","CU"})) return "pt";
  if (hasAnyPrefix({"EI","EJ"})) return "ie";
  if (hasAnyPrefix({"UA","UB","UC","UD","UE","UF","UG","UH","UI","RA","RB","RC","RD","RE","RF","RG","RH","RI","RJ","RK","RL","RM","RN","RO","RP","RQ","RR","RS","RT","RU","RV","RW","RX","RY","RZ"})) return "ru";
  if (hasAnyPrefix({"UR","US","UT","UU","UV","UW","UX","UY","UZ","EM","EN","EO"})) return "ua";
  if (hasAnyPrefix({"UJ","UK","UL","UM"})) return "uz";
  if (hasAnyPrefix({"UN","UO","UP","UQ"})) return "kz";

  if (hasAnyPrefix({"K","N","W","AA","AB","AC","AD","AE","AF","AG","AH","AI","AJ","AK","KA","KB","KC","KD","KE","KF","KG","KH","KI","KJ","KK","KL","KM","KN","KO","KP","KQ","KR","KS","KT","KU","KV","KW","KX","KY","KZ",
                    "NA","NB","NC","ND","NE","NF","NG","NH","NI","NJ","NK","NL","NM","NN","NO","NP","NQ","NR","NS","NT","NU","NV","NW","NX","NY","NZ",
                    "WA","WB","WC","WD","WE","WF","WG","WH","WI","WJ","WK","WL","WM","WN","WO","WP","WQ","WR","WS","WT","WU","WV","WW","WX","WY","WZ"})) return "us";
  if (hasAnyPrefix({"CF","CG","CH","CI","CJ","CK","CY","CZ","VA","VB","VC","VD","VE","VF","VG","VO","VX","VY","XJ","XK","XL","XM","XN","XO"})) return "ca";
  if (hasAnyPrefix({"XA","XB","XC","XD","XE","XF","XG","XH","XI","4A","4B","4C","6D","6E","6F","6G","6H","6I","6J"})) return "mx";
  if (hasAnyPrefix({"V2"})) return "ag";
  if (hasAnyPrefix({"V3"})) return "bz";
  if (hasAnyPrefix({"V4"})) return "kn";
  if (hasAnyPrefix({"J3"})) return "gd";
  if (hasAnyPrefix({"J6"})) return "lc";
  if (hasAnyPrefix({"J7"})) return "dm";
  if (hasAnyPrefix({"J8"})) return "vc";
  if (hasAnyPrefix({"8P"})) return "bb";
  if (hasAnyPrefix({"8R"})) return "gy";
  if (hasAnyPrefix({"9Y","9Z"})) return "tt";
  if (hasAnyPrefix({"C6"})) return "bs";
  if (hasAnyPrefix({"CO","CL","CM","T4"})) return "cu";
  if (hasAnyPrefix({"HP","HO","H3","H8","H9","3E","3F"})) return "pa";
  if (hasAnyPrefix({"P4"})) return "aw";
  if (hasAnyPrefix({"PJ"})) return "cw";
  if (hasAnyPrefix({"PZ"})) return "sr";
  if (hasAnyPrefix({"TI","TE"})) return "cr";
  if (hasAnyPrefix({"TG","TD"})) return "gt";
  if (hasAnyPrefix({"YN","H6","H7","HT"})) return "ni";
  if (hasAnyPrefix({"HR","HQ"})) return "hn";
  if (hasAnyPrefix({"HU","YS"})) return "sv";
  if (hasAnyPrefix({"HI"})) return "do";
  if (hasAnyPrefix({"HH","4V"})) return "ht";

  if (hasAnyPrefix({"AY","AZ","L2","L3","L4","L5","L6","L7","L8","L9","LO","LP","LQ","LR","LS","LT","LU","LV","LW"})) return "ar";
  if (hasAnyPrefix({"CP"})) return "bo";
  if (hasAnyPrefix({"CX","CV","CW"})) return "uy";
  if (hasAnyPrefix({"HC","HD"})) return "ec";
  if (hasAnyPrefix({"HK","HJ","5J","5K"})) return "co";
  if (hasAnyPrefix({"OA","OB","OC","4T"})) return "pe";
  if (hasAnyPrefix({"YV","YW","YX","YY","4M"})) return "ve";
  if (hasAnyPrefix({"PP","PQ","PR","PS","PT","PU","PV","PW","PX","PY","ZV","ZW","ZX","ZY","ZZ"})) return "br";
  if (hasAnyPrefix({"CA","CB","CC","CD","CE","3G","XQ","XR"})) return "cl";
  if (hasAnyPrefix({"ZP"})) return "py";

  if (hasAnyPrefix({"A2","8O"})) return "bw";
  if (hasAnyPrefix({"3B"})) return "mu";
  if (hasAnyPrefix({"3C"})) return "gq";
  if (hasPrefix("3D")) return c.startsWith("3DN") ? "fj" : "sz";
  if (hasAnyPrefix({"3X"})) return "gn";
  if (hasAnyPrefix({"5A"})) return "ly";
  if (hasAnyPrefix({"5H","5I"})) return "tz";
  if (hasAnyPrefix({"5L","5M","6Z","A8","D5","EL"})) return "lr";
  if (hasAnyPrefix({"5N","5O"})) return "ng";
  if (hasAnyPrefix({"5R","5S","6X"})) return "mg";
  if (hasAnyPrefix({"5T"})) return "mr";
  if (hasAnyPrefix({"5U"})) return "ne";
  if (hasAnyPrefix({"5V"})) return "tg";
  if (hasAnyPrefix({"5X"})) return "ug";
  if (hasAnyPrefix({"5Y","5Z"})) return "ke";
  if (hasAnyPrefix({"6V","6W"})) return "sn";
  if (hasAnyPrefix({"7P"})) return "ls";
  if (hasAnyPrefix({"7Q"})) return "mw";
  if (hasAnyPrefix({"7R","7T","7U","7V","7W","7X","7Y"})) return "dz";
  if (hasAnyPrefix({"9E","9F","ET"})) return "et";
  if (hasAnyPrefix({"9G"})) return "gh";
  if (hasAnyPrefix({"9I","9J"})) return "zm";
  if (hasAnyPrefix({"9L"})) return "sl";
  if (hasAnyPrefix({"9O","9P","9Q","9R","9S","9T"})) return "cd";
  if (hasAnyPrefix({"9U"})) return "bi";
  if (hasAnyPrefix({"9X"})) return "rw";
  if (hasAnyPrefix({"C5"})) return "gm";
  if (hasAnyPrefix({"C8","C9"})) return "mz";
  if (hasAnyPrefix({"CN","5C","5D","5E","5F","5G"})) return "ma";
  if (hasAnyPrefix({"D2","D3"})) return "ao";
  if (hasAnyPrefix({"D4"})) return "cv";
  if (hasAnyPrefix({"D6"})) return "km";
  if (hasAnyPrefix({"J2"})) return "dj";
  if (hasAnyPrefix({"S7"})) return "sc";
  if (hasAnyPrefix({"S8","ZR","ZS","ZT","ZU"})) return "za";
  if (hasAnyPrefix({"S9"})) return "st";
  if (hasAnyPrefix({"SU","SSA","SSB","SSC","SSD","SSE","SSF","SSG","SSH","SSI","SSJ","SSK","SSL","SSM"})) return "eg";
  if (hasAnyPrefix({"SSN","SSO","SSP","SSQ","SSR","SSS","SST","SSU","SSV","SSW","SSX","SSY","SSZ","ST","6T","6U"})) return "sd";
  if (hasAnyPrefix({"TJ"})) return "cm";
  if (hasAnyPrefix({"TL"})) return "cf";
  if (hasAnyPrefix({"TN"})) return "cg";
  if (hasAnyPrefix({"TR"})) return "ga";
  if (hasAnyPrefix({"TS","3V"})) return "tn";
  if (hasAnyPrefix({"TT"})) return "td";
  if (hasAnyPrefix({"TU"})) return "ci";
  if (hasAnyPrefix({"TY"})) return "bj";
  if (hasAnyPrefix({"TZ"})) return "ml";
  if (hasAnyPrefix({"XT"})) return "bf";
  if (hasAnyPrefix({"Z2"})) return "zw";
  if (hasAnyPrefix({"Z8"})) return "ss";

  if (hasAnyPrefix({"4J","4K"})) return "az";
  if (hasAnyPrefix({"4L"})) return "ge";
  if (hasAnyPrefix({"4X","4Z"})) return "il";
  if (hasAnyPrefix({"5B","C4","H2","P3"})) return "cy";
  if (hasAnyPrefix({"7O"})) return "ye";
  if (hasAnyPrefix({"7Z","8Z","HZ"})) return "sa";
  if (hasAnyPrefix({"9K"})) return "kw";
  if (hasAnyPrefix({"9N"})) return "np";
  if (hasAnyPrefix({"A4"})) return "om";
  if (hasAnyPrefix({"A5"})) return "bt";
  if (hasAnyPrefix({"A6"})) return "ae";
  if (hasAnyPrefix({"A7"})) return "qa";
  if (hasAnyPrefix({"A9"})) return "bh";
  if (hasAnyPrefix({"AP","AQ","AR","AS","6P","6Q","6R","6S"})) return "pk";
  if (hasAnyPrefix({"EP","EQ","9B","9C","9D"})) return "ir";
  if (hasAnyPrefix({"HN","YI"})) return "iq";
  if (hasAnyPrefix({"JY"})) return "jo";
  if (hasAnyPrefix({"OD"})) return "lb";
  if (hasAnyPrefix({"YK","6C"})) return "sy";
  if (hasAnyPrefix({"E4"})) return "ps";
  if (hasAnyPrefix({"T6","YA"})) return "af";

  if (hasAnyPrefix({"8Q"})) return "mv";
  if (hasAnyPrefix({"8T","8U","8V","8W","8X","8Y","AT","AU","AV","AW","VT","VU","VV","VW"})) return "in";
  if (hasAnyPrefix({"9M","9W"})) return "my";
  if (hasAnyPrefix({"9V","S6"})) return "sg";
  if (hasAnyPrefix({"B","3H","3I","3J","3K","3L","3M","3N","3O","3P","3Q","3R","3S","3T","3U","BA","BB","BC","BD","BE","BF","BG","BH","BI","BJ","BK","BL","BM","BN","BO","BP","BQ","BR","BS","BT","BU","BV","BW","BX","BY","BZ","XS"})) return "cn";
  if (hasAnyPrefix({"BV"})) return "tw";
  if (hasAnyPrefix({"DS","DT","D7","D8","D9","6K","6L","6M","6N","HL"})) return "kr";
  if (hasAnyPrefix({"HM","P5","P6","P7","P8","P9"})) return "kp";
  if (hasAnyPrefix({"E2","HS"})) return "th";
  if (hasAnyPrefix({"JA","JB","JC","JD","JE","JF","JG","JH","JI","JJ","JK","JL","JM","JN","JO","JP","JQ","JR","JS","7J","7K","7L","7M","7N","8J","8K","8L","8M","8N"})) return "jp";
  if (hasAnyPrefix({"JT","JU","JV"})) return "mn";
  if (hasAnyPrefix({"PK","PL","PM","PN","PO","7A","7B","7C","7D","7E","7F","7G","7H","7I","8A","8B","8C","8D","8E","8F","8G","8H","8I","JZ","YB","YC","YD","YE","YF","YG","YH"})) return "id";
  if (hasAnyPrefix({"V8"})) return "bn";
  if (hasAnyPrefix({"XU"})) return "kh";
  if (hasAnyPrefix({"XV","3W"})) return "vn";
  if (hasAnyPrefix({"XW"})) return "la";
  if (hasAnyPrefix({"XY","XZ"})) return "mm";
  if (hasAnyPrefix({"P2"})) return "pg";
  if (hasAnyPrefix({"A3"})) return "to";
  if (hasAnyPrefix({"C2"})) return "nr";
  if (hasAnyPrefix({"E5"})) return "ck";
  if (hasAnyPrefix({"E6"})) return "nu";
  if (hasAnyPrefix({"H4"})) return "sb";
  if (hasAnyPrefix({"T2"})) return "tv";
  if (hasAnyPrefix({"T3"})) return "ki";
  if (hasAnyPrefix({"T8"})) return "pw";
  if (hasAnyPrefix({"V6"})) return "fm";
  if (hasAnyPrefix({"V7"})) return "mh";
  if (hasAnyPrefix({"YJ"})) return "vu";
  if (hasAnyPrefix({"4W"})) return "tl";
  if (hasAnyPrefix({"AX","VH","VI","VJ","VK","VL","VM","VN","VZ"})) return "au";
  if (hasAnyPrefix({"ZK","ZL","ZM"})) return "nz";
  if (hasAnyPrefix({"5W"})) return "ws";

  return "de";
}

uint16_t bmpRead16(fs::File &f) {
  uint16_t result;
  ((uint8_t *)&result)[0] = f.read();
  ((uint8_t *)&result)[1] = f.read();
  return result;
}

uint32_t bmpRead32(fs::File &f) {
  uint32_t result;
  ((uint8_t *)&result)[0] = f.read();
  ((uint8_t *)&result)[1] = f.read();
  ((uint8_t *)&result)[2] = f.read();
  ((uint8_t *)&result)[3] = f.read();
  return result;
}

bool drawBmp24FromSD(const char *filename, int16_t x, int16_t y, int16_t maxW, int16_t maxH) {
  File bmpFS = storageOpen(filename, FILE_READ);
  if (!bmpFS) return false;

  if (bmpRead16(bmpFS) != 0x4D42) {
    bmpFS.close();
    return false;
  }

  bmpRead32(bmpFS);
  bmpRead32(bmpFS);
  uint32_t imageOffset = bmpRead32(bmpFS);
  uint32_t headerSize = bmpRead32(bmpFS);
  int32_t bmpWidth = (int32_t)bmpRead32(bmpFS);
  int32_t bmpHeight = (int32_t)bmpRead32(bmpFS);

  if (bmpRead16(bmpFS) != 1) {
    bmpFS.close();
    return false;
  }

  uint16_t depth = bmpRead16(bmpFS);
  uint32_t compression = bmpRead32(bmpFS);

  if (headerSize < 40 || depth != 24 || compression != 0) {
    bmpFS.close();
    return false;
  }

  bool flip = true;
  if (bmpHeight < 0) {
    bmpHeight = -bmpHeight;
    flip = false;
  }

  if (bmpWidth <= 0 || bmpHeight <= 0) {
    bmpFS.close();
    return false;
  }

  if (bmpWidth > maxW || bmpHeight > maxH) {
    bmpFS.close();
    return false;
  }

  uint32_t rowSize = (bmpWidth * 3 + 3) & ~3;
  uint8_t sdbuffer[3 * 20];
  uint8_t buffidx = sizeof(sdbuffer);

  for (int row = 0; row < bmpHeight; row++) {
    uint32_t pos = imageOffset + (flip ? (bmpHeight - 1 - row) : row) * rowSize;
    bmpFS.seek(pos);

    buffidx = sizeof(sdbuffer);

    for (int col = 0; col < bmpWidth; col++) {
      if (buffidx >= sizeof(sdbuffer)) {
        bmpFS.read(sdbuffer, sizeof(sdbuffer));
        buffidx = 0;
      }

      uint8_t b = sdbuffer[buffidx++];
      uint8_t g = sdbuffer[buffidx++];
      uint8_t r = sdbuffer[buffidx++];

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
      tft.drawPixel(x + col, y + row, rgb888To565(r, g, b));
#elif DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
      tft.drawPixel(x + 8 + col, y + 8 + row, rgb888To565(r, g, b));
#else

#endif
    }
  }

  bmpFS.close();
  return true;
}

void drawFlagOrPlaceholder(int x, int y, int w, int h, const String& code) {
  fillPanel(x, y, w, h, theme.panel2, theme.border);

  if (code.length()) {
    String path = "/flags/" + code + ".bmp";
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
    if (drawBmp24FromSD(path.c_str(), x + UW(11), y + UH(5) - 4, w - UW(10), h - UH(10))) {
#else
    if (drawBmp24FromSD(path.c_str(), x + UW(11), y + UH(5), w - UW(10), h - UH(10))) {
#endif
      return;
    }
  }

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.accent, theme.panel2);
  tft.setTextFont(2);
  tft.drawString(code.length() ? code : "--", x + w / 2, y + h / 2);
}

void drawBrightnessSlider() {
  int rowX = UX(14);
  int rowY = UY(186);
  int rowW = UW(290);
  int rowH = UH(22);

  fillSoftPanel(rowX, rowY, UW(76), rowH, theme.panel2, theme.border, 5);
  fillSoftPanel(rowX + UW(80), rowY, rowW - UW(80), rowH, theme.panel2, theme.border, 5);

  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.warn, theme.panel2);
  tft.drawString("BRIGHT", rowX + UW(8), rowY + rowH / 2);

  tft.fillRoundRect(BRIGHT_SLIDER_X, BRIGHT_SLIDER_Y, BRIGHT_SLIDER_W, BRIGHT_SLIDER_H, UR(4), theme.panel);
  tft.drawRoundRect(BRIGHT_SLIDER_X, BRIGHT_SLIDER_Y, BRIGHT_SLIDER_W, BRIGHT_SLIDER_H, UR(4), theme.border);

  int knobX = map(cfg.brightness, 0, 255,
                  BRIGHT_SLIDER_X + max(1, UW(4)),
                  BRIGHT_SLIDER_X + BRIGHT_SLIDER_W - max(1, UW(5)));
  int fillW = knobX - BRIGHT_SLIDER_X;
  if (fillW > 0) tft.fillRoundRect(BRIGHT_SLIDER_X, BRIGHT_SLIDER_Y, fillW, BRIGHT_SLIDER_H, UR(4), theme.accent);
  tft.fillCircle(knobX, BRIGHT_SLIDER_Y + BRIGHT_SLIDER_H / 2, max(2, UR(5)), theme.warn);

  tft.setTextDatum(MR_DATUM);
  tft.setTextFont(1);
  tft.setTextColor(theme.text, theme.panel2);
  tft.drawString(String(cfg.brightness), rowX + rowW - UW(8), rowY + rowH / 2);
}

void drawLivePage() {
  drawTopTabs();

  HeardRecord rec;
  bool active = isLiveActive();
  if (active) rec = liveRecord;

  String activeMode = active ? normalizeModeName(rec.mode) : "";
  bool dmrActive = active && activeMode == "DMR";
  bool dstarActive = active && (activeMode == "DSTAR" || activeMode == "D-STAR");
  bool ysfActive = active && (activeMode == "YSF" || activeMode == "FUSION");
  bool p25Active = active && activeMode == "P25";
  bool nxdnActive = active && activeMode == "NXDN";
  bool pocsagActive = active && activeMode == "POCSAG";

  drawModeButton(UX(10), UY(38), UW(70), UH(20), "DMR", modeDMR, dmrActive);
  drawModeButton(UX(86), UY(38), UW(70), UH(20), "DSTAR", modeDSTAR, dstarActive);
  drawModeButton(UX(10), UY(62), UW(70), UH(20), "YSF", modeYSF, ysfActive);
  drawModeButton(UX(86), UY(62), UW(70), UH(20), "P25", modeP25, p25Active);
  drawModeButton(UX(10), UY(86), UW(70), UH(20), "NXDN", modeNXDN, nxdnActive);
  drawModeButton(UX(86), UY(86), UW(70), UH(20), "POCSAG", modePOCSAG, pocsagActive);

  drawSignalMeter(UX(170), UY(44), UW(140), UH(54), active, active ? &rec : nullptr);

  String operatorName = active ? radioIdDisplayNameForRecord(rec, true) : "";
  String operatorLine = active ? formatCallsignName(rec.callsign, operatorName, 0, 0) : "-";
  String statusLine = liveStatusText(active, rec);
  String ts1Line = active ? resolveTargetDisplay(rec) : "-";
  String rxFreq = hotspotRxFreq.length() ? hotspotRxFreq : currentRxFreq;
  String txFreq = hotspotTxFreq.length() ? hotspotTxFreq : currentTxFreq;
  if (!rxFreq.length()) rxFreq = (normalizedBackend() == "wpsd") ? "Auto" : "438.8000";
  if (!txFreq.length()) txFreq = (normalizedBackend() == "wpsd") ? "Auto" : "430.8000";

  drawLiveInfoPanel(UX(10), UY(112), UW(300), UH(68), operatorLine, statusLine, ts1Line, active);

  drawFrequencyBox(UX(10), UY(190), UW(146), "TX", txFreq);
  drawFrequencyBox(UX(164), UY(190), UW(146), "RX", rxFreq);

  drawFooter();
}

void drawLastPage() {
  drawTopTabs();

  fillSoftPanel(UX(6), UY(36), UW(308), UH(184), theme.panel, theme.border, 12);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.drawString("LAST HEARD", UX(16), UY(46));

  tft.setTextColor(theme.subtext, theme.panel);
  tft.setTextFont(1);
  tft.drawString("Tap any row for details", UX(16), UY(64));

  int rows = min(heardCount, 5);
  int y = UY(76);

  for (int i = 0; i < rows; i++) {
    HeardRecord& r = heardList[i];
    uint16_t lineBg = (i % 2 == 0) ? theme.panel2 : mix565(theme.panel2, theme.bg, 0.18f);
    fillSoftPanel(UX(14), y, UW(292), UH(24), lineBg, theme.border, 6);

    tft.setTextFont(2);
    tft.setTextDatum(TL_DATUM);

    tft.setTextColor(theme.accent, lineBg);
    tft.drawString(r.callsign.substring(0, 10), UX(22), y + UY(5));

    tft.setTextColor(theme.warn, lineBg);
    tft.drawString(r.mode.substring(0, 6), UX(94), y + UY(5));

    tft.setTextColor(theme.text, lineBg);
    tft.drawString(r.target.substring(0, 10), UX(150), y + UY(5));

    tft.setTextColor(theme.subtext, lineBg);
    tft.drawString(r.timeUtc.length() > 11 ? r.timeUtc.substring(11) : r.timeUtc, UX(236), y + UY(5));

    y += UH(28);
  }

  if (rows == 0) {
    tft.setTextColor(theme.subtext, theme.panel);
    tft.setTextFont(2);
    tft.drawString("No traffic received yet", UX(18), UY(86));
  }

  drawFooter();
}

void drawSetupPage() {
  drawTopTabs();

  fillSoftPanel(UX(6), UY(36), UW(308), UH(184), theme.panel, theme.border, 12);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.drawString("CONFIG / STATUS", UX(14), UY(46));

  drawInfoRow(UX(14), UY(66), UW(290), "Wi-Fi", wifiOk ? "Connected" : "Disconnected", theme.panel2, theme.panel2,
              theme.warn, wifiOk ? theme.good : theme.bad);
  drawInfoRow(UX(14), UY(90), UW(290), "SSID", cfg.wifiSsid, theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(14), UY(114), UW(290), "HOST", WiFi.localIP().toString(), theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(14), UY(138), UW(290), "CALLSIGN", getDeviceCallsign(), theme.panel2, theme.panel2, theme.warn, theme.good);
  drawInfoRow(UX(14), UY(162), UW(138), "REFRESH", String(cfg.refreshMs) + " ms", theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(166), UY(162), UW(138), "FLIP", cfg.flipScreen ? "On" : "Off", theme.panel2, theme.panel2, theme.warn, cfg.flipScreen ? theme.good : theme.subtext);
  drawInfoRow(UX(14), UY(186), UW(138), "SAVER", cfg.screensaverEnabled ? (String(cfg.screensaverTimeoutMs / 1000) + " s") : "Off", theme.panel2, theme.panel2, theme.warn, cfg.screensaverEnabled ? theme.good : theme.subtext);

  drawBrightnessSlider();
  drawFooter();
}

void drawApModePage() {
  drawTopTabs();

  fillSoftPanel(UX(6), UY(36), UW(308), UH(184), theme.panel, theme.border, 12);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.drawString("ACCESS POINT MODE", UX(14), UY(46));

  String modeText = apModeActive ? "Active" : "Inactive";
  String ipText = apModeActive ? WiFi.softAPIP().toString() : "-";
  String stateText = setupRequired ? "Setup required before normal run" : "Optional AP status";
  String ssidText = apSsid.length() ? apSsid : "-";
  String passText = apModeActive ? apPass : "-";

  drawInfoRow(UX(14), UY(66), UW(290), "AP MODE", modeText, theme.panel2, theme.panel2,
              theme.warn, apModeActive ? theme.good : theme.subtext);
  drawInfoRow(UX(14), UY(90), UW(290), "SSID", ssidText, theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(14), UY(114), UW(290), "PASSWORD", passText, theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(14), UY(138), UW(290), "OPEN", ipText, theme.panel2, theme.panel2, theme.warn, theme.good);
  drawInfoRow(UX(14), UY(162), UW(290), "STORAGE", activeStorageLabel, theme.panel2, theme.panel2, theme.warn, theme.text);

  fillSoftPanel(UX(14), UY(188), UW(290), UH(22), theme.panel2, theme.border, 6);
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel2);
  tft.drawString(stateText, UX(20), UY(195));

  drawFooter();
}

void drawStatsPage() {
  tft.fillScreen(theme.bg);

  const int cardX = UX(10);
  const int cardY = UY(10);
  const int cardW = SCREEN_W - UW(20);
  const int cardH = UH(220);

  fillSoftPanel(cardX, cardY, cardW, cardH, theme.panel2, theme.border, 14);
  fillSoftPanel(cardX + UW(10), cardY + UH(10), cardW - UW(20), UH(52), theme.panel, theme.border, 12);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.drawString("SESSION STATISTICS", cardX + UW(20), cardY + UH(20));
  tft.setTextColor(theme.subtext, theme.panel);
  tft.setTextFont(1);
  tft.drawString(getDateText() + "  " + getDetailedClockText(), cardX + UW(20), cardY + UH(38));

  drawInfoRow(cardX + UW(16), cardY + UH(72), cardW - UW(32), "DEVICE", getDeviceCallsign(), theme.panel, theme.panel2, theme.warn, theme.good);
  drawInfoRow(cardX + UW(16), cardY + UH(96), cardW - UW(32), "UPTIME", formatUptimeBrief(), theme.panel, theme.panel2, theme.warn, theme.text);
  drawInfoRow(cardX + UW(16), cardY + UH(120), cardW - UW(32), "TRAFFIC", String(sessionStats.totalQsos) + " hits", theme.panel, theme.panel2, theme.warn, theme.text);
  String rssiSummary = sessionStats.hasRssi ? (String(sessionStats.bestRssi) + "/" + String(sessionStats.worstRssi) + " dBm") : "-";
  drawInfoRow(cardX + UW(16), cardY + UH(144), cardW - UW(32), "RSSI B/W", rssiSummary, theme.panel, theme.panel2, theme.warn, theme.text);

  int miniY = cardY + UH(172);
  int miniW = UW(66);
  const char* labels[4] = {"DMR", "DSTAR", "YSF", "OTHERS"};
  uint32_t values[4] = {sessionStats.modeDMR, sessionStats.modeDSTAR, sessionStats.modeYSF,
                        sessionStats.modeP25 + sessionStats.modeNXDN + sessionStats.modePOCSAG};

  for (int i = 0; i < 4; ++i) {
    int x = cardX + UW(18) + i * UW(66);
    fillSoftPanel(x, miniY, miniW, UH(34), theme.panel, theme.border, 10);
    tft.setTextFont(1);
    tft.setTextColor(theme.warn, theme.panel);
    tft.drawString(labels[i], x + UW(8), miniY + UH(7));
    tft.setTextFont(2);
    tft.setTextColor(theme.text, theme.panel);
    tft.drawString(String(values[i]), x + UW(8), miniY + UH(18));
  }
}

void drawDetailPopup(const HeardRecord& r) {
  fillPanel(UX(12), UY(32), SCREEN_W - UX(24), SCREEN_H - UY(58), theme.panel2, theme.border);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel2);
  tft.drawString("QSO DETAILS", UX(22), UY(42));

  String cc = countryCodeFromCallsign(r.callsign);

  drawFlagOrPlaceholder(SCREEN_W - UW(82), UY(42), UW(52), UH(42), cc);

  String countryText = radioIdDisplayCountryForRecord(r, true);
  if (!countryText.length()) countryText = cc;
  String heardName = radioIdDisplayNameForRecord(r, true);
  drawLabelValue(UX(22), UY(70),  "User:", r.callsign, theme.warn, theme.text, theme.panel2);
  drawLabelValue(UX(22), UY(86),  "Name:", heardName.length() ? clipWithTilde(heardName, 18) : "-", theme.warn, theme.text, theme.panel2);
  drawLabelValue(UX(22), UY(102), "Mode:", r.mode, theme.warn, theme.text, theme.panel2);
  drawLabelValue(UX(22), UY(118), "Dest:", clipWithTilde(resolveTargetDisplay(r), 18), theme.warn, theme.text, theme.panel2);
  drawLabelValue(UX(22), UY(134), "Grid:", r.grid.length() ? r.grid : "-", theme.warn, theme.good, theme.panel2);
  drawLabelValue(UX(22), UY(150), "Cntry:", countryText.length() ? countryText : "-", theme.warn, theme.text, theme.panel2);
  String quality = "RSSI " + compactRssiValue(r.rssi) + "  BER " + prettyMetricValue(r.ber, "%", false);
  drawLabelValue(UX(22), UY(166), "Qual:", clipWithTilde(quality, 18), theme.warn, theme.subtext, theme.panel2);
  drawLabelValue(UX(22), UY(182), cfg.apiUtcToLocalTime ? "Local:" : "UTC:", r.timeUtc.length() ? r.timeUtc : "-", theme.warn, theme.subtext, theme.panel2);
}

String getScreensaverTimeText() {
  struct tm timeinfo;
  if (getLocalTime(&timeinfo, 10)) {
    char buf[8];
    strftime(buf, sizeof(buf), "%H:%M", &timeinfo);
    return String(buf);
  }
  return "--:--";
}

String getHeaderTimeText() {
  struct tm timeinfo;
  if (getLocalTime(&timeinfo, 10)) {
    char buf[8];
    strftime(buf, sizeof(buf), "%H:%M", &timeinfo);
    return String(buf);
  }
  return "--:--";
}

String getDetailedClockText() {
  struct tm timeinfo;
  if (getLocalTime(&timeinfo, 10)) {
    char buf[10];
    strftime(buf, sizeof(buf), "%H:%M", &timeinfo);
    return String(buf);
  }
  return "--:--";
}

String getDateText() {
  struct tm timeinfo;
  if (getLocalTime(&timeinfo, 10)) {
    char buf[20];
    strftime(buf, sizeof(buf), "%d %b %Y", &timeinfo);
    return String(buf);
  }
  return "-- --- ----";
}

static int64_t daysFromCivil(int year, unsigned month, unsigned day) {
  year -= (month <= 2) ? 1 : 0;
  const int era = (year >= 0 ? year : year - 399) / 400;
  const unsigned yoe = (unsigned)(year - era * 400);
  const unsigned doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
  const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
  return (int64_t)era * 146097 + (int64_t)doe - 719468;
}

time_t utcTmToEpoch(const struct tm& tmValue) {
  const int year = tmValue.tm_year + 1900;
  const unsigned month = (unsigned)tmValue.tm_mon + 1;
  const unsigned day = (unsigned)tmValue.tm_mday;

  int64_t days = daysFromCivil(year, month, day);
  int64_t seconds = days * 86400LL +
                    (int64_t)tmValue.tm_hour * 3600LL +
                    (int64_t)tmValue.tm_min * 60LL +
                    (int64_t)tmValue.tm_sec;

  return (time_t)seconds;
}

bool parseApiDateTime(const String& input, struct tm& tmOut) {
  String s = trimSafe(input);
  if (s.length() < 19) return false;

  s.replace('T', ' ');

  int year = 0;
  int month = 0;
  int day = 0;
  int hour = 0;
  int minute = 0;
  int second = 0;

  if (sscanf(s.c_str(), "%d-%d-%d %d:%d:%d", &year, &month, &day, &hour, &minute, &second) != 6) {
    return false;
  }

  if (year < 1970 || month < 1 || month > 12 || day < 1 || day > 31 ||
      hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 60) {
    return false;
  }

  memset(&tmOut, 0, sizeof(tmOut));
  tmOut.tm_year = year - 1900;
  tmOut.tm_mon = month - 1;
  tmOut.tm_mday = day;
  tmOut.tm_hour = hour;
  tmOut.tm_min = minute;
  tmOut.tm_sec = second;
  tmOut.tm_isdst = -1;
  return true;
}

String formatApiDateTime(const struct tm& tmValue) {
  char buf[24];
  strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tmValue);
  return String(buf);
}

String normalizeHeardTimeDisplay(const String& apiTimeUtc) {
  String raw = trimSafe(apiTimeUtc);
  if (!raw.length()) return "";
  if (!cfg.apiUtcToLocalTime) return raw;

  struct tm parsedUtc;
  if (!parseApiDateTime(raw, parsedUtc)) return raw;

  time_t epochUtc = utcTmToEpoch(parsedUtc);
  if (epochUtc == (time_t)-1) return raw;

  struct tm localTm;
  localtime_r(&epochUtc, &localTm);
  return formatApiDateTime(localTm);
}

void drawScreensaver(bool forceClear) {
  static String lastFrameKey = "";

  if (forceClear) {
    lastFrameKey = "";
  }

  HeardRecord rec;
  bool active = isLiveActive();
  bool hasLastHeard = liveRecord.valid;
  if (hasLastHeard) rec = liveRecord;

  String clockText = getDetailedClockText();
  String dateText = getDateText();
  String callsignText = getDeviceCallsign();
  String shownCallsign = callsignText.length() ? normalizeHamCallsign(callsignText) : "-";
  String operatorText = rec.callsign;
  String shownOperator = operatorText.length() ? normalizeHamCallsign(operatorText) : "-";
  String screensaverName = hasLastHeard ? radioIdDisplayNameForRecord(rec, true) : "";
  String screensaverUser = hasLastHeard ? formatCallsignName(rec.callsign, screensaverName, 10, 10) : shownCallsign;
  String flagCallsign = hasLastHeard && operatorText.length() ? operatorText : callsignText;
  String targetText = hasLastHeard ? resolveTargetDisplay(rec) : "Standby";
  if (targetText.length() > 24) targetText = targetText.substring(0, 23) + "~";

  uint32_t phase = millis() / 12000UL;
  int driftX = ((int)(phase % 5) - 2) * UW(4);
  int driftY = ((int)((phase / 2) % 5) - 2) * UH(3);
  int cx = SCREEN_W / 2 + driftX;
  int clockY = UY(82) + driftY;
  int dateY = UY(118) + driftY;
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  int CallsignY = UY(146) + driftY;
#else
  int CallsignY = UY(154) + driftY;
#endif

  int badgeY = UY(174) + driftY;

  String frameKey = clockText + "|" + dateText + "|" + shownCallsign + "|" + screensaverUser + "|" + targetText + "|" + String(driftX) + "|" + String(driftY);
  if (frameKey == lastFrameKey && !forceClear) return;

  tft.fillScreen(TFT_BLACK);

#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.drawFastHLine(UX(18), UY(10), SCREEN_W - UW(36), theme.panel2);
  tft.drawFastHLine(UX(30), UY(11), SCREEN_W - UW(60), theme.border);
  tft.drawFastHLine(UX(18), SCREEN_H - UY(10), SCREEN_W - UW(36), theme.panel2);
  tft.drawFastHLine(UX(30), SCREEN_H - UY(11), SCREEN_W - UW(60), theme.border);
#else
  tft.drawFastHLine(UX(18), UY(20), SCREEN_W - UW(36), theme.panel2);
  tft.drawFastHLine(UX(30), UY(21), SCREEN_W - UW(60), theme.border);
  tft.drawFastHLine(UX(18), SCREEN_H - UY(20), SCREEN_W - UW(36), theme.panel2);
  tft.drawFastHLine(UX(30), SCREEN_H - UY(21), SCREEN_W - UW(60), theme.border);
#endif

  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(7);
  tft.setTextColor(theme.accent, TFT_BLACK);
  tft.drawString(clockText, cx, clockY);

  tft.setTextFont(2);
  tft.setTextColor(theme.subtext, TFT_BLACK);
  tft.drawString(dateText, cx, dateY);

  tft.setTextFont(4);
  tft.setTextColor(theme.warn, theme.panel);
  tft.drawString(shownCallsign, cx, CallsignY);

  int badgeW = UW(248);
  int badgeH = UH(44);
  int badgeX = (SCREEN_W - badgeW) / 2 + driftX;
  badgeX = constrain(badgeX, UX(8), SCREEN_W - badgeW - UX(8));
  fillSoftPanel(badgeX, badgeY, badgeW, badgeH, theme.panel, theme.border, 14);

  const int badgeFlagW = 32;
  const int badgeFlagH = 32;
  int badgeFlagX = badgeX + UW(10);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  int badgeFlagY = badgeY + UH(6) - 3;
#else
  int badgeFlagY = badgeY + UH(6);
#endif
  badgeFlagY = constrain(badgeFlagY, badgeY + UH(4), badgeY + badgeH - badgeFlagH - UH(4));
  drawFlagForCallsign(badgeFlagX, badgeFlagY, badgeFlagW, badgeFlagH, flagCallsign);

  int badgeTextX = badgeFlagX + badgeFlagW + UW(10);
  int badgeTextMax = max(8, (badgeW - (badgeTextX - badgeX) - UW(10)) / 6);
  String badgeUser = clipWithTilde(screensaverUser, badgeTextMax);
  String badgeTarget = clipWithTilde(targetText, badgeTextMax + 2);

  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.warn, theme.panel);

#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.drawString(badgeUser, badgeTextX, badgeY + UH(13) + 5);
#else
  tft.drawString(badgeUser, badgeTextX, badgeY + UH(13) + 3);
#endif

  tft.setTextFont(1);
  tft.setTextColor(theme.text, theme.panel);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.drawString(badgeTarget, badgeTextX, badgeY + UH(27) + 5);
#else
  tft.drawString(badgeTarget, badgeTextX, badgeY + UH(27) + 3);
#endif

  lastFrameKey = frameKey;
}

void drawAboutPage() {
  tft.setTextDatum(TL_DATUM);

  const int cardX = UX(10);
  const int cardY = UY(10);
  const int cardW = SCREEN_W - UW(20);

  fillSoftPanel(UX(12), UY(44), SCREEN_W - UW(24), UH(164), theme.panel, theme.border, 12);

  tft.setTextColor(theme.accent, theme.panel);
  tft.setTextFont(4);
  tft.drawString("MMDVM Display", UX(24), UY(56));

  drawInfoRow(cardX + UW(16), cardY + UH(88), cardW - UW(32), "CREATOR", "13MAD86 / Martin", theme.panel, theme.panel2, theme.warn, theme.good);
  drawInfoRow(cardX + UW(16), cardY + UH(112), cardW - UW(32), "VERSION", DISPLAY_VERSION, theme.panel, theme.panel2, theme.warn, theme.text);

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.warn, theme.panel);
  tft.setTextFont(2);
  tft.drawString("Free for Public, use at own Risk!", SCREEN_W / 2, UY(176));
}

void redrawCurrentPage(bool fullRedraw = false) {
  Page pageToDraw = (setupRequired && currentPage != PAGE_STAT && currentPage != PAGE_STATUS && currentPage != PAGE_ABOUT) ? PAGE_AP : currentPage;
  bool pageChanged = (lastRenderedPage != pageToDraw);
  bool popupChanged = (lastDetailPopupState != detailPopupOpen);

if (screensaverActive) {
  drawScreensaver(fullRedraw || pageChanged || popupChanged);
  lastRenderedPage = pageToDraw;
  lastDetailPopupState = detailPopupOpen;
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.flush();
#endif
  return;
}

  if (fullRedraw || pageChanged || popupChanged) {
    clearBody();
  }

  if (pageToDraw == PAGE_LIVE) drawLivePage();
  else if (pageToDraw == PAGE_LAST) drawLastPage();
  else if (pageToDraw == PAGE_STATUS) drawSetupPage();
  else if (pageToDraw == PAGE_AP) drawApModePage();
  else if (pageToDraw == PAGE_STAT) drawStatsPage();
  else if (pageToDraw == PAGE_ABOUT) drawAboutPage();

  if (detailPopupOpen && selectedHeardIndex >= 0 && selectedHeardIndex < heardCount) {
    drawDetailPopup(heardList[selectedHeardIndex]);
  }

  lastRenderedPage = pageToDraw;
  lastDetailPopupState = detailPopupOpen;
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.flush();
#endif
}

bool saveConfigToFile() {
  DynamicJsonDocument doc(4096);

  doc["backend"] = cfg.backend;
  doc["wifi_ssid"] = cfg.wifiSsid;
  doc["wifi_pass"] = cfg.wifiPass;
  doc["pistar_host"] = cfg.pistarHost;
  doc["device_callsign"] = cfg.deviceCallsign;
  doc["theme_color"] = cfg.themeColor;
  doc["refresh_ms"] = cfg.refreshMs;
  doc["callsign_hold_ms"] = cfg.callsignHoldMs;
  doc["api_utc_to_local_time"] = cfg.apiUtcToLocalTime;
  doc["timezone_tz"] = cfg.timezoneTz;
  doc["brightness"] = cfg.brightness;
  doc["flip_screen"] = cfg.flipScreen;
  doc["screensaver_enabled"] = cfg.screensaverEnabled;
  doc["screensaver_timeout_ms"] = cfg.screensaverTimeoutMs;
  doc["screensaver_dim_brightness"] = cfg.screensaverDimBrightness;
  doc["radioid_lookup_enabled"] = cfg.radioIdLookupEnabled;
  doc["radioid_users_api_base"] = cfg.radioIdUsersApiBase;

  JsonObject th = doc.createNestedObject("theme");

  th["bg"] = color565ToHex(theme.bg);
  th["panel"] = color565ToHex(theme.panel);
  th["panel2"] = color565ToHex(theme.panel2);
  th["border"] = color565ToHex(theme.border);
  th["text"] = color565ToHex(theme.text);
  th["subtext"] = color565ToHex(theme.subtext);
  th["accent"] = color565ToHex(theme.accent);
  th["good"] = color565ToHex(theme.good);
  th["warn"] = color565ToHex(theme.warn);
  th["bad"] = color565ToHex(theme.bad);
  th["live"] = color565ToHex(theme.live);
  th["rx"] = color565ToHex(theme.rx);
  th["tx"] = color565ToHex(theme.tx);
  th["tabIdle"] = color565ToHex(theme.tabIdle);
  th["tabActive"] = color565ToHex(theme.tabActive);

  storageRemove(CONFIG_FILE);
  File f = storageOpen(CONFIG_FILE, FILE_WRITE);
  if (!f) return false;
  bool ok = serializeJsonPretty(doc, f) > 0;
  f.close();
  return ok;
}

bool saveDefaultConfig() {
  DynamicJsonDocument doc(4096);

  doc["backend"] = "Pi-Star";
  doc["wifi_ssid"] = "YOUR_WIFI";
  doc["wifi_pass"] = "YOUR_PASSWORD";
  doc["pistar_host"] = "192.168.1.50";
  doc["device_callsign"] = "13MAD86";
  doc["theme_color"] = "#35C2FF";
  doc["refresh_ms"] = 1500;
  doc["callsign_hold_ms"] = 12000;
  doc["api_utc_to_local_time"] = true;
  doc["timezone_tz"] = DEFAULT_TZ_RULE;
  doc["brightness"] = 220;
  doc["flip_screen"] = false;
  doc["screensaver_enabled"] = true;
  doc["screensaver_timeout_ms"] = 60000;
  doc["screensaver_dim_brightness"] = 20;
  doc["radioid_lookup_enabled"] = true;
  doc["radioid_users_api_base"] = RADIOID_USERS_API_BASE_DEFAULT;

  JsonObject th = doc.createNestedObject("theme");
  th["bg"] = "#000000";
  th["panel"] = "#1C1C1C";
  th["panel2"] = "#2A2A2A";
  th["border"] = "#666666";
  th["text"] = "#FFFFFF";
  th["subtext"] = "#C0C0C0";
  th["accent"] = "#00FFFF";
  th["good"] = "#00FF00";
  th["warn"] = "#FFFF00";
  th["bad"] = "#FF0000";
  th["live"] = "#FFA500";
  th["rx"] = "#00FF00";
  th["tx"] = "#0080FF";
  th["tabIdle"] = "#406070";
  th["tabActive"] = "#008080";

  storageRemove(CONFIG_FILE);
  File f = storageOpen(CONFIG_FILE, FILE_WRITE);
  if (!f) return false;
  bool ok = serializeJsonPretty(doc, f) > 0;
  f.close();
  return ok;
}

bool configLooksValid() {
  return cfg.wifiSsid.length() > 0 &&
         cfg.pistarHost.length() > 0 &&
         (cfg.backend == "WPSD" || cfg.backend == "Pi-Star");
}

bool loadConfig() {
  setDefaultTheme();

  if (!storageExists(CONFIG_FILE)) {
    if (!saveDefaultConfig()) return false;
  }

  File f = storageOpen(CONFIG_FILE, FILE_READ);
  if (!f) return false;

  DynamicJsonDocument doc(4096);
  DeserializationError err = deserializeJson(doc, f);
  f.close();
  if (err) return false;

  cfg.backend        = String(doc["backend"] | "");
  cfg.wifiSsid       = String(doc["wifi_ssid"] | "");
  cfg.wifiPass       = String(doc["wifi_pass"] | "");
  cfg.pistarHost     = String(doc["pistar_host"] | "");
  cfg.deviceCallsign = String(doc["device_callsign"] | "");
  cfg.themeColor     = String(doc["theme_color"] | "#35C2FF");
  cfg.refreshMs      = doc["refresh_ms"] | 1500;
  cfg.callsignHoldMs = doc["callsign_hold_ms"] | 12000;
  cfg.apiUtcToLocalTime = doc["api_utc_to_local_time"] | true;
  cfg.timezoneTz    = String(doc["timezone_tz"] | DEFAULT_TZ_RULE);
  cfg.brightness     = doc["brightness"] | 220;
  cfg.flipScreen     = doc["flip_screen"] | false;
  cfg.screensaverEnabled = doc["screensaver_enabled"] | true;
  cfg.screensaverTimeoutMs = doc["screensaver_timeout_ms"] | 60000;
  cfg.screensaverDimBrightness = doc["screensaver_dim_brightness"] | 20;
  cfg.radioIdLookupEnabled = doc.containsKey("radioid_lookup_enabled") ? (bool)doc["radioid_lookup_enabled"] : true;
  cfg.radioIdUsersApiBase = String(doc["radioid_users_api_base"] | RADIOID_USERS_API_BASE_DEFAULT);

  cfg.wifiSsid = trimSafe(cfg.wifiSsid);
  cfg.pistarHost = trimSafe(cfg.pistarHost);
  cfg.deviceCallsign = trimSafe(cfg.deviceCallsign);
  cfg.timezoneTz = trimSafe(cfg.timezoneTz);
  if (!cfg.timezoneTz.length()) cfg.timezoneTz = String(DEFAULT_TZ_RULE);
  cfg.themeColor = trimSafe(cfg.themeColor);
  cfg.radioIdUsersApiBase = trimSafe(cfg.radioIdUsersApiBase);
  if (!cfg.radioIdUsersApiBase.length()) cfg.radioIdUsersApiBase = String(RADIOID_USERS_API_BASE_DEFAULT);
  cfg.brightness = constrain(cfg.brightness, 0, 255);
  cfg.screensaverDimBrightness = constrain(cfg.screensaverDimBrightness, 0, 255);
  if (cfg.screensaverTimeoutMs < 5000) cfg.screensaverTimeoutMs = 5000;

  applyBaseThemeColor(cfg.themeColor);

  clearRadioIdLookupCache();

  if (doc.containsKey("theme") && doc["theme"].is<JsonObject>()) {
    JsonObject themeObj = doc["theme"].as<JsonObject>();
    loadThemeFromJson(themeObj);
  }

  return cfg.wifiSsid.length() && cfg.pistarHost.length();
}

void applyBrightness() {
  analogWrite(TFT_BL, screensaverActive ? cfg.screensaverDimBrightness : cfg.brightness);
}

void applyDisplaySettings() {
  tft.setRotation(cfg.flipScreen ? 3 : 1);
  tft.fillScreen(theme.bg);
  applyBrightness();
  requestRedraw();
}

void setScreensaverActive(bool active) {
  if (screensaverActive == active) return;
  screensaverActive = active;
  applyBrightness();
  footerDirty = true;
  requestRedraw();
  if (active) {
    detailPopupOpen = false;
    selectedHeardIndex = -1;
    lastScreensaverFrameMillis = 0;
  }
}

void noteScreenActivity(bool fromTouch) {
  lastInteractionMillis = millis();
  if (screensaverActive) {
    setScreensaverActive(false);
    tft.fillScreen(theme.bg);
    requestRedraw();
  }
}

void noteUserInteraction(bool fromTouch) {
  noteScreenActivity(fromTouch);
}

void setBrightnessFromTouchX(int x) {
  int val = map(x, BRIGHT_SLIDER_X + max(2, UW(4)), BRIGHT_SLIDER_X + BRIGHT_SLIDER_W - max(2, UW(5)), 0, 255);
  cfg.brightness = constrain(val, 0, 255);
  applyBrightness();
}

bool connectWiFi(uint32_t timeoutMs = WIFI_CONNECT_TIMEOUT_MS) {
  String ssid = cfg.wifiSsid;
  ssid.trim();
  if (!ssid.length()) {
    wifiOk = false;
    wifiConnectInProgress = false;
    setFooterText("Wi-Fi SSID missing");
    return false;
  }

  if (apModeActive) {
    stopSetupAccessPoint();
    delay(100);
  }

  WiFi.mode(WIFI_STA);
  WiFi.persistent(false);
  WiFi.setAutoReconnect(true);
  WiFi.begin(cfg.wifiSsid.c_str(), cfg.wifiPass.c_str());

  unsigned long start = millis();
  wifiConnectInProgress = true;
  wifiConnectStartedAt = start;
  lastWifiRetryMillis = start;
  setFooterText("Connecting Wi-Fi...");
  requestRedraw();

  while (WiFi.status() != WL_CONNECTED && millis() - start < timeoutMs) {
    server.handleClient();
    delay(50);
  }

  wifiConnectInProgress = false;
  wifiOk = (WiFi.status() == WL_CONNECTED);
  if (wifiOk) {
    syncTimeIfNeeded();
  }
  setFooterText(wifiOk ? ("Wi-Fi OK: " + WiFi.localIP().toString()) : "Wi-Fi failed");
  return wifiOk;
}

void syncTimeIfNeeded() {
  if (!timeConfigured) {
    String tzRule = trimSafe(cfg.timezoneTz);
    if (!tzRule.length()) tzRule = String(DEFAULT_TZ_RULE);
    configTzTime(tzRule.c_str(), "pool.ntp.org", "time.nist.gov", "time.google.com");
    timeConfigured = true;
  }
}

void beginWiFiConnectAttempt(bool stopApFirst) {
  String ssid = cfg.wifiSsid;
  ssid.trim();
  if (!ssid.length()) {
    wifiOk = false;
    wifiConnectInProgress = false;
    setFooterText("Wi-Fi SSID missing");
    requestRedraw();
    return;
  }

  if (stopApFirst && apModeActive) {
    stopSetupAccessPoint();
    delay(50);
  }

  WiFi.mode(WIFI_STA);
  WiFi.persistent(false);
  WiFi.setAutoReconnect(true);
  WiFi.begin(cfg.wifiSsid.c_str(), cfg.wifiPass.c_str());

  wifiOk = false;
  wifiConnectInProgress = true;
  wifiConnectStartedAt = millis();
  lastWifiRetryMillis = wifiConnectStartedAt;
  setFooterText("Connecting Wi-Fi...");
  requestRedraw();
}

void handleWiFiConnectProgress() {
  if (!wifiConnectInProgress) return;

  wl_status_t status = WiFi.status();
  if (status == WL_CONNECTED) {
    wifiConnectInProgress = false;
    wifiOk = true;
    setupRequired = false;
    syncTimeIfNeeded();
    if (apModeActive) stopSetupAccessPoint();
    setFooterText("Wi-Fi OK: " + WiFi.localIP().toString());
    requestRedraw();
    return;
  }

  if (millis() - wifiConnectStartedAt >= WIFI_CONNECT_TIMEOUT_MS) {
    wifiConnectInProgress = false;
    wifiOk = false;
    setupRequired = true;
    currentPage = PAGE_AP;
    startSetupAccessPoint();
    setFooterText(apModeActive ? ("Setup required: " + apSsid + " / http://" + WiFi.softAPIP().toString() + "/") : "Wi-Fi failed");
    requestRedraw();
  }
}

bool startSetupAccessPoint() {
  if (apModeActive) {
    IPAddress ip = WiFi.softAPIP();
    setFooterText("Setup AP: " + apSsid + " / " + ip.toString());
    return true;
  }

  WiFi.disconnect(true, true);
  delay(200);
  WiFi.mode(WIFI_AP);

  String chip = String((uint32_t)(ESP.getEfuseMac() & 0xFFFFFF), HEX);
  chip.toUpperCase();
  while (chip.length() < 6) chip = "0" + chip;
  apSsid = "MMDVM-Display-" + chip;

  bool ok = WiFi.softAP(apSsid.c_str(), apPass.c_str());
  apModeActive = ok;
  if (ok) {
    IPAddress ip = WiFi.softAPIP();
    setFooterText("Setup AP: " + apSsid + " / " + ip.toString());
  } else {
    setFooterText("Setup AP failed");
  }
  return ok;
}

void stopSetupAccessPoint() {
  if (!apModeActive) return;
  WiFi.softAPdisconnect(true);
  apModeActive = false;
}

String htmlEscape(const String& s) {
  String out = s;
  out.replace("&", "&amp;");
  out.replace("<", "&lt;");
  out.replace(">", "&gt;");
  out.replace("\"", "&quot;");
  return out;
}

String formRow(const String& label, const String& name, const String& value, const String& type = "text", const String& placeholder = "") {
  String row = "<div class='form-row'>";
  row += "<label for='" + name + "'>" + label + "</label>";
  row += "<input type='" + type + "' id='" + name + "' name='" + name + "' value='" + htmlEscape(value) + "'";
  if (placeholder.length()) row += " placeholder='" + htmlEscape(placeholder) + "'";
  row += ">";
  row += "</div>";
  return row;
}

String selectRow(const String& label, const String& name, const String& current) {
  String row = "<div class='form-row'>";
  row += "<label for='" + name + "'>" + label + "</label>";
  row += "<select id='" + name + "' name='" + name + "'>";
  row += "<option value='WPSD'";
  if (current == "WPSD") row += " selected";
  row += ">WPSD</option>";
  row += "<option value='Pi-Star'";
  if (current == "Pi-Star") row += " selected";
  row += ">Pi-Star</option>";
  row += "</select>";
  row += "</div>";
  return row;
}

String colorRow(const String& label, const String& name, const String& value) {
  String row = "<div class='form-row'>";
  row += "<label for='" + name + "'>" + label + "</label>";
  row += "<div class='color-inline'>";
  row += "<input class='color-picker' type='color' id='" + name + "_picker' value='" + htmlEscape(value) + "' ";
  row += "oninput=\"document.getElementById('" + name + "').value=this.value\">";
  row += "<input class='color-text' type='text' id='" + name + "' name='" + name + "' value='" + htmlEscape(value) + "'>";
  row += "</div>";
  row += "</div>";
  return row;
}

String checkboxRow(const String& label, const String& name, bool checked) {
  String row = "<div class='form-row'>";
  row += "<label for='" + name + "'>" + label + "</label>";
  row += "<label class='toggle-row'><input type='checkbox' id='" + name + "' name='" + name + "' value='1'";
  if (checked) row += " checked";
  row += ">Enable</label>";
  row += "</div>";
  return row;
}

String navItem(const String& id, const String& label, bool active = false) {
  String s = "<a class='side-link";
  if (active) s += " active";
  s += "' href='#" + id + "'>" + label + "</a>";
  return s;
}

String navHrefItem(const String& href, const String& label, bool active = false) {
  String s = "<a class='side-link";
  if (active) s += " active";
  s += "' href='" + href + "'>" + label + "</a>";
  return s;
}

void handleRoot() {
  String ip = wifiOk ? WiFi.localIP().toString() : (apModeActive ? WiFi.softAPIP().toString() : "not connected");
  String backend = cfg.backend;
  if (backend != "WPSD" && backend != "Pi-Star") {
    backend = "Pi-Star";
  }

  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");

  String page;
  page.reserve(21500);

  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--bg:#0b1220;--bg2:#111827;--panel:rgba(15,23,42,.82);--panel-solid:#111827;--line:rgba(148,163,184,.20);--line2:rgba(148,163,184,.12);--text:#e5eefc;--muted:#94a3b8;--side:rgba(7,13,24,.76);--accent:" + accentHex + ";--accent-soft:rgba(53,194,255,.16);--danger:#f87171;--success:#34d399;--shadow:0 18px 45px rgba(2,6,23,.34)}";
  page += "*{box-sizing:border-box}";
  page += "html{scroll-behavior:smooth}";
  page += "body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:var(--bg);background-attachment:fixed;color:var(--text)}";
  page += "body:before{content:'';position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.03),transparent 45%,rgba(255,255,255,.015));pointer-events:none}";
  page += ".topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px}";
  page += ".brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}";
  page += ".brand-sub{font-size:13px;color:var(--muted);margin-top:4px}";
  page += ".top-chip{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:999px;border:1px solid var(--line);background:rgba(148,163,184,.08);color:#fff;font-size:13px;font-weight:700;box-shadow:var(--shadow)}";
  page += ".chip-dot{width:10px;height:10px;border-radius:999px;background:var(--accent);box-shadow:0 0 0 5px rgba(53,194,255,.14)}";
  page += ".layout{display:flex;min-height:calc(100vh - 78px)}";
  page += ".sidebar{width:248px;background:var(--side);border-right:1px solid var(--line);padding:24px 16px;position:sticky;top:78px;height:calc(100vh - 78px);overflow:auto;backdrop-filter:blur(16px)}";
  page += ".side-title{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);margin:0 0 12px 10px;font-weight:800}";
  page += ".side-link{display:block;text-decoration:none;color:#cbd5e1;padding:12px 14px;border-radius:14px;margin-bottom:8px;border:1px solid transparent;font-size:14px;font-weight:700;transition:all .18s ease}";
  page += ".side-link:hover{background:rgba(255,255,255,.04);border-color:var(--line);transform:translateX(2px)}";
  page += ".side-link.active{background:linear-gradient(135deg,var(--accent-soft),rgba(255,255,255,.02));border-color:rgba(53,194,255,.28);color:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.04)}";
  page += ".content{flex:1;min-width:0;padding:28px}";
  page += ".breadcrumb{font-size:13px;color:var(--muted);margin-bottom:14px}";
  page += ".breadcrumb a{text-decoration:none;color:var(--text)}";
  page += ".page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:20px}";
  page += ".page-head h1{margin:0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}";
  page += ".page-sub{margin-top:8px;color:var(--muted);font-size:15px;max-width:720px}";
  page += ".status-box,.startup-card,.section{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow);backdrop-filter:blur(18px)}";
  page += ".startup-card{margin-bottom:18px;padding:18px 20px}";
  page += ".startup-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:14px}";
  page += ".metric{padding:14px 16px;border-radius:16px;background:rgba(255,255,255,.03);border:1px solid var(--line2)}";
  page += ".metric-label{font-size:12px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:800;margin-bottom:8px}";
  page += ".metric-value{font-size:16px;font-weight:700;color:#fff;word-break:break-word}";
  page += ".status-box{padding:14px 16px;min-width:260px}";
  page += ".status-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;font-size:13px}";
  page += ".status-key{color:var(--muted);font-weight:700}";
  page += ".status-val{font-weight:700;color:#fff;text-align:right}";
  page += ".section{margin-bottom:18px;overflow:hidden;scroll-margin-top:18px;transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}";
  page += ".section:hover{transform:translateY(-2px);border-color:rgba(255,255,255,.18)}";
  page += ".section-head{padding:16px 20px;background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.01));border-bottom:1px solid var(--line2)}";
  page += ".section-head h2{margin:0;font-size:19px;font-weight:800;color:#fff}";
  page += ".section-head p{margin:6px 0 0 0;font-size:13px;color:var(--muted)}";
  page += ".section-body{padding:20px}";
  page += ".form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px 18px}";
  page += ".form-row{display:flex;flex-direction:column}";
  page += ".form-row label{font-size:13px;font-weight:800;color:#dbe5f5;margin-bottom:8px}";
  page += "input,select{width:100%;padding:13px 14px;border:1px solid rgba(148,163,184,.26);border-radius:14px;background:rgba(15,23,42,.80);color:#fff;font-size:14px;transition:border-color .18s ease,box-shadow .18s ease,background .18s ease}";
  page += "input::placeholder{color:#64748b}";
  page += "input[type='checkbox']{width:auto;transform:scale(1.12);margin-right:10px;padding:0;border:none;accent-color:var(--accent)}";
  page += "input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px rgba(53,194,255,.14);background:rgba(15,23,42,.95)}";
  page += ".toggle-row{display:flex;align-items:center;min-height:48px;padding:0 2px;color:#e2e8f0;font-weight:700}";
  page += ".color-inline{display:flex;gap:10px;align-items:center}";
  page += ".color-picker{width:58px;min-width:58px;height:46px;padding:4px;border-radius:14px;border:1px solid rgba(148,163,184,.26);background:rgba(15,23,42,.8)}";
  page += ".color-text{flex:1}";
  page += ".theme-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px 18px}";
  page += ".footer-actions{display:flex;justify-content:flex-end;gap:12px;padding-top:8px}";
  page += ".btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent),rgba(255,255,255,.18));color:#08111d;font-weight:800;text-decoration:none;cursor:pointer;box-shadow:0 14px 30px rgba(53,194,255,.20);transition:transform .16s ease,filter .16s ease,box-shadow .16s ease}";
  page += ".btn:hover{transform:translateY(-1px);filter:brightness(1.04);box-shadow:0 18px 34px rgba(53,194,255,.24)}";
  page += ".btn-secondary{background:rgba(255,255,255,.03);color:#fff;border-color:var(--line);box-shadow:none}";
  page += ".mini-note{font-size:12px;color:var(--muted);margin-top:12px;line-height:1.55}";
  page += ".hero-card{display:flex;justify-content:space-between;gap:18px;align-items:center;background:linear-gradient(135deg,rgba(15,23,42,.94),rgba(17,24,39,.82));border:1px solid rgba(148,163,184,.18);border-radius:26px;padding:24px 26px;margin:0 0 18px 0;box-shadow:var(--shadow);position:relative;overflow:hidden}";
  page += ".hero-card:after{content:'';position:absolute;right:-40px;top:-40px;width:180px;height:180px;background:radial-gradient(circle,var(--accent-soft),transparent 70%)}";
  page += ".hero-card h3{position:relative;margin:6px 0 8px 0;font-size:28px;font-weight:800;letter-spacing:-.04em;color:#fff}";
  page += ".hero-card p{position:relative;margin:0;color:#c8d4ea;max-width:640px;line-height:1.6}";
  page += ".hero-eyebrow{position:relative;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#7dd3fc;font-weight:900}";
  page += ".hero-pill{position:relative;padding:14px 18px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(148,163,184,.18);color:#fff;font-weight:800;white-space:nowrap}";
  page += ".theme-preview{display:flex;align-items:center;gap:12px;margin-top:14px;padding:14px 16px;border-radius:16px;background:rgba(255,255,255,.03);border:1px solid var(--line2)}";
  page += ".theme-swatch{width:42px;height:42px;border-radius:14px;background:var(--accent);box-shadow:0 0 0 6px rgba(53,194,255,.10)}";
  page += ".theme-preview strong{display:block;color:#fff;margin-bottom:2px}";
  page += ".alert-card{margin:0 0 18px 0;padding:16px 18px;border-radius:18px;border:1px solid rgba(248,113,113,.34);background:rgba(127,29,29,.18);color:#fecaca;box-shadow:0 8px 22px rgba(127,29,29,.14)}";
  page += ".alert-card p{color:#fecaca}";
  page += "@media(max-width:1080px){.layout{display:block}.sidebar{width:auto;height:auto;position:relative;top:0;border-right:none;border-bottom:1px solid var(--line)}.content{padding:18px}.page-head{flex-direction:column}.form-grid,.theme-grid,.startup-grid{grid-template-columns:1fr}.hero-card{flex-direction:column;align-items:flex-start}.hero-pill{white-space:normal}.topbar{position:relative}}"; 
  page += "</style></head><body>";

  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version "+ String(DISPLAY_VERSION) +" by 13MAD86 / Martin</div></div></div>";
  page += "<div class='layout'>";

  page += "<aside class='sidebar'>";
  page += "<div class='side-title' style='margin-top:18px'>Navigation</div>";
  page += navItem("system", "System");
  page += navItem("network", "Network");
  page += navItem("wifi", "Wifi");
  page += navItem("display", "Display");
  page += navItem("lookup", "Lookup");
  page += navItem("theme", "Theme");
  page += "</aside>";

  page += "<main class='content'>";
  page += "<div class='startup-card'>";
  page += "<div style='display:flex;justify-content:space-between;gap:18px;align-items:flex-start;flex-wrap:wrap'>";
  page += "<div><h2 style='margin:0 0 8px 0;font-size:22px;font-weight:800;color:#fff'>Startup status</h2><p style='margin:0;color:#94a3b8;line-height:1.6'></p></div>";
  page += "</div>";
  page += "<div class='startup-grid'>";
  page += "<div class='metric'><div class='metric-label'>Storage</div><div class='metric-value'>" + htmlEscape(activeStorageLabel) + "</div></div>";
  page += "<div class='metric'><div class='metric-label'>Network mode</div><div class='metric-value'>" + htmlEscape(String(apModeActive ? "Setup AP" : "Wi-Fi station")) + "</div></div>";
  page += "<div class='metric'><div class='metric-label'>Access</div><div class='metric-value'>http://" + htmlEscape(ip) + "/</div></div>";
  page += "</div>";
  if (apModeActive) {
    page += "<p style='margin:14px 0 0 0;color:#fdba74'>Connect to SSID <strong>" + htmlEscape(apSsid) + "</strong> and open the address above to finish setup.</p>";
  }
  page += "</div>";

  page += "<div class='page-head'>";
  page += "<div>";
  page += "<h1>Configuration</h1>";
  page += "<div class='page-sub'>To set up an MMDVM display.</div>";
  page += "</div>";

  page += "<div class='status-box'>";
  page += "<div class='status-row'><span class='status-key'>Device IP</span><span class='status-val'>" + htmlEscape(ip) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Backend</span><span class='status-val'>" + htmlEscape(backend) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Hotspot</span><span class='status-val'>" + htmlEscape(cfg.pistarHost) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Callsign</span><span class='status-val'>" + htmlEscape(getDeviceCallsign()) + "</span></div>";
  page += "</div>";
  page += "</div>";

  page += "<div class='hero-card'><div><div class='hero-eyebrow'>Personalization</div><h3>Make the display yours</h3><p>Set a custom device callsign for the boot splash, about page and status panels. Leave it empty to keep the default callsign. The admin UI now also picks up your configured theme color as its accent.</p></div><div class='hero-pill'>Active callsign: " + htmlEscape(getDeviceCallsign()) + "</div></div>";

  if (setupRequired) {
    page += "<div class='alert-card'><strong>Setup required.</strong><p>";
    page += (configOkState ? "Wi-Fi could not connect with the saved settings." : "No valid config was found yet.");
    page += "</p><p>Please complete the setup on this page and save it before normal operation starts.</p></div>";
  }

  page += "<form id='config-form' method='POST' action='/save'>";

  page += "<section class='section' id='system'>";
  page += "<div class='section-head'><h2>Hotspot</h2><p>MMDVM backend and Host IP settings.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += selectRow("Backend", "backend", backend);
  page += formRow("Pi-Star / WPSD Host or IP", "pistar_host", cfg.pistarHost, "text", "192.168.1.50");
  page += formRow("Device callsign", "device_callsign", cfg.deviceCallsign, "text", DEVICE_CALLSIGN);
  page += "</div>";
  page += "<div class='mini-note'>Select which backend the display should talk to. The host can be a local IP or hostname. Set a custom device callsign here or leave it blank to use the built-in default.</div>";
  page += "</div></section>";

  page += "<section class='section' id='network'>";
  page += "<div class='section-head'><h2>Network</h2><p>Polling and connection related settings.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += formRow("Refresh interval (ms)", "refresh_ms", String(cfg.refreshMs), "number");
  page += formRow("Callsign hold (ms)", "callsign_hold_ms", String(cfg.callsignHoldMs), "number");
  page += checkboxRow("Convert API UTC time to local device time", "api_utc_to_local_time", cfg.apiUtcToLocalTime);
  page += formRow("Time zone rule (POSIX TZ)", "timezone_tz", cfg.timezoneTz, "text", DEFAULT_TZ_RULE);
  page += "</div><div class='mini-note'>When enabled, last heard entries such as time_utc are converted from UTC to the device local time zone used by the header clock. The time zone rule below is also used by the header clock itself. Example for Germany: <span class='mono'>CET-1CEST,M3.5.0/2,M10.5.0/3</span>.</div></div></section>";

  page += "<section class='section' id='wifi'>";
  page += "<div class='section-head'><h2>Wifi</h2><p>Wireless access credentials for the device.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += formRow("WiFi SSID", "wifi_ssid", cfg.wifiSsid);
  page += formRow("WiFi Password", "wifi_pass", cfg.wifiPass, "password");
  page += "</div></div></section>";

  page += "<section class='section' id='display'>";
  page += "<div class='section-head'><h2>Display</h2><p>Local display behavior, intensity, orientation and idle mode.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += formRow("Brightness (0-255)", "brightness", String(cfg.brightness), "number");
  page += formRow("Theme base color", "theme_color", cfg.themeColor);
  page += checkboxRow("Flip screen 180°", "flip_screen", cfg.flipScreen);
  page += checkboxRow("Enable screensaver", "screensaver_enabled", cfg.screensaverEnabled);
  page += formRow("Screensaver timeout (ms)", "screensaver_timeout_ms", String(cfg.screensaverTimeoutMs), "number");
  page += formRow("Screensaver brightness (0-255)", "screensaver_dim_brightness", String(cfg.screensaverDimBrightness), "number");
  page += "</div><div class='mini-note'>When enabled, the screensaver dims the backlight after inactivity. Any touch wakes the display.</div></div></section>";

  page += "<section class='section' id='lookup'>";
  page += "<div class='section-head'><h2>Lookup</h2><p>Optional online callsign fallback with a configurable users API source.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += checkboxRow("Enable RadioID fallback", "radioid_lookup_enabled", cfg.radioIdLookupEnabled);
  page += formRow("Users API base URL", "radioid_users_api_base", cfg.radioIdUsersApiBase, "text", RADIOID_USERS_API_BASE_DEFAULT);
  page += "</div><div class='mini-note'>The callsign is appended automatically. For other APIs you can also use <span class='mono'>%CALLSIGN%</span> anywhere in the URL and it will be replaced with the encoded callsign.</div>";
  page += "</div></section>";

  page += "<section class='section' id='theme'>";
  page += "<div class='section-head'><h2>Theme</h2><p>UI colors for panels, labels and state indicators.</p></div>";
  page += "<div class='section-body'>";
  page += "<div class='theme-grid'>";
  page += colorRow("Background", "bg", color565ToHex(theme.bg));
  page += colorRow("Panel", "panel", color565ToHex(theme.panel));
  page += colorRow("Panel 2", "panel2", color565ToHex(theme.panel2));
  page += colorRow("Border", "border", color565ToHex(theme.border));
  page += colorRow("Text", "text", color565ToHex(theme.text));
  page += colorRow("Subtext", "subtext", color565ToHex(theme.subtext));
  page += colorRow("Accent", "accent", color565ToHex(theme.accent));
  page += colorRow("Good", "good", color565ToHex(theme.good));
  page += colorRow("Warn", "warn", color565ToHex(theme.warn));
  page += colorRow("Bad", "bad", color565ToHex(theme.bad));
  page += colorRow("Live", "live", color565ToHex(theme.live));
  page += colorRow("RX", "rx", color565ToHex(theme.rx));
  page += colorRow("TX", "tx", color565ToHex(theme.tx));
  page += colorRow("Tab idle", "tabIdle", color565ToHex(theme.tabIdle));
  page += colorRow("Tab active", "tabActive", color565ToHex(theme.tabActive));
  page += "</div></div>";
  page += "</section>";

  page += "<div class='footer-actions' style='margin-top:18px'>";
  page += "<button class='btn btn-secondary' type='submit' form='config-form'>Save configuration</button>";
  page += "</div>";

  page += "</form>";

  page += "</main></div>";
  page += "</body></html>";

  server.send(200, "text/html", page);
}

void handleSave() {
  DynamicJsonDocument doc(4096);

  cfg.backend = server.arg("backend");
  if (cfg.backend != "WPSD" && cfg.backend != "Pi-Star") {
    cfg.backend = "Pi-Star";
  }
  cfg.wifiSsid = server.arg("wifi_ssid");
  cfg.wifiPass = server.arg("wifi_pass");
  cfg.pistarHost = server.arg("pistar_host");
  cfg.deviceCallsign = trimSafe(server.arg("device_callsign"));
  cfg.themeColor = server.arg("theme_color");
  cfg.refreshMs = server.arg("refresh_ms").toInt();
  cfg.callsignHoldMs = server.arg("callsign_hold_ms").toInt();
  cfg.apiUtcToLocalTime = server.hasArg("api_utc_to_local_time");
  cfg.timezoneTz = trimSafe(server.arg("timezone_tz"));
  if (!cfg.timezoneTz.length()) cfg.timezoneTz = String(DEFAULT_TZ_RULE);
  cfg.brightness = constrain(server.arg("brightness").toInt(), 0, 255);
  cfg.flipScreen = server.hasArg("flip_screen");
  cfg.screensaverEnabled = server.hasArg("screensaver_enabled");
  cfg.screensaverTimeoutMs = max(5000L, server.arg("screensaver_timeout_ms").toInt());
  cfg.screensaverDimBrightness = constrain(server.arg("screensaver_dim_brightness").toInt(), 0, 255);
  cfg.radioIdLookupEnabled = server.hasArg("radioid_lookup_enabled");
  cfg.radioIdUsersApiBase = trimSafe(server.arg("radioid_users_api_base"));
  if (!cfg.radioIdUsersApiBase.length()) cfg.radioIdUsersApiBase = String(RADIOID_USERS_API_BASE_DEFAULT);

  doc["backend"] = cfg.backend;
  doc["wifi_ssid"] = cfg.wifiSsid;
  doc["wifi_pass"] = cfg.wifiPass;
  doc["pistar_host"] = cfg.pistarHost;
  doc["device_callsign"] = cfg.deviceCallsign;
  doc["theme_color"] = cfg.themeColor;
  doc["refresh_ms"] = cfg.refreshMs;
  doc["callsign_hold_ms"] = cfg.callsignHoldMs;
  doc["api_utc_to_local_time"] = cfg.apiUtcToLocalTime;
  doc["timezone_tz"] = cfg.timezoneTz;
  doc["brightness"] = cfg.brightness;
  doc["flip_screen"] = cfg.flipScreen;
  doc["screensaver_enabled"] = cfg.screensaverEnabled;
  doc["screensaver_timeout_ms"] = cfg.screensaverTimeoutMs;
  doc["screensaver_dim_brightness"] = cfg.screensaverDimBrightness;
  doc["radioid_lookup_enabled"] = cfg.radioIdLookupEnabled;
  doc["radioid_users_api_base"] = cfg.radioIdUsersApiBase;

  JsonObject th = doc.createNestedObject("theme");
  th["bg"] = server.arg("bg");
  th["panel"] = server.arg("panel");
  th["panel2"] = server.arg("panel2");
  th["border"] = server.arg("border");
  th["text"] = server.arg("text");
  th["subtext"] = server.arg("subtext");
  th["accent"] = server.arg("accent");
  th["good"] = server.arg("good");
  th["warn"] = server.arg("warn");
  th["bad"] = server.arg("bad");
  th["live"] = server.arg("live");
  th["rx"] = server.arg("rx");
  th["tx"] = server.arg("tx");
  th["tabIdle"] = server.arg("tabIdle");
  th["tabActive"] = server.arg("tabActive");

  storageRemove(CONFIG_FILE);
  File f = storageOpen(CONFIG_FILE, FILE_WRITE);
  if (!f) {
    server.send(500, "text/plain", "Failed to save config");
    return;
  }

  serializeJsonPretty(doc, f);
  f.close();

  configOkState = loadConfig();
  timeConfigured = false;
  applyDisplaySettings();
  if (cfg.radioIdLookupEnabled && heardCount > 0) {
    applyRadioIdCacheToActiveData();
    if (heardCount > 0) liveRecord = heardList[0];
    requestRedraw();
  }
  noteUserInteraction(false);
  lastLiveActiveState = isLiveActive();

  pendingConfigApply = true;
  pendingConfigApplyAt = millis() + 350;

  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");

  String page;
  page.reserve(7600);
  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--accent:" + accentHex + ";--bg:#0b1220;--panel:rgba(15,23,42,.88);--line:rgba(148,163,184,.18);--text:#e5eefc;--muted:#94a3b8}";
  page += "*{box-sizing:border-box}";
  page += "body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:#0b1220;color:var(--text)}";
  page += ".topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px}";
  page += ".brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}";
  page += ".brand-sub{font-size:13px;color:var(--muted);margin-top:4px}";
  page += ".sub{font-size:13px;color:var(--muted);margin-top:4px}";
  page += ".wrap{max-width:820px;margin:34px auto;padding:0 18px}";
  page += ".card{background:var(--panel);border:1px solid var(--line);border-radius:26px;padding:28px;box-shadow:0 24px 46px rgba(2,6,23,.34)}";
  page += ".pill{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:999px;background:rgba(255,255,255,.04);border:1px solid var(--line);font-size:13px;font-weight:800}";
  page += ".dot{width:10px;height:10px;border-radius:999px;background:var(--accent);box-shadow:0 0 0 5px rgba(53,194,255,.12)}";
  page += "h2{margin:16px 0 10px 0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}";
  page += "p{color:#cbd5e1;font-size:15px;line-height:1.65}";
  page += ".btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent),rgba(255,255,255,.18));color:#08111d;font-weight:800;text-decoration:none;cursor:pointer;box-shadow:0 14px 30px rgba(53,194,255,.20);transition:transform .16s ease,filter .16s ease,box-shadow .16s ease}";
  page += ".btn:hover{transform:translateY(-1px);filter:brightness(1.04);box-shadow:0 18px 34px rgba(53,194,255,.24)}";
  page += ".btn-secondary{background:rgba(255,255,255,.03);color:#fff;border-color:var(--line);box-shadow:none}";
  page += ".mono{font-family:monospace;background:rgba(255,255,255,.05);padding:2px 8px;border-radius:7px;color:#fff}";
  page += "</style></head><body>";
  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version "+ String(DISPLAY_VERSION) +" by 13MAD86 / Martin</div></div></div>";
  page += "<div class='wrap'><div class='card'>";
  page += "<h2>Configuration saved</h2>";
  page += "<p>The settings were saved to the device. Current last-heard entries were refreshed with the new online lookup setting, and the network is now restarting, so the current AP page may disconnect for a moment.</p>";
  page += "<p>After a few seconds, reopen the device on its new address from your router or use the setup AP again if Wi-Fi did not connect.</p>";
  page += "<p><a class='btn btn-secondary' href='/' onclick='setTimeout(function(){location.href=\"/\";},1200);return true;'>Back to configuration</a></p>";
  page += "</div></div>";
  page += "</body></html>";
  server.send(200, "text/html", page);
}

void setupWebServer() {

  server.on("/", HTTP_GET, handleRoot);
  server.on("/save", HTTP_POST, handleSave);
  server.begin();
}

void clearHeard() {
  heardCount = 0;
  for (int i = 0; i < MAX_HEARD; i++) heardList[i] = HeardRecord();
}

bool heardListsEqual(const HeardRecord* a, int aCount, const HeardRecord* b, int bCount) {
  if (aCount != bCount) return false;
  for (int i = 0; i < aCount; i++) {
    if (!sameRecord(a[i], b[i])) return false;
  }
  return true;
}

String firstNonEmpty(const String& a, const String& b, const String& c, const String& d) {
  if (trimSafe(a).length()) return trimSafe(a);
  if (trimSafe(b).length()) return trimSafe(b);
  if (trimSafe(c).length()) return trimSafe(c);
  return trimSafe(d);
}

String prettyMetricValue(const String& value, const char* suffix, bool keepPercent) {
  String out = trimSafe(value);
  if (!out.length()) return "-";
  if (out.equalsIgnoreCase("n/a") || out.equalsIgnoreCase("nan") || out.equalsIgnoreCase("null")) return "-";
  if (!keepPercent) out.replace("%", "");
  if (suffix && strlen(suffix) > 0) {
    String suf = String(suffix);
    if (!out.endsWith(suf)) out += suf;
  }
  return out;
}

String lookupCommonTalkgroupName(const String& target) {
  String tg = trimSafe(target);
  if (!tg.length()) return "";
  if (tg == "9") return "Local";
  if (tg == "91") return "World-Wide";
  if (tg == "92") return "Europe";
  if (tg == "93") return "USA";
  if (tg == "204") return "Netherlands";
  if (tg == "208") return "France";
  if (tg == "214") return "Spain";
  if (tg == "222") return "Italy";
  if (tg == "235") return "United Kingdom";
  if (tg == "262") return "Germany";
  if (tg == "263") return "Austria";
  if (tg == "310") return "USA TAC";
  if (tg == "9990") return "Parrot";
  return "";
}

String resolveTargetLabel(const HeardRecord& rec) {
  String mode = normalizeModeName(rec.mode);
  if (mode == "DMR") return "TG";
  if (mode == "DSTAR" || mode == "D-STAR") return "REF";
  if (mode == "YSF" || mode == "FUSION") return "ROOM";
  if (mode == "P25" || mode == "NXDN") return "NET";
  return "DST";
}

String resolveTargetDisplay(const HeardRecord& rec) {
  String label = resolveTargetLabel(rec);
  String rawTarget = trimSafe(rec.target);
  String explicitName = trimSafe(rec.targetName);
  String mode = normalizeModeName(rec.mode);

  if (explicitName.length() && rawTarget.length()) return label + " " + rawTarget + " " + explicitName;
  if (explicitName.length()) return explicitName;
  if (!rawTarget.length()) return "-";

  if (mode == "DMR") {
    String mapped = lookupCommonTalkgroupName(rawTarget);
    if (mapped.length()) return label + " " + rawTarget + " " + mapped;
    return label + " " + rawTarget;
  }

  if (mode == "DSTAR" || mode == "D-STAR") return label + " " + rawTarget;
  if (mode == "YSF" || mode == "FUSION") return label + " " + rawTarget;
  return rawTarget;
}

void updateSessionStatsFromRecord(const HeardRecord& rec) {
  if (!rec.valid) return;
  sessionStats.totalLiveHits++;
  sessionStats.totalQsos++;
  String mode = normalizeModeName(rec.mode);
  if (mode == "DMR") sessionStats.modeDMR++;
  else if (mode == "DSTAR" || mode == "D-STAR") sessionStats.modeDSTAR++;
  else if (mode == "YSF" || mode == "FUSION") sessionStats.modeYSF++;
  else if (mode == "P25") sessionStats.modeP25++;
  else if (mode == "NXDN") sessionStats.modeNXDN++;
  else if (mode == "POCSAG") sessionStats.modePOCSAG++;

  String rssiText = trimSafe(rec.rssi);
  if (rssiText.length()) {
    int rssi = rssiText.toInt();
    if (rssi != 0 || rssiText == "0") {
      if (!sessionStats.hasRssi) {
        sessionStats.bestRssi = rssi;
        sessionStats.worstRssi = rssi;
        sessionStats.hasRssi = true;
      } else {
        if (rssi > sessionStats.bestRssi) sessionStats.bestRssi = rssi;
        if (rssi < sessionStats.worstRssi) sessionStats.worstRssi = rssi;
      }
    }
  }
}

bool parseHeardArray(const String& payload) {
  DynamicJsonDocument doc(32768);
  auto err = deserializeJson(doc, payload);
  if (err) {
    lastError = String("JSON: ") + err.c_str();
    return false;
  }

  JsonArray arr;
  if (doc.is<JsonArray>()) {
    arr = doc.as<JsonArray>();
  } else if (doc.is<JsonObject>()) {
    JsonObject root = doc.as<JsonObject>();
    parseModeStatusesFromObject(root);

    if (root["last_heard"].is<JsonArray>()) arr = root["last_heard"].as<JsonArray>();
    else if (root["traffic"].is<JsonArray>()) arr = root["traffic"].as<JsonArray>();
    else if (root["data"].is<JsonArray>()) arr = root["data"].as<JsonArray>();
    else if (root["results"].is<JsonArray>()) arr = root["results"].as<JsonArray>();
    else {
      lastError = "JSON object missing heard array";
      return false;
    }
  } else {
    lastError = "JSON root invalid";
    return false;
  }

  HeardRecord newList[MAX_HEARD];
  int newCount = 0;

  for (JsonObject obj : arr) {
    if (newCount >= MAX_HEARD) break;

    HeardRecord r;
    r.timeUtc   = normalizeHeardTimeDisplay(firstNonEmpty(String(obj["time_utc"] | ""), String(obj["timestamp"] | ""), String(obj["time"] | "")));
    r.mode      = firstNonEmpty(String(obj["mode"] | ""), String(obj["module"] | ""));
    r.callsign  = firstNonEmpty(String(obj["callsign"] | ""), String(obj["callsign_long"] | ""), String(obj["call"] | ""));
    String apiName = firstNonEmpty(
                      String(obj["name"] | ""),
                      String(obj["display_name"] | ""),
                      String(obj["full_name"] | ""),
                      String(obj["fullname"] | "")
                    );
    String apiFirstName = firstNonEmpty(String(obj["fname"] | ""), String(obj["first_name"] | ""), String(obj["firstname"] | ""), String(obj["forename"] | ""));
    String apiLastName  = firstNonEmpty(String(obj["lname"] | ""), String(obj["last_name"] | ""), String(obj["lastname"] | ""), String(obj["surname"] | ""));
    r.name      = trimSafe(apiName.length() ? apiName : buildOperatorName(apiFirstName, apiLastName));
    r.country   = firstNonEmpty(String(obj["country"] | ""), String(obj["country_name"] | ""));
    r.grid      = firstNonEmpty(String(obj["grid"] | ""), String(obj["locator"] | ""), String(obj["maidenhead"] | ""), String(obj["qth_locator"] | ""));
    r.target    = firstNonEmpty(String(obj["target"] | ""), String(obj["talkgroup"] | ""), String(obj["destination"] | ""), String(obj["reflector"] | ""));
    r.targetName= firstNonEmpty(String(obj["target_name"] | ""), String(obj["talkgroup_name"] | ""), String(obj["room_name"] | ""), String(obj["reflector_name"] | ""));
    r.src       = firstNonEmpty(String(obj["src"] | ""), String(obj["source"] | ""));
    r.duration  = firstNonEmpty(String(obj["duration"] | ""), String(obj["length"] | ""));
    r.loss      = firstNonEmpty(String(obj["loss"] | ""), String(obj["packet_loss"] | ""));
    r.ber       = firstNonEmpty(String(obj["bit_error_rate"] | ""), String(obj["ber"] | ""));
    r.rssi      = firstNonEmpty(String(obj["rssi"] | ""), String(obj["signal"] | ""));
    r.rxFreq    = trimSafe(String(obj["rxFreq"] | obj["rx_freq"] | obj["RXFrequency"] | obj["rx_frequency"] | ""));
    r.txFreq    = trimSafe(String(obj["txFreq"] | obj["tx_freq"] | obj["TXFrequency"] | obj["tx_frequency"] | ""));
    r.valid     = r.callsign.length() > 0;

    if (r.valid) {
      newList[newCount] = r;
      newCount++;
    }
  }

  applyRadioIdCacheToList(newList, newCount);
  inferModeStatusesFromHeard(newList, newCount);

  bool listChanged = !heardListsEqual(heardList, heardCount, newList, newCount);

  if (arr.size() == 0 || newCount == 0) {
    if (heardCount != 0) {
      clearHeard();
      requestRedraw();
    }
    setFooterText("API OK, no entries");
    return true;
  }

  if (listChanged) {
    clearHeard();
    for (int i = 0; i < newCount; i++) heardList[i] = newList[i];
    heardCount = newCount;
    requestRedraw();
  }

  if (newCount > 0) {
    if (newList[0].rxFreq.length()) currentRxFreq = newList[0].rxFreq;
    if (newList[0].txFreq.length()) currentTxFreq = newList[0].txFreq;
    queueRadioIdLookupForRecord(newList[0]);
    if (!sameRecord(liveRecord, newList[0])) {
      liveRecord = newList[0];
      updateSessionStatsFromRecord(liveRecord);
      liveRecordMillis = millis();
      requestRedraw();
      noteScreenActivity(false);
    } else {
      liveRecordMillis = millis();
    }
  }

  setFooterText("Updated from " + cfg.pistarHost);
  return true;
}

String htmlDecodeBasic(String s) {
  s.replace("&nbsp;", " ");
  s.replace("&amp;", "&");
  s.replace("&quot;", "\"");
  s.replace("&#39;", "'");
  s.replace("&lt;", "<");
  s.replace("&gt;", ">");
  return s;
}

String extractHtmlText(const String& html) {
  String out;
  out.reserve(html.length());
  bool inTag = false;
  for (size_t i = 0; i < html.length(); i++) {
    char c = html[i];
    if (c == '<') { inTag = true; continue; }
    if (c == '>') { inTag = false; continue; }
    if (!inTag) out += c;
  }
  out = htmlDecodeBasic(out);
  out.replace("\n", " ");
  out.replace("\r", " ");
  out.trim();
  while (out.indexOf("  ") >= 0) out.replace("  ", " ");
  return out;
}

bool extractTableCellAfterHeader(const String& html, const String& headerLabel, String& valueOut) {
  int h = html.indexOf(">" + headerLabel + "</th>");
  if (h < 0) return false;
  int tdStart = html.indexOf("<td", h);
  if (tdStart < 0) return false;
  int gt = html.indexOf(">", tdStart);
  if (gt < 0) return false;
  int tdEnd = html.indexOf("</td>", gt);
  if (tdEnd < 0) return false;
  valueOut = extractHtmlText(html.substring(gt + 1, tdEnd));
  return valueOut.length() > 0;
}

bool extractDivCellAfterHeader(const String& html, const String& headerLabel, String& valueOut) {
  int h = html.indexOf(">" + headerLabel + "</div>");
  if (h < 0) return false;
  int cell = html.indexOf("divTableCell", h);
  if (cell < 0) return false;
  int gt = html.indexOf(">", cell);
  if (gt < 0) return false;
  int end = html.indexOf("</div>", gt);
  if (end < 0) return false;
  valueOut = extractHtmlText(html.substring(gt + 1, end));
  return valueOut.length() > 0;
}

bool extractWpsdTableFirstCellValue(const String& html, String& valueOut) {
  int tableStart = html.indexOf("<div class=\"divTable\"");
  if (tableStart < 0) return false;
  int bodyStart = html.indexOf("<div class=\"divTableBody\"", tableStart);
  if (bodyStart < 0) return false;
  int row2 = html.indexOf("<div class=\"divTableRow", bodyStart);
  if (row2 < 0) return false;
  row2 = html.indexOf("<div class=\"divTableRow", row2 + 1);
  if (row2 < 0) return false;

  int cell = html.indexOf("divTableCell", row2);
  if (cell < 0) return false;
  int gt = html.indexOf(">", cell);
  if (gt < 0) return false;
  int end = html.indexOf("</div>", gt);
  if (end < 0) return false;

  valueOut = extractHtmlText(html.substring(gt + 1, end));
  return valueOut.length() > 0;
}

bool extractPiStarTrxValue(const String& html, String& valueOut) {
  return extractTableCellAfterHeader(html, "Trx", valueOut);
}

bool extractWpsdStatusPill(const String& html, const String& label, ModeStatus& st) {
  int searchFrom = 0;
  while (true) {
    int pill = html.indexOf("<div class='status-pill ", searchFrom);
    if (pill < 0) return false;
    int pillEnd = html.indexOf("</div>", pill);
    if (pillEnd < 0) return false;

    int labelSpan = html.indexOf("<span>", pill);
    if (labelSpan < 0 || labelSpan > pillEnd) {
      searchFrom = pill + 10;
      continue;
    }
    int labelEnd = html.indexOf("</span>", labelSpan);
    if (labelEnd < 0 || labelEnd > pillEnd) {
      searchFrom = pill + 10;
      continue;
    }

    String pillLabel = extractHtmlText(html.substring(labelSpan, labelEnd + 7));
    if (pillLabel != label) {
      searchFrom = pillEnd + 6;
      continue;
    }

    st.known = true;
    String openTag = html.substring(pill, min(pill + 80, (int)html.length()));
    if (openTag.indexOf(" paused") >= 0 || openTag.indexOf(" inactive") >= 0) st.enabled = false;
    else st.enabled = true;
    return true;
  }
}

bool extractWpsdRadioInfoValue(const String& html, const String& headerLabel, String& valueOut) {
  int tableStart = html.indexOf("<div class=\"divTable\"");
  if (tableStart < 0) return false;
  int bodyStart = html.indexOf("<div class=\"divTableBody\"", tableStart);
  if (bodyStart < 0) return false;

  int row1 = html.indexOf("<div class=\"divTableRow", bodyStart);
  if (row1 < 0) return false;
  int row1End = html.indexOf("</div>", row1);
  if (row1End < 0) return false;
  int row2 = html.indexOf("<div class=\"divTableRow", row1End);
  if (row2 < 0) return false;

  String headers[16];
  int headerCount = 0;
  int pos = row1;
  while (headerCount < 16) {
    int cell = html.indexOf("divTableHeadCell", pos);
    if (cell < 0 || cell > row2) break;
    int gt = html.indexOf(">", cell);
    if (gt < 0 || gt > row2) break;
    int end = html.indexOf("</div>", gt);
    if (end < 0 || end > row2) break;
    headers[headerCount++] = extractHtmlText(html.substring(gt + 1, end));
    pos = end + 6;
  }

  String values[16];
  int valueCount = 0;
  pos = row2;
  while (valueCount < 16) {
    int cell = html.indexOf("divTableCell", pos);
    if (cell < 0) break;
    int gt = html.indexOf(">", cell);
    if (gt < 0) break;
    int end = html.indexOf("</div>", gt);
    if (end < 0) break;
    values[valueCount++] = extractHtmlText(html.substring(gt + 1, end));
    pos = end + 6;
    if (valueCount >= headerCount) break;
  }

  for (int i = 0; i < headerCount && i < valueCount; i++) {
    if (headers[i] == headerLabel) {
      valueOut = values[i];
      return valueOut.length() > 0;
    }
  }
  return false;
}

void parseModeCellWPSD(const String& html, const String& label, ModeStatus& st) {
  int pos = html.indexOf(">" + label + "</div>");
  if (pos < 0) return;
  int divStart = html.lastIndexOf("<div", pos);
  if (divStart < 0) return;
  String snippet = html.substring(divStart, min(pos + (int)label.length() + 20, (int)html.length()));
  st.known = true;
  if (snippet.indexOf("disabled-mode-cell") >= 0) st.enabled = false;
  else st.enabled = true;
}

void parseModeCellPiStar(const String& html, const String& label, ModeStatus& st) {
  int pos = html.indexOf(">" + label + "</td>");
  if (pos < 0) return;
  int tdStart = html.lastIndexOf("<td", pos);
  if (tdStart < 0) return;
  int gt = html.indexOf(">", tdStart);
  if (gt < 0 || gt > pos) return;
  String openTag = html.substring(tdStart, gt + 1);
  st.known = true;
  if (openTag.indexOf("#606060") >= 0 || openTag.indexOf("aria-disabled") >= 0) st.enabled = false;
  else st.enabled = true;
}

bool parseDashboardStatusHtml(const String& payload) {
  ModeStatus oldDMR = modeDMR;
  ModeStatus oldDSTAR = modeDSTAR;
  ModeStatus oldYSF = modeYSF;
  ModeStatus oldP25 = modeP25;
  ModeStatus oldNXDN = modeNXDN;
  ModeStatus oldPOCSAG = modePOCSAG;
  String oldHotspotRxFreq = hotspotRxFreq;
  String oldHotspotTxFreq = hotspotTxFreq;
  String oldHotspotStatusText = hotspotStatusText;

  bool hadKnownModes = modeDMR.known || modeDSTAR.known || modeYSF.known || modeP25.known || modeNXDN.known || modePOCSAG.known;
  resetModeStatuses();

  String rx, tx, statusText;
  if (normalizedBackend() == "wpsd") {
    extractWpsdStatusPill(payload, "DMR", modeDMR);
    extractWpsdStatusPill(payload, "D-Star", modeDSTAR);
    extractWpsdStatusPill(payload, "YSF", modeYSF);
    extractWpsdStatusPill(payload, "P25", modeP25);
    extractWpsdStatusPill(payload, "NXDN", modeNXDN);
    extractWpsdStatusPill(payload, "POCSAG", modePOCSAG);

    extractWpsdRadioInfoValue(payload, "TX Freq.", tx);
    extractWpsdRadioInfoValue(payload, "RX Freq.", rx);
    if (!tx.length() && !rx.length()) {
      if (extractWpsdRadioInfoValue(payload, "TX/RX Freq.", tx) && !rx.length()) rx = tx;
    }
    extractWpsdTableFirstCellValue(payload, statusText);
  } else {
    parseModeCellPiStar(payload, "DMR", modeDMR);
    parseModeCellPiStar(payload, "D-Star", modeDSTAR);
    parseModeCellPiStar(payload, "YSF", modeYSF);
    parseModeCellPiStar(payload, "P25", modeP25);
    parseModeCellPiStar(payload, "NXDN", modeNXDN);
    parseModeCellPiStar(payload, "POCSAG", modePOCSAG);

    extractTableCellAfterHeader(payload, "Tx", tx);
    extractTableCellAfterHeader(payload, "Rx", rx);
    extractPiStarTrxValue(payload, statusText);
  }

  tx = trimSafe(tx);
  rx = trimSafe(rx);
  statusText = trimSafe(statusText);
  tx.replace(" MHz", "");
  tx.replace("MHz", "");
  rx.replace(" MHz", "");
  rx.replace("MHz", "");
  tx.trim();
  rx.trim();

  if (tx.length()) hotspotTxFreq = tx;
  if (rx.length()) hotspotRxFreq = rx;
  if (statusText.length()) hotspotStatusText = statusText;

  bool modesKnownNow = modeDMR.known || modeDSTAR.known || modeYSF.known || modeP25.known || modeNXDN.known || modePOCSAG.known;
  if (!modesKnownNow && hadKnownModes) {
    modeDMR = oldDMR;
    modeDSTAR = oldDSTAR;
    modeYSF = oldYSF;
    modeP25 = oldP25;
    modeNXDN = oldNXDN;
    modePOCSAG = oldPOCSAG;
    modesKnownNow = true;
  }

  bool modeStatusChanged = !allModeStatusesEqual(oldDMR, oldDSTAR, oldYSF, oldP25, oldNXDN, oldPOCSAG) ||
                           oldHotspotRxFreq != hotspotRxFreq || oldHotspotTxFreq != hotspotTxFreq ||
                           oldHotspotStatusText != hotspotStatusText;
  if (modeStatusChanged) {
    requestRedraw();
    if (oldHotspotStatusText != hotspotStatusText) noteScreenActivity(false);
  }

  return modesKnownNow || hotspotRxFreq.length() || hotspotTxFreq.length();
}

bool httpGetString(const String& url, String& payload, int& codeOut) {
  HTTPClient http;
  http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
  http.setTimeout(HTTP_READ_TIMEOUT_MS);
  if (!http.begin(url)) {
    codeOut = -1;
    return false;
  }
  codeOut = http.GET();
  if (codeOut == 200) payload = http.getString();
  http.end();
  return codeOut == 200;
}

bool fetchModeStatus() {
  if (WiFi.status() != WL_CONNECTED) {
    wifiOk = false;
    setFooterText("Wi-Fi disconnected");
    requestRedraw();
    return false;
  }

  bool ok = false;
  String payload;
  int code = 0;

  if (httpGetString(dashboardStatusUrl(), payload, code)) {
    ok = parseDashboardStatusHtml(payload) || ok;
  }

  String freqUrl = dashboardFreqUrl();
  if (freqUrl != dashboardStatusUrl()) {
    payload = "";
    code = 0;
    if (httpGetString(freqUrl, payload, code)) {
      ok = parseDashboardStatusHtml(payload) || ok;
    }
  }

  if (!ok) {
    if (code == -1) setFooterText("Dashboard begin failed");
    else if (code != 0 && code != 200) setFooterText("Dashboard HTTP: " + String(code));
    else setFooterText("Dashboard parse failed");
  }

  return ok;
}

bool fetchLastHeard() {
  if (WiFi.status() != WL_CONNECTED) {
    wifiOk = false;
    setFooterText("Wi-Fi disconnected");
    requestRedraw();
    return false;
  }

  HTTPClient http;
  String url = heardUrl();
  http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
  http.setTimeout(HTTP_READ_TIMEOUT_MS);

  if (!http.begin(url)) {
    setFooterText("HTTP begin failed");
    requestRedraw();
    return false;
  }

  int code = http.GET();
  if (code != 200) {
    setFooterText("HTTP error: " + String(code));
    http.end();
    requestRedraw();
    return false;
  }

  String payload = http.getString();
  http.end();

  return parseHeardArray(payload);
}

bool fetchAllData() {
  bool ok = false;

  if (nextFetchHeard) {
    ok = fetchLastHeard();
  } else {
    ok = fetchModeStatus();
  }

  nextFetchHeard = !nextFetchHeard;
  return ok;
}

static const bool TOUCH_SWAP_XY = true;
static const bool TOUCH_FLIP_X  = false;
static const bool TOUCH_FLIP_Y  = true;
static const unsigned long TOUCH_DEBOUNCE_MS = 90;
static const unsigned long TOUCH_POLL_GRACE_MS = 700;

bool getTouchPoint(int& x, int& y) {
  if (!ts.touched()) return false;

  TS_Point p = ts.getPoint();

#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  x = constrain((int)p.x, 0, SCREEN_W - 1);
  y = constrain((int)p.y, 0, SCREEN_H - 1);
  if (cfg.flipScreen) {
    x = (SCREEN_W - 1) - x;
    y = (SCREEN_H - 1) - y;
  }
  return true;
#else
  int rawX = p.x;
  int rawY = p.y;

  if (TOUCH_SWAP_XY) {
    x = map(rawY, touchMinY, touchMaxY, 0, SCREEN_W - 1);
    y = map(rawX, touchMinX, touchMaxX, 0, SCREEN_H - 1);
  } else {
    x = map(rawX, touchMinX, touchMaxX, 0, SCREEN_W - 1);
    y = map(rawY, touchMinY, touchMaxY, 0, SCREEN_H - 1);
  }

  if (TOUCH_FLIP_X) x = (SCREEN_W - 1) - x;
  if (TOUCH_FLIP_Y) y = (SCREEN_H - 1) - y;
  if (cfg.flipScreen) {
    x = (SCREEN_W - 1) - x;
    y = (SCREEN_H - 1) - y;
  }

  x = constrain(x, 0, SCREEN_W - 1);
  y = constrain(y, 0, SCREEN_H - 1);

  return true;
#endif
}

void handleTouch() {
  static unsigned long lastTouch = 0;
  if (millis() - lastTouch < TOUCH_DEBOUNCE_MS) return;

  int x, y;
  if (!getTouchPoint(x, y)) return;
  lastTouch = millis();
  lastInteractionMillis = lastTouch;
  bool wasScreensaverActive = screensaverActive;
  noteUserInteraction(true);
  if (wasScreensaverActive) {
    tft.fillScreen(theme.bg);
    requestRedraw();
    return;
  }

  if (detailPopupOpen) {
    detailPopupOpen = false;
    selectedHeardIndex = -1;
    requestRedraw();
    return;
  }

  if (currentPage == PAGE_STAT) {
    currentPage = previousPage;
    tft.fillScreen(theme.bg);
    requestRedraw();
    return;
  }

  if (y <= UY(32)) {
    const int tabGap = UW(4);
    const int tabW = UW(54);
    const int startX = UX(6);
    const bool showApTab = setupRequired && apModeActive;
    const bool showStatTab = !showApTab;

    const int clockW = UW(68);
    const int clockH = UH(24);
    const int clockX = UX(246);
    const int clockY = UY(6);

    if (x >= clockX && x < clockX + clockW && y >= clockY && y < clockY + clockH) {
      previousPage = currentPage;
      currentPage = PAGE_ABOUT;
      requestRedraw();
      return;
    }

    int tabIndex = -1;
    for (int i = 0; i < 4; i++) {
      int tabX = startX + i * (tabW + tabGap);
      if (x >= tabX && x < tabX + tabW) {
        tabIndex = i;
        break;
      }
    }

    if (tabIndex == 0) currentPage = PAGE_LIVE;
    else if (tabIndex == 1) currentPage = PAGE_LAST;
    else if (tabIndex == 2) currentPage = PAGE_STATUS;
    else if (tabIndex == 3 && showApTab) currentPage = PAGE_AP;
    else if (tabIndex == 3 && showStatTab) currentPage = PAGE_STAT;
    else return;

    requestRedraw();
    return;
  }

  if (currentPage == PAGE_LAST) {
    int rows = min(heardCount, 5);
    for (int i = 0; i < rows; i++) {
      int rowY = UY(76) + (i * UH(28));
      if (x >= UX(14) && x <= SCREEN_W - UX(14) && y >= rowY && y <= rowY + UH(24)) {
        selectedHeardIndex = i;
        detailPopupOpen = true;
        requestRedraw();
        return;
      }
    }
  }

  if (currentPage == PAGE_STATUS) {
    if (x >= BRIGHT_SLIDER_X && x <= BRIGHT_SLIDER_X + BRIGHT_SLIDER_W &&
        y >= BRIGHT_SLIDER_Y - UH(8) && y <= BRIGHT_SLIDER_Y + BRIGHT_SLIDER_H + UH(8)) {
      setBrightnessFromTouchX(x);
      applyBrightness();
      saveConfigToFile();
      requestRedraw();
      return;
    }
  }
}

void initBacklight() {
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, TFT_BACKLIGHT_ON);
}

static void deselectSpiDevices() {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  pinMode(TFT_CS, OUTPUT);
  pinMode(TOUCH_CS, OUTPUT);
  pinMode(SD_CS, OUTPUT);
  digitalWrite(TFT_CS, HIGH);
  digitalWrite(TOUCH_CS, HIGH);
  digitalWrite(SD_CS, HIGH);
#endif
}

void setup() {
  Serial.begin(115200);
  delay(300);
  Serial.println();
  Serial.println("Boot start");
  Serial.printf("Reset reason CPU0: %d\n", (int)rtc_get_reset_reason(0));

  initBacklight();
  deselectSpiDevices();

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R || DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  sdSPI.begin(SD_SCLK, SD_MISO, SD_MOSI, SD_CS);
#endif

  setDefaultTheme();

  tft.init();
  tft.setRotation(1);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  jc3248InitLvglBridge();
#endif
  animateBootProgress("POWERING DISPLAY", 0, 14, 24);

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  deselectSpiDevices();
#endif
  if (!SD.begin(SD_CS, sdSPI, 10000000)) {
    activeStorageLabel = "FLASH";
    animateBootProgress("SD mount failed / using FLASH", 14, 38, 18);
  } else {
    activeStorageLabel = "SD";
    animateBootProgress("SD CARD READY", 14, 38, 18);
  }

  bool configOk = loadConfig();
  configOkState = configOk;
  setupRequired = !configOk;
  if (!configOk) {
    animateBootProgress("Config missing / invalid", 38, 62, 18);
  } else {
    animateBootProgress("CONFIG LOADED", 38, 62, 18);
  }

  tft.fillRoundRect(UX(92), UY(142), UW(136), UH(28), UR(8), theme.panel);
  tft.drawRoundRect(UX(92), UY(142), UW(136), UH(28), UR(8), theme.accent);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.setTextDatum(MC_DATUM);
  tft.drawString(getDeviceCallsign(), SCREEN_W / 2, UY(156));
  tft.setTextDatum(TL_DATUM);

  lastLiveActiveState = isLiveActive();
  lastInteractionMillis = millis();

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  touchSPI.begin(TOUCH_CLK, TOUCH_MISO, TOUCH_MOSI, TOUCH_CS);
  ts.begin(touchSPI);
  ts.setRotation(0);
  animateBootProgress("TOUCH ONLINE", 62, 78, 18);
#else
  ts.begin(touchSPI);
  ts.setRotation(1);
  animateBootProgress("TOUCH ONLINE", 62, 78, 18);
#endif

  animateBootProgress("LINKING WIFI", 78, 88, 18);
  if (configOk && configLooksValid()) {
    connectWiFi(WIFI_CONNECT_TIMEOUT_MS);
  } else {
    wifiOk = false;
  }

  if (!configOk || !wifiOk) {
    setupRequired = true;
    animateBootProgress("STARTING SETUP AP", 88, 94, 18);
    startSetupAccessPoint();
    currentPage = PAGE_AP;
    previousPage = PAGE_AP;
  } else {
    setupRequired = false;
  }
  setupWebServer();

  animateBootProgress(wifiOk ? "BOOT COMPLETE" : (apModeActive ? "SETUP AP READY" : "BOOT COMPLETE / OFFLINE"), 94, 100, 18);
  delay(350);

  setFooterText(wifiOk ? ("Web UI: http://" + WiFi.localIP().toString() + "/") : (apModeActive ? ("Setup required: " + apSsid + " / http://" + WiFi.softAPIP().toString() + "/") : "Wi-Fi failed"));

  applyDisplaySettings();
  tft.fillScreen(theme.bg);

  requestRedraw();
  redrawCurrentPage(true);
  lastHeaderClock = getHeaderTimeText();
  uiDirty = false;
}

void loop() {
  handleTouch();
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  jc3248HandleLvgl();
#endif
  server.handleClient();

  if (pendingConfigApply && millis() >= pendingConfigApplyAt) {
    pendingConfigApply = false;

    if (configOkState && configLooksValid()) {
      setupRequired = false;
      beginWiFiConnectAttempt(true);
    } else {
      wifiConnectInProgress = false;
      wifiOk = false;
      setupRequired = true;
      currentPage = PAGE_AP;
      startSetupAccessPoint();
      setFooterText(apModeActive ? ("Setup required: " + apSsid + " / http://" + WiFi.softAPIP().toString() + "/") : "Config saved from web");
      requestRedraw();
    }
  }

  if (millis() - lastWifiStateCheck >= 250) {
    lastWifiStateCheck = millis();

    bool configValidNow = configLooksValid();
    bool stationConnected = (WiFi.status() == WL_CONNECTED);

    if (!configValidNow) {
      wifiConnectInProgress = false;
      wifiOk = false;
      setupRequired = true;
      currentPage = PAGE_AP;
      if (!apModeActive) startSetupAccessPoint();
    } else {
      if (wifiConnectInProgress) {
        handleWiFiConnectProgress();
        stationConnected = (WiFi.status() == WL_CONNECTED);
      }

      if (stationConnected) {
        wifiOk = true;
        setupRequired = false;
        syncTimeIfNeeded();
        if (apModeActive) stopSetupAccessPoint();
      } else {
        wifiOk = false;
        if (!setupRequired && !wifiConnectInProgress && millis() - lastWifiRetryMillis >= 10000UL) {
          beginWiFiConnectAttempt(false);
        }
      }
    }
  }

  bool pageNeedsNetworkPolling = (currentPage == PAGE_LIVE || currentPage == PAGE_LAST);
  bool touchGraceActive = (millis() - lastInteractionMillis) < TOUCH_POLL_GRACE_MS;

  if (!setupRequired && wifiOk && !apModeActive && !wifiConnectInProgress &&
      pageNeedsNetworkPolling && !touchGraceActive &&
      millis() - lastPoll >= cfg.refreshMs) {
    lastPoll = millis();
    fetchAllData();
  }

  processRadioIdLookups();

  if (cfg.screensaverEnabled && !screensaverActive && millis() - lastInteractionMillis >= cfg.screensaverTimeoutMs) {
    setScreensaverActive(true);
  }

  if (screensaverActive && millis() - lastScreensaverFrameMillis >= 60000) {
    lastScreensaverFrameMillis = millis();
	requestRedraw();
  }

  bool liveActiveNow = isLiveActive();
  if (liveActiveNow != lastLiveActiveState) {
    lastLiveActiveState = liveActiveNow;
    if (currentPage == PAGE_LIVE) requestRedraw();
  }

  if (!screensaverActive && millis() - lastClockCheck >= 5000) {
    lastClockCheck = millis();
    String nowClock = getHeaderTimeText();
    if (nowClock != lastHeaderClock) {
      lastHeaderClock = nowClock;
      requestRedraw();
    }
  }

  if (uiDirty || footerDirty) {
    redrawCurrentPage(uiDirty);
    footerDirty = false;
    uiDirty = false;
  }
}
