// 20260406 ChatGPT
// Exercise 18_GPS_Field_QA

#include <Arduino.h>
#include <ctype.h>
#include <SPI.h>
#include <SD.h>
#include <stdarg.h>
#include <strings.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>

#include "BatteryMonitor.h"
#include "ClockDiscipline.h"
#include "Config.h"
#include "DisplayManager.h"
#include "GnssManager.h"
#include "RunStats.h"
#include "StartupSdManager.h"
#include "StorageManager.h"
#include "tbeam_supreme_adapter.h"

using namespace field_qa;

namespace {

XPowersLibInterface* g_pmu = nullptr;
DisplayManager g_display;
GnssManager g_gnss;
ClockDiscipline g_clock;
BatteryMonitor g_battery;
StorageManager g_storage;
RunStats g_stats;
StartupSdManager g_sd(Serial);
WebServer g_server(80);

char g_runId[48] = {0};
char g_bootTimestampUtc[32] = {0};
char g_serialLine[160] = {0};
char g_apSsid[32] = {0};
size_t g_serialLineLen = 0;

bool g_clockDisciplined = false;
bool g_loggingEnabled = false;
bool g_periodicSerialEnabled = false;
bool g_storageReady = false;
bool g_storageMounted = false;
bool g_webReady = false;
size_t g_logFileCount = 0;
uint32_t g_sampleSeq = 0;
uint32_t g_runStartMs = 0;
bool g_buttonPrevPressed = false;
bool g_buttonConfirmActive = false;
bool g_buttonHoldHandled = false;
uint32_t g_buttonPressedMs = 0;
uint32_t g_buttonConfirmDeadlineMs = 0;
uint32_t g_buttonStopMessageUntilMs = 0;
char g_lastHaltReason[32] = "none";
bool g_displayAutoSleepEnabled = false;
uint32_t g_displayWakeUntilMs = 0;

uint32_t g_lastSampleMs = 0;
uint32_t g_lastFlushMs = 0;
uint32_t g_lastDisplayMs = 0;
uint32_t g_lastStatusMs = 0;
uint32_t g_lastDisciplineAttemptMs = 0;
volatile uint32_t g_ppsEdgeCount = 0;

static constexpr uint32_t kButtonHoldPromptMs = 1500;
static constexpr uint32_t kButtonConfirmWindowMs = 3000;
static constexpr uint32_t kButtonStopMessageMs = 4000;
static constexpr uint32_t kDisplayWakeMs = 120000;
static constexpr size_t kEventLogCapacity = 128;
static constexpr size_t kEventLogLineBytes = 96;
char g_eventLog[kEventLogCapacity][kEventLogLineBytes] = {};
size_t g_eventLogHead = 0;
size_t g_eventLogCount = 0;

void IRAM_ATTR onPpsEdge() {
  ++g_ppsEdgeCount;
}

String htmlEscape(const String& in);

void logGnssTalkerCounts(uint32_t sampleSeq, const SatelliteInfo* sats, size_t satCount) {
#if GNSS_DIAG
  size_t gp = 0, ga = 0, gl = 0, gb = 0, gi = 0, gq = 0, gs = 0, gn = 0, other = 0;
  for (size_t i = 0; i < satCount; ++i) {
    if (!sats[i].valid) {
      continue;
    }
    if (strcmp(sats[i].talker, "GP") == 0) ++gp;
    else if (strcmp(sats[i].talker, "GA") == 0) ++ga;
    else if (strcmp(sats[i].talker, "GL") == 0) ++gl;
    else if (strcmp(sats[i].talker, "GB") == 0 || strcmp(sats[i].talker, "BD") == 0) ++gb;
    else if (strcmp(sats[i].talker, "GI") == 0) ++gi;
    else if (strcmp(sats[i].talker, "GQ") == 0) ++gq;
    else if (strcmp(sats[i].talker, "GS") == 0) ++gs;
    else if (strcmp(sats[i].talker, "GN") == 0) ++gn;
    else ++other;
  }
  Serial.printf("[GNSS] flush sample=%lu satCount=%u GP=%u GA=%u GL=%u GB=%u GI=%u GQ=%u GS=%u GN=%u other=%u\n",
                (unsigned long)sampleSeq,
                (unsigned)satCount,
                (unsigned)gp,
                (unsigned)ga,
                (unsigned)gl,
                (unsigned)gb,
                (unsigned)gi,
                (unsigned)gq,
                (unsigned)gs,
                (unsigned)gn,
                (unsigned)other);
#else
  (void)sampleSeq;
  (void)sats;
  (void)satCount;
#endif
}

void recordEvent(const char* fmt, ...) {
  char msg[72];
  va_list args;
  va_start(args, fmt);
  vsnprintf(msg, sizeof(msg), fmt, args);
  va_end(args);

  char line[kEventLogLineBytes];
  snprintf(line, sizeof(line), "%10lu %s", (unsigned long)millis(), msg);
  strlcpy(g_eventLog[g_eventLogHead], line, sizeof(g_eventLog[g_eventLogHead]));
  g_eventLogHead = (g_eventLogHead + 1) % kEventLogCapacity;
  if (g_eventLogCount < kEventLogCapacity) {
    ++g_eventLogCount;
  }
  Serial.printf("EVENT: %s\n", msg);
}

void setLastHaltReason(const char* reason) {
  strlcpy(g_lastHaltReason, (reason && reason[0] != '\0') ? reason : "none", sizeof(g_lastHaltReason));
}

const char* currentLoggingState() {
  if (!g_clockDisciplined) {
    return "waiting_clock";
  }
  if (!g_storageMounted) {
    return "sd_absent";
  }
  if (g_loggingEnabled && g_storageReady) {
    return "recording";
  }
  if (g_lastHaltReason[0] != '\0' && strcmp(g_lastHaltReason, "none") != 0) {
    return "halted";
  }
  if (!g_storageReady) {
    return "storage_not_ready";
  }
  return "idle";
}

void wakeDisplay(uint32_t durationMs = kDisplayWakeMs) {
  g_display.setPowerSave(false);
  g_displayWakeUntilMs = millis() + durationMs;
}

void updateDisplayPowerState() {
  if (!g_displayAutoSleepEnabled) {
    g_display.setPowerSave(false);
    return;
  }
  if ((int32_t)(millis() - g_displayWakeUntilMs) >= 0) {
    g_display.setPowerSave(true);
  }
}

void appendRecentEventsHtml(String& html) {
  html += "<h2>Recent Events</h2><pre>";
  const size_t start = (g_eventLogCount < kEventLogCapacity) ? 0 : g_eventLogHead;
  for (size_t i = 0; i < g_eventLogCount; ++i) {
    const size_t idx = (start + i) % kEventLogCapacity;
    html += htmlEscape(String(g_eventLog[idx]));
    html += "\n";
  }
  html += "</pre>";
}

String htmlEscape(const String& in) {
  String out;
  out.reserve(in.length() + 16);
  for (size_t i = 0; i < in.length(); ++i) {
    const char c = in[i];
    if (c == '&') out += "&amp;";
    else if (c == '<') out += "&lt;";
    else if (c == '>') out += "&gt;";
    else if (c == '"') out += "&quot;";
    else out += c;
  }
  return out;
}

String urlEncode(const String& in) {
  String out;
  char hex[4];
  for (size_t i = 0; i < in.length(); ++i) {
    const unsigned char c = (unsigned char)in[i];
    if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') {
      out += (char)c;
    } else {
      snprintf(hex, sizeof(hex), "%%%02X", c);
      out += hex;
    }
  }
  return out;
}

