// Slab eyes — landscape Arduino eyes for Waveshare ESP32-C6-LCD-1.47
// (Amazon B0DK5J6LX3). LCD-only slab — no touch, no IMU.
//
// Port of robot-face's egg eyes / looks / blink+wink machines, locked
// landscape 320×172 the whole time. Life is autonomous: look-arounds,
// winks, fidgets, drowsy → asleep. BOOT pokes it; hold BOOT cycles looks.
//
// Serial 115200: mood <name> | look <n|next|prev|list> | gaze x y |
//                blink | wink | poke | pet | sleep | wake | status | help
//
// Backlight stays ≤ 50% (Waveshare heat-spot warning); dims further asleep.
// The browser sim is board-sketches/slab-eyes/sim/index.html
//   python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/slab-eyes

#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <Adafruit_NeoPixel.h>
#include <Preferences.h>
#include <math.h>

#include "../../../shared/c6_slab_pins.h"

#define BOOT_LONG_MS 700UL

static const int FB_W = 320;
static const int FB_H = 172;

static const uint8_t BG[3] = {12, 13, 18};
static const float EYE_L_X = 86;
static const float EYE_R_X = 234;
static const float EYE_Y = 86;
static const float EYE_A = 58;
static const float EYE_B = 64;
static const float PUPIL_R = 22;
static const float LOOK_TRAVEL_X = 0.50f;
static const float LOOK_TRAVEL_Y = 0.42f;
static const float PAIR_SHIFT_X = 6;
static const float PAIR_SHIFT_Y = 3;
static const float BLUSH_DX = 28;
static const float BLUSH_Y = 148;
static const float BLUSH_R = 16;

enum PupilStyle : uint8_t { ST_NONE = 0, ST_ROUND, ST_DOT, ST_SLIT, ST_STAR, ST_HEART, ST_RING };

struct Look {
  const char *name;
  uint8_t sclera[3];
  uint8_t pupil[3];
  bool hasPupil;
  uint8_t pupil2[3];
  bool hasPupil2;
  bool shine;
  PupilStyle style;
  uint8_t blush[3];
};

// Same palettes as robot-face. Keep in sync with sim/index.html
static const Look LOOKS[] = {
  {"cloud",      {242, 236, 226}, {22, 24, 30},    true,  {0, 0, 0},     false, true,  ST_ROUND, {232, 96, 110}},
  {"periwinkle", {168, 196, 240}, {0, 0, 0},       false, {0, 0, 0},     false, false, ST_NONE,  {225, 110, 150}},
  {"glowstick",  {200, 255, 30},  {10, 10, 16},    true,  {0, 0, 0},     false, false, ST_SLIT,  {255, 120, 80}},
  {"matcha",     {207, 230, 196}, {30, 40, 90},    true,  {190, 80, 20}, true,  false, ST_DOT,   {240, 120, 120}},
  {"blueberry",  {36, 48, 255},   {214, 246, 255}, true,  {0, 0, 0},     false, false, ST_ROUND, {200, 90, 255}},
  {"bubblegum",  {255, 60, 225},  {60, 225, 90},   true,  {0, 0, 0},     false, false, ST_DOT,   {255, 140, 200}},
  {"lagoon",     {28, 120, 190},  {220, 255, 60},  true,  {0, 0, 0},     false, false, ST_SLIT,  {120, 200, 255}},
  {"peach",      {255, 176, 140}, {70, 40, 40},    true,  {0, 0, 0},     false, true,  ST_ROUND, {255, 110, 120}},
  {"ghost",      {250, 250, 255}, {120, 140, 255}, true,  {0, 0, 0},     false, false, ST_STAR,  {190, 200, 255}},
};
static const int LOOK_COUNT = sizeof(LOOKS) / sizeof(LOOKS[0]);
static const uint8_t COL_SHINE[3] = {255, 255, 255};
static const uint8_t COL_HEART[3] = {232, 96, 110};
static const uint8_t COL_TEAR[3] = {160, 210, 255};

enum Mood : uint8_t {
  MOOD_IDLE = 0, MOOD_HAPPY, MOOD_SLEEPY, MOOD_CURIOUS, MOOD_SURPRISED, MOOD_LOVE,
  MOOD_DIZZY, MOOD_ANNOYED, MOOD_SAD, MOOD_EXCITED, MOOD_ASLEEP, MOOD_COUNT
};
static const char *MOOD_NAMES[MOOD_COUNT] = {
  "idle", "happy", "sleepy", "curious", "surprised", "love", "dizzy", "annoyed", "sad", "excited", "asleep"};

enum GestureType : uint8_t {
  G_NONE = 0, G_GLANCE2, G_THINK, G_BROW, G_WIGGLE, G_STARE, G_SIGH, G_STRETCH, G_EYEROLL
};
enum Awake : uint8_t { AWAKE = 0, DROWSY, ASLEEP };

static const uint32_t DROWSY_AFTER_MS = 75000;
static const uint32_t DROWSY_LEN_MS = 12000;
static const uint32_t LONELY_AFTER_MS = 45000;

struct Shape {
  float sx = 1, sy = 1, top = 0, ang = 0, curve = 0, bot = 0, pupil = 1;
};

static Arduino_DataBus *bus = new Arduino_ESP32SPI(
    C6_LCD_DC_PIN, C6_LCD_CS_PIN, C6_LCD_SCLK_PIN, C6_LCD_MOSI_PIN, GFX_NOT_DEFINED);
static Arduino_GFX *gfx = new Arduino_ST7789(
    bus, C6_LCD_RST_PIN, 0 /* rotation; setRotation(1) in setup */, true /* IPS */,
    C6_LCD_W, C6_LCD_H, 34, 0, 34, 0);

static Adafruit_NeoPixel rgb(1, C6_RGB_PIN, NEO_GRB + NEO_KHZ800);
static Preferences prefs;
static uint16_t *fb = nullptr;

