/* =============================================================================
   OAT Night Eyes  —  glowing eyes in a dark corner, run from your phone
   OpenAgricultureTechnology.com  ·  the Sketch Library
   -----------------------------------------------------------------------------
   Two pixel LEDs become a pair of eyes that blink, wink, breathe, flare and
   vanish. Up to four pairs on one wire, each pair living its own life. A motion
   sensor can keep them dark until someone walks by, and a light sensor can wake
   them at dusk. Garden Guard turns the same board into a predator-eye light that
   flashes red at night to make deer and raccoons think twice.

   STANDALONE. The board raises its own Wi-Fi network (OAT-Eyes-XXXXXX, password
   oatsetup until you change it). Join it and the control page opens at
   http://192.168.4.1. It joins no home network and sends nothing anywhere.
   Everything except the eyes lives in oat_node_core (the network, the setup page,
   the password, the USB Console); this file is the eyes.

   WIRING (classic ESP32 devkit; the IBC board v1.01/1.02 already has these)
     GPIO 23 ── 470 Ω ── DIN of the first eye. Chain DOUT → DIN: left, right,
                          left, right… One data wire for every pair.
     5V / GND ─────────── every eye's power legs. A 0.1 µF cap across each eye's
                          legs is cheap insurance when the eyes sit on a long wire.
     GPIO 32 ─────────── motion sensor OUT (AM312 at 3.3 V, or HC-SR501 at 5 V:
                          its output is 3.3 V, safe for the pin). Optional.
     GPIO 34 ─────────── light sensor: photoresistor divider. Optional. ADC1
                          only, because ADC2 cannot be read while Wi-Fi is on.

   5 mm PIXELS. The common 5 mm part (PL9823) takes colors in RGB order; WS2812B
   strips and the WS2812D 5 mm take GRB. Color order is a setting, and the page's
   Color check settles it in two seconds. A PL9823 wants about 3.5 V for a data
   "high" and the ESP32 gives 3.3: it nearly always works. If an eye flickers or
   ignores commands, a 1N4001 in the eyes' 5 V line runs them at ~4.3 V and lowers
   the bar to ~3.0 V.

   NO CLOCK. Nothing here needs the time of day. Dusk comes from the light
   sensor; visitor times are shown in the phone's own clock, which the page works
   out from "this many seconds ago".
   ============================================================================= */

#include <oat_node_core.h>
#include <Adafruit_NeoPixel.h>
#include "eyes_page.h"        // the control page (HTML + JS)

// ---------------------------------------------------------------------------
// 1. Identity
// ---------------------------------------------------------------------------
#define TIER        "oat-night-eyes"
#define FW_SEMVER   "1.0.0"
#define FW_VERSION  "OAT-Night-Eyes/1.0.0"
#define NVS_NS      "oateyes"

static const int MAX_PAIRS = 4;
static const int MAX_EYES  = MAX_PAIRS * 2;

// One pair of eyes, living its own life. Declared up here because the Arduino
// builder writes prototypes above the first function, and they name this type.
struct Pair {
  // blink
  bool          blinking = false;
  unsigned long blinkStart = 0;
  uint16_t      closeMs = 70, holdMs = 40, openMs = 140;
  uint8_t       which = 0;              // 0 both, 1 left only, 2 right only
  bool          doubleQueued = false;
  bool          secondNext = false;     // the next blink IS the second of a double: it may not queue a third
  unsigned long nextBlink = 0;
  // presence: fades toward visible/hidden
  float         pres = 0;
  bool          lastTarget = false;
  uint16_t      fadeMs = 2000;
  bool          hidden = false;         // vanished
  bool          returning = false;      // just un-vanished: fade back in slowly
  unsigned long hiddenUntil = 0, nextVanish = 0;
  // flare (Demon's own, or the Flare button)
  unsigned long flareStart = 0;
  uint16_t      flareMs = 0;
  unsigned long nextAutoFlare = 0;
  // flicker
  float         flick = 1, flickTo = 1;
  unsigned long nextFlick = 0;
  float         phase = 0;
  // guard flashes
  unsigned long flashOnUntil = 0, nextFlash = 0, secondFlashAt = 0;
};

// ---------------------------------------------------------------------------
// 2. The characters. Data, not code: a new character is one row.
// ---------------------------------------------------------------------------
enum Mood : uint8_t { M_STEADY, M_BREATHE, M_FLICKER, M_FLARE, M_GUARD, M_COUNT };
static const char* MOOD_NAMES[M_COUNT] = { "Steady", "Breathing", "Flicker", "Flaring", "Guard flash" };

struct Character {
  const char* name;
  const char* desc;
  uint8_t r, g, b;
  Mood    mood;
  uint16_t blinkMinS10, blinkMaxS10;   // gap between blinks, tenths of a second (0 = never)
  uint16_t closeMs, holdMs, openMs;    // one blink: lids down, shut, up
  uint8_t  doublePct;                  // chance a blink comes as a double
  uint8_t  winkPct;                    // chance a blink is one eye only
  uint16_t breatheMs;                  // breathing period
  float    breatheDepth;               // 0..1, how far it dims at the bottom of a breath
};

static const Character CHARS[] = {
  { "Wolf",   "Amber, steady, slow blinks",          255,  90,   0, M_BREATHE,  30,  90,  70,  40, 140, 15,  0, 7000, 0.15f },
  { "Demon",  "Red, flares now and then",            255,   0,   0, M_FLARE,    40, 100,  60,  40, 120, 10,  5, 5000, 0.25f },
  { "Cat",    "Yellow-green, the slow cat blink",    150, 255,   0, M_BREATHE,  50, 120, 260, 350, 380,  5, 10, 5000, 0.20f },
  { "Ghost",  "Pale blue, flickering",               110, 170, 255, M_FLICKER,  60, 150,  90,  60, 200,  0,  0, 6000, 0.00f },
  { "Owl",    "Wide amber, one eye then the other",  255, 150,   0, M_STEADY,   20,  60, 200, 120, 260, 10, 45, 6000, 0.00f },
  { "Toxic",  "Green, quick and twitchy",              0, 255,  30, M_BREATHE,  15,  60,  50,  30,  90, 30,  0, 2200, 0.35f },
  { "Garden Guard", "Red flashes at night, for the garden",  255, 0, 0, M_GUARD, 0, 0, 0, 0, 0, 0, 0, 0, 0.0f },
  { "Custom", "Your color, mood and blink rate",      0,   0,   0, M_STEADY,    0,   0,  70,  40, 140, 15,  5, 5000, 0.25f },
};
static const int N_CHARS = sizeof(CHARS) / sizeof(CHARS[0]);
static const int CUSTOM  = N_CHARS - 1;