bool waitForNextPps(void*, uint32_t timeoutMs) {
  const uint32_t startEdges = g_ppsEdgeCount;
  const uint32_t startMs = millis();
  while ((uint32_t)(millis() - startMs) < timeoutMs) {
    g_gnss.poll();
    g_sd.update();
    if (g_ppsEdgeCount != startEdges) {
      return true;
    }
    delay(2);
  }
  return false;
}

void setRunIdentityFromClock(const ClockDateTime& dt) {
  ClockDiscipline::formatIsoUtc(dt, g_bootTimestampUtc, sizeof(g_bootTimestampUtc));
  ClockDiscipline::makeRunId(dt, kBoardId, g_runId, sizeof(g_runId));
}

void printProvenance() {
  Serial.printf("exercise=%s\n", kExerciseName);
  Serial.printf("version=%s\n", kFirmwareVersion);
  Serial.printf("board_id=%s\n", kBoardId);
  Serial.printf("gnss_chip=%s\n", kGnssChip);
  Serial.printf("detected_chip=%s\n", g_gnss.detectedChipName());
  Serial.printf("storage=%s\n", kStorageName);
  Serial.printf("sample_period_ms=%lu\n", (unsigned long)kSamplePeriodMs);
  Serial.printf("log_period_ms=%lu\n", (unsigned long)kLogFlushPeriodMs);
  Serial.printf("run_id=%s\n", g_runId[0] ? g_runId : "PENDING_CLOCK");
  Serial.printf("clock_disciplined=%s\n", g_clockDisciplined ? "yes" : "no");
  Serial.printf("web_ssid=%s\n", g_apSsid);
  Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet);
}

