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

#include <Arduino.h>
#include <Wire.h>
#include <SD.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

#ifndef FILE_APPEND
#define FILE_APPEND FILE_WRITE
#endif

#ifndef FW_BUILD_EPOCH
#define FW_BUILD_EPOCH 0
#endif

#ifndef FW_BUILD_UTC
#define FW_BUILD_UTC "unknown"
#endif

static const uint32_t kSerialDelayMs = 5000;
static const uint32_t kLoopMsDiscipline = 60000;
static const uint32_t kNoTimeDelayMs = 12000;
static const uint32_t kGpsStartupProbeMs = 20000;
static const uint32_t kPpsWaitTimeoutMs = 1500;
static const bool kTemporaryAllowSdLogWithoutGps = true;
static const uint32_t kGpsDisciplineGraceMs = 12000;

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_nextDisciplineMs = 0;
static uint32_t g_bootMs = 0;
static bool g_gpsPathReady = false;

static char g_gpsLine[128];
static size_t g_gpsLineLen = 0;

static volatile uint32_t g_ppsEdgeCount = 0;

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

struct GpsState {
  bool sawAnySentence = false;
  bool hasValidUtc = false;
  bool hasValidPosition = false;
  bool hasValidAltitude = false;
  uint8_t satsUsed = 0;
  uint8_t satsInView = 0;
  float hdop = -1.0f;
  float altitudeM = 0.0f;
  double latitudeDeg = 0.0;
  double longitudeDeg = 0.0;
  DateTime utc{};
  uint32_t lastUtcMs = 0;
};

static GpsState g_gps;

