#pragma once

#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#include "EpubProcessor.h"
#include "../NodePrefs.h"

// Forward declarations
class UITask;

// ============================================================================
// Configuration
// ============================================================================
#define BOOKS_FOLDER      "/books"
#define INDEX_FOLDER      "/.indexes"
#define INDEX_VERSION     12  // v12: indexer breaks page BEFORE overflowing line (matches renderer pre-check)
#define PREINDEX_PAGES    100
#define READER_MAX_FILES  50
#define READER_BUF_SIZE   4096

// ============================================================================
// Word Wrap Helper (same algorithm as standalone reader)
// ============================================================================
struct WrapResult {
  int lineEnd;
  int nextStart;
};

inline WrapResult findLineBreak(const char* buffer, int bufLen, int lineStart, int maxChars) {
  WrapResult 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) {
      // Skip UTF-8 continuation bytes (0x80-0xBF) - the lead byte already
      // counted as one display character, so don't double-count these.
      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;
}

// ============================================================================
// Pixel-width line breaking for proportional fonts (T5S3)
//
// Measures actual rendered text width via DisplayDriver::getTextWidth() at
// each word boundary. This gives correct line breaks regardless of character
// width variation in proportional fonts like FreeSans12pt.
// maxChars is a safety upper bound to prevent runaway on spaceless lines.
// ============================================================================
#if defined(LilyGo_T5S3_EPaper_Pro)
#include <helpers/ui/DisplayDriver.h>

inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineStart,
                                      DisplayDriver* display, int maxChars) {
  WrapResult result;
  result.lineEnd = lineStart;
  result.nextStart = lineStart;
  if (lineStart >= bufLen || !display) return result;

#if defined(LilyGo_T5S3_EPaper_Pro)
  int rightMargin = 5;  // Wider margin for T5S3 (portrait mode especially tight)
#else
  int rightMargin = 3;
#endif
  int displayW = display->width() - rightMargin;
  char measBuf[300];                // temp buffer for pixel measurement
  int measLen = 0;
  int lastBreakPoint = -1;
  int lastBreakMeasLen = 0;         // measLen at lastBreakPoint (for mid-word fallback)
  bool inWord = false;
  int charCount = 0;

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

    // Newline handling (identical to char-count version)
    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 ((uint8_t)c >= 32) {
      // UTF-8 handling: decode multi-byte sequences to CP437 for accurate
      // width measurement.  The renderer (renderPage) does this same conversion,
      // so the measurement must match or it underestimates line width.
      int charStartIdx = i;  // buffer index where this character started
      if ((uint8_t)c >= 0xC0) {
        // UTF-8 lead byte — decode full sequence to CP437
        int decPos = i;
        uint32_t cp = decodeUtf8Char(buffer, bufLen, &decPos);
        uint8_t glyph = unicodeToCP437(cp);
        if (glyph >= 32 && measLen < 298) {
          measBuf[measLen++] = (char)glyph;
          charCount++;
        }
        i = decPos - 1;  // -1 because the for loop will i++
        inWord = true;
      } else if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) {
        // Orphan continuation byte — treat as CP437 pass-through (same as renderer)
        if (measLen < 298) measBuf[measLen++] = c;
        charCount++;
        inWord = true;
      } else {
        // Plain ASCII
        charCount++;
        if (measLen < 298) measBuf[measLen++] = c;

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

      // Per-character pixel width check — catches long words that exceed
      // displayW without ever hitting a space/hyphen break point.
      // Only measure every 3 chars to avoid excessive getTextWidth() calls.
      if ((charCount & 3) == 0 || c == ' ' || c == '-') {
        measBuf[measLen] = '\0';
        int pw = display->getTextWidth(measBuf);
        if (pw >= displayW) {
          if (lastBreakPoint > lineStart) {
            // Break at last word boundary
            result.lineEnd = lastBreakPoint;
            result.nextStart = lastBreakPoint;
            while (result.nextStart < bufLen &&
                   (buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
              result.nextStart++;
          } else {
            // No word boundary found — break mid-word before this character
            result.lineEnd = charStartIdx;
            result.nextStart = charStartIdx;
          }
          return result;
        }
      }

      // Safety: hard char limit (handles spaceless lines, URLs, etc.)
      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 // LilyGo_T5S3_EPaper_Pro

// ============================================================================
// Page Indexer (word-wrap aware, matches display rendering)
// When textAreaHeight and lineHeight are provided (both > 0), uses height-based
// pagination that accounts for blank lines getting 40% height (matching renderer).
// Otherwise falls back to simple line counting.
// ============================================================================
inline int indexPagesWordWrap(File& file, long startPos,
                              std::vector<long>& pagePositions,
                              int linesPerPage, int charsPerLine,
                              int maxPages,
                              int textAreaHeight = 0, int lineHeight = 0) {
  const int BUF_SIZE = READER_BUF_SIZE;  // Match page buffer to avoid chunk boundary wrap mismatches
  char buffer[BUF_SIZE];

  bool heightAware = (textAreaHeight > 0 && lineHeight > 0);
  int blankLineH = heightAware ? max(2, lineHeight * 2 / 5) : 0;

  file.seek(startPos);
  int pagesAdded = 0;
  int lineCount = 0;
  int accHeight = 0;
  int leftover = 0;
  long chunkFileStart = startPos;

  while (file.available() && (maxPages <= 0 || pagesAdded < maxPages)) {
    int bytesRead = file.readBytes(buffer + leftover, BUF_SIZE - leftover);
    int bufLen = leftover + bytesRead;
    if (bufLen == 0) break;

    int pos = 0;
    while (pos < bufLen) {
      int lineStart = pos;
      WrapResult wrap = findLineBreak(buffer, bufLen, pos, charsPerLine);
      if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;

      // Blank line = newline at line start (no printable content before it)
      bool isBlankLine = (wrap.lineEnd == lineStart);

      bool pageBreak = false;
      if (heightAware) {
        int thisH = isBlankLine ? blankLineH : lineHeight;
        // Check BEFORE adding: does this line fit on the current page?
        // The renderer checks y <= maxY before rendering each line,
        // so we must break the page BEFORE a line that won't fit.
        if (accHeight > 0 && accHeight + thisH > textAreaHeight) {
          // This line doesn't fit — start new page at this line's position
          long pageFilePos = chunkFileStart + lineStart;
          pagePositions.push_back(pageFilePos);
          pagesAdded++;
          accHeight = 0;
          if (maxPages > 0 && pagesAdded >= maxPages) break;
        }
        accHeight += thisH;
      } else {
        lineCount++;
        if (lineCount >= linesPerPage) {
          pageBreak = true;
          lineCount = 0;
        }
      }

      pos = wrap.nextStart;

      if (pageBreak) {
        long pageFilePos = chunkFileStart + pos;
        pagePositions.push_back(pageFilePos);
        pagesAdded++;
        if (maxPages > 0 && pagesAdded >= maxPages) break;
      }
      if (pos >= bufLen) break;
    }

    leftover = bufLen - pos;
    if (leftover > 0 && leftover < BUF_SIZE) {
      memmove(buffer, buffer + pos, leftover);
    } else {
      leftover = 0;
    }
    chunkFileStart = file.position() - leftover;
  }

  return pagesAdded;
}

// ============================================================================
// Pixel-based Page Indexer for T5S3 (proportional font word wrap)
// ============================================================================
#if defined(LilyGo_T5S3_EPaper_Pro)
inline int indexPagesWordWrapPixel(File& file, long startPos,
                                    std::vector<long>& pagePositions,
                                    int linesPerPage, int maxChars,
                                    DisplayDriver* display, int maxPages,
                                    NodePrefs* prefs = nullptr) {
  const int BUF_SIZE = READER_BUF_SIZE;  // Match page buffer to avoid chunk boundary wrap mismatches
  char buffer[BUF_SIZE];

  // Ensure body font is active for pixel measurement
  display->setTextSize(prefs ? prefs->smallTextSize() : 0);

  file.seek(startPos);
  int pagesAdded = 0;
  int lineCount = 0;
  int leftover = 0;
  long chunkFileStart = startPos;

  while (file.available() && (maxPages <= 0 || pagesAdded < maxPages)) {
    int bytesRead = file.readBytes(buffer + leftover, BUF_SIZE - leftover);
    int bufLen = leftover + bytesRead;
    if (bufLen == 0) break;

    int pos = 0;
    while (pos < bufLen) {
      WrapResult wrap = findLineBreakPixel(buffer, bufLen, pos, display, maxChars);
      if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;

      lineCount++;
      pos = wrap.nextStart;

      if (lineCount >= linesPerPage) {
        long pageFilePos = chunkFileStart + pos;
        pagePositions.push_back(pageFilePos);
        pagesAdded++;
        lineCount = 0;
        if (maxPages > 0 && pagesAdded >= maxPages) break;
      }
      if (pos >= bufLen) break;
    }

    leftover = bufLen - pos;
    if (leftover > 0 && leftover < BUF_SIZE) {
      memmove(buffer, buffer + pos, leftover);
    } else {
      leftover = 0;
    }
    chunkFileStart = file.position() - leftover;
  }

  display->setTextSize(1);  // Restore
  return pagesAdded;
}
#endif // LilyGo_T5S3_EPaper_Pro

// ============================================================================
// TextReaderScreen
// ============================================================================

class TextReaderScreen : public UIScreen {
public:
  enum Mode { FILE_LIST, READING };

  // File cache entry (index + resume position)
  struct FileCache {
    String filename;
    std::vector<long> pagePositions;
    unsigned long fileSize;
    bool fullyIndexed;
    int lastReadPage;
  };

private:
  UITask* _task;
  NodePrefs* _prefs;
  Mode _mode;
  bool _sdReady;
  bool _initialized;       // Layout metrics calculated
  uint8_t _lastFontPref;   // Font preference at last layout init (detect changes)
  bool _bootIndexed;       // Boot-time pre-indexing done
  DisplayDriver* _display; // Stored reference for splash screens

  // Display layout (calculated once from display metrics)
  int _charsPerLine;
  int _linesPerPage;
  int _lineHeight;     // virtual coord units per text line
  int _textAreaHeight; // usable height for text (excluding header/footer)
  int _headerHeight;
  int _footerHeight;

  // File list state
  std::vector<String> _fileList;
  std::vector<String> _dirList;    // Subdirectories at current path
  std::vector<FileCache> _fileCache;
  int _selectedFile;
  String _currentPath;             // Current browsed directory

  // Reading state
  File _file;
  String _currentFile;
  bool _fileOpen;
  int _currentPage;
  int _totalPages;
  std::vector<long> _pagePositions;

  // Page content buffer (pre-read from SD before render)
  char _pageBuf[READER_BUF_SIZE];
  int _pageBufLen;
  bool _contentDirty;  // Need to re-read from SD

  // Go-to-page input mode (Enter in reading view)
  bool _gotoMode = false;
  char _gotoBuf[6];      // Up to 5 digits + null
  int _gotoBufLen = 0;

  // ---- Splash Screen Drawing ----
  // Draw directly to display outside the normal render cycle.
  // Matches the style of the standalone text reader firmware splash.

  // Generic splash screen: title (large green), subtitle (normal), detail (normal)
  void drawSplash(const char* title, const char* subtitle, const char* detail) {
    if (!_display) return;
    _display->startFrame();

    // Title in large text
    _display->setTextSize(2);
    _display->setColor(DisplayDriver::GREEN);
    _display->setCursor(10, 11);
    _display->print(title);

    _display->setTextSize(1);
    _display->setColor(DisplayDriver::LIGHT);

    int y = 35;

    // Subtitle
    if (subtitle && subtitle[0]) {
      _display->setCursor(10, y);
      _display->print(subtitle);
      y += 8;
    }

    // Detail line
    if (detail && detail[0]) {
      _display->setCursor(10, y);
      _display->print(detail);
    }

    _display->endFrame();
  }

  // Word-wrapping splash for opening a large book.
  // Shows: "Indexing / Pages..." (large), word-wrapped filename, "Please wait. / Loading shortly..."
  void drawIndexingSplash(const String& filename) {
    if (!_display) return;
    _display->startFrame();

    // "Indexing" / "Pages..." in large text
    // Original: textSize(2) at real (20, 40) and (20, 65)
    // Virtual: (10, 11) and (10, 21)
    _display->setTextSize(2);
    _display->setColor(DisplayDriver::GREEN);
    _display->setCursor(10, 11);
    _display->print("Indexing");
    _display->setCursor(10, 21);
    _display->print("Pages...");

    // Word-wrapped filename in normal text
    _display->setTextSize(1);
    _display->setColor(DisplayDriver::LIGHT);
    int y = 39;
    int leftMargin = 10;
    // Calculate max chars that fit: (display_width - margin) / char_width
    uint16_t charW = _display->getTextWidth("M");
    int maxChars = charW > 0 ? (_display->width() - leftMargin) / charW : 20;
    if (maxChars < 10) maxChars = 10;
    if (maxChars > 40) maxChars = 40;
    String remaining = filename;

    while (remaining.length() > 0 && y < 80) {
      String line;
      if ((int)remaining.length() <= maxChars) {
        line = remaining;
        remaining = "";
      } else {
        int breakPoint = maxChars;
        for (int i = maxChars; i > 0; i--) {
          if (remaining[i] == ' ' || remaining[i] == '-' || remaining[i] == '_') {
            breakPoint = i;
            break;
          }
        }
        line = remaining.substring(0, breakPoint);
        remaining = remaining.substring(breakPoint);
        remaining.trim();
      }
      _display->setCursor(10, y);
      _display->print(line.c_str());
      y += 5;
    }

    // "Please wait." / "Loading shortly..."
    // Original: textSize(1) at real (20, 230) and (20, 245)
    // Virtual: y=87 and y=93
    _display->setColor(DisplayDriver::LIGHT);
    _display->setCursor(10, 87);
    _display->print("Please wait.");
    _display->setCursor(10, 93);
    _display->print("Loading shortly...");

    _display->endFrame();
  }

  // Boot-time progress splash with file counter.
  // Shows: "Indexing / Pages..." (large), "(2/10)", word-wrapped filename, "Please wait."
  // If current==0 and total==0, skips the progress counter (used for initial scan splash).
  void drawBootSplash(int current, int total, const String& filename) {
    if (!_display) return;
    _display->startFrame();

    // "Indexing" / "Pages..." in large text
    _display->setTextSize(2);
    _display->setColor(DisplayDriver::GREEN);
    _display->setCursor(10, 11);
    _display->print("Indexing");
    _display->setCursor(10, 21);
    _display->print("Pages...");

    _display->setTextSize(1);
    _display->setColor(DisplayDriver::LIGHT);

    int y = 35;

    // Progress counter (skip if both zero)
    if (current > 0 || total > 0) {
      char progress[20];
      sprintf(progress, "(%d/%d)", current, total);
      _display->setCursor(10, y);
      _display->print(progress);
      y += 8;
    }

    // Word-wrapped filename
    int leftMargin = 10;
    uint16_t charW = _display->getTextWidth("M");
    int maxChars = charW > 0 ? (_display->width() - leftMargin) / charW : 20;
    if (maxChars < 10) maxChars = 10;
    if (maxChars > 40) maxChars = 40;
    String remaining = filename;

    while (remaining.length() > 0 && y < 80) {
      String line;
      if ((int)remaining.length() <= maxChars) {
        line = remaining;
        remaining = "";
      } else {
        int breakPoint = maxChars;
        for (int i = maxChars; i > 0; i--) {
          if (remaining[i] == ' ' || remaining[i] == '-' || remaining[i] == '_') {
            breakPoint = i;
            break;
          }
        }
        line = remaining.substring(0, breakPoint);
        remaining = remaining.substring(breakPoint);
        remaining.trim();
      }
      _display->setCursor(10, y);
      _display->print(line.c_str());
      y += 5;
    }

    // "Please wait."
    _display->setCursor(10, 87);
    _display->print("Please wait.");

    _display->endFrame();
  }

  // ---- SD Index I/O ----

  String getIndexPath(const String& filename) {
    return String(INDEX_FOLDER) + "/" + filename + ".idx";
  }

  bool loadIndex(const String& filename, FileCache& cache) {
    String idxPath = getIndexPath(filename);
    File idxFile = SD.open(idxPath.c_str(), FILE_READ);
    if (!idxFile) return false;

    uint8_t version = 0;
    unsigned long savedSize = 0, pageCount = 0;
    uint8_t fullyFlag = 0;
    int lastRead = 0;

    idxFile.read(&version, 1);
    if (version != INDEX_VERSION) {
      // Wrong version - discard and rebuild
      idxFile.close();
      SD.remove(idxPath.c_str());
      return false;
    }

    idxFile.read((uint8_t*)&savedSize, 4);
    idxFile.read((uint8_t*)&pageCount, 4);
    idxFile.read(&fullyFlag, 1);
    idxFile.read((uint8_t*)&lastRead, 4);

    // Verify file hasn't changed - try current path first, then epub cache
    String fullPath = _currentPath + "/" + filename;
    File txtFile = SD.open(fullPath.c_str(), FILE_READ);
    if (!txtFile) {
      // Fallback: check epub cache directory
      String cachePath = String("/books/.epub_cache/") + filename;
      txtFile = SD.open(cachePath.c_str(), FILE_READ);
    }
    if (!txtFile) { idxFile.close(); return false; }
    unsigned long curSize = txtFile.size();
    txtFile.close();

    if (savedSize != curSize) {
      idxFile.close();
      SD.remove(idxPath.c_str());
      return false;
    }

    cache.filename = filename;
    cache.fileSize = savedSize;
    cache.fullyIndexed = (fullyFlag == 1);
    cache.lastReadPage = lastRead;
    cache.pagePositions.clear();

    for (unsigned long i = 0; i < pageCount; i++) {
      long pos = 0;
      idxFile.read((uint8_t*)&pos, 4);
      cache.pagePositions.push_back(pos);
    }

    idxFile.close();
    return true;
  }

  bool saveIndex(const String& filename, const std::vector<long>& pages,
                 unsigned long fileSize, bool fullyIndexed, int lastReadPage) {
    if (!SD.exists(INDEX_FOLDER)) SD.mkdir(INDEX_FOLDER);

    String idxPath = getIndexPath(filename);
    if (SD.exists(idxPath.c_str())) SD.remove(idxPath.c_str());

    File idxFile = SD.open(idxPath.c_str(), FILE_WRITE);
    if (!idxFile) return false;

    uint8_t version = INDEX_VERSION;
    unsigned long pageCount = pages.size();
    uint8_t fullyFlag = fullyIndexed ? 1 : 0;

    idxFile.write(&version, 1);
    idxFile.write((uint8_t*)&fileSize, 4);
    idxFile.write((uint8_t*)&pageCount, 4);
    idxFile.write(&fullyFlag, 1);
    idxFile.write((uint8_t*)&lastReadPage, 4);

    for (unsigned long i = 0; i < pageCount; i++) {
      long pos = pages[i];
      idxFile.write((uint8_t*)&pos, 4);
    }
    idxFile.close();
    return true;
  }

  bool saveReadingPosition(const String& filename, int page) {
    String idxPath = getIndexPath(filename);
    File idxFile = SD.open(idxPath.c_str(), "r+");
    if (!idxFile) return false;

    uint8_t version = 0;
    idxFile.read(&version, 1);

    if (version != INDEX_VERSION) {
      idxFile.close();
      for (int i = 0; i < (int)_fileCache.size(); i++) {
        if (_fileCache[i].filename == filename) {
          _fileCache[i].lastReadPage = page;
          return saveIndex(filename, _fileCache[i].pagePositions,
                           _fileCache[i].fileSize, _fileCache[i].fullyIndexed, page);
        }
      }
      return false;
    }

    // Seek to lastReadPage field: version(1) + fileSize(4) + pageCount(4) + fullyIndexed(1)
    idxFile.seek(1 + 4 + 4 + 1);
    idxFile.write((uint8_t*)&page, 4);
    idxFile.close();
    return true;
  }

  // ---- File Scanning ----

  // ---- Folder Navigation Helpers ----

  bool isAtBooksRoot() const {
    return _currentPath == String(BOOKS_FOLDER);
  }

  // Number of non-file entries at the start of the visual list
  int dirEntryCount() const {
    int count = _dirList.size();
    if (!isAtBooksRoot()) count++;  // ".." entry
    return count;
  }

  // Total items in the visual list (parent + dirs + files)
  int totalListItems() const {
    return dirEntryCount() + (int)_fileList.size();
  }

  // What type of entry is at visual list index idx?
  // Returns: 0 = ".." parent, 1 = directory, 2 = file
  int itemTypeAt(int idx) const {
    bool hasParent = !isAtBooksRoot();
    if (hasParent && idx == 0) return 0;  // ".."
    int dirStart = hasParent ? 1 : 0;
    if (idx < dirStart + (int)_dirList.size()) return 1;  // directory
    return 2;  // file
  }

  // Get directory name for visual index (only valid when itemTypeAt == 1)
  const String& dirNameAt(int idx) const {
    int dirStart = isAtBooksRoot() ? 0 : 1;
    return _dirList[idx - dirStart];
  }

  // Get file list index for visual index (only valid when itemTypeAt == 2)
  int fileIndexAt(int idx) const {
    return idx - dirEntryCount();
  }

  void navigateToParent() {
    int lastSlash = _currentPath.lastIndexOf('/');
    if (lastSlash > 0) {
      _currentPath = _currentPath.substring(0, lastSlash);
    } else {
      _currentPath = BOOKS_FOLDER;
    }
  }

  void navigateToChild(const String& dirName) {
    _currentPath = _currentPath + "/" + dirName;
  }

  // ---- File Scanning ----

  void scanFiles() {
    _fileList.clear();
    _dirList.clear();
    if (!SD.exists(BOOKS_FOLDER)) {
      SD.mkdir(BOOKS_FOLDER);
      Serial.printf("TextReader: Created %s\n", BOOKS_FOLDER);
    }

    File root = SD.open(_currentPath.c_str());
    if (!root || !root.isDirectory()) return;

    File f = root.openNextFile();
    while (f && (_fileList.size() + _dirList.size()) < READER_MAX_FILES) {
      String name = String(f.name());
      int slash = name.lastIndexOf('/');
      if (slash >= 0) name = name.substring(slash + 1);

      // Skip hidden files/dirs
      if (name.startsWith(".")) {
        f = root.openNextFile();
        continue;
      }

      if (f.isDirectory()) {
        _dirList.push_back(name);
      } else if (name.endsWith(".txt") || name.endsWith(".TXT") ||
                 name.endsWith(".epub") || name.endsWith(".EPUB")) {
        _fileList.push_back(name);
      }
      f = root.openNextFile();
    }
    root.close();
    Serial.printf("TextReader: %s — %d dirs, %d files\n",
                  _currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size());
  }

  // ---- Book Open/Close ----

  void openBook(const String& filename) {
    if (_fileOpen) closeBook();

    // ---- EPUB auto-conversion ----
    String actualFilename = filename;
    String actualFullPath = _currentPath + "/" + filename;
    bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");

    if (isEpub) {
      // Build cache path for this EPUB
      char cachePath[160];
      EpubProcessor::buildCachePath(actualFullPath.c_str(), cachePath, sizeof(cachePath));

      // Check if already converted
      digitalWrite(SDCARD_CS, LOW);
      bool cached = SD.exists(cachePath);
      digitalWrite(SDCARD_CS, HIGH);

      if (!cached) {
        // Show conversion splash on e-ink
        char shortName[28];
        if (filename.length() > 24) {
          strncpy(shortName, filename.c_str(), 21);
          shortName[21] = '\0';
          strcat(shortName, "...");
        } else {
          strncpy(shortName, filename.c_str(), sizeof(shortName) - 1);
          shortName[sizeof(shortName) - 1] = '\0';
        }
        drawSplash("Converting EPUB...", "Please wait", shortName);

        Serial.printf("TextReader: Converting EPUB '%s'\n", filename.c_str());
        unsigned long t0 = millis();

        digitalWrite(SDCARD_CS, LOW);
        bool ok = EpubProcessor::processToText(actualFullPath.c_str(), cachePath);
        digitalWrite(SDCARD_CS, HIGH);

        if (!ok) {
          Serial.println("TextReader: EPUB conversion failed!");
          drawSplash("Convert failed!", "", shortName);
          delay(2000);
          return;  // Stay in file list
        }
        Serial.printf("TextReader: EPUB converted in %lu ms\n", millis() - t0);
      } else {
        Serial.printf("TextReader: EPUB cache hit for '%s'\n", filename.c_str());
      }

      // Redirect to the cached .txt
      actualFullPath = String(cachePath);
      const char* lastSlash = strrchr(cachePath, '/');
      actualFilename = String(lastSlash ? lastSlash + 1 : cachePath);
    }
    // ---- End EPUB auto-conversion ----

    // Find cached index for this file
    FileCache* cache = nullptr;
    for (int i = 0; i < (int)_fileCache.size(); i++) {
      if (_fileCache[i].filename == actualFilename) {
        cache = &_fileCache[i];
        break;
      }
    }

    _file = SD.open(actualFullPath.c_str(), FILE_READ);

    // Fallback: try epub cache dir (for files discovered during boot scan)
    if (!_file && !isEpub) {
      String cacheFallback = String("/books/.epub_cache/") + actualFilename;
      _file = SD.open(cacheFallback.c_str(), FILE_READ);
      if (_file) {
        actualFullPath = cacheFallback;
        Serial.printf("TextReader: Opened from epub cache: %s\n", actualFilename.c_str());
      }
    }

    if (!_file) {
      Serial.printf("TextReader: Failed to open %s\n", actualFilename.c_str());
      return;
    }

    _currentFile = actualFilename;
    _fileOpen = true;
    _currentPage = 0;
    _pagePositions.clear();

    if (cache) {
      for (int i = 0; i < (int)cache->pagePositions.size(); i++) {
        _pagePositions.push_back(cache->pagePositions[i]);
      }
      if (cache->lastReadPage > 0 && cache->lastReadPage < (int)cache->pagePositions.size()) {
        _currentPage = cache->lastReadPage;
      }

      // Already fully indexed â€” open immediately
      if (cache->fullyIndexed) {
        _totalPages = _pagePositions.size();
        _mode = READING;
        loadPageContent();
        Serial.printf("TextReader: Opened %s, %d pages, resume pg %d\n",
                      actualFilename.c_str(), _totalPages, _currentPage + 1);
        return;
      }

      // Partially indexed â€” finish indexing with splash
      Serial.printf("TextReader: Finishing index for %s (have %d pages so far)\n",
                    actualFilename.c_str(), (int)_pagePositions.size());

      char shortName[28];
      if (actualFilename.length() > 24) {
        strncpy(shortName, actualFilename.c_str(), 21);
        shortName[21] = '\0';
        strcat(shortName, "...");
      } else {
        strncpy(shortName, actualFilename.c_str(), sizeof(shortName) - 1);
        shortName[sizeof(shortName) - 1] = '\0';
      }
      drawSplash("Indexing...", "Please wait", shortName);

      if (_pagePositions.empty()) {
        // Cache had no pages (e.g. dummy entry) â€” full index from scratch
        _pagePositions.push_back(0);
        indexPagesWordWrap(_file, 0, _pagePositions,
                           _linesPerPage, _charsPerLine, 0,
                           _textAreaHeight, _lineHeight);
      } else {
        long lastPos = cache->pagePositions.back();
        indexPagesWordWrap(_file, lastPos, _pagePositions,
                           _linesPerPage, _charsPerLine, 0,
                           _textAreaHeight, _lineHeight);
      }
    } else {
      // No cache â€” full index from scratch
      Serial.printf("TextReader: Full index for %s\n", actualFilename.c_str());

      char shortName[28];
      if (actualFilename.length() > 24) {
        strncpy(shortName, actualFilename.c_str(), 21);
        shortName[21] = '\0';
        strcat(shortName, "...");
      } else {
        strncpy(shortName, actualFilename.c_str(), sizeof(shortName) - 1);
        shortName[sizeof(shortName) - 1] = '\0';
      }
      drawSplash("Indexing...", "Please wait", shortName);

      _pagePositions.push_back(0);
      indexPagesWordWrap(_file, 0, _pagePositions,
                         _linesPerPage, _charsPerLine, 0,
                         _textAreaHeight, _lineHeight);
    }

    // Save complete index
    _totalPages = _pagePositions.size();

    // Update or create cache entry
    bool foundCache = false;
    for (int i = 0; i < (int)_fileCache.size(); i++) {
      if (_fileCache[i].filename == actualFilename) {
        _fileCache[i].pagePositions = _pagePositions;
        _fileCache[i].fullyIndexed = true;
        _fileCache[i].fileSize = _file.size();
        foundCache = true;
        break;
      }
    }
    if (!foundCache) {
      FileCache newCache;
      newCache.filename = actualFilename;
      newCache.fileSize = _file.size();
      newCache.fullyIndexed = true;
      newCache.lastReadPage = _currentPage;
      newCache.pagePositions = _pagePositions;
      _fileCache.push_back(newCache);
    }

    saveIndex(actualFilename, _pagePositions, _file.size(), true, _currentPage);

    _mode = READING;
    loadPageContent();
    Serial.printf("TextReader: Opened %s, %d pages\n",
                  actualFilename.c_str(), _totalPages);
  }

  void closeBook() {
    if (!_fileOpen) return;
    saveReadingPosition(_currentFile, _currentPage);

    for (int i = 0; i < (int)_fileCache.size(); i++) {
      if (_fileCache[i].filename == _currentFile) {
        _fileCache[i].lastReadPage = _currentPage;
        break;
      }
    }

    _file.close();
    _fileOpen = false;
    _pagePositions.clear();
    _pagePositions.shrink_to_fit();
    // Deselect SD to free SPI bus
    digitalWrite(SDCARD_CS, HIGH);
    Serial.printf("TextReader: Closed, saved at page %d\n", _currentPage + 1);
  }

  // ---- Page Content Loading ----
  // Read exact span between indexed page positions so renderer gets
  // exactly the bytes the indexer counted for this page.

  void loadPageContent() {
    if (!_fileOpen || _currentPage >= _totalPages) {
      _pageBufLen = 0;
      return;
    }

    long pageStart = _pagePositions[_currentPage];
    long pageEnd;
    if (_currentPage + 1 < _totalPages) {
      pageEnd = _pagePositions[_currentPage + 1];
    } else {
      // Last page - read remaining file content
      pageEnd = _file.size();
    }

    long pageSpan = pageEnd - pageStart;
    int toRead = (int)min((long)(READER_BUF_SIZE - 1), pageSpan);

    _file.seek(pageStart);
    _pageBufLen = _file.readBytes(_pageBuf, toRead);
    _pageBuf[_pageBufLen] = '\0';
    _contentDirty = false;

    // Deselect SD to free SPI bus for display
    digitalWrite(SDCARD_CS, HIGH);
  }

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

  void renderFileList(DisplayDriver& display) {
    char tmp[40];

    // Header
    display.setCursor(0, 0);
    display.setTextSize(1);
    display.setColor(DisplayDriver::GREEN);
    if (isAtBooksRoot()) {
      display.print("Text Reader");
    } else {
      // Show current subfolder name
      int lastSlash = _currentPath.lastIndexOf('/');
      String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath;
      char hdrBuf[20];
      strncpy(hdrBuf, folderName.c_str(), 17);
      hdrBuf[17] = '\0';
      display.print(hdrBuf);
    }

    int totalItems = totalListItems();
    sprintf(tmp, "[%d]", totalItems);
    display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
    display.print(tmp);

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

    if (totalItems == 0) {
      display.setCursor(0, 18);
      display.setColor(DisplayDriver::LIGHT);
      display.print("No files found");
      display.setCursor(0, 30);
      display.print("Add .txt or .epub to");
      display.setCursor(0, 42);
      display.print("/books/ on SD card");
    } else {
      display.setTextSize(_prefs->smallTextSize());  // Tiny font for file list
      int listLineH = _prefs->smallLineH();
      int startY = 14;
      int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
      if (maxVisible < 3) maxVisible = 3;
      if (maxVisible > 15) maxVisible = 15;

      int startIdx = max(0, min(_selectedFile - maxVisible / 2,
                                totalItems - maxVisible));
      int endIdx = min(totalItems, startIdx + maxVisible);

      int y = startY;
      for (int i = startIdx; i < endIdx; i++) {
        bool selected = (i == _selectedFile);

        if (selected) {
          display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
          display.fillRect(0, y, display.width(), listLineH);
#else
          // setCursor adds +5 to y internally, but fillRect does not.
          // Offset fillRect by +5 to align highlight bar with text.
          display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
          display.setColor(DisplayDriver::DARK);
        } else {
          display.setColor(DisplayDriver::LIGHT);
        }

        // Set cursor AFTER fillRect so text draws on top of highlight
        int type = itemTypeAt(i);
        String line = selected ? "> " : "  ";

        if (type == 0) {
          // ".." parent directory
          line += ".. (up)";
        } else if (type == 1) {
          // Subdirectory
          line += "/" + dirNameAt(i);
        } else {
          // File
          int fi = fileIndexAt(i);
          String name = _fileList[fi];

          // Check for resume indicator
          String suffix = "";
          if (fi < (int)_fileCache.size()) {
            if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) {
              suffix = " *";
            }
          }
          line += name + suffix;
        }

        // Pixel-aware ellipsis — small margin prevents GxEPD edge wrapping
        display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
        y += listLineH;
      }
      display.setTextSize(1);  // Restore
    }

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

#if defined(LilyGo_T5S3_EPaper_Pro)
    display.setTextSize(_prefs->smallTextSize());
    display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll   Tap: Open   Boot: home");
#else
    display.setCursor(0, footerY);
    display.print("Q:Bk");

    const char* right = "Tap/Ent:Open";
    display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
    display.print(right);
#endif
  }

  void renderPage(DisplayDriver& display) {
    // Use tiny font for maximum text density
    display.setTextSize(_prefs->smallTextSize());
    display.setColor(DisplayDriver::LIGHT);

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

    // Render all lines in the page buffer using word wrap.
    // The buffer contains exactly the bytes for this page (from indexed positions),
    // so we render everything in it.
    while (pos < _pageBufLen && y <= maxY) {
      int oldPos = pos;
      WrapResult wrap = findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine);

      // Safety: stop if findLineBreak made no progress (stuck at end of buffer)
      if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break;

      display.setCursor(0, y);
      // Print line with UTF-8 decoding: multi-byte sequences are decoded
      // to Unicode codepoints, then mapped to CP437 for the built-in font.
      bool lineHasContent = false;
      char charStr[2] = {0, 0};
      int j = pos;
      while (j < wrap.lineEnd && j < _pageBufLen) {
        uint8_t b = (uint8_t)_pageBuf[j];

        if (b < 32) {
          // Control character — skip
          j++;
          continue;
        }

        if (b < 0x80) {
          // Plain ASCII — print directly
          charStr[0] = (char)b;
          display.print(charStr);
          lineHasContent = true;
          j++;
        } else if (b >= 0xC0) {
          // UTF-8 lead byte — decode full sequence and map to CP437
          int savedJ = j;
          uint32_t cp = decodeUtf8Char(_pageBuf, wrap.lineEnd, &j);
          uint8_t glyph = unicodeToCP437(cp);
          if (glyph) {
            charStr[0] = (char)glyph;
            display.print(charStr);
            lineHasContent = true;
          }
          // If unmappable (glyph==0), just skip the character
        } else {
          // Standalone byte 0x80-0xBF: not a valid UTF-8 lead byte.
          // Treat as CP437 pass-through (e.g. from EPUB numeric entity decoding).
          charStr[0] = (char)b;
          display.print(charStr);
          lineHasContent = true;
          j++;
        }
      }

      // Blank lines (paragraph breaks) get reduced height for compact layout.
      // Full _lineHeight for blank lines wastes too much space — on T5S3 each
      // blank line is ~34px, making paragraph gaps 7-8× the normal line spacing.
      // Using 40% height gives a visible paragraph break without wasting space.
      if (lineHasContent) {
        y += _lineHeight;
      } else {
        y += max(2, _lineHeight * 2 / 5);  // ~40% height for blank lines
      }
      lineCount++;
      pos = wrap.nextStart;
      if (pos >= _pageBufLen) break;
    }

    // Restore text size for footer
    display.setTextSize(1);

    // Footer: page info on left, navigation hints on right
    int footerY = display.height() - 12;
    display.drawRect(0, footerY - 2, display.width(), 1);
    display.setColor(DisplayDriver::YELLOW);

    char status[30];
    int pct = _totalPages > 1 ? (_currentPage * 100) / (_totalPages - 1) : 100;

    if (_gotoMode) {
      // Go-to-page input mode — show typed digits in footer
      snprintf(status, sizeof(status), "Go to: %.*s_", _gotoBufLen, _gotoBuf);
    } else {
      sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
    }

#if defined(LilyGo_T5S3_EPaper_Pro)
    display.setTextSize(_prefs->smallTextSize());
    display.setCursor(0, footerY);
    display.print(status);
    const char* right = "Swipe:Page  Tap:GoTo  Hold:Close";
    display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
    display.print(right);
#else
    display.setCursor(0, footerY);
    display.print(status);

    const char* right = _gotoMode ? "Ent:Go Q:Cancel" : "Entr:Pg# Q:Bk";
    display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
    display.print(right);
#endif
  }

public:
  TextReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
    : _task(task), _prefs(prefs), _mode(FILE_LIST), _sdReady(false), _initialized(false), _lastFontPref(0),
      _bootIndexed(false), _display(nullptr),
      _charsPerLine(38), _linesPerPage(22), _lineHeight(5),
      _textAreaHeight(100), _headerHeight(14), _footerHeight(14),
      _selectedFile(0), _currentPath(BOOKS_FOLDER),
      _fileOpen(false), _currentPage(0), _totalPages(0),
      _pageBufLen(0), _contentDirty(true) {
  }

  // Reset layout so it recalculates on next render (orientation change).
  // If a book is open, forces full reindex with new layout params.
  void invalidateLayout() {
    _initialized = false;
    if (_fileOpen) {
      _pagePositions.clear();
      _totalPages = 0;
      _currentPage = 0;
      _pageBufLen = 0;
      _contentDirty = true;
      Serial.println("TextReader: Layout invalidated, will reindex on next enter");
    }
  }

  // Call once after display is available to calculate layout metrics
  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("TextReader: font changed, recalculating layout");
    }
    if (_initialized) return;
    _lastFontPref = curFont;

    // Store display reference for splash screens during openBook
    _display = &display;

    // Measure tiny font metrics using the display driver
    display.setTextSize(_prefs->smallTextSize());

    // Measure character width: use 10 M's for monospace (T-Deck Pro tiny font).
    // Proportional fonts (T5S3 and T-Deck Pro large_font) override below with
    // average-width measurement since M is the widest glyph (~40% wider than average).
    uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
    if (tenCharsW > 0) {
      _charsPerLine = (display.width() * 10) / tenCharsW;
    }
#if defined(LilyGo_T5S3_EPaper_Pro)
    // T5S3 uses proportional font (FreeSans12pt) — measure average character
    // width from a representative English sample.  M-based measurement is far
    // too conservative (M is the widest glyph), leaving half the line empty.
    {
      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) {
        // 95% factor as small safety margin for slightly-wider-than-average text
        _charsPerLine = (display.width() * sampleLen * 95) / ((int)sampleW * 100);
      }
    }
    if (_charsPerLine < 15) _charsPerLine = 15;
    if (_charsPerLine > 80) _charsPerLine = 80;
#else
    // T-Deck Pro: large_font uses FreeSans9pt (proportional) — same fix
    if (_prefs && _prefs->large_font) {
      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);
      }
    }
    if (_charsPerLine < 15) _charsPerLine = 15;
    if (_charsPerLine > 60) _charsPerLine = 60;