void printSummary() {
  Serial.println("summary:");
  Serial.printf("build=%s\n", kFirmwareVersion);
  Serial.printf("run_id=%s\n", g_runId[0] ? g_runId : "PENDING_CLOCK");
  Serial.printf("clock_disciplined=%s\n", g_clockDisciplined ? "yes" : "no");
  Serial.printf("boot_timestamp_utc=%s\n", g_bootTimestampUtc[0] ? g_bootTimestampUtc : "UNKNOWN");
  Serial.printf("elapsed_ms=%lu\n", (unsigned long)g_stats.elapsedMs(millis()));
  Serial.printf("ttff_ms=%lu\n", (unsigned long)g_stats.ttffMs());
  Serial.printf("longest_no_fix_ms=%lu\n", (unsigned long)g_stats.longestNoFixMs());
  Serial.printf("storage_ready=%s\n", g_storageReady ? "yes" : "no");
  Serial.printf("storage_mounted=%s\n", g_storageMounted ? "yes" : "no");
  Serial.printf("storage_error=%s\n", g_storage.lastError());
  Serial.printf("storage_log_dir=%s\n", kLogDir);
  Serial.printf("log_file=%s\n", g_storage.currentPath());
  Serial.printf("storage_file_open=%s\n", g_storage.fileOpen() ? "yes" : "no");
  Serial.printf("storage_total_bytes=%u\n", g_storageMounted ? (unsigned)SD.totalBytes() : 0U);
  Serial.printf("storage_used_bytes=%u\n", g_storageMounted ? (unsigned)SD.usedBytes() : 0U);
  Serial.printf("storage_buffered_bytes=%u\n", (unsigned)g_storage.bufferedBytes());
  Serial.printf("storage_log_count=%u\n", (unsigned)g_logFileCount);
  Serial.printf("logging_state=%s\n", currentLoggingState());
  Serial.printf("halt_reason=%s\n", g_lastHaltReason);
  Serial.printf("battery_voltage=%s\n", g_battery.voltageText());
  Serial.printf("web_ready=%s\n", g_webReady ? "yes" : "no");
  Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet);
}

void printStatusLine(const GnssSample& sample) {
  char ts[24];
  if (sample.validTime) {
    snprintf(ts,
             sizeof(ts),
             "%04u%02u%02u_%02u%02u%02uZ",
             (unsigned)sample.year,
             (unsigned)sample.month,
             (unsigned)sample.day,
             (unsigned)sample.hour,
             (unsigned)sample.minute,
             (unsigned)sample.second);
  } else {
    strlcpy(ts, "NO_UTC", sizeof(ts));
  }
  Serial.printf("%s board=%s fix=%s used=%d view=%d hdop=%s lat=%s lon=%s alt=%s q=%s clock=%s log=%s\n",
                ts,
                kBoardId,
                fixTypeToString(sample.fixType),
                sample.satsUsed < 0 ? 0 : sample.satsUsed,
                sample.satsInView < 0 ? 0 : sample.satsInView,
                sample.validHdop ? String(sample.hdop, 1).c_str() : "",
                sample.validLocation ? String(sample.latitude, 5).c_str() : "",
                sample.validLocation ? String(sample.longitude, 5).c_str() : "",
                sample.validAltitude ? String(sample.altitudeM, 1).c_str() : "",
                qualityClassForSample(sample),
                g_clockDisciplined ? "disciplined" : "waiting",
                g_loggingEnabled ? "on" : "off");
}

bool ensureStorageReady() {
  if (!g_clockDisciplined || !g_storageMounted || g_storageReady || g_runId[0] == '\0') {
    return g_storageReady;
  }

  g_logFileCount = g_storage.logFileCount();
  if (g_logFileCount > kMaxLogFilesBeforePause) {
    Serial.printf("INFO: auto logging paused, log count %u exceeds limit %u\n",
                  (unsigned)g_logFileCount,
                  (unsigned)kMaxLogFilesBeforePause);
    return false;
  }

  g_storageReady = g_storage.startLog(g_runId, g_bootTimestampUtc);
  if (!g_storageReady) {
    Serial.printf("ERROR: log start failed: %s\n", g_storage.lastError());
    return false;
  }

  g_sampleSeq = 0;
  g_runStartMs = millis();
  g_loggingEnabled = true;
  setLastHaltReason("none");
  g_displayAutoSleepEnabled = true;
  wakeDisplay();
  g_logFileCount = g_storage.logFileCount();
  recordEvent("logging_started path=%s", g_storage.currentPath());
  Serial.printf("logging started: %s\n", g_storage.currentPath());
  return true;
}

