#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <FS.h>
#include <ArduinoJson.h>
#include <PubSubClient.h>
#include <ArduinoOTA.h>
#include <Ticker.h>
#include <ESP8266HTTPClient.h>
#include <ESP8266httpUpdate.h>
#include <map>
#include <time.h>
#include <WiFiClientSecure.h>
#define RESET_CONFIG_ON_FIRST_BOOT


// ==========================
//  Firmware framework_5
//  Mejoras:
//  - Publicación inicial retenida de estados (valores/<alias>) al conectar a MQTT
//  - Confirmación inmediata de comandos OUTPUT publicando valor actual (retained)
//  - Publicación de IP y state retained en cada conexión
//  - Descarga de puertos desde servidor con HTTPS (insecure), usando config si existe
//  - **OTA HTTP vía MQTT: descarga firmware desde servidor**
//  - Ajustes menores de estabilidad y logs
// ==========================

ESP8266WebServer server(80);
WiFiClient espClient;
WiFiClientSecure secureClient;
PubSubClient client(espClient);
Ticker watchdogReset;
Ticker verificadorWiFi;
Ticker verificadorMQTT;
Ticker tickerA0;

std::map<String, uint8_t> pines;
std::map<String, uint8_t> modosPines;
std::map<String, int> ultimoEstadoEntrada;
std::map<String, bool> esAnalogico;

String ssid, password, mqttUser, mqttPass, mqttServer, url_puertos_json;
String wifiSSID = "";

unsigned long ultimoIntentoMQTT = 0;
const unsigned long intervaloReintentoMQTT = 5000;
int mqttPort = 1883;

struct TopicsMQTT {
  String state;
  String comando;
  String ip;
  String valores;
  String reboot;
  String ota;
  String otaStatus;
};

TopicsMQTT topics;
bool estadoInicialPublicado = false;
int ultimoValorA0 = -1;
const int UMBRAL_A0 = 10;

// Flags para ejecutar tareas pesadas fuera del contexto de Ticker
volatile bool flagCheckWiFi = false;
volatile bool flagCheckMQTT = false;

void onTickWiFi() { flagCheckWiFi = true; }
void onTickMQTT() { flagCheckMQTT = true; }

String getChipID() {
  String id = String(ESP.getChipId(), HEX);
  id.toUpperCase();
  return id;
}

void inicializarTopicsMQTT() {
  topics.state = "dispositivo/" + getChipID() + "/state";
  topics.comando = "dispositivo/" + getChipID() + "/comando";
  topics.ip = "dispositivo/" + getChipID() + "/ip";
  topics.valores = "dispositivo/" + getChipID() + "/valores"; // prefijo
  topics.reboot = topics.comando + "/reboot";
  topics.ota = topics.comando + "/ota";
  topics.otaStatus = "dispositivo/" + getChipID() + "/ota/status";
}

void resetWatchdog() { ESP.wdtFeed(); }

void setupOTA() {
  ArduinoOTA.setHostname("ESP-Dispositivo");
  ArduinoOTA.onStart([]() { Serial.println("\n🔄 OTA iniciado..."); });
  ArduinoOTA.onEnd([]() { Serial.println("\n✅ OTA completado"); });
  ArduinoOTA.onError([](ota_error_t error) { Serial.printf("❌ OTA Error[%u]\n", error); });
  ArduinoOTA.begin();
}

void configurarHora() {
  configTime(-3 * 3600, 0, "pool.ntp.org", "time.nist.gov");
  Serial.print("⏳ Sincronizando hora");
  while (time(nullptr) < 100000) { Serial.print("."); delay(200); ESP.wdtFeed(); yield(); }
  Serial.println("\n✅ Hora sincronizada");
}

void leerConfig() {
  if (!SPIFFS.exists("/config.json")) {
    Serial.println("Creando valores por defecto en config.json");
    ssid = ""; password = ""; mqttServer = ""; mqttUser = ""; mqttPass = ""; url_puertos_json = ""; mqttPort = 1883;
    File f = SPIFFS.open("/config.json", "w");
    if (f) {
      StaticJsonDocument<512> doc;
      doc["ssid"] = ssid; doc["password"] = password; doc["mqttServer"] = mqttServer;
      doc["mqttUser"] = mqttUser; doc["mqttPass"] = mqttPass; doc["mqttPort"] = mqttPort; doc["url_puertos_json"] = url_puertos_json;
      serializeJson(doc, f); f.close();
    }
    return;
  }
  File file = SPIFFS.open("/config.json", "r");
  if (!file) { Serial.println("No se pudo abrir config.json"); return; }
  StaticJsonDocument<512> doc;
  auto err = deserializeJson(doc, file); file.close();
  if (err) { Serial.println("Error al parsear JSON config"); return; }
  ssid = doc["ssid" ] | "";
  password = doc["password"] | "";
  mqttServer = doc["mqttServer"] | "";
  mqttUser = doc["mqttUser"] | "";
  mqttPass = doc["mqttPass"] | "";
  mqttPort = doc["mqttPort"] | 1883;
  url_puertos_json = doc["url_puertos_json"] | "";
}

