/*
 * Entrixy WebSocket — контроллер открытия для ESP32 / ESP32-S3 / ESP32-C3.
 *
 * Прошивка для контроллера с постоянным подключением к серверу
 * Entrixy через WiFi + WSS. Команда "Открыть" приходит от приложения
 * через сервер, контроллер на короткое время (типично 1 секунда)
 * замыкает реле — это симулирует нажатие кнопки "Открыть" на плате
 * привода ворот, шлагбаума, домофона.
 *
 * Сайт:  https://entrixy.com
 * Конфигуратор: https://entrixy.com/esp/socket/
 *
 * Зависимости (устанавливаются автоматически в сборщике):
 *   - WebSockets by Markus Sattler   (>= 2.4.0)
 *   - ArduinoJson                    (>= 7.0.0)
 */

#include <WiFi.h>
#include <WebSocketsClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>

// ═══════════════════════════════════════════════════════════════════
//   НАСТРОЙКИ (подставляются конфигуратором)
// ═══════════════════════════════════════════════════════════════════
const char* WIFI_SSID     = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";

const char* DEVICE_KEY    = "PASTE_DEVICE_KEY_HERE";
const char* DEVICE_SECRET = "PASTE_DEVICE_SECRET_HERE";

const char* WS_HOST = "entrixy.com";
constexpr int  WS_PORT = 443;
const char* WS_PATH = "/ws";

constexpr int  PIN_RELAY              = 4;
constexpr bool RELAY_ACTIVE_HIGH      = true;
constexpr uint32_t RELAY_PULSE_MS     = 1000;

constexpr int  PIN_STATUS_LED         = -1;     // -1 если нет
constexpr bool STATUS_LED_ACTIVE_HIGH = true;
constexpr int  PIN_OPEN_LED           = -1;     // -1 если нет
constexpr bool OPEN_LED_ACTIVE_HIGH   = true;

// CLOSE_ENABLED=false (по умолчанию): момент-стайл, только команда "open",
// замок сам возвращается в исходное (защёлка/пружина). Приложение не
// показывает индикатор положения, прошивка не шлёт поле position.
//
// CLOSE_ENABLED=true: бистабильный механизм, поддерживается команда "close"
// и репортинг текущего положения. Состояние хранится в NVS и едет в каждом
// device_status. Приложение рисует уголки на аватаре когда замок открыт.
constexpr bool CLOSE_ENABLED          = false;

constexpr uint32_t RECONNECT_MS   = 5000;
constexpr uint32_t HEARTBEAT_MS   = 15000;
constexpr uint32_t HEARTBEAT_TO   = 5000;
constexpr uint32_t WIFI_TIMEOUT_MS = 30000;

// ═══════════════════════════════════════════════════════════════════

WebSocketsClient ws;
bool authenticated = false;

// Состояние замка (only used when CLOSE_ENABLED=true). Грузится из NVS при
// boot, обновляется на open/close-команде, читается при отправке device_status.
// "unknown" пишем при первом boot до получения команды.
String g_lockPosition = "unknown";
Preferences g_prefs;

// hb_interval приходит от сервера в device_ok (раз в N секунд шлём heartbeat
// с текущим position). По умолчанию 300с = 5 мин, см. docs/DESIGN_OBJECT_STATE.md.
uint32_t g_hbIntervalMs = 300000;
uint32_t g_lastHbAt = 0;

static inline void writeRelay(bool on) {
  if (PIN_RELAY < 0) return;
  digitalWrite(PIN_RELAY, on ? (RELAY_ACTIVE_HIGH ? HIGH : LOW)
                             : (RELAY_ACTIVE_HIGH ? LOW : HIGH));
}
static inline void writeStatusLed(bool on) {
  if (PIN_STATUS_LED < 0) return;
  digitalWrite(PIN_STATUS_LED, on ? (STATUS_LED_ACTIVE_HIGH ? HIGH : LOW)
                                  : (STATUS_LED_ACTIVE_HIGH ? LOW : HIGH));
}
static inline void writeOpenLed(bool on) {
  if (PIN_OPEN_LED < 0) return;
  digitalWrite(PIN_OPEN_LED, on ? (OPEN_LED_ACTIVE_HIGH ? HIGH : LOW)
                                : (OPEN_LED_ACTIVE_HIGH ? LOW : HIGH));
}

void pulseRelay() {
  writeRelay(true);
  writeOpenLed(true);
  delay(RELAY_PULSE_MS);
  writeRelay(false);
  writeOpenLed(false);
}

// Сохранить новое положение в NVS. Вызываем после каждого open/close.
// Без CLOSE_ENABLED no-op — NVS не трогаем, position в device_status не идёт.
void persistLockPosition(const char* p) {
  if (!CLOSE_ENABLED) return;
  g_lockPosition = String(p);
  g_prefs.begin("entrixy", false);
  g_prefs.putString("lock_pos", g_lockPosition);
  g_prefs.end();
}

void sendStatus(const char* commandId, const char* level, const char* message) {
  JsonDocument doc;
  doc["type"]       = "device_status";
  doc["command_id"] = commandId;
  doc["level"]      = level;
  doc["message"]    = message;
  doc["final"]      = true;
  // Поле position включаем только для бистабильного замка. Сервер по наличию
  // поля делает diff + broadcast obj_state_update. Если момент-стайл —
  // поле отсутствует, сервер state-логику пропускает.
  if (CLOSE_ENABLED) {
    doc["position"] = g_lockPosition;
  }
  String out;
  serializeJson(doc, out);
  ws.sendTXT(out);
}

