// 20260219 ChatGPT
// Exercise 12_FiveTalk

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <U8g2lib.h>
#include <RadioLib.h>

#include <cctype>
#include <cstring>
#include <stdarg.h>

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

#ifdef SX1262
#undef SX1262
#endif

#ifndef NODE_LABEL
#define NODE_LABEL "UNNAMED"
#endif

#ifndef NODE_SHORT
#define NODE_SHORT "?"
#endif

#ifndef NODE_SLOT_COUNT
#define NODE_SLOT_COUNT 7
#endif

#ifndef NODE_SLOT_INDEX
#define NODE_SLOT_INDEX 0
#endif

#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

#if (NODE_SLOT_INDEX < 0) || (NODE_SLOT_INDEX >= NODE_SLOT_COUNT)
#error "NODE_SLOT_INDEX must be 0..NODE_SLOT_COUNT-1"
#endif

static const uint32_t kSerialDelayMs = 1000;
static const uint32_t kDisciplineMaxAgeSec = 24UL * 60UL * 60UL;
static const uint32_t kDisciplineRetryMs = 5000;
static const uint32_t kPpsWaitTimeoutMs = 1500;
static const uint32_t kSdMessagePeriodMs = 1200;
static const uint32_t kNoGpsMessagePeriodMs = 1500;
static const uint32_t kHealthCheckPeriodMs = 60000;
static const uint32_t kSlotSeconds = 2;

static XPowersLibInterface *g_pmu = nullptr;
static StartupSdManager g_sd(Serial);
static U8G2_SH1106_128X64_NONAME_F_HW_I2C g_oled(U8G2_R0, U8X8_PIN_NONE);
static HardwareSerial g_gpsSerial(1);
static SX1262 g_radio = new Module(LORA_CS, LORA_DIO1, LORA_RESET, LORA_BUSY);

static volatile bool g_rxFlag = false;
static volatile uint32_t g_ppsEdgeCount = 0;

static uint32_t g_logSeq = 0;
static uint32_t g_lastWarnMs = 0;
static uint32_t g_lastDisciplineTryMs = 0;
static uint32_t g_lastHealthCheckMs = 0;

static int64_t g_lastDisciplineEpoch = -1;
static int64_t g_lastTxEpochSecond = -1;
static uint32_t g_txCount = 0;
static uint32_t g_txAttemptId = 0;

static bool g_radioReady = false;
static bool g_sessionReady = false;
static bool g_gpsPathReady = false;

static char g_sessionStamp[20] = {0};
static char g_sentPath[64] = {0};
static char g_recvPath[64] = {0};

static File g_sentFile;
static File g_recvFile;

static char g_gpsLine[128];
static size_t g_gpsLineLen = 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;
  uint32_t lastUtcMs = 0;
  DateTime utc{};
  double latitudeDeg = 0.0;
  double longitudeDeg = 0.0;
  float altitudeM = 0.0f;
};

static GpsState g_gps;

static void parsePayloadFields(const char *msg,
                               char *txIdOut, size_t txIdLen,
                               char *latOut, size_t latLen,
                               char *lonOut, size_t lonLen,
                               char *altOut, size_t altLen);

enum class AppPhase : uint8_t
{
  WAIT_SD = 0,
  WAIT_DISCIPLINE,
  RUN
};

static AppPhase g_phase = AppPhase::WAIT_SD;

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

static uint32_t computeFrameSeconds(uint32_t requiredSeconds)
{
  uint32_t frame = ((requiredSeconds + 4U) / 5U) * 5U; // round up to 5s
  while (frame <= 60U && (60U % frame) != 0U)
  {
    frame += 5U;
  }
  if (frame == 0U || frame > 60U)
  {
    frame = 60U; // fallback
  }
  return frame;
}

