#if defined(ESP8266)
#include <ESP8266WiFi.h> //https://github.com/esp8266/Arduino
#else
#include <WiFi.h>
#endif

#include <ESPAsyncWebServer.h>   //Local WebServer used to serve the configuration portal
#include <ESPAsyncWiFiManager.h> //https://github.com/tzapu/WiFiManager WiFi Configuration Magic

#include "Audio.h"

#include <SPI.h>
#include <SD.h>
#include <MFRC522.h> //RFID Reader
#include <map>
#if defined(ESP32)
#include <esp_system.h>
#endif

#include <vector>

#include "globals.h"
#include "DirectoryNode.h"
#include "DirectoryWalker.h"
#include "config.h"
#include "main.h"
#include <memory>


// webrequest_blockings is a simple watchdog counter that tracks how long at least one HTTP request has been “active” (not yet disconnected) according to the AsyncWebServer.
int webrequest_blockings = 0;

// Prebuilt system paths to avoid repeated String allocations
static String SYS_PREFIX;
static String PATH_INDEX, PATH_INDEX_GZ, PATH_STYLE, PATH_STYLE_GZ, PATH_SCRIPT, PATH_SCRIPT_GZ;
static String PATH_MAPPING, PATH_PROGRESS, PATH_CONFIG, PATH_SLEEP, PATH_STARTUP;

static inline void buildSystemPathsOnce() {
  if (!SYS_PREFIX.isEmpty()) return;

  SYS_PREFIX = "/";
  SYS_PREFIX += sys_dir;
  SYS_PREFIX += "/";

  auto make = [](const char* name) {
    String s;
    s = SYS_PREFIX;
    s += name;
    return s;
  };

  PATH_INDEX   = make(index_file);
  PATH_STYLE   = make(style_file);
  PATH_SCRIPT  = make(script_file);
  PATH_MAPPING = make(mapping_file);
  PATH_PROGRESS= make(progress_file);
  PATH_CONFIG  = make(config_file);
  PATH_SLEEP   = make(sleep_sound);
  PATH_STARTUP = make(startup_sound);

  PATH_INDEX_GZ  = PATH_INDEX;  PATH_INDEX_GZ  += F(".gz");
  PATH_STYLE_GZ  = PATH_STYLE;  PATH_STYLE_GZ  += F(".gz");
  PATH_SCRIPT_GZ = PATH_SCRIPT; PATH_SCRIPT_GZ += F(".gz");
}


void activateSD()
{
  if (SDActive)
    return;

  if (!SD.begin(CS_SDCARD))
  {
    Serial.println(F("SD initialization failed!"));
  }
  SDActive = true;
}

void deactivateSD()
{
  if (SDActive)
  {
    digitalWrite(CS_SDCARD, HIGH);
    SDActive = false;
  }
}

void activateRFID()
{
  SPI.begin(-1, -1, -1, CS_RFID);
  rfid.PCD_Init(CS_RFID, RST_RFID);
  RFIDActive = true;
}

void deactivateRFID()
{
  if (RFIDActive)
  {
    digitalWrite(CS_RFID, HIGH);
    RFIDActive = false;
  }
}

// Make size of files human readable
// source: https://github.com/CelliesProjects/minimalUploadAuthESP32
String humanReadableSize(const size_t bytes)
{
  if (bytes < 1024)
    return String(bytes) + " B";
  else if (bytes < (1024 * 1024))
    return String(bytes / 1024.0) + " KB";
  else
    return String(bytes / 1024.0 / 1024.0) + " MB";
}

void handleUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final)
{
  if (!index)
  {
    // Validate filename and file extension
    if (filename.length() == 0)
    {
      request->send(400, txt_plain, F("Invalid filename"));
      return;
    }

    // Use const reference to avoid string copies
    const String &lowerFilename = filename;
    if (!lowerFilename.endsWith(".mp3") && !lowerFilename.endsWith(".wav") &&
        !lowerFilename.endsWith(".m4a") && !lowerFilename.endsWith(".ogg"))
    {
      request->send(400, txt_plain, F("Invalid file type. Only audio files are allowed."));
      return;
    }

    // More efficient space check using bit shift
    uint32_t freeSpace = (SD.cardSize() - SD.usedBytes()) >> 20; // Bit shift instead of division

    if (freeSpace < 10)
    { // Less than 10MB free
      request->send(507, txt_plain, F("Insufficient storage"));
      return;
    }


    // Ensure SD is active
    activateSD();

    // Check if file already exists and create backup name if needed
    char filepath[256];
    snprintf(filepath, sizeof(filepath), "/%s", filename.c_str());
    if (SD.exists(filepath))
    {
      request->send(500, txt_plain, F("File already exists."));
    }

    // Open the file for writing (guard with simple mutex)
    sd_lock_acquire();
    request->_tempFile = SD.open(filepath, FILE_WRITE);
    if (!request->_tempFile)
    {
      sd_lock_release();
      request->send(500, txt_plain, F("Failed to create file"));
      return;
    }
    sd_lock_release();
  }

  if (len)
  {
    // Check if file handle is valid
    if (!request->_tempFile)
    {
      request->send(500, txt_plain, F("File handle invalid"));
      return;
    }

    // Write data and verify bytes written (guard writes with simple mutex)
    sd_lock_acquire();
    size_t bytesWritten = request->_tempFile.write(data, len);
    if (bytesWritten != len)
    {
      // ensure we close while holding the lock to keep SD state consistent
      request->_tempFile.close();
      sd_lock_release();
      request->send_P(500, txt_plain, PSTR("Write error"));
      return;
    }

    // Flush data periodically to ensure it's written
    if (index % buffer_size == 0)
    { // Flush every so often
      request->_tempFile.flush();
    }
    sd_lock_release();

  }

  if (final)
  {
    if (request->_tempFile)
    {
      sd_lock_acquire();
      request->_tempFile.flush(); // Ensure all data is written
      request->_tempFile.close();
      sd_lock_release();

      Serial.print(F("Upload Complete: "));
      Serial.print(filename);
      Serial.print(F(", size: "));
      Serial.println(humanReadableSize(index + len));

      // Rebuild directory tree to include new file (guarded)
      sd_lock_acquire();
      rootNode.buildDirectoryTree("/");
      sd_lock_release();

      request->send_P(200, txt_plain, PSTR("Upload successful"));
    }
    else
    {
      request->send_P(500, txt_plain, PSTR("Upload failed"));
    }
  }
}

void handleMoveFile(AsyncWebServerRequest *request)
{
  webreq_enter();
  request->onDisconnect([](){ webreq_exit(); });
  String from = request->arg("from");
  String to = request->arg("to");

  if (SD.exists(from))
  {
    sd_lock_acquire();
    SD.rename(from, to);
    sd_lock_release();
    Serial.print(F("Moved file: ")); Serial.print(from); Serial.print(F(" to ")); Serial.println(to);
    // Rebuild directory tree to update file list (guarded)
    sd_lock_acquire();
    rootNode.buildDirectoryTree("/");
    sd_lock_release();
    request->send(200, txt_plain, F("File moved successfully."));
  }
  else
  {
    Serial.print(F("File not found: ")); Serial.println(from);
    request->send_P(404, txt_plain, PSTR("File not found."));
  }
}