// ---------------------------------------------------------------------------
// 3. The show settings (the control page). One blob in NVS, saved a few seconds
//    after the last change so dragging a slider does not write flash per pixel.
// ---------------------------------------------------------------------------
enum Master : uint8_t { MASTER_AUTO, MASTER_ON, MASTER_OFF };
enum Sched  : uint8_t { SCHED_ALWAYS, SCHED_DUSK_DAWN, SCHED_DUSK_HOURS };
enum Motion : uint8_t { MOTION_IGNORE, MOTION_COUNT, MOTION_WAKE };

struct Show {
  uint16_t magic;
  uint8_t  master, character, r, g, b, mood, bright, blinkRate;
  uint8_t  sched, hours, motion, stare, vanish, dusk;
  uint16_t holdS;
};
static const uint16_t SHOW_MAGIC = 0xE7E1;
static Show show;

static void showDefaults() {
  show = Show();
  show.magic = SHOW_MAGIC;
  show.master = MASTER_AUTO; show.character = 0;
  show.r = 255; show.g = 0; show.b = 80; show.mood = M_BREATHE; show.blinkRate = 5;
  show.bright = 35;
  show.sched = SCHED_ALWAYS; show.hours = 4; show.dusk = 25;
  show.motion = MOTION_COUNT; show.stare = 1; show.holdS = 20;
  show.vanish = 1;
}

static unsigned long g_saveAt = 0;
static void showChanged() { g_saveAt = millis() + 3000; if (!g_saveAt) g_saveAt = 1; }

// ---------------------------------------------------------------------------
// 4. Runtime state
// ---------------------------------------------------------------------------
static Adafruit_NeoPixel strip(MAX_EYES, 23, NEO_RGB + NEO_KHZ800);
static int  g_pxPinLive = -1;

static Pair pairs[MAX_PAIRS];

static unsigned long g_lastFrame = 0;
static unsigned long g_testUntil = 0;     // Color check: pure red, everything else ignored
static unsigned long g_burstUntil = 0;    // Garden Guard startle burst
static bool          g_wakeFast = false;  // next appearance snaps open (motion), not a slow fade
static float         g_stare = 1.0f;

// light sensor
static float         g_light = -1;        // 0..100 % brightness, smoothed; -1 = no sensor
static unsigned long g_lastLight = 0;
static bool          g_dark = false;
static unsigned long g_darkCandidateSince = 0;
static unsigned long g_duskAt = 0;        // millis() of the last dusk (or boot-in-the-dark)
static bool          g_haveDusk = false;
static bool          g_duskIsBoot = false;    // powered up already in the dark: "tonight" starts at power-on
static const unsigned long DARK_CONFIRM_MS = 30000UL;   // a flashlight or headlights don't flip it
static const float         DARK_HYST = 8.0f;             // % above dusk before it counts as day again

// motion sensor
static const unsigned long PIR_WARMUP_MS = 20000UL;     // PIR outputs are junk while they settle
static const unsigned long VISIT_GAP_MS  = 30000UL;     // quiet this long, and the next motion is a new visitor
static bool          g_pirHigh = false;
static unsigned long g_lastMotion = 0;    // last millis() the PIR was high
static bool          g_everMotion = false;
static unsigned long g_wakeUntil = 0;     // motion wake: eyes open until here

// visitors
static const int     VISIT_LOG = 48;
static unsigned long g_visits[VISIT_LOG];
static int           g_visitHead = 0, g_visitLogN = 0;
static uint32_t      g_visitsTotal = 0, g_visitsTonight = 0;
static unsigned long g_visitsSince = 0;

static bool g_active = false;
static String g_why;

// ---------------------------------------------------------------------------
// 5. Hardware settings (the setup page). Stored as fields; applied in hwApply().
//    set() only validates and stores: the core loads fields BEFORE begin(), so
//    touching a pin from set() would drive hardware that is not set up yet.
// ---------------------------------------------------------------------------
static int  g_pxPin   = 23;
static int  g_pairs   = 1;
static bool g_grb     = false;   // false = RGB (PL9823 5 mm), true = GRB (WS2812B/D)
static int  g_pirPin  = 32;      // -1 = no motion sensor
static int  g_ldrPin  = 34;      // -1 = no light sensor
static bool g_brightLow = true;  // IBC board: bright light reads LOW on the ADC

static String g_hwNote;          // what hwApply() had to refuse, for the status page

// Classic ESP32. Every pin not listed is refused with the reason, because a pin is
// persisted and re-applied at every boot: a bad one is a trap, not a typo.
static String pinWhyNot(int p, bool needOutput) {
  if (p >= 6 && p <= 11) return "is wired to the flash chip the firmware runs from";
  if (p == 1 || p == 3)  return "is the USB console (UART0)";
  if (p == 0)            return "is the BOOT button (hold it 5 s to reset the Wi-Fi password)";
  if (p == 12)           return "is a strapping pin that sets flash voltage at boot";
  if (p == 20 || p == 24 || (p >= 28 && p <= 31) || p > 39 || p < 0) return "does not exist on a classic ESP32";
  if (needOutput && p >= 34) return "is input-only and cannot drive a pixel";
  return "";
}

