/**
 * Dual CC1101 always-on key-fob jammer.
 * ESP32-S3 DevKitC-1: two CC1101 on shared SPI.
 * Radio 1: sweeps 300–320 MHz (US band — Honda 303.825, Toyota 315, Ford/GM/Chrysler 315, Linear 318 MHz)
 * Radio 2: sweeps 390–436 MHz (EU/global — LiftMaster 390, Holtek 418, Somfy 433.42, EU 433.92, Nero 434.42 MHz)
 * FM noise via LEDC on GDO0 pins (±120 kHz bandwidth per hop).
 * WiFi AP + web UI on boot; OTA updates via ArduinoOTA.
 */

#include <Arduino.h>
#include <RadioLib.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
#include <Preferences.h>
#include <math.h>
#include <Wire.h>
#include <U8g2lib.h>
#include "config.h"

// Shared SPI; each Module uses its own CS.
// Must pass SPIClass explicitly so RadioLib uses our configured pins.
static SPIClass spi(FSPI);
static ArduinoHal hal(spi, SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
static Module mod1(&hal, CC1101_1_CS, CC1101_1_GDO0, RADIOLIB_NC, RADIOLIB_NC);
static Module mod2(&hal, CC1101_2_CS, CC1101_2_GDO0, RADIOLIB_NC, RADIOLIB_NC);

CC1101 radio1(&mod1);
CC1101 radio2(&mod2);

static WebServer server(WEB_PORT);
static Preferences preferences;

// CC1101 valid discrete power levels in dBm (RadioLib only accepts these exact values)
static const int8_t kPowerTable[JAM_POWER_LEVELS] = { -30, -20, -15, -10, 0, 5, 7, 10 };

// Jamming state
static bool jammingEnabled = JAMMING_ENABLED;
static uint8_t jamPowerIdx = DEFAULT_JAM_POWER_IDX;  // index into kPowerTable
static int8_t jamPower = 10;                          // actual dBm value passed to RadioLib

// Individual radio status tracking
static int8_t radio1Status = -1;  // 0=standby, 1=initialized, 2=transmitting, -1=disabled/error
static int8_t radio2Status = -1;
static String radio1Error = "Disabled / not initialized";
static String radio2Error = "Disabled / not initialized";

// Telemetry
static uint32_t uptimeStart = 0;
static float currentRssi1 = NAN;
static float currentRssi2 = NAN;

// Frequency sweep state
static uint8_t  sweepStep1    = 0;
static uint8_t  sweepStep2    = 0;
static uint32_t lastSweep1Ms  = 0;
static uint32_t lastSweep2Ms  = 0;
static float    sweepFreq1    = SWEEP_1_CENTER_MHZ;
static float    sweepFreq2    = SWEEP_2_CENTER_MHZ;

// Runtime-adjustable sweep parameters (loaded from NVS)
static uint32_t sweepDwellMs  = SWEEP_DWELL_MS;
static uint8_t  sweep1Steps   = SWEEP_1_STEPS;
static uint8_t  sweep2Steps   = SWEEP_2_STEPS;
static float    sweep1SpanMhz = SWEEP_1_SPAN_MHZ;
static float    sweep2SpanMhz = SWEEP_2_SPAN_MHZ;

// Amp gain for effective power display (user-configurable)
static int8_t   ampGainDb     = DEFAULT_AMP_GAIN_DB;

// Auto-reinit watchdog
static uint32_t lastReInitCheck = 0;

// 24-hour operation health tracking
static uint32_t hopCount1      = 0;          // total frequency hops since boot
static uint32_t hopCount2      = 0;
static uint32_t minFreeHeap    = 0xFFFFFFFF; // lowest heap ever observed
static uint32_t lastTempWarnMs = 0;          // rate-limit temperature warnings

// ─── OLED (0.96" SSD1306 128x64) ─────────────────────────────────────────────
// SW_I2C: bit-bangs GPIO directly — no Wire library involved, always works
// if the pins are physically correct. SDA=GPIO17, SCL=GPIO18.
static U8G2_SSD1306_128X64_NONAME_F_SW_I2C
  u8g2(U8G2_R0, OLED_SCL_PIN, OLED_SDA_PIN, U8X8_PIN_NONE);
static bool     oledOk    = false;
static uint8_t  oledPage  = 0;       // 0=status, 1=freq/hops, 2=health
static uint32_t oledPageMs  = 0;
static uint32_t oledTickMs  = 0;
static uint8_t  waveFrame   = 0;     // 0-3 animated arc count
static uint32_t waveMs      = 0;
static uint32_t notifEnd    = 0;     // millis() when current notification expires
static char     notifL1[22] = {};
static char     notifL2[22] = {};

// ─── Rotary encoder ──────────────────────────────────────────────────────────
static volatile int8_t  encDelta   = 0;   // +1 CW / -1 CCW per detent
static          uint8_t encLastClk = HIGH;

void IRAM_ATTR encISR() {
  const uint8_t clk = digitalRead(ENC_CLK_PIN);
  if (clk == encLastClk) return;           // filter glitch
  encLastClk = clk;
  if (clk == LOW) {                        // falling edge = one detent
    encDelta += (digitalRead(ENC_DT_PIN) == HIGH) ? +1 : -1;
  }
}

// Log ring buffer
static constexpr size_t LOG_LINES = 100;
static String logRing[LOG_LINES];
static size_t logHead = 0;
static size_t logCount = 0;

static void logLine(const String& s) {
  uint32_t ms = millis();
  uint32_t ss = ms / 1000;
  uint32_t mm = ss / 60; ss %= 60;
  uint32_t hh = mm / 60; mm %= 60;
  char ts[12];
  snprintf(ts, sizeof(ts), "[%02u:%02u:%02u] ", hh, mm, ss);
  const String line = String(ts) + s;
  logRing[logHead] = line;
  logHead = (logHead + 1) % LOG_LINES;
  if (logCount < LOG_LINES) logCount++;
  Serial.println(line);
}

static String getLogsText() {
  String out;
  out.reserve(4096);
  const size_t start = (logCount == LOG_LINES) ? logHead : 0;
  for (size_t i = 0; i < logCount; i++) {
    const size_t idx = (start + i) % LOG_LINES;
    out += logRing[idx];
    out += '\n';
  }
  return out;
}

static String jsonEscape(const String& in) {
  String out;
  out.reserve(in.length() + 8);
  for (size_t i = 0; i < in.length(); ++i) {
    const char c = in.charAt(i);
    if (c == '\\') out += "\\\\";
    else if (c == '\"') out += "\\\"";
    else if (c == '\n') out += "\\n";
    else if (c == '\r') out += "\\r";
    else if (c == '\t') out += "\\t";
    else out += c;
  }
  return out;
}

// Forward declarations
static void noiseGenStart();

// Manually probe a CC1101 via raw SPI to verify bus connectivity.
// Reads the VERSION register (0xF1 = burst read of reg 0x31).
// Returns the raw byte, or 0xFF if bus appears dead.
static uint8_t probeCC1101(uint8_t csPin) {
  pinMode(csPin, OUTPUT);
  digitalWrite(csPin, HIGH);
  delayMicroseconds(50);
  spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
  digitalWrite(csPin, LOW);
  delayMicroseconds(10);
  spi.transfer(0xF1); // read status reg 0x31 (VERSION)
  uint8_t val = spi.transfer(0x00);
  digitalWrite(csPin, HIGH);
  spi.endTransaction();
  return val;
}

// Manually pulse CS to hardware-reset a CC1101 before RadioLib init.
static void hardResetCC1101(uint8_t csPin) {
  pinMode(csPin, OUTPUT);
  digitalWrite(csPin, LOW);
  delayMicroseconds(5);
  digitalWrite(csPin, HIGH);
  delayMicroseconds(45);
  // Hold CS low, wait for MISO to settle, then release
  spi.beginTransaction(SPISettings(SPI_SPEED_HZ, MSBFIRST, SPI_MODE0));
  digitalWrite(csPin, LOW);
  delay(10);
  spi.transfer(0x30); // SRES strobe
  digitalWrite(csPin, HIGH);
  spi.endTransaction();
  delay(5);
}

// Start simultaneous jamming on both radios
static void startJamming() {
  logLine("[JAM] Starting simultaneous jamming system");
  logLine("[JAM] Power: " + String(jamPower) + " dBm");
  
  // Reset radio status
  radio1Status = 0;
  radio2Status = 0;
  radio1Error = "";
  radio2Error = "";
  
  // Initialize radio 1 with retries
  int st1 = RADIOLIB_ERR_CHIP_NOT_FOUND;
  for (int attempt = 0; attempt < 3 && st1 != RADIOLIB_ERR_NONE; attempt++) {
    if (attempt > 0) { delay(50); }
    st1 = radio1.begin(CC1101_1_FREQ_MHZ, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16);
  }
  if (st1 != RADIOLIB_ERR_NONE) {
    radio1Status = -1;
    radio1Error = "Init failed: " + String(st1);
    logLine("[R1] init failed: " + String(st1));
  } else {
    radio1Status = 1;
  }
  
  // Initialize radio 2 with retries
  int st2 = RADIOLIB_ERR_CHIP_NOT_FOUND;
  for (int attempt = 0; attempt < 3 && st2 != RADIOLIB_ERR_NONE; attempt++) {
    if (attempt > 0) { delay(50); }
    st2 = radio2.begin(CC1101_2_FREQ_MHZ, JAM_BITRATE_KBPS, JAM_FREQ_DEV_KHZ, JAM_RX_BW_KHZ, jamPower, 16);
  }
  if (st2 != RADIOLIB_ERR_NONE) {
    radio2Status = -1;
    radio2Error = "Init failed: " + String(st2);
    logLine("[R2] init failed: " + String(st2));
  } else {
    radio2Status = 1;
  }
  
  // Start both radios transmitting simultaneously
  int stTx1 = RADIOLIB_ERR_NONE;
  int stTx2 = RADIOLIB_ERR_NONE;
  
  // Start async direct TX — GDO0 becomes serial-data input, LEDC drives it with
  // a 120 kHz square wave producing ±120 kHz FM noise instead of narrow-band CW.
  noiseGenStart();

  if (radio1Status == 1) {
    stTx1 = radio1.transmitDirectAsync();
    if (stTx1 != RADIOLIB_ERR_NONE) {
      radio1Status = -1;
      radio1Error = "Transmit failed: " + String(stTx1);
      logLine("[R1] transmitDirectAsync failed: " + String(stTx1));
    } else {
      radio1Status = 2; // Transmitting
    }
  }
  
  if (radio2Status == 1) {
    stTx2 = radio2.transmitDirectAsync();
    if (stTx2 != RADIOLIB_ERR_NONE) {
      radio2Status = -1;
      radio2Error = "Transmit failed: " + String(stTx2);
      logLine("[R2] transmitDirectAsync failed: " + String(stTx2));
    } else {
      radio2Status = 2; // Transmitting
    }
  }
  
  if (radio1Status == 2 || radio2Status == 2) {
    logLine("[JAM] Jamming active (async FM noise mode):");
    logLine("[JAM]   Radio 1: sweep 300-320 MHz at " + String(jamPower) + " dBm (status: " + String(radio1Status == 2 ? "TX" : "FAIL") + ")");
    logLine("[JAM]   Radio 2: sweep 390-436 MHz at " + String(jamPower) + " dBm (status: " + String(radio2Status == 2 ? "TX" : "FAIL") + ")");
  } else {
    logLine("[JAM] Both radios failed to start - check SPI connections");
    logLine("[JAM]   R1 error: " + radio1Error);
    logLine("[JAM]   R2 error: " + radio2Error);
    // jammingEnabled stays true so it retries on next toggle or reboot
  }
}

// Stop jamming
static void stopJamming() {
  if (!jammingEnabled) {
    logLine("[JAM] Not currently jamming, ignoring stop request");
    return;
  }
  
  logLine("[JAM] Stopping jamming system");
  
  // Stop transmission on both radios
  if (radio1Status == 2) { // Transmitting
    int st1 = radio1.standby();
    if (st1 != RADIOLIB_ERR_NONE) {
      radio1Error = "Standby failed: " + String(st1);
      logLine("[R1] standby failed: " + String(st1));
    } else {
      radio1Status = 1; // Initialized but not transmitting
      radio1Error = "";
    }
  }
  
  if (radio2Status == 2) { // Transmitting
    int st2 = radio2.standby();
    if (st2 != RADIOLIB_ERR_NONE) {
      radio2Error = "Standby failed: " + String(st2);
      logLine("[R2] standby failed: " + String(st2));
    } else {
      radio2Status = 1; // Initialized but not transmitting
      radio2Error = "";
    }
  }
  
  // Release GDO0 noise PWM so pins return to normal GPIO
  ledcDetachPin(CC1101_1_GDO0);
  ledcDetachPin(CC1101_2_GDO0);

  jammingEnabled = false;
  logLine("[JAM] Jamming stopped");
}

// ─── OLED functions ──────────────────────────────────────────────────────────

// Queue a full-screen notification overlay for dur ms.
static void oledNotify(const char* l1, const char* l2, uint32_t dur = 2500) {
  if (!oledOk) return;
  strlcpy(notifL1, l1, sizeof(notifL1));
  strlcpy(notifL2, l2, sizeof(notifL2));
  notifEnd = millis() + dur;
  oledPageMs = notifEnd; // reset page timer after notification clears
}

// Show a synchronous one-shot boot status message (called during setup).
static void oledBootMsg(const char* line) {
  if (!oledOk) return;
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_7x13_tf);
  u8g2.drawStr(0, 14, "CC1101 JAMMER");
  u8g2.setFont(u8g2_font_6x10_tf);
  u8g2.drawStr(0, 27, "ESP32-S3 INIT");
  u8g2.drawHLine(0, 30, 128);
  u8g2.drawStr(0, 46, line);
  u8g2.sendBuffer();
}