bool descargarYActualizarPuertos() {
  String urlBase = url_puertos_json.length() ? url_puertos_json : String("esp.penki.com.ar");
  String urlCompleta = "https://" + urlBase + "/api/get_puertos.php?chipid=" + getChipID();
  Serial.println("📡 Solicitando: " + urlCompleta);
  secureClient.setInsecure();
  HTTPClient http;
  http.setAuthorization("api", "API#2025");
  if (!http.begin(secureClient, urlCompleta)) { Serial.println("❌ Error al iniciar conexión HTTPS"); return false; }
  int httpCode = http.GET();
  if (httpCode <= 0) { Serial.printf("❌ Error HTTP al intentar descargar: %d\n", httpCode); http.end(); return false; }
  Serial.printf("📥 HTTP %d recibido\n", httpCode);
  if (httpCode == HTTP_CODE_OK) {
    String body = http.getString(); http.end();
    StaticJsonDocument<4096> docRemoto; DeserializationError err = deserializeJson(docRemoto, body);
    if (err) { Serial.println("❌ Error al parsear JSON remoto"); return false; }
    const char* fechaRemota = docRemoto["update"] | "";
    if (SPIFFS.exists("/puertos.json")) {
      File fl = SPIFFS.open("/puertos.json", "r"); StaticJsonDocument<4096> docLocal; DeserializationError e2 = deserializeJson(docLocal, fl); fl.close();
      if (!e2) {
        const char* fechaLocal = docLocal["update"] | "";
        if (strcmp(fechaRemota, fechaLocal) <= 0) { Serial.println("✅ puertos.json local actualizado o más reciente"); return true; }
      }
    }
    File fn = SPIFFS.open("/puertos.json", "w"); if (!fn) { Serial.println("❌ No se pudo abrir puertos.json para guardar"); return false; }
    fn.print(body); fn.close(); Serial.println("✅ puertos.json actualizado desde servidor remoto");
    return true;
  }
  return false;
}

void leerPuertos() {
  pines.clear(); modosPines.clear(); ultimoEstadoEntrada.clear(); esAnalogico.clear();
  if (!SPIFFS.exists("/puertos.json")) { Serial.println("❌ puertos.json no existe"); return; }
  File file = SPIFFS.open("/puertos.json", "r"); if (!file) { Serial.println("❌ No se pudo abrir puertos.json"); return; }
  StaticJsonDocument<4096> doc; DeserializationError error = deserializeJson(doc, file); file.close();
  if (error) { Serial.println("❌ Error al parsear puertos.json"); return; }
  for (JsonPair kv : doc.as<JsonObject>()) {
    String key = kv.key().c_str(); if (key == "update") continue;
    JsonObject obj = kv.value().as<JsonObject>(); if (!obj.containsKey("gpio") || !obj.containsKey("modo")) continue;
    String gpioStr = obj["gpio"].as<String>(); uint8_t gpio = (gpioStr == "A0") ? A0 : (uint8_t) gpioStr.toInt();
    String alias = key; String modoStr = obj["modo"].as<String>();
    uint8_t modoPin;
    if (modoStr == "INPUT") modoPin = INPUT; else if (modoStr == "OUTPUT") modoPin = OUTPUT; else if (modoStr == "INPUT_PULLUP") modoPin = INPUT_PULLUP; else { modoPin = INPUT_PULLUP; }
    pines[alias] = gpio; modosPines[alias] = modoPin; esAnalogico[alias] = (gpio == A0);
    Serial.printf("🔧 %s => GPIO %d, modo %s\n", alias.c_str(), gpio, modoStr.c_str());
  }
  Serial.println("✅ Pines y modos cargados");
}