static Shape shapeL, shapeR;
static float lookX = 0, lookY = 0, velX = 0, velY = 0;
static float offX = 0, offY = 0, offVelY = 0;
static float blinkL = 0, blinkR = 0, openPop = 0;
static float blush = 0, swirl = 0, heartbeat = 0, breath = 0;
static float tear = 0;
static int tearSide = 1;
static float drowsy = 0;
static float backlight = 1;

static int lookIndex = 0;
static int pendingLook = -1;
static Mood mood = MOOD_IDLE;
static uint32_t moodUntil = 0;
static bool moodHold = false;
static uint32_t moodEnteredAt = 0;
static bool pokeHappyPending = false;

static float targetLookX = 0, targetLookY = 0;
static bool glancing = false;
static uint32_t glanceUntil = 0;
static uint32_t nextGlanceAt = 0;
static uint32_t nextAutoMoodAt = 0;
static uint32_t nextFidgetAt = 0;
static uint32_t nextMicroAt = 0;
static float microX = 0, microY = 0;

static uint32_t nextBlinkAt = 0;
static uint32_t blinkStart = 0;
static uint32_t blinkClose = 75, blinkHold = 35, blinkOpen = 160;
static bool blinking = false;
static bool blinkAgain = false;
static uint8_t winkEye = 0;
static uint32_t winkStart = 0;
static uint32_t winkDur = 0;
static uint32_t nextWinkAt = 0;

static GestureType gesture = G_NONE;
static uint32_t gestureStart = 0, gestureDur = 0;
static int gestureSide = 1;
static float gAx = 0, gAy = 0, gBx = 0, gBy = 0;

static uint32_t lastInteraction = 0;
static Awake awake = AWAKE;
static uint32_t drowsySince = 0;
static int petCount = 0;
static uint32_t lastPetAt = 0;

static PupilStyle frameStyle = ST_ROUND;
static float frameHeartPulse = 1;
static float frameOffX = 0, frameOffY = 0;
static float frameBlinkL = 0, frameBlinkR = 0;
static uint8_t blushCol[3] = {0, 0, 0};

static bool g_bootDown = false;
static bool g_bootLong = false;
static uint32_t g_bootDownAt = 0;
static String g_line;
static int dirtyTop = 0, dirtyBot = FB_H - 1;
static int prevTop = 0, prevBot = FB_H - 1;
static uint32_t frames = 0;
static uint32_t nextStatsAt = 0;

static float clampf(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); }
static float lerpf(float a, float b, float t) { return a + (b - a) * t; }
static float easeOut(float t) { t = clampf(t, 0, 1); return 1.0f - (1.0f - t) * (1.0f - t); }
static float easeIn(float t) { t = clampf(t, 0, 1); return t * t; }
static float randf(float a, float b) { return a + (b - a) * (random(0, 10000) / 10000.0f); }
static bool chance(float p) { return randf(0, 1) < p; }
static int32_t since(uint32_t now, uint32_t then) { return (int32_t)(now - then); }
static bool after(uint32_t now, uint32_t when) { return since(now, when) >= 0; }

static void startGesture(GestureType type, uint32_t dur, uint32_t now, int side = 1);
static void startBlink(uint32_t now, bool slow);
static void scheduleBlink(uint32_t from);

static void setMood(Mood next, uint32_t holdMs, bool hold = false) {
  const uint32_t now = millis();
  if (next != mood) {
    moodEnteredAt = now;
    if (next == MOOD_SURPRISED) offVelY -= 95;
    if (next == MOOD_HAPPY || next == MOOD_EXCITED) offVelY -= 55;
    if (next == MOOD_SAD) { tear = 0.001f; tearSide = chance(0.5f) ? -1 : 1; }
  }
  mood = next;
  moodUntil = now + holdMs;
  moodHold = hold;
}

static void wakeUp(uint32_t now) {
  awake = AWAKE;
  drowsy = 0;
  startGesture(G_STRETCH, 700, now);
  pokeHappyPending = true;
  setMood(MOOD_SURPRISED, 700);
  scheduleBlink(now + 750);
  blinkAgain = true;
}

static void touched(uint32_t now) {
  lastInteraction = now;
  if (awake != AWAKE) wakeUp(now);
}

static void fallAsleep(uint32_t now) {
  (void)now;
  awake = ASLEEP;
  gesture = G_NONE;
  glancing = false;
  pokeHappyPending = false;
  setMood(MOOD_ASLEEP, 0, true);
}

static void scheduleBlink(uint32_t from) {
  const bool sleepyish = mood == MOOD_SLEEPY || awake == DROWSY;
  nextBlinkAt = from + (sleepyish ? random(1100, 2200) : random(2600, 5200));
}
static void scheduleGlance(uint32_t from) { nextGlanceAt = from + random(2800, 6200); }
static void scheduleAutoMood(uint32_t from) { nextAutoMoodAt = from + random(12000, 22000); }
static void scheduleFidget(uint32_t from) { nextFidgetAt = from + random(4500, 9000); }
static void scheduleWink(uint32_t from) { nextWinkAt = from + random(8000, 18000); }

static void startBlink(uint32_t now, bool slow) {
  blinking = true;
  blinkStart = now;
  if (slow) { blinkClose = 220; blinkHold = 120; blinkOpen = 420; }
  else { blinkClose = 75; blinkHold = 35; blinkOpen = 160; }
}

static float blinkCurve(int32_t u) {
  if (u < 0) return 0;
  if ((uint32_t)u < blinkClose) return easeIn(u / (float)blinkClose);
  if ((uint32_t)u < blinkClose + blinkHold) return 1;
  const float o = (u - (int32_t)blinkClose - (int32_t)blinkHold) / (float)blinkOpen;
  if (o >= 1) return 0;
  return 1 - easeOut(o);
}

static void startGesture(GestureType type, uint32_t dur, uint32_t now, int side) {
  gesture = type;
  gestureStart = now;
  gestureDur = dur;
  gestureSide = side;
  gAx = gAy = gBx = gBy = 0;
  if (type == G_GLANCE2) {
    gAx = (chance(0.5f) ? -1 : 1) * randf(0.5f, 0.85f);
    gAy = randf(-0.35f, 0.3f);
    gBx = -gAx * randf(0.6f, 1.0f);
    gBy = randf(-0.3f, 0.3f);
  }
}

