/* =============================================================================
   OAT LoRa Gateway  —  v1.0.0
   OpenAgricultureTechnology.com  ·  the Sketch Library (Collect layer)
   -----------------------------------------------------------------------------
   The far-field twin of the BLE Listener. An ESP32 with a LoRa radio (Heltec WiFi
   LoRa 32 V3 or V2) sits where Wi-Fi lives, hears every OAT LoRa Field Node on
   its private 915 MHz channel, claims a slot per sensor, folds the readings, and
   pushes oat-ods to the endpoint you own — the same setup page, Console, push
   engine, heartbeat and signature as every other OAT gateway, all from
   oat_node_core. This file is the radio and the frame decode, nothing else.

   HOW IT WORKS
     The radio sits in continuous receive. Its interrupt raises a flag; collect()
     reads the frame on the main loop, validates it (magic, version, CRC), and:
       ROSTER  files sub-id -> hardware id for that unit and claims a slot per
               sensor under the id the wired sketch for that part would have used
               (ds18b20:<rom>, sht30:<serial>, <unit>:a<pin>), so a probe moved
               from a Wi-Fi node keeps its series.
       DATA    folds each reading into its sensor's slot via the codebook; stamps
               the unit's link quality (RSSI, SNR, battery) on every slot it owns.
     A unit that goes silent for 3 of its announced intervals has its slots
     released, so the endpoint sees the absence instead of a frozen last value.
     Readings for a sub-id with no roster entry yet are counted, not filed: a
     stream must never be created under a placeholder id.

   NAMING LAW: the gateway names nothing. Stream ids are hardware ids; the
   endpoint owns the hardware-to-place map.

   LICENSE: openly licensed. Copy it, change it, sell what you build with it.
   ============================================================================= */

#include <oat_node_core.h>
#include <oat_measurands.h>
#include <oat_lora_frame.h>
#include <oat_lora_radio.h>

#define TIER        "oat-lora-gateway"
#define FW_SEMVER   "1.0.0"
#define FW_VERSION  "OAT-LoRa-Gateway/1.0.0"
#define NVS_NS      "oatlgw"

// Table sizes. The campus defaults (32 nodes x 40 sensors, 96 slots) fit the S3;
// the classic ESP32's static RAM does not hold them, so the V2 env builds a
// smaller table in platformio.ini. Override with -DOAT_MAX_UNITS / -DOAT_MAX_SUBS.
#ifndef OAT_MAX_UNITS
  #define OAT_MAX_UNITS 32    // field nodes tracked
#endif
#ifndef OAT_MAX_SUBS
  #define OAT_MAX_SUBS  40    // sensors per node (sub-ids)
#endif
#define MAX_UNITS   OAT_MAX_UNITS
#define MAX_SUBS    OAT_MAX_SUBS

using namespace oatlora;

struct SubEntry { bool used; uint8_t subId; uint8_t kind; int slot; char streamId[oatcore::ID_LEN]; };
struct Unit {
  bool     used = false;
  uint32_t id = 0;
  uint8_t  lastSeq = 0; bool haveSeq = false;
  uint16_t intervalSec = 0;
  unsigned long lastSeenMs = 0;
  float    rssi = 0, snr = 0;
  uint8_t  battery = BATT_NA;
  uint32_t frames = 0, lost = 0, orphanReadings = 0;
  bool     gone = false;
  SubEntry subs[MAX_SUBS];
};
static Unit units[MAX_UNITS];
static Radio radio;
static volatile bool g_rxFlag = false;
static uint32_t g_rxFrames = 0, g_rxBad = 0, g_rxRoster = 0, g_rxData = 0;
static char g_lastBad[24] = {0};

static void IRAM_ATTR onRx() { g_rxFlag = true; }

// ---------------------------------------------------------------------------
// Radio plan as driver fields: the core renders, persists and routes them.
// ---------------------------------------------------------------------------
static RadioPlan g_plan = DEFAULT_PLAN;
static String getFreq()  { return String(g_plan.freqMHz, 1); }
static String getBw()    { return String((int)g_plan.bwKHz); }
static String getSf()    { return String(g_plan.sf); }
static String getSync()  { char b[8]; snprintf(b, sizeof(b), "%02x", g_plan.syncWord); return String(b); }
static bool setFreq(const String& v, String& why) { float f = v.toFloat(); if (f < 902 || f > 928) { why = "frequency must be 902..928 MHz (US915)"; return false; } g_plan.freqMHz = f; return true; }
static bool setBw(const String& v, String& why)   { float b = v.toFloat(); if (b != 125 && b != 250 && b != 500) { why = "bandwidth must be 125, 250 or 500 kHz"; return false; } g_plan.bwKHz = b; return true; }
static bool setSf(const String& v, String& why)   { int s = v.toInt(); if (s < 7 || s > 12) { why = "spreading factor must be 7..12"; return false; } g_plan.sf = s; return true; }
static bool setSync(const String& v, String& why) { long s = strtol(v.c_str(), nullptr, 16); if (s < 0 || s > 255 || s == 0x34) { why = "sync word is one hex byte, and never 34 (LoRaWAN)"; return false; } g_plan.syncWord = (uint8_t)s; return true; }

