#include "mqtt_manager.h"
#include "StepperManager.h"
#include "AutomationManager.h"
#include "async_network.h"
#include "diagnostics_manager.h"
#include "storage.h"
#include <ArduinoJson.h>
#include <esp_mac.h>

MqttManager mqttManager;

MqttManager::MqttManager()
    : _mqttClient(_wifiClient), _enabled(false), _wasConnected(false),
      _hasEverConnected(false), _lastConnectAttempt(0), _lastStatePublish(0),
      _reconnectDelay(MQTT_RECONNECT_INTERVAL_MS) {
  memset(_deviceId, 0, sizeof(_deviceId));
}

void MqttManager::begin() {
  if (!config.mqtt_enabled || strlen(config.mqtt_broker) == 0)
    return;
  _enabled = true;
  generateDeviceId();

  snprintf(_topicState, sizeof(_topicState), MQTT_TOPIC_STATE, _deviceId);
  snprintf(_topicControl, sizeof(_topicControl), MQTT_TOPIC_CONTROL, _deviceId);
  snprintf(_topicSetPos, sizeof(_topicSetPos), MQTT_TOPIC_SET_POS, _deviceId);
  snprintf(_topicOpen, sizeof(_topicOpen), MQTT_TOPIC_OPEN, _deviceId);
  snprintf(_topicClose, sizeof(_topicClose), MQTT_TOPIC_CLOSE, _deviceId);
  snprintf(_topicConfigSet, sizeof(_topicConfigSet), MQTT_TOPIC_CONFIG_SET,
           _deviceId);
  snprintf(_topicAvail, sizeof(_topicAvail), MQTT_TOPIC_AVAIL, _deviceId);
  snprintf(_topicDiag, sizeof(_topicDiag), MQTT_TOPIC_DIAGNOSTICS, _deviceId);

  if (strlen(config.linked_device_id) > 0) {
    snprintf(_topicLinkedDevice, sizeof(_topicLinkedDevice), MQTT_TOPIC_STATE, config.linked_device_id);
  } else {
    _topicLinkedDevice[0] = '\0';
  }

  _mqttClient.setServer(config.mqtt_broker, config.mqtt_port);
  _mqttClient.setBufferSize(MQTT_STATE_BUFFER_SIZE);
  _mqttClient.setKeepAlive(MQTT_KEEPALIVE_SEC);

  _mqttClient.setCallback(
      [this](char *topic, byte *payload, unsigned int length) {
        this->handleMessage(topic, payload, length);
      });

  Serial.printf("[MQTT] Initialized! ID: %s\n", _deviceId);
}

void MqttManager::generateDeviceId() {
  // Use esp_read_mac() for explicit byte access — no bit-shifting confusion.
  // MAC layout: mac[0..2] = OUI (manufacturer prefix, same across batch)
  //             mac[3..5] = NIC (unique per device, assigned by IEEE)
  uint8_t mac[6];
  esp_read_mac(mac, ESP_MAC_WIFI_STA);
  snprintf(_deviceId, sizeof(_deviceId), "%02X%02X%02X", mac[3], mac[4],
           mac[5]);

  Serial.printf("[MQTT] Full MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", mac[0],
                mac[1], mac[2], mac[3], mac[4], mac[5]);
  Serial.printf("[MQTT] Device ID: %s (from NIC bytes mac[3..5])\n", _deviceId);

  if (strlen(config.mqtt_device_id) == 0) {
    strlcpy(config.mqtt_device_id, _deviceId, sizeof(config.mqtt_device_id));
  }
}

void MqttManager::update() {
  // Process non-blocking identify wiggle FIRST, unconditionally.
  // The identify is a local hardware operation (stepper move) that must complete
  // even if MQTT or WiFi disconnects mid-wiggle, otherwise the blind gets stuck
  // at the wiggle position and never returns to base.
  extern void processIdentifyWiggle();
  processIdentifyWiggle();

  if (!_enabled || !network.isConnected())
    return;

  if (_mqttClient.connected()) {
    _mqttClient.loop();

    if (!_wasConnected) {
      _wasConnected = true;
      _hasEverConnected = true;
      _reconnectDelay = MQTT_RECONNECT_INTERVAL_MS;
      diagnosticsManager.incrementMqttReconnects();
      diagnosticsManager.logMqttEvent("CONN", 0);
      Serial.println(F("[MQTT] Connected!"));
      publishState(true);
    }
  } else {
    if (_wasConnected)
      _wasConnected = false;
    unsigned long now = millis();
    if (now - _lastConnectAttempt >= _reconnectDelay) {
      _lastConnectAttempt = now;
      connect();
    }
  }
}