static void pickFidget(uint32_t now) {
  const bool lonely = since(now, lastInteraction) > (int32_t)LONELY_AFTER_MS;
  const float roll = randf(0, 1);
  if (lonely && roll < 0.12f) { setMood(MOOD_SAD, 2400); return; }
  if (roll < 0.22f) startGesture(G_GLANCE2, random(1500, 2200), now);
  else if (roll < 0.40f) startGesture(G_THINK, random(1000, 1500), now, chance(0.5f) ? -1 : 1);
  else if (roll < 0.55f) startGesture(G_BROW, random(800, 1200), now, chance(0.5f) ? -1 : 1);
  else if (roll < 0.65f) { startBlink(now, false); blinkAgain = true; }
  else if (roll < 0.78f) startGesture(G_WIGGLE, 700, now);
  else if (roll < 0.90f) startGesture(G_STARE, random(1300, 1900), now);
  else startGesture(G_SIGH, 1500, now);
}

static void poke() {
  const uint32_t now = millis();
  touched(now);
  pokeHappyPending = true;
  setMood(MOOD_SURPRISED, 380);
}

static void pokeEye(int side) {
  const uint32_t now = millis();
  touched(now);
  winkEye = side < 0 ? 1 : 2;
  winkStart = now;
  winkDur = 460;
  pokeHappyPending = false;
  setMood(MOOD_ANNOYED, 900);
  glancing = true;
  glanceUntil = now + 900;
  targetLookX = -side * 0.75f;
  targetLookY = -0.35f;
  offVelY -= 40;
}

static void pet(int dir) {
  const uint32_t now = millis();
  touched(now);
  petCount = (since(now, lastPetAt) < 5000) ? petCount + 1 : 1;
  lastPetAt = now;
  glancing = true;
  glanceUntil = now + 800;
  targetLookX = dir * 0.8f;
  targetLookY = 0.05f;
  pokeHappyPending = false;
  if (petCount >= 3) {
    petCount = 0;
    setMood(MOOD_LOVE, 2800);
  } else {
    setMood(MOOD_HAPPY, 1100);
  }
}

static void saveLook() { prefs.putUChar("look", (uint8_t)lookIndex); }

static void setLook(int i, bool animate) {
  i = ((i % LOOK_COUNT) + LOOK_COUNT) % LOOK_COUNT;
  const uint32_t now = millis();
  touched(now);
  if (!animate) { lookIndex = i; saveLook(); return; }
  pendingLook = i;
  startBlink(now, false);
}

static void forceMood(Mood m, uint32_t holdMs) {
  const uint32_t now = millis();
  if (m == MOOD_ASLEEP) { lastInteraction = now - DROWSY_AFTER_MS - 1; fallAsleep(now); return; }
  touched(now);
  pokeHappyPending = false;
  setMood(m, holdMs, m == MOOD_IDLE);
}

static void startWinkNow(uint32_t now) {
  if (blinking || awake != AWAKE) return;
  winkEye = chance(0.6f) ? 1 : 2;
  winkStart = now;
  winkDur = 280;
}