void handleDeleteFile(AsyncWebServerRequest *request)
{
  webreq_enter();
  request->onDisconnect([](){ webreq_exit(); });
  String filename = request->arg("filename");

  if (SD.exists(filename))
  {
    sd_lock_acquire();
    SD.remove(filename.c_str());
    sd_lock_release();
    Serial.print(F("Deleted file: ")); Serial.println(filename);
    // Rebuild directory tree to update file list (guarded)
    sd_lock_acquire();
    rootNode.buildDirectoryTree("/");
    sd_lock_release();
    request->send(200, txt_plain, F("File deleted."));
  }
  else
  {
    Serial.print(F("File not found: ")); Serial.println(filename);
    request->send_P(404, txt_plain, PSTR("File not found."));
  }
}

uint32_t getBatteryVoltageMv()
{
  uint32_t voltage = analogReadMilliVolts(BAT_VOLTAGE_PIN);
  voltage *= 2; //*2 because of the voltage divider.
  return voltage;
}

void playSongById(uint16_t id, uint32_t continueSeconds = 0)
{
  currentNode = rootNode.advanceToMP3(id);

  if (currentNode == nullptr)
  {
    Serial.print(F("No node found for ID: "));
    Serial.println(id);
    return;
  }

  // Check if the current playing song is valid
  if (currentNode->getCurrentPlaying().isEmpty())
  {
    currentNode = nullptr;
    Serial.print(F("No song found for ID: "));
    Serial.println(id);
    return;
  }

  String mp3File = currentNode->getCurrentPlaying();
  if (mp3File.length() == 0)
  {
    currentNode = nullptr;
    Serial.print(F("Empty file path for ID: "));
    Serial.println(id);
    return;
  }

  Serial.print(F("Playing by ID: "));
  Serial.println(id);
  Serial.println(mp3File);

  deactivateRFID();
  activateSD();

  if (!playFile(mp3File.c_str()))
  {
    Serial.print(F("Failed to play file: "));
    Serial.println(mp3File);
    currentNode = nullptr;
    return;
  }
  if (continueSeconds > 0)
  {
    pendingSeekSeconds = continueSeconds;
    pendingSeek = true;
  }
}

void playSongByName(const String &song)
{
  if (song.length() == 0)
  {
    Serial.println(F("Empty song name provided"));
    return;
  }

  currentNode = rootNode.advanceToMP3(song);
  if (currentNode == nullptr)
  {
    Serial.print(F("No node found for song: "));
    Serial.println(song);
    return;
  }

  // Check if the current playing song is valid
  if (currentNode->getCurrentPlaying().isEmpty())
  {
    currentNode = nullptr;
    Serial.print(F("No song found for name: "));
    Serial.println(song);
    return;
  }

  String mp3File = currentNode->getCurrentPlaying();
  if (mp3File.length() == 0)
  {
    currentNode = nullptr;
    Serial.print(F("Empty file path for song: "));
    Serial.println(song);
    return;
  }

  Serial.print(F("Playing song: ")); Serial.println(mp3File);
  deactivateRFID();
  activateSD();

  if (!playFile(mp3File.c_str()))
  {
    Serial.print(F("Failed to play file: ")); Serial.println(mp3File);
    currentNode = nullptr;
    return;
  }
}

void playSongByPath(const String &path)
{
  playFile(path.c_str());
}

void playSongByRFID(const String &id)
{
  if (id.length() == 0)
  {
    Serial.println(F("Empty RFID ID provided"));
    return;
  }

  auto it = rfid_map.find(id);
  if (it == rfid_map.end())
  {
    Serial.print(F("Song for UID not found: "));
    Serial.println(id);
    return;
  }

  MappingEntry entry = it->second;
  if (entry.target.length() == 0)
  {
    Serial.print(F("Empty mapping target for UID: "));
    Serial.println(id);
    return;
  }

  Serial.print(F("RFID mapping found. Target: "));
  Serial.print(entry.target);
  Serial.print(" Mode: ");
  Serial.println(entry.mode);

  // Reset folder tracking
  folderFlatList.clear();
  folderFlatIndex = -1;
  folderRootPath = "";
  folderModeActive = false;

  // Set continuous mode based on mapping ('c' => continuous, otherwise not)
  continuousMode = (entry.mode == 'c');

  // Try to locate the target in the directory tree
  currentNode = rootNode.advanceToMP3(entry.target);
  if (currentNode == nullptr)
  {
    Serial.print(F("No node/file found for mapping target: "));
    Serial.println(entry.target);
    return;
  }

  String mp3File = currentNode->getCurrentPlaying();
  if (mp3File.isEmpty())
  {
    Serial.print(F("Empty file path for mapping target: "));
    Serial.println(entry.target);
    return;
  }

  // Detect whether the mapping targeted a folder (matching a subdirectory name).
  // advanceToMP3 returns the directory node if a subdirectory name was matched.
  bool targetIsFolder = false;
  if (!entry.target.startsWith("/") && entry.target == currentNode->getName())
  {
    targetIsFolder = true;
  }

  // If the mapping targets a folder (or explicitly 'f' or 'r' mode), activate folder tracking
  if (targetIsFolder || entry.mode == 'f' || entry.mode == 'r')
  {
    folderModeActive = true;
    folderRootNode = currentNode;
    // Build flat list of files inside this folder for sequential/looped playback
    folderFlatList.clear();
    folderRootNode->buildFlatMP3List(folderFlatList);

    // If random-folder mode requested, shuffle the flat list once
    if (entry.mode == 'r' && folderFlatList.size() > 1)
    {
      // Fisher-Yates shuffle using Arduino random()
      for (int i = (int)folderFlatList.size() - 1; i > 0; --i)
      {
        int j = (int)random((long)(i + 1)); // 0..i
        auto tmp = folderFlatList[i];
        folderFlatList[i] = folderFlatList[j];
        folderFlatList[j] = tmp;
      }
    }

    if (entry.mode == 'r' && !folderFlatList.empty())
    {
      // In random mode, pick a random start index and move it to front
      int startIdx = (int)random((long)folderFlatList.size());
      if (startIdx != 0)
      {
        auto tmp = folderFlatList[0];
        folderFlatList[0] = folderFlatList[startIdx];
        folderFlatList[startIdx] = tmp;
      }
      folderFlatIndex = 0;
      DirectoryNode *startNode = folderFlatList[0].first;
      int fileIdx = folderFlatList[0].second;
      Serial.print(F("Shuffle start: "));
      Serial.println(startNode->getMP3Files()[fileIdx]);
      startNode->setCurrentPlaying(startNode->getMP3Files()[fileIdx]);
      currentNode = startNode;
      mp3File = currentNode->getCurrentPlaying();
    }
    else
    {
      // Find index of current playing file within the folder list
      uint16_t targetId = currentNode->getCurrentPlayingId();
      for (size_t i = 0; i < folderFlatList.size(); i++)
      {
        DirectoryNode *node = folderFlatList[i].first;
        int fileIdx = folderFlatList[i].second;
        if (node == currentNode && node->getFileIdAt(fileIdx) == targetId)
        {
          folderFlatIndex = (int)i;
          break;
        }
      }
      Serial.print(F("RFID Folder Index: ")); Serial.println(folderFlatIndex);
    }

    // Compute root path for safety checks (path up to last '/')
    int lastSlash = mp3File.lastIndexOf('/');
    if (lastSlash >= 0)
    {
      folderRootPath = mp3File.substring(0, lastSlash + 1); // include trailing slash
    }
  }

  Serial.print(F("Playing mapped target: "));
  Serial.println(mp3File);

  deactivateRFID();
  activateSD();

  if (!playFile(mp3File.c_str()))
  {
    Serial.print(F("Failed to play mapped file: "));
    Serial.println( mp3File);
    currentNode = nullptr;

    return;
  }
}