bool MqttManager::isConnected() { return _mqttClient.connected(); }

void MqttManager::connect() {
  if (!_enabled)
    return;
  Serial.printf("[MQTT] Connecting to %s:%d...\n", config.mqtt_broker,
                config.mqtt_port);

  // CRITICAL FIX: Limit the TCP socket timeout to 1 second.
  // The default WiFiClient timeout is ~5 seconds, which blocks the main loop
  // for 5000ms+ every time a reconnect is attempted during a network outage.
  _wifiClient.setTimeout(1);

  char clientId[32];
  snprintf(clientId, sizeof(clientId), "SM-%s", _deviceId);

  if (_mqttClient.connect(clientId, config.mqtt_user, config.mqtt_pass,
                          _topicAvail, 1, true, "offline")) {
    Serial.println(F("[MQTT] Connected!"));
    _mqttClient.publish(_topicAvail, "online", true);
    _mqttClient.subscribe(_topicControl);
    _mqttClient.subscribe(_topicSetPos);
    _mqttClient.subscribe(_topicOpen);
    _mqttClient.subscribe(_topicClose);
    _mqttClient.subscribe(_topicConfigSet);
    
    if (strlen(_topicLinkedDevice) > 0) {
      _mqttClient.subscribe(_topicLinkedDevice);
      Serial.printf("[MQTT] Subscribed to Linked Device: %s\n", _topicLinkedDevice);
    }
    
    network.reportNetworkSuccess();
    diagnosticsManager.logGeneralEvent("NET", "MQTT broker connected");
  } else {
    int rc = _mqttClient.state();
    Serial.printf("[MQTT] Connection failed, rc=%d\n", rc);
    _reconnectDelay = min(_reconnectDelay * 2, MAX_RECONNECT_DELAY);
    network.reportNetworkFailure();
    diagnosticsManager.logMqttEvent("FAIL", (int8_t)rc);
  }
}

void MqttManager::handleMessage(char *topic, byte *payload,
                                unsigned int length) {
  if (length >= MQTT_MSG_BUFFER_SIZE)
    return;
  
  // Static buffer: MQTT messages are processed synchronously within
  // PubSubClient::loop(), so concurrent access is impossible. Using a static
  // buffer avoids heap fragmentation from per-message allocation.
  static char message[MQTT_MSG_BUFFER_SIZE];
  memcpy(message, payload, length);
  message[length] = '\0';

  if (strcmp(topic, _topicControl) == 0) {
    processControlCommand(message, length);
  } else if (strcmp(topic, _topicSetPos) == 0) {
    processControlCommand(message, length);
  } else if (strcmp(topic, _topicOpen) == 0) {
    stepperManager.open();    // setTargetPercentage publishes state internally
  } else if (strcmp(topic, _topicClose) == 0) {
    stepperManager.close();   // setTargetPercentage publishes state internally
  } else if (strcmp(topic, _topicConfigSet) == 0) {
    processConfigCommand(message, length);
  } else if (strlen(_topicLinkedDevice) > 0 && strcmp(topic, _topicLinkedDevice) == 0) {
    // Process Telemetry from Linked Device
    JsonDocument doc;
    if (!deserializeJson(doc, message, length)) {
        bool motion = doc["motion"] | false;
        bool still = doc["still"] | false;
        float cpuTemp = doc["cpuTemp"] | 0.0f;
        uint8_t switchMode = doc["mode"] | 0;
        bool lightState = doc["light"] | false;
        automationManager.updateLinkedDeviceTelemetry(motion, still, cpuTemp, switchMode, lightState);
    }
  }
  network.reportNetworkSuccess();
}

// =========================================================================
// NON-BLOCKING IDENTIFY WIGGLE
// =========================================================================
// CRITICAL FIX (BUG-1): Previously used xTaskCreate() to spawn a separate
// FreeRTOS thread that called stepperManager.setTargetPercentage() concurrently
// with the main loop. While the stepper mutex protects AccelStepper state,
// setTargetPercentage() also calls mqttManager.publishState() which accesses
// the WiFiClient/PubSubClient buffer — NOT thread-safe. This caused potential
// MQTT buffer corruption and malformed publishes.
// Now uses a phase-based state machine processed from MqttManager::update().
static bool _identifyActive = false;
static unsigned long _identifyStartTime = 0;
static int _identifyPhase = 0;       // 0=move out, 1=move back, 2=done
static int _identifyBasePos = 0;

