// 20260217 ChatGPT
// $Id$
// $HeadURL$

#include <Arduino.h>
#include <SPIFFS.h>
#include <Wire.h>
#include <U8g2lib.h>

#include "StartupSdManager.h"
#include "tbeam_supreme_adapter.h"

#ifndef OLED_SDA
#define OLED_SDA 17
#endif

#ifndef OLED_SCL
#define OLED_SCL 18
#endif

#ifndef OLED_ADDR
#define OLED_ADDR 0x3C
#endif

#ifndef RTC_I2C_ADDR
#define RTC_I2C_ADDR 0x51
#endif

#ifndef GPS_BAUD
#define GPS_BAUD 9600
#endif

static const uint32_t kSerialDelayMs = 5000;
static const uint32_t kMinuteMs = 60000;
static const uint32_t kGpsDiagnosticLogMs = 15000;
static const char *kGpsLogDir = "/gpsdiag";
static const char *kGpsLogPath = "/gpsdiag/current.log";
static const char *kBuildDate = __DATE__;
static const char *kBuildTime = __TIME__;

static XPowersLibInterface *g_pmu = nullptr;
static StartupSdManager g_sd(Serial);
static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, /* reset=*/U8X8_PIN_NONE);
static HardwareSerial g_gpsSerial(1);

static uint32_t g_logSeq = 0;
static uint32_t g_lastMinuteReportMs = 0;
static uint32_t g_lastGpsDiagnosticLogMs = 0;
static uint32_t g_gpsBaud = GPS_BAUD;
static int g_gpsRxPin = GPS_RX_PIN;
static int g_gpsTxPin = GPS_TX_PIN;
static bool g_spiffsReady = false;
static bool g_ubloxConfigAttempted = false;
static bool g_ubloxConfigured = false;
static bool g_ubloxIsM10 = false;

static bool g_prevHadSatellites = false;
static bool g_prevHadValidUtc = false;
static bool g_satellitesAcquiredAnnounced = false;
static bool g_timeAcquiredAnnounced = false;
static uint8_t g_lastDrawnSatsUsed = 255;
static uint8_t g_lastDrawnSatsView = 255;
static bool g_lastDrawnValidUtc = false;
static bool g_haveLastDrawnState = false;
static uint32_t g_lastDisplayRefreshMs = 0;

static char g_gpsLine[128];
static size_t g_gpsLineLen = 0;
static char g_serialLine[128];
static size_t g_serialLineLen = 0;
static uint8_t g_rawLogGgaCount = 0;
static uint8_t g_rawLogGsaCount = 0;
static uint8_t g_rawLogGsvCount = 0;
static uint8_t g_rawLogRmcCount = 0;
static uint8_t g_rawLogPubxCount = 0;

enum class GpsModuleKind : uint8_t
{
  UNKNOWN = 0,
  L76K,
  UBLOX
};

#if defined(GPS_UBLOX)
static const GpsModuleKind kExpectedGpsModule = GpsModuleKind::UBLOX;
#elif defined(GPS_L76K)
static const GpsModuleKind kExpectedGpsModule = GpsModuleKind::L76K;
#else
static const GpsModuleKind kExpectedGpsModule = GpsModuleKind::UNKNOWN;
#endif

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

struct GpsState
{
  GpsModuleKind module = kExpectedGpsModule;
  bool sawAnySentence = false;

  uint8_t satsUsed = 0;
  uint8_t satsInView = 0;
  uint8_t satsUsedWindowMax = 0;
  uint8_t satsInViewWindowMax = 0;
  uint32_t satsUsedWindowMs = 0;
  uint32_t satsInViewWindowMs = 0;

  bool hasValidUtc = false;
  uint32_t utcFixMs = 0;
  uint16_t utcYear = 0;
  uint8_t utcMonth = 0;
  uint8_t utcDay = 0;
  uint8_t utcHour = 0;
  uint8_t utcMinute = 0;
  uint8_t utcSecond = 0;
};

static GpsState g_gps;
static const uint32_t kSatelliteWindowMs = 2000;
static const uint32_t kDisplayRefreshMinMs = 1000;
static const uint32_t kFixFreshMs = 5000;

static String gpsModuleToString(GpsModuleKind kind);
static GpsModuleKind activeGpsModule();
static uint8_t bestSatelliteCount();
static uint8_t displayedSatsUsed();
static uint8_t displayedSatsInView();
static bool displayHasFreshUtc();
static String formatRtcNow();

static bool ensureGpsLogDirectory()
{
  if (!g_spiffsReady)
  {
    return false;
  }
  if (SPIFFS.exists(kGpsLogDir))
  {
    return true;
  }
  return SPIFFS.mkdir(kGpsLogDir);
}

static bool gpsDiagAppendLine(const char *line)
{
  if (!g_spiffsReady || !line)
  {
    return false;
  }
  File file = SPIFFS.open(kGpsLogPath, FILE_APPEND);
  if (!file)
  {
    return false;
  }
  file.print(line);
  file.print("\r\n");
  file.close();
  return true;
}