static void update(uint32_t now, float dt) {
  if (awake == AWAKE && since(now, lastInteraction) > (int32_t)DROWSY_AFTER_MS && mood != MOOD_LOVE) {
    awake = DROWSY;
    drowsySince = now;
    gesture = G_NONE;
    pokeHappyPending = false;
    setMood(MOOD_SLEEPY, 0, true);
  }
  if (awake == DROWSY) {
    drowsy = clampf(since(now, drowsySince) / (float)DROWSY_LEN_MS, 0, 1);
    if (drowsy >= 1) fallAsleep(now);
  }
  backlight = lerpf(backlight, awake == ASLEEP ? 0.35f : 1.0f, 0.04f);

  if (mood != MOOD_IDLE && !moodHold && after(now, moodUntil)) {
    if (pokeHappyPending) {
      pokeHappyPending = false;
      setMood(MOOD_HAPPY, 1800);
    } else {
      mood = MOOD_IDLE;
    }
  }

  const bool asleep = awake == ASLEEP;
  if (!blinking && !asleep && after(now, nextBlinkAt) && mood != MOOD_LOVE && mood != MOOD_DIZZY) {
    startBlink(now, mood == MOOD_SLEEPY || awake == DROWSY);
  }
  if (blinking) {
    const int32_t u = since(now, blinkStart);
    blinkL = blinkCurve(u);
    blinkR = blinkCurve(u - 14);
    if (pendingLook >= 0 && u >= (int32_t)blinkClose) {
      lookIndex = pendingLook;
      pendingLook = -1;
      saveLook();
    }
    if (u >= (int32_t)(blinkClose + blinkHold + blinkOpen + 14)) {
      blinking = false;
      blinkL = blinkR = 0;
      openPop = 1;
      if (blinkAgain) { blinkAgain = false; startBlink(now + 60, false); }
      else scheduleBlink(now);
    }
  } else if (asleep) {
    blinkL = lerpf(blinkL, 1, 0.06f);
    blinkR = lerpf(blinkR, 1, 0.06f);
  } else {
    blinkL = blinkR = 0;
  }
  openPop *= 0.82f;

  if (winkEye == 0 && after(now, nextWinkAt) && mood == MOOD_IDLE && !blinking && awake == AWAKE) {
    startWinkNow(now);
  }
  float winkAmt = 0;
  if (winkEye != 0) {
    const float u = since(now, winkStart) / (float)winkDur;
    winkAmt = u < 0.35f ? easeIn(u / 0.35f) : 1 - easeOut((u - 0.35f) / 0.65f);
    if (u >= 1) { winkEye = 0; winkAmt = 0; scheduleWink(now); }
  }

  const bool canIdle = mood == MOOD_IDLE && awake == AWAKE && gesture == G_NONE;
  if (!glancing && canIdle && after(now, nextGlanceAt)) {
    glancing = true;
    glanceUntil = now + random(700, 1400);
    targetLookX = (chance(0.5f) ? -1 : 1) * randf(0.45f, 0.85f);
    targetLookY = randf(-0.3f, 0.35f);
  }
  if (glancing && after(now, glanceUntil)) {
    glancing = false;
    targetLookX = 0;
    targetLookY = 0;
    scheduleGlance(now);
  }
  if (canIdle && !glancing && after(now, nextFidgetAt)) {
    pickFidget(now);
    scheduleFidget(now);
  }
  if (canIdle && after(now, nextAutoMoodAt)) {
    const float roll = randf(0, 1);
    if (roll < 0.40f) setMood(MOOD_HAPPY, 2200);
    else if (roll < 0.70f) setMood(MOOD_CURIOUS, 2000);
    else if (roll < 0.82f) setMood(MOOD_EXCITED, 1600);
    else if (roll < 0.90f) setMood(MOOD_DIZZY, 1800);
    else setMood(MOOD_SLEEPY, 2800);
    scheduleAutoMood(now);
  }
  if (gesture != G_NONE && since(now, gestureStart) >= (int32_t)gestureDur) gesture = G_NONE;

  if (after(now, nextMicroAt)) {
    microX = randf(-0.05f, 0.05f);
    microY = randf(-0.04f, 0.04f);
    nextMicroAt = now + random(600, 1600);
  }

  float lx = targetLookX;
  float ly = targetLookY;
  bool directLook = false;
  if (mood == MOOD_DIZZY) {
    swirl += 0.20f;
    lx = sinf(swirl) * 0.85f;
    ly = cosf(swirl) * 0.5f;
    directLook = true;
  } else if (mood == MOOD_ASLEEP) {
    lx = 0;
    ly = 0.3f;
  } else if (!glancing) {
    switch (mood) {
      case MOOD_ANNOYED: lx = 0; ly = -0.3f; break;
      case MOOD_CURIOUS: lx = 0.7f; ly = -0.2f; break;
      case MOOD_SAD: lx = 0.15f * tearSide; ly = 0.5f; break;
      case MOOD_SLEEPY: lx = 0.1f; ly = 0.35f + drowsy * 0.2f; break;
      case MOOD_HAPPY: lx = 0; ly = 0.06f; break;
      case MOOD_LOVE: lx = 0; ly = -0.05f; break;
      case MOOD_SURPRISED:
      case MOOD_EXCITED: lx = 0; ly = 0; break;
      default: break;
    }
  }
  const float gu = gesture != G_NONE ? clampf(since(now, gestureStart) / (float)gestureDur, 0, 1) : 0;
  if (gesture != G_NONE) {
    switch (gesture) {
      case G_GLANCE2:
        if (gu < 0.4f) { lx = gAx; ly = gAy; }
        else if (gu < 0.8f) { lx = gBx; ly = gBy; }
        else { lx = 0; ly = 0; }
        break;
      case G_THINK: lx = -0.45f * gestureSide; ly = -0.55f; break;
      case G_STARE: lx = 0; ly = -0.04f; break;
      case G_SIGH: ly = gu < 0.6f ? 0.35f : 0; lx = 0; break;
      case G_BROW: lx = 0.3f * gestureSide; ly = -0.1f; break;
      case G_EYEROLL: {
        const float a = gu * PI * 1.6f;
        lx = sinf(a) * 0.7f;
        ly = -fabsf(cosf(a)) * 0.6f;
        break;
      }
      default: break;
    }
  }
  if (mood == MOOD_IDLE) { lx += microX; ly += microY; }

  if (directLook) {
    lookX = lerpf(lookX, lx, 0.3f);
    lookY = lerpf(lookY, ly, 0.3f);
    velX = velY = 0;
  } else {
    const float K = 260, D = 14;
    velX += (lx - lookX) * K * dt;
    velY += (ly - lookY) * K * dt;
    const float damp = expf(-D * dt);
    velX *= damp;
    velY *= damp;
    lookX += velX * dt;
    lookY += velY * dt;
  }

  Shape L, R;
  float offYTarget = 0;
  float wantBlush = 0;
  float rate = 0.16f;
  switch (mood) {
    case MOOD_HAPPY:
      L.bot = R.bot = 0.58f; L.top = R.top = 0.04f; L.pupil = R.pupil = 0.92f;
      offYTarget = -2; wantBlush = 1;
      break;
    case MOOD_SLEEPY:
      L.top = R.top = 0.42f + drowsy * 0.3f; L.curve = R.curve = 0.3f;
      L.bot = R.bot = 0.06f; L.pupil = R.pupil = 0.95f;
      break;
    case MOOD_CURIOUS: {
      Shape &wide = lookX >= 0 ? R : L;
      Shape &narrow = lookX >= 0 ? L : R;
      wide.sx = 1.06f; wide.sy = 1.09f; narrow.top = 0.28f;
      break;
    }
    case MOOD_SURPRISED:
      L.sx = R.sx = 1.12f; L.sy = R.sy = 1.16f; L.top = R.top = -0.04f;
      L.pupil = R.pupil = 0.42f; offYTarget = -4; rate = 0.34f;
      break;
    case MOOD_LOVE: L.bot = R.bot = 0.22f; wantBlush = 1; break;
    case MOOD_DIZZY: L.top = R.top = 0.18f; L.bot = R.bot = 0.08f; L.pupil = R.pupil = 0.8f; wantBlush = 0.5f; break;
    case MOOD_ANNOYED: L.top = R.top = 0.40f; L.ang = R.ang = 0.65f; L.pupil = R.pupil = 0.9f; break;
    case MOOD_SAD:
      L.top = R.top = 0.26f; L.ang = R.ang = -0.55f; L.curve = R.curve = 0.15f;
      L.pupil = R.pupil = 1.05f; offYTarget = 3;
      break;
    case MOOD_EXCITED:
      L.sx = R.sx = 1.05f; L.sy = R.sy = 1.05f; L.bot = R.bot = 0.22f;
      L.pupil = R.pupil = 1.25f; wantBlush = 0.8f;
      break;
    case MOOD_ASLEEP: L.top = R.top = 0.3f; L.curve = R.curve = 0.3f; break;
    default: break;
  }
  const float lean = 1 + 0.04f * fabsf(lookX);
  L.sx *= lean;
  R.sx *= lean;

  float gOffX = 0;
  if (gesture != G_NONE) {
    const float env = sinf(PI * gu);
    switch (gesture) {
      case G_THINK: L.top += 0.3f * env; R.top += 0.3f * env; break;
      case G_BROW: {
        Shape &up = gestureSide > 0 ? R : L;
        Shape &down = gestureSide > 0 ? L : R;
        up.sy *= 1 + 0.12f * env; up.sx *= 1 + 0.04f * env; down.top += 0.24f * env;
        break;
      }
      case G_WIGGLE:
        gOffX = sinf(gu * PI * 4) * 3.5f * env;
        L.bot += 0.22f * env; R.bot += 0.22f * env;
        break;
      case G_STARE:
        L.sx *= 1 + 0.03f * env; R.sx *= 1 + 0.03f * env;
        L.sy *= 1 + 0.04f * env; R.sy *= 1 + 0.04f * env;
        break;
      case G_SIGH: {
        const float e = gu < 0.6f ? (gu / 0.6f) : 1 - easeOut((gu - 0.6f) / 0.4f);
        L.top += 0.32f * e; R.top += 0.32f * e; L.bot += 0.06f * e; R.bot += 0.06f * e;
        break;
      }
      case G_STRETCH:
        L.sx *= 1 + 0.07f * env; R.sx *= 1 + 0.07f * env;
        L.sy *= 1 + 0.13f * env; R.sy *= 1 + 0.13f * env;
        L.top -= 0.06f * env; R.top -= 0.06f * env;
        break;
      case G_EYEROLL: L.top += 0.15f * env; R.top += 0.15f * env; break;
      default: break;
    }
  }
  if (mood == MOOD_ANNOYED && since(now, moodEnteredAt) > 500 && gesture == G_NONE && chance(0.004f)) {
    startGesture(G_EYEROLL, 900, now);
  }

  breath = 0.5f + 0.5f * sinf(now / 1600.0f);
  float wobX = 0, wobY = 0;
  if (mood == MOOD_DIZZY) { wobX = sinf(swirl * 0.9f) * 3; wobY = cosf(swirl * 0.7f) * 2; }
  if (mood == MOOD_EXCITED) wobX = sinf(now / 26.0f) * 1.6f;
  if (mood == MOOD_ASLEEP) wobY = sinf(now / 900.0f) * 1.5f;

  shapeL.sx = lerpf(shapeL.sx, L.sx, rate);
  shapeL.sy = lerpf(shapeL.sy, L.sy, rate);
  shapeL.top = lerpf(shapeL.top, L.top, rate);
  shapeL.ang = lerpf(shapeL.ang, L.ang, rate);
  shapeL.curve = lerpf(shapeL.curve, L.curve, rate);
  shapeL.bot = lerpf(shapeL.bot, L.bot, rate);
  shapeL.pupil = lerpf(shapeL.pupil, L.pupil, rate * 1.4f);
  shapeR.sx = lerpf(shapeR.sx, R.sx, rate);
  shapeR.sy = lerpf(shapeR.sy, R.sy, rate);
  shapeR.top = lerpf(shapeR.top, R.top, rate);
  shapeR.ang = lerpf(shapeR.ang, R.ang, rate);
  shapeR.curve = lerpf(shapeR.curve, R.curve, rate);
  shapeR.bot = lerpf(shapeR.bot, R.bot, rate);
  shapeR.pupil = lerpf(shapeR.pupil, R.pupil, rate * 1.4f);

  const float K2 = 220, D2 = 12;
  offVelY += (offYTarget - offY) * K2 * dt;
  offVelY *= expf(-D2 * dt);
  offY += offVelY * dt;
  offX = lerpf(offX, lookX * PAIR_SHIFT_X + gOffX + wobX, 0.25f);

  blush = lerpf(blush, wantBlush, 0.08f);
  heartbeat += dt * 7;
  if (tear > 0) {
    tear += dt * 0.55f;
    if (mood != MOOD_SAD || tear > 1.6f) tear = 0;
  }

  frameBlinkL = clampf(blinkL + (winkEye == 1 ? winkAmt : 0), 0, 1);
  frameBlinkR = clampf(blinkR + (winkEye == 2 ? winkAmt : 0), 0, 1);
  frameOffX = offX;
  frameOffY = offY + lookY * PAIR_SHIFT_Y + wobY + breath * 0.6f;
  const PupilStyle baseStyle = LOOKS[lookIndex].style;
  frameStyle = mood == MOOD_LOVE ? ST_HEART
             : (mood == MOOD_DIZZY && baseStyle != ST_NONE) ? ST_RING
             : baseStyle;
  const uint8_t *lb = LOOKS[lookIndex].blush;
  blushCol[0] = lb[0];
  blushCol[1] = lb[1];
  blushCol[2] = lb[2];
  const float hb = fmaxf(0, sinf(heartbeat));
  frameHeartPulse = 1 + 0.08f * hb * hb * hb;
}

