// 20260405 ChatGPT
// Exercise 18_GPS_Field_QA

#include <Arduino.h>
#include <SPIFFS.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>

#include "Config.h"
#include "DisplayManager.h"
#include "GnssManager.h"
#include "RunStats.h"
#include "StorageManager.h"
#include "tbeam_supreme_adapter.h"

using namespace field_qa;

namespace {

struct RtcDateTime {
  uint16_t year;
  uint8_t month;
  uint8_t day;
  uint8_t hour;
  uint8_t minute;
  uint8_t second;
};

XPowersLibInterface* g_pmu = nullptr;
DisplayManager g_display;
GnssManager g_gnss;
StorageManager g_storage;
RunStats g_stats;
WebServer g_server(80);

char g_runId[48] = {0};
char g_bootTimestampUtc[32] = {0};
char g_serialLine[128] = {0};
char g_apSsid[32] = {0};
size_t g_serialLineLen = 0;
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_lastSampleMs = 0;
uint32_t g_lastFlushMs = 0;
uint32_t g_lastDisplayMs = 0;
uint32_t g_lastStatusMs = 0;

uint8_t fromBcd(uint8_t b) {
  return ((b >> 4U) * 10U) + (b & 0x0FU);
}

bool rtcRead(RtcDateTime& out, bool& lowVoltageFlag) {
  Wire1.beginTransmission(RTC_I2C_ADDR);
  Wire1.write(0x02);
  if (Wire1.endTransmission(false) != 0) {
    return false;
  }

  const uint8_t need = 7;
  uint8_t got = Wire1.requestFrom((int)RTC_I2C_ADDR, (int)need);
  if (got != need) {
    return false;
  }

  uint8_t sec = Wire1.read();
  uint8_t min = Wire1.read();
  uint8_t hour = Wire1.read();
  uint8_t day = Wire1.read();
  (void)Wire1.read();
  uint8_t month = Wire1.read();
  uint8_t year = Wire1.read();

  lowVoltageFlag = (sec & 0x80U) != 0;
  out.second = fromBcd(sec & 0x7FU);
  out.minute = fromBcd(min & 0x7FU);
  out.hour = fromBcd(hour & 0x3FU);
  out.day = fromBcd(day & 0x3FU);
  out.month = fromBcd(month & 0x1FU);
  uint8_t yy = fromBcd(year);
  bool century = (month & 0x80U) != 0;
  out.year = century ? (1900U + yy) : (2000U + yy);
  return true;
}

bool readBootTimestampFromRtc(char* isoOut, size_t isoOutSize, char* runIdOut, size_t runIdOutSize) {
  RtcDateTime now{};
  bool low = false;
  if (!rtcRead(now, low)) {
    return false;
  }
  snprintf(isoOut,
           isoOutSize,
           "%04u-%02u-%02uT%02u:%02u:%02uZ",
           (unsigned)now.year,
           (unsigned)now.month,
           (unsigned)now.day,
           (unsigned)now.hour,
           (unsigned)now.minute,
           (unsigned)now.second);
  snprintf(runIdOut,
           runIdOutSize,
           "%04u%02u%02u_%02u%02u%02u_%s",
           (unsigned)now.year,
           (unsigned)now.month,
           (unsigned)now.day,
           (unsigned)now.hour,
           (unsigned)now.minute,
           (unsigned)now.second,
           kBoardId);
  return true;
}

void formatUtcNowFallback(char* out, size_t outSize) {
  const uint32_t sec = millis() / 1000U;
  const uint32_t hh = sec / 3600U;
  const uint32_t mm = (sec % 3600U) / 60U;
  const uint32_t ss = sec % 60U;
  snprintf(out, outSize, "uptime_%02lu%02lu%02lu", (unsigned long)hh, (unsigned long)mm, (unsigned long)ss);
}

void setBootTimestampFromSample(const GnssSample& sample) {
  if (g_bootTimestampUtc[0] != '\0' || !sample.validTime) {
    return;
  }
  snprintf(g_bootTimestampUtc,
           sizeof(g_bootTimestampUtc),
           "%04u-%02u-%02uT%02u:%02u:%02uZ",
           (unsigned)sample.year,
           (unsigned)sample.month,
           (unsigned)sample.day,
           (unsigned)sample.hour,
           (unsigned)sample.minute,
           (unsigned)sample.second);
}

void makeRunId(const GnssSample* sample) {
  if (sample && sample->validTime) {
    snprintf(g_runId,
             sizeof(g_runId),
             "%04u%02u%02u_%02u%02u%02u_%s",
             (unsigned)sample->year,
             (unsigned)sample->month,
             (unsigned)sample->day,
             (unsigned)sample->hour,
             (unsigned)sample->minute,
             (unsigned)sample->second,
             kBoardId);
  } else {
    char stamp[24];
    formatUtcNowFallback(stamp, sizeof(stamp));
    snprintf(g_runId, sizeof(g_runId), "%s_%s", stamp, kBoardId);
  }
}

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);
  Serial.printf("web_ssid=%s\n", g_apSsid);
  Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet);
}