#endif

    // Line height for built-in 6x8 font:
    // setCursor adds +5 to y, so effective text top = (y+5)*scale_y
    // The font is ~8px tall in real coords. In virtual coords: 8/scale_y ~ 3.2 units
    // We derive from measured char width since we can't measure height directly.
    // Built-in font: 6px wide, 8px tall -> height ~ width * 7/6
    // Then add ~20% for spacing
    uint16_t mWidth = display.getTextWidth("M");
    if (mWidth > 0) {
      _lineHeight = max(3, (int)((mWidth * 7 * 12) / (6 * 10)));
    } else {
      _lineHeight = 5;  // Safe fallback
    }

#if defined(LilyGo_T5S3_EPaper_Pro)
    // T5S3 uses FreeSans12pt/FreeSerif12pt for size 0 (yAdvance=29px).
    {
      extern DISPLAY_CLASS display;
      _lineHeight = display.isPortraitMode() ? 5 : 8;
    }
#else
    // T-Deck Pro large_font uses FreeSans9pt (yAdvance=22px at scale 1.5625×).
    // The 6x8 formula above gives ~5-7 which is way too small — lines overlap.
    // Use smallLineH() which is already tuned for this font.
    if (_prefs && _prefs->large_font) {
      _lineHeight = _prefs->smallLineH();
    }
