/*
 * Fluister device — ESP32-WROOM-32
 *
 * Een zelfgebouwde deelnemer voor de Fluister-kring: microfoon (INMP441),
 * koptelefoon (PCM5102A, 3.5 mm-jack) of ingebouwd luidsprekertje (MAX98357A).
 * Praat met dezelfde server als de telefoons: 16 kHz mono 16-bit PCM over WebSocket.
 *
 * Board:      "ESP32 Dev Module"  (Arduino-ESP32 core 3.x)
 * Partition:  "Huge APP (3MB No OTA)"  (SSL + wifi-portaal is te groot voor de standaard)
 * Libraries:  WebSockets (Markus Sattler / links2004) ≥ 2.6
 *             WiFiManager (tzapu) ≥ 2.0.17
 *             ArduinoJson (Benoit Blanchon) ≥ 7
 *             Adafruit NeoPixel (alleen voor variant 1)
 */
#include <WiFi.h>
#include <WiFiManager.h>
#include <WebSocketsClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <ESP_I2S.h>
#include <freertos/stream_buffer.h>
#include "config.h"
#if HAS_LED
#include <Adafruit_NeoPixel.h>
#endif

// ---------------------------------------------------------------- audio-constanten
static const uint32_t SAMPLE_RATE = 16000;
static const size_t   FRAME       = 320;            // 20 ms
static const size_t   FRAME_BYTES = FRAME * 2;
static const size_t   PLAY_BUF    = 16000;          // 0,5 s buffer (bytes)
static const size_t   PRIME_BYTES = 3200;           // 100 ms voor het afspelen start
static const size_t   MAX_BYTES   = 12800;          // > 400 ms → inhalen

// ---------------------------------------------------------------- objecten
I2SClass i2sMic;
I2SClass i2sOut;
WebSocketsClient ws;
Preferences prefs;
#if HAS_LED
Adafruit_NeoPixel led(1, PIN_LED, NEO_GRB + NEO_KHZ800);
#endif

StreamBufferHandle_t playBuf;
QueueHandle_t micQ;

// ---------------------------------------------------------------- voorwaartse declaraties
void setLed(uint8_t r, uint8_t g, uint8_t b);
void sendMute();
void runPortal(bool force);

// ---------------------------------------------------------------- toestand
String cfgHost, cfgRoom, cfgName, cfgPath;
uint16_t cfgPort;
bool cfgSsl;

volatile bool  wsConnected = false;
volatile bool  muted = false;
volatile bool  instrPlaying = false;
volatile bool  headphones = false;
volatile bool  hasNeighbor = true;
volatile float volume = HAS_POT ? 0.7f : 1.0f;  // zonder potmeter: volume via je koptelefoon/telefoon
volatile float playLevel = 0;   // luidsprekerniveau (voor echo-demping)
volatile float micLevel = 0;    // voor de LED
volatile uint32_t underruns = 0;
char mode[12] = "groep";