void printSummary() {
  const String currentPath = String(g_storage.currentPath());
  File rootDir = SPIFFS.open("/");
  const bool rootDirOk = rootDir && rootDir.isDirectory();
  Serial.println("summary:");
  Serial.printf("build=%s\n", kFirmwareVersion);
  Serial.printf("run_id=%s\n", g_runId);
  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_path_len=%u\n", (unsigned)currentPath.length());
  Serial.printf("storage_path_exists=%s\n",
                (!currentPath.isEmpty() && SPIFFS.exists(currentPath)) ? "yes" : "no");
  Serial.printf("storage_root_dir=%s\n", rootDirOk ? "yes" : "no");
  Serial.printf("storage_total_bytes=%u\n", (unsigned)SPIFFS.totalBytes());
  Serial.printf("storage_used_bytes=%u\n", (unsigned)SPIFFS.usedBytes());
  Serial.printf("storage_buffered_bytes=%u\n", (unsigned)g_storage.bufferedBytes());
  Serial.printf("storage_log_count=%u\n", (unsigned)g_logFileCount);
  Serial.printf("storage_auto_log_limit=%u\n", (unsigned)kMaxLogFilesBeforePause);
  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 chip=%s fix=%s used=%d view=%d hdop=%s lat=%s lon=%s alt=%s q=%s\n",
                ts,
                kBoardId,
                g_gnss.detectedChipName(),
                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));
}

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);
    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("log_file=%s\n", g_storage.currentPath());
    Serial.printf("storage_log_count=%u\n", (unsigned)g_logFileCount);
    Serial.printf("periodic_serial=%s\n", g_periodicSerialEnabled ? "on" : "off");
    Serial.printf("web_ready=%s\n", g_webReady ? "yes" : "no");
    Serial.printf("web_url=http://192.168.%u.1/\n", (unsigned)kLogApIpOctet);
  } else if (strcasecmp(line, "summary") == 0) {
    printSummary();
  } else if (strcasecmp(line, "start") == 0) {
    if (g_storageReady) {
      g_loggingEnabled = true;
      Serial.println("logging already active");
    } else if (!g_storageMounted) {
      Serial.println("storage not mounted");
    } else if (g_storage.startLog(g_runId, g_bootTimestampUtc)) {
      g_storageReady = true;
      g_loggingEnabled = true;
      g_logFileCount = g_storage.logFileCount();
      Serial.println("logging started");
    } else {
      Serial.printf("logging start failed: %s\n", g_storage.lastError());
    }
  } else if (strcasecmp(line, "stop") == 0) {
    g_loggingEnabled = false;
    g_storage.flush();
    Serial.println("logging stopped");
  } else if (strcasecmp(line, "quiet") == 0) {
    g_periodicSerialEnabled = false;
    Serial.println("periodic serial output disabled");
  } else if (strcasecmp(line, "verbose") == 0) {
    g_periodicSerialEnabled = true;
    Serial.println("periodic serial output enabled");
  } else if (strcasecmp(line, "flush") == 0) {
    g_storage.flush();
    Serial.println("log buffer flushed");
  } else if (strcasecmp(line, "ls") == 0) {
    g_storage.flush();
    g_storage.listFiles(Serial);
  } else if (strncasecmp(line, "cat ", 4) == 0) {
    g_storage.flush();
    g_storage.catFile(Serial, line + 4);
  } else if (strcasecmp(line, "erase_logs") == 0) {
    g_storage.eraseLogs(Serial);
    g_logFileCount = g_storage.logFileCount();
  } else {
    Serial.println("commands: status quiet verbose flush start stop summary ls cat <filename> erase_logs");
  }
}