static void formatGpsSnapshot(char *out, size_t outSize, const char *event)
{
  if (!out || outSize == 0)
  {
    return;
  }

  const uint8_t sats = bestSatelliteCount();
  const char *ev = event ? event : "sample";
  if (g_gps.hasValidUtc)
  {
    snprintf(out,
             outSize,
             "ms=%lu event=%s module=%s nmea=%s sats_used=%u sats_view=%u sats_best=%u utc=%04u-%02u-%02uT%02u:%02u:%02u rx=%d tx=%d baud=%lu",
             (unsigned long)millis(),
             ev,
             gpsModuleToString(activeGpsModule()).c_str(),
             g_gps.sawAnySentence ? "yes" : "no",
             (unsigned)g_gps.satsUsed,
             (unsigned)g_gps.satsInView,
             (unsigned)sats,
             (unsigned)g_gps.utcYear,
             (unsigned)g_gps.utcMonth,
             (unsigned)g_gps.utcDay,
             (unsigned)g_gps.utcHour,
             (unsigned)g_gps.utcMinute,
             (unsigned)g_gps.utcSecond,
             g_gpsRxPin,
             g_gpsTxPin,
             (unsigned long)g_gpsBaud);
  }
  else
  {
    String rtc = formatRtcNow();
    snprintf(out,
             outSize,
             "ms=%lu event=%s module=%s nmea=%s sats_used=%u sats_view=%u sats_best=%u utc=NO rtc=\"%s\" rx=%d tx=%d baud=%lu",
             (unsigned long)millis(),
             ev,
             gpsModuleToString(activeGpsModule()).c_str(),
             g_gps.sawAnySentence ? "yes" : "no",
             (unsigned)g_gps.satsUsed,
             (unsigned)g_gps.satsInView,
             (unsigned)sats,
             rtc.c_str(),
             g_gpsRxPin,
             g_gpsTxPin,
             (unsigned long)g_gpsBaud);
  }
}

static void appendGpsSnapshot(const char *event)
{
  char line[256];
  formatGpsSnapshot(line, sizeof(line), event);
  (void)gpsDiagAppendLine(line);
}

static String buildStampShort()
{
  char buf[32];
  snprintf(buf, sizeof(buf), "%s %.5s", kBuildDate, kBuildTime);
  return String(buf);
}

static void maybeLogRawSentence(const char *type, const char *sentence)
{
  if (!type || !sentence || !g_spiffsReady)
  {
    return;
  }

  uint8_t *counter = nullptr;
  if (strcmp(type, "GGA") == 0)
  {
    counter = &g_rawLogGgaCount;
  }
  else if (strcmp(type, "GSA") == 0)
  {
    counter = &g_rawLogGsaCount;
  }
  else if (strcmp(type, "GSV") == 0)
  {
    counter = &g_rawLogGsvCount;
  }
  else if (strcmp(type, "RMC") == 0)
  {
    counter = &g_rawLogRmcCount;
  }
  else if (strcmp(type, "PUBX") == 0)
  {
    counter = &g_rawLogPubxCount;
  }

  if (!counter || *counter >= 12)
  {
    return;
  }
  (*counter)++;

  char line[220];
  snprintf(line,
           sizeof(line),
           "ms=%lu event=raw_%s idx=%u sentence=%s",
           (unsigned long)millis(),
           type,
           (unsigned)*counter,
           sentence);
  (void)gpsDiagAppendLine(line);
}

static void clearGpsSerialInput()
{
  g_gpsLineLen = 0;
  while (g_gpsSerial.available() > 0)
  {
    (void)g_gpsSerial.read();
  }
}

static void ubxChecksum(uint8_t *message, size_t length)
{
  uint8_t ckA = 0;
  uint8_t ckB = 0;
  for (size_t i = 2; i < length - 2; ++i)
  {
    ckA = (uint8_t)((ckA + message[i]) & 0xFF);
    ckB = (uint8_t)((ckB + ckA) & 0xFF);
  }
  message[length - 2] = ckA;
  message[length - 1] = ckB;
}

static size_t makeUbxPacket(uint8_t *out,
                            size_t outSize,
                            uint8_t classId,
                            uint8_t msgId,
                            const uint8_t *payload,
                            uint16_t payloadSize)
{
  if (!out || outSize < (size_t)payloadSize + 8U)
  {
    return 0;
  }
  out[0] = 0xB5;
  out[1] = 0x62;
  out[2] = classId;
  out[3] = msgId;
  out[4] = (uint8_t)(payloadSize & 0xFF);
  out[5] = (uint8_t)((payloadSize >> 8) & 0xFF);
  for (uint16_t i = 0; i < payloadSize; ++i)
  {
    out[6 + i] = payload ? payload[i] : 0;
  }
  out[6 + payloadSize] = 0;
  out[7 + payloadSize] = 0;
  ubxChecksum(out, payloadSize + 8U);
  return (size_t)payloadSize + 8U;
}

static bool waitForUbxAck(uint8_t classId, uint8_t msgId, uint32_t waitMs)
{
  uint8_t ack[10] = {0xB5, 0x62, 0x05, 0x01, 0x02, 0x00, classId, msgId, 0x00, 0x00};
  ubxChecksum(ack, sizeof(ack));
  uint8_t ackPos = 0;
  uint32_t deadline = millis() + waitMs;

  while ((int32_t)(deadline - millis()) > 0)
  {
    if (g_gpsSerial.available() <= 0)
    {
      delay(2);
      continue;
    }
    uint8_t b = (uint8_t)g_gpsSerial.read();
    if (b == ack[ackPos])
    {
      ackPos++;
      if (ackPos == sizeof(ack))
      {
        return true;
      }
    }
    else
    {
      ackPos = (b == ack[0]) ? 1 : 0;
    }
  }
  return false;
}