void stopLoggingCleanly(const char* reason) {
  if (!g_loggingEnabled && !g_storageReady) {
    return;
  }
  (void)g_storage.flush();
  g_storage.close();
  g_storageReady = false;
  g_loggingEnabled = false;
  setLastHaltReason(reason);
  g_displayAutoSleepEnabled = true;
  wakeDisplay();
  recordEvent("logging_stopped reason=%s", g_lastHaltReason);
  if (reason && reason[0] != '\0') {
    Serial.printf("logging stopped: %s\n", reason);
  } else {
    Serial.println("logging stopped");
  }
  g_buttonStopMessageUntilMs = millis() + kButtonStopMessageMs;
}

void stopLoggingForStorageFailure(const char* phase) {
  g_storageReady = false;
  g_loggingEnabled = false;
  setLastHaltReason("sd_write_failed");
  g_displayAutoSleepEnabled = true;
  wakeDisplay();
  recordEvent("sd_write_failed phase=%s err=%s",
              phase ? phase : "unknown",
              g_storage.lastError()[0] ? g_storage.lastError() : "none");
  Serial.printf("ERROR: sd_write_failed phase=%s err=%s\n",
                phase ? phase : "unknown",
                g_storage.lastError()[0] ? g_storage.lastError() : "none");
  g_buttonStopMessageUntilMs = millis() + kButtonStopMessageMs;
}

bool rescanSdCard() {
  recordEvent("sd_rescan requested");
  (void)g_storage.flush();
  g_storage.close();
  g_storageReady = false;
  g_loggingEnabled = false;
  const bool mounted = g_sd.forceRemount();
  g_storageMounted = g_sd.isMounted();
  wakeDisplay();
  if (mounted) {
    recordEvent("sd_rescan mounted");
    g_sd.printCardInfo();
    (void)ensureStorageReady();
  } else {
    recordEvent("sd_rescan failed");
  }
  return mounted;
}

void pollStopButton() {
  const uint32_t now = millis();
  const bool pressed = (digitalRead(BUTTON_PIN) == LOW);

  if (pressed) {
    wakeDisplay();
  }

  if (g_buttonConfirmActive && (int32_t)(now - g_buttonConfirmDeadlineMs) >= 0) {
    g_buttonConfirmActive = false;
  }

  if (pressed && !g_buttonPrevPressed) {
    g_buttonPressedMs = now;
    g_buttonHoldHandled = false;
    if (g_buttonConfirmActive && g_loggingEnabled) {
      stopLoggingCleanly("button_confirm");
      g_buttonConfirmActive = false;
    }
  } else if (pressed && !g_buttonHoldHandled && !g_buttonConfirmActive && g_loggingEnabled &&
             (uint32_t)(now - g_buttonPressedMs) >= kButtonHoldPromptMs) {
    g_buttonHoldHandled = true;
    g_buttonConfirmActive = true;
    g_buttonConfirmDeadlineMs = now + kButtonConfirmWindowMs;
    recordEvent("button_prompt stop_recording");
    g_display.showBoot("Stop recording?", "Press again in 3s", nullptr, g_battery.voltageText());
  }

  if (!pressed && g_buttonPrevPressed) {
    g_buttonHoldHandled = false;
  }

  g_buttonPrevPressed = pressed;
}

void handleSdStateTransitions() {
  g_sd.update();
  if (g_sd.consumeMountedEvent()) {
    g_storageMounted = true;
    recordEvent("sd_mounted");
    Serial.println("SD mounted");
    g_sd.printCardInfo();
    (void)ensureStorageReady();
  }
  if (g_sd.consumeRemovedEvent()) {
    setLastHaltReason("sd_removed");
    recordEvent("sd_removed");
    Serial.println("SD removed");
    g_storageMounted = false;
    g_storage.close();
    g_storageReady = false;
    g_loggingEnabled = false;
  }
  g_storageMounted = g_sd.isMounted();
}

void attemptClockDiscipline(const GnssSample& sample) {
  if (g_clockDisciplined || !sample.validTime) {
    return;
  }
  if ((uint32_t)(millis() - g_lastDisciplineAttemptMs) < kClockDisciplineRetryMs) {
    return;
  }
  g_lastDisciplineAttemptMs = millis();

  ClockDateTime disciplinedUtc{};
  bool hadPriorRtc = false;
  int64_t driftSeconds = 0;
  if (!g_clock.disciplineFromGnss(sample,
                                  waitForNextPps,
                                  nullptr,
                                  disciplinedUtc,
                                  hadPriorRtc,
                                  driftSeconds)) {
    Serial.println("clock discipline pending: waiting for fresh GPS time and PPS");
    return;
  }

  setRunIdentityFromClock(disciplinedUtc);
  g_clockDisciplined = true;
  g_displayAutoSleepEnabled = true;
  wakeDisplay();
  recordEvent("clock_disciplined run_id=%s", g_runId);
  Serial.printf("RTC disciplined to GPS: %s", g_bootTimestampUtc);
  if (hadPriorRtc) {
    Serial.printf(" drift=%+llds", (long long)driftSeconds);
  }
  Serial.println();
  g_display.showBoot("Clock disciplined", g_runId);
  (void)ensureStorageReady();
}