static void logf(const char* fmt, ...) {
  char msg[240];
  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 toBcd(uint8_t v) {
  return (uint8_t)(((v / 10U) << 4U) | (v % 10U));
}

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

static bool rtcRead(DateTime& 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;
}

static bool rtcWrite(const DateTime& dt) {
  Wire1.beginTransmission(RTC_I2C_ADDR);
  Wire1.write(0x02);
  Wire1.write(toBcd(dt.second & 0x7FU));
  Wire1.write(toBcd(dt.minute));
  Wire1.write(toBcd(dt.hour));
  Wire1.write(toBcd(dt.day));
  Wire1.write(0x00);

  uint8_t monthReg = toBcd(dt.month);
  if (dt.year < 2000U) {
    monthReg |= 0x80U;
  }
  Wire1.write(monthReg);
  Wire1.write(toBcd((uint8_t)(dt.year % 100U)));

  return Wire1.endTransmission() == 0;
}

static bool isLeapYear(uint16_t y) {
  return ((y % 4U) == 0U && (y % 100U) != 0U) || ((y % 400U) == 0U);
}

static uint8_t daysInMonth(uint16_t year, uint8_t month) {
  static const uint8_t kDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
  if (month == 2) {
    return (uint8_t)(isLeapYear(year) ? 29 : 28);
  }
  if (month >= 1 && month <= 12) {
    return kDays[month - 1];
  }
  return 30;
}

static bool isValidDateTime(const DateTime& dt) {
  if (dt.year < 2000U || dt.year > 2099U) return false;
  if (dt.month < 1 || dt.month > 12) return false;
  if (dt.day < 1 || dt.day > daysInMonth(dt.year, dt.month)) return false;
  if (dt.hour > 23 || dt.minute > 59 || dt.second > 59) return false;
  return true;
}

static int64_t daysFromCivil(int y, unsigned m, unsigned d) {
  y -= (m <= 2);
  const int era = (y >= 0 ? y : y - 399) / 400;
  const unsigned yoe = (unsigned)(y - era * 400);
  const unsigned doy = (153 * (m + (m > 2 ? (unsigned)-3 : 9)) + 2) / 5 + d - 1;
  const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
  return era * 146097 + (int)doe - 719468;
}

static int64_t toEpochSeconds(const DateTime& dt) {
  int64_t days = daysFromCivil((int)dt.year, dt.month, dt.day);
  return days * 86400LL + (int64_t)dt.hour * 3600LL + (int64_t)dt.minute * 60LL + (int64_t)dt.second;
}

static bool fromEpochSeconds(int64_t sec, DateTime& out) {
  if (sec < 0) {
    return false;
  }

  int64_t days = sec / 86400LL;
  int64_t rem = sec % 86400LL;
  if (rem < 0) {
    rem += 86400LL;
    days -= 1;
  }

  out.hour = (uint8_t)(rem / 3600LL);
  rem %= 3600LL;
  out.minute = (uint8_t)(rem / 60LL);
  out.second = (uint8_t)(rem % 60LL);

  days += 719468;
  const int era = (days >= 0 ? days : days - 146096) / 146097;
  const unsigned doe = (unsigned)(days - era * 146097);
  const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
  int y = (int)yoe + era * 400;
  const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  const unsigned mp = (5 * doy + 2) / 153;
  const unsigned d = doy - (153 * mp + 2) / 5 + 1;
  const unsigned m = mp + (mp < 10 ? 3 : (unsigned)-9);
  y += (m <= 2);

  out.year = (uint16_t)y;
  out.month = (uint8_t)m;
  out.day = (uint8_t)d;
  return isValidDateTime(out);
}

static void addOneSecond(DateTime& dt) {
  int64_t t = toEpochSeconds(dt);
  DateTime out{};
  if (fromEpochSeconds(t + 1, out)) {
    dt = out;
  }
}

static void formatUtcCompact(const DateTime& dt, char* out, size_t outLen) {
  snprintf(out,
           outLen,
           "%04u%02u%02u_%02u%02u%02u_z",
           (unsigned)dt.year,
           (unsigned)dt.month,
           (unsigned)dt.day,
           (unsigned)dt.hour,
           (unsigned)dt.minute,
           (unsigned)dt.second);
}

static void formatUtcHuman(const DateTime& dt, char* out, size_t outLen) {
  snprintf(out,
           outLen,
           "%04u-%02u-%02u %02u:%02u:%02u UTC",
           (unsigned)dt.year,
           (unsigned)dt.month,
           (unsigned)dt.day,
           (unsigned)dt.hour,
           (unsigned)dt.minute,
           (unsigned)dt.second);
}

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 bool parseNmeaCoordToDecimal(const char* raw, const char* hemi, bool isLat, double& outDeg) {
  if (!raw || !hemi || raw[0] == '\0' || hemi[0] == '\0') {
    return false;
  }

  // NMEA uses ddmm.mmmm (lat) and dddmm.mmmm (lon), with leading zeros preserved.
  // Parse from string slices so longitudes like 071xx.xxxx do not collapse to 7xx.xxxx.
  int degDigits = isLat ? 2 : 3;
  size_t n = strlen(raw);
  if (n <= (size_t)degDigits + 2) {
    return false;
  }

  for (int i = 0; i < degDigits; ++i) {
    if (!isdigit((unsigned char)raw[i])) {
      return false;
    }
  }

  char degBuf[4] = {0};
  memcpy(degBuf, raw, degDigits);
  int deg = atoi(degBuf);

  const char* minPtr = raw + degDigits;
  double minutes = atof(minPtr);
  if (minutes < 0.0 || minutes >= 60.0) {
    return false;
  }

  double dec = (double)deg + (minutes / 60.0);
  char h = (char)toupper((unsigned char)hemi[0]);
  if (h == 'S' || h == 'W') {
    dec = -dec;
  } else if (h != 'N' && h != 'E') {
    return false;
  }

  outDeg = dec;
  return true;
}

static void parseGga(char* fields[], int count) {
  if (count <= 7) {
    return;
  }
  const char* latRaw = (count > 2) ? fields[2] : nullptr;
  const char* latHem = (count > 3) ? fields[3] : nullptr;
  const char* lonRaw = (count > 4) ? fields[4] : nullptr;
  const char* lonHem = (count > 5) ? fields[5] : nullptr;
  int sats = atoi(fields[7]);
  if (sats >= 0 && sats <= 255) {
    g_gps.satsUsed = (uint8_t)sats;
  }
  if (count > 8 && fields[8] && fields[8][0] != '\0') {
    g_gps.hdop = (float)atof(fields[8]);
  }
  if (count > 9 && fields[9] && fields[9][0] != '\0') {
    g_gps.altitudeM = (float)atof(fields[9]);
    g_gps.hasValidAltitude = true;
  }

  // Position fallback from GGA so we still log coordinates if RMC position is missing.
  double lat = 0.0;
  double lon = 0.0;
  if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) &&
      parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) {
    g_gps.latitudeDeg = lat;
    g_gps.longitudeDeg = lon;
    g_gps.hasValidPosition = true;
  }
}

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;
  }
}

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

  const char* utc = fields[1];
  const char* status = fields[2];
  const char* latRaw = fields[3];
  const char* latHem = fields[4];
  const char* lonRaw = fields[5];
  const char* lonHem = fields[6];
  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.utc.hour = hh;
  g_gps.utc.minute = mm;
  g_gps.utc.second = ss;
  g_gps.utc.day = dd;
  g_gps.utc.month = mo;
  g_gps.utc.year = (uint16_t)(2000U + yy);
  g_gps.hasValidUtc = true;
  g_gps.lastUtcMs = millis();

  double lat = 0.0;
  double lon = 0.0;
  if (parseNmeaCoordToDecimal(latRaw, latHem, true, lat) &&
      parseNmeaCoordToDecimal(lonRaw, lonHem, false, lon)) {
    g_gps.latitudeDeg = lat;
    g_gps.longitudeDeg = lon;
    g_gps.hasValidPosition = true;
  }
}