static int waitForUbxPayload(uint8_t *buffer,
                             uint16_t bufferSize,
                             uint8_t classId,
                             uint8_t msgId,
                             uint32_t waitMs)
{
  uint16_t framePos = 0;
  uint16_t needRead = 0;
  uint32_t deadline = millis() + waitMs;

  while ((int32_t)(deadline - millis()) > 0)
  {
    if (g_gpsSerial.available() <= 0)
    {
      delay(2);
      continue;
    }

    int c = g_gpsSerial.read();
    switch (framePos)
    {
    case 0:
      framePos = (c == 0xB5) ? 1 : 0;
      break;
    case 1:
      framePos = (c == 0x62) ? 2 : 0;
      break;
    case 2:
      framePos = (c == classId) ? 3 : 0;
      break;
    case 3:
      framePos = (c == msgId) ? 4 : 0;
      break;
    case 4:
      needRead = (uint16_t)c;
      framePos = 5;
      break;
    case 5:
      needRead |= (uint16_t)(c << 8);
      if (needRead == 0 || needRead >= bufferSize)
      {
        framePos = 0;
        break;
      }
      if (g_gpsSerial.readBytes(buffer, needRead) != needRead)
      {
        framePos = 0;
        break;
      }
      if (g_gpsSerial.available() >= 2)
      {
        (void)g_gpsSerial.read();
        (void)g_gpsSerial.read();
      }
      return (int)needRead;
    default:
      framePos = 0;
      break;
    }
  }

  return 0;
}

static bool detectUbloxM10()
{
  uint8_t packet[8];
  uint8_t payload[256] = {0};
  size_t len = makeUbxPacket(packet, sizeof(packet), 0x0A, 0x04, nullptr, 0);
  if (len == 0)
  {
    return false;
  }

  clearGpsSerialInput();
  g_gpsSerial.write(packet, len);
  int payloadLen = waitForUbxPayload(payload, sizeof(payload), 0x0A, 0x04, 1200);
  if (payloadLen < 40)
  {
    appendGpsSnapshot("ubx_monver_timeout");
    return false;
  }

  char hwVersion[11] = {0};
  memcpy(hwVersion, payload + 30, 10);
  char line[160];
  snprintf(line, sizeof(line), "ms=%lu event=ubx_monver hw=%s", (unsigned long)millis(), hwVersion);
  (void)gpsDiagAppendLine(line);

  if (strncmp(hwVersion, "000A0000", 8) == 0)
  {
    return true;
  }

  for (int pos = 40; pos + 30 <= payloadLen; pos += 30)
  {
    if (strncmp((const char *)(payload + pos), "PROTVER=", 8) == 0)
    {
      int prot = atoi((const char *)(payload + pos + 8));
      snprintf(line, sizeof(line), "ms=%lu event=ubx_monver prot=%d", (unsigned long)millis(), prot);
      (void)gpsDiagAppendLine(line);
      if (prot >= 27)
      {
        return true;
      }
    }
  }

  return false;
}

static bool sendUbxValset(uint8_t classId,
                          uint8_t msgId,
                          const uint8_t *payload,
                          uint16_t payloadLen,
                          uint32_t ackMs,
                          const char *eventName)
{
  uint8_t packet[96];
  size_t len = makeUbxPacket(packet, sizeof(packet), classId, msgId, payload, payloadLen);
  if (len == 0)
  {
    return false;
  }
  clearGpsSerialInput();
  g_gpsSerial.write(packet, len);
  bool ok = waitForUbxAck(classId, msgId, ackMs);
  char line[160];
  snprintf(line,
           sizeof(line),
           "ms=%lu event=%s ack=%s",
           (unsigned long)millis(),
           eventName ? eventName : "ubx_cfg",
           ok ? "yes" : "no");
  (void)gpsDiagAppendLine(line);
  delay(150);
  return ok;
}

static bool configureUbloxM10()
{
  static const uint8_t kValsetDisableTxtRam[] = {0x00, 0x01, 0x00, 0x00, 0x07, 0x00, 0x92, 0x20, 0x03};
  static const uint8_t kValsetDisableTxtBbr[] = {0x00, 0x02, 0x00, 0x00, 0x07, 0x00, 0x92, 0x20, 0x03};
  static const uint8_t kValsetEnableNmeaRam[] = {0x00, 0x01, 0x00, 0x00, 0xbb, 0x00, 0x91, 0x20, 0x01, 0xac, 0x00, 0x91, 0x20, 0x01};
  static const uint8_t kValsetEnableNmeaBbr[] = {0x00, 0x02, 0x00, 0x00, 0xbb, 0x00, 0x91, 0x20, 0x01, 0xac, 0x00, 0x91, 0x20, 0x01};
  static const uint8_t kSave10[] = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01};

  bool ok = true;
  ok &= sendUbxValset(0x06, 0x8A, kValsetDisableTxtRam, sizeof(kValsetDisableTxtRam), 300, "ubx_m10_disable_txt_ram");
  ok &= sendUbxValset(0x06, 0x8A, kValsetDisableTxtBbr, sizeof(kValsetDisableTxtBbr), 300, "ubx_m10_disable_txt_bbr");
  ok &= sendUbxValset(0x06, 0x8A, kValsetEnableNmeaBbr, sizeof(kValsetEnableNmeaBbr), 400, "ubx_m10_enable_nmea_bbr");
  ok &= sendUbxValset(0x06, 0x8A, kValsetEnableNmeaRam, sizeof(kValsetEnableNmeaRam), 400, "ubx_m10_enable_nmea_ram");
  ok &= sendUbxValset(0x06, 0x09, kSave10, sizeof(kSave10), 800, "ubx_m10_save");
  appendGpsSnapshot(ok ? "ubx_m10_configured" : "ubx_m10_config_failed");
  return ok;
}

static void maybeConfigureUblox()
{
  if (g_ubloxConfigAttempted || kExpectedGpsModule != GpsModuleKind::UBLOX)
  {
    return;
  }
  g_ubloxConfigAttempted = true;
  appendGpsSnapshot("ubx_config_attempt");

  g_ubloxIsM10 = detectUbloxM10();
  if (!g_ubloxIsM10)
  {
    appendGpsSnapshot("ubx_non_m10_or_unknown");
    return;
  }

  g_ubloxConfigured = configureUbloxM10();
}