String htmlEscape(const String& in) {
  String out;
  out.reserve(in.length() + 16);
  for (size_t i = 0; i < in.length(); ++i) {
    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) {
  static const char* hex = "0123456789ABCDEF";
  String out;
  out.reserve(in.length() * 3);
  for (size_t i = 0; i < in.length(); ++i) {
    uint8_t c = (uint8_t)in[i];
    if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
        (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
      out += (char)c;
    } else {
      out += '%';
      out += hex[(c >> 4) & 0x0F];
      out += hex[c & 0x0F];
    }
  }
  return out;
}

String normalizeLogPath(const String& name) {
  if (name.isEmpty() || name.indexOf("..") >= 0) {
    return "";
  }
  if (name[0] == '/') {
    return name;
  }
  return String("/") + name;
}

void handleWebIndex() {
  g_storage.flush();
  String html;
  html.reserve(4096);
  html += "<!doctype html><html><head><meta charset='utf-8'><title>GPSQA ";
  html += kBoardId;
  html += "</title></head><body>";
  html += "<h1>GPSQA ";
  html += kBoardId;
  html += "</h1>";
  html += "<p>Run ID: ";
  html += htmlEscape(String(g_runId));
  html += "<br>Build: ";
  html += htmlEscape(String(kFirmwareVersion));
  html += "<br>Boot UTC: ";
  html += htmlEscape(String(g_bootTimestampUtc[0] != '\0' ? g_bootTimestampUtc : "UNKNOWN"));
  html += "<br>Board: ";
  html += htmlEscape(String(kBoardId));
  html += "<br>GNSS configured: ";
  html += htmlEscape(String(kGnssChip));
  html += "<br>GNSS detected: ";
  html += htmlEscape(String(g_gnss.detectedChipName()));
  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 += "</p><ul>";

  if (!g_storageMounted) {
    html += "<li>storage not mounted</li>";
  } else {
    File dir = SPIFFS.open("/");
    if (!dir || !dir.isDirectory()) {
      html += "<li>root directory unavailable</li>";
    } else {
      File file = dir.openNextFile();
      if (!file) {
        html += "<li>(no files)</li>";
      }
      while (file) {
        String name = file.name();
        if (name.endsWith(".csv") || name.endsWith(".tsv")) {
          html += "<li><a href='/download?name=";
          html += urlEncode(name);
          html += "'>";
          html += htmlEscape(name);
          html += "</a> (";
          html += String((unsigned)file.size());
          html += " bytes)</li>";
        }
        file = dir.openNextFile();
      }
    }
  }

  html += "</ul></body></html>";
  g_server.send(200, "text/html; charset=utf-8", html);
}

void handleWebDownload() {
  g_storage.flush();
  if (!g_server.hasArg("name")) {
    g_server.send(400, "text/plain", "missing name");
    return;
  }
  String fullPath = normalizeLogPath(g_server.arg("name"));
  if (fullPath.isEmpty()) {
    g_server.send(400, "text/plain", "invalid name");
    return;
  }
  File file = SPIFFS.open(fullPath, FILE_READ);
  if (!file) {
    g_server.send(404, "text/plain", "not found");
    return;
  }
  String downloadName = file.name();
  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, "text/csv; charset=utf-8", "");
  WiFiClient client = g_server.client();
  uint8_t buffer[512];
  while (file.available()) {
    size_t readBytes = file.read(buffer, sizeof(buffer));
    if (readBytes == 0) {
      break;
    }
    client.write(buffer, readBytes);
  }
  file.close();
}

void handleWebDebugRead() {
  g_storage.flush();
  if (!g_server.hasArg("name")) {
    g_server.send(400, "text/plain", "missing name\n");
    return;
  }
  String fullPath = normalizeLogPath(g_server.arg("name"));
  if (fullPath.isEmpty()) {
    g_server.send(400, "text/plain", "invalid name\n");
    return;
  }
  File file = SPIFFS.open(fullPath, FILE_READ);
  if (!file) {
    g_server.send(404, "text/plain", String("open failed: ") + fullPath + "\n");
    return;
  }

  String out;
  out.reserve(512);
  out += "path=";
  out += fullPath;
  out += "\nsize=";
  out += String((unsigned)file.size());
  out += "\navailable_before=";
  out += String((unsigned)file.available());
  out += "\n";

  char buf[129];
  size_t count = file.readBytes(buf, sizeof(buf) - 1);
  buf[count] = '\0';
  out += "read_bytes=";
  out += String((unsigned)count);
  out += "\navailable_after=";
  out += String((unsigned)file.available());
  out += "\npreview_begin\n";
  out += String(buf);
  out += "\npreview_end\n";
  file.close();
  g_server.send(200, "text/plain; charset=utf-8", out);
}