void imprimirPuertosJson() {
  if (!SPIFFS.begin()) { Serial.println("Error al montar SPIFFS"); return; }
  if (!SPIFFS.exists("/puertos.json")) { Serial.println("Archivo puertos.json no existe"); return; }
  File file = SPIFFS.open("/puertos.json", "r"); if (!file) { Serial.println("No se pudo abrir puertos.json"); return; }
  Serial.println("Contenido de puertos.json:"); while (file.available()) { Serial.write(file.read()); } file.close(); Serial.println();
}

void conectarWiFi() {
  WiFi.mode(WIFI_STA); WiFi.begin(ssid.c_str(), password.c_str()); Serial.print("Conectando a WiFi");
  int intentos = 20; while (WiFi.status() != WL_CONNECTED && intentos-- > 0) { delay(500); Serial.print("."); ESP.wdtFeed(); yield(); }
  if (WiFi.status() == WL_CONNECTED) { Serial.println("\n✅ WiFi conectado!"); Serial.print("IP: "); Serial.println(WiFi.localIP()); wifiSSID = WiFi.SSID(); }
  else { Serial.println("\n❌ No se pudo conectar. Modo AP."); WiFi.softAP("ESP_Config", "12345678"); Serial.print("AP IP: "); Serial.println(WiFi.softAPIP()); server.begin(); }
}

void publicarEstadoAlias(const String& alias, int val, uint8_t modo) {
  if (esAnalogico[alias]) return;
  String estado = val == HIGH ? "ON" : "OFF";
  String modoStr = (modo == INPUT) ? "INPUT" : (modo == OUTPUT ? "OUTPUT" : (modo == INPUT_PULLUP ? "INPUT_PULLUP" : "DESCONOCIDO"));
  StaticJsonDocument<128> doc; doc["valor"] = estado; doc["modo"] = modoStr;
  char buffer[128]; serializeJson(doc, buffer, sizeof(buffer));
  String topic = topics.valores + "/" + alias;
  client.publish(topic.c_str(), buffer, true);
}

void publicarEstadoInicialEntradas() {
  for (auto const& it : pines) {
    const String alias = it.first; const uint8_t gpio = it.second; if (esAnalogico[alias]) continue;
    int val;
    // Para OUTPUT, usar el estado conocido (LOW inicial); para INPUT, leer el pin
    if (modosPines[alias] == OUTPUT) {
      val = LOW; // Estado inicial configurado en setup
    } else {
      val = digitalRead(gpio); // INPUT o INPUT_PULLUP
    }
    ultimoEstadoEntrada[alias] = val; publicarEstadoAlias(alias, val, modosPines[alias]);
    Serial.printf("Publicado estado inicial %s => %s\n", alias.c_str(), (val == HIGH ? "ON" : "OFF"));
  }
  estadoInicialPublicado = true;
}

void publicarCambiosEntradas() {
  for (auto const& it : pines) {
    const String alias = it.first; const uint8_t gpio = it.second; 
    // Solo monitorear entradas, NO outputs
    if (esAnalogico[alias] || modosPines[alias] == OUTPUT) continue;
    int val = digitalRead(gpio);
    if (!ultimoEstadoEntrada.count(alias) || ultimoEstadoEntrada[alias] != val) {
      ultimoEstadoEntrada[alias] = val; publicarEstadoAlias(alias, val, modosPines[alias]);
      Serial.printf("📤 Cambio en %s: %s\n", alias.c_str(), (val == HIGH ? "ON" : "OFF"));
    }
  }
}