void spawnIdentifyWiggle() {
    // Guard: ignore if already wiggling (prevents capturing mid-wiggle base position)
    if (_identifyActive) return;

    _identifyActive = true;
    _identifyPhase = 0;
    _identifyStartTime = millis();
    _identifyBasePos = stepperManager.getCurrentPercentage();

    // Phase 0: Move out by 5%
    int wiggleTarget = (_identifyBasePos < 50)
                     ? _identifyBasePos + 5
                     : _identifyBasePos - 5;
    stepperManager.setTargetPercentage(wiggleTarget, 0);
    Serial.printf("[Identify] Phase 0: %d%% -> %d%%\n", _identifyBasePos, wiggleTarget);
}

// Called from MqttManager::update() in the main loop context
void processIdentifyWiggle() {
    if (!_identifyActive) return;

    unsigned long elapsed = millis() - _identifyStartTime;

    switch (_identifyPhase) {
    case 0:
        // Wait 1500ms for stepper to reach wiggle position
        if (elapsed >= 1500) {
            _identifyPhase = 1;
            _identifyStartTime = millis();
            stepperManager.setTargetPercentage(_identifyBasePos, 0);
            Serial.printf("[Identify] Phase 1: returning to %d%%\n", _identifyBasePos);
        }
        break;

    case 1:
        // Wait another 1500ms for return, then finish
        if (elapsed >= 1500) {
            _identifyActive = false;
            _identifyPhase = 0;
            Serial.println(F("[Identify] Complete"));
        }
        break;

    default:
        _identifyActive = false;
        break;
    }
}

void MqttManager::processControlCommand(const char *payload, unsigned int len) {
  JsonDocument doc;
  if (deserializeJson(doc, payload, len))
    return;

  // Track whether a motor command already published state to avoid
  // redundant publishes (setTargetPercentage calls publishState internally).
  bool alreadyPublished = false;

  // The frontend sends { "position": X } on stepper/set_position
  if (doc.containsKey("position")) {
    int pos = doc["position"];
    uint32_t speed = doc.containsKey("speed") ? doc["speed"] : 0;
    stepperManager.setTargetPercentage(pos, speed);
    alreadyPublished = true;  // setTargetPercentage publishes internally
  } else if (doc.containsKey("blindPosition")) {
    // Legacy support
    int pos = doc["blindPosition"];
    uint32_t speed = doc.containsKey("speed") ? doc["speed"] : 0;
    stepperManager.setTargetPercentage(pos, speed);
    alreadyPublished = true;
  } else if (doc.containsKey("jog")) {
    // Manual jogging for calibration
    int dir = doc["jog"];
    stepperManager.jog(dir);
  }

  if (doc.containsKey("command")) {
    const char *cmd = doc["command"];
    if (strcmp(cmd, "stop") == 0) {
      stepperManager.stop();
    } else if (strcmp(cmd, "emergencyStop") == 0) {
      // FEAT-7: Immediate halt — no deceleration, motor disabled
      stepperManager.emergencyStop();
      alreadyPublished = true;  // emergencyStop publishes internally
    } else if (strcmp(cmd, "diagnostics") == 0) {
      bool verbose = doc["verbose"] | false;
      bool reset = doc["reset"] | false;
      publishDiagnostics(verbose, reset);
      return; // Don't publish normal state
    } else if (strcmp(cmd, "otaUrl") == 0) {
      const char* url = doc["url"] | "";
      if (strlen(url) > 0) {
          Serial.printf("[MQTT] Received otaUrl command: %s\n", url);
          extern void performHTTPOTA(const char *url);
          performHTTPOTA(url);
      }
      return;
    } else if (strcmp(cmd, "identify") == 0) {
      Serial.println(F("[MQTT] Identify command received. Jogging blinds..."));
      extern void spawnIdentifyWiggle();
      spawnIdentifyWiggle();
    } else if (strcmp(cmd, "reboot") == 0) {
      Serial.println(F("[MQTT] Reboot command received"));
      publishState(true);
      delay(1000);
      ESP.restart();
    } else if (strcmp(cmd, "getState") == 0) {
      Serial.println(F("[MQTT] getState command received"));
      // publishState(true) will be called below
    }
  }

  if (!alreadyPublished) {
    publishState(true);
  }
}