void sampleAndMaybeLog() {
  g_battery.update(millis());
  GnssSample sample = g_gnss.makeSample();
  g_stats.updateFromSample(sample, millis());
  sample.ttffMs = g_stats.ttffMs();
  sample.longestNoFixMs = g_stats.longestNoFixMs();

  attemptClockDiscipline(sample);

  if (g_loggingEnabled && g_storageReady) {
    const uint32_t sampleSeq = ++g_sampleSeq;
    const uint32_t msSinceRunStart = millis() - g_runStartMs;
    SatelliteInfo sats[kMaxSatellites];
    const size_t satCount = g_gnss.copySatellites(sats, kMaxSatellites);
    logGnssTalkerCounts(sampleSeq, sats, satCount);
    if (!g_storage.appendSampleCsv(sample, sampleSeq, msSinceRunStart, g_runId, g_bootTimestampUtc)) {
      stopLoggingForStorageFailure("append_sample");
    } else if (satCount > 0 &&
               !g_storage.appendSatelliteCsv(sample, sampleSeq, msSinceRunStart, sats, satCount, g_runId, g_bootTimestampUtc)) {
      stopLoggingForStorageFailure("append_satellite");
    }
  }

  if (g_periodicSerialEnabled && (uint32_t)(millis() - g_lastStatusMs) >= kStatusPeriodMs) {
    g_lastStatusMs = millis();
    printStatusLine(sample);
  }
  if ((uint32_t)(millis() - g_lastDisplayMs) >= kDisplayPeriodMs) {
    g_lastDisplayMs = millis();
    if (g_clockDisciplined) {
      if (g_buttonConfirmActive) {
        g_display.showBoot("Stop recording?", "Press again in 3s", nullptr, g_battery.voltageText());
      } else if ((uint32_t)(millis() - g_buttonStopMessageUntilMs) < kButtonStopMessageMs) {
        g_display.showBoot("Halted", g_lastHaltReason, "Safe to power off", g_battery.voltageText());
      } else if (!g_storageMounted) {
        g_display.showBoot("No logging", "SD not mounted", g_runId, g_battery.voltageText());
      } else if (!g_storageReady && !g_loggingEnabled) {
        g_display.showBoot("No logging", "Storage not ready", g_runId, g_battery.voltageText());
      } else if (!g_display.powerSave()) {
        g_display.showSample(sample, g_stats, g_loggingEnabled, g_battery.voltageText());
      }
    } else {
      g_display.showBoot("Waiting for GPS UTC",
                         sample.validTime ? "Awaiting PPS" : "No valid time yet",
                         nullptr,
                         g_battery.voltageText());
    }
  }
  updateDisplayPowerState();
}

void buildFileTreeHtml(String& html, const char* path) {
  File dir = SD.open(path, FILE_READ);
  if (!dir || !dir.isDirectory()) {
    dir.close();
    html += "<li>directory unavailable</li>";
    return;
  }

  File entry = dir.openNextFile();
  while (entry) {
    const String name = entry.name();
    String leaf = name;
    const int slash = leaf.lastIndexOf('/');
    if (slash >= 0) {
      leaf.remove(0, slash + 1);
    }

    if (leaf.startsWith(".")) {
      entry.close();
      entry = dir.openNextFile();
      continue;
    }

    if (entry.isDirectory()) {
      String parent = path ? String(path) : String("/");
      if (parent.isEmpty()) {
        parent = "/";
      }
      if (!parent.endsWith("/")) {
        parent += "/";
      }
      const String childPath = parent + leaf;
      html += "<li><strong>";
      html += htmlEscape(leaf);
      html += "/</strong><ul>";
      entry.close();
      buildFileTreeHtml(html, childPath.c_str());
      html += "</ul></li>";
    } else {
      String parent = path ? String(path) : String("/");
      if (parent.isEmpty()) {
        parent = "/";
      }
      if (!parent.endsWith("/")) {
        parent += "/";
      }
      const String childPath = parent + leaf;
      html += "<li>";
      html += htmlEscape(leaf);
      html += " (";
      html += String((unsigned)entry.size());
      html += " bytes) ";
      html += "<a href='/download?path=";
      html += urlEncode(childPath);
      html += "'>download</a> ";
      html += "<a href='/cmd?erase=";
      html += urlEncode(childPath);
      html += "'>erase</a></li>";
      entry.close();
    }
    entry = dir.openNextFile();
  }
  dir.close();
}