static void logf(const char *fmt, ...)
{
  char msg[220];
  va_list args;
  va_start(args, fmt);
  vsnprintf(msg, sizeof(msg), fmt, args);
  va_end(args);
  Serial.printf("[%10lu][%06lu] %s\r\n", (unsigned long)millis(), (unsigned long)g_logSeq++, msg);
}

static void oledShowLines(const char *l1,
                          const char *l2 = nullptr,
                          const char *l3 = nullptr,
                          const char *l4 = nullptr,
                          const char *l5 = nullptr)
{
  g_oled.clearBuffer();
  g_oled.setFont(u8g2_font_5x8_tf);
  if (l1)
    g_oled.drawUTF8(0, 12, l1);
  if (l2)
    g_oled.drawUTF8(0, 24, l2);
  if (l3)
    g_oled.drawUTF8(0, 36, l3);
  if (l4)
    g_oled.drawUTF8(0, 48, l4);
  if (l5)
    g_oled.drawUTF8(0, 60, l5);
  g_oled.sendBuffer();
}

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

static 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(); // weekday
  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;
}

static String formatRtcNow()
{
  RtcDateTime now{};
  bool lowV = false;
  if (!rtcRead(now, lowV))
  {
    return "RTC: read failed";
  }

  char buf[48];
  snprintf(buf,
           sizeof(buf),
           "RTC %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,
           lowV ? " !LOWV" : "");
  return String(buf);
}

static String gpsModuleToString(GpsModuleKind kind)
{
  if (kind == GpsModuleKind::L76K)
  {
    return "L76K";
  }
  if (kind == GpsModuleKind::UBLOX)
  {
    return "UBLOX";
  }
  return "Unknown";
}

static bool parseUInt2(const char *s, uint8_t &out)
{
  if (!s || !isdigit((unsigned char)s[0]) || !isdigit((unsigned char)s[1]))
  {
    return false;
  }
  out = (uint8_t)((s[0] - '0') * 10 + (s[1] - '0'));
  return true;
}

static void detectModuleFromText(const char *text)
{
  if (!text || text[0] == '\0')
  {
    return;
  }

  String t(text);
  t.toUpperCase();

  if (t.indexOf("L76K") >= 0)
  {
    g_gps.module = GpsModuleKind::L76K;
    return;
  }

  if (t.indexOf("QUECTEL") >= 0 || t.indexOf("2021-10-20") >= 0 || t.indexOf("2021/10/20") >= 0)
  {
    g_gps.module = GpsModuleKind::L76K;
  }
}

static void parseGga(char *fields[], int count)
{
  if (count <= 7)
  {
    return;
  }
  int sats = atoi(fields[7]);
  if (sats >= 0 && sats <= 255)
  {
    g_gps.satsUsed = (uint8_t)sats;
    if ((uint8_t)sats > g_gps.satsUsedWindowMax)
    {
      g_gps.satsUsedWindowMax = (uint8_t)sats;
    }
    g_gps.satsUsedWindowMs = millis();
  }
}

static void parseGsv(char *fields[], int count)
{
  if (count <= 3)
  {
    return;
  }
  int sats = atoi(fields[3]);
  if (sats >= 0 && sats <= 255)
  {
    g_gps.satsInView = (uint8_t)sats;
    if ((uint8_t)sats > g_gps.satsInViewWindowMax)
    {
      g_gps.satsInViewWindowMax = (uint8_t)sats;
    }
    g_gps.satsInViewWindowMs = millis();
  }
}

static void parseGsa(char *fields[], int count)
{
  if (count <= 3)
  {
    return;
  }
}

static void parseRmc(char *fields[], int count)
{
  if (count <= 9)
  {
    return;
  }

  const char *utc = fields[1];
  const char *status = fields[2];
  const char *date = fields[9];

  if (!status || status[0] != 'A')
  {
    return;
  }

  if (!utc || strlen(utc) < 6 || !date || strlen(date) < 6)
  {
    return;
  }

  uint8_t hh = 0, mm = 0, ss = 0;
  uint8_t dd = 0, mo = 0, yy = 0;
  if (!parseUInt2(utc + 0, hh) || !parseUInt2(utc + 2, mm) || !parseUInt2(utc + 4, ss))
  {
    return;
  }
  if (!parseUInt2(date + 0, dd) || !parseUInt2(date + 2, mo) || !parseUInt2(date + 4, yy))
  {
    return;
  }

  g_gps.utcHour = hh;
  g_gps.utcMinute = mm;
  g_gps.utcSecond = ss;
  g_gps.utcDay = dd;
  g_gps.utcMonth = mo;
  g_gps.utcYear = (uint16_t)(2000U + yy);
  g_gps.hasValidUtc = true;
  g_gps.utcFixMs = millis();
}

static void parseTxt(char *fields[], int count)
{
  if (count <= 4)
  {
    return;
  }
  detectModuleFromText(fields[4]);
}

static int splitCsvPreserveEmpty(char *line, char *fields[], int maxFields)
{
  if (!line || !fields || maxFields <= 0)
  {
    return 0;
  }

  int count = 0;
  char *p = line;
  fields[count++] = p;

  while (*p && count < maxFields)
  {
    if (*p == ',')
    {
      *p = '\0';
      fields[count++] = p + 1;
    }
    ++p;
  }

  return count;
}

