#include <Wire.h>
#include <RTClib.h>
#include <Adafruit_NeoPixel.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>   // captive portal — redirects all DNS queries to our IP
#include <ESPmDNS.h>     // celestialarc.local fallback

// =====================================================
// PINS
// =====================================================

#define IN1 D7
#define IN2 D8
#define IN3 D9
#define IN4 D10

#define LIMIT_SWITCH D6

#define BACKLIGHT_PIN D0
#define BACKLIGHT_COUNT 118

#define SUNMOON_PIN D1
#define SUNMOON_COUNT 9

#define SDA_PIN D4
#define SCL_PIN D5

#define TOUCH_PIN D3

// =====================================================
// 24C32 EEPROM
// Most DS3231 combo boards pull A0-A2 HIGH -> 0x57.
// If yours has A0-A2 LOW, change to 0x50.
// =====================================================

#define EEPROM_I2C_ADDR   0x57
#define EEPROM_MAGIC      0xCA
#define EEPROM_ADDR_MAGIC 0x0000  // 1 byte  -- magic
#define EEPROM_ADDR_TZ    0x0001  // 2 bytes -- int16_t offset in minutes

// =====================================================
// TIMEZONES  (38 real-world zones, UTC baseline)
// =====================================================

struct Timezone {
  const char* label;
  const char* abbr;
  int16_t     offset; // minutes from UTC
};

const Timezone TIMEZONES[] = {
  { "UTC-12:00", "IDLW",  -720 },
  { "UTC-11:00", "NUT",   -660 },
  { "UTC-10:00", "HST",   -600 },
  { "UTC-09:30", "MART",  -570 },
  { "UTC-09:00", "AKST",  -540 },
  { "UTC-08:00", "PST",   -480 },
  { "UTC-07:00", "MST",   -420 },
  { "UTC-06:00", "CST",   -360 },
  { "UTC-05:00", "EST",   -300 },
  { "UTC-04:00", "AST",   -240 },
  { "UTC-03:30", "NST",   -210 },
  { "UTC-03:00", "BRT",   -180 },
  { "UTC-02:00", "GST",   -120 },
  { "UTC-01:00", "AZOT",   -60 },
  { "UTC+00:00", "UTC",      0 },
  { "UTC+01:00", "CET",     60 },
  { "UTC+02:00", "EET",    120 },
  { "UTC+03:00", "MSK",    180 },
  { "UTC+03:30", "IRST",   210 },
  { "UTC+04:00", "GST",    240 },
  { "UTC+04:30", "AFT",    270 },
  { "UTC+05:00", "PKT",    300 },
  { "UTC+05:30", "IST",    330 },
  { "UTC+05:45", "NPT",    345 },
  { "UTC+06:00", "BST",    360 },
  { "UTC+06:30", "MMT",    390 },
  { "UTC+07:00", "ICT",    420 },
  { "UTC+08:00", "CST",    480 },
  { "UTC+08:45", "ACWST",  525 },
  { "UTC+09:00", "JST",    540 },
  { "UTC+09:30", "ACST",   570 },
  { "UTC+10:00", "AEST",   600 },
  { "UTC+10:30", "LHST",   630 },
  { "UTC+11:00", "SBT",    660 },
  { "UTC+12:00", "NZST",   720 },
  { "UTC+12:45", "CHAST",  765 },
  { "UTC+13:00", "TOT",    780 },
  { "UTC+14:00", "LINT",   840 }
};

const uint8_t TZ_COUNT = sizeof(TIMEZONES) / sizeof(TIMEZONES[0]);

int16_t activeTZOffset = 0; // loaded from EEPROM; defaults to UTC

// =====================================================
// RTC
// =====================================================

RTC_DS3231 rtc;

// =====================================================
// LEDS
// =====================================================