bool normalizeWebPath(const String& input, String& out) {
  return g_storage.normalizePath(input.c_str(), out);
}

void handleWebIndex() {
  (void)g_storage.flush();
  String html;
  html.reserve(8192);
  html += "<!doctype html><html><head><meta charset='utf-8'><title>GPSQA ";
  html += kBoardId;
  html += "</title></head><body>";
  html += "<h1>GPSQA ";
  html += kBoardId;
  html += "</h1><p>";
  html += "Clock disciplined: ";
  html += g_clockDisciplined ? "yes" : "no";
  html += "<br>Run ID: ";
  html += htmlEscape(g_runId[0] ? String(g_runId) : String("PENDING_CLOCK"));
  html += "<br>Boot UTC: ";
  html += htmlEscape(g_bootTimestampUtc[0] ? String(g_bootTimestampUtc) : String("UNKNOWN"));
  html += "<br>Storage mounted: ";
  html += g_storageMounted ? "yes" : "no";
  html += "<br>Storage ready: ";
  html += g_storageReady ? "yes" : "no";
  html += "<br>Storage error: ";
  html += htmlEscape(String(g_storage.lastError()));
  html += "<br>Current log: ";
  html += htmlEscape(String(g_storage.currentPath()));
  html += "<br>Logging state: ";
  html += htmlEscape(String(currentLoggingState()));
  html += "<br>Halt reason: ";
  html += htmlEscape(String(g_lastHaltReason));
  html += "<br>Battery: ";
  html += htmlEscape(String(g_battery.voltageText()));
  html += "</p>";
  html += "<p><a href='/cmd?status=1'>status</a> ";
  html += "<a href='/cmd?flush=1'>flush</a> ";
  html += "<a href='/cmd?start=1'>start</a> ";
  html += "<a href='/cmd?stop=1'>stop</a> ";
  html += "<a href='/cmd?sd_rescan=1'>sd_rescan</a> ";
  html += "<a href='/cmd?erase_csv=1'>erase_csv</a> ";
  html += "<a href='/cmd?erase_logs=1'>erase_logs</a></p>";
  html += "<h2>SD Tree</h2><ul>";

  if (!g_storageMounted) {
    html += "<li>SD not mounted</li>";
  } else {
    buildFileTreeHtml(html, "/");
  }

  html += "</ul><p>Web commands also accept query forms like ";
  html += "<code>/cmd?erase=/logs/20260406_093912_CY.csv</code></p>";
  appendRecentEventsHtml(html);
  html += "</body></html>";
  g_server.send(200, "text/html; charset=utf-8", html);
}

void handleWebDownload() {
  (void)g_storage.flush();
  String pathArg = g_server.hasArg("path") ? g_server.arg("path") : g_server.arg("name");
  String fullPath;
  if (!normalizeWebPath(pathArg, fullPath)) {
    g_server.send(400, "text/plain", "invalid path");
    return;
  }

  File file = SD.open(fullPath.c_str(), FILE_READ);
  if (!file) {
    g_server.send(404, "text/plain", "not found");
    return;
  }

  String downloadName = file.name();
  const int slash = downloadName.lastIndexOf('/');
  if (slash >= 0) {
    downloadName.remove(0, slash + 1);
  }
  g_server.sendHeader("Content-Disposition", String("attachment; filename=\"") + downloadName + "\"");
  g_server.setContentLength(file.size());
  g_server.send(200, "application/octet-stream", "");
  WiFiClient client = g_server.client();
  uint8_t buffer[512];
  while (file.available()) {
    const size_t readBytes = file.read(buffer, sizeof(buffer));
    if (readBytes == 0) {
      break;
    }
    client.write(buffer, readBytes);
  }
  file.close();
}