static void processNmeaLine(char *line)
{
  if (!line || line[0] != '$')
  {
    return;
  }
  g_gps.sawAnySentence = true;
  char rawLine[128];
  strncpy(rawLine, line, sizeof(rawLine) - 1);
  rawLine[sizeof(rawLine) - 1] = '\0';

  char *star = strchr(line, '*');
  if (star)
  {
    *star = '\0';
  }

  char *fields[24] = {0};
  int count = splitCsvPreserveEmpty(line, fields, 24);
  if (count <= 0 || !fields[0])
  {
    return;
  }

  const char *header = fields[0];
  if (strcmp(header, "$PUBX") == 0)
  {
    g_gps.module = GpsModuleKind::UBLOX;
    maybeLogRawSentence("PUBX", rawLine);
    return;
  }
  size_t n = strlen(header);
  if (n < 6)
  {
    return;
  }
  const char *type = header + (n - 3);
  maybeLogRawSentence(type, rawLine);

  if (strcmp(type, "GGA") == 0)
  {
    parseGga(fields, count);
  }
  else if (strcmp(type, "GSA") == 0)
  {
    parseGsa(fields, count);
  }
  else if (strcmp(type, "GSV") == 0)
  {
    parseGsv(fields, count);
  }
  else if (strcmp(type, "RMC") == 0)
  {
    parseRmc(fields, count);
  }
  else if (strcmp(type, "TXT") == 0)
  {
    parseTxt(fields, count);
  }
}

static void pollGpsSerial()
{
  while (g_gpsSerial.available() > 0)
  {
    char c = (char)g_gpsSerial.read();
    if (c == '\r')
    {
      continue;
    }
    if (c == '\n')
    {
      if (g_gpsLineLen > 0)
      {
        g_gpsLine[g_gpsLineLen] = '\0';
        processNmeaLine(g_gpsLine);
        g_gpsLineLen = 0;
      }
      continue;
    }

    if (g_gpsLineLen + 1 < sizeof(g_gpsLine))
    {
      g_gpsLine[g_gpsLineLen++] = c;
    }
    else
    {
      g_gpsLineLen = 0;
    }
  }
}

static void showGpsLogHelp()
{
  Serial.println("Command list:");
  Serial.println("  help   - show command menu");
  Serial.println("  stat   - show current GPS log file info");
  Serial.println("  list   - list files in /gpsdiag");
  Serial.println("  read   - dump current GPS log");
  Serial.println("  clear  - erase current GPS log");
}

static void gpsLogStat()
{
  Serial.printf("SPIFFS: %s\r\n", g_spiffsReady ? "ready" : "not ready");
  Serial.printf("Path: %s\r\n", kGpsLogPath);
  if (!g_spiffsReady || !SPIFFS.exists(kGpsLogPath))
  {
    Serial.println("Current GPS log does not exist");
    return;
  }
  File file = SPIFFS.open(kGpsLogPath, FILE_READ);
  if (!file)
  {
    Serial.println("Unable to open current GPS log");
    return;
  }
  Serial.printf("Size: %u bytes\r\n", (unsigned)file.size());
  file.close();
  Serial.printf("Flash total=%u used=%u free=%u\r\n",
                (unsigned)SPIFFS.totalBytes(),
                (unsigned)SPIFFS.usedBytes(),
                (unsigned)(SPIFFS.totalBytes() - SPIFFS.usedBytes()));
}

static void gpsLogList()
{
  if (!g_spiffsReady)
  {
    Serial.println("SPIFFS not ready");
    return;
  }
  File dir = SPIFFS.open(kGpsLogDir);
  if (!dir || !dir.isDirectory())
  {
    Serial.printf("Unable to open %s\r\n", kGpsLogDir);
    return;
  }
  Serial.printf("Files in %s:\r\n", kGpsLogDir);
  File file = dir.openNextFile();
  while (file)
  {
    Serial.printf("  %s (%u bytes)\r\n", file.name(), (unsigned)file.size());
    file = dir.openNextFile();
  }
}

static void gpsLogRead()
{
  if (!g_spiffsReady || !SPIFFS.exists(kGpsLogPath))
  {
    Serial.println("Current GPS log is not available");
    return;
  }
  File file = SPIFFS.open(kGpsLogPath, FILE_READ);
  if (!file)
  {
    Serial.println("Unable to read current GPS log");
    return;
  }
  Serial.printf("Reading %s:\r\n", kGpsLogPath);
  while (file.available())
  {
    Serial.write(file.read());
  }
  if (file.size() > 0)
  {
    Serial.println();
  }
  file.close();
}

static void gpsLogClear()
{
  if (!g_spiffsReady)
  {
    Serial.println("SPIFFS not ready");
    return;
  }
  File file = SPIFFS.open(kGpsLogPath, FILE_WRITE);
  if (!file)
  {
    Serial.println("Unable to clear current GPS log");
    return;
  }
  file.close();
  Serial.printf("Cleared %s\r\n", kGpsLogPath);
}
// Process a command received on the serial console.
static void processSerialCommand(const char *line)
{
  if (!line || line[0] == '\0')
  {
    return;
  }
  // Echo the command back to the console for clarity and posterity.
  Serial.printf("-->%s\r\n", line);
  if (strcasecmp(line, "help") == 0)
  {
    showGpsLogHelp();
  }
  else if (strcasecmp(line, "stat") == 0)
  {
    gpsLogStat();
  }
  else if (strcasecmp(line, "list") == 0)
  {
    gpsLogList();
  }
  else if (strcasecmp(line, "read") == 0)
  {
    gpsLogRead();
  }
  else if (strcasecmp(line, "clear") == 0)
  {
    gpsLogClear();
  }
  else
  {
    Serial.println("Unknown command (help for list)");
  }
}