static void processNmeaLine(char* line) {
  if (!line || line[0] != '$') {
    return;
  }

  g_gps.sawAnySentence = true;

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

  char* fields[24] = {0};
  int count = 0;
  char* saveptr = nullptr;
  char* tok = strtok_r(line, ",", &saveptr);
  while (tok && count < 24) {
    fields[count++] = tok;
    tok = strtok_r(nullptr, ",", &saveptr);
  }

  if (count <= 0 || !fields[0]) {
    return;
  }

  const char* header = fields[0];
  size_t n = strlen(header);
  if (n < 6) {
    return;
  }

  const char* type = header + (n - 3);
  if (strcmp(type, "GGA") == 0) {
    parseGga(fields, count);
  } else if (strcmp(type, "GSV") == 0) {
    parseGsv(fields, count);
  } else if (strcmp(type, "RMC") == 0) {
    parseRmc(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 bool collectGpsTraffic(uint32_t windowMs) {
  uint32_t start = millis();
  bool sawBytes = false;
  while ((uint32_t)(millis() - start) < windowMs) {
    if (g_gpsSerial.available() > 0) {
      sawBytes = true;
    }
    pollGpsSerial();
    g_sd.update();
    delay(2);
  }
  return sawBytes || g_gps.sawAnySentence;
}

static void initialGpsProbe() {
  logf("GPS startup probe at %u baud", (unsigned)GPS_BAUD);
  (void)collectGpsTraffic(kGpsStartupProbeMs);
  logf("GPS probe complete: nmea=%s sats_used=%u sats_view=%u utc=%s",
       g_gps.sawAnySentence ? "yes" : "no",
       (unsigned)g_gps.satsUsed,
       (unsigned)g_gps.satsInView,
       g_gps.hasValidUtc ? "yes" : "no");
}

static IRAM_ATTR void onPpsEdge() {
  g_ppsEdgeCount++;
}

static uint8_t bestSatelliteCount() {
  return (g_gps.satsUsed > g_gps.satsInView) ? g_gps.satsUsed : g_gps.satsInView;
}

static bool ensureGpsLogPathReady() {
  if (!g_sd.isMounted()) {
    g_gpsPathReady = false;
    return false;
  }

  if (g_gpsPathReady) {
    return true;
  }

  if (!g_sd.ensureDirRecursive("/gps")) {
    logf("Could not create /gps directory");
    return false;
  }

  // Touch the log file so a clean SD card is prepared before first discipline event.
  File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND);
  if (!f) {
    logf("Could not open /gps/discipline_rtc.log for append");
    return false;
  }
  f.close();

  g_gpsPathReady = true;
  return true;
}

static bool appendDisciplineLog(const DateTime& gpsUtc,
                                bool havePriorRtc,
                                int64_t rtcMinusGpsSeconds,
                                uint8_t sats,
                                uint32_t utcAgeMs,
                                uint32_t ppsEdges,
                                char* outTs,
                                size_t outTsLen) {
  if (!ensureGpsLogPathReady()) {
    logf("SD not mounted, skipping append to gps/discipline_rtc.log");
    return false;
  }

  File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND);
  if (!f) {
    logf("Could not open /gps/discipline_rtc.log for append");
    return false;
  }

  char ts[32];
  formatUtcCompact(gpsUtc, ts, sizeof(ts));
  if (outTs && outTsLen > 0) {
    snprintf(outTs, outTsLen, "%s", ts);
  }

  char drift[40];
  if (havePriorRtc) {
    snprintf(drift, sizeof(drift), "%+lld s", (long long)rtcMinusGpsSeconds);
  } else {
    snprintf(drift, sizeof(drift), "RTC_unset");
  }

  char pos[64];
  if (g_gps.hasValidPosition) {
    snprintf(pos, sizeof(pos), "lat=%.6f lon=%.6f", g_gps.latitudeDeg, g_gps.longitudeDeg);
  } else {
    snprintf(pos, sizeof(pos), "lat=NA lon=NA");
  }

  char hdop[16];
  if (g_gps.hdop > 0.0f) {
    snprintf(hdop, sizeof(hdop), "%.1f", g_gps.hdop);
  } else {
    snprintf(hdop, sizeof(hdop), "NA");
  }

  char alt[16];
  if (g_gps.hasValidAltitude) {
    snprintf(alt, sizeof(alt), "%.1f", g_gps.altitudeM);
  } else {
    snprintf(alt, sizeof(alt), "NA");
  }

  char line[320];
  snprintf(line,
           sizeof(line),
           "%s\t set RTC to GPS using 1PPS pulse-per-second discipline\t"
           "rtc-gps drift=%s; sats=%u; %s; alt_m=%s; hdop=%s; utc_age_ms=%lu; pps_edges=%lu; "
           "fw_epoch=%lu; fw_build_utc=%s",
           ts,
           drift,
           (unsigned)sats,
           pos,
           alt,
           hdop,
           (unsigned long)utcAgeMs,
           (unsigned long)ppsEdges,
           (unsigned long)FW_BUILD_EPOCH,
           FW_BUILD_UTC);

  size_t wrote = f.println(line);
  f.close();
  if (wrote == 0) {
    logf("Append write failed: /gps/discipline_rtc.log");
    return false;
  }
  return true;
}

static bool gpsUtcIsFresh() {
  if (!g_gps.hasValidUtc) {
    return false;
  }
  return (uint32_t)(millis() - g_gps.lastUtcMs) <= 2000;
}

static bool waitForNextPps(uint32_t timeoutMs) {
  uint32_t startCount = g_ppsEdgeCount;
  uint32_t startMs = millis();
  while ((uint32_t)(millis() - startMs) < timeoutMs) {
    pollGpsSerial();
    g_sd.update();
    if (g_ppsEdgeCount != startCount) {
      return true;
    }
    delay(2);
  }
  return false;
}

static void waitWithUpdates(uint32_t delayMs) {
  uint32_t start = millis();
  while ((uint32_t)(millis() - start) < delayMs) {
    pollGpsSerial();
    g_sd.update();
    delay(10);
  }
}

static bool appendFallbackLogNoGps(const char* reason) {
  logf("Attempting write to SD Card");
  if (!ensureGpsLogPathReady()) {
    logf("SD not mounted, skipping fallback append to gps/discipline_rtc.log");
    return false;
  }

  File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND);
  if (!f) {
    logf("Could not open /gps/discipline_rtc.log for fallback append");
    return false;
  }

  char line[256];
  snprintf(line,
           sizeof(line),
           "NO_GPS_UTC\t millis=%lu\t reason=%s\t this is a test write\t sd_probe_only=1\t fw_epoch=%lu; fw_build_utc=%s",
           (unsigned long)millis(),
           reason ? reason : "unknown",
           (unsigned long)FW_BUILD_EPOCH,
           FW_BUILD_UTC);
  size_t wrote = f.println(line);
  f.close();
  if (wrote == 0) {
    logf("Fallback append failed: /gps/discipline_rtc.log");
    return false;
  }
  logf("Write to SD Card successful");
  return true;
}