/**
 * @brief Wrapper, so that we can intercept each call for other stuff.
 *
 * @param filename
 * @param resumeFilePos
 * @return true
 * @return false
 */
bool playFile(const char *filename, uint32_t resumeFilePos)
{
  if (filename == nullptr || strlen(filename) == 0)
  {
    Serial.println(F("filename empty."));
    return false;
  }
  // Serialize access to SD when audio opens the file (short critical section)
  bool result = false;
  sd_lock_acquire();
  result = audio.connecttoFS(SD, filename, resumeFilePos);
  sd_lock_release();
  return result;
}

void playNextMp3()
{
  stop();
  // Do not force continuous mode here; respect current global state.
  if (currentNode == nullptr)
  {
    currentNode = rootNode.findFirstDirectoryWithMP3s();
    if (currentNode)
    {
      currentNode->advanceToFirstMP3InThisNode();
    }
  }
  else
  {
    currentNode = rootNode.advanceToNextMP3(currentNode->getCurrentPlaying());
  }

  if (currentNode != nullptr)
  {
    currentNode->setSecondsPlayed(0);
  }

  Serial.print(F("Advancing to "));
  String mp3File = currentNode->getCurrentPlaying();
  // FIXME crash here if last song.
  if (mp3File.isEmpty())
  {

    currentNode = rootNode.findFirstDirectoryWithMP3s();
    return;
  }
  Serial.println(mp3File);
  deactivateRFID();
  activateSD();
  playFile(mp3File.c_str());
}

void audio_info(const char *info)
{
  // Serial.print("info        "); Serial.println(info);
}

void mute()
{
  if (audio.getVolume() != 0)
  {
    volume = audio.getVolume();
  }

  audio.setVolume(0);
}

void unmute()
{
  audio.setVolume(volume);
}

void writeSongProgress(const char *filename, uint16_t id, uint32_t seconds)
{
  File file = SD.open(filename, FILE_WRITE);
  if (file)
  {
    file.print(id);
    file.print(" ");
    file.println(seconds);
    file.close();
#ifdef DEBUG    
    Serial.print(F("Progress written: ID ")); Serial.print(id); Serial.print(F(", s ")); Serial.println(seconds);
#endif
  }
  else
  {
    Serial.print(F("Error opening file for writing: "));
    Serial.println(filename);
  }
}

boolean readSongProgress(const char *filename)
{
  File file = SD.open(filename);

  if (!file)
  {
    Serial.print(F("Error opening file for reading: "));
    Serial.println(filename);
    return false;
  }

  // Read file with size limit to prevent buffer overflow
  String data;
  data.reserve(64); // Increased reserve size for safety
  size_t bytesRead = 0;
  const size_t maxBytes = 50; // Limit file read size

  while (file.available() && bytesRead < maxBytes)
  {
    char character = file.read();
    if (character == '\n' || character == '\r')
    {
      break; // Stop at first line ending
    }
    data += character;
    bytesRead++;
  }
  file.close();

  // Validate data before parsing
  data.trim();
  if (data.length() == 0)
  {
    Serial.println(F("Progress file empty"));
    return false;
  }

  // Use safer parsing with proper type specifiers
  int tempId = 0;
  unsigned long tempSeconds = 0;
  int parsed = sscanf(data.c_str(), "%d %lu", &tempId, &tempSeconds);

  if (parsed != 2)
  {
    Serial.print(F("Failed to parse progress data: "));
    Serial.println(data);
    return false;
  }

  // Validate ranges before assignment
  if (tempId < 0 || tempId > 65535)
  {
    Serial.print(F("Invalid song in progress: ")); Serial.println(tempId);
    return false;
  }

  if (tempSeconds > 4294967295UL)
  {
    Serial.print(F("Invalid seconds in progress: ")); Serial.println(tempSeconds);
    return false;
  }

  currentSongId = (uint16_t)tempId;
  currentSongSeconds = (uint32_t)tempSeconds;

#ifdef DEBUG  
  Serial.print(F("Data read from file: ")); Serial.println(data);
  Serial.print(F("Parsed ID: ")); Serial.print(currentSongId); Serial.print(F(", s: ")); Serial.println(currentSongSeconds);
#endif

  return true;
}


// Function to save the rfid_map to the mapping file
void saveMappingToFile(const String &filename)
{
  File file = SD.open(filename, FILE_WRITE);
  if (file)
  {
    for (const auto &pair : rfid_map)
    {
      // Format: UID=target|mode
      file.print(pair.first);
      file.print("=");
      file.print(pair.second.target);
      file.print("|");
      file.println(pair.second.mode);
    }
    file.close();
    Serial.println(F("Mapping saved to file."));
  }
  else
  {
    Serial.println(F("Error opening file for writing."));
  }
}

// Function to handle edit requests
void editMapping(AsyncWebServerRequest *request)
{
  webreq_enter();
  request->onDisconnect([](){ webreq_exit(); });
  if (request->hasParam("rfid", true) && request->hasParam("song", true))
  {
    String rfid = request->getParam("rfid", true)->value();
    String song = request->getParam("song", true)->value();
    rfid.trim();
    song.trim();

    char mode = 's';
    if (request->hasParam("mode", true))
    {
      String mStr = request->getParam("mode", true)->value();
      if (mStr.length() > 0)
        mode = mStr.charAt(0);
    }

    rfid_map[rfid] = MappingEntry(song, mode);
    saveMappingToFile(PATH_MAPPING);
    request->send_P(200, txt_plain, PSTR("Mapping updated"));
  }
  else
  {
    request->send_P(400, txt_plain, PSTR("Invalid parameters"));
  }
}