static inline void markDirty(int y0, int y1) {
  y0 = max(0, y0);
  y1 = min(FB_H - 1, y1);
  if (y0 < dirtyTop) dirtyTop = y0;
  if (y1 > dirtyBot) dirtyBot = y1;
}

static inline uint16_t packColor(int r, int g, int b) {
  return (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3));
}

// C6 has no FPU — keep the inner loop integer. Integer sqrt for ellipse spans.
static int isqrt(int n) {
  if (n <= 0) return 0;
  unsigned x = (unsigned)n;
  unsigned r = 0;
  unsigned bit = 1u << 30;
  while (bit > x) bit >>= 2;
  while (bit) {
    unsigned t = r + bit;
    r >>= 1;
    if (x >= t) {
      x -= t;
      r += bit;
    }
    bit >>= 2;
  }
  return (int)r;
}

static void fillEllipse(int cx, int cy, int rx, int ry, uint16_t c) {
  if (rx < 1) rx = 1;
  if (ry < 1) ry = 1;
  const int rx2 = rx * rx;
  const int ry2 = ry * ry;
  const int y0 = max(0, cy - ry);
  const int y1 = min(FB_H - 1, cy + ry);
  markDirty(y0, y1);
  for (int y = y0; y <= y1; y++) {
    const int dy = y - cy;
    const int inner = ry2 - dy * dy;
    if (inner < 0) continue;
    const int half = isqrt((int)((int32_t)rx2 * inner / ry2));
    int x0 = cx - half;
    int x1 = cx + half;
    if (x0 < 0) x0 = 0;
    if (x1 >= FB_W) x1 = FB_W - 1;
    uint16_t *row = fb + y * FB_W + x0;
    for (int x = x0; x <= x1; x++) *row++ = c;
  }
}