Adafruit_NeoPixel backlight(BACKLIGHT_COUNT, BACKLIGHT_PIN, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel sunMoon(SUNMOON_COUNT, SUNMOON_PIN, NEO_GRB + NEO_KHZ800);

// =====================================================
// MOTOR
// =====================================================

const uint8_t stepSequence[8][4] = {
  {1,0,0,0},
  {1,1,0,0},
  {0,1,0,0},
  {0,1,1,0},
  {0,0,1,0},
  {0,0,1,1},
  {0,0,0,1},
  {1,0,0,1}
};

const unsigned long stepDelayMs      = 1;
const long          MAX_POSITION     = 23500;
const long          HOMING_MAX_STEPS = 30000;

int  currentStepIndex = 0;
long currentPosition  = 0;
long targetPosition   = 0;

// =====================================================
// WIFI / WEBSERVER
// =====================================================

const char* AP_SSID = "CelestialArc";
WebServer   server(80);
DNSServer   dnsServer;              // captive portal DNS
const byte  DNS_PORT = 53;

bool pendingMotorTest = false;      // set by /test route, consumed in loop

// =====================================================
// TIMERS
// =====================================================

unsigned long lastStepTime      = 0;
int           lastPrintedMinute = -1;
int           lastTimeSlot      = -1;

// AP timeout: exit settings if no client connects within this window
const unsigned long AP_TIMEOUT_MS = 60000UL; // 60 seconds
unsigned long       apStartTime   = 0;
bool                apClientEverConnected = false;

// =====================================================
// TOUCH BUTTON
// =====================================================

bool          lastTouchState   = false;
unsigned long touchPressStart  = 0;
bool          longPressHandled = false;
const unsigned long LONG_PRESS_MS = 2000;

// =====================================================
// STATES
// =====================================================

enum SystemState { HOMING, STARTUP_SYNC, RUNNING, SETTINGS, FAULT };
SystemState systemState = HOMING;

// =====================================================
// 24C32 EEPROM HELPERS
// =====================================================

void eeprom_writeByte(uint16_t addr, uint8_t data) {
  Wire.beginTransmission(EEPROM_I2C_ADDR);
  Wire.write((uint8_t)(addr >> 8));
  Wire.write((uint8_t)(addr & 0xFF));
  Wire.write(data);
  Wire.endTransmission();
  delay(5); // 24C32 write cycle max 5ms
}

uint8_t eeprom_readByte(uint16_t addr) {
  Wire.beginTransmission(EEPROM_I2C_ADDR);
  Wire.write((uint8_t)(addr >> 8));
  Wire.write((uint8_t)(addr & 0xFF));
  Wire.endTransmission(false); // repeated start
  Wire.requestFrom((uint8_t)EEPROM_I2C_ADDR, (uint8_t)1);
  return Wire.available() ? Wire.read() : 0xFF;
}

void eeprom_writeInt16(uint16_t addr, int16_t value) {
  eeprom_writeByte(addr,     (uint8_t)((uint16_t)value >> 8));
  eeprom_writeByte(addr + 1, (uint8_t)((uint16_t)value & 0xFF));
}

int16_t eeprom_readInt16(uint16_t addr) {
  uint8_t hi = eeprom_readByte(addr);
  uint8_t lo = eeprom_readByte(addr + 1);
  return (int16_t)(((uint16_t)hi << 8) | lo);
}

void loadTimezoneFromEEPROM() {
  uint8_t magic = eeprom_readByte(EEPROM_ADDR_MAGIC);
  if (magic == EEPROM_MAGIC) {
    int16_t stored = eeprom_readInt16(EEPROM_ADDR_TZ);
    for (uint8_t i = 0; i < TZ_COUNT; i++) {
      if (TIMEZONES[i].offset == stored) {
        activeTZOffset = stored;
        Serial.print("TZ loaded from EEPROM: ");
        Serial.println(stored);
        return;
      }
    }
    Serial.println("EEPROM TZ unrecognised, defaulting UTC");
  } else {
    Serial.println("EEPROM uninitialised, defaulting UTC");
  }
  activeTZOffset = 0;
}

void saveTimezoneToEEPROM(int16_t offset) {
  eeprom_writeByte(EEPROM_ADDR_MAGIC, EEPROM_MAGIC);
  eeprom_writeInt16(EEPROM_ADDR_TZ, offset);
  Serial.print("TZ saved: ");
  Serial.println(offset);
}

// =====================================================
// TIME HELPERS
// =====================================================

DateTime applyOffset(DateTime utc, int16_t offsetMinutes) {
  uint32_t unix = utc.unixtime() + (int32_t)offsetMinutes * 60L;
  return DateTime(unix);
}

DateTime localNow() {
  return applyOffset(rtc.now(), activeTZOffset);
}

// =====================================================
// LED FUNCTIONS
// =====================================================

void setBacklightColor(uint8_t r, uint8_t g, uint8_t b) {
  for (int i = 0; i < BACKLIGHT_COUNT; i++)
    backlight.setPixelColor(i, backlight.Color(r, g, b));
  backlight.show();
}

void setSunMoonColor(uint8_t r, uint8_t g, uint8_t b) {
  for (int i = 0; i < SUNMOON_COUNT; i++)
    sunMoon.setPixelColor(i, sunMoon.Color(r, g, b));
  sunMoon.show();
}

void dayMode() {
  setBacklightColor(255, 180, 0);
  setSunMoonColor(255, 120, 20);
}

void nightMode() {
  setBacklightColor(0, 0, 0);
  setSunMoonColor(180, 180, 255);
}

void settingsMode_LED() {
  backlight.setBrightness(128);
  setBacklightColor(255, 0, 0);
}

void restoreNormalBrightness() {
  backlight.setBrightness(50);
}

// =====================================================
// MOTOR FUNCTIONS
// =====================================================

void writeMotorOutputs() {
  digitalWrite(IN1, stepSequence[currentStepIndex][0]);
  digitalWrite(IN2, stepSequence[currentStepIndex][1]);
  digitalWrite(IN3, stepSequence[currentStepIndex][2]);
  digitalWrite(IN4, stepSequence[currentStepIndex][3]);
}

void stepCW() {
  currentStepIndex = (currentStepIndex + 1) % 8;
  writeMotorOutputs();
  currentPosition++;
}

void stepCCW() {
  currentStepIndex = (currentStepIndex + 7) % 8;
  writeMotorOutputs();
  currentPosition--;
}

void motorOff() {
  digitalWrite(IN1, LOW);
  digitalWrite(IN2, LOW);
  digitalWrite(IN3, LOW);
  digitalWrite(IN4, LOW);
}

void fastTravelTo(long target) {
  const long MAX_TRAVEL = MAX_POSITION + 500;
  long steps = 0;
  while (currentPosition < target && steps < MAX_TRAVEL) { stepCW();  delay(1); steps++; }
  while (currentPosition > target && steps < MAX_TRAVEL) { stepCCW(); delay(1); steps++; }
  motorOff();
  lastStepTime = millis();
}

// =====================================================
// RTC PRINT
// =====================================================

void printTime(DateTime t) {
  Serial.printf("Local: %02d:%02d\n", t.hour(), t.minute());
}

// =====================================================
// TIME SLOT
// =====================================================

int get5MinuteSlot(DateTime t) { return t.minute() / 5; }

// =====================================================
// POSITION CALCULATION
// =====================================================

long calculatePosition(DateTime local) {
  int hour          = local.hour();
  int minute        = local.minute();
  int flooredMinute = (minute / 5) * 5;

  if ((hour > 6 && hour < 18) || (hour == 6) || (hour == 18 && flooredMinute == 0)) {
    long minutesSince6AM = ((hour - 6) * 60L) + flooredMinute;
    if (minutesSince6AM > 720) minutesSince6AM = 720;
    return (minutesSince6AM * MAX_POSITION) / 720L;
  }

  long minutesSince6PM;
  if (hour >= 18) minutesSince6PM = ((hour - 18) * 60L) + flooredMinute;
  else            minutesSince6PM = ((hour + 6)  * 60L) + flooredMinute;

  long position = MAX_POSITION - ((minutesSince6PM * MAX_POSITION) / 720L);
  return (position < 0) ? 0 : position;
}

// =====================================================
// LIGHTING
// =====================================================

void updateLighting(DateTime local) {
  int h = local.hour(), m = local.minute();
  if ((h > 6 && h < 18) || (h == 6) || (h == 18 && m == 0)) dayMode();
  else nightMode();
}

// =====================================================
// HOMING
// =====================================================

long homingSteps = 0;

void runHoming() {
  if (digitalRead(LIMIT_SWITCH) == LOW) {
    currentPosition = 0;
    motorOff();
    Serial.println("HOME FOUND");
    systemState = STARTUP_SYNC;
    return;
  }
  if (homingSteps >= HOMING_MAX_STEPS) {
    motorOff();
    Serial.println("HOMING FAILED");
    systemState = FAULT;
    return;
  }
  if (millis() - lastStepTime >= stepDelayMs) {
    lastStepTime = millis();
    stepCCW();
    homingSteps++;
  }
}

// =====================================================
// STARTUP SYNC
// =====================================================

void startupSync() {
  DateTime local = localNow();
  updateLighting(local);
  targetPosition = calculatePosition(local);
  Serial.print("Sync -> position ");
  Serial.println(targetPosition);
  fastTravelTo(targetPosition);
  lastTimeSlot = get5MinuteSlot(local);
  Serial.println("Sync complete -> RUNNING");
  systemState = RUNNING;
}

// =====================================================
// MOTOR TRACKING
// =====================================================

void updateMotor() {
  if (millis() - lastStepTime < stepDelayMs) return;
  lastStepTime = millis();
  if      (currentPosition < targetPosition) stepCW();
  else if (currentPosition > targetPosition) stepCCW();
  else    motorOff();
}

// =====================================================
// TOUCH BUTTON
// =====================================================

void handleTouchButton() {
  bool touched = (digitalRead(TOUCH_PIN) == HIGH);

  if (touched && !lastTouchState) {
    touchPressStart  = millis();
    longPressHandled = false;
  }

  if (touched && !longPressHandled) {
    if (millis() - touchPressStart >= LONG_PRESS_MS) {
      longPressHandled = true;
      if (systemState == RUNNING) enterSettingsMode();
    }
  }

  lastTouchState = touched;
}

// =====================================================
// MOTOR TEST SEQUENCE
// Called from loop() when pendingMotorTest is true.
// Blocking by design — this is a deliberate full-sweep test.
// Sequence: home → MAX_POSITION (dayMode) → home (nightMode) → RUNNING
// =====================================================

void runMotorTest() {
  pendingMotorTest = false;
  Serial.println("MOTOR TEST: starting");

  // ---- Phase 1: Home ----
  Serial.println("MOTOR TEST: homing");
  long safetySteps = 0;
  while (digitalRead(LIMIT_SWITCH) != LOW && safetySteps < HOMING_MAX_STEPS) {
    stepCCW();
    delay(1);
    safetySteps++;
  }

  if (digitalRead(LIMIT_SWITCH) != LOW) {
    // Homing failed during test — abort and go to FAULT
    motorOff();
    Serial.println("MOTOR TEST: homing failed — entering FAULT");
    systemState = FAULT;
    return;
  }

  currentPosition = 0;
  motorOff();
  Serial.println("MOTOR TEST: home found");
  delay(300);

  // ---- Phase 2: Sweep to MAX_POSITION in dayMode ----
  Serial.println("MOTOR TEST: sweeping to MAX (dayMode)");
  dayMode();
  fastTravelTo(MAX_POSITION);
  delay(500); // brief pause at end of arc so it's visible

  // ---- Phase 3: Return to home in nightMode ----
  Serial.println("MOTOR TEST: returning home (nightMode)");
  nightMode();
  fastTravelTo(0);
  delay(300);

  motorOff();
  Serial.println("MOTOR TEST: complete — resuming RUNNING");

  // ---- Phase 4: Exit settings and resume normally ----
  exitSettingsMode(activeTZOffset); // no TZ change, just clean resume
}

// =====================================================
// SETTINGS MODE — ENTER / EXIT
// =====================================================

void enterSettingsMode() {
  Serial.println("-> SETTINGS MODE");
  systemState = SETTINGS;
  settingsMode_LED();

  WiFi.softAP(AP_SSID);
  IPAddress apIP = WiFi.softAPIP(); // default: 192.168.4.1
  Serial.print("AP IP: ");
  Serial.println(apIP);

  // Captive portal: answer every DNS query with our own IP.
  // This makes Android/iOS/Windows detect a "login required" network
  // and pop the browser open automatically.
  dnsServer.start(DNS_PORT, "*", apIP);

  setupWebServer();
  server.begin();

  // mDNS: reachable at http://celestialarc.local as a named fallback
  if (MDNS.begin("celestialarc")) {
    MDNS.addService("http", "tcp", 80);
    Serial.println("mDNS: http://celestialarc.local");
  }

  // Start the no-connection timeout countdown
  apStartTime           = millis();
  apClientEverConnected = false;

  Serial.println("Web server started (60s timeout if no client connects)");
}

void exitSettingsMode(int16_t newOffset) {
  bool tzChanged = (newOffset != activeTZOffset);

  if (tzChanged) {
    activeTZOffset = newOffset;
    saveTimezoneToEEPROM(activeTZOffset);
    Serial.print("TZ changed to: ");
    Serial.println(activeTZOffset);
  }

  server.stop();
  dnsServer.stop();
  MDNS.end();
  WiFi.softAPdisconnect(true);
  WiFi.mode(WIFI_OFF);

  restoreNormalBrightness();

  DateTime local = localNow();
  updateLighting(local);

  if (tzChanged) {
    long newTarget = calculatePosition(local);
    Serial.print("Fast-travel -> ");
    Serial.println(newTarget);
    fastTravelTo(newTarget);
    targetPosition = newTarget;
  }

  lastTimeSlot = get5MinuteSlot(local);
  lastStepTime = millis();
  systemState  = RUNNING;
  Serial.println("-> RUNNING");
}

// =====================================================
// WEBPAGE HELPERS
// =====================================================

uint8_t tzIndexByOffset(int16_t offset) {
  for (uint8_t i = 0; i < TZ_COUNT; i++)
    if (TIMEZONES[i].offset == offset) return i;
  return 14; // UTC fallback
}

String formatHMS(DateTime t) {
  char buf[9];
  snprintf(buf, sizeof(buf), "%02d:%02d:%02d", t.hour(), t.minute(), t.second());
  return String(buf);
}

String formatDate(DateTime t) {
  const char* days[]   = {"Sunday","Monday","Tuesday","Wednesday",
                           "Thursday","Friday","Saturday"};
  const char* months[] = {"January","February","March","April","May","June",
                           "July","August","September","October","November","December"};
  return String(days[t.dayOfTheWeek()]) + ", " +
         String(t.day()) + " " +
         String(months[t.month() - 1]) + " " +
         String(t.year());
}

// =====================================================
// WEBPAGE BUILD
// =====================================================

String buildHTMLPage() {
  DateTime utcTime  = rtc.now();
  DateTime locTime  = applyOffset(utcTime, activeTZOffset);

  uint8_t  activeIdx = tzIndexByOffset(activeTZOffset);

  // Build timezone <option> list
  String tzOptions = "";
  for (uint8_t i = 0; i < TZ_COUNT; i++) {
    tzOptions += "<option value=\"";
    tzOptions += TIMEZONES[i].offset;
    tzOptions += "\"";
    if (i == activeIdx) tzOptions += " selected";
    tzOptions += ">";
    tzOptions += TIMEZONES[i].label;
    tzOptions += " \xe2\x80\x94 ";  // em dash UTF-8
    tzOptions += TIMEZONES[i].abbr;
    tzOptions += "</option>\n";
  }

  // Build JS timezone array (mirrors C++ array for client-side time math)
  String jsTZArray = "[\n";
  for (uint8_t i = 0; i < TZ_COUNT; i++) {
    jsTZArray += "{offset:";
    jsTZArray += TIMEZONES[i].offset;
    jsTZArray += ",abbr:\"";
    jsTZArray += TIMEZONES[i].abbr;
    jsTZArray += "\",label:\"";
    jsTZArray += TIMEZONES[i].label;
    jsTZArray += "\"}";
    if (i < TZ_COUNT - 1) jsTZArray += ",";
    jsTZArray += "\n";
  }
  jsTZArray += "]";

  String html = "";
  html.reserve(8000);

  html += "<!DOCTYPE html><html lang='en'><head>"
          "<meta charset='UTF-8'>"
          "<meta name='viewport' content='width=device-width,initial-scale=1.0'>"
          "<title>CelestialArc Settings</title>"
          "<style>"
          "*{box-sizing:border-box;margin:0;padding:0}"
          "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
          "background:#0f0f1a;color:#e0d8ff;min-height:100vh;"
          "display:flex;flex-direction:column;align-items:center;"
          "justify-content:center;padding:24px;gap:14px;}"
          "h1{font-size:1.25rem;font-weight:500;letter-spacing:.12em;"
          "color:#a090d0;text-transform:uppercase;}"
          ".card{background:#1a1830;border:1px solid #2e2a50;border-radius:16px;"
          "padding:24px 28px;width:100%;max-width:400px;}"
          ".lbl{font-size:.65rem;text-transform:uppercase;letter-spacing:.15em;"
          "color:#5a5080;margin-bottom:8px;}"
          ".time-row{display:flex;align-items:baseline;gap:10px;}"
          ".t-big{font-size:2.8rem;font-weight:300;letter-spacing:.04em;"
          "color:#fff;font-variant-numeric:tabular-nums;}"
          ".t-abbr{font-size:.85rem;color:#7060a0;}"
          ".date-ln{font-size:.88rem;color:#7060a0;margin-top:4px;margin-bottom:16px;}"
          ".divider{border:none;border-top:1px solid #2e2a50;margin:14px 0;}"
          ".utc-row{display:flex;align-items:center;justify-content:space-between;}"
          ".utc-lbl{font-size:.65rem;text-transform:uppercase;letter-spacing:.12em;color:#5a5080;}"
          ".utc-t{font-size:1.3rem;font-weight:300;letter-spacing:.04em;"
          "color:#5a5080;font-variant-numeric:tabular-nums;}"
          "select{width:100%;background:#0f0f1a;color:#e0d8ff;"
          "border:1px solid #2e2a50;border-radius:10px;padding:11px 36px 11px 14px;"
          "font-size:.88rem;appearance:none;cursor:pointer;"
          "background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'"
          " width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%237060a0'"
          " stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\");"
          "background-repeat:no-repeat;background-position:right 14px center;}"
          "select:focus{outline:none;border-color:#534AB7;}"
          ".preview-row{display:flex;align-items:baseline;gap:8px;margin-top:12px;min-height:30px;}"
          ".prev-t{font-size:1.6rem;font-weight:300;color:#c0b0ff;font-variant-numeric:tabular-nums;}"
          ".prev-lbl{font-size:.8rem;color:#5a5080;}"
          ".save-btn{background:#534AB7;color:#fff;border:none;border-radius:10px;"
          "padding:14px;font-size:.95rem;font-weight:500;cursor:pointer;"
          "width:100%;max-width:400px;transition:background .2s;}"
          ".save-btn:hover{background:#7F77DD;}"
          ".test-btn{background:#1a3a2a;color:#4dbb7a;border:1px solid #2a5a3a;"
          "border-radius:10px;padding:13px;font-size:.9rem;font-weight:500;"
          "cursor:pointer;width:100%;max-width:400px;transition:background .2s;}"
          ".test-btn:hover{background:#1e4a32;}"
          ".test-note{font-size:.68rem;color:#3a3060;text-align:center;"
          "max-width:400px;line-height:1.5;}"
          "</style></head><body>"
          "<h1>&#9732; CelestialArc</h1>";

  // --- Active timezone card ---
  html += "<div class='card'>"
          "<div class='lbl'>Active timezone &mdash; ";
  html += TIMEZONES[activeIdx].label;
  html += " (";
  html += TIMEZONES[activeIdx].abbr;
  html += ")</div>"
          "<div class='time-row'>"
          "<span class='t-big' id='localClock'>";
  html += formatHMS(locTime);
  html += "</span>"
          "<span class='t-abbr' id='localAbbr'>";
  html += TIMEZONES[activeIdx].abbr;
  html += "</span></div>"
          "<div class='date-ln' id='localDate'>";
  html += formatDate(locTime);
  html += "</div>"
          "<hr class='divider'>"
          "<div class='utc-row'>"
          "<span class='utc-lbl'>UTC</span>"
          "<span class='utc-t' id='utcClock'>";
  html += formatHMS(utcTime);
  html += "</span></div></div>";

  // --- Timezone selector card ---
  html += "<div class='card'>"
          "<div class='lbl'>Select timezone</div>"
          "<select id='tzSelect' onchange='onTZChange(this)'>\n";
  html += tzOptions;
  html += "</select>"
          "<div class='preview-row'>"
          "<span class='prev-t' id='previewClock'></span>"
          "<span class='prev-lbl' id='previewLabel'></span>"
          "</div></div>";

  // --- Save form ---
  html += "<form action='/save' method='POST'>"
          "<input type='hidden' name='tz' id='tzHidden' value='";
  html += activeTZOffset;
  html += "'>"
          "<button class='save-btn' type='submit'>Save &amp; Exit</button>"
          "</form>";

  // --- Motor test button ---
  html += "<form action='/test' method='POST'>"
          "<button class='test-btn' type='submit'>"
          "&#9654; Run Motor Test"
          "</button>"
          "</form>"
          "<div class='test-note'>"
          "Homes &rarr; sweeps to MAX (day) &rarr; returns home (night) &rarr; resumes"
          "</div>";

  // --- Script ---
  html += "<script>"
          "const TZ=";
  html += jsTZArray;
  html += ";\n"
          "const utcParts=document.getElementById('utcClock').textContent.split(':');\n"
          "let uh=+utcParts[0],um=+utcParts[1],us=+utcParts[2];\n"
          "let activeTZMin=";
  html += activeTZOffset;
  html += ";\n"
          "let selectedTZMin=activeTZMin;\n"
          "function pad(n){return String(n).padStart(2,'0');}\n"
          "function localFromUTC(uh,um,us,offMin){\n"
          "  let t=uh*3600+um*60+us+offMin*60;\n"
          "  t=((t%86400)+86400)%86400;\n"
          "  return{h:Math.floor(t/3600),m:Math.floor((t%3600)/60),s:t%60};\n"
          "}\n"
          "function tick(){\n"
          "  us++;if(us>=60){us=0;um++;}if(um>=60){um=0;uh++;}if(uh>=24)uh=0;\n"
          "  document.getElementById('utcClock').textContent=pad(uh)+':'+pad(um)+':'+pad(us);\n"
          "  const loc=localFromUTC(uh,um,us,activeTZMin);\n"
          "  document.getElementById('localClock').textContent=pad(loc.h)+':'+pad(loc.m)+':'+pad(loc.s);\n"
          "  if(selectedTZMin!==activeTZMin){\n"
          "    const p=localFromUTC(uh,um,us,selectedTZMin);\n"
          "    document.getElementById('previewClock').textContent=pad(p.h)+':'+pad(p.m)+':'+pad(p.s);\n"
          "  }\n"
          "}\n"
          "setInterval(tick,1000);\n"
          "function onTZChange(sel){\n"
          "  selectedTZMin=parseInt(sel.value);\n"
          "  document.getElementById('tzHidden').value=selectedTZMin;\n"
          "  const tz=TZ.find(t=>t.offset===selectedTZMin);\n"
          "  if(selectedTZMin===activeTZMin){\n"
          "    document.getElementById('previewClock').textContent='';\n"
          "    document.getElementById('previewLabel').textContent='';\n"
          "  } else {\n"
          "    const p=localFromUTC(uh,um,us,selectedTZMin);\n"
          "    document.getElementById('previewClock').textContent=pad(p.h)+':'+pad(p.m)+':'+pad(p.s);\n"
          "    document.getElementById('previewLabel').textContent=tz?tz.abbr+' '+tz.label:'';\n"
          "  }\n"
          "}\n"
          "</script></body></html>";

  return html;
}

// =====================================================
// WEB SERVER ROUTES
// =====================================================

void setupWebServer() {

  // ---- Captive portal detection endpoints ----
  // Each OS probes a known URL; if it doesn't get the expected response
  // it concludes there's a captive portal and pops the browser.
  // We redirect all of them to our main page.

  // iOS / macOS
  server.on("/hotspot-detect.html",        HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });
  server.on("/library/test/success.html",  HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });

  // Android
  server.on("/generate_204",               HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });
  server.on("/gen_204",                    HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });

  // Windows (NCSI)
  server.on("/ncsi.txt",                   HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });
  server.on("/connecttest.txt",            HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });
  server.on("/redirect",                   HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });

  // Catch-all: anything not matched above also redirects to main page.
  // This is the safety net that makes the captive portal fully robust —
  // any unknown probe URL from any OS still lands on the settings page.
  server.onNotFound([]() {
    server.sendHeader("Location", "/", true);
    server.send(302, "text/plain", "");
  });

  // ---- Main routes ----
  server.on("/", HTTP_GET, []() {
    server.send(200, "text/html", buildHTMLPage());
  });

  server.on("/save", HTTP_POST, []() {
    int16_t newOffset = activeTZOffset;

    if (server.hasArg("tz")) {
      long val = server.arg("tz").toInt();
      for (uint8_t i = 0; i < TZ_COUNT; i++) {
        if (TIMEZONES[i].offset == (int16_t)val) {
          newOffset = (int16_t)val;
          break;
        }
      }
    }

    uint8_t idx = tzIndexByOffset(newOffset);

    String resp = "<!DOCTYPE html><html lang='en'><head>"
                  "<meta charset='UTF-8'>"
                  "<meta name='viewport' content='width=device-width,initial-scale=1.0'>"
                  "<title>CelestialArc</title>"
                  "<style>"
                  "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
                  "background:#0f0f1a;color:#e0d8ff;min-height:100vh;display:flex;"
                  "flex-direction:column;align-items:center;justify-content:center;"
                  "padding:24px;text-align:center;gap:12px;}"
                  "h2{font-weight:400;color:#a090d0;font-size:1.2rem;}"
                  "p{color:#5a5080;font-size:.85rem;line-height:1.6;}"
                  ".tz{color:#c0b0ff;font-size:1rem;}"
                  "</style></head><body>"
                  "<h2>&#10003; Settings saved</h2>"
                  "<p class='tz'>";
    resp += TIMEZONES[idx].label;
    resp += " &mdash; ";
    resp += TIMEZONES[idx].abbr;
    resp += "</p><p>CelestialArc is syncing to the new timezone<br>"
            "and resuming normal operation.</p>"
            "<p>You can close this page.</p>"
            "</body></html>";

    server.send(200, "text/html", resp);
    exitSettingsMode(newOffset);
  });

  server.on("/test", HTTP_POST, []() {
    // Send the response immediately — the test sequence is blocking
    // so we can't run it inside the handler. Set a flag and let loop() do it.
    String resp = "<!DOCTYPE html><html lang='en'><head>"
                  "<meta charset='UTF-8'>"
                  "<meta name='viewport' content='width=device-width,initial-scale=1.0'>"
                  "<title>CelestialArc</title>"
                  "<style>"
                  "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
                  "background:#0f0f1a;color:#e0d8ff;min-height:100vh;display:flex;"
                  "flex-direction:column;align-items:center;justify-content:center;"
                  "padding:24px;text-align:center;gap:14px;}"
                  "h2{font-weight:400;color:#a090d0;font-size:1.2rem;}"
                  "p{color:#5a5080;font-size:.85rem;line-height:1.6;}"
                  ".steps{background:#1a1830;border:1px solid #2e2a50;border-radius:12px;"
                  "padding:20px 24px;text-align:left;max-width:320px;width:100%;}"
                  ".step{display:flex;align-items:center;gap:10px;padding:6px 0;"
                  "font-size:.85rem;color:#8070b0;border-bottom:1px solid #1e1c30;}"
                  ".step:last-child{border-bottom:none;}"
                  ".dot{width:8px;height:8px;border-radius:50%;background:#534AB7;flex-shrink:0;}"
                  "</style></head><body>"
                  "<h2>&#9654; Motor Test Running</h2>"
                  "<div class='steps'>"
                  "<div class='step'><span class='dot'></span>Homing to position 0</div>"
                  "<div class='step'><span class='dot'></span>Sweeping to MAX in day mode</div>"
                  "<div class='step'><span class='dot'></span>Returning home in night mode</div>"
                  "<div class='step'><span class='dot'></span>Resuming normal operation</div>"
                  "</div>"
                  "<p>CelestialArc will resume automatically.<br>You can close this page.</p>"
                  "</body></html>";
    server.send(200, "text/html", resp);
    pendingMotorTest = true; // handled in loop() after response is sent
  });
}