// Draw animated right-half radio-wave arcs at (cx, cy), n arcs (0-3).
static void oledDrawWaves(uint8_t cx, uint8_t cy, uint8_t n) {
  for (uint8_t i = 0; i < n; i++) {
    u8g2.drawCircle(cx, cy, (i + 1) * 3,
                    U8G2_DRAW_UPPER_RIGHT | U8G2_DRAW_LOWER_RIGHT);
  }
}

// Draw page indicator dots in the yellow zone (top-right corner)
// page = current page (0-2)
static void oledPageDots(uint8_t page) {
  for (uint8_t i = 0; i < 3; i++) {
    const uint8_t x = 116 + i * 5;
    if (i == page) u8g2.drawBox(x, 4, 3, 3);   // filled = active
    else           u8g2.drawFrame(x, 4, 3, 3);  // outline = inactive
  }
}

// Page 0 — Live Status
// Yellow zone (y 0-15): status header
// Blue zone  (y16-63):  4 data lines with 5x7 font
static void oledDrawStatus() {
  const bool jam = jammingEnabled;
  const bool r1  = (radio1Status == 2);
  const bool r2  = (radio2Status == 2);

  // Yellow zone header — inverted box when jamming
  u8g2.setFont(u8g2_font_6x10_tf);
  if (jam) {
    u8g2.drawBox(0, 0, 110, 13);
    u8g2.setDrawColor(0);
    u8g2.drawStr(2, 10, ">> JAMMING ACTIVE <<");
    u8g2.setDrawColor(1);
  } else {
    u8g2.drawStr(2, 10, "-- STANDBY --");
  }
  oledPageDots(0);

  // Blue zone — 5x7 font, 4 rows at y=22,32,43,54 then 5th at y=63
  u8g2.setFont(u8g2_font_5x7_tf);
  const uint8_t nW = waveFrame;

  // Row 1: ANT1
  if (r1) {
    char buf[20];
    snprintf(buf, sizeof(buf), "1: %.3f MHz", (double)sweepFreq1);
    u8g2.drawStr(0, 24, buf);
    oledDrawWaves(101, 19, nW);
  } else {
    u8g2.drawStr(0, 24, "1: [OFFLINE]");
  }

  // Row 2: ANT2
  if (r2) {
    char buf[20];
    snprintf(buf, sizeof(buf), "2: %.3f MHz", (double)sweepFreq2);
    u8g2.drawStr(0, 33, buf);
    oledDrawWaves(101, 28, nW);
  } else {
    u8g2.drawStr(0, 33, "2: [OFFLINE]");
  }

  // Row 3: power
  {
    char buf[28];
    snprintf(buf, sizeof(buf), "TX %d+%d=%ddBm",
             (int)jamPower, (int)ampGainDb, (int)jamPower + (int)ampGainDb);
    u8g2.drawStr(0, 44, buf);
  }

  // Row 4: temp + heap  OR  FULL TX badge
  if (jam && r1 && r2) {
    u8g2.drawStr(0, 55, "[ FULL DUAL-BAND TX ]");
  } else {
    char buf[28];
    snprintf(buf, sizeof(buf), "%.1fC  %lukB",
             (double)temperatureRead(), (unsigned long)(ESP.getFreeHeap() / 1024));
    u8g2.drawStr(0, 55, buf);
  }

  // Row 5: uptime small
  {
    const uint32_t up = millis() - uptimeStart;
    char buf[20];
    snprintf(buf, sizeof(buf), "up %uh%um%us",
             (unsigned)(up/3600000), (unsigned)((up/60000)%60), (unsigned)((up/1000)%60));
    u8g2.drawStr(0, 63, buf);
  }
}