static void drawBlush(float cx, float cy, float amt, const uint8_t *col) {
  if (amt < 0.03f) return;
  const int R = (int)lroundf(BLUSH_R * amt);
  if (R < 2) return;
  const int a8 = (int)(amt * 140.0f);
  const uint16_t c = packColor(BG[0] + ((col[0] - BG[0]) * a8 >> 8),
                               BG[1] + ((col[1] - BG[1]) * a8 >> 8),
                               BG[2] + ((col[2] - BG[2]) * a8 >> 8));
  fillEllipse((int)lroundf(cx), (int)lroundf(cy), R + 2, R, c);
}

static void drawTear(float cx, float cy, float amt) {
  if (amt <= 0) return;
  const int x = (int)lroundf(cx);
  const int y = (int)lroundf(cy + amt * 14);
  const uint16_t c = packColor(COL_TEAR[0], COL_TEAR[1], COL_TEAR[2]);
  fillEllipse(x, y, 3, 3, c);
  fillEllipse(x, y - 3, 2, 3, c);
}

static bool pupilHit(PupilStyle style, int ddx, int ddy, int pr, int slitAx, int slitAy, int dotR) {
  const int d2 = ddx * ddx + ddy * ddy;
  switch (style) {
    case ST_ROUND:
      return d2 <= pr * pr;
    case ST_DOT:
      return d2 <= dotR * dotR;
    case ST_SLIT: {
      const int32_t ax2 = slitAx * slitAx;
      const int32_t ay2 = slitAy * slitAy;
      return (int64_t)ddx * ddx * ay2 + (int64_t)ddy * ddy * ax2 <= (int64_t)ax2 * ay2;
    }
    case ST_RING: {
      const int mid = (pr * 4) / 5;
      const int thick = pr / 5 + 1;
      const int outer = mid + thick;
      const int inner = mid - thick;
      const int core = pr / 4;
      return (d2 <= outer * outer && d2 >= inner * inner) || d2 <= core * core;
    }
    case ST_STAR: {
      const int ax = ddx < 0 ? -ddx : ddx;
      const int ay = ddy < 0 ? -ddy : ddy;
      return (ax + ay) <= pr || (ax <= pr / 3 && ay <= pr) || (ay <= pr / 3 && ax <= pr);
    }
    case ST_HEART: {
      const int pr2 = (pr * 2) / 5;
      const int lx = ddx + pr / 3, ly = ddy + pr / 5;
      const int rx = ddx - pr / 3, ry = ddy + pr / 5;
      if (lx * lx + ly * ly <= pr2 * pr2) return true;
      if (rx * rx + ry * ry <= pr2 * pr2) return true;
      const int ax = ddx < 0 ? -ddx : ddx;
      return ddy >= 0 && (ax + ddy) < (pr * 4) / 5;
    }
    default:
      return false;
  }
}

static void drawEye(float cxf, float cyf, const Shape &sh, float blink, int side, const Look &look,
                    const uint8_t *pupilCol) {
  const float sy = sh.sy * (1 - 0.94f * blink) * (1 + 0.05f * openPop);
  const int cx = (int)lroundf(cxf);
  const int cy = (int)lroundf(cyf + blink * EYE_B * 0.10f - openPop * 1.5f);
  int rx = (int)lroundf(EYE_A * sh.sx);
  int ry = (int)lroundf(EYE_B * sy);
  if (rx < 4) rx = 4;
  if (ry < 2) ry = 2;

  const int topBaseQ = (int)lroundf((-1.0f + 2.1f * sh.top) * 256.0f);
  const int topCurveQ = (int)lroundf(sh.curve * 0.5f * 256.0f);
  const int botBaseQ = (int)lroundf((1.0f - 1.35f * sh.bot) * 256.0f);
  const int botCurveQ = (int)lroundf(sh.bot * 0.55f * 256.0f);
  const int angQ = (int)lroundf(sh.ang * 0.55f * (float)side * 256.0f);

  const PupilStyle style = frameStyle;
  const bool pupilOn = blink < 0.75f && style != ST_NONE && pupilCol != nullptr;
  int pr = (int)lroundf(style == ST_HEART ? PUPIL_R * 1.3f * frameHeartPulse : PUPIL_R * sh.pupil);
  if (pr < 3) pr = 3;
  const int slitAx = (int)lroundf(clampf(PUPIL_R * (0.36f + (1 - sh.pupil) * 0.9f), PUPIL_R * 0.18f, PUPIL_R * 0.95f));
  const int slitAy = (int)lroundf(PUPIL_R * 1.3f);
  const int dotR = (int)lroundf(PUPIL_R * 0.36f * clampf(sh.pupil, 0.6f, 1.3f));
  const int px = cx + (int)lroundf(lookX * EYE_A * LOOK_TRAVEL_X);
  const int py = cy + (int)lroundf(lookY * EYE_B * LOOK_TRAVEL_Y);
  const bool shineOn = look.shine && (style == ST_ROUND || style == ST_HEART);
  const uint8_t *pc = style == ST_HEART ? COL_HEART : pupilCol;
  const uint16_t sclera = packColor(look.sclera[0], look.sclera[1], look.sclera[2]);
  const uint16_t pcol = pc ? packColor(pc[0], pc[1], pc[2]) : sclera;
  const uint16_t shine = packColor(COL_SHINE[0], COL_SHINE[1], COL_SHINE[2]);
  const int s1x = px - (pr * 36) / 100, s1y = py - (pr * 38) / 100, sr1 = (pr * 30) / 100;
  const int s2x = px + (pr * 26) / 100, s2y = py + (pr * 26) / 100, sr2 = (pr * 13) / 100;
  const int sr1_2 = sr1 * sr1, sr2_2 = sr2 * sr2;

  const int rx2 = rx * rx;
  const int ry2 = ry * ry;
  const int y0 = max(0, cy - ry - 1);
  const int y1 = min(FB_H - 1, cy + ry + 1);
  markDirty(y0, y1);

  for (int y = y0; y <= y1; y++) {
    const int dy = y - cy;
    const int inner = ry2 - dy * dy;
    if (inner < 0) continue;
    const int half = isqrt((int)((int32_t)rx2 * inner / ry2));
    int x0 = cx - half;
    int x1 = cx + half;
    if (x0 < 0) x0 = 0;
    if (x1 >= FB_W) x1 = FB_W - 1;
    const int nyQ = (dy * 256) / ry;
    uint16_t *row = fb + y * FB_W;
    for (int x = x0; x <= x1; x++) {
      const int nxQ = ((x - cx) * 256) / rx;
      const int nx2 = (nxQ * nxQ) >> 8;
      const int topQ = topBaseQ + ((angQ * nxQ) >> 8) - ((topCurveQ * nx2) >> 8);
      if (nyQ < topQ) continue;
      const int botQ = botBaseQ + ((botCurveQ * nx2) >> 8);
      if (nyQ > botQ) continue;
      uint16_t pix = sclera;
      if (pupilOn && pupilHit(style, x - px, y - py, pr, slitAx, slitAy, dotR)) {
        pix = pcol;
        if (shineOn) {
          const int d1 = (x - s1x) * (x - s1x) + (y - s1y) * (y - s1y);
          const int d2 = (x - s2x) * (x - s2x) + (y - s2y) * (y - s2y);
          if (d1 <= sr1_2 || d2 <= sr2_2) pix = shine;
        }
      }
      row[x] = pix;
    }
  }
}