// ========================================
// Función para realizar OTA HTTP
// ========================================
void performHTTPUpdate(const String& firmwareURL) {
  Serial.println("🔄 Iniciando actualización OTA HTTP...");
  Serial.println("URL: " + firmwareURL);
  
  // Publicar estado inicial
  client.publish(topics.otaStatus.c_str(), "downloading", true);
  
  // Configurar cliente HTTPS inseguro (sin validación de certificado)
  WiFiClientSecure clientSecure;
  clientSecure.setInsecure();
  
  // Configurar callback de progreso
  ESPhttpUpdate.onProgress([](int current, int total) {
    static unsigned long lastReport = 0;
    if (millis() - lastReport > 2000) { // Reportar cada 2 segundos
      int percent = (current * 100) / total;
      Serial.printf("Progreso: %d%% (%d/%d bytes)\n", percent, current, total);
      
      // Publicar progreso por MQTT
      String progressMsg = String(percent) + "%";
      client.publish(topics.otaStatus.c_str(), progressMsg.c_str(), false);
      client.loop(); // Mantener conexión MQTT
      
      lastReport = millis();
    }
    ESP.wdtFeed(); // Alimentar watchdog
  });
  
  // Configurar callback de error
  ESPhttpUpdate.onError([](int error) {
    Serial.printf("❌ Error OTA HTTP: %d\n", error);
    String errorMsg = "error:" + String(error);
    client.publish(topics.otaStatus.c_str(), errorMsg.c_str(), true);
  });
  
  // Intentar actualización
  ESPhttpUpdate.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
  t_httpUpdate_return ret = ESPhttpUpdate.update(clientSecure, firmwareURL);
  
  switch (ret) {
    case HTTP_UPDATE_FAILED:
      Serial.printf("❌ HTTP_UPDATE_FAILED Error (%d): %s\n", 
                    ESPhttpUpdate.getLastError(), 
                    ESPhttpUpdate.getLastErrorString().c_str());
      client.publish(topics.otaStatus.c_str(), "failed", true);
      break;
      
    case HTTP_UPDATE_NO_UPDATES:
      Serial.println("⚠️ HTTP_UPDATE_NO_UPDATES");
      client.publish(topics.otaStatus.c_str(), "no_updates", true);
      break;
      
    case HTTP_UPDATE_OK:
      Serial.println("✅ HTTP_UPDATE_OK - Reiniciando...");
      client.publish(topics.otaStatus.c_str(), "success", true);
      delay(500);
      ESP.restart();
      break;
  }
}

void conectarMQTT();

void callback(char* topic, uint8_t* payload, unsigned int length) {
  payload[length] = '\0'; String mensaje = String((char*)payload);
  Serial.printf("Mensaje recibido en topic: %s -> %s\n", topic, mensaje.c_str());

  // Handler para reboot
  if (String(topic) == topics.reboot.c_str()) { 
    if (mensaje == "1") { 
      Serial.println("Reiniciando dispositivo..."); 
      delay(100); 
      ESP.restart(); 
      return; 
    } 
  }

  // ========================================
  // Handler para OTA HTTP
  // ========================================
  if (String(topic) == topics.ota.c_str()) {
    Serial.println("📥 Comando OTA recibido");
    
    // Parsear JSON: {"url": "https://..."}
    StaticJsonDocument<512> doc;
    DeserializationError err = deserializeJson(doc, mensaje);
    
    if (err) {
      Serial.println("❌ Error al parsear JSON de OTA");
      client.publish(topics.otaStatus.c_str(), "error:invalid_json", true);
      return;
    }
    
    String url = doc["url"] | "";
    
    if (url.length() == 0 || !url.endsWith(".bin")) {
      Serial.println("❌ URL inválida o no es un .bin");
      client.publish(topics.otaStatus.c_str(), "error:invalid_url", true);
      return;
    }
    
    Serial.println("✅ URL válida, iniciando descarga...");
    client.publish(topics.otaStatus.c_str(), "starting", true);
    delay(100); // Dar tiempo a que se publique
    
    // Ejecutar actualización
    performHTTPUpdate(url);
    return;
  }

  // Handler para comandos de pines (OUTPUT)
  if (String(topic) == topics.comando) {
    StaticJsonDocument<256> doc; DeserializationError err = deserializeJson(doc, mensaje);
    if (err) { Serial.println("Error al parsear comando JSON"); return; }
    String alias = doc["alias"] | ""; String valor = doc["valor"] | "";
    if (pines.count(alias) && (modosPines[alias] == OUTPUT)) {
      uint8_t gpio = pines[alias];
      // Confirmar estado publicado (retained)
      int valNow = (valor == "ON") ? HIGH : LOW;
      digitalWrite(gpio, valNow);
      ultimoEstadoEntrada[alias] = valNow;
      publicarEstadoAlias(alias, valNow, modosPines[alias]);
      Serial.printf("✔ Comando aplicado %s => %s\n", alias.c_str(), (valNow == HIGH ? "ON" : "OFF"));
    } else {
      Serial.println("Alias no encontrado o no es salida");
    }
  }
}

