/*
 * Clawd: Claude Code desk buddy firmware (Atech 14-port board, ESP32-S3)
 *
 * What it does on its own:
 *   - two portrait screens are the eyes: a terminal-cursor pupil that blinks, glances around, and shows a mood
 *     (idle / thinking / listening / talking / happy / alert / sleepy)
 *   - the distance sensor wakes it when someone comes close and sends it to sleep when they leave
 *   - the boop button chirps + a happy face; the knob is the volume (ring shows the level), knob press = push-to-talk
 *   - the mic level is posted so the host can see when you speak
 *
 * What the host (host/clawd.py) adds over USB serial, Atech JSON protocol (software/talking-to-your-device.md):
 *   actions  set_state <name> | look {"x":-1..1,"y":-1..1} | blink | say_level 0..1 | set_volume 0..1 |
 *            play_tone {"frequency","duration"} | play_rtttl <str> | play_pcm <b64 16 kHz mono s16> | stop |
 *            listen 0|1 (stream mic PCM as "pcm" events, base64, 512 samples per chunk) | mood_hold ms
 *   events   button_press/button_release (boop), knob {"pos","dir"}, ptt 1/0, presence 1/0, distance mm,
 *            sound_level, pcm <b64>, state <name>
 *
 * Structure follows software/program-structure.md: everything runs in FreeRTOS tasks, loop() parks.
 */
#include <Arduino.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_task_wdt.h"
#include <Wire.h>
#include <ArduinoJson.h>

#include "modules/st7735_tft.h"
#include "modules/speaker.h"
#include "modules/microphone.h"
#include "modules/button.h"
#include "modules/rotary_encoder.h"
#include "modules/vl53l5cx.h"
#include "modules/serial_templates.h"

// ---- modules ------------------------------------------------------------------------------------------------------
ST7735_TFT eyeL(EYE_L_SCLK, EYE_L_CS, EYE_L_MOSI, EYE_L_DC);
ST7735_TFT eyeR(EYE_R_SCLK, EYE_R_CS, EYE_R_MOSI, EYE_R_DC);
Speaker    speaker(SPK_BCLK, SPK_LRCLK, SPK_DIN);
Microphone mic(MIC_LR, MIC_WS, MIC_SCK, MIC_SD);
ButtonModule boop(BTN_SW, BTN_LED);
RotaryEncoder knob(KNOB_CLK, KNOB_DT, KNOB_SW, KNOB_RING);
VL53L5CX_Sensor nose(Wire);
AtechSerial wifi(115200);          // named `wifi` regardless of transport (Atech convention)

// ---- eye geometry (portrait: 80 wide × 160 tall, drawn onto the landscape 160×80 panel) ----------------------------
static const int EW = 80, EH = 160;
static const uint16_t BG = 0x0000;
static const uint16_t PUPIL = 0xF6F0;        // warm white (247,232,210) in RGB565
static const uint16_t GLOW = 0x2124;         // faint warm glow behind the pupil
static const uint16_t ORANGE = 0xCB07;       // Claude terracotta (#cd6240)
static const uint16_t BAR = 0xC4E0;

struct Eye {
    ST7735_TFT* tft; bool flip;
    void plot(int u, int v, int w, int h, int r, uint16_t c) {      // portrait rect (u across, v down) -> landscape panel
        int x = v, y = u, ww = h, hh = w;
        if (flip) { x = 160 - v - h; y = 80 - u - w; }
        if (r > 0) tft->fillRoundRect(x, y, ww, hh, r, c); else tft->fillRect(x, y, ww, hh, c);
    }
    void dot(int u, int v, int rad, uint16_t c) { int x = v, y = u; if (flip) { x = 160 - v; y = 80 - u; } tft->fillCircle(x, y, rad, c); }
};
Eye eyes[2] = { { &eyeL, EYE_L_FLIP != 0 }, { &eyeR, EYE_R_FLIP != 0 } };