void readDataFromFile(const String &filename)
{

  File file = SD.open(filename);

  if (file)
  {
    while (file.available())
    {
      // Read key and raw value from the file
      String line = file.readStringUntil('\n');
      int separatorIndex = line.indexOf('=');
      if (separatorIndex != -1)
      {
        // Extract key and raw value
        String key = line.substring(0, separatorIndex);
        String raw = line.substring(separatorIndex + 1);
        key.trim();
        raw.trim();

        // Support optional mode delimited by '|' => target|mode
        String target = raw;
        char mode = 's';
        int delim = raw.indexOf('|');
        if (delim != -1)
        {
          target = raw.substring(0, delim);
          String mstr = raw.substring(delim + 1);
          mstr.trim();
          if (mstr.length() > 0)
            mode = mstr.charAt(0);
        }
#ifdef DEBUG
        Serial.print(F("found rfid mapping for ")); Serial.print(target); Serial.print(F(" mode ")); Serial.println(mode);
#endif        
        // Add key-value pair to the map
        rfid_map[key] = MappingEntry(target, mode);
      }
    }
    file.close();
  }
  else
  {
    Serial.print(F("Error opening file "));
    Serial.println(filename);
  }
}

// Memory-optimized helpers and streamers to avoid large temporary Strings

static inline void htmlEscapeAndPrint(Print &out, const String &s)
{
  for (size_t i = 0; i < s.length(); ++i)
  {
    char c = s[i];
    switch (c)
    {
      case '&': out.print(F("&"));  break;
      case '<':  out.print(F("<"));   break;
      case '>':  out.print(F(">"));   break;
      case '\"':    out.print(F("\"")); break;
      case '\'':    out.print(F("&#39;"));  break;
      default:      out.print(c);           break;
    }
  }
}

static inline void jsonEscapeAndPrint(Print &out, const String &s)
{
  for (size_t i = 0; i < s.length(); ++i)
  {
    char c = s[i];
    switch (c)
    {
    case '\"': out.print(F("\\\"")); break;
    case '\\': out.print(F("\\\\")); break;
    case '\b': out.print(F("\\b"));  break;
    case '\f': out.print(F("\\f"));  break;
    case '\n': out.print(F("\\n"));  break;
    case '\r': out.print(F("\\r"));  break;
    case '\t': out.print(F("\\t"));  break;
    default:
      if ((uint8_t)c < 0x20) { // control chars as \u00XX
        out.print(F("\\u00"));
        const char hex[] = "0123456789ABCDEF";
        out.print(hex[(c >> 4) & 0x0F]);
        out.print(hex[c & 0x0F]);
      } else {
        out.print(c);
      }
      break;
    }
  }
}

static void streamMappingHTML(Print &out)
{
  out.print(F("<table style='width:100%;border-collapse:collapse;'><tr><th style='border:1px solid #ccc;padding:4px;'>RFID</th><th style='border:1px solid #ccc;padding:4px;'>Song</th></tr>"));
  for (const auto &pair : rfid_map)
  {
    out.print(F("<tr><td style='border:1px solid #ccc;padding:4px;'>"));
    htmlEscapeAndPrint(out, pair.first);
    out.print(F("</td><td style='border:1px solid #ccc;padding:4px;'>"));
    // target|mode
    htmlEscapeAndPrint(out, pair.second.target);
    out.print(F("|"));
    out.print(pair.second.mode);
    out.print(F("</td></tr>"));
    // Yield occasionally if async server is buffering
    out.flush();
    yield();
  }
  out.print(F("</table>"));
}

static void streamStateJSON(Print &out)
{
  const bool isRunning = audio.isRunning();
  static const String emptyStr;
  const String &current =
      (currentNode != nullptr) ? currentNode->getCurrentPlaying() : emptyStr;

  out.print(F("{\"playing\":"));
  out.print(isRunning ? F("true") : F("false"));

  out.print(F(",\"title\":\""));
  if (!current.isEmpty()) jsonEscapeAndPrint(out, current);
  else out.print(F("Stopped"));
  out.print(F("\""));

  out.print(F(",\"filepath\":\""));
  jsonEscapeAndPrint(out, current);
  out.print(F("\""));

  out.print(F(",\"time\":"));
  out.print(audio.getAudioCurrentTime());

  out.print(F(",\"volume\":"));
  out.print(audio.getVolume());

  out.print(F(",\"length\":"));
  out.print(audio.getAudioFileDuration());

  out.print(F(",\"voltage\":"));
  out.print(lastVoltage);

  out.print(F(",\"uid\":\""));
  jsonEscapeAndPrint(out, lastUid);
  out.print(F("\""));

  out.print(F(",\"heap\":"));
  out.print(free_heap);

  out.print(F("}"));
}

struct ChunkedSkipBufferPrint : public Print {
  uint8_t* out;
  size_t maxLen;
  size_t pos;
  size_t skip;
  size_t seen;
  ChunkedSkipBufferPrint(uint8_t* o, size_t m, size_t s) : out(o), maxLen(m), pos(0), skip(s), seen(0) {}
  virtual size_t write(uint8_t c) {
    seen++;
    if (skip > 0) { skip--; return 1; }
    if (pos < maxLen) { out[pos++] = c; return 1; }
    // buffer full - keep counting to know total size
    return 1;
  }
  virtual size_t write(const uint8_t* buffer, size_t size) {
    size_t n = 0;
    while (n < size) { if (write(buffer[n]) != 1) break; n++; }
    return n;
  }
  size_t bytesWritten() const { return pos; }
  size_t totalProduced() const { return seen; }
};

void stop()
{
  if (audio.isRunning())
  {
    Serial.println(F("stopping audio."));
    audio.stopSong();
    if (currentNode != NULL)
    {
      currentNode->setSecondsPlayed(0);
    }
  }
}

void start()
{
  if (currentNode != NULL)
  {
    currentNode->setCurrentPlaying("");
    currentNode = NULL;
  }

  playNextMp3();
}

void togglePlayPause()
{
  if (currentNode != NULL)
  {
    writeSongProgress(PATH_PROGRESS.c_str(), currentNode->getCurrentPlayingId(), currentNode->getSecondsPlayed());
    audio.pauseResume();
  }
  else
  {
    playNextMp3();
  }
  lastInteraction = millis();
}

void next()
{
  playNextMp3();
  lastInteraction = millis();
}