// ================================================================= MICROFOON-TAAK
// Leest de INMP441 (24-bit in 32-bit slots), filtert DC weg, versterkt,
// en zet frames van 20 ms in een wachtrij voor de netwerk-loop.
void micTask(void *) {
  static int32_t raw[FRAME * 2];        // stereo-slots: we kiezen zelf het juiste kanaal
  static int16_t out[FRAME];
  int ch = -1;                           // 0 = links, 1 = rechts (automatisch bepaald)
  double eL = 0, eR = 0; int probe = 0;
  float dcX = 0, dcY = 0, duck = 1.0f;

  for (;;) {
    size_t n = i2sMic.readBytes((char *)raw, sizeof(raw));
    if (n != sizeof(raw)) { vTaskDelay(1); continue; }

    // Bij opstart: welk kanaal bevat het signaal? (L/R-pin van de INMP441 bepaalt dat)
    if (ch < 0) {
      for (size_t i = 0; i < FRAME; i++) { eL += fabs((double)(raw[2 * i] >> 8)); eR += fabs((double)(raw[2 * i + 1] >> 8)); }
      if (++probe >= 25) { ch = (eR > eL) ? 1 : 0; Serial.printf("[mic] kanaal %s\n", ch ? "rechts" : "links"); }
      continue;
    }

    // echo-demping: speelt de luidspreker, dan micro (tijdelijk) stiller
    float wantDuck = (!headphones && playLevel > 0.01f) ? ECHO_DUCK : 1.0f;
    float g = muted ? 0.0f : MIC_GAIN / 256.0f;
    float sum = 0;
    for (size_t i = 0; i < FRAME; i++) {
      duck += (wantDuck - duck) * (wantDuck < duck ? 0.2f : 0.002f);
      float x = (float)(raw[2 * i + ch] >> 8);          // 24-bit waarde
      float y = x - dcX + 0.995f * dcY;                  // DC-blokker
      dcX = x; dcY = y;
      float v = y * g * duck;
      if (v > 32767.f) v = 32767.f; else if (v < -32768.f) v = -32768.f;
      out[i] = (int16_t)v;
      sum += v * v;
    }
    micLevel = sqrtf(sum / FRAME) / 32768.0f;
    if (wsConnected) xQueueSend(micQ, out, 0);           // vol? frame laten vallen
  }
}

// ================================================================= LUIDSPREKER-TAAK
// Haalt 20 ms uit de afspeelbuffer en schrijft het (stereo) naar I2S.
// i2sOut.write() blokkeert tot er plaats is: dat geeft vanzelf het juiste tempo.
void speakerTask(void *) {
  static int16_t mono[FRAME];
  static int32_t stereo[FRAME * 2];   // 32-bit slots: BCLK = 1,024 MHz (veilig voor PCM5102A-PLL)
  static uint8_t scratch[FRAME_BYTES];
  bool playing = false;

  for (;;) {
    size_t avail = xStreamBufferBytesAvailable(playBuf);
    if (!playing && avail >= PRIME_BYTES) playing = true;
    while (avail > MAX_BYTES) {                          // te veel vertraging: inhalen
      avail -= xStreamBufferReceive(playBuf, scratch, FRAME_BYTES, 0);
    }
    size_t got = 0;
    if (playing) {
      got = xStreamBufferReceive(playBuf, mono, FRAME_BYTES, 0);
      if (got < FRAME_BYTES) { playing = false; underruns++; }
    }
    memset(((uint8_t *)mono) + got, 0, FRAME_BYTES - got);

    float maxV = headphones ? HEADPHONE_MAX : SPEAKER_MAX;
    float g = volume * maxV;
    float sum = 0;
    for (size_t i = 0; i < FRAME; i++) {
      float v = mono[i] * g;
      if (v > 32767.f) v = 32767.f; else if (v < -32768.f) v = -32768.f;
      int32_t s = ((int32_t)(int16_t)v) << 16;
      stereo[2 * i] = s; stereo[2 * i + 1] = s;
      sum += v * v;
    }
    playLevel = sqrtf(sum / FRAME) / 32768.0f;
    i2sOut.write((uint8_t *)stereo, sizeof(stereo));
  }
}

// ================================================================= WEBSOCKET
void sendJson(JsonDocument &doc) {
  String s; serializeJson(doc, s);
  ws.sendTXT(s);
}

void sendHello() {
  JsonDocument d;
  d["t"] = "hello"; d["room"] = cfgRoom; d["name"] = cfgName;
  d["kind"] = "device"; d["role"] = "participant";
  sendJson(d);
  if (muted) sendMute();
}

void sendMute() {
  JsonDocument d; d["t"] = "mute"; d["muted"] = (bool)muted;
  sendJson(d);
}