static 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';
        processSerialCommand(g_serialLine);
        g_serialLineLen = 0;
      }
      continue;
    }
    if (g_serialLineLen + 1 < sizeof(g_serialLine))
    {
      g_serialLine[g_serialLineLen++] = (char)c;
    }
    else
    {
      g_serialLineLen = 0;
    }
  }
}

static void startGpsUart(uint32_t baud, int rxPin, int txPin)
{
  g_gpsSerial.end();
  delay(20);
  g_gpsSerial.setRxBufferSize(1024);
  g_gpsSerial.begin(baud, SERIAL_8N1, rxPin, txPin);
  g_gpsBaud = baud;
  g_gpsRxPin = rxPin;
  g_gpsTxPin = txPin;
}

static bool collectGpsTraffic(uint32_t windowMs, bool updateSd)
{
  uint32_t start = millis();
  bool sawBytes = false;
  while ((uint32_t)(millis() - start) < windowMs)
  {
    if (g_gpsSerial.available() > 0)
    {
      sawBytes = true;
    }
    pollGpsSerial();
    if (updateSd)
    {
      g_sd.update();
    }
    delay(2);
  }
  return sawBytes || g_gps.sawAnySentence;
}

static bool probeGpsAtBaud(uint32_t baud, int rxPin, int txPin)
{
  startGpsUart(baud, rxPin, txPin);
  logf("Probing GPS at %lu baud on RX=%d TX=%d...", (unsigned long)baud, rxPin, txPin);
  if (collectGpsTraffic(700, true))
  {
    return true;
  }

  // Common commands for MTK/L76K and related chipsets.
  g_gpsSerial.write("$PCAS06,0*1B\r\n");
  g_gpsSerial.write("$PMTK605*31\r\n");
  g_gpsSerial.write("$PQTMVERNO*58\r\n");
  g_gpsSerial.write("$PUBX,00*33\r\n");
  g_gpsSerial.write("$PMTK353,1,1,1,1,1*2A\r\n");
  g_gpsSerial.write("$PMTK314,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0*29\r\n");

  return collectGpsTraffic(1200, true);
}

static void initialGpsProbe()
{
  const uint32_t bauds[] = {GPS_BAUD, 115200, 38400, 57600, 19200};
  int pinCandidates[2][2] = {
      {GPS_RX_PIN, GPS_TX_PIN},
      {34, 12}, // Legacy T-Beam UBLOX mapping.
  };
  size_t pinCount = 1;
  if (kExpectedGpsModule == GpsModuleKind::UBLOX &&
      !(GPS_RX_PIN == 34 && GPS_TX_PIN == 12))
  {
    pinCount = 2;
  }

  for (size_t p = 0; p < pinCount; ++p)
  {
    int rxPin = pinCandidates[p][0];
    int txPin = pinCandidates[p][1];
    for (size_t i = 0; i < sizeof(bauds) / sizeof(bauds[0]); ++i)
    {
      if (probeGpsAtBaud(bauds[i], rxPin, txPin))
      {
        logf("GPS traffic detected at %lu baud on RX=%d TX=%d",
             (unsigned long)g_gpsBaud, g_gpsRxPin, g_gpsTxPin);
        return;
      }
    }
  }
  logf("No GPS traffic detected during startup probe");
}

static uint32_t startupProbeWindowMs()
{
  return (kExpectedGpsModule == GpsModuleKind::UBLOX) ? 45000U : 20000U;
}

static GpsModuleKind activeGpsModule()
{
  if (g_gps.module != GpsModuleKind::UNKNOWN)
  {
    return g_gps.module;
  }
  return kExpectedGpsModule;
}

static uint8_t bestSatelliteCount()
{
  uint32_t now = millis();

  if ((uint32_t)(now - g_gps.satsUsedWindowMs) > kSatelliteWindowMs)
  {
    g_gps.satsUsedWindowMax = g_gps.satsUsed;
  }
  if ((uint32_t)(now - g_gps.satsInViewWindowMs) > kSatelliteWindowMs)
  {
    g_gps.satsInViewWindowMax = g_gps.satsInView;
  }

  uint8_t used = (g_gps.satsUsedWindowMax > g_gps.satsUsed) ? g_gps.satsUsedWindowMax : g_gps.satsUsed;
  uint8_t inView = (g_gps.satsInViewWindowMax > g_gps.satsInView) ? g_gps.satsInViewWindowMax : g_gps.satsInView;
  return (used > inView) ? used : inView;
}

static uint8_t displayedSatsUsed()
{
  if ((uint32_t)(millis() - g_gps.satsUsedWindowMs) > kFixFreshMs)
  {
    return 0;
  }
  return g_gps.satsUsed;
}

static uint8_t displayedSatsInView()
{
  if ((uint32_t)(millis() - g_gps.satsInViewWindowMs) > kFixFreshMs)
  {
    return 0;
  }
  return g_gps.satsInView;
}

static bool displayHasFreshUtc()
{
  return g_gps.hasValidUtc && (uint32_t)(millis() - g_gps.utcFixMs) <= kFixFreshMs;
}

static bool isUnsupportedGpsMode()
{
  GpsModuleKind active = activeGpsModule();
  if (kExpectedGpsModule == GpsModuleKind::UNKNOWN || active == GpsModuleKind::UNKNOWN)
  {
    return false;
  }
  return active != kExpectedGpsModule;
}