// ---- state --------------------------------------------------------------------------------------------------------
enum Mood { IDLE, THINKING, LISTENING, TALKING, HAPPY, ALERT, SLEEPY };
const char* MOOD_NAMES[] = { "idle", "thinking", "listening", "talking", "happy", "alert", "sleepy" };
volatile Mood mood = IDLE;
Mood hostMood = IDLE;                 // what the host asked for; local reactions (boop, presence) override it briefly
unsigned long moodHoldUntil = 0;      // local override expiry
float lookX = 0, lookY = 0;           // -1..1, where the pupils point
float lookTX = 0, lookTY = 0;
float sayLevel = 0;                   // 0..1 speech envelope from the host while talking
float micLevel = 0;                   // 0..1 smoothed mic RMS
unsigned long nextBlink = 0, blinkStart = 0, nextGlance = 0, lastSeen = 0, lastPresencePost = 0, lastLevelPost = 0;
bool present = false, listening = false, ptt = false;
float volume = 0.4f;
int32_t knobPos = 0;

TaskHandle_t mainTaskHandle = NULL, eyeTaskHandle = NULL;

static Mood currentMood() { return (millis() < moodHoldUntil) ? mood : hostMood; }
static void holdMood(Mood m, unsigned long ms) { mood = m; moodHoldUntil = millis() + ms; }
static float clamp01(float v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }

// ---- eyes ---------------------------------------------------------------------------------------------------------
// One frame per eye: background, glow, pupil (a cursor block) with a mood-dependent shape, catchlight.
static void drawEye(Eye& e, int side, float t, float blink) {
    Mood m = currentMood();
    int pw = 36, ph = 56, r = 7;                       // pupil block
    float ox = lookX * 14, oy = lookY * 20;            // glance offset
    uint16_t col = PUPIL;
    switch (m) {
        case THINKING: { pw = 24; ph = 24; r = 6; float a = t * 3.0f + side * 3.14f; ox = cosf(a) * 12; oy = sinf(a) * 18; break; }
        case LISTENING: pw = 44; ph = 64; r = 9; break;
        case TALKING:   ph = 40 + (int)(sayLevel * 28); pw = 34 + (int)(sayLevel * 8); break;
        case HAPPY:     break;                             // drawn as an arc below
        case ALERT:     pw = 46; ph = 70; r = 10; col = ORANGE; break;
        case SLEEPY:    ph = 24; oy = 22; col = 0xA514; break;
        default: break;
    }
    e.tft->fillScreen(BG);
    int cu = EW / 2 + (int)ox, cv = EH / 2 + (int)oy;
    if (m == HAPPY) {                                  // ^ ^ : a thick upward arc = two stacked rounded bars
        e.plot(cu - 22, cv - 4, 44, 12, 6, PUPIL); e.plot(cu - 12, cv - 14, 24, 12, 6, PUPIL); e.plot(cu - 4, cv - 22, 8, 10, 4, PUPIL);
        e.plot(cu - 22, cv + 8, 8, 8, 4, PUPIL); e.plot(cu + 14, cv + 8, 8, 8, 4, PUPIL);
    } else {
        int bh = (int)(ph * (1.0f - blink)); if (bh < 3) bh = 3;
        e.plot(cu - pw / 2 - 6, cv - bh / 2 - 6, pw + 12, bh + 12, r + 4, GLOW);
        e.plot(cu - pw / 2, cv - bh / 2, pw, bh, r, col);
        if (blink < 0.5f && m != SLEEPY) e.dot(cu - pw / 2 + 8, cv - bh / 2 + 9, 3, 0xFFFF);
    }
    if (m == LISTENING || listening) {                 // mic level bar along the bottom edge
        int len = (int)(micLevel * 64); e.plot(8, EH - 12, 64, 4, 2, 0x2104); if (len > 0) e.plot(8, EH - 12, len, 4, 2, BAR);
    }
    e.tft->display();
}

void eyeTask(void*) {
    esp_task_wdt_add(NULL);
    nextBlink = millis() + 2500;
    while (true) {
        esp_task_wdt_reset();
        unsigned long now = millis(); float t = now / 1000.0f;
        // blink: 140 ms, every 3–6 s (slower when sleepy, never when alert)
        float blink = 0;
        Mood m = currentMood();
        if (blinkStart && now - blinkStart < 140) { float u = (now - blinkStart) / 140.0f; blink = sinf(u * 3.14159f); }
        else if (blinkStart) { blinkStart = 0; nextBlink = now + (m == SLEEPY ? 6000 : 3000) + (esp_random() % 3000); }
        else if (now > nextBlink && m != ALERT) blinkStart = now;
        // glances: drift toward a random target every 2–5 s when idle
        if (now > nextGlance) { lookTX = ((int)(esp_random() % 200) - 100) / 100.0f * 0.6f; lookTY = ((int)(esp_random() % 200) - 100) / 100.0f * 0.4f; nextGlance = now + 2000 + esp_random() % 3000; }
        if (m == IDLE || m == SLEEPY) { lookX += (lookTX - lookX) * 0.08f; lookY += (lookTY - lookY) * 0.08f; }
        else if (m != THINKING) { lookX *= 0.9f; lookY *= 0.9f; }
        for (int i = 0; i < 2; i++) drawEye(eyes[i], i, t, blink);
        vTaskDelay(pdMS_TO_TICKS(33));                 // ~30 fps, two hardware SPI buses at 40 MHz
    }
}