static void drawFace() {
  const Look &look = LOOKS[lookIndex];
  const uint16_t bg = packColor(BG[0], BG[1], BG[2]);
  const int clear0 = max(0, prevTop) * FB_W;
  const int clearN = (min(FB_H - 1, prevBot) + 1) * FB_W;
  uint32_t *p32 = (uint32_t *)(fb + clear0);
  const uint32_t bg2 = ((uint32_t)bg << 16) | bg;
  const int n32 = (clearN - clear0) >> 1;
  for (int i = 0; i < n32; i++) p32[i] = bg2;
  dirtyTop = FB_H;
  dirtyBot = -1;

  drawBlush(EYE_L_X - BLUSH_DX + frameOffX * 0.5f, BLUSH_Y + frameOffY * 0.5f, blush, blushCol);
  drawBlush(EYE_R_X + BLUSH_DX + frameOffX * 0.5f, BLUSH_Y + frameOffY * 0.5f, blush, blushCol);
  if (tear > 0) {
    const float ex = tearSide < 0 ? EYE_L_X - 36 : EYE_R_X + 36;
    drawTear(ex + frameOffX, EYE_Y + EYE_B * 0.55f + frameOffY, tear);
  }
  const uint8_t *pupilL = look.hasPupil ? look.pupil : nullptr;
  const uint8_t *pupilR = look.hasPupil2 ? look.pupil2 : pupilL;
  drawEye(EYE_L_X + frameOffX, EYE_Y + frameOffY, shapeL, frameBlinkL, +1, look, pupilL);
  drawEye(EYE_R_X + frameOffX, EYE_Y + frameOffY, shapeR, frameBlinkR, -1, look, pupilR);

  if (dirtyBot < dirtyTop) { dirtyTop = 0; dirtyBot = 0; }
  const int top = min(prevTop, dirtyTop);
  const int bot = max(prevBot, dirtyBot);
  gfx->draw16bitRGBBitmap(0, top, fb + top * FB_W, FB_W, bot - top + 1);
  prevTop = dirtyTop;
  prevBot = dirtyBot;
}

static void applyBacklight() {
  const int pwm = (int)(C6_BL_PWM * clampf(backlight, 0.25f, 1.0f));
  analogWrite(C6_LCD_BL_PIN, pwm);
}

static void pulseLed(uint32_t now) {
  const Look &L = LOOKS[lookIndex];
  const uint8_t *c = mood == MOOD_LOVE ? COL_HEART : L.sclera;
  const float u = 0.45f + 0.55f * (0.5f + 0.5f * sinf(now / 420.0f));
  const float pop = 1.0f + 0.35f * openPop;
  rgb.setPixelColor(0, rgb.Color((uint8_t)(c[0] * u * pop), (uint8_t)(c[1] * u * pop),
                                 (uint8_t)(c[2] * u * pop)));
  rgb.show();
}

static Mood moodByName(const String &s) {
  for (int i = 0; i < MOOD_COUNT; i++) {
    if (s.equalsIgnoreCase(MOOD_NAMES[i])) return (Mood)i;
  }
  return MOOD_COUNT;
}

static void printStatus() {
  Serial.printf("mood=%s look=%s(%d) awake=%s look=%.2f,%.2f wink=%u\n",
                MOOD_NAMES[mood], LOOKS[lookIndex].name, lookIndex,
                awake == AWAKE ? "awake" : (awake == DROWSY ? "drowsy" : "asleep"),
                lookX, lookY, (unsigned)winkEye);
}