static bool parsePinOrOff(const String& v, int& out, bool allowOff, bool needOutput, bool adc1Only, String& why) {
  String s = v; s.trim(); s.toLowerCase();
  if (allowOff && (s == "off" || s == "none" || s == "-1" || s == "")) { out = -1; return true; }
  if (!s.length() || !isDigit(s[0])) { why = "'" + v + "' is not a pin number" + String(allowOff ? " (or off)" : ""); return false; }
  int p = s.toInt();
  String no = pinWhyNot(p, needOutput);
  if (no.length()) { why = "GPIO " + String(p) + " " + no + "."; return false; }
  if (adc1Only && !(p == 32 || p == 33 || (p >= 34 && p <= 39))) {
    why = "GPIO " + String(p) + " is not an ADC1 pin. Use 32-39: ADC2 cannot be read while Wi-Fi is on."; return false;
  }
  out = p; return true;
}

static String pinStr(int p) { return p < 0 ? String("off") : String(p); }

static String getPx()    { return String(g_pxPin); }
static bool   setPx(const String& v, String& why) { int p; if (!parsePinOrOff(v, p, false, true, false, why)) { why = "Eye pin: " + why; return false; } g_pxPin = p; return true; }
static String getPairs() { return String(g_pairs); }
static bool   setPairs(const String& v, String& why) {
  int n = v.toInt();
  if (n < 1 || n > MAX_PAIRS) { why = "Pairs of eyes must be 1 to 4."; return false; }
  g_pairs = n; return true;
}
static String getOrder() { return g_grb ? "grb" : "rgb"; }
static bool   setOrder(const String& v, String& why) {
  String s = v; s.trim(); s.toLowerCase();
  if (s == "rgb") g_grb = false; else if (s == "grb") g_grb = true;
  else { why = "Color order must be rgb or grb."; return false; }
  return true;
}
static String getPir()   { return pinStr(g_pirPin); }
static bool   setPir(const String& v, String& why) { int p; if (!parsePinOrOff(v, p, true, false, false, why)) { why = "Motion pin: " + why; return false; } g_pirPin = p; return true; }
static String getLdr()   { return pinStr(g_ldrPin); }
static bool   setLdr(const String& v, String& why) { int p; if (!parsePinOrOff(v, p, true, false, true, why)) { why = "Light pin: " + why; return false; } g_ldrPin = p; return true; }
static String getLdrDir(){ return g_brightLow ? "low" : "high"; }
static bool   setLdrDir(const String& v, String& why) {
  String s = v; s.trim(); s.toLowerCase();
  if (s == "low") g_brightLow = true; else if (s == "high") g_brightLow = false;
  else { why = "Bright light reads must be low or high."; return false; }
  return true;
}

static const oatcore::Field FIELDS[] = {
  { "px_pin",  "Eye pixels data pin", "GPIO 23 on the IBC board v1.01/1.02 (15 on v1.00). Through a 470 &Omega; resistor to the first eye's DIN.", getPx, setPx },
  { "pairs",   "Pairs of eyes (1-4)", "Chained on one wire: left, right, left, right&hellip;", getPairs, setPairs },
  { "order",   "Color order (rgb or grb)", "5 mm PL9823 pixels are rgb. WS2812B strips and WS2812D 5 mm are grb. Red showing as green? Swap it.", getOrder, setOrder },
  { "pir_pin", "Motion sensor pin (or off)", "GPIO 32 suggested. AM312 or HC-SR501 OUT straight to the pin.", getPir, setPir },
  { "ldr_pin", "Light sensor pin (or off)", "GPIO 34 on the IBC board. ADC1 pins only (32-39).", getLdr, setLdr },
  { "ldr_dir", "Bright light reads (low or high)", "low on the IBC board. If the page shows Light now going UP as it gets dark, swap it.", getLdrDir, setLdrDir },
};

// ---------------------------------------------------------------------------
// 6. Small helpers
// ---------------------------------------------------------------------------
static inline bool due(unsigned long now, unsigned long at) { return (long)(now - at) >= 0; }
static inline float clamp01(float x) { return x < 0 ? 0 : (x > 1 ? 1 : x); }
static inline float smooth(float x) { x = clamp01(x); return x * x * (3 - 2 * x); }
static unsigned long rnd(unsigned long lo, unsigned long hi) { return hi <= lo ? lo : lo + (esp_random() % (hi - lo + 1)); }

static const Character& cur() { return CHARS[show.character < N_CHARS ? show.character : 0]; }
static Mood curMood() { return show.character == CUSTOM ? (Mood)(show.mood < M_COUNT ? show.mood : 0) : cur().mood; }
static void curColor(uint8_t& r, uint8_t& g, uint8_t& b) {
  if (show.character == CUSTOM) { r = show.r; g = show.g; b = show.b; }
  else { r = cur().r; g = cur().g; b = cur().b; }
}

// Gap to the next blink, from the character (or the Custom blink-rate slider).
static unsigned long blinkGap() {
  uint32_t lo, hi;
  if (show.character == CUSTOM) {
    if (show.blinkRate == 0) return 0;                       // never
    hi = 26000UL - (uint32_t)show.blinkRate * 2400UL;        // rate 1: ~24 s .. rate 10: 2 s
    lo = hi * 2 / 5;
  } else {
    if (cur().blinkMaxS10 == 0) return 0;
    lo = cur().blinkMinS10 * 100UL; hi = cur().blinkMaxS10 * 100UL;
  }
  return rnd(lo, hi);
}

static void scheduleBlink(Pair& p, unsigned long now) {
  unsigned long g = blinkGap();
  p.nextBlink = g ? now + g : 0;
}

static void startBlink(Pair& p, unsigned long now, uint8_t which) {
  const Character& c = cur();
  p.blinking = true; p.blinkStart = now; p.which = which;
  p.closeMs = c.closeMs; p.holdMs = c.holdMs; p.openMs = c.openMs;
}