void onWs(WStype_t type, uint8_t *payload, size_t length) {
  switch (type) {
    case WStype_CONNECTED:
      Serial.println("[ws] verbonden");
      wsConnected = true;
      sendHello();
      break;
    case WStype_DISCONNECTED:
      if (wsConnected) Serial.println("[ws] verbinding weg");
      wsConnected = false;
      break;
    case WStype_BIN:
      // inkomende audio → afspeelbuffer (vol = frame laten vallen)
      xStreamBufferSend(playBuf, payload, length, 0);
      break;
    case WStype_TEXT: {
      JsonDocument d;
      if (deserializeJson(d, payload, length)) break;
      const char *t = d["t"] | "";
      if (!strcmp(t, "state")) {
        strlcpy(mode, d["mode"] | "groep", sizeof(mode));
        muted = d["muted"] | false;
        instrPlaying = d["instr"] | false;
        hasNeighbor = !d["from"].isNull() || strcmp(mode, "fluister") != 0;
        Serial.printf("[state] modus=%s hoort=%s instructie=%d n=%d\n", mode, (const char *)(d["from"] | "-"), (int)instrPlaying, (int)(d["n"] | 0));
      } else if (!strcmp(t, "welcome")) {
        Serial.printf("[ws] welkom in kring '%s'\n", (const char *)(d["room"] | "?"));
      } else if (!strcmp(t, "error")) {
        Serial.printf("[ws] fout: %s\n", (const char *)(d["msg"] | "?"));
      }
      break;
    }
    default: break;
  }
}

void startWs() {
  ws.onEvent(onWs);
  ws.setReconnectInterval(2000);
  ws.enableHeartbeat(15000, 4000, 2);
  if (cfgSsl) ws.beginSSL(cfgHost.c_str(), cfgPort, cfgPath.c_str());   // zonder certificaatcontrole
  else        ws.begin(cfgHost.c_str(), cfgPort, cfgPath.c_str());
}

// ================================================================= WIFI + INSTELLINGEN
bool saveFlag = false;

void loadPrefs() {
  prefs.begin("fluister", true);
  cfgHost = prefs.getString("host", DEFAULT_SERVER_HOST);
  cfgPath = prefs.getString("path", WS_PATH);
  cfgPort = prefs.getUShort("port", DEFAULT_SERVER_PORT);
  cfgSsl  = prefs.getBool("ssl", DEFAULT_USE_SSL);
  cfgRoom = prefs.getString("room", DEFAULT_ROOM);
  cfgName = prefs.getString("name", DEFAULT_NAME);
  prefs.end();
  if (cfgName.length() == 0) {
    char buf[16]; uint64_t mac = ESP.getEfuseMac();
    snprintf(buf, sizeof(buf), "device-%04X", (uint16_t)(mac >> 32));
    cfgName = buf;
  }
}

void runPortal(bool force) {
  WiFiManager wm;
  char portStr[6]; snprintf(portStr, sizeof(portStr), "%u", cfgPort);
  WiFiManagerParameter pHost("host", "Server (bv. experiments.samenschool.org)", cfgHost.c_str(), 63);
  WiFiManagerParameter pPath("path", "Pad (bv. /fluister/ws of /ws)", cfgPath.c_str(), 47);
  WiFiManagerParameter pPort("port", "Poort (443 = internet)", portStr, 5);
  WiFiManagerParameter pSsl("ssl", "Beveiligd wss (1/0)", cfgSsl ? "1" : "0", 1);
  WiFiManagerParameter pRoom("room", "Kring", cfgRoom.c_str(), 39);
  WiFiManagerParameter pName("name", "Naam van dit device", cfgName.c_str(), 29);
  wm.addParameter(&pHost); wm.addParameter(&pPath); wm.addParameter(&pPort); wm.addParameter(&pSsl);
  wm.addParameter(&pRoom); wm.addParameter(&pName);
  wm.setSaveParamsCallback([]() { saveFlag = true; });
  wm.setSaveConfigCallback([]() { saveFlag = true; });
  wm.setConfigPortalTimeout(240);
  wm.setTitle("Fluister device");

  char ap[24]; snprintf(ap, sizeof(ap), "Fluister-%04X", (uint16_t)(ESP.getEfuseMac() >> 32));
  setLed(255, 120, 0);
  bool ok = force ? wm.startConfigPortal(ap) : wm.autoConnect(ap);

  if (saveFlag) {
    prefs.begin("fluister", false);
    prefs.putString("host", pHost.getValue());
    prefs.putString("path", pPath.getValue());
    prefs.putUShort("port", (uint16_t)atoi(pPort.getValue()));
    prefs.putBool("ssl", pSsl.getValue()[0] == '1');
    prefs.putString("room", pRoom.getValue());
    prefs.putString("name", pName.getValue());
    prefs.end();
    Serial.println("[cfg] instellingen bewaard, herstart");
    delay(300);
    ESP.restart();
  }
  if (!ok) { Serial.println("[wifi] geen verbinding, herstart"); delay(500); ESP.restart(); }
}