static void printHelp() {
  Serial.println(F("slab-eyes — landscape Arduino eyes on ESP32-C6-LCD-1.47"));
  Serial.println(F("  mood <name> | look <n|next|prev|list> | gaze x y"));
  Serial.println(F("  blink | wink | poke | pokeeye [left|right] | pet | sleep | wake"));
  Serial.println(F("  status | help"));
  Serial.print(F("moods:"));
  for (int i = 0; i < MOOD_COUNT; i++) Serial.printf(" %s", MOOD_NAMES[i]);
  Serial.println();
  Serial.println(F("Always landscape. No IMU — it looks around and winks on its own."));
  Serial.println(F("BOOT short = poke. Hold BOOT = next look."));
}

static void handleSerialLine(String line) {
  line.trim();
  if (!line.length()) return;
  const uint32_t now = millis();
  int sp = line.indexOf(' ');
  String cmd = sp < 0 ? line : line.substring(0, sp);
  String arg = sp < 0 ? "" : line.substring(sp + 1);
  arg.trim();
  cmd.toLowerCase();

  if (cmd == "mood") {
    const Mood m = moodByName(arg);
    if (m == MOOD_COUNT) Serial.println(F("unknown mood"));
    else forceMood(m, 8000);
  } else if (cmd == "look") {
    if (arg == "next") setLook(lookIndex + 1, true);
    else if (arg == "prev") setLook(lookIndex - 1, true);
    else if (arg == "list") {
      for (int i = 0; i < LOOK_COUNT; i++) Serial.printf("%d %s\n", i, LOOKS[i].name);
    } else {
      int found = -1;
      for (int i = 0; i < LOOK_COUNT; i++) {
        if (arg.equalsIgnoreCase(LOOKS[i].name)) found = i;
      }
      setLook(found >= 0 ? found : arg.toInt(), true);
    }
  } else if (cmd == "gaze") {
    int sp2 = arg.indexOf(' ');
    glancing = true;
    glanceUntil = now + 2500;
    targetLookX = clampf(arg.substring(0, sp2 < 0 ? arg.length() : sp2).toFloat(), -1, 1);
    targetLookY = sp2 < 0 ? 0 : clampf(arg.substring(sp2 + 1).toFloat(), -1, 1);
    touched(now);
  } else if (cmd == "blink") {
    startBlink(now, false);
  } else if (cmd == "wink") {
    startWinkNow(now);
  } else if (cmd == "sleep") {
    forceMood(MOOD_ASLEEP, 0);
  } else if (cmd == "wake") {
    touched(now);
  } else if (cmd == "poke") {
    poke();
  } else if (cmd == "pokeeye") {
    pokeEye(arg == "right" ? 1 : -1);
  } else if (cmd == "pet") {
    pet(1);
  } else if (cmd == "status") {
    printStatus();
  } else if (cmd == "help" || cmd == "?") {
    printHelp();
  } else {
    Serial.println(F("? (try help)"));
  }
}

static void pollSerial() {
  while (Serial.available()) {
    char c = (char)Serial.read();
    if (c == '\r') continue;
    if (c == '\n') {
      handleSerialLine(g_line);
      g_line = "";
    } else if (g_line.length() < 64) {
      g_line += c;
    }
  }
}

static void pollBoot() {
  const bool down = digitalRead(C6_BOOT_PIN) == LOW;
  const uint32_t now = millis();
  if (down && !g_bootDown) {
    g_bootDown = true;
    g_bootLong = false;
    g_bootDownAt = now;
  }
  if (down && g_bootDown && !g_bootLong && (now - g_bootDownAt) >= BOOT_LONG_MS) {
    g_bootLong = true;
    setLook(lookIndex + 1, true);
    Serial.printf("look %s\n", LOOKS[(lookIndex + 1) % LOOK_COUNT].name);
  }
  if (!down && g_bootDown) {
    if (!g_bootLong && (now - g_bootDownAt) > 40) poke();
    g_bootDown = false;
  }
}

static int defaultLookFromChip() {
  uint64_t mac = ESP.getEfuseMac();
  uint32_t h = 2166136261u;
  for (int i = 0; i < 6; i++) {
    h ^= (uint8_t)(mac >> (8 * i));
    h *= 16777619u;
  }
  return (int)(h % LOOK_COUNT);
}

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println();
  Serial.println(F("slab-eyes"));

  pinMode(C6_BOOT_PIN, INPUT_PULLUP);
  pinMode(C6_LCD_BL_PIN, OUTPUT);
  analogWrite(C6_LCD_BL_PIN, C6_BL_PWM);

  if (!gfx->begin(40000000)) Serial.println(F("LCD init failed"));
  gfx->setRotation(1);
  gfx->fillScreen(packColor(BG[0], BG[1], BG[2]));

  fb = (uint16_t *)malloc(FB_W * FB_H * sizeof(uint16_t));
  if (!fb) {
    Serial.println(F("fb alloc failed"));
    while (true) delay(1000);
  }
  for (int i = 0; i < FB_W * FB_H; i++) fb[i] = packColor(BG[0], BG[1], BG[2]);

  rgb.begin();
  rgb.clear();
  rgb.show();

  prefs.begin("slabeyes", false);
  lookIndex = prefs.getUChar("look", (uint8_t)defaultLookFromChip()) % LOOK_COUNT;
  Serial.printf("look: %s (%d)  heap=%u\n", LOOKS[lookIndex].name, lookIndex, ESP.getFreeHeap());

  randomSeed(esp_random());
  const uint32_t now = millis();
  lastInteraction = now;
  scheduleBlink(now);
  scheduleGlance(now);
  scheduleAutoMood(now);
  scheduleFidget(now);
  scheduleWink(now);
  nextStatsAt = now + 5000;
  printHelp();
}

void loop() {
  static uint32_t lastMs = millis();
  const uint32_t now = millis();
  const float dt = clampf((now - lastMs) / 1000.0f, 0.001f, 0.08f);
  lastMs = now;

  pollBoot();
  pollSerial();
  update(now, dt);
  drawFace();
  applyBacklight();
  if ((frames & 3) == 0) pulseLed(now);

  frames++;
  if (after(now, nextStatsAt)) {
    Serial.printf("fps=%.1f ", frames / 5.0f);
    printStatus();
    frames = 0;
    nextStatsAt = now + 5000;
  }
}