void handleWebCommand() {
  String response;

  if (g_server.hasArg("erase")) {
    (void)g_storage.flush();
    const String path = g_server.arg("erase");
    if (g_storage.eraseFile(path.c_str())) {
      g_storageReady = g_storage.ready();
      if (!g_storageReady) {
        g_loggingEnabled = false;
      }
      g_logFileCount = g_storage.logFileCount();
      response = String("erased ") + path;
    } else {
      response = String("erase failed: ") + g_storage.lastError();
    }
  } else if (g_server.hasArg("erase_csv") || g_server.hasArg("erase_logs")) {
    (void)g_storage.flush();
    g_storage.eraseCsv(Serial);
    g_storageReady = g_storage.ready();
    if (!g_storageReady) {
      g_loggingEnabled = false;
    }
    g_logFileCount = g_storage.logFileCount();
    response = "csv files erased";
  } else if (g_server.hasArg("flush")) {
    response = g_storage.flush() ? "buffer flushed" : String("flush failed: ") + g_storage.lastError();
  } else if (g_server.hasArg("stop")) {
    stopLoggingCleanly("web stop");
    response = "logging stopped";
  } else if (g_server.hasArg("sd_rescan")) {
    response = rescanSdCard() ? "sd mounted" : "sd rescan failed";
  } else if (g_server.hasArg("start")) {
    if (!g_clockDisciplined) {
      response = "clock not disciplined yet";
    } else if (!g_storageMounted) {
      response = "sd not mounted";
    } else {
      g_storageReady = false;
      response = ensureStorageReady() ? String("logging started: ") + g_storage.currentPath()
                                      : String("start failed: ") + g_storage.lastError();
    }
  } else if (g_server.hasArg("status")) {
    response.reserve(256);
    response += "clock_disciplined=";
    response += g_clockDisciplined ? "yes" : "no";
    response += "\nrun_id=";
    response += g_runId[0] ? g_runId : "PENDING_CLOCK";
    response += "\nlog_file=";
    response += g_storage.currentPath();
    response += "\nstorage_mounted=";
    response += g_storageMounted ? "yes" : "no";
    response += "\nstorage_ready=";
    response += g_storageReady ? "yes" : "no";
    response += "\nsd_state=";
    response += g_storageMounted ? "mounted" : "absent";
    response += "\nlogging_state=";
    response += currentLoggingState();
    response += "\nhalt_reason=";
    response += g_lastHaltReason;
  } else {
    response = "commands: status flush start stop sd_rescan erase=<path> erase_csv=1";
  }

  g_server.send(200, "text/plain; charset=utf-8", response);
}

void startWebServer() {
  snprintf(g_apSsid, sizeof(g_apSsid), "%s%s", kLogApPrefix, kBoardId);
  WiFi.mode(WIFI_AP);
  IPAddress ip(192, 168, kLogApIpOctet, 1);
  IPAddress gw(192, 168, kLogApIpOctet, 1);
  IPAddress nm(255, 255, 255, 0);
  WiFi.softAPConfig(ip, gw, nm);
  if (strlen(kLogApPassword) > 0) {
    WiFi.softAP(g_apSsid, kLogApPassword);
  } else {
    WiFi.softAP(g_apSsid, nullptr);
  }

  g_server.on("/", HTTP_GET, handleWebIndex);
  g_server.on("/download", HTTP_GET, handleWebDownload);
  g_server.on("/cmd", HTTP_GET, handleWebCommand);
  g_server.begin();
  g_webReady = true;
}

void handleCommand(const char* line) {
  if (!line || line[0] == '\0') {
    return;
  }
  Serial.printf("-->%s\n", line);

  if (strcasecmp(line, "status") == 0) {
    GnssSample sample = g_gnss.makeSample();
    sample.longestNoFixMs = g_stats.longestNoFixMs();
    sample.ttffMs = g_stats.ttffMs();
    printStatusLine(sample);
  } else if (strcasecmp(line, "summary") == 0) {
    printSummary();
  } else if (strcasecmp(line, "verbose") == 0) {
    g_periodicSerialEnabled = true;
    Serial.println("periodic status enabled");
  } else if (strcasecmp(line, "quiet") == 0) {
    g_periodicSerialEnabled = false;
    Serial.println("periodic status disabled");
  } else if (strcasecmp(line, "start") == 0) {
    if (!g_clockDisciplined) {
      Serial.println("clock not disciplined yet");
    } else if (!g_storageMounted) {
      Serial.println("sd not mounted");
    } else {
      g_storageReady = false;
      if (ensureStorageReady()) {
        Serial.println("logging started");
      } else {
        Serial.printf("logging start failed: %s\n", g_storage.lastError());
      }
    }
  } else if (strcasecmp(line, "stop") == 0) {
    stopLoggingCleanly("serial stop");
  } else if (strcasecmp(line, "sd_rescan") == 0) {
    Serial.println(rescanSdCard() ? "sd mounted" : "sd rescan failed");
  } else if (strcasecmp(line, "flush") == 0) {
    g_storage.flush();
    Serial.println("log buffer flushed");
  } else if (strcasecmp(line, "ls") == 0) {
    g_storage.listFiles(Serial);
  } else if (strncasecmp(line, "cat ", 4) == 0) {
    g_storage.catFile(Serial, line + 4);
  } else if (strncasecmp(line, "erase ", 6) == 0) {
    if (g_storage.eraseFile(line + 6)) {
      g_storageReady = g_storage.ready();
      if (!g_storageReady) {
        g_loggingEnabled = false;
      }
      g_logFileCount = g_storage.logFileCount();
      Serial.println("file erased");
    } else {
      Serial.printf("erase failed: %s\n", g_storage.lastError());
    }
  } else if (strcasecmp(line, "erase_csv") == 0 || strcasecmp(line, "erase_logs") == 0) {
    g_storage.eraseCsv(Serial);
    g_storageReady = g_storage.ready();
    if (!g_storageReady) {
      g_loggingEnabled = false;
    }
    g_logFileCount = g_storage.logFileCount();
  } else if (strcasecmp(line, "discipline") == 0) {
    g_clockDisciplined = false;
    g_lastDisciplineAttemptMs = 0;
    Serial.println("clock discipline requested");
  } else {
    Serial.println("commands: status quiet verbose flush start stop sd_rescan summary ls cat <path> erase <path> erase_csv discipline");
  }
}