void MqttManager::processConfigCommand(const char *payload, unsigned int len) {
  JsonDocument doc;
  if (deserializeJson(doc, payload, len))
    return;

  bool changed = false;

  // NTP server references needed for timezone reconfiguration
  extern const char* ntpServer1;
  extern const char* ntpServer2;
  extern const char* ntpServer3;

  // Manual explicit setting
  if (doc.containsKey("stepperTop")) {
    config.stepper_top_position = doc["stepperTop"];
    changed = true;
  }
  if (doc.containsKey("stepperBottom")) {
    config.stepper_bottom_position = doc["stepperBottom"];
    changed = true;
  }

  // Command based setting (from BLE setup UI)
  if (doc.containsKey("cmd")) {
    const char *cmd = doc["cmd"];
    if (strcmp(cmd, "save_top") == 0) {
      config.stepper_top_position = stepperManager.getCurrentAbsolutePosition();
      changed = true;
    } else if (strcmp(cmd, "save_bottom") == 0) {
      config.stepper_bottom_position =
          stepperManager.getCurrentAbsolutePosition();
      changed = true;
    } else if (strcmp(cmd, "factory_reset") == 0) {
      Serial.println(
          "[MQTT] Received factory_reset command. Resetting storage...");
      storage.reset();
      delay(500);
      ESP.restart();
    }
  }

  // Speed settings (clamped to safe hardware ranges)
  if (doc.containsKey("stepperOpenSpeed")) {
    config.stepper_open_speed = constrain((uint32_t)doc["stepperOpenSpeed"], 100, 10000);
    changed = true;
  }
  if (doc.containsKey("stepperCloseSpeed")) {
    config.stepper_close_speed = constrain((uint32_t)doc["stepperCloseSpeed"], 100, 10000);
    changed = true;
  }
  if (doc.containsKey("stepperRelaxSteps")) {
    config.stepper_relax_steps = constrain((uint32_t)doc["stepperRelaxSteps"], 0, 1000);
    changed = true;
  }
  if (doc.containsKey("stepperStopDelay")) {
    config.stepper_stop_delay_ms = constrain((uint32_t)doc["stepperStopDelay"], 500, 30000);
    changed = true;
  }
  if (doc.containsKey("stepperAcceleration")) {
    config.stepper_acceleration = constrain((uint32_t)doc["stepperAcceleration"], 200, 8000);
    // Apply immediately to the running stepper
    stepperManager.setAcceleration(config.stepper_acceleration);
    changed = true;
  }

  // Automations - Rules
  if (doc.containsKey("rules")) {
    JsonObject rules = doc["rules"];
    if (rules.containsKey("sunset")) config.sunset_enabled = rules["sunset"];
    if (rules.containsKey("presence")) config.presence_enabled = rules["presence"];
    if (rules.containsKey("morningOpen")) config.morning_enabled = rules["morningOpen"];
    if (rules.containsKey("nightLock")) config.night_enabled = rules["nightLock"];
    if (rules.containsKey("temperature")) config.temp_enabled = rules["temperature"];
    changed = true;
  }

  // Automations - Config fields (all values range-validated)
  if (doc.containsKey("config")) {
    JsonObject cfg = doc["config"];
    if (cfg.containsKey("sunsetOffset")) config.sunset_offset = constrain((int32_t)cfg["sunsetOffset"], -120, 120);
    if (cfg.containsKey("sunsetTarget")) config.sunset_target = constrain((int32_t)cfg["sunsetTarget"], 0, 100);
    if (cfg.containsKey("motionTimeout")) config.motion_timeout = constrain((int32_t)cfg["motionTimeout"], 60, 3600);
    if (cfg.containsKey("presenceTarget")) config.presence_target = constrain((int32_t)cfg["presenceTarget"], 0, 100);
    if (cfg.containsKey("presenceOpenTarget")) config.presence_open_target = constrain((int32_t)cfg["presenceOpenTarget"], 0, 100);
    
    if (cfg.containsKey("presenceAction")) {
      const char* actionStr = cfg["presenceAction"];
      if (actionStr && strcmp(actionStr, "open_close") == 0) config.presence_action = 1;
      else config.presence_action = 0; // close_only
    }
    
    if (cfg.containsKey("presenceTimeFilter")) {
      const char* filterStr = cfg["presenceTimeFilter"];
      if (filterStr && strcmp(filterStr, "day") == 0) config.presence_time_filter = 1;
      else if (filterStr && strcmp(filterStr, "night") == 0) config.presence_time_filter = 2;
      else config.presence_time_filter = 0; // all
    }
    
    if (cfg.containsKey("morningTarget")) {
      // Legacy single-value: apply to all 7 days
      int val = constrain((int)cfg["morningTarget"], 0, 100);
      for (int i = 0; i < 7; i++) config.morning_target[i] = val;
    }
    if (cfg.containsKey("morningDuration")) {
      int val = constrain((int)cfg["morningDuration"], 1, 120);
      for (int i = 0; i < 7; i++) config.morning_duration[i] = val;
    }
    if (cfg.containsKey("nightTarget")) config.night_target = constrain((int32_t)cfg["nightTarget"], 0, 100);
    if (cfg.containsKey("tempThreshold")) config.temp_threshold = constrain((int32_t)cfg["tempThreshold"], 20, 80);
    if (cfg.containsKey("tempTarget")) config.temp_target = constrain((int32_t)cfg["tempTarget"], 0, 100);

    // Time Parsing ("HH:MM")
    if (cfg.containsKey("morningTime")) {
      const char* timeStr = cfg["morningTime"];
      if (timeStr && strlen(timeStr) >= 5) {
         uint8_t h = constrain((timeStr[0] - '0') * 10 + (timeStr[1] - '0'), 0, 23);
         uint8_t m = constrain((timeStr[3] - '0') * 10 + (timeStr[4] - '0'), 0, 59);
         // Legacy single-value: apply to all 7 days
         for (int i = 0; i < 7; i++) {
           config.morning_time_h[i] = h;
           config.morning_time_m[i] = m;
         }
      }
    }
    if (cfg.containsKey("nightTime")) {
      const char* timeStr = cfg["nightTime"];
      if (timeStr && strlen(timeStr) >= 5) {
         config.night_time_h = constrain((timeStr[0] - '0') * 10 + (timeStr[1] - '0'), 0, 23);
         config.night_time_m = constrain((timeStr[3] - '0') * 10 + (timeStr[4] - '0'), 0, 59);
       }
    }
    
    // Timezone Offsets (in seconds — matches ESP32's configTime() and Weather API)
    if (cfg.containsKey("gmtOffset") || cfg.containsKey("daylightOffset")) {
      if (cfg.containsKey("gmtOffset")) config.gmt_offset = cfg["gmtOffset"];
      if (cfg.containsKey("daylightOffset")) config.daylight_offset = cfg["daylightOffset"];
      
      // CRITICAL: Apply the timezone change immediately.
      // Without this, configTime() is only called at boot or by the Weather API,
      // so timezone updates from the frontend would be silently ignored until reboot.
      configTime(config.gmt_offset, config.daylight_offset, ntpServer1, ntpServer2, ntpServer3);
      Serial.printf("[MQTT] Timezone applied: GMT=%ld, DST=%ld (total: %ld seconds)\n",
                    config.gmt_offset, config.daylight_offset,
                    config.gmt_offset + config.daylight_offset);
    }

    // FEAT-5: POSIX Timezone String — supersedes static offsets for DST support.
    // Sent from the web app as e.g. "GMT0BST,M3.5.0/1,M10.5.0" (UK).
    // Once set, automatic DST handling is active and the Weather API's static
    // offset is ignored.
    if (cfg.containsKey("tzPosix")) {
      const char* tz = cfg["tzPosix"];
      if (tz && strlen(tz) > 0 && strlen(tz) < sizeof(config.tz_posix)) {
        strlcpy(config.tz_posix, tz, sizeof(config.tz_posix));
        // Apply immediately
        setenv("TZ", config.tz_posix, 1);
        tzset();
        Serial.printf("[MQTT] POSIX TZ applied: %s\n", config.tz_posix);
      } else if (tz && strlen(tz) == 0) {
        // Empty string = clear POSIX TZ, revert to static offsets
        memset(config.tz_posix, 0, sizeof(config.tz_posix));
        // CRITICAL: Unset TZ env var so localtime() stops using stale POSIX rules
        unsetenv("TZ");
        tzset();
        configTime(config.gmt_offset, config.daylight_offset, ntpServer1, ntpServer2, ntpServer3);
        Serial.println("[MQTT] POSIX TZ cleared — reverted to static offsets");
      }
    }
    
    // Day of Week Masks (JSON array of booleans: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
    if (cfg.containsKey("morningDays") && cfg["morningDays"].is<JsonArray>()) {
      JsonArray days = cfg["morningDays"].as<JsonArray>();
      uint8_t mask = 0;
      for (int i = 0; i < 7 && i < (int)days.size(); i++) {
        if (days[i].is<JsonObject>()) {
          // Full per-day object: { enabled, time, duration, target }
          JsonObject day = days[i].as<JsonObject>();
          if (day["enabled"].as<bool>()) mask |= (1 << i);
          if (day.containsKey("time")) {
            const char* t = day["time"].as<const char*>();
            if (t && strlen(t) >= 5) {
              config.morning_time_h[i] = (t[0] - '0') * 10 + (t[1] - '0');
              config.morning_time_m[i] = (t[3] - '0') * 10 + (t[4] - '0');
            }
          }
          if (day.containsKey("duration")) config.morning_duration[i] = day["duration"].as<int32_t>();
          if (day.containsKey("target")) config.morning_target[i] = day["target"].as<int32_t>();
        } else {
          // Legacy boolean format: just set enabled flag
          if (days[i].as<bool>()) mask |= (1 << i);
        }
      }
      config.morning_days = mask;
    }
    
    if (cfg.containsKey("nightDays")) {
      JsonArray days = cfg["nightDays"].as<JsonArray>();
      uint8_t mask = 0;
      for (int i = 0; i < 7 && i < days.size(); i++) {
        if (days[i].as<bool>()) mask |= (1 << i);
      }
      config.night_days = mask;
    }
    
    changed = true;
  }
  
  // TWT (WiFi 6 Power Saving)
  if (doc.containsKey("twtEnabled")) {
    bool newTwtState = doc["twtEnabled"].as<bool>();
    bool stateChanged = (newTwtState != config.twt_enabled);
    config.twt_enabled = newTwtState;
    changed = true;

    // Actually enable/disable TWT on the network (not just save config)
    if (stateChanged) {
      if (newTwtState) {
        Serial.println(F("[MQTT] Enabling TWT..."));
        network.enableTWT();
      } else {
        Serial.println(F("[MQTT] Disabling TWT..."));
        network.disableTWT();
      }
    }
  }
  
  if (doc.containsKey("linkedDeviceId")) {
      const char* linkedObj = doc["linkedDeviceId"];
      if (linkedObj && strlen(linkedObj) > 0) {
          strlcpy(config.linked_device_id, linkedObj, sizeof(config.linked_device_id));
      } else {
          config.linked_device_id[0] = '\0';
      }
      changed = true;
      
      // Resubscribe to new sensor
      if (_mqttClient.connected()) {
          if (strlen(_topicLinkedDevice) > 0) _mqttClient.unsubscribe(_topicLinkedDevice);
          
          if (strlen(config.linked_device_id) > 0) {
             snprintf(_topicLinkedDevice, sizeof(_topicLinkedDevice), MQTT_TOPIC_STATE, config.linked_device_id);
             _mqttClient.subscribe(_topicLinkedDevice);
             Serial.printf("[MQTT] Subscribed to NEW Linked Device: %s\n", _topicLinkedDevice);
          } else {
             _topicLinkedDevice[0] = '\0';
          }
      }
  }

  if (changed) {
    storage.save();
    publishState(true);
  }
}