static void showNoTimeAndDelay() {
  uint8_t sats = bestSatelliteCount();
  char l3[24];
  snprintf(l3, sizeof(l3), "Satellites: %u", (unsigned)sats);
  oledShowLines("GPS time unavailable", "RTC NOT disciplined", l3, "Retry in 30 seconds");
  logf("GPS UTC unavailable. satellites=%u. Waiting 30 seconds.", (unsigned)sats);
  waitWithUpdates(kNoTimeDelayMs);
}

static bool disciplineRtcToGps() {
  if (!gpsUtcIsFresh()) {
    if (kTemporaryAllowSdLogWithoutGps &&
        (uint32_t)(millis() - g_bootMs) >= kGpsDisciplineGraceMs) {
      const bool ok = appendFallbackLogNoGps("gps_utc_unavailable_after_grace");
      oledShowLines("GPS time unavailable", "SD fallback log only", ok ? "Fallback write ok" : "Fallback write fail");
      logf("Temporary bypass: GPS UTC unavailable after grace; fallback log %s", ok ? "ok" : "failed");
      waitWithUpdates(kNoTimeDelayMs);
      return false;
    }
    showNoTimeAndDelay();
    return false;
  }

  DateTime priorRtc{};
  bool lowV = false;
  bool havePriorRtc = rtcRead(priorRtc, lowV);
  if (havePriorRtc && (lowV || !isValidDateTime(priorRtc))) {
    havePriorRtc = false;
  }

  DateTime gpsSnap = g_gps.utc;
  if (!waitForNextPps(kPpsWaitTimeoutMs)) {
    if (kTemporaryAllowSdLogWithoutGps &&
        (uint32_t)(millis() - g_bootMs) >= kGpsDisciplineGraceMs) {
      const bool ok = appendFallbackLogNoGps("pps_missing_after_grace");
      oledShowLines("GPS 1PPS missing", "SD fallback log only", ok ? "Fallback write ok" : "Fallback write fail");
      logf("Temporary bypass: 1PPS missing after grace; fallback log %s", ok ? "ok" : "failed");
      waitWithUpdates(kNoTimeDelayMs);
      return false;
    }
    oledShowLines("GPS 1PPS missing", "RTC NOT disciplined", "Retry in 30 seconds");
    logf("No 1PPS edge observed within timeout. Waiting 30 seconds.");
    waitWithUpdates(kNoTimeDelayMs);
    return false;
  }

  DateTime target = gpsSnap;
  addOneSecond(target);

  if (!rtcWrite(target)) {
    oledShowLines("RTC write failed", "Could not set from GPS");
    logf("RTC write failed");
    return false;
  }

  int64_t driftSec = 0;
  if (havePriorRtc) {
    driftSec = toEpochSeconds(priorRtc) - toEpochSeconds(target);
  }

  uint8_t sats = bestSatelliteCount();
  uint32_t utcAgeMs = (uint32_t)(millis() - g_gps.lastUtcMs);
  uint32_t ppsEdges = g_ppsEdgeCount;
  char tsCompact[32];
  bool logOk = appendDisciplineLog(target,
                                   havePriorRtc,
                                   driftSec,
                                   sats,
                                   utcAgeMs,
                                   ppsEdges,
                                   tsCompact,
                                   sizeof(tsCompact));

  char utcLine[36];
  char driftLine[36];
  char logLine[36];
  formatUtcHuman(target, utcLine, sizeof(utcLine));
  if (havePriorRtc) {
    snprintf(driftLine, sizeof(driftLine), "rtc-gps drift: %+lld s", (long long)driftSec);
  } else {
    snprintf(driftLine, sizeof(driftLine), "rtc-gps drift: RTC_unset");
  }
  snprintf(logLine, sizeof(logLine), "Log:%s sats:%u", logOk ? "ok" : "fail", (unsigned)sats);

  oledShowLines("RTC disciplined to GPS", utcLine, driftLine, logLine, tsCompact);

  logf("RTC disciplined to GPS with 1PPS. %s drift=%+llds lowV=%s",
       utcLine,
       (long long)driftSec,
       lowV ? "yes" : "no");
  return true;
}

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

  Serial.println("\r\n==================================================");
  Serial.println("Exercise 11: Set RTC to GPS with 1PPS discipline");
  Serial.println("==================================================");

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

  Wire.begin(OLED_SDA, OLED_SCL);
  g_oled.setI2CAddress(OLED_ADDR << 1);
  g_oled.begin();
  oledShowLines("Exercise 11", "RTC <- GPS (1PPS)", "Booting...");

  SdWatcherConfig sdCfg{};
  if (!g_sd.begin(sdCfg, nullptr)) {
    logf("SD startup manager begin() failed");
  }
  (void)ensureGpsLogPathReady();

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

  g_gpsSerial.setRxBufferSize(1024);
  g_gpsSerial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
  logf("GPS UART started: RX=%d TX=%d baud=%u", GPS_RX_PIN, GPS_TX_PIN, (unsigned)GPS_BAUD);

  oledShowLines("GPS startup probe", "Checking UTC + 1PPS");
  initialGpsProbe();

  g_nextDisciplineMs = millis();
}

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

  if (g_sd.consumeMountedEvent()) {
    g_gpsPathReady = false;
    g_sd.printCardInfo();
    (void)ensureGpsLogPathReady();
  }
  if (g_sd.consumeRemovedEvent()) {
    g_gpsPathReady = false;
  }

  uint32_t now = millis();
  if ((int32_t)(now - g_nextDisciplineMs) >= 0) {
    bool ok = disciplineRtcToGps();
    g_nextDisciplineMs = now + (ok ? kLoopMsDiscipline : kNoTimeDelayMs);
  }

  delay(5);
}