void pollSerialConsole() {
  while (Serial.available() > 0) {
    const int c = Serial.read();
    if (c < 0) {
      continue;
    }
    if (c == '\r' || c == '\n') {
      if (g_serialLineLen > 0) {
        g_serialLine[g_serialLineLen] = '\0';
        handleCommand(g_serialLine);
        g_serialLineLen = 0;
      }
      continue;
    }
    if (g_serialLineLen + 1 < sizeof(g_serialLine)) {
      g_serialLine[g_serialLineLen++] = (char)c;
    } else {
      g_serialLineLen = 0;
    }
  }
}

}  // namespace

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

  Serial.println();
  Serial.println("==================================================");
  Serial.println("Exercise 18: GPS Field QA");
  Serial.println("==================================================");

  if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) {
    Serial.println("WARNING: PMU init failed");
  }

  g_battery.begin(g_pmu);
  g_display.begin();
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  wakeDisplay();
  g_display.showBoot("Booting...", kBoardId, nullptr, g_battery.voltageText());
  g_stats.begin(millis());
  g_gnss.begin();
  (void)g_gnss.probeAtStartup(Serial);

  SdWatcherConfig sdCfg;
  sdCfg.recoveryRailOffMs = 400;
  sdCfg.recoveryRailOnSettleMs = 1200;
  sdCfg.startupWarmupMs = 2500;
  if (!g_sd.begin(sdCfg)) {
    Serial.println("WARNING: SD watcher init failed");
  }
  g_storageMounted = g_sd.isMounted();
  if (!g_storageMounted) {
    Serial.println("INFO: cold-boot SD second-chance remount");
    delay(750);
    if (g_sd.forceRemount()) {
      g_storageMounted = true;
    }
  }
  if (g_storageMounted) {
    g_sd.printCardInfo();
  }

  startWebServer();

#ifdef GPS_1PPS_PIN
  attachInterrupt(digitalPinToInterrupt(GPS_1PPS_PIN), onPpsEdge, RISING);
#endif

  ClockDateTime rtcNow{};
  int64_t rtcEpoch = 0;
  if (g_clock.readValidRtc(rtcNow, &rtcEpoch)) {
    char rtcIso[32];
    ClockDiscipline::formatIsoUtc(rtcNow, rtcIso, sizeof(rtcIso));
    Serial.printf("RTC present at boot: %s\n", rtcIso);
  } else {
    Serial.println("RTC invalid at boot");
  }

  recordEvent("boot board=%s", kBoardId);
  printProvenance();
  g_display.showBoot("Waiting for GPS UTC", "No log before RTC set", nullptr, g_battery.voltageText());

  g_lastSampleMs = millis();
  g_lastFlushMs = millis();
}

void loop() {
  pollSerialConsole();
  pollStopButton();
  g_gnss.poll();
  handleSdStateTransitions();
  g_server.handleClient();

  const uint32_t now = millis();
  if ((uint32_t)(now - g_lastSampleMs) >= kSamplePeriodMs) {
    g_lastSampleMs = now;
    sampleAndMaybeLog();
  }
  if (g_storageReady && (uint32_t)(now - g_lastFlushMs) >= kLogFlushPeriodMs) {
    g_lastFlushMs = now;
    if (!g_storage.flush()) {
      stopLoggingForStorageFailure("periodic_flush");
    }
  }
}