void previous()
{
  lastInteraction = millis();
  if (currentNode == NULL)
  {
    Serial.println(F("previous(): currentNode is null"));

    return;
  }

  // Validate current state
  const String currentSong = currentNode->getCurrentPlaying();
  if (currentSong == NULL)
  {
#ifdef DEBUG
    Serial.println(F("previous(): currentPlaying is null, cannot go to previous"));
#endif    
    return;
  }

  Serial.print(F("previous(): Current song: "));
  Serial.println(currentSong);

  // Use audio library's current time instead of tracked seconds for more accuracy
  uint32_t currentAudioTime = audio.getAudioCurrentTime();
  Serial.print(F("previous(): Current audio time: "));
  Serial.print(currentAudioTime);
  Serial.println(F(" seconds"));

  // Try to go to previous within current directory first
  DirectoryNode *newNode = currentNode->goToPreviousMP3(2); // Use 2 second threshold

  if (newNode != NULL)
  {
    // Check if we're restarting the same song or moving to a different song
    const String newSong = newNode->getCurrentPlaying();

    if (currentSong == newSong && currentAudioTime > 2)
    {
      // Restart current song if it's been playing for more than 2 seconds
      Serial.println(F("previous(): Restarting current song"));
      audio.setAudioPlayPosition(0);
      currentNode->setSecondsPlayed(0);
    }
    else if (currentSong != newSong)
    {
      // Move to previous song in same directory
      Serial.print(F("previous(): Moving to previous song in directory: "));
      Serial.println(newSong);
      currentNode = newNode;
      stop();
      deactivateRFID();
      activateSD();
      playFile(currentNode->getCurrentPlaying().c_str());
    }
  }
  else
  {
    // Need to find previous song globally (across directories)
#ifdef DEBUG                      
    Serial.println(F("previous(): Looking for previous song globally"));
#endif    
    DirectoryNode *globalPrevNode = rootNode.findPreviousMP3Globally(currentSong);

    if (globalPrevNode != NULL)
    {
      const String globalPrevSong = globalPrevNode->getCurrentPlaying();
      if (!globalPrevSong.isEmpty())
      {
        Serial.print(F("previous(): Found previous song globally: "));
        Serial.println(globalPrevSong);
        currentNode = globalPrevNode;
        stop();
        playFile(globalPrevSong.c_str());
      }
#ifdef DEBUG                        
      else
      {
        Serial.println(F("prev: Global previous song is null"));
      }
#endif      
    }
    else
    {
#ifdef DEBUG                        
      Serial.println(F("prev: No previous song found, beginning again"));
#endif      
      // Optionally restart current song or do nothing
      audio.setAudioPlayPosition(0);
      currentNode->setSecondsPlayed(0);
    }
  }
}

void audio_eof_mp3(const char *info)
{
  Serial.println(F("audio file ended."));

#ifdef DEBUG
  if (folderModeActive)
    Serial.println("folder mode active");
#endif

    if (prepareSleepMode)
    return;

  // If folder-mode is active, advance only inside that folder.
  if (folderModeActive)
  {
      if (folderRootNode == nullptr) {
#ifdef DEBUG        
          Serial.println(F("DEBUG: folderRootNode was null, fixing..."));
#endif          
          folderRootNode = currentNode;
      }
  }

  if (folderModeActive && folderRootNode != nullptr)
  {
    // Ensure flat list is built
    if (folderFlatList.empty())
      folderRootNode->buildFlatMP3List(folderFlatList);

    // Try to find current index if not set
    if (folderFlatIndex < 0 && currentNode != nullptr)
    {
      uint16_t currentId = currentNode->getCurrentPlayingId();
      Serial.print(F("EOF: Searching for ID ")); Serial.println(currentId);
      for (size_t i = 0; i < folderFlatList.size(); i++)
      {
        if (folderFlatList[i].first == currentNode &&
            folderFlatList[i].first->getFileIdAt(folderFlatList[i].second) == currentId)
        {
          folderFlatIndex = (int)i;
#ifdef DEBUG      
          Serial.print(F("EOF: Found at ")); Serial.println(folderFlatIndex);
#endif
          break;
        }
      }      
      if (folderFlatIndex < 0) {
        Serial.println(F("EOF: ID not found in flat list"));
      }
    }

    if (folderFlatIndex >= 0 && folderFlatIndex < (int)folderFlatList.size() - 1)
    {
      // Advance to next file in the folder
      folderFlatIndex++;
      DirectoryNode *nextNode = folderFlatList[folderFlatIndex].first;
      int fileIdx = folderFlatList[folderFlatIndex].second;
      nextNode->setCurrentPlaying(nextNode->getMP3Files()[fileIdx]);
      currentNode = nextNode;
      currentNode->setSecondsPlayed(0);
      deactivateRFID();
      activateSD();
      playFile(currentNode->getCurrentPlaying().c_str());
    }
    else
    {
      // Reached end of folder list
      if (continuousMode && !folderFlatList.empty())
      {
        // Loop back to first in folder
        folderFlatIndex = 0;
        DirectoryNode *nextNode = folderFlatList[folderFlatIndex].first;
        int fileIdx = folderFlatList[folderFlatIndex].second;
        nextNode->setCurrentPlaying(nextNode->getMP3Files()[fileIdx]);
        currentNode = nextNode;
        currentNode->setSecondsPlayed(0);
        deactivateRFID();
        activateSD();
        playFile(currentNode->getCurrentPlaying().c_str());
      }
      else
      {
        // Stop playback and clear folder mode
        folderModeActive = false;
        folderRootNode = nullptr;
        folderFlatList.clear();
        folderFlatIndex = -1;
        stop();
      }
    }
    return;
  }

  // Default behavior: if continuous mode is enabled, go to next globally
  if (continuousMode && !prepareSleepMode)
    playNextMp3();
}

/* not working, FIXME remove me! */
void IRAM_ATTR rfid_interrupt()
{
  newRfidInt = true;
}

void readRFID()
{
  rfid.PICC_ReadCardSerial();

  String newUid = getRFIDString(rfid.uid.uidByte);
  if (newUid == lastUid)
  {
    return;
  }
  stop();
  lastUid = newUid;

  Serial.print(F("Card UID: "));
  Serial.println(lastUid);

  // rfid.PICC_DumpDetailsToSerial(&(rfid.uid));

  playSongByRFID(lastUid);
  lastInteraction = millis();
}