void handleWebRaw() {
  g_storage.flush();
  if (!g_server.hasArg("name")) {
    g_server.send(400, "text/plain", "missing name\n");
    return;
  }
  String fullPath = normalizeLogPath(g_server.arg("name"));
  if (fullPath.isEmpty()) {
    g_server.send(400, "text/plain", "invalid name\n");
    return;
  }
  File file = SPIFFS.open(fullPath, FILE_READ);
  if (!file) {
    g_server.send(404, "text/plain", "not found\n");
    return;
  }
  String body;
  body.reserve(file.size() + 1);
  while (file.available()) {
    int c = file.read();
    if (c < 0) {
      break;
    }
    body += (char)c;
  }
  file.close();
  g_server.send(200, "text/plain; charset=utf-8", body);
}

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("/debug_read", HTTP_GET, handleWebDebugRead);
  g_server.on("/raw", HTTP_GET, handleWebRaw);
  g_server.begin();
  g_webReady = true;
}

void pollSerialConsole() {
  while (Serial.available() > 0) {
    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;
    }
  }
}

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

  setBootTimestampFromSample(sample);
  if (g_runId[0] == '\0') {
    makeRunId(&sample);
  }
  if (g_bootTimestampUtc[0] == '\0') {
    strlcpy(g_bootTimestampUtc, "UNKNOWN", sizeof(g_bootTimestampUtc));
  }

  if (g_loggingEnabled && g_storageReady) {
    SatelliteInfo sats[kMaxSatellites];
    size_t satCount = g_gnss.copySatellites(sats, kMaxSatellites);
    g_storage.appendSampleTsv(sample, g_runId, g_bootTimestampUtc);
    g_storage.appendSatelliteTsv(sample, sats, satCount, g_runId, g_bootTimestampUtc);
  }

  if (g_periodicSerialEnabled && (uint32_t)(millis() - g_lastStatusMs) >= kStatusPeriodMs) {
    g_lastStatusMs = millis();
    printStatusLine(sample);
  }
  if ((uint32_t)(millis() - g_lastDisplayMs) >= kDisplayPeriodMs) {
    g_lastDisplayMs = millis();
    g_display.showSample(sample, g_stats);
  }
}

}  // 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_display.begin();
  g_display.showBoot("Booting...", kBoardId);

  g_stats.begin(millis());
  g_gnss.begin();
  (void)g_gnss.probeAtStartup(Serial);
  startWebServer();

  if (!readBootTimestampFromRtc(g_bootTimestampUtc, sizeof(g_bootTimestampUtc), g_runId, sizeof(g_runId))) {
    makeRunId(nullptr);
    strlcpy(g_bootTimestampUtc, "UNKNOWN", sizeof(g_bootTimestampUtc));
  }
  g_storageMounted = false;
  g_storageReady = false;
  g_storageMounted = g_storage.mount();
  g_storageMounted = g_storage.mounted();
  g_logFileCount = g_storage.logFileCount();
  if (!g_storageMounted) {
    Serial.printf("ERROR: SPIFFS mount failed: %s\n", g_storage.lastError());
    g_display.showError("SPIFFS mount failed");
  } else if (g_logFileCount <= kMaxLogFilesBeforePause) {
    g_storageReady = g_storage.startLog(g_runId, g_bootTimestampUtc);
    if (g_storageReady) {
      g_loggingEnabled = true;
    } else {
      Serial.printf("ERROR: log start failed: %s\n", g_storage.lastError());
    }
  } else {
    Serial.printf("INFO: auto logging paused, log count %u exceeds limit %u\n",
                  (unsigned)g_logFileCount,
                  (unsigned)kMaxLogFilesBeforePause);
  }

  printProvenance();
  g_display.showBoot("Survey mode", g_loggingEnabled ? "Logging active" : "Logging paused");

  g_lastSampleMs = millis();
  g_lastFlushMs = millis();
  g_lastDisplayMs = 0;
  g_lastStatusMs = 0;
}

void loop() {
  pollSerialConsole();
  g_gnss.poll();
  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;
    g_storage.flush();
  }
}