// Page 1 — Frequency + Hops
static void oledDrawFreq() {
  // Yellow zone header
  u8g2.setFont(u8g2_font_6x10_tf);
  u8g2.drawStr(2, 10, "FREQ & HOPS");
  oledPageDots(1);

  u8g2.setFont(u8g2_font_5x7_tf);
  char buf[24];

  snprintf(buf, sizeof(buf), "R1  %.4f MHz", (double)sweepFreq1);
  u8g2.drawStr(0, 24, buf);
  snprintf(buf, sizeof(buf), "    %lu hops", (unsigned long)hopCount1);
  u8g2.drawStr(0, 33, buf);

  snprintf(buf, sizeof(buf), "R2  %.4f MHz", (double)sweepFreq2);
  u8g2.drawStr(0, 45, buf);
  snprintf(buf, sizeof(buf), "    %lu hops", (unsigned long)hopCount2);
  u8g2.drawStr(0, 54, buf);

  // Total hops per second (approx from 5s heartbeat window)
  const uint32_t up = (millis() - uptimeStart) / 1000;
  if (up > 0) {
    snprintf(buf, sizeof(buf), "~%lu h/s total",
             (unsigned long)((hopCount1 + hopCount2) / up));
    u8g2.drawStr(0, 63, buf);
  }
}

// Page 2 — System Health
static void oledDrawHealth() {
  // Yellow zone header
  u8g2.setFont(u8g2_font_6x10_tf);
  u8g2.drawStr(2, 10, "SYS HEALTH");
  oledPageDots(2);

  u8g2.setFont(u8g2_font_5x7_tf);
  char buf[24];

  snprintf(buf, sizeof(buf), "TEMP  %.1f C", (double)temperatureRead());
  u8g2.drawStr(0, 24, buf);

  const uint32_t freeK = ESP.getFreeHeap() / 1024;
  const uint32_t minK  = (minFreeHeap == 0xFFFFFFFF ? ESP.getFreeHeap() : minFreeHeap) / 1024;
  snprintf(buf, sizeof(buf), "HEAP  %lukB  min%lukB", freeK, minK);
  u8g2.drawStr(0, 33, buf);

  const uint32_t up = millis() - uptimeStart;
  snprintf(buf, sizeof(buf), "UP    %uh %um %us",
           (unsigned)(up/3600000), (unsigned)((up/60000)%60), (unsigned)((up/1000)%60));
  u8g2.drawStr(0, 44, buf);

  const int effDbm = (int)jamPower + (int)ampGainDb;
  const uint32_t effMw = (uint32_t)roundf(powf(10.0f, effDbm / 10.0f));
  snprintf(buf, sizeof(buf), "PWR   %ddBm / %umW", effDbm, min(effMw, (uint32_t)9999));
  u8g2.drawStr(0, 55, buf);

  snprintf(buf, sizeof(buf), "WIFI  %d client(s)", WiFi.softAPgetStationNum());
  u8g2.drawStr(0, 63, buf);
}

// Full-screen inverted notification overlay
static void oledDrawNotif() {
  u8g2.drawBox(0, 0, 128, 64);
  u8g2.setDrawColor(0);

  u8g2.setFont(u8g2_font_7x13_tf);
  int16_t x1 = (128 - (int16_t)strlen(notifL1) * 7) / 2;
  u8g2.drawStr((uint8_t)max((int16_t)0, x1), 26, notifL1);

  u8g2.setFont(u8g2_font_6x10_tf);
  int16_t x2 = (128 - (int16_t)strlen(notifL2) * 6) / 2;
  u8g2.drawStr((uint8_t)max((int16_t)0, x2), 44, notifL2);

  u8g2.setDrawColor(1);
}

// Main OLED update — call from loop() every pass; self-throttles to 100ms.
static void oledTick() {
  if (!oledOk) return;
  const uint32_t now = millis();
  if (now - oledTickMs < 100) return;
  oledTickMs = now;

  // Advance wave animation every 220ms (4 frames → ~1.1s full cycle)
  if (now - waveMs >= 220) {
    waveMs = now;
    waveFrame = (waveFrame + 1) & 3;
  }

  // Consume encoder — manual page change resets the auto-cycle timer
  if (encDelta != 0) {
    const int8_t d = encDelta;
    encDelta = 0;
    oledPage = (uint8_t)((oledPage + 3 + (d > 0 ? 1 : -1)) % 3);
    oledPageMs = now;   // reset auto-advance so page stays visible
  }

  // Auto page-advance every 8s (not during notification, not if encoder just moved)
  if (now > notifEnd && now - oledPageMs >= 8000) {
    oledPageMs = now;
    oledPage   = (oledPage + 1) % 3;
  }

  u8g2.clearBuffer();
  if (now < notifEnd) {
    oledDrawNotif();
  } else if (oledPage == 0) {
    oledDrawStatus();
  } else if (oledPage == 1) {
    oledDrawFreq();
  } else {
    oledDrawHealth();
  }
  u8g2.sendBuffer();
}

// Drive each CC1101's GDO0 pin with a 120 kHz square wave (LEDC channels 0 & 1).
// In transmitDirectAsync mode the CC1101 reads GDO0 as serial data,
// producing an FM signal that spans ±120 kHz (240 kHz bandwidth) per hop
// instead of a near-zero-bandwidth CW carrier.
static void noiseGenStart() {
  ledcDetachPin(CC1101_1_GDO0); // safe no-op if not yet attached
  ledcDetachPin(CC1101_2_GDO0);
  // 120 kHz = CC1101 deviation → full ±f_dev FM modulation, 50% duty = symmetric 2-tone
  ledcSetup(0, 120000, 8);      // LEDC channel 0: 120 kHz, 8-bit
  ledcAttachPin(CC1101_1_GDO0, 0);
  ledcWrite(0, 128);            // 50% duty cycle (128/256)
  ledcSetup(1, 120000, 8);      // LEDC channel 1: same
  ledcAttachPin(CC1101_2_GDO0, 1);
  ledcWrite(1, 128);
}

// Update jamming power; idx is 0-7 mapping to kPowerTable dBm values.
static void updateJamPower(uint8_t idx) {
  if (idx >= JAM_POWER_LEVELS) idx = JAM_POWER_LEVELS - 1;
  
  int8_t newDbm = kPowerTable[idx];
  logLine("[JAM] Updating TX power: index " + String(idx) + " = " + String(newDbm) + " dBm");
  
  jamPowerIdx = idx;
  jamPower = newDbm;
  preferences.putInt("jamPowerIdx", jamPowerIdx);
  
  if (radio1Status >= 1) {
    int st1 = radio1.setOutputPower(newDbm);
    if (st1 != RADIOLIB_ERR_NONE) {
      radio1Error = "Power update failed: " + String(st1);
      logLine("[R1] setOutputPower(" + String(newDbm) + ") failed: " + String(st1));
    } else {
      radio1Error = "";
      logLine("[R1] TX power -> " + String(newDbm) + " dBm");
    }
  }
  
  if (radio2Status >= 1) {
    int st2 = radio2.setOutputPower(newDbm);
    if (st2 != RADIOLIB_ERR_NONE) {
      radio2Error = "Power update failed: " + String(st2);
      logLine("[R2] setOutputPower(" + String(newDbm) + ") failed: " + String(st2));
    } else {
      radio2Error = "";
      logLine("[R2] TX power -> " + String(newDbm) + " dBm");
    }
  }
  
  logLine("[JAM] Power update complete");
}