// ---- sounds -------------------------------------------------------------------------------------------------------
static void chirpHello() { speaker.playRTTTL("hello:d=16,o=6,b=200:c,e,g,8c7"); }
static void chirpBoop()  { speaker.playRTTTL("boop:d=16,o=6,b=220:e,8g"); }
static void chirpBye()   { speaker.playRTTTL("bye:d=16,o=6,b=160:g,e,8c"); }

// ---- host actions -------------------------------------------------------------------------------------------------
static bool moodFromName(const char* s, Mood& out) {
    for (int i = 0; i < 7; i++) if (strcmp(s, MOOD_NAMES[i]) == 0) { out = (Mood)i; return true; }
    return false;
}

void handleMessage(const char* action, const char* value) {
    if (strcmp(action, "set_state") == 0) { Mood m; if (moodFromName(value, m)) { hostMood = m; moodHoldUntil = 0; wifi.postStateEvent("state", value); } }
    else if (strcmp(action, "mood_hold") == 0) { JsonDocument d; if (!deserializeJson(d, value)) { Mood m; if (moodFromName(d["mood"] | "happy", m)) holdMood(m, d["ms"] | 1500); } }
    else if (strcmp(action, "look") == 0) { JsonDocument d; if (!deserializeJson(d, value)) { lookTX = lookX = d["x"] | 0.0f; lookTY = lookY = d["y"] | 0.0f; nextGlance = millis() + 4000; } }
    else if (strcmp(action, "blink") == 0) { blinkStart = millis(); }
    else if (strcmp(action, "say_level") == 0) { sayLevel = clamp01(atof(value)); }
    else if (strcmp(action, "set_volume") == 0) { volume = clamp01(atof(value)); speaker.setVolume(volume); knob.setRingPosition(volume); }
    else if (strcmp(action, "play_tone") == 0) { JsonDocument d; if (!deserializeJson(d, value)) speaker.playTone(d["frequency"] | 440, d["duration"] | 200); }
    else if (strcmp(action, "play_rtttl") == 0) { speaker.playRTTTL(value); }
    else if (strcmp(action, "play_pcm") == 0) { speaker.playPCMBase64(value); }
    else if (strcmp(action, "stop") == 0) { speaker.stop(); sayLevel = 0; }
    else if (strcmp(action, "listen") == 0) { listening = (value[0] == '1' || strcmp(value, "true") == 0); }
    else if (strcmp(action, "chirp") == 0) { if (strcmp(value, "hello") == 0) chirpHello(); else if (strcmp(value, "bye") == 0) chirpBye(); else chirpBoop(); }
    else if (strcmp(action, "ping") == 0) { wifi.postLogEvent("clawd pong"); }
}

// ---- mic streaming ------------------------------------------------------------------------------------------------
static const int PCM_CHUNK = 512;                      // 32 ms at 16 kHz
static int16_t pcmBuf[PCM_CHUNK];
static char b64Buf[PCM_CHUNK * 2 * 4 / 3 + 8];
static const char* B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static void b64encode(const uint8_t* in, size_t n, char* out) {
    size_t o = 0;
    for (size_t i = 0; i < n; i += 3) {
        uint32_t v = in[i] << 16 | (i + 1 < n ? in[i + 1] << 8 : 0) | (i + 2 < n ? in[i + 2] : 0);
        out[o++] = B64[(v >> 18) & 63]; out[o++] = B64[(v >> 12) & 63];
        out[o++] = i + 1 < n ? B64[(v >> 6) & 63] : '='; out[o++] = i + 2 < n ? B64[v & 63] : '=';
    }
    out[o] = 0;
}

