#pragma once

// =============================================================================
// WebReaderScreen.h - Minimal Web Reader ("Reader Mode") for T-Deck Pro
//
// A Lynx-like web page reader that fetches URLs over WiFi, strips HTML to
// readable text, extracts links as numbered references, and paginates
// content for the e-ink display with keyboard navigation.
//
// Requires WiFi capability - wrap includes with appropriate guards.
// Shortcut key: B (Browser) from home screen.
//
// Network backends:
//   - WiFi (default): Uses ESP32 WiFi STA mode. Credentials saved to SD.
//   - 4G/PPP (future): When PPP is established via A7682E modem, the same
//     HTTPClient code works transparently over cellular. To enable this,
//     establish PPP before calling fetchPage() and the ESP networking
//     stack will route through the modem automatically.
//
// Modes:
//   WIFI_SETUP   - Connect to a WiFi network (scan + password entry)
//   HOME         - URL bar, bookmarks, history
//   FETCHING     - Loading indicator while downloading
//   READING      - Paginated text view with numbered [links]
//   LINK_SELECT  - Pick a link by number to follow
// =============================================================================

#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include "variant.h"
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <mbedtls/platform.h>
#include <esp_heap_caps.h>
#include <SD.h>
#include <vector>
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#endif
#include "Utf8CP437.h"
#include "../NodePrefs.h"

// Forward declarations
class UITask;

// ============================================================================
// PSRAM allocator for mbedTLS
//
// ESP32-S3 internal RAM has only ~30KB largest contiguous block after WiFi
// init, but TLS handshake needs ~32-48KB for I/O buffers. Redirect mbedtls
// allocations to PSRAM (which has plenty of contiguous space) so HTTPS works.
// ============================================================================
static void* _webreader_psram_calloc(size_t num, size_t size) {
  void* ptr = heap_caps_calloc(num, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
  if (!ptr) ptr = calloc(num, size);  // Fallback to internal if PSRAM fails
  return ptr;
}

static void _webreader_psram_free(void* ptr) {
  free(ptr);  // Works for both PSRAM and internal allocations
}

static bool _webreader_tls_psram_set = false;

static void ensureTlsUsesPsram() {
#if defined(MBEDTLS_PLATFORM_MEMORY) || defined(CONFIG_MBEDTLS_PLATFORM_MEMORY)
  if (!_webreader_tls_psram_set) {
    mbedtls_platform_set_calloc_free(_webreader_psram_calloc, _webreader_psram_free);
    _webreader_tls_psram_set = true;
    Serial.println("WebReader: mbedTLS allocator redirected to PSRAM");
  }
#else
  if (!_webreader_tls_psram_set) {
    Serial.println("WebReader: WARNING - mbedTLS PSRAM redirect not available");
    _webreader_tls_psram_set = true;
  }
#endif
}

// ============================================================================
// Configuration
// ============================================================================
#define WEB_CACHE_DIR       "/web"
#define WEB_BOOKMARKS_FILE  "/web/bookmarks.txt"
#define WEB_HISTORY_FILE    "/web/history.txt"

// IRC configuration
#define IRC_CONFIG_FILE     "/web/irc.cfg"
#define IRC_MAX_MESSAGES    64       // Circular buffer size
#define IRC_MAX_MSG_LEN     200      // Max display length per message
#define IRC_MAX_NICK_LEN    16
#define IRC_MAX_CHANNEL_LEN 64
#define IRC_MAX_HOST_LEN    128
#define IRC_LINE_BUF_SIZE   512      // Raw IRC protocol line buffer
#define IRC_COMPOSE_MAX     180      // Max compose message length
#define IRC_RECONNECT_MS    10000    // Auto-reconnect delay
#define IRC_PING_TIMEOUT_MS 300000   // 5 min no data = connection dead

struct IRCMessage {
  char nick[IRC_MAX_NICK_LEN];
  char text[IRC_MAX_MSG_LEN];
  bool isSystem;  // true for join/part/server notices
};
#define WEB_MAX_PAGE_SIZE   196608  // Max HTML download size (192KB)
#define WEB_MAX_TEXT_SIZE   98304   // Max extracted text size (96KB)
#define WEB_MAX_LINKS       512     // Max links per page
#define WEB_MAX_URL_LEN     256
#define WEB_MAX_BOOKMARKS   20
#define WEB_MAX_HISTORY     30
#define WEB_MAX_SSIDS       10
#define WEB_WIFI_PASS_LEN   64
#define WEB_USER_AGENT      "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"

// ============================================================================
// Link structure - stores extracted hyperlinks
// ============================================================================
struct WebLink {
  char url[WEB_MAX_URL_LEN];
  char text[48];   // Display text for the link (truncated)
};

// ============================================================================
// Form structures - stores parsed HTML forms for user interaction
// ============================================================================
#define WEB_MAX_FORMS        4
#define WEB_MAX_FORM_FIELDS  16
#define WEB_MAX_FIELD_VALUE  128

struct WebFormField {
  char name[64];                    // name= attribute
  char value[WEB_MAX_FIELD_VALUE];  // Current/default value
  char label[48];                   // Display label (from <label> or placeholder)
  char type;                        // 't'=text, 'p'=password, 'h'=hidden, 's'=submit, 'c'=checkbox
};

struct WebForm {
  char action[WEB_MAX_URL_LEN];    // Form action URL
  bool isPost;                      // true=POST, false=GET
  WebFormField fields[WEB_MAX_FORM_FIELDS];
  int fieldCount;
  int textFieldCount;               // Visible (non-hidden) field count
  int formMarker;                   // Index in text where form marker was placed
};

// ============================================================================
// HTML Parser - minimal tag-stripping reader-mode extractor
// ============================================================================

// Tags whose content should be completely removed (not just the tag itself)
// Note: form/input/button/label are NOT skipped — they're parsed for form support.
// header is NOT skipped — it contains login/navigation links on most sites.
// nav IS skipped — its links are redundant with header and add clutter.
static const char* HTML_SKIP_TAGS[] = {
  "script", "style", "nav", "footer", "aside",
  "iframe", "noscript", "svg", "select", "textarea", nullptr
};

// Tags that produce a paragraph break
static const char* HTML_BLOCK_TAGS[] = {
  "div", "br", "tr", "blockquote", "article", "section", "figcaption",
  "ul", "ol", "dl",
  nullptr
};

// Tags that get paragraph-style double breaks
static const char* HTML_PARA_TAGS[] = {
  "p", nullptr
};

inline bool tagNameEquals(const char* tag, int tagLen, const char* name) {
  int nameLen = strlen(name);
  if (tagLen != nameLen) return false;
  for (int i = 0; i < nameLen; i++) {
    char c = tag[i];
    if (c >= 'A' && c <= 'Z') c += 32; // tolower
    if (c != name[i]) return false;
  }
  return true;
}

inline bool isSkipTag(const char* tag, int tagLen) {
  for (int i = 0; HTML_SKIP_TAGS[i]; i++) {
    if (tagNameEquals(tag, tagLen, HTML_SKIP_TAGS[i])) return true;
  }
  return false;
}

inline bool isBlockTag(const char* tag, int tagLen) {
  for (int i = 0; HTML_BLOCK_TAGS[i]; i++) {
    if (tagNameEquals(tag, tagLen, HTML_BLOCK_TAGS[i])) return true;
  }
  return false;
}

// Decode common HTML entities: &amp; &lt; &gt; &quot; &apos; &nbsp; &#NNN; &#xHH;
inline int decodeHtmlEntity(const char* src, int srcLen, int pos, char* outChar) {
  if (pos >= srcLen || src[pos] != '&') return 0;

  // Find the semicolon
  int end = pos + 1;
  int maxSearch = pos + 10; // entities are short
  if (maxSearch > srcLen) maxSearch = srcLen;
  while (end < maxSearch && src[end] != ';' && src[end] != '&' && src[end] != '<') end++;
  if (end >= maxSearch || src[end] != ';') return 0;

  int entLen = end - pos - 1; // length between & and ;
  const char* ent = src + pos + 1;

  if (entLen == 3 && memcmp(ent, "amp", 3) == 0) { *outChar = '&'; return end - pos + 1; }
  if (entLen == 2 && memcmp(ent, "lt", 2) == 0) { *outChar = '<'; return end - pos + 1; }
  if (entLen == 2 && memcmp(ent, "gt", 2) == 0) { *outChar = '>'; return end - pos + 1; }
  if (entLen == 4 && memcmp(ent, "quot", 4) == 0) { *outChar = '"'; return end - pos + 1; }
  if (entLen == 4 && memcmp(ent, "apos", 4) == 0) { *outChar = '\''; return end - pos + 1; }
  if (entLen == 4 && memcmp(ent, "nbsp", 4) == 0) { *outChar = ' '; return end - pos + 1; }
  if (entLen == 5 && memcmp(ent, "mdash", 5) == 0) { *outChar = '-'; return end - pos + 1; }
  if (entLen == 5 && memcmp(ent, "ndash", 5) == 0) { *outChar = '-'; return end - pos + 1; }
  if (entLen == 5 && memcmp(ent, "lsquo", 5) == 0) { *outChar = '\''; return end - pos + 1; }
  if (entLen == 5 && memcmp(ent, "rsquo", 5) == 0) { *outChar = '\''; return end - pos + 1; }
  if (entLen == 5 && memcmp(ent, "ldquo", 5) == 0) { *outChar = '"'; return end - pos + 1; }
  if (entLen == 5 && memcmp(ent, "rdquo", 5) == 0) { *outChar = '"'; return end - pos + 1; }
  if (entLen == 5 && memcmp(ent, "laquo", 5) == 0) { *outChar = '<'; return end - pos + 1; }
  if (entLen == 5 && memcmp(ent, "raquo", 5) == 0) { *outChar = '>'; return end - pos + 1; }
  if (entLen == 5 && memcmp(ent, "trade", 5) == 0) { *outChar = ' '; return end - pos + 1; }
  if (entLen == 4 && memcmp(ent, "copy", 4) == 0) { *outChar = 'c'; return end - pos + 1; }
  if (entLen == 4 && memcmp(ent, "bull", 4) == 0) { *outChar = '*'; return end - pos + 1; }
  // hellip handled specially in caller (outputs "..." multi-char)

  // Numeric: &#NNN; or &#xHH;
  if (entLen >= 2 && ent[0] == '#') {
    uint32_t cp = 0;
    if (ent[1] == 'x' || ent[1] == 'X') {
      for (int i = 2; i < entLen; i++) {
        char c = ent[i];
        if (c >= '0' && c <= '9') cp = cp * 16 + (c - '0');
        else if (c >= 'a' && c <= 'f') cp = cp * 16 + (c - 'a' + 10);
        else if (c >= 'A' && c <= 'F') cp = cp * 16 + (c - 'A' + 10);
        else break;
      }
    } else {
      for (int i = 1; i < entLen; i++) {
        if (ent[i] >= '0' && ent[i] <= '9') cp = cp * 10 + (ent[i] - '0');
        else break;
      }
    }
    if (cp < 128) {
      *outChar = (char)cp;
    } else {
      // Try CP437 mapping for common chars
      uint8_t glyph = unicodeToCP437(cp);
      *outChar = glyph ? (char)glyph : '?';
    }
    return end - pos + 1;
  }

  return 0; // Unknown entity
}

// Extract the tag name from inside a < > bracket.
// Returns length of tag name, and sets isClosing if it starts with /
inline int extractTagName(const char* inside, int insideLen, bool& isClosing) {
  int i = 0;
  isClosing = false;
  while (i < insideLen && (inside[i] == ' ' || inside[i] == '\t')) i++;
  if (i < insideLen && inside[i] == '/') { isClosing = true; i++; }
  int start = i;
  while (i < insideLen && inside[i] != ' ' && inside[i] != '/' &&
         inside[i] != '>' && inside[i] != '\t' && inside[i] != '\n') i++;
  return i - start; // tagName starts at inside+start, length is return value
}

// Extract href attribute value from inside an <a ...> tag
inline bool extractHref(const char* tagContent, int tagLen, char* hrefOut, int hrefMax) {
  // Search for href= (case insensitive)
  for (int i = 0; i < tagLen - 5; i++) {
    char c0 = tagContent[i]; if (c0 >= 'A' && c0 <= 'Z') c0 += 32;
    char c1 = tagContent[i+1]; if (c1 >= 'A' && c1 <= 'Z') c1 += 32;
    char c2 = tagContent[i+2]; if (c2 >= 'A' && c2 <= 'Z') c2 += 32;
    char c3 = tagContent[i+3]; if (c3 >= 'A' && c3 <= 'Z') c3 += 32;

    if (c0 == 'h' && c1 == 'r' && c2 == 'e' && c3 == 'f') {
      int j = i + 4;
      while (j < tagLen && tagContent[j] == ' ') j++;
      if (j < tagLen && tagContent[j] == '=') {
        j++;
        while (j < tagLen && tagContent[j] == ' ') j++;
        char quote = 0;
        if (j < tagLen && (tagContent[j] == '"' || tagContent[j] == '\'')) {
          quote = tagContent[j]; j++;
        }
        int start = j;
        if (quote) {
          while (j < tagLen && tagContent[j] != quote) j++;
        } else {
          while (j < tagLen && tagContent[j] != ' ' && tagContent[j] != '>') j++;
        }
        int len = j - start;
        if (len >= hrefMax) len = hrefMax - 1;
        memcpy(hrefOut, tagContent + start, len);
        hrefOut[len] = '\0';
        return len > 0;
      }
    }
  }
  return false;
}

// Encode spaces in URL as %20 (in-place, buffer must have room)
inline void encodeUrlSpaces(char* url, int maxLen) {
  int len = strlen(url);
  // Count spaces to check if result fits
  int spaces = 0;
  for (int i = 0; i < len; i++) {
    if (url[i] == ' ') spaces++;
  }
  int newLen = len + spaces * 2;  // Each space becomes 3 chars (%20) instead of 1
  if (newLen >= maxLen) return;    // Won't fit, leave as-is

  // Work backwards to encode in-place
  url[newLen] = '\0';
  int dst = newLen - 1;
  for (int src = len - 1; src >= 0; src--) {
    if (url[src] == ' ') {
      url[dst--] = '0';
      url[dst--] = '2';
      url[dst--] = '%';
    } else {
      url[dst--] = url[src];
    }
  }
}

// Resolve a relative URL against a base URL
inline void resolveUrl(const char* base, const char* relative, char* out, int outMax) {
  if (!relative || !relative[0]) {
    strncpy(out, base, outMax - 1);
    out[outMax - 1] = '\0';
    return;
  }

  // Already absolute
  if (strncmp(relative, "http://", 7) == 0 || strncmp(relative, "https://", 8) == 0) {
    strncpy(out, relative, outMax - 1);
    out[outMax - 1] = '\0';
    return;
  }

  // Protocol-relative //example.com/...
  if (relative[0] == '/' && relative[1] == '/') {
    snprintf(out, outMax, "https:%s", relative);
    return;
  }

  // Find scheme + host from base
  const char* schemeEnd = strstr(base, "://");
  if (!schemeEnd) {
    strncpy(out, relative, outMax - 1);
    out[outMax - 1] = '\0';
    return;
  }
  const char* hostStart = schemeEnd + 3;
  const char* pathStart = strchr(hostStart, '/');

  if (relative[0] == '/') {
    // Absolute path
    int hostLen = pathStart ? (pathStart - base) : strlen(base);
    snprintf(out, outMax, "%.*s%s", hostLen, base, relative);
  } else {
    // Relative path - append to base directory
    if (pathStart) {
      const char* lastSlash = strrchr(pathStart, '/');
      int baseLen = lastSlash ? (lastSlash - base + 1) : strlen(base);
      snprintf(out, outMax, "%.*s%s", baseLen, base, relative);
    } else {
      snprintf(out, outMax, "%s/%s", base, relative);
    }
  }
}


// Extract a named attribute value from inside a tag
inline bool extractAttr(const char* tag, int tagLen, const char* attrName,
                        char* out, int outMax) {
  int nameLen = strlen(attrName);
  for (int i = 0; i < tagLen - nameLen; i++) {
    bool match = true;
    for (int j = 0; j < nameLen && match; j++) {
      char c = tag[i + j];
      if (c >= 'A' && c <= 'Z') c += 32;
      if (c != attrName[j]) match = false;
    }
    if (!match) continue;
    int j = i + nameLen;
    while (j < tagLen && tag[j] == ' ') j++;
    if (j >= tagLen || tag[j] != '=') continue;
    j++;
    while (j < tagLen && tag[j] == ' ') j++;
    char quote = 0;
    if (j < tagLen && (tag[j] == '"' || tag[j] == '\'')) { quote = tag[j]; j++; }
    int start = j;
    if (quote) { while (j < tagLen && tag[j] != quote) j++; }
    else { while (j < tagLen && tag[j] != ' ' && tag[j] != '>' && tag[j] != '/') j++; }
    int len = j - start;
    if (len >= outMax) len = outMax - 1;
    memcpy(out, tag + start, len);
    out[len] = '\0';
    return len > 0;
  }
  return false;
}

// ============================================================================
// Main HTML-to-text parser
//
// Strips HTML tags, extracts text content, collects links and forms.
// Outputs clean text with paragraph breaks as double newlines.
// Links are inserted as [N] markers in the text flow.
// Forms are inserted as {FN} markers with visible fields.
// ============================================================================

struct ParseResult {
  int textLen;
  int linkCount;
  int formCount;
};

inline ParseResult parseHtml(const char* html, int htmlLen,
                             char* textOut, int textMax,
                             WebLink* links, int maxLinks,
                             WebForm* forms, int maxForms,
                             const char* baseUrl) {
  ParseResult result = {0, 0, 0};
  int ti = 0;       // text output index
  int hi = 0;       // html input index
  int skipDepth = 0; // depth inside skip tags
  bool inTag = false;
  bool inAnchor = false;
  int anchorTextStart = 0;
  char currentHref[WEB_MAX_URL_LEN] = {0};
  bool lastWasBreak = true; // Track if we just emitted a paragraph break (avoid doubles)
  bool lastWasSpace = false;

  // Form parsing state
  bool inForm = false;
  int currentForm = -1;
  char pendingLabel[48] = {0};
  bool inLabel = false;
  int labelTextStart = 0;

  // Find <body> tag to skip <head> section
  for (int i = 0; i < htmlLen - 6; i++) {
    char c = html[i];
    if (c == '<') {
      // Check for <body
      char b1 = html[i+1]; if (b1 >= 'A' && b1 <= 'Z') b1 += 32;
      char b2 = html[i+2]; if (b2 >= 'A' && b2 <= 'Z') b2 += 32;
      char b3 = html[i+3]; if (b3 >= 'A' && b3 <= 'Z') b3 += 32;
      char b4 = html[i+4]; if (b4 >= 'A' && b4 <= 'Z') b4 += 32;
      if (b1 == 'b' && b2 == 'o' && b3 == 'd' && b4 == 'y') {
        // Skip to after the >
        while (i < htmlLen && html[i] != '>') i++;
        hi = i + 1;
        break;
      }
    }
  }

  while (hi < htmlLen && ti < textMax - 4) {
    char c = html[hi];

    if (c == '<') {
      // Start of a tag
      int tagStart = hi + 1;
      int tagEnd = tagStart;
      // Find closing >
      while (tagEnd < htmlLen && html[tagEnd] != '>') tagEnd++;
      if (tagEnd >= htmlLen) break;

      int insideLen = tagEnd - tagStart;
      const char* inside = html + tagStart;

      // Extract tag name
      bool isClosing = false;
      int nameStart = 0;
      while (nameStart < insideLen && (inside[nameStart] == ' ' || inside[nameStart] == '\t'))
        nameStart++;
      if (nameStart < insideLen && inside[nameStart] == '/') {
        isClosing = true;
        nameStart++;
      }
      int nameEnd = nameStart;
      while (nameEnd < insideLen && inside[nameEnd] != ' ' && inside[nameEnd] != '/' &&
             inside[nameEnd] != '>' && inside[nameEnd] != '\t' && inside[nameEnd] != '\n')
        nameEnd++;

      const char* tagName = inside + nameStart;
      int tagNameLen = nameEnd - nameStart;

      // Check for skip tags (script, style, nav, etc.)
      if (isSkipTag(tagName, tagNameLen)) {
        if (isClosing) {
          if (skipDepth > 0) skipDepth--;
        } else {
          // Check if self-closing
          bool selfClose = (insideLen > 0 && inside[insideLen - 1] == '/');
          if (!selfClose) skipDepth++;
        }
        hi = tagEnd + 1;
        continue;
      }

      if (skipDepth > 0) {
        hi = tagEnd + 1;
        continue;
      }

      // Handle paragraph tags - emit double break
      bool isPara = false;
      for (int pt = 0; HTML_PARA_TAGS[pt]; pt++) {
        if (tagNameEquals(tagName, tagNameLen, HTML_PARA_TAGS[pt])) { isPara = true; break; }
      }
      if (isPara) {
        if (!lastWasBreak && ti > 0) {
          textOut[ti++] = '\n';
          if (ti < textMax - 2) textOut[ti++] = '\n';
          lastWasBreak = true;
          lastWasSpace = false;
        }
      }

      // Handle block tags - emit single break
      if (!isPara && isBlockTag(tagName, tagNameLen)) {
        if (!lastWasBreak && ti > 0) {
          textOut[ti++] = '\n';
          lastWasBreak = true;
          lastWasSpace = false;
        }
      }

      // Handle <h1>-<h6> opening: ensure line break before heading
      if (!isClosing && tagNameLen == 2 && tagName[0] == 'h' &&
          tagName[1] >= '1' && tagName[1] <= '6') {
        if (ti < textMax - 12) {
          if (!lastWasBreak && ti > 0) {
            textOut[ti++] = '\n';
          }
          // Separator line before h4 headings (work titles on listing pages)
          if (tagName[1] == '4' && ti > 1) {
            const char* sep = "--------";
            for (int s = 0; sep[s]; s++) textOut[ti++] = sep[s];
            textOut[ti++] = '\n';
          }
          // Double break before h1/h2 for visual separation
          if (tagName[1] <= '2' && ti > 0 && ti < textMax - 1) {
            textOut[ti++] = '\n';
          }
          // Wrap h1-h4 headings with * markers to make them stand out
          if (tagName[1] <= '4' && ti < textMax - 2) {
            textOut[ti++] = '*';
            textOut[ti++] = ' ';
          }
          lastWasBreak = false;
          lastWasSpace = true;
        }
      }

      // Handle closing </h1>-</h6>: closing marker + line break
      if (isClosing && tagNameLen == 2 && tagName[0] == 'h' &&
          tagName[1] >= '1' && tagName[1] <= '6') {
        if (ti < textMax - 2) {
          // Trim trailing space before closing marker
          if (ti > 0 && textOut[ti-1] == ' ') ti--;
          if (tagName[1] <= '4') {
            textOut[ti++] = ' ';
            textOut[ti++] = '*';
          }
          textOut[ti++] = '\n';
          lastWasBreak = true;
          lastWasSpace = false;
        }
      }

      // Handle <a href="..."> - collect link
      if (!isClosing && tagNameLen == 1 && (tagName[0] == 'a' || tagName[0] == 'A')) {
        char href[WEB_MAX_URL_LEN] = {0};
        if (extractHref(inside, insideLen, href, WEB_MAX_URL_LEN)) {
          // Skip javascript:, mailto:, and # fragment-only links
          if (strncmp(href, "javascript:", 11) != 0 &&
              strncmp(href, "mailto:", 7) != 0 &&
              href[0] != '#') {
            resolveUrl(baseUrl, href, currentHref, WEB_MAX_URL_LEN);
            inAnchor = true;
            anchorTextStart = ti;
          }
        }
      }

      // Handle </a> - finalize link
      if (isClosing && tagNameLen == 1 && (tagName[0] == 'a' || tagName[0] == 'A')) {
        if (inAnchor && currentHref[0] && result.linkCount < maxLinks) {
          WebLink& link = links[result.linkCount];
          strncpy(link.url, currentHref, WEB_MAX_URL_LEN - 1);
          link.url[WEB_MAX_URL_LEN - 1] = '\0';

          // Extract link display text from what was accumulated
          int linkTextLen = ti - anchorTextStart;
          if (linkTextLen > (int)sizeof(link.text) - 1)
            linkTextLen = sizeof(link.text) - 1;
          if (linkTextLen > 0) {
            memcpy(link.text, textOut + anchorTextStart, linkTextLen);
          }
          link.text[linkTextLen] = '\0';

          // Append link number marker: [N]
          result.linkCount++;
          if (ti < textMax - 8) {
            int n = result.linkCount;
            textOut[ti++] = '[';
            if (n >= 100) textOut[ti++] = '0' + (n / 100);
            if (n >= 10) textOut[ti++] = '0' + ((n / 10) % 10);
            textOut[ti++] = '0' + (n % 10);
            textOut[ti++] = ']';
            lastWasSpace = false;
            lastWasBreak = false;
          }
        }
        inAnchor = false;
        currentHref[0] = '\0';
      }

      // Handle <li> - always comma-separated inline flow
      if (!isClosing && tagNameLen == 2 && tagName[0] == 'l' && tagName[1] == 'i') {
        if (ti < textMax - 3) {
          while (ti > 0 && textOut[ti-1] == ' ') ti--;
          if (ti > 0 && textOut[ti-1] != '\n' && textOut[ti-1] != ',') {
            textOut[ti++] = ',';
            textOut[ti++] = ' ';
            lastWasSpace = true;
          } else if (ti > 0 && textOut[ti-1] == ',') {
            textOut[ti++] = ' ';
            lastWasSpace = true;
          }
          lastWasBreak = (ti == 0 || textOut[ti-1] == '\n');
        }
      }

      // Handle <dt> - inline flow with space separator (matches browser's inline stats)
      if (!isClosing && tagNameLen == 2 && tagName[0] == 'd' && tagName[1] == 't') {
        if (ti > 0 && !lastWasSpace && !lastWasBreak) {
          textOut[ti++] = ' ';
          lastWasSpace = true;
        }
      }

      // Handle <dd> - just a space after the term (keeps "Label: Value" on one line)
      if (!isClosing && tagNameLen == 2 && tagName[0] == 'd' && tagName[1] == 'd') {
        if (ti > 0 && !lastWasSpace && !lastWasBreak) {
          textOut[ti++] = ' ';
          lastWasSpace = true;
        }
        lastWasBreak = false;
      }

      // ---- Form handling ----

      // <form action="..." method="...">
      if (!isClosing && tagNameLen == 4 &&
          tagName[0] == 'f' && tagName[1] == 'o' && tagName[2] == 'r' && tagName[3] == 'm') {
        if (result.formCount < maxForms) {
          currentForm = result.formCount;
          WebForm& f = forms[currentForm];
          memset(&f, 0, sizeof(WebForm));
          char actionBuf[WEB_MAX_URL_LEN] = {0};
          extractAttr(inside, insideLen, "action", actionBuf, WEB_MAX_URL_LEN);
          if (actionBuf[0]) {
            resolveUrl(baseUrl, actionBuf, f.action, WEB_MAX_URL_LEN);
          } else {
            strncpy(f.action, baseUrl, WEB_MAX_URL_LEN - 1);
          }
          char methodBuf[8] = {0};
          extractAttr(inside, insideLen, "method", methodBuf, sizeof(methodBuf));
          // tolower
          for (int m = 0; methodBuf[m]; m++) {
            if (methodBuf[m] >= 'A' && methodBuf[m] <= 'Z') methodBuf[m] += 32;
          }
          f.isPost = (strcmp(methodBuf, "post") == 0);
          inForm = true;
          // Emit form marker in text
          if (!lastWasBreak && ti > 0) { textOut[ti++] = '\n'; textOut[ti++] = '\n'; }
          f.formMarker = ti;
          if (ti < textMax - 12) {
            textOut[ti++] = '-'; textOut[ti++] = '-';
            textOut[ti++] = ' '; textOut[ti++] = 'F';
            textOut[ti++] = 'o'; textOut[ti++] = 'r';
            textOut[ti++] = 'm'; textOut[ti++] = ' ';
            textOut[ti++] = '{'; textOut[ti++] = 'F';
            textOut[ti++] = '0' + (result.formCount + 1);
            textOut[ti++] = '}';
            textOut[ti++] = ' '; textOut[ti++] = '-'; textOut[ti++] = '-';
            textOut[ti++] = '\n';
          }
          lastWasBreak = false; lastWasSpace = false;
        }
      }

      // </form>
      if (isClosing && tagNameLen == 4 &&
          tagName[0] == 'f' && tagName[1] == 'o' && tagName[2] == 'r' && tagName[3] == 'm') {
        if (inForm && currentForm >= 0) {
          // Emit submit hint if form has visible fields
          WebForm& f = forms[currentForm];
          if (f.textFieldCount > 0 && ti < textMax - 20) {
            textOut[ti++] = '\n';
            textOut[ti++] = '['; textOut[ti++] = 'f';
            textOut[ti++] = ':'; textOut[ti++] = ' ';
            textOut[ti++] = 'F'; textOut[ti++] = 'i';
            textOut[ti++] = 'l'; textOut[ti++] = 'l';
            textOut[ti++] = ' '; textOut[ti++] = 'f';
            textOut[ti++] = 'o'; textOut[ti++] = 'r';
            textOut[ti++] = 'm'; textOut[ti++] = ']';
            textOut[ti++] = '\n'; textOut[ti++] = '\n';
          }
          result.formCount++;
          lastWasBreak = true;
        }
        inForm = false;
        currentForm = -1;
      }

      // <input type="..." name="..." value="...">
      if (!isClosing && tagNameLen == 5 &&
          tagName[0] == 'i' && tagName[1] == 'n' && tagName[2] == 'p' &&
          tagName[3] == 'u' && tagName[4] == 't') {
        if (inForm && currentForm >= 0) {
          WebForm& f = forms[currentForm];
          if (f.fieldCount < WEB_MAX_FORM_FIELDS) {
            WebFormField& fld = f.fields[f.fieldCount];
            memset(&fld, 0, sizeof(WebFormField));
            char typeBuf[16] = "text";
            extractAttr(inside, insideLen, "type", typeBuf, sizeof(typeBuf));
            for (int m = 0; typeBuf[m]; m++) {
              if (typeBuf[m] >= 'A' && typeBuf[m] <= 'Z') typeBuf[m] += 32;
            }
            extractAttr(inside, insideLen, "name", fld.name, sizeof(fld.name));
            extractAttr(inside, insideLen, "value", fld.value, sizeof(fld.value));

            if (strcmp(typeBuf, "hidden") == 0) {
              fld.type = 'h';
              // type 'h' marks it as hidden — no display needed
            } else if (strcmp(typeBuf, "password") == 0) {
              fld.type = 'p';
              // Use pending label or placeholder
              if (pendingLabel[0]) { strncpy(fld.label, pendingLabel, sizeof(fld.label)-1); pendingLabel[0] = 0; }
              else extractAttr(inside, insideLen, "placeholder", fld.label, sizeof(fld.label));
              if (!fld.label[0]) strncpy(fld.label, "Password", sizeof(fld.label)-1);
              f.textFieldCount++;
              // Emit field display
              if (ti < textMax - 40) {
                int w = snprintf(textOut + ti, textMax - ti, "%s: [****]\n", fld.label);
                if (w > 0) ti += w;
              }
              lastWasBreak = false; lastWasSpace = false;
            } else if (strcmp(typeBuf, "submit") == 0) {
              fld.type = 's';
              if (!fld.value[0]) strncpy(fld.value, "Submit", sizeof(fld.value)-1);
              strncpy(fld.label, fld.value, sizeof(fld.label)-1);
            } else if (strcmp(typeBuf, "checkbox") == 0) {
              fld.type = 'c';
              if (pendingLabel[0]) { strncpy(fld.label, pendingLabel, sizeof(fld.label)-1); pendingLabel[0] = 0; }
            } else {
              // text, email, search, etc — treat as text input
              fld.type = 't';
              if (pendingLabel[0]) { strncpy(fld.label, pendingLabel, sizeof(fld.label)-1); pendingLabel[0] = 0; }
              else extractAttr(inside, insideLen, "placeholder", fld.label, sizeof(fld.label));
              if (!fld.label[0]) {
                // Use name as fallback label
                strncpy(fld.label, fld.name, sizeof(fld.label)-1);
              }
              f.textFieldCount++;
              // Emit field display
              if (ti < textMax - 40) {
                int w = snprintf(textOut + ti, textMax - ti, "%s: [___]\n", fld.label);
                if (w > 0) ti += w;
              }
              lastWasBreak = false; lastWasSpace = false;
            }
            f.fieldCount++;
          }
        }
      }

      // <label> / </label> - capture text for next input
      if (tagNameLen == 5 &&
          tagName[0] == 'l' && tagName[1] == 'a' && tagName[2] == 'b' &&
          tagName[3] == 'e' && tagName[4] == 'l') {
        if (!isClosing) {
          inLabel = true;
          labelTextStart = ti;
        } else if (inLabel) {
          // Capture label text
          int labelLen = ti - labelTextStart;
          if (labelLen > (int)sizeof(pendingLabel) - 1)
            labelLen = sizeof(pendingLabel) - 1;
          if (labelLen > 0)
            memcpy(pendingLabel, textOut + labelTextStart, labelLen);
          pendingLabel[labelLen] = '\0';
          // Remove trailing colon/space
          while (labelLen > 0 && (pendingLabel[labelLen-1] == ':' || pendingLabel[labelLen-1] == ' '))
            pendingLabel[--labelLen] = '\0';
          // Rewind text output — label text is used for form field display only,
          // not shown in reading view (avoids duplicate: label text + field marker)
          ti = labelTextStart;
          lastWasBreak = (ti == 0 || textOut[ti-1] == '\n');
          lastWasSpace = (ti > 0 && textOut[ti-1] == ' ');
          inLabel = false;
        }
      }

      // <button> - render content inline (don't skip, as buttons may wrap links)
      // Form submit buttons are handled separately in form rendering.

      hi = tagEnd + 1;
      continue;
    }

    // Skip content inside skip tags
    if (skipDepth > 0) {
      hi++;
      continue;
    }

    // HTML entity
    if (c == '&') {
      // Special multi-char entity: &hellip; → ...
      if (hi + 7 <= htmlLen && memcmp(html + hi, "&hellip;", 8) == 0) {
        if (ti < textMax - 3) {
          textOut[ti++] = '.';
          textOut[ti++] = '.';
          textOut[ti++] = '.';
          lastWasSpace = false;
          lastWasBreak = false;
        }
        hi += 8;
        continue;
      }
      char decoded;
      int consumed = decodeHtmlEntity(html, htmlLen, hi, &decoded);
      if (consumed > 0) {
        if (decoded == ' ') {
          if (!lastWasSpace && !lastWasBreak) {
            textOut[ti++] = ' ';
            lastWasSpace = true;
          }
        } else {
          textOut[ti++] = decoded;
          lastWasSpace = false;
          lastWasBreak = false;
        }
        hi += consumed;
        continue;
      }
    }

    // Whitespace collapsing
    if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
      if (!lastWasSpace && !lastWasBreak && ti > 0) {
        textOut[ti++] = ' ';
        lastWasSpace = true;
      }
      hi++;
      continue;
    }

    // Regular character
    textOut[ti++] = c;
    lastWasSpace = false;
    lastWasBreak = false;
    hi++;
  }

  textOut[ti] = '\0';
  
  // Post-processing: clean up stray commas from empty list items
  // (e.g. <li> containing only images produce ", " with no content)
  int wi = 0;
  for (int ri = 0; ri < ti; ri++) {
    // Skip ", " that follows a newline (first empty items)
    if (textOut[ri] == ',' && ri + 1 < ti && textOut[ri+1] == ' ') {
      // Check if preceded by newline or start of string
      if (wi == 0 || textOut[wi-1] == '\n') {
        ri++; // skip the space too
        continue;
      }
      // Check if followed by another comma (consecutive empty items)
      if (ri + 2 < ti && textOut[ri+2] == ',') {
        ri++; // skip ", " — next iteration handles the next comma
        continue;
      }
    }
    textOut[wi++] = textOut[ri];
  }
  textOut[wi] = '\0';
  ti = wi;
  
  // Also collapse multiple consecutive newlines (max 2)
  wi = 0;
  int nlCount = 0;
  for (int ri = 0; ri < ti; ri++) {
    if (textOut[ri] == '\n') {
      nlCount++;
      if (nlCount <= 2) textOut[wi++] = textOut[ri];
    } else {
      nlCount = 0;
      textOut[wi++] = textOut[ri];
    }
  }
  textOut[wi] = '\0';
  ti = wi;

  result.textLen = ti;
  return result;
}