static void serveStaticFile(AsyncWebServerRequest *request,
                            const String &plainPath,
                            const String &gzPath,
                            const char *contentType,
                            const char *cacheControl,
                            const __FlashStringHelper *notFoundMsg,
                            bool allowGzip = true)
{
  webreq_enter();
  // Ensure SD is active and RFID is deactivated while serving files.
  deactivateRFID();
  activateSD();

  // Prefer gz if present
  bool useGz = allowGzip && SD.exists(gzPath);
  const String &sendPath = useGz ? gzPath : plainPath;

  if (SD.exists(sendPath))
  {
#ifdef DEBUG
    Serial.printf("Serving %s heap=%u webreq_cnt=%u\n", sendPath.c_str(), (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt);
#endif
    // Chunked streaming with short SD lock per read
    struct FileCtx { File f; };
    FileCtx *ctx = new FileCtx();
    ctx->f = SD.open(sendPath);
    if (!ctx->f) { delete ctx; request->send(500, txt_plain, F("Open failed")); webreq_exit(); return; }
    auto resp = request->beginChunkedResponse(contentType,
                  [ctx](uint8_t *buffer, size_t maxLen, size_t index) -> size_t {
                    size_t toRead = maxLen;
                    if (toRead > buffer_size) toRead = buffer_size;
                    sd_lock_acquire();
                    size_t n = ctx->f.read(buffer, toRead);
                    sd_lock_release();
                    if (n == 0) { ctx->f.close(); }
                    return n;
                  });
    resp->addHeader(hdr_cache_control_key, cacheControl);
    resp->addHeader(hdr_connection_key, hdr_connection_val);
    if (useGz) {
      resp->addHeader(F("Content-Encoding"), F("gzip"));
    }
    // Ensure FileCtx cleanup even on aborted connections
    request->onDisconnect([ctx](){
      sd_lock_acquire();
      if (ctx->f) ctx->f.close();
      sd_lock_release();
      delete ctx;
      
    });
    request->send(resp);
    webreq_exit();
  }
  else
  {
    // Fallback: 404 for missing asset
    request->send(404, txt_plain, notFoundMsg);
    webreq_exit();
  }
}




void init_webserver() {

    server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
              {
                serveStaticFile(request, PATH_INDEX, PATH_INDEX_GZ, txt_html_charset, hdr_cache_control_val, F("ERROR: /system/index.html(.gz) not found!"), true);              
              });

    server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest *request)
              {
                serveStaticFile(request, PATH_STYLE, PATH_STYLE_GZ, "text/css", "public, max-age=300", F("ERROR: /system/style.css(.gz) not found!"), true);
              });

    server.on("/script.js", HTTP_GET, [](AsyncWebServerRequest *request)
              {
                serveStaticFile(request, PATH_SCRIPT, PATH_SCRIPT_GZ, "application/javascript", "public, max-age=300", F("ERROR: /system/script.js(.gz) not found!"), true);
              });


    
    // Dynamic endpoints to avoid template processing heap spikes
    server.on("/directory", HTTP_GET, [](AsyncWebServerRequest *request)
              {
                webreq_enter();
                // Use shared_ptr to manage Walker lifecycle, ensuring it persists as long as the response needs it
                // and is automatically deleted when the response is finished/destroyed.
                std::shared_ptr<DirectoryWalker> walker = std::make_shared<DirectoryWalker>(&rootNode);
                
                request->onDisconnect([](){ webreq_exit(); });
#ifdef DEBUG
                Serial.printf("Serving /directory heap=%u webreq_cnt=%u numOfFiles=%u\n", (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt, rootNode.getNumOfFiles());
#endif
                // True chunked response using stateful walker
                AsyncWebServerResponse *response = request->beginChunkedResponse(
                  txt_html_charset,
                  [walker](uint8_t *buffer, size_t maxLen, size_t index) -> size_t {
                    return walker->read(buffer, maxLen);
                  }
                );
                // Optional headers:
                response->addHeader(hdr_cache_control_key, hdr_cache_control_val);
                response->addHeader(hdr_connection_key, hdr_connection_val);
                request->send(response);
              });

    server.on("/mapping", HTTP_GET, [](AsyncWebServerRequest *request)
              {
                webreq_enter();
                request->onDisconnect([](){ webreq_exit();});
#ifdef DEBUG
                Serial.printf("Serving /mapping heap=%u webreq_cnt=%u\n", (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt);
#endif
                // True chunked response using a deterministic generator with byte skipping based on 'index'
                AsyncWebServerResponse *response = request->beginChunkedResponse(
                  txt_html_charset,
                  [](uint8_t *buffer, size_t maxLen, size_t index) -> size_t {
                    ChunkedSkipBufferPrint sink(buffer, maxLen, index);
                    streamMappingHTML(sink);
                    // finished?
                    if (index >= sink.totalProduced()) {
                      return 0;
                    }
                    return sink.bytesWritten();
                  }
                );
                // Optional headers:
                response->addHeader(hdr_cache_control_key, hdr_cache_control_val);
                response->addHeader(hdr_connection_key, hdr_connection_val);
                request->send(response);
              });


    server.on("/state", HTTP_GET, [](AsyncWebServerRequest *request)
              { 
                webreq_enter();
                request->onDisconnect([](){ webreq_exit(); });
#ifdef DEBUG
                Serial.printf("Serving /state heap=%u webreq_cnt=%u\n", (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt);
#endif
                // Stream JSON directly to avoid DynamicJsonDocument/String allocations
                AsyncResponseStream* stream = request->beginResponseStream(F("application/json; charset=UTF-8"), buffer_size);
                stream->addHeader(hdr_cache_control_key, hdr_cache_control_val);
                stream->addHeader(hdr_connection_key, hdr_connection_val);
                streamStateJSON(*stream);
                request->send(stream);
              });

    server.on("/start", HTTP_GET, [](AsyncWebServerRequest *request)
              {
        webreq_enter();
        request->onDisconnect([](){ webreq_exit(); });
        request->send_P(200, txt_plain, PSTR("start")); 
        start(); });

    server.on("/toggleplaypause", HTTP_GET, [](AsyncWebServerRequest *request)
              {
        webreq_enter();
        request->onDisconnect([](){ webreq_exit(); });
        request->send_P(200, txt_plain, PSTR("toggleplaypause")); 
        togglePlayPause(); });

    server.on("/stop", HTTP_GET, [](AsyncWebServerRequest *request)
              {
        webreq_enter();
        request->onDisconnect([](){ webreq_exit(); });
        request->send_P(200, txt_plain, PSTR("stop")); 
        stop(); });

    server.on("/next", HTTP_GET, [](AsyncWebServerRequest *request)
              {
                  webreq_enter();
                  request->onDisconnect([](){ webreq_exit(); });
                  request->send_P(200, txt_plain, PSTR("next"));
                  next(); });

    server.on("/previous", HTTP_GET, [](AsyncWebServerRequest *request)
              {
                  webreq_enter();
                  request->onDisconnect([](){ webreq_exit(); });
                  request->send_P(200, txt_plain, PSTR("previous"));
                  previous(); });

    server.on("/playbyid", HTTP_GET, id_song_action);

    server.on("/progress", HTTP_POST, progress_action);

    server.on("/volume", HTTP_POST, volume_action);

    server.on("/edit_mapping", HTTP_POST, editMapping);

        // run handleUpload function when any file is uploaded
    server.on("/upload", HTTP_POST,
              [](AsyncWebServerRequest *request)
              {
                webreq_enter();
                // Ensure any in-progress upload file is closed on client abort to free the FD
                request->onDisconnect([request](){
                  // Close temporary upload file if still open
                  if (request->_tempFile) {
                    sd_lock_acquire();
                    request->_tempFile.close();
                    sd_lock_release();
                  }
                  webreq_exit();
                });
                request->send(200);
              },
              handleUpload);

    server.on("/move_file", HTTP_GET, handleMoveFile);
    server.on("/delete_file", HTTP_GET, handleDeleteFile);

    server.on("/reset_wifi", HTTP_POST, [](AsyncWebServerRequest *request)
              {
                webreq_enter();
                request->onDisconnect([](){ webreq_exit(); });
                request->send(200, txt_plain, F("WiFi reset. Device will restart..."));
                asyncReset = true;
              });
}

void setup()
{
  Serial.begin(115200);

  pinMode(BTN_START_STOP, INPUT_PULLUP);
  pinMode(BTN_NEXT, INPUT_PULLUP);
  pinMode(BTN_PREV, INPUT_PULLUP);

  /* setup the IRQ pin, not working because the pin is input only:*/
  // pinMode(IRQ_RFID, INPUT_PULLUP);

  pinMode(CS_RFID, OUTPUT);
  pinMode(CS_SDCARD, OUTPUT);

  digitalWrite(CS_RFID, HIGH);
  digitalWrite(CS_SDCARD, HIGH);
  RFIDActive = false;
  SDActive = false;

  Serial.print(F("Initializing SD card..."));
  activateSD();
  Serial.println(F("SD initialization done."));
  buildSystemPathsOnce();

  // Seed RNG for shuffle mode
#if defined(ESP32)
  randomSeed(esp_random());
#else
  randomSeed((uint32_t)micros());
#endif

  // Load configuration from SD card
  Serial.println(F("Loading configuration..."));
  loadConfig();

  // deep sleep wakeup
  esp_sleep_enable_ext0_wakeup((gpio_num_t)BTN_START_STOP, LOW);

  rootNode.buildDirectoryTree("/");
  rootNode.printDirectoryTree();
  Serial.printf("Heap after dir tree: %u\n", (unsigned)xPortGetFreeHeapSize());

  readDataFromFile(PATH_MAPPING);

  String progressPath = PATH_PROGRESS;

  continuePlaying = config.startAtStoredProgress && readSongProgress(progressPath.c_str());

  if (continuePlaying)
  {
    Serial.print(F("deleting "));
    Serial.println(progressPath);
    SD.remove(progressPath);
  }

  deactivateSD();
  activateRFID();
  Serial.println(F("RFID"));

  // Init MFRC522
  // Init SPI bus
  // SPI.begin(-1, -1, -1, CS_RFID);
  rfid.PCD_Init(CS_RFID, RST_RFID);

  // somehow this test stops rfid from working!
  /*
  if (rfid.PCD_PerformSelfTest())
   {
     Serial.println("RFID OK");
   }
   else
   {
     Serial.println("RFID Self Test failed!");
   }
 */
  audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
  audio.setVolume(config.initialVolume); // Use config value
  volume = config.initialVolume;         // Update global volume variable

  // Optimize audio buffer size to save heap (lower = less RAM, but risk of underflow on high bitrates)
  audio.setBufferSize(8192);

  Serial.println(F("Audio init"));

  lastVoltage = getBatteryVoltageMv();

  free_heap = xPortGetFreeHeapSize();

  AsyncWiFiManager wifiManager(&server, &dns);

  wifiManager.setDebugOutput(true);

  // Reduce timeouts to free memory faster
  wifiManager.setTimeout(150);             // Reduced from 180
  wifiManager.setConnectTimeout(30);      // Faster connection attempts
  wifiManager.setConfigPortalTimeout(120); // Shorter portal timeout

#ifdef DEBUG                  
  Serial.println(F("Deactivating Brownout detector..."));
#endif  
  WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); // disable brownout detector

  if (wifiManager.autoConnect("HannaBox"))
  {
    Serial.printf("Heap before init_webserver: %u\n", (unsigned)xPortGetFreeHeapSize());
    init_webserver();
    Serial.printf("Heap before server.begin: %u\n", (unsigned)xPortGetFreeHeapSize());
    server.begin();
    Serial.printf("Heap after server.begin: %u\n", (unsigned)xPortGetFreeHeapSize());
    Serial.println(F("Wifi init"));
  }
  else
  {
    Serial.println(F("Wifi timed out. Fallback."));
  }

  Serial.println(F("Activating Brownout detector..."));
  WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 1); // enable brownout detector

  xTaskCreatePinnedToCore(
      loop2,      /* Function to implement the task */
      "RFIDTask", /* Name of the task */
      2048,       /* Stack size in words - reduced from 4096 to 2048 to free heap */
      NULL,       /* Task input parameter */
      0,          /* Priority of the task */
      &RfidTask,  /* Task handle. */
      0);         /* Core where the task should run */

  lastInteraction = millis();
  Serial.println(F("Init done."));
}