void conectarMQTT() {
  if (client.connected()) return;
  if ((long)(millis() - ultimoIntentoMQTT) < intervaloReintentoMQTT) return;
  ultimoIntentoMQTT = millis();

  client.setServer(mqttServer.c_str(), mqttPort);
  client.setCallback(callback);
  String clientId = "WemosClient-" + String(ESP.getChipId());
  Serial.print("Conectando a MQTT: "); Serial.println(mqttServer);

  if (client.connect(clientId.c_str(), mqttUser.c_str(), mqttPass.c_str(),
                     topics.state.c_str(), 1, true, "offline")) {
    Serial.println("✅ Conectado al broker MQTT");
    String ip = WiFi.localIP().toString();
    client.publish(topics.state.c_str(), "online", true);
    client.publish(topics.ip.c_str(), ip.c_str(), true);
    client.subscribe(topics.comando.c_str());
    client.subscribe(topics.reboot.c_str());
    client.subscribe(topics.ota.c_str());
    client.publish(topics.reboot.c_str(), "0", true);
    client.publish(topics.otaStatus.c_str(), "idle", true);
    // Publicar estados iniciales una vez tras conectar
    publicarEstadoInicialEntradas();
  } else {
    Serial.print("❌ MQTT falló. Código: "); Serial.println(client.state());
  }
}

void verificarConexionWiFi() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("⚠️ WiFi desconectado. Intentando reconectar...");
    WiFi.disconnect(); WiFi.begin(ssid.c_str(), password.c_str());
    unsigned long inicio = millis(); while (WiFi.status() != WL_CONNECTED && millis() - inicio < 10000) { delay(500); Serial.print("."); }
    if (WiFi.status() == WL_CONNECTED) { Serial.println("\n✅ Reconectado al WiFi!"); }
    else { Serial.println("\n❌ No se pudo reconectar. Reiniciando ESP..."); ESP.restart(); }
  }
}

int intentosMQTTFallidos = 0; const int MAX_INTENTOS_MQTT = 5;
void verificarConexionMQTT() {
  if (WiFi.status() != WL_CONNECTED) return;
  if (!client.connected()) {
    Serial.println("⚠️ MQTT desconectado. Intentando reconectar...");
    conectarMQTT();
    if (client.connected()) { Serial.println("✅ Reconexion MQTT exitosa"); intentosMQTTFallidos = 0; }
    else {
      intentosMQTTFallidos++; Serial.printf("❌ Fallo reconexión MQTT (%d/%d)\n", intentosMQTTFallidos, MAX_INTENTOS_MQTT);
      if (intentosMQTTFallidos >= MAX_INTENTOS_MQTT) { Serial.println("🚨 Demasiados fallos MQTT. Reiniciando ESP..."); ESP.restart(); }
    }
  } else { intentosMQTTFallidos = 0; }
}

void publicarA0() {
  int valorActual = analogRead(A0);
  if (ultimoValorA0 == -1 || abs(valorActual - ultimoValorA0) > UMBRAL_A0) {
    ultimoValorA0 = valorActual;
    String topic = topics.valores + "/A0";
    String payload = String(valorActual);
    client.publish(topic.c_str(), payload.c_str(), true);
    Serial.printf("Publicado cambio A0: %d\n", valorActual);
  }
}

void manejarRoot() {
  String pagina = "<html><head><meta name='viewport' content='width=device-width'><link rel='stylesheet' href='/bootstrap.min.css'></head><body class='d-flex justify-content-center align-items-center vh-100'><div class='container'><div class='card p-4 shadow'><h3>Configuración WiFi</h3><form method='POST' action='/guardar'>";
  pagina += "<label>SSID:</label><select name='ssid' class='form-select'>"; int n = WiFi.scanNetworks(); for (int i = 0; i < n; i++) { String net = WiFi.SSID(i); pagina += "<option value='" + net + "'"; if (net == ssid) pagina += " selected"; pagina += ">" + net + "</option>"; } pagina += "</select>";
  pagina += "<label>Password:</label><input name='password' class='form-control' value='" + password + "'>";
  pagina += "<label>MQTT Server:</label><input name='mqttServer' class='form-control' value='" + mqttServer + "'>";
  pagina += "<label>MQTT User:</label><input name='mqttUser' class='form-control' value='" + mqttUser + "'>";
  pagina += "<label>MQTT Pass:</label><input name='mqttPass' class='form-control' value='" + mqttPass + "'><br>";
  pagina += "<label>MQTT Port:</label><input name='mqttPort' class='form-control' type='number' value='" + String(mqttPort) + "'><br>";
  pagina += "<label>URL Base para puertos.json (host sin ruta):</label><input name='url_puertos_json' class='form-control' value='" + url_puertos_json + "'>";
  pagina += "<button type='submit' class='btn btn-primary'>Guardar</button></form></div></div></body></html>";
  server.send(200, "text/html", pagina);
}