// ============================================================================
// Word Wrap - reuse same algorithm as TextReaderScreen
// (included from TextReaderScreen.h via findLineBreak, or duplicated here
//  for standalone compilation)
// ============================================================================
#ifndef WEBREADER_WORD_WRAP
#define WEBREADER_WORD_WRAP
struct WebWrapResult {
  int lineEnd;
  int nextStart;
};

inline WebWrapResult webFindLineBreak(const char* buffer, int bufLen,
                                       int lineStart, int maxChars) {
  WebWrapResult result;
  result.lineEnd = lineStart;
  result.nextStart = lineStart;

  if (lineStart >= bufLen) return result;

  int charCount = 0;
  int lastBreakPoint = -1;
  bool inWord = false;

  for (int i = lineStart; i < bufLen; i++) {
    char c = buffer[i];

    if (c == '\n') {
      result.lineEnd = i;
      result.nextStart = i + 1;
      if (result.nextStart < bufLen && buffer[result.nextStart] == '\r')
        result.nextStart++;
      return result;
    }
    if (c == '\r') {
      result.lineEnd = i;
      result.nextStart = i + 1;
      if (result.nextStart < bufLen && buffer[result.nextStart] == '\n')
        result.nextStart++;
      return result;
    }

    if (c >= 32) {
      if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) continue;

      charCount++;
      if (c == ' ' || c == '\t') {
        if (inWord) {
          lastBreakPoint = i;
          inWord = false;
        }
      } else if (c == '-') {
        if (inWord) lastBreakPoint = i + 1;
      } else {
        inWord = true;
      }

      if (charCount >= maxChars) {
        if (lastBreakPoint > lineStart) {
          result.lineEnd = lastBreakPoint;
          result.nextStart = lastBreakPoint;
          while (result.nextStart < bufLen &&
                 (buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
            result.nextStart++;
        } else {
          result.lineEnd = i;
          result.nextStart = i;
        }
        return result;
      }
    }
  }

  result.lineEnd = bufLen;
  result.nextStart = bufLen;
  return result;
}
#endif

// ============================================================================
// WebReaderScreen
// ============================================================================

class WebReaderScreen : public UIScreen {
public:
  enum Mode {
    WIFI_SETUP,    // Connect to WiFi
    HOME,          // URL entry + bookmarks
    FETCHING,      // Loading page
    READING,       // Viewing extracted text
    LINK_SELECT,   // Choosing a link number
    FORM_FILL,     // Filling in a form
    IRC_SETUP,     // IRC server/nick/channel configuration
    IRC_CHAT,      // Live IRC chat view
    DOWNLOAD_DONE  // File download completed (EPUB, etc.)
  };

  enum WifiState {
    WIFI_IDLE,
    WIFI_SCANNING,
    WIFI_SCAN_DONE,
    WIFI_ENTERING_PASS,
    WIFI_CONNECTING,
    WIFI_CONNECTED,
    WIFI_FAILED
  };

private:
  UITask* _task;
  NodePrefs* _prefs;
  Mode _mode;
  bool _initialized;
  uint8_t _lastFontPref;
  DisplayDriver* _display;

  // Display layout (calculated once)
  int _charsPerLine;
  int _linesPerPage;
  int _lineHeight;
  int _footerHeight;

  // WiFi state
  WifiState _wifiState;
  String _ssidList[WEB_MAX_SSIDS];
  int _ssidCount;
  int _selectedSSID;
  char _wifiPass[WEB_WIFI_PASS_LEN];
  int _wifiPassLen;
  unsigned long _wifiTimeout;
  String _connectedSSID;

  // URL entry
  char _urlBuffer[WEB_MAX_URL_LEN];
  int _urlLen;
  int _urlCursor;  // Cursor position within URL

  // Page content
  char* _textBuffer;       // PSRAM allocated - extracted text
  int _textLen;
  WebLink* _links;         // PSRAM allocated - extracted links
  int _linkCount;
  char _pageTitle[64];     // Page title (from <title> tag)
  char _currentUrl[WEB_MAX_URL_LEN];
  std::vector<String> _backHistory;  // URL stack for back navigation

  // Pagination
  std::vector<int> _pageOffsets;  // Byte offset of each page start
  int _currentPage;
  int _totalPages;

  // Bookmarks & History
  std::vector<String> _bookmarks;
  std::vector<String> _history;
  int _homeSelected;  // Selected item in home view (0=IRC, 1=URL, 2=Search, then bookmarks, then history)
  int _homeScrollY;   // Pixel scroll offset for home view
  bool _urlEditing;   // True when URL bar is active for text entry
  bool _searchEditing; // True when search bar is active for text entry
  char _searchBuffer[128]; // Search query text
  int _searchLen;

  // Link selection
  int _linkInput;     // Accumulated link number digits
  bool _linkInputActive;

  // Brief toast notification
  char _toastMsg[32];
  unsigned long _toastTime;  // millis() when toast was set

  // Forms
  WebForm* _forms;  // PSRAM allocated
  int _formCount;
  int _activeForm;      // Which form is being filled (-1 = none)
  int _activeField;     // Which field in the active form (index into visible fields)
  bool _formFieldEditing; // True when typing into a form field
  char _formEditBuf[WEB_MAX_FIELD_VALUE]; // Edit buffer for current field
  int _formEditLen;
  unsigned long _formLastCharAt; // millis() of last char typed (for brief password reveal)

  // Cookies (simple key=value store per domain)
  #define WEB_MAX_COOKIES 16
  struct Cookie {
    char domain[64];
    char name[64];
    char value[512]; // AO3 session cookies are 300+ chars of base64
  };
  Cookie* _cookies;  // PSRAM allocated
  int _cookieCount;

  // Fetch state
  unsigned long _fetchStartTime;
  int _fetchProgress;    // Bytes received so far
  int _fetchRetryCount;  // Current retry attempt (0 = first try)
  String _fetchError;

  // Persistent TLS client — kept alive between page loads to reuse TCP
  // connections to the same host (avoids repeated 5-8s TLS handshakes).
  // Destroyed on error, host change, or WiFi disconnect.
  WiFiClientSecure* _tlsClient;
  String _tlsHost;  // Host _tlsClient is connected/configured for

  // Download state (for EPUB/file downloads to SD)
  char _downloadedFile[64];   // Filename of last downloaded file
  bool _downloadOk;           // true if download succeeded
  bool _requestTextReader;    // true when user wants to open downloaded file in reader

  // ---- IRC State ----
  WiFiClient* _ircClient;       // WiFiClient (plain) or WiFiClientSecure (TLS)
  bool _ircUseTLS;              // true when connected via TLS
  bool _ircConnected;
  bool _ircRegistered;       // Received 001 RPL_WELCOME
  bool _ircJoined;           // Successfully joined channel

  char _ircHost[IRC_MAX_HOST_LEN];
  uint16_t _ircPort;
  char _ircNick[IRC_MAX_NICK_LEN];
  char _ircChannel[IRC_MAX_CHANNEL_LEN];

  // Message circular buffer
  IRCMessage* _ircMessages;  // PSRAM allocated
  int _ircMsgHead;           // Next write position
  int _ircMsgCount;          // Total messages stored

  // Protocol line buffer (partial line accumulation)
  char _ircLineBuf[IRC_LINE_BUF_SIZE];
  int _ircLineLen;

  // Compose buffer
  char _ircCompose[IRC_COMPOSE_MAX];
  int _ircComposeLen;
  bool _ircComposing;        // true when typing a message

  // Display state
  int _ircScrollPos;         // Scroll offset from bottom (0 = newest)
  int _ircLinesPerPage;

  // Setup screen state
  int _ircSetupField;        // 0=host, 1=port, 2=nick, 3=channel, 4=connect button
  char _ircSetupBuf[IRC_MAX_HOST_LEN]; // Edit buffer for setup fields
  int _ircSetupBufLen;
  bool _ircSetupEditing;     // true when typing into a field

  // Connection management
  unsigned long _ircLastDataTime;
  unsigned long _ircReconnectAt;  // 0 = no reconnect pending
  bool _ircDirty;                 // New messages need rendering
  unsigned long _ircLastRender;   // Throttle directRedraw from poll()

  // ---- Memory Management ----

  bool allocateBuffers() {
    if (!_textBuffer) {
      _textBuffer = (char*)ps_malloc(WEB_MAX_TEXT_SIZE);
      if (!_textBuffer) {
        Serial.println("WebReader: Failed to allocate text buffer from PSRAM");
        return false;
      }
    }
    if (!_links) {
      _links = (WebLink*)ps_malloc(sizeof(WebLink) * WEB_MAX_LINKS);
      if (!_links) {
        Serial.println("WebReader: Failed to allocate links buffer from PSRAM");
        return false;
      }
    }
    return true;
  }

  void freeBuffers() {
    if (_textBuffer) { free(_textBuffer); _textBuffer = nullptr; }
    if (_links) { free(_links); _links = nullptr; }
    _textLen = 0;
    _linkCount = 0;
    _formCount = 0;
    _pageOffsets.clear();
  }

  // ---- Domain extraction from URL ----
  static void extractDomain(const char* url, char* domain, int domainMax) {
    const char* host = strstr(url, "://");
    if (host) host += 3; else host = url;
    int i = 0;
    while (host[i] && host[i] != '/' && host[i] != ':' && host[i] != '?' && host[i] != '#' && i < domainMax - 1) {
      domain[i] = host[i]; i++;
    }
    domain[i] = '\0';
  }

  // ---- Cookie Management ----
  void setCookie(const char* domain, const char* name, const char* value) {
    // Update existing cookie
    for (int i = 0; i < _cookieCount; i++) {
      if (strcmp(_cookies[i].domain, domain) == 0 &&
          strcmp(_cookies[i].name, name) == 0) {
        strncpy(_cookies[i].value, value, sizeof(_cookies[i].value) - 1);
        return;
      }
    }
    // Add new cookie
    if (_cookieCount < WEB_MAX_COOKIES) {
      Cookie& c = _cookies[_cookieCount++];
      strncpy(c.domain, domain, sizeof(c.domain) - 1);
      strncpy(c.name, name, sizeof(c.name) - 1);
      strncpy(c.value, value, sizeof(c.value) - 1);
    }
  }

  // Build Cookie header value for a domain
  String buildCookieHeader(const char* domain) {
    String result;
    for (int i = 0; i < _cookieCount; i++) {
      // Match domain (simple suffix match)
      if (strstr(domain, _cookies[i].domain) ||
          strcmp(domain, _cookies[i].domain) == 0) {
        if (result.length() > 0) result += "; ";
        result += _cookies[i].name;
        result += "=";
        result += _cookies[i].value;
      }
    }
    return result;
  }

  // Remove Cloudflare challenge cookies for a domain.
  // Stale __cf_bm and _cfuvid tokens from failed handshakes can poison
  // subsequent requests, causing Cloudflare to keep returning 525/503.
  void clearCloudflareCookies(const char* domain) {
    int dst = 0;
    for (int i = 0; i < _cookieCount; i++) {
      bool isCfDomain = (strstr(domain, _cookies[i].domain) ||
                         strcmp(domain, _cookies[i].domain) == 0);
      bool isCfCookie = (strncmp(_cookies[i].name, "__cf_bm", 7) == 0 ||
                         strncmp(_cookies[i].name, "_cfuvid", 7) == 0 ||
                         strncmp(_cookies[i].name, "cf_", 3) == 0);
      if (isCfDomain && isCfCookie) {
        Serial.printf("WebReader: Cleared CF cookie '%s'\n", _cookies[i].name);
        continue;  // Skip — don't copy to dst
      }
      if (dst != i) _cookies[dst] = _cookies[i];
      dst++;
    }
    _cookieCount = dst;
  }

  // Parse Set-Cookie header(s) from HTTP response
  void parseSetCookie(const String& headerVal, const char* domain) {
    // Format: name=value; Path=/; ...
    int eq = headerVal.indexOf('=');
    if (eq < 1) return;
    String name = headerVal.substring(0, eq);
    name.trim();
    int semi = headerVal.indexOf(';', eq);
    String value = (semi > 0) ? headerVal.substring(eq + 1, semi)
                              : headerVal.substring(eq + 1);
    value.trim();

    // Check for domain in cookie attributes
    char cookieDomain[64];
    strncpy(cookieDomain, domain, sizeof(cookieDomain) - 1);
    int domIdx = headerVal.indexOf("domain=");
    if (domIdx < 0) domIdx = headerVal.indexOf("Domain=");
    if (domIdx >= 0) {
      int dStart = domIdx + 7;
      int dEnd = headerVal.indexOf(';', dStart);
      String d = (dEnd > 0) ? headerVal.substring(dStart, dEnd) : headerVal.substring(dStart);
      d.trim();
      if (d.startsWith(".")) d = d.substring(1);
      strncpy(cookieDomain, d.c_str(), sizeof(cookieDomain) - 1);
    }
    setCookie(cookieDomain, name.c_str(), value.c_str());
    Serial.printf("Cookie SET: %s=%s (domain=%s)\n", name.c_str(), value.c_str(), cookieDomain);
  }

  // Capture all Set-Cookie headers from response using index iteration.
  // ESP32 HTTPClient's header("Set-Cookie") may only return the first/last
  // when multiple Set-Cookie headers exist. Iterating by index catches all.
  void captureResponseCookies(HTTPClient& http, const char* domain) {
    int hCount = http.headers();
    Serial.printf("Cookie capture: %d header slots, domain=%s\n", hCount, domain);
    int found = 0;
    for (int h = 0; h < hCount; h++) {
      String name = http.headerName(h);
      String val = http.header(h);
      Serial.printf("  Slot[%d] '%s' (%d chars) = '%.200s%s'\n", h, name.c_str(),
                    val.length(), val.c_str(), val.length() > 200 ? "..." : "");
      if (name.equalsIgnoreCase("Set-Cookie")) {
        // A single header entry might still contain multiple cookies
        // concatenated by ESP32 with comma. Try to split them.
        int start = 0;
        while (start < (int)val.length()) {
          int comma = val.indexOf(", ", start);
          String single;
          if (comma >= 0) {
            // Check if what follows looks like a new cookie (name=value before ;)
            String rest = val.substring(comma + 2);
            int eq = rest.indexOf('=');
            int semi = rest.indexOf(';');
            if (eq > 0 && (semi < 0 || eq < semi)) {
              single = val.substring(start, comma);
              start = comma + 2;
            } else {
              // Comma is part of a cookie value (e.g. date), skip
              comma = val.indexOf(", ", comma + 2);
              if (comma >= 0) {
                single = val.substring(start, comma);
                start = comma + 2;
              } else {
                single = val.substring(start);
                start = val.length();
              }
            }
          } else {
            single = val.substring(start);
            start = val.length();
          }
          parseSetCookie(single, domain);
          found++;
        }
      }
    }
    // Also try the name-based lookup in case index iteration missed something
    if (found == 0 && http.hasHeader("Set-Cookie")) {
      String sc = http.header("Set-Cookie");
      if (sc.length() > 0) {
        Serial.printf("Cookie: fallback name-based: %.80s\n", sc.c_str());
        parseSetCookie(sc, domain);
      }
    }
    if (found == 0 && !http.hasHeader("Set-Cookie")) {
      Serial.println("Cookie capture: NO Set-Cookie headers in response at all");
    }
    Serial.printf("Cookie jar now has %d cookies for domain %s\n", _cookieCount, domain);
  }

  // ---- URL Encoding ----
  static void urlEncode(const char* src, char* dst, int dstMax) {
    static const char hex[] = "0123456789ABCDEF";
    int di = 0;
    for (int i = 0; src[i] && di < dstMax - 4; i++) {
      char c = src[i];
      if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
          (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
        dst[di++] = c;
      } else if (c == ' ') {
        dst[di++] = '+';
      } else {
        dst[di++] = '%';
        dst[di++] = hex[(uint8_t)c >> 4];
        dst[di++] = hex[(uint8_t)c & 0x0F];
      }
    }
    dst[di] = '\0';
  }

  // Build form POST body from form fields
  String buildFormBody(const WebForm& form) {
    String body;
    for (int i = 0; i < form.fieldCount; i++) {
      const WebFormField& f = form.fields[i];
      if (!f.name[0]) continue;  // Skip unnamed fields
      if (body.length() > 0) body += "&";
      char encName[128], encVal[256];
      urlEncode(f.name, encName, sizeof(encName));
      urlEncode(f.value, encVal, sizeof(encVal));
      body += encName;
      body += "=";
      body += encVal;
    }
    return body;
  }

  // ---- WiFi Management ----
  // Note: On the BLE variant, WiFi and BLE coexist on the ESP32-S3 radio.
  // Some throughput reduction is normal. WiFi is started on-demand and stays
  // connected until explicitly disconnected or the device sleeps.
  // On the 4G variant, if PPP is active (modem providing network), WiFi is
  // not needed — HTTPClient routes through the PPP interface automatically.

  bool isWiFiConnected() {
    return WiFi.status() == WL_CONNECTED;
  }

  // Generic network check: returns true if any network interface is up.
  // Works for WiFi and will also detect PPP (4G modem) connections.
  bool isNetworkAvailable() {
    if (WiFi.status() == WL_CONNECTED) return true;
#ifdef HAS_4G_MODEM
    // When PPP is active via the A7682E modem, the ESP32 gets an IP on the
    // ppp netif. Check if we have a non-zero IP on any interface.
    // TODO: implement PPP status check when modem PPP driver is added
#endif
    return false;
  }

  void startWifiScan() {
    _wifiState = WIFI_SCANNING;

    // Show scanning splash before the blocking scan (3-5 seconds)
    if (_display) {
      _display->startFrame();
      _display->setColor(DisplayDriver::GREEN);
      _display->setTextSize(1);
      _display->setCursor(0, 0);
      _display->print("WiFi Setup");
      _display->drawRect(0, 11, _display->width(), 1);
      _display->setColor(DisplayDriver::LIGHT);
      _display->setTextSize(_prefs->smallTextSize());
      _display->setCursor(0, 18);
      _display->print("Scanning for networks...");
      _display->endFrame();
    }

    Serial.printf("WebReader: Starting WiFi scan (mode=%d, status=%d)\n",
                  WiFi.getMode(), WiFi.status());

    // Use blocking scan — takes 3-5 seconds, which is fine for e-ink.
    // Async scan has race conditions with WiFi.disconnect() on ESP32-S3.
    int n = WiFi.scanNetworks(false, false, false, 300);  // blocking, no hidden, active, 300ms/ch
    Serial.printf("WebReader: Scan complete, found %d networks\n", n);

    if (n > 0) {
      _ssidCount = min(n, WEB_MAX_SSIDS);
      for (int i = 0; i < _ssidCount; i++) {
        _ssidList[i] = WiFi.SSID(i);
        Serial.printf("  [%d] %s (RSSI %d)\n", i, _ssidList[i].c_str(), WiFi.RSSI(i));
      }
      WiFi.scanDelete();
      _selectedSSID = 0;
      _wifiState = WIFI_SCAN_DONE;
    } else if (n == 0) {
      _wifiState = WIFI_FAILED;
      _fetchError = "No networks found";
    } else {
      _wifiState = WIFI_FAILED;
      _fetchError = "Scan failed (err " + String(n) + ")";
      Serial.printf("WebReader: Scan error code: %d\n", n);
    }
  }

  void checkWifiScan() {
    int n = WiFi.scanComplete();
    if (n == WIFI_SCAN_RUNNING) {
      if (millis() > _wifiTimeout) {
        Serial.println("WebReader: scan timeout");
        _wifiState = WIFI_FAILED;
        _fetchError = "Scan timeout";
      }
      return;
    }
    if (n == WIFI_SCAN_FAILED || n < 0) {
      Serial.printf("WebReader: scanComplete returned %d (failed)\n", n);
      _wifiState = WIFI_FAILED;
      _fetchError = "Scan failed";
      return;
    }
    if (n == 0) {
      Serial.println("WebReader: scan found 0 networks");
      _wifiState = WIFI_FAILED;
      _fetchError = "No networks found";
      return;
    }

    Serial.printf("WebReader: scan found %d networks\n", n);

    _ssidCount = min(n, WEB_MAX_SSIDS);
    for (int i = 0; i < _ssidCount; i++) {
      _ssidList[i] = WiFi.SSID(i);
    }
    WiFi.scanDelete();
    _selectedSSID = 0;
    _wifiState = WIFI_SCAN_DONE;
  }

  void connectToSSID(const String& ssid, const char* password) {
    _wifiState = WIFI_CONNECTING;
    WiFi.begin(ssid.c_str(), password);
    _wifiTimeout = millis() + 15000;
    _connectedSSID = ssid;
  }

  void checkWifiConnect() {
    if (WiFi.status() == WL_CONNECTED) {
      _wifiState = WIFI_CONNECTED;
      Serial.printf("WebReader: WiFi connected to %s, IP: %s\n",
                    _connectedSSID.c_str(), WiFi.localIP().toString().c_str());
      // Save credentials to SD for auto-reconnect
      saveWifiCredentials(_connectedSSID.c_str(), _wifiPass);
      return;
    }
    if (millis() > _wifiTimeout) {
      _wifiState = WIFI_FAILED;
      _fetchError = "Connection timeout";
    }
  }

  // Show a brief "Connected!" confirmation splash, then transition to HOME.
  // Called after successful WiFi connection (auto or manual).
  void showConnectedAndGoHome() {
    if (_display) {
      _display->startFrame();
      _display->setColor(DisplayDriver::GREEN);
      _display->setTextSize(1);
      _display->setCursor(0, 0);
      _display->print("Web Reader");
      _display->drawRect(0, 11, _display->width(), 1);

      _display->setTextSize(_prefs->smallTextSize());
      _display->setCursor(0, 18);
      _display->print("Connected!");
      _display->setCursor(0, 30);
      _display->setColor(DisplayDriver::LIGHT);
      char ipBuf[48];
      snprintf(ipBuf, sizeof(ipBuf), "SSID: %s", _connectedSSID.c_str());
      _display->print(ipBuf);
      _display->setCursor(0, 40);
      snprintf(ipBuf, sizeof(ipBuf), "IP:   %s", WiFi.localIP().toString().c_str());
      _display->print(ipBuf);
      _display->endFrame();
    }
    delay(1500);  // Brief pause so user sees the confirmation
    _mode = HOME;
    directRedraw();  // Now show the URL entry page
  }

  void saveWifiCredentials(const char* ssid, const char* pass) {
    if (!SD.exists(WEB_CACHE_DIR)) SD.mkdir(WEB_CACHE_DIR);
    File f = SD.open("/web/wifi.cfg", FILE_WRITE);
    if (f) {
      f.println(ssid);
      f.println(pass);
      f.close();
    }
    digitalWrite(SDCARD_CS, HIGH);
  }

  bool loadAndAutoConnect() {
    File f = SD.open("/web/wifi.cfg", FILE_READ);
    if (!f) { digitalWrite(SDCARD_CS, HIGH); return false; }

    String ssid = f.readStringUntil('\n');
    String pass = f.readStringUntil('\n');
    f.close();
    digitalWrite(SDCARD_CS, HIGH);

    ssid.trim();
    pass.trim();

    if (ssid.length() == 0) return false;

    Serial.printf("WebReader: Auto-connecting to %s\n", ssid.c_str());
    // WiFi STA mode already set by main.cpp before enter()
    WiFi.begin(ssid.c_str(), pass.c_str());

    // Brief blocking wait (up to 5 seconds) during init
    unsigned long timeout = millis() + 5000;
    while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
      delay(100);
    }

    if (WiFi.status() == WL_CONNECTED) {
      _connectedSSID = ssid;
      _wifiState = WIFI_CONNECTED;
      Serial.printf("WebReader: Auto-connected, IP: %s\n",
                    WiFi.localIP().toString().c_str());
      return true;
    }
    return false;
  }

  // Read HTTP response body, handling both chunked and fixed-length transfers.
  // Chunked transfer encoding embeds chunk size headers in the stream:
  //   <hex-size>\r\n<data>\r\n ... 0\r\n\r\n
  // If we read raw from getStreamPtr(), those headers corrupt our HTML.
  int readResponseBody(HTTPClient& http, char* buffer, int maxLen) {
    int contentLen = http.getSize();
    WiFiClient* stream = http.getStreamPtr();
    int totalRead = 0;
    unsigned long lastSplash = millis();

    if (contentLen > 0) {
      // Known content length — read directly (no chunking)
      int toRead = min(contentLen, maxLen - 1);
      while (totalRead < toRead) {
        if (!stream->available()) {
          unsigned long waitStart = millis();
          while (!stream->available() && (millis() - waitStart) < 5000) {
            delay(10);
            yield();
          }
          if (!stream->available()) break;
        }
        int chunk = stream->readBytes(buffer + totalRead,
                                      min(1024, toRead - totalRead));
        if (chunk <= 0) break;
        totalRead += chunk;
        _fetchProgress = totalRead;
        if (_display && (millis() - lastSplash) >= 2000) {
          _display->startFrame();
          renderFetching(*_display);
          _display->endFrame();
          lastSplash = millis();
        }
        yield();
      }
    } else {
      // Chunked transfer or unknown length (-1)
      // Read chunk by chunk: each chunk starts with hex size + \r\n
      Serial.println("WebReader: Chunked transfer encoding detected");
      while (totalRead < maxLen - 1) {
        // Read chunk size line (hex + \r\n)
        String sizeLine = stream->readStringUntil('\n');
        sizeLine.trim(); // Remove \r
        if (sizeLine.length() == 0) {
          // Empty line, try to continue
          if (!stream->available()) break;
          continue;
        }

        // Parse hex chunk size
        int chunkSize = (int)strtol(sizeLine.c_str(), nullptr, 16);
        if (chunkSize == 0) break; // Final chunk — we're done

        // Read chunk data
        int chunkRead = 0;
        while (chunkRead < chunkSize && totalRead < maxLen - 1) {
          if (!stream->available()) {
            unsigned long waitStart = millis();
            while (!stream->available() && (millis() - waitStart) < 5000) {
              delay(10);
              yield();
            }
            if (!stream->available()) break;
          }
          int toGet = min(1024, min(chunkSize - chunkRead, maxLen - 1 - totalRead));
          int got = stream->readBytes(buffer + totalRead, toGet);
          if (got <= 0) break;
          totalRead += got;
          chunkRead += got;
          _fetchProgress = totalRead;
          if (_display && (millis() - lastSplash) >= 2000) {
            _display->startFrame();
            renderFetching(*_display);
            _display->endFrame();
            lastSplash = millis();
          }
          yield();
        }

        // Consume trailing \r\n after chunk data
        if (stream->available()) {
          char cr = stream->read(); // \r
          if (stream->available() && cr == '\r') stream->read(); // \n
        }
      }
    }
    return totalRead;
  }

  // ---- EPUB/File Download to SD ----
  // Streams HTTP response body directly to SD card without buffering in RAM.
  // Used for EPUB downloads from sites like AO3.

  bool downloadToSD(HTTPClient& http, const char* url, const String& contentDisposition) {
    // Extract filename from Content-Disposition or URL
    char filename[64] = {0};

    // Try Content-Disposition: attachment; filename="Title.epub"
    if (contentDisposition.length() > 0) {
      int fnIdx = contentDisposition.indexOf("filename=");
      if (fnIdx >= 0) {
        int start = fnIdx + 9;
        if (contentDisposition[start] == '"') start++;
        int end = start;
        while (end < (int)contentDisposition.length() &&
               contentDisposition[end] != '"' && contentDisposition[end] != ';')
          end++;
        int len = end - start;
        if (len > 60) len = 60;
        memcpy(filename, contentDisposition.c_str() + start, len);
        filename[len] = '\0';
      }
    }

    // Fallback: extract from URL path
    if (filename[0] == '\0') {
      const char* lastSlash = strrchr(url, '/');
      if (lastSlash) {
        const char* start = lastSlash + 1;
        // Strip query params
        const char* qmark = strchr(start, '?');
        int len = qmark ? (qmark - start) : strlen(start);
        if (len > 60) len = 60;
        memcpy(filename, start, len);
        filename[len] = '\0';
      }
    }

    // Ensure .epub extension
    if (filename[0] == '\0') {
      snprintf(filename, sizeof(filename), "download_%lu.epub", millis());
    }

    // URL-decode the filename (%20 -> space, etc.)
    char decoded[64];
    int di = 0;
    for (int i = 0; filename[i] && di < 62; i++) {
      if (filename[i] == '%' && filename[i+1] && filename[i+2]) {
        char hex[3] = {filename[i+1], filename[i+2], 0};
        decoded[di++] = (char)strtol(hex, nullptr, 16);
        i += 2;
      } else {
        decoded[di++] = filename[i];
      }
    }
    decoded[di] = '\0';
    strncpy(filename, decoded, sizeof(filename) - 1);

    // Replace underscores with spaces for friendlier names
    // (AO3 uses underscores in download filenames)
    // Actually, keep as-is — filesystem is happier without spaces

    Serial.printf("WebReader: Downloading to /books/%s\n", filename);

    // Ensure /books/ directory exists
    digitalWrite(SDCARD_CS, LOW);
    if (!SD.exists("/books")) {
      SD.mkdir("/books");
    }

    // Build full path
    char filepath[128];
    snprintf(filepath, sizeof(filepath), "/books/%s", filename);

    // Check if file already exists
    if (SD.exists(filepath)) {
      Serial.printf("WebReader: File already exists: %s\n", filepath);
      // Overwrite — user explicitly navigated to download
    }

    File outFile = SD.open(filepath, FILE_WRITE);
    if (!outFile) {
      digitalWrite(SDCARD_CS, HIGH);
      http.end();
      _fetchError = "SD write failed";
      _mode = HOME;
      Serial.println("WebReader: Failed to open SD file for writing");
      return false;
    }

    // Stream from HTTP to SD in 4KB chunks (heap allocated to avoid stack overflow)
    WiFiClient* stream = http.getStreamPtr();
    int contentLen = http.getSize();
    int totalWritten = 0;
    unsigned long lastSplash = 0;
    const int DL_BUF_SIZE = 4096;
    uint8_t* buf = (uint8_t*)ps_malloc(DL_BUF_SIZE);
    if (!buf) {
      buf = (uint8_t*)malloc(DL_BUF_SIZE);  // Fallback to internal
    }
    if (!buf) {
      outFile.close();
      digitalWrite(SDCARD_CS, HIGH);
      http.end();
      _fetchError = "Out of memory";
      _mode = HOME;
      return false;
    }
    bool writeError = false;

    _fetchProgress = 0;

    // Show initial download screen
    if (_display) {
      _display->startFrame();
      _display->setColor(DisplayDriver::GREEN);
      _display->setTextSize(2);
      _display->setCursor(10, 10);
      _display->print("Downloading");
      _display->setTextSize(1);
      _display->setColor(DisplayDriver::LIGHT);
      _display->setCursor(10, 35);
      char fnDisp[40];
      strncpy(fnDisp, filename, 38);
      fnDisp[38] = '\0';
      _display->print(fnDisp);
      _display->setCursor(10, 50);
      _display->print("to /books/");
      _display->endFrame();
      lastSplash = millis();
    }

    while (true) {
      if (!stream->available()) {
        unsigned long waitStart = millis();
        while (!stream->available() && (millis() - waitStart) < 10000) {
          delay(10);
          yield();
        }
        if (!stream->available()) break;  // Timeout or done
      }

      int toRead = DL_BUF_SIZE;
      if (contentLen > 0) {
        int remaining = contentLen - totalWritten;
        if (remaining <= 0) break;
        if (toRead > remaining) toRead = remaining;
      }

      int got = stream->readBytes(buf, toRead);
      if (got <= 0) break;

      size_t written = outFile.write(buf, got);
      if ((int)written != got) {
        writeError = true;
        Serial.printf("WebReader: SD write error: wrote %d of %d bytes\n",
                      (int)written, got);
        break;
      }

      totalWritten += got;
      _fetchProgress = totalWritten;

      // Update progress display every 2 seconds
      if (_display && (millis() - lastSplash) >= 2000) {
        _display->startFrame();
        _display->setColor(DisplayDriver::GREEN);
        _display->setTextSize(2);
        _display->setCursor(10, 10);
        _display->print("Downloading");
        _display->setTextSize(1);
        _display->setColor(DisplayDriver::LIGHT);
        _display->setCursor(10, 35);
        char progBuf[48];
        if (contentLen > 0) {
          int pct = (totalWritten * 100) / contentLen;
          snprintf(progBuf, sizeof(progBuf), "%d / %d KB (%d%%)",
                   totalWritten / 1024, contentLen / 1024, pct);
        } else {
          snprintf(progBuf, sizeof(progBuf), "%d KB downloaded",
                   totalWritten / 1024);
        }
        _display->print(progBuf);
        _display->setCursor(10, 50);
        char fnDisp2[40];
        strncpy(fnDisp2, filename, 38);
        fnDisp2[38] = '\0';
        _display->print(fnDisp2);
        _display->endFrame();
        lastSplash = millis();
      }

      yield();
    }

    outFile.close();
    free(buf);
    digitalWrite(SDCARD_CS, HIGH);
    http.end();

    if (writeError || totalWritten == 0) {
      // Clean up partial download
      digitalWrite(SDCARD_CS, LOW);
      SD.remove(filepath);
      digitalWrite(SDCARD_CS, HIGH);
      _fetchError = writeError ? "SD write error" : "Empty download";
      _downloadOk = false;
    } else {
      _downloadOk = true;
      Serial.printf("WebReader: Downloaded %d bytes to %s\n", totalWritten, filepath);
    }

    strncpy(_downloadedFile, filename, sizeof(_downloadedFile) - 1);
    _downloadedFile[sizeof(_downloadedFile) - 1] = '\0';
    _mode = DOWNLOAD_DONE;

    // Show result screen
    if (_display) {
      _display->startFrame();
      renderDownloadDone(*_display);
      _display->endFrame();
    }

    return _downloadOk;
  }

  // ---- HTTP Fetch ----
  // Uses ESP32 HTTPClient which works over any active network interface
  // (WiFi STA, PPP via 4G modem, etc). The caller is responsible for
  // ensuring network connectivity before calling fetchPage().

  // Fetch a page with a self-referrer (origin of the URL) for Cloudflare compatibility
  bool fetchWithSelfRef(const char* url) {
    char selfRef[256];
    const char* schEnd = strstr(url, "://");
    if (schEnd) {
      const char* pathStart = strchr(schEnd + 3, '/');
      int oLen = pathStart ? (pathStart - url) : strlen(url);
      if (oLen > 254) oLen = 254;
      memcpy(selfRef, url, oLen);
      selfRef[oLen] = '/';
      selfRef[oLen + 1] = '\0';
    } else {
      strncpy(selfRef, url, 255);
      selfRef[255] = '\0';
    }
    return fetchPage(url, nullptr, nullptr, selfRef);
  }

  // Translate ESP32 HTTPClient error codes to readable strings
  static String httpErrorString(int code) {
    if (code == 525) return "SSL error (Cloudflare)";
    if (code == 403) return "Blocked (403)";
    if (code == 503) return "Unavailable (503)";
    if (code > 0) return "HTTP " + String(code);
    switch (code) {
      case -1:  return "Connection refused";
      case -2:  return "Send header failed";
      case -3:  return "Send payload failed";
      case -4:  return "Not connected";
      case -5:  return "Connection lost";
      case -6:  return "No stream";
      case -7:  return "No HTTP server";
      case -8:  return "Out of RAM";
      case -9:  return "Encoding error";
      case -10: return "Stream write error";
      case -11: return "Read timeout";
      default:  return "Error " + String(code);
    }
  }

  bool fetchPage(const char* url, const char* postBody = nullptr,
                 const char* contentType = nullptr,
                 const char* referer = nullptr) {
    Serial.printf("WebReader: fetchPage('%s', post=%s, ref=%s)\n",
                  url, postBody ? "yes" : "no", referer ? referer : "(null)");

    // Pause modem polling during fetch to avoid Core 0 contention with
    // WiFi/TLS.  The modem task's 10ms UART poll loop on Core 0 disrupts
    // TLS handshakes, causing Cloudflare 525/503 errors.
#ifdef HAS_4G_MODEM
    struct ModemPauseGuard {
      ModemPauseGuard()  { modemManager.pausePolling(); Serial.println("WebReader: modem paused"); }
      ~ModemPauseGuard() { modemManager.resumePolling(); Serial.println("WebReader: modem resumed"); }
    } _modemGuard;
#endif

    if (!allocateBuffers()) {
      _fetchError = "Out of memory";
      _mode = HOME;
      return false;
    }

    _mode = FETCHING;
    _fetchStartTime = millis();
    _fetchProgress = 0;
    _fetchRetryCount = 0;
    _fetchError = "";

    // Push current URL to back stack (if we have one)
    if (_currentUrl[0] != '\0') {
      _backHistory.push_back(String(_currentUrl));
      if (_backHistory.size() > 20) _backHistory.erase(_backHistory.begin());
    }

    // Show the loading screen before blocking fetch
    if (_display) {
      _display->startFrame();
      renderFetching(*_display);
      _display->endFrame();
    }

    // Download HTML
    char* htmlBuffer = (char*)ps_malloc(WEB_MAX_PAGE_SIZE);
    if (!htmlBuffer) {
      _fetchError = "Out of memory (HTML)";
      _mode = HOME;
      return false;
    }

    int htmlLen = 0;
    bool success = false;

    // Extract domain for cookies
    char domain[64];
    extractDomain(url, domain, sizeof(domain));
    String cookieHeader = buildCookieHeader(domain);

    // Headers we want to capture from response
    const char* collectHeaderNames[] = {"Set-Cookie", "Location", "Content-Type", "Content-Disposition"};

    // Manual redirect loop — we handle redirects ourselves to capture
    // Set-Cookie headers at each hop. We reuse the TLS client for
    // same-host redirects to avoid repeated 5-8s TLS handshakes.
    String currentUrl = url;
    int redirectCount = 0;
    const int maxRedirects = 5;
    bool isPost = (postBody != nullptr);

    // Use persistent TLS client (member variable) — survives across
    // fetchPage() calls for connection reuse to the same host.
    ensureTlsUsesPsram();
    int totalRetries = 0;     // Combined conn + server retries (max 4)
    bool needsFreshTls = false;  // Deferred TLS client recreation

    while (redirectCount <= maxRedirects) {
      // Determine current host for TLS session management
      bool isHttps = currentUrl.startsWith("https://");
      String currentHost;
      if (isHttps) {
        currentHost = currentUrl.substring(8); // skip "https://"
        int slashIdx = currentHost.indexOf('/');
        if (slashIdx > 0) currentHost = currentHost.substring(0, slashIdx);
      }

      // Unified TLS client creation/recreation.
      // This runs BEFORE 'HTTPClient http' below is constructed, so it's safe
      // to delete _tlsClient (no stack-local HTTPClient holds a reference yet).
      // Triggers: error recovery, host change, first use, stale connection.
      bool tlsStale = (_tlsClient && !_tlsClient->connected());
      if (tlsStale) {
        Serial.println("WebReader: TLS connection closed by server, reconnecting");
      }
      if (isHttps && (needsFreshTls || !_tlsClient || _tlsHost != currentHost || tlsStale)) {
        if (_tlsClient) {
          _tlsClient->stop();
          delete _tlsClient;
        }
        _tlsClient = new WiFiClientSecure();
        _tlsClient->setInsecure();
        _tlsClient->setHandshakeTimeout(15);
        _tlsHost = currentHost;
        needsFreshTls = false;
        Serial.printf("WebReader: New TLS session for %s (heap: %d, largest: %d)\n",
                      currentHost.c_str(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
      }

      // Update domain and cookies for current URL
      extractDomain(currentUrl.c_str(), domain, sizeof(domain));
      cookieHeader = buildCookieHeader(domain);

      Serial.printf("WebReader: %s %s (redirect #%d)\n",
                    isPost ? "POST" : "GET", currentUrl.c_str(), redirectCount);
      Serial.printf("WebReader: heap: %d, largest: %d\n",
                    ESP.getFreeHeap(), ESP.getMaxAllocHeap());

      HTTPClient http;
      http.setUserAgent(WEB_USER_AGENT);
      http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
      http.setTimeout(15000);  // 15s — fail fast to allow retries
      http.setReuse(true); // Keep connection alive for redirects

      bool beginOk;
      if (isHttps) {
        beginOk = http.begin(*_tlsClient, currentUrl);
      } else {
        beginOk = http.begin(currentUrl);
      }

      if (!beginOk) {
        _fetchError = "Connection failed";
        break;
      }

      // MUST be after begin() — begin() resets collected headers
      http.collectHeaders(collectHeaderNames, 4);

      if (cookieHeader.length() > 0) {
        http.addHeader("Cookie", cookieHeader);
        Serial.printf("WebReader: Cookies (%d chars)\n", cookieHeader.length());
      }

      // Standard browser headers
      http.addHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
      http.addHeader("Accept-Language", "en-US,en;q=0.9");
      http.addHeader("Upgrade-Insecure-Requests", "1");
      // Cache-busting: tell Cloudflare to revalidate with origin.
      // Using max-age=0 (browser standard) instead of no-cache to avoid
      // Cloudflare 525 SSL errors on some origins.
      http.addHeader("Cache-Control", "max-age=0");

      // Re-render splash before blocking call
      if (_display) {
        _display->startFrame();
        renderFetching(*_display);
        _display->endFrame();
      }

      int httpCode;
      if (isPost) {
        http.addHeader("Content-Type", contentType ? contentType
                       : "application/x-www-form-urlencoded");
        // Referer = page that contained the form; Origin = scheme+host
        const char* ref = referer ? referer : currentUrl.c_str();
        http.addHeader("Referer", ref);
        char origin[128];
        const char* schEnd = strstr(ref, "://");
        if (schEnd) {
          const char* pathStart = strchr(schEnd + 3, '/');
          int oLen = pathStart ? (pathStart - ref) : strlen(ref);
          if (oLen > (int)sizeof(origin) - 1) oLen = sizeof(origin) - 1;
          memcpy(origin, ref, oLen);
          origin[oLen] = '\0';
        } else {
          strncpy(origin, ref, sizeof(origin) - 1);
          origin[sizeof(origin) - 1] = '\0';
        }
        http.addHeader("Origin", origin);
        // Sec-Fetch headers for form submissions
        http.addHeader("Sec-Fetch-Dest", "document");
        http.addHeader("Sec-Fetch-Mode", "navigate");
        http.addHeader("Sec-Fetch-Site", "same-origin");
        http.addHeader("Sec-Fetch-User", "?1");
        httpCode = http.POST((uint8_t*)postBody, strlen(postBody));
        Serial.printf("WebReader: POST -> %d (Referer: %s)\n", httpCode, ref);
      } else {
        // Send Referer on GET too — helps with Cloudflare bot detection
        if (referer && referer[0]) {
          http.addHeader("Referer", referer);
        }
        httpCode = http.GET();
        Serial.printf("WebReader: GET -> %d\n", httpCode);
      }

      // Capture all Set-Cookie headers from this response
      captureResponseCookies(http, domain);

      // Connection-level failure (timeout, TLS error, etc)
      if (httpCode < 0 && totalRetries < 4) {
        http.end();
        totalRetries++;
        _fetchRetryCount++;
        needsFreshTls = true;  // Recreate at top of next iteration (after http destructor)

        // After 2 failures, try WiFi reconnect — the lwIP stack may be wedged
        // Skip on WiFi companion variant — disconnect kills the companion TCP server
        #ifndef MECK_WIFI_COMPANION
        if (totalRetries == 2 && isWiFiConnected()) {
          Serial.println("WebReader: WiFi reconnect after persistent failures");
          // Destroy TLS before WiFi teardown
          if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
          _tlsHost = "";
          WiFi.disconnect(false);
          delay(500);
          WiFi.reconnect();
          unsigned long wt = millis();
          while (WiFi.status() != WL_CONNECTED && millis() - wt < 8000) delay(100);
          if (WiFi.status() != WL_CONNECTED) {
            Serial.println("WebReader: WiFi reconnect failed");
            _fetchError = "WiFi reconnect failed";
            break;
          }
          Serial.printf("WebReader: WiFi reconnected, IP: %s\n",
                        WiFi.localIP().toString().c_str());
        }
        #endif

        int retryDelay = 1000 + totalRetries * 1000;  // 2s, 3s, 4s, 5s
        Serial.printf("WebReader: Connection error %d, retrying in %dms... (attempt %d/4)\n",
                      httpCode, retryDelay, totalRetries);
        if (_display) {
          _display->startFrame();
          renderFetching(*_display);
          _display->endFrame();
        }
        delay(retryDelay);
        continue;
      }
      if (httpCode < 0) {
        _fetchError = httpErrorString(httpCode);
        http.end();
        break;
      }

      // Handle redirects
      if (httpCode == 301 || httpCode == 302 || httpCode == 303 || httpCode == 307) {
        String location = http.header("Location");
        // End the connection — the next loop iteration creates a fresh
        // HTTPClient. Don't use getString() to "consume" the body because
        // chunked responses without proper termination can hang forever.
        http.end();
        if (location.length() == 0) {
          _fetchError = "Redirect with no Location";
          break;
        }
        char resolved[WEB_MAX_URL_LEN];
        resolveUrl(currentUrl.c_str(), location.c_str(), resolved, WEB_MAX_URL_LEN);
        currentUrl = resolved;
        if (httpCode == 302 || httpCode == 303) isPost = false;
        redirectCount++;
        Serial.printf("WebReader: Redirect -> %s\n", currentUrl.c_str());
        continue;
      }

      if (httpCode == HTTP_CODE_OK) {
        // Check if this is a file download (EPUB, etc.) rather than HTML
        String ctype = http.header("Content-Type");
        String cdisp = http.header("Content-Disposition");
        ctype.toLowerCase();
        bool isEpubUrl = currentUrl.endsWith(".epub");
        bool isEpubContent = ctype.indexOf("epub") >= 0 ||
                             (ctype.indexOf("octet-stream") >= 0 && isEpubUrl);
        bool isDownload = cdisp.indexOf("attachment") >= 0;

        if (isEpubUrl || isEpubContent || (isDownload && isEpubUrl)) {
          Serial.println("WebReader: EPUB detected, downloading to SD");
          free(htmlBuffer);
          bool dlOk = downloadToSD(http, currentUrl.c_str(), cdisp);
          // _tlsClient persists as member — cleaned up by exitReader()
          return dlOk;
        }

        htmlLen = readResponseBody(http, htmlBuffer, WEB_MAX_PAGE_SIZE);
        success = (htmlLen > 0);
        http.end();
        break;
      } else if (httpCode >= 500 && httpCode < 600 && totalRetries < 4) {
        // Server error (e.g. Cloudflare 525/503)
        // Don't call getString() — CF error responses can use chunked encoding
        // without proper termination, causing getString() to block forever.
        // http.end() forcefully closes the socket which is fine since we're
        // recreating the TLS client anyway.
        http.end();
        needsFreshTls = true;  // Recreate at top of next iteration
        totalRetries++;
        _fetchRetryCount++;

        // Clear Cloudflare cookies — stale __cf_bm tokens from failed
        // handshakes cause CF to keep rejecting us
        clearCloudflareCookies(domain);

        // WiFi reconnect after 2 total failures (same logic as conn errors)
        // Skip on WiFi companion variant — disconnect kills the companion TCP server
        #ifndef MECK_WIFI_COMPANION
        if (totalRetries == 2 && isWiFiConnected()) {
          Serial.println("WebReader: WiFi reconnect after persistent failures");
          if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
          _tlsHost = "";
          WiFi.disconnect(false);
          delay(500);
          WiFi.reconnect();
          unsigned long wt = millis();
          while (WiFi.status() != WL_CONNECTED && millis() - wt < 8000) delay(100);
          if (WiFi.status() != WL_CONNECTED) {
            _fetchError = "WiFi reconnect failed";
            break;
          }
          Serial.printf("WebReader: WiFi reconnected, IP: %s\n",
                        WiFi.localIP().toString().c_str());
        }
        #endif

        int retryDelay = 1000 + totalRetries * 1000;  // 2s, 3s, 4s, 5s
        Serial.printf("WebReader: Server error %d, retrying in %dms... (attempt %d/4)\n",
                      httpCode, retryDelay, totalRetries);
        if (_display) {
          _display->startFrame();
          renderFetching(*_display);
          _display->endFrame();
        }
        delay(retryDelay);
        continue;
      } else {
        _fetchError = httpErrorString(httpCode);
        http.end();
        break;
      }
    } // end redirect loop

    // Don't delete _tlsClient — keep for connection reuse on next fetchPage().
    // Cleaned up by exitReader() or on next fetch to a different host.

    if (redirectCount > maxRedirects && !success) {
      _fetchError = "Too many redirects";
    }

    if (!success) {
      free(htmlBuffer);
      Serial.printf("WebReader: Fetch failed: %s\n", _fetchError.c_str());
      _mode = HOME;
      // Show error briefly then return to URL entry
      if (_display) {
        _display->startFrame();
        _display->setColor(DisplayDriver::GREEN);
        _display->setTextSize(1);
        _display->setCursor(0, 0);
        _display->print("Web Reader");
        _display->drawRect(0, 11, _display->width(), 1);
        _display->setColor(DisplayDriver::YELLOW);
        _display->setTextSize(_prefs->smallTextSize());
        _display->setCursor(0, 18);
        _display->print("Fetch failed:");
        _display->setColor(DisplayDriver::LIGHT);
        _display->setCursor(0, 30);
        // Word-wrap error message
        String errMsg = _fetchError;
        int y2 = 30;
        while (errMsg.length() > 0 && y2 < 60) {
          String line = errMsg.substring(0, _charsPerLine);
          errMsg = errMsg.substring(line.length());
          _display->setCursor(0, y2);
          _display->print(line.c_str());
          y2 += 8;
        }
        _display->setCursor(0, 70);
        _display->print(_urlBuffer);
        _display->setCursor(0, 90);
        _display->setColor(DisplayDriver::GREEN);
        _display->print("Returning to URL entry...");
        _display->endFrame();
      }
      delay(2500);
      return false;
    }

    htmlBuffer[htmlLen] = '\0';

    // Extract <title> if present
    _pageTitle[0] = '\0';
    const char* titleStart = strcasestr(htmlBuffer, "<title>");
    if (titleStart) {
      titleStart += 7;
      const char* titleEnd = strcasestr(titleStart, "</title>");
      if (titleEnd) {
        int titleLen = titleEnd - titleStart;
        if (titleLen > (int)sizeof(_pageTitle) - 1)
          titleLen = sizeof(_pageTitle) - 1;
        memcpy(_pageTitle, titleStart, titleLen);
        _pageTitle[titleLen] = '\0';
      }
    }

    // Parse HTML to text
    ParseResult pr = parseHtml(htmlBuffer, htmlLen, _textBuffer, WEB_MAX_TEXT_SIZE,
                               _links, WEB_MAX_LINKS,
                               _forms, WEB_MAX_FORMS, currentUrl.c_str());
    _textLen = pr.textLen;
    _linkCount = pr.linkCount;
    _formCount = pr.formCount;
    Serial.printf("WebReader: Parsed %d chars, %d links, %d forms\n",
                  _textLen, _linkCount, _formCount);

    free(htmlBuffer);

    // Update URL bar with final URL (may differ from original after redirects)
    strncpy(_urlBuffer, currentUrl.c_str(), WEB_MAX_URL_LEN - 1);
    _urlLen = strlen(_urlBuffer);

    // Store current URL (final URL after any redirects)
    strncpy(_currentUrl, currentUrl.c_str(), WEB_MAX_URL_LEN - 1);
    _currentUrl[WEB_MAX_URL_LEN - 1] = '\0';
    Serial.printf("WebReader: _currentUrl set to: %s\n", _currentUrl);

    // Add to history (final URL after redirects)
    addToHistory(currentUrl.c_str());

    // Paginate
    paginateText();

    Serial.printf("WebReader: Fetched %s - %d chars text, %d links, %d forms, %d pages\n",
                  currentUrl.c_str(), _textLen, _linkCount, _formCount, _totalPages);

    _mode = READING;
    _currentPage = 0;
    return true;
  }

  // Submit a form via GET or POST
  bool submitForm(int formIdx) {
    if (formIdx < 0 || formIdx >= _formCount) return false;
    WebForm& form = _forms[formIdx];

    Serial.printf("WebReader: Submitting form %d (%s) to %s\n",
                  formIdx, form.isPost ? "POST" : "GET", form.action);
    Serial.printf("WebReader: Referer will be: %s\n", _currentUrl);
    Serial.printf("WebReader: Cookie jar has %d cookies:\n", _cookieCount);
    for (int c = 0; c < _cookieCount; c++) {
      Serial.printf("  [%d] %s = %.30s... (domain=%s)\n",
                    c, _cookies[c].name, _cookies[c].value, _cookies[c].domain);
    }

    if (form.isPost) {
      // Save user-entered field values before first POST attempt.
      // After fetchPage(), the form structures get overwritten by the new page.
      // We need these to retry if CSRF fails (stale token from cached page).
      struct SavedField { char name[32]; char value[WEB_MAX_FIELD_VALUE]; };
      SavedField savedFields[WEB_MAX_FORM_FIELDS];
      int savedCount = form.fieldCount;
      char savedAction[WEB_MAX_URL_LEN];
      char savedReferer[WEB_MAX_URL_LEN];
      strncpy(savedAction, form.action, WEB_MAX_URL_LEN - 1);
      savedAction[WEB_MAX_URL_LEN - 1] = '\0';
      strncpy(savedReferer, _currentUrl, WEB_MAX_URL_LEN - 1);
      savedReferer[WEB_MAX_URL_LEN - 1] = '\0';
      for (int f = 0; f < form.fieldCount && f < WEB_MAX_FORM_FIELDS; f++) {
        strncpy(savedFields[f].name, form.fields[f].name, 31);
        savedFields[f].name[31] = '\0';
        strncpy(savedFields[f].value, form.fields[f].value, WEB_MAX_FIELD_VALUE - 1);
        savedFields[f].value[WEB_MAX_FIELD_VALUE - 1] = '\0';
      }

      String body = buildFormBody(form);
      Serial.printf("WebReader: POST body (%d bytes): %s\n",
                    body.length(), body.c_str());
      strncpy(_urlBuffer, form.action, WEB_MAX_URL_LEN - 1);
      _urlLen = strlen(_urlBuffer);

      // --- First POST attempt ---
      // On Cloudflare-cached sites, this may fail because the CSRF token
      // came from a cached page with no session. But the 302 response
      // WILL set _otwarchive_session, creating the session we need.
      bool result = fetchPage(form.action, body.c_str(), nullptr, _currentUrl);

      // Check if we got redirected to an auth error page
      if (strstr(_currentUrl, "auth_error") || strstr(_currentUrl, "session_expired")) {
        Serial.println("WebReader: Auth error detected — CSRF token was stale (cached page)");
        Serial.println("WebReader: Retrying with fresh session cookie...");

        // Show retry status on display
        if (_display) {
          _display->startFrame();
          _display->setColor(DisplayDriver::GREEN);
          _display->setTextSize(2);
          _display->setCursor(10, 20);
          _display->print("Logging in...");
          _display->setTextSize(_prefs->smallTextSize());
          _display->setColor(DisplayDriver::LIGHT);
          _display->setCursor(10, 45);
          _display->print("Refreshing session...");
          _display->endFrame();
        }

        // Re-fetch the original form page.
        // Now that we have _otwarchive_session cookie, Cloudflare should
        // bypass its cache and serve a fresh page from AO3's origin with
        // a CSRF token that matches our session.
        Serial.printf("WebReader: Re-fetching form page: %s\n", savedReferer);
        result = fetchWithSelfRef(savedReferer);

        if (result && _formCount > 0) {
          // Find the login form and update its fields with saved user data
          int retryForm = -1;
          for (int fi = 0; fi < _formCount; fi++) {
            // Match by action URL
            if (strstr(_forms[fi].action, "login") || strstr(_forms[fi].action, "session")) {
              retryForm = fi;
              break;
            }
          }
          if (retryForm < 0) retryForm = 0; // fallback to first form

          WebForm& newForm = _forms[retryForm];
          Serial.printf("WebReader: Found retry form %d with %d fields, action: %s\n",
                        retryForm, newForm.fieldCount, newForm.action);

          // Copy saved user values into matching fields of new form.
          // Skip CSRF tokens (they have fresh values from the re-fetch).
          for (int sf = 0; sf < savedCount; sf++) {
            // Skip CSRF-type fields — new form already has fresh token
            if (strcmp(savedFields[sf].name, "authenticity_token") == 0 ||
                strcmp(savedFields[sf].name, "csrf_token") == 0 ||
                strcmp(savedFields[sf].name, "_token") == 0 ||
                strcmp(savedFields[sf].name, "commit") == 0) {
              continue;
            }
            // Find matching field in new form
            for (int nf = 0; nf < newForm.fieldCount; nf++) {
              if (strcmp(newForm.fields[nf].name, savedFields[sf].name) == 0) {
                strncpy(newForm.fields[nf].value, savedFields[sf].value,
                        WEB_MAX_FIELD_VALUE - 1);
                Serial.printf("WebReader: Restored field '%s'\n", savedFields[sf].name);
                break;
              }
            }
          }

          // Build new POST body with fresh CSRF token + saved user data
          String retryBody = buildFormBody(newForm);
          Serial.printf("WebReader: Retry POST body (%d bytes): %s\n",
                        retryBody.length(), retryBody.c_str());
          Serial.printf("WebReader: Cookie jar has %d cookies:\n", _cookieCount);
          for (int c = 0; c < _cookieCount; c++) {
            Serial.printf("  [%d] %s = %.30s... (domain=%s)\n",
                          c, _cookies[c].name, _cookies[c].value, _cookies[c].domain);
          }

          strncpy(_urlBuffer, newForm.action, WEB_MAX_URL_LEN - 1);
          _urlLen = strlen(_urlBuffer);
          result = fetchPage(newForm.action, retryBody.c_str(), nullptr, savedReferer);
        } else {
          Serial.println("WebReader: Re-fetch failed or no forms found for retry");
        }
      }
      return result;
    } else {
      // GET - append form data as query string
      String getUrl = form.action;
      String body = buildFormBody(form);
      if (body.length() > 0) {
        getUrl += (getUrl.indexOf('?') >= 0) ? "&" : "?";
        getUrl += body;
      }
      strncpy(_urlBuffer, getUrl.c_str(), WEB_MAX_URL_LEN - 1);
      _urlLen = strlen(_urlBuffer);
      return fetchPage(getUrl.c_str(), nullptr, nullptr, _currentUrl);
    }
  }

  // strcasestr implementation (not always available)
  static const char* strcasestr(const char* haystack, const char* needle) {
    if (!needle[0]) return haystack;
    for (const char* p = haystack; *p; p++) {
      const char* h = p;
      const char* n = needle;
      while (*h && *n) {
        char hc = *h; if (hc >= 'A' && hc <= 'Z') hc += 32;
        char nc = *n; if (nc >= 'A' && nc <= 'Z') nc += 32;
        if (hc != nc) break;
        h++; n++;
      }
      if (!*n) return p;
    }
    return nullptr;
  }

  // ---- Pagination ----

  void paginateText() {
    _pageOffsets.clear();
    _pageOffsets.push_back(0);

    int pos = 0;
    while (pos < _textLen) {
      int lineCount = 0;
      while (lineCount < _linesPerPage && pos < _textLen) {
        WebWrapResult wrap = webFindLineBreak(_textBuffer, _textLen, pos, _charsPerLine);
        if (wrap.nextStart <= pos) {
          pos = _textLen; // Safety: prevent infinite loop
          break;
        }
        pos = wrap.nextStart;
        lineCount++;
      }
      if (pos < _textLen) {
        _pageOffsets.push_back(pos);
      }
    }

    _totalPages = _pageOffsets.size();
    _currentPage = 0;
  }

  // ---- Bookmarks & History ----

  void loadBookmarks() {
    _bookmarks.clear();
    File f = SD.open(WEB_BOOKMARKS_FILE, FILE_READ);
    if (!f) { digitalWrite(SDCARD_CS, HIGH); return; }
    while (f.available() && _bookmarks.size() < WEB_MAX_BOOKMARKS) {
      String line = f.readStringUntil('\n');
      line.trim();
      if (line.length() > 0) _bookmarks.push_back(line);
    }
    f.close();
    digitalWrite(SDCARD_CS, HIGH);
  }

  void saveBookmarks() {
    if (!SD.exists(WEB_CACHE_DIR)) SD.mkdir(WEB_CACHE_DIR);
    File f = SD.open(WEB_BOOKMARKS_FILE, FILE_WRITE);
    if (!f) return;
    for (auto& b : _bookmarks) {
      f.println(b);
    }
    f.close();
    digitalWrite(SDCARD_CS, HIGH);
  }

  void addBookmark(const char* url) {
    // Remove if already exists
    for (int i = 0; i < (int)_bookmarks.size(); i++) {
      if (_bookmarks[i] == url) {
        _bookmarks.erase(_bookmarks.begin() + i);
        break;
      }
    }
    // Add at front
    _bookmarks.insert(_bookmarks.begin(), String(url));
    if (_bookmarks.size() > WEB_MAX_BOOKMARKS) _bookmarks.pop_back();
    saveBookmarks();
  }

  void loadHistory() {
    _history.clear();
    File f = SD.open(WEB_HISTORY_FILE, FILE_READ);
    if (!f) { digitalWrite(SDCARD_CS, HIGH); return; }
    while (f.available() && _history.size() < WEB_MAX_HISTORY) {
      String line = f.readStringUntil('\n');
      line.trim();
      if (line.length() > 0) _history.push_back(line);
    }
    f.close();
    digitalWrite(SDCARD_CS, HIGH);
  }

  void saveHistory() {
    if (!SD.exists(WEB_CACHE_DIR)) SD.mkdir(WEB_CACHE_DIR);
    File f = SD.open(WEB_HISTORY_FILE, FILE_WRITE);
    if (!f) return;
    for (auto& h : _history) {
      f.println(h);
    }
    f.close();
    digitalWrite(SDCARD_CS, HIGH);
  }

  void addToHistory(const char* url) {
    // Remove duplicates
    for (int i = 0; i < (int)_history.size(); i++) {
      if (_history[i] == url) {
        _history.erase(_history.begin() + i);
        break;
      }
    }
    _history.insert(_history.begin(), String(url));
    if (_history.size() > WEB_MAX_HISTORY) _history.pop_back();
    saveHistory();
  }

  // ---- Rendering Helpers ----

  void renderWifiSetup(DisplayDriver& display) {
    display.setColor(DisplayDriver::GREEN);
    display.setTextSize(1);
    display.setCursor(0, 0);
    display.print("WiFi Setup");
    display.drawRect(0, 11, display.width(), 1);

    display.setColor(DisplayDriver::LIGHT);
    display.setTextSize(_prefs->smallTextSize());

    if (_wifiState == WIFI_SCANNING) {
      display.setCursor(0, 18);
      display.print("Scanning for networks...");
    } else if (_wifiState == WIFI_SCAN_DONE) {
      int y = 14;
      int listLineH = _prefs ? _prefs->smallLineH() : 9;
      for (int i = 0; i < _ssidCount && y < display.height() - 24; i++) {
        bool selected = (i == _selectedSSID);
        if (selected) {
          display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
          display.fillRect(0, y, display.width(), listLineH);
#else
          display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
          display.setColor(DisplayDriver::DARK);
        } else {
          display.setColor(DisplayDriver::LIGHT);
        }
        display.setCursor(0, y);
        String line = selected ? "> " : "  ";
        line += _ssidList[i];
        if ((int)line.length() > _charsPerLine)
          line = line.substring(0, _charsPerLine - 3) + "...";
        display.print(line.c_str());
        y += listLineH;
      }
    } else if (_wifiState == WIFI_ENTERING_PASS) {
      int y = 14;
      display.setCursor(0, y);
      display.setColor(DisplayDriver::LIGHT);
      char tmp[64];
      snprintf(tmp, sizeof(tmp), "SSID: %s", _ssidList[_selectedSSID].c_str());
      display.print(tmp);
      y += 12;
      display.setCursor(0, y);
      display.print("Password:");
      y += _prefs->smallLineH() + 1;
      display.setCursor(0, y);
      // Show masked password with brief reveal of last char
      char passBuf[WEB_WIFI_PASS_LEN + 2];
      for (int pi = 0; pi < _wifiPassLen; pi++) {
        passBuf[pi] = '*';
      }
      // Brief reveal: show last char for ~800ms after typing
      if (_wifiPassLen > 0 && _formLastCharAt > 0 &&
          (millis() - _formLastCharAt) < 800) {
        passBuf[_wifiPassLen - 1] = _wifiPass[_wifiPassLen - 1];
      }
      passBuf[_wifiPassLen] = '_'; // Cursor
      passBuf[_wifiPassLen + 1] = '\0';
      display.print(passBuf);
    } else if (_wifiState == WIFI_CONNECTING) {
      display.setCursor(0, 18);
      display.print("Connecting...");
      display.setCursor(0, 30);
      char tmp[48];
      snprintf(tmp, sizeof(tmp), "SSID: %s", _connectedSSID.c_str());
      display.print(tmp);
    } else if (_wifiState == WIFI_FAILED) {
      display.setColor(DisplayDriver::YELLOW);
      display.setCursor(0, 18);
      display.print("WiFi Error:");
      display.setCursor(0, 30);
      display.setColor(DisplayDriver::LIGHT);
      // Word-wrap the error message
      String err = _fetchError;
      int y2 = 30;
      while (err.length() > 0 && y2 < 70) {
        String line;
        if ((int)err.length() <= _charsPerLine) {
          line = err; err = "";
        } else {
          line = err.substring(0, _charsPerLine);
          err = err.substring(_charsPerLine);
        }
        display.setCursor(0, y2);
        display.print(line.c_str());
        y2 += 8;
      }
      display.setCursor(0, 80);
      display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
      display.print("Tap: Retry");
#else
      display.print("Enter: Retry  Q: Back");
#endif
    }

    // Footer
    display.setTextSize(1);
    int footerY = display.height() - 12;
    display.drawRect(0, footerY - 2, display.width(), 1);
    display.setCursor(0, footerY);
    display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
    if (_wifiState == WIFI_ENTERING_PASS)
      display.print("Tap: Enter Password  Hold: Back");
    else
      display.print("Swipe: Navigate  Tap: Select");
#else
    display.print("Q:Back W/S:Nav Ent:Select");
#endif
  }

  void renderHome(DisplayDriver& display) {
    // ---- Fixed header (not scrolled) ----
    display.setColor(DisplayDriver::GREEN);
    display.setTextSize(1);
    display.setCursor(0, 0);

    if (isNetworkAvailable()) {
      display.print("Web Reader");
      display.setTextSize(_prefs->smallTextSize());
      display.setColor(DisplayDriver::GREEN);
      if (isWiFiConnected()) {
        IPAddress ip = WiFi.localIP();
        char ipStr[20];
        snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
        display.setCursor(display.width() - display.getTextWidth(ipStr) - 2, -3);
        display.print(ipStr);
      } else {
        const char* netStr = "4G";
        display.setCursor(display.width() - display.getTextWidth(netStr) - 2, -3);
        display.print(netStr);
      }
    } else {
      display.print("Web Reader (Offline)");
    }

    display.setTextSize(1);
    display.drawRect(0, 11, display.width(), 1);

    // ---- Layout constants ----
    const int headerY = 14;         // Content starts here
    const int footerH = 14;
    const int footerY = display.height() - 12;
    const int viewportH = display.height() - headerY - footerH;
    const int scrollbarW = 4;
    const int listLineH = _prefs ? _prefs->smallLineH() : 9;
    const int sepH = 8;            // Separator between IRC and web sections
    const int sectionH = listLineH; // Section header height
    int maxChars = _charsPerLine - 2; // Account for "> " prefix
    if (maxChars < 10) maxChars = 10;
    int totalItems = 3 + (int)_bookmarks.size() + (int)_history.size();

    // ---- Layout pass: compute virtual Y extent of each item ----
    // We track: for each selectable item, its (virtualY, height).
    // Non-selectable elements (separators, section headers) are accounted
    // for in the gaps between items.

    int virtualY = 0;
    int itemIdx = 0;
    int selectedTop = 0, selectedBot = 0;

    // Item 0: IRC
    int ircH = listLineH + 2;
    if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + ircH; }
    virtualY += ircH;
    itemIdx++;

    // Separator
    virtualY += sepH;

    // Item 1: URL bar
    int urlBarH = listLineH + 2;
    if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + urlBarH; }
    virtualY += urlBarH;
    itemIdx++;

    // Item 2: Search bar
    int searchBarH = listLineH + 2;
    if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + searchBarH; }
    virtualY += searchBarH;
    itemIdx++;

    // Bookmarks
    if (_bookmarks.size() > 0) {
      virtualY += sectionH; // "-- Bookmarks --" header
      for (int i = 0; i < (int)_bookmarks.size(); i++) {
        int urlLen = _bookmarks[i].length();
        int numLines = (urlLen + maxChars - 1) / maxChars;
        if (numLines < 1) numLines = 1;
        int h = numLines * listLineH;
        if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + h; }
        virtualY += h;
        itemIdx++;
      }
    }

    // History
    if (_history.size() > 0) {
      virtualY += sectionH; // "-- History --" header
      for (int i = 0; i < (int)_history.size(); i++) {
        int urlLen = _history[i].length();
        int numLines = (urlLen + maxChars - 1) / maxChars;
        if (numLines < 1) numLines = 1;
        int h = numLines * listLineH;
        if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + h; }
        virtualY += h;
        itemIdx++;
      }
    }

    int totalContentH = virtualY;

    // ---- Adjust scroll to keep selected item visible ----
    if (selectedTop < _homeScrollY) {
      _homeScrollY = selectedTop;
    }
    if (selectedBot > _homeScrollY + viewportH) {
      _homeScrollY = selectedBot - viewportH;
    }
    if (_homeScrollY < 0) _homeScrollY = 0;
    if (totalContentH <= viewportH) _homeScrollY = 0;

    // ---- Render pass (with scroll offset) ----
    display.setTextSize(_prefs->smallTextSize());
    int y = headerY - _homeScrollY;  // Start Y in screen coords
    itemIdx = 0;
    bool needsScroll = (totalContentH > viewportH);
    // Clip region: only draw items with y between headerY and headerY+viewportH
    int clipTop = headerY;
    int clipBot = headerY + viewportH;

    // Helper: check if a rect at (y, height) is at least partially visible
    #define HOME_VISIBLE(yy, hh) ((yy) + (hh) > clipTop && (yy) < clipBot)

    // Item 0: IRC Chat
    {
      bool selected = (_homeSelected == itemIdx);
      if (HOME_VISIBLE(y, ircH)) {
        if (selected) {
          display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
          display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
          display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
          display.setColor(DisplayDriver::DARK);
        } else {
          display.setColor(DisplayDriver::GREEN);
        }
        display.setCursor(0, y);
        if (_ircConnected && _ircJoined) {
          char ircLabel[80];
          snprintf(ircLabel, sizeof(ircLabel), "IRC: %s [connected]", _ircChannel);
          display.print(ircLabel);
        } else if (_ircConnected) {
          display.print("IRC: connecting...");
        } else {
          char ircLabel[80];
          snprintf(ircLabel, sizeof(ircLabel), "IRC: %s:%d", _ircHost, _ircPort);
          display.print(ircLabel);
        }
      }
      y += ircH;
      itemIdx++;
    }

    // Separator
    if (HOME_VISIBLE(y, sepH)) {
      display.setColor(DisplayDriver::GREEN);
      display.drawRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), 1);
    }
    y += sepH;

    // Item 1: URL bar
    {
      bool selected = (_homeSelected == itemIdx);
      if (HOME_VISIBLE(y, urlBarH)) {
        if (selected) {
          display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
          display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
          display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
          display.setColor(DisplayDriver::DARK);
        } else {
          display.setColor(DisplayDriver::LIGHT);
        }
        display.setCursor(0, y);
        if (_urlEditing) {
          char urlDisp[WEB_MAX_URL_LEN + 2];
          int maxShow = maxChars - 3;  // "Web: " prefix
          int start = 0;
          if (_urlLen > maxShow) start = _urlLen - maxShow;
          snprintf(urlDisp, sizeof(urlDisp), "Web: %s_", _urlBuffer + start);
          display.print(urlDisp);
        } else if (_urlLen > 0) {
          char urlDisp[80];
          int maxShow = maxChars - 3;
          snprintf(urlDisp, sizeof(urlDisp), "Web: %s",
                   _urlLen > maxShow ? (_urlBuffer + _urlLen - maxShow) : _urlBuffer);
          display.print(urlDisp);
        } else {
          display.print("Web: [Enter URL]");
        }
      }
      y += urlBarH;
      itemIdx++;
    }

    // Item 2: Search bar
    {
      bool selected = (_homeSelected == itemIdx);
      if (HOME_VISIBLE(y, searchBarH)) {
        if (selected) {
          display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
          display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
          display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
          display.setColor(DisplayDriver::DARK);
        } else {
          display.setColor(DisplayDriver::LIGHT);
        }
        display.setCursor(0, y);
        if (_searchEditing) {
          char searchDisp[140];
          int maxShow = maxChars - 8; // "Search: " prefix + cursor
          int start = 0;
          if (_searchLen > maxShow) start = _searchLen - maxShow;
          snprintf(searchDisp, sizeof(searchDisp), "Search: %s_", _searchBuffer + start);
          display.print(searchDisp);
        } else if (_searchLen > 0) {
          char searchDisp[140];
          int maxShow = maxChars - 7;
          snprintf(searchDisp, sizeof(searchDisp), "Search: %s",
                   _searchLen > maxShow ? (_searchBuffer + _searchLen - maxShow) : _searchBuffer);
          display.print(searchDisp);
        } else {
          display.print("Search: [DuckDuckGo Lite]");
        }
      }
      y += searchBarH;
      itemIdx++;
    }

    // Bookmarks section
    if (_bookmarks.size() > 0) {
      // Section header
      if (HOME_VISIBLE(y, sectionH)) {
        display.setColor(DisplayDriver::GREEN);
        display.setCursor(0, y);
        display.print("-- Bookmarks --");
      }
      y += sectionH;

      for (int i = 0; i < (int)_bookmarks.size(); i++) {
        bool selected = (_homeSelected == itemIdx);
        const char* url = _bookmarks[i].c_str();
        int urlLen = strlen(url);
        int numLines = (urlLen + maxChars - 1) / maxChars;
        if (numLines < 1) numLines = 1;
        int itemH = numLines * listLineH;

        if (HOME_VISIBLE(y, itemH)) {
          int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
          if (selected) {
            display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
            display.fillRect(0, y, contentW, itemH);
#else
            display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
#endif
            display.setColor(DisplayDriver::DARK);
          } else {
            display.setColor(DisplayDriver::LIGHT);
          }

          int off = 0;
          for (int ln = 0; ln < numLines; ln++) {
            int lineY = y + ln * listLineH;
            if (lineY >= clipTop && lineY < clipBot) {
              display.setCursor(0, lineY);
              char lineBuf[128];
              const char* prefix = (ln == 0) ? (selected ? "> " : "  ") : "  ";
              int chars = maxChars;
              if (urlLen - off < chars) chars = urlLen - off;
              snprintf(lineBuf, sizeof(lineBuf), "%s%.*s", prefix, chars, url + off);
              display.print(lineBuf);
            }
            off += maxChars;
          }
        }
        y += itemH;
        itemIdx++;
      }
    }

    // History section
    if (_history.size() > 0) {
      // Section header
      if (HOME_VISIBLE(y, sectionH)) {
        display.setColor(DisplayDriver::GREEN);
        display.setCursor(0, y);
        display.print("-- History --");
      }
      y += sectionH;

      for (int i = 0; i < (int)_history.size(); i++) {
        bool selected = (_homeSelected == itemIdx);
        const char* url = _history[i].c_str();
        int urlLen = strlen(url);
        int numLines = (urlLen + maxChars - 1) / maxChars;
        if (numLines < 1) numLines = 1;
        int itemH = numLines * listLineH;

        if (HOME_VISIBLE(y, itemH)) {
          int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
          if (selected) {
            display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
            display.fillRect(0, y, contentW, itemH);
#else
            display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
#endif
            display.setColor(DisplayDriver::DARK);
          } else {
            display.setColor(DisplayDriver::LIGHT);
          }

          int off = 0;
          for (int ln = 0; ln < numLines; ln++) {
            int lineY = y + ln * listLineH;
            if (lineY >= clipTop && lineY < clipBot) {
              display.setCursor(0, lineY);
              char lineBuf[128];
              const char* prefix = (ln == 0) ? (selected ? "> " : "  ") : "  ";
              int chars = maxChars;
              if (urlLen - off < chars) chars = urlLen - off;
              snprintf(lineBuf, sizeof(lineBuf), "%s%.*s", prefix, chars, url + off);
              display.print(lineBuf);
            }
            off += maxChars;
          }
        }
        y += itemH;
        itemIdx++;
      }
    }

    #undef HOME_VISIBLE

    // ---- Scrollbar ----
    if (needsScroll) {
      int sbX = display.width() - scrollbarW;
      int sbTop = headerY;
      int sbH = viewportH;

      // Track line
      display.setColor(DisplayDriver::DARK);
      display.drawRect(sbX, sbTop, 1, sbH);

      // Thumb
      int thumbH = (viewportH * viewportH) / totalContentH;
      if (thumbH < 6) thumbH = 6;
      if (thumbH > sbH) thumbH = sbH;
      int scrollRange = totalContentH - viewportH;
      int thumbY = sbTop;
      if (scrollRange > 0) {
        thumbY = sbTop + (_homeScrollY * (sbH - thumbH)) / scrollRange;
      }
      display.setColor(DisplayDriver::GREEN);
      display.fillRect(sbX, thumbY, scrollbarW, thumbH);
    }

    // ---- Footer (fixed, not scrolled) ----
    // Clear footer area in case scrolled content bleeds into it
    display.setColor(DisplayDriver::DARK);
    display.fillRect(0, footerY - 2, display.width(), footerH + 2);

    display.setTextSize(1);
    display.drawRect(0, footerY - 2, display.width(), 1);
    display.setCursor(0, footerY);
    display.setColor(DisplayDriver::YELLOW);
    if (_urlEditing) {
      display.print("Type URL  Ent:Go");
    } else if (_searchEditing) {
      display.print("Type query Ent:Search");
    } else {
      char footerBuf[48];
#if defined(LilyGo_T5S3_EPaper_Pro)
      bool onBookmark = (_homeSelected >= 3 && _homeSelected < 3 + (int)_bookmarks.size());
      bool onUrl = (_homeSelected == 1);
      bool onSearch = (_homeSelected == 2);
      if (onUrl)
        snprintf(footerBuf, sizeof(footerBuf), "Tap: Enter URL  Hold: Back");
      else if (onSearch)
        snprintf(footerBuf, sizeof(footerBuf), "Tap: Search  Hold: Back");
      else if (onBookmark)
        snprintf(footerBuf, sizeof(footerBuf), "Swipe: Navigate  Tap: Open  Hold: Delete");
      else
        snprintf(footerBuf, sizeof(footerBuf), "Swipe: Navigate  Tap: Open  Hold: Exit");
#else
      bool hasData = (_cookieCount > 0 || !_history.empty());
      bool onBookmark = (_homeSelected >= 3 && _homeSelected < 3 + (int)_bookmarks.size());
      if (onBookmark && hasData)
        snprintf(footerBuf, sizeof(footerBuf), "Ent:Go Del:Del Bkmk X:Clr Ckies");
      else if (onBookmark)
        snprintf(footerBuf, sizeof(footerBuf), "Q:Bk Ent:Go Del:Del Bkmk");
      else if (hasData)
        snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S Ent:Go X:Clr Ckies");
      else
        snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S:Nav Ent:Go");
#endif
      display.print(footerBuf);
    }

    // Toast notification overlay (for bookmark deleted, etc.)
    if (_toastMsg[0] && (millis() - _toastTime < 1500)) {
      display.setTextSize(1);
      int tw = display.getTextWidth(_toastMsg);
      int bw = tw + 16;
      int bh = 20;
      int bx = (display.width() - bw) / 2;
      int by = (display.height() - bh) / 2;
      display.setColor(DisplayDriver::DARK);
      display.fillRect(bx, by, bw, bh);
      display.setColor(DisplayDriver::LIGHT);
      display.drawRect(bx, by, bw, 1);
      display.drawRect(bx, by + bh - 1, bw, 1);
      display.drawRect(bx, by, 1, bh);
      display.drawRect(bx + bw - 1, by, 1, bh);
      display.setCursor(bx + 8, by + 5);
      display.print(_toastMsg);
    } else if (_toastMsg[0]) {
      _toastMsg[0] = '\0';
    }
  }

  void renderFetching(DisplayDriver& display) {
    display.setColor(DisplayDriver::GREEN);
    display.setTextSize(2);
    display.setCursor(10, 20);
    display.print("Loading...");

    display.setTextSize(_prefs->smallTextSize());
    display.setColor(DisplayDriver::LIGHT);

    // Word-wrap the URL across multiple lines
    int urlLen = strlen(_urlBuffer);
    int y = 45;
    int off = 0;
    int maxChars = _charsPerLine > 2 ? _charsPerLine - 2 : 30;  // small margin
    while (off < urlLen && y < 85) {
      int lineLen = urlLen - off;
      if (lineLen > maxChars) lineLen = maxChars;
      char lineBuf[128];
      snprintf(lineBuf, sizeof(lineBuf), "%.*s", lineLen, _urlBuffer + off);
      display.setCursor(10, y);
      display.print(lineBuf);
      off += lineLen;
      y += 8;
    }

    display.setCursor(10, y + 4);
    display.setTextSize(1);
    char progBuf[48];
    int elapsed = (int)((millis() - _fetchStartTime) / 1000);
    if (_fetchRetryCount > 0) {
      snprintf(progBuf, sizeof(progBuf), "Retry %d/4...  %ds", _fetchRetryCount, elapsed);
      display.setColor(DisplayDriver::YELLOW);
    } else if (_fetchProgress > 0) {
      snprintf(progBuf, sizeof(progBuf), "%d bytes  (%ds)", _fetchProgress, elapsed);
    } else if (elapsed >= 2) {
      snprintf(progBuf, sizeof(progBuf), "Connecting... %ds", elapsed);
    } else {
      snprintf(progBuf, sizeof(progBuf), "Connecting...");
    }
    display.print(progBuf);
  }

  void renderDownloadDone(DisplayDriver& display) {
    display.setTextSize(1);
    display.setCursor(0, 0);

    if (_downloadOk) {
      display.setColor(DisplayDriver::GREEN);
      display.print("Download Complete");
      display.drawRect(0, 11, display.width(), 1);

      display.setTextSize(_prefs->smallTextSize());
      display.setColor(DisplayDriver::LIGHT);
      display.setCursor(0, 16);
      display.print("Saved to /books/:");
      display.setCursor(0, 26);

      // Word-wrap filename
      int fnLen = strlen(_downloadedFile);
      int off = 0;
      int y = 26;
      while (off < fnLen && y < 54) {
        int lineLen = min(_charsPerLine, fnLen - off);
        char lineBuf[64];
        snprintf(lineBuf, sizeof(lineBuf), "%.*s", lineLen, _downloadedFile + off);
        display.setCursor(0, y);
        display.print(lineBuf);
        off += lineLen;
        y += 8;
      }

      display.setCursor(0, y + 6);
      display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
      display.print("Tap: Open in Reader");
#else
      display.print("Ent: Open in Reader");
      display.setCursor(0, y + 16);
      display.print("Q:   Back to browser");
#endif
    } else {
      display.setColor(DisplayDriver::YELLOW);
      display.print("Download Failed");
      display.drawRect(0, 11, display.width(), 1);

      display.setTextSize(_prefs->smallTextSize());
      display.setColor(DisplayDriver::LIGHT);
      display.setCursor(0, 18);
      display.print(_fetchError.c_str());
      display.setCursor(0, 36);
      display.print(_downloadedFile);

      display.setCursor(0, 56);
      display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
      display.print("Tap: Back to browser");
#else
      display.print("Q: Back to browser");
#endif
    }

    // Footer
    display.setTextSize(1);
    int footerY = display.height() - 12;
    display.drawRect(0, footerY - 2, display.width(), 1);
    display.setCursor(0, footerY);
    display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
    display.print(_downloadOk ? "Tap: Open in Reader" : "Tap: Back");
#else
    display.print(_downloadOk ? "Ent:Read  Q:Back" : "Q:Back");
#endif
  }

  void renderReading(DisplayDriver& display) {
    if (!_textBuffer || _textLen == 0) {
      display.setCursor(0, 20);
      display.setColor(DisplayDriver::LIGHT);
      display.print("No content");
      return;
    }

    display.setTextSize(_prefs->smallTextSize());
    display.setColor(DisplayDriver::LIGHT);

    // Determine page bounds
    int pageStart = (_currentPage < (int)_pageOffsets.size()) ?
                    _pageOffsets[_currentPage] : 0;
    int pageEnd = (_currentPage + 1 < (int)_pageOffsets.size()) ?
                  _pageOffsets[_currentPage + 1] : _textLen;

    int y = 0;
    int lineCount = 0;
    int pos = pageStart;
    int maxY = display.height() - _footerHeight - _lineHeight;

    while (pos < pageEnd && lineCount < _linesPerPage && y <= maxY) {
      int oldPos = pos;
      WebWrapResult wrap = webFindLineBreak(_textBuffer, pageEnd, pos, _charsPerLine);

      if (wrap.nextStart <= oldPos && wrap.lineEnd >= pageEnd) break;

      display.setCursor(0, y);

      // Render characters with UTF-8/CP437 handling
      char charStr[2] = {0, 0};
      
      int j = pos;
      while (j < wrap.lineEnd && j < pageEnd) {
        uint8_t b = (uint8_t)_textBuffer[j];

        if (b < 32) { j++; continue; }

        // Detect link markers [N] and render in different color
        if (b == '[' && j + 1 < pageEnd) {
          // Check if this is a link number [N] or [NN]
          int k = j + 1;
          bool isNum = true;
          while (k < pageEnd && _textBuffer[k] >= '0' && _textBuffer[k] <= '9') k++;
          if (k > j + 1 && k < pageEnd && _textBuffer[k] == ']') {
            // It's a link marker - render in highlight color
            display.setColor(DisplayDriver::GREEN);
            while (j <= k) {
              charStr[0] = _textBuffer[j++];
              display.print(charStr);
            }
            display.setColor(DisplayDriver::LIGHT);
            continue;
          }
          // Check for form hint [f: ...]
          if (_textBuffer[j+1] == 'f' && j + 2 < pageEnd && _textBuffer[j+2] == ':') {
            display.setColor(DisplayDriver::GREEN);
            while (j < pageEnd && _textBuffer[j] != ']') {
              charStr[0] = _textBuffer[j++];
              display.print(charStr);
            }
            if (j < pageEnd) { charStr[0] = ']'; display.print(charStr); j++; }
            display.setColor(DisplayDriver::LIGHT);
            continue;
          }
        }

        // Detect form markers {FN} and render highlighted
        if (b == '{' && j + 1 < pageEnd && _textBuffer[j+1] == 'F') {
          int k = j + 2;
          while (k < pageEnd && _textBuffer[k] >= '0' && _textBuffer[k] <= '9') k++;
          if (k > j + 2 && k < pageEnd && _textBuffer[k] == '}') {
            display.setColor(DisplayDriver::YELLOW);
            while (j <= k) {
              charStr[0] = _textBuffer[j++];
              display.print(charStr);
            }
            display.setColor(DisplayDriver::LIGHT);
            continue;
          }
        }

        if (b < 0x80) {
          charStr[0] = (char)b;
          display.print(charStr);
          j++;
        } else if (b >= 0xC0) {
          uint32_t cp = decodeUtf8Char(_textBuffer, wrap.lineEnd, &j);
          uint8_t glyph = unicodeToCP437(cp);
          if (glyph) {
            charStr[0] = (char)glyph;
            display.print(charStr);
          }
        } else {
          charStr[0] = (char)b;
          display.print(charStr);
          j++;
        }
      }

      y += _lineHeight;
      lineCount++;
      pos = wrap.nextStart;
      if (pos >= pageEnd) break;
    }

    // Footer
    display.setTextSize(1);
    int footerY = display.height() - 12;
    display.drawRect(0, footerY - 2, display.width(), 1);
    display.setColor(DisplayDriver::YELLOW);

    // Page counter on left
    char pageBuf[20];
    snprintf(pageBuf, sizeof(pageBuf), "%d/%d", _currentPage + 1, _totalPages);
    display.setCursor(0, footerY);
    display.print(pageBuf);

    // Navigation hint on right (compact to fit setTextSize 1)
    const char* hint;
    char linkBuf[32];
    if (_linkInputActive) {
      snprintf(linkBuf, sizeof(linkBuf), "#%d_ Ent:Go", _linkInput);
      hint = linkBuf;
#if defined(LilyGo_T5S3_EPaper_Pro)
    } else if (_linkCount > 0) {
      hint = "Tap: Page | Tap Footer Bar: Enter Link # | Hold: Back";
    } else {
      hint = "Tap: Page  Hold: Back";
    }
#else
    } else if (_formCount > 0 && _linkCount > 0) {
      hint = "L:Lnk F:Frm B:Bk Q:X";
    } else if (_formCount > 0) {
      hint = "F:Frm B:Bk Q:X";
    } else if (_linkCount > 0) {
      hint = "L:Lnk B:Bk Q:X";
    } else {
      hint = "B:Bk Q:X";
    }