static void reseedPairs(unsigned long now) {
  for (int i = 0; i < MAX_PAIRS; i++) {
    Pair& p = pairs[i];
    p.blinking = false; p.doubleQueued = false; p.secondNext = false;
    scheduleBlink(p, now + i * 700);                 // pairs out of step from the start
    p.nextVanish = now + rnd(25000, 90000);
    p.nextAutoFlare = now + rnd(6000, 20000);
    p.phase = (float)rnd(0, 6283) / 1000.0f;
    p.nextFlash = now + rnd(300, 2500);
  }
}

// ---------------------------------------------------------------------------
// 7. Hardware bring-up
// ---------------------------------------------------------------------------
static void hwApply() {
  g_hwNote = "";
  // Conflicts are resolved here, not in set(): the form submits fields one at a
  // time, so a swap of two pins would be refused halfway through.
  if (g_pirPin >= 0 && g_pirPin == g_pxPin) { g_hwNote += "Motion pin is the eye pin: motion sensor off. "; g_pirPin = -1; }
  if (g_ldrPin >= 0 && (g_ldrPin == g_pxPin || g_ldrPin == g_pirPin)) { g_hwNote += "Light pin is already in use: light sensor off. "; g_ldrPin = -1; }

  if (g_pxPinLive >= 0) { strip.clear(); strip.show(); }   // blank the old wiring first
  strip.updateType(g_grb ? (NEO_GRB + NEO_KHZ800) : (NEO_RGB + NEO_KHZ800));
  strip.updateLength(g_pairs * 2);
  // Touch the pin ONLY when it moved. On Arduino-ESP32 3.x any pinMode() detaches
  // the RMT channel driving it, while the NeoPixel library still believes it is
  // attached and never re-inits: re-calling setPin()/begin() on the same pin left
  // the eyes dark after every setup save until a reboot. A new pin forces a fresh
  // init on the next show(), so moving is safe.
  if (g_pxPinLive != g_pxPin) {
    strip.setPin(g_pxPin);
    strip.begin();
    g_pxPinLive = g_pxPin;
  }
  strip.clear(); strip.show();

  if (g_pirPin >= 0) pinMode(g_pirPin, g_pirPin >= 34 ? INPUT : INPUT_PULLDOWN);   // 34-39 have no pulldown
  if (g_ldrPin >= 0) { pinMode(g_ldrPin, INPUT); analogSetPinAttenuation(g_ldrPin, ADC_11db); }
  g_light = -1;                                       // re-read from the (possibly new) pin
  g_lastLight = 0;
  Serial.printf("[eyes] %d pair(s) on GPIO %d (%s), motion %s, light %s\n", g_pairs, g_pxPin, g_grb ? "grb" : "rgb",
                pinStr(g_pirPin).c_str(), pinStr(g_ldrPin).c_str());
}

// ---------------------------------------------------------------------------
// 8. Sensors: light (dusk) and motion (visitors, wake)
// ---------------------------------------------------------------------------
static void readLight(unsigned long now) {
  if (g_ldrPin < 0) { g_light = -1; return; }
  if (g_lastLight && now - g_lastLight < 500) return;
  g_lastLight = now;
  int raw = analogRead(g_ldrPin);
  float pct = raw * 100.0f / 4095.0f;
  if (g_brightLow) pct = 100.0f - pct;
  bool first = g_light < 0;
  g_light = first ? pct : g_light * 0.8f + pct * 0.2f;

  bool looksDark  = g_light < show.dusk;
  bool looksLight = g_light > fminf(show.dusk + DARK_HYST, 99.0f);   // a high dusk must still leave room for day
  if (first) {                                        // power-up (or a re-wired pin): believe the first reading
    g_dark = looksDark; g_darkCandidateSince = 0;
    if (g_dark && !g_haveDusk) { g_duskAt = now; g_haveDusk = true; g_duskIsBoot = true; }   // counts tonight from here; never wipes them
    return;
  }
  bool wantFlip = g_dark ? looksLight : looksDark;
  if (!wantFlip) { g_darkCandidateSince = 0; return; }
  if (!g_darkCandidateSince) { g_darkCandidateSince = now; return; }
  if (now - g_darkCandidateSince < DARK_CONFIRM_MS) return;
  g_dark = !g_dark; g_darkCandidateSince = 0;
  if (g_dark) { g_duskAt = now; g_haveDusk = true; g_duskIsBoot = false; g_visitsTonight = 0; Serial.println("[eyes] dusk"); }
  else Serial.println("[eyes] daylight");
}

static void logVisit(unsigned long now) {
  g_visitsTotal++; g_visitsTonight++;
  g_visits[g_visitHead] = now;
  g_visitHead = (g_visitHead + 1) % VISIT_LOG;
  if (g_visitLogN < VISIT_LOG) g_visitLogN++;
}

static void motionEvent(unsigned long now, bool real);

static void readMotion(unsigned long now) {
  if (g_pirPin < 0 || show.motion == MOTION_IGNORE || now < PIR_WARMUP_MS) { g_pirHigh = false; return; }
  bool hi = digitalRead(g_pirPin) == HIGH;
  if (hi && !g_pirHigh) {                             // rising edge
    bool newVisitor = !g_everMotion || now - g_lastMotion > VISIT_GAP_MS;
    if (newVisitor) logVisit(now);
    motionEvent(now, true);
  }
  if (hi) { g_lastMotion = now; g_everMotion = true;
            if (show.motion == MOTION_WAKE) g_wakeUntil = now + show.holdS * 1000UL; }
  g_pirHigh = hi;
}

// Something moved (or the page pretended it did).
// A pretend never touches the visitor bookkeeping: it would hide a real visitor
// who arrived inside the next 30 s.
static void motionEvent(unsigned long now, bool real) {
  if (curMood() == M_GUARD) g_burstUntil = now + 1500;  // startle, in every motion mode
  if (show.motion != MOTION_WAKE && real) return;
  bool wasOpen = g_active;
  g_wakeUntil = now + show.holdS * 1000UL;
  if (!wasOpen) {
    g_wakeFast = true;                                // snap open
    g_stare = show.stare ? 0.5f : 1.0f;
    bool blinks = blinkGap() != 0;                    // Custom at "never" stays never
    for (int i = 0; i < MAX_PAIRS; i++) pairs[i].nextBlink = blinks ? now + 700 + i * 250 : 0;
  }
}