void id_song_action(AsyncWebServerRequest *request)
{
  webreq_enter();
  request->onDisconnect([](){ webreq_exit(); });
  int params = request->params();
  folderModeActive = true;
  for (int i = 0; i < params; i++)
  {
    const AsyncWebParameter *p = request->getParam(i);
    if (p->name() == "id")
    {
      playSongById(atoi(p->value().c_str()));
    }
  }
  if (currentNode != nullptr)
  {
    folderRootNode = currentNode;
  }
  lastInteraction = millis();
  request->send_P(200, txt_plain, PSTR("ok"));
}

void progress_action(AsyncWebServerRequest *request)
{
  webreq_enter();
  request->onDisconnect([](){ webreq_exit(); });

  int params = request->params();
  for (int i = 0; i < params; i++)
  {
    const AsyncWebParameter *p = request->getParam(i);
    if (p->name() == "value")
    {
      audio.setAudioPlayPosition(atoi(p->value().c_str()));
    }
  }
  lastInteraction = millis();
  request->send_P(200, txt_plain, PSTR("ok"));
}

void volume_action(AsyncWebServerRequest *request)
{
  webreq_enter();
  request->onDisconnect([](){ webreq_exit(); });

  int params = request->params();
  for (int i = 0; i < params; i++)
  {
    const AsyncWebParameter *p = request->getParam(i);
    if (p->name() == "value")
    {
      audio.setVolume(atoi(p->value().c_str()));
    }
  }
  lastInteraction = millis();
  request->send_P(200, txt_plain, PSTR("ok"));
}