void MqttManager::publishLinkedDeviceMode(uint8_t mode) {
  if (!_mqttClient.connected() || strlen(config.linked_device_id) == 0) return;

  char topicControl[80];
  snprintf(topicControl, sizeof(topicControl), MQTT_TOPIC_CONTROL, config.linked_device_id);

  JsonDocument doc;
  doc["mode"] = mode;

  char payload[128];
  size_t n = serializeJson(doc, payload);

  Serial.printf("[MQTT] 🔗 Linked Device Control '%s' -> mode: %d\n", topicControl, mode);
  _mqttClient.publish(topicControl, payload, n);
}

// FEAT-4: Exit bedtime on linked Zaylo using the {"sleep": false} command.
// CRITICAL: The Zaylo intentionally blocks {"mode": X} commands during bedtime
// to prevent accidental wake-ups. Only {"sleep": false} can exit bedtime mode,
// because it also properly terminates the sleep tracking session.
void MqttManager::publishLinkedDeviceSleepExit() {
  if (!_mqttClient.connected() || strlen(config.linked_device_id) == 0) return;

  char topicControl[80];
  snprintf(topicControl, sizeof(topicControl), MQTT_TOPIC_CONTROL, config.linked_device_id);

  JsonDocument doc;
  doc["sleep"] = false;

  char payload[128];
  size_t n = serializeJson(doc, payload);

  Serial.printf("[MQTT] 🔗 Linked Device Sleep Exit '%s'\n", topicControl);
  _mqttClient.publish(topicControl, payload, n);
}