static const oatcore::Field FIELDS[] = {
  { "freq", "Frequency (MHz)", "Must match every field node. 915.0 by default.", getFreq, setFreq },
  { "bw",   "Bandwidth (kHz)", "125 reaches farther; 500 uses a quarter of the airtime. Must match the nodes.", getBw, setBw },
  { "sf",   "Spreading factor", "7 = fast, campus range. 9 = a few km. Must match the nodes.", getSf, setSf },
  { "sync", "Sync word (hex)", "Your private network's word, 12 by default. Must match the nodes.", getSync, setSync },
};

// ---------------------------------------------------------------------------
// Units and their sensors
// ---------------------------------------------------------------------------
static Unit* unitFor(uint32_t id, bool create) {
  int freeIdx = -1;
  for (int i = 0; i < MAX_UNITS; i++) {
    if (units[i].used && units[i].id == id) return &units[i];
    if (!units[i].used && freeIdx < 0) freeIdx = i;
  }
  if (!create || freeIdx < 0) return nullptr;
  Unit& u = units[freeIdx]; u = Unit(); u.used = true; u.id = id;
  return &u;
}
static SubEntry* subFor(Unit& u, uint8_t subId, bool create) {
  int freeIdx = -1;
  for (int i = 0; i < MAX_SUBS; i++) {
    if (u.subs[i].used && u.subs[i].subId == subId) return &u.subs[i];
    if (!u.subs[i].used && freeIdx < 0) freeIdx = i;
  }
  if (!create || freeIdx < 0) return nullptr;
  SubEntry& s = u.subs[freeIdx]; s.used = true; s.subId = subId; s.kind = 0; s.slot = -1; s.streamId[0] = 0;
  return &s;
}
static void stampProvenance(int slot, uint8_t kind) {
  switch (kind) {
    case SK_DS18B20: oatcore::slotMeta(slot, "Analog Devices", "DS18B20"); break;
    case SK_SHT30:   oatcore::slotMeta(slot, "Sensirion", "SHT30"); break;
    case SK_ANALOG:  oatcore::slotMeta(slot, "", "ADC"); break;
    case SK_NODE:    oatcore::slotMeta(slot, "Heltec", "LoRa field node"); break;
  }
}

static void fileRoster(Unit& u, const uint8_t* d, size_t n, const Header& h) {
  eachRoster(d, n, h, [&](uint8_t subId, uint8_t kind, const uint8_t* id, uint8_t idLen) {
    SubEntry* s = subFor(u, subId, true);
    if (!s) return;
    char sid[oatcore::ID_LEN];
    streamIdFor(u.id, kind, id, idLen, sid, sizeof(sid));
    if (s->slot >= 0 && strcmp(s->streamId, sid) == 0) return;        // unchanged
    if (s->slot >= 0) oatcore::release(s->slot);                        // sub-id re-used for a different part
    strncpy(s->streamId, sid, sizeof(s->streamId) - 1); s->streamId[sizeof(s->streamId) - 1] = 0;
    s->kind = kind;
    s->slot = oatcore::slotFor(s->streamId, s->streamId);
    if (s->slot >= 0) stampProvenance(s->slot, kind);
  });
}

static void fileData(Unit& u, const uint8_t* d, size_t n, const Header& h) {
  int batt = h.battery == BATT_NA ? -1 : (int)h.battery;
  eachData(d, n, h, [&](uint8_t subId, uint8_t code, int16_t raw) {
    const Code* c = codeFor(code);
    SubEntry* s = subFor(u, subId, false);
    if (!c) return;                                                     // a code this build does not know: skip, never guess
    if (!s || s->slot < 0) { u.orphanReadings++; return; }              // no roster yet: counted, not filed
    oatcore::slotLink(s->slot, (int)lroundf(u.rssi), batt);
    oatcore::fold(s->slot, c->measurement, c->unit, c->kind, decodeValue(*c, raw));
  });
  // Link quality is the gateway's own measurement of this unit, folded onto the unit's node stream.
  SubEntry* node = subFor(u, 0, false);
  if (node && node->slot >= 0) {
    oatcore::fold(node->slot, "rssi", "dBm", oat::KIND_GAUGE, u.rssi);
    oatcore::fold(node->slot, "snr",  "dB",  oat::KIND_GAUGE, u.snr);
  }
}