// ---------------------------------------------------------------------------
// 9. Should the eyes be open right now, and why (the page says it in words)
// ---------------------------------------------------------------------------
static void decide(unsigned long now) {
  String who = String(cur().name);
  if (show.master == MASTER_OFF) { g_active = false; g_why = "Off"; return; }
  if (show.master == MASTER_ON)  { g_active = true;  g_why = who + " · On (ignoring the schedule and motion)"; return; }

  bool sched = true; String schedWhy;
  if (show.sched != SCHED_ALWAYS) {
    if (g_ldrPin < 0) schedWhy = " (no light sensor, so always)";
    else if (g_light < 0) { sched = false; schedWhy = "Reading the light"; }
    else if (!g_dark) { sched = false; schedWhy = "Waiting for dark · light " + String((int)g_light) + "%, dusk below " + String(show.dusk) + "%"; }
    else if (show.sched == SCHED_DUSK_HOURS && g_haveDusk && now - g_duskAt >= show.hours * 3600000UL) {
      sched = false; schedWhy = "Done for tonight · " + String(show.hours) + " h after dusk";
    }
  }
  if (!sched) { g_active = false; g_why = schedWhy; return; }

  if (show.motion == MOTION_WAKE && g_pirPin >= 0) {
    if (g_wakeUntil && due(now, g_wakeUntil)) g_wakeUntil = 0;   // expired: zero it, or it "un-expires" 24.8 days on
    if (g_wakeUntil && !due(now, g_wakeUntil)) {
      g_active = true;
      g_why = who + " · Watching (" + String((g_wakeUntil - now) / 1000 + 1) + " s more)";
    } else if (now < PIR_WARMUP_MS) {
      g_active = false; g_why = "Motion sensor warming up";
    } else {
      g_active = false; g_why = "Asleep until someone comes" + schedWhy;
    }
    return;
  }
  g_active = true;
  g_why = who + " · Watching" + schedWhy;
  if (show.motion == MOTION_WAKE && g_pirPin < 0) g_why += " · no motion sensor set, so always open";
}

// ---------------------------------------------------------------------------
// 10. The animation: every 10 ms, each eye's brightness from presence × lids ×
//     mood × flare × stare, then gamma, then color.
// ---------------------------------------------------------------------------
static float lidFor(Pair& p, unsigned long now, int eye) {
  if (!p.blinking) return 1;
  if (p.which == 1 && eye == 1) return 1;            // wink left: right eye stays open
  if (p.which == 2 && eye == 0) return 1;
  unsigned long t = now - p.blinkStart;
  if (t < p.closeMs) return 1 - smooth((float)t / p.closeMs);
  t -= p.closeMs;
  if (t < p.holdMs) return 0;
  t -= p.holdMs;
  if (t < p.openMs) return smooth((float)t / p.openMs);
  return 1;
}

static void stepBlink(Pair& p, unsigned long now) {
  if (p.blinking && now - p.blinkStart >= (unsigned long)p.closeMs + p.holdMs + p.openMs) {
    p.blinking = false;
    if (p.doubleQueued) { p.doubleQueued = false; p.secondNext = true; p.nextBlink = now + 120; }
    else scheduleBlink(p, now);
  }
  if (!p.blinking && p.nextBlink && due(now, p.nextBlink)) {
    const Character& c = cur();
    uint8_t which = (rnd(0, 99) < c.winkPct) ? (uint8_t)rnd(1, 2) : 0;
    startBlink(p, now, which);
    if (p.secondNext) p.secondNext = false;
    else p.doubleQueued = rnd(0, 99) < c.doublePct;
  }
}

static float moodFor(Pair& p, unsigned long now, int idx) {
  switch (curMood()) {
    case M_BREATHE: {
      const Character& c = show.character == CUSTOM ? CHARS[0] : cur();
      float depth = show.character == CUSTOM ? 0.3f : c.breatheDepth;
      float s = 0.5f + 0.5f * sinf(6.2832f * (float)(now % c.breatheMs) / c.breatheMs + p.phase);
      return 1 - depth * s;
    }
    case M_FLICKER: {
      if (due(now, p.nextFlick)) {
        p.nextFlick = now + rnd(40, 140);
        p.flickTo = (rnd(0, 99) < 4) ? 0.15f : 0.55f + (float)rnd(0, 45) / 100.0f;   // the odd drop-out
      }
      p.flick += (p.flickTo - p.flick) * 0.25f;
      return p.flick;
    }
    case M_FLARE: {
      if (due(now, p.nextAutoFlare)) { p.flareStart = now; p.flareMs = 1300; p.nextAutoFlare = now + rnd(8000, 25000); }
      return 0.7f + 0.1f * sinf(6.2832f * (float)(now % 4000) / 4000.0f + p.phase);
    }
    case M_GUARD: {
      // Short red flashes at uneven gaps: a fixed rhythm is exactly what animals
      // learn to ignore. A burst (motion, or the Flare button) is a rapid volley.
      if (g_burstUntil && due(now, g_burstUntil)) g_burstUntil = 0;   // same 24.8-day trap as the wake timer
      if (g_burstUntil) return ((now / 75) + idx) % 2 ? 1.0f : 0.0f;
      if (p.secondFlashAt && due(now, p.secondFlashAt)) { p.secondFlashAt = 0; p.flashOnUntil = now + 110; }
      if (due(now, p.nextFlash)) {
        p.flashOnUntil = now + rnd(90, 160);
        if (rnd(0, 99) < 20) p.secondFlashAt = now + 260;
        p.nextFlash = now + rnd(700, 2600);
      }
      return due(now, p.flashOnUntil) ? 0.0f : 1.0f;
    }
    default: return 1;
  }
}