// =====================================================
// SETUP
// =====================================================

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

  pinMode(IN1, OUTPUT);
  pinMode(IN2, OUTPUT);
  pinMode(IN3, OUTPUT);
  pinMode(IN4, OUTPUT);
  pinMode(LIMIT_SWITCH, INPUT_PULLUP);
  pinMode(TOUCH_PIN, INPUT);

  Wire.begin(SDA_PIN, SCL_PIN);

  if (!rtc.begin()) {
    Serial.println("RTC NOT FOUND");
    backlight.begin();
    backlight.setBrightness(50);
    setBacklightColor(255, 0, 0);
    motorOff();
    systemState = FAULT;
    return;
  }

  if (rtc.lostPower())
    Serial.println("WARNING: RTC lost power - time may be invalid");

  loadTimezoneFromEEPROM();

  backlight.begin();
  backlight.setBrightness(50);
  backlight.show();

  sunMoon.begin();
  sunMoon.setBrightness(204);
  sunMoon.show();

  dayMode();
  WiFi.mode(WIFI_OFF);

  Serial.println("STARTING HOMING");
}

// =====================================================
// LOOP
// =====================================================

void loop() {
  if (systemState == RUNNING || systemState == SETTINGS)
    handleTouchButton();

  switch (systemState) {

    case HOMING:
      runHoming();
      break;

    case STARTUP_SYNC:
      startupSync();
      break;

    case RUNNING: {
      DateTime local = localNow();
      updateLighting(local);

      if (local.minute() != lastPrintedMinute) {
        lastPrintedMinute = local.minute();
        printTime(local);
      }

      int slot = get5MinuteSlot(local);
      if (slot != lastTimeSlot) {
        lastTimeSlot   = slot;
        targetPosition = calculatePosition(local);
        Serial.print("TARGET=");
        Serial.println(targetPosition);
      }

      updateMotor();
      break;
    }

    case SETTINGS:
      dnsServer.processNextRequest(); // must be called every loop for captive portal
      updateMotor();
      server.handleClient();

      // Motor test requested via webpage — run after HTTP response is flushed
      if (pendingMotorTest) {
        runMotorTest(); // blocking; exits settings mode internally when done
        break;
      }

      // Latch once any device joins the AP — timer stops permanently
      if (!apClientEverConnected && WiFi.softAPgetStationNum() > 0) {
        apClientEverConnected = true;
        Serial.println("Client connected — AP timeout cancelled");
      }

      // If no client ever connected and the window has expired, bail out
      if (!apClientEverConnected &&
          (millis() - apStartTime >= AP_TIMEOUT_MS)) {
        Serial.println("AP timeout — no client connected, returning to RUNNING");
        exitSettingsMode(activeTZOffset); // no TZ change, just clean exit
      }
      break;

    case FAULT:
      motorOff();
      break;
  }
}