// Web server handlers
const char kHtml[] = R"HTML(
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>CC1101 JAMMER</title><style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:#020504;color:#86f28a;font-family:'Courier New',monospace;font-size:12px;line-height:1.4}
header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid #1a3a1e;background:#030705}
h1{font-size:17px;letter-spacing:.12em;color:#a0f5a4}
.sub{font-size:10px;color:#4fbf59;margin-top:2px}
#progWrap{height:3px;background:#060e07}
#progBar{height:3px;background:#4fbf59;width:0;transition:width .8s linear}
main{padding:10px;max-width:920px;margin:0 auto;display:flex;flex-direction:column;gap:8px}
.card{background:#030705;border:1px solid #1a3a1e;padding:10px}
.card h2{font-size:10px;color:#4fbf59;letter-spacing:.12em;text-transform:uppercase;border-bottom:1px solid #122814;padding-bottom:5px;margin-bottom:8px}
.banner{padding:14px;text-align:center;border:2px solid #1a3a1e}
.bt{font-size:22px;font-weight:bold;letter-spacing:.18em}
.bs{font-size:11px;margin-top:5px}
.ban-on{border-color:#4fbf59;background:#060f07}
.ban-off{border-color:#3a1218;background:#030504}
@keyframes pulse{0%,100%{box-shadow:0 0 6px rgba(134,242,138,.15)}50%{box-shadow:0 0 20px rgba(134,242,138,.45)}}
.ban-on{animation:pulse 2.2s ease-in-out infinite}
.sg{display:grid;grid-template-columns:repeat(auto-fill,minmax(105px,1fr));gap:5px}
.s{background:#020504;border:1px solid #1a3a1e;padding:6px 8px}
.sl{font-size:8px;color:#4fbf59;text-transform:uppercase;letter-spacing:.1em;white-space:nowrap}
.sv{font-size:15px;font-weight:bold;margin-top:2px}
.su{font-size:8px;color:#4fbf59;margin-left:1px}
.warm{color:#f5d87c!important}.hot{color:#f28a86!important}.lo{color:#f5d87c!important}.crit{color:#f28a86!important}
.band{margin-bottom:10px}
.bl{font-size:10px;color:#4fbf59;display:flex;justify-content:space-between;align-items:center;margin-bottom:3px}
canvas{display:block;width:100%}
canvas.sw{height:92px;border:1px solid #122814;background:#020504}
canvas.sp{height:44px;border:1px solid #122814;background:#020504}
.row{display:flex;flex-wrap:wrap;gap:10px}
.col{flex:1;min-width:140px}
.rrow{display:flex;gap:7px;align-items:center;margin-bottom:4px}
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0}
.on{background:#86f28a}.off{background:#f28a86}
hr{border:none;border-top:1px solid #122814;margin:8px 0}
label{font-size:10px;color:#4fbf59;display:block;margin-bottom:3px}
input[type=range]{width:100%;accent-color:#86f28a;margin:2px 0}
input[type=number]{background:#020504;border:1px solid #1a3a1e;color:#86f28a;padding:3px 6px;font-family:inherit;font-size:11px;width:100%}
button{padding:6px 11px;background:#0a1e0c;color:#86f28a;border:1px solid #2a5a2e;cursor:pointer;font-family:inherit;font-size:11px;letter-spacing:.04em}
button:hover{background:#142a16}
button.d{background:#140608;border-color:#4a1820;color:#f28a86}
button.d:hover{background:#200a10}
.err{color:#f28a86;font-size:10px;margin-top:2px}
pre{margin:0;padding:8px;background:#020504;border:1px solid #122814;height:28vh;overflow-y:auto;font-size:10px;line-height:1.6;color:#5fbf69}
#connDot{width:9px;height:9px;border-radius:50%;background:#f28a86}
</style></head><body>
<header>
  <div><h1>CC1101 JAMMER</h1>
  <div class="sub">ESP32-S3 &bull; 300–320 MHz + 390–436 MHz &bull; Dual-band FM noise sweep</div></div>
  <div style="text-align:right">
    <div id="mission" style="font-size:9px;color:#4fbf59;margin-bottom:5px">—</div>
    <div id="connDot"></div>
  </div>
</header>
<div id="progWrap"><div id="progBar"></div></div>
<main>

<div class="banner ban-off" id="banner">
  <div class="bt" id="bt">INITIALIZING</div>
  <div class="bs" id="bs"></div>
</div>

<div class="card">
  <h2>System Metrics</h2>
  <div class="sg">
    <div class="s"><div class="sl">Uptime</div><div class="sv" id="mUp">—</div></div>
    <div class="s"><div class="sl">CC1101 TX</div><div class="sv" id="mPow">—<span class="su">dBm</span></div></div>
    <div class="s"><div class="sl">Eff. Power</div><div class="sv" id="mEff">—<span class="su">dBm</span></div></div>
    <div class="s"><div class="sl">Eff. Watts</div><div class="sv" id="mW">—<span class="su">mW</span></div></div>
    <div class="s"><div class="sl">Temp</div><div class="sv" id="mTmp">—<span class="su">°C</span></div></div>
    <div class="s"><div class="sl">Free Heap</div><div class="sv" id="mH">—<span class="su">kB</span></div></div>
    <div class="s"><div class="sl">Min Heap</div><div class="sv" id="mMH">—<span class="su">kB</span></div></div>
    <div class="s"><div class="sl">Dwell</div><div class="sv" id="mDw">—<span class="su">ms</span></div></div>
    <div class="s"><div class="sl">Hops R1</div><div class="sv" id="mH1">—</div></div>
    <div class="s"><div class="sl">Hops R2</div><div class="sv" id="mH2">—</div></div>
    <div class="s"><div class="sl">Hops/sec</div><div class="sv" id="mHR">—</div></div>
    <div class="s"><div class="sl">AP Clients</div><div class="sv" id="mCl">—</div></div>
  </div>
</div>

<div class="card">
  <h2>Live Frequency Sweep</h2>
  <div class="band">
    <div class="bl">
      <span><span class="dot on" id="d1"></span>&nbsp;Radio 1 — 300–320 MHz&nbsp;<small style="color:#2a6a2e">(Honda 303.825 · Toyota 314.98 · Ford/GM 315 · Linear 318)</small></span>
      <span id="f1c" style="color:#86f28a;font-weight:bold">—</span>
    </div>
    <canvas class="sw" id="c1"></canvas>
  </div>
  <div class="band">
    <div class="bl">
      <span><span class="dot on" id="d2"></span>&nbsp;Radio 2 — 390–436 MHz&nbsp;<small style="color:#2a6a2e">(LiftMaster 390 · Holtek 418 · Somfy 433.42 · EU 433.92 · Nero 434.42)</small></span>
      <span id="f2c" style="color:#86f28a;font-weight:bold">—</span>
    </div>
    <canvas class="sw" id="c2"></canvas>
  </div>
</div>

<div class="card">
  <h2>2-Minute History</h2>
  <div class="row">
    <div class="col">
      <div class="bl"><span style="color:#4fbf59">Temperature (°C)</span><span id="tNow" style="font-weight:bold">—</span></div>
      <canvas class="sp" id="cT"></canvas>
    </div>
    <div class="col">
      <div class="bl"><span style="color:#4fbf59">Free Heap (kB)</span><span id="hNow" style="font-weight:bold">—</span></div>
      <canvas class="sp" id="cH"></canvas>
    </div>
  </div>
</div>

<div class="card">
  <h2>Radio Status</h2>
  <div class="row">
    <div class="col">
      <div class="rrow"><span class="dot off" id="r1d"></span><strong>Radio 1 — 300–320 MHz</strong></div>
      <div id="r1s" class="sub">—</div><div id="r1e" class="err"></div>
    </div>
    <div class="col">
      <div class="rrow"><span class="dot off" id="r2d"></span><strong>Radio 2 — 390–436 MHz</strong></div>
      <div id="r2s" class="sub">—</div><div id="r2e" class="err"></div>
    </div>
  </div>
</div>

<div class="card">
  <h2>Controls</h2>
  <div class="row" style="margin-bottom:10px">
    <div class="col">
      <label>TX Power: <strong id="pv">10</strong> dBm</label>
      <input type="range" id="jp" min="0" max="7" value="7" step="1">
      <div style="font-size:9px;color:#3a7a3e;margin-top:2px">−30 −20 −15 −10 0 +5 +7 +10 dBm</div>
    </div>
    <div class="col" style="display:flex;flex-direction:column;gap:6px;justify-content:flex-end">
      <button id="tog">Start Jamming</button>
      <button id="apow" class="d">Apply Power</button>
    </div>
  </div>
  <hr>
  <div class="row" style="margin-bottom:10px">
    <div class="col"><label>External Amp Gain (dB)</label><input type="number" id="ag" min="0" max="60" value="20" style="width:85px"></div>
    <div class="col" style="display:flex;align-items:flex-end"><button id="aamp">Apply Amp</button></div>
  </div>
  <hr>
  <h2 style="margin-bottom:8px">Sweep Tuning</h2>
  <div class="row">
    <div class="col"><label>Dwell / hop (ms)</label><input type="number" id="sd" min="1" max="500" value="5" style="width:75px"></div>
    <div class="col">
      <label>Steps (R1 / R2)</label>
      <input type="number" id="ss1" min="2" max="100" value="25" style="width:60px">
      <input type="number" id="ss2" min="2" max="100" value="47" style="width:60px;margin-top:4px">
    </div>
    <div class="col">
      <label>Span MHz (R1 / R2)</label>
      <input type="number" id="sp1" min="0.1" max="50" step="0.5" value="20.0" style="width:65px">
      <input type="number" id="sp2" min="0.1" max="80" step="0.5" value="46.0" style="width:65px;margin-top:4px">
    </div>
    <div class="col" style="display:flex;align-items:flex-end"><button id="asw">Apply Sweep</button></div>
  </div>
</div>

<div class="card">
  <h2>System Log &nbsp;<button id="dl" style="font-size:9px;padding:2px 7px">Download</button></h2>
  <pre id="log"></pre>
</div>

</main><script>
const PT=[-30,-20,-15,-10,0,5,7,10];
let T={},ph1=0,ph2=0,lastP=Date.now();
const HS=120,hT=new Array(HS).fill(null),hH=new Array(HS).fill(null);
let hi=0;
const TL=50,tr1=[],tr2=[];
const MK1=[{f:303.825,l:'Honda'},{f:310,l:'Chmb'},{f:314.98,l:'Toyot'},{f:315,l:'Ford'},{f:318,l:'Line'}];
const MK2=[{f:390,l:'Lift'},{f:418,l:'Holt'},{f:433.42,l:'Somfy'},{f:433.92,l:'EU'},{f:434.42,l:'Nero'}];

function ff(f){return f?(+f).toFixed(4)+' MHz':'—'}
function fu(ms){const s=Math.floor(ms/1000),m=Math.floor(s/60),h=Math.floor(m/60),d=Math.floor(h/24);
  return d?`${d}d ${h%24}h ${m%60}m`:h?`${h}h ${m%60}m ${s%60}s`:`${m}m ${s%60}s`}
function ct(v){return v>80?'hot':v>65?'warm':''}
function ch(kb){return kb<30?'crit':kb<60?'lo':''}

function updTr(arr,norm){
  arr.forEach(t=>t.age++);
  while(arr.length&&arr[0].age>=TL)arr.shift();
  arr.push({x:Math.max(0,Math.min(1,norm)),age:0});
}

function drawSw(id,freq,ctr,span,active,trail,marks){
  const cv=document.getElementById(id);if(!cv)return;
  const W=cv.offsetWidth||400,H=92;cv.width=W;cv.height=H;
  const ctx=cv.getContext('2d');
  const lo=ctr-span/2,sp=Math.max(span,0.001);
  const tx=f=>Math.max(0,Math.min(W,(f-lo)/sp*W));
  ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
  // Subtle grid
  ctx.strokeStyle='#0a180c';ctx.lineWidth=1;ctx.setLineDash([2,10]);
  for(let i=1;i<10;i++){const x=i/10*W;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H-14);ctx.stroke();}
  ctx.setLineDash([]);
  // Known freq markers
  marks.forEach(({f,l})=>{
    if(f<lo||f>lo+sp)return;
    const x=tx(f);
    ctx.strokeStyle='rgba(74,180,84,0.4)';ctx.lineWidth=1;ctx.setLineDash([3,4]);
    ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H-15);ctx.stroke();ctx.setLineDash([]);
    ctx.fillStyle='rgba(74,180,84,0.7)';ctx.font='8px monospace';ctx.textAlign='center';ctx.fillText(l,x,H-16);
  });
  // Heat trail
  trail.forEach(t=>{
    const a=(1-t.age/TL)*0.5,bw=55,bx=t.x*W;
    const g=ctx.createLinearGradient(bx-bw,0,bx+bw,0);
    g.addColorStop(0,'rgba(80,240,110,0)');
    g.addColorStop(.5,`rgba(80,240,110,${a})`);
    g.addColorStop(1,'rgba(80,240,110,0)');
    ctx.fillStyle=g;ctx.fillRect(bx-bw,0,bw*2,H-14);
  });
  if(active&&freq){
    const cx=tx(freq);
    // Wide glow
    const g=ctx.createLinearGradient(cx-80,0,cx+80,0);
    g.addColorStop(0,'rgba(134,242,138,0)');g.addColorStop(.5,'rgba(134,242,138,0.22)');g.addColorStop(1,'rgba(134,242,138,0)');
    ctx.fillStyle=g;ctx.fillRect(cx-80,0,160,H-14);
    // Cursor line
    ctx.strokeStyle='#86f28a';ctx.lineWidth=2;
    ctx.beginPath();ctx.moveTo(cx,0);ctx.lineTo(cx,H-14);ctx.stroke();
    // Cursor dot
    ctx.fillStyle='#86f28a';ctx.beginPath();ctx.arc(cx,(H-14)/2,4,0,Math.PI*2);ctx.fill();
    // Top notch
    ctx.fillRect(Math.max(0,cx-2),0,4,4);
  } else if(!active){
    ctx.strokeStyle='#2a1216';ctx.lineWidth=1;
    ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H-14);ctx.stroke();
  }
  // Axis bar
  ctx.fillStyle='#0a180c';ctx.fillRect(0,H-14,W,14);
  ctx.fillStyle='#3a7a3e';ctx.font='9px monospace';
  ctx.textAlign='left';ctx.fillText(lo.toFixed(1)+' MHz',3,H-3);
  ctx.textAlign='right';ctx.fillText((lo+sp).toFixed(1)+' MHz',W-3,H-3);
  if(active&&freq){ctx.fillStyle='#86f28a';ctx.textAlign='center';ctx.fillText((+freq).toFixed(4)+' MHz',tx(freq),H-3);}
}

function drawSp(id,data,color,minH,maxH){
  const cv=document.getElementById(id);if(!cv)return;
  const W=cv.offsetWidth||280,H=44;cv.width=W;cv.height=H;
  const ctx=cv.getContext('2d');
  ctx.fillStyle='#020504';ctx.fillRect(0,0,W,H);
  const vd=data.filter(v=>v!==null);if(vd.length<2)return;
  const mn=minH??Math.min(...vd),mx=maxH??Math.max(...vd),rng=Math.max(mx-mn,0.5);
  const toY=v=>H-4-((v-mn)/rng*(H-8));
  // Fill
  ctx.beginPath();let fs=true;
  data.forEach((v,i)=>{if(v===null){fs=true;return;}const x=i/(HS-1)*W,y=toY(v);if(fs){ctx.moveTo(x,H-4);ctx.lineTo(x,y);fs=false;}else ctx.lineTo(x,y);});
  ctx.lineTo(W,H-4);ctx.closePath();
  ctx.fillStyle=color+'28';ctx.fill();
  // Line
  ctx.strokeStyle=color;ctx.lineWidth=1.5;ctx.beginPath();fs=true;
  data.forEach((v,i)=>{if(v===null){fs=true;return;}const x=i/(HS-1)*W,y=toY(v);if(fs){ctx.moveTo(x,y);fs=false;}else ctx.lineTo(x,y);});
  ctx.stroke();
  // Range labels
  ctx.fillStyle=color+'90';ctx.font='8px monospace';
  ctx.textAlign='left';ctx.fillText(mn.toFixed(0),2,H-3);
  ctx.textAlign='right';ctx.fillText(mx.toFixed(0),W-2,H-3);
}

function applyTelemetry(t){
  T=t;
  const now=Date.now(),dt=(now-lastP)/1000;lastP=now;
  // 24h progress
  const pct=Math.min(100,t.uptime_ms/864000);
  document.getElementById('progBar').style.width=pct+'%';
  document.getElementById('mission').textContent=fu(t.uptime_ms)+' / 24h  ('+pct.toFixed(1)+'%)';
  // Banner
  const jam=t.jamming_enabled;
  document.getElementById('banner').className='banner '+(jam?'ban-on':'ban-off');
  document.getElementById('bt').textContent=jam?'◉ JAMMING ACTIVE ◉':'STANDBY';
  document.getElementById('bt').style.color=jam?'#86f28a':'#f28a86';
  const bands=[];if(t.radio1_active)bands.push('300–320 MHz');if(t.radio2_active)bands.push('390–436 MHz');
  document.getElementById('bs').textContent=jam&&bands.length
    ?`${bands.join(' + ')} | ${t.jam_power}dBm + ${t.amp_gain_db}dB amp = ${t.eff_power_dbm}dBm (${(t.eff_power_w*1000).toFixed(0)}mW)`
    :(jam?'No radios active':'Ready — press Start Jamming');
  document.getElementById('bs').style.color=jam&&bands.length?'#86f28a':jam?'#f28a86':'#4fbf59';
  document.getElementById('tog').textContent=jam?'Stop Jamming':'Start Jamming';
  // Metrics
  document.getElementById('mUp').textContent=fu(t.uptime_ms);
  document.getElementById('mPow').innerHTML=t.jam_power+'<span class="su">dBm</span>';
  document.getElementById('mEff').innerHTML=t.eff_power_dbm+'<span class="su">dBm</span>';
  document.getElementById('mW').innerHTML=(t.eff_power_w*1000).toFixed(0)+'<span class="su">mW</span>';
  const tEl=document.getElementById('mTmp');tEl.innerHTML=t.temp_c+'<span class="su">°C</span>';tEl.className='sv '+ct(+t.temp_c);
  const hkb=t.free_heap/1024;const hEl=document.getElementById('mH');hEl.innerHTML=hkb.toFixed(0)+'<span class="su">kB</span>';hEl.className='sv '+ch(hkb);
  const mhkb=t.min_heap/1024;document.getElementById('mMH').innerHTML=mhkb.toFixed(0)+'<span class="su">kB</span>';
  document.getElementById('mDw').innerHTML=t.sweep_dwell_ms+'<span class="su">ms</span>';
  const h1=t.hop_count1||0,h2=t.hop_count2||0,dh=(h1-ph1+h2-ph2),rate=dt>0?(dh/dt).toFixed(0):0;
  ph1=h1;ph2=h2;
  document.getElementById('mH1').textContent=h1.toLocaleString();
  document.getElementById('mH2').textContent=h2.toLocaleString();
  document.getElementById('mHR').innerHTML=rate+'<span class="su">/s</span>';
  document.getElementById('mCl').textContent=t.ap_clients??'—';
  // Trails + sweep canvases
  const sp1=Math.max(t.sweep_span1||20,0.001),sp2=Math.max(t.sweep_span2||46,0.001);
  updTr(tr1,(t.sweep_freq1-(t.sweep_center1-sp1/2))/sp1);
  updTr(tr2,(t.sweep_freq2-(t.sweep_center2-sp2/2))/sp2);
  drawSw('c1',t.sweep_freq1,t.sweep_center1||310,sp1,t.radio1_active,tr1,MK1);
  drawSw('c2',t.sweep_freq2,t.sweep_center2||413,sp2,t.radio2_active,tr2,MK2);
  document.getElementById('f1c').textContent=ff(t.sweep_freq1);
  document.getElementById('f2c').textContent=ff(t.sweep_freq2);
  // Dots
  const sd=(id,ok)=>{const d=document.getElementById(id);d.className='dot '+(ok?'on':'off');};
  sd('d1',t.radio1_active);sd('d2',t.radio2_active);sd('r1d',t.radio1_active);sd('r2d',t.radio2_active);
  // Radio status
  const rs=s=>s===2?'TRANSMITTING':s===1?'STANDBY':s===0?'INIT':'ERROR';
  document.getElementById('r1s').textContent=rs(t.radio1_status)+' — '+ff(t.sweep_freq1);
  document.getElementById('r2s').textContent=rs(t.radio2_status)+' — '+ff(t.sweep_freq2);
  document.getElementById('r1e').textContent=t.radio1_error||'';
  document.getElementById('r2e').textContent=t.radio2_error||'';
  // Controls sync
  if(t.jam_power_idx!==undefined){document.getElementById('jp').value=t.jam_power_idx;document.getElementById('pv').textContent=t.jam_power;}
  if(!document.activeElement.id.startsWith('s')){
    document.getElementById('sd').value=t.sweep_dwell_ms||5;
    document.getElementById('ss1').value=t.sweep_steps1||25;
    document.getElementById('ss2').value=t.sweep_steps2||47;
    document.getElementById('sp1').value=t.sweep_span1||20;
    document.getElementById('sp2').value=t.sweep_span2||46;
  }
  if(!document.getElementById('ag').matches(':focus'))document.getElementById('ag').value=t.amp_gain_db||20;
  // History
  hT[hi]=+t.temp_c;hH[hi]=t.free_heap/1024;hi=(hi+1)%HS;
  document.getElementById('tNow').textContent=(+t.temp_c).toFixed(1)+'°C';
  document.getElementById('tNow').className=ct(+t.temp_c);
  document.getElementById('hNow').textContent=(t.free_heap/1024).toFixed(0)+' kB';
  document.getElementById('hNow').className=ch(t.free_heap/1024);
  drawSp('cT',hT,'#86f28a',20,90);
  drawSp('cH',hH,'#4fbf59',0,320);
  document.getElementById('connDot').style.background='#86f28a';
}

let logTick=0;
async function poll(){
  try{
    const tr=await fetch('/api/telemetry');applyTelemetry(await tr.json());
    if(++logTick%5===0){  // fetch log every 5s instead of every second
      const lr=await fetch('/api/log');
      const log=document.getElementById('log');
      const atBot=log.scrollHeight-log.scrollTop<=log.clientHeight+40;
      log.textContent=await lr.text();
      if(atBot)log.scrollTop=log.scrollHeight;
    }
  }catch(e){
    document.getElementById('connDot').style.background='#f28a86';
    document.getElementById('bt').textContent='OFFLINE';
  }
}

document.getElementById('jp').addEventListener('input',e=>document.getElementById('pv').textContent=PT[+e.target.value]);
document.getElementById('tog').addEventListener('click',async()=>{try{const r=await fetch('/api/toggle',{method:'POST'});applyTelemetry({...T,jamming_enabled:(await r.json()).enabled});}catch(e){}});
document.getElementById('apow').addEventListener('click',async()=>{try{const i=+document.getElementById('jp').value;const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({power_idx:i})});const d=await r.json();if(d.success){T.jam_power=d.jam_power;T.jam_power_idx=i;}}catch(e){}});
document.getElementById('aamp').addEventListener('click',async()=>{try{await fetch('/api/amp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({gain_db:+document.getElementById('ag').value})});}catch(e){}});
document.getElementById('asw').addEventListener('click',async()=>{try{await fetch('/api/sweep',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dwell_ms:+document.getElementById('sd').value,steps1:+document.getElementById('ss1').value,steps2:+document.getElementById('ss2').value,span1_mhz:+document.getElementById('sp1').value,span2_mhz:+document.getElementById('sp2').value})});}catch(e){}});
document.getElementById('dl').addEventListener('click',()=>{const a=document.createElement('a');a.href='/api/log';a.download='jammer-log.txt';a.click();});

poll();setInterval(poll,1000);
</script></body></html>
)HTML";

static void handleRoot() {
  server.sendHeader("Cache-Control", "no-store, max-age=0");
  server.setContentLength(sizeof(kHtml) - 1);
  server.send(200, "text/html; charset=utf-8", "");
  server.sendContent(kHtml);
}

static void handleLog() {
  server.send(200, "text/plain; charset=utf-8", getLogsText());
}

static void handleTelemetry() {
  float tempC = temperatureRead();
  int8_t effDbm = jamPower + ampGainDb;
  float effWatts = powf(10.0f, effDbm / 10.0f) / 1000.0f; // dBm -> watts

  String json = "{";
  json += "\"uptime_ms\":"    + String(millis() - uptimeStart) + ",";
  json += "\"free_heap\":"    + String(ESP.getFreeHeap()) + ",";
  json += "\"temp_c\":"       + String(tempC, 1) + ",";
  json += "\"jamming_enabled\":" + String(jammingEnabled ? "true" : "false") + ",";
  json += "\"jam_power\":"    + String(jamPower) + ",";
  json += "\"jam_power_idx\":" + String(jamPowerIdx) + ",";
  json += "\"amp_gain_db\":"  + String(ampGainDb) + ",";
  json += "\"eff_power_dbm\":" + String(effDbm) + ",";
  json += "\"eff_power_w\":"  + String(effWatts, 3) + ",";

  // Sweep state
  json += "\"sweep_freq1\":"     + String(sweepFreq1, 4) + ",";
  json += "\"sweep_center1\":"   + String(SWEEP_1_CENTER_MHZ, 2) + ",";
  json += "\"sweep_span1\":"     + String(sweep1SpanMhz, 2) + ",";
  json += "\"sweep_steps1\":"    + String(sweep1Steps) + ",";
  json += "\"sweep_freq2\":"     + String(sweepFreq2, 4) + ",";
  json += "\"sweep_center2\":"   + String(SWEEP_2_CENTER_MHZ, 2) + ",";
  json += "\"sweep_span2\":"     + String(sweep2SpanMhz, 2) + ",";
  json += "\"sweep_steps2\":"    + String(sweep2Steps) + ",";
  json += "\"sweep_dwell_ms\":"  + String(sweepDwellMs) + ",";

  // Radio 1
  json += "\"radio1_status\":"   + String(radio1Status) + ",";
  json += "\"radio1_error\":\""  + jsonEscape(radio1Error) + "\",";
  json += "\"radio1_freq\":"     + String(sweepFreq1, 4) + ",";
  json += "\"radio1_active\":"   + String(radio1Status == 2 ? "true" : "false") + ",";

  // Radio 2
  json += "\"radio2_status\":"   + String(radio2Status) + ",";
  json += "\"radio2_error\":\""  + jsonEscape(radio2Error) + "\",";
  json += "\"radio2_freq\":"     + String(sweepFreq2, 4) + ",";
  json += "\"radio2_active\":"   + String(radio2Status == 2 ? "true" : "false") + ",";

  // 24-hour health metrics
  json += "\"hop_count1\":"      + String(hopCount1) + ",";
  json += "\"hop_count2\":"      + String(hopCount2) + ",";
  json += "\"min_heap\":"        + String(minFreeHeap == 0xFFFFFFFF ? ESP.getFreeHeap() : minFreeHeap) + ",";
  json += "\"ap_clients\":"      + String(WiFi.softAPgetStationNum());

  json += "}";
  server.send(200, "application/json; charset=utf-8", json);
}

static void handleToggle() {
  if (jammingEnabled) {
    stopJamming();
    oledNotify("STANDBY", "Jamming stopped");
  } else {
    jammingEnabled = true; // must be set before startJamming so sweep loop and watchdog see it
    startJamming();
    if (radio1Status != 2 && radio2Status != 2) {
      jammingEnabled = false; // both radios failed — don't pretend we're jamming
      oledNotify("RADIO FAIL", "Check connections");
    } else {
      oledNotify("JAMMING", "STARTED");
    }
  }
  
  // Save new state
  preferences.putBool("jamEnabled", jammingEnabled);
  
  String json = "{\"enabled\":" + String(jammingEnabled ? "true" : "false") + "}";
  server.send(200, "application/json; charset=utf-8", json);
}

static void handleSettings() {
  if (server.hasArg("plain")) {
    String body = server.arg("plain");
    body.trim();
    
    // Expected format: {"power_idx":7}
    int keyPos = body.indexOf("\"power_idx\":");
    if (keyPos >= 0) {
      int colonPos = keyPos + 12;
      int endPos = body.indexOf(",", colonPos);
      if (endPos == -1) endPos = body.indexOf("}", colonPos);
      if (endPos > colonPos) {
        String valStr = body.substring(colonPos, endPos);
        valStr.trim();
        uint8_t idx = (uint8_t)constrain(valStr.toInt(), 0, JAM_POWER_LEVELS - 1);
        updateJamPower(idx);
        {
          char l2[22];
          snprintf(l2, sizeof(l2), "%d dBm (eff %d dBm)",
                   (int)kPowerTable[idx], (int)kPowerTable[idx] + (int)ampGainDb);
          oledNotify("POWER SET", l2);
        }
      }
    } else {
      logLine("[HTTP] No power_idx in JSON body");
    }
  } else {
    logLine("[HTTP] No JSON body received");
  }
  
  // Return current state
  String json = "{\"success\":true,\"power_idx\":" + String(jamPowerIdx) + ",\"jam_power\":" + String(jamPower) + "}";
  server.send(200, "application/json; charset=utf-8", json);
}

static void handleSweepSettings() {
  if (server.hasArg("plain")) {
    String body = server.arg("plain");

    auto extractFloat = [&](const char* key, float& val, float mn, float mx) {
      int p = body.indexOf(key);
      if (p < 0) return;
      int c = p + strlen(key);
      int e = body.indexOf(",", c); if (e < 0) e = body.indexOf("}", c);
      if (e > c) { float v = body.substring(c, e).toFloat(); val = constrain(v, mn, mx); }
    };
    auto extractInt = [&](const char* key, uint32_t& val, uint32_t mn, uint32_t mx) {
      int p = body.indexOf(key);
      if (p < 0) return;
      int c = p + strlen(key);
      int e = body.indexOf(",", c); if (e < 0) e = body.indexOf("}", c);
      if (e > c) { uint32_t v = (uint32_t)body.substring(c, e).toInt(); val = constrain(v, mn, mx); }
    };

    extractInt(  "\"dwell_ms\":",  sweepDwellMs,   1,   500);
    uint32_t s1 = sweep1Steps, s2 = sweep2Steps;
    extractInt(  "\"steps1\":",    s1,             2,   100); sweep1Steps = (uint8_t)s1;
    extractInt(  "\"steps2\":",    s2,             2,   100); sweep2Steps = (uint8_t)s2;
    extractFloat("\"span1_mhz\":", sweep1SpanMhz, 0.1f, 50.0f);
    extractFloat("\"span2_mhz\":", sweep2SpanMhz, 0.1f, 80.0f);

    preferences.putInt("sweepDwell",  (int)sweepDwellMs);
    preferences.putInt("sweep1Steps", sweep1Steps);
    preferences.putInt("sweep2Steps", sweep2Steps);
    preferences.putFloat("sweep1Span", sweep1SpanMhz);
    preferences.putFloat("sweep2Span", sweep2SpanMhz);

    logLine("[SWEEP] dwell=" + String(sweepDwellMs) + "ms steps=" +
            String(sweep1Steps) + "/" + String(sweep2Steps) +
            " span=" + String(sweep1SpanMhz,2) + "/" + String(sweep2SpanMhz,2) + "MHz");
  }
  server.send(200, "application/json; charset=utf-8",
    "{\"success\":true,\"dwell_ms\":" + String(sweepDwellMs) +
    ",\"steps1\":" + String(sweep1Steps) +
    ",\"steps2\":" + String(sweep2Steps) +
    ",\"span1_mhz\":" + String(sweep1SpanMhz, 2) +
    ",\"span2_mhz\":" + String(sweep2SpanMhz, 2) + "}");
}

static void handleAmpSettings() {
  if (server.hasArg("plain")) {
    String body = server.arg("plain");
    int p = body.indexOf("\"gain_db\":");
    if (p >= 0) {
      int c = p + 10;
      int e = body.indexOf(",", c); if (e < 0) e = body.indexOf("}", c);
      if (e > c) {
        ampGainDb = (int8_t)constrain(body.substring(c, e).toInt(), 0, 60);
        preferences.putInt("ampGainDb", ampGainDb);
        logLine("[AMP] Gain set to " + String(ampGainDb) + " dB");
      }
    }
  }
  server.send(200, "application/json; charset=utf-8",
    "{\"success\":true,\"gain_db\":" + String(ampGainDb) + "}");
}

static void handleHealth() {
  String json = "{";
  json += "\"ok\":true,";
  json += "\"uptime_ms\":" + String(millis() - uptimeStart) + ",";
  json += "\"heap\":" + String(ESP.getFreeHeap()) + ",";
  json += "\"ap_clients\":" + String(WiFi.softAPgetStationNum());
  json += "}";
  server.send(200, "application/json; charset=utf-8", json);
}

static void handleNotFound() {
  const String uri = server.uri();
  logLine("[HTTP] 404 " + uri);
  server.send(404, "text/plain", "404: Not found");
}

void setup() {
  // Shorter delay for Serial to initialize on ESP32-S3 in production
  Serial.begin(115200);

  // OLED init — SW_I2C bit-bangs GPIO17/18 directly; no Wire needed.
  // begin() always returns true for SW_I2C so just call it and force oledOk.
  u8g2.begin();
  u8g2.setContrast(255);  // max brightness — some panels boot dim
  oledOk = true;
  Serial.println("[OLED] SW_I2C init done (GPIO17=SDA GPIO18=SCL)");

  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_7x13_tf);
  u8g2.drawStr(18, 22, "CC1101");
  u8g2.drawStr(12, 38, "JAMMER");
  u8g2.setFont(u8g2_font_5x7_tf);
  u8g2.drawStr(14, 54, "ESP32-S3 BOOTING...");
  u8g2.sendBuffer();

  // Wait for Serial to be ready (timeout after 500ms for production)
  unsigned long start = millis();
  while (!Serial && (millis() - start) < 500) {
    delay(10);
  }
  
  // Immediate debug output to verify boot
  Serial.println("=== CAR-KEY-KILLER BOOT START ===");
  Serial.flush();
  
  uptimeStart = millis();
  
  // Load preferences
  preferences.begin("jammer4", false);
  jammingEnabled  = preferences.getBool("jamEnabled",   JAMMING_ENABLED);
  jamPowerIdx     = (uint8_t)preferences.getInt("jamPowerIdx", DEFAULT_JAM_POWER_IDX);
  ampGainDb       = (int8_t) preferences.getInt("ampGainDb",   DEFAULT_AMP_GAIN_DB);
  sweepDwellMs    = (uint32_t)preferences.getInt("sweepDwell",  SWEEP_DWELL_MS);
  sweep1Steps     = (uint8_t)preferences.getInt("sweep1Steps", SWEEP_1_STEPS);
  sweep2Steps     = (uint8_t)preferences.getInt("sweep2Steps", SWEEP_2_STEPS);
  sweep1SpanMhz   = preferences.getFloat("sweep1Span", SWEEP_1_SPAN_MHZ);
  sweep2SpanMhz   = preferences.getFloat("sweep2Span", SWEEP_2_SPAN_MHZ);
  if (jamPowerIdx >= JAM_POWER_LEVELS) jamPowerIdx = DEFAULT_JAM_POWER_IDX;
  if (sweepDwellMs < 1) sweepDwellMs = 1;
  if (sweep1Steps < 2)  sweep1Steps  = 2;
  if (sweep2Steps < 2)  sweep2Steps  = 2;
  jamPower = kPowerTable[jamPowerIdx];
  logLine("[NVS] jamEnabled=" + String(jammingEnabled) + " jamPowerIdx=" + String(jamPowerIdx) +
          " (" + String(jamPower) + " dBm) ampGain=" + String(ampGainDb) +
          "dB sweepDwell=" + String(sweepDwellMs) + "ms");
  
  logLine("[BOOT] CC1101 Key-Fob Jammer starting");
  logLine("[BOOT] ESP32-S3 DevKitC-1");
  Serial.flush();

  // Rotary encoder — interrupt on CLK falling edge
  pinMode(ENC_CLK_PIN, INPUT_PULLUP);
  pinMode(ENC_DT_PIN,  INPUT_PULLUP);
  encLastClk = digitalRead(ENC_CLK_PIN);
  attachInterrupt(digitalPinToInterrupt(ENC_CLK_PIN), encISR, CHANGE);
  logLine("[ENC] Rotary encoder ready GPIO14=CLK GPIO21=DT");

  oledBootMsg("SPI init...");

  // Initialize SPI (required for CC1101 communication)
  Serial.println("[SPI] Initializing SPI bus...");
  Serial.flush();

  // Drive CS pins HIGH before SPI init to prevent bus collisions
  pinMode(CC1101_1_CS, OUTPUT);
  digitalWrite(CC1101_1_CS, HIGH);
  pinMode(CC1101_2_CS, OUTPUT);
  digitalWrite(CC1101_2_CS, HIGH);
  delay(10);
  
  // Initialize the FSPI bus on the explicit ESP32-S3 pins
  spi.begin(SPI_SCK_PIN, SPI_MISO_PIN, SPI_MOSI_PIN, -1);
  // Pull MISO high to prevent floating bus reads from returning garbage
  pinMode(SPI_MISO_PIN, INPUT_PULLUP);
  logLine("[SPI] SPI bus initialized on SCK=" + String(SPI_SCK_PIN) +
          " MISO=" + String(SPI_MISO_PIN) + " MOSI=" + String(SPI_MOSI_PIN) +
          " speed=" + String(SPI_SPEED_HZ));
  delay(150); // Allow CC1101 VCC to stabilize
  
  oledBootMsg("WiFi AP start...");

  // Start WiFi AP
  Serial.println("[DEBUG] Starting WiFi AP...");
  Serial.flush();
  WiFi.persistent(false);
  WiFi.setSleep(false);
  WiFi.mode(WIFI_MODE_AP);
  WiFi.softAPdisconnect(true);
  delay(100);
  WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1), IPAddress(255, 255, 255, 0));

  bool apOk = false;
  for (int attempt = 1; attempt <= 5 && !apOk; ++attempt) {
    if (strlen(WIFI_AP_PASS) == 0) {
      apOk = WiFi.softAP(WIFI_AP_SSID, nullptr, 1, 0, 4);
    } else {
      apOk = WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASS, 1, 0, 4);
    }
    Serial.println("[DEBUG] WiFi.softAP attempt " + String(attempt) + ": " + (apOk ? "OK" : "FAILED"));
    Serial.flush();
    if (!apOk) {
      delay(300);
    }
  }
  Serial.println(String("[DEBUG] WiFi.softAP result: ") + (apOk ? "OK" : "FAILED"));
  Serial.flush();
  if (!apOk) {
    logLine("[WIFI] softAP failed");
    Serial.println("[ERROR] WiFi softAP failed");
    Serial.flush();
  }

  delay(250);
  IPAddress ip = WiFi.softAPIP();
  logLine("[WIFI] AP started: " + String(WIFI_AP_SSID) + " IP: " + ip.toString());
  Serial.println("[WIFI] AP SSID: " + String(WIFI_AP_SSID));
  Serial.println("[WIFI] AP IP: " + ip.toString());
  Serial.flush();
  
  if (MDNS.begin("killer")) {
    MDNS.addService("http", "tcp", WEB_PORT);
    Serial.println("[MDNS] Started: http://killer.local");
  } else {
    Serial.println("[MDNS] Failed");
  }
  Serial.flush();
  
  // Setup web server routes
  server.on("/",                  handleRoot);
  server.on("/api/log",           handleLog);
  server.on("/api/telemetry",     handleTelemetry);
  server.on("/api/health",        handleHealth);
  server.on("/api/toggle",        HTTP_POST, handleToggle);
  server.on("/api/settings",      HTTP_POST, handleSettings);
  server.on("/api/sweep",         HTTP_POST, handleSweepSettings);
  server.on("/api/amp",           HTTP_POST, handleAmpSettings);
  server.onNotFound(handleNotFound);
  server.begin();

  // OTA firmware updates over WiFi (connect to 'killer' AP, upload via PlatformIO OTA)
  ArduinoOTA.setHostname("killer");
  ArduinoOTA.setPassword("killerpw");
  ArduinoOTA.onStart([]() { logLine("[OTA] Update starting..."); });
  ArduinoOTA.onEnd([]()   { logLine("[OTA] Update complete, rebooting"); });
  ArduinoOTA.onError([](ota_error_t e) { logLine("[OTA] Error: " + String(e)); });
  ArduinoOTA.begin();
  logLine("[OTA] Ready — hostname: killer, port: 3232");
  logLine("[HTTP] Server started on port " + String(WEB_PORT));
  Serial.println("[HTTP] Server started on port " + String(WEB_PORT));
  Serial.flush();
  
  // Start jamming immediately if enabled
  if (jammingEnabled) {
    oledBootMsg("Radio 1 init...");
    // (radio 2 init happens inside startJamming immediately after radio 1)
    startJamming();
    if (radio1Status == 2 || radio2Status == 2) {
      oledBootMsg("JAMMING - ACTIVE!");
    } else {
      oledBootMsg("RADIO INIT FAILED");
    }
  } else {
    radio1Status = -1;
    radio2Status = -1;
    radio1Error = "Disabled / not initialized";
    radio2Error = "Disabled / not initialized";
    logLine("[JAM] Jamming disabled on boot");
    oledBootMsg("Standby. Press START.");
  }
  delay(800); // hold boot result on display briefly before switching to live pages
}

// Advance one radio to the next sweep frequency.
static void tickSweep(CC1101& radio, uint8_t& step, uint8_t steps,
                      float center, float span, uint32_t& lastMs, float& curFreq,
                      uint32_t& hopCnt) {
  const uint32_t now = millis();
  if (now - lastMs < sweepDwellMs) return;
  lastMs = now;

  const float divisor = (steps > 1) ? (float)(steps - 1) : 1.0f;
  float freq = center - (span / 2.0f) + (span / divisor) * (float)step;
  if (fabsf(freq - curFreq) > 0.001f) {
    radio.standby();
    if (radio.setFrequency(freq) == RADIOLIB_ERR_NONE) {
      radio.transmitDirectAsync();
      curFreq = freq;
      hopCnt++;
    }
  }
  step = (step + 1) % steps;
}

void loop() {
  ArduinoOTA.handle();
  server.handleClient();
  oledTick();
  yield();

  const uint32_t now = millis();

  // Auto-reinit watchdog: if jamming should be active but a radio failed, retry every 30s
  if (jammingEnabled && now - lastReInitCheck >= 30000) {
    lastReInitCheck = now;
    bool needReinit = (radio1Status != 2 || radio2Status != 2);
    if (needReinit) {
      logLine("[WDT] Radio failure detected, attempting reinit...");
      oledNotify("RADIO REINIT", "R1 + R2...");
      startJamming();
    }
  }

  static uint32_t lastHeartbeat = 0;
  if (now - lastHeartbeat >= 5000) {
    lastHeartbeat = now;

    const uint32_t freeHeap = ESP.getFreeHeap();
    if (freeHeap < minFreeHeap) minFreeHeap = freeHeap;

    // Low-heap protection: heap below 15 KB risks crash — reboot cleanly
    if (freeHeap < 15360) {
      logLine("[CRIT] Heap critical: " + String(freeHeap) + "B — rebooting");
      delay(500);
      ESP.restart();
    }

    // Temperature alarm: log once per minute if over threshold
    const float tempC = temperatureRead();
    if (tempC > 75.0f && now - lastTempWarnMs > 60000) {
      lastTempWarnMs = now;
      logLine("[WARN] High temp: " + String(tempC, 1) + "°C");
    }

    Serial.println("[HEARTBEAT] up=" + String(now - uptimeStart) + "ms heap=" +
                   String(freeHeap) + " minHeap=" + String(minFreeHeap) +
                   " temp=" + String(tempC, 1) + "°C" +
                   " hops=" + String(hopCount1) + "/" + String(hopCount2));
    Serial.flush();
  }
  
  // Frequency sweep — hop both radios across their bands while jamming
  if (jammingEnabled) {
    if (radio1Status == 2)
      tickSweep(radio1, sweepStep1, sweep1Steps, SWEEP_1_CENTER_MHZ, sweep1SpanMhz, lastSweep1Ms, sweepFreq1, hopCount1);
    if (radio2Status == 2)
      tickSweep(radio2, sweepStep2, sweep2Steps, SWEEP_2_CENTER_MHZ, sweep2SpanMhz, lastSweep2Ms, sweepFreq2, hopCount2);
  }
  
  // Handle serial input for debugging
  if (Serial.available()) {
    String cmd = Serial.readStringUntil('\n');
    cmd.trim();
    if (cmd == "start") {
      startJamming();
    } else if (cmd == "stop") {
      stopJamming();
    } else if (cmd == "status") {
      Serial.println("Jamming: " + String(jammingEnabled ? "ON" : "OFF"));
      Serial.println("Power: " + String(jamPower) + " dBm");
      Serial.println("Radio 1 (300-320 MHz): " + String(jammingEnabled ? "TRANSMITTING" : "STANDBY"));
      Serial.println("Radio 2 (390-436 MHz): " + String(jammingEnabled ? "TRANSMITTING" : "STANDBY"));
    }
  }
}