static void handleFrame(const uint8_t* d, size_t n) {
  Header h; const char* why = "";
  if (!parseHeader(d, n, h, why)) { g_rxBad++; strncpy(g_lastBad, why, sizeof(g_lastBad) - 1); return; }
  Unit* up = unitFor(h.unitId, true);
  if (!up) { g_rxBad++; strncpy(g_lastBad, "unit table full", sizeof(g_lastBad) - 1); return; }
  Unit& u = *up;
  if (u.haveSeq) { uint8_t gap = (uint8_t)(h.seq - u.lastSeq); if (gap > 1 && gap < 128) u.lost += gap - 1; }
  u.lastSeq = h.seq; u.haveSeq = true;
  u.lastSeenMs = millis(); u.frames++; u.gone = false;
  u.rssi = radio.rssi(); u.snr = radio.snr(); u.battery = h.battery;
  if (h.intervalSec) u.intervalSec = h.intervalSec;
  g_rxFrames++;
  if (h.kind == FRAME_ROSTER)    { g_rxRoster++; fileRoster(u, d, n, h); }
  else if (h.kind == FRAME_DATA) { g_rxData++;   fileData(u, d, n, h); }
  else { g_rxBad++; strncpy(g_lastBad, "kind", sizeof(g_lastBad) - 1); }
}

// Silence handling: after MISSED_BEFORE_GONE announced intervals, release the
// unit's slots. The roster is kept, so the next frame files straight back in.
static void sweepAbsent() {
  static unsigned long last = 0;
  if (millis() - last < 5000) return;
  last = millis();
  for (int i = 0; i < MAX_UNITS; i++) {
    Unit& u = units[i];
    if (!u.used || u.gone || !u.intervalSec) continue;
    if (millis() - u.lastSeenMs > (unsigned long)u.intervalSec * 1000UL * MISSED_BEFORE_GONE) {
      for (int j = 0; j < MAX_SUBS; j++) if (u.subs[j].used && u.subs[j].slot >= 0) oatcore::release(u.subs[j].slot);
      u.gone = true;
      Serial.printf("[lora] unit %08lx silent for %u intervals: released\n", (unsigned long)u.id, MISSED_BEFORE_GONE);
    }
  }
  // A released slot needs re-claiming on return.
}
static void reclaimIfBack(Unit& u) {
  for (int j = 0; j < MAX_SUBS; j++) {
    SubEntry& s = u.subs[j];
    if (!s.used || !s.streamId[0]) continue;
    s.slot = oatcore::slotFor(s.streamId, s.streamId);
    if (s.slot >= 0) stampProvenance(s.slot, s.kind);
  }
}

// ---------------------------------------------------------------------------
// Driver hooks
// ---------------------------------------------------------------------------
static void radioBegin() {
  bool ok = radio.begin(g_plan);
  Serial.printf("[lora] %s: radio %s (%.1f MHz, bw %.0f, sf %u, sync 0x%02x)\n", BOARD_NAME, Radio::stateName(radio.lastState), g_plan.freqMHz, g_plan.bwKHz, g_plan.sf, g_plan.syncWord);
  if (!ok) return;
  radio.onReceive(onRx);
  radio.startReceive();
}
static void sensorSample()  { }
static void sensorCollect() {
  if (g_rxFlag) {
    g_rxFlag = false;
    static uint8_t buf[MAX_FRAME + 4];
    size_t n = radio.packetLength();
    if (n > 0 && n <= sizeof(buf)) {
      int st = radio.readData(buf, n);
      if (st == RADIOLIB_ERR_NONE) {
        // A unit coming back from 'gone' re-claims before its readings are filed.
        Header h; const char* why;
        if (parseHeader(buf, n, h, why)) { Unit* u = unitFor(h.unitId, false); if (u && u->gone) reclaimIfBack(*u); }
        handleFrame(buf, n);
      } else { g_rxBad++; strncpy(g_lastBad, Radio::stateName(st), sizeof(g_lastBad) - 1); }
    }
    radio.startReceive();
  }
  sweepAbsent();
}
static void sensorRescan()  { radioBegin(); }