// One 8-bit channel. Gamma pushes low levels below 1, so at a dim setting a lit eye
// rounded to 0 while the page said "Watching", and Wolf's green dropped out first and
// turned it red. A channel the colour uses stays at least 1 until the eye is truly
// fading out (under a twentieth of a step).
static uint8_t chan(float c, float Ig) {
  float v = c * Ig;
  if (v <= 0.05f) return 0;
  return v < 1.0f ? 1 : (uint8_t)(v + 0.5f);
}

static void frame(unsigned long now) {
  uint8_t cr, cg, cb; curColor(cr, cg, cb);
  int n = g_pairs * 2;

  float dt = g_lastFrame ? (float)(now - g_lastFrame) : 10.0f;
  if (dt > 50) dt = 50;                               // a slow page render is not a fade
  g_lastFrame = now;

  if (g_testUntil && !due(now, g_testUntil)) {        // Color check: pure red, both eyes, no tricks
    for (int i = 0; i < n; i++) strip.setPixelColor(i, 120, 0, 0);
    strip.show();
    return;
  }
  g_testUntil = 0;

  // Stare-down: brighter the longer someone stands there.
  if (show.motion == MOTION_WAKE && show.stare && g_pirHigh) g_stare = fminf(1.0f, g_stare + dt / 16000.0f);
  if (!show.stare || show.motion != MOTION_WAKE) g_stare = 1.0f;

  Mood mood = curMood();
  float cap = show.bright / 100.0f;

  // The eyes just opened (dusk, motion, Auto->On). Timers that sat still while they
  // were dark are stale: a vanish that came due during the sleep would hide the eyes
  // from the very visitor who woke them. Start every pair fresh and visible.
  static bool wasActive = false;
  if (g_active && !wasActive) {
    for (int i = 0; i < MAX_PAIRS; i++) {
      Pair& p = pairs[i];
      p.hidden = false; p.returning = false;
      p.nextVanish = now + rnd(25000, 90000);
      p.nextAutoFlare = now + rnd(6000, 20000);
      if (p.nextBlink && due(now, p.nextBlink)) scheduleBlink(p, now);
    }
  }
  wasActive = g_active;

  for (int i = 0; i < g_pairs; i++) {
    Pair& p = pairs[i];

    // Random vanishing (not for Garden Guard, whose flashes already come and go).
    if (show.vanish && mood != M_GUARD && g_active) {
      if (!p.hidden && due(now, p.nextVanish)) { p.hidden = true; p.hiddenUntil = now + rnd(4000, 18000); }
      if (p.hidden && due(now, p.hiddenUntil)) { p.hidden = false; p.returning = true; p.nextVanish = now + rnd(25000, 90000); }
    } else if (p.hidden && due(now, p.hiddenUntil)) { p.hidden = false; p.returning = true; }

    bool target = g_active && !p.hidden;
    if (target != p.lastTarget) {
      p.fadeMs = target ? (g_wakeFast ? 180 : (p.returning ? 1800 : 2200)) : (p.hidden ? 1200 : 2500);
      p.lastTarget = target;
      if (target) p.returning = false;
    }
    float step = dt / (float)p.fadeMs;
    p.pres = target ? fminf(1.0f, p.pres + step) : fmaxf(0.0f, p.pres - step);

    if (mood != M_GUARD) stepBlink(p, now);
    float m = moodFor(p, now, i);

    // Flare: a surge to full and a little toward white, then back.
    float env = 0;
    if (p.flareMs && now - p.flareStart < p.flareMs) env = sinf(3.1416f * (float)(now - p.flareStart) / p.flareMs);
    else p.flareMs = 0;
    float mm = m * (1 - env) + env;
    float whiten = env * 0.35f;
    float r = cr + (255 - cr) * whiten, g = cg + (255 - cg) * whiten, b = cb + (255 - cb) * whiten;

    for (int e = 0; e < 2; e++) {
      float lid = mood == M_GUARD ? 1.0f : lidFor(p, now, e);
      float I = clamp01(p.pres * lid * mm * g_stare * cap);
      float Ig = powf(I, 2.2f);                       // perceptual: half the slider looks half as bright
      strip.setPixelColor(i * 2 + e, chan(r, Ig), chan(g, Ig), chan(b, Ig));
    }
  }
  if (g_active) g_wakeFast = false;                   // every pair took its fade on the opening frame
  strip.show();
}

// ---------------------------------------------------------------------------
// 11. Actions (the page's Scare now buttons and the Console)
// ---------------------------------------------------------------------------
static String doAction(const String& a) {
  unsigned long now = millis();
  if (a == "blink") { for (int i = 0; i < g_pairs; i++) startBlink(pairs[i], now, 0); }
  else if (a == "wink") { int i = rnd(0, g_pairs - 1); startBlink(pairs[i], now, (uint8_t)rnd(1, 2)); }
  else if (a == "vanish") {
    for (int i = 0; i < g_pairs; i++) { pairs[i].hidden = true; pairs[i].hiddenUntil = now + rnd(6000, 15000); }
  }
  else if (a == "flare") {
    if (curMood() == M_GUARD) g_burstUntil = now + 1500;
    else for (int i = 0; i < g_pairs; i++) { pairs[i].flareStart = now; pairs[i].flareMs = 1500; }
  }
  else if (a == "motion") { motionEvent(now, false); if (show.motion != MOTION_WAKE) return "pretended (eyes only sleep until motion when Motion is set to Stay dark until someone comes)"; }
  else if (a == "test") { g_testUntil = now + 3000; }
  else return "unknown action";
  if (!g_active && a != "test" && a != "motion") return "done, but the eyes are dark right now";
  return "ok";
}

// ---------------------------------------------------------------------------
// 12. Web: the control page and its small JSON API
// ---------------------------------------------------------------------------
static String jsonEsc(const String& s) {
  String o; o.reserve(s.length() + 4);
  for (size_t i = 0; i < s.length(); i++) { char c = s[i]; if (c == '"' || c == '\\') o += '\\'; if ((uint8_t)c >= 0x20) o += c; }
  return o;
}