// Heartbeat: spontaneous device_status без command_id, шлёт текущее position.
// Сервер дропает если совпало с last_state. Раз в g_hbIntervalMs (default 5мин).
void sendHeartbeat() {
  if (!CLOSE_ENABLED) return;  // момент-стайл heartbeat не нужен
  if (!authenticated) return;
  sendStatus("", "info", "");
}

void onMessage(uint8_t* payload, size_t length) {
  JsonDocument doc;
  if (deserializeJson(doc, payload, length)) return;

  const char* type = doc["type"] | "";

  if (strcmp(type, "device_ok") == 0) {
    authenticated = true;
    writeStatusLed(true);
    uint32_t hb = doc["hb_interval"] | 300;
    if (hb < 10) hb = 10;       // нижний предел чтобы не задолбать сервер
    if (hb > 3600) hb = 3600;
    g_hbIntervalMs = hb * 1000UL;
    g_lastHbAt = millis();
    Serial.printf("[WS] auth OK, hb=%us, pos=%s\n", (unsigned)hb, g_lockPosition.c_str());
    // После reconnect/boot — сразу шлём initial sync чтобы сервер увидел
    // текущее position (особенно если оно сменилось во время offline).
    sendHeartbeat();
    return;
  }
  if (strcmp(type, "ping") == 0) {
    ws.sendTXT("{\"type\":\"pong\"}");
    return;
  }
  if (strcmp(type, "hb_interval") == 0) {
    // Сервер может пушить новый интервал в любой момент (адаптив по нагрузке).
    uint32_t hb = doc["seconds"] | 300;
    if (hb < 10) hb = 10;
    if (hb > 3600) hb = 3600;
    g_hbIntervalMs = hb * 1000UL;
    Serial.printf("[WS] hb_interval -> %us\n", (unsigned)hb);
    return;
  }
  if (strcmp(type, "error") == 0) {
    const char* reason = doc["reason"] | "?";
    Serial.printf("[WS] server error: %s\n", reason);
    return;
  }
  if (strcmp(type, "device_command") == 0) {
    const char* action    = doc["action"]     | "";
    const char* commandId = doc["command_id"] | "";
    if (strcmp(action, "open") == 0) {
      Serial.printf("[CMD] open command_id=%s\n", commandId);
      pulseRelay();
      persistLockPosition("open");
      sendStatus(commandId, "success", "Открыто");
    } else if (CLOSE_ENABLED && strcmp(action, "close") == 0) {
      Serial.printf("[CMD] close command_id=%s\n", commandId);
      // В простой схеме close = тот же pulse реле. Если механизм требует
      // отдельный GPIO/паттерн — менять здесь под конкретное железо.
      pulseRelay();
      persistLockPosition("closed");
      sendStatus(commandId, "success", "Закрыто");
    } else {
      sendStatus(commandId, "warning", "Неизвестная команда");
    }
    return;
  }
}

void onWsEvent(WStype_t type, uint8_t* payload, size_t length) {
  switch (type) {
    case WStype_CONNECTED: {
      Serial.println("[WS] connected, sending hello");
      authenticated = false;
      JsonDocument doc;
      doc["type"]          = "device_hello";
      doc["device_key"]    = DEVICE_KEY;
      doc["device_secret"] = DEVICE_SECRET;
      String out;
      serializeJson(doc, out);
      ws.sendTXT(out);
      break;
    }
    case WStype_DISCONNECTED:
      Serial.println("[WS] disconnected");
      authenticated = false;
      writeStatusLed(false);
      break;
    case WStype_TEXT:
      onMessage(payload, length);
      break;
    default:
      break;
  }
}

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println();
  Serial.println("[Entrixy WS] boot");

  if (PIN_RELAY >= 0) {
    pinMode(PIN_RELAY, OUTPUT);
    writeRelay(false);
  }
  if (PIN_STATUS_LED >= 0) {
    pinMode(PIN_STATUS_LED, OUTPUT);
    writeStatusLed(false);
  }
  if (PIN_OPEN_LED >= 0) {
    pinMode(PIN_OPEN_LED, OUTPUT);
    writeOpenLed(false);
  }

  // Восстанавливаем последнее известное положение из NVS — нужно чтобы
  // после power-cycle первый heartbeat нёс актуальное состояние замка,
  // а не дефолтное "unknown".
  if (CLOSE_ENABLED) {
    g_prefs.begin("entrixy", true);
    g_lockPosition = g_prefs.getString("lock_pos", "unknown");
    g_prefs.end();
    Serial.printf("[NVS] lock_pos=%s\n", g_lockPosition.c_str());
  }

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  Serial.printf("[WiFi] connecting to '%s'", WIFI_SSID);

  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(500);
    if (millis() - t0 > WIFI_TIMEOUT_MS) {
      Serial.println("\n[WiFi] timeout, restarting in 5s");
      delay(5000);
      ESP.restart();
    }
  }
  Serial.printf(" OK %s\n", WiFi.localIP().toString().c_str());

  ws.beginSSL(WS_HOST, WS_PORT, WS_PATH);
  ws.onEvent(onWsEvent);
  ws.setReconnectInterval(RECONNECT_MS);
  ws.enableHeartbeat(HEARTBEAT_MS, HEARTBEAT_TO, 2);
}

void loop() {
  ws.loop();
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[WiFi] lost, reconnecting");
    WiFi.reconnect();
    delay(2000);
  }
  // Heartbeat для бистабильных замков — раз в g_hbIntervalMs. Diff-фильтр
  // на сервере отбросит если position не сменился.
  if (CLOSE_ENABLED && authenticated &&
      (uint32_t)(millis() - g_lastHbAt) >= g_hbIntervalMs) {
    g_lastHbAt = millis();
    sendHeartbeat();
  }
}