// ================================================================= LED / KNOP / POT
void setLed(uint8_t r, uint8_t g, uint8_t b) {
#if HAS_LED
  led.setPixelColor(0, led.Color(r, g, b));
  led.show();
#else
  digitalWrite(PIN_ONBOARD_LED, (r + g + b) > 0 ? HIGH : LOW);
#endif
}

bool headphonesNow() {
#if OUTPUT_MODE == 1
  return true;
#elif OUTPUT_MODE == 2
  return false;
#else
  return digitalRead(PIN_HP_DETECT) == HP_ACTIVE_LEVEL;
#endif
}

void updateLed() {
#if HAS_LED
  uint32_t ms = millis();
  float pulse = 0.5f + 0.5f * sinf(ms / 180.0f);
  float lv = fminf(1.0f, micLevel * 12.0f);
  if (WiFi.status() != WL_CONNECTED || !wsConnected) {
    bool on = (ms / 300) % 2; setLed(on ? 255 : 0, on ? 90 : 0, 0);            // oranje knipperen
  } else if (instrPlaying) {
    uint8_t v = 40 + 215 * pulse; setLed(v, v, v);                           // wit pulseren
  } else if (muted) {
    setLed(120, 0, 0);                                                        // rood
  } else if (!strcmp(mode, "fluister")) {
    if (!hasNeighbor) { bool on = (ms / 800) % 2; setLed(on ? 90 : 0, 0, on ? 160 : 0); }
    else setLed(70 + 185 * lv, 0, 140 + 115 * lv);                           // paars
  } else if (!strcmp(mode, "stilte")) {
    setLed(25, 25, 35);
  } else {
    setLed(0, 60 + 195 * lv, 140 + 115 * lv);                                // groep: cyaan
  }
#else
  // blauwe led op het bordje: snel knipperen = verbinden, traag = instructie, uit = micro uit, aan = in de kring
  uint32_t ms = millis();
  if (WiFi.status() != WL_CONNECTED || !wsConnected) setLed((ms / 150) % 2, 0, 0);
  else if (instrPlaying) setLed((ms / 500) % 2, 0, 0);
  else if (muted) setLed(0, 0, 0);
  else setLed(1, 0, 0);
#endif
}

void handleButton() {
  static bool last = HIGH; static uint32_t downAt = 0; static bool longDone = false;
  bool s = digitalRead(PIN_BUTTON);
  uint32_t now = millis();
  if (s == LOW && last == HIGH) { downAt = now; longDone = false; }
  if (s == LOW && !longDone && now - downAt > 3000) {
    longDone = true;
    Serial.println("[knop] wifi-portaal");
    ws.disconnect();
    runPortal(true);
  }
  if (s == HIGH && last == LOW && !longDone && now - downAt > 40) {
    muted = !muted;
    Serial.printf("[knop] micro %s\n", muted ? "uit" : "aan");
    if (wsConnected) sendMute();
  }
  last = s;
}

void readControls() {
  bool hp = headphonesNow();
  if (hp != headphones) {
    headphones = hp;
    digitalWrite(PIN_AMP_SD, hp ? LOW : HIGH);     // luidspreker uit bij koptelefoon
    Serial.printf("[audio] %s\n", hp ? "koptelefoon" : "luidspreker");
  }
#if HAS_POT
  static float smooth = -1;
  float v = analogRead(PIN_POT) / 4095.0f;
  smooth = smooth < 0 ? v : smooth * 0.8f + v * 0.2f;
  volume = smooth * smooth * 1.5f;                // kwadratisch: natuurlijker volumeverloop
#endif
}