static void reportStatusToSerial()
{
  uint8_t satsUsed = displayedSatsUsed();
  uint8_t satsView = displayedSatsInView();
  logf("GPS module active: %s", gpsModuleToString(activeGpsModule()).c_str());
  logf("GPS module expected: %s", gpsModuleToString(kExpectedGpsModule).c_str());
  logf("GPS sentences seen: %s", g_gps.sawAnySentence ? "yes" : "no");
  logf("GPS satellites: used=%u in-view=%u recent-best=%u",
       (unsigned)satsUsed,
       (unsigned)satsView,
       (unsigned)bestSatelliteCount());
  logf("GPS can provide time from satellites: %s", displayHasFreshUtc() ? "YES" : "NO");
  appendGpsSnapshot("status");
}

static void maybeAnnounceGpsTransitions()
{
  if (isUnsupportedGpsMode())
  {
    return;
  }

  uint8_t satsUsed = displayedSatsUsed();
  uint8_t satsView = displayedSatsInView();
  uint8_t sats = satsUsed > 0 ? satsUsed : satsView;
  bool hasSats = sats > 0;
  bool hasUtc = displayHasFreshUtc();

  if (!g_satellitesAcquiredAnnounced && !g_prevHadSatellites && hasSats)
  {
    String rtc = formatRtcNow();
    char l2[28];
    char l3[28];
    snprintf(l2, sizeof(l2), "Used: %u", (unsigned)satsUsed);
    snprintf(l3, sizeof(l3), "View: %u", (unsigned)satsView);
    oledShowLines("GPS acquired", l2, l3, "Waiting for UTC...", rtc.c_str());
    logf("Transition: satellites acquired (%u)", (unsigned)sats);
    appendGpsSnapshot("satellites_acquired");
    g_satellitesAcquiredAnnounced = true;
  }

  if (!g_timeAcquiredAnnounced && !g_prevHadValidUtc && hasUtc)
  {
    char line2[40];
    char line3[28];
    char line4[28];
    snprintf(line2,
             sizeof(line2),
             "%04u-%02u-%02u %02u:%02u:%02u",
             (unsigned)g_gps.utcYear,
             (unsigned)g_gps.utcMonth,
             (unsigned)g_gps.utcDay,
             (unsigned)g_gps.utcHour,
             (unsigned)g_gps.utcMinute,
             (unsigned)g_gps.utcSecond);
    snprintf(line3, sizeof(line3), "Used:%u View:%u", (unsigned)satsUsed, (unsigned)satsView);
    snprintf(line4, sizeof(line4), "Source: %s", gpsModuleToString(activeGpsModule()).c_str());
    oledShowLines("GPS UTC acquired", line2, line3, line4);
    logf("Transition: GPS UTC acquired: %s", line2);
    appendGpsSnapshot("utc_acquired");
    g_timeAcquiredAnnounced = true;
  }

  g_prevHadSatellites = hasSats;
  g_prevHadValidUtc = hasUtc;
}

static void drawMinuteStatus()
{
  if (isUnsupportedGpsMode())
  {
    oledShowLines("GPS module mismatch",
                  ("Expected: " + gpsModuleToString(kExpectedGpsModule)).c_str(),
                  ("Detected: " + gpsModuleToString(activeGpsModule())).c_str(),
                  "Check node profile");
    logf("GPS module mismatch: expected=%s detected=%s",
         gpsModuleToString(kExpectedGpsModule).c_str(),
         gpsModuleToString(activeGpsModule()).c_str());
    return;
  }

  uint8_t satsUsed = displayedSatsUsed();
  uint8_t satsView = displayedSatsInView();
  if (displayHasFreshUtc())
  {
    char line2[40];
    char line3[28];
    char line4[28];
    snprintf(line2,
             sizeof(line2),
             "%04u-%02u-%02u %02u:%02u:%02u",
             (unsigned)g_gps.utcYear,
             (unsigned)g_gps.utcMonth,
             (unsigned)g_gps.utcDay,
             (unsigned)g_gps.utcHour,
             (unsigned)g_gps.utcMinute,
             (unsigned)g_gps.utcSecond);
    snprintf(line3, sizeof(line3), "Used:%u View:%u", (unsigned)satsUsed, (unsigned)satsView);
    snprintf(line4, sizeof(line4), "Source: %s", gpsModuleToString(activeGpsModule()).c_str());
    oledShowLines("GPS time (UTC)", line2, line3, line4);
    logf("GPS time (UTC): %s  used=%u view=%u", line2, (unsigned)satsUsed, (unsigned)satsView);
    return;
  }

  String rtc = formatRtcNow();
  if (satsUsed > 0 || satsView > 0)
  {
    char line2[28];
    char line3[28];
    snprintf(line2, sizeof(line2), "Used: %u", (unsigned)satsUsed);
    snprintf(line3, sizeof(line3), "View: %u", (unsigned)satsView);
    oledShowLines("GPS signal detected", line2, line3, "GPS UTC not ready", rtc.c_str());
    logf("Satellites detected (used=%u view=%u) but GPS UTC not ready. %s",
         (unsigned)satsUsed,
         (unsigned)satsView,
         rtc.c_str());
  }
  else
  {
    oledShowLines("Unable to acquire", "satellites", "Take me outside so I", "can see satellites", rtc.c_str());
    logf("Unable to acquire satellites. %s", rtc.c_str());
  }
}

static bool shouldRefreshDisplay()
{
  uint32_t now = millis();
  if (g_lastDisplayRefreshMs != 0 && (uint32_t)(now - g_lastDisplayRefreshMs) < kDisplayRefreshMinMs)
  {
    return false;
  }
  uint8_t satsUsed = displayedSatsUsed();
  uint8_t satsView = displayedSatsInView();
  bool hasUtc = displayHasFreshUtc();
  if (!g_haveLastDrawnState)
  {
    return true;
  }
  return satsUsed != g_lastDrawnSatsUsed ||
         satsView != g_lastDrawnSatsView ||
         hasUtc != g_lastDrawnValidUtc;
}