static void logf(const char *fmt, ...)
{
  char msg[256];
  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 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 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 getCurrentUtc(DateTime &dt, int64_t &epoch)
{
  bool lowV = false;
  if (!rtcRead(dt, lowV))
    return false;
  if (lowV || !isValidDateTime(dt))
    return false;
  epoch = toEpochSeconds(dt);
  return true;
}

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 void formatUtcCompact(const DateTime &dt, char *out, size_t outLen)
{
  snprintf(out,
           outLen,
           "%04u%02u%02u_%02u%02u%02u",
           (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;

  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 parseRmc(char *fields[], int count)
{
  if (count <= 9)
    return;

  const char *utc = fields[1];
  const char *status = fields[2];
  const char *latRaw = (count > 3) ? fields[3] : nullptr;
  const char *latHem = (count > 4) ? fields[4] : nullptr;
  const char *lonRaw = (count > 5) ? fields[5] : nullptr;
  const char *lonHem = (count > 6) ? fields[6] : nullptr;
  const char *date = fields[9];

  if (!status || status[0] != 'A')
    return;
  if (!utc || !date || strlen(utc) < 6 || 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 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 > 9 && fields[9] && fields[9][0] != '\0')
  {
    g_gps.altitudeM = (float)atof(fields[9]);
    g_gps.hasValidAltitude = true;
  }

  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 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, "RMC") == 0)
    parseRmc(fields, count);
  else if (strcmp(type, "GGA") == 0)
    parseGga(fields, count);
  else if (strcmp(type, "GSV") == 0)
    parseGsv(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 gpsUtcIsFresh()
{
  return g_gps.hasValidUtc && ((uint32_t)(millis() - g_gps.lastUtcMs) <= 2000U);
}

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

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

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

  if (!g_sd.ensureDirRecursive("/gps"))
    return false;
  File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND);
  if (!f)
    return false;
  f.close();
  g_gpsPathReady = true;
  return true;
}

static bool appendDisciplineLog(const DateTime &gpsUtc, int64_t rtcMinusGpsSeconds, bool hadPriorRtc)
{
  if (!ensureGpsLogPathReady())
    return false;

  File f = SD.open("/gps/discipline_rtc.log", FILE_APPEND);
  if (!f)
    return false;

  char ts[24];
  snprintf(ts,
           sizeof(ts),
           "%04u%02u%02u_%02u%02u%02u_z",
           (unsigned)gpsUtc.year,
           (unsigned)gpsUtc.month,
           (unsigned)gpsUtc.day,
           (unsigned)gpsUtc.hour,
           (unsigned)gpsUtc.minute,
           (unsigned)gpsUtc.second);

  char line[256];
  if (hadPriorRtc)
  {
    snprintf(line,
             sizeof(line),
             "%s\t set RTC to GPS for FiveTalk\trtc-gps drift=%+lld s; sats=%u; fw_build_utc=%s",
             ts,
             (long long)rtcMinusGpsSeconds,
             (unsigned)bestSatelliteCount(),
             FW_BUILD_UTC);
  }
  else
  {
    snprintf(line,
             sizeof(line),
             "%s\t set RTC to GPS for FiveTalk\trtc-gps drift=RTC_unset; sats=%u; fw_build_utc=%s",
             ts,
             (unsigned)bestSatelliteCount(),
             FW_BUILD_UTC);
  }

  size_t wrote = f.println(line);
  f.close();
  return wrote > 0;
}

static bool disciplineRtcToGps()
{
  if (!gpsUtcIsFresh())
    return false;

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

  DateTime gpsSnap = g_gps.utc;
  if (!waitForNextPps(kPpsWaitTimeoutMs))
    return false;

  int64_t snapEpoch = toEpochSeconds(gpsSnap);
  DateTime target{};
  if (!fromEpochSeconds(snapEpoch + 1, target))
    return false;
  if (!rtcWrite(target))
    return false;

  int64_t driftSec = 0;
  if (havePriorRtc)
    driftSec = toEpochSeconds(prior) - toEpochSeconds(target);

  if (!appendDisciplineLog(target, driftSec, havePriorRtc))
  {
    logf("WARN: Failed to append /gps/discipline_rtc.log");
  }

  g_lastDisciplineEpoch = toEpochSeconds(target);
  char human[32];
  formatUtcHuman(target, human, sizeof(human));
  logf("RTC disciplined to GPS (%s), sats=%u", human, (unsigned)bestSatelliteCount());
  return true;
}

static bool parseLogTimestampToken(const char *token, int64_t &epochOut)
{
  if (!token)
    return false;

  unsigned y = 0, m = 0, d = 0, hh = 0, mm = 0, ss = 0;
  if (sscanf(token, "%4u%2u%2u_%2u%2u%2u", &y, &m, &d, &hh, &mm, &ss) != 6)
    return false;

  DateTime dt{};
  dt.year = (uint16_t)y;
  dt.month = (uint8_t)m;
  dt.day = (uint8_t)d;
  dt.hour = (uint8_t)hh;
  dt.minute = (uint8_t)mm;
  dt.second = (uint8_t)ss;
  if (!isValidDateTime(dt))
    return false;

  epochOut = toEpochSeconds(dt);
  return true;
}

static bool loadLastDisciplineEpoch(int64_t &epochOut)
{
  epochOut = -1;
  if (!g_sd.isMounted())
    return false;
  if (!SD.exists("/gps/discipline_rtc.log"))
    return false;

  File f = SD.open("/gps/discipline_rtc.log", FILE_READ);
  if (!f)
    return false;

  while (f.available())
  {
    String line = f.readStringUntil('\n');
    line.trim();
    if (line.length() == 0)
      continue;

    int sep = line.indexOf('\t');
    String token = (sep >= 0) ? line.substring(0, sep) : line;

    char buf[32];
    size_t n = token.length();
    if (n >= sizeof(buf))
      n = sizeof(buf) - 1;
    memcpy(buf, token.c_str(), n);
    buf[n] = '\0';

    int64_t parsed = -1;
    if (parseLogTimestampToken(buf, parsed))
      epochOut = parsed;
  }

  f.close();
  return epochOut >= 0;
}

static bool isDisciplineStale()
{
  DateTime now{};
  int64_t nowEpoch = 0;
  if (!getCurrentUtc(now, nowEpoch))
    return true;

  int64_t lastEpoch = -1;
  if (!loadLastDisciplineEpoch(lastEpoch))
  {
    if (g_lastDisciplineEpoch < 0)
      return true;
    lastEpoch = g_lastDisciplineEpoch;
  }

  g_lastDisciplineEpoch = lastEpoch;
  if (lastEpoch < 0)
    return true;

  int64_t age = nowEpoch - lastEpoch;
  return age < 0 || age > (int64_t)kDisciplineMaxAgeSec;
}

static void readBattery(float &voltageV, bool &present)
{
  voltageV = -1.0f;
  present = false;
  if (!g_pmu)
    return;

  present = g_pmu->isBatteryConnect();
  voltageV = g_pmu->getBattVoltage() / 1000.0f;
}

static void closeSessionLogs()
{
  if (g_sentFile)
    g_sentFile.close();
  if (g_recvFile)
    g_recvFile.close();
  g_sessionReady = false;
}

static bool openSessionLogs()
{
  closeSessionLogs();

  DateTime now{};
  int64_t nowEpoch = 0;
  if (!getCurrentUtc(now, nowEpoch))
  {
    logf("Cannot open session logs: RTC unavailable");
    return false;
  }

  formatUtcCompact(now, g_sessionStamp, sizeof(g_sessionStamp));
  snprintf(g_sentPath, sizeof(g_sentPath), "/%s_sent_%s.log", NODE_SHORT, g_sessionStamp);
  snprintf(g_recvPath, sizeof(g_recvPath), "/%s_received_%s.log", NODE_SHORT, g_sessionStamp);

  g_sentFile = SD.open(g_sentPath, FILE_APPEND);
  g_recvFile = SD.open(g_recvPath, FILE_APPEND);
  if (!g_sentFile || !g_recvFile)
  {
    logf("Failed to open session logs: %s | %s", g_sentPath, g_recvPath);
    closeSessionLogs();
    return false;
  }

  char human[32];
  formatUtcHuman(now, human, sizeof(human));
  g_sentFile.printf("# session_start_epoch=%lld utc=%s node=%s (%s) fw_build=%s\n",
                    (long long)nowEpoch,
                    human,
                    NODE_SHORT,
                    NODE_LABEL,
                    FW_BUILD_UTC);
  g_recvFile.printf("# session_start_epoch=%lld utc=%s node=%s (%s) fw_build=%s\n",
                    (long long)nowEpoch,
                    human,
                    NODE_SHORT,
                    NODE_LABEL,
                    FW_BUILD_UTC);
  g_sentFile.flush();
  g_recvFile.flush();

  logf("Session logs ready: %s | %s", g_sentPath, g_recvPath);
  g_sessionReady = true;
  return true;
}

static void gpsFieldStrings(char *latOut, size_t latLen, char *lonOut, size_t lonLen, char *altOut, size_t altLen)
{
  if (latOut && latLen > 0)
    latOut[0] = '\0';
  if (lonOut && lonLen > 0)
    lonOut[0] = '\0';
  if (altOut && altLen > 0)
    altOut[0] = '\0';

  if (g_gps.hasValidPosition)
  {
    if (latOut && latLen > 0)
      snprintf(latOut, latLen, "%.6f", g_gps.latitudeDeg);
    if (lonOut && lonLen > 0)
      snprintf(lonOut, lonLen, "%.6f", g_gps.longitudeDeg);
  }
  if (g_gps.hasValidAltitude)
  {
    if (altOut && altLen > 0)
      snprintf(altOut, altLen, "%.2f", g_gps.altitudeM);
  }
}

static void writeSentLog(int64_t epoch, const DateTime &dt, uint32_t txId, const char *payload, bool txOk)
{
  if (!g_sessionReady || !g_sentFile)
    return;

  float battV = -1.0f;
  bool battPresent = false;
  readBattery(battV, battPresent);

  char human[32];
  formatUtcHuman(dt, human, sizeof(human));

  char lat[24], lon[24], alt[24];
  gpsFieldStrings(lat, sizeof(lat), lon, sizeof(lon), alt, sizeof(alt));

  g_sentFile.printf("epoch=%lld\tutc=%s\tunit=%s\tmsg=%s\ttx_id=%lu\ttx_ok=%u\tlat=%s\tlon=%s\talt_m=%s\ttx_count=%lu\tbatt_present=%u\tbatt_v=%.3f\n",
                    (long long)epoch,
                    human,
                    NODE_SHORT,
                    payload ? payload : "",
                    (unsigned long)txId,
                    txOk ? 1U : 0U,
                    lat,
                    lon,
                    alt,
                    (unsigned long)g_txCount,
                    battPresent ? 1U : 0U,
                    battV);
  g_sentFile.flush();
}

static void writeRecvLog(int64_t epoch, const DateTime &dt, const char *msg, float rssi, float snr)
{
  if (!g_sessionReady || !g_recvFile)
    return;

  float battV = -1.0f;
  bool battPresent = false;
  readBattery(battV, battPresent);

  char human[32];
  formatUtcHuman(dt, human, sizeof(human));

  char txId[24], lat[24], lon[24], alt[24];
  parsePayloadFields(msg, txId, sizeof(txId), lat, sizeof(lat), lon, sizeof(lon), alt, sizeof(alt));

  g_recvFile.printf("epoch=%lld\tutc=%s\tunit=%s\trx_msg=%s\trx_tx_id=%s\trx_lat=%s\trx_lon=%s\trx_alt_m=%s\trssi=%.1f\tsnr=%.1f\tbatt_present=%u\tbatt_v=%.3f\n",
                    (long long)epoch,
                    human,
                    NODE_SHORT,
                    msg ? msg : "",
                    txId,
                    lat,
                    lon,
                    alt,
                    rssi,
                    snr,
                    battPresent ? 1U : 0U,
                    battV);
  g_recvFile.flush();
}

static void buildTxPayload(char *out, size_t outLen, uint32_t txId)
{
  if (!out || outLen == 0)
    return;
  out[0] = '\0';

  char lat[24], lon[24], alt[24];
  gpsFieldStrings(lat, sizeof(lat), lon, sizeof(lon), alt, sizeof(alt));
  snprintf(out, outLen, "%s,%lu,%s,%s,%s", NODE_SHORT, (unsigned long)txId, lat, lon, alt);
}

static bool isAllDigits(const char *s)
{
  if (!s || s[0] == '\0')
    return false;
  for (size_t i = 0; s[i] != '\0'; ++i)
  {
    if (!isdigit((unsigned char)s[i]))
      return false;
  }
  return true;
}

static void parsePayloadFields(const char *msg,
                               char *txIdOut, size_t txIdLen,
                               char *latOut, size_t latLen,
                               char *lonOut, size_t lonLen,
                               char *altOut, size_t altLen)
{
  if (txIdOut && txIdLen > 0)
    txIdOut[0] = '\0';
  if (latOut && latLen > 0)
    latOut[0] = '\0';
  if (lonOut && lonLen > 0)
    lonOut[0] = '\0';
  if (altOut && altLen > 0)
    altOut[0] = '\0';
  if (!msg || msg[0] == '\0')
    return;

  char buf[128];
  size_t n = strlen(msg);
  if (n >= sizeof(buf))
    n = sizeof(buf) - 1;
  memcpy(buf, msg, n);
  buf[n] = '\0';

  char *saveptr = nullptr;
  char *token = strtok_r(buf, ",", &saveptr); // unit label
  (void)token;

  token = strtok_r(nullptr, ",", &saveptr); // tx_id or lat (legacy)
  if (!token)
    return;

  if (isAllDigits(token))
  {
    if (txIdOut && txIdLen > 0)
      snprintf(txIdOut, txIdLen, "%s", token);

    token = strtok_r(nullptr, ",", &saveptr); // lat
    if (token && latOut && latLen > 0)
      snprintf(latOut, latLen, "%s", token);

    token = strtok_r(nullptr, ",", &saveptr); // lon
    if (token && lonOut && lonLen > 0)
      snprintf(lonOut, lonLen, "%s", token);

    token = strtok_r(nullptr, ",", &saveptr); // alt
    if (token && altOut && altLen > 0)
      snprintf(altOut, altLen, "%s", token);
  }
  else
  {
    // Backward compatibility: older payloads were "UNIT,lat,lon,alt".
    if (latOut && latLen > 0)
      snprintf(latOut, latLen, "%s", token);

    token = strtok_r(nullptr, ",", &saveptr); // lon
    if (token && lonOut && lonLen > 0)
      snprintf(lonOut, lonLen, "%s", token);

    token = strtok_r(nullptr, ",", &saveptr); // alt
    if (token && altOut && altLen > 0)
      snprintf(altOut, altLen, "%s", token);
  }
}

static void onLoRaDio1Rise()
{
  g_rxFlag = true;
}

static bool initRadio()
{
  SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS);

  int state = g_radio.begin(915.0, 125.0, 7, 5, 0x12, 14);
  if (state != RADIOLIB_ERR_NONE)
  {
    logf("radio.begin failed code=%d", state);
    return false;
  }

  g_radio.setDio1Action(onLoRaDio1Rise);
  state = g_radio.startReceive();
  if (state != RADIOLIB_ERR_NONE)
  {
    logf("radio.startReceive failed code=%d", state);
    return false;
  }

  logf("Radio ready for %s (%s), slot=%d/%d (2s each)", NODE_LABEL, NODE_SHORT, NODE_SLOT_INDEX, NODE_SLOT_COUNT);
  return true;
}

static void showRxOnOled(const DateTime &dt, const char *msg)
{
  char hhmmss[16];
  snprintf(hhmmss, sizeof(hhmmss), "%02u:%02u:%02u", (unsigned)dt.hour, (unsigned)dt.minute, (unsigned)dt.second);

  char line[32];
  snprintf(line, sizeof(line), "[%s, %s]", msg ? msg : "", NODE_SHORT);
  oledShowLines(hhmmss, line);
}

static void runTxScheduler()
{
  DateTime now{};
  int64_t epoch = 0;
  if (!getCurrentUtc(now, epoch))
    return;

  uint32_t requiredTxSeconds = (uint32_t)NODE_SLOT_COUNT * kSlotSeconds;
  uint32_t frameSeconds = computeFrameSeconds(requiredTxSeconds);
  uint32_t slotSecond = (uint32_t)NODE_SLOT_INDEX * kSlotSeconds;
  if (slotSecond >= frameSeconds)
    return;

  uint32_t secInFrame = (uint32_t)now.second % frameSeconds;
  if (secInFrame >= requiredTxSeconds)
    return; // idle guard interval
  if (secInFrame != slotSecond)
    return;

  int64_t epochSecond = epoch;
  if (epochSecond == g_lastTxEpochSecond)
    return;

  g_lastTxEpochSecond = epochSecond;
  uint32_t txId = ++g_txAttemptId;

  g_rxFlag = false;
  g_radio.clearDio1Action();

  char payload[96];
  buildTxPayload(payload, sizeof(payload), txId);
  int tx = g_radio.transmit(payload);
  if (tx == RADIOLIB_ERR_NONE)
  {
    g_txCount++;
    writeSentLog(epoch, now, txId, payload, true);
    logf("TX %s tx_id=%lu success_count=%lu payload=%s", NODE_SHORT, (unsigned long)txId, (unsigned long)g_txCount, payload);
  }
  else
  {
    writeSentLog(epoch, now, txId, payload, false);
    logf("TX failed code=%d", tx);
  }

  g_rxFlag = false;
  g_radio.setDio1Action(onLoRaDio1Rise);
  g_radio.startReceive();
}

static void runRxHandler()
{
  if (!g_rxFlag)
    return;
  g_rxFlag = false;

  String rx;
  int rc = g_radio.readData(rx);
  if (rc != RADIOLIB_ERR_NONE)
  {
    g_radio.startReceive();
    return;
  }

  DateTime now{};
  int64_t epoch = 0;
  if (getCurrentUtc(now, epoch))
  {
    writeRecvLog(epoch, now, rx.c_str(), g_radio.getRSSI(), g_radio.getSNR());
    showRxOnOled(now, rx.c_str());
  }

  g_radio.startReceive();
}

static void enterWaitSdState()
{
  if (g_phase == AppPhase::WAIT_SD)
    return;
  g_phase = AppPhase::WAIT_SD;
  closeSessionLogs();
  logf("State -> WAIT_SD");
}

static void enterWaitDisciplineState()
{
  if (g_phase == AppPhase::WAIT_DISCIPLINE)
    return;
  g_phase = AppPhase::WAIT_DISCIPLINE;
  closeSessionLogs();
  logf("State -> WAIT_DISCIPLINE");
}

static void enterRunState()
{
  if (g_phase == AppPhase::RUN)
    return;
  if (!openSessionLogs())
    return;
  g_lastTxEpochSecond = -1;
  g_lastHealthCheckMs = millis();
  g_phase = AppPhase::RUN;
  logf("State -> RUN");
}

static void updateWaitSd()
{
  if (g_sd.isMounted())
  {
    g_lastWarnMs = 0;
    g_gpsPathReady = false;
    enterWaitDisciplineState();
    return;
  }

  uint32_t now = millis();
  if ((uint32_t)(now - g_lastWarnMs) >= kSdMessagePeriodMs)
  {
    g_lastWarnMs = now;
    oledShowLines("Reinsert SD Card", NODE_SHORT, NODE_LABEL);
  }
}

static void updateWaitDiscipline()
{
  if (!g_sd.isMounted())
  {
    enterWaitSdState();
    return;
  }

  if (!isDisciplineStale())
  {
    enterRunState();
    return;
  }

  uint32_t now = millis();
  if ((uint32_t)(now - g_lastWarnMs) >= kNoGpsMessagePeriodMs)
  {
    g_lastWarnMs = now;
    char satsLine[24];
    snprintf(satsLine, sizeof(satsLine), "Satellites: %u", (unsigned)bestSatelliteCount());
    oledShowLines("Take me outside", "Need GPS time sync", satsLine);
  }

  if ((uint32_t)(now - g_lastDisciplineTryMs) < kDisciplineRetryMs)
    return;
  g_lastDisciplineTryMs = now;

  if (disciplineRtcToGps())
  {
    g_lastWarnMs = 0;
    enterRunState();
  }
}

static void updateRun()
{
  uint32_t now = millis();

  if (!g_sd.isMounted())
  {
    if ((uint32_t)(now - g_lastWarnMs) >= kSdMessagePeriodMs)
    {
      g_lastWarnMs = now;
      oledShowLines("SD removed", "Logging paused", "LoRa continues");
    }
  }
  else if (!g_sessionReady)
  {
    // Card came back while running. Resume append logging without pausing radio work.
    (void)openSessionLogs();
  }

  if ((uint32_t)(now - g_lastHealthCheckMs) >= kHealthCheckPeriodMs)
  {
    g_lastHealthCheckMs = now;
    if (isDisciplineStale())
    {
      enterWaitDisciplineState();
      return;
    }
  }

  runTxScheduler();
  runRxHandler();
}

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

  Serial.println("\r\n==================================================");
  Serial.println("Exercise 12: FiveTalk");
  Serial.println("==================================================");

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

  Wire.begin(OLED_SDA, OLED_SCL);
  g_oled.setI2CAddress(OLED_ADDR << 1);
  g_oled.begin();
  oledShowLines("Exercise 12", "FiveTalk startup", NODE_SHORT, NODE_LABEL);

  SdWatcherConfig sdCfg{};
  if (!g_sd.begin(sdCfg, nullptr))
  {
    logf("WARN: SD watcher begin failed");
  }

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

  g_gpsSerial.setRxBufferSize(1024);
  g_gpsSerial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);

  g_radioReady = initRadio();
  if (!g_radioReady)
  {
    oledShowLines("LoRa init failed", "Check radio pins");
  }

  g_phase = g_sd.isMounted() ? AppPhase::WAIT_DISCIPLINE : AppPhase::WAIT_SD;
}

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

  if (g_sd.consumeMountedEvent())
  {
    logf("SD mounted");
    g_gpsPathReady = false;
    if (g_phase == AppPhase::RUN)
    {
      g_lastWarnMs = 0;
      if (!g_sessionReady)
      {
        (void)openSessionLogs();
      }
    }
  }
  if (g_sd.consumeRemovedEvent())
  {
    logf("SD removed");
    g_gpsPathReady = false;
    if (g_phase == AppPhase::RUN)
    {
      closeSessionLogs();
      g_lastWarnMs = 0;
      oledShowLines("SD removed", "Logging paused", "LoRa continues");
    }
  }

  if (!g_radioReady)
  {
    delay(50);
    return;
  }

  switch (g_phase)
  {
  case AppPhase::WAIT_SD:
    updateWaitSd();
    break;
  case AppPhase::WAIT_DISCIPLINE:
    updateWaitDiscipline();
    break;
  case AppPhase::RUN:
    updateRun();
    break;
  }

  delay(5);
}