#endif
    display.setCursor(display.width() - display.getTextWidth(hint) - 2, footerY);
    display.print(hint);

    // Toast notification overlay
    if (_toastMsg[0] && (millis() - _toastTime < 1500)) {
      display.setTextSize(1);
      int tw = display.getTextWidth(_toastMsg);
      int bw = tw + 16;
      int bh = 20;
      int bx = (display.width() - bw) / 2;
      int by = (display.height() - bh) / 2;
      display.setColor(DisplayDriver::DARK);
      display.fillRect(bx, by, bw, bh);
      display.setColor(DisplayDriver::LIGHT);
      display.drawRect(bx, by, bw, 1);
      display.drawRect(bx, by + bh - 1, bw, 1);
      display.drawRect(bx, by, 1, bh);
      display.drawRect(bx + bw - 1, by, 1, bh);
      display.setCursor(bx + 8, by + 5);
      display.print(_toastMsg);
    } else if (_toastMsg[0]) {
      _toastMsg[0] = '\0';  // Clear expired toast
    }
  }

  // ---- Layout Initialization ----

  void initLayout(DisplayDriver& display) {
    // Re-init if font preference changed since last layout
    uint8_t curFont = _prefs ? _prefs->large_font : 0;
    if (_initialized && curFont != _lastFontPref) {
      _initialized = false;
      Serial.println("WebReader: font changed, recalculating layout");
    }
    if (_initialized) return;
    _lastFontPref = curFont;

    display.setTextSize(_prefs->smallTextSize());
    uint16_t mWidth = display.getTextWidth("M");
    if (mWidth > 0) {
      _charsPerLine = display.width() / mWidth;
      _lineHeight = max(3, (int)((mWidth * 7 * 12) / (6 * 10)));
    } else {
      _charsPerLine = 40;
      _lineHeight = 5;
    }
    // Proportional font: use average-width measurement instead of M-width
    if (_prefs && _prefs->large_font && mWidth > 0) {
      const char* sample = "the quick brown fox jumps over lazy dog";
      uint16_t sampleW = display.getTextWidth(sample);
      int sampleLen = strlen(sample);
      if (sampleW > 0 && sampleLen > 0) {
        _charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
      }
    }
    // Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
    if (_prefs && _prefs->large_font) {
      _lineHeight = _prefs->smallLineH();
    }

    _footerHeight = 14;
    int textAreaHeight = display.height() - _footerHeight;
    _linesPerPage = textAreaHeight / _lineHeight;
    if (_linesPerPage < 5) _linesPerPage = 5;
    if (_linesPerPage > 40) _linesPerPage = 40;

    display.setTextSize(1);
    _initialized = true;

    Serial.printf("WebReader layout: %d chars/line, %d lines/page, lineH=%d\n",
                  _charsPerLine, _linesPerPage, _lineHeight);
  }

  // ---- Input Handlers ----

  bool handleWifiInput(char c) {
    if (_wifiState == WIFI_SCAN_DONE) {
      if (c == 'w' || c == 'W' || c == 0xF2) {
        if (_selectedSSID > 0) _selectedSSID--;
        return true;
      }
      if (c == 's' || c == 'S' || c == 0xF1) {
        if (_selectedSSID < _ssidCount - 1) _selectedSSID++;
        return true;
      }
      if (c == '\r' || c == 13) {
        _wifiState = WIFI_ENTERING_PASS;
        _wifiPassLen = 0;
        _wifiPass[0] = '\0';
        return true;
      }
    } else if (_wifiState == WIFI_ENTERING_PASS) {
      // Text entry for password
      if (c == '\r' || c == 13) {
        // Connect
        connectToSSID(_ssidList[_selectedSSID], _wifiPass);
        return true;
      }
      if (c == '\b' || c == 127) {
        if (_wifiPassLen > 0) {
          _wifiPass[--_wifiPassLen] = '\0';
          _formLastCharAt = 0;  // Clear reveal on delete
        }
        return true;
      }
      if (c >= 32 && c < 127 && _wifiPassLen < WEB_WIFI_PASS_LEN - 1) {
        _wifiPass[_wifiPassLen++] = c;
        _wifiPass[_wifiPassLen] = '\0';
        _formLastCharAt = millis();  // Brief reveal timer
        return true;
      }
    } else if (_wifiState == WIFI_FAILED) {
      if (c == '\r' || c == 13) {
        startWifiScan();
        return true;
      }
    } else if (_wifiState == WIFI_CONNECTED) {
      // Any key goes to home
      _mode = HOME;
      return true;
    }

    // Q - back to home (if possible) or exit
    if (c == 'q' || c == 'Q') {
      if (_wifiState == WIFI_ENTERING_PASS) {
        _wifiState = WIFI_SCAN_DONE;
      } else {
        _mode = HOME;
      }
      return true;
    }

    return false;
  }

  bool handleHomeInput(char c) {
    int totalItems = 3 + _bookmarks.size() + _history.size(); // IRC + URL + Search + bookmarks + history

    if (_urlEditing) {
      // URL text entry mode
      if (c == '\r' || c == 13) {
        if (_urlLen > 0) {
          // Auto-add https:// if no scheme
          if (strncmp(_urlBuffer, "http://", 7) != 0 &&
              strncmp(_urlBuffer, "https://", 8) != 0) {
            char tmp[WEB_MAX_URL_LEN];
            snprintf(tmp, WEB_MAX_URL_LEN, "https://%s", _urlBuffer);
            strncpy(_urlBuffer, tmp, WEB_MAX_URL_LEN - 1);
            _urlLen = strlen(_urlBuffer);
          }
          // Encode spaces as %20
          encodeUrlSpaces(_urlBuffer, WEB_MAX_URL_LEN);
          _urlLen = strlen(_urlBuffer);
          _urlEditing = false;
          if (!isNetworkAvailable()) {
            _mode = WIFI_SETUP;
            if (!loadAndAutoConnect()) {
              startWifiScan();
            } else {
              fetchWithSelfRef(_urlBuffer);
            }
          } else {
            fetchWithSelfRef(_urlBuffer);
          }
        }
        return true;
      }
      if (c == '\b' || c == 127) {
        if (_urlLen > 0) {
          _urlBuffer[--_urlLen] = '\0';
        }
        return true;
      }
      if (c == 'q' && _urlLen == 0) {
        // Q exits URL editing when empty
        _urlEditing = false;
        return true;
      }
      // Escape URL editing mode
      if (c == 0x1B) { // ESC
        _urlEditing = false;
        return true;
      }
      if (c >= 32 && c < 127 && _urlLen < WEB_MAX_URL_LEN - 1) {
        _urlBuffer[_urlLen++] = c;
        _urlBuffer[_urlLen] = '\0';
        return true;
      }
      return true; // Consume all keys in editing mode
    }

    // Search text entry mode
    if (_searchEditing) {
      if (c == '\r' || c == 13) {
        if (_searchLen > 0) {
          _searchEditing = false;
          // Build DuckDuckGo Lite search URL
          // URL-encode the query: spaces become +, special chars become %XX
          char encoded[256];
          int ei = 0;
          for (int i = 0; i < _searchLen && ei < (int)sizeof(encoded) - 4; i++) {
            char ch = _searchBuffer[i];
            if (ch == ' ') {
              encoded[ei++] = '+';
            } else if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
                       (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') {
              encoded[ei++] = ch;
            } else {
              if (ei < (int)sizeof(encoded) - 4) {
                snprintf(encoded + ei, 4, "%%%02X", (unsigned char)ch);
                ei += 3;
              }
            }
          }
          encoded[ei] = '\0';
          snprintf(_urlBuffer, WEB_MAX_URL_LEN, "https://html.duckduckgo.com/lite/?q=%s", encoded);
          _urlLen = strlen(_urlBuffer);
          if (!isNetworkAvailable()) {
            _mode = WIFI_SETUP;
            if (!loadAndAutoConnect()) {
              startWifiScan();
            } else {
              fetchWithSelfRef(_urlBuffer);
            }
          } else {
            fetchWithSelfRef(_urlBuffer);
          }
        }
        return true;
      }
      if (c == '\b' || c == 127) {
        if (_searchLen > 0) {
          _searchBuffer[--_searchLen] = '\0';
        }
        return true;
      }
      if (c == 'q' && _searchLen == 0) {
        _searchEditing = false;
        return true;
      }
      if (c == 0x1B) { // ESC
        _searchEditing = false;
        return true;
      }
      if (c >= 32 && c < 127 && _searchLen < (int)sizeof(_searchBuffer) - 1) {
        _searchBuffer[_searchLen++] = c;
        _searchBuffer[_searchLen] = '\0';
        return true;
      }
      return true; // Consume all keys in search editing mode
    }

    // Normal navigation
    if (c == 'w' || c == 'W' || c == 0xF2) {
      if (_homeSelected > 0) _homeSelected--;
      return true;
    }
    if (c == 's' || c == 'S' || c == 0xF1) {
      if (_homeSelected < totalItems - 1) _homeSelected++;
      return true;
    }
    if (c == '\r' || c == 13) {
      if (_homeSelected == 0) {
        // IRC Chat selected
        if (_ircConnected) {
          // Already connected — go straight to chat
          _mode = IRC_CHAT;
          _ircComposing = false;
          _ircScrollPos = 0;
        } else {
          // Open IRC setup
          _mode = IRC_SETUP;
          _ircSetupField = 0;
          _ircSetupEditing = false;
        }
        return true;
      }
      if (_homeSelected == 1) {
        // Activate URL editing
        _urlEditing = true;
        return true;
      }
      if (_homeSelected == 2) {
        // Activate search editing
        _searchEditing = true;
        return true;
      }
      // Bookmark or history item selected (offset by 3 for IRC + URL + Search)
      const char* selectedUrl = nullptr;
      int bmIdx = _homeSelected - 3;
      if (bmIdx < (int)_bookmarks.size()) {
        selectedUrl = _bookmarks[bmIdx].c_str();
      } else {
        int histIdx = bmIdx - _bookmarks.size();
        if (histIdx < (int)_history.size()) {
          selectedUrl = _history[histIdx].c_str();
        }
      }
      if (selectedUrl) {
        strncpy(_urlBuffer, selectedUrl, WEB_MAX_URL_LEN - 1);
        _urlLen = strlen(_urlBuffer);
        if (!isNetworkAvailable()) {
          _mode = WIFI_SETUP;
          if (!loadAndAutoConnect()) {
            startWifiScan();
          } else {
            fetchWithSelfRef(_urlBuffer);
          }
        } else {
          fetchWithSelfRef(_urlBuffer);
        }
      }
      return true;
    }

    // Delete/Backspace - remove selected bookmark
    if (c == '\b' || c == 127) {
      int bmIdx = _homeSelected - 3;
      if (bmIdx >= 0 && bmIdx < (int)_bookmarks.size()) {
        _bookmarks.erase(_bookmarks.begin() + bmIdx);
        saveBookmarks();
        // Adjust selection if we deleted the last item
        int newTotal = 3 + _bookmarks.size() + _history.size();
        if (_homeSelected >= newTotal && _homeSelected > 0) {
          _homeSelected--;
        }
        strncpy(_toastMsg, "Bookmark deleted", sizeof(_toastMsg));
        _toastTime = millis();
        Serial.printf("WebReader: Deleted bookmark %d\n", bmIdx);
        return true;
      }
      return false;
    }

    // X - clear all cookies
    if (c == 'x' || c == 'X') {
      bool hadData = (_cookieCount > 0 || !_history.empty());
      _cookieCount = 0;
      memset(_cookies, 0, sizeof(Cookie) * WEB_MAX_COOKIES);
      _history.clear();
      saveHistory();
      Serial.println("WebReader: Cookies and history cleared");
      return hadData;
    }

    return false;
  }

  bool handleReadingInput(char c) {
    // Link number input mode - accumulate digits, Enter to follow
    if (_linkInputActive) {
      if (c >= '0' && c <= '9') {
        int newVal = _linkInput * 10 + (c - '0');
        if (newVal <= 999) {
          _linkInput = newVal;
        }
        return true;
      }
      if (c == '\r' || c == 13) {
        // Enter confirms the link
        if (_linkInput > 0 && _linkInput <= _linkCount) {
          _linkInputActive = false;
          WebLink& link = _links[_linkInput - 1];
          strncpy(_urlBuffer, link.url, WEB_MAX_URL_LEN - 1);
          _urlLen = strlen(_urlBuffer);
          fetchPage(_urlBuffer, nullptr, nullptr, _currentUrl);
        } else {
          _linkInputActive = false;
          _linkInput = 0;
        }
        return true;
      }
      if (c == '\b' || c == 127) {
        // Backspace removes last digit
        _linkInput /= 10;
        if (_linkInput == 0) {
          _linkInputActive = false;
        }
        return true;
      }
      // Any other key cancels link input
      _linkInputActive = false;
      _linkInput = 0;
      return true;
    }

    // W/A - previous page
    if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {
      if (_currentPage > 0) {
        _currentPage--;
        return true;
      }
      return false;
    }

    // S/D/Space - next page
    if (c == 's' || c == 'S' || c == 'd' || c == 'D' || c == ' ' || c == 0xF1) {
      if (_currentPage < _totalPages - 1) {
        _currentPage++;
        return true;
      }
      return false;
    }

    // L - enter link selection mode
    if (c == 'l' || c == 'L') {
      if (_linkCount > 0) {
        _linkInputActive = true;
        _linkInput = 0;
        return true;
      }
      return false;
    }

    // G - go to URL (back to home to enter a new URL)
    if (c == 'g' || c == 'G') {
      _mode = HOME;
      _homeSelected = 0;
      return true;
    }

    // K - add current page to bookmarks
    if (c == 'k' || c == 'K') {
      if (_currentUrl[0]) {
        addBookmark(_currentUrl);
        strncpy(_toastMsg, "Bookmarked!", sizeof(_toastMsg));
        _toastTime = millis();
        return true;
      }
      return false;
    }

    // B - back to previous page
    if (c == 'b' || c == 'B') {
      if (!_backHistory.empty()) {
        String prevUrl = _backHistory.back();
        _backHistory.pop_back();
        // Temporarily clear _currentUrl so fetchPage doesn't re-push it
        _currentUrl[0] = '\0';
        strncpy(_urlBuffer, prevUrl.c_str(), WEB_MAX_URL_LEN - 1);
        _urlLen = strlen(_urlBuffer);
        fetchWithSelfRef(_urlBuffer);
      } else {
        _mode = HOME;
        _homeSelected = 0;
      }
      return true;
    }

    // Q - exit to home
    if (c == 'q' || c == 'Q') {
      _mode = HOME;
      _homeSelected = 0;
      return true;
    }

    // F - enter form fill mode (if page has forms)
    if (c == 'f' || c == 'F') {
      if (_formCount > 0) {
        // If only one form, select it directly
        _activeForm = 0;
        _activeField = 0;
        _formFieldEditing = false;
        _mode = FORM_FILL;
        return true;
      }
      return false;
    }

    // Enter - if links exist, enter link mode (same as L)
    if ((c == '\r' || c == 13) && _linkCount > 0) {
      _linkInputActive = true;
      _linkInput = 0;
      return true;
    }

    return false;
  }

  // Get the actual field index for the n-th visible (non-hidden) field
  int getVisibleFieldIdx(const WebForm& form, int visIdx) {
    int vis = 0;
    for (int i = 0; i < form.fieldCount; i++) {
      if (form.fields[i].type != 'h') {
        if (vis == visIdx) return i;
        vis++;
      }
    }
    return -1;
  }

  int getVisibleFieldCount(const WebForm& form) {
    int count = 0;
    for (int i = 0; i < form.fieldCount; i++) {
      if (form.fields[i].type != 'h') count++;
    }
    return count;
  }

  void renderFormFill(DisplayDriver& display) {
    if (_activeForm < 0 || _activeForm >= _formCount) return;
    WebForm& form = _forms[_activeForm];

    display.setTextSize(_prefs->smallTextSize());

    // Header
    display.setColor(DisplayDriver::GREEN);
    display.setCursor(0, 0);
    // Show form number if multiple forms
    if (_formCount > 1) {
      char hdr[40];
      snprintf(hdr, sizeof(hdr), "Form %d/%d", _activeForm + 1, _formCount);
      display.print(hdr);
    } else {
      display.print("Form");
    }

    // Show form action domain on right
    char actionDomain[32];
    extractDomain(form.action, actionDomain, sizeof(actionDomain));
    display.setCursor(display.width() - display.getTextWidth(actionDomain) - 2, 0);
    display.print(actionDomain);

    display.drawRect(0, 9, display.width(), 1);

    int y = 12;
    int lineH = _prefs->smallLineH() + 1; // Taller lines for form fields
    int visCount = getVisibleFieldCount(form);

    // Render each visible field
    int visIdx = 0;
    for (int i = 0; i < form.fieldCount && y < display.height() - 24; i++) {
      WebFormField& fld = form.fields[i];
      if (fld.type == 'h') continue; // Skip hidden fields

      bool isActive = (visIdx == _activeField);

      // Label
      display.setColor(isActive ? DisplayDriver::GREEN : DisplayDriver::YELLOW);
      display.setCursor(0, y);
      display.print(fld.label);
      y += 8;

      // Field value
      if (isActive) {
        display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
        display.fillRect(0, y, display.width(), 9);
#else
        display.fillRect(0, y + 4, display.width(), 9);
#endif
        display.setColor(DisplayDriver::DARK);
      } else {
        display.setColor(DisplayDriver::LIGHT);
      }
      display.setCursor(2, y);

      if (isActive && _formFieldEditing) {
        // Show edit buffer with cursor
        if (fld.type == 'p') {
          // Password - show last char briefly (800ms), rest as dots
          bool revealing = (_formEditLen > 0 && (millis() - _formLastCharAt) < 800);
          char masked[WEB_MAX_FIELD_VALUE + 2];
          for (int m = 0; m < _formEditLen; m++) {
            if (m == _formEditLen - 1 && revealing)
              masked[m] = _formEditBuf[m]; // Show last char
            else
              masked[m] = '*';
          }
          masked[_formEditLen] = '_'; // Cursor
          masked[_formEditLen + 1] = '\0';
          display.print(masked);
        } else {
          // Show text with cursor
          int maxShow = _charsPerLine - 2;
          int start = 0;
          if (_formEditLen > maxShow) start = _formEditLen - maxShow;
          char disp[WEB_MAX_FIELD_VALUE + 2];
          snprintf(disp, sizeof(disp), "%s_", _formEditBuf + start);
          display.print(disp);
        }
      } else if (fld.type == 's') {
        // Submit button
        display.setColor(isActive ? DisplayDriver::DARK : DisplayDriver::GREEN);
        char btn[60];
        snprintf(btn, sizeof(btn), "[ %s ]", fld.value[0] ? fld.value : "Submit");
        display.print(btn);
      } else if (fld.type == 'c') {
        // Checkbox
        display.print(fld.value[0] && strcmp(fld.value, "0") != 0 ? "[X]" : "[ ]");
      } else {
        // Text/password display (not editing)
        if (fld.type == 'p' && fld.value[0]) {
          String masked;
          int len = strlen(fld.value);
          for (int m = 0; m < len; m++) masked += '*';
          display.print(masked.c_str());
        } else if (fld.value[0]) {
          display.print(fld.value);
        } else {
          display.setColor(isActive ? DisplayDriver::DARK : DisplayDriver::YELLOW);
          display.print("(empty)");
        }
      }

      y += lineH;
      visIdx++;
    }

    // Footer
    display.setTextSize(1);
    int footerY = display.height() - 12;
    display.drawRect(0, footerY - 2, display.width(), 1);
    display.setColor(DisplayDriver::YELLOW);
    display.setCursor(0, footerY);

    if (_formFieldEditing) {
      display.print("Type text  Ent:Next Q:Undo");
    } else {
      const char* hint;
#if defined(LilyGo_T5S3_EPaper_Pro)
      hint = "Swipe: Navigate  Tap: Edit  Hold: Back";
#else
      if (_formCount > 1)
        hint = "W/S:Nav Ent:Edit </>:Form Q:Back";
      else
        hint = "W/S:Nav Ent:Edit/Go Q:Back";
#endif
      display.print(hint);
    }
  }

  bool handleFormFillInput(char c) {
    if (_activeForm < 0 || _activeForm >= _formCount) {
      _mode = READING;
      return true;
    }
    WebForm& form = _forms[_activeForm];
    int visCount = getVisibleFieldCount(form);
    if (visCount == 0) {
      _mode = READING;
      return true;
    }

    // --- Field editing mode ---
    if (_formFieldEditing) {
      int realIdx = getVisibleFieldIdx(form, _activeField);
      if (realIdx < 0) { _formFieldEditing = false; return true; }
      WebFormField& fld = form.fields[realIdx];

      // Enter - save field and advance to next field
      if (c == '\r' || c == 13) {
        memcpy(fld.value, _formEditBuf, _formEditLen);
        fld.value[_formEditLen] = '\0';
        _formFieldEditing = false;
        _formLastCharAt = 0;
        // Auto-advance to next field
        if (_activeField < visCount - 1) _activeField++;
        return true;
      }

      // Backspace
      if (c == 8 || c == 127 || c == 0xF3) {
        if (_formEditLen > 0) _formEditLen--;
        _formEditBuf[_formEditLen] = '\0';
        _formLastCharAt = 0;  // No reveal after delete
        return true;
      }

      // Q as cancel — discard edits, restore original value
      if ((c == 'q' || c == 'Q') && _formEditLen == 0) {
        _formFieldEditing = false;
        _formLastCharAt = 0;
        return true;
      }

      // Printable character
      if (c >= 32 && c < 127 && _formEditLen < WEB_MAX_FIELD_VALUE - 1) {
        _formEditBuf[_formEditLen++] = c;
        _formEditBuf[_formEditLen] = '\0';
        _formLastCharAt = millis();  // Start brief reveal
        return true;
      }
      return false;
    }

    // --- Navigation mode ---

    // W/Up - previous field
    if (c == 'w' || c == 'W' || c == 0xF2) {
      if (_activeField > 0) _activeField--;
      return true;
    }

    // S/Down - next field
    if (c == 's' || c == 'S' || c == 0xF1) {
      if (_activeField < visCount - 1) _activeField++;
      return true;
    }

    // Enter - edit current field or submit
    if (c == '\r' || c == 13) {
      int realIdx = getVisibleFieldIdx(form, _activeField);
      if (realIdx < 0) return false;
      WebFormField& fld = form.fields[realIdx];

      if (fld.type == 's') {
        // Submit button - submit the form
        submitForm(_activeForm);
        return true;
      } else if (fld.type == 'c') {
        // Toggle checkbox
        if (fld.value[0] && strcmp(fld.value, "0") != 0)
          strcpy(fld.value, "0");
        else
          strcpy(fld.value, "1");
        return true;
      } else {
        // Text/password - enter editing mode
        _formFieldEditing = true;
        _formEditLen = strlen(fld.value);
        memcpy(_formEditBuf, fld.value, _formEditLen);
        _formEditBuf[_formEditLen] = '\0';
        return true;
      }
    }

    // < > or , . to switch between forms (when multiple)
    if ((c == ',' || c == '<') && _formCount > 1) {
      if (_activeForm > 0) {
        _activeForm--;
        _activeField = 0;
        _formFieldEditing = false;
      }
      return true;
    }
    if ((c == '.' || c == '>') && _formCount > 1) {
      if (_activeForm < _formCount - 1) {
        _activeForm++;
        _activeField = 0;
        _formFieldEditing = false;
      }
      return true;
    }

    // Q - back to reading
    if (c == 'q' || c == 'Q') {
      _mode = READING;
      _formFieldEditing = false;
      return true;
    }

    return false;
  }

  // ==========================================================================
  // IRC Client Implementation
  // ==========================================================================

  void loadIRCConfig() {
    // Default values
    strncpy(_ircHost, "irc.eastmesh.au", IRC_MAX_HOST_LEN);
    _ircPort = 6697;
    strncpy(_ircNick, "meck-user", IRC_MAX_NICK_LEN);
    _ircChannel[0] = '\0';  // No default channel — user must configure

    File f = SD.open(IRC_CONFIG_FILE, FILE_READ);
    if (!f) { digitalWrite(SDCARD_CS, HIGH); return; }

    String host = f.readStringUntil('\n'); host.trim();
    String port = f.readStringUntil('\n'); port.trim();
    String nick = f.readStringUntil('\n'); nick.trim();
    String chan = f.readStringUntil('\n'); chan.trim();
    f.close();
    digitalWrite(SDCARD_CS, HIGH);

    if (host.length() > 0) strncpy(_ircHost, host.c_str(), IRC_MAX_HOST_LEN - 1);
    if (port.length() > 0) _ircPort = port.toInt();
    if (nick.length() > 0) strncpy(_ircNick, nick.c_str(), IRC_MAX_NICK_LEN - 1);
    if (chan.length() > 0) strncpy(_ircChannel, chan.c_str(), IRC_MAX_CHANNEL_LEN - 1);

    Serial.printf("IRC: Config loaded - %s:%d nick=%s chan=%s\n",
                  _ircHost, _ircPort, _ircNick, _ircChannel);
  }

  void saveIRCConfig() {
    if (!SD.exists(WEB_CACHE_DIR)) SD.mkdir(WEB_CACHE_DIR);
    File f = SD.open(IRC_CONFIG_FILE, FILE_WRITE);
    if (f) {
      f.println(_ircHost);
      f.println(_ircPort);
      f.println(_ircNick);
      f.println(_ircChannel);
      f.close();
    }
    digitalWrite(SDCARD_CS, HIGH);
  }

  bool allocateIRCBuffers() {
    if (!_ircMessages) {
      _ircMessages = (IRCMessage*)ps_calloc(IRC_MAX_MESSAGES, sizeof(IRCMessage));
      if (!_ircMessages) {
        Serial.println("IRC: Failed to allocate message buffer");
        return false;
      }
    }
    return true;
  }

  void addIRCMessage(const char* nick, const char* text, bool isSystem = false) {
    if (!_ircMessages) return;
    IRCMessage& msg = _ircMessages[_ircMsgHead];
    strncpy(msg.nick, nick, IRC_MAX_NICK_LEN - 1);
    msg.nick[IRC_MAX_NICK_LEN - 1] = '\0';

    // Convert UTF-8 text to CP437 for proper e-ink rendering
    int srcLen = strlen(text);
    int dstIdx = 0;
    int srcPos = 0;
    while (srcPos < srcLen && dstIdx < IRC_MAX_MSG_LEN - 1) {
      uint32_t cp = decodeUtf8Char(text, srcLen, &srcPos);
      if (cp == 0 || cp == 0xFFFD) continue;  // Skip invalid
      if (cp < 128) {
        msg.text[dstIdx++] = (char)cp;
      } else {
        uint8_t glyph = unicodeToCP437(cp);
        msg.text[dstIdx++] = glyph ? (char)glyph : '?';
      }
    }
    msg.text[dstIdx] = '\0';

    msg.isSystem = isSystem;
    _ircMsgHead = (_ircMsgHead + 1) % IRC_MAX_MESSAGES;
    if (_ircMsgCount < IRC_MAX_MESSAGES) _ircMsgCount++;
    _ircDirty = true;
  }

  // Get message by index from oldest (0) to newest (count-1)
  IRCMessage* getIRCMessage(int idx) {
    if (!_ircMessages || idx < 0 || idx >= _ircMsgCount) return nullptr;
    int start = (_ircMsgHead - _ircMsgCount + IRC_MAX_MESSAGES) % IRC_MAX_MESSAGES;
    int actual = (start + idx) % IRC_MAX_MESSAGES;
    return &_ircMessages[actual];
  }

  void ircSendRaw(const char* line) {
    if (!_ircClient || !_ircClient->connected()) return;
    _ircClient->print(line);
    _ircClient->print("\r\n");
    Serial.printf("IRC TX: %s\n", line);
  }

  bool connectIRC() {
    if (!allocateIRCBuffers()) return false;

    addIRCMessage("*", "Connecting...", true);

    if (_ircClient) {
      _ircClient->stop();
      delete _ircClient;
    }

    _ircUseTLS = (_ircPort == 6697);

    if (_ircUseTLS) {
      // Redirect mbedTLS allocations to PSRAM — internal heap doesn't have
      // enough contiguous space (~32-48KB) for TLS handshake buffers.
      ensureTlsUsesPsram();
      auto* secClient = new WiFiClientSecure();
      secClient->setInsecure();  // Accept any cert (for self-signed IRC servers)
      _ircClient = secClient;
      Serial.printf("IRC: Connecting to %s:%d (TLS)...\n", _ircHost, _ircPort);
    } else {
      _ircClient = new WiFiClient();
      Serial.printf("IRC: Connecting to %s:%d (plain)...\n", _ircHost, _ircPort);
    }

    if (!_ircClient->connect(_ircHost, _ircPort, 10000)) {
      Serial.println("IRC: Connection failed");
      addIRCMessage("*", "Connection failed!", true);
      delete _ircClient;
      _ircClient = nullptr;
      _ircConnected = false;
      return false;
    }

    _ircConnected = true;
    _ircRegistered = false;
    _ircJoined = false;
    _ircLineLen = 0;
    _ircLastDataTime = millis();

    // Send registration
    char buf[256];
    snprintf(buf, sizeof(buf), "NICK %s", _ircNick);
    ircSendRaw(buf);
    snprintf(buf, sizeof(buf), "USER %s 0 * :Meck T-Deck Pro", _ircNick);
    ircSendRaw(buf);

    addIRCMessage("*", "Registering...", true);
    return true;
  }

  void disconnectIRC() {
    if (_ircClient) {
      if (_ircClient->connected()) {
        ircSendRaw("QUIT :Meck signing off");
      }
      _ircClient->stop();
      delete _ircClient;
      _ircClient = nullptr;
    }
    _ircConnected = false;
    _ircRegistered = false;
    _ircJoined = false;
    addIRCMessage("*", "Disconnected", true);
  }

  void parseIRCLine(const char* line) {
    Serial.printf("IRC RX: %s\n", line);
    _ircLastDataTime = millis();

    // PING :xxx → respond with PONG :xxx
    if (strncmp(line, "PING ", 5) == 0) {
      char pong[IRC_LINE_BUF_SIZE];
      snprintf(pong, sizeof(pong), "PONG %s", line + 5);
      ircSendRaw(pong);
      return;
    }

    // Lines starting with : are server/user messages
    if (line[0] != ':') return;

    // Parse prefix and command
    const char* p = line + 1;
    const char* prefixEnd = strchr(p, ' ');
    if (!prefixEnd) return;

    // Extract nick from prefix (nick!user@host)
    char senderNick[IRC_MAX_NICK_LEN] = {0};
    {
      int nLen = 0;
      const char* excl = strchr(p, '!');
      const char* end = excl ? excl : prefixEnd;
      int maxLen = end - p;
      if (maxLen > IRC_MAX_NICK_LEN - 1) maxLen = IRC_MAX_NICK_LEN - 1;
      memcpy(senderNick, p, maxLen);
      senderNick[maxLen] = '\0';
    }

    // Skip to command
    const char* cmd = prefixEnd + 1;
    while (*cmd == ' ') cmd++;

    // Check numeric replies
    if (cmd[0] >= '0' && cmd[0] <= '9' && cmd[1] >= '0' && cmd[1] <= '9') {
      int numeric = atoi(cmd);

      if (numeric == 1) {
        // RPL_WELCOME - registration complete
        _ircRegistered = true;

        // Only join if a channel is configured
        if (_ircChannel[0] == '\0') {
          addIRCMessage("*", "Registered! Use /join #channel", true);
          return;
        }
        // Auto-prefix with # if missing
        if (_ircChannel[0] != '#' && _ircChannel[0] != '&' && _ircChannel[0] != '+') {
          char tmp[IRC_MAX_CHANNEL_LEN];
          snprintf(tmp, sizeof(tmp), "#%s", _ircChannel);
          strncpy(_ircChannel, tmp, IRC_MAX_CHANNEL_LEN - 1);
        }

        char statusBuf[128];
        snprintf(statusBuf, sizeof(statusBuf), "Registered! Joining %s...", _ircChannel);
        addIRCMessage("*", statusBuf, true);

        char join[128];
        snprintf(join, sizeof(join), "JOIN %s", _ircChannel);
        ircSendRaw(join);
        return;
      }

      if (numeric == 433) {
        // Nick already in use — cycle last character (respects server NICKLEN)
        int nLen = strlen(_ircNick);
        if (nLen > 0) {
          char last = _ircNick[nLen - 1];
          if (last >= '0' && last < '9') {
            _ircNick[nLen - 1] = last + 1;  // meck-user1 -> meck-user2
          } else if (last == '9') {
            _ircNick[nLen - 1] = '0';        // wrap around
          } else {
            _ircNick[nLen - 1] = '1';         // meck-user -> meck-use1
          }
          char buf[128];
          snprintf(buf, sizeof(buf), "NICK %s", _ircNick);
          ircSendRaw(buf);
          char msgBuf[64];
          snprintf(msgBuf, sizeof(msgBuf), "Nick taken, trying %s", _ircNick);
          addIRCMessage("*", msgBuf, true);
        }
        return;
      }

      // MOTD and other informational numerics - show select ones
      if (numeric == 372 || numeric == 375 || numeric == 376) {
        // MOTD lines - extract text after the last :
        const char* text = strrchr(cmd, ':');
        if (text) {
          addIRCMessage("*", text + 1, true);
        }
        return;
      }

      // Topic (332) and names (353) - show as system messages
      if (numeric == 332 || numeric == 353) {
        const char* text = strrchr(cmd, ':');
        if (text) {
          addIRCMessage("*", text + 1, true);
        }
        return;
      }

      // Error numerics (400-599) - show as system messages
      if (numeric >= 400 && numeric < 600) {
        const char* text = strrchr(cmd, ':');
        if (text) {
          char errBuf[IRC_MAX_MSG_LEN];
          snprintf(errBuf, sizeof(errBuf), "Error %d: %s", numeric, text + 1);
          addIRCMessage("*", errBuf, true);
        }
        return;
      }

      return; // Skip other numerics
    }

    // PRIVMSG #channel :message
    if (strncmp(cmd, "PRIVMSG ", 8) == 0) {
      const char* target = cmd + 8;
      const char* msgText = strchr(target, ':');
      if (msgText) {
        msgText++; // skip the :

        // Check for CTCP ACTION (\001ACTION text\001)
        if (strncmp(msgText, "\001ACTION ", 8) == 0) {
          char actionBuf[IRC_MAX_MSG_LEN];
          const char* actionEnd = strchr(msgText + 8, '\001');
          int aLen = actionEnd ? (actionEnd - msgText - 8) : strlen(msgText + 8);
          if (aLen > IRC_MAX_MSG_LEN - 4) aLen = IRC_MAX_MSG_LEN - 4;
          snprintf(actionBuf, sizeof(actionBuf), "* %.*s", aLen, msgText + 8);
          addIRCMessage(senderNick, actionBuf);
        } else {
          addIRCMessage(senderNick, msgText);
        }
      }
      return;
    }

    // JOIN
    if (strncmp(cmd, "JOIN", 4) == 0) {
      if (strcmp(senderNick, _ircNick) == 0) {
        _ircJoined = true;
        char buf[128];
        snprintf(buf, sizeof(buf), "Joined %s", _ircChannel);
        addIRCMessage("*", buf, true);
      } else {
        char buf[128];
        snprintf(buf, sizeof(buf), "%s joined", senderNick);
        addIRCMessage("*", buf, true);
      }
      return;
    }

    // PART
    if (strncmp(cmd, "PART", 4) == 0) {
      char buf[128];
      snprintf(buf, sizeof(buf), "%s left", senderNick);
      addIRCMessage("*", buf, true);
      return;
    }

    // QUIT
    if (strncmp(cmd, "QUIT", 4) == 0) {
      char buf[128];
      const char* reason = strchr(cmd + 5, ':');
      if (reason) {
        snprintf(buf, sizeof(buf), "%s quit (%s)", senderNick, reason + 1);
      } else {
        snprintf(buf, sizeof(buf), "%s quit", senderNick);
      }
      addIRCMessage("*", buf, true);
      return;
    }

    // NICK change
    if (strncmp(cmd, "NICK", 4) == 0) {
      const char* newNick = strchr(cmd + 5, ':');
      if (!newNick) newNick = cmd + 5;
      else newNick++;
      char buf[128];
      snprintf(buf, sizeof(buf), "%s is now %s", senderNick, newNick);
      addIRCMessage("*", buf, true);

      // Update our own nick if it was us
      if (strcmp(senderNick, _ircNick) == 0) {
        strncpy(_ircNick, newNick, IRC_MAX_NICK_LEN - 1);
      }
      return;
    }
  }

  void pollIRC() {
    if (!_ircClient || !_ircConnected) {
      // Check for reconnect
      if (_ircReconnectAt > 0 && millis() > _ircReconnectAt) {
        _ircReconnectAt = 0;
        connectIRC();
      }
      return;
    }

    if (!_ircClient->connected()) {
      Serial.println("IRC: Connection lost");
      addIRCMessage("*", "Connection lost. Reconnecting...", true);
      _ircConnected = false;
      _ircRegistered = false;
      _ircJoined = false;
      delete _ircClient;
      _ircClient = nullptr;
      _ircReconnectAt = millis() + IRC_RECONNECT_MS;
      return;
    }

    // Check for ping timeout
    if (millis() - _ircLastDataTime > IRC_PING_TIMEOUT_MS) {
      Serial.println("IRC: Ping timeout");
      addIRCMessage("*", "Ping timeout. Reconnecting...", true);
      disconnectIRC();
      _ircReconnectAt = millis() + IRC_RECONNECT_MS;
      return;
    }

    // Read available data and accumulate lines
    while (_ircClient->available()) {
      char c = _ircClient->read();
      if (c == '\r') continue;  // skip CR
      if (c == '\n') {
        // Complete line received
        _ircLineBuf[_ircLineLen] = '\0';
        if (_ircLineLen > 0) {
          parseIRCLine(_ircLineBuf);
        }
        _ircLineLen = 0;
        continue;
      }
      if (_ircLineLen < IRC_LINE_BUF_SIZE - 1) {
        _ircLineBuf[_ircLineLen++] = c;
      }
    }
  }

  void sendIRCMessage() {
    if (!_ircConnected || _ircComposeLen == 0) return;

    _ircCompose[_ircComposeLen] = '\0';

    // Check for /commands (allowed even before joining a channel)
    if (_ircCompose[0] == '/') {
      if (strncmp(_ircCompose, "/me ", 4) == 0) {
        if (!_ircJoined) { addIRCMessage("*", "Not in a channel", true); }
        else {
          char buf[IRC_LINE_BUF_SIZE];
          snprintf(buf, sizeof(buf), "PRIVMSG %s :\001ACTION %s\001",
                   _ircChannel, _ircCompose + 4);
          ircSendRaw(buf);
          char dispBuf[IRC_MAX_MSG_LEN];
          snprintf(dispBuf, sizeof(dispBuf), "* %s", _ircCompose + 4);
          addIRCMessage(_ircNick, dispBuf);
        }
      } else if (strncmp(_ircCompose, "/nick ", 6) == 0) {
        char buf[128];
        snprintf(buf, sizeof(buf), "NICK %s", _ircCompose + 6);
        ircSendRaw(buf);
      } else if (strncmp(_ircCompose, "/join ", 6) == 0) {
        const char* chan = _ircCompose + 6;
        // Auto-prefix with # if missing
        if (chan[0] != '#' && chan[0] != '&' && chan[0] != '+') {
          char tmp[IRC_MAX_CHANNEL_LEN];
          snprintf(tmp, sizeof(tmp), "#%s", chan);
          strncpy(_ircChannel, tmp, IRC_MAX_CHANNEL_LEN - 1);
        } else {
          strncpy(_ircChannel, chan, IRC_MAX_CHANNEL_LEN - 1);
        }
        char buf[128];
        snprintf(buf, sizeof(buf), "JOIN %s", _ircChannel);
        ircSendRaw(buf);
        _ircJoined = false;
      } else if (strcmp(_ircCompose, "/quit") == 0) {
        disconnectIRC();
        _mode = HOME;
        _homeSelected = 0;
      } else {
        // Send as raw IRC command (strip the /)
        ircSendRaw(_ircCompose + 1);
      }
    } else {
      // Normal message - requires being in a channel
      if (!_ircJoined) {
        addIRCMessage("*", "Not in a channel. Use /join #channel", true);
      } else {
        char buf[IRC_LINE_BUF_SIZE];
        snprintf(buf, sizeof(buf), "PRIVMSG %s :%s", _ircChannel, _ircCompose);
        ircSendRaw(buf);
        addIRCMessage(_ircNick, _ircCompose);
      }
    }

    _ircComposeLen = 0;
    _ircCompose[0] = '\0';
    _ircScrollPos = 0;  // Snap to bottom on send
  }

  // ---- IRC Setup Screen ----

  void renderIRCSetup(DisplayDriver& display) {
    display.setColor(DisplayDriver::GREEN);
    display.setTextSize(1);
    display.setCursor(0, 0);
    display.print("IRC Setup");
    display.drawRect(0, 11, display.width(), 1);

    display.setTextSize(_prefs->smallTextSize());
    int y = 16;
    int lineH = _prefs->smallLineH() + 1;

    const char* labels[] = {"Server:", "Port:", "Nick:", "Channel:", "[ Connect ]"};
    const char* chanDisp = (_ircChannel[0] != '\0') ? _ircChannel : "(none)";
    const char* values[] = {_ircHost, nullptr, _ircNick, chanDisp, nullptr};
    char portStr[8];
    snprintf(portStr, sizeof(portStr), "%d", _ircPort);

    for (int i = 0; i < 5; i++) {
      bool sel = (_ircSetupField == i);
      if (sel) {
        display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
        display.fillRect(0, y, display.width(), lineH);
#else
        display.fillRect(0, y + 4, display.width(), lineH);
#endif
        display.setColor(DisplayDriver::DARK);
      } else {
        display.setColor(DisplayDriver::LIGHT);
      }
      display.setCursor(2, y);

      if (i == 4) {
        // Connect button
        display.setCursor(display.width() / 2 - 30, y);
        display.print(sel ? "> Connect <" : "[ Connect ]");
      } else if (i == 1) {
        // Port
        char buf[64];
        if (_ircSetupEditing && sel) {
          snprintf(buf, sizeof(buf), "%s %s_", labels[i], _ircSetupBuf);
        } else {
          snprintf(buf, sizeof(buf), "%s %s", labels[i], portStr);
        }
        display.print(buf);
      } else {
        char buf[128];
        if (_ircSetupEditing && sel) {
          snprintf(buf, sizeof(buf), "%s %s_", labels[i], _ircSetupBuf);
        } else {
          snprintf(buf, sizeof(buf), "%s %s", labels[i], values[i]);
        }
        display.print(buf);
      }
      y += lineH;
    }

    // Status
    y += 6;
    display.setColor(DisplayDriver::YELLOW);
    display.setCursor(2, y);
    if (_ircConnected) {
      display.print(_ircJoined ? "Connected & joined" : "Connected...");
    } else {
      display.print("Not connected");
    }

    // Footer
    display.setTextSize(1);
    int footerY = display.height() - 12;
    display.drawRect(0, footerY - 2, display.width(), 1);
    display.setCursor(0, footerY);
    display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
    display.print("Swipe: Navigate  Tap: Edit  Hold: Back");
#else
    display.print("W/S:Nav Ent:Edit/Go Q:Back");
#endif
  }

  bool handleIRCSetupInput(char c) {
    if (_ircSetupEditing) {
      if (c == '\r' || c == 13) {
        // Save the field
        _ircSetupBuf[_ircSetupBufLen] = '\0';
        switch (_ircSetupField) {
          case 0: strncpy(_ircHost, _ircSetupBuf, IRC_MAX_HOST_LEN - 1); break;
          case 1: _ircPort = atoi(_ircSetupBuf); if (_ircPort == 0) _ircPort = 6697; break;
          case 2: strncpy(_ircNick, _ircSetupBuf, IRC_MAX_NICK_LEN - 1); break;
          case 3: strncpy(_ircChannel, _ircSetupBuf, IRC_MAX_CHANNEL_LEN - 1); break;
        }
        _ircSetupEditing = false;
        return true;
      }
      if (c == '\b' || c == 127) {
        if (_ircSetupBufLen > 0) _ircSetupBuf[--_ircSetupBufLen] = '\0';
        return true;
      }
      if (c == 0x1B) { _ircSetupEditing = false; return true; }
      if (c >= 32 && c < 127 && _ircSetupBufLen < (int)sizeof(_ircSetupBuf) - 1) {
        _ircSetupBuf[_ircSetupBufLen++] = c;
        _ircSetupBuf[_ircSetupBufLen] = '\0';
        return true;
      }
      return true;
    }

    // Navigation
    if (c == 'w' || c == 'W' || c == 0xF2) {
      if (_ircSetupField > 0) _ircSetupField--;
      return true;
    }
    if (c == 's' || c == 'S' || c == 0xF1) {
      if (_ircSetupField < 4) _ircSetupField++;
      return true;
    }
    if (c == '\r' || c == 13) {
      if (_ircSetupField == 4) {
        // Connect button
        saveIRCConfig();
        if (!isNetworkAvailable()) {
          addIRCMessage("*", "No network! Connect WiFi first.", true);
          _mode = WIFI_SETUP;
          startWifiScan();
          return true;
        }
        if (connectIRC()) {
          _mode = IRC_CHAT;
          _ircComposing = false;
          _ircComposeLen = 0;
          _ircCompose[0] = '\0';
          _ircScrollPos = 0;
        }
        return true;
      }
      // Start editing the selected field
      _ircSetupEditing = true;
      switch (_ircSetupField) {
        case 0: strncpy(_ircSetupBuf, _ircHost, sizeof(_ircSetupBuf)); break;
        case 1: snprintf(_ircSetupBuf, sizeof(_ircSetupBuf), "%d", _ircPort); break;
        case 2: strncpy(_ircSetupBuf, _ircNick, sizeof(_ircSetupBuf)); break;
        case 3: strncpy(_ircSetupBuf, _ircChannel, sizeof(_ircSetupBuf)); break;
      }
      _ircSetupBufLen = strlen(_ircSetupBuf);
      return true;
    }
    if (c == 'q' || c == 'Q') {
      _mode = HOME;
      _homeSelected = 0;
      return true;
    }
    return false;
  }

  // ---- IRC Chat Screen ----

  void renderIRCChat(DisplayDriver& display) {
    display.setColor(DisplayDriver::GREEN);
    display.setTextSize(1);
    display.setCursor(0, 0);

    // Header: channel name + connection status
    char header[64];
    snprintf(header, sizeof(header), "%s", _ircChannel);
    display.print(header);

    // Connection indicator on right
    display.setTextSize(_prefs->smallTextSize());
    if (!_ircConnected) {
      display.setColor(DisplayDriver::YELLOW);
      display.setCursor(display.width() - 42, -3);
      display.print("DISCONN");
    } else if (!_ircJoined) {
      display.setColor(DisplayDriver::YELLOW);
      display.setCursor(display.width() - 36, -3);
      display.print("joining");
    } else {
      display.setColor(DisplayDriver::GREEN);
      const char* nickDisp = _ircNick;
      display.setCursor(display.width() - display.getTextWidth(nickDisp) - 2, -3);
      display.print(nickDisp);
    }

    display.setTextSize(1);
    display.drawRect(0, 11, display.width(), 1);

    // Footer area
    int footerY = display.height() - 12;
    display.drawRect(0, footerY - 2, display.width(), 1);
    display.setTextSize(1);

    if (_ircComposing) {
      // Compose text just above separator (tiny font to match messages)
      display.setTextSize(_prefs->smallTextSize());
      display.setColor(DisplayDriver::LIGHT);
      display.setCursor(0, footerY - 12);
      char compDisp[IRC_COMPOSE_MAX + 4];
      int maxShow = _charsPerLine - 2;
      int start = 0;
      if (_ircComposeLen > maxShow) start = _ircComposeLen - maxShow;
      snprintf(compDisp, sizeof(compDisp), "> %s_", _ircCompose + start);
      display.print(compDisp);

      // Hint on footer line (large font)
      display.setTextSize(1);
      display.setColor(DisplayDriver::YELLOW);
      display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
      display.print("Tap: Send  Hold: Exit");
#else
      display.print("Ent:Send Del:Exit");
#endif
    } else {
      display.setColor(DisplayDriver::YELLOW);
      display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
      display.print("Tap: Compose  Swipe: Scroll  Hold: Back");
#else
      display.print("Ent:Msg W/S:Scrl Q:Bk");
#endif
    }

    // Message area
    display.setTextSize(_prefs->smallTextSize());
    int msgAreaTop = 14;
    int msgAreaBottom = _ircComposing ? footerY - 16 : footerY - 4;
    int lineH = _prefs->smallLineH() - 1;
    int scrollBarW = 4;
    int lineW = _charsPerLine - 1;  // Reserve space for scroll bar
    _ircLinesPerPage = (msgAreaBottom - msgAreaTop) / lineH;

    if (_ircMsgCount == 0) {
      display.setColor(DisplayDriver::LIGHT);
      display.setCursor(4, msgAreaTop + 10);
      display.print("No messages yet...");

      // Draw empty scrollbar track
      int sbX = display.width() - scrollBarW;
      display.setColor(DisplayDriver::LIGHT);
      display.drawRect(sbX, msgAreaTop, scrollBarW, msgAreaBottom - msgAreaTop);
      return;
    }

    // Calculate which messages to show (from bottom, with scroll offset)
    int startMsg = _ircMsgCount - _ircLinesPerPage - _ircScrollPos;
    if (startMsg < 0) startMsg = 0;
    int endMsg = startMsg + _ircLinesPerPage;
    if (endMsg > _ircMsgCount) endMsg = _ircMsgCount;

    int y = msgAreaTop;
    for (int i = startMsg; i < endMsg && y < msgAreaBottom; i++) {
      IRCMessage* msg = getIRCMessage(i);
      if (!msg) continue;

      display.setCursor(0, y);
      if (msg->isSystem) {
        display.setColor(DisplayDriver::YELLOW);
        char line[IRC_MAX_MSG_LEN + IRC_MAX_NICK_LEN + 4];
        snprintf(line, sizeof(line), "-- %s", msg->text);
        // Truncate to screen width (minus scrollbar)
        if ((int)strlen(line) > lineW)
          line[lineW] = '\0';
        display.print(line);
      } else {
        // Nick in green, message in light
        display.setColor(DisplayDriver::GREEN);
        char nickBuf[IRC_MAX_NICK_LEN + 2];
        snprintf(nickBuf, sizeof(nickBuf), "%s: ", msg->nick);
        display.print(nickBuf);

        display.setColor(DisplayDriver::LIGHT);
        int nickW = display.getTextWidth(nickBuf);
        int remainChars = lineW - strlen(nickBuf);
        if (remainChars > 0) {
          char textBuf[IRC_MAX_MSG_LEN];
          strncpy(textBuf, msg->text, remainChars);
          textBuf[remainChars] = '\0';
          display.print(textBuf);

          // If message is longer, wrap to next line
          int msgLen = strlen(msg->text);
          if (msgLen > remainChars && y + lineH < msgAreaBottom) {
            y += lineH;
            display.setCursor(0, y);
            int off = remainChars;
            while (off < msgLen && y < msgAreaBottom) {
              char wrapBuf[256];
              int wrapLen = lineW;
              if (msgLen - off < wrapLen) wrapLen = msgLen - off;
              memcpy(wrapBuf, msg->text + off, wrapLen);
              wrapBuf[wrapLen] = '\0';
              display.print(wrapBuf);
              off += wrapLen;
              if (off < msgLen) {
                y += lineH;
                display.setCursor(0, y);
              }
            }
          }
        }
      }
      y += lineH;
    }

    // --- Scroll bar (channel screen style) ---
    int sbX = display.width() - scrollBarW;
    int sbTop = msgAreaTop;
    int sbHeight = msgAreaBottom - msgAreaTop;

    // Draw track outline
    display.setColor(DisplayDriver::LIGHT);
    display.drawRect(sbX, sbTop, scrollBarW, sbHeight);

    if (_ircMsgCount > _ircLinesPerPage) {
      // Scrollable: draw proportional thumb
      int maxScroll = _ircMsgCount - _ircLinesPerPage;
      if (maxScroll < 1) maxScroll = 1;
      int thumbH = (_ircLinesPerPage * sbHeight) / _ircMsgCount;
      if (thumbH < 4) thumbH = 4;
      // _ircScrollPos=0 is newest (bottom), so invert for thumb position
      int thumbY = sbTop + ((maxScroll - _ircScrollPos) * (sbHeight - thumbH)) / maxScroll;
      for (int ty = thumbY + 1; ty < thumbY + thumbH - 1; ty++)
        display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
    } else {
      // All messages fit: fill entire track
      for (int ty = sbTop + 1; ty < sbTop + sbHeight - 1; ty++)
        display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
    }
  }

  bool handleIRCChatInput(char c) {
    if (_ircComposing) {
      if (c == '\r' || c == 13) {
        if (_ircComposeLen > 0) {
          sendIRCMessage();
        }
        // Always exit compose on Enter (after send or when empty)
        _ircComposing = false;
        return true;
      }
      if (c == '\b' || c == 127) {
        if (_ircComposeLen > 0) {
          _ircCompose[--_ircComposeLen] = '\0';
        } else {
          // Backspace on empty compose exits compose mode
          _ircComposing = false;
        }
        return true;
      }
      if (c == 0x1B) { // ESC exits compose
        _ircComposing = false;
        return true;
      }
      if (c >= 32 && c < 127 && _ircComposeLen < IRC_COMPOSE_MAX - 1) {
        _ircCompose[_ircComposeLen++] = c;
        _ircCompose[_ircComposeLen] = '\0';
        return true;
      }
      return true; // Consume all keys while composing
    }

    // Non-composing mode
    if (c == '\r' || c == 13) {
      _ircComposing = true;
      _ircComposeLen = 0;
      _ircCompose[0] = '\0';
      return true;
    }

    // W - scroll up (older messages)
    if (c == 'w' || c == 'W' || c == 0xF2) {
      int maxScroll = _ircMsgCount - _ircLinesPerPage;
      if (maxScroll < 0) maxScroll = 0;
      if (_ircScrollPos < maxScroll) _ircScrollPos++;
      return true;
    }

    // S - scroll down (newer messages)
    if (c == 's' || c == 'S' || c == 0xF1) {
      if (_ircScrollPos > 0) _ircScrollPos--;
      return true;
    }

    // Q - back to home (keep connection alive)
    if (c == 'q' || c == 'Q') {
      _mode = HOME;
      _homeSelected = 0;
      return true;
    }

    // X - disconnect
    if (c == 'x' || c == 'X') {
      disconnectIRC();
      _mode = HOME;
      _homeSelected = 0;
      return true;
    }

    return false;
  }

  bool isIRCSetupEditing() const {
    return _mode == IRC_SETUP && _ircSetupEditing;
  }

  bool isIRCComposing() const {
    return _mode == IRC_CHAT && _ircComposing;
  }