void MqttManager::publishState(bool force) {
  if (!_mqttClient.connected())
    return;
  if (!force && millis() - _lastStatePublish < 500)
    return;

  // ROB-4: Heap guard — JsonDocument allocates from heap during construction.
  // Skip publish if memory is too low to safely serialize the full state JSON.
  uint32_t freeHeap = ESP.getFreeHeap();
  if (freeHeap < 15000) {
    Serial.printf("[MQTT] Skipping state publish - low heap: %lu\n", freeHeap);
    return;
  }

  _lastStatePublish = millis();
  JsonDocument doc;

  doc["deviceId"] = _deviceId;
  doc["position"] = stepperManager.getCurrentPercentage();
  doc["targetPosition"] = stepperManager.getTargetPercentage();
  doc["blindPosition"] = stepperManager.getCurrentPercentage(); // Legacy
  doc["isMoving"] = stepperManager.isMoving();
  doc["isCalibrated"] = stepperManager.isCalibrated();

  doc["uptime"] = millis() / 1000;
  doc["rssi"] = WiFi.RSSI();
  doc["firmware"] = FIRMWARE_VERSION;

  // Publish weather-derived sun times for frontend display
  // These are the raw epoch timestamps from the weather API
  uint32_t sr = network.getSunriseTime();
  uint32_t ss = network.getSunsetTime();
  if (sr > 0) {
    time_t srEpoch = (time_t)sr;
    struct tm srTm = *localtime(&srEpoch);
    doc["sunriseTime"] = srTm.tm_hour * 60 + srTm.tm_min; // minutes-of-day
  }
  if (ss > 0) {
    time_t ssEpoch = (time_t)ss;
    struct tm ssTm = *localtime(&ssEpoch);
    doc["sunsetTime"] = ssTm.tm_hour * 60 + ssTm.tm_min;  // minutes-of-day
  }

  if (strlen(config.linked_device_id) > 0) {
      doc["linkedDeviceId"] = config.linked_device_id;
      doc["linkedDeviceOnline"] = automationManager.isLinkedDeviceOnline();
  }

  // Publish rules state
  JsonObject rules = doc["rules"].to<JsonObject>();
  rules["sunset"] = config.sunset_enabled;
  rules["presence"] = config.presence_enabled;
  rules["morningOpen"] = config.morning_enabled;
  rules["nightLock"] = config.night_enabled;
  rules["temperature"] = config.temp_enabled;

  JsonObject cfg = doc["config"].to<JsonObject>();
  cfg["stepperTop"] = config.stepper_top_position;
  cfg["stepperBottom"] = config.stepper_bottom_position;
  cfg["stepperOpenSpeed"] = config.stepper_open_speed;
  cfg["stepperCloseSpeed"] = config.stepper_close_speed;
  cfg["stepperRelaxSteps"] = config.stepper_relax_steps;
  cfg["stepperStopDelay"] = config.stepper_stop_delay_ms;
  cfg["stepperAcceleration"] = config.stepper_acceleration;
  
  // Publish automation config state
  cfg["sunsetOffset"] = config.sunset_offset;
  cfg["sunsetTarget"] = config.sunset_target;
  cfg["motionTimeout"] = config.motion_timeout;
  cfg["presenceTarget"] = config.presence_target;
  cfg["presenceOpenTarget"] = config.presence_open_target;
  cfg["presenceAction"] = (config.presence_action == 1) ? "open_close" : "close_only";
  if (config.presence_time_filter == 1) cfg["presenceTimeFilter"] = "day";
  else if (config.presence_time_filter == 2) cfg["presenceTimeFilter"] = "night";
  else cfg["presenceTimeFilter"] = "all";
  cfg["morningTarget"] = config.morning_target[0];
  cfg["morningDuration"] = config.morning_duration[0];
  cfg["nightTarget"] = config.night_target;
  cfg["tempThreshold"] = config.temp_threshold;
  cfg["tempTarget"] = config.temp_target;
  cfg["twtEnabled"] = config.twt_enabled;
  cfg["twtActive"] = network.isTWTActive();
  
  // FEAT-5: Publish POSIX timezone for frontend synchronization
  if (strlen(config.tz_posix) > 0) {
    cfg["tzPosix"] = (char*)config.tz_posix; // char* for ArduinoJson v7 copy semantics
  }
  cfg["gmtOffset"] = config.gmt_offset;
  cfg["daylightOffset"] = config.daylight_offset;
  
  // FIX Bug #5: Do NOT cast to (const char*) — ArduinoJson v7 treats const char*
  // as pointer-only (no copy). Pass as char* so ArduinoJson copies into its pool.
  char morningTimeBuf[8];
  snprintf(morningTimeBuf, sizeof(morningTimeBuf), "%02d:%02d", config.morning_time_h[0], config.morning_time_m[0]);
  cfg["morningTime"] = morningTimeBuf;
  char nightTimeBuf[8];
  snprintf(nightTimeBuf, sizeof(nightTimeBuf), "%02d:%02d", config.night_time_h, config.night_time_m);
  cfg["nightTime"] = nightTimeBuf;

  // Publish per-day morning schedule as array of objects
  JsonArray mDays = cfg["morningDays"].to<JsonArray>();
  for (int i = 0; i < 7; i++) {
    JsonObject dayObj = mDays.add<JsonObject>();
    dayObj["enabled"] = (config.morning_days & (1 << i)) != 0;
    char tb[8];
    snprintf(tb, sizeof(tb), "%02d:%02d", config.morning_time_h[i], config.morning_time_m[i]);
    dayObj["time"] = tb;
    dayObj["duration"] = config.morning_duration[i];
    dayObj["target"] = config.morning_target[i];
  }

  // Publish night lock day-of-week mask as boolean array [Sun..Sat]
  // This allows the frontend to sync which days Night Lock is active.
  JsonArray nDays = cfg["nightDays"].to<JsonArray>();
  for (int i = 0; i < 7; i++) {
    nDays.add((config.night_days & (1 << i)) != 0);
  }

  // ROB-3: Truncation detection — if the JSON exceeds the buffer, serializeJson
  // returns the full required length. Detect this and warn rather than silently
  // sending malformed/truncated JSON that would crash the frontend JSON parser.
  size_t len = serializeJson(doc, _publishBuffer, sizeof(_publishBuffer));
  if (len >= sizeof(_publishBuffer)) {
    Serial.printf("[MQTT] State JSON truncated! Need %d bytes, buffer is %d\n",
                  (int)len, (int)sizeof(_publishBuffer));
  }
  _mqttClient.publish(_topicState, _publishBuffer, true); // Retain for reconnects
}