// ================================================================= SETUP / LOOP
void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println("\n=== Fluister device ===");

  pinMode(PIN_BUTTON, INPUT_PULLUP);
#if OUTPUT_MODE == 0
  pinMode(PIN_HP_DETECT, INPUT_PULLUP);
#endif
#if !HAS_LED
  pinMode(PIN_ONBOARD_LED, OUTPUT);
#endif
  pinMode(PIN_AMP_SD, OUTPUT);
  digitalWrite(PIN_AMP_SD, LOW);                  // luidspreker uit tijdens opstart (geen plop)
#if HAS_POT
  analogReadResolution(12);
  analogSetPinAttenuation(PIN_POT, ADC_11db);
#endif
#if HAS_LED
  led.begin(); led.setBrightness(LED_BRIGHTNESS); setLed(255, 90, 0);
#endif

  loadPrefs();
  Serial.printf("[cfg] %s://%s:%u%s  kring=%s  naam=%s\n", cfgSsl ? "wss" : "ws", cfgHost.c_str(), cfgPort, cfgPath.c_str(), cfgRoom.c_str(), cfgName.c_str());

  // knop ingedrukt bij opstart → meteen het portaal
  bool forcePortal = PIN_BUTTON != 0 && digitalRead(PIN_BUTTON) == LOW; // (GPIO0 laag bij opstart = flashmodus)
  WiFi.mode(WIFI_STA);
  runPortal(forcePortal);
  WiFi.setSleep(false);                           // stabielere latency voor audio
  Serial.printf("[wifi] verbonden, IP %s\n", WiFi.localIP().toString().c_str());

  // I2S: microfoon (stereo-slots lezen, kanaal wordt automatisch gekozen)
  i2sMic.setPins(PIN_MIC_BCLK, PIN_MIC_WS, -1, PIN_MIC_SD);
  if (!i2sMic.begin(I2S_MODE_STD, SAMPLE_RATE, I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO)) {
    Serial.println("[i2s] microfoon start niet!");
  }
  // I2S: uitgang naar PCM5102A + MAX98357A
  i2sOut.setPins(PIN_OUT_BCLK, PIN_OUT_LRC, PIN_OUT_DOUT);
  if (!i2sOut.begin(I2S_MODE_STD, SAMPLE_RATE, I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO)) {
    Serial.println("[i2s] uitgang start niet!");
  }

  playBuf = xStreamBufferCreate(PLAY_BUF, 1);
  micQ = xQueueCreate(8, FRAME_BYTES);

  headphones = !headphonesNow(); // forceer eerste update
  readControls();

  xTaskCreatePinnedToCore(micTask, "mic", 6144, nullptr, 5, nullptr, 1);
  xTaskCreatePinnedToCore(speakerTask, "spk", 6144, nullptr, 5, nullptr, 1);

  startWs();
}

void loop() {
  ws.loop();

  // microfoonframes versturen (alleen vanuit deze taak: de WebSocket-lib is niet thread-safe)
  static uint8_t frame[FRAME_BYTES];
  int sent = 0;
  while (sent < 4 && xQueueReceive(micQ, frame, 0) == pdTRUE) {
    if (wsConnected) ws.sendBIN(frame, FRAME_BYTES);
    sent++;
  }

  static uint32_t tCtl = 0, tLed = 0, tLog = 0;
  uint32_t now = millis();
  handleButton();
  if (now - tCtl > 50) { tCtl = now; readControls(); }
  if (now - tLed > 30) { tLed = now; updateLed(); }
  if (now - tLog > 10000) {
    tLog = now;
    Serial.printf("[stat] heap=%u buffer=%ums underruns=%u rssi=%d\n", ESP.getFreeHeap(),
                  (unsigned)(xStreamBufferBytesAvailable(playBuf) / 32), (unsigned)underruns, WiFi.RSSI());
  }
  delay(1);
}