static String stateJson() {
  unsigned long now = millis();
  String j; j.reserve(1400);
  j += "{\"fw\":\"" FW_SEMVER "\"";
  j += ",\"master\":" + String(show.master) + ",\"char\":" + String(show.character);
  char hex[8]; snprintf(hex, sizeof(hex), "#%02x%02x%02x", show.r, show.g, show.b);
  j += ",\"color\":\"" + String(hex) + "\",\"mood\":" + String(show.mood) + ",\"bright\":" + String(show.bright);
  j += ",\"blink\":" + String(show.blinkRate) + ",\"sched\":" + String(show.sched) + ",\"hours\":" + String(show.hours);
  j += ",\"motion\":" + String(show.motion) + ",\"stare\":" + String(show.stare) + ",\"hold\":" + String(show.holdS);
  j += ",\"vanish\":" + String(show.vanish) + ",\"dusk\":" + String(show.dusk);
  j += ",\"live\":{\"active\":" + String(g_active ? "true" : "false") + ",\"why\":\"" + jsonEsc(g_why) + "\"";
  j += ",\"light\":" + String(g_light < 0 ? -1 : (int)(g_light + 0.5f)) + ",\"dark\":" + String(g_dark ? "true" : "false");
  j += ",\"pir\":" + String(g_pirPin >= 0 ? "true" : "false") + ",\"ldr\":" + String(g_ldrPin >= 0 ? "true" : "false");
  j += ",\"moving\":" + String(g_pirHigh ? "true" : "false") + ",\"warm\":" + String(now < PIR_WARMUP_MS ? "true" : "false");
  j += ",\"pairs\":" + String(g_pairs) + ",\"up\":" + String(now);
  j += ",\"dusk_at\":" + (g_haveDusk ? String(g_duskAt) : String("null")) + ",\"dusk_boot\":" + String(g_duskIsBoot ? "true" : "false");
  j += ",\"total\":" + String(g_visitsTotal) + ",\"tonight\":" + String(g_visitsTonight) + ",\"since\":" + String(g_visitsSince);
  j += ",\"note\":\"" + jsonEsc(g_hwNote) + "\",\"times\":[";
  for (int k = 0; k < g_visitLogN; k++) {
    int idx = (g_visitHead - 1 - k + VISIT_LOG) % VISIT_LOG;
    if (k) j += ",";
    j += String(g_visits[idx]);
  }
  j += "]},\"chars\":[";
  for (int i = 0; i < N_CHARS; i++) {
    char c[8]; snprintf(c, sizeof(c), "#%02x%02x%02x", CHARS[i].r, CHARS[i].g, CHARS[i].b);
    if (i) j += ",";
    j += "{\"n\":\"" + String(CHARS[i].name) + "\",\"d\":\"" + String(CHARS[i].desc) + "\",\"c\":\"" + String(c) + "\"}";
  }
  j += "],\"moods\":[";
  for (int i = 0; i < M_COUNT; i++) { if (i) j += ","; j += "\"" + String(MOOD_NAMES[i]) + "\""; }
  j += "]}";
  return j;
}

static int argInt(const char* k, int lo, int hi, int cur) {
  if (!oatcore::web().hasArg(k)) return cur;
  long v = oatcore::web().arg(k).toInt();
  return v < lo ? lo : (v > hi ? hi : (int)v);
}

static void apiSet() {
  auto& w = oatcore::web();      // the core's own server: a sketch never starts a second one
  Show before = show;
  show.master    = argInt("master", 0, 2, show.master);
  int ch = argInt("char", 0, N_CHARS - 1, show.character);
  show.bright    = argInt("bright", 5, 100, show.bright);
  show.blinkRate = argInt("blink", 0, 10, show.blinkRate);
  show.mood      = argInt("mood", 0, M_COUNT - 1, show.mood);
  show.sched     = argInt("sched", 0, 2, show.sched);
  show.hours     = argInt("hours", 1, 12, show.hours);
  show.motion    = argInt("motion", 0, 2, show.motion);
  show.stare     = argInt("stare", 0, 1, show.stare);
  show.holdS     = argInt("hold", 5, 600, show.holdS);
  show.vanish    = argInt("vanish", 0, 1, show.vanish);
  show.dusk      = argInt("dusk", 1, 90, show.dusk);
  if (w.hasArg("color")) {
    String c = w.arg("color"); c.replace("#", "");
    if (c.length() == 6) { long v = strtol(c.c_str(), nullptr, 16); show.r = v >> 16; show.g = (v >> 8) & 255; show.b = v & 255; }
  }
  unsigned long now = millis();
  if (ch != show.character || show.mood != before.mood || show.blinkRate != before.blinkRate) {
    show.character = ch; reseedPairs(now);
  }
  if (show.motion != before.motion && show.motion == MOTION_WAKE) g_wakeUntil = 0;   // go to sleep and wait
  if (memcmp(&show, &before, sizeof(Show)) != 0) showChanged();
  decide(now);
  w.send(200, "application/json", stateJson());
}

static void apiDo() {
  String r = doAction(oatcore::web().arg("a"));
  decide(millis());
  oatcore::web().send(200, "application/json", "{\"result\":\"" + jsonEsc(r) + "\"}");
}

static void apiState() { decide(millis()); oatcore::web().send(200, "application/json", stateJson()); }

static void apiResetVisits() {
  g_visitsTotal = 0; g_visitsTonight = 0; g_visitLogN = 0; g_visitHead = 0; g_visitsSince = millis();
  oatcore::web().send(200, "application/json", stateJson());
}


static void pageRoot() {
  String p = oatcore::pageHead();
  p += FPSTR(EYES_PAGE);
  p += oatcore::pageFoot();
  oatcore::web().send(200, "text/html", p);
}