void loop()
{
  if (webreq_cnt > 0 && webrequest_blockings > MAX_WEBREQUEST_BLOCKINGS) {
    if (!server_reset_pending) {
      Serial.println(F("excessive webrequest blocking - scheduling server reset"));
      server_reset_pending = true;
    }
    // reset the counter to avoid repeated scheduling while still busy
    webrequest_blockings = 0;
  }

  if (asyncReset)
  {
    asyncReset = false;
    delay(1000);
    Serial.println(F("Disconnecting WiFi and resetting..."));
    WiFi.disconnect(true, true);
    
    ESP.restart();
  }

  if (audio.isRunning())
  {
    if (asyncStop)
    {
      asyncStop = false;
      stop();
    }
    audio.loop();
    if (currentNode != nullptr && !prepareSleepMode)
    {
      currentNode->setSecondsPlayed(audio.getAudioCurrentTime());
    }
    // Apply pending seek once decoder is ready (after header parsed and bitrate known)
    if (pendingSeek && audio.getBitRate(true) > 0 && audio.getAudioFileDuration() > 0)
    {
      audio.setAudioPlayPosition(pendingSeekSeconds);
      if (currentNode != nullptr)
      {
        currentNode->setSecondsPlayed(pendingSeekSeconds);
      }
      pendingSeek = false;
    }
  }
  else if (asyncStart && webreq_cnt == 0)
  {
    asyncStart = false;
    start();
  }

  if (continuePlaying && webreq_cnt == 0)
  {
    continuePlaying = false;
    startupSoundPlayed = true;
    playSongById(currentSongId, currentSongSeconds);
    currentNode->setSecondsPlayed(currentSongSeconds);
  }
  else if (!startupSoundPlayed)
  {
    startupSoundPlayed = true;
    playSongByPath(PATH_STARTUP);
  }

  // send device to sleep:
  long now = millis();

  if (!sleepSoundPlayed && now - lastInteraction > config.sleepMessageDelay)
  {
    sleepSoundPlayed = true;
    prepareSleepMode = true;
    if (currentNode != nullptr)
    {
      deactivateRFID();
      activateSD();
      writeSongProgress(PATH_PROGRESS.c_str(), currentNode->getCurrentPlayingId(), currentNode->getSecondsPlayed());
    }

    playSongByPath(PATH_SLEEP);
  }

  if (now - lastInteraction > config.sleepDelay)
  {
    Serial.println(F("entering deep sleep.."));
    deactivateRFID();
    deactivateSD();
    esp_deep_sleep_start();
  }

  if (asyncTogglePlayPause)
  {
    asyncTogglePlayPause = false;
    togglePlayPause();
  }
  else if (asyncNext)
  {
    asyncNext = false;
    // If the play/start button is held, treat NEXT as volume up
    if (playButtonDown)
    {
      uint8_t vol = audio.getVolume();
      if (vol < config.maxVolume)
      {
        vol++;
        audio.setVolume(vol);
        volume = vol; // update stored volume for mute/unmute
        volumeAdjustedDuringHold = true;
      }
      // do not play the startup sound when changing volume while holding play
    }
    else
    {
      if (audio.isRunning())
      {
        next();
      }
      else
      {
        uint8_t vol = audio.getVolume();
        if (vol != config.maxVolume)
        {
          vol++;
        }
        audio.setVolume(vol);
        volume = vol;
        playSongByPath(PATH_STARTUP);
      }
    }
  }
  else if (asyncPrev)
  {
    asyncPrev = false;
    // If the play/start button is held, treat PREV as volume down
    if (playButtonDown)
    {
      uint8_t vol = audio.getVolume();
      if (vol > 0)
      {
        vol--;
        audio.setVolume(vol);
        volume = vol; // update stored volume for mute/unmute
        volumeAdjustedDuringHold = true;
      }
      // do not play the startup sound when changing volume while holding play
    }
    else
    {
      if (audio.isRunning())
      {
        previous();
      }
      else
      {
        uint8_t vol = audio.getVolume();
        if (vol != 0)
        {
          vol--;
        }
        audio.setVolume(vol);
        volume = vol;
        playSongByPath(PATH_STARTUP);
      }
    }
  }

  if (loopCounter % config.rfidLoopInterval == 0 && webreq_cnt == 0)
  {
    deactivateSD();
    activateRFID();
    if (rfid.PICC_IsNewCardPresent())
    {
      readRFID();
    }
    deactivateRFID();
    activateSD();
  }

  if (loopCounter % VOLTAGE_LOOP_INTERVAL == 0 && webreq_cnt == 0)
  {
    lastVoltage = getBatteryVoltageMv();
    free_heap = xPortGetFreeHeapSize();
    if (lastVoltage < config.minVoltage && config.minVoltage > 0)
    {
      if (voltage_threshold_counter > 3)
      {
        Serial.print(F("deep sleep due to low volts ("));
        Serial.print(lastVoltage);
        Serial.print(F(") min: "));
        Serial.println(config.minVoltage);

        lastInteraction = millis() - config.sleepMessageDelay;
        voltage_threshold_counter = 0;
      }
      else
      {
        voltage_threshold_counter++;
      }
    }
    else
    {
      voltage_threshold_counter = 0;
    }
  }

  if (webreq_cnt>0) {
    webrequest_blockings++;
  } else {
    webrequest_blockings = 0;
  }

  // Perform deferred server reset when no active requests to avoid AsyncTCP stack corruption
  if (server_reset_pending && webreq_cnt == 0) {
    Serial.println(F("performing deferred server reset"));
    server.reset();
    init_webserver();
    server.begin();
    server_reset_pending = false;
  }

  loopCounter++;
  vTaskDelay(1);

}

void loop2(void *parameter)
{
  bool loggingDone = false;

  for (;;)
  {
    // Track whether the play/start button is currently held down and detect press/release
    bool currentDown = (digitalRead(BTN_START_STOP) == LOW);
    static bool prevDown = false;
    playButtonDown = currentDown;

    // On press: start hold tracking and reset volume-change marker
    if (currentDown && !prevDown)
    {
      playHoldActive = true;
      volumeAdjustedDuringHold = false;
      lastInteraction = millis();
    }
    // On release: toggle only if no volume change occurred during hold
    if (!currentDown && prevDown)
    {
      if (playHoldActive)
      {
        if (!volumeAdjustedDuringHold)
        {
          asyncTogglePlayPause = true;
        }
        playHoldActive = false;
        volumeAdjustedDuringHold = false;
      }
    }
    prevDown = currentDown;

    if (buttonPressed(BTN_NEXT))
    {
      asyncNext = true;
    }
    if (buttonPressed(BTN_PREV))
    {
      asyncPrev = true;
    }
    if (!loggingDone)
    {
      Serial.println(F("loop2 started"));
      loggingDone = true;
    }
    vTaskDelay(1);
  }
}

boolean buttonPressed(const uint8_t pin)
{

  if (digitalRead(pin) == LOW && buttontoignore != pin)
  {

    unsigned long now = millis();
    if (now - lastStart > SHORT_PRESS_TIME)
    {
      lastStart = now;
      buttontoignore = pin;
      lastInteraction = now;
      return true;
    }
  }
  else if (digitalRead(pin) == HIGH && buttontoignore == pin)
  {
    buttontoignore = 0;
  }

  return false;
}