static String statusHtml() {
  String p = "<div class='muted'>Radio " + String(Radio::stateName(radio.lastState)) + " &middot; " + String(g_plan.freqMHz, 1) + " MHz &middot; bw " + String((int)g_plan.bwKHz) + " &middot; SF" + String(g_plan.sf) + " &middot; sync " + getSync() + "</div>";
  p += "<table class='tbl'><tr><th>Field node</th><th>Sensors</th><th>Signal</th><th>Battery</th><th>Every</th><th>Last heard</th><th>Frames</th></tr>";
  int shown = 0;
  for (int i = 0; i < MAX_UNITS; i++) {
    Unit& u = units[i]; if (!u.used) continue; shown++;
    int nsub = 0; for (int j = 0; j < MAX_SUBS; j++) if (u.subs[j].used && u.subs[j].subId != 0) nsub++;
    char id[12]; snprintf(id, sizeof(id), "%08lx", (unsigned long)u.id);
    unsigned long age = (millis() - u.lastSeenMs) / 1000;
    p += "<tr><td>" + String(id) + (u.gone ? " <span class='bad'>silent</span>" : "") + "</td><td>" + String(nsub) +
         (u.orphanReadings ? " <span class='muted'>(" + String(u.orphanReadings) + " waiting for roster)</span>" : "") +
         "</td><td>" + String(u.rssi, 0) + " dBm / " + String(u.snr, 1) + " dB</td><td>" + (u.battery == BATT_NA ? String("&mdash;") : String(u.battery) + "%") +
         "</td><td>" + (u.intervalSec ? String(u.intervalSec) + " s" : String("?")) + "</td><td>" + String(age) + "s ago</td><td>" + String(u.frames) +
         (u.lost ? " <span class='muted'>(" + String(u.lost) + " lost)</span>" : "") + "</td></tr>";
  }
  p += "</table>";
  if (!shown) p += "<p class='bad'>Nothing heard yet. A field node transmits on its cadence (three minutes by default) and sends its roster first, so give it one cycle. If it stays empty: the node's radio plan (frequency, bandwidth, spreading factor, sync word) must match the four settings above exactly, then check both antennas.</p>";
  p += "<div class='muted'>Frames " + String(g_rxFrames) + " &middot; roster " + String(g_rxRoster) + " &middot; data " + String(g_rxData) + " &middot; rejected " + String(g_rxBad) + (g_lastBad[0] ? " (last: " + String(g_lastBad) + ")" : "") + "</div>";
  return p;
}
static String statusText() {
  String s = "lora radio=" + String(Radio::stateName(radio.lastState)) + " frames=" + String(g_rxFrames) + " roster=" + String(g_rxRoster) + " data=" + String(g_rxData) + " bad=" + String(g_rxBad) + "\n";
  for (int i = 0; i < MAX_UNITS; i++) {
    Unit& u = units[i]; if (!u.used) continue;
    char id[12]; snprintf(id, sizeof(id), "%08lx", (unsigned long)u.id);
    s += "unit " + String(id) + (u.gone ? " SILENT" : "") + " rssi=" + String(u.rssi, 0) + " snr=" + String(u.snr, 1) + " batt=" + (u.battery == BATT_NA ? String("-") : String(u.battery)) +
         " every=" + String(u.intervalSec) + "s last=" + String((millis() - u.lastSeenMs) / 1000) + "s frames=" + String(u.frames) + " lost=" + String(u.lost) + " orphans=" + String(u.orphanReadings) + "\n";
    for (int j = 0; j < MAX_SUBS; j++) if (u.subs[j].used) s += "  sub " + String(u.subs[j].subId) + " " + String(u.subs[j].streamId) + " slot " + String(u.subs[j].slot) + "\n";
  }
  return s;
}
static String diagLine() {
  int n = 0; for (int i = 0; i < MAX_UNITS; i++) if (units[i].used) n++;
  return "radio " + String(Radio::stateName(radio.lastState)) + ", heard " + String(g_rxFrames) + " frame(s) from " + String(n) +
         " node(s), rejected " + String(g_rxBad) + ". A radio cannot be 'wired wrong' — if frames is zero the plan does not match the nodes, or an antenna is missing.";
}

static void cmdNodes(const String&) { Serial.print(statusText()); }
static const oatcore::Command COMMANDS[] = {
  { "nodes", "list the field nodes heard, their sensors, signal and loss", cmdNodes },
};

static const oatcore::Driver DRIVER = {
  TIER, "Field nodes heard", FW_VERSION, FW_SEMVER, NVS_NS,
  radioBegin, sensorSample, sensorCollect, nullptr, sensorRescan,
  statusHtml, statusText, nullptr, diagLine,
  FIELDS,   (int)(sizeof(FIELDS)   / sizeof(FIELDS[0])),
  COMMANDS, (int)(sizeof(COMMANDS) / sizeof(COMMANDS[0])),
};

void setup() { oatcore::begin(DRIVER); }
void loop()  { oatcore::loop(); }