void manejarGuardar() {
  ssid = server.arg("ssid"); password = server.arg("password"); mqttServer = server.arg("mqttServer"); mqttUser = server.arg("mqttUser"); mqttPass = server.arg("mqttPass"); mqttPort = server.arg("mqttPort").toInt(); mqttPort = constrain(mqttPort, 1, 65535);
  String nuevo_url = server.arg("url_puertos_json"); if (nuevo_url.length() > 0) { url_puertos_json = nuevo_url; }
  StaticJsonDocument<512> doc; doc["ssid"] = ssid; doc["password"] = password; doc["mqttServer"] = mqttServer; doc["mqttUser"] = mqttUser; doc["mqttPass"] = mqttPass; doc["mqttPort"] = mqttPort; doc["url_puertos_json"] = url_puertos_json;
  File file = SPIFFS.open("/config.json", "w"); serializeJson(doc, file); file.close();
  server.send(200, "text/html", "<html><body><h2>Guardado con éxito</h2><p>Reinicia el ESP para aplicar cambios.</p><a href='/'>Volver</a></body></html>");
}

void manejarBootstrap() {
  if (SPIFFS.exists("/bootstrap.min.css")) { File file = SPIFFS.open("/bootstrap.min.css", "r"); server.streamFile(file, "text/css"); file.close(); }
  else { server.send(404, "text/plain", "No encontrado"); }
}

void setup() {
  Serial.begin(115200);
  ESP.wdtDisable(); ESP.wdtEnable(8000);
  // Alimentar WDT desde el inicio para evitar resets durante tareas largas
  watchdogReset.attach_ms(1000, resetWatchdog);
  if (!SPIFFS.begin()) { Serial.println("❌ Error al montar SPIFFS"); } else { Serial.println("✅ SPIFFS montado correctamente"); }

  #ifdef RESET_CONFIG_ON_FIRST_BOOT
  // Eliminar config.json solo en el primer arranque después de flashear
  if (!SPIFFS.exists("/firstboot.flag")) {
    Serial.println("🗑️ Primera ejecución detectada, eliminando config.json...");
    SPIFFS.remove("/config.json");
    File f = SPIFFS.open("/firstboot.flag", "w");
    if (f) { f.print("ok"); f.close(); }
  }
#endif

  leerConfig();
  inicializarTopicsMQTT();
  conectarWiFi();

  if (WiFi.status() == WL_CONNECTED) {
    if (!descargarYActualizarPuertos()) { Serial.println("⚠️ Usando puertos.json local"); }
    leerPuertos();
  } else { Serial.println("⚠️ No hay WiFi, usando puertos.json local"); }

  // Configurar pines y estados iniciales LOW para OUTPUT
  for (auto const& it : pines) {
    const String alias = it.first; const uint8_t gpio = it.second; uint8_t modo = modosPines.count(alias) ? modosPines[alias] : INPUT;
    pinMode(gpio, modo);
    if (modo == OUTPUT) { digitalWrite(gpio, LOW); }
    Serial.printf("Pin %d (%s) configurado modo %d\n", gpio, alias.c_str(), modo);
  }

  if (WiFi.status() == WL_CONNECTED) { conectarMQTT(); }

  setupOTA();

  // Web server
  server.on("/", manejarRoot);
  server.on("/guardar", HTTP_POST, manejarGuardar);
  server.on("/bootstrap.min.css", HTTP_GET, manejarBootstrap);
  server.begin();

  // Tickers
  verificadorWiFi.attach(60, onTickWiFi);
  verificadorMQTT.attach(15, onTickMQTT);
  tickerA0.attach_ms(50, publicarA0); // 50ms, con UMBRAL evita spam
}

void loop() {
  if (WiFi.status() == WL_CONNECTED) { client.loop(); conectarMQTT(); }
  server.handleClient(); ArduinoOTA.handle();
  publicarCambiosEntradas();

  // Ejecutar verificaciones disparadas por Tickers en el contexto del loop
  if (flagCheckWiFi) { flagCheckWiFi = false; verificarConexionWiFi(); }
  if (flagCheckMQTT) { flagCheckMQTT = false; verificarConexionMQTT(); }
}