#endif

    _headerHeight = 0;  // No header in reading mode (maximize text area)
    _footerHeight = 14;
    _textAreaHeight = display.height() - _headerHeight - _footerHeight;
    _linesPerPage = _textAreaHeight / _lineHeight;
    if (_linesPerPage < 5) _linesPerPage = 5;
    if (_linesPerPage > 40) _linesPerPage = 40;

    display.setTextSize(1);  // Restore
    _initialized = true;

    Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d, textH=%d (display %dx%d)\n",
                  _charsPerLine, _linesPerPage, _lineHeight, _textAreaHeight, display.width(), display.height());
  }

  // ---- Boot-time Indexing ----
  // Called from setup() after SD card init. Scans files, pre-indexes first
  // 100 pages of each, and shows progress on the e-ink display.

  // Pre-index files inside one level of subdirectories so navigating
  // into them later is instant (idx files already on SD).
  void bootIndexSubfolders() {
    // Work from the root-level _dirList that scanFiles() already populated.
    // Copy it -- scanFiles() will overwrite _dirList when we scan each subfolder.
    std::vector<String> subDirs = _dirList;
    if (subDirs.empty()) return;

    Serial.printf("TextReader: Pre-indexing %d subfolders\n", (int)subDirs.size());

    int totalSubFiles = 0;
    int cachedSubFiles = 0;
    int indexedSubFiles = 0;

    for (int d = 0; d < (int)subDirs.size(); d++) {
      String subPath = String(BOOKS_FOLDER) + "/" + subDirs[d];
      _currentPath = subPath;
      scanFiles();  // populates _fileList for this subfolder

      // Also pick up previously converted EPUB cache files for this subfolder
      String epubCachePath = subPath + "/.epub_cache";
      if (SD.exists(epubCachePath.c_str())) {
        File cacheDir = SD.open(epubCachePath.c_str());
        if (cacheDir && cacheDir.isDirectory()) {
          File cf = cacheDir.openNextFile();
          while (cf && _fileList.size() < READER_MAX_FILES) {
            if (!cf.isDirectory()) {
              String cname = String(cf.name());
              int cslash = cname.lastIndexOf('/');
              if (cslash >= 0) cname = cname.substring(cslash + 1);
              if (cname.endsWith(".txt") || cname.endsWith(".TXT")) {
                bool dup = false;
                for (int k = 0; k < (int)_fileList.size(); k++) {
                  if (_fileList[k] == cname) { dup = true; break; }
                }
                if (!dup) _fileList.push_back(cname);
              }
            }
            cf = cacheDir.openNextFile();
          }
          cacheDir.close();
        }
      }

      for (int i = 0; i < (int)_fileList.size(); i++) {
        totalSubFiles++;

        // Try loading existing .idx cache -- if hit, skip
        FileCache tempCache;
        if (loadIndex(_fileList[i], tempCache)) {
          cachedSubFiles++;
          continue;
        }

        // Skip .epub files (converted on first open)
        if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) continue;

        // Index this .txt file
        String fullPath = _currentPath + "/" + _fileList[i];
        File file = SD.open(fullPath.c_str(), FILE_READ);
        if (!file) {
          // Try epub cache fallback
          String cacheFallback = epubCachePath + "/" + _fileList[i];
          file = SD.open(cacheFallback.c_str(), FILE_READ);
        }
        if (!file) continue;

        indexedSubFiles++;
        String displayName = subDirs[d] + "/" + _fileList[i];
        drawBootSplash(indexedSubFiles, 0, displayName);

        FileCache cache;
        cache.filename = _fileList[i];
        cache.fileSize = file.size();
        cache.fullyIndexed = false;
        cache.lastReadPage = 0;
        cache.pagePositions.clear();
        cache.pagePositions.push_back(0);

        indexPagesWordWrap(file, 0, cache.pagePositions,
                           _linesPerPage, _charsPerLine,
                           PREINDEX_PAGES - 1,
                           _textAreaHeight, _lineHeight);
        cache.fullyIndexed = !file.available();
        file.close();

        saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
                  cache.fullyIndexed, 0);

        Serial.printf("TextReader: %s/%s - indexed %d pages%s\n",
                      subDirs[d].c_str(), _fileList[i].c_str(),
                      (int)cache.pagePositions.size(),
                      cache.fullyIndexed ? " (complete)" : "");
        yield();  // Feed WDT between files
      }
    }

    Serial.printf("TextReader: Subfolder pre-index: %d files (%d cached, %d newly indexed)\n",
                  totalSubFiles, cachedSubFiles, indexedSubFiles);
  }

  void bootIndex(DisplayDriver& display) {
    if (!_sdReady) return;

    // Calculate layout metrics first (needed for indexing)
    initLayout(display);

    // Show initial splash
    drawBootSplash(0, 0, "Scanning...");
    Serial.println("TextReader: Boot indexing started");

    // Scan for files (includes .txt and .epub)
    scanFiles();

    // Also pick up previously converted EPUB cache files
    if (SD.exists("/books/.epub_cache")) {
      File cacheDir = SD.open("/books/.epub_cache");
      if (cacheDir && cacheDir.isDirectory()) {
        File f = cacheDir.openNextFile();
        while (f && _fileList.size() < READER_MAX_FILES) {
          if (!f.isDirectory()) {
            String name = String(f.name());
            int slash = name.lastIndexOf('/');
            if (slash >= 0) name = name.substring(slash + 1);
            if (name.endsWith(".txt") || name.endsWith(".TXT")) {
              // Avoid duplicates
              bool dup = false;
              for (int i = 0; i < (int)_fileList.size(); i++) {
                if (_fileList[i] == name) { dup = true; break; }
              }
              if (!dup) {
                _fileList.push_back(name);
                Serial.printf("TextReader: Found cached EPUB txt: %s\n", name.c_str());
              }
            }
          }
          f = cacheDir.openNextFile();
        }
        cacheDir.close();
      }
    }

    if (_fileList.size() == 0 && _dirList.size() == 0) {
      Serial.println("TextReader: No files or folders to index");
      _bootIndexed = true;
      return;
    }

    int cachedCount = 0;
    int needsIndexCount = 0;

    // --- Pass 1 & 2: Index root-level files ---
    if (_fileList.size() > 0) {

    // --- Pass 1: Fast cache load (no per-file splash screens) ---
    // Try to load existing .idx files from SD for every file.
    // This is just SD reads â€” no indexing, no e-ink refreshes.
    _fileCache.clear();
    _fileCache.resize(_fileList.size());  // Pre-allocate slots to maintain alignment with _fileList


    for (int i = 0; i < (int)_fileList.size(); i++) {
      if (loadIndex(_fileList[i], _fileCache[i])) {
        Serial.printf("TextReader: %s - cached %d pages (resume pg %d)\n",
                      _fileList[i].c_str(), _fileCache[i].pagePositions.size(),
                      _fileCache[i].lastReadPage + 1);
        cachedCount++;
      } else {
        // Mark as needing indexing (filename will be empty)
        _fileCache[i].filename = "";
        needsIndexCount++;
      }
    }

    Serial.printf("TextReader: %d cached, %d need indexing\n", cachedCount, needsIndexCount);

    // --- Pass 2: Index only new/changed files (with splash screens) ---
    if (needsIndexCount > 0) {
      int indexProgress = 0;
      for (int i = 0; i < (int)_fileList.size(); i++) {
        // Skip files that loaded from cache
        if (_fileCache[i].filename.length() > 0) continue;

        // Skip .epub files â€” they'll be converted on first open via openBook()
        if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) {
          needsIndexCount--;  // Don't count epubs in progress display
          continue;
        }

        indexProgress++;
        drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);

        // Try current path first, then epub cache fallback
        String fullPath = _currentPath + "/" + _fileList[i];
        File file = SD.open(fullPath.c_str(), FILE_READ);
        if (!file) {
          String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
          file = SD.open(cacheFallback.c_str(), FILE_READ);
        }
        if (!file) continue;

        FileCache& cache = _fileCache[i];
        cache.filename = _fileList[i];
        cache.fileSize = file.size();
        cache.fullyIndexed = false;
        cache.lastReadPage = 0;
        cache.pagePositions.clear();
        cache.pagePositions.push_back(0);

        int added = indexPagesWordWrap(file, 0, cache.pagePositions,
                                       _linesPerPage, _charsPerLine,
                                       PREINDEX_PAGES - 1,
                                       _textAreaHeight, _lineHeight);
        cache.fullyIndexed = !file.available();
        file.close();

        saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
                  cache.fullyIndexed, 0);

        Serial.printf("TextReader: %s - indexed %d pages%s\n",
                      _fileList[i].c_str(), (int)cache.pagePositions.size(),
                      cache.fullyIndexed ? " (complete)" : "");
      }
    }

    } // end if (_fileList.size() > 0)

    // --- Pass 3: Pre-index files inside subfolders (one level deep) ---
    // Save root state -- bootIndexSubfolders() will overwrite _fileList/_dirList
    // via scanFiles() as it iterates each subdirectory.
    if (_dirList.size() > 0) {
      std::vector<String> savedFileList = _fileList;
      std::vector<String> savedDirList  = _dirList;
      std::vector<FileCache> savedFileCache = _fileCache;

      bootIndexSubfolders();

      // Restore root state
      _currentPath = String(BOOKS_FOLDER);
      _fileList  = savedFileList;
      _dirList   = savedDirList;
      _fileCache = savedFileCache;
    }


    // Deselect SD to free SPI bus
    digitalWrite(SDCARD_CS, HIGH);

    _bootIndexed = true;
    Serial.printf("TextReader: Boot indexing complete, %d files (%d cached, %d newly indexed)\n",
                  (int)_fileList.size(), cachedCount, needsIndexCount);
  }

  // ---- Public Interface ----

  void setSDReady(bool ready) { _sdReady = ready; }
  bool isSDReady() const { return _sdReady; }

  // Called when entering the reader screen (press R).
  // If boot indexing already ran, this is lightweight.
  void enter(DisplayDriver& display) {
    initLayout(display);

    if (_sdReady && !_bootIndexed) {
      // Boot indexing didn't run (shouldn't happen, but safety fallback)
      bootIndex(display);
    }

    if (!_fileOpen) {
      _selectedFile = 0;
      _mode = FILE_LIST;
    } else if (_pagePositions.empty()) {
      // Layout was invalidated (orientation change) — reindex the open book
      Serial.println("TextReader: Reindexing after layout change");
      _pagePositions.push_back(0);
      indexPagesWordWrap(_file, 0, _pagePositions,
                         _linesPerPage, _charsPerLine, 0,
                         _textAreaHeight, _lineHeight);
      _totalPages = _pagePositions.size();
      if (_currentPage >= _totalPages) _currentPage = 0;
      _mode = READING;
      loadPageContent();
    } else {
      _mode = READING;
      loadPageContent();
    }
  }

  // Are we currently reading a book? (for key routing in main.cpp)
  bool isReading() const { return _mode == READING; }
  bool isInFileList() const { return _mode == FILE_LIST; }

  // Jump to a specific page number (1-based for user-facing, converted to 0-based)
  void gotoPage(int pageNum) {
    if (!_fileOpen || _totalPages == 0) return;
    int target = pageNum - 1;  // Convert 1-based input to 0-based
    if (target < 0) target = 0;
    if (target >= _totalPages) target = _totalPages - 1;
    _currentPage = target;
    loadPageContent();
    Serial.printf("TextReader: Go to page %d/%d\n", _currentPage + 1, _totalPages);
  }

  int getTotalPages() const { return _totalPages; }
  int getCurrentPage() const { return _currentPage; }

  // Tap-to-select: given virtual Y, select file list row.
  // Returns: 0=miss, 1=moved, 2=tapped current row.
  int selectRowAtVY(int vy) {
    if (_mode != FILE_LIST) return 0;
    const int startY = 14, footerH = 14;
    const int listLineH = _prefs ? _prefs->smallLineH() : 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
    const int bodyTop = startY;
#else
    const int bodyTop = startY + (_prefs ? _prefs->smallHighlightOff() : 5);
#endif
    if (vy < bodyTop || vy >= 128 - footerH) return 0;

    int totalItems = totalListItems();
    if (totalItems == 0) return 0;
    int maxVisible = (128 - startY - footerH) / listLineH;
    if (maxVisible < 3) maxVisible = 3;
    if (maxVisible > 15) maxVisible = 15;
    int startIdx = max(0, min(_selectedFile - maxVisible / 2,
                              totalItems - maxVisible));

    int tappedRow = startIdx + (vy - bodyTop) / listLineH;
    if (tappedRow < 0 || tappedRow >= totalItems) return 0;

    if (tappedRow == _selectedFile) return 2;
    _selectedFile = tappedRow;
    return 1;
  }

  int render(DisplayDriver& display) override {
    if (!_sdReady) {
      display.setCursor(0, 20);
      display.setTextSize(1);
      display.setColor(DisplayDriver::LIGHT);
      display.print("SD card not found");
      display.setCursor(0, 35);
      display.print("Insert SD with /books/");
      return 5000;
    }

    if (_mode == FILE_LIST) {
      renderFileList(display);
    } else if (_mode == READING) {
      renderPage(display);
    }

    return 5000;  // E-ink refresh interval
  }

  bool handleInput(char c) override {
    if (_mode == FILE_LIST) {
      return handleFileListInput(c);
    } else if (_mode == READING) {
      if (_gotoMode) return handleGotoInput(c);
      return handleReadingInput(c);
    }
    return false;
  }

  bool handleFileListInput(char c) {
    int total = totalListItems();

    // W - scroll up
    if (c == 'w' || c == 'W' || c == 0xF2) {
      if (_selectedFile > 0) {
        _selectedFile--;
        return true;
      }
      return false;
    }

    // S - scroll down
    if (c == 's' || c == 'S' || c == 0xF1) {
      if (_selectedFile < total - 1) {
        _selectedFile++;
        return true;
      }
      return false;
    }

    // Enter - open selected item (directory or file)
    if (c == '\r' || c == 13) {
      if (total == 0 || _selectedFile >= total) return false;

      int type = itemTypeAt(_selectedFile);

      if (type == 0) {
        // ".." — navigate to parent
        navigateToParent();
        rescanAndIndex();
        return true;
      } else if (type == 1) {
        // Subdirectory — navigate into it
        navigateToChild(dirNameAt(_selectedFile));
        rescanAndIndex();
        return true;
      } else {
        // File — open it
        int fi = fileIndexAt(_selectedFile);
        if (fi >= 0 && fi < (int)_fileList.size()) {
          openBook(_fileList[fi]);
          return true;
        }
      }
      return false;
    }

    return false;
  }

  // Rescan current directory and re-index its files.
  // Called when navigating into or out of a subfolder.
  void rescanAndIndex() {
    scanFiles();
    _selectedFile = 0;

    // Rebuild file cache for the new directory's files
    _fileCache.clear();
    _fileCache.resize(_fileList.size());

    for (int i = 0; i < (int)_fileList.size(); i++) {
      if (!loadIndex(_fileList[i], _fileCache[i])) {
        // Not cached — skip EPUB auto-indexing here (it happens on open)
        // For .txt files, index now
        if (!(_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB"))) {
          String fullPath = _currentPath + "/" + _fileList[i];
          File file = SD.open(fullPath.c_str(), FILE_READ);
          if (!file) {
            // Try epub cache fallback
            String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
            file = SD.open(cacheFallback.c_str(), FILE_READ);
          }
          if (file) {
            FileCache& cache = _fileCache[i];
            cache.filename = _fileList[i];
            cache.fileSize = file.size();
            cache.fullyIndexed = false;
            cache.lastReadPage = 0;
            cache.pagePositions.clear();
            cache.pagePositions.push_back(0);
            indexPagesWordWrap(file, 0, cache.pagePositions,
                               _linesPerPage, _charsPerLine,
                               PREINDEX_PAGES - 1,
                               _textAreaHeight, _lineHeight);
            cache.fullyIndexed = !file.available();
            file.close();
            saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
                      cache.fullyIndexed, 0);
          }
        } else {
          _fileCache[i].filename = "";
        }
      }
      yield();  // Feed WDT between files
    }
    digitalWrite(SDCARD_CS, HIGH);
  }

  bool handleReadingInput(char c) {
    // W/A - previous page
    if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {
      if (_currentPage > 0) {
        _currentPage--;
        loadPageContent();
        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++;
        loadPageContent();
        return true;
      }
      return false;
    }

    // Enter - go-to-page input mode
    if (c == '\r' || c == 13) {
      _gotoMode = true;
      _gotoBufLen = 0;
      _gotoBuf[0] = '\0';
      return true;
    }

    // Q - close book, back to file list
    if (c == 'q' || c == 'Q') {
      closeBook();
      _mode = FILE_LIST;
      return true;
    }

    return false;
  }

  bool handleGotoInput(char c) {
    // Enter — commit page number
    if (c == '\r' || c == 13) {
      if (_gotoBufLen > 0) {
        int pageNum = atoi(_gotoBuf);
        gotoPage(pageNum);
      }
      _gotoMode = false;
      return true;
    }

    // Q or Escape — cancel
    if (c == 'q' || c == 'Q' || c == 0x1B) {
      _gotoMode = false;
      return true;
    }

    // Backspace — delete last digit
    if (c == '\b' || c == 0x7F) {
      if (_gotoBufLen > 0) {
        _gotoBufLen--;
        _gotoBuf[_gotoBufLen] = '\0';
      }
      return true;
    }

    // Digit — append (max 5 digits)
    if (c >= '0' && c <= '9' && _gotoBufLen < 5) {
      _gotoBuf[_gotoBufLen++] = c;
      _gotoBuf[_gotoBufLen] = '\0';
      return true;
    }

    return true;  // Consume all other keys while in goto mode
  }

  // External close (called when leaving reader screen entirely)
  void exitReader() {
    if (_fileOpen) closeBook();
    _mode = FILE_LIST;
  }
};