static void eyesRoutes() {
  auto& w = oatcore::web();      // the core's own server: a sketch never starts a second one
  w.on("/",                   HTTP_GET,  pageRoot);
  w.on("/api/state",          HTTP_GET,  apiState);
  w.on("/api/set",            HTTP_POST, apiSet);
  w.on("/api/do",             HTTP_POST, apiDo);
  w.on("/api/visitors/reset", HTTP_POST, apiResetVisits);
}

// ---------------------------------------------------------------------------
// 13. Driver hooks
// ---------------------------------------------------------------------------
static void eyesBegin() {
  showDefaults();
  Show saved;
  if (oatcore::blobLoad("show", &saved, sizeof(saved)) == sizeof(saved) && saved.magic == SHOW_MAGIC) show = saved;
  if (show.character >= N_CHARS) show.character = 0;
  hwApply();
  reseedPairs(millis());
  readLight(millis());                                // decide day or night before the first frame
}

static void eyesRescan() { hwApply(); reseedPairs(millis()); }

static void eyesCollect() {
  unsigned long now = millis();
  readLight(now);
  readMotion(now);
  if (now - g_lastFrame >= 10) { decide(now); frame(now); }
  if (g_saveAt && due(now, g_saveAt)) { g_saveAt = 0; oatcore::blobSave("show", &show, sizeof(show)); }
}

static String eyesStatusHtml() {
  String h;
  h += "<div class='muted'>" + g_why + "</div>";
  h += "<div class='muted'>" + String(g_pairs) + " pair(s) on GPIO " + String(g_pxPin) + " (" + getOrder() + ") &middot; brightness " + String(show.bright) + "%</div>";
  h += "<div class='muted'>Motion: " + (g_pirPin < 0 ? String("no sensor") : "GPIO " + String(g_pirPin) + (g_pirHigh ? " &middot; moving now" : " &middot; quiet")) +
       " &middot; visitors " + String(g_visitsTonight) + " tonight / " + String(g_visitsTotal) + " total</div>";
  h += "<div class='muted'>Light: " + (g_ldrPin < 0 ? String("no sensor") : "GPIO " + String(g_ldrPin) + " &middot; " + String((int)g_light) + "% &middot; " + (g_dark ? "dark" : "day")) + "</div>";
  if (g_hwNote.length()) h += "<div class='bad'>" + g_hwNote + "</div>";
  return h;
}

static String eyesStatusText() {
  String s = "eyes: " + g_why + "\n";
  s += "pixels: " + String(g_pairs) + " pair(s) GPIO " + String(g_pxPin) + " " + getOrder() + " bright=" + String(show.bright) + "%\n";
  s += "motion: " + (g_pirPin < 0 ? String("off") : "GPIO " + String(g_pirPin) + (g_pirHigh ? " moving" : " quiet")) +
       " visitors tonight=" + String(g_visitsTonight) + " total=" + String(g_visitsTotal) + "\n";
  s += "light: " + (g_ldrPin < 0 ? String("off") : "GPIO " + String(g_ldrPin) + " " + String((int)g_light) + "% " + (g_dark ? "dark" : "day")) +
       " dusk<" + String(show.dusk) + "%\n";
  if (g_hwNote.length()) s += "note: " + g_hwNote + "\n";
  return s;
}

// Console: the same actions as the page's buttons.
static void cmdAct(const String& a) { Serial.println("[eyes] " + doAction(a)); }
static void cmdBlink(const String&)  { cmdAct("blink"); }
static void cmdWink(const String&)   { cmdAct("wink"); }
static void cmdVanish(const String&) { cmdAct("vanish"); }
static void cmdFlare(const String&)  { cmdAct("flare"); }
static void cmdMotion(const String&) { cmdAct("motion"); }
static void cmdTest(const String&)   { cmdAct("test"); }
static void cmdMode(const String& r) {
  if (r == "auto") show.master = MASTER_AUTO; else if (r == "on") show.master = MASTER_ON;
  else if (r == "off") show.master = MASTER_OFF; else { Serial.println("[eyes] mode auto|on|off"); return; }
  showChanged(); decide(millis()); Serial.println("[eyes] " + g_why);
}
static void cmdChar(const String& r) {
  for (int i = 0; i < N_CHARS; i++)
    if (r.equalsIgnoreCase(CHARS[i].name) || r == String(i)) {
      show.character = i; reseedPairs(millis()); showChanged(); Serial.printf("[eyes] %s\n", CHARS[i].name); return;
    }
  String names; for (int i = 0; i < N_CHARS; i++) { names += (i ? ", " : ""); names += CHARS[i].name; }
  Serial.println("[eyes] characters: " + names);
}

static const oatcore::Command COMMANDS[] = {
  { "blink",  "both eyes blink",                 cmdBlink },
  { "wink",   "one eye winks",                   cmdWink },
  { "vanish", "eyes fade away and come back",    cmdVanish },
  { "flare",  "a bright surge (Guard: a volley)", cmdFlare },
  { "motion", "pretend someone walked by",       cmdMotion },
  { "test",   "color check: 3 s of pure red",   cmdTest },
  { "mode",   "mode auto|on|off",                cmdMode },
  { "char",   "char <name>: Wolf, Demon, Cat, Ghost, Owl, Toxic, Garden Guard, Custom", cmdChar },
};

static const oatcore::Driver DRIVER = {
  TIER, "The eyes", FW_VERSION, FW_SEMVER, NVS_NS,
  eyesBegin,
  nullptr,                           // startSample: nothing to sample on a timer
  eyesCollect,                       // every loop: sensors + a 10 ms animation frame
  nullptr,                           // sampleBlocking
  eyesRescan,                        // a hardware field changed: re-open the pins
  eyesStatusHtml, eyesStatusText,
  nullptr, nullptr,                  // diagFull, diag
  FIELDS,   (int)(sizeof(FIELDS)   / sizeof(FIELDS[0])),
  COMMANDS, (int)(sizeof(COMMANDS) / sizeof(COMMANDS[0])),
  true,                              // standalone: its own network, sends nothing
  "OAT-Eyes",
  eyesRoutes,
};

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