static void markDisplayStateDrawn()
{
  g_lastDrawnSatsUsed = displayedSatsUsed();
  g_lastDrawnSatsView = displayedSatsInView();
  g_lastDrawnValidUtc = displayHasFreshUtc();
  g_haveLastDrawnState = true;
  g_lastDisplayRefreshMs = millis();
}

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

  Serial.println("\r\n==================================================");
  Serial.println("Exercise 09: GPS Time");
  Serial.println("==================================================");
  Serial.printf("Build: %s %s\r\n", kBuildDate, kBuildTime);

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

  // SPI Flash File System ("SPIFFS") is used for logging GPS diagnostics,
  // which may be helpful for analyzing GPS behavior in different
  // environments and over time.
  g_spiffsReady = SPIFFS.begin(true);
  if (!g_spiffsReady)
  {
    logf("SPIFFS mount failed");
  }
  else if (!ensureGpsLogDirectory())
  {
    logf("GPS log directory create/open failed");
  }
  else
  {
    File file = SPIFFS.open(kGpsLogPath, FILE_WRITE);
    if (file)
    {
      file.println("Exercise 09 GPS diagnostics");
      file.printf("Build: %s %s\r\n", kBuildDate, kBuildTime);
      file.close();
    }
    else
    {
      logf("GPS log file open failed: %s", kGpsLogPath);
    }
  }

  // Initialize the OLED display and show a boot message with build info.
  Wire.begin(OLED_SDA, OLED_SCL);
  g_oled.setI2CAddress(OLED_ADDR << 1);
  g_oled.begin();
  String buildStamp = buildStampShort();
  oledShowLines("09_GPS_Time", buildStamp.c_str(), "Booting...");
  // The GPS startup probe may take a while,
  // especially for a cold start. Log some
  SdWatcherConfig sdCfg{};
  if (!g_sd.begin(sdCfg, nullptr))
  {
    logf("SD startup manager begin() failed");
  }

#ifdef GPS_WAKEUP_PIN
  // Keep wake pin neutral; avoid forcing an unknown standby state.
  pinMode(GPS_WAKEUP_PIN, INPUT);
#endif

#ifdef GPS_1PPS_PIN
  pinMode(GPS_1PPS_PIN, INPUT);
#endif

  startGpsUart(GPS_BAUD, GPS_RX_PIN, GPS_TX_PIN);
  logf("GPS UART started: RX=%d TX=%d baud=%lu", g_gpsRxPin, g_gpsTxPin, (unsigned long)g_gpsBaud);
  appendGpsSnapshot("uart_started");
  initialGpsProbe();
  appendGpsSnapshot("startup_probe_complete");
  maybeConfigureUblox();

  oledShowLines("GPS startup probe", "Checking satellites", "and GPS time...");

  uint32_t probeWindowMs = startupProbeWindowMs();
  if (kExpectedGpsModule == GpsModuleKind::UBLOX)
  {
    logf("UBLOX startup window: %lu ms (allowing cold start acquisition)",
         (unsigned long)probeWindowMs);
  }

  uint32_t probeStart = millis();
  uint32_t lastProbeUiMs = 0;
  while ((uint32_t)(millis() - probeStart) < probeWindowMs)
  {
    pollSerialConsole();
    pollGpsSerial();
    g_sd.update();

    uint32_t now = millis();
    if ((uint32_t)(now - g_lastGpsDiagnosticLogMs) >= kGpsDiagnosticLogMs)
    {
      g_lastGpsDiagnosticLogMs = now;
      appendGpsSnapshot("startup_wait");
    }
    if ((uint32_t)(now - lastProbeUiMs) >= 1000)
    {
      lastProbeUiMs = now;
      char l3[28];
      char l4[30];
      char l5[24];
      snprintf(l3, sizeof(l3), "Used:%u View:%u", (unsigned)displayedSatsUsed(), (unsigned)displayedSatsInView());
      snprintf(l4, sizeof(l4), "Module: %s", gpsModuleToString(activeGpsModule()).c_str());
      snprintf(l5, sizeof(l5), "NMEA:%s %d/%d", g_gps.sawAnySentence ? "yes" : "no", g_gpsRxPin, g_gpsTxPin);
      oledShowLines("GPS startup probe", "Checking satellites", l3, l4, l5);
    }
    delay(10);
  }

  reportStatusToSerial();
  g_prevHadSatellites = (displayedSatsUsed() > 0 || displayedSatsInView() > 0);
  g_prevHadValidUtc = displayHasFreshUtc();
  drawMinuteStatus();
  markDisplayStateDrawn();
  g_lastMinuteReportMs = millis();
  g_lastGpsDiagnosticLogMs = millis();
}

void loop()
{
  pollSerialConsole();
  pollGpsSerial();
  g_sd.update();
  maybeAnnounceGpsTransitions();

  uint32_t now = millis();
  if (shouldRefreshDisplay())
  {
    drawMinuteStatus();
    markDisplayStateDrawn();
  }
  if ((uint32_t)(now - g_lastGpsDiagnosticLogMs) >= kGpsDiagnosticLogMs)
  {
    g_lastGpsDiagnosticLogMs = now;
    appendGpsSnapshot("periodic");
  }
  if ((uint32_t)(now - g_lastMinuteReportMs) >= kMinuteMs)
  {
    g_lastMinuteReportMs = now;
    drawMinuteStatus();
    markDisplayStateDrawn();
    appendGpsSnapshot("minute_status");
  }
}