void MqttManager::publishDiagnostics(bool verbose, bool reset) {
  if (!_mqttClient.connected()) return;

  // Memory safety: diagnostics payload is large (~4KB)
  uint32_t freeHeap = ESP.getFreeHeap();
  if (freeHeap < 20000) {
    Serial.printf("[MQTT] Skipping diagnostics - low heap: %lu\n", freeHeap);
    return;
  }

  if (reset) {
    diagnosticsManager.resetAll();
    Serial.println(F("[MQTT] Diagnostics counters reset"));
  }

  JsonDocument doc;
  doc["deviceId"] = _deviceId;
  diagnosticsManager.buildDiagnosticsJson(doc, verbose);

  size_t len = serializeJson(doc, _publishBuffer, sizeof(_publishBuffer));

  if (len >= sizeof(_publishBuffer)) {
    Serial.printf("[MQTT] Diagnostics JSON truncated! Need %d bytes\n", len);
  }

  if (_mqttClient.publish(_topicDiag, _publishBuffer, false)) {
    Serial.printf("[MQTT] Published diagnostics (%d bytes)\n", len);
    diagnosticsManager.incrementPublishSuccess();
  } else {
    Serial.println(F("[MQTT] Failed to publish diagnostics"));
    diagnosticsManager.logErrorEvent("MQTT", "Failed to publish diagnostics payload");
    diagnosticsManager.incrementPublishFail();
  }
}