public:
  WebReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
    : _task(task), _prefs(prefs), _mode(HOME), _initialized(false), _lastFontPref(0), _display(nullptr),
      _charsPerLine(40), _linesPerPage(15), _lineHeight(5), _footerHeight(14),
      _wifiState(WIFI_IDLE), _ssidCount(0), _selectedSSID(0), _wifiPassLen(0),
      _urlLen(0), _urlCursor(0),
      _textBuffer(nullptr), _textLen(0), _links(nullptr), _linkCount(0),
      _currentPage(0), _totalPages(0),
      _homeSelected(0), _homeScrollY(0), _urlEditing(false),
      _searchEditing(false), _searchLen(0),
      _linkInput(0), _linkInputActive(false),
      _formCount(0), _forms(nullptr), _activeForm(-1), _activeField(0),
      _formFieldEditing(false), _formEditLen(0), _formLastCharAt(0),
      _cookies(nullptr), _cookieCount(0),
      _fetchStartTime(0), _fetchProgress(0), _fetchRetryCount(0),
      _tlsClient(nullptr),
      _downloadOk(false),
      _requestTextReader(false),
      _ircClient(nullptr), _ircUseTLS(false), _ircConnected(false), _ircRegistered(false),
      _ircJoined(false), _ircPort(6697), _ircMessages(nullptr),
      _ircMsgHead(0), _ircMsgCount(0), _ircLineLen(0),
      _ircComposeLen(0), _ircComposing(false), _ircScrollPos(0), _ircLinesPerPage(12),
      _ircSetupField(0), _ircSetupBufLen(0), _ircSetupEditing(false),
      _ircLastDataTime(0), _ircReconnectAt(0),
      _ircDirty(false), _ircLastRender(0) {
    _urlBuffer[0] = '\0';
    _searchBuffer[0] = '\0';
    _wifiPass[0] = '\0';
    _pageTitle[0] = '\0';
    _currentUrl[0] = '\0';
    _formEditBuf[0] = '\0';
    _downloadedFile[0] = '\0';
    _ircHost[0] = '\0';
    _ircNick[0] = '\0';
    _ircChannel[0] = '\0';
    _ircCompose[0] = '\0';
    _ircSetupBuf[0] = '\0';
    _ircLineBuf[0] = '\0';
    _toastMsg[0] = '\0';
    _toastTime = 0;
    // Allocate forms and cookies in PSRAM to free internal heap for TLS
    _forms = (WebForm*)ps_calloc(WEB_MAX_FORMS, sizeof(WebForm));
    _cookies = (Cookie*)ps_calloc(WEB_MAX_COOKIES, sizeof(Cookie));
    loadIRCConfig();
  }

  ~WebReaderScreen() {
    freeBuffers();
    if (_forms) { free(_forms); _forms = nullptr; }
    if (_cookies) { free(_cookies); _cookies = nullptr; }
    if (_ircClient) {
      if (_ircClient->connected()) _ircClient->stop();
      delete _ircClient;
      _ircClient = nullptr;
    }
    if (_ircMessages) { free(_ircMessages); _ircMessages = nullptr; }
    if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
  }

  // Called when entering the web reader screen
  void enter(DisplayDriver& display) {
    _display = &display;
    _fetchError = "";  // Clear stale errors from previous session
    initLayout(display);
    loadBookmarks();
    loadHistory();

    // Check if already connected to a network
    if (isNetworkAvailable()) {
      _wifiState = WIFI_CONNECTED;
      _mode = (_textLen > 0) ? READING : HOME;
      Serial.printf("WebReader enter: already connected, mode=%d\n", _mode);
      return;
    }

    // Not connected — try auto-connect from saved credentials first.
    // Show a status screen during the blocking wait (up to 5s).
    Serial.println("WebReader enter: not connected, trying auto-connect");
    if (_display) {
      _display->startFrame();
      _display->setColor(DisplayDriver::GREEN);
      _display->setTextSize(1);
      _display->setCursor(0, 0);
      _display->print("Web Reader");
      _display->drawRect(0, 11, _display->width(), 1);
      _display->setColor(DisplayDriver::LIGHT);
      _display->setTextSize(_prefs->smallTextSize());
      _display->setCursor(0, 18);
      _display->print("Connecting to WiFi...");
      _display->endFrame();
    }
    if (loadAndAutoConnect()) {
      Serial.printf("WebReader enter: auto-connect OK\n");
      showConnectedAndGoHome();
      return;
    }

    // No saved credentials or auto-connect failed — prompt user for WiFi setup.
    // This must happen BEFORE showing the URL entry page so the user isn't
    // asked to type a URL they can't fetch.
    Serial.println("WebReader enter: auto-connect failed, starting WiFi setup");

    _mode = WIFI_SETUP;
    startWifiScan();  // Shows its own "Scanning..." splash, then blocks
    directRedraw();   // Replace splash with scan results or error screen
    Serial.printf("WebReader enter: mode=WIFI_SETUP, wifiState=%d\n", _wifiState);
  }

  // Called when leaving the screen
  void exitReader() {
    Serial.printf("WebReader: exitReader - heap before: %d, largest: %d\n",
                  ESP.getFreeHeap(), ESP.getMaxAllocHeap());

    // Disconnect IRC if active
    if (_ircClient) {
      if (_ircClient->connected()) {
        // Send QUIT before disconnecting
        _ircClient->println("QUIT :leaving");
        delay(100);
        _ircClient->stop();
      }
      delete _ircClient;
      _ircClient = nullptr;
    }
    _ircConnected = false;
    _ircRegistered = false;
    _ircJoined = false;
    _ircReconnectAt = 0;

    // Free PSRAM buffers (text, links — will be re-allocated on next fetch)
    freeBuffers();

    // Free IRC message ring buffer
    if (_ircMessages) { free(_ircMessages); _ircMessages = nullptr; }
    _ircMsgHead = 0;
    _ircMsgCount = 0;

    // Clear String vectors to release heap fragments
    // (bookmarks/history are reloaded from SD on re-entry via enter())
    _bookmarks.clear();
    _bookmarks.shrink_to_fit();
    _history.clear();
    _history.shrink_to_fit();
    _backHistory.clear();
    _backHistory.shrink_to_fit();
    _pageOffsets.clear();
    _pageOffsets.shrink_to_fit();

    // Clear Arduino Strings that may hold heap allocations
    _fetchError = String();
    _connectedSSID = String();

    // Destroy persistent TLS client before WiFi shutdown
    if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
    _tlsHost = String();

  #ifdef MECK_WIFI_COMPANION
    // WiFi companion: keep WiFi alive for the companion TCP server.
    // Don't disconnect or change mode — just reset our internal state.
    _wifiState = WIFI_CONNECTED;  // WiFi is still up
  #else
    // Shut down WiFi to reclaim ~50-70KB internal RAM
    WiFi.disconnect(true);
    WiFi.mode(WIFI_OFF);
    _wifiState = WIFI_IDLE;
  #endif

    // Reset mode so re-entry starts fresh
    _mode = HOME;
    _homeSelected = 0;
    _homeScrollY = 0;
    _urlEditing = false;
    _searchEditing = false;

    Serial.printf("WebReader: exitReader - heap after: %d, largest: %d\n",
                  ESP.getFreeHeap(), ESP.getMaxAllocHeap());
  }

  bool isReading() const { return _mode == READING; }
  bool isHome() const { return _mode == HOME; }
  bool wantsTextReader() const { return _requestTextReader; }
  void clearTextReaderRequest() { _requestTextReader = false; }
  bool isUrlEditing() const { return _urlEditing && _mode == HOME; }
  bool isWifiSetup() const { return _mode == WIFI_SETUP; }
  bool isPasswordEntry() const {
    return _mode == WIFI_SETUP && _wifiState == WIFI_ENTERING_PASS;
  }
  bool isFormFilling() const {
    return _mode == FORM_FILL && _formFieldEditing;
  }
  bool isSearchEditing() const {
    return _searchEditing && _mode == HOME;
  }
  bool isIRCMode() const { return _mode == IRC_CHAT || _mode == IRC_SETUP; }
  bool isIRCTextEntry() const {
    return (_mode == IRC_CHAT && _ircComposing) ||
           (_mode == IRC_SETUP && _ircSetupEditing);
  }

  // ---- Accessors for T5S3 touch mapping and VKB integration ----
  int getHomeSelected() const { return _homeSelected; }
  int getLinkCount() const { return _linkCount; }
  int getBookmarkCount() const { return (int)_bookmarks.size(); }
  const char* getUrlText() const { return _urlBuffer; }

  // Set URL text and activate editing mode (for VKB submit)
  void setUrlText(const char* text) {
    strncpy(_urlBuffer, text, WEB_MAX_URL_LEN - 1);
    _urlBuffer[WEB_MAX_URL_LEN - 1] = '\0';
    _urlLen = strlen(_urlBuffer);
    _urlEditing = true;
  }
  // Set search text and activate editing mode (for VKB submit)
  void setSearchText(const char* text) {
    strncpy(_searchBuffer, text, sizeof(_searchBuffer) - 1);
    _searchBuffer[sizeof(_searchBuffer) - 1] = '\0';
    _searchLen = strlen(_searchBuffer);
    _searchEditing = true;
  }
  // Set WiFi password text (for VKB submit)
  void setWifiPassText(const char* text) {
    strncpy(_wifiPass, text, WEB_WIFI_PASS_LEN - 1);
    _wifiPass[WEB_WIFI_PASS_LEN - 1] = '\0';
    _wifiPassLen = strlen(_wifiPass);
  }
  // Returns true if a password reveal is active and needs a refresh after expiry
  bool needsRevealRefresh() const {
    if (_formLastCharAt > 0 && (millis() - _formLastCharAt) < 900) {
      if (_mode == FORM_FILL && _formFieldEditing) return true;
      if (_mode == WIFI_SETUP && _wifiState == WIFI_ENTERING_PASS) return true;
    }
    return false;
  }

  // Direct render — bypasses UITask's render scheduling.
  // Called from main.cpp during URL/password text entry for responsive typing.
  void directRedraw() {
    if (!_display) return;
    _display->startFrame();
    render(*_display);
    _display->endFrame();
    _ircDirty = false;
    _ircLastRender = millis();
  }

  int render(DisplayDriver& display) override {
    switch (_mode) {
      case WIFI_SETUP:
        renderWifiSetup(display);
        return 1000;
      case HOME:
        renderHome(display);
        return 5000;
      case FETCHING:
        renderFetching(display);
        return 500;
      case READING:
        renderReading(display);
        return 5000;
      case LINK_SELECT:
        renderReading(display);
        return 1000;
      case FORM_FILL:
        renderFormFill(display);
        return 1000;
      case IRC_SETUP:
        renderIRCSetup(display);
        return 1000;
      case IRC_CHAT:
        renderIRCChat(display);
        return 500;  // Fast refresh for live chat
      case DOWNLOAD_DONE:
        renderDownloadDone(display);
        return 5000;
      default:
        return 5000;
    }
  }

  void poll() override {
    // Poll IRC connection (regardless of current mode, to keep connection alive)
    if (_ircConnected || _ircReconnectAt > 0) {
      pollIRC();
    }

    // Auto-render when new IRC messages arrive (e-ink throttled to ~1s)
    if (_ircDirty && _mode == IRC_CHAT && _display) {
      unsigned long now = millis();
      if (now - _ircLastRender >= 900) {
        _display->startFrame();
        render(*_display);
        _display->endFrame();
        _ircLastRender = now;
        _ircDirty = false;
      }
    }

    // Handle async WiFi operations
    if (_mode == WIFI_SETUP) {
      if (_wifiState == WIFI_SCANNING) {
        checkWifiScan();
      } else if (_wifiState == WIFI_CONNECTING) {
        checkWifiConnect();
        if (_wifiState == WIFI_CONNECTED) {
          // Show "Connected!" confirmation then go to URL entry
          if (_urlLen > 0) {
            // URL was pending — fetch it directly
            fetchWithSelfRef(_urlBuffer);
          } else {
            showConnectedAndGoHome();
          }
        }
      }
    }
  }

  bool handleInput(char c) override {
    switch (_mode) {
      case WIFI_SETUP:
        return handleWifiInput(c);
      case HOME:
        return handleHomeInput(c);
      case READING:
      case LINK_SELECT:
        return handleReadingInput(c);
      case FORM_FILL:
        return handleFormFillInput(c);
      case IRC_SETUP:
        return handleIRCSetupInput(c);
      case IRC_CHAT:
        return handleIRCChatInput(c);
      case FETCHING:
        // Q to cancel fetch (can't actually cancel HTTP mid-stream, but
        // go back to home)
        if (c == 'q' || c == 'Q') {
          _mode = HOME;
          return true;
        }
        return false;
      case DOWNLOAD_DONE:
        if ((c == '\r' || c == 13) && _downloadOk) {
          // Set flag — main.cpp will navigate to text reader
          exitReader();
          _requestTextReader = true;
          return true;
        }
        if (c == 'q' || c == 'Q') {
          _mode = HOME;
          _homeSelected = 0;
          return true;
        }
        return true;  // Consume all other keys
      default:
        return false;
    }
  }
};