// ---- main task ----------------------------------------------------------------------------------------------------
void mainTask(void*) {
    esp_task_wdt_add(NULL);
    unsigned long lastDistance = 0;
    while (true) {
        esp_task_wdt_reset();
        wifi.maintain();
        boop.update(); knob.update();
        unsigned long now = millis();

        // boop
        if (boop.wasPressed()) { chirpBoop(); holdMood(HAPPY, 1500); boop.setColor(205, 98, 64); boop.on(); wifi.postButtonEvent("button_press", 1); }
        if (boop.wasReleased()) { boop.off(); wifi.postButtonEvent("button_release", 1); }

        // knob = volume; press = push-to-talk (posted, the host decides what to do with it)
        int32_t p = knob.getPosition();
        if (p != knobPos) {
            volume = clamp01(volume + (p - knobPos) * (1.0f / 15));
            knobPos = p; speaker.setVolume(volume); knob.setRingPosition(volume);
            JsonDocument d; d["pos"] = (int)p; d["volume"] = volume; String s; serializeJson(d, s); wifi.postSensorEventStr("knob", s.c_str(), "knob");
            if (!speaker.isPlaying()) speaker.playTone(600 + (int)(volume * 600), 30);
        }
        bool k = knob.isPressed();
        if (k != ptt) { ptt = k; wifi.postButtonEvent("ptt", ptt ? 1 : 0); if (ptt) { holdMood(LISTENING, 60000); } else { moodHoldUntil = 0; } }

        // presence: the nose ranges continuously in a background task
        if (now - lastDistance >= 100 && nose.isReady()) {
            lastDistance = now; int mm = nose.getMinDistance();
            bool near = mm > 0 && mm < 900;
            if (near) lastSeen = now;
            if (near && !present) { present = true; wifi.postButtonEvent("presence", 1); if (currentMood() == SLEEPY || hostMood == SLEEPY) { hostMood = IDLE; } chirpHello(); holdMood(ALERT, 900); }
            if (!near && present && now - lastSeen > 20000) { present = false; wifi.postButtonEvent("presence", 0); chirpBye(); hostMood = SLEEPY; }
            if (now - lastPresencePost >= 1000) { lastPresencePost = now; wifi.postSensorEventInt("distance", mm, "vl53l5cx"); }
        }

        // mic: level always (for the bar + host VAD), PCM stream when the host asked or PTT is held
        int lvl = mic.getSoundLevel(); micLevel = micLevel * 0.7f + clamp01(lvl / 6000.0f) * 0.3f;
        if (now - lastLevelPost >= 250) { lastLevelPost = now; wifi.postSensorEventInt("sound_level", lvl, "microphone"); }
        if (listening || ptt) {
            int n = mic.getSamples(pcmBuf, PCM_CHUNK);
            if (n > 0) { b64encode((const uint8_t*)pcmBuf, n * 2, b64Buf); wifi.postSensorEventStr("pcm", b64Buf, "microphone"); }
        }
        vTaskDelay(pdMS_TO_TICKS(listening || ptt ? 10 : 20));
    }
}

void setup() {
    wifi.connect();
    wifi.onMessage(handleMessage);
    eyeL.begin(); eyeR.begin(); eyeL.setDirectMode(false); eyeR.setDirectMode(false);
    speaker.begin(); speaker.setVolume(volume);
    mic.begin();
    boop.begin(); boop.setColor(205, 98, 64);
    knob.begin(); knob.enableRing(true); knob.setRingColor(205, 98, 64); knob.setRingBrightness(40); knob.setRingPosition(volume);
    Wire.begin(NOSE_SDA, NOSE_SCL, 400000);
    nose.setGridMode(VL53L5CX_Sensor::GRID_SINGLE);
    if (!nose.begin()) wifi.postLogEvent("[clawd] distance sensor not found on port 11");
    wifi.postLogEvent("[clawd] awake");
    chirpHello();
    esp_task_wdt_init(30, true);
    xTaskCreatePinnedToCore(mainTask, "MainTask", 16384, NULL, 1, &mainTaskHandle, 1);
    xTaskCreatePinnedToCore(eyeTask, "EyeTask", 8192, NULL, 1, &eyeTaskHandle, 1);
}

void loop() { vTaskDelay(portMAX_DELAY